From 713d554dc213ade5a3d1a63473fbccbf84e33af8 Mon Sep 17 00:00:00 2001 From: Rustam Khalimov Date: Fri, 11 Sep 2026 19:01:42 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=BE=D1=80=D0=BC=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D0=BE=D0=B4=D1=8B=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=B2=20LF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF, CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения), .editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize. Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc. --- .dockerignore | 54 +- .editorconfig | 3 +- .gitattributes | 20 + .github/workflows/ci.yml | 56 +- .gitignore | 64 +- README.md | 70 +- archive/leadradar-legacy/.env.example | 64 +- archive/leadradar-legacy/README.md | 48 +- archive/leadradar-legacy/backend/.gitignore | 10 +- archive/leadradar-legacy/backend/Dockerfile | 48 +- .../leadradar-legacy/backend/app/__init__.py | 2 +- archive/leadradar-legacy/backend/app/auth.py | 196 +- .../leadradar-legacy/backend/app/config.py | 198 +- .../leadradar-legacy/backend/app/constants.py | 506 +- .../leadradar-legacy/backend/app/crypto.py | 140 +- archive/leadradar-legacy/backend/app/db.py | 814 ++-- archive/leadradar-legacy/backend/app/main.py | 432 +- .../backend/app/routers/__init__.py | 2 +- .../backend/app/routers/auth_routes.py | 130 +- .../backend/app/routers/dashboard_routes.py | 818 ++-- .../backend/app/routers/discovery_routes.py | 570 +-- .../backend/app/routers/events_routes.py | 76 +- .../backend/app/routers/ml_routes.py | 342 +- .../backend/app/routers/processing_routes.py | 148 +- .../backend/app/routers/projects_routes.py | 422 +- .../backend/app/routers/settings_routes.py | 486 +- .../backend/app/routers/tg_routes.py | 308 +- .../backend/app/services/__init__.py | 2 +- .../backend/app/services/ai.py | 714 +-- .../backend/app/services/ban_guard.py | 160 +- .../backend/app/services/discovery.py | 1216 ++--- .../backend/app/services/discovery_eval.py | 474 +- .../backend/app/services/discovery_worker.py | 968 ++-- .../backend/app/services/files.py | 200 +- .../backend/app/services/fts.py | 206 +- .../backend/app/services/leads.py | 1102 ++--- .../backend/app/services/ml_client.py | 324 +- .../backend/app/services/object_store.py | 216 +- .../backend/app/services/pipeline.py | 2366 +++++----- .../backend/app/services/processing.py | 640 +-- .../backend/app/services/projects.py | 564 +-- .../backend/app/services/rates.py | 260 +- .../backend/app/services/rules.py | 780 ++-- .../backend/app/services/suggest.py | 494 +- .../backend/app/services/telegram.py | 1766 +++---- archive/leadradar-legacy/backend/app/sse.py | 102 +- .../backend/devtests/boot_test.py | 114 +- .../backend/devtests/docker_smoke.py | 146 +- .../backend/devtests/e2e_test.py | 350 +- .../backend/devtests/pipeline_test.py | 328 +- .../leadradar-legacy/backend/requirements.txt | 18 +- archive/leadradar-legacy/docker-compose.yml | 94 +- .../leadradar-legacy/mlservice/.dockerignore | 6 +- archive/leadradar-legacy/mlservice/Dockerfile | 26 +- archive/leadradar-legacy/mlservice/model.py | 706 +-- .../mlservice/requirements.txt | 6 +- archive/leadradar-legacy/mlservice/server.py | 140 +- archive/style-guide-original/Стиль_кода.docx | Bin 1561059 -> 1561042 bytes deploy/.env.example | 10 +- deploy/.env.prod.example | 124 +- deploy/caddy/Caddyfile | 96 +- deploy/compose.dev.yml | 540 +-- deploy/compose.prod.yml | 700 +-- .../grafana/dashboards/Deal-Auth.json | 776 ++-- .../grafana/dashboards/Deal-Errors.json | 820 ++-- .../grafana/dashboards/Deal-Health.json | 456 +- .../grafana/dashboards/Deal-Logs.json | 606 +-- .../dashboards/Deal-Metrics-Overview.json | 1212 ++--- .../grafana/dashboards/Deal-Rps.json | 750 +-- .../provisioning/dashboards/dashboards.yml | 32 +- .../provisioning/datasources/datasources.yml | 56 +- deploy/observability/loki.yml | 90 +- deploy/observability/prometheus-rules.yml | 162 +- deploy/observability/prometheus.yml | 96 +- deploy/observability/promtail.yml | 104 +- scripts/backup.sh | 392 +- scripts/build.sh | 30 +- scripts/ci.sh | 90 +- scripts/deal-backup-lib.sh | 368 +- scripts/dedup_summary_inheritdoc.py | 380 +- scripts/dev-smoke.sh | 432 +- scripts/fix_private_docs.py | 310 +- scripts/fix_summary_blocks.py | 216 +- scripts/loadtest/README.md | 108 +- scripts/loadtest/api-loadtest.sh | 196 +- scripts/loadtest/deal-loadtest.js | 112 +- scripts/mtls-certs.sh | 420 +- scripts/restore.sh | 366 +- scripts/test.sh | 46 +- src/README.md | 20 +- src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs | 1152 ++--- src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs | 278 +- .../Deal.Ai.Tests/Ai/FakeProviderClient.cs | 78 +- .../Deal.Ai.Tests/Ai/ProviderCallerTests.cs | 300 +- .../Deal.Ai.Tests/Ai/TokenEstimatorTests.cs | 128 +- src/ai-service/Deal.Ai.Tests/AssemblyInfo.cs | 10 +- .../Deal.Ai.Tests/Deal.Ai.Tests.csproj | 80 +- .../Deal.Ai.Tests/Grpc/AiServiceHostTests.cs | 376 +- .../Support/JsonExtractorTests.cs | 196 +- .../Support/LlmHttpClientTests.cs | 436 +- .../Support/StubHttpMessageHandler.cs | 212 +- src/ai-service/Deal.Ai.sln | 152 +- src/ai-service/Deal.Ai/AiServiceHost.cs | 114 +- src/ai-service/Deal.Ai/AiServiceImpl.cs | 896 ++-- src/ai-service/Deal.Ai/AssemblyInfo.cs | 10 +- src/ai-service/Deal.Ai/Deal.Ai.csproj | 86 +- src/ai-service/Deal.Ai/Dockerfile | 86 +- src/ai-service/Deal.Ai/Llm/IProviderClient.cs | 40 +- src/ai-service/Deal.Ai/Llm/JsonExtractor.cs | 144 +- .../Deal.Ai/Llm/LlmCallException.cs | 72 +- .../Deal.Ai/Llm/LlmCallFailureKind.cs | 34 +- src/ai-service/Deal.Ai/Llm/LlmCallResult.cs | 32 +- src/ai-service/Deal.Ai/Llm/LlmConfig.cs | 82 +- src/ai-service/Deal.Ai/Llm/LlmHttpClient.cs | 606 +-- .../Deal.Ai/Llm/LlmHttpException.cs | 52 +- src/ai-service/Deal.Ai/Llm/LlmRetryPolicy.cs | 52 +- src/ai-service/Deal.Ai/Llm/LlmUsage.cs | 18 +- src/ai-service/Deal.Ai/Llm/ProviderCaller.cs | 180 +- .../Deal.Ai/Llm/ProviderChatResult.cs | 16 +- src/ai-service/Deal.Ai/Llm/ProviderUsage.cs | 18 +- src/ai-service/Deal.Ai/Llm/TokenEstimator.cs | 70 +- src/ai-service/Deal.Ai/Program.cs | 72 +- src/ai-service/Directory.Build.props | 22 +- src/contracts/Deal.Proto.csproj | 96 +- src/contracts/README.md | 408 +- src/contracts/sources.proto | 182 +- src/contracts/storage.proto | 156 +- .../Deal.Api/Configuration/CookieOptions.cs | 48 +- .../Configuration/DataRetentionOptions.cs | 34 +- .../Configuration/ForwardedHeadersConfig.cs | 44 +- .../Configuration/OperatorCookieOptions.cs | 48 +- .../Configuration/RateLimitOptions.cs | 74 +- .../Deal.Api/Configuration/SecurityOptions.cs | 24 +- src/core/Deal.Api/Deal.Api.csproj | 114 +- src/core/Deal.Api/Dockerfile | 86 +- src/core/Deal.Api/Dtos/AdminTickResultDto.cs | 32 +- .../Deal.Api/Endpoints/AiCheckEndpoint.cs | 306 +- .../Deal.Api/Endpoints/AiSuggestEndpoints.cs | 146 +- .../Endpoints/CardDetailsEndpoints.cs | 968 ++-- src/core/Deal.Api/Endpoints/CardsEndpoints.cs | 922 ++-- .../Endpoints/ChangePasswordRequest.cs | 16 +- .../Deal.Api/Endpoints/CheckMessageRequest.cs | 14 +- .../Deal.Api/Endpoints/ContainersEndpoints.cs | 624 +-- .../Deal.Api/Endpoints/DiscoveryEndpoints.cs | 1016 ++-- src/core/Deal.Api/Endpoints/EventsEndpoint.cs | 214 +- .../Endpoints/FilterTesterEndpoints.cs | 94 +- src/core/Deal.Api/Endpoints/JoinEndpoint.cs | 196 +- src/core/Deal.Api/Endpoints/JoinRequest.cs | 20 +- src/core/Deal.Api/Endpoints/LoginRequest.cs | 16 +- src/core/Deal.Api/Endpoints/MlApplyRequest.cs | 18 +- .../Deal.Api/Endpoints/MlCandidatesRequest.cs | 16 +- src/core/Deal.Api/Endpoints/MlEndpoints.cs | 326 +- .../Deal.Api/Endpoints/MlPredictRequest.cs | 14 +- .../Endpoints/OperatorAnalyticsEndpoints.cs | 310 +- .../Endpoints/OperatorAuditEndpoints.cs | 146 +- .../Endpoints/OperatorHealthEndpoints.cs | 364 +- .../Endpoints/OperatorInviteCreateRequest.cs | 16 +- .../Endpoints/OperatorInvitesEndpoints.cs | 276 +- .../Endpoints/OperatorLimitUpdateRequest.cs | 16 +- .../Endpoints/OperatorLimitsEndpoints.cs | 444 +- .../Endpoints/OperatorMaintenanceEndpoints.cs | 114 +- .../Endpoints/OperatorSettingsEndpoints.cs | 282 +- .../Endpoints/OperatorTenantCreateRequest.cs | 16 +- .../OperatorTenantImpersonateRequest.cs | 14 +- .../Endpoints/OperatorTenantsEndpoints.cs | 540 +-- .../Deal.Api/Endpoints/PipelineEndpoints.cs | 306 +- src/core/Deal.Api/Endpoints/RatesEndpoints.cs | 142 +- .../RequestModels/CardLinkRequest.cs | 16 +- .../Endpoints/RequestModels/ClearColBody.cs | 12 +- .../Endpoints/RequestModels/ColStateBody.cs | 12 +- .../Endpoints/RequestModels/CommentBody.cs | 12 +- .../RequestModels/ContainerCreateRequest.cs | 48 +- .../RequestModels/ContainerPatchRequest.cs | 48 +- .../RequestModels/CreateCardRequest.cs | 48 +- .../RequestModels/DiscoveryTaskCreateBody.cs | 98 +- .../RequestModels/DiscoveryTaskPatchBody.cs | 104 +- .../Endpoints/RequestModels/MarkColBody.cs | 12 +- .../Endpoints/RequestModels/MoveBody.cs | 12 +- .../OperatorTelegramKeysRequest.cs | 16 +- .../Endpoints/RequestModels/OrderBody.cs | 16 +- .../Endpoints/RequestModels/ReclassifyBody.cs | 12 +- .../RequestModels/ReminderSetRequest.cs | 14 +- .../RequestModels/ReturnReasonRequest.cs | 12 +- .../RequestModels/TakeCardRequest.cs | 16 +- .../Endpoints/RequestModels/TgMonitorBody.cs | 14 +- .../Endpoints/RequestModels/TgPreviewBody.cs | 16 +- .../RequestModels/TgSendCodeRequest.cs | 14 +- .../RequestModels/TgSendPasswordRequest.cs | 14 +- .../RequestModels/TgStartPhoneRequest.cs | 14 +- .../Deal.Api/Endpoints/SettingsEndpoints.cs | 202 +- .../Deal.Api/Endpoints/StorageEndpoints.cs | 112 +- .../Deal.Api/Endpoints/TelegramEndpoints.cs | 1002 ++-- .../Endpoints/TelegramQrImageEndpoint.cs | 140 +- src/core/Deal.Api/Events/SseBroker.cs | 234 +- src/core/Deal.Api/Events/SseEvent.cs | 30 +- src/core/Deal.Api/Events/SseSubscription.cs | 22 +- .../Deal.Api/Events/StorageToastPublisher.cs | 126 +- src/core/Deal.Api/Extensions/AuthHelpers.cs | 114 +- .../Deal.Api/Hosting/BudgetAlertScheduler.cs | 364 +- .../Hosting/DataRetentionScheduler.cs | 318 +- .../Hosting/DiscoveryWorkerScheduler.cs | 342 +- .../Hosting/MlOutboxFlushScheduler.cs | 408 +- .../Deal.Api/Hosting/StorageTickScheduler.cs | 398 +- src/core/Deal.Api/Logging/DealLogging.cs | 208 +- .../Middleware/HttpAccessLogMiddleware.cs | 222 +- .../Middleware/OriginGuardMiddleware.cs | 156 +- .../Deal.Api/Middleware/RateLimitPolicies.cs | 276 +- src/core/Deal.Api/Models/CurrentOperator.cs | 18 +- src/core/Deal.Api/Models/CurrentUser.cs | 20 +- .../Observability/DealMetricsCollector.cs | 238 +- .../Observability/DealMetricsHosting.cs | 124 +- .../Observability/RuntimeDepthsCollector.cs | 384 +- .../Observability/RuntimeDepthsDto.cs | 28 +- .../Observability/TenantBudgetUsageDto.cs | 16 +- .../Deal.Api/Properties/launchSettings.json | 28 +- .../Services/AdminTickOrchestrator.cs | 274 +- src/core/Deal.Api/Services/AuditAppender.cs | 162 +- src/core/Deal.Api/Services/EndpointResults.cs | 114 +- src/core/Deal.Api/Services/InlineProgress.cs | 22 +- .../Deal.Api/Services/LoginAttemptGuard.cs | 274 +- .../Deal.Api/Services/PipelinePumpGate.cs | 50 +- .../Services/PipelineWorkerScheduler.cs | 390 +- .../Services/RatesRefreshScheduler.cs | 120 +- .../Deal.Api/Services/SessionCookieWriter.cs | 66 +- .../StoreBackedFixedWindowRateLimiter.cs | 218 +- .../Services/SuspiciousActivityReporter.cs | 76 +- .../Services/TelegramBackfillScheduler.cs | 248 +- .../Deal.Api/Sources/IngressTenantResolver.cs | 128 +- .../Sources/SourceIngressGrpcService.cs | 206 +- .../Deal.Api/Sources/SourceProtoMapper.cs | 170 +- .../Telegram/IngressRateLimitInterceptor.cs | 184 +- .../IngressServiceTokenInterceptor.cs | 144 +- .../Telegram/RpcCallLoggingInterceptor.cs | 164 +- .../Telegram/TelegramIngressService.cs | 464 +- .../Telegram/TelegramKeysMaskedDto.cs | 18 +- .../Deal.Api/Telegram/TelegramKeysService.cs | 300 +- .../Deal.Api/Telegram/TelegramKeysValue.cs | 16 +- src/core/Deal.Api/Telegram/TgKeysSnapshot.cs | 28 +- .../Deal.Api/Telegram/TgReportedStatus.cs | 64 +- src/core/Deal.Api/Telegram/TgStatusService.cs | 174 +- .../Deal.Api/appsettings.Development.json | 44 +- src/core/Deal.Api/appsettings.json | 102 +- src/core/Deal.Contracts/Deal.Contracts.csproj | 18 +- .../Models/TelegramSourceContentDto.cs | 18 +- .../Deal.Infrastructure.csproj | 84 +- .../Abstractions/IMlTrainClient.cs | 36 +- .../Integrations/Models/AiGrpcConnection.cs | 158 +- .../Integrations/Models/MlGrpcConnection.cs | 158 +- .../Models/TelegramGrpcConnection.cs | 158 +- .../Integrations/Options/MtlsOptions.cs | 202 +- .../Services/AiConnectionChecker.cs | 480 +- .../Services/AiProviderConfigBuilder.cs | 300 +- .../Services/BudgetedAiClassifier.cs | 212 +- .../Integrations/Services/BudgetedAiTools.cs | 240 +- .../Integrations/Services/CbrRateSource.cs | 320 +- .../Integrations/Services/GrpcAiClassifier.cs | 420 +- .../Integrations/Services/GrpcAiTools.cs | 398 +- .../Integrations/Services/GrpcMlClient.cs | 746 +-- .../Services/GrpcTelegramClient.cs | 944 ++-- .../Services/LocalAiClassifier.cs | 54 +- .../Services/LocalColumnSuggester.cs | 404 +- .../Integrations/Services/LocalMlClient.cs | 266 +- .../Integrations/Services/MlOutboxQueue.cs | 112 +- .../Services/ServiceHealthProbe.cs | 134 +- .../Services/TokenUsageRecorder.cs | 456 +- .../Sources/TelegramSourceContentProvider.cs | 78 +- .../Storage/Services/FileStorageRegistrar.cs | 302 +- .../Storage/Services/MinioFileStorage.cs | 414 +- ...0910152246_AddTokenUsageEvents.Designer.cs | 806 ++-- .../20260910152246_AddTokenUsageEvents.cs | 128 +- ...260910172545_RateLimitCounters.Designer.cs | 848 ++-- .../20260910172545_RateLimitCounters.cs | 86 +- .../20260910194443_GlobalSettings.Designer.cs | 884 ++-- .../20260910194443_GlobalSettings.cs | 72 +- .../Migrations/DealDbContextModelSnapshot.cs | 878 ++-- .../Configurations/AuditLogConfiguration.cs | 52 +- .../Configurations/CardConfiguration.cs | 106 +- .../Configurations/CardMoveConfiguration.cs | 36 +- .../Configurations/ContainerConfiguration.cs | 74 +- .../Configurations/DedupEntryConfiguration.cs | 36 +- .../Configurations/DialogConfiguration.cs | 40 +- .../DiscBlacklistConfiguration.cs | 36 +- .../DiscCandidateConfiguration.cs | 46 +- .../Configurations/DiscLogConfiguration.cs | 40 +- .../Configurations/DiscTaskConfiguration.cs | 40 +- .../GlobalSettingConfiguration.cs | 48 +- .../Configurations/InviteConfiguration.cs | 72 +- .../LeadCommentConfiguration.cs | 50 +- .../Configurations/MlOutboxConfiguration.cs | 42 +- .../Configurations/OperatorConfiguration.cs | 54 +- .../OperatorSessionConfiguration.cs | 68 +- .../Configurations/QueueItemConfiguration.cs | 58 +- .../RateLimitCounterConfiguration.cs | 50 +- .../RejectedItemConfiguration.cs | 66 +- .../Configurations/SessionConfiguration.cs | 68 +- .../Configurations/TenantConfiguration.cs | 44 +- .../TenantLimitConfiguration.cs | 62 +- .../TenantSettingConfiguration.cs | 48 +- .../Configurations/TgMessageConfiguration.cs | 40 +- .../TokenUsageEventConfiguration.cs | 72 +- .../Configurations/UserConfiguration.cs | 66 +- .../Persistence/DealDbContext.cs | 114 +- .../Persistence/Entities/CardEntity.cs | 378 +- .../Persistence/Entities/ContainerEntity.cs | 152 +- .../Entities/GlobalSettingEntity.cs | 38 +- .../Entities/RateLimitCounterEntity.cs | 54 +- .../Entities/RejectedItemEntity.cs | 168 +- .../Entities/TokenUsageEventEntity.cs | 84 +- .../20260911114400_InitialTenant.Designer.cs | 1488 +++--- .../TenantDb/20260911114400_InitialTenant.cs | 924 ++-- .../TenantDb/TenantDbContextModelSnapshot.cs | 1482 +++--- .../Persistence/Repositories/AuditLogStore.cs | 244 +- .../Persistence/Repositories/AuthStore.cs | 256 +- .../Repositories/DiscoveryStore.Blacklist.cs | 130 +- .../Repositories/DiscoveryStore.Candidates.cs | 328 +- .../Repositories/DiscoveryStore.Logs.cs | 108 +- .../Repositories/DiscoveryStore.Tasks.cs | 444 +- .../Repositories/DiscoveryStore.cs | 398 +- .../Repositories/GlobalSettingsStore.cs | 96 +- .../Persistence/Repositories/InviteStore.cs | 210 +- .../Repositories/KanbanStore.Cards.cs | 500 +- .../Repositories/KanbanStore.Comments.cs | 164 +- .../Repositories/KanbanStore.Containers.cs | 210 +- .../Repositories/KanbanStore.Selected.cs | 602 +-- .../Repositories/KanbanStore.StorageRules.cs | 264 +- .../Persistence/Repositories/KanbanStore.cs | 576 +-- .../Repositories/MlLearningStore.cs | 128 +- .../Repositories/OperatorAuthStore.cs | 164 +- .../Persistence/Repositories/PipelineStore.cs | 710 +-- .../Repositories/RateLimitCounterStore.cs | 398 +- .../Persistence/Repositories/SettingsStore.cs | 142 +- .../Persistence/Repositories/TelegramStore.cs | 482 +- .../Repositories/TenantLimitStore.cs | 554 +-- .../Repositories/TenantRepository.cs | 170 +- .../Repositories/TokenUsageEventStore.cs | 350 +- .../Persistence/TenantDbContext.cs | 210 +- .../Tenancy/TenantMigrationSummary.cs | 44 +- .../Application/Dtos/CardMoveResultDto.cs | 20 +- .../Application/Sources/ContactRef.cs | 40 +- .../Application/Sources/DataRef.cs | 96 +- .../Sources/ISourceContentProvider.cs | 40 +- .../Sources/ISourceIngestObserver.cs | 30 +- .../Application/Sources/SourceContent.cs | 70 +- .../Sources/SourceContentResolver.cs | 84 +- .../Application/Sources/SourceItem.cs | 22 +- .../Application/Sources/SourceRef.cs | 94 +- .../Application/Sources/SourceRefs.cs | 94 +- .../Deal.Modules.Cards.csproj | 36 +- .../Abstractions/IDiscoveryStore.cs | 532 +-- .../DiscoveryTaskPatchExtensions.cs | 36 +- .../Registrars/DiscoveryModuleRegistrar.cs | 68 +- .../Application/Services/DiscoveryBanGuard.cs | 190 +- .../Services/DiscoveryBlacklistService.cs | 106 +- .../Services/DiscoveryCandidatesService.cs | 618 +-- .../Services/DiscoveryEvaluator.cs | 584 +-- .../Services/DiscoveryLogService.cs | 114 +- .../Application/Services/DiscoveryPacer.cs | 62 +- .../Services/DiscoveryPlanGuard.cs | 154 +- .../Services/DiscoveryTasksService.cs | 528 +-- .../DiscoveryWorkerService.Evaluate.cs | 422 +- .../DiscoveryWorkerService.Helpers.cs | 74 +- .../Services/DiscoveryWorkerService.Join.cs | 260 +- .../Services/DiscoveryWorkerService.Search.cs | 180 +- .../Services/DiscoveryWorkerService.cs | 284 +- .../Deal.Modules.Discovery.csproj | 38 +- .../Application/Abstractions/ICardStore.cs | 742 +-- .../Abstractions/IMlLearningStore.cs | 108 +- .../Application/ColumnRules/TypeAliases.cs | 98 +- .../Application/Models/CardFileKind.cs | 24 +- .../Application/Models/CardLocalCreateDto.cs | 40 +- .../Application/Models/CardPatch.cs | 48 +- .../Models/CardReclassificationDto.cs | 64 +- .../Application/Models/CardReminderDueDto.cs | 24 +- .../Application/Models/CardResultDto.cs | 16 +- .../Registrars/KanbanModuleRegistrar.cs | 52 +- .../Application/Services/BudgetNormalizer.cs | 308 +- .../Services/CardsService.Files.cs | 286 +- .../Services/CardsService.Helpers.cs | 176 +- .../Services/CardsService.Operations.cs | 556 +-- .../Services/CardsService.Reminders.cs | 220 +- .../Services/CardsService.Selected.cs | 764 +-- .../Services/CardsService.Source.cs | 60 +- .../Application/Services/CardsService.cs | 188 +- .../Application/Services/ContainersService.cs | 558 +-- .../Services/ConversionRecomputer.cs | 160 +- .../Services/StorageTickService.cs | 102 +- .../Application/Services/SuggestHeuristics.cs | 498 +- .../Deal.Modules.Kanban.csproj | 40 +- .../Models/GlobalExcludeSettings.cs | 32 +- .../Models/GlobalExclusionResult.cs | 18 +- .../Application/Models/MlApplyResult.cs | 32 +- .../Application/Models/MlCandidateDto.cs | 118 +- .../Models/MlCandidatePredictionDto.cs | 24 +- .../Application/Models/PipelineDryRunDto.cs | 42 +- .../Models/PipelineDryRunStageDto.cs | 36 +- .../Models/ReclassifyProgressDto.cs | 36 +- .../Application/Models/ReclassifyResultDto.cs | 52 +- .../Registrars/PipelineModuleRegistrar.cs | 80 +- .../Application/Services/AiCardLearning.cs | 106 +- .../Application/Services/AiCardMapper.cs | 98 +- .../Services/AiClassifyContextBuilder.cs | 314 +- .../Application/Services/AiRawCardMapper.cs | 586 +-- .../Application/Services/CardComposer.cs | 320 +- .../Application/Services/CardReclassifier.cs | 736 +-- .../Services/GlobalExclusionRules.cs | 376 +- .../Application/Services/MlReviewService.cs | 942 ++-- .../Services/PipelineCardWriter.cs | 70 +- .../Services/PipelineIngestService.cs | 108 +- .../Services/PipelineProcessingService.cs | 514 +-- .../Services/PipelineWorkerService.Checks.cs | 240 +- .../PipelineWorkerService.Decisions.cs | 116 +- .../Services/PipelineWorkerService.DryRun.cs | 412 +- .../PipelineWorkerService.Learning.cs | 94 +- .../Services/PipelineWorkerService.Pump.cs | 574 +-- .../PipelineWorkerService.Rejections.cs | 114 +- .../PipelineWorkerService.Settings.cs | 126 +- .../Services/PipelineWorkerService.State.cs | 154 +- .../Services/PipelineWorkerService.cs | 290 +- .../Application/Services/ReclassifyGate.cs | 46 +- .../Deal.Modules.Pipeline.csproj | 40 +- .../Abstractions/IGlobalSettingsStore.cs | 52 +- .../Application/Models/GlobalSettingsKeys.cs | 18 +- .../Application/Models/IncomingRules.cs | 440 +- .../Application/Models/SettingsDefaults.cs | 540 +-- .../Models/TenantSettingsSnapshot.cs | 454 +- .../Registrars/SettingsModuleRegistrar.cs | 44 +- .../Application/Services/RatesService.cs | 340 +- .../SettingsService.PatchMyPrompts.cs | 158 +- .../SettingsService.PatchScalarKeys.cs | 280 +- .../Services/SettingsService.PatchSecrets.cs | 204 +- .../Services/SettingsService.PublicForms.cs | 52 +- .../Services/SettingsService.ReadMerge.cs | 502 +- .../Application/Services/SettingsService.cs | 562 +-- .../Deal.Modules.Settings.csproj | 36 +- .../TelegramSourceIngestObserver.cs | 94 +- .../Deal.Modules.Telegram.csproj | 42 +- .../Abstractions/IAuditLogStore.cs | 74 +- .../Application/Abstractions/IAuthStore.cs | 152 +- .../Application/Abstractions/IInviteStore.cs | 120 +- .../Abstractions/IOperatorAuthStore.cs | 92 +- .../Abstractions/IRateLimitCounterStore.cs | 92 +- .../Abstractions/ITenantLimitStore.cs | 146 +- .../Abstractions/ITenantProvisioner.cs | 30 +- .../Abstractions/ITenantRepository.cs | 100 +- .../Abstractions/ITokenUsageEventStore.cs | 46 +- .../Extensions/InviteDtoExtensions.cs | 26 +- .../Models/AnalyticsActivityDto.cs | 28 +- .../Models/AnalyticsOverviewDto.cs | 60 +- .../Application/Models/AnalyticsTokensDto.cs | 32 +- .../Models/SuspiciousActivityDto.cs | 64 +- .../Models/SuspiciousFindingDto.cs | 32 +- .../Application/Models/TokenBudgetDefaults.cs | 44 +- .../Models/TokenUsageAggregateDto.cs | 32 +- .../Application/Models/TokenUsageEventDto.cs | 48 +- .../Models/TokenUsageEventKinds.cs | 34 +- .../Models/TokenUsageEventQueryDto.cs | 40 +- .../Application/Models/TokenUsageGroupBys.cs | 54 +- .../Application/Models/TokenUsageSources.cs | 34 +- .../Registrars/TenantModuleRegistrar.cs | 62 +- .../Application/Services/AnalyticsService.cs | 320 +- .../Application/Services/AuditService.cs | 146 +- .../Services/DefaultPasswordHasher.cs | 32 +- .../Services/InviteCodeGenerator.cs | 48 +- .../Application/Services/InvitesService.cs | 338 +- .../Application/Services/JoinService.cs | 262 +- .../Services/SuspiciousActivityService.cs | 770 ++-- .../Services/TenantAdminService.cs | 298 +- .../Services/TokenUsageEventService.cs | 54 +- .../Deal.Modules.Tenants.csproj | 38 +- .../Deal.SharedKernel.csproj | 18 +- .../Observability/DealMetrics.cs | 436 +- src/core/Deal.sln | 386 +- src/core/Directory.Build.props | 22 +- src/core/dotnet-tools.json | 24 +- .../Api/DataRetentionSchedulerTests.cs | 210 +- .../Api/DiscoveryWorkerSchedulerTests.cs | 416 +- .../Api/HttpAccessLogMiddlewareTests.cs | 322 +- .../Api/IngressRateLimitInterceptorTests.cs | 316 +- .../Api/InlineProgressTests.cs | 58 +- .../Api/LoginAttemptGuardTests.cs | 408 +- .../Api/MlOutboxFlushSchedulerTests.cs | 428 +- .../OperatorBootstrapHostedServiceTests.cs | 396 +- .../Api/RuntimeDepthsCollectorTests.cs | 348 +- .../Api/SuspiciousActivityReporterTests.cs | 222 +- .../Api/TelegramEndpointsMappingTests.cs | 120 +- .../Api/TelegramKeysServiceTests.cs | 338 +- .../Api/TgStatusServiceTests.cs | 282 +- .../Contracts/BudgetedAiClassifierTests.cs | 348 +- .../Contracts/BudgetedAiToolsTests.cs | 304 +- .../Contracts/CardComposerTests.cs | 616 +-- .../Contracts/CardsServiceTests.cs | 1584 +++---- .../Contracts/DialogsServiceTests.cs | 1056 ++--- .../Contracts/DiscoveryEvaluatorTests.cs | 404 +- .../Contracts/DiscoveryWorkerServiceTests.cs | 1088 ++--- .../Contracts/FakeTelegramGateway.cs | 378 +- .../Contracts/GrpcAiToolsTests.cs | 284 +- .../Contracts/GrpcMlClientTests.cs | 660 +-- .../Contracts/GrpcTelegramClientTests.cs | 518 +-- .../Contracts/IntegrationsDiTests.cs | 318 +- .../Contracts/LocalAiClassifierTests.cs | 306 +- .../Contracts/PipelineCardWriterTests.cs | 240 +- .../Contracts/PipelineWorkerGrpcAiTests.cs | 444 +- .../Deal.Tests.Unit/Deal.Tests.Unit.csproj | 92 +- .../Grpc/RecordingAiService.cs | 330 +- .../Grpc/RecordingMlService.cs | 284 +- .../Grpc/RecordingTelegramService.cs | 394 +- .../Infrastructure/AuditLogStoreTests.cs | 286 +- .../Infrastructure/CardMoverTests.cs | 174 +- .../GlobalSettingsStoreTests.cs | 112 +- .../Infrastructure/InviteStoreTests.cs | 240 +- .../Infrastructure/MtlsOptionsTests.cs | 206 +- .../Infrastructure/OperatorAuthStoreTests.cs | 176 +- .../RateLimitCounterStoreTests.cs | 212 +- .../Infrastructure/TenantLimitStoreTests.cs | 596 +-- .../Infrastructure/TenantRepositoryTests.cs | 96 +- .../Discovery/DiscoveryBanGuardTests.cs | 236 +- .../Discovery/DiscoveryTasksServiceTests.cs | 806 ++-- .../Kanban/AiClassifyContextBuilderTests.cs | 362 +- .../Kanban/CardsServiceRemindersTests.cs | 438 +- .../Modules/Kanban/MlReviewServiceTests.cs | 506 +- .../Modules/Kanban/StorageTickServiceTests.cs | 454 +- .../Pipeline/PipelineIngestServiceTests.cs | 332 +- .../PipelineProcessingServiceTests.cs | 902 ++-- .../Modules/Tenants/AuditEventsTests.cs | 64 +- .../Tenants/FailingTenantProvisioner.cs | 106 +- .../Tenants/FakeRateLimitCounterStore.cs | 146 +- .../DealMetricsBudgetRatioTests.cs | 118 +- .../Support/AdminTickOrchestratorTests.cs | 654 +-- .../Deal.Tests.Unit/Support/AiGrpcTestHost.cs | 120 +- .../Support/BudgetAlertSchedulerTests.cs | 348 +- .../Support/CardReclassifierTests.cs | 916 ++-- .../Support/CardsServiceFilesTests.cs | 518 +-- .../Support/CardsServiceSelectedTests.cs | 1160 ++--- .../Support/ColumnRulesNewGroupsTests.cs | 294 +- .../Support/ForwardedHeadersHttpTests.cs | 412 +- .../Support/GrpcAiClassifierTests.cs | 648 +-- .../Support/JoinEndpointHttpTests.cs | 682 +-- .../Support/LocalMlClientTests.cs | 676 +-- .../Support/LoginAttemptEndpointHttpTests.cs | 352 +- .../Deal.Tests.Unit/Support/MlGrpcTestHost.cs | 128 +- .../Support/MtlsCertificatesTests.cs | 660 +-- .../Support/OperatorAuthHttpHost.cs | 496 +- .../OperatorSettingsEndpointsHttpTests.cs | 646 +-- .../Support/OriginGuardHttpTests.cs | 422 +- .../Support/PipelineWorkerSchedulerTests.cs | 614 +-- .../Support/RateLimitHttpTests.cs | 440 +- .../Support/SecurityEventCapture.cs | 156 +- .../Support/ServiceHealthProbeTests.cs | 216 +- .../Support/SourceContentResolverTests.cs | 112 +- .../Support/SourceIngressGrpcServiceTests.cs | 576 +-- .../Support/SourceItemFactory.cs | 402 +- .../Support/SourceProtoMapperTests.cs | 422 +- .../Support/SourceRefsTests.cs | 86 +- .../Deal.Tests.Unit/Support/SseBrokerTests.cs | 220 +- .../Support/StorageTickSchedulerTests.cs | 1016 ++-- .../Support/StorageToastPublisherTests.cs | 148 +- .../Support/TelegramGrpcTestHost.cs | 128 +- .../Support/TelegramIngressServiceTests.cs | 548 +-- .../Support/TelegramIngressTestHost.cs | 318 +- .../TelegramSourceContentProviderTests.cs | 220 +- .../TelegramSourceIngestObserverTests.cs | 218 +- .../tests/Deal.Tests.Unit/Support/TestPort.cs | 84 +- src/frontend/.gitignore | 8 +- src/frontend/README.md | 70 +- src/frontend/index.html | 60 +- src/frontend/package-lock.json | 4092 ++++++++--------- src/frontend/package.json | 42 +- src/frontend/scripts/i18n-lint.mjs | 454 +- src/frontend/src/App.vue | 38 +- src/frontend/src/components/ConfirmDialog.vue | 132 +- src/frontend/src/components/Icon.vue | 718 +-- .../src/components/PromptLibraryModal.vue | 428 +- .../src/components/ReminderNotice.vue | 132 +- .../src/components/RenameBoardDialog.vue | 166 +- src/frontend/src/components/SearchPalette.vue | 110 +- src/frontend/src/components/Sidebar.vue | 818 ++-- src/frontend/src/components/Toasts.vue | 190 +- src/frontend/src/components/card/Card.vue | 660 +-- src/frontend/src/components/card/MoveMenu.vue | 208 +- .../discovery/DiscoveryBlacklistList.vue | 114 +- .../components/discovery/DiscoveryLogList.vue | 62 +- .../discovery/DiscoveryQuotaPanel.vue | 322 +- .../discovery/DiscoveryTaskCard.vue | 86 +- .../discovery/DiscoveryTaskMaster.vue | 566 +-- src/frontend/src/components/discovery/meta.js | 94 +- .../src/components/operator/AuditFilters.vue | 128 +- .../src/components/operator/AuditTable.vue | 146 +- .../src/components/operator/SectionLayout.vue | 48 +- .../src/components/settings/AppearanceTab.vue | 146 +- src/frontend/src/components/ui/Badge.vue | 58 +- src/frontend/src/components/ui/BarList.vue | 82 +- src/frontend/src/components/ui/BaseModal.vue | 118 +- src/frontend/src/components/ui/Button.vue | 84 +- src/frontend/src/components/ui/Card.vue | 56 +- .../src/components/ui/ChannelAvatar.vue | 104 +- .../src/components/ui/CommentSection.vue | 184 +- src/frontend/src/components/ui/DataTable.vue | 152 +- src/frontend/src/components/ui/EmptyState.vue | 32 +- src/frontend/src/components/ui/Field.vue | 42 +- src/frontend/src/components/ui/Pagination.vue | 64 +- .../src/components/ui/PipelineDetails.vue | 124 +- .../src/components/ui/SelectInput.vue | 58 +- .../src/components/ui/SourceContentView.vue | 336 +- src/frontend/src/components/ui/StackChips.vue | 44 +- src/frontend/src/components/ui/StatCard.vue | 58 +- src/frontend/src/components/ui/TagInput.vue | 220 +- src/frontend/src/components/ui/TextInput.vue | 82 +- .../src/components/ui/ToggleSwitch.vue | 68 +- src/frontend/src/components/ui/fileMeta.js | 68 +- src/frontend/src/composables/dnd.js | 132 +- src/frontend/src/composables/progressive.js | 80 +- src/frontend/src/composables/theme.js | 148 +- src/frontend/src/data.js | 52 +- src/frontend/src/i18n/errors.js | 110 +- src/frontend/src/i18n/index.js | 206 +- src/frontend/src/i18n/locales/ru.data.js | 370 +- src/frontend/src/main.js | 24 +- src/frontend/src/router.js | 146 +- src/frontend/src/store.js | 10 +- src/frontend/src/store/index.js | 28 +- src/frontend/src/store/ml.js | 162 +- src/frontend/src/store/session.js | 152 +- src/frontend/src/style.css | 500 +- src/frontend/src/views/DashboardView.vue | 256 +- src/frontend/src/views/LoginView.vue | 196 +- src/frontend/src/views/MainApp.vue | 160 +- src/frontend/src/views/ProjectsView.vue | 94 +- .../src/views/operator/AuditSection.vue | 138 +- .../src/views/operator/LimitsSection.vue | 330 +- .../src/views/operator/OperatorLogin.vue | 176 +- .../src/views/operator/TelegramSection.vue | 206 +- src/frontend/vite.config.js | 82 +- .../Deal.Grpc.Hosting.csproj | 108 +- .../Models/MtlsCertificates.cs | 468 +- .../Services/GrpcHostEnvironment.cs | 132 +- src/ml-service/Deal.Ml.Tests/AssemblyInfo.cs | 10 +- .../Deal.Ml.Tests/Deal.Ml.Tests.csproj | 90 +- .../Deal.Ml.Tests/Grpc/MlRpcTests.cs | 562 +-- .../Deal.Ml.Tests/Grpc/MlServiceHostTests.cs | 460 +- .../Deal.Ml.Tests/Grpc/MlTestHost.cs | 268 +- .../Deal.Ml.Tests/Ml/LearningData.cs | 196 +- .../Deal.Ml.Tests/Ml/MlTokenizerTests.cs | 150 +- .../Deal.Ml.Tests/Ml/ModelPersistenceTests.cs | 308 +- .../Deal.Ml.Tests/Ml/ModelPoolTests.cs | 168 +- .../Ml/TenantModelLearningTests.cs | 738 +-- src/ml-service/Deal.Ml.sln | 152 +- src/ml-service/Deal.Ml/Deal.Ml.csproj | 96 +- src/ml-service/Deal.Ml/Dockerfile | 90 +- src/ml-service/Deal.Ml/MlServiceHost.cs | 98 +- src/ml-service/Deal.Ml/MlServiceImpl.cs | 536 +-- src/ml-service/Deal.Ml/Model/EvalEntry.cs | 20 +- src/ml-service/Deal.Ml/Model/LearnItem.cs | 18 +- src/ml-service/Deal.Ml/Model/MlEvalInfo.cs | 18 +- src/ml-service/Deal.Ml/Model/MlOptions.cs | 140 +- .../Deal.Ml/Model/MlPredictResult.cs | 44 +- .../Deal.Ml/Model/MlStatusResult.cs | 28 +- src/ml-service/Deal.Ml/Model/MlTokenizer.cs | 98 +- .../Deal.Ml/Model/MlTypeDecision.cs | 20 +- .../Deal.Ml/Model/ModelConstants.cs | 304 +- src/ml-service/Deal.Ml/Model/ModelPool.cs | 170 +- src/ml-service/Deal.Ml/Model/ModelState.cs | 64 +- .../Deal.Ml/Model/OnlineNaiveBayes.cs | 592 +-- src/ml-service/Deal.Ml/Model/TenantModel.cs | 422 +- src/ml-service/Deal.Ml/Program.cs | 72 +- src/ml-service/Deal.Ml/Storage/MlDb.cs | 552 +-- src/ml-service/Directory.Build.props | 22 +- .../Deal.Storage.Tests/AssemblyInfo.cs | 6 +- .../Deal.Storage.Tests.csproj | 62 +- .../Grpc/StorageServiceHostTests.cs | 256 +- .../Services/FileKindSnifferTests.cs | 162 +- src/storage-service/Deal.Storage.sln | 152 +- .../Deal.Storage/Deal.Storage.csproj | 52 +- src/storage-service/Deal.Storage/Dockerfile | 64 +- .../Deal.Storage/Models/StoredObject.cs | 24 +- .../Deal.Storage/Options/StorageOptions.cs | 146 +- src/storage-service/Deal.Storage/Program.cs | 62 +- .../Deal.Storage/Services/FileKindSniffer.cs | 276 +- .../Deal.Storage/Services/MinioObjectStore.cs | 390 +- .../Deal.Storage/StorageServiceHost.cs | 94 +- .../Deal.Storage/StorageServiceImpl.cs | 270 +- src/storage-service/Directory.Build.props | 22 +- .../Deal.Telegram.Tests/AssemblyInfo.cs | 8 +- .../Deal.Telegram.Tests.csproj | 78 +- .../Grpc/CoreIngressClientTests.cs | 228 +- .../Grpc/DialogRpcTests.cs | 570 +-- .../Grpc/DiscoveryProtoMapperTests.cs | 260 +- .../Grpc/DiscoveryRpcTests.cs | 662 +-- .../Grpc/FakeIngressServer.cs | 314 +- .../Grpc/ReadSourceRpcTests.cs | 310 +- .../Grpc/RealtimeListenerTests.cs | 276 +- .../Grpc/TelegramServiceHostTests.cs | 308 +- .../Grpc/TelegramSessionRpcTests.cs | 452 +- .../Grpc/TelegramTestHost.cs | 340 +- .../Deal.Telegram.Tests/Grpc/TestDoubles.cs | 184 +- .../Support/SessionStorageTests.cs | 708 +-- .../Support/TenantSessionTests.cs | 1066 ++--- .../Telegram/BackfillServiceTests.cs | 380 +- .../Telegram/DialogCatalogTests.cs | 256 +- .../Telegram/DiscoveryOpsTests.cs | 582 +-- .../Telegram/FakeSessionClient.cs | 992 ++-- .../Telegram/FarmHarness.cs | 156 +- .../Telegram/LruCacheTests.cs | 226 +- .../Telegram/RealtimeSweepTests.cs | 298 +- .../Telegram/SessionHarness.cs | 188 +- .../Telegram/TestSessionFactory.cs | 120 +- .../Telegram/TlMessageMapperTests.cs | 470 +- src/telegram-service/Deal.Telegram.sln | 152 +- .../Deal.Telegram/Caching/LruCache.cs | 218 +- .../Deal.Telegram/Core/CoreIngressClient.cs | 272 +- .../Deal.Telegram/Core/CoreIngressOptions.cs | 154 +- .../Deal.Telegram/Core/ICoreIngressClient.cs | 68 +- .../Deal.Telegram/Deal.Telegram.csproj | 80 +- .../Deal.Telegram/Dialogs/BackfillService.cs | 370 +- .../Deal.Telegram/Dialogs/DialogCatalog.cs | 390 +- .../Deal.Telegram/Dialogs/DialogHue.cs | 76 +- .../Dialogs/DialogProtoMapper.cs | 138 +- .../Deal.Telegram/Dialogs/IBackfillPacer.cs | 38 +- .../Dialogs/RandomBackfillPacer.cs | 58 +- .../Deal.Telegram/Dialogs/RealtimeListener.cs | 184 +- .../Deal.Telegram/Dialogs/RealtimeSweep.cs | 356 +- .../Deal.Telegram/Discovery/DiscoveryOps.cs | 330 +- .../Discovery/DiscoveryProtoMapper.cs | 164 +- src/telegram-service/Deal.Telegram/Dockerfile | 90 +- .../Hosting/RealtimeMonitorService.cs | 254 +- .../Hosting/RealtimeSweepService.cs | 104 +- .../Hosting/SessionHeartbeatService.cs | 142 +- src/telegram-service/Deal.Telegram/Program.cs | 72 +- .../Deal.Telegram/Sessions/AuthPhase.cs | 74 +- .../Sessions/SessionErrorMessages.cs | 212 +- .../Sessions/SessionException.cs | 58 +- .../Deal.Telegram/Sessions/SessionFarm.cs | 698 +-- .../Sessions/SessionFileCipher.cs | 202 +- .../Deal.Telegram/Sessions/SessionStore.cs | 338 +- .../Deal.Telegram/Sessions/StoredSession.cs | 64 +- .../Deal.Telegram/Sessions/TenantSession.cs | 2128 ++++----- .../Sessions/TenantSessionSnapshot.cs | 124 +- .../Deal.Telegram/Sessions/TgOptions.cs | 224 +- .../Deal.Telegram/Telegram/ClientFactory.cs | 28 +- .../Deal.Telegram/Telegram/DialogKinds.cs | 54 +- .../Telegram/DiscoveryMessage.cs | 108 +- .../Telegram/DiscoveryReadResult.cs | 108 +- .../Deal.Telegram/Telegram/ISessionClient.cs | 348 +- .../Telegram/ITelegramClientFactory.cs | 38 +- .../Deal.Telegram/Telegram/TelegramDialog.cs | 124 +- .../Deal.Telegram/Telegram/TelegramMessage.cs | 124 +- .../Telegram/TelegramSourceInfo.cs | 124 +- .../Deal.Telegram/Telegram/TlMessageMapper.cs | 410 +- .../Telegram/WTelegramSessionClient.cs | 2164 ++++----- .../Deal.Telegram/TelegramServiceHost.cs | 144 +- .../Deal.Telegram/TelegramServiceImpl.cs | 1098 ++--- src/telegram-service/Directory.Build.props | 22 +- 751 files changed, 94507 insertions(+), 94486 deletions(-) create mode 100644 .gitattributes diff --git a/.dockerignore b/.dockerignore index 20cd42a..b46c125 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,27 +1,27 @@ -# зависимости и сборки -**/node_modules -**/dist -**/.cache - -# данные и секреты -data -backend/data -.env -*.log - -# mTLS-сертификаты (deploy/certs): генерируются scripts/mtls-certs.sh на хосте, в build-контекст и образ не попадают -deploy/certs - -# git/мусор -.git -.gitignore -QUESTIONS.md -QUESTIONS2.md -ТЗ.md -__pycache__ -**/__pycache__ -*.pyc - -# .NET build outputs (context сборки — корень репозитория: compose.dev.yml build.context: ..) -**/bin -**/obj +# зависимости и сборки +**/node_modules +**/dist +**/.cache + +# данные и секреты +data +backend/data +.env +*.log + +# mTLS-сертификаты (deploy/certs): генерируются scripts/mtls-certs.sh на хосте, в build-контекст и образ не попадают +deploy/certs + +# git/мусор +.git +.gitignore +QUESTIONS.md +QUESTIONS2.md +ТЗ.md +__pycache__ +**/__pycache__ +*.pyc + +# .NET build outputs (context сборки — корень репозитория: compose.dev.yml build.context: ..) +**/bin +**/obj diff --git a/.editorconfig b/.editorconfig index b2bec5b..820394c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,7 +2,8 @@ root = true [*] charset = utf-8 -end_of_line = crlf +# LF: пишут инструменты проекта (Python/Node), требуется shell-скриптам на Linux CI +end_of_line = lf insert_final_newline = true indent_style = space indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..18d39ac --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +# Нормализация концов строк: в репозитории и рабочей копии — LF. +# Решение 2026-09-11 (backlog TD-STYLE-ANALYZERS): Python/Node-инструменты проекта пишут LF, +# shell-скрипты на Linux CI не работают с CRLF, фактическое большинство файлов — LF. +* text=auto eol=lf + +# Явно бинарные (на всякий случай, auto-детект и так их не трогает) +*.docx binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.zip binary +*.gz binary +*.ttf binary +*.woff binary +*.woff2 binary +*.eot binary +*.pyc binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0dacc1e..745baa4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,28 +1,28 @@ -name: ci - -on: - push: - pull_request: - workflow_dispatch: - -jobs: - build-test: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: .NET 10 - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '10.0.x' - - - name: Node 20 - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: npm - cache-dependency-path: src/frontend/package-lock.json - - - name: CI (сборка, тесты, скан уязвимостей, фронт) - run: sh scripts/ci.sh +name: ci + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Node 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: src/frontend/package-lock.json + + - name: CI (сборка, тесты, скан уязвимостей, фронт) + run: sh scripts/ci.sh diff --git a/.gitignore b/.gitignore index 8466703..35a5cdb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,35 +1,35 @@ -# === .NET === -**/bin/ -**/obj/ -*.user -*.suo -.vs/ -*.nupkg - -# === Node / фронтенд === -**/node_modules/ -**/dist/ -*.log -npm-debug.log* -pnpm-debug.log* -yarn-error.log* - -# === IDE / редакторы === -.idea/ -.vscode/ - -# === Секреты и шаблоны окружения === -# реальные .env не коммитим, но примеры (*.example) — храним -.env -.env.* -!*.example - -# === Локальные сертификаты и ключи (генерируются скриптами) === -deploy/certs/ - -# === Рантайм-данные (БД, объектное хранилище, ключи шифрования) === -archive/leadradar-legacy/data/ -src/core/Deal.Api/data/ +# === .NET === +**/bin/ +**/obj/ +*.user +*.suo +.vs/ +*.nupkg + +# === Node / фронтенд === +**/node_modules/ +**/dist/ +*.log +npm-debug.log* +pnpm-debug.log* +yarn-error.log* + +# === IDE / редакторы === +.idea/ +.vscode/ + +# === Секреты и шаблоны окружения === +# реальные .env не коммитим, но примеры (*.example) — храним +.env +.env.* +!*.example + +# === Локальные сертификаты и ключи (генерируются скриптами) === +deploy/certs/ + +# === Рантайм-данные (БД, объектное хранилище, ключи шифрования) === +archive/leadradar-legacy/data/ +src/core/Deal.Api/data/ # Python __pycache__/ diff --git a/README.md b/README.md index d0d3f0d..2703acc 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,35 @@ -# Дейл (Deal) - -SaaS-мониторинг Telegram: реальные заказы и клиенты вместо рекламы и дубликатов. - -## Документация (актуальное) - -- **ТЗ**: `docs/spec/ТЗ-дейл-новая-архитектура.md` -- **Инструкция пользователя**: `docs/user-guide/Инструкция-пользователя-Дейл.md` -- **Техническая документация** (стек, развёртывание, эксплуатация): `docs/technical/Техническая-документация-Дейл.md` -- **Карта API**: `docs/api/api-map.md` -- **Статус и борд состояния**: `docs/superpowers/STATUS.md` -- **Бэклог (техдолг и отложенное)**: `backlog.md` -- **Код-стайл (полный свод правил)**: `docs/spec/Код-стайл-Дейл.md` - -Исходники: `src/` (`core`, `frontend`, `ai-service`, `ml-service`, `telegram-service`, `contracts`, `grpc-hosting`). -Планы и ledgers этапов: `docs/superpowers/plans/`, `.superpowers/sdd/`. -Архив: `archive/leadradar-legacy/` (прототип), `archive/style-guide-original/` (исходный `Стиль_кода.docx`). - -## Запуск dev-окружения - -Полный стек (postgres + minio + сервисы + core): - -```sh -docker compose -f deploy/compose.dev.yml up -d --build -``` - -Фронтенд (dev-сервер, проксирует `/api` на core :5080): - -```sh -cd src/frontend && npm run dev -``` - -Вход: `admin` / `admin`. Оператор-консоль: `http://localhost:5173/#/operator` (`operator` / `operator`). -Сквозная проверка стека: `sh scripts/dev-smoke.sh`. Сборка: `sh scripts/build.sh`; тесты: `sh scripts/test.sh`; -полный CI-прогон (сборка + тесты + скан уязвимостей + фронт): `sh scripts/ci.sh`. +# Дейл (Deal) + +SaaS-мониторинг Telegram: реальные заказы и клиенты вместо рекламы и дубликатов. + +## Документация (актуальное) + +- **ТЗ**: `docs/spec/ТЗ-дейл-новая-архитектура.md` +- **Инструкция пользователя**: `docs/user-guide/Инструкция-пользователя-Дейл.md` +- **Техническая документация** (стек, развёртывание, эксплуатация): `docs/technical/Техническая-документация-Дейл.md` +- **Карта API**: `docs/api/api-map.md` +- **Статус и борд состояния**: `docs/superpowers/STATUS.md` +- **Бэклог (техдолг и отложенное)**: `backlog.md` +- **Код-стайл (полный свод правил)**: `docs/spec/Код-стайл-Дейл.md` + +Исходники: `src/` (`core`, `frontend`, `ai-service`, `ml-service`, `telegram-service`, `contracts`, `grpc-hosting`). +Планы и ledgers этапов: `docs/superpowers/plans/`, `.superpowers/sdd/`. +Архив: `archive/leadradar-legacy/` (прототип), `archive/style-guide-original/` (исходный `Стиль_кода.docx`). + +## Запуск dev-окружения + +Полный стек (postgres + minio + сервисы + core): + +```sh +docker compose -f deploy/compose.dev.yml up -d --build +``` + +Фронтенд (dev-сервер, проксирует `/api` на core :5080): + +```sh +cd src/frontend && npm run dev +``` + +Вход: `admin` / `admin`. Оператор-консоль: `http://localhost:5173/#/operator` (`operator` / `operator`). +Сквозная проверка стека: `sh scripts/dev-smoke.sh`. Сборка: `sh scripts/build.sh`; тесты: `sh scripts/test.sh`; +полный CI-прогон (сборка + тесты + скан уязвимостей + фронт): `sh scripts/ci.sh`. diff --git a/archive/leadradar-legacy/.env.example b/archive/leadradar-legacy/.env.example index 873afc0..3d1cc4a 100644 --- a/archive/leadradar-legacy/.env.example +++ b/archive/leadradar-legacy/.env.example @@ -1,32 +1,32 @@ -# ─── Неконфиденциальные параметры (по ТЗ секреты в env не передаются) ─── -# Секреты (Telegram api_id/hash, ключи AI, пароль дашборда) задаются в UI -# и хранятся в БД. - -LEADRADAR_HOST=0.0.0.0 -LEADRADAR_PORT=8000 - -# Пути (внутри контейнера монтируются в том ./data) -LEADRADAR_DATA=/data -LEADRADAR_DB_NAME=leadradar.duckdb - -# Опционально: стабильный секрет подписи сессий (иначе создаётся сам -# и сохраняется в data/session_secret.key). Для многоузлового деплоя задайте. -# LEADRADAR_SESSION_SECRET= - -# Учётные данные первого входа (если не заданы — admin/admin) -LEADRADAR_BOOTSTRAP_LOGIN=admin -LEADRADAR_BOOTSTRAP_PASSWORD=admin - -LEADRADAR_LOG_LEVEL=INFO - -# ─── Ключ шифрования секретов БД (Telegram api_hash, ключи AI) ───────── -# Обязателен для продакшена; для локальной разработки без него создаётся -# файл data/encryption.key (см. backend/app/crypto.py). -LEADRADAR_ENCRYPTION_KEY= - -# ─── MinIO (вложения). Креды — в env (по договорённости) ───────────────── -LEADRADAR_MINIO_ENDPOINT=minio:9000 -LEADRADAR_MINIO_ACCESS_KEY=leadradar -LEADRADAR_MINIO_SECRET_KEY=leadradar-secret -LEADRADAR_MINIO_BUCKET=leadradar -LEADRADAR_MINIO_SECURE=false +# ─── Неконфиденциальные параметры (по ТЗ секреты в env не передаются) ─── +# Секреты (Telegram api_id/hash, ключи AI, пароль дашборда) задаются в UI +# и хранятся в БД. + +LEADRADAR_HOST=0.0.0.0 +LEADRADAR_PORT=8000 + +# Пути (внутри контейнера монтируются в том ./data) +LEADRADAR_DATA=/data +LEADRADAR_DB_NAME=leadradar.duckdb + +# Опционально: стабильный секрет подписи сессий (иначе создаётся сам +# и сохраняется в data/session_secret.key). Для многоузлового деплоя задайте. +# LEADRADAR_SESSION_SECRET= + +# Учётные данные первого входа (если не заданы — admin/admin) +LEADRADAR_BOOTSTRAP_LOGIN=admin +LEADRADAR_BOOTSTRAP_PASSWORD=admin + +LEADRADAR_LOG_LEVEL=INFO + +# ─── Ключ шифрования секретов БД (Telegram api_hash, ключи AI) ───────── +# Обязателен для продакшена; для локальной разработки без него создаётся +# файл data/encryption.key (см. backend/app/crypto.py). +LEADRADAR_ENCRYPTION_KEY= + +# ─── MinIO (вложения). Креды — в env (по договорённости) ───────────────── +LEADRADAR_MINIO_ENDPOINT=minio:9000 +LEADRADAR_MINIO_ACCESS_KEY=leadradar +LEADRADAR_MINIO_SECRET_KEY=leadradar-secret +LEADRADAR_MINIO_BUCKET=leadradar +LEADRADAR_MINIO_SECURE=false diff --git a/archive/leadradar-legacy/README.md b/archive/leadradar-legacy/README.md index 60ca529..beb11fe 100644 --- a/archive/leadradar-legacy/README.md +++ b/archive/leadradar-legacy/README.md @@ -1,24 +1,24 @@ -# Архив: legacy LeadRadar - -Здесь лежит прототип **LeadRadar** (Python), который предшествовал проекту **«Дейл»**. -Он больше не используется и не собирается; оставлен только как историческая справка. - -Перемещено 2026-09-10 (из корня репозитория), чтобы не путаться с актуальным стеком: - -- `backend/` — прежний Python-бэкенд (FastAPI) прототипа. -- `mlservice/` — прежний Python-сервис ML прототипа. -- `docker-compose.yml` — прежний compose прототипа (DuckDB/MinIO/Python), ссылается на `backend/`/`mlservice/`. -- `.ruff_cache/` — кэш линтера Python прототипа. -- `data/` — рабочие данные прототипа (DuckDB-дампы, сессии, логи, `encryption.key`). -- `.env`, `.env.example` — env прототипа (`LEADRADAR_*`). -- `ТЗ-LeadRadar-v1.2.md` — исходное ТЗ прототипа LeadRadar V1.2 (DuckDB/FastAPI). - -**Актуальное ТЗ «Дейла»** — `docs/spec/ТЗ-дейл-новая-архитектура.md` (единственный канонический ТЗ). - -**Актуальный стек «Дейл»:** - -- Код — `src/{core,frontend,ai-service,ml-service,telegram-service,grpc-hosting,contracts}`. -- Запуск — `deploy/compose.dev.yml` (dev) / `deploy/compose.prod.yml` (prod). -- Документация — `docs/` (ТЗ, инструкция, техдок, api-map), планы/ledgers — `docs/superpowers/`, `.superpowers/sdd/`. - -Удалять этот архив без необходимости не нужно; он не влияет на сборку и запуск. +# Архив: legacy LeadRadar + +Здесь лежит прототип **LeadRadar** (Python), который предшествовал проекту **«Дейл»**. +Он больше не используется и не собирается; оставлен только как историческая справка. + +Перемещено 2026-09-10 (из корня репозитория), чтобы не путаться с актуальным стеком: + +- `backend/` — прежний Python-бэкенд (FastAPI) прототипа. +- `mlservice/` — прежний Python-сервис ML прототипа. +- `docker-compose.yml` — прежний compose прототипа (DuckDB/MinIO/Python), ссылается на `backend/`/`mlservice/`. +- `.ruff_cache/` — кэш линтера Python прототипа. +- `data/` — рабочие данные прототипа (DuckDB-дампы, сессии, логи, `encryption.key`). +- `.env`, `.env.example` — env прототипа (`LEADRADAR_*`). +- `ТЗ-LeadRadar-v1.2.md` — исходное ТЗ прототипа LeadRadar V1.2 (DuckDB/FastAPI). + +**Актуальное ТЗ «Дейла»** — `docs/spec/ТЗ-дейл-новая-архитектура.md` (единственный канонический ТЗ). + +**Актуальный стек «Дейл»:** + +- Код — `src/{core,frontend,ai-service,ml-service,telegram-service,grpc-hosting,contracts}`. +- Запуск — `deploy/compose.dev.yml` (dev) / `deploy/compose.prod.yml` (prod). +- Документация — `docs/` (ТЗ, инструкция, техдок, api-map), планы/ledgers — `docs/superpowers/`, `.superpowers/sdd/`. + +Удалять этот архив без необходимости не нужно; он не влияет на сборку и запуск. diff --git a/archive/leadradar-legacy/backend/.gitignore b/archive/leadradar-legacy/backend/.gitignore index f491367..59f881a 100644 --- a/archive/leadradar-legacy/backend/.gitignore +++ b/archive/leadradar-legacy/backend/.gitignore @@ -1,5 +1,5 @@ -__pycache__/ -*.pyc -data/ -*.duckdb -.env +__pycache__/ +*.pyc +data/ +*.duckdb +.env diff --git a/archive/leadradar-legacy/backend/Dockerfile b/archive/leadradar-legacy/backend/Dockerfile index 9142b15..bec92b5 100644 --- a/archive/leadradar-legacy/backend/Dockerfile +++ b/archive/leadradar-legacy/backend/Dockerfile @@ -1,24 +1,24 @@ -# ─── Этап 1: сборка фронтенда ───────────────────────────────────────────── -FROM node:22-alpine AS frontend -WORKDIR /fe -COPY frontend/package.json frontend/package-lock.json* ./ -RUN npm install --no-audit --no-fund -COPY frontend/ ./ -RUN npm run build - -# ─── Этап 2: бэкенд + статика фронта ────────────────────────────────────── -FROM python:3.12-slim -WORKDIR /srv - -COPY backend/requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY backend/app ./app -COPY --from=frontend /fe/dist ./frontend_dist - -ENV LEADRADAR_FRONTEND_DIST=/srv/frontend_dist \ - LEADRADAR_DATA=/data \ - PYTHONUNBUFFERED=1 - -EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +# ─── Этап 1: сборка фронтенда ───────────────────────────────────────────── +FROM node:22-alpine AS frontend +WORKDIR /fe +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm install --no-audit --no-fund +COPY frontend/ ./ +RUN npm run build + +# ─── Этап 2: бэкенд + статика фронта ────────────────────────────────────── +FROM python:3.12-slim +WORKDIR /srv + +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/app ./app +COPY --from=frontend /fe/dist ./frontend_dist + +ENV LEADRADAR_FRONTEND_DIST=/srv/frontend_dist \ + LEADRADAR_DATA=/data \ + PYTHONUNBUFFERED=1 + +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/archive/leadradar-legacy/backend/app/__init__.py b/archive/leadradar-legacy/backend/app/__init__.py index 220018e..d227147 100644 --- a/archive/leadradar-legacy/backend/app/__init__.py +++ b/archive/leadradar-legacy/backend/app/__init__.py @@ -1 +1 @@ -"""LeadRadar backend (FastAPI + DuckDB).""" +"""LeadRadar backend (FastAPI + DuckDB).""" diff --git a/archive/leadradar-legacy/backend/app/auth.py b/archive/leadradar-legacy/backend/app/auth.py index 043eab9..d61327e 100644 --- a/archive/leadradar-legacy/backend/app/auth.py +++ b/archive/leadradar-legacy/backend/app/auth.py @@ -1,98 +1,98 @@ -"""Авторизация в дашборд: admin/admin по умолчанию, сессия 30 дней. - -Хэш пароля — PBKDF2-HMAC-SHA256. Токен сессии — случайный, хранится в БД, -передаётся httpOnly-кукой. По ТЗ смена пароля — через интерфейс. -""" -from __future__ import annotations - -import hashlib -import hmac -import secrets -import time - -from fastapi import Cookie, HTTPException, Response - -from . import config -from .db import store - -_ITERATIONS = 200_000 - - -def _hash_password(password: str, salt: str) -> str: - return hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), _ITERATIONS).hex() - - -def _make_salt() -> str: - return secrets.token_hex(16) - - -def ensure_creds() -> None: - """Создаёт учётные данные по умолчанию, если их нет.""" - if store.scalar("SELECT count(*) FROM creds") == 0: - salt = _make_salt() - store.execute( - "INSERT INTO creds(login, password_hash, salt) VALUES (?, ?, ?)", - [config.BOOTSTRAP_LOGIN, _hash_password(config.BOOTSTRAP_PASSWORD, salt), salt], - ) - - -def verify_password(login: str, password: str) -> bool: - row = store.query_one("SELECT password_hash, salt FROM creds WHERE login = ?", [login]) - if not row: - return False - return hmac.compare_digest(row["password_hash"], _hash_password(password, row["salt"])) - - -def change_password(login: str, old_password: str, new_password: str) -> bool: - if not verify_password(login, old_password): - return False - if len(new_password) < 4: - raise HTTPException(400, "Пароль слишком короткий (минимум 4 символа)") - salt = _make_salt() - store.execute( - "UPDATE creds SET password_hash = ?, salt = ? WHERE login = ?", - [_hash_password(new_password, salt), salt, login], - ) - # разлогиниваем все старые сессии пользователя - store.execute("DELETE FROM sessions WHERE login = ?", [login]) - return True - - -def create_session(login: str) -> str: - token = secrets.token_urlsafe(32) - expires = time.time_ns() // 1_000_000 + config.SESSION_DAYS * 86_400_000 - store.execute("INSERT INTO sessions(token, login, expires) VALUES (?, ?, ?)", [token, login, expires]) - return token - - -def resolve_session(token: str | None) -> str | None: - if not token: - return None - row = store.query_one( - "SELECT login FROM sessions WHERE token = ? AND expires > ?", - [token, time.time_ns() // 1_000_000], - ) - return row["login"] if row else None - - -def set_session_cookie(response: Response, token: str) -> None: - response.set_cookie( - key=config.COOKIE_NAME, - value=token, - max_age=config.SESSION_DAYS * 86400, - httponly=True, - samesite="lax", - # secure включается при HTTPS-проксировании; флаг не критичен локально - ) - - -def destroy_session(token: str | None) -> None: - if token: - store.execute("DELETE FROM sessions WHERE token = ?", [token]) - - -def current_login(token: str | None = Cookie(default=None, alias=config.COOKIE_NAME)) -> str | None: - login = resolve_session(token) - if not login: - raise HTTPException(401, "Требуется авторизация") - return login +"""Авторизация в дашборд: admin/admin по умолчанию, сессия 30 дней. + +Хэш пароля — PBKDF2-HMAC-SHA256. Токен сессии — случайный, хранится в БД, +передаётся httpOnly-кукой. По ТЗ смена пароля — через интерфейс. +""" +from __future__ import annotations + +import hashlib +import hmac +import secrets +import time + +from fastapi import Cookie, HTTPException, Response + +from . import config +from .db import store + +_ITERATIONS = 200_000 + + +def _hash_password(password: str, salt: str) -> str: + return hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), _ITERATIONS).hex() + + +def _make_salt() -> str: + return secrets.token_hex(16) + + +def ensure_creds() -> None: + """Создаёт учётные данные по умолчанию, если их нет.""" + if store.scalar("SELECT count(*) FROM creds") == 0: + salt = _make_salt() + store.execute( + "INSERT INTO creds(login, password_hash, salt) VALUES (?, ?, ?)", + [config.BOOTSTRAP_LOGIN, _hash_password(config.BOOTSTRAP_PASSWORD, salt), salt], + ) + + +def verify_password(login: str, password: str) -> bool: + row = store.query_one("SELECT password_hash, salt FROM creds WHERE login = ?", [login]) + if not row: + return False + return hmac.compare_digest(row["password_hash"], _hash_password(password, row["salt"])) + + +def change_password(login: str, old_password: str, new_password: str) -> bool: + if not verify_password(login, old_password): + return False + if len(new_password) < 4: + raise HTTPException(400, "Пароль слишком короткий (минимум 4 символа)") + salt = _make_salt() + store.execute( + "UPDATE creds SET password_hash = ?, salt = ? WHERE login = ?", + [_hash_password(new_password, salt), salt, login], + ) + # разлогиниваем все старые сессии пользователя + store.execute("DELETE FROM sessions WHERE login = ?", [login]) + return True + + +def create_session(login: str) -> str: + token = secrets.token_urlsafe(32) + expires = time.time_ns() // 1_000_000 + config.SESSION_DAYS * 86_400_000 + store.execute("INSERT INTO sessions(token, login, expires) VALUES (?, ?, ?)", [token, login, expires]) + return token + + +def resolve_session(token: str | None) -> str | None: + if not token: + return None + row = store.query_one( + "SELECT login FROM sessions WHERE token = ? AND expires > ?", + [token, time.time_ns() // 1_000_000], + ) + return row["login"] if row else None + + +def set_session_cookie(response: Response, token: str) -> None: + response.set_cookie( + key=config.COOKIE_NAME, + value=token, + max_age=config.SESSION_DAYS * 86400, + httponly=True, + samesite="lax", + # secure включается при HTTPS-проксировании; флаг не критичен локально + ) + + +def destroy_session(token: str | None) -> None: + if token: + store.execute("DELETE FROM sessions WHERE token = ?", [token]) + + +def current_login(token: str | None = Cookie(default=None, alias=config.COOKIE_NAME)) -> str | None: + login = resolve_session(token) + if not login: + raise HTTPException(401, "Требуется авторизация") + return login diff --git a/archive/leadradar-legacy/backend/app/config.py b/archive/leadradar-legacy/backend/app/config.py index 63d37ac..5b7a80e 100644 --- a/archive/leadradar-legacy/backend/app/config.py +++ b/archive/leadradar-legacy/backend/app/config.py @@ -1,99 +1,99 @@ -"""Конфигурация из переменных окружения. - -По ТЗ в env выносятся ТОЛЬКО неконфиденциальные параметры: -порты, пути к БД/данным/сессиям. Секреты (Telegram api_id/hash, -ключи AI, пароль дашборда) живут в базе и задаются через UI. -""" -from __future__ import annotations - -import os -from pathlib import Path - - -def _bool(name: str, default: bool = False) -> bool: - raw = os.getenv(name) - if raw is None: - return default - return raw.strip().lower() in {"1", "true", "yes", "on"} - - -def _int(name: str, default: int) -> int: - try: - return int(os.getenv(name, str(default))) - except ValueError: - return default - - -# Корень данных (том в docker-compose). Внутри лежат leadradar.duckdb, -# telegram-сессии, файлы вложений (позже — MinIO), секрет подписи сессий. -DATA_DIR = Path(os.getenv("LEADRADAR_DATA", str(Path(__file__).resolve().parent.parent / "data"))).resolve() -DB_PATH = DATA_DIR / os.getenv("LEADRADAR_DB_NAME", "leadradar.duckdb") -SESSIONS_DIR = DATA_DIR / "telegram_sessions" -FILES_DIR = DATA_DIR / "attachments" -SECRET_FILE = DATA_DIR / "session_secret.key" - -# Хост/порт HTTP-сервера -HOST = os.getenv("LEADRADAR_HOST", "0.0.0.0") -PORT = _int("LEADRADAR_PORT", 8000) - -# Через env допустимо задать стартовый секрет (например, из секрет-менеджера), -# но если он не задан — будет создан случайный и сохранён в SECRET_FILE. -SESSION_SECRET = os.getenv("LEADRADAR_SESSION_SECRET", "") or None - -# Учётные данные первого входа можно выставить и в env (удобно для развёртывания), -# НО по умолчанию система стартует с admin/admin и требует смены пароля. -BOOTSTRAP_LOGIN = os.getenv("LEADRADAR_BOOTSTRAP_LOGIN", "admin") -BOOTSTRAP_PASSWORD = os.getenv("LEADRADAR_BOOTSTRAP_PASSWORD", "admin") - -# Шифрование секретов в БД. По договорённости ключ живёт в env; -# при локальной разработке без env создаётся файл data/encryption.key. -ENCRYPTION_KEY = os.getenv("LEADRADAR_ENCRYPTION_KEY", "") or None -ENCRYPTION_KEY_FILE = DATA_DIR / "encryption.key" - -# MinIO (вложения; креды по договорённости — в env) -MINIO_ENDPOINT = os.getenv("LEADRADAR_MINIO_ENDPOINT", "") -MINIO_ACCESS_KEY = os.getenv("LEADRADAR_MINIO_ACCESS_KEY", "") -MINIO_SECRET_KEY = os.getenv("LEADRADAR_MINIO_SECRET_KEY", "") -MINIO_BUCKET = os.getenv("LEADRADAR_MINIO_BUCKET", "leadradar") -MINIO_SECURE = _bool("LEADRADAR_MINIO_SECURE", False) - -# Автономный ML-сервис (отдельный контейнер). Если недоступен — модель не -# используется в пайплайне, а события обучения копятся в outbox до его возврата. -ML_URL = os.getenv("LEADRADAR_ML_URL", "http://127.0.0.1:8100").rstrip("/") -ML_TIMEOUT = _int("LEADRADAR_ML_TIMEOUT", 30) # обучение батчами — тяжёлое, таймаут щедрый - -# Куки -COOKIE_NAME = "leadradar_session" -SESSION_DAYS = 30 - -# Telegram-сессия (Telethon) -SESSION_PREFIX = "user" - -# Путь к статике фронтенда (собранный dist). Если папки нет — API работает -# отдельно (фронт поднимается dev-сервером), статика просто не раздаётся. -FRONTEND_DIST = Path(os.getenv("LEADRADAR_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"))).resolve() - -# Логи -LOG_LEVEL = os.getenv("LEADRADAR_LOG_LEVEL", "INFO") - -# Демо-эндпоинты (simulate-lead, age-lead). В проде выключено; -# включается только для локальных тестов разработчика: LEADRADAR_DEMO=1 -DEMO_ENABLED = _bool("LEADRADAR_DEMO", False) - - -def ensure_dirs() -> None: - DATA_DIR.mkdir(parents=True, exist_ok=True) - SESSIONS_DIR.mkdir(parents=True, exist_ok=True) - FILES_DIR.mkdir(parents=True, exist_ok=True) - - -def get_or_create_secret() -> str: - if SESSION_SECRET: - return SESSION_SECRET - if SECRET_FILE.exists(): - return SECRET_FILE.read_text(encoding="utf-8").strip() - import secrets - - secret = secrets.token_hex(32) - SECRET_FILE.write_text(secret, encoding="utf-8") - return secret +"""Конфигурация из переменных окружения. + +По ТЗ в env выносятся ТОЛЬКО неконфиденциальные параметры: +порты, пути к БД/данным/сессиям. Секреты (Telegram api_id/hash, +ключи AI, пароль дашборда) живут в базе и задаются через UI. +""" +from __future__ import annotations + +import os +from pathlib import Path + + +def _bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _int(name: str, default: int) -> int: + try: + return int(os.getenv(name, str(default))) + except ValueError: + return default + + +# Корень данных (том в docker-compose). Внутри лежат leadradar.duckdb, +# telegram-сессии, файлы вложений (позже — MinIO), секрет подписи сессий. +DATA_DIR = Path(os.getenv("LEADRADAR_DATA", str(Path(__file__).resolve().parent.parent / "data"))).resolve() +DB_PATH = DATA_DIR / os.getenv("LEADRADAR_DB_NAME", "leadradar.duckdb") +SESSIONS_DIR = DATA_DIR / "telegram_sessions" +FILES_DIR = DATA_DIR / "attachments" +SECRET_FILE = DATA_DIR / "session_secret.key" + +# Хост/порт HTTP-сервера +HOST = os.getenv("LEADRADAR_HOST", "0.0.0.0") +PORT = _int("LEADRADAR_PORT", 8000) + +# Через env допустимо задать стартовый секрет (например, из секрет-менеджера), +# но если он не задан — будет создан случайный и сохранён в SECRET_FILE. +SESSION_SECRET = os.getenv("LEADRADAR_SESSION_SECRET", "") or None + +# Учётные данные первого входа можно выставить и в env (удобно для развёртывания), +# НО по умолчанию система стартует с admin/admin и требует смены пароля. +BOOTSTRAP_LOGIN = os.getenv("LEADRADAR_BOOTSTRAP_LOGIN", "admin") +BOOTSTRAP_PASSWORD = os.getenv("LEADRADAR_BOOTSTRAP_PASSWORD", "admin") + +# Шифрование секретов в БД. По договорённости ключ живёт в env; +# при локальной разработке без env создаётся файл data/encryption.key. +ENCRYPTION_KEY = os.getenv("LEADRADAR_ENCRYPTION_KEY", "") or None +ENCRYPTION_KEY_FILE = DATA_DIR / "encryption.key" + +# MinIO (вложения; креды по договорённости — в env) +MINIO_ENDPOINT = os.getenv("LEADRADAR_MINIO_ENDPOINT", "") +MINIO_ACCESS_KEY = os.getenv("LEADRADAR_MINIO_ACCESS_KEY", "") +MINIO_SECRET_KEY = os.getenv("LEADRADAR_MINIO_SECRET_KEY", "") +MINIO_BUCKET = os.getenv("LEADRADAR_MINIO_BUCKET", "leadradar") +MINIO_SECURE = _bool("LEADRADAR_MINIO_SECURE", False) + +# Автономный ML-сервис (отдельный контейнер). Если недоступен — модель не +# используется в пайплайне, а события обучения копятся в outbox до его возврата. +ML_URL = os.getenv("LEADRADAR_ML_URL", "http://127.0.0.1:8100").rstrip("/") +ML_TIMEOUT = _int("LEADRADAR_ML_TIMEOUT", 30) # обучение батчами — тяжёлое, таймаут щедрый + +# Куки +COOKIE_NAME = "leadradar_session" +SESSION_DAYS = 30 + +# Telegram-сессия (Telethon) +SESSION_PREFIX = "user" + +# Путь к статике фронтенда (собранный dist). Если папки нет — API работает +# отдельно (фронт поднимается dev-сервером), статика просто не раздаётся. +FRONTEND_DIST = Path(os.getenv("LEADRADAR_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"))).resolve() + +# Логи +LOG_LEVEL = os.getenv("LEADRADAR_LOG_LEVEL", "INFO") + +# Демо-эндпоинты (simulate-lead, age-lead). В проде выключено; +# включается только для локальных тестов разработчика: LEADRADAR_DEMO=1 +DEMO_ENABLED = _bool("LEADRADAR_DEMO", False) + + +def ensure_dirs() -> None: + DATA_DIR.mkdir(parents=True, exist_ok=True) + SESSIONS_DIR.mkdir(parents=True, exist_ok=True) + FILES_DIR.mkdir(parents=True, exist_ok=True) + + +def get_or_create_secret() -> str: + if SESSION_SECRET: + return SESSION_SECRET + if SECRET_FILE.exists(): + return SECRET_FILE.read_text(encoding="utf-8").strip() + import secrets + + secret = secrets.token_hex(32) + SECRET_FILE.write_text(secret, encoding="utf-8") + return secret diff --git a/archive/leadradar-legacy/backend/app/constants.py b/archive/leadradar-legacy/backend/app/constants.py index b393f70..91260e1 100644 --- a/archive/leadradar-legacy/backend/app/constants.py +++ b/archive/leadradar-legacy/backend/app/constants.py @@ -1,253 +1,253 @@ -"""Базовые константы: палитры, стадии, промпты, валюты, провайдеры AI. - -Значения согласованы с фронтенд-прототипом (frontend/src/data.js), -чтобы интеграция была безболезненной. -""" - -# Палитра колонок-досок (по умолчанию) -PALETTE = ["#818cf8", "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"] - -# Колонки не создаются по умолчанию: их создаёт пользователь или предлагает -# ИИ (suggested=TRUE — ждёт решения пользователя). - -# Служебные колонки дашборда -SERVICE_COLS = {"inbox", "archive", "trash", "taken"} - -# Стадии канбана «Выбранных» -PIPELINE_STAGES = [ - {"id": "planned", "name": "Запланировано", "color": "#818cf8", "terminal": False}, - {"id": "reply", "name": "Отклик", "color": "#38bdf8", "terminal": False}, - {"id": "agree", "name": "Согласование", "color": "#a78bfa", "terminal": False}, - {"id": "work", "name": "В работе", "color": "#fbbf24", "terminal": False}, - {"id": "review", "name": "Проверка", "color": "#f97316", "terminal": False}, - {"id": "ready", "name": "Готово", "color": "#4ade80", "terminal": False}, - {"id": "hold", "name": "Отложено", "color": "#94a3b8", "terminal": False}, - {"id": "finished", "name": "Выполнено", "color": "#2bd576", "terminal": True}, - {"id": "rejected", "name": "Отклонено", "color": "#ff6b6b", "terminal": True}, -] - -# Валюты и мок-курсы (до первого успешного запроса к ЦБ) -CURRENCIES = [ - {"code": "RUB", "name": "Российский рубль", "symbol": "₽"}, - {"code": "USD", "name": "Доллар США", "symbol": "$"}, - {"code": "EUR", "name": "Евро", "symbol": "€"}, - {"code": "CNY", "name": "Китайский юань", "symbol": "¥"}, - {"code": "KZT", "name": "Казахстанский тенге", "symbol": "₸"}, - {"code": "BYN", "name": "Белорусский рубль", "symbol": "Br"}, - {"code": "USDT", "name": "Tether (USDT)", "symbol": "₮"}, - {"code": "GBP", "name": "Британский фунт", "symbol": "£"}, -] - -MOCK_RATES = { - "RUB": 1.0, - "USD": 92.5, - "EUR": 99.9, - "CNY": 13.1, - "KZT": 0.19, - "BYN": 28.6, - "USDT": 92.5, - "GBP": 117.4, -} - -CBR_URL = "https://www.cbr-xml-daily.ru/daily_json.js" - -# Стоп-фразы по умолчанию (этап 1 фильтра — без ИИ) -DEFAULT_STOP_PHRASES = ["взаимный пиар", "резюме", "ищу работу", "набор в команду"] - -DEFAULT_MIN_LEN = 24 - -# Промпты. В тексте можно использовать плейсхолдеры, которые подставляются из -# настроек «Сферы и ключей» при каждом вызове ИИ: -# {domain} — domainDescription (что для вас заявка/лид, ваша сфера) -# {keywords} — domainKeywords (общие слова-маркеры заявок) -DEFAULT_AI_PROMPT = ( - "Ты — классификатор входящих сообщений. Сообщение — ЗАЯВКА (лид), только если в нём есть конкретный" - " запрос или предложение по делу: заказ услуги/товара/работы, поиск исполнителя или найм человека." - " Направление вашей сферы:\n{domain}\n\n" - "Частые слова-маркеры заявок в вашей сфере: {keywords}\n\n" - "Определи по тексту:\n" - "1. is_spam — true, если это НЕ заявка: служебные сообщения (коды входа/подтверждения, уведомления)," - " приветствия и поздравления, флуд и обсуждения без задачи, вопросы без конкретики, реклама, скам," - " фин. пирамиды, резюме соискателей, взаимный пиар, приглашения в чаты, рассылки." - " Если сомневаешься — ставь true (лучше пропустить сообщение, чем засорить карточками)\n" - "2. board — id подходящей доски из списка. Выбирай по КРИТЕРИЯМ колонки" - " (в скобках указаны её направление, тематика, ключевые слова, уровень, бюджет, описание), а не только по названию;" - " если ни одна колонка не подходит под текст — верни null (карточка пойдёт в «Неразобранное»)\n" - "3. is_vacancy — true, если это НАЙМ/постоянная или проектная занятость: ищут человека в команду" - " (признаки: вакансия, грейд/уровень, «в компанию», hr, зарплата за месяц);" - " false — разовая сделка: заказ/услуга/товар (фриланс, подряд, «нужно сделать/купить», цена за работу)\n" - "4. title — короткий заголовок (4–9 слов, без эмодзи, хэштегов и знаков препинания в конце)." - " Запрещено: markdown, ссылки в любом виде ([текст](url), голые url) — только чистый текст\n" - "5. company — кто разместил заявку: компания/бренд/частное лицо/заказчик. Только факт из текста;" - " не указано — верни пустую строку \"\"\n" - "6. format — формат работы/выполнения: удалённо/офис/гибрид, город, график. Коротко; нет — \"\"\n" - "7. task — 1–2 предложения: что за задача/роль и в чём суть (для кого, что нужно сделать)." - " Ёмко, без пересказа всего объявления и без служебных строк\n" - "8. requirements — JSON-массив строк ключевых требований к кандидату/исполнителю" - " (что нужно уметь/иметь, пункты «Требования/Обязанности/Нужно»). Нет — []\n" - "9. plus — JSON-массив строк «будет плюсом»/«приветствуется»/«желательно». Нет — []\n" - "10. conditions — условия одной строкой: оплата/ЗП/вилка, сроки, объём, тип занятости. Нет — \"\"\n" - "11. stack — JSON-массив строк (не строка!): технологии/предметы/услуги/материалы из заявки." - " Названия пиши слитно как в оригинале: \".NET\", \"C#\", \"Node.js\", \"ASP.NET Core\" — не разбивай на отдельные буквы (2–8)\n" - "12. budget — объект { from, to, currency }: одна сумма → from=to=X; «до X» → from=null, to=X;" - " диапазон «от X до Y» → from=X, to=Y. Если суммы нет — null\n" - "13. contacts — JSON-массив строк контактов ДЛЯ СВЯЗИ: телефон, email, @username (не бот)," - " ссылка на профиль человека (LinkedIn, t.me/…). НЕ включай ботов, каналы/группы и ссылки на" - " вакансию/пост/форму. Если контакта в тексте нет — пустой массив []\n" - "Поля 5–10 — это блок «О заявке» на карточке: заполняй их только фактами из текста, ничего не выдумывай." - " Запрещено: markdown-разметка, ссылки в любом виде, эмодзи, хэштеги, «от»/«привет», дословное копирование исходника.\n" - "Отвечай строго в формате JSON." -) - -DEFAULT_AI_FILTER_PROMPT = ( - "Ты — страж входящих сообщений каналов. Пропускай только реальные заявки/лиды по вашей сфере" - " (заказ, услуга, товар, найм — с конкретикой).\n" - "Ваша сфера и что считать заявкой:\n{domain}\n\n" - "Слова-маркеры заявок: {keywords}\n\n" - "НЕ пропускай:\n" - "- служебные сообщения: коды входа/подтверждения, уведомления, приветствия, поздравления\n" - "- просто сообщения без задачи и конкретики: флуд, обсуждения, вопросы «кто работал с …»\n" - "- рекламу и саморекламу\n" - "- скам, фин. пирамиды, «заработок»\n" - "- резюме, поиск работы соискателями\n" - "- взаимный пиар, приглашения в чаты\n" - "- рассылки и дайджесты без прямых заявок\n" - "При сомнении — не пропускай.\n" - 'Верни строго JSON: { "pass": true|false, "reason": "причина отказа или null" }' -) - -# Промпт структуры карточки (блок «О заявке»). Отдельный от классификатора: -# задаёт, какие поля и как заполнять, чтобы все карточки имели одинаковую -# структуру текста (разной длины). Добавляется к промпту классификатора -# отдельным блоком (см. services/ai.classify). Редактируется в UI. -DEFAULT_AI_CARD_PROMPT = ( - "Ты также возвращаешь содержимое блока «О заявке» карточки — поля company, format," - " task, requirements, plus, conditions. Карточка всегда собирается из одних и тех же блоков:" - " одинаковая структура у всех карточек, различается только длина.\n" - "1. company — кто ищет/разместил: компания, бренд, агентство, частное лицо, заказчик. Одно предложение, факт из текста.\n" - "2. format — формат работы: удалённо/офис/гибрид/разъездной, город/страна, график (5/2, full-time, part-time). Коротко.\n" - "3. task — 1–2 предложения по шаблону: что за задача/роль → для кого → что нужно сделать/какой результат." - " Пиши ёмко, не пересказывай объявление дословно.\n" - "4. requirements — вынеси сюда только реальные требования/обязанности из текста" - " (пункты списков «Требования», «Обязанности», «Что предстоит делать», «Нужно»):" - " каждый пункт короткой строкой в JSON-массиве. Не придумывай сверх текста.\n" - "5. plus — только то, что отмечено как «будет плюсом»/«приветствуется»/«желательно»." - " Нет таких пунктов — пустой массив [].\n" - "6. conditions — условия одной строкой: оплата/ЗП/вилка/гонорар, сроки, объём, тип занятости." - " Не дублируй budget-числа в других полях.\n" - "Для не-IT сфер «стек/требования» означают материалы, услуги, навыки, инструменты — по смыслу заявки." - " Запрещено во всех полях: markdown-разметка, ссылки в любом виде ([текст](url), голые url)," - " эмодзи, хэштеги, «от»/«привет»/служебные строки канала." -) - -# Дефолтные маркеры локального разбора (настраиваются в UI «Сфера и ключи»). -DEFAULT_HIRE_MARKERS = [ - "вакансия", "вакансию", "вакансии", "вакантна", "вакант", "нанимаем", "найм", "full-time", - "на постоянную", "занятость", "в офис", "официальное оформление", "пятидневка", - "грейд", "в компанию", "полная занятость", "на постоянную работу", "в команду", "нанимает", - "на постоянную основу", "в штат", -] - -DEFAULT_LEVEL_TERMS = [ - "junior", "джун", "джуниор", "middle", "мидл", "mid", "senior", "сеньор", "сеньйор", - "lead", "тимлид", "тиэмлид", "architect", "архитектор", "стажёр", "стажер", "intern", "trainee", -] - -# Маркеры, по которым сообщение опознаётся как резюме/самопрезентация -# соискателя («ищу работу», «моё резюме»). По умолчанию такие сообщения -# отсекаются на этапе 1 (до ИИ и правил колонок), если пользователь ищет -# вакансии и заказы, а не кандидатов. Редактируется в «Сфере и ключах»; -# выключите отсев — резюме начнут собираться как обычные лиды. -# Слово «резюме» имеет контекстный guard (см. pipeline._resume_reason): -# «…вакансия…, присылайте резюме» — это объявление работодателя, его не режем. -DEFAULT_RESUME_MARKERS = [ - "резюме", "#резюме", "моё резюме", "мое резюме", "ищу работу", "ищу вакансию", - "рассмотрю предложения", "готов к собеседованию", "в поиске работы", - "ищу проект", "ищу подработку", -] - -# AI-провайдеры (активен один) -AI_PROVIDERS = [ - {"id": "deepseek", "name": "DeepSeek", "base": "https://api.deepseek.com", "local": False, - "models": ["deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-flash-vision-exp"]}, - {"id": "openai", "name": "OpenAI", "base": "https://api.openai.com/v1", "local": False, - "models": ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]}, - {"id": "openrouter", "name": "OpenRouter", "base": "https://openrouter.ai/api/v1", "local": False, - "models": ["deepseek/deepseek-v4-flash", "anthropic/claude-sonnet-5", "openai/gpt-5.6-luna", - "google/gemini-3.8-flash", "qwen/qwen3.8-flash"]}, - {"id": "anthropic", "name": "Anthropic Claude", "base": "https://api.anthropic.com", "local": False, - "api_style": "anthropic", - "models": ["claude-sonnet-5", "claude-opus-5", "claude-fable-5-1", "claude-haiku-4-5-20251001"]}, - {"id": "ollama", "name": "Ollama (локально)", "base": "http://localhost:11434/v1", "local": True, - "models": ["qwen3-coder:30b", "qwen3-coder:480b", "qwen3:32b"]}, - {"id": "lmstudio", "name": "LM Studio (локально)", "base": "http://localhost:1234/v1", "local": True, - "models": ["qwen3-coder-30b", "qwen3-coder-480b", "llama-3.3-70b"]}, - {"id": "custom", "name": "Другой (OpenAI-совместимый)", "base": "https://", "local": False, "models": []}, -] - -# Настройки по умолчанию (ключи -> значение; не секреты) -DEFAULT_SETTINGS = { - # хранилище - "autoArchive": True, - "archiveAfterDays": 14, # 1..30 - "archiveClearDays": 90, - "trashClearDays": 7, - # фильтры входящих - "minLen": DEFAULT_MIN_LEN, - "stopPhrases": DEFAULT_STOP_PHRASES, - "mlEnabled": True, # локальный ML-слой (обучается на действиях пользователя) - "aiEnabled": True, # полный выключатель ИИ: false = только фильтр + ML - "aiFilterEnabled": True, - "aiFilterPrompt": DEFAULT_AI_FILTER_PROMPT, - "aiPrompt": DEFAULT_AI_PROMPT, - "cardPrompt": DEFAULT_AI_CARD_PROMPT, # структура карточки/блока «О заявке» (отдельный промпт) - # сфера и ключи (универсальный классификатор: редактируется в UI) - "domainDescription": "", - "domainKeywords": [], - "hireMarkers": DEFAULT_HIRE_MARKERS, - "levelTerms": DEFAULT_LEVEL_TERMS, - "resumeMarkers": DEFAULT_RESUME_MARKERS, - "blockResumes": True, # True = ищем вакансии/заказы, резюме соискателей отсекаем - # тип заявок, которые собираем: both | vacancy | freelance (этап 1, до ИИ) - "wantedType": "both", - # «без указания суммы карточку не создаём»: отдельно для найма и заказов - "budgetRequiredHire": False, - "budgetRequiredOrder": False, - # подписи типов на карточках (по ситуации/сфере пользователя) - "hireLabel": "вакансия", - "orderLabel": "фриланс", - # личная библиотека промптов пользователя: [{id, name, description, prompt}] - "myPrompts": [], - # напоминания - "remindersEnabled": True, - # валюта и курсы - "conversionOn": True, - "targetCurrency": "RUB", - "rateSource": "cbr", # cbr | mock - # UI-состояния колонок - "colState": {}, - # AI: активный провайдер и конфиги (ключи хранятся здесь же, наружу маскируются) - "aiProvider": "deepseek", - "aiConfigs": { - p["id"]: {"apiKey": "", "baseUrl": p["base"], "model": (p["models"] or [""])[0]} - for p in AI_PROVIDERS - }, - # telegram-ключи: {api_id, api_hash, account} - "tgKeys": {"apiId": "", "apiHash": ""}, - # авто-мониторинг новых чатов/каналов (добавлены с другого клиента) - "autoMonitorNew": True, - # поиск каналов (Discovery) - "discJoinLimit": 50, # суточный лимит авто-вступлений (общий) - "discJoinDelayMin": 50, # сек, нижняя граница паузы между авто-вступлениями - "discJoinDelayMax": 70, # сек, верхняя граница - "discEvalSample": 10, # размер выборки сообщений при оценке - "discEvalThreshold": 40, # % подходящих сообщений -} - -# Диалоговые цвета (детерминированно по имени) -DIALOG_HUES = ["#3b82f6", "#38bdf8", "#f472b6", "#f59e0b", "#a78bfa", "#34d399", "#fb7185", "#22c55e"] - -# Множители единиц времени -DAY_MS = 86_400_000 -HOUR_MS = 3_600_000 -MIN_MS = 60_000 +"""Базовые константы: палитры, стадии, промпты, валюты, провайдеры AI. + +Значения согласованы с фронтенд-прототипом (frontend/src/data.js), +чтобы интеграция была безболезненной. +""" + +# Палитра колонок-досок (по умолчанию) +PALETTE = ["#818cf8", "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"] + +# Колонки не создаются по умолчанию: их создаёт пользователь или предлагает +# ИИ (suggested=TRUE — ждёт решения пользователя). + +# Служебные колонки дашборда +SERVICE_COLS = {"inbox", "archive", "trash", "taken"} + +# Стадии канбана «Выбранных» +PIPELINE_STAGES = [ + {"id": "planned", "name": "Запланировано", "color": "#818cf8", "terminal": False}, + {"id": "reply", "name": "Отклик", "color": "#38bdf8", "terminal": False}, + {"id": "agree", "name": "Согласование", "color": "#a78bfa", "terminal": False}, + {"id": "work", "name": "В работе", "color": "#fbbf24", "terminal": False}, + {"id": "review", "name": "Проверка", "color": "#f97316", "terminal": False}, + {"id": "ready", "name": "Готово", "color": "#4ade80", "terminal": False}, + {"id": "hold", "name": "Отложено", "color": "#94a3b8", "terminal": False}, + {"id": "finished", "name": "Выполнено", "color": "#2bd576", "terminal": True}, + {"id": "rejected", "name": "Отклонено", "color": "#ff6b6b", "terminal": True}, +] + +# Валюты и мок-курсы (до первого успешного запроса к ЦБ) +CURRENCIES = [ + {"code": "RUB", "name": "Российский рубль", "symbol": "₽"}, + {"code": "USD", "name": "Доллар США", "symbol": "$"}, + {"code": "EUR", "name": "Евро", "symbol": "€"}, + {"code": "CNY", "name": "Китайский юань", "symbol": "¥"}, + {"code": "KZT", "name": "Казахстанский тенге", "symbol": "₸"}, + {"code": "BYN", "name": "Белорусский рубль", "symbol": "Br"}, + {"code": "USDT", "name": "Tether (USDT)", "symbol": "₮"}, + {"code": "GBP", "name": "Британский фунт", "symbol": "£"}, +] + +MOCK_RATES = { + "RUB": 1.0, + "USD": 92.5, + "EUR": 99.9, + "CNY": 13.1, + "KZT": 0.19, + "BYN": 28.6, + "USDT": 92.5, + "GBP": 117.4, +} + +CBR_URL = "https://www.cbr-xml-daily.ru/daily_json.js" + +# Стоп-фразы по умолчанию (этап 1 фильтра — без ИИ) +DEFAULT_STOP_PHRASES = ["взаимный пиар", "резюме", "ищу работу", "набор в команду"] + +DEFAULT_MIN_LEN = 24 + +# Промпты. В тексте можно использовать плейсхолдеры, которые подставляются из +# настроек «Сферы и ключей» при каждом вызове ИИ: +# {domain} — domainDescription (что для вас заявка/лид, ваша сфера) +# {keywords} — domainKeywords (общие слова-маркеры заявок) +DEFAULT_AI_PROMPT = ( + "Ты — классификатор входящих сообщений. Сообщение — ЗАЯВКА (лид), только если в нём есть конкретный" + " запрос или предложение по делу: заказ услуги/товара/работы, поиск исполнителя или найм человека." + " Направление вашей сферы:\n{domain}\n\n" + "Частые слова-маркеры заявок в вашей сфере: {keywords}\n\n" + "Определи по тексту:\n" + "1. is_spam — true, если это НЕ заявка: служебные сообщения (коды входа/подтверждения, уведомления)," + " приветствия и поздравления, флуд и обсуждения без задачи, вопросы без конкретики, реклама, скам," + " фин. пирамиды, резюме соискателей, взаимный пиар, приглашения в чаты, рассылки." + " Если сомневаешься — ставь true (лучше пропустить сообщение, чем засорить карточками)\n" + "2. board — id подходящей доски из списка. Выбирай по КРИТЕРИЯМ колонки" + " (в скобках указаны её направление, тематика, ключевые слова, уровень, бюджет, описание), а не только по названию;" + " если ни одна колонка не подходит под текст — верни null (карточка пойдёт в «Неразобранное»)\n" + "3. is_vacancy — true, если это НАЙМ/постоянная или проектная занятость: ищут человека в команду" + " (признаки: вакансия, грейд/уровень, «в компанию», hr, зарплата за месяц);" + " false — разовая сделка: заказ/услуга/товар (фриланс, подряд, «нужно сделать/купить», цена за работу)\n" + "4. title — короткий заголовок (4–9 слов, без эмодзи, хэштегов и знаков препинания в конце)." + " Запрещено: markdown, ссылки в любом виде ([текст](url), голые url) — только чистый текст\n" + "5. company — кто разместил заявку: компания/бренд/частное лицо/заказчик. Только факт из текста;" + " не указано — верни пустую строку \"\"\n" + "6. format — формат работы/выполнения: удалённо/офис/гибрид, город, график. Коротко; нет — \"\"\n" + "7. task — 1–2 предложения: что за задача/роль и в чём суть (для кого, что нужно сделать)." + " Ёмко, без пересказа всего объявления и без служебных строк\n" + "8. requirements — JSON-массив строк ключевых требований к кандидату/исполнителю" + " (что нужно уметь/иметь, пункты «Требования/Обязанности/Нужно»). Нет — []\n" + "9. plus — JSON-массив строк «будет плюсом»/«приветствуется»/«желательно». Нет — []\n" + "10. conditions — условия одной строкой: оплата/ЗП/вилка, сроки, объём, тип занятости. Нет — \"\"\n" + "11. stack — JSON-массив строк (не строка!): технологии/предметы/услуги/материалы из заявки." + " Названия пиши слитно как в оригинале: \".NET\", \"C#\", \"Node.js\", \"ASP.NET Core\" — не разбивай на отдельные буквы (2–8)\n" + "12. budget — объект { from, to, currency }: одна сумма → from=to=X; «до X» → from=null, to=X;" + " диапазон «от X до Y» → from=X, to=Y. Если суммы нет — null\n" + "13. contacts — JSON-массив строк контактов ДЛЯ СВЯЗИ: телефон, email, @username (не бот)," + " ссылка на профиль человека (LinkedIn, t.me/…). НЕ включай ботов, каналы/группы и ссылки на" + " вакансию/пост/форму. Если контакта в тексте нет — пустой массив []\n" + "Поля 5–10 — это блок «О заявке» на карточке: заполняй их только фактами из текста, ничего не выдумывай." + " Запрещено: markdown-разметка, ссылки в любом виде, эмодзи, хэштеги, «от»/«привет», дословное копирование исходника.\n" + "Отвечай строго в формате JSON." +) + +DEFAULT_AI_FILTER_PROMPT = ( + "Ты — страж входящих сообщений каналов. Пропускай только реальные заявки/лиды по вашей сфере" + " (заказ, услуга, товар, найм — с конкретикой).\n" + "Ваша сфера и что считать заявкой:\n{domain}\n\n" + "Слова-маркеры заявок: {keywords}\n\n" + "НЕ пропускай:\n" + "- служебные сообщения: коды входа/подтверждения, уведомления, приветствия, поздравления\n" + "- просто сообщения без задачи и конкретики: флуд, обсуждения, вопросы «кто работал с …»\n" + "- рекламу и саморекламу\n" + "- скам, фин. пирамиды, «заработок»\n" + "- резюме, поиск работы соискателями\n" + "- взаимный пиар, приглашения в чаты\n" + "- рассылки и дайджесты без прямых заявок\n" + "При сомнении — не пропускай.\n" + 'Верни строго JSON: { "pass": true|false, "reason": "причина отказа или null" }' +) + +# Промпт структуры карточки (блок «О заявке»). Отдельный от классификатора: +# задаёт, какие поля и как заполнять, чтобы все карточки имели одинаковую +# структуру текста (разной длины). Добавляется к промпту классификатора +# отдельным блоком (см. services/ai.classify). Редактируется в UI. +DEFAULT_AI_CARD_PROMPT = ( + "Ты также возвращаешь содержимое блока «О заявке» карточки — поля company, format," + " task, requirements, plus, conditions. Карточка всегда собирается из одних и тех же блоков:" + " одинаковая структура у всех карточек, различается только длина.\n" + "1. company — кто ищет/разместил: компания, бренд, агентство, частное лицо, заказчик. Одно предложение, факт из текста.\n" + "2. format — формат работы: удалённо/офис/гибрид/разъездной, город/страна, график (5/2, full-time, part-time). Коротко.\n" + "3. task — 1–2 предложения по шаблону: что за задача/роль → для кого → что нужно сделать/какой результат." + " Пиши ёмко, не пересказывай объявление дословно.\n" + "4. requirements — вынеси сюда только реальные требования/обязанности из текста" + " (пункты списков «Требования», «Обязанности», «Что предстоит делать», «Нужно»):" + " каждый пункт короткой строкой в JSON-массиве. Не придумывай сверх текста.\n" + "5. plus — только то, что отмечено как «будет плюсом»/«приветствуется»/«желательно»." + " Нет таких пунктов — пустой массив [].\n" + "6. conditions — условия одной строкой: оплата/ЗП/вилка/гонорар, сроки, объём, тип занятости." + " Не дублируй budget-числа в других полях.\n" + "Для не-IT сфер «стек/требования» означают материалы, услуги, навыки, инструменты — по смыслу заявки." + " Запрещено во всех полях: markdown-разметка, ссылки в любом виде ([текст](url), голые url)," + " эмодзи, хэштеги, «от»/«привет»/служебные строки канала." +) + +# Дефолтные маркеры локального разбора (настраиваются в UI «Сфера и ключи»). +DEFAULT_HIRE_MARKERS = [ + "вакансия", "вакансию", "вакансии", "вакантна", "вакант", "нанимаем", "найм", "full-time", + "на постоянную", "занятость", "в офис", "официальное оформление", "пятидневка", + "грейд", "в компанию", "полная занятость", "на постоянную работу", "в команду", "нанимает", + "на постоянную основу", "в штат", +] + +DEFAULT_LEVEL_TERMS = [ + "junior", "джун", "джуниор", "middle", "мидл", "mid", "senior", "сеньор", "сеньйор", + "lead", "тимлид", "тиэмлид", "architect", "архитектор", "стажёр", "стажер", "intern", "trainee", +] + +# Маркеры, по которым сообщение опознаётся как резюме/самопрезентация +# соискателя («ищу работу», «моё резюме»). По умолчанию такие сообщения +# отсекаются на этапе 1 (до ИИ и правил колонок), если пользователь ищет +# вакансии и заказы, а не кандидатов. Редактируется в «Сфере и ключах»; +# выключите отсев — резюме начнут собираться как обычные лиды. +# Слово «резюме» имеет контекстный guard (см. pipeline._resume_reason): +# «…вакансия…, присылайте резюме» — это объявление работодателя, его не режем. +DEFAULT_RESUME_MARKERS = [ + "резюме", "#резюме", "моё резюме", "мое резюме", "ищу работу", "ищу вакансию", + "рассмотрю предложения", "готов к собеседованию", "в поиске работы", + "ищу проект", "ищу подработку", +] + +# AI-провайдеры (активен один) +AI_PROVIDERS = [ + {"id": "deepseek", "name": "DeepSeek", "base": "https://api.deepseek.com", "local": False, + "models": ["deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-flash-vision-exp"]}, + {"id": "openai", "name": "OpenAI", "base": "https://api.openai.com/v1", "local": False, + "models": ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]}, + {"id": "openrouter", "name": "OpenRouter", "base": "https://openrouter.ai/api/v1", "local": False, + "models": ["deepseek/deepseek-v4-flash", "anthropic/claude-sonnet-5", "openai/gpt-5.6-luna", + "google/gemini-3.8-flash", "qwen/qwen3.8-flash"]}, + {"id": "anthropic", "name": "Anthropic Claude", "base": "https://api.anthropic.com", "local": False, + "api_style": "anthropic", + "models": ["claude-sonnet-5", "claude-opus-5", "claude-fable-5-1", "claude-haiku-4-5-20251001"]}, + {"id": "ollama", "name": "Ollama (локально)", "base": "http://localhost:11434/v1", "local": True, + "models": ["qwen3-coder:30b", "qwen3-coder:480b", "qwen3:32b"]}, + {"id": "lmstudio", "name": "LM Studio (локально)", "base": "http://localhost:1234/v1", "local": True, + "models": ["qwen3-coder-30b", "qwen3-coder-480b", "llama-3.3-70b"]}, + {"id": "custom", "name": "Другой (OpenAI-совместимый)", "base": "https://", "local": False, "models": []}, +] + +# Настройки по умолчанию (ключи -> значение; не секреты) +DEFAULT_SETTINGS = { + # хранилище + "autoArchive": True, + "archiveAfterDays": 14, # 1..30 + "archiveClearDays": 90, + "trashClearDays": 7, + # фильтры входящих + "minLen": DEFAULT_MIN_LEN, + "stopPhrases": DEFAULT_STOP_PHRASES, + "mlEnabled": True, # локальный ML-слой (обучается на действиях пользователя) + "aiEnabled": True, # полный выключатель ИИ: false = только фильтр + ML + "aiFilterEnabled": True, + "aiFilterPrompt": DEFAULT_AI_FILTER_PROMPT, + "aiPrompt": DEFAULT_AI_PROMPT, + "cardPrompt": DEFAULT_AI_CARD_PROMPT, # структура карточки/блока «О заявке» (отдельный промпт) + # сфера и ключи (универсальный классификатор: редактируется в UI) + "domainDescription": "", + "domainKeywords": [], + "hireMarkers": DEFAULT_HIRE_MARKERS, + "levelTerms": DEFAULT_LEVEL_TERMS, + "resumeMarkers": DEFAULT_RESUME_MARKERS, + "blockResumes": True, # True = ищем вакансии/заказы, резюме соискателей отсекаем + # тип заявок, которые собираем: both | vacancy | freelance (этап 1, до ИИ) + "wantedType": "both", + # «без указания суммы карточку не создаём»: отдельно для найма и заказов + "budgetRequiredHire": False, + "budgetRequiredOrder": False, + # подписи типов на карточках (по ситуации/сфере пользователя) + "hireLabel": "вакансия", + "orderLabel": "фриланс", + # личная библиотека промптов пользователя: [{id, name, description, prompt}] + "myPrompts": [], + # напоминания + "remindersEnabled": True, + # валюта и курсы + "conversionOn": True, + "targetCurrency": "RUB", + "rateSource": "cbr", # cbr | mock + # UI-состояния колонок + "colState": {}, + # AI: активный провайдер и конфиги (ключи хранятся здесь же, наружу маскируются) + "aiProvider": "deepseek", + "aiConfigs": { + p["id"]: {"apiKey": "", "baseUrl": p["base"], "model": (p["models"] or [""])[0]} + for p in AI_PROVIDERS + }, + # telegram-ключи: {api_id, api_hash, account} + "tgKeys": {"apiId": "", "apiHash": ""}, + # авто-мониторинг новых чатов/каналов (добавлены с другого клиента) + "autoMonitorNew": True, + # поиск каналов (Discovery) + "discJoinLimit": 50, # суточный лимит авто-вступлений (общий) + "discJoinDelayMin": 50, # сек, нижняя граница паузы между авто-вступлениями + "discJoinDelayMax": 70, # сек, верхняя граница + "discEvalSample": 10, # размер выборки сообщений при оценке + "discEvalThreshold": 40, # % подходящих сообщений +} + +# Диалоговые цвета (детерминированно по имени) +DIALOG_HUES = ["#3b82f6", "#38bdf8", "#f472b6", "#f59e0b", "#a78bfa", "#34d399", "#fb7185", "#22c55e"] + +# Множители единиц времени +DAY_MS = 86_400_000 +HOUR_MS = 3_600_000 +MIN_MS = 60_000 diff --git a/archive/leadradar-legacy/backend/app/crypto.py b/archive/leadradar-legacy/backend/app/crypto.py index f52ee4c..165c66e 100644 --- a/archive/leadradar-legacy/backend/app/crypto.py +++ b/archive/leadradar-legacy/backend/app/crypto.py @@ -1,70 +1,70 @@ -"""Шифрование секретов, хранимых в БД (Telegram api_hash, ключи AI). - -Решение по итогам ревью: ключи шифруются симметричным ключом; ключ шифрования -пока живёт в env (LEADRADAR_ENCRYPTION_KEY). Для локальной разработки без env -ключ генерируется и кладётся в data/encryption.key (с предупреждением). -""" -from __future__ import annotations - -import base64 -import logging -import os - -from cryptography.fernet import Fernet, InvalidToken - -from . import config - -log = logging.getLogger("leadradar.crypto") - -_fernet: Fernet | None = None - - -def _get_fernet() -> Fernet: - global _fernet - if _fernet is not None: - return _fernet - key = config.ENCRYPTION_KEY - if key: - try: - _fernet = Fernet(key.encode() if not key.endswith("=") else key.encode()) - return _fernet - except Exception: # noqa: BLE001 - log.error("LEADRADAR_ENCRYPTION_KEY не похож на Fernet-ключ (32 байта urlsafe b64)") - raise - if config.ENCRYPTION_KEY_FILE.exists(): - key = config.ENCRYPTION_KEY_FILE.read_text(encoding="utf-8").strip() - else: - config.ensure_dirs() - key = Fernet.generate_key().decode() - config.ENCRYPTION_KEY_FILE.write_text(key, encoding="utf-8") - log.warning("Ключ шифрования создан в %s (для продакшена задайте LEADRADAR_ENCRYPTION_KEY)", config.ENCRYPTION_KEY_FILE) - _fernet = Fernet(key.encode()) - return _fernet - - -def encrypt_text(value: str) -> str: - if not value: - return "" - token = _get_fernet().encrypt(value.encode()) - return "enc:" + token.decode() - - -def decrypt_text(value: str) -> str: - if not value: - return "" - if not value.startswith("enc:"): - return value # совместимость с незашифрованными значениями ранних версий - try: - return _get_fernet().decrypt(value[4:].encode()).decode() - except InvalidToken: - log.error("Не удалось расшифровать секрет (неверный ключ шифрования)") - return "" - - -def maybe_encrypt(value: str) -> str: - """Шифруем только непустые значения.""" - return encrypt_text(value) if value else "" - - -def random_key() -> str: - return Fernet.generate_key().decode() +"""Шифрование секретов, хранимых в БД (Telegram api_hash, ключи AI). + +Решение по итогам ревью: ключи шифруются симметричным ключом; ключ шифрования +пока живёт в env (LEADRADAR_ENCRYPTION_KEY). Для локальной разработки без env +ключ генерируется и кладётся в data/encryption.key (с предупреждением). +""" +from __future__ import annotations + +import base64 +import logging +import os + +from cryptography.fernet import Fernet, InvalidToken + +from . import config + +log = logging.getLogger("leadradar.crypto") + +_fernet: Fernet | None = None + + +def _get_fernet() -> Fernet: + global _fernet + if _fernet is not None: + return _fernet + key = config.ENCRYPTION_KEY + if key: + try: + _fernet = Fernet(key.encode() if not key.endswith("=") else key.encode()) + return _fernet + except Exception: # noqa: BLE001 + log.error("LEADRADAR_ENCRYPTION_KEY не похож на Fernet-ключ (32 байта urlsafe b64)") + raise + if config.ENCRYPTION_KEY_FILE.exists(): + key = config.ENCRYPTION_KEY_FILE.read_text(encoding="utf-8").strip() + else: + config.ensure_dirs() + key = Fernet.generate_key().decode() + config.ENCRYPTION_KEY_FILE.write_text(key, encoding="utf-8") + log.warning("Ключ шифрования создан в %s (для продакшена задайте LEADRADAR_ENCRYPTION_KEY)", config.ENCRYPTION_KEY_FILE) + _fernet = Fernet(key.encode()) + return _fernet + + +def encrypt_text(value: str) -> str: + if not value: + return "" + token = _get_fernet().encrypt(value.encode()) + return "enc:" + token.decode() + + +def decrypt_text(value: str) -> str: + if not value: + return "" + if not value.startswith("enc:"): + return value # совместимость с незашифрованными значениями ранних версий + try: + return _get_fernet().decrypt(value[4:].encode()).decode() + except InvalidToken: + log.error("Не удалось расшифровать секрет (неверный ключ шифрования)") + return "" + + +def maybe_encrypt(value: str) -> str: + """Шифруем только непустые значения.""" + return encrypt_text(value) if value else "" + + +def random_key() -> str: + return Fernet.generate_key().decode() diff --git a/archive/leadradar-legacy/backend/app/db.py b/archive/leadradar-legacy/backend/app/db.py index c4e9bc4..c7217c7 100644 --- a/archive/leadradar-legacy/backend/app/db.py +++ b/archive/leadradar-legacy/backend/app/db.py @@ -1,407 +1,407 @@ -"""DuckDB: единый доступ, схема, стартовые данные. - -DuckDB — одна запись в момент времени. Внутри одного процесса мы -сериализуем все операции общим локом (чтение дёшево, объёмы личные), -что полностью соответствует ТЗ (self-hosted, один файл). -""" -from __future__ import annotations - -import json -import threading -import time -import uuid - -import duckdb - -from . import constants as C -from . import config - -_LOCK = threading.RLock() - -_SCHEMA = """ -CREATE TABLE IF NOT EXISTS boards ( - id VARCHAR PRIMARY KEY, - name VARCHAR NOT NULL, - description VARCHAR NOT NULL DEFAULT '', - color VARCHAR NOT NULL DEFAULT '#818cf8', - width VARCHAR NOT NULL DEFAULT 'md', - pos INTEGER NOT NULL DEFAULT 0, - keywords VARCHAR NOT NULL DEFAULT '[]', - prompt VARCHAR NOT NULL DEFAULT '', - visible_fields VARCHAR NOT NULL DEFAULT '["budget","stack","contacts"]', - collapsed BOOLEAN NOT NULL DEFAULT FALSE, - suggested BOOLEAN NOT NULL DEFAULT FALSE, - rules VARCHAR NOT NULL DEFAULT '{}', - note VARCHAR NOT NULL DEFAULT '', - created_at BIGINT NOT NULL -); - -CREATE TABLE IF NOT EXISTS leads ( - id VARCHAR PRIMARY KEY, - col VARCHAR NOT NULL, - is_new BOOLEAN NOT NULL DEFAULT TRUE, - is_vacancy BOOLEAN NOT NULL DEFAULT FALSE, - title VARCHAR NOT NULL, - summary VARCHAR NOT NULL DEFAULT '', - stack VARCHAR NOT NULL DEFAULT '[]', - budget_from DOUBLE, - budget_to DOUBLE, - budget_cur VARCHAR NOT NULL DEFAULT '', - conv_from DOUBLE, - conv_to DOUBLE, - conv_cur VARCHAR NOT NULL DEFAULT '', - contact VARCHAR NOT NULL DEFAULT '', - contacts VARCHAR NOT NULL DEFAULT '[]', - ch_name VARCHAR NOT NULL DEFAULT '', - ch_handle VARCHAR NOT NULL DEFAULT '', - ch_hue VARCHAR NOT NULL DEFAULT '#666', - time_label VARCHAR NOT NULL DEFAULT '', - received_at BIGINT NOT NULL, - source_msg VARCHAR NOT NULL DEFAULT '', - prev_col VARCHAR NOT NULL DEFAULT 'inbox', - comments VARCHAR NOT NULL DEFAULT '[]', - created_at BIGINT NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_leads_col ON leads(col); - -CREATE TABLE IF NOT EXISTS messages ( - id VARCHAR PRIMARY KEY, - dialog_id VARCHAR NOT NULL, - text VARCHAR NOT NULL DEFAULT '', - msg_at BIGINT NOT NULL, - lead_id VARCHAR -); -CREATE INDEX IF NOT EXISTS idx_messages_dialog ON messages(dialog_id, msg_at); - -CREATE TABLE IF NOT EXISTS dialogs ( - id VARCHAR PRIMARY KEY, - name VARCHAR NOT NULL, - handle VARCHAR NOT NULL DEFAULT '', - kind VARCHAR NOT NULL DEFAULT 'чат', - hue VARCHAR NOT NULL DEFAULT '#666', - monitor BOOLEAN NOT NULL DEFAULT FALSE, - last_text VARCHAR NOT NULL DEFAULT '', - last_at BIGINT, - updated_at BIGINT NOT NULL -); - -CREATE TABLE IF NOT EXISTS dedup ( - hash VARCHAR PRIMARY KEY, - lead_id VARCHAR, - created_at BIGINT NOT NULL -); - -CREATE TABLE IF NOT EXISTS learning_log ( - id VARCHAR PRIMARY KEY, - lead_id VARCHAR NOT NULL, - action VARCHAR NOT NULL, - from_col VARCHAR, - to_col VARCHAR, - created_at BIGINT NOT NULL -); - -CREATE TABLE IF NOT EXISTS projects ( - id VARCHAR PRIMARY KEY, - stage VARCHAR NOT NULL DEFAULT 'planned', - local BOOLEAN NOT NULL DEFAULT FALSE, - lead_id VARCHAR, - title VARCHAR NOT NULL DEFAULT '', - summary VARCHAR NOT NULL DEFAULT '', - stack VARCHAR NOT NULL DEFAULT '[]', - budget_from DOUBLE, - budget_to DOUBLE, - budget_cur VARCHAR NOT NULL DEFAULT '', - contact VARCHAR NOT NULL DEFAULT '', - comments VARCHAR NOT NULL DEFAULT '[]', - links VARCHAR NOT NULL DEFAULT '[]', - files VARCHAR NOT NULL DEFAULT '[]', - tz_text VARCHAR NOT NULL DEFAULT '', - history VARCHAR NOT NULL DEFAULT '[]', - reminder_at BIGINT, - reminder_fired BOOLEAN NOT NULL DEFAULT FALSE, - created_at BIGINT NOT NULL, - updated_at BIGINT NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_projects_stage ON projects(stage); - -CREATE TABLE IF NOT EXISTS rates ( - id INTEGER PRIMARY KEY, - rates VARCHAR NOT NULL DEFAULT '{}', - source VARCHAR NOT NULL DEFAULT 'cbr', - updated_at BIGINT NOT NULL -); - --- Discovery: задачи поиска Telegram-каналов. keywords — JSON-список, столбцы --- search_* / found / evaluated / joined / rejected — живой прогресс по задаче. -CREATE TABLE IF NOT EXISTS disc_tasks ( - id VARCHAR PRIMARY KEY, - name VARCHAR NOT NULL, - description VARCHAR NOT NULL DEFAULT '', - keywords VARCHAR NOT NULL DEFAULT '[]', - min_subscribers INTEGER NOT NULL DEFAULT 0, - lang VARCHAR NOT NULL DEFAULT 'ru', - threshold INTEGER NOT NULL DEFAULT 40, - sample_size INTEGER NOT NULL DEFAULT 10, - plan_joins INTEGER NOT NULL DEFAULT 1, - auto_join BOOLEAN NOT NULL DEFAULT FALSE, - status VARCHAR NOT NULL DEFAULT 'draft', -- draft|running|paused|done|failed - search_idx INTEGER NOT NULL DEFAULT 0, - search_done BOOLEAN NOT NULL DEFAULT FALSE, - found INTEGER NOT NULL DEFAULT 0, - evaluated INTEGER NOT NULL DEFAULT 0, - joined INTEGER NOT NULL DEFAULT 0, - rejected INTEGER NOT NULL DEFAULT 0, - created_at BIGINT NOT NULL, - updated_at BIGINT NOT NULL -); - --- Discovery: найденные кандидаты. marks/topics — JSON-списки (оценки ИИ). -CREATE TABLE IF NOT EXISTS disc_candidates ( - dialog_id VARCHAR PRIMARY KEY, - task_id VARCHAR NOT NULL, - name VARCHAR NOT NULL DEFAULT '', - username VARCHAR NOT NULL DEFAULT '', - kind VARCHAR NOT NULL DEFAULT 'channel', -- channel|group|forum - hue VARCHAR NOT NULL DEFAULT '#666', - participants INTEGER, - lang_ru BOOLEAN, - marks VARCHAR NOT NULL DEFAULT '[]', - topics VARCHAR NOT NULL DEFAULT '[]', - fit_ratio DOUBLE, - status VARCHAR NOT NULL DEFAULT 'new', -- new|review|joined|rejected - auto_joined BOOLEAN NOT NULL DEFAULT FALSE, - join_failures INTEGER NOT NULL DEFAULT 0, -- неудачные авто-вступления подряд (3 → кандидат удаляется) - created_at BIGINT NOT NULL, - updated_at BIGINT NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_disc_cand_task ON disc_candidates(task_id, status); - --- Discovery: чёрный список (пропускать при поиске). -CREATE TABLE IF NOT EXISTS disc_blacklist ( - dialog_id VARCHAR PRIMARY KEY, - name VARCHAR NOT NULL DEFAULT '', - reason VARCHAR NOT NULL DEFAULT '', - created_at BIGINT NOT NULL -); - --- Discovery: лог событий по задаче (search|found|skip|eval|review|join_auto| --- join_manual|leave|reject|flood|error|done). -CREATE TABLE IF NOT EXISTS disc_log ( - id VARCHAR PRIMARY KEY, - task_id VARCHAR NOT NULL, - event VARCHAR NOT NULL, - text VARCHAR NOT NULL DEFAULT '', - created_at BIGINT NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_disc_log_task ON disc_log(task_id, created_at); - -CREATE TABLE IF NOT EXISTS settings ( - key VARCHAR PRIMARY KEY, - value VARCHAR NOT NULL -); - -CREATE TABLE IF NOT EXISTS sessions ( - token VARCHAR PRIMARY KEY, - login VARCHAR NOT NULL, - expires BIGINT NOT NULL -); - -CREATE TABLE IF NOT EXISTS creds ( - login VARCHAR PRIMARY KEY, - password_hash VARCHAR NOT NULL, - salt VARCHAR NOT NULL -); - --- Outbox обучения ML: действия пользователя всегда пишутся сюда (синхронно, --- локально), а фоновый воркер отправляет их в автономный ML-сервис. --- Так ML «обучается всегда» даже если сервис временно недоступен. -CREATE TABLE IF NOT EXISTS ml_outbox ( - id VARCHAR PRIMARY KEY, - text VARCHAR NOT NULL, - label VARCHAR NOT NULL, - delta DOUBLE NOT NULL DEFAULT 1.0, - created_at BIGINT NOT NULL -); - --- Очередь входящих: все сообщения из групп попадают сюда и разбираются --- фоновым воркером. Не прошедшие фильтры удаляются сразу (не копятся). --- force=TRUE — сообщение возвращено из отсева пользователем: фильтры-отсев --- для него игнорируются (сообщение уходит на ML/ИИ и создаёт карточку). -CREATE TABLE IF NOT EXISTS pipeline_msg ( - id VARCHAR PRIMARY KEY, - dialog_id VARCHAR NOT NULL, - ch_name VARCHAR NOT NULL DEFAULT '', - ch_handle VARCHAR NOT NULL DEFAULT '', - ch_hue VARCHAR NOT NULL DEFAULT '#666', - text VARCHAR NOT NULL, - msg_id BIGINT, - msg_at BIGINT NOT NULL, - status VARCHAR NOT NULL DEFAULT 'new', - force BOOLEAN DEFAULT FALSE, - created_at BIGINT NOT NULL, - updated_at BIGINT NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_pipeline_status ON pipeline_msg(status, created_at); - --- Отсев пайплайна (мониторинг «Обработка»): сообщения, не прошедшие этапы --- (стоп-лист/резюме/тип/без суммы/устарело/ML/ИИ). Хранится причина и «чьё» --- решение. Автоочистка раз в 3 суток + ручная очистка из UI. --- При возврате в обработку запись не удаляется: помечается returned с причиной. -CREATE TABLE IF NOT EXISTS rejected_msgs ( - id VARCHAR PRIMARY KEY, - dialog_id VARCHAR NOT NULL DEFAULT '', - msg_id BIGINT, - ch_name VARCHAR NOT NULL DEFAULT '', - ch_handle VARCHAR NOT NULL DEFAULT '', - ch_hue VARCHAR NOT NULL DEFAULT '#666', - text VARCHAR NOT NULL, - stage VARCHAR NOT NULL DEFAULT '', - reason VARCHAR NOT NULL DEFAULT '', - kw VARCHAR NOT NULL DEFAULT '', - source VARCHAR NOT NULL DEFAULT 'stop', - msg_at BIGINT, - rejected_at BIGINT NOT NULL, - returned BOOLEAN DEFAULT FALSE, - returned_at BIGINT, - return_reason VARCHAR NOT NULL DEFAULT '' -); -CREATE INDEX IF NOT EXISTS idx_rejected_rejected_at ON rejected_msgs(rejected_at); -""" - -# DuckDB не поддерживает IF NOT EXISTS в ADD COLUMN на старых версиях; -# выполняем в try для совместимости (поле нужно для правила «архив очищается -# через 90 дней после помещения в архив»). -_MIGRATIONS = [ - "ALTER TABLE leads ADD COLUMN IF NOT EXISTS archived_at BIGINT", - "ALTER TABLE dialogs ADD COLUMN IF NOT EXISTS backfilled BOOLEAN", - # исходное сообщение: id в Telegram + id диалога (для «открыть исходник») - "ALTER TABLE leads ADD COLUMN IF NOT EXISTS source_dialog_id VARCHAR DEFAULT ''", - "ALTER TABLE leads ADD COLUMN IF NOT EXISTS source_msg_id BIGINT", - # квалифицированные контакты: JSON-список [{type, value}] (tg/phone/email/linkedin/site) - "ALTER TABLE leads ADD COLUMN IF NOT EXISTS contacts VARCHAR DEFAULT '[]'", - # модель ML переезжает в отдельный контейнер — старые встроенные таблицы не нужны - "DROP TABLE IF EXISTS ml_classes", - "DROP TABLE IF EXISTS ml_terms", - # колонки: ИИ-предложения и правила маршрутизации (DuckDB не умеет - # ADD COLUMN с NOT NULL — значения трактуются как FALSE/{}/'') - "ALTER TABLE boards ADD COLUMN IF NOT EXISTS suggested BOOLEAN", - "ALTER TABLE boards ADD COLUMN IF NOT EXISTS rules VARCHAR", - "ALTER TABLE boards ADD COLUMN IF NOT EXISTS note VARCHAR", - # описание колонки (для пользователя и подсказки ИИ/ML) - "ALTER TABLE boards ADD COLUMN IF NOT EXISTS description VARCHAR DEFAULT ''", - # какие критерии фильтра совпали при попадании карточки в колонку (JSON) - "ALTER TABLE leads ADD COLUMN IF NOT EXISTS match_hits VARCHAR DEFAULT '[]'", - # тип «найм/разовое» подтверждён ИИ по контексту (маркерная эвристика — нет) - "ALTER TABLE leads ADD COLUMN IF NOT EXISTS is_vacancy_known BOOLEAN DEFAULT FALSE", - # возврат из отсева в обработку: force на строке очереди (фильтры игнорируются) - "ALTER TABLE pipeline_msg ADD COLUMN IF NOT EXISTS force BOOLEAN DEFAULT FALSE", - # отсев: исходный диалог/сообщение (для повторного возврата в очередь) и метки возврата - "ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS dialog_id VARCHAR DEFAULT ''", - "ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS msg_id BIGINT", - "ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS ch_hue VARCHAR DEFAULT '#666'", - "ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS returned BOOLEAN DEFAULT FALSE", - "ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS returned_at BIGINT", - "ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS return_reason VARCHAR DEFAULT ''", - # discovery: счётчик неудачных авто-вступлений кандидата (лимит ретраев join) - "ALTER TABLE disc_candidates ADD COLUMN IF NOT EXISTS join_failures INTEGER DEFAULT 0", -] - - -class Store: - """Синглтон-доступ к DuckDB.""" - - def __init__(self) -> None: - self._con: duckdb.DuckDBPyConnection | None = None - - def init(self) -> None: - config.ensure_dirs() - with _LOCK: - self._con = duckdb.connect(str(config.DB_PATH)) - for stmt in _SCHEMA.split(";"): - if stmt.strip(): - self._con.execute(stmt) - for stmt in _MIGRATIONS: - try: - self._con.execute(stmt) - except Exception: - pass - self._seed_defaults() - - def close(self) -> None: - with _LOCK: - if self._con is not None: - try: - self._con.close() - except Exception: - pass - self._con = None - - # ── низкоуровневые примитивы ────────────────────────────────────────── - - def execute(self, sql: str, params: dict | list | tuple | None = None) -> None: - with _LOCK: - self._con.execute(sql, params or []) - - def query(self, sql: str, params: dict | list | tuple | None = None) -> list[dict]: - with _LOCK: - cur = self._con.execute(sql, params or []) - cols = [d[0] for d in cur.description] - rows = cur.fetchall() - return [dict(zip(cols, row)) for row in rows] - - def query_one(self, sql: str, params: dict | list | tuple | None = None) -> dict | None: - rows = self.query(sql, params) - return rows[0] if rows else None - - def scalar(self, sql: str, params: dict | list | tuple | None = None): - with _LOCK: - cur = self._con.execute(sql, params or []) - row = cur.fetchone() - return row[0] if row else None - - # ── настройки ───────────────────────────────────────────────────────── - - def get_setting(self, key: str): - row = self.query_one("SELECT value FROM settings WHERE key = ?", [key]) - if row is None: - default = C.DEFAULT_SETTINGS.get(key, None) - return default - return json.loads(row["value"]) - - def all_settings(self) -> dict: - out = dict(C.DEFAULT_SETTINGS) - for row in self.query("SELECT key, value FROM settings"): - try: - out[row["key"]] = json.loads(row["value"]) - except Exception: - pass - return out - - def set_setting(self, key: str, value) -> None: - self.execute( - "INSERT INTO settings(key, value) VALUES (?, ?) " - "ON CONFLICT(key) DO UPDATE SET value = excluded.value", - [key, json.dumps(value, ensure_ascii=False)], - ) - - # ── стартовые данные ────────────────────────────────────────────────── - - def _seed_defaults(self) -> None: - now = time.time_ns() // 1_000_000 - # Колонки не создаются по умолчанию: их делает пользователь или - # предлагает ИИ (см. services/suggest.py). - if self.scalar("SELECT count(*) FROM rates") == 0: - self.execute( - "INSERT INTO rates(id, rates, source, updated_at) VALUES (1, ?, 'mock', ?)", - [json.dumps(C.MOCK_RATES), now], - ) - # начальные настройки сохраняем только при отсутствии (значения берутся из DEFAULT_SETTINGS сами) - - # ── генераторы ──────────────────────────────────────────────────────── - - @staticmethod - def uid(prefix: str = "") -> str: - return f"{prefix}{uuid.uuid4().hex[:12]}" - - -store = Store() +"""DuckDB: единый доступ, схема, стартовые данные. + +DuckDB — одна запись в момент времени. Внутри одного процесса мы +сериализуем все операции общим локом (чтение дёшево, объёмы личные), +что полностью соответствует ТЗ (self-hosted, один файл). +""" +from __future__ import annotations + +import json +import threading +import time +import uuid + +import duckdb + +from . import constants as C +from . import config + +_LOCK = threading.RLock() + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS boards ( + id VARCHAR PRIMARY KEY, + name VARCHAR NOT NULL, + description VARCHAR NOT NULL DEFAULT '', + color VARCHAR NOT NULL DEFAULT '#818cf8', + width VARCHAR NOT NULL DEFAULT 'md', + pos INTEGER NOT NULL DEFAULT 0, + keywords VARCHAR NOT NULL DEFAULT '[]', + prompt VARCHAR NOT NULL DEFAULT '', + visible_fields VARCHAR NOT NULL DEFAULT '["budget","stack","contacts"]', + collapsed BOOLEAN NOT NULL DEFAULT FALSE, + suggested BOOLEAN NOT NULL DEFAULT FALSE, + rules VARCHAR NOT NULL DEFAULT '{}', + note VARCHAR NOT NULL DEFAULT '', + created_at BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS leads ( + id VARCHAR PRIMARY KEY, + col VARCHAR NOT NULL, + is_new BOOLEAN NOT NULL DEFAULT TRUE, + is_vacancy BOOLEAN NOT NULL DEFAULT FALSE, + title VARCHAR NOT NULL, + summary VARCHAR NOT NULL DEFAULT '', + stack VARCHAR NOT NULL DEFAULT '[]', + budget_from DOUBLE, + budget_to DOUBLE, + budget_cur VARCHAR NOT NULL DEFAULT '', + conv_from DOUBLE, + conv_to DOUBLE, + conv_cur VARCHAR NOT NULL DEFAULT '', + contact VARCHAR NOT NULL DEFAULT '', + contacts VARCHAR NOT NULL DEFAULT '[]', + ch_name VARCHAR NOT NULL DEFAULT '', + ch_handle VARCHAR NOT NULL DEFAULT '', + ch_hue VARCHAR NOT NULL DEFAULT '#666', + time_label VARCHAR NOT NULL DEFAULT '', + received_at BIGINT NOT NULL, + source_msg VARCHAR NOT NULL DEFAULT '', + prev_col VARCHAR NOT NULL DEFAULT 'inbox', + comments VARCHAR NOT NULL DEFAULT '[]', + created_at BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_leads_col ON leads(col); + +CREATE TABLE IF NOT EXISTS messages ( + id VARCHAR PRIMARY KEY, + dialog_id VARCHAR NOT NULL, + text VARCHAR NOT NULL DEFAULT '', + msg_at BIGINT NOT NULL, + lead_id VARCHAR +); +CREATE INDEX IF NOT EXISTS idx_messages_dialog ON messages(dialog_id, msg_at); + +CREATE TABLE IF NOT EXISTS dialogs ( + id VARCHAR PRIMARY KEY, + name VARCHAR NOT NULL, + handle VARCHAR NOT NULL DEFAULT '', + kind VARCHAR NOT NULL DEFAULT 'чат', + hue VARCHAR NOT NULL DEFAULT '#666', + monitor BOOLEAN NOT NULL DEFAULT FALSE, + last_text VARCHAR NOT NULL DEFAULT '', + last_at BIGINT, + updated_at BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS dedup ( + hash VARCHAR PRIMARY KEY, + lead_id VARCHAR, + created_at BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS learning_log ( + id VARCHAR PRIMARY KEY, + lead_id VARCHAR NOT NULL, + action VARCHAR NOT NULL, + from_col VARCHAR, + to_col VARCHAR, + created_at BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS projects ( + id VARCHAR PRIMARY KEY, + stage VARCHAR NOT NULL DEFAULT 'planned', + local BOOLEAN NOT NULL DEFAULT FALSE, + lead_id VARCHAR, + title VARCHAR NOT NULL DEFAULT '', + summary VARCHAR NOT NULL DEFAULT '', + stack VARCHAR NOT NULL DEFAULT '[]', + budget_from DOUBLE, + budget_to DOUBLE, + budget_cur VARCHAR NOT NULL DEFAULT '', + contact VARCHAR NOT NULL DEFAULT '', + comments VARCHAR NOT NULL DEFAULT '[]', + links VARCHAR NOT NULL DEFAULT '[]', + files VARCHAR NOT NULL DEFAULT '[]', + tz_text VARCHAR NOT NULL DEFAULT '', + history VARCHAR NOT NULL DEFAULT '[]', + reminder_at BIGINT, + reminder_fired BOOLEAN NOT NULL DEFAULT FALSE, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_projects_stage ON projects(stage); + +CREATE TABLE IF NOT EXISTS rates ( + id INTEGER PRIMARY KEY, + rates VARCHAR NOT NULL DEFAULT '{}', + source VARCHAR NOT NULL DEFAULT 'cbr', + updated_at BIGINT NOT NULL +); + +-- Discovery: задачи поиска Telegram-каналов. keywords — JSON-список, столбцы +-- search_* / found / evaluated / joined / rejected — живой прогресс по задаче. +CREATE TABLE IF NOT EXISTS disc_tasks ( + id VARCHAR PRIMARY KEY, + name VARCHAR NOT NULL, + description VARCHAR NOT NULL DEFAULT '', + keywords VARCHAR NOT NULL DEFAULT '[]', + min_subscribers INTEGER NOT NULL DEFAULT 0, + lang VARCHAR NOT NULL DEFAULT 'ru', + threshold INTEGER NOT NULL DEFAULT 40, + sample_size INTEGER NOT NULL DEFAULT 10, + plan_joins INTEGER NOT NULL DEFAULT 1, + auto_join BOOLEAN NOT NULL DEFAULT FALSE, + status VARCHAR NOT NULL DEFAULT 'draft', -- draft|running|paused|done|failed + search_idx INTEGER NOT NULL DEFAULT 0, + search_done BOOLEAN NOT NULL DEFAULT FALSE, + found INTEGER NOT NULL DEFAULT 0, + evaluated INTEGER NOT NULL DEFAULT 0, + joined INTEGER NOT NULL DEFAULT 0, + rejected INTEGER NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); + +-- Discovery: найденные кандидаты. marks/topics — JSON-списки (оценки ИИ). +CREATE TABLE IF NOT EXISTS disc_candidates ( + dialog_id VARCHAR PRIMARY KEY, + task_id VARCHAR NOT NULL, + name VARCHAR NOT NULL DEFAULT '', + username VARCHAR NOT NULL DEFAULT '', + kind VARCHAR NOT NULL DEFAULT 'channel', -- channel|group|forum + hue VARCHAR NOT NULL DEFAULT '#666', + participants INTEGER, + lang_ru BOOLEAN, + marks VARCHAR NOT NULL DEFAULT '[]', + topics VARCHAR NOT NULL DEFAULT '[]', + fit_ratio DOUBLE, + status VARCHAR NOT NULL DEFAULT 'new', -- new|review|joined|rejected + auto_joined BOOLEAN NOT NULL DEFAULT FALSE, + join_failures INTEGER NOT NULL DEFAULT 0, -- неудачные авто-вступления подряд (3 → кандидат удаляется) + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_disc_cand_task ON disc_candidates(task_id, status); + +-- Discovery: чёрный список (пропускать при поиске). +CREATE TABLE IF NOT EXISTS disc_blacklist ( + dialog_id VARCHAR PRIMARY KEY, + name VARCHAR NOT NULL DEFAULT '', + reason VARCHAR NOT NULL DEFAULT '', + created_at BIGINT NOT NULL +); + +-- Discovery: лог событий по задаче (search|found|skip|eval|review|join_auto| +-- join_manual|leave|reject|flood|error|done). +CREATE TABLE IF NOT EXISTS disc_log ( + id VARCHAR PRIMARY KEY, + task_id VARCHAR NOT NULL, + event VARCHAR NOT NULL, + text VARCHAR NOT NULL DEFAULT '', + created_at BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_disc_log_task ON disc_log(task_id, created_at); + +CREATE TABLE IF NOT EXISTS settings ( + key VARCHAR PRIMARY KEY, + value VARCHAR NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + token VARCHAR PRIMARY KEY, + login VARCHAR NOT NULL, + expires BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS creds ( + login VARCHAR PRIMARY KEY, + password_hash VARCHAR NOT NULL, + salt VARCHAR NOT NULL +); + +-- Outbox обучения ML: действия пользователя всегда пишутся сюда (синхронно, +-- локально), а фоновый воркер отправляет их в автономный ML-сервис. +-- Так ML «обучается всегда» даже если сервис временно недоступен. +CREATE TABLE IF NOT EXISTS ml_outbox ( + id VARCHAR PRIMARY KEY, + text VARCHAR NOT NULL, + label VARCHAR NOT NULL, + delta DOUBLE NOT NULL DEFAULT 1.0, + created_at BIGINT NOT NULL +); + +-- Очередь входящих: все сообщения из групп попадают сюда и разбираются +-- фоновым воркером. Не прошедшие фильтры удаляются сразу (не копятся). +-- force=TRUE — сообщение возвращено из отсева пользователем: фильтры-отсев +-- для него игнорируются (сообщение уходит на ML/ИИ и создаёт карточку). +CREATE TABLE IF NOT EXISTS pipeline_msg ( + id VARCHAR PRIMARY KEY, + dialog_id VARCHAR NOT NULL, + ch_name VARCHAR NOT NULL DEFAULT '', + ch_handle VARCHAR NOT NULL DEFAULT '', + ch_hue VARCHAR NOT NULL DEFAULT '#666', + text VARCHAR NOT NULL, + msg_id BIGINT, + msg_at BIGINT NOT NULL, + status VARCHAR NOT NULL DEFAULT 'new', + force BOOLEAN DEFAULT FALSE, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_pipeline_status ON pipeline_msg(status, created_at); + +-- Отсев пайплайна (мониторинг «Обработка»): сообщения, не прошедшие этапы +-- (стоп-лист/резюме/тип/без суммы/устарело/ML/ИИ). Хранится причина и «чьё» +-- решение. Автоочистка раз в 3 суток + ручная очистка из UI. +-- При возврате в обработку запись не удаляется: помечается returned с причиной. +CREATE TABLE IF NOT EXISTS rejected_msgs ( + id VARCHAR PRIMARY KEY, + dialog_id VARCHAR NOT NULL DEFAULT '', + msg_id BIGINT, + ch_name VARCHAR NOT NULL DEFAULT '', + ch_handle VARCHAR NOT NULL DEFAULT '', + ch_hue VARCHAR NOT NULL DEFAULT '#666', + text VARCHAR NOT NULL, + stage VARCHAR NOT NULL DEFAULT '', + reason VARCHAR NOT NULL DEFAULT '', + kw VARCHAR NOT NULL DEFAULT '', + source VARCHAR NOT NULL DEFAULT 'stop', + msg_at BIGINT, + rejected_at BIGINT NOT NULL, + returned BOOLEAN DEFAULT FALSE, + returned_at BIGINT, + return_reason VARCHAR NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_rejected_rejected_at ON rejected_msgs(rejected_at); +""" + +# DuckDB не поддерживает IF NOT EXISTS в ADD COLUMN на старых версиях; +# выполняем в try для совместимости (поле нужно для правила «архив очищается +# через 90 дней после помещения в архив»). +_MIGRATIONS = [ + "ALTER TABLE leads ADD COLUMN IF NOT EXISTS archived_at BIGINT", + "ALTER TABLE dialogs ADD COLUMN IF NOT EXISTS backfilled BOOLEAN", + # исходное сообщение: id в Telegram + id диалога (для «открыть исходник») + "ALTER TABLE leads ADD COLUMN IF NOT EXISTS source_dialog_id VARCHAR DEFAULT ''", + "ALTER TABLE leads ADD COLUMN IF NOT EXISTS source_msg_id BIGINT", + # квалифицированные контакты: JSON-список [{type, value}] (tg/phone/email/linkedin/site) + "ALTER TABLE leads ADD COLUMN IF NOT EXISTS contacts VARCHAR DEFAULT '[]'", + # модель ML переезжает в отдельный контейнер — старые встроенные таблицы не нужны + "DROP TABLE IF EXISTS ml_classes", + "DROP TABLE IF EXISTS ml_terms", + # колонки: ИИ-предложения и правила маршрутизации (DuckDB не умеет + # ADD COLUMN с NOT NULL — значения трактуются как FALSE/{}/'') + "ALTER TABLE boards ADD COLUMN IF NOT EXISTS suggested BOOLEAN", + "ALTER TABLE boards ADD COLUMN IF NOT EXISTS rules VARCHAR", + "ALTER TABLE boards ADD COLUMN IF NOT EXISTS note VARCHAR", + # описание колонки (для пользователя и подсказки ИИ/ML) + "ALTER TABLE boards ADD COLUMN IF NOT EXISTS description VARCHAR DEFAULT ''", + # какие критерии фильтра совпали при попадании карточки в колонку (JSON) + "ALTER TABLE leads ADD COLUMN IF NOT EXISTS match_hits VARCHAR DEFAULT '[]'", + # тип «найм/разовое» подтверждён ИИ по контексту (маркерная эвристика — нет) + "ALTER TABLE leads ADD COLUMN IF NOT EXISTS is_vacancy_known BOOLEAN DEFAULT FALSE", + # возврат из отсева в обработку: force на строке очереди (фильтры игнорируются) + "ALTER TABLE pipeline_msg ADD COLUMN IF NOT EXISTS force BOOLEAN DEFAULT FALSE", + # отсев: исходный диалог/сообщение (для повторного возврата в очередь) и метки возврата + "ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS dialog_id VARCHAR DEFAULT ''", + "ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS msg_id BIGINT", + "ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS ch_hue VARCHAR DEFAULT '#666'", + "ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS returned BOOLEAN DEFAULT FALSE", + "ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS returned_at BIGINT", + "ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS return_reason VARCHAR DEFAULT ''", + # discovery: счётчик неудачных авто-вступлений кандидата (лимит ретраев join) + "ALTER TABLE disc_candidates ADD COLUMN IF NOT EXISTS join_failures INTEGER DEFAULT 0", +] + + +class Store: + """Синглтон-доступ к DuckDB.""" + + def __init__(self) -> None: + self._con: duckdb.DuckDBPyConnection | None = None + + def init(self) -> None: + config.ensure_dirs() + with _LOCK: + self._con = duckdb.connect(str(config.DB_PATH)) + for stmt in _SCHEMA.split(";"): + if stmt.strip(): + self._con.execute(stmt) + for stmt in _MIGRATIONS: + try: + self._con.execute(stmt) + except Exception: + pass + self._seed_defaults() + + def close(self) -> None: + with _LOCK: + if self._con is not None: + try: + self._con.close() + except Exception: + pass + self._con = None + + # ── низкоуровневые примитивы ────────────────────────────────────────── + + def execute(self, sql: str, params: dict | list | tuple | None = None) -> None: + with _LOCK: + self._con.execute(sql, params or []) + + def query(self, sql: str, params: dict | list | tuple | None = None) -> list[dict]: + with _LOCK: + cur = self._con.execute(sql, params or []) + cols = [d[0] for d in cur.description] + rows = cur.fetchall() + return [dict(zip(cols, row)) for row in rows] + + def query_one(self, sql: str, params: dict | list | tuple | None = None) -> dict | None: + rows = self.query(sql, params) + return rows[0] if rows else None + + def scalar(self, sql: str, params: dict | list | tuple | None = None): + with _LOCK: + cur = self._con.execute(sql, params or []) + row = cur.fetchone() + return row[0] if row else None + + # ── настройки ───────────────────────────────────────────────────────── + + def get_setting(self, key: str): + row = self.query_one("SELECT value FROM settings WHERE key = ?", [key]) + if row is None: + default = C.DEFAULT_SETTINGS.get(key, None) + return default + return json.loads(row["value"]) + + def all_settings(self) -> dict: + out = dict(C.DEFAULT_SETTINGS) + for row in self.query("SELECT key, value FROM settings"): + try: + out[row["key"]] = json.loads(row["value"]) + except Exception: + pass + return out + + def set_setting(self, key: str, value) -> None: + self.execute( + "INSERT INTO settings(key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + [key, json.dumps(value, ensure_ascii=False)], + ) + + # ── стартовые данные ────────────────────────────────────────────────── + + def _seed_defaults(self) -> None: + now = time.time_ns() // 1_000_000 + # Колонки не создаются по умолчанию: их делает пользователь или + # предлагает ИИ (см. services/suggest.py). + if self.scalar("SELECT count(*) FROM rates") == 0: + self.execute( + "INSERT INTO rates(id, rates, source, updated_at) VALUES (1, ?, 'mock', ?)", + [json.dumps(C.MOCK_RATES), now], + ) + # начальные настройки сохраняем только при отсутствии (значения берутся из DEFAULT_SETTINGS сами) + + # ── генераторы ──────────────────────────────────────────────────────── + + @staticmethod + def uid(prefix: str = "") -> str: + return f"{prefix}{uuid.uuid4().hex[:12]}" + + +store = Store() diff --git a/archive/leadradar-legacy/backend/app/main.py b/archive/leadradar-legacy/backend/app/main.py index 2bcf471..276a7dc 100644 --- a/archive/leadradar-legacy/backend/app/main.py +++ b/archive/leadradar-legacy/backend/app/main.py @@ -1,216 +1,216 @@ -"""Точка входа FastAPI-приложения LeadRadar. - -Запуск: uvicorn app.main:app --host 0.0.0.0 --port 8000 -или: python -m app.main -""" -from __future__ import annotations - -import asyncio -import contextlib -import logging -from pathlib import Path - -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, JSONResponse -from fastapi.staticfiles import StaticFiles - -from . import config -from .auth import ensure_creds -from .db import store -from .routers import ( - auth_routes, - dashboard_routes, - discovery_routes, - events_routes, - ml_routes, - processing_routes, - projects_routes, - settings_routes, - tg_routes, -) -from .services import fts as fts_svc -from .services import ml_client -from .services import projects as proj_svc -from .services import rates as rates_svc -from .services.leads import notify_tick_stats, tick_storage -from .services.telegram import register_main_loop, tg - -logging.basicConfig(level=config.LOG_LEVEL, format="%(asctime)s %(levelname)s %(name)s: %(message)s") -log = logging.getLogger("leadradar") - - -async def _storage_loop() -> None: - """Каждые 30 секунд: правила хранения (архив/очистка), напоминания, сердцебиение Telegram.""" - while True: - try: - stats = tick_storage() - await notify_tick_stats(stats) - await proj_svc.check_reminders() - await tg.heartbeat() - except Exception: # noqa: BLE001 - log.exception("storage loop error") - await asyncio.sleep(30) - - -async def _fts_loop() -> None: - """Полнотекстовые индексы пересобираем раз в сутки (FTS — снимок).""" - while True: - await asyncio.sleep(24 * 3600) - try: - fts_svc.rebuild() - except Exception: # noqa: BLE001 - log.exception("fts rebuild error") - - -async def _rates_loop() -> None: - """Курсы ЦБ РФ: проверяем раз в 30 минут, тянем не чаще 1 раза в 6 часов.""" - while True: - try: - if rates_svc.should_fetch(): - ok = await rates_svc.refresh_rates() - if not ok: - log.warning("rates fetch failed (повтор через 30 минут)") - except Exception: # noqa: BLE001 - log.exception("rates loop error") - await asyncio.sleep(30 * 60) - - -async def _pipeline_loop() -> None: - """Фоновый воркер очереди входящих: разбирает сообщения каждые 2 секунды.""" - from .services.pipeline import pump_once - - while True: - try: - await pump_once() - except Exception: # noqa: BLE001 - log.exception("pipeline worker error") - await asyncio.sleep(2) - - -async def _discovery_loop() -> None: - """Фоновый воркер Discovery: поиск → оценка → авто-вступление (раз в 5 c).""" - from .services.discovery_worker import tick - - while True: - try: - await tick() - except Exception: # noqa: BLE001 - log.exception("discovery worker error") - await asyncio.sleep(5) - - -async def _ml_sync_loop() -> None: - """Отправка событий обучения в ML-сервис + кэш его статуса (каждые 10 c).""" - while True: - try: - await ml_client.flush_outbox() - await ml_client.refresh_status() - except Exception: # noqa: BLE001 - log.exception("ml sync error") - await asyncio.sleep(10) - - -async def _suggest_loop() -> None: - """Раз в 3 минуты пробуем предложить колонки по «Неразобранному» (кулдаун 20 мин).""" - from .services.suggest import suggest_from_inbox - - while True: - try: - await suggest_from_inbox(force=False) - except Exception: # noqa: BLE001 - log.exception("suggest loop error") - await asyncio.sleep(180) - - -async def _tg_sweep_loop() -> None: - """Раз в 30 с: страховочная догонялка непрочитанного по включённым каналам - (событие могло потеряться при рестарте/разрыве соединения).""" - from .services.telegram import tg as tg_svc - - while True: - await asyncio.sleep(30) - try: - await tg_svc.realtime_sweep() - except Exception: # noqa: BLE001 - log.exception("tg realtime sweep error") - - -@contextlib.asynccontextmanager -async def lifespan(app: FastAPI): - config.ensure_dirs() - store.init() - ensure_creds() - register_main_loop(asyncio.get_running_loop()) - tasks = [ - asyncio.create_task(_storage_loop()), - asyncio.create_task(_rates_loop()), - asyncio.create_task(_fts_loop()), - asyncio.create_task(_pipeline_loop()), - asyncio.create_task(_discovery_loop()), - asyncio.create_task(_ml_sync_loop()), - asyncio.create_task(_suggest_loop()), - asyncio.create_task(_tg_sweep_loop()), - asyncio.create_task(tg.auto_resume()), - ] - # полнотекстовый поиск: пробуем установить расширение и собрать индекс - if fts_svc.install_extension(): - fts_svc.rebuild() - # первичное обновление курсов из ЦБ (если источник cbr) - if store.get_setting("rateSource") == "cbr": - try: - await rates_svc.refresh_rates() - except Exception: # noqa: BLE001 - log.warning("initial rates fetch failed; продолжаем с мок-курсами") - try: - yield - finally: - for t in tasks: - t.cancel() - await asyncio.gather(*tasks, return_exceptions=True) - await tg.disconnect() - store.close() - - -app = FastAPI(title="LeadRadar", version="1.2.0", lifespan=lifespan) - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # локальный dev-режим; в проде замените на свой origin - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# API -for mod in (auth_routes, tg_routes, dashboard_routes, projects_routes, settings_routes, events_routes, discovery_routes, ml_routes, processing_routes): - app.include_router(mod.router) - - -@app.get("/api/health") -def health() -> dict: - return {"ok": True, "service": "leadradar"} - - -# Статика фронтенда (собранный dist). Если папки нет — API живёт отдельно. -_DIST = config.FRONTEND_DIST -if _DIST.is_dir(): - assets = _DIST / "assets" - if assets.is_dir(): - app.mount("/assets", StaticFiles(directory=str(assets)), name="assets") - - @app.get("/{full_path:path}", include_in_schema=False) - async def spa(full_path: str): - candidate = (_DIST / full_path).resolve() - if full_path and candidate.is_file() and candidate.is_relative_to(_DIST): - return FileResponse(str(candidate)) - index = _DIST / "index.html" - if index.exists(): - return FileResponse(str(index)) - return JSONResponse({"detail": "фронтенд не собран — используйте npm run dev"}, status_code=200) - - -if __name__ == "__main__": - import uvicorn - - uvicorn.run("app.main:app", host=config.HOST, port=config.PORT, reload=False) +"""Точка входа FastAPI-приложения LeadRadar. + +Запуск: uvicorn app.main:app --host 0.0.0.0 --port 8000 +или: python -m app.main +""" +from __future__ import annotations + +import asyncio +import contextlib +import logging +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles + +from . import config +from .auth import ensure_creds +from .db import store +from .routers import ( + auth_routes, + dashboard_routes, + discovery_routes, + events_routes, + ml_routes, + processing_routes, + projects_routes, + settings_routes, + tg_routes, +) +from .services import fts as fts_svc +from .services import ml_client +from .services import projects as proj_svc +from .services import rates as rates_svc +from .services.leads import notify_tick_stats, tick_storage +from .services.telegram import register_main_loop, tg + +logging.basicConfig(level=config.LOG_LEVEL, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +log = logging.getLogger("leadradar") + + +async def _storage_loop() -> None: + """Каждые 30 секунд: правила хранения (архив/очистка), напоминания, сердцебиение Telegram.""" + while True: + try: + stats = tick_storage() + await notify_tick_stats(stats) + await proj_svc.check_reminders() + await tg.heartbeat() + except Exception: # noqa: BLE001 + log.exception("storage loop error") + await asyncio.sleep(30) + + +async def _fts_loop() -> None: + """Полнотекстовые индексы пересобираем раз в сутки (FTS — снимок).""" + while True: + await asyncio.sleep(24 * 3600) + try: + fts_svc.rebuild() + except Exception: # noqa: BLE001 + log.exception("fts rebuild error") + + +async def _rates_loop() -> None: + """Курсы ЦБ РФ: проверяем раз в 30 минут, тянем не чаще 1 раза в 6 часов.""" + while True: + try: + if rates_svc.should_fetch(): + ok = await rates_svc.refresh_rates() + if not ok: + log.warning("rates fetch failed (повтор через 30 минут)") + except Exception: # noqa: BLE001 + log.exception("rates loop error") + await asyncio.sleep(30 * 60) + + +async def _pipeline_loop() -> None: + """Фоновый воркер очереди входящих: разбирает сообщения каждые 2 секунды.""" + from .services.pipeline import pump_once + + while True: + try: + await pump_once() + except Exception: # noqa: BLE001 + log.exception("pipeline worker error") + await asyncio.sleep(2) + + +async def _discovery_loop() -> None: + """Фоновый воркер Discovery: поиск → оценка → авто-вступление (раз в 5 c).""" + from .services.discovery_worker import tick + + while True: + try: + await tick() + except Exception: # noqa: BLE001 + log.exception("discovery worker error") + await asyncio.sleep(5) + + +async def _ml_sync_loop() -> None: + """Отправка событий обучения в ML-сервис + кэш его статуса (каждые 10 c).""" + while True: + try: + await ml_client.flush_outbox() + await ml_client.refresh_status() + except Exception: # noqa: BLE001 + log.exception("ml sync error") + await asyncio.sleep(10) + + +async def _suggest_loop() -> None: + """Раз в 3 минуты пробуем предложить колонки по «Неразобранному» (кулдаун 20 мин).""" + from .services.suggest import suggest_from_inbox + + while True: + try: + await suggest_from_inbox(force=False) + except Exception: # noqa: BLE001 + log.exception("suggest loop error") + await asyncio.sleep(180) + + +async def _tg_sweep_loop() -> None: + """Раз в 30 с: страховочная догонялка непрочитанного по включённым каналам + (событие могло потеряться при рестарте/разрыве соединения).""" + from .services.telegram import tg as tg_svc + + while True: + await asyncio.sleep(30) + try: + await tg_svc.realtime_sweep() + except Exception: # noqa: BLE001 + log.exception("tg realtime sweep error") + + +@contextlib.asynccontextmanager +async def lifespan(app: FastAPI): + config.ensure_dirs() + store.init() + ensure_creds() + register_main_loop(asyncio.get_running_loop()) + tasks = [ + asyncio.create_task(_storage_loop()), + asyncio.create_task(_rates_loop()), + asyncio.create_task(_fts_loop()), + asyncio.create_task(_pipeline_loop()), + asyncio.create_task(_discovery_loop()), + asyncio.create_task(_ml_sync_loop()), + asyncio.create_task(_suggest_loop()), + asyncio.create_task(_tg_sweep_loop()), + asyncio.create_task(tg.auto_resume()), + ] + # полнотекстовый поиск: пробуем установить расширение и собрать индекс + if fts_svc.install_extension(): + fts_svc.rebuild() + # первичное обновление курсов из ЦБ (если источник cbr) + if store.get_setting("rateSource") == "cbr": + try: + await rates_svc.refresh_rates() + except Exception: # noqa: BLE001 + log.warning("initial rates fetch failed; продолжаем с мок-курсами") + try: + yield + finally: + for t in tasks: + t.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + await tg.disconnect() + store.close() + + +app = FastAPI(title="LeadRadar", version="1.2.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # локальный dev-режим; в проде замените на свой origin + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# API +for mod in (auth_routes, tg_routes, dashboard_routes, projects_routes, settings_routes, events_routes, discovery_routes, ml_routes, processing_routes): + app.include_router(mod.router) + + +@app.get("/api/health") +def health() -> dict: + return {"ok": True, "service": "leadradar"} + + +# Статика фронтенда (собранный dist). Если папки нет — API живёт отдельно. +_DIST = config.FRONTEND_DIST +if _DIST.is_dir(): + assets = _DIST / "assets" + if assets.is_dir(): + app.mount("/assets", StaticFiles(directory=str(assets)), name="assets") + + @app.get("/{full_path:path}", include_in_schema=False) + async def spa(full_path: str): + candidate = (_DIST / full_path).resolve() + if full_path and candidate.is_file() and candidate.is_relative_to(_DIST): + return FileResponse(str(candidate)) + index = _DIST / "index.html" + if index.exists(): + return FileResponse(str(index)) + return JSONResponse({"detail": "фронтенд не собран — используйте npm run dev"}, status_code=200) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("app.main:app", host=config.HOST, port=config.PORT, reload=False) diff --git a/archive/leadradar-legacy/backend/app/routers/__init__.py b/archive/leadradar-legacy/backend/app/routers/__init__.py index f42dd1e..129f1a0 100644 --- a/archive/leadradar-legacy/backend/app/routers/__init__.py +++ b/archive/leadradar-legacy/backend/app/routers/__init__.py @@ -1 +1 @@ -"""Пакет роутеров API.""" +"""Пакет роутеров API.""" diff --git a/archive/leadradar-legacy/backend/app/routers/auth_routes.py b/archive/leadradar-legacy/backend/app/routers/auth_routes.py index 8764dbb..98e4852 100644 --- a/archive/leadradar-legacy/backend/app/routers/auth_routes.py +++ b/archive/leadradar-legacy/backend/app/routers/auth_routes.py @@ -1,65 +1,65 @@ -"""Вход в дашборд и сессии (п.4.1 ТЗ).""" -from __future__ import annotations - -from fastapi import APIRouter, Depends, HTTPException, Request, Response -from pydantic import BaseModel - -from ..auth import ( - change_password, - create_session, - current_login, - destroy_session, - ensure_creds, - set_session_cookie, - verify_password, -) -from ..config import COOKIE_NAME - -router = APIRouter(prefix="/api", tags=["auth"]) - - -class LoginBody(BaseModel): - login: str - password: str - - -class PasswordBody(BaseModel): - oldPassword: str - newPassword: str - - -@router.post("/auth/login") -def login(body: LoginBody, response: Response) -> dict: - ensure_creds() - login_name = body.login.strip() - if not verify_password(login_name, body.password): - raise HTTPException(401, "Неверный логин или пароль") - token = create_session(login_name) - set_session_cookie(response, token) - return {"ok": True, "login": login_name} - - -@router.post("/auth/logout") -def logout(request: Request, response: Response) -> dict: - token = request.cookies.get(COOKIE_NAME) - destroy_session(token) - response.delete_cookie(COOKIE_NAME) - return {"ok": True} - - -@router.get("/auth/me") -def me(login: str = Depends(current_login)) -> dict: - return {"login": login, "ok": True} - - -@router.post("/auth/change-password") -def change(body: PasswordBody, request: Request, response: Response, login: str = Depends(current_login)) -> dict: - token = request.cookies.get(COOKIE_NAME) - ok = change_password(login, body.oldPassword, body.newPassword) - if not ok: - raise HTTPException(400, "Текущий пароль неверен") - # старые сессии удалены внутри change_password; выдаём свежую - fresh = create_session(login) - destroy_session(token) - set_session_cookie(response, fresh) - return {"ok": True} +"""Вход в дашборд и сессии (п.4.1 ТЗ).""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from pydantic import BaseModel + +from ..auth import ( + change_password, + create_session, + current_login, + destroy_session, + ensure_creds, + set_session_cookie, + verify_password, +) +from ..config import COOKIE_NAME + +router = APIRouter(prefix="/api", tags=["auth"]) + + +class LoginBody(BaseModel): + login: str + password: str + + +class PasswordBody(BaseModel): + oldPassword: str + newPassword: str + + +@router.post("/auth/login") +def login(body: LoginBody, response: Response) -> dict: + ensure_creds() + login_name = body.login.strip() + if not verify_password(login_name, body.password): + raise HTTPException(401, "Неверный логин или пароль") + token = create_session(login_name) + set_session_cookie(response, token) + return {"ok": True, "login": login_name} + + +@router.post("/auth/logout") +def logout(request: Request, response: Response) -> dict: + token = request.cookies.get(COOKIE_NAME) + destroy_session(token) + response.delete_cookie(COOKIE_NAME) + return {"ok": True} + + +@router.get("/auth/me") +def me(login: str = Depends(current_login)) -> dict: + return {"login": login, "ok": True} + + +@router.post("/auth/change-password") +def change(body: PasswordBody, request: Request, response: Response, login: str = Depends(current_login)) -> dict: + token = request.cookies.get(COOKIE_NAME) + ok = change_password(login, body.oldPassword, body.newPassword) + if not ok: + raise HTTPException(400, "Текущий пароль неверен") + # старые сессии удалены внутри change_password; выдаём свежую + fresh = create_session(login) + destroy_session(token) + set_session_cookie(response, fresh) + return {"ok": True} diff --git a/archive/leadradar-legacy/backend/app/routers/dashboard_routes.py b/archive/leadradar-legacy/backend/app/routers/dashboard_routes.py index 166716f..3556c06 100644 --- a/archive/leadradar-legacy/backend/app/routers/dashboard_routes.py +++ b/archive/leadradar-legacy/backend/app/routers/dashboard_routes.py @@ -1,409 +1,409 @@ -"""Дашборд: доски, колонки, карточки, поиск, правила хранения.""" -from __future__ import annotations - -import secrets -import time - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel - -from .. import config -from .. import constants as C -from ..auth import current_login -from ..db import store -from ..sse import broker -from ..services import fts as fts_svc -from ..services import leads as leads_svc -from ..services.ai import normalize_dedup -from ..services.pipeline import _store_lead, lead_to_dict, stage1_plain -from ..services.rates import refresh_rates # noqa: F401 (для админ-тика может пригодиться) - -router = APIRouter(prefix="/api", tags=["dashboard"]) - - -class BoardCreate(BaseModel): - name: str - description: str = "" - color: str | None = None - keywords: list[str] | None = None - prompt: str = "" - rules: dict | None = None - - -class BoardPatch(BaseModel): - name: str | None = None - description: str | None = None - color: str | None = None - width: str | None = None - collapsed: bool | None = None - prompt: str | None = None - keywords: list[str] | None = None - visibleFields: list[str] | None = None - suggested: bool | None = None - rules: dict | None = None - note: str | None = None - - -class OrderBody(BaseModel): - order: list[str] - - -class ColStateBody(BaseModel): - collapsed: bool | None = None - width: str | None = None - - -class MoveBody(BaseModel): - to: str - - -class CommentBody(BaseModel): - text: str - - -class ReclassifyBody(BaseModel): - ids: list[str] | None = None - - -class PumpGateBody(BaseModel): - limit: int | None = None # 0/None — выключить; >0 — остановить воркер после N созданных карточек - - -class CheckMessageBody(BaseModel): - text: str - - -# Демо-пул для кнопки «Симулировать лид» (без обращения к ИИ) -_DEMO_POOL = [ - ("Нужен Middle Python-разработчик на бота для CRM, удалённо, 1 600–2 200$.", - {"title": "Middle Python-разработчик на бота для CRM", "summary": "Развитие CRM: бот для менеджеров, интеграция с amoCRM.", - "stack": ["Python", "aiogram", "amoCRM"], "budget": {"from": 1600, "to": 2200, "currency": "USD"}, - "contacts": "@crm_head", "is_vacancy": True, "board": None, "is_spam": False}), - ("Frontend-разработчик в продуктовую команду, Vue 3, 2 400$.", - {"title": "Frontend-разработчик в продуктовую команду", "summary": "Развитие SPA: компоненты, стейт, производительность.", - "stack": ["Vue 3", "Pinia", "Vitest"], "budget": {"from": 2400, "to": 2400, "currency": "USD"}, - "contacts": "@front_hh", "is_vacancy": True, "board": None, "is_spam": False}), - ("Ищу подрядчика на бота для такси: клиент заказывает, водитель принимает. Бюджет обсуждаем.", - {"title": "Бот для заказов такси", "summary": "Клиент заказывает, водитель принимает.", - "stack": [], "budget": None, "contacts": "@taxi_owner", "is_vacancy": False, "board": None, "is_spam": False}), -] - - -def _lead_or_404(lead_id: str) -> dict: - lead = leads_svc.get_lead(lead_id) - if not lead: - raise HTTPException(404, "Карточка не найдена") - return lead - - -# ─── Доски ──────────────────────────────────────────────────────────────── - -@router.get("/boards") -def boards(_: str = Depends(current_login)) -> list[dict]: - return leads_svc.list_boards() - - -@router.post("/boards") -def create_board(body: BoardCreate, _: str = Depends(current_login)) -> dict: - return leads_svc.create_board( - name=body.name, - color=body.color, - keywords=body.keywords, - prompt=body.prompt, - description=body.description, - rules=body.rules, - ) - - -@router.patch("/boards/{board_id}") -def patch_board(board_id: str, body: BoardPatch, _: str = Depends(current_login)) -> dict: - try: - return leads_svc.patch_board(board_id, body.model_dump(exclude_none=True)) - except KeyError: - raise HTTPException(404, "Доска не найдена") - - -@router.delete("/boards/{board_id}") -def delete_board(board_id: str, _: str = Depends(current_login)) -> dict: - moved = leads_svc.delete_board(board_id) - return {"ok": True, "movedToInbox": moved} - - -@router.post("/boards/reorder") -def reorder(body: OrderBody, _: str = Depends(current_login)) -> dict: - leads_svc.reorder_boards(body.order) - return {"ok": True} - - -# ─── Состояние колонок ──────────────────────────────────────────────────── - -@router.get("/columns/state") -def columns_state(_: str = Depends(current_login)) -> dict: - return leads_svc.get_col_state() - - -@router.patch("/columns/{col_id}/state") -def set_column_state(col_id: str, body: ColStateBody, _: str = Depends(current_login)) -> dict: - state = leads_svc.get_col_state().get(col_id, {}) - state.update(body.model_dump(exclude_none=True)) - return leads_svc.set_col_state(col_id, state) - - -# ─── Лиды ───────────────────────────────────────────────────────────────── - -@router.get("/leads") -def list_leads(col: str | None = None, _: str = Depends(current_login)) -> dict: - if col and col not in ("inbox", "archive", "trash") and not any(b["id"] == col for b in leads_svc.list_boards()): - raise HTTPException(400, "Неизвестная колонка") - return {"items": leads_svc.list_leads(col)} - - -@router.get("/leads/counts") -def leads_counts(_: str = Depends(current_login)) -> dict: - return leads_svc.counts() - - -@router.get("/leads/{lead_id}") -def get_lead(lead_id: str, _: str = Depends(current_login)) -> dict: - return _lead_or_404(lead_id) - - -@router.post("/leads/{lead_id}/seen") -def seen(lead_id: str, _: str = Depends(current_login)) -> dict: - leads_svc.mark_seen(lead_id=lead_id) - return {"ok": True} - - -@router.post("/leads/mark-all-seen") -def mark_all_seen(_: str = Depends(current_login)) -> dict: - leads_svc.mark_seen() - return {"ok": True} - - -class MarkColBody(BaseModel): - col: str - - -@router.post("/leads/mark-col-seen") -def mark_col_seen(body: MarkColBody, _: str = Depends(current_login)) -> dict: - """Снять «новое» с колонки (используется при открытии колонки из сайдбара).""" - leads_svc.mark_seen(col=body.col) - return {"ok": True} - - -@router.post("/leads/{lead_id}/move") -def move(lead_id: str, body: MoveBody, _: str = Depends(current_login)) -> dict: - try: - leads_svc.move_lead(lead_id, body.to) - except ValueError as exc: - raise HTTPException(400, str(exc)) from exc - return _lead_or_404(lead_id) - - -@router.post("/leads/{lead_id}/trash") -def trash(lead_id: str, _: str = Depends(current_login)) -> dict: - _lead_or_404(lead_id) - leads_svc.trash_lead(lead_id) - return {"ok": True} - - -@router.post("/leads/{lead_id}/restore") -def restore(lead_id: str, _: str = Depends(current_login)) -> dict: - _lead_or_404(lead_id) - back = leads_svc.restore_lead(lead_id) - return {"ok": True, "col": back} - - -@router.delete("/leads/{lead_id}") -def delete(lead_id: str, _: str = Depends(current_login)) -> dict: - _lead_or_404(lead_id) - leads_svc.delete_forever(lead_id) - return {"ok": True} - - -class ClearColBody(BaseModel): - col: str - - -@router.post("/leads/clear-col") -def clear_col(body: ClearColBody, _: str = Depends(current_login)) -> dict: - """Ручная полная очистка корзины/архива (безвозвратно).""" - try: - cleared = leads_svc.clear_col(body.col) - except ValueError as exc: - raise HTTPException(400, str(exc)) from exc - return {"ok": True, "cleared": cleared} - - -@router.post("/leads/{lead_id}/comments") -def comment(lead_id: str, body: CommentBody, _: str = Depends(current_login)) -> dict: - if not body.text.strip(): - raise HTTPException(400, "Пустой комментарий") - return {"comments": leads_svc.add_comment(lead_id, body.text)} - - -@router.post("/leads/reclassify") -async def reclassify(body: ReclassifyBody | None = None, _: str = Depends(current_login)) -> dict: - """Переклассификация «Неразобранного»: фоновая задача (одна за раз).""" - ids = body.ids if body else None - return await leads_svc.start_reclassify(ids) - - -# ─── Поиск ──────────────────────────────────────────────────────────────── - -@router.get("/search") -def search(q: str = "", _: str = Depends(current_login)) -> dict: - return leads_svc.search(q) - - -# ─── Админ-тик (правила хранения) ───────────────────────────────────────── - -@router.post("/admin/fts/rebuild") -def fts_rebuild(_: str = Depends(current_login)) -> dict: - ok = fts_svc.rebuild() - return {"ok": ok, "ready": fts_svc.is_ready()} - - -@router.post("/admin/check-message") -async def check_message(body: CheckMessageBody, _: str = Depends(current_login)) -> dict: - """Проверка фильтра входящих (этап 1 + этап 2) для тестера в настройках.""" - from ..services.ai import filter_incoming - - r1 = stage1_plain(body.text) - result = {"stage1": {"pass": r1["pass"], "reason": r1["reason"]}} - if not r1["pass"]: - result["stage2"] = {"pass": False, "reason": None, "skipped": True} - result["passed"] = False - return result - try: - r2 = await filter_incoming(body.text) - except Exception as exc: # noqa: BLE001 - r2 = {"pass": True, "reason": None, "skipped": True} - result["stage2"] = r2 - result["passed"] = bool(r2["pass"]) - return result - - -def _demo_off() -> None: - if not config.DEMO_ENABLED: - raise HTTPException(404, "Демо-режим отключён") - - -@router.post("/demo/simulate-lead") -async def simulate_lead(_: str = Depends(current_login)) -> dict: - """Служебный демо-эндпоинт (включён только при LEADRADAR_DEMO=1).""" - _demo_off() - import random - - text, raw = random.choice(_DEMO_POOL) - digest = "demo_" + secrets.token_hex(8) - lead = _store_lead( - digest, "demo_channel", "Демо-канал", "demo_channel", "#8b8ff8", text, raw, time.time_ns() // 1_000_000, - ) - if lead: - await broker.publish("new_lead", lead) - await broker.publish_toast("Демо: новый лид", "sparkles") - return lead or {} - - -@router.post("/demo/age-lead") -async def demo_age_lead(_: str = Depends(current_login)) -> dict: - """Служебный демо-эндпоинт (включён только при LEADRADAR_DEMO=1).""" - _demo_off() - days = int(store.get_setting("archiveAfterDays") or 14) - row = store.query_one( - "SELECT id FROM leads WHERE col IN (SELECT id FROM boards) ORDER BY received_at ASC LIMIT 1" - ) - if not row: - raise HTTPException(400, "Нет карточек на досках для демо") - aged = time.time_ns() // 1_000_000 - (days + 1) * C.DAY_MS - store.execute("UPDATE leads SET received_at = ? WHERE id = ?", [aged, row["id"]]) - stats = leads_svc.tick_storage() - if stats["archived"]: - await broker.publish_toast(f"Демо: карточка → Архив (старше {days + 1} дн.)", "clock") - return {"ok": True, "stats": stats} - - -@router.post("/admin/tick") -async def admin_tick(_: str = Depends(current_login)) -> dict: - from ..services import projects as projects_svc - from ..services.pipeline import pump_once, queue_len - - stats = leads_svc.tick_storage() - await leads_svc.notify_tick_stats(stats) - due = await projects_svc.check_reminders() - # разгребаем очередь входящих (этап 1 -> правила -> ML/ИИ) - pump = await pump_once() - return {"storage": stats, "reminders": due, "pipeline": pump, "queue": queue_len()} - - -@router.post("/admin/wipe") -async def admin_wipe(_: str = Depends(current_login)) -> dict: - """Полный сброс под новый прогон: все карточки, обучение ML, счётчики ИИ/ML. - - Удаляет карточки со всех колонок (включая архив/корзину), исходные - сообщения, журнал обучения, очередь обучения и очередь входящих; сбрасывает - модель ML и счётчики реальных действий. Колонки/доски, каналы и настройки - не трогаются. - """ - from ..services import ml_client - from ..services.ml_client import DECISIONS_AI, DECISIONS_ML - - n_leads = int(store.scalar("SELECT count(*) FROM leads") or 0) - for tbl in ("leads", "dedup", "messages", "learning_log", "ml_outbox", "pipeline_msg", "rejected_msgs"): - store.execute(f"DELETE FROM {tbl}") - store.set_setting(DECISIONS_ML, 0) - store.set_setting(DECISIONS_AI, 0) - fts_svc.rebuild() - ml = await ml_client.reset_model() - return {"ok": True, "cardsRemoved": n_leads, "ml": ml} - - -@router.post("/admin/clear-cards") -async def admin_clear_cards(_: str = Depends(current_login)) -> dict: - """Очистить карточки и очереди для повторного прогона, НЕ трогая ML. - - Удаляет карточки (все колонки/архив/корзина), исходники, дедуп, журнал - обучения и очередь входящих. Модель ML, очередь её обучения (ml_outbox) - и счётчики ИИ/ML остаются нетронутыми — нужно для тестов «обучить ML на - прогоне с ИИ, затем прогнать те же сообщения без ИИ». - """ - n_leads = int(store.scalar("SELECT count(*) FROM leads") or 0) - for tbl in ("leads", "dedup", "messages", "learning_log", "pipeline_msg", "rejected_msgs"): - store.execute(f"DELETE FROM {tbl}") - fts_svc.rebuild() - return {"ok": True, "cardsRemoved": n_leads} - - -@router.post("/admin/pump-gate") -def pump_gate(body: PumpGateBody, _: str = Depends(current_login)) -> dict: - """Шлагбаум пайплайна: остановить воркер после N созданных карточек. - - limit=0 или None — снять ограничение (воркер продолжит разбирать очередь); - limit>0 — обнулить счётчик и останавливаться после N карточек (проверка - результата: «прогон с остановкой после первых N сообщений»). - """ - if body.limit is not None: - v = max(0, int(body.limit)) - store.set_setting("pumpGate", v) - store.set_setting("pumpGateDone", 0) - limit = int(store.get_setting("pumpGate") or 0) - done = int(store.get_setting("pumpGateDone") or 0) - return {"ok": True, "limit": limit, "done": done} - - -@router.post("/ai/suggest-columns") -async def ai_suggest_columns(_: str = Depends(current_login)) -> dict: - """Ручной запуск анализа «Неразобранного»: ИИ предлагает колонки.""" - from ..services.suggest import suggest_from_inbox - - result = await suggest_from_inbox(force=True) - return result - - -@router.post("/ai/suggest-keywords") -async def ai_suggest_keywords(_: str = Depends(current_login)) -> dict: - """ИИ предлагает общие ключевые слова-маркеры сферы по вашим карточкам.""" - from ..services.suggest import suggest_domain_keywords - - return await suggest_domain_keywords() +"""Дашборд: доски, колонки, карточки, поиск, правила хранения.""" +from __future__ import annotations + +import secrets +import time + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from .. import config +from .. import constants as C +from ..auth import current_login +from ..db import store +from ..sse import broker +from ..services import fts as fts_svc +from ..services import leads as leads_svc +from ..services.ai import normalize_dedup +from ..services.pipeline import _store_lead, lead_to_dict, stage1_plain +from ..services.rates import refresh_rates # noqa: F401 (для админ-тика может пригодиться) + +router = APIRouter(prefix="/api", tags=["dashboard"]) + + +class BoardCreate(BaseModel): + name: str + description: str = "" + color: str | None = None + keywords: list[str] | None = None + prompt: str = "" + rules: dict | None = None + + +class BoardPatch(BaseModel): + name: str | None = None + description: str | None = None + color: str | None = None + width: str | None = None + collapsed: bool | None = None + prompt: str | None = None + keywords: list[str] | None = None + visibleFields: list[str] | None = None + suggested: bool | None = None + rules: dict | None = None + note: str | None = None + + +class OrderBody(BaseModel): + order: list[str] + + +class ColStateBody(BaseModel): + collapsed: bool | None = None + width: str | None = None + + +class MoveBody(BaseModel): + to: str + + +class CommentBody(BaseModel): + text: str + + +class ReclassifyBody(BaseModel): + ids: list[str] | None = None + + +class PumpGateBody(BaseModel): + limit: int | None = None # 0/None — выключить; >0 — остановить воркер после N созданных карточек + + +class CheckMessageBody(BaseModel): + text: str + + +# Демо-пул для кнопки «Симулировать лид» (без обращения к ИИ) +_DEMO_POOL = [ + ("Нужен Middle Python-разработчик на бота для CRM, удалённо, 1 600–2 200$.", + {"title": "Middle Python-разработчик на бота для CRM", "summary": "Развитие CRM: бот для менеджеров, интеграция с amoCRM.", + "stack": ["Python", "aiogram", "amoCRM"], "budget": {"from": 1600, "to": 2200, "currency": "USD"}, + "contacts": "@crm_head", "is_vacancy": True, "board": None, "is_spam": False}), + ("Frontend-разработчик в продуктовую команду, Vue 3, 2 400$.", + {"title": "Frontend-разработчик в продуктовую команду", "summary": "Развитие SPA: компоненты, стейт, производительность.", + "stack": ["Vue 3", "Pinia", "Vitest"], "budget": {"from": 2400, "to": 2400, "currency": "USD"}, + "contacts": "@front_hh", "is_vacancy": True, "board": None, "is_spam": False}), + ("Ищу подрядчика на бота для такси: клиент заказывает, водитель принимает. Бюджет обсуждаем.", + {"title": "Бот для заказов такси", "summary": "Клиент заказывает, водитель принимает.", + "stack": [], "budget": None, "contacts": "@taxi_owner", "is_vacancy": False, "board": None, "is_spam": False}), +] + + +def _lead_or_404(lead_id: str) -> dict: + lead = leads_svc.get_lead(lead_id) + if not lead: + raise HTTPException(404, "Карточка не найдена") + return lead + + +# ─── Доски ──────────────────────────────────────────────────────────────── + +@router.get("/boards") +def boards(_: str = Depends(current_login)) -> list[dict]: + return leads_svc.list_boards() + + +@router.post("/boards") +def create_board(body: BoardCreate, _: str = Depends(current_login)) -> dict: + return leads_svc.create_board( + name=body.name, + color=body.color, + keywords=body.keywords, + prompt=body.prompt, + description=body.description, + rules=body.rules, + ) + + +@router.patch("/boards/{board_id}") +def patch_board(board_id: str, body: BoardPatch, _: str = Depends(current_login)) -> dict: + try: + return leads_svc.patch_board(board_id, body.model_dump(exclude_none=True)) + except KeyError: + raise HTTPException(404, "Доска не найдена") + + +@router.delete("/boards/{board_id}") +def delete_board(board_id: str, _: str = Depends(current_login)) -> dict: + moved = leads_svc.delete_board(board_id) + return {"ok": True, "movedToInbox": moved} + + +@router.post("/boards/reorder") +def reorder(body: OrderBody, _: str = Depends(current_login)) -> dict: + leads_svc.reorder_boards(body.order) + return {"ok": True} + + +# ─── Состояние колонок ──────────────────────────────────────────────────── + +@router.get("/columns/state") +def columns_state(_: str = Depends(current_login)) -> dict: + return leads_svc.get_col_state() + + +@router.patch("/columns/{col_id}/state") +def set_column_state(col_id: str, body: ColStateBody, _: str = Depends(current_login)) -> dict: + state = leads_svc.get_col_state().get(col_id, {}) + state.update(body.model_dump(exclude_none=True)) + return leads_svc.set_col_state(col_id, state) + + +# ─── Лиды ───────────────────────────────────────────────────────────────── + +@router.get("/leads") +def list_leads(col: str | None = None, _: str = Depends(current_login)) -> dict: + if col and col not in ("inbox", "archive", "trash") and not any(b["id"] == col for b in leads_svc.list_boards()): + raise HTTPException(400, "Неизвестная колонка") + return {"items": leads_svc.list_leads(col)} + + +@router.get("/leads/counts") +def leads_counts(_: str = Depends(current_login)) -> dict: + return leads_svc.counts() + + +@router.get("/leads/{lead_id}") +def get_lead(lead_id: str, _: str = Depends(current_login)) -> dict: + return _lead_or_404(lead_id) + + +@router.post("/leads/{lead_id}/seen") +def seen(lead_id: str, _: str = Depends(current_login)) -> dict: + leads_svc.mark_seen(lead_id=lead_id) + return {"ok": True} + + +@router.post("/leads/mark-all-seen") +def mark_all_seen(_: str = Depends(current_login)) -> dict: + leads_svc.mark_seen() + return {"ok": True} + + +class MarkColBody(BaseModel): + col: str + + +@router.post("/leads/mark-col-seen") +def mark_col_seen(body: MarkColBody, _: str = Depends(current_login)) -> dict: + """Снять «новое» с колонки (используется при открытии колонки из сайдбара).""" + leads_svc.mark_seen(col=body.col) + return {"ok": True} + + +@router.post("/leads/{lead_id}/move") +def move(lead_id: str, body: MoveBody, _: str = Depends(current_login)) -> dict: + try: + leads_svc.move_lead(lead_id, body.to) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return _lead_or_404(lead_id) + + +@router.post("/leads/{lead_id}/trash") +def trash(lead_id: str, _: str = Depends(current_login)) -> dict: + _lead_or_404(lead_id) + leads_svc.trash_lead(lead_id) + return {"ok": True} + + +@router.post("/leads/{lead_id}/restore") +def restore(lead_id: str, _: str = Depends(current_login)) -> dict: + _lead_or_404(lead_id) + back = leads_svc.restore_lead(lead_id) + return {"ok": True, "col": back} + + +@router.delete("/leads/{lead_id}") +def delete(lead_id: str, _: str = Depends(current_login)) -> dict: + _lead_or_404(lead_id) + leads_svc.delete_forever(lead_id) + return {"ok": True} + + +class ClearColBody(BaseModel): + col: str + + +@router.post("/leads/clear-col") +def clear_col(body: ClearColBody, _: str = Depends(current_login)) -> dict: + """Ручная полная очистка корзины/архива (безвозвратно).""" + try: + cleared = leads_svc.clear_col(body.col) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return {"ok": True, "cleared": cleared} + + +@router.post("/leads/{lead_id}/comments") +def comment(lead_id: str, body: CommentBody, _: str = Depends(current_login)) -> dict: + if not body.text.strip(): + raise HTTPException(400, "Пустой комментарий") + return {"comments": leads_svc.add_comment(lead_id, body.text)} + + +@router.post("/leads/reclassify") +async def reclassify(body: ReclassifyBody | None = None, _: str = Depends(current_login)) -> dict: + """Переклассификация «Неразобранного»: фоновая задача (одна за раз).""" + ids = body.ids if body else None + return await leads_svc.start_reclassify(ids) + + +# ─── Поиск ──────────────────────────────────────────────────────────────── + +@router.get("/search") +def search(q: str = "", _: str = Depends(current_login)) -> dict: + return leads_svc.search(q) + + +# ─── Админ-тик (правила хранения) ───────────────────────────────────────── + +@router.post("/admin/fts/rebuild") +def fts_rebuild(_: str = Depends(current_login)) -> dict: + ok = fts_svc.rebuild() + return {"ok": ok, "ready": fts_svc.is_ready()} + + +@router.post("/admin/check-message") +async def check_message(body: CheckMessageBody, _: str = Depends(current_login)) -> dict: + """Проверка фильтра входящих (этап 1 + этап 2) для тестера в настройках.""" + from ..services.ai import filter_incoming + + r1 = stage1_plain(body.text) + result = {"stage1": {"pass": r1["pass"], "reason": r1["reason"]}} + if not r1["pass"]: + result["stage2"] = {"pass": False, "reason": None, "skipped": True} + result["passed"] = False + return result + try: + r2 = await filter_incoming(body.text) + except Exception as exc: # noqa: BLE001 + r2 = {"pass": True, "reason": None, "skipped": True} + result["stage2"] = r2 + result["passed"] = bool(r2["pass"]) + return result + + +def _demo_off() -> None: + if not config.DEMO_ENABLED: + raise HTTPException(404, "Демо-режим отключён") + + +@router.post("/demo/simulate-lead") +async def simulate_lead(_: str = Depends(current_login)) -> dict: + """Служебный демо-эндпоинт (включён только при LEADRADAR_DEMO=1).""" + _demo_off() + import random + + text, raw = random.choice(_DEMO_POOL) + digest = "demo_" + secrets.token_hex(8) + lead = _store_lead( + digest, "demo_channel", "Демо-канал", "demo_channel", "#8b8ff8", text, raw, time.time_ns() // 1_000_000, + ) + if lead: + await broker.publish("new_lead", lead) + await broker.publish_toast("Демо: новый лид", "sparkles") + return lead or {} + + +@router.post("/demo/age-lead") +async def demo_age_lead(_: str = Depends(current_login)) -> dict: + """Служебный демо-эндпоинт (включён только при LEADRADAR_DEMO=1).""" + _demo_off() + days = int(store.get_setting("archiveAfterDays") or 14) + row = store.query_one( + "SELECT id FROM leads WHERE col IN (SELECT id FROM boards) ORDER BY received_at ASC LIMIT 1" + ) + if not row: + raise HTTPException(400, "Нет карточек на досках для демо") + aged = time.time_ns() // 1_000_000 - (days + 1) * C.DAY_MS + store.execute("UPDATE leads SET received_at = ? WHERE id = ?", [aged, row["id"]]) + stats = leads_svc.tick_storage() + if stats["archived"]: + await broker.publish_toast(f"Демо: карточка → Архив (старше {days + 1} дн.)", "clock") + return {"ok": True, "stats": stats} + + +@router.post("/admin/tick") +async def admin_tick(_: str = Depends(current_login)) -> dict: + from ..services import projects as projects_svc + from ..services.pipeline import pump_once, queue_len + + stats = leads_svc.tick_storage() + await leads_svc.notify_tick_stats(stats) + due = await projects_svc.check_reminders() + # разгребаем очередь входящих (этап 1 -> правила -> ML/ИИ) + pump = await pump_once() + return {"storage": stats, "reminders": due, "pipeline": pump, "queue": queue_len()} + + +@router.post("/admin/wipe") +async def admin_wipe(_: str = Depends(current_login)) -> dict: + """Полный сброс под новый прогон: все карточки, обучение ML, счётчики ИИ/ML. + + Удаляет карточки со всех колонок (включая архив/корзину), исходные + сообщения, журнал обучения, очередь обучения и очередь входящих; сбрасывает + модель ML и счётчики реальных действий. Колонки/доски, каналы и настройки + не трогаются. + """ + from ..services import ml_client + from ..services.ml_client import DECISIONS_AI, DECISIONS_ML + + n_leads = int(store.scalar("SELECT count(*) FROM leads") or 0) + for tbl in ("leads", "dedup", "messages", "learning_log", "ml_outbox", "pipeline_msg", "rejected_msgs"): + store.execute(f"DELETE FROM {tbl}") + store.set_setting(DECISIONS_ML, 0) + store.set_setting(DECISIONS_AI, 0) + fts_svc.rebuild() + ml = await ml_client.reset_model() + return {"ok": True, "cardsRemoved": n_leads, "ml": ml} + + +@router.post("/admin/clear-cards") +async def admin_clear_cards(_: str = Depends(current_login)) -> dict: + """Очистить карточки и очереди для повторного прогона, НЕ трогая ML. + + Удаляет карточки (все колонки/архив/корзина), исходники, дедуп, журнал + обучения и очередь входящих. Модель ML, очередь её обучения (ml_outbox) + и счётчики ИИ/ML остаются нетронутыми — нужно для тестов «обучить ML на + прогоне с ИИ, затем прогнать те же сообщения без ИИ». + """ + n_leads = int(store.scalar("SELECT count(*) FROM leads") or 0) + for tbl in ("leads", "dedup", "messages", "learning_log", "pipeline_msg", "rejected_msgs"): + store.execute(f"DELETE FROM {tbl}") + fts_svc.rebuild() + return {"ok": True, "cardsRemoved": n_leads} + + +@router.post("/admin/pump-gate") +def pump_gate(body: PumpGateBody, _: str = Depends(current_login)) -> dict: + """Шлагбаум пайплайна: остановить воркер после N созданных карточек. + + limit=0 или None — снять ограничение (воркер продолжит разбирать очередь); + limit>0 — обнулить счётчик и останавливаться после N карточек (проверка + результата: «прогон с остановкой после первых N сообщений»). + """ + if body.limit is not None: + v = max(0, int(body.limit)) + store.set_setting("pumpGate", v) + store.set_setting("pumpGateDone", 0) + limit = int(store.get_setting("pumpGate") or 0) + done = int(store.get_setting("pumpGateDone") or 0) + return {"ok": True, "limit": limit, "done": done} + + +@router.post("/ai/suggest-columns") +async def ai_suggest_columns(_: str = Depends(current_login)) -> dict: + """Ручной запуск анализа «Неразобранного»: ИИ предлагает колонки.""" + from ..services.suggest import suggest_from_inbox + + result = await suggest_from_inbox(force=True) + return result + + +@router.post("/ai/suggest-keywords") +async def ai_suggest_keywords(_: str = Depends(current_login)) -> dict: + """ИИ предлагает общие ключевые слова-маркеры сферы по вашим карточкам.""" + from ..services.suggest import suggest_domain_keywords + + return await suggest_domain_keywords() diff --git a/archive/leadradar-legacy/backend/app/routers/discovery_routes.py b/archive/leadradar-legacy/backend/app/routers/discovery_routes.py index 961cadb..0462dbf 100644 --- a/archive/leadradar-legacy/backend/app/routers/discovery_routes.py +++ b/archive/leadradar-legacy/backend/app/routers/discovery_routes.py @@ -1,285 +1,285 @@ -"""API Discovery (Task 7): задачи поиска каналов, кандидаты, чёрный список, лог. - -Prefix /api/discovery, авторизация — current_login (как в соседних роутерах). -Сервис discovery отдаёт наружу camelCase-словари (см. его docstring), поэтому -Pydantic-модели повторяют имена полей API без алиасов (как PreviewBody в tg_routes). -Списки наружу — {"items": [...]}, единичные объекты — как есть (конвенция проекта). - -Обработка ошибок контракта: ValueError -> HTTP 400, KeyError -> HTTP 404. -""" -from __future__ import annotations - -import logging -from typing import Literal - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel - -from ..auth import current_login -from ..db import store -from ..services import ai as ai_service -from ..services import discovery -from ..services.telegram import _spawn, tg - -log = logging.getLogger("leadradar.discovery_api") - -router = APIRouter(prefix="/api/discovery", tags=["discovery"]) - -# потолок текста описания, уходящего ИИ-генератору ключей -_AI_DESCRIPTION_LIMIT = 4000 -# страховочный потолок числа сгенерированных ключей (промпт просит 10–16) -_KEYWORDS_LIMIT = 30 -# потолок длины одного ключа (короткие фразы для поиска Telegram) -_KEYWORD_LENGTH_LIMIT = 60 - -# промпт генерации ключевых слов по описанию задачи (RU+EN, глобальный поиск) -_KEYWORDS_PROMPT = ( - "Ты — эксперт по поиску Telegram-каналов и групп. По описанию ниши/задачи " - "составь поисковые ключевые слова, по которым в глобальном поиске Telegram " - "находят подходящие источники. Верни строго JSON вида " - '{"keywords": ["...", "..."]}. Требования к списку:\n' - "- 10–16 ключей;\n" - "- примерно поровну русских и английских (английские — популярные в нише термины);\n" - "- короткие фразы 1–4 слова;\n" - "- без #, @, кавычек и лишней пунктуации;\n" - "- конкретные для ниши, включая сленг заказчиков и подрядчиков;\n" - "- без дублей и близких по смыслу повторов." -) - - -class TaskCreate(BaseModel): - name: str - description: str = "" - keywords: list[str] = [] - minSubscribers: int = 0 - lang: str = "ru" - threshold: int | None = None - sampleSize: int | None = None - planJoins: int = 1 - autoJoin: bool = False - - -class TaskPatch(BaseModel): - name: str | None = None - description: str | None = None - keywords: list[str] | None = None - minSubscribers: int | None = None - lang: str | None = None - threshold: int | None = None - sampleSize: int | None = None - planJoins: int | None = None - autoJoin: bool | None = None - - -def _payload(body: BaseModel) -> dict: - """Поля модели -> payload сервиса, без явных None. - - discovery.create_task/patch_task сами подставляют значения по умолчанию - (в т.ч. threshold/sampleSize из настроек), поэтому None-поля пропускаем. - """ - return body.model_dump(exclude_none=True) - - -def _task_or_404(task_id: str) -> dict: - task = discovery.get_task(task_id) - if task is None: - raise HTTPException(404, "Задача не найдена") - return task - - -def _candidate_or_404(dialog_id: str) -> dict: - row = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id]) - if row is None: - raise HTTPException(404, "Кандидат не найден") - return row - - -def _ai_unavailable_reason() -> str | None: - """Причина недоступности ИИ (None — можно вызывать).""" - if not store.get_setting("aiEnabled"): - return "ИИ выключен в настройках (aiEnabled)" - try: - status = ai_service.provider_status() - except Exception as exc: # noqa: BLE001 — статус не читается = ИИ недоступен - log.debug("generate-keywords: статус ИИ недоступен (%s)", exc) - return "Не удалось прочитать статус ИИ-провайдера" - if not (status.get("local") or status.get("keySet")): - return "Не задан API-ключ ИИ-провайдера" - return None - - -def _clean_keywords(raw) -> list[str]: - """Ключи из ответа ИИ: строки без пустых/длинных и повторов (casefold).""" - seen: set[str] = set() - out: list[str] = [] - for item in raw or []: - if not isinstance(item, str): - continue - keyword = item.strip() - if not keyword or len(keyword) > _KEYWORD_LENGTH_LIMIT: - continue - key = keyword.casefold() - if key in seen: - continue - seen.add(key) - out.append(keyword) - if len(out) >= _KEYWORDS_LIMIT: - break - return out - - -async def _backfill_quiet(dialog_id: str) -> None: - """Догон последних сообщений вступившего источника (фон, best-effort).""" - try: - await tg.backfill_dialog(dialog_id) - except Exception as exc: # noqa: BLE001 — вступление уже состоялось - log.warning("join %s: backfill не удался: %s", dialog_id, exc) - - -# ─── задачи ──────────────────────────────────────────────────────────────── - -@router.get("/tasks") -def list_tasks(_: str = Depends(current_login)) -> dict: - return {"items": discovery.list_tasks()} - - -@router.post("/tasks") -def create_task(body: TaskCreate, _: str = Depends(current_login)) -> dict: - try: - return discovery.create_task(_payload(body)) - except ValueError as exc: - raise HTTPException(400, str(exc)) from exc - - -@router.patch("/tasks/{task_id}") -def patch_task(task_id: str, body: TaskPatch, _: str = Depends(current_login)) -> dict: - try: - return discovery.patch_task(task_id, _payload(body)) - except KeyError as exc: - raise HTTPException(404, "Задача не найдена") from exc - except ValueError as exc: - raise HTTPException(400, str(exc)) from exc - - -@router.delete("/tasks/{task_id}") -def delete_task(task_id: str, _: str = Depends(current_login)) -> dict: - _task_or_404(task_id) - discovery.delete_task(task_id) - return {"ok": True} - - -@router.post("/tasks/{task_id}/start") -def start_task(task_id: str, _: str = Depends(current_login)) -> dict: - try: - return discovery.start_task(task_id) - except KeyError as exc: - raise HTTPException(404, "Задача не найдена") from exc - except ValueError as exc: - raise HTTPException(400, str(exc)) from exc - - -@router.post("/tasks/{task_id}/pause") -def pause_task(task_id: str, _: str = Depends(current_login)) -> dict: - try: - return discovery.pause_task(task_id) - except KeyError as exc: - raise HTTPException(404, "Задача не найдена") from exc - - -@router.post("/tasks/{task_id}/generate-keywords") -async def generate_keywords(task_id: str, _: str = Depends(current_login)) -> dict: - """ИИ-генерация ключей по описанию задачи: RU+EN, 10–16 строк. - - ИИ выключен/не настроен/ответил ошибкой — {"keywords": [], "error": "..."} - с HTTP 200, чтобы UI показал причину, а не падал. - """ - task = _task_or_404(task_id) - reason = _ai_unavailable_reason() - if reason: - return {"keywords": [], "error": reason} - description = str(task.get("description") or "").strip() - if not description: - return {"keywords": [], "error": "У задачи нет описания — по нему генерируются ключи"} - try: - out = await ai_service.chat_json( - _KEYWORDS_PROMPT, - f"Описание ниши/задачи:\n{description[:_AI_DESCRIPTION_LIMIT]}", - ) - except Exception as exc: # noqa: BLE001 — сбой провайдера не роняет API - log.warning("generate-keywords задача %s: ИИ не ответил: %s", task_id, exc) - return {"keywords": [], "error": str(exc)} - return {"keywords": _clean_keywords(out.get("keywords", []) if isinstance(out, dict) else [])} - - -# ─── кандидаты ───────────────────────────────────────────────────────────── - -@router.get("/tasks/{task_id}/candidates") -def list_candidates( - task_id: str, - status: Literal["new", "review", "joined", "rejected"] | None = None, - _: str = Depends(current_login), -) -> dict: - _task_or_404(task_id) - return {"items": discovery.list_candidates(task_id, status)} - - -@router.post("/candidates/{dialog_id}/join") -async def join_candidate(dialog_id: str, _: str = Depends(current_login)) -> dict: - """Ручное вступление (вне квот и пауз воркера). - - tg.discovery_join -> add_dialog_monitored -> backfill_dialog (последние - сообщения, best-effort) -> mark_joined(auto=False); источник снимается с - чёрного списка. Ошибка Telegram -> 400 с текстом причины. - """ - row = _candidate_or_404(dialog_id) - if row["status"] == "joined": - raise HTTPException(400, "Уже вступили в этот источник") - username = str(row.get("username") or "") - try: - await tg.discovery_join(username) - except Exception as exc: # текст ошибки уходит наружу - raise HTTPException(400, f"Не удалось вступить в @{username}: {exc}") from exc - tg.add_dialog_monitored(dialog_id, row.get("name"), username, row.get("kind"), row.get("hue")) - # догон последних сообщений — в фоне: join из UI не должен висеть на - # паузах backfill (10 сообщений × 1.5–3 с); источник уже в мониторинге - _spawn(_backfill_quiet(dialog_id)) - discovery.remove_blacklist(dialog_id) - try: - return discovery.mark_joined(dialog_id, auto=False) - except KeyError as exc: - raise HTTPException(404, "Кандидат не найден") from exc - - -@router.post("/candidates/{dialog_id}/reject") -def reject_candidate(dialog_id: str, _: str = Depends(current_login)) -> dict: - """Отклонить кандидата (в чёрный список). Уже вступившего — нельзя.""" - row = _candidate_or_404(dialog_id) - if row["status"] == "joined": - raise HTTPException(400, "Уже вступили — удалите источник из каналов") - try: - return discovery.mark_rejected(dialog_id, reason="отклонено вручную") - except KeyError as exc: - raise HTTPException(404, "Кандидат не найден") from exc - except ValueError as exc: - raise HTTPException(400, str(exc)) from exc - - -# ─── чёрный список ───────────────────────────────────────────────────────── - -@router.get("/blacklist") -def list_blacklist(_: str = Depends(current_login)) -> dict: - return {"items": discovery.list_blacklist()} - - -@router.delete("/blacklist/{dialog_id}") -def remove_blacklist(dialog_id: str, _: str = Depends(current_login)) -> dict: - discovery.remove_blacklist(dialog_id) - return {"ok": True} - - -# ─── лог задачи ──────────────────────────────────────────────────────────── - -@router.get("/tasks/{task_id}/log") -def task_log(task_id: str, _: str = Depends(current_login)) -> dict: - _task_or_404(task_id) - return {"items": discovery.task_log(task_id)} +"""API Discovery (Task 7): задачи поиска каналов, кандидаты, чёрный список, лог. + +Prefix /api/discovery, авторизация — current_login (как в соседних роутерах). +Сервис discovery отдаёт наружу camelCase-словари (см. его docstring), поэтому +Pydantic-модели повторяют имена полей API без алиасов (как PreviewBody в tg_routes). +Списки наружу — {"items": [...]}, единичные объекты — как есть (конвенция проекта). + +Обработка ошибок контракта: ValueError -> HTTP 400, KeyError -> HTTP 404. +""" +from __future__ import annotations + +import logging +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from ..auth import current_login +from ..db import store +from ..services import ai as ai_service +from ..services import discovery +from ..services.telegram import _spawn, tg + +log = logging.getLogger("leadradar.discovery_api") + +router = APIRouter(prefix="/api/discovery", tags=["discovery"]) + +# потолок текста описания, уходящего ИИ-генератору ключей +_AI_DESCRIPTION_LIMIT = 4000 +# страховочный потолок числа сгенерированных ключей (промпт просит 10–16) +_KEYWORDS_LIMIT = 30 +# потолок длины одного ключа (короткие фразы для поиска Telegram) +_KEYWORD_LENGTH_LIMIT = 60 + +# промпт генерации ключевых слов по описанию задачи (RU+EN, глобальный поиск) +_KEYWORDS_PROMPT = ( + "Ты — эксперт по поиску Telegram-каналов и групп. По описанию ниши/задачи " + "составь поисковые ключевые слова, по которым в глобальном поиске Telegram " + "находят подходящие источники. Верни строго JSON вида " + '{"keywords": ["...", "..."]}. Требования к списку:\n' + "- 10–16 ключей;\n" + "- примерно поровну русских и английских (английские — популярные в нише термины);\n" + "- короткие фразы 1–4 слова;\n" + "- без #, @, кавычек и лишней пунктуации;\n" + "- конкретные для ниши, включая сленг заказчиков и подрядчиков;\n" + "- без дублей и близких по смыслу повторов." +) + + +class TaskCreate(BaseModel): + name: str + description: str = "" + keywords: list[str] = [] + minSubscribers: int = 0 + lang: str = "ru" + threshold: int | None = None + sampleSize: int | None = None + planJoins: int = 1 + autoJoin: bool = False + + +class TaskPatch(BaseModel): + name: str | None = None + description: str | None = None + keywords: list[str] | None = None + minSubscribers: int | None = None + lang: str | None = None + threshold: int | None = None + sampleSize: int | None = None + planJoins: int | None = None + autoJoin: bool | None = None + + +def _payload(body: BaseModel) -> dict: + """Поля модели -> payload сервиса, без явных None. + + discovery.create_task/patch_task сами подставляют значения по умолчанию + (в т.ч. threshold/sampleSize из настроек), поэтому None-поля пропускаем. + """ + return body.model_dump(exclude_none=True) + + +def _task_or_404(task_id: str) -> dict: + task = discovery.get_task(task_id) + if task is None: + raise HTTPException(404, "Задача не найдена") + return task + + +def _candidate_or_404(dialog_id: str) -> dict: + row = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id]) + if row is None: + raise HTTPException(404, "Кандидат не найден") + return row + + +def _ai_unavailable_reason() -> str | None: + """Причина недоступности ИИ (None — можно вызывать).""" + if not store.get_setting("aiEnabled"): + return "ИИ выключен в настройках (aiEnabled)" + try: + status = ai_service.provider_status() + except Exception as exc: # noqa: BLE001 — статус не читается = ИИ недоступен + log.debug("generate-keywords: статус ИИ недоступен (%s)", exc) + return "Не удалось прочитать статус ИИ-провайдера" + if not (status.get("local") or status.get("keySet")): + return "Не задан API-ключ ИИ-провайдера" + return None + + +def _clean_keywords(raw) -> list[str]: + """Ключи из ответа ИИ: строки без пустых/длинных и повторов (casefold).""" + seen: set[str] = set() + out: list[str] = [] + for item in raw or []: + if not isinstance(item, str): + continue + keyword = item.strip() + if not keyword or len(keyword) > _KEYWORD_LENGTH_LIMIT: + continue + key = keyword.casefold() + if key in seen: + continue + seen.add(key) + out.append(keyword) + if len(out) >= _KEYWORDS_LIMIT: + break + return out + + +async def _backfill_quiet(dialog_id: str) -> None: + """Догон последних сообщений вступившего источника (фон, best-effort).""" + try: + await tg.backfill_dialog(dialog_id) + except Exception as exc: # noqa: BLE001 — вступление уже состоялось + log.warning("join %s: backfill не удался: %s", dialog_id, exc) + + +# ─── задачи ──────────────────────────────────────────────────────────────── + +@router.get("/tasks") +def list_tasks(_: str = Depends(current_login)) -> dict: + return {"items": discovery.list_tasks()} + + +@router.post("/tasks") +def create_task(body: TaskCreate, _: str = Depends(current_login)) -> dict: + try: + return discovery.create_task(_payload(body)) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + + +@router.patch("/tasks/{task_id}") +def patch_task(task_id: str, body: TaskPatch, _: str = Depends(current_login)) -> dict: + try: + return discovery.patch_task(task_id, _payload(body)) + except KeyError as exc: + raise HTTPException(404, "Задача не найдена") from exc + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + + +@router.delete("/tasks/{task_id}") +def delete_task(task_id: str, _: str = Depends(current_login)) -> dict: + _task_or_404(task_id) + discovery.delete_task(task_id) + return {"ok": True} + + +@router.post("/tasks/{task_id}/start") +def start_task(task_id: str, _: str = Depends(current_login)) -> dict: + try: + return discovery.start_task(task_id) + except KeyError as exc: + raise HTTPException(404, "Задача не найдена") from exc + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + + +@router.post("/tasks/{task_id}/pause") +def pause_task(task_id: str, _: str = Depends(current_login)) -> dict: + try: + return discovery.pause_task(task_id) + except KeyError as exc: + raise HTTPException(404, "Задача не найдена") from exc + + +@router.post("/tasks/{task_id}/generate-keywords") +async def generate_keywords(task_id: str, _: str = Depends(current_login)) -> dict: + """ИИ-генерация ключей по описанию задачи: RU+EN, 10–16 строк. + + ИИ выключен/не настроен/ответил ошибкой — {"keywords": [], "error": "..."} + с HTTP 200, чтобы UI показал причину, а не падал. + """ + task = _task_or_404(task_id) + reason = _ai_unavailable_reason() + if reason: + return {"keywords": [], "error": reason} + description = str(task.get("description") or "").strip() + if not description: + return {"keywords": [], "error": "У задачи нет описания — по нему генерируются ключи"} + try: + out = await ai_service.chat_json( + _KEYWORDS_PROMPT, + f"Описание ниши/задачи:\n{description[:_AI_DESCRIPTION_LIMIT]}", + ) + except Exception as exc: # noqa: BLE001 — сбой провайдера не роняет API + log.warning("generate-keywords задача %s: ИИ не ответил: %s", task_id, exc) + return {"keywords": [], "error": str(exc)} + return {"keywords": _clean_keywords(out.get("keywords", []) if isinstance(out, dict) else [])} + + +# ─── кандидаты ───────────────────────────────────────────────────────────── + +@router.get("/tasks/{task_id}/candidates") +def list_candidates( + task_id: str, + status: Literal["new", "review", "joined", "rejected"] | None = None, + _: str = Depends(current_login), +) -> dict: + _task_or_404(task_id) + return {"items": discovery.list_candidates(task_id, status)} + + +@router.post("/candidates/{dialog_id}/join") +async def join_candidate(dialog_id: str, _: str = Depends(current_login)) -> dict: + """Ручное вступление (вне квот и пауз воркера). + + tg.discovery_join -> add_dialog_monitored -> backfill_dialog (последние + сообщения, best-effort) -> mark_joined(auto=False); источник снимается с + чёрного списка. Ошибка Telegram -> 400 с текстом причины. + """ + row = _candidate_or_404(dialog_id) + if row["status"] == "joined": + raise HTTPException(400, "Уже вступили в этот источник") + username = str(row.get("username") or "") + try: + await tg.discovery_join(username) + except Exception as exc: # текст ошибки уходит наружу + raise HTTPException(400, f"Не удалось вступить в @{username}: {exc}") from exc + tg.add_dialog_monitored(dialog_id, row.get("name"), username, row.get("kind"), row.get("hue")) + # догон последних сообщений — в фоне: join из UI не должен висеть на + # паузах backfill (10 сообщений × 1.5–3 с); источник уже в мониторинге + _spawn(_backfill_quiet(dialog_id)) + discovery.remove_blacklist(dialog_id) + try: + return discovery.mark_joined(dialog_id, auto=False) + except KeyError as exc: + raise HTTPException(404, "Кандидат не найден") from exc + + +@router.post("/candidates/{dialog_id}/reject") +def reject_candidate(dialog_id: str, _: str = Depends(current_login)) -> dict: + """Отклонить кандидата (в чёрный список). Уже вступившего — нельзя.""" + row = _candidate_or_404(dialog_id) + if row["status"] == "joined": + raise HTTPException(400, "Уже вступили — удалите источник из каналов") + try: + return discovery.mark_rejected(dialog_id, reason="отклонено вручную") + except KeyError as exc: + raise HTTPException(404, "Кандидат не найден") from exc + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + + +# ─── чёрный список ───────────────────────────────────────────────────────── + +@router.get("/blacklist") +def list_blacklist(_: str = Depends(current_login)) -> dict: + return {"items": discovery.list_blacklist()} + + +@router.delete("/blacklist/{dialog_id}") +def remove_blacklist(dialog_id: str, _: str = Depends(current_login)) -> dict: + discovery.remove_blacklist(dialog_id) + return {"ok": True} + + +# ─── лог задачи ──────────────────────────────────────────────────────────── + +@router.get("/tasks/{task_id}/log") +def task_log(task_id: str, _: str = Depends(current_login)) -> dict: + _task_or_404(task_id) + return {"items": discovery.task_log(task_id)} diff --git a/archive/leadradar-legacy/backend/app/routers/events_routes.py b/archive/leadradar-legacy/backend/app/routers/events_routes.py index 7609d44..033fe4c 100644 --- a/archive/leadradar-legacy/backend/app/routers/events_routes.py +++ b/archive/leadradar-legacy/backend/app/routers/events_routes.py @@ -1,38 +1,38 @@ -"""SSE-события (realtime) для фронтенда.""" -from __future__ import annotations - -import asyncio - -from fastapi import APIRouter, Depends, Request -from fastapi.responses import StreamingResponse - -from ..auth import current_login -from ..sse import broker - -router = APIRouter(prefix="/api", tags=["events"]) - - -@router.get("/events") -async def events(request: Request, _: str = Depends(current_login)): - async def stream(): - q = await broker.subscribe() - try: - while True: - if await request.is_disconnected(): - break - try: - yield await asyncio.wait_for(q.get(), timeout=15) - except asyncio.TimeoutError: - yield ": ping\n\n" - finally: - await broker.unsubscribe(q) - - return StreamingResponse( - stream(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) +"""SSE-события (realtime) для фронтенда.""" +from __future__ import annotations + +import asyncio + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import StreamingResponse + +from ..auth import current_login +from ..sse import broker + +router = APIRouter(prefix="/api", tags=["events"]) + + +@router.get("/events") +async def events(request: Request, _: str = Depends(current_login)): + async def stream(): + q = await broker.subscribe() + try: + while True: + if await request.is_disconnected(): + break + try: + yield await asyncio.wait_for(q.get(), timeout=15) + except asyncio.TimeoutError: + yield ": ping\n\n" + finally: + await broker.unsubscribe(q) + + return StreamingResponse( + stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/archive/leadradar-legacy/backend/app/routers/ml_routes.py b/archive/leadradar-legacy/backend/app/routers/ml_routes.py index 9a964a7..3161736 100644 --- a/archive/leadradar-legacy/backend/app/routers/ml_routes.py +++ b/archive/leadradar-legacy/backend/app/routers/ml_routes.py @@ -1,171 +1,171 @@ -"""ML-лаборатория: статус, проверка на сообщении, обучение на канале. - -Всё обучение уходит в outbox (гарантированно), фоновый цикл отправляет его -в автономный ML-сервис. Использование ML в пайплайне — только по настройке -`mlEnabled`; здесь можно проверить модель и вручную разметить сообщения. -""" -from __future__ import annotations - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel - -from ..auth import current_login -from ..db import store -from ..services import leads as leads_svc -from ..services import ml_client -from ..services.telegram import tg - -router = APIRouter(prefix="/api/ml", tags=["ml"]) - - -class PredictBody(BaseModel): - text: str - - -class LearnBody(BaseModel): - text: str - label: str - - -class CandidatesBody(BaseModel): - dialogId: str - limit: int = 10 - - -class ApplyBody(BaseModel): - dialogId: str - msgId: int - action: str # 'spam' | 'board:' | 'skip' - - -def _msg_text(dialog_id: str, msg_id: int) -> str | None: - """Текст исходного сообщения (из карточки или из messages).""" - row = store.query_one( - "SELECT source_msg AS text FROM leads WHERE source_dialog_id = ? AND source_msg_id = ? LIMIT 1", - [dialog_id, msg_id], - ) - if row and row["text"]: - return row["text"] - m = store.query_one("SELECT text FROM messages WHERE id = ?", [f"m_{dialog_id}_{msg_id}"]) - return m["text"] if m else None - - -def _lead_by_msg(dialog_id: str, msg_id: int) -> dict | None: - row = store.query_one( - "SELECT id FROM leads WHERE source_dialog_id = ? AND source_msg_id = ? LIMIT 1", - [dialog_id, msg_id], - ) - if row: - return leads_svc.get_lead(row["id"]) - link = store.query_one("SELECT lead_id FROM messages WHERE id = ?", [f"m_{dialog_id}_{msg_id}"]) - if link and link["lead_id"]: - return leads_svc.get_lead(link["lead_id"]) - return None - - -@router.get("/status") -async def ml_status(_: str = Depends(current_login)) -> dict: - """Свежий статус ML-сервиса + локальная статистика (с принудительным refresh).""" - svc = await ml_client.refresh_status() - return { - "enabled": store.get_setting("mlEnabled") is not False, - "service": svc, - "reachable": ml_client._cached["reachable"], # noqa: SLF001 - "stats": ml_client.snapshot(), - } - - -@router.post("/reset") -async def ml_reset(_: str = Depends(current_login)) -> dict: - """Полный сброс ML-модели (классы/термины) + очистка очереди обучения.""" - return await ml_client.reset_model() - - -@router.post("/predict") -async def ml_predict(body: PredictBody, _: str = Depends(current_login)) -> dict: - text = (body.text or "").strip() - if len(text) < 2: - raise HTTPException(400, "Введите текст") - result = await ml_client.predict(text) - return {"text": text[:200], **result} - - -@router.post("/learn") -async def ml_learn(body: LearnBody, _: str = Depends(current_login)) -> dict: - """Ручная разметка: «это сообщение -> сюда». Пишется в outbox (всегда).""" - text = (body.text or "").strip() - label = (body.label or "").strip() - if not text or not label: - raise HTTPException(400, "text и label обязательны") - ml_client.push(text, label) - return {"ok": True, "outbox": ml_client.outbox_len()} - - -@router.post("/flush") -async def ml_flush(_: str = Depends(current_login)) -> dict: - """Отправить накопленное обучение в ML-сервис немедленно (обычно — фон раз в 10 c).""" - flushed = await ml_client.flush_outbox() - svc = await ml_client.refresh_status() - return {"ok": True, "flushed": flushed, "outbox": ml_client.outbox_len(), "service": svc} - - -@router.post("/candidates") -async def ml_candidates(body: CandidatesBody, _: str = Depends(current_login)) -> dict: - """Последние сообщения канала для разбора/обучения + мнение ML по каждому.""" - limit = max(1, min(body.limit, 60)) - items = await tg.dialog_messages(body.dialogId, limit) - out = [] - for m in items: - text = (m.get("text") or "").strip() - if not text: - continue - pred = await ml_client.predict(text) if ml_client.is_enabled() else { - "take": False, "label": None, "scores": {}, "ready": False} - out.append( - { - "id": m["id"], - "dialogId": body.dialogId, - "text": text[:600], - "time": m.get("time"), - "lead": bool(m.get("lead")), - "pred": {"take": pred.get("take"), "label": pred.get("label"), "scores": pred.get("scores", {})}, - } - ) - return {"items": out} - - -@router.post("/apply") -async def ml_apply(body: ApplyBody, _: str = Depends(current_login)) -> dict: - """Ручное решение по сообщению: учим ML и (если карточка есть) двигаем её.""" - text = _msg_text(body.dialogId, body.msgId) - if not text: - raise HTTPException(404, "Исходное сообщение не найдено") - action = body.action - if action == "skip": - return {"ok": True, "learned": False, "moved": None} - - lead = _lead_by_msg(body.dialogId, body.msgId) - result: dict = {"ok": True, "learned": False, "moved": None, "leadId": None} - - if action == "spam": - ml_client.push(text, "spam") - result["learned"] = True - if lead: - leads_svc.trash_lead(lead["id"], teach=False) - result["moved"] = "trash" - result["leadId"] = lead["id"] - elif action.startswith("board:"): - board_id = action.split(":", 1)[1] - if board_id != "inbox" and store.query_one("SELECT 1 FROM boards WHERE id = ?", [board_id]) is None: - raise HTTPException(400, "Неизвестная доска") - ml_client.push(text, board_id) - result["learned"] = True - if lead: - # повторная разметка карточки, которая уже на доске, — просто обучение - if lead["col"] != board_id: - leads_svc.move_lead(lead["id"], board_id, teach=False) - result["moved"] = board_id - result["leadId"] = lead["id"] - else: - raise HTTPException(400, "Неизвестное действие") - return result +"""ML-лаборатория: статус, проверка на сообщении, обучение на канале. + +Всё обучение уходит в outbox (гарантированно), фоновый цикл отправляет его +в автономный ML-сервис. Использование ML в пайплайне — только по настройке +`mlEnabled`; здесь можно проверить модель и вручную разметить сообщения. +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from ..auth import current_login +from ..db import store +from ..services import leads as leads_svc +from ..services import ml_client +from ..services.telegram import tg + +router = APIRouter(prefix="/api/ml", tags=["ml"]) + + +class PredictBody(BaseModel): + text: str + + +class LearnBody(BaseModel): + text: str + label: str + + +class CandidatesBody(BaseModel): + dialogId: str + limit: int = 10 + + +class ApplyBody(BaseModel): + dialogId: str + msgId: int + action: str # 'spam' | 'board:' | 'skip' + + +def _msg_text(dialog_id: str, msg_id: int) -> str | None: + """Текст исходного сообщения (из карточки или из messages).""" + row = store.query_one( + "SELECT source_msg AS text FROM leads WHERE source_dialog_id = ? AND source_msg_id = ? LIMIT 1", + [dialog_id, msg_id], + ) + if row and row["text"]: + return row["text"] + m = store.query_one("SELECT text FROM messages WHERE id = ?", [f"m_{dialog_id}_{msg_id}"]) + return m["text"] if m else None + + +def _lead_by_msg(dialog_id: str, msg_id: int) -> dict | None: + row = store.query_one( + "SELECT id FROM leads WHERE source_dialog_id = ? AND source_msg_id = ? LIMIT 1", + [dialog_id, msg_id], + ) + if row: + return leads_svc.get_lead(row["id"]) + link = store.query_one("SELECT lead_id FROM messages WHERE id = ?", [f"m_{dialog_id}_{msg_id}"]) + if link and link["lead_id"]: + return leads_svc.get_lead(link["lead_id"]) + return None + + +@router.get("/status") +async def ml_status(_: str = Depends(current_login)) -> dict: + """Свежий статус ML-сервиса + локальная статистика (с принудительным refresh).""" + svc = await ml_client.refresh_status() + return { + "enabled": store.get_setting("mlEnabled") is not False, + "service": svc, + "reachable": ml_client._cached["reachable"], # noqa: SLF001 + "stats": ml_client.snapshot(), + } + + +@router.post("/reset") +async def ml_reset(_: str = Depends(current_login)) -> dict: + """Полный сброс ML-модели (классы/термины) + очистка очереди обучения.""" + return await ml_client.reset_model() + + +@router.post("/predict") +async def ml_predict(body: PredictBody, _: str = Depends(current_login)) -> dict: + text = (body.text or "").strip() + if len(text) < 2: + raise HTTPException(400, "Введите текст") + result = await ml_client.predict(text) + return {"text": text[:200], **result} + + +@router.post("/learn") +async def ml_learn(body: LearnBody, _: str = Depends(current_login)) -> dict: + """Ручная разметка: «это сообщение -> сюда». Пишется в outbox (всегда).""" + text = (body.text or "").strip() + label = (body.label or "").strip() + if not text or not label: + raise HTTPException(400, "text и label обязательны") + ml_client.push(text, label) + return {"ok": True, "outbox": ml_client.outbox_len()} + + +@router.post("/flush") +async def ml_flush(_: str = Depends(current_login)) -> dict: + """Отправить накопленное обучение в ML-сервис немедленно (обычно — фон раз в 10 c).""" + flushed = await ml_client.flush_outbox() + svc = await ml_client.refresh_status() + return {"ok": True, "flushed": flushed, "outbox": ml_client.outbox_len(), "service": svc} + + +@router.post("/candidates") +async def ml_candidates(body: CandidatesBody, _: str = Depends(current_login)) -> dict: + """Последние сообщения канала для разбора/обучения + мнение ML по каждому.""" + limit = max(1, min(body.limit, 60)) + items = await tg.dialog_messages(body.dialogId, limit) + out = [] + for m in items: + text = (m.get("text") or "").strip() + if not text: + continue + pred = await ml_client.predict(text) if ml_client.is_enabled() else { + "take": False, "label": None, "scores": {}, "ready": False} + out.append( + { + "id": m["id"], + "dialogId": body.dialogId, + "text": text[:600], + "time": m.get("time"), + "lead": bool(m.get("lead")), + "pred": {"take": pred.get("take"), "label": pred.get("label"), "scores": pred.get("scores", {})}, + } + ) + return {"items": out} + + +@router.post("/apply") +async def ml_apply(body: ApplyBody, _: str = Depends(current_login)) -> dict: + """Ручное решение по сообщению: учим ML и (если карточка есть) двигаем её.""" + text = _msg_text(body.dialogId, body.msgId) + if not text: + raise HTTPException(404, "Исходное сообщение не найдено") + action = body.action + if action == "skip": + return {"ok": True, "learned": False, "moved": None} + + lead = _lead_by_msg(body.dialogId, body.msgId) + result: dict = {"ok": True, "learned": False, "moved": None, "leadId": None} + + if action == "spam": + ml_client.push(text, "spam") + result["learned"] = True + if lead: + leads_svc.trash_lead(lead["id"], teach=False) + result["moved"] = "trash" + result["leadId"] = lead["id"] + elif action.startswith("board:"): + board_id = action.split(":", 1)[1] + if board_id != "inbox" and store.query_one("SELECT 1 FROM boards WHERE id = ?", [board_id]) is None: + raise HTTPException(400, "Неизвестная доска") + ml_client.push(text, board_id) + result["learned"] = True + if lead: + # повторная разметка карточки, которая уже на доске, — просто обучение + if lead["col"] != board_id: + leads_svc.move_lead(lead["id"], board_id, teach=False) + result["moved"] = board_id + result["leadId"] = lead["id"] + else: + raise HTTPException(400, "Неизвестное действие") + return result diff --git a/archive/leadradar-legacy/backend/app/routers/processing_routes.py b/archive/leadradar-legacy/backend/app/routers/processing_routes.py index 8678106..e8c7e33 100644 --- a/archive/leadradar-legacy/backend/app/routers/processing_routes.py +++ b/archive/leadradar-legacy/backend/app/routers/processing_routes.py @@ -1,74 +1,74 @@ -"""Мониторинг пайплайна — вкладка «Обработка» (очередь и отсев).""" -from __future__ import annotations - -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel - -from ..auth import current_login -from ..services import processing as processing_svc - -router = APIRouter(prefix="/api/pipeline", tags=["processing"]) - - -class ReturnBody(BaseModel): - reason: str = "" - - -@router.get("/stats") -def stats(_: str = Depends(current_login)) -> dict: - """Сводка: размер очереди (new/ai) и число записей в отсеве.""" - return processing_svc.stats() - - -@router.get("/queue") -def queue(limit: int = 100, _: str = Depends(current_login)) -> dict: - """Сырые сообщения, ожидающие обработки (этап 1 или ИИ).""" - q = processing_svc.queue_counts() - return { - "items": processing_svc.list_queue(limit), - "counts": q, - "rejected": processing_svc.rejected_count(), - } - - -@router.get("/rejected") -def rejected( - q: str = "", - offset: int = 0, - limit: int = 100, - _: str = Depends(current_login), -) -> dict: - """Отсев: что и почему не прошло пайплайн (полнотекстовый поиск по q).""" - return processing_svc.list_rejected(q, offset, limit) - - -@router.post("/rejected/clear") -def rejected_clear(_: str = Depends(current_login)) -> dict: - """Ручная полная очистка отсева (безвозвратно).""" - cleared = processing_svc.clear_all() - return {"ok": True, "cleared": cleared} - - -@router.delete("/rejected/{rej_id}") -def rejected_delete(rej_id: str, _: str = Depends(current_login)) -> dict: - processing_svc.delete_one(rej_id) - return {"ok": True} - - -@router.post("/rejected/{rej_id}/return") -def rejected_return(rej_id: str, body: ReturnBody, _: str = Depends(current_login)) -> dict: - """Вернуть отсеянное сообщение в обработку. - - Для возвращённого сообщения причины отсева (стоп-лист, резюме, тип, - без суммы, устарело, ML/ИИ-спам) игнорируются: оно уходит на ML/ИИ - и создаёт карточку. ML обучается на действии (снятие «спама»), а решение - ИИ по возвращённому сообщению снова учит ML. Запись в отсеве помечается - «возвращено» с указанной пользователем причиной. - """ - try: - out = processing_svc.return_to_queue(rej_id, body.reason) - except KeyError as exc: - raise HTTPException(404, "Запись не найдена") from exc - except ValueError as exc: - raise HTTPException(400, str(exc)) from exc - return out +"""Мониторинг пайплайна — вкладка «Обработка» (очередь и отсев).""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from ..auth import current_login +from ..services import processing as processing_svc + +router = APIRouter(prefix="/api/pipeline", tags=["processing"]) + + +class ReturnBody(BaseModel): + reason: str = "" + + +@router.get("/stats") +def stats(_: str = Depends(current_login)) -> dict: + """Сводка: размер очереди (new/ai) и число записей в отсеве.""" + return processing_svc.stats() + + +@router.get("/queue") +def queue(limit: int = 100, _: str = Depends(current_login)) -> dict: + """Сырые сообщения, ожидающие обработки (этап 1 или ИИ).""" + q = processing_svc.queue_counts() + return { + "items": processing_svc.list_queue(limit), + "counts": q, + "rejected": processing_svc.rejected_count(), + } + + +@router.get("/rejected") +def rejected( + q: str = "", + offset: int = 0, + limit: int = 100, + _: str = Depends(current_login), +) -> dict: + """Отсев: что и почему не прошло пайплайн (полнотекстовый поиск по q).""" + return processing_svc.list_rejected(q, offset, limit) + + +@router.post("/rejected/clear") +def rejected_clear(_: str = Depends(current_login)) -> dict: + """Ручная полная очистка отсева (безвозвратно).""" + cleared = processing_svc.clear_all() + return {"ok": True, "cleared": cleared} + + +@router.delete("/rejected/{rej_id}") +def rejected_delete(rej_id: str, _: str = Depends(current_login)) -> dict: + processing_svc.delete_one(rej_id) + return {"ok": True} + + +@router.post("/rejected/{rej_id}/return") +def rejected_return(rej_id: str, body: ReturnBody, _: str = Depends(current_login)) -> dict: + """Вернуть отсеянное сообщение в обработку. + + Для возвращённого сообщения причины отсева (стоп-лист, резюме, тип, + без суммы, устарело, ML/ИИ-спам) игнорируются: оно уходит на ML/ИИ + и создаёт карточку. ML обучается на действии (снятие «спама»), а решение + ИИ по возвращённому сообщению снова учит ML. Запись в отсеве помечается + «возвращено» с указанной пользователем причиной. + """ + try: + out = processing_svc.return_to_queue(rej_id, body.reason) + except KeyError as exc: + raise HTTPException(404, "Запись не найдена") from exc + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return out diff --git a/archive/leadradar-legacy/backend/app/routers/projects_routes.py b/archive/leadradar-legacy/backend/app/routers/projects_routes.py index 72ed841..7b06ec4 100644 --- a/archive/leadradar-legacy/backend/app/routers/projects_routes.py +++ b/archive/leadradar-legacy/backend/app/routers/projects_routes.py @@ -1,211 +1,211 @@ -"""«Выбранные»: проектные карточки, стадии, история, напоминания, вложения.""" -from __future__ import annotations - -from fastapi import APIRouter, Depends, HTTPException, UploadFile -from fastapi.responses import StreamingResponse -from pydantic import BaseModel - -from ..auth import current_login -from ..services import files as files_svc -from ..services import object_store -from ..services import projects as proj_svc - -router = APIRouter(prefix="/api/projects", tags=["projects"]) - - -class TakeBody(BaseModel): - leadId: str - - -class CreateBody(BaseModel): - title: str = "" - summary: str = "" - stack: list[str] | None = None - budget: dict | None = None - contact: str = "" - tzText: str = "" - stage: str | None = None - - -class PatchBody(BaseModel): - title: str | None = None - summary: str | None = None - stack: list[str] | None = None - budget: dict | None = None - contact: str | None = None - tzText: str | None = None - - -class StageBody(BaseModel): - stage: str - - -class CommentBody(BaseModel): - text: str - - -class LinkBody(BaseModel): - name: str = "" - url: str - - -class ReminderBody(BaseModel): - at: int # epoch ms - - -def _card_or_404(card_id: str) -> dict: - card = proj_svc.get_card(card_id) - if not card: - raise HTTPException(404, "Карточка не найдена") - return card - - -@router.get("") -def list_cards(stage: str | None = None, _: str = Depends(current_login)) -> dict: - return {"items": proj_svc.list_cards(stage)} - - -@router.get("/reminders") -def reminders(_: str = Depends(current_login)) -> dict: - return {"items": proj_svc.active_reminders()} - - -@router.get("/{card_id}") -def get_card(card_id: str, _: str = Depends(current_login)) -> dict: - return _card_or_404(card_id) - - -@router.post("") -def create_card(body: CreateBody, _: str = Depends(current_login)) -> dict: - return proj_svc.create_local_card(body.model_dump()) - - -@router.post("/take") -def take(body: TakeBody, _: str = Depends(current_login)) -> dict: - """«Взять в работу» — лид безвозвратно уходит с дашборда.""" - try: - return proj_svc.take_lead_to_projects(body.leadId) - except KeyError: - raise HTTPException(404, "Лид не найден") - - -@router.post("/clear-rejected") -def clear_rejected(_: str = Depends(current_login)) -> dict: - """Полная очистка стадии «Отклонено» (безвозвратно).""" - try: - cleared = proj_svc.clear_stage("rejected") - except ValueError as exc: - raise HTTPException(400, str(exc)) from exc - return {"ok": True, "cleared": cleared} - - -@router.patch("/{card_id}") -def patch(card_id: str, body: PatchBody, _: str = Depends(current_login)) -> dict: - _card_or_404(card_id) - return proj_svc.patch_card(card_id, body.model_dump(exclude_none=True)) - - -@router.delete("/{card_id}") -def delete(card_id: str, _: str = Depends(current_login)) -> dict: - # По решению удаление проектных карточек не делаем — карточка живёт - # до финального статуса «Выполнено»/«Отклонено». - raise HTTPException(400, "Удаление проектных карточек отключено") - - -@router.post("/{card_id}/move") -def move(card_id: str, body: StageBody, _: str = Depends(current_login)) -> dict: - _card_or_404(card_id) - try: - return proj_svc.move_stage(card_id, body.stage) - except ValueError as exc: - raise HTTPException(400, str(exc)) from exc - - -@router.post("/{card_id}/comments") -def comment(card_id: str, body: CommentBody, _: str = Depends(current_login)) -> dict: - if not body.text.strip(): - raise HTTPException(400, "Пустой комментарий") - return {"comments": proj_svc.add_comment(card_id, body.text)} - - -# ─── Ссылки ─────────────────────────────────────────────────────────────── - -@router.post("/{card_id}/links") -def add_link(card_id: str, body: LinkBody, _: str = Depends(current_login)) -> dict: - _card_or_404(card_id) - url = body.url.strip() - if not url: - raise HTTPException(400, "Пустая ссылка") - if not url.startswith(("http://", "https://")): - url = "https://" + url - card = _card_or_404(card_id) - links = card["links"] + [{"id": f"pl_{card_id[:6]}_{len(card['links'])}", "name": body.name.strip() or url, "url": url}] - return proj_svc.patch_card(card_id, {"links": links}) - - -@router.delete("/{card_id}/links/{link_id}") -def remove_link(card_id: str, link_id: str, _: str = Depends(current_login)) -> dict: - card = _card_or_404(card_id) - links = [l for l in card["links"] if l["id"] != link_id] - return proj_svc.patch_card(card_id, {"links": links}) - - -# ─── Вложения (мета; MinIO позже) ───────────────────────────────────────── - -@router.post("/{card_id}/files") -async def upload_files(card_id: str, files: list[UploadFile], _: str = Depends(current_login)) -> dict: - _card_or_404(card_id) - added = [] - for f in files: - content = await f.read() - added.append(files_svc.add_file(card_id, f.filename or "file", content, f.content_type or "")) - return {"items": added} - - -@router.get("/{card_id}/files/{file_id}/download") -def download_file(card_id: str, file_id: str, _: str = Depends(current_login)): - entry, _ = files_svc.get_file_entry(card_id, file_id) - if not entry.get("objectKey"): - raise HTTPException(410, "Файл не сохранён в объектном хранилище") - try: - stream = object_store.get(entry["objectKey"]) - except Exception as exc: # noqa: BLE001 - raise HTTPException(404, "Файл не найден в MinIO") from exc - safe_name = entry["name"].replace('"', "") - return StreamingResponse( - stream, - media_type="application/octet-stream", - headers={"Content-Disposition": f'attachment; filename="{safe_name}"'}, - ) - - -@router.delete("/{card_id}/files/{file_id}") -def remove_file(card_id: str, file_id: str, _: str = Depends(current_login)) -> dict: - _card_or_404(card_id) - files_svc.remove_file(card_id, file_id) - return {"ok": True} - - -# ─── Напоминания об «Отложено» ──────────────────────────────────────────── - -@router.post("/{card_id}/reminder") -def set_reminder(card_id: str, body: ReminderBody, _: str = Depends(current_login)) -> dict: - _card_or_404(card_id) - try: - return proj_svc.set_reminder(card_id, body.at) - except PermissionError as exc: - raise HTTPException(400, str(exc)) from exc - - -@router.delete("/{card_id}/reminder") -def clear_reminder(card_id: str, _: str = Depends(current_login)) -> dict: - _card_or_404(card_id) - proj_svc.clear_reminder(card_id) - return {"ok": True} - - -@router.post("/{card_id}/reminder/snooze") -def snooze(card_id: str, _: str = Depends(current_login)) -> dict: - _card_or_404(card_id) - proj_svc.snooze(card_id) - return {"ok": True} +"""«Выбранные»: проектные карточки, стадии, история, напоминания, вложения.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, UploadFile +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from ..auth import current_login +from ..services import files as files_svc +from ..services import object_store +from ..services import projects as proj_svc + +router = APIRouter(prefix="/api/projects", tags=["projects"]) + + +class TakeBody(BaseModel): + leadId: str + + +class CreateBody(BaseModel): + title: str = "" + summary: str = "" + stack: list[str] | None = None + budget: dict | None = None + contact: str = "" + tzText: str = "" + stage: str | None = None + + +class PatchBody(BaseModel): + title: str | None = None + summary: str | None = None + stack: list[str] | None = None + budget: dict | None = None + contact: str | None = None + tzText: str | None = None + + +class StageBody(BaseModel): + stage: str + + +class CommentBody(BaseModel): + text: str + + +class LinkBody(BaseModel): + name: str = "" + url: str + + +class ReminderBody(BaseModel): + at: int # epoch ms + + +def _card_or_404(card_id: str) -> dict: + card = proj_svc.get_card(card_id) + if not card: + raise HTTPException(404, "Карточка не найдена") + return card + + +@router.get("") +def list_cards(stage: str | None = None, _: str = Depends(current_login)) -> dict: + return {"items": proj_svc.list_cards(stage)} + + +@router.get("/reminders") +def reminders(_: str = Depends(current_login)) -> dict: + return {"items": proj_svc.active_reminders()} + + +@router.get("/{card_id}") +def get_card(card_id: str, _: str = Depends(current_login)) -> dict: + return _card_or_404(card_id) + + +@router.post("") +def create_card(body: CreateBody, _: str = Depends(current_login)) -> dict: + return proj_svc.create_local_card(body.model_dump()) + + +@router.post("/take") +def take(body: TakeBody, _: str = Depends(current_login)) -> dict: + """«Взять в работу» — лид безвозвратно уходит с дашборда.""" + try: + return proj_svc.take_lead_to_projects(body.leadId) + except KeyError: + raise HTTPException(404, "Лид не найден") + + +@router.post("/clear-rejected") +def clear_rejected(_: str = Depends(current_login)) -> dict: + """Полная очистка стадии «Отклонено» (безвозвратно).""" + try: + cleared = proj_svc.clear_stage("rejected") + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return {"ok": True, "cleared": cleared} + + +@router.patch("/{card_id}") +def patch(card_id: str, body: PatchBody, _: str = Depends(current_login)) -> dict: + _card_or_404(card_id) + return proj_svc.patch_card(card_id, body.model_dump(exclude_none=True)) + + +@router.delete("/{card_id}") +def delete(card_id: str, _: str = Depends(current_login)) -> dict: + # По решению удаление проектных карточек не делаем — карточка живёт + # до финального статуса «Выполнено»/«Отклонено». + raise HTTPException(400, "Удаление проектных карточек отключено") + + +@router.post("/{card_id}/move") +def move(card_id: str, body: StageBody, _: str = Depends(current_login)) -> dict: + _card_or_404(card_id) + try: + return proj_svc.move_stage(card_id, body.stage) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + + +@router.post("/{card_id}/comments") +def comment(card_id: str, body: CommentBody, _: str = Depends(current_login)) -> dict: + if not body.text.strip(): + raise HTTPException(400, "Пустой комментарий") + return {"comments": proj_svc.add_comment(card_id, body.text)} + + +# ─── Ссылки ─────────────────────────────────────────────────────────────── + +@router.post("/{card_id}/links") +def add_link(card_id: str, body: LinkBody, _: str = Depends(current_login)) -> dict: + _card_or_404(card_id) + url = body.url.strip() + if not url: + raise HTTPException(400, "Пустая ссылка") + if not url.startswith(("http://", "https://")): + url = "https://" + url + card = _card_or_404(card_id) + links = card["links"] + [{"id": f"pl_{card_id[:6]}_{len(card['links'])}", "name": body.name.strip() or url, "url": url}] + return proj_svc.patch_card(card_id, {"links": links}) + + +@router.delete("/{card_id}/links/{link_id}") +def remove_link(card_id: str, link_id: str, _: str = Depends(current_login)) -> dict: + card = _card_or_404(card_id) + links = [l for l in card["links"] if l["id"] != link_id] + return proj_svc.patch_card(card_id, {"links": links}) + + +# ─── Вложения (мета; MinIO позже) ───────────────────────────────────────── + +@router.post("/{card_id}/files") +async def upload_files(card_id: str, files: list[UploadFile], _: str = Depends(current_login)) -> dict: + _card_or_404(card_id) + added = [] + for f in files: + content = await f.read() + added.append(files_svc.add_file(card_id, f.filename or "file", content, f.content_type or "")) + return {"items": added} + + +@router.get("/{card_id}/files/{file_id}/download") +def download_file(card_id: str, file_id: str, _: str = Depends(current_login)): + entry, _ = files_svc.get_file_entry(card_id, file_id) + if not entry.get("objectKey"): + raise HTTPException(410, "Файл не сохранён в объектном хранилище") + try: + stream = object_store.get(entry["objectKey"]) + except Exception as exc: # noqa: BLE001 + raise HTTPException(404, "Файл не найден в MinIO") from exc + safe_name = entry["name"].replace('"', "") + return StreamingResponse( + stream, + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{safe_name}"'}, + ) + + +@router.delete("/{card_id}/files/{file_id}") +def remove_file(card_id: str, file_id: str, _: str = Depends(current_login)) -> dict: + _card_or_404(card_id) + files_svc.remove_file(card_id, file_id) + return {"ok": True} + + +# ─── Напоминания об «Отложено» ──────────────────────────────────────────── + +@router.post("/{card_id}/reminder") +def set_reminder(card_id: str, body: ReminderBody, _: str = Depends(current_login)) -> dict: + _card_or_404(card_id) + try: + return proj_svc.set_reminder(card_id, body.at) + except PermissionError as exc: + raise HTTPException(400, str(exc)) from exc + + +@router.delete("/{card_id}/reminder") +def clear_reminder(card_id: str, _: str = Depends(current_login)) -> dict: + _card_or_404(card_id) + proj_svc.clear_reminder(card_id) + return {"ok": True} + + +@router.post("/{card_id}/reminder/snooze") +def snooze(card_id: str, _: str = Depends(current_login)) -> dict: + _card_or_404(card_id) + proj_svc.snooze(card_id) + return {"ok": True} diff --git a/archive/leadradar-legacy/backend/app/routers/settings_routes.py b/archive/leadradar-legacy/backend/app/routers/settings_routes.py index 69593f5..2c7c494 100644 --- a/archive/leadradar-legacy/backend/app/routers/settings_routes.py +++ b/archive/leadradar-legacy/backend/app/routers/settings_routes.py @@ -1,243 +1,243 @@ -"""Настройки, AI-провайдеры, курсы валют (роутер /api).""" -from __future__ import annotations - -import asyncio -import json - -import httpx -from fastapi import APIRouter, Depends, HTTPException - -from .. import constants as C -from ..auth import current_login -from ..crypto import decrypt_text, encrypt_text -from ..db import store -from ..services import ai as ai_svc -from ..services import rates as rates_svc -from ..services.telegram import tg - -router = APIRouter(prefix="/api", tags=["settings"]) - -# Какие настройки видны наружу (без секретов). Значения секретов маскируются. -_PUBLIC_INT = {"archiveAfterDays", "archiveClearDays", "trashClearDays", "minLen", "discJoinLimit", "discJoinDelayMin", "discJoinDelayMax", "discEvalSample", "discEvalThreshold"} -_PUBLIC_BOOL = {"autoArchive", "aiFilterEnabled", "aiEnabled", "conversionOn", "remindersEnabled", "mlEnabled", "blockResumes", "budgetRequiredHire", "budgetRequiredOrder", "autoMonitorNew", "discPaused"} -_PUBLIC_STR = {"targetCurrency", "rateSource", "aiProvider", "aiPrompt", "aiFilterPrompt", "cardPrompt", "domainDescription", "wantedType", "hireLabel", "orderLabel"} -_PUBLIC_LIST = {"stopPhrases", "domainKeywords", "hireMarkers", "levelTerms", "resumeMarkers"} -_PUBLIC_DICT = {"colState"} - - -def mask(value: str) -> str: - if not value: - return "" - return value if len(value) <= 8 else f"{value[:4]}…{value[-4:]}" - - -def public_settings() -> dict: - out: dict = {} - allset = store.all_settings() - for k in _PUBLIC_INT: - out[k] = int(allset.get(k, 0)) - for k in _PUBLIC_BOOL: - out[k] = bool(allset.get(k)) - for k in _PUBLIC_STR: - out[k] = allset.get(k, "") - for k in _PUBLIC_LIST: - out[k] = allset.get(k, []) - for k in _PUBLIC_DICT: - out[k] = allset.get(k, {}) - out["myPrompts"] = allset.get("myPrompts", []) - - tg_keys = store.get_setting("tgKeys") or {} - api_hash = str(tg_keys.get("apiHash", "")) - out["tgKeys"] = { - "apiId": mask(str(tg_keys.get("apiId", ""))), - "apiHashSet": bool(decrypt_text(api_hash)) if api_hash.startswith("enc:") else bool(api_hash), - } - cfg = store.get_setting("aiConfigs") or {} - out["aiConfigs"] = {} - for pid, conf in cfg.items(): - raw_key = str(conf.get("apiKey", "")) - plain_key = decrypt_text(raw_key) if raw_key.startswith("enc:") else raw_key - out["aiConfigs"][pid] = { - "baseUrl": conf.get("baseUrl", ""), - "model": conf.get("model", ""), - "keySet": bool(plain_key), - "keyMasked": mask(plain_key), - } - out["providers"] = C.AI_PROVIDERS - return out - - -@router.get("/settings") -def get_settings(_: str = Depends(current_login)) -> dict: - return public_settings() - - -@router.patch("/settings") -async def patch_settings(body: dict, _: str = Depends(current_login)) -> dict: - current = store.all_settings() - # инвариант пауз авто-вступлений: если пришли оба конца интервала — клампы - # (5..600) и min <= max (если нет — меняем местами, как делает фронт); если - # пришёл один конец — клампим его относительно сохранённого другого конца - delay_min, delay_max = body.get("discJoinDelayMin"), body.get("discJoinDelayMax") - if delay_min is not None and delay_max is not None: - try: - dmin, dmax = int(delay_min), int(delay_max) - except (TypeError, ValueError): - pass - else: - dmin = max(5, min(600, dmin)) - dmax = max(5, min(600, dmax)) - if dmin > dmax: - dmin, dmax = dmax, dmin - body["discJoinDelayMin"] = dmin - body["discJoinDelayMax"] = dmax - elif delay_min is not None: - try: - cur_max = int(current.get("discJoinDelayMax") or 0) - value = max(5, min(600, int(delay_min))) - except (TypeError, ValueError): - pass - else: - body["discJoinDelayMin"] = min(value, cur_max) if cur_max >= 5 else value - elif delay_max is not None: - try: - cur_min = int(current.get("discJoinDelayMin") or 0) - value = max(5, min(600, int(delay_max))) - except (TypeError, ValueError): - pass - else: - body["discJoinDelayMax"] = max(value, cur_min) if cur_min <= 600 else value - for key, value in body.items(): - if key in _PUBLIC_INT: - try: - value = int(value) - except (TypeError, ValueError): - continue - if key == "archiveAfterDays": - value = max(1, min(30, value)) - if key == "minLen": - value = max(10, min(500, value)) - if key == "discJoinLimit": - value = max(1, min(200, value)) - if key in {"discJoinDelayMin", "discJoinDelayMax"}: - value = max(5, min(600, value)) - if key == "discEvalSample": - value = max(3, min(30, value)) - if key == "discEvalThreshold": - value = max(1, min(100, value)) - store.set_setting(key, value) - elif key in _PUBLIC_BOOL: - store.set_setting(key, bool(value)) - elif key in _PUBLIC_STR: - if key in {"targetCurrency"}: - value = str(value).upper() - if key == "aiProvider" and not any(p["id"] == value for p in C.AI_PROVIDERS): - continue - store.set_setting(key, str(value)) - elif key in _PUBLIC_LIST: - if isinstance(value, list): - store.set_setting(key, [str(x) for x in value][:200]) - elif key in _PUBLIC_DICT: - if isinstance(value, dict): - store.set_setting(key, value) - elif key == "myPrompts" and isinstance(value, list): - clean: list[dict] = [] - for item in value[:100]: - if not isinstance(item, dict): - continue - name = str(item.get("name") or "").strip()[:80] - prompt = str(item.get("prompt") or "").strip()[:8000] - if not name or not prompt: - continue - clean.append( - { - "id": str(item.get("id") or "")[:40] or store.uid("pp_"), - "name": name, - "description": str(item.get("description") or "").strip()[:300], - "prompt": prompt, - } - ) - store.set_setting("myPrompts", clean) - elif key == "aiConfigs" and isinstance(value, dict): - cfg = current.get("aiConfigs") or {} - for pid, conf in value.items(): - if pid not in cfg: - continue - entry = dict(cfg[pid]) - if isinstance(conf, dict): - for fk in ("baseUrl", "model"): - if fk in conf and conf[fk] is not None: - entry[fk] = str(conf[fk]) - new_key = conf.get("apiKey") - if new_key and not str(new_key).startswith(("enc:",)) and len(str(new_key)) >= 8: - entry["apiKey"] = encrypt_text(str(new_key)) - cfg[pid] = entry - store.set_setting("aiConfigs", cfg) - elif key == "tgKeys" and isinstance(value, dict): - keys = current.get("tgKeys") or {} - if value.get("apiId") is not None: - api = str(value["apiId"]).strip() - if api.isdigit() and 5 < len(api) < 10: - keys["apiId"] = api - new_hash = value.get("apiHash") - if new_hash and len(str(new_hash)) >= 16 and not str(new_hash).startswith("enc:"): - keys["apiHash"] = encrypt_text(str(new_hash).strip()) - store.set_setting("tgKeys", keys) - # смена источника курсов — обновляем в фоне; смена целевой валюты/конвертации — - # пересчёт старых карточек (кроме архива/корзины) - if body.get("rateSource"): - asyncio.get_running_loop().create_task(rates_svc.refresh_rates()) - if body.get("targetCurrency") is not None or body.get("conversionOn") is not None: - rates_svc.recompute_conversions() - return public_settings() - - -@router.post("/ai/check") -async def ai_check(_: str = Depends(current_login)) -> dict: - status = ai_svc.provider_status() - provider_id, data = ai_svc._cfg() - meta = data["meta"] - if meta.get("local"): - return {**status, "ok": True, "message": f"Локальный сервер «{meta['name']}» (ping в проде)"} - key = data["cfg"].get("apiKey") - if not key: - return {**status, "ok": False, "message": "Не задан API-ключ"} - # Лёгкая проверка: GET /models (для OpenAI-совместимых) или /v1/models (Anthropic) - base = str(data["cfg"].get("baseUrl") or meta["base"]).rstrip("/") - url = base + ("/v1/models" if meta.get("api_style") == "anthropic" else "/models") - headers = {"Authorization": f"Bearer {key}"} if meta.get("api_style") != "anthropic" else { - "x-api-key": key, "anthropic-version": "2023-06-01"} - try: - async with httpx.AsyncClient(timeout=12) as client: - resp = await client.get(url, headers=headers) - if resp.status_code < 400: - return {**status, "ok": True, "message": "Подключение успешно"} - if resp.status_code in (401, 403): - return {**status, "ok": False, "message": f"Ключ не принят (HTTP {resp.status_code}) — проверьте ключ и доступ к модели"} - return {**status, "ok": False, "message": f"HTTP {resp.status_code} — проверьте Base URL и модель"} - except Exception as exc: # noqa: BLE001 - return {**status, "ok": False, "message": f"Ошибка соединения: {exc}"} - - -# ─── Курсы ──────────────────────────────────────────────────────────────── - -@router.get("/rates") -def get_rates(_: str = Depends(current_login)) -> dict: - return rates_svc.get_rates() - - -@router.post("/rates/refresh") -async def refresh_rates(_: str = Depends(current_login)) -> dict: - ok = await rates_svc.refresh_rates() - return {"ok": ok, "rates": rates_svc.get_rates()} - - -# ─── Методанные для фронта ──────────────────────────────────────────────── - -@router.get("/meta/constants") -def meta_constants(_: str = Depends(current_login)) -> dict: - return { - "currencies": C.CURRENCIES, - "stages": C.PIPELINE_STAGES, - "palette": C.PALETTE, - } +"""Настройки, AI-провайдеры, курсы валют (роутер /api).""" +from __future__ import annotations + +import asyncio +import json + +import httpx +from fastapi import APIRouter, Depends, HTTPException + +from .. import constants as C +from ..auth import current_login +from ..crypto import decrypt_text, encrypt_text +from ..db import store +from ..services import ai as ai_svc +from ..services import rates as rates_svc +from ..services.telegram import tg + +router = APIRouter(prefix="/api", tags=["settings"]) + +# Какие настройки видны наружу (без секретов). Значения секретов маскируются. +_PUBLIC_INT = {"archiveAfterDays", "archiveClearDays", "trashClearDays", "minLen", "discJoinLimit", "discJoinDelayMin", "discJoinDelayMax", "discEvalSample", "discEvalThreshold"} +_PUBLIC_BOOL = {"autoArchive", "aiFilterEnabled", "aiEnabled", "conversionOn", "remindersEnabled", "mlEnabled", "blockResumes", "budgetRequiredHire", "budgetRequiredOrder", "autoMonitorNew", "discPaused"} +_PUBLIC_STR = {"targetCurrency", "rateSource", "aiProvider", "aiPrompt", "aiFilterPrompt", "cardPrompt", "domainDescription", "wantedType", "hireLabel", "orderLabel"} +_PUBLIC_LIST = {"stopPhrases", "domainKeywords", "hireMarkers", "levelTerms", "resumeMarkers"} +_PUBLIC_DICT = {"colState"} + + +def mask(value: str) -> str: + if not value: + return "" + return value if len(value) <= 8 else f"{value[:4]}…{value[-4:]}" + + +def public_settings() -> dict: + out: dict = {} + allset = store.all_settings() + for k in _PUBLIC_INT: + out[k] = int(allset.get(k, 0)) + for k in _PUBLIC_BOOL: + out[k] = bool(allset.get(k)) + for k in _PUBLIC_STR: + out[k] = allset.get(k, "") + for k in _PUBLIC_LIST: + out[k] = allset.get(k, []) + for k in _PUBLIC_DICT: + out[k] = allset.get(k, {}) + out["myPrompts"] = allset.get("myPrompts", []) + + tg_keys = store.get_setting("tgKeys") or {} + api_hash = str(tg_keys.get("apiHash", "")) + out["tgKeys"] = { + "apiId": mask(str(tg_keys.get("apiId", ""))), + "apiHashSet": bool(decrypt_text(api_hash)) if api_hash.startswith("enc:") else bool(api_hash), + } + cfg = store.get_setting("aiConfigs") or {} + out["aiConfigs"] = {} + for pid, conf in cfg.items(): + raw_key = str(conf.get("apiKey", "")) + plain_key = decrypt_text(raw_key) if raw_key.startswith("enc:") else raw_key + out["aiConfigs"][pid] = { + "baseUrl": conf.get("baseUrl", ""), + "model": conf.get("model", ""), + "keySet": bool(plain_key), + "keyMasked": mask(plain_key), + } + out["providers"] = C.AI_PROVIDERS + return out + + +@router.get("/settings") +def get_settings(_: str = Depends(current_login)) -> dict: + return public_settings() + + +@router.patch("/settings") +async def patch_settings(body: dict, _: str = Depends(current_login)) -> dict: + current = store.all_settings() + # инвариант пауз авто-вступлений: если пришли оба конца интервала — клампы + # (5..600) и min <= max (если нет — меняем местами, как делает фронт); если + # пришёл один конец — клампим его относительно сохранённого другого конца + delay_min, delay_max = body.get("discJoinDelayMin"), body.get("discJoinDelayMax") + if delay_min is not None and delay_max is not None: + try: + dmin, dmax = int(delay_min), int(delay_max) + except (TypeError, ValueError): + pass + else: + dmin = max(5, min(600, dmin)) + dmax = max(5, min(600, dmax)) + if dmin > dmax: + dmin, dmax = dmax, dmin + body["discJoinDelayMin"] = dmin + body["discJoinDelayMax"] = dmax + elif delay_min is not None: + try: + cur_max = int(current.get("discJoinDelayMax") or 0) + value = max(5, min(600, int(delay_min))) + except (TypeError, ValueError): + pass + else: + body["discJoinDelayMin"] = min(value, cur_max) if cur_max >= 5 else value + elif delay_max is not None: + try: + cur_min = int(current.get("discJoinDelayMin") or 0) + value = max(5, min(600, int(delay_max))) + except (TypeError, ValueError): + pass + else: + body["discJoinDelayMax"] = max(value, cur_min) if cur_min <= 600 else value + for key, value in body.items(): + if key in _PUBLIC_INT: + try: + value = int(value) + except (TypeError, ValueError): + continue + if key == "archiveAfterDays": + value = max(1, min(30, value)) + if key == "minLen": + value = max(10, min(500, value)) + if key == "discJoinLimit": + value = max(1, min(200, value)) + if key in {"discJoinDelayMin", "discJoinDelayMax"}: + value = max(5, min(600, value)) + if key == "discEvalSample": + value = max(3, min(30, value)) + if key == "discEvalThreshold": + value = max(1, min(100, value)) + store.set_setting(key, value) + elif key in _PUBLIC_BOOL: + store.set_setting(key, bool(value)) + elif key in _PUBLIC_STR: + if key in {"targetCurrency"}: + value = str(value).upper() + if key == "aiProvider" and not any(p["id"] == value for p in C.AI_PROVIDERS): + continue + store.set_setting(key, str(value)) + elif key in _PUBLIC_LIST: + if isinstance(value, list): + store.set_setting(key, [str(x) for x in value][:200]) + elif key in _PUBLIC_DICT: + if isinstance(value, dict): + store.set_setting(key, value) + elif key == "myPrompts" and isinstance(value, list): + clean: list[dict] = [] + for item in value[:100]: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip()[:80] + prompt = str(item.get("prompt") or "").strip()[:8000] + if not name or not prompt: + continue + clean.append( + { + "id": str(item.get("id") or "")[:40] or store.uid("pp_"), + "name": name, + "description": str(item.get("description") or "").strip()[:300], + "prompt": prompt, + } + ) + store.set_setting("myPrompts", clean) + elif key == "aiConfigs" and isinstance(value, dict): + cfg = current.get("aiConfigs") or {} + for pid, conf in value.items(): + if pid not in cfg: + continue + entry = dict(cfg[pid]) + if isinstance(conf, dict): + for fk in ("baseUrl", "model"): + if fk in conf and conf[fk] is not None: + entry[fk] = str(conf[fk]) + new_key = conf.get("apiKey") + if new_key and not str(new_key).startswith(("enc:",)) and len(str(new_key)) >= 8: + entry["apiKey"] = encrypt_text(str(new_key)) + cfg[pid] = entry + store.set_setting("aiConfigs", cfg) + elif key == "tgKeys" and isinstance(value, dict): + keys = current.get("tgKeys") or {} + if value.get("apiId") is not None: + api = str(value["apiId"]).strip() + if api.isdigit() and 5 < len(api) < 10: + keys["apiId"] = api + new_hash = value.get("apiHash") + if new_hash and len(str(new_hash)) >= 16 and not str(new_hash).startswith("enc:"): + keys["apiHash"] = encrypt_text(str(new_hash).strip()) + store.set_setting("tgKeys", keys) + # смена источника курсов — обновляем в фоне; смена целевой валюты/конвертации — + # пересчёт старых карточек (кроме архива/корзины) + if body.get("rateSource"): + asyncio.get_running_loop().create_task(rates_svc.refresh_rates()) + if body.get("targetCurrency") is not None or body.get("conversionOn") is not None: + rates_svc.recompute_conversions() + return public_settings() + + +@router.post("/ai/check") +async def ai_check(_: str = Depends(current_login)) -> dict: + status = ai_svc.provider_status() + provider_id, data = ai_svc._cfg() + meta = data["meta"] + if meta.get("local"): + return {**status, "ok": True, "message": f"Локальный сервер «{meta['name']}» (ping в проде)"} + key = data["cfg"].get("apiKey") + if not key: + return {**status, "ok": False, "message": "Не задан API-ключ"} + # Лёгкая проверка: GET /models (для OpenAI-совместимых) или /v1/models (Anthropic) + base = str(data["cfg"].get("baseUrl") or meta["base"]).rstrip("/") + url = base + ("/v1/models" if meta.get("api_style") == "anthropic" else "/models") + headers = {"Authorization": f"Bearer {key}"} if meta.get("api_style") != "anthropic" else { + "x-api-key": key, "anthropic-version": "2023-06-01"} + try: + async with httpx.AsyncClient(timeout=12) as client: + resp = await client.get(url, headers=headers) + if resp.status_code < 400: + return {**status, "ok": True, "message": "Подключение успешно"} + if resp.status_code in (401, 403): + return {**status, "ok": False, "message": f"Ключ не принят (HTTP {resp.status_code}) — проверьте ключ и доступ к модели"} + return {**status, "ok": False, "message": f"HTTP {resp.status_code} — проверьте Base URL и модель"} + except Exception as exc: # noqa: BLE001 + return {**status, "ok": False, "message": f"Ошибка соединения: {exc}"} + + +# ─── Курсы ──────────────────────────────────────────────────────────────── + +@router.get("/rates") +def get_rates(_: str = Depends(current_login)) -> dict: + return rates_svc.get_rates() + + +@router.post("/rates/refresh") +async def refresh_rates(_: str = Depends(current_login)) -> dict: + ok = await rates_svc.refresh_rates() + return {"ok": ok, "rates": rates_svc.get_rates()} + + +# ─── Методанные для фронта ──────────────────────────────────────────────── + +@router.get("/meta/constants") +def meta_constants(_: str = Depends(current_login)) -> dict: + return { + "currencies": C.CURRENCIES, + "stages": C.PIPELINE_STAGES, + "palette": C.PALETTE, + } diff --git a/archive/leadradar-legacy/backend/app/routers/tg_routes.py b/archive/leadradar-legacy/backend/app/routers/tg_routes.py index 6015148..c789f43 100644 --- a/archive/leadradar-legacy/backend/app/routers/tg_routes.py +++ b/archive/leadradar-legacy/backend/app/routers/tg_routes.py @@ -1,154 +1,154 @@ -"""Telegram: статус, веб-авторизация, диалоги и мониторинг (п.4.2, 4.3 ТЗ).""" -from __future__ import annotations - -import asyncio - -from fastapi import APIRouter, Depends, HTTPException, Response -from pydantic import BaseModel - -from ..auth import current_login -from ..services.telegram import tg - -router = APIRouter(prefix="/api/tg", tags=["telegram"]) - - -def _qr_svg(url: str) -> str: - """Реальный QR-код (SVG, без внешних растровых зависимостей).""" - import io - - import qrcode - import qrcode.image.svg - - qr = qrcode.QRCode(version=None, box_size=12, border=1, error_correction=qrcode.constants.ERROR_CORRECT_M) - qr.add_data(url) - qr.make(fit=True) - buf = io.StringIO() - qr.make_image(image_factory=qrcode.image.svg.SvgPathImage).save(buf) - return buf.getvalue() - - -@router.get("/qr-image") -def qr_image(_: str = Depends(current_login)) -> Response: - """SVG-картинка QR для сканирования (фаза входа 'qr').""" - if tg.phase != "qr" or not tg.qr_url: - raise HTTPException(404, "QR не активен — начните вход по QR") - return Response( - _qr_svg(tg.qr_url), - media_type="image/svg+xml", - headers={"Cache-Control": "no-store", "Content-Disposition": "inline"}, - ) - - -class PhoneBody(BaseModel): - phone: str - - -class CodeBody(BaseModel): - code: str - - -class PasswordBody(BaseModel): - password: str - - -class MonitorBody(BaseModel): - enabled: bool - - -class PreviewBody(BaseModel): - dialogId: str - limit: int = 24 - - -@router.get("/status") -def status(_: str = Depends(current_login)) -> dict: - return tg.status() - - -@router.post("/start-phone") -async def start_phone(body: PhoneBody, _: str = Depends(current_login)) -> dict: - try: - await tg.start_phone(body.phone.strip()) - except Exception as exc: # noqa: BLE001 - raise HTTPException(400, tg.error or str(exc)) from exc - return {"phase": tg.phase} - - -@router.post("/start-qr") -async def start_qr(_: str = Depends(current_login)) -> dict: - try: - url = await tg.qr_start() - except Exception as exc: # noqa: BLE001 - raise HTTPException(400, tg.error or str(exc)) from exc - return {"phase": tg.phase, "qrUrl": url} - - -@router.post("/send-code") -async def send_code(body: CodeBody, _: str = Depends(current_login)) -> dict: - try: - await tg.submit_code(body.code.strip()) - except ValueError as exc: - raise HTTPException(400, str(exc)) from exc - except Exception as exc: # noqa: BLE001 - raise HTTPException(400, tg.error or str(exc)) from exc - return {"phase": tg.phase} - - -@router.post("/send-password") -async def send_password(body: PasswordBody, _: str = Depends(current_login)) -> dict: - try: - await tg.submit_password(body.password) - except ValueError as exc: - raise HTTPException(400, str(exc)) from exc - return {"phase": tg.phase} - - -@router.post("/logout") -async def logout(_: str = Depends(current_login)) -> dict: - await tg.disconnect() - return {"ok": True} - - -@router.get("/dialogs") -def dialogs(_: str = Depends(current_login)) -> dict: - return {"items": tg.list_dialogs()} - - -@router.post("/dialogs/refresh") -async def dialogs_refresh(_: str = Depends(current_login)) -> dict: - if not tg.client or not tg.client.is_connected(): - return {"ok": False, "reason": "not-connected", "count": 0} - count = await asyncio.wait_for(tg.refresh_dialogs(), timeout=60) - return {"ok": True, "count": count} - - -@router.post("/dialogs/monitor-all") -def monitor_all(body: MonitorBody, _: str = Depends(current_login)) -> dict: - """Включить/выключить мониторинг сразу для всех каналов.""" - count = tg.set_monitor_all(body.enabled) - return {"ok": True, "count": count, "enabled": body.enabled} - - -@router.post("/dialogs/backfill-all") -def backfill_all(_: str = Depends(current_login)) -> dict: - """Перечитать последние 10 сообщений всех включённых каналов (кнопка).""" - count = tg.backfill_monitored() - return {"ok": True, "count": count} - - -@router.post("/dialogs/{dialog_id}/monitor") -def set_monitor(dialog_id: str, body: MonitorBody, _: str = Depends(current_login)) -> dict: - tg.set_monitor(dialog_id, body.enabled) - return {"ok": True, "enabled": body.enabled} - - -@router.post("/dialogs/{dialog_id}/backfill") -async def backfill(dialog_id: str, _: str = Depends(current_login)) -> dict: - processed = await tg.backfill_dialog(dialog_id) - return {"ok": True, "processed": processed} - - -@router.post("/dialogs/preview") -async def preview(body: PreviewBody, _: str = Depends(current_login)) -> dict: - items = await tg.dialog_messages(body.dialogId, min(max(body.limit, 1), 50)) - return {"items": items} +"""Telegram: статус, веб-авторизация, диалоги и мониторинг (п.4.2, 4.3 ТЗ).""" +from __future__ import annotations + +import asyncio + +from fastapi import APIRouter, Depends, HTTPException, Response +from pydantic import BaseModel + +from ..auth import current_login +from ..services.telegram import tg + +router = APIRouter(prefix="/api/tg", tags=["telegram"]) + + +def _qr_svg(url: str) -> str: + """Реальный QR-код (SVG, без внешних растровых зависимостей).""" + import io + + import qrcode + import qrcode.image.svg + + qr = qrcode.QRCode(version=None, box_size=12, border=1, error_correction=qrcode.constants.ERROR_CORRECT_M) + qr.add_data(url) + qr.make(fit=True) + buf = io.StringIO() + qr.make_image(image_factory=qrcode.image.svg.SvgPathImage).save(buf) + return buf.getvalue() + + +@router.get("/qr-image") +def qr_image(_: str = Depends(current_login)) -> Response: + """SVG-картинка QR для сканирования (фаза входа 'qr').""" + if tg.phase != "qr" or not tg.qr_url: + raise HTTPException(404, "QR не активен — начните вход по QR") + return Response( + _qr_svg(tg.qr_url), + media_type="image/svg+xml", + headers={"Cache-Control": "no-store", "Content-Disposition": "inline"}, + ) + + +class PhoneBody(BaseModel): + phone: str + + +class CodeBody(BaseModel): + code: str + + +class PasswordBody(BaseModel): + password: str + + +class MonitorBody(BaseModel): + enabled: bool + + +class PreviewBody(BaseModel): + dialogId: str + limit: int = 24 + + +@router.get("/status") +def status(_: str = Depends(current_login)) -> dict: + return tg.status() + + +@router.post("/start-phone") +async def start_phone(body: PhoneBody, _: str = Depends(current_login)) -> dict: + try: + await tg.start_phone(body.phone.strip()) + except Exception as exc: # noqa: BLE001 + raise HTTPException(400, tg.error or str(exc)) from exc + return {"phase": tg.phase} + + +@router.post("/start-qr") +async def start_qr(_: str = Depends(current_login)) -> dict: + try: + url = await tg.qr_start() + except Exception as exc: # noqa: BLE001 + raise HTTPException(400, tg.error or str(exc)) from exc + return {"phase": tg.phase, "qrUrl": url} + + +@router.post("/send-code") +async def send_code(body: CodeBody, _: str = Depends(current_login)) -> dict: + try: + await tg.submit_code(body.code.strip()) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(400, tg.error or str(exc)) from exc + return {"phase": tg.phase} + + +@router.post("/send-password") +async def send_password(body: PasswordBody, _: str = Depends(current_login)) -> dict: + try: + await tg.submit_password(body.password) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return {"phase": tg.phase} + + +@router.post("/logout") +async def logout(_: str = Depends(current_login)) -> dict: + await tg.disconnect() + return {"ok": True} + + +@router.get("/dialogs") +def dialogs(_: str = Depends(current_login)) -> dict: + return {"items": tg.list_dialogs()} + + +@router.post("/dialogs/refresh") +async def dialogs_refresh(_: str = Depends(current_login)) -> dict: + if not tg.client or not tg.client.is_connected(): + return {"ok": False, "reason": "not-connected", "count": 0} + count = await asyncio.wait_for(tg.refresh_dialogs(), timeout=60) + return {"ok": True, "count": count} + + +@router.post("/dialogs/monitor-all") +def monitor_all(body: MonitorBody, _: str = Depends(current_login)) -> dict: + """Включить/выключить мониторинг сразу для всех каналов.""" + count = tg.set_monitor_all(body.enabled) + return {"ok": True, "count": count, "enabled": body.enabled} + + +@router.post("/dialogs/backfill-all") +def backfill_all(_: str = Depends(current_login)) -> dict: + """Перечитать последние 10 сообщений всех включённых каналов (кнопка).""" + count = tg.backfill_monitored() + return {"ok": True, "count": count} + + +@router.post("/dialogs/{dialog_id}/monitor") +def set_monitor(dialog_id: str, body: MonitorBody, _: str = Depends(current_login)) -> dict: + tg.set_monitor(dialog_id, body.enabled) + return {"ok": True, "enabled": body.enabled} + + +@router.post("/dialogs/{dialog_id}/backfill") +async def backfill(dialog_id: str, _: str = Depends(current_login)) -> dict: + processed = await tg.backfill_dialog(dialog_id) + return {"ok": True, "processed": processed} + + +@router.post("/dialogs/preview") +async def preview(body: PreviewBody, _: str = Depends(current_login)) -> dict: + items = await tg.dialog_messages(body.dialogId, min(max(body.limit, 1), 50)) + return {"items": items} diff --git a/archive/leadradar-legacy/backend/app/services/__init__.py b/archive/leadradar-legacy/backend/app/services/__init__.py index bb04e26..5595293 100644 --- a/archive/leadradar-legacy/backend/app/services/__init__.py +++ b/archive/leadradar-legacy/backend/app/services/__init__.py @@ -1 +1 @@ -"""Сервисный слой.""" +"""Сервисный слой.""" diff --git a/archive/leadradar-legacy/backend/app/services/ai.py b/archive/leadradar-legacy/backend/app/services/ai.py index 2bc57f5..e152e07 100644 --- a/archive/leadradar-legacy/backend/app/services/ai.py +++ b/archive/leadradar-legacy/backend/app/services/ai.py @@ -1,357 +1,357 @@ -"""AI-классификатор поверх выбранного провайдера. - -Провайдеров много (облако + локальные OpenAI-совместимые + Anthropic), -активен один. Всё сведено к двум задачам: - * filter_incoming(text) — этап 2 фильтра входящих (промпт aiFilterPrompt); - * classify(text, boards) — разбор лида (промпт aiPrompt + обучающие примеры). -Ответы моделей строго JSON; распознаём и обрезаем markdown-обёртки ```json. -""" -from __future__ import annotations - -import json -import logging -import re - -import httpx - -from .. import constants as C -from ..crypto import decrypt_text -from ..db import store -from .rates import convert_amount - -log = logging.getLogger("leadradar.ai") - - -def _cfg() -> tuple[str, dict]: - provider_id = store.get_setting("aiProvider") or "deepseek" - configs = store.get_setting("aiConfigs") or {} - meta = next((p for p in C.AI_PROVIDERS if p["id"] == provider_id), None) or C.AI_PROVIDERS[0] - raw = configs.get(provider_id, {}) - cfg = dict(raw) - if cfg.get("apiKey"): - cfg["apiKey"] = decrypt_text(str(cfg["apiKey"])) - return provider_id, {"meta": meta, "cfg": cfg} - - -def provider_status() -> dict: - """Статус для UI: кто активен, есть ли ключ, base, модель (ключ маскируется).""" - provider_id, data = _cfg() - meta = data["meta"] - cfg = data["cfg"] - key = str(cfg.get("apiKey") or "") - return { - "provider": provider_id, - "name": meta["name"], - "base": cfg.get("baseUrl") or meta["base"], - "model": cfg.get("model") or (meta["models"] or [""])[0], - "local": bool(meta.get("local")), - "keySet": bool(key), - "keyMasked": mask_key(key), - } - - -def mask_key(key: str) -> str: - if not key: - return "" - if len(key) <= 8: - return key[0] + "…" - return f"{key[:4]}…{key[-4:]}" - - -# ── «Сфера и ключи»: подстановка в промпты из настроек пользователя ──────── - -def fill_prompt(prompt: str) -> str: - """Заменяет в тексте промпта {domain} и {keywords} значениями из настроек. - - Настройки задаются в UI (вкладка «Сфера и ключи»), поэтому ИИ-классификатор - работает для любой сферы: разработка, дизайн, недвижимость, стройка и т.п. - """ - domain = str(store.get_setting("domainDescription") or "").strip() - if not domain: - domain = "Универсально: заявка = конкретный запрос на услугу/товар/работу или найм человека." - raw_kws = store.get_setting("domainKeywords") or [] - if isinstance(raw_kws, str): - raw_kws = [raw_kws] - kws = [str(k).strip() for k in raw_kws if str(k).strip()] - kws_text = ", ".join(kws[:60]) if kws else "(не заданы — определяй по тексту)" - return (prompt or "").replace("{domain}", domain).replace("{keywords}", kws_text) - - -async def chat_json(system: str, user: str, max_retries: int = 2, max_tokens: int = 8000) -> dict: - """Единая точка вызова выбранной модели. Возвращает dict (JSON-ответ). - - Модель иногда отвечает HTTP 200 с пустым/не-JSON содержимым (особенно - на больших запросах или при перегрузке API) — делаем несколько попыток - с нарастающей паузой, чтобы не сыпать «ИИ недоступен» из-за разового сбоя. - """ - provider_id, data = _cfg() - meta = data["meta"] - cfg = data["cfg"] - base = str(cfg.get("baseUrl") or meta["base"]).rstrip("/") - model = cfg.get("model") or (meta["models"] or [""])[0] - api_key = str(cfg.get("apiKey") or "") - - last_err: Exception | None = None - last_raw = "" - for attempt in range(max_retries + 1): - try: - if meta.get("api_style") == "anthropic": - text = await _call_anthropic(base, api_key, model, system, user, max_tokens) - else: - text = await _call_openai(base, api_key, model, system, user, max_tokens) - last_raw = text - return extract_json(text) - except Exception as exc: # noqa: BLE001 — пробуем ещё раз - last_err = exc - if attempt < max_retries: - await asyncio_sleep(0.8 + 1.2 * attempt) # 0.8с, 2с, 3.2с - log.warning( - "AI call failed (%s, попыток %d): %s | ответ: %r", - meta["name"], - max_retries + 1, - last_err, - last_raw[:200], - ) - raise RuntimeError( - f"ИИ ({meta['name']}) не ответил корректно — повторите попытку через несколько секунд" - ) - - -async def asyncio_sleep(secs: float) -> None: - import asyncio - - await asyncio.sleep(secs) - - -async def _call_openai(base: str, api_key: str, model: str, system: str, user: str, max_tokens: int = 8000) -> str: - url = base + "/chat/completions" - headers = {"Content-Type": "application/json"} - if api_key: - headers["Authorization"] = f"Bearer {api_key}" - body = { - "model": model, - "messages": [ - {"role": "system", "content": system}, - {"role": "user", "content": user}, - ], - "temperature": 0.2, - "max_tokens": max_tokens, - } - async with httpx.AsyncClient(timeout=90) as client: - resp = await client.post(url, headers=headers, json=body) - resp.raise_for_status() - payload = resp.json() - try: - msg = payload["choices"][0]["message"] - except (KeyError, IndexError, TypeError): - raise ValueError(f"неожиданный ответ API: {str(payload)[:200]}") - content = msg.get("content") - if not content and msg.get("reasoning_content"): - # модель «подумала», но не дала ответа (переполнение/обрыв) — считаем сбоем и повторим - raise ValueError("модель вернула только reasoning без ответа") - return str(content or "") - - -async def _call_anthropic(base: str, api_key: str, model: str, system: str, user: str, max_tokens: int = 8000) -> str: - url = base.rstrip("/") + "/v1/messages" - headers = { - "Content-Type": "application/json", - "x-api-key": api_key, - "anthropic-version": "2023-06-01", - } - body = { - "model": model, - "max_tokens": max_tokens, - "system": system, - "messages": [{"role": "user", "content": user}], - } - async with httpx.AsyncClient(timeout=60) as client: - resp = await client.post(url, headers=headers, json=body) - resp.raise_for_status() - payload = resp.json() - return "".join(blk.get("text", "") for blk in payload.get("content", [])) - - -def extract_json(text: str) -> dict: - raw = text.strip() - fence = re.search(r"```(?:json)?\s*(.*?)```", raw, re.S) - if fence: - raw = fence.group(1).strip() - start, end = raw.find("{"), raw.rfind("}") - if start >= 0 and end > start: - raw = raw[start : end + 1] - return json.loads(raw) - - -# ── Задачи ──────────────────────────────────────────────────────────────── - -async def filter_incoming(text: str) -> dict: - """Этап 2 (ИИ-фильтр). Если выключен — считаем пропущенным.""" - if not store.get_setting("aiFilterEnabled"): - return {"pass": True, "reason": None, "skipped": True} - prompt = fill_prompt(store.get_setting("aiFilterPrompt")) - out = await chat_json(prompt, f"Сообщение:\n{text[:4000]}") - return { - "pass": bool(out.get("pass", True)), - "reason": out.get("reason"), - "skipped": False, - } - - -def _learning_examples(limit: int = 8) -> list[dict]: - """Примеры разметки пользователя для few-shot классификации.""" - rows = store.query( - "SELECT l.source_msg AS msg, ll.to_col " - "FROM learning_log ll JOIN leads l ON l.id = ll.lead_id " - "WHERE ll.action IN ('move','restore') AND l.source_msg <> '' " - "ORDER BY ll.created_at DESC LIMIT ?", - [limit], - ) - examples = [] - for r in rows: - if r["to_col"] in ("trash", "archive"): - continue - examples.append({"text": (r["msg"] or "")[:500], "board": r["to_col"]}) - return examples - - -async def classify(message_text: str) -> dict: - """Полный разбор лида. Возвращает сырой словарь модели. - - Колонка для ИИ — это набор критериев (правила), а не просто название: - в промпт уходят правила колонок, чтобы модель выбирала осмысленно. - """ - from .rules import describe as describe_rules - - # в классификации участвуют только принятые колонки (не ИИ-предложения) - boards = store.query( - "SELECT id, name, description, keywords, rules FROM boards WHERE suggested = FALSE ORDER BY pos" - ) - lines = [] - for b in boards: - rules = json.loads(b["rules"] or "{}") if b["rules"] else {} - has_rules = any(rules.get(k) for k in ("direction", "keywords", "stack", "grade", "budget")) - if has_rules: - suffix = f" (критерии: {describe_rules(rules)})" - else: - kws = json.loads(b["keywords"] or "[]")[:8] - suffix = f" (ключевые слова: {', '.join(kws)})" if kws else "" - line = f"- {b['id']}: {b['name']}{suffix}" - if b.get("description"): - line += f" — {str(b['description']).strip()[:160]}" - lines.append(line) - board_map = "\n".join(lines) or "- (колонок пока нет — верните board: null)" - examples = _learning_examples() - user = ( - f"Доски: {board_map}\n\n" - + ("Примеры разметки пользователя:\n" + "\n".join( - f"текст: {e['text']}\n→ колонка: {e['board']}" for e in examples - ) + "\n\n" if examples else "") - + f"Новое сообщение:\n{message_text[:5000]}" - ) - prompt = fill_prompt(store.get_setting("aiPrompt")) - # отдельный промпт структуры карточки («О заявке») — задаёт поля контента, - # чтобы все карточки имели одинаковую структуру текста - card = fill_prompt(store.get_setting("cardPrompt") or "") - if card: - prompt = prompt + "\n\n" + card - return await chat_json(prompt, user) - - -def normalize_dedup(text: str) -> str: - """Дедупликация по нормализованному тексту (регистр и спецсимволы).""" - import hashlib - import re as _re - - norm = _re.sub(r"[^\wа-яё]+", "", text.casefold()) - return hashlib.sha1(norm.encode()).hexdigest() - - -# Синонимы валют из ответов ИИ → коды хранения (согласовано с rules._CUR_*) -_CUR_ALIASES = { - "USD": "USD", "$": "USD", "US$": "USD", "ДОЛЛАР": "USD", "ДОЛЛАРОВ": "USD", "ДОЛЛ": "USD", "БАКС": "USD", "БАКСОВ": "USD", - "EUR": "EUR", "€": "EUR", "ЕВРО": "EUR", - "RUB": "RUB", "RUR": "RUB", "₽": "RUB", "РУБ": "RUB", "РУБЛЕЙ": "RUB", "РУБЛИ": "RUB", "РУБЛЬ": "RUB", "РУБЛЯ": "RUB", - "GBP": "GBP", "£": "GBP", "CNY": "CNY", "¥": "CNY", "USDT": "USDT", "₮": "USDT", -} - - -def _norm_currency(raw) -> str | None: - """Приводит название/символ валюты из ИИ к коду (USD/EUR/RUB/…).""" - s = str(raw or "").strip().upper() - if not s: - return None - if s in _CUR_ALIASES: - return _CUR_ALIASES[s] - letters = re.sub(r"[^A-ZА-Я]", "", s) - if letters in _CUR_ALIASES: - return _CUR_ALIASES[letters] - if len(s) == 3 and s.isalpha(): - return s - return None - - -def _budget_num(v): - """Число из значения бюджета ИИ: «2к»/«2К» → 2000, «2000₽»/«2000р» → 2000.""" - if v is None: - return None - s = str(v).strip().casefold().replace("\u00a0", "").replace(" ", "") - if not s: - return None - mult = 1.0 - if s.endswith(("к", "k")): - mult = 1000.0 - s = s[:-1] - if s.endswith("руб"): - s = s[:-3] - elif s.endswith(("р", "₽")): - s = s[:-1] - try: - x = float(s.replace(",", ".")) * mult - except ValueError: - return None - return None if x == 0 else x - - -def clean_budget(budget) -> dict | None: - """Нормализация бюджета из ИИ/локального разбора. - - Соглашение хранения (и отображения в UI): - одна сумма X → {from: X, to: X} - «до X» → {from: None, to: X} - диапазон «от X до Y» → {from: X, to: Y} - from=0 трактуется как отсутствие нижней границы («от 0 до X» == «до X»). - """ - if not isinstance(budget, dict): - return None - cur = _norm_currency(budget.get("currency") or budget.get("cur")) - if not cur: - return None - - def num(v): - return _budget_num(v) - - f, t = num(budget.get("from")), num(budget.get("to")) - if f is None and t is None: - return None - if t is None: - t = f # одна сумма или «от X» без верхней границы - return {"from": f, "to": t, "currency": cur} - - -def budget_to_target(budget: dict | None) -> dict: - """Пересчёт «один раз при поступлении» в целевую валюту по текущим курсам.""" - if not budget or not budget.get("currency"): - return {"convFrom": None, "convTo": None, "convCur": ""} - cur = budget["currency"] - target = store.get_setting("targetCurrency") or "RUB" - if not store.get_setting("conversionOn"): - return {"convFrom": None, "convTo": None, "convCur": ""} - from_, to_ = budget.get("from"), budget.get("to") - c_from = convert_amount(from_, cur, target) if from_ is not None else None - c_to = None - if to_ is not None: - c_to = convert_amount(to_, cur, target) - elif from_ is not None: - c_to = c_from - return {"convFrom": c_from, "convTo": c_to, "convCur": target} +"""AI-классификатор поверх выбранного провайдера. + +Провайдеров много (облако + локальные OpenAI-совместимые + Anthropic), +активен один. Всё сведено к двум задачам: + * filter_incoming(text) — этап 2 фильтра входящих (промпт aiFilterPrompt); + * classify(text, boards) — разбор лида (промпт aiPrompt + обучающие примеры). +Ответы моделей строго JSON; распознаём и обрезаем markdown-обёртки ```json. +""" +from __future__ import annotations + +import json +import logging +import re + +import httpx + +from .. import constants as C +from ..crypto import decrypt_text +from ..db import store +from .rates import convert_amount + +log = logging.getLogger("leadradar.ai") + + +def _cfg() -> tuple[str, dict]: + provider_id = store.get_setting("aiProvider") or "deepseek" + configs = store.get_setting("aiConfigs") or {} + meta = next((p for p in C.AI_PROVIDERS if p["id"] == provider_id), None) or C.AI_PROVIDERS[0] + raw = configs.get(provider_id, {}) + cfg = dict(raw) + if cfg.get("apiKey"): + cfg["apiKey"] = decrypt_text(str(cfg["apiKey"])) + return provider_id, {"meta": meta, "cfg": cfg} + + +def provider_status() -> dict: + """Статус для UI: кто активен, есть ли ключ, base, модель (ключ маскируется).""" + provider_id, data = _cfg() + meta = data["meta"] + cfg = data["cfg"] + key = str(cfg.get("apiKey") or "") + return { + "provider": provider_id, + "name": meta["name"], + "base": cfg.get("baseUrl") or meta["base"], + "model": cfg.get("model") or (meta["models"] or [""])[0], + "local": bool(meta.get("local")), + "keySet": bool(key), + "keyMasked": mask_key(key), + } + + +def mask_key(key: str) -> str: + if not key: + return "" + if len(key) <= 8: + return key[0] + "…" + return f"{key[:4]}…{key[-4:]}" + + +# ── «Сфера и ключи»: подстановка в промпты из настроек пользователя ──────── + +def fill_prompt(prompt: str) -> str: + """Заменяет в тексте промпта {domain} и {keywords} значениями из настроек. + + Настройки задаются в UI (вкладка «Сфера и ключи»), поэтому ИИ-классификатор + работает для любой сферы: разработка, дизайн, недвижимость, стройка и т.п. + """ + domain = str(store.get_setting("domainDescription") or "").strip() + if not domain: + domain = "Универсально: заявка = конкретный запрос на услугу/товар/работу или найм человека." + raw_kws = store.get_setting("domainKeywords") or [] + if isinstance(raw_kws, str): + raw_kws = [raw_kws] + kws = [str(k).strip() for k in raw_kws if str(k).strip()] + kws_text = ", ".join(kws[:60]) if kws else "(не заданы — определяй по тексту)" + return (prompt or "").replace("{domain}", domain).replace("{keywords}", kws_text) + + +async def chat_json(system: str, user: str, max_retries: int = 2, max_tokens: int = 8000) -> dict: + """Единая точка вызова выбранной модели. Возвращает dict (JSON-ответ). + + Модель иногда отвечает HTTP 200 с пустым/не-JSON содержимым (особенно + на больших запросах или при перегрузке API) — делаем несколько попыток + с нарастающей паузой, чтобы не сыпать «ИИ недоступен» из-за разового сбоя. + """ + provider_id, data = _cfg() + meta = data["meta"] + cfg = data["cfg"] + base = str(cfg.get("baseUrl") or meta["base"]).rstrip("/") + model = cfg.get("model") or (meta["models"] or [""])[0] + api_key = str(cfg.get("apiKey") or "") + + last_err: Exception | None = None + last_raw = "" + for attempt in range(max_retries + 1): + try: + if meta.get("api_style") == "anthropic": + text = await _call_anthropic(base, api_key, model, system, user, max_tokens) + else: + text = await _call_openai(base, api_key, model, system, user, max_tokens) + last_raw = text + return extract_json(text) + except Exception as exc: # noqa: BLE001 — пробуем ещё раз + last_err = exc + if attempt < max_retries: + await asyncio_sleep(0.8 + 1.2 * attempt) # 0.8с, 2с, 3.2с + log.warning( + "AI call failed (%s, попыток %d): %s | ответ: %r", + meta["name"], + max_retries + 1, + last_err, + last_raw[:200], + ) + raise RuntimeError( + f"ИИ ({meta['name']}) не ответил корректно — повторите попытку через несколько секунд" + ) + + +async def asyncio_sleep(secs: float) -> None: + import asyncio + + await asyncio.sleep(secs) + + +async def _call_openai(base: str, api_key: str, model: str, system: str, user: str, max_tokens: int = 8000) -> str: + url = base + "/chat/completions" + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + body = { + "model": model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "temperature": 0.2, + "max_tokens": max_tokens, + } + async with httpx.AsyncClient(timeout=90) as client: + resp = await client.post(url, headers=headers, json=body) + resp.raise_for_status() + payload = resp.json() + try: + msg = payload["choices"][0]["message"] + except (KeyError, IndexError, TypeError): + raise ValueError(f"неожиданный ответ API: {str(payload)[:200]}") + content = msg.get("content") + if not content and msg.get("reasoning_content"): + # модель «подумала», но не дала ответа (переполнение/обрыв) — считаем сбоем и повторим + raise ValueError("модель вернула только reasoning без ответа") + return str(content or "") + + +async def _call_anthropic(base: str, api_key: str, model: str, system: str, user: str, max_tokens: int = 8000) -> str: + url = base.rstrip("/") + "/v1/messages" + headers = { + "Content-Type": "application/json", + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + } + body = { + "model": model, + "max_tokens": max_tokens, + "system": system, + "messages": [{"role": "user", "content": user}], + } + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post(url, headers=headers, json=body) + resp.raise_for_status() + payload = resp.json() + return "".join(blk.get("text", "") for blk in payload.get("content", [])) + + +def extract_json(text: str) -> dict: + raw = text.strip() + fence = re.search(r"```(?:json)?\s*(.*?)```", raw, re.S) + if fence: + raw = fence.group(1).strip() + start, end = raw.find("{"), raw.rfind("}") + if start >= 0 and end > start: + raw = raw[start : end + 1] + return json.loads(raw) + + +# ── Задачи ──────────────────────────────────────────────────────────────── + +async def filter_incoming(text: str) -> dict: + """Этап 2 (ИИ-фильтр). Если выключен — считаем пропущенным.""" + if not store.get_setting("aiFilterEnabled"): + return {"pass": True, "reason": None, "skipped": True} + prompt = fill_prompt(store.get_setting("aiFilterPrompt")) + out = await chat_json(prompt, f"Сообщение:\n{text[:4000]}") + return { + "pass": bool(out.get("pass", True)), + "reason": out.get("reason"), + "skipped": False, + } + + +def _learning_examples(limit: int = 8) -> list[dict]: + """Примеры разметки пользователя для few-shot классификации.""" + rows = store.query( + "SELECT l.source_msg AS msg, ll.to_col " + "FROM learning_log ll JOIN leads l ON l.id = ll.lead_id " + "WHERE ll.action IN ('move','restore') AND l.source_msg <> '' " + "ORDER BY ll.created_at DESC LIMIT ?", + [limit], + ) + examples = [] + for r in rows: + if r["to_col"] in ("trash", "archive"): + continue + examples.append({"text": (r["msg"] or "")[:500], "board": r["to_col"]}) + return examples + + +async def classify(message_text: str) -> dict: + """Полный разбор лида. Возвращает сырой словарь модели. + + Колонка для ИИ — это набор критериев (правила), а не просто название: + в промпт уходят правила колонок, чтобы модель выбирала осмысленно. + """ + from .rules import describe as describe_rules + + # в классификации участвуют только принятые колонки (не ИИ-предложения) + boards = store.query( + "SELECT id, name, description, keywords, rules FROM boards WHERE suggested = FALSE ORDER BY pos" + ) + lines = [] + for b in boards: + rules = json.loads(b["rules"] or "{}") if b["rules"] else {} + has_rules = any(rules.get(k) for k in ("direction", "keywords", "stack", "grade", "budget")) + if has_rules: + suffix = f" (критерии: {describe_rules(rules)})" + else: + kws = json.loads(b["keywords"] or "[]")[:8] + suffix = f" (ключевые слова: {', '.join(kws)})" if kws else "" + line = f"- {b['id']}: {b['name']}{suffix}" + if b.get("description"): + line += f" — {str(b['description']).strip()[:160]}" + lines.append(line) + board_map = "\n".join(lines) or "- (колонок пока нет — верните board: null)" + examples = _learning_examples() + user = ( + f"Доски: {board_map}\n\n" + + ("Примеры разметки пользователя:\n" + "\n".join( + f"текст: {e['text']}\n→ колонка: {e['board']}" for e in examples + ) + "\n\n" if examples else "") + + f"Новое сообщение:\n{message_text[:5000]}" + ) + prompt = fill_prompt(store.get_setting("aiPrompt")) + # отдельный промпт структуры карточки («О заявке») — задаёт поля контента, + # чтобы все карточки имели одинаковую структуру текста + card = fill_prompt(store.get_setting("cardPrompt") or "") + if card: + prompt = prompt + "\n\n" + card + return await chat_json(prompt, user) + + +def normalize_dedup(text: str) -> str: + """Дедупликация по нормализованному тексту (регистр и спецсимволы).""" + import hashlib + import re as _re + + norm = _re.sub(r"[^\wа-яё]+", "", text.casefold()) + return hashlib.sha1(norm.encode()).hexdigest() + + +# Синонимы валют из ответов ИИ → коды хранения (согласовано с rules._CUR_*) +_CUR_ALIASES = { + "USD": "USD", "$": "USD", "US$": "USD", "ДОЛЛАР": "USD", "ДОЛЛАРОВ": "USD", "ДОЛЛ": "USD", "БАКС": "USD", "БАКСОВ": "USD", + "EUR": "EUR", "€": "EUR", "ЕВРО": "EUR", + "RUB": "RUB", "RUR": "RUB", "₽": "RUB", "РУБ": "RUB", "РУБЛЕЙ": "RUB", "РУБЛИ": "RUB", "РУБЛЬ": "RUB", "РУБЛЯ": "RUB", + "GBP": "GBP", "£": "GBP", "CNY": "CNY", "¥": "CNY", "USDT": "USDT", "₮": "USDT", +} + + +def _norm_currency(raw) -> str | None: + """Приводит название/символ валюты из ИИ к коду (USD/EUR/RUB/…).""" + s = str(raw or "").strip().upper() + if not s: + return None + if s in _CUR_ALIASES: + return _CUR_ALIASES[s] + letters = re.sub(r"[^A-ZА-Я]", "", s) + if letters in _CUR_ALIASES: + return _CUR_ALIASES[letters] + if len(s) == 3 and s.isalpha(): + return s + return None + + +def _budget_num(v): + """Число из значения бюджета ИИ: «2к»/«2К» → 2000, «2000₽»/«2000р» → 2000.""" + if v is None: + return None + s = str(v).strip().casefold().replace("\u00a0", "").replace(" ", "") + if not s: + return None + mult = 1.0 + if s.endswith(("к", "k")): + mult = 1000.0 + s = s[:-1] + if s.endswith("руб"): + s = s[:-3] + elif s.endswith(("р", "₽")): + s = s[:-1] + try: + x = float(s.replace(",", ".")) * mult + except ValueError: + return None + return None if x == 0 else x + + +def clean_budget(budget) -> dict | None: + """Нормализация бюджета из ИИ/локального разбора. + + Соглашение хранения (и отображения в UI): + одна сумма X → {from: X, to: X} + «до X» → {from: None, to: X} + диапазон «от X до Y» → {from: X, to: Y} + from=0 трактуется как отсутствие нижней границы («от 0 до X» == «до X»). + """ + if not isinstance(budget, dict): + return None + cur = _norm_currency(budget.get("currency") or budget.get("cur")) + if not cur: + return None + + def num(v): + return _budget_num(v) + + f, t = num(budget.get("from")), num(budget.get("to")) + if f is None and t is None: + return None + if t is None: + t = f # одна сумма или «от X» без верхней границы + return {"from": f, "to": t, "currency": cur} + + +def budget_to_target(budget: dict | None) -> dict: + """Пересчёт «один раз при поступлении» в целевую валюту по текущим курсам.""" + if not budget or not budget.get("currency"): + return {"convFrom": None, "convTo": None, "convCur": ""} + cur = budget["currency"] + target = store.get_setting("targetCurrency") or "RUB" + if not store.get_setting("conversionOn"): + return {"convFrom": None, "convTo": None, "convCur": ""} + from_, to_ = budget.get("from"), budget.get("to") + c_from = convert_amount(from_, cur, target) if from_ is not None else None + c_to = None + if to_ is not None: + c_to = convert_amount(to_, cur, target) + elif from_ is not None: + c_to = c_from + return {"convFrom": c_from, "convTo": c_to, "convCur": target} diff --git a/archive/leadradar-legacy/backend/app/services/ban_guard.py b/archive/leadradar-legacy/backend/app/services/ban_guard.py index c1a9f7f..f6fd3b1 100644 --- a/archive/leadradar-legacy/backend/app/services/ban_guard.py +++ b/archive/leadradar-legacy/backend/app/services/ban_guard.py @@ -1,80 +1,80 @@ -"""BanGuard: квоты, паузы и защита от flood для авто-вступлений Discovery. - -Суточный лимит (discJoinLimit) считается по disc_log (event='join_auto') -за текущие UTC-сутки; между вступлениями выдерживается случайная пауза -(discJoinDelayMin/Max). При FloodWait от Telegram ставится блокировка до -конца суток (discFloodDay), плюс есть ручной стоп-кран (discPaused). -""" -from __future__ import annotations - -import asyncio -import random -from datetime import datetime, timezone - -from ..db import store - -_KEY_JOIN_LIMIT = "discJoinLimit" -_KEY_DELAY_MIN = "discJoinDelayMin" -_KEY_DELAY_MAX = "discJoinDelayMax" -_KEY_FLOOD_DAY = "discFloodDay" -_KEY_PAUSED = "discPaused" - - -def _start_of_day_ms() -> int: - """Начало текущих UTC-суток в миллисекундах.""" - start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) - return int(start.timestamp() * 1000) - - -def joins_today_auto() -> int: - """Авто-вступления за текущие UTC-сутки (disc_log, event='join_auto').""" - row = store.scalar( - "SELECT count(*) FROM disc_log WHERE event = 'join_auto' AND created_at >= ?", - [_start_of_day_ms()], - ) - return int(row or 0) - - -def can_auto_join() -> bool: - """Разрешено ли авто-вступление: лимит не исчерпан, нет flood на сегодня, нет паузы.""" - limit = int(store.get_setting(_KEY_JOIN_LIMIT) or 0) - return joins_today_auto() < limit and not flood_today() and not global_paused() - - -async def wait_join_delay() -> None: - """Случайная пауза перед авто-вступлением (сек, из настроек). - - Защита инварианта min <= max: настройки мог изменить один конец интервала - (single-key PATCH), поэтому при инверсии концы меняются местами. - """ - low = float(store.get_setting(_KEY_DELAY_MIN) or 0) - high = float(store.get_setting(_KEY_DELAY_MAX) or 0) - if low <= 0 and high <= 0: - return # обе настройки не заданы — паузы нет (крайний случай) - if low > high: - low, high = high, low - await asyncio.sleep(random.uniform(low, high)) - - -def note_flood() -> None: - """Зафиксировать FloodWait: блокировка авто-вступлений до конца суток.""" - store.set_setting(_KEY_FLOOD_DAY, _start_of_day_ms()) - - -def flood_today() -> bool: - """Была ли flood-блокировка в текущие UTC-сутки.""" - return int(store.get_setting(_KEY_FLOOD_DAY) or 0) == _start_of_day_ms() - - -def global_paused() -> bool: - """Ручной стоп-кран авто-вступлений (setting discPaused).""" - return bool(store.get_setting(_KEY_PAUSED)) - - -def set_global_pause(v: bool) -> None: - store.set_setting(_KEY_PAUSED, bool(v)) - - -def search_pause() -> float: - """Пауза между поисковыми запросами Telegram (сек).""" - return random.uniform(2.0, 4.0) +"""BanGuard: квоты, паузы и защита от flood для авто-вступлений Discovery. + +Суточный лимит (discJoinLimit) считается по disc_log (event='join_auto') +за текущие UTC-сутки; между вступлениями выдерживается случайная пауза +(discJoinDelayMin/Max). При FloodWait от Telegram ставится блокировка до +конца суток (discFloodDay), плюс есть ручной стоп-кран (discPaused). +""" +from __future__ import annotations + +import asyncio +import random +from datetime import datetime, timezone + +from ..db import store + +_KEY_JOIN_LIMIT = "discJoinLimit" +_KEY_DELAY_MIN = "discJoinDelayMin" +_KEY_DELAY_MAX = "discJoinDelayMax" +_KEY_FLOOD_DAY = "discFloodDay" +_KEY_PAUSED = "discPaused" + + +def _start_of_day_ms() -> int: + """Начало текущих UTC-суток в миллисекундах.""" + start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + return int(start.timestamp() * 1000) + + +def joins_today_auto() -> int: + """Авто-вступления за текущие UTC-сутки (disc_log, event='join_auto').""" + row = store.scalar( + "SELECT count(*) FROM disc_log WHERE event = 'join_auto' AND created_at >= ?", + [_start_of_day_ms()], + ) + return int(row or 0) + + +def can_auto_join() -> bool: + """Разрешено ли авто-вступление: лимит не исчерпан, нет flood на сегодня, нет паузы.""" + limit = int(store.get_setting(_KEY_JOIN_LIMIT) or 0) + return joins_today_auto() < limit and not flood_today() and not global_paused() + + +async def wait_join_delay() -> None: + """Случайная пауза перед авто-вступлением (сек, из настроек). + + Защита инварианта min <= max: настройки мог изменить один конец интервала + (single-key PATCH), поэтому при инверсии концы меняются местами. + """ + low = float(store.get_setting(_KEY_DELAY_MIN) or 0) + high = float(store.get_setting(_KEY_DELAY_MAX) or 0) + if low <= 0 and high <= 0: + return # обе настройки не заданы — паузы нет (крайний случай) + if low > high: + low, high = high, low + await asyncio.sleep(random.uniform(low, high)) + + +def note_flood() -> None: + """Зафиксировать FloodWait: блокировка авто-вступлений до конца суток.""" + store.set_setting(_KEY_FLOOD_DAY, _start_of_day_ms()) + + +def flood_today() -> bool: + """Была ли flood-блокировка в текущие UTC-сутки.""" + return int(store.get_setting(_KEY_FLOOD_DAY) or 0) == _start_of_day_ms() + + +def global_paused() -> bool: + """Ручной стоп-кран авто-вступлений (setting discPaused).""" + return bool(store.get_setting(_KEY_PAUSED)) + + +def set_global_pause(v: bool) -> None: + store.set_setting(_KEY_PAUSED, bool(v)) + + +def search_pause() -> float: + """Пауза между поисковыми запросами Telegram (сек).""" + return random.uniform(2.0, 4.0) diff --git a/archive/leadradar-legacy/backend/app/services/discovery.py b/archive/leadradar-legacy/backend/app/services/discovery.py index fe6c4f4..de7c6a7 100644 --- a/archive/leadradar-legacy/backend/app/services/discovery.py +++ b/archive/leadradar-legacy/backend/app/services/discovery.py @@ -1,608 +1,608 @@ -"""Discovery: хранилище задач поиска каналов, кандидатов, чёрного списка и лога. - -Единый слой доступа к disc_tasks / disc_candidates / disc_blacklist / disc_log -(Task 1). Потребляется и API (Task 7), и фоновым воркером (Task 6). - -Соглашения модуля: -- все обращения к БД только через `store.*` с параметрами (без конкатенации SQL); -- время — миллисекунды (`time.time_ns() // 1_000_000`); -- JSON-поля (keywords/marks/topics) в БД — VARCHAR, наружу всегда список - (`json.dumps(..., ensure_ascii=False)` при записи, `json.loads` при чтении); -- наружные dict-ы — camelCase (конвенция границы API проекта), поля совпадают - с колонками БД: keywords/minSubscribers/sampleSize/planJoins/autoJoin, - searchIdx/searchDone, fitRatio/autoJoined, createdAt/updatedAt, dialogId... -- create/patch принимают ключи и в camelCase, и в snake_case (поле "min_subscribers" - и т.п.) — на входе идёт нормализация к колонкам БД; -- статусы кандидата: new -> review -> joined|rejected. Переводы в joined/rejected - делаются ТОЛЬКО через mark_joined()/mark_rejected() (счётчики, чёрный список, - лог); set_candidate_status() разрешает new/review. - -Бюджет авто-вступлений: сумма plan_joins задач со статусом NOT IN ('done','failed') -плюс plan_joins новой/увеличиваемой задачи не должна превышать discJoinLimit. -""" -from __future__ import annotations - -import json -import time - -from ..db import store - -# префиксы id (конвенция: видно, из какой таблицы запись) -_ID_TASK = "dt_" -_ID_LOG = "dl_" - -# статусы кандидата, при которых повторное добавление источника запрещено -_ACTIVE_CANDIDATE = {"new", "review", "joined"} -# статусы задачи, которые НЕ занимают бюджет plan_joins -_DONE_TASK = {"done", "failed"} - -# алиасы полей задачи: колонка БД -> набор имён в payload/патче -_TASK_ALIASES = { - "min_subscribers": {"minSubscribers", "min_subscribers"}, - "sample_size": {"sampleSize", "sample_size"}, - "plan_joins": {"planJoins", "plan_joins"}, - "auto_join": {"autoJoin", "auto_join"}, - # остальные поля называются одинаково: name, description, keywords, lang, threshold -} -# поля кандидата, которые можно менять через set_candidate() -_CANDIDATE_FIELDS = { - "name": "name", - "username": "username", - "kind": "kind", - "hue": "hue", - "participants": "participants", - "lang_ru": "lang_ru", - "langRu": "lang_ru", - "marks": "marks", - "topics": "topics", - "fit_ratio": "fit_ratio", - "fitRatio": "fit_ratio", - "auto_joined": "auto_joined", - "autoJoined": "auto_joined", -} - - -def _now() -> int: - return time.time_ns() // 1_000_000 - - -def _json(value) -> str: - return json.dumps(value or [], ensure_ascii=False) - - -def _loads(raw, default: list | None = None) -> list: - try: - return json.loads(raw or "[]") - except (TypeError, ValueError): - return list(default or []) - - -def _plan_limit() -> int: - """Верхняя граница plan_joins: текущий суточный лимит discJoinLimit.""" - return max(1, int(store.get_setting("discJoinLimit") or 0)) - - -def _active_plan_sum(exclude_id: str | None = None) -> int: - sql = "SELECT coalesce(sum(plan_joins), 0) FROM disc_tasks WHERE status NOT IN ('done', 'failed')" - params: list = [] - if exclude_id: - sql += " AND id <> ?" - params.append(exclude_id) - return int(store.scalar(sql, params) or 0) - - -def _assert_plan(plan_joins: int) -> None: - """plan_joins >= 1 и не больше суточного лимита (настраивается в UI).""" - if plan_joins < 1: - raise ValueError("plan_joins должен быть не меньше 1") - limit = _plan_limit() - if plan_joins > limit: - raise ValueError(f"plan_joins {plan_joins} больше суточного лимита авто-вступлений ({limit})") - - -def _assert_budget(plan_joins: int, exclude_id: str | None = None) -> None: - """Правило бюджета: сумма планов активных задач + новая <= discJoinLimit.""" - limit = _plan_limit() - used = _active_plan_sum(exclude_id) - if used + plan_joins > limit: - raise ValueError( - f"Бюджет авто-вступлений исчерпан: задачи уже занимают {used} из {limit} в сутки, " - f"ещё {plan_joins} не влезает" - ) - - -# ─── view-слои (наружу camelCase) ────────────────────────────────────────── - -def _task_view(row: dict) -> dict: - return { - "id": row["id"], - "name": row["name"], - "description": row["description"], - "keywords": _loads(row["keywords"]), - "minSubscribers": int(row["min_subscribers"]), - "lang": row["lang"], - "threshold": int(row["threshold"]), - "sampleSize": int(row["sample_size"]), - "planJoins": int(row["plan_joins"]), - "autoJoin": bool(row["auto_join"]), - "status": row["status"], - "searchIdx": int(row["search_idx"]), - "searchDone": bool(row["search_done"]), - "found": int(row["found"]), - "evaluated": int(row["evaluated"]), - "joined": int(row["joined"]), - "rejected": int(row["rejected"]), - "createdAt": row["created_at"], - "updatedAt": row["updated_at"], - } - - -def _candidate_view(row: dict) -> dict: - return { - "dialogId": row["dialog_id"], - "taskId": row["task_id"], - "name": row["name"], - "username": row["username"], - "kind": row["kind"], - "hue": row["hue"], - "participants": row["participants"], - "langRu": row["lang_ru"], - "marks": _loads(row["marks"]), - "topics": _loads(row["topics"]), - "fitRatio": row["fit_ratio"], - "status": row["status"], - "autoJoined": bool(row["auto_joined"]), - "joinFailures": int(row.get("join_failures") or 0), - "createdAt": row["created_at"], - "updatedAt": row["updated_at"], - } - - -def _blacklist_view(row: dict) -> dict: - return { - "dialogId": row["dialog_id"], - "name": row["name"], - "reason": row["reason"], - "createdAt": row["created_at"], - } - - -def _log_view(row: dict) -> dict: - return { - "id": row["id"], - "taskId": row["task_id"], - "event": row["event"], - "text": row["text"], - "createdAt": row["created_at"], - } - - -# ─── задачи ──────────────────────────────────────────────────────────────── - -def _task_or_raise(task_id: str) -> dict: - row = store.query_one("SELECT * FROM disc_tasks WHERE id = ?", [task_id]) - if row is None: - raise KeyError(task_id) - return row - - -def list_tasks() -> list[dict]: - """Все задачи, старые первыми (воркер берёт самую старую running).""" - return [_task_view(r) for r in store.query("SELECT * FROM disc_tasks ORDER BY created_at ASC")] - - -def get_task(task_id: str) -> dict | None: - row = store.query_one("SELECT * FROM disc_tasks WHERE id = ?", [task_id]) - return _task_view(row) if row else None - - -def _norm_task_values(patch: dict) -> dict: - """Нормализация payload/патча задачи (camel/snake алиасы) к колонкам БД.""" - out: dict = {} - for key, value in patch.items(): - if key in ("name", "description", "keywords", "lang", "threshold"): - col = key - else: - col = next((c for c, aliases in _TASK_ALIASES.items() if key in aliases), None) - if col is None: - continue # неизвестное поле игнорируем - out[col] = value - return out - - -def _validate_task_values(cols: dict) -> None: - """Проверка границ значений задачи (после нормализации).""" - if "threshold" in cols: - cols["threshold"] = max(1, min(100, int(cols["threshold"]))) - if "sample_size" in cols: - cols["sample_size"] = max(1, int(cols["sample_size"])) - if "min_subscribers" in cols: - cols["min_subscribers"] = max(0, int(cols["min_subscribers"])) - if "lang" in cols and cols["lang"] not in ("ru", "any"): - cols["lang"] = "ru" - if "auto_join" in cols: - cols["auto_join"] = bool(cols["auto_join"]) - if "keywords" in cols: - keywords = cols["keywords"] if isinstance(cols["keywords"], list) else [cols["keywords"]] - cols["keywords"] = [str(k).strip() for k in keywords if str(k).strip()] - if "description" in cols: - cols["description"] = str(cols["description"] or "") - if "name" in cols: - cols["name"] = str(cols["name"] or "").strip() - - -def create_task(payload: dict) -> dict: - """Создать задачу поиска. - - Валидация: name непустое; plan_joins 1..discJoinLimit; правило бюджета - (сумма plan_joins активных задач + новая <= discJoinLimit) — иначе ValueError. - """ - cols = _norm_task_values(payload) - name = str(cols.get("name") or "").strip() - if not name: - raise ValueError("Укажите название задачи") - plan_joins = int(cols.get("plan_joins", 1)) - _assert_plan(plan_joins) - _assert_budget(plan_joins) - - values = { - "name": name, - "description": str(cols.get("description") or ""), - "keywords": cols.get("keywords", []), - "min_subscribers": max(0, int(cols.get("min_subscribers", 0))), - "lang": cols.get("lang", "ru"), - "threshold": max(1, min(100, int(cols.get("threshold", int(store.get_setting("discEvalThreshold") or 40))))), - "sample_size": max(1, int(cols.get("sample_size", int(store.get_setting("discEvalSample") or 10)))), - "plan_joins": plan_joins, - "auto_join": bool(cols.get("auto_join", False)), - } - _validate_task_values(values) - task_id = store.uid(_ID_TASK) - now = _now() - store.execute( - "INSERT INTO disc_tasks(id, name, description, keywords, min_subscribers, lang, threshold, " - "sample_size, plan_joins, auto_join, status, search_idx, search_done, found, evaluated, " - "joined, rejected, created_at, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 0, FALSE, 0, 0, 0, 0, ?, ?)", - [ - task_id, - values["name"], - values["description"], - _json(values["keywords"]), - values["min_subscribers"], - values["lang"], - values["threshold"], - values["sample_size"], - values["plan_joins"], - values["auto_join"], - now, - now, - ], - ) - return get_task(task_id) # type: ignore[return-value] - - -def patch_task(task_id: str, patch: dict) -> dict: - """Обновить поля задачи. Увеличение plan_joins — с проверкой бюджета.""" - row = _task_or_raise(task_id) - cols = _norm_task_values(patch) - if not cols: - return get_task(task_id) # type: ignore[return-value] - - old_plan = int(row["plan_joins"]) - if "plan_joins" in cols: - new_plan = int(cols["plan_joins"]) - _assert_plan(new_plan) - if new_plan > old_plan: - _assert_budget(new_plan, exclude_id=task_id) - cols["plan_joins"] = new_plan - _validate_task_values(cols) - - sets = ["updated_at = ?"] - params: list = [_now()] - for col, value in cols.items(): - sets.append(f"{col} = ?") - params.append(_json(value) if col == "keywords" else value) - params.append(task_id) - store.execute( - f"UPDATE disc_tasks SET {', '.join(sets)} WHERE id = ?", - params, - ) - return get_task(task_id) # type: ignore[return-value] - - -def delete_task(task_id: str) -> None: - """Удалить задачу вместе с её кандидатами и логом (чёрный список общий).""" - store.execute("DELETE FROM disc_tasks WHERE id = ?", [task_id]) - store.execute("DELETE FROM disc_candidates WHERE task_id = ?", [task_id]) - store.execute("DELETE FROM disc_log WHERE task_id = ?", [task_id]) - - -def start_task(task_id: str) -> dict: - """Запустить поиск: keywords непустые; status=running. - - При повторном запуске завершённой/упавшей задачи прогресс поиска обнуляется - (свежий проход по ключам); при продолжении из paused — сохраняется. - """ - row = _task_or_raise(task_id) - keywords = _loads(row["keywords"]) - if not keywords: - raise ValueError("Нет ключевых слов для поиска — добавьте их в задачу") - now = _now() - reset = row["status"] in _DONE_TASK - if reset: - # повторный прогон завершённой/упавшей задачи — свежий проход по ключам - store.execute( - "UPDATE disc_tasks SET status = 'running', search_idx = 0, search_done = FALSE, " - "found = 0, evaluated = 0, joined = 0, rejected = 0, updated_at = ? WHERE id = ?", - [now, task_id], - ) - else: - # старт из draft или продолжение из paused — прогресс поиска сохраняется - store.execute( - "UPDATE disc_tasks SET status = 'running', updated_at = ? WHERE id = ?", - [now, task_id], - ) - return get_task(task_id) # type: ignore[return-value] - - -def pause_task(task_id: str) -> dict: - """Поставить задачу на паузу.""" - _task_or_raise(task_id) - store.execute( - "UPDATE disc_tasks SET status = 'paused', updated_at = ? WHERE id = ?", - [_now(), task_id], - ) - return get_task(task_id) # type: ignore[return-value] - - -def bump_counter(task_id: str, field: str, n: int = 1) -> None: - """Увеличить счётчик задачи: found|evaluated|joined|rejected.""" - if field not in ("found", "evaluated", "joined", "rejected"): - raise ValueError(f"Неизвестный счётчик задачи: {field}") - row = _task_or_raise(task_id) - row[field] = int(row[field]) + max(0, int(n)) - store.execute( - f"UPDATE disc_tasks SET {field} = ?, updated_at = ? WHERE id = ?", - [row[field], _now(), task_id], - ) - - -def advance_search(task_id: str) -> None: - """Перейти к следующему ключу; когда search_idx >= len(keywords) — search_done=True.""" - row = _task_or_raise(task_id) - keywords = _loads(row["keywords"]) - new_idx = int(row["search_idx"]) + 1 - done = new_idx >= len(keywords) - store.execute( - "UPDATE disc_tasks SET search_idx = ?, search_done = ?, updated_at = ? WHERE id = ?", - [new_idx, done, _now(), task_id], - ) - - -# ─── кандидаты ───────────────────────────────────────────────────────────── - -def list_candidates(task_id: str, status: str | None = None) -> list[dict]: - """Кандидаты задачи (marks/topics уже списки); status — фильтр.""" - if status: - rows = store.query( - "SELECT * FROM disc_candidates WHERE task_id = ? AND status = ? ORDER BY created_at ASC", - [task_id, status], - ) - else: - rows = store.query( - "SELECT * FROM disc_candidates WHERE task_id = ? ORDER BY created_at ASC", - [task_id], - ) - return [_candidate_view(r) for r in rows] - - -def _get_candidate(dialog_id: str) -> dict | None: - row = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id]) - return row - - -def _skip(task_id: str, reason: str) -> None: - add_log(task_id, "skip", reason) - - -def add_candidate(task_id: str, dialog_id: str, name: str, username: str, kind: str, hue: str) -> dict | None: - """Добавить найденный источник как кандидата задачи. - - None (с логом skip), если источник уже мониторится (есть в dialogs), в - чёрном списке или уже добавлен в статусе new/review/joined. Прежняя запись - со статусом rejected (например, после remove_blacklist) заменяется новой. - """ - _task_or_raise(task_id) - dialog_id = str(dialog_id) - if store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id]): - _skip(task_id, f"пропущен {dialog_id}: источник уже мониторится (мы состоим)") - return None - if store.scalar("SELECT 1 FROM disc_blacklist WHERE dialog_id = ? LIMIT 1", [dialog_id]): - _skip(task_id, f"пропущен {dialog_id}: источник в чёрном списке") - return None - existing = store.query_one( - "SELECT status FROM disc_candidates WHERE dialog_id = ?", - [dialog_id], - ) - if existing and existing["status"] in _ACTIVE_CANDIDATE: - _skip(task_id, f"пропущен {dialog_id}: кандидат уже есть (статус {existing['status']})") - return None - if existing: - # устаревшая rejected-запись: перезаписываем как новый кандидат - store.execute("DELETE FROM disc_candidates WHERE dialog_id = ?", [dialog_id]) - - now = _now() - store.execute( - "INSERT INTO disc_candidates(dialog_id, task_id, name, username, kind, hue, participants, " - "lang_ru, marks, topics, fit_ratio, status, auto_joined, created_at, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, '[]', '[]', NULL, 'new', FALSE, ?, ?)", - [ - dialog_id, - task_id, - str(name or "").strip() or dialog_id, - str(username or "").strip(), - str(kind or "channel"), - str(hue or "#666"), - now, - now, - ], - ) - bump_counter(task_id, "found") - row = _get_candidate(dialog_id) - return _candidate_view(row) if row else None - - -def set_candidate(task_id: str, dialog_id: str, patch: dict) -> dict: - """Обновить поля кандидата задачи (например, по результатам оценки). - - patch — значения в нотации кандидата (camelCase или snake_case): - participants, kind, langRu/lang_ru, marks, topics, fitRatio/fit_ratio, - autoJoined/auto_joined, name, username, hue. - """ - _task_or_raise(task_id) - row = _get_candidate(dialog_id) - if row is None or row["task_id"] != task_id: - raise KeyError(dialog_id) - - cols: dict = {} - for key, value in patch.items(): - col = _CANDIDATE_FIELDS.get(key) - if col is None: - continue - cols[col] = value - if not cols: - return _candidate_view(row) - if "marks" in cols: - cols["marks"] = _json([str(m) for m in cols["marks"]]) - if "topics" in cols: - cols["topics"] = _json(cols["topics"]) - for col in ("name", "username", "kind", "hue"): - if col in cols: - cols[col] = str(cols[col] or "").strip() or row[col] - if "participants" in cols and cols["participants"] is not None: - cols["participants"] = int(cols["participants"]) - - sets = ["updated_at = ?"] - params: list = [_now()] - for col, value in cols.items(): - sets.append(f"{col} = ?") - params.append(value) - params.append(dialog_id) - store.execute(f"UPDATE disc_candidates SET {', '.join(sets)} WHERE dialog_id = ?", params) - updated = _get_candidate(dialog_id) - return _candidate_view(updated) if updated else _candidate_view(row) - - -def set_candidate_status(dialog_id: str, status: str) -> dict: - """Перевести кандидата в new/review (+ лог review). - - joined/rejected меняются только через mark_joined()/mark_rejected() — - там счётчики задачи, чёрный список и лог join/reject. - """ - if status not in ("new", "review"): - raise ValueError(f"Статус {status} выставляется через mark_joined/mark_rejected") - row = _get_candidate(dialog_id) - if row is None: - raise KeyError(dialog_id) - store.execute( - "UPDATE disc_candidates SET status = ?, updated_at = ? WHERE dialog_id = ?", - [status, _now(), dialog_id], - ) - if status == "review": - add_log(row["task_id"], "review", f"кандидат {dialog_id} переведён в review") - updated = _get_candidate(dialog_id) - return _candidate_view(updated) if updated else _candidate_view(row) - - -def delete_candidate(dialog_id: str) -> None: - """Удалить кандидата (skip-ветки воркера; повторный вызов безопасен).""" - store.execute("DELETE FROM disc_candidates WHERE dialog_id = ?", [dialog_id]) - - -def mark_joined(dialog_id: str, auto: bool) -> dict: - """Источник вступил: status=joined, счётчик joined задачи, лог join_auto/join_manual.""" - row = _get_candidate(dialog_id) - if row is None: - raise KeyError(dialog_id) - if row["status"] == "joined": - return _candidate_view(row) # идемпотентно: повторно не считаем - now = _now() - store.execute( - "UPDATE disc_candidates SET status = 'joined', auto_joined = ?, updated_at = ? WHERE dialog_id = ?", - [bool(auto), now, dialog_id], - ) - bump_counter(row["task_id"], "joined") - add_log(row["task_id"], "join_auto" if auto else "join_manual", f"вступили в {dialog_id}") - updated = _get_candidate(dialog_id) - return _candidate_view(updated) if updated else _candidate_view(row) - - -def mark_rejected(dialog_id: str, reason: str = "") -> dict: - """Отклонить кандидата: status=rejected, счётчик rejected, лог reject, чёрный список. - - Повторный вызов для уже отклонённого — идемпотентен: счётчик/лог/чёрный - список не трогаются (кандидата мог отклонить и человек, и воркер). - """ - row = _get_candidate(dialog_id) - if row is None: - raise KeyError(dialog_id) - if row["status"] == "joined": - raise ValueError("Нельзя отклонить источник, в который уже вступили") - if row["status"] == "rejected": - return _candidate_view(row) # идемпотентно: повторно не считаем и не логируем - now = _now() - store.execute( - "UPDATE disc_candidates SET status = 'rejected', updated_at = ? WHERE dialog_id = ?", - [now, dialog_id], - ) - bump_counter(row["task_id"], "rejected") - add_log(row["task_id"], "reject", reason or f"отклонён {dialog_id}") - add_blacklist(dialog_id, row["name"], reason or "") - updated = _get_candidate(dialog_id) - return _candidate_view(updated) if updated else _candidate_view(row) - - -# ─── чёрный список ───────────────────────────────────────────────────────── - -def add_blacklist(dialog_id: str, name: str = "", reason: str = "") -> dict: - """Пометить источник в чёрном списке (существующая запись обновляется).""" - dialog_id = str(dialog_id) - now = _now() - store.execute( - "INSERT INTO disc_blacklist(dialog_id, name, reason, created_at) VALUES (?, ?, ?, ?) " - "ON CONFLICT(dialog_id) DO UPDATE SET name = excluded.name, reason = excluded.reason", - [dialog_id, str(name or "").strip() or dialog_id, str(reason or ""), now], - ) - row = store.query_one("SELECT * FROM disc_blacklist WHERE dialog_id = ?", [dialog_id]) - return _blacklist_view(row) if row else {"dialogId": dialog_id, "name": name, "reason": reason, "createdAt": now} - - -def remove_blacklist(dialog_id: str) -> None: - store.execute("DELETE FROM disc_blacklist WHERE dialog_id = ?", [dialog_id]) - - -def list_blacklist() -> list[dict]: - return [ - _blacklist_view(r) - for r in store.query("SELECT * FROM disc_blacklist ORDER BY created_at DESC") - ] - - -# ─── лог ─────────────────────────────────────────────────────────────────── - -def add_log(task_id: str, event: str, text: str = "") -> None: - store.execute( - "INSERT INTO disc_log(id, task_id, event, text, created_at) VALUES (?, ?, ?, ?, ?)", - [store.uid(_ID_LOG), task_id, str(event), str(text or ""), _now()], - ) - - -def task_log(task_id: str, limit: int = 100) -> list[dict]: - """Последние события задачи, новые сверху.""" - limit = max(1, min(500, int(limit))) - rows = store.query( - "SELECT * FROM disc_log WHERE task_id = ? ORDER BY created_at DESC LIMIT ?", - [task_id, limit], - ) - return [_log_view(r) for r in rows] +"""Discovery: хранилище задач поиска каналов, кандидатов, чёрного списка и лога. + +Единый слой доступа к disc_tasks / disc_candidates / disc_blacklist / disc_log +(Task 1). Потребляется и API (Task 7), и фоновым воркером (Task 6). + +Соглашения модуля: +- все обращения к БД только через `store.*` с параметрами (без конкатенации SQL); +- время — миллисекунды (`time.time_ns() // 1_000_000`); +- JSON-поля (keywords/marks/topics) в БД — VARCHAR, наружу всегда список + (`json.dumps(..., ensure_ascii=False)` при записи, `json.loads` при чтении); +- наружные dict-ы — camelCase (конвенция границы API проекта), поля совпадают + с колонками БД: keywords/minSubscribers/sampleSize/planJoins/autoJoin, + searchIdx/searchDone, fitRatio/autoJoined, createdAt/updatedAt, dialogId... +- create/patch принимают ключи и в camelCase, и в snake_case (поле "min_subscribers" + и т.п.) — на входе идёт нормализация к колонкам БД; +- статусы кандидата: new -> review -> joined|rejected. Переводы в joined/rejected + делаются ТОЛЬКО через mark_joined()/mark_rejected() (счётчики, чёрный список, + лог); set_candidate_status() разрешает new/review. + +Бюджет авто-вступлений: сумма plan_joins задач со статусом NOT IN ('done','failed') +плюс plan_joins новой/увеличиваемой задачи не должна превышать discJoinLimit. +""" +from __future__ import annotations + +import json +import time + +from ..db import store + +# префиксы id (конвенция: видно, из какой таблицы запись) +_ID_TASK = "dt_" +_ID_LOG = "dl_" + +# статусы кандидата, при которых повторное добавление источника запрещено +_ACTIVE_CANDIDATE = {"new", "review", "joined"} +# статусы задачи, которые НЕ занимают бюджет plan_joins +_DONE_TASK = {"done", "failed"} + +# алиасы полей задачи: колонка БД -> набор имён в payload/патче +_TASK_ALIASES = { + "min_subscribers": {"minSubscribers", "min_subscribers"}, + "sample_size": {"sampleSize", "sample_size"}, + "plan_joins": {"planJoins", "plan_joins"}, + "auto_join": {"autoJoin", "auto_join"}, + # остальные поля называются одинаково: name, description, keywords, lang, threshold +} +# поля кандидата, которые можно менять через set_candidate() +_CANDIDATE_FIELDS = { + "name": "name", + "username": "username", + "kind": "kind", + "hue": "hue", + "participants": "participants", + "lang_ru": "lang_ru", + "langRu": "lang_ru", + "marks": "marks", + "topics": "topics", + "fit_ratio": "fit_ratio", + "fitRatio": "fit_ratio", + "auto_joined": "auto_joined", + "autoJoined": "auto_joined", +} + + +def _now() -> int: + return time.time_ns() // 1_000_000 + + +def _json(value) -> str: + return json.dumps(value or [], ensure_ascii=False) + + +def _loads(raw, default: list | None = None) -> list: + try: + return json.loads(raw or "[]") + except (TypeError, ValueError): + return list(default or []) + + +def _plan_limit() -> int: + """Верхняя граница plan_joins: текущий суточный лимит discJoinLimit.""" + return max(1, int(store.get_setting("discJoinLimit") or 0)) + + +def _active_plan_sum(exclude_id: str | None = None) -> int: + sql = "SELECT coalesce(sum(plan_joins), 0) FROM disc_tasks WHERE status NOT IN ('done', 'failed')" + params: list = [] + if exclude_id: + sql += " AND id <> ?" + params.append(exclude_id) + return int(store.scalar(sql, params) or 0) + + +def _assert_plan(plan_joins: int) -> None: + """plan_joins >= 1 и не больше суточного лимита (настраивается в UI).""" + if plan_joins < 1: + raise ValueError("plan_joins должен быть не меньше 1") + limit = _plan_limit() + if plan_joins > limit: + raise ValueError(f"plan_joins {plan_joins} больше суточного лимита авто-вступлений ({limit})") + + +def _assert_budget(plan_joins: int, exclude_id: str | None = None) -> None: + """Правило бюджета: сумма планов активных задач + новая <= discJoinLimit.""" + limit = _plan_limit() + used = _active_plan_sum(exclude_id) + if used + plan_joins > limit: + raise ValueError( + f"Бюджет авто-вступлений исчерпан: задачи уже занимают {used} из {limit} в сутки, " + f"ещё {plan_joins} не влезает" + ) + + +# ─── view-слои (наружу camelCase) ────────────────────────────────────────── + +def _task_view(row: dict) -> dict: + return { + "id": row["id"], + "name": row["name"], + "description": row["description"], + "keywords": _loads(row["keywords"]), + "minSubscribers": int(row["min_subscribers"]), + "lang": row["lang"], + "threshold": int(row["threshold"]), + "sampleSize": int(row["sample_size"]), + "planJoins": int(row["plan_joins"]), + "autoJoin": bool(row["auto_join"]), + "status": row["status"], + "searchIdx": int(row["search_idx"]), + "searchDone": bool(row["search_done"]), + "found": int(row["found"]), + "evaluated": int(row["evaluated"]), + "joined": int(row["joined"]), + "rejected": int(row["rejected"]), + "createdAt": row["created_at"], + "updatedAt": row["updated_at"], + } + + +def _candidate_view(row: dict) -> dict: + return { + "dialogId": row["dialog_id"], + "taskId": row["task_id"], + "name": row["name"], + "username": row["username"], + "kind": row["kind"], + "hue": row["hue"], + "participants": row["participants"], + "langRu": row["lang_ru"], + "marks": _loads(row["marks"]), + "topics": _loads(row["topics"]), + "fitRatio": row["fit_ratio"], + "status": row["status"], + "autoJoined": bool(row["auto_joined"]), + "joinFailures": int(row.get("join_failures") or 0), + "createdAt": row["created_at"], + "updatedAt": row["updated_at"], + } + + +def _blacklist_view(row: dict) -> dict: + return { + "dialogId": row["dialog_id"], + "name": row["name"], + "reason": row["reason"], + "createdAt": row["created_at"], + } + + +def _log_view(row: dict) -> dict: + return { + "id": row["id"], + "taskId": row["task_id"], + "event": row["event"], + "text": row["text"], + "createdAt": row["created_at"], + } + + +# ─── задачи ──────────────────────────────────────────────────────────────── + +def _task_or_raise(task_id: str) -> dict: + row = store.query_one("SELECT * FROM disc_tasks WHERE id = ?", [task_id]) + if row is None: + raise KeyError(task_id) + return row + + +def list_tasks() -> list[dict]: + """Все задачи, старые первыми (воркер берёт самую старую running).""" + return [_task_view(r) for r in store.query("SELECT * FROM disc_tasks ORDER BY created_at ASC")] + + +def get_task(task_id: str) -> dict | None: + row = store.query_one("SELECT * FROM disc_tasks WHERE id = ?", [task_id]) + return _task_view(row) if row else None + + +def _norm_task_values(patch: dict) -> dict: + """Нормализация payload/патча задачи (camel/snake алиасы) к колонкам БД.""" + out: dict = {} + for key, value in patch.items(): + if key in ("name", "description", "keywords", "lang", "threshold"): + col = key + else: + col = next((c for c, aliases in _TASK_ALIASES.items() if key in aliases), None) + if col is None: + continue # неизвестное поле игнорируем + out[col] = value + return out + + +def _validate_task_values(cols: dict) -> None: + """Проверка границ значений задачи (после нормализации).""" + if "threshold" in cols: + cols["threshold"] = max(1, min(100, int(cols["threshold"]))) + if "sample_size" in cols: + cols["sample_size"] = max(1, int(cols["sample_size"])) + if "min_subscribers" in cols: + cols["min_subscribers"] = max(0, int(cols["min_subscribers"])) + if "lang" in cols and cols["lang"] not in ("ru", "any"): + cols["lang"] = "ru" + if "auto_join" in cols: + cols["auto_join"] = bool(cols["auto_join"]) + if "keywords" in cols: + keywords = cols["keywords"] if isinstance(cols["keywords"], list) else [cols["keywords"]] + cols["keywords"] = [str(k).strip() for k in keywords if str(k).strip()] + if "description" in cols: + cols["description"] = str(cols["description"] or "") + if "name" in cols: + cols["name"] = str(cols["name"] or "").strip() + + +def create_task(payload: dict) -> dict: + """Создать задачу поиска. + + Валидация: name непустое; plan_joins 1..discJoinLimit; правило бюджета + (сумма plan_joins активных задач + новая <= discJoinLimit) — иначе ValueError. + """ + cols = _norm_task_values(payload) + name = str(cols.get("name") or "").strip() + if not name: + raise ValueError("Укажите название задачи") + plan_joins = int(cols.get("plan_joins", 1)) + _assert_plan(plan_joins) + _assert_budget(plan_joins) + + values = { + "name": name, + "description": str(cols.get("description") or ""), + "keywords": cols.get("keywords", []), + "min_subscribers": max(0, int(cols.get("min_subscribers", 0))), + "lang": cols.get("lang", "ru"), + "threshold": max(1, min(100, int(cols.get("threshold", int(store.get_setting("discEvalThreshold") or 40))))), + "sample_size": max(1, int(cols.get("sample_size", int(store.get_setting("discEvalSample") or 10)))), + "plan_joins": plan_joins, + "auto_join": bool(cols.get("auto_join", False)), + } + _validate_task_values(values) + task_id = store.uid(_ID_TASK) + now = _now() + store.execute( + "INSERT INTO disc_tasks(id, name, description, keywords, min_subscribers, lang, threshold, " + "sample_size, plan_joins, auto_join, status, search_idx, search_done, found, evaluated, " + "joined, rejected, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 0, FALSE, 0, 0, 0, 0, ?, ?)", + [ + task_id, + values["name"], + values["description"], + _json(values["keywords"]), + values["min_subscribers"], + values["lang"], + values["threshold"], + values["sample_size"], + values["plan_joins"], + values["auto_join"], + now, + now, + ], + ) + return get_task(task_id) # type: ignore[return-value] + + +def patch_task(task_id: str, patch: dict) -> dict: + """Обновить поля задачи. Увеличение plan_joins — с проверкой бюджета.""" + row = _task_or_raise(task_id) + cols = _norm_task_values(patch) + if not cols: + return get_task(task_id) # type: ignore[return-value] + + old_plan = int(row["plan_joins"]) + if "plan_joins" in cols: + new_plan = int(cols["plan_joins"]) + _assert_plan(new_plan) + if new_plan > old_plan: + _assert_budget(new_plan, exclude_id=task_id) + cols["plan_joins"] = new_plan + _validate_task_values(cols) + + sets = ["updated_at = ?"] + params: list = [_now()] + for col, value in cols.items(): + sets.append(f"{col} = ?") + params.append(_json(value) if col == "keywords" else value) + params.append(task_id) + store.execute( + f"UPDATE disc_tasks SET {', '.join(sets)} WHERE id = ?", + params, + ) + return get_task(task_id) # type: ignore[return-value] + + +def delete_task(task_id: str) -> None: + """Удалить задачу вместе с её кандидатами и логом (чёрный список общий).""" + store.execute("DELETE FROM disc_tasks WHERE id = ?", [task_id]) + store.execute("DELETE FROM disc_candidates WHERE task_id = ?", [task_id]) + store.execute("DELETE FROM disc_log WHERE task_id = ?", [task_id]) + + +def start_task(task_id: str) -> dict: + """Запустить поиск: keywords непустые; status=running. + + При повторном запуске завершённой/упавшей задачи прогресс поиска обнуляется + (свежий проход по ключам); при продолжении из paused — сохраняется. + """ + row = _task_or_raise(task_id) + keywords = _loads(row["keywords"]) + if not keywords: + raise ValueError("Нет ключевых слов для поиска — добавьте их в задачу") + now = _now() + reset = row["status"] in _DONE_TASK + if reset: + # повторный прогон завершённой/упавшей задачи — свежий проход по ключам + store.execute( + "UPDATE disc_tasks SET status = 'running', search_idx = 0, search_done = FALSE, " + "found = 0, evaluated = 0, joined = 0, rejected = 0, updated_at = ? WHERE id = ?", + [now, task_id], + ) + else: + # старт из draft или продолжение из paused — прогресс поиска сохраняется + store.execute( + "UPDATE disc_tasks SET status = 'running', updated_at = ? WHERE id = ?", + [now, task_id], + ) + return get_task(task_id) # type: ignore[return-value] + + +def pause_task(task_id: str) -> dict: + """Поставить задачу на паузу.""" + _task_or_raise(task_id) + store.execute( + "UPDATE disc_tasks SET status = 'paused', updated_at = ? WHERE id = ?", + [_now(), task_id], + ) + return get_task(task_id) # type: ignore[return-value] + + +def bump_counter(task_id: str, field: str, n: int = 1) -> None: + """Увеличить счётчик задачи: found|evaluated|joined|rejected.""" + if field not in ("found", "evaluated", "joined", "rejected"): + raise ValueError(f"Неизвестный счётчик задачи: {field}") + row = _task_or_raise(task_id) + row[field] = int(row[field]) + max(0, int(n)) + store.execute( + f"UPDATE disc_tasks SET {field} = ?, updated_at = ? WHERE id = ?", + [row[field], _now(), task_id], + ) + + +def advance_search(task_id: str) -> None: + """Перейти к следующему ключу; когда search_idx >= len(keywords) — search_done=True.""" + row = _task_or_raise(task_id) + keywords = _loads(row["keywords"]) + new_idx = int(row["search_idx"]) + 1 + done = new_idx >= len(keywords) + store.execute( + "UPDATE disc_tasks SET search_idx = ?, search_done = ?, updated_at = ? WHERE id = ?", + [new_idx, done, _now(), task_id], + ) + + +# ─── кандидаты ───────────────────────────────────────────────────────────── + +def list_candidates(task_id: str, status: str | None = None) -> list[dict]: + """Кандидаты задачи (marks/topics уже списки); status — фильтр.""" + if status: + rows = store.query( + "SELECT * FROM disc_candidates WHERE task_id = ? AND status = ? ORDER BY created_at ASC", + [task_id, status], + ) + else: + rows = store.query( + "SELECT * FROM disc_candidates WHERE task_id = ? ORDER BY created_at ASC", + [task_id], + ) + return [_candidate_view(r) for r in rows] + + +def _get_candidate(dialog_id: str) -> dict | None: + row = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id]) + return row + + +def _skip(task_id: str, reason: str) -> None: + add_log(task_id, "skip", reason) + + +def add_candidate(task_id: str, dialog_id: str, name: str, username: str, kind: str, hue: str) -> dict | None: + """Добавить найденный источник как кандидата задачи. + + None (с логом skip), если источник уже мониторится (есть в dialogs), в + чёрном списке или уже добавлен в статусе new/review/joined. Прежняя запись + со статусом rejected (например, после remove_blacklist) заменяется новой. + """ + _task_or_raise(task_id) + dialog_id = str(dialog_id) + if store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id]): + _skip(task_id, f"пропущен {dialog_id}: источник уже мониторится (мы состоим)") + return None + if store.scalar("SELECT 1 FROM disc_blacklist WHERE dialog_id = ? LIMIT 1", [dialog_id]): + _skip(task_id, f"пропущен {dialog_id}: источник в чёрном списке") + return None + existing = store.query_one( + "SELECT status FROM disc_candidates WHERE dialog_id = ?", + [dialog_id], + ) + if existing and existing["status"] in _ACTIVE_CANDIDATE: + _skip(task_id, f"пропущен {dialog_id}: кандидат уже есть (статус {existing['status']})") + return None + if existing: + # устаревшая rejected-запись: перезаписываем как новый кандидат + store.execute("DELETE FROM disc_candidates WHERE dialog_id = ?", [dialog_id]) + + now = _now() + store.execute( + "INSERT INTO disc_candidates(dialog_id, task_id, name, username, kind, hue, participants, " + "lang_ru, marks, topics, fit_ratio, status, auto_joined, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, '[]', '[]', NULL, 'new', FALSE, ?, ?)", + [ + dialog_id, + task_id, + str(name or "").strip() or dialog_id, + str(username or "").strip(), + str(kind or "channel"), + str(hue or "#666"), + now, + now, + ], + ) + bump_counter(task_id, "found") + row = _get_candidate(dialog_id) + return _candidate_view(row) if row else None + + +def set_candidate(task_id: str, dialog_id: str, patch: dict) -> dict: + """Обновить поля кандидата задачи (например, по результатам оценки). + + patch — значения в нотации кандидата (camelCase или snake_case): + participants, kind, langRu/lang_ru, marks, topics, fitRatio/fit_ratio, + autoJoined/auto_joined, name, username, hue. + """ + _task_or_raise(task_id) + row = _get_candidate(dialog_id) + if row is None or row["task_id"] != task_id: + raise KeyError(dialog_id) + + cols: dict = {} + for key, value in patch.items(): + col = _CANDIDATE_FIELDS.get(key) + if col is None: + continue + cols[col] = value + if not cols: + return _candidate_view(row) + if "marks" in cols: + cols["marks"] = _json([str(m) for m in cols["marks"]]) + if "topics" in cols: + cols["topics"] = _json(cols["topics"]) + for col in ("name", "username", "kind", "hue"): + if col in cols: + cols[col] = str(cols[col] or "").strip() or row[col] + if "participants" in cols and cols["participants"] is not None: + cols["participants"] = int(cols["participants"]) + + sets = ["updated_at = ?"] + params: list = [_now()] + for col, value in cols.items(): + sets.append(f"{col} = ?") + params.append(value) + params.append(dialog_id) + store.execute(f"UPDATE disc_candidates SET {', '.join(sets)} WHERE dialog_id = ?", params) + updated = _get_candidate(dialog_id) + return _candidate_view(updated) if updated else _candidate_view(row) + + +def set_candidate_status(dialog_id: str, status: str) -> dict: + """Перевести кандидата в new/review (+ лог review). + + joined/rejected меняются только через mark_joined()/mark_rejected() — + там счётчики задачи, чёрный список и лог join/reject. + """ + if status not in ("new", "review"): + raise ValueError(f"Статус {status} выставляется через mark_joined/mark_rejected") + row = _get_candidate(dialog_id) + if row is None: + raise KeyError(dialog_id) + store.execute( + "UPDATE disc_candidates SET status = ?, updated_at = ? WHERE dialog_id = ?", + [status, _now(), dialog_id], + ) + if status == "review": + add_log(row["task_id"], "review", f"кандидат {dialog_id} переведён в review") + updated = _get_candidate(dialog_id) + return _candidate_view(updated) if updated else _candidate_view(row) + + +def delete_candidate(dialog_id: str) -> None: + """Удалить кандидата (skip-ветки воркера; повторный вызов безопасен).""" + store.execute("DELETE FROM disc_candidates WHERE dialog_id = ?", [dialog_id]) + + +def mark_joined(dialog_id: str, auto: bool) -> dict: + """Источник вступил: status=joined, счётчик joined задачи, лог join_auto/join_manual.""" + row = _get_candidate(dialog_id) + if row is None: + raise KeyError(dialog_id) + if row["status"] == "joined": + return _candidate_view(row) # идемпотентно: повторно не считаем + now = _now() + store.execute( + "UPDATE disc_candidates SET status = 'joined', auto_joined = ?, updated_at = ? WHERE dialog_id = ?", + [bool(auto), now, dialog_id], + ) + bump_counter(row["task_id"], "joined") + add_log(row["task_id"], "join_auto" if auto else "join_manual", f"вступили в {dialog_id}") + updated = _get_candidate(dialog_id) + return _candidate_view(updated) if updated else _candidate_view(row) + + +def mark_rejected(dialog_id: str, reason: str = "") -> dict: + """Отклонить кандидата: status=rejected, счётчик rejected, лог reject, чёрный список. + + Повторный вызов для уже отклонённого — идемпотентен: счётчик/лог/чёрный + список не трогаются (кандидата мог отклонить и человек, и воркер). + """ + row = _get_candidate(dialog_id) + if row is None: + raise KeyError(dialog_id) + if row["status"] == "joined": + raise ValueError("Нельзя отклонить источник, в который уже вступили") + if row["status"] == "rejected": + return _candidate_view(row) # идемпотентно: повторно не считаем и не логируем + now = _now() + store.execute( + "UPDATE disc_candidates SET status = 'rejected', updated_at = ? WHERE dialog_id = ?", + [now, dialog_id], + ) + bump_counter(row["task_id"], "rejected") + add_log(row["task_id"], "reject", reason or f"отклонён {dialog_id}") + add_blacklist(dialog_id, row["name"], reason or "") + updated = _get_candidate(dialog_id) + return _candidate_view(updated) if updated else _candidate_view(row) + + +# ─── чёрный список ───────────────────────────────────────────────────────── + +def add_blacklist(dialog_id: str, name: str = "", reason: str = "") -> dict: + """Пометить источник в чёрном списке (существующая запись обновляется).""" + dialog_id = str(dialog_id) + now = _now() + store.execute( + "INSERT INTO disc_blacklist(dialog_id, name, reason, created_at) VALUES (?, ?, ?, ?) " + "ON CONFLICT(dialog_id) DO UPDATE SET name = excluded.name, reason = excluded.reason", + [dialog_id, str(name or "").strip() or dialog_id, str(reason or ""), now], + ) + row = store.query_one("SELECT * FROM disc_blacklist WHERE dialog_id = ?", [dialog_id]) + return _blacklist_view(row) if row else {"dialogId": dialog_id, "name": name, "reason": reason, "createdAt": now} + + +def remove_blacklist(dialog_id: str) -> None: + store.execute("DELETE FROM disc_blacklist WHERE dialog_id = ?", [dialog_id]) + + +def list_blacklist() -> list[dict]: + return [ + _blacklist_view(r) + for r in store.query("SELECT * FROM disc_blacklist ORDER BY created_at DESC") + ] + + +# ─── лог ─────────────────────────────────────────────────────────────────── + +def add_log(task_id: str, event: str, text: str = "") -> None: + store.execute( + "INSERT INTO disc_log(id, task_id, event, text, created_at) VALUES (?, ?, ?, ?, ?)", + [store.uid(_ID_LOG), task_id, str(event), str(text or ""), _now()], + ) + + +def task_log(task_id: str, limit: int = 100) -> list[dict]: + """Последние события задачи, новые сверху.""" + limit = max(1, min(500, int(limit))) + rows = store.query( + "SELECT * FROM disc_log WHERE task_id = ? ORDER BY created_at DESC LIMIT ?", + [task_id, limit], + ) + return [_log_view(r) for r in rows] diff --git a/archive/leadradar-legacy/backend/app/services/discovery_eval.py b/archive/leadradar-legacy/backend/app/services/discovery_eval.py index fe42e2a..7d83535 100644 --- a/archive/leadradar-legacy/backend/app/services/discovery_eval.py +++ b/archive/leadradar-legacy/backend/app/services/discovery_eval.py @@ -1,237 +1,237 @@ -"""Discovery: оценка контента — язык, темы, fit сообщений под задачу поиска. - -Чистая логика оценки (без карточек, очередей и обучения): - * detect_lang_ru — доля кириллических букв среди всех букв выборки; - * group_by_topic — группировка выборки по topic_id (None -> "main") - для форумов: {topic_id, title, messages: [...]}; - * evaluate_message — fit одного сообщения под задачу: каскад - короткое -> ML-спам -> ИИ -> эвристика; - * evaluate_sample — агрегат по выборке сообщений кандидата; - * passed — вердикт «источник подходит» (объём выборки + доля fit). - -Каскад оценки сообщения (evaluate_message): - 1) текст пустой/короче 10 символов -> False «слишком короткое»; - 2) ML: ml_client.is_enabled() и прогноз take + label=='spam' -> False; - 3) ИИ: если включён (aiEnabled) и доступен ключ/локальный провайдер — один - JSON-вызов ai_service.chat_json({fit, reason}); любая ошибка (нет ключа, - сеть, не-JSON) ловится и оценка продолжается эвристикой; - 4) эвристика: любой ключ задачи входит в clean_short(text).casefold(). - -task — внешний dict в camelCase (конвенция discovery.Task 3): description, -keywords (list[str]), lang, threshold, sampleSize. Иные ключи игнорируются, -поэтому сюда можно передавать и полный view задачи из discovery.get_task. -""" -from __future__ import annotations - -import logging - -from ..db import store -from . import ai as ai_service -from . import ml_client -from .pipeline import clean_short - -log = logging.getLogger("leadradar.discovery_eval") - -# пороги доли кириллицы (задача ru-языка): >= hi -> True, <= lo -> False -_RU_RATIO_HI = 0.15 -_RU_RATIO_LO = 0.03 -# минимальная длина сообщения для содержательной оценки -_MIN_TEXT_LEN = 10 -# ограничение текста, уходящего провайдеру (в одном сообщении больше не нужно) -_AI_TEXT_LIMIT = 4000 -# потолок причины из ИИ (в UI не нужны простыни) -_AI_REASON_LIMIT = 200 -# длина title темы (сниппет первого текста) -_TITLE_LIMIT = 60 -# topic_id=None в выборке/группировке -> общая тема ("main") -_MAIN_TOPIC = "main" - -# промпт ИИ-оценки (задача: относится ли сообщение к сфере/описанию) -_AI_PROMPT = ( - "Оцени, относится ли сообщение к сфере/задаче. Описание: {description}. " - "Ключи: {keywords}. " - "Верни JSON {{\"fit\": 0|1, \"reason\": \"краткая причина\"}}." -) - - -def _is_cyrillic(ch: str) -> bool: - """Кириллица ли символ (базовый блок U+0400–U+04FF, включает ё/ў и т.п.).""" - return 0x0400 <= ord(ch) <= 0x04FF - - -def detect_lang_ru(texts: list[str]) -> bool | None: - """Доля кириллицы среди всех букв выборки: >=0.15 True, <=0.03 False, иначе None. - - None означает «неопределённо» — воркер не отсекает кандидата, а помечает - язык как неподтверждённый. Пустая выборка без букв тоже даёт None. - """ - letters = 0 - cyr = 0 - for t in texts or []: - for ch in str(t or ""): - if ch.isalpha(): - letters += 1 - if _is_cyrillic(ch): - cyr += 1 - if letters == 0: - return None - ratio = cyr / letters - if ratio >= _RU_RATIO_HI: - return True - if ratio <= _RU_RATIO_LO: - return False - return None - - -def _topic_title(messages: list[dict]) -> str: - """Сниппет первого непустого текста темы (<=60 симв., whitespace схлопнут).""" - for m in messages: - text = str(m.get("text") or "").strip() - if text: - text = " ".join(text.split()) - return text[:_TITLE_LIMIT] - return "" - - -def group_by_topic(messages: list[dict]) -> list[dict]: - """Группирует сообщения по topic_id (None -> "main"), сортирует группы по - числу сообщений (убыв.), порядок сообщений внутри группы — входной. - - Возвращает [{"topic_id", "title", "messages": [...]}], где title — сниппет - первого текста темы (первое непустое сообщение во входном порядке). - """ - groups: dict[str, list[dict]] = {} - order: list[str] = [] - for m in messages or []: - tid = m.get("topic_id") - key = _MAIN_TOPIC if tid is None else tid - if key not in groups: - groups[key] = [] - order.append(key) - groups[key].append(m) - out = [ - {"topic_id": key, "title": _topic_title(groups[key]), "messages": groups[key]} - for key in order - ] - out.sort(key=lambda g: len(g["messages"]), reverse=True) - return out - - -def _keywords(task: dict) -> list[str]: - kws = task.get("keywords") or [] - if isinstance(kws, str): - kws = [kws] - return [str(k).strip() for k in kws if str(k).strip()] - - -def _ai_usable() -> bool: - """ИИ-ветка доступна: полный выключатель включён и есть ключ/локальный сервер. - - Локальные OpenAI-совместимые (Ollama/LM Studio) работают без ключа, поэтому - для них проверка ключа не требуется. Если статус провайдера прочитать нельзя - (нет БД/настроек) — считаем ИИ недоступным, чтобы не дёргать сеть впустую. - """ - if not store.get_setting("aiEnabled"): - return False - try: - status = ai_service.provider_status() - except Exception as exc: # noqa: BLE001 — отсутствие статуса = ИИ недоступен - log.debug("discovery eval: статус ИИ недоступен (%s) — эвристика", exc) - return False - return bool(status.get("local") or status.get("keySet")) - - -def _heuristic(task: dict, text: str) -> dict: - """Эвристика: ключ задачи входит в очищенный текст (без учёта регистра).""" - hay = clean_short(text).casefold() - for kw in _keywords(task): - if kw and kw.casefold() in hay: - return {"fit": True, "reason": f'совпал ключ "{kw}"', "source": "heuristic"} - return {"fit": False, "reason": "нет совпадений с ключами", "source": "heuristic"} - - -def _ai_prompt(task: dict) -> str: - description = str(task.get("description") or "").strip() - return _AI_PROMPT.format(description=description, keywords=", ".join(_keywords(task))) - - -def _ai_fit(out: dict) -> bool: - """fit из JSON-ответа ИИ: 1/true/«да»-подобные -> True, иначе False.""" - v = out.get("fit") - if isinstance(v, str): - s = v.strip().casefold() - return bool(s) and s not in {"0", "false", "no", "нет", "null", "none"} - return bool(v) - - -def _ai_reason(out: dict) -> str: - reason = str(out.get("reason") or "").strip() - if not reason: - reason = "подходит" if _ai_fit(out) else "не подходит" - return reason[:_AI_REASON_LIMIT] - - -async def evaluate_message(task: dict, text: str) -> dict: - """Оценка fit одного сообщения. Возвращает {"fit", "reason", "source"}.""" - raw = str(text or "") - if len(raw.strip()) < _MIN_TEXT_LEN: - return {"fit": False, "reason": "слишком короткое", "source": "heuristic"} - - # ML: уверенный спам отсекаем без обращения к ИИ (когда ML доступен) - if ml_client.is_enabled(): - pred = await ml_client.predict(raw) - if pred.get("take") and pred.get("label") == "spam": - return {"fit": False, "reason": "ML: спам", "source": "ml"} - - # ИИ: один JSON-вызов; любая ошибка провайдера -> шаг эвристики ниже - if _ai_usable(): - try: - out = await ai_service.chat_json(_ai_prompt(task), f"Сообщение:\n{raw[:_AI_TEXT_LIMIT]}") - return {"fit": _ai_fit(out), "reason": _ai_reason(out), "source": "ai"} - except Exception as exc: # noqa: BLE001 — сбой ИИ не роняет оценку - log.debug("discovery eval: ИИ не ответил (%s) — эвристика", exc) - - return _heuristic(task, raw) - - -async def evaluate_sample(task: dict, messages: list[dict]) -> dict: - """Последовательная оценка всех сообщений выборки кандидата. - - Возвращает {"fit_count", "total", "fit_ratio", "per_message": [...]} с - per_message [{"text", "fit", "reason", "topic_id"}] (topic_id None -> "main", - чтобы per_message стыковался с ключами group_by_topic). - """ - per_message = [] - fit_count = 0 - for m in messages or []: - text = str(m.get("text") or "") - res = await evaluate_message(task, text) - tid = m.get("topic_id") - per_message.append( - { - "text": text, - "fit": bool(res["fit"]), - "reason": str(res["reason"]), - "topic_id": _MAIN_TOPIC if tid is None else tid, - } - ) - if res["fit"]: - fit_count += 1 - total = len(per_message) - return { - "fit_count": fit_count, - "total": total, - "fit_ratio": (fit_count / total) if total else 0.0, - "per_message": per_message, - } - - -def passed(ev: dict, task: dict) -> bool: - """Вердикт «источник подходит»: выборки >=3 сообщений и доля fit >= threshold (%). - - Сообщения не обязаны быть «чистыми»: каналы часто разбавляют полезный - контент офтопом, поэтому достаточно доли, а не сплошного соответствия. - """ - total = int(ev.get("total") or 0) - ratio = float(ev.get("fit_ratio") or 0.0) - return total >= 3 and ratio * 100 >= int(task.get("threshold") or 0) +"""Discovery: оценка контента — язык, темы, fit сообщений под задачу поиска. + +Чистая логика оценки (без карточек, очередей и обучения): + * detect_lang_ru — доля кириллических букв среди всех букв выборки; + * group_by_topic — группировка выборки по topic_id (None -> "main") + для форумов: {topic_id, title, messages: [...]}; + * evaluate_message — fit одного сообщения под задачу: каскад + короткое -> ML-спам -> ИИ -> эвристика; + * evaluate_sample — агрегат по выборке сообщений кандидата; + * passed — вердикт «источник подходит» (объём выборки + доля fit). + +Каскад оценки сообщения (evaluate_message): + 1) текст пустой/короче 10 символов -> False «слишком короткое»; + 2) ML: ml_client.is_enabled() и прогноз take + label=='spam' -> False; + 3) ИИ: если включён (aiEnabled) и доступен ключ/локальный провайдер — один + JSON-вызов ai_service.chat_json({fit, reason}); любая ошибка (нет ключа, + сеть, не-JSON) ловится и оценка продолжается эвристикой; + 4) эвристика: любой ключ задачи входит в clean_short(text).casefold(). + +task — внешний dict в camelCase (конвенция discovery.Task 3): description, +keywords (list[str]), lang, threshold, sampleSize. Иные ключи игнорируются, +поэтому сюда можно передавать и полный view задачи из discovery.get_task. +""" +from __future__ import annotations + +import logging + +from ..db import store +from . import ai as ai_service +from . import ml_client +from .pipeline import clean_short + +log = logging.getLogger("leadradar.discovery_eval") + +# пороги доли кириллицы (задача ru-языка): >= hi -> True, <= lo -> False +_RU_RATIO_HI = 0.15 +_RU_RATIO_LO = 0.03 +# минимальная длина сообщения для содержательной оценки +_MIN_TEXT_LEN = 10 +# ограничение текста, уходящего провайдеру (в одном сообщении больше не нужно) +_AI_TEXT_LIMIT = 4000 +# потолок причины из ИИ (в UI не нужны простыни) +_AI_REASON_LIMIT = 200 +# длина title темы (сниппет первого текста) +_TITLE_LIMIT = 60 +# topic_id=None в выборке/группировке -> общая тема ("main") +_MAIN_TOPIC = "main" + +# промпт ИИ-оценки (задача: относится ли сообщение к сфере/описанию) +_AI_PROMPT = ( + "Оцени, относится ли сообщение к сфере/задаче. Описание: {description}. " + "Ключи: {keywords}. " + "Верни JSON {{\"fit\": 0|1, \"reason\": \"краткая причина\"}}." +) + + +def _is_cyrillic(ch: str) -> bool: + """Кириллица ли символ (базовый блок U+0400–U+04FF, включает ё/ў и т.п.).""" + return 0x0400 <= ord(ch) <= 0x04FF + + +def detect_lang_ru(texts: list[str]) -> bool | None: + """Доля кириллицы среди всех букв выборки: >=0.15 True, <=0.03 False, иначе None. + + None означает «неопределённо» — воркер не отсекает кандидата, а помечает + язык как неподтверждённый. Пустая выборка без букв тоже даёт None. + """ + letters = 0 + cyr = 0 + for t in texts or []: + for ch in str(t or ""): + if ch.isalpha(): + letters += 1 + if _is_cyrillic(ch): + cyr += 1 + if letters == 0: + return None + ratio = cyr / letters + if ratio >= _RU_RATIO_HI: + return True + if ratio <= _RU_RATIO_LO: + return False + return None + + +def _topic_title(messages: list[dict]) -> str: + """Сниппет первого непустого текста темы (<=60 симв., whitespace схлопнут).""" + for m in messages: + text = str(m.get("text") or "").strip() + if text: + text = " ".join(text.split()) + return text[:_TITLE_LIMIT] + return "" + + +def group_by_topic(messages: list[dict]) -> list[dict]: + """Группирует сообщения по topic_id (None -> "main"), сортирует группы по + числу сообщений (убыв.), порядок сообщений внутри группы — входной. + + Возвращает [{"topic_id", "title", "messages": [...]}], где title — сниппет + первого текста темы (первое непустое сообщение во входном порядке). + """ + groups: dict[str, list[dict]] = {} + order: list[str] = [] + for m in messages or []: + tid = m.get("topic_id") + key = _MAIN_TOPIC if tid is None else tid + if key not in groups: + groups[key] = [] + order.append(key) + groups[key].append(m) + out = [ + {"topic_id": key, "title": _topic_title(groups[key]), "messages": groups[key]} + for key in order + ] + out.sort(key=lambda g: len(g["messages"]), reverse=True) + return out + + +def _keywords(task: dict) -> list[str]: + kws = task.get("keywords") or [] + if isinstance(kws, str): + kws = [kws] + return [str(k).strip() for k in kws if str(k).strip()] + + +def _ai_usable() -> bool: + """ИИ-ветка доступна: полный выключатель включён и есть ключ/локальный сервер. + + Локальные OpenAI-совместимые (Ollama/LM Studio) работают без ключа, поэтому + для них проверка ключа не требуется. Если статус провайдера прочитать нельзя + (нет БД/настроек) — считаем ИИ недоступным, чтобы не дёргать сеть впустую. + """ + if not store.get_setting("aiEnabled"): + return False + try: + status = ai_service.provider_status() + except Exception as exc: # noqa: BLE001 — отсутствие статуса = ИИ недоступен + log.debug("discovery eval: статус ИИ недоступен (%s) — эвристика", exc) + return False + return bool(status.get("local") or status.get("keySet")) + + +def _heuristic(task: dict, text: str) -> dict: + """Эвристика: ключ задачи входит в очищенный текст (без учёта регистра).""" + hay = clean_short(text).casefold() + for kw in _keywords(task): + if kw and kw.casefold() in hay: + return {"fit": True, "reason": f'совпал ключ "{kw}"', "source": "heuristic"} + return {"fit": False, "reason": "нет совпадений с ключами", "source": "heuristic"} + + +def _ai_prompt(task: dict) -> str: + description = str(task.get("description") or "").strip() + return _AI_PROMPT.format(description=description, keywords=", ".join(_keywords(task))) + + +def _ai_fit(out: dict) -> bool: + """fit из JSON-ответа ИИ: 1/true/«да»-подобные -> True, иначе False.""" + v = out.get("fit") + if isinstance(v, str): + s = v.strip().casefold() + return bool(s) and s not in {"0", "false", "no", "нет", "null", "none"} + return bool(v) + + +def _ai_reason(out: dict) -> str: + reason = str(out.get("reason") or "").strip() + if not reason: + reason = "подходит" if _ai_fit(out) else "не подходит" + return reason[:_AI_REASON_LIMIT] + + +async def evaluate_message(task: dict, text: str) -> dict: + """Оценка fit одного сообщения. Возвращает {"fit", "reason", "source"}.""" + raw = str(text or "") + if len(raw.strip()) < _MIN_TEXT_LEN: + return {"fit": False, "reason": "слишком короткое", "source": "heuristic"} + + # ML: уверенный спам отсекаем без обращения к ИИ (когда ML доступен) + if ml_client.is_enabled(): + pred = await ml_client.predict(raw) + if pred.get("take") and pred.get("label") == "spam": + return {"fit": False, "reason": "ML: спам", "source": "ml"} + + # ИИ: один JSON-вызов; любая ошибка провайдера -> шаг эвристики ниже + if _ai_usable(): + try: + out = await ai_service.chat_json(_ai_prompt(task), f"Сообщение:\n{raw[:_AI_TEXT_LIMIT]}") + return {"fit": _ai_fit(out), "reason": _ai_reason(out), "source": "ai"} + except Exception as exc: # noqa: BLE001 — сбой ИИ не роняет оценку + log.debug("discovery eval: ИИ не ответил (%s) — эвристика", exc) + + return _heuristic(task, raw) + + +async def evaluate_sample(task: dict, messages: list[dict]) -> dict: + """Последовательная оценка всех сообщений выборки кандидата. + + Возвращает {"fit_count", "total", "fit_ratio", "per_message": [...]} с + per_message [{"text", "fit", "reason", "topic_id"}] (topic_id None -> "main", + чтобы per_message стыковался с ключами group_by_topic). + """ + per_message = [] + fit_count = 0 + for m in messages or []: + text = str(m.get("text") or "") + res = await evaluate_message(task, text) + tid = m.get("topic_id") + per_message.append( + { + "text": text, + "fit": bool(res["fit"]), + "reason": str(res["reason"]), + "topic_id": _MAIN_TOPIC if tid is None else tid, + } + ) + if res["fit"]: + fit_count += 1 + total = len(per_message) + return { + "fit_count": fit_count, + "total": total, + "fit_ratio": (fit_count / total) if total else 0.0, + "per_message": per_message, + } + + +def passed(ev: dict, task: dict) -> bool: + """Вердикт «источник подходит»: выборки >=3 сообщений и доля fit >= threshold (%). + + Сообщения не обязаны быть «чистыми»: каналы часто разбавляют полезный + контент офтопом, поэтому достаточно доли, а не сплошного соответствия. + """ + total = int(ev.get("total") or 0) + ratio = float(ev.get("fit_ratio") or 0.0) + return total >= 3 and ratio * 100 >= int(task.get("threshold") or 0) diff --git a/archive/leadradar-legacy/backend/app/services/discovery_worker.py b/archive/leadradar-legacy/backend/app/services/discovery_worker.py index 5033435..b5b6c1a 100644 --- a/archive/leadradar-legacy/backend/app/services/discovery_worker.py +++ b/archive/leadradar-legacy/backend/app/services/discovery_worker.py @@ -1,484 +1,484 @@ -"""Discovery worker: поиск → оценка → авто-вступление (Task 6). - -Фоновый цикл main._discovery_loop вызывает `tick()` каждые ~5 секунд; каждый -вызов выполняет ОДНО действие для самой старой running-задачи и возвращает -{"action": "search"|"review"|"skip"|"join"|"reject"|"flood"|"error"|"done"|"none", - "taskId": ...}. Если работы нет — {"action": "none"}. - -Приоритеты внутри tick: - 0. ban_guard.global_paused() — ручной стоп-кран: возвращаем none; - 1. задача достигла плана вступлений (joined >= planJoins) → status=done - (+ лог done) — занимаемый ею бюджет планов освобождается сразу; - 2. шаг поиска: search_done=False → следующий ключ keywords[search_idx], - tg.discovery_search, каждый результат — discovery.add_candidate, - discovery.advance_search; при переходе search_done=True — лог search - «поиск завершён: N кандидатов» (N = счётчик found задачи). Личные чаты/ - боты (kind «чат») пропускаются (лог skip). FloodWaitError → note_flood + - лог flood; прочие ошибки поиска → лог error; индекс ключей не двигается - (тик повторит ключ позже), но после 3 ошибок подряд ключ пропускается - (advance_search + лог error «ключ пропущен») — битый ключ не должен - зацикливать поиск навсегда; - 2a. флуд-стоп: пока действует flood-блокировка дня (ban_guard.flood_today()) - discovery-воркер полностью стоит (никаких сетевых действий поиска/оценки/ - вступлений) — это и есть «стоп до конца суток» из лога flood; - 3. шаг оценки: первый кандидат status='new' → tg.discovery_info (участники, - kind; форум — если is_forum), фильтр minSubscribers (меньше — delete + - лог skip; не получено — метка), tg.discovery_read: история недоступна → - review с меткой («канал…»/«закрытая группа…», контент не оцениваем), - язык (для ru-задач), evaluate_sample (форумы — по темам через - group_by_topic) → passed → review с fitRatio/topics, иначе - delete_candidate + лог skip «мало подходящих (X из N)»; - 4. авто-вступление (отдельный проход, приоритет ниже оценки): у running- - задачи с autoJoin и кандидатом status='review', если - ban_guard.can_auto_join() → повторная проверка «мы не состоим» - (dialogs/disc_blacklist — могли вступить между оценкой и join) → если - уже состоим/в чёрном списке — discovery.mark_rejected + лог; - ban_guard.wait_join_delay() (50–70 с — спейсинг авто-вступлений), ПОСЛЕ - паузы кандидат перечитывается — join выполняется, только если запись - ещё есть и в review, задача ещё running с autoJoin и мы не состоим - (иначе — тик выходит без join); - tg.discovery_join(username) → discovery.mark_joined(auto=True) → - tg.add_dialog_monitored(...) → tg.backfill_dialog(dialog_id) → - discovery.remove_blacklist. FloodWaitError → ban_guard.note_flood() - + лог flood (кандидат остаётся review); прочие ошибки join → - join_failures += 1, лог error, кандидат остаётся review для повтора - (ретраи ограничены: после 3-й неудачи кандидат удаляется, лог skip). - -Метки кандидата (marks) — список строк-чипов: - «участники не подтверждены», «язык не подтверждён», - «канал: история недоступна», «закрытая группа (история скрыта) — вступите сами», - «мало сообщений». - -Темы форума (topics) — список dict (заполняется только для kind='forum'): - {"topicId": str|int, "title": str, "fitCount": int, "total": int, - "fitRatio": float, "passed": bool} - fit каждой темы считается по своей выборке (evaluate_sample + passed); - общий вердикт форума — есть хотя бы одна проходная тема. fitRatio кандидата - — агрегат по всей выборке (fit из N). Для не-форумов topics не заполняется. -""" -from __future__ import annotations - -import logging -import time -from contextlib import suppress - -from telethon.errors.rpcerrorlist import FloodWaitError - -from ..db import store -from . import ban_guard, discovery -from . import discovery_eval as eval_svc -from .telegram import tg - -log = logging.getLogger("leadradar.discovery_worker") - -_ACTION_NONE = {"action": "none"} - -# минимальный объём содержательной выборки для вердикта оценки -_MIN_CONTENT = 3 - -# ошибки поиска одного ключа подряд, после которых ключ пропускается -_SEARCH_ERRORS_TO_SKIP = 3 -# счётчик ошибок поиска по задачам (память процесса; при рестарте сбрасывается) -_search_errors: dict[str, int] = {} - -# метки кандидата (marks) — чипы в UI -_MARK_PARTICIPANTS = "участники не подтверждены" -_MARK_LANG = "язык не подтверждён" -_MARK_CHANNEL_NO_HISTORY = "канал: история недоступна" -_MARK_CLOSED_GROUP = "закрытая группа (история скрыта) — вступите сами" -_MARK_FEW_MESSAGES = "мало сообщений" - -# kind из Telegram (_kind_of: канал/группа/чат) → код кандидата (channel/group/forum) -_KIND_RU_TO_CODE = {"канал": "channel", "группа": "group", "чат": "group"} - - -def _now_ms() -> int: - return time.time_ns() // 1_000_000 - - -def _kind_code(kind: str, is_forum: bool = False) -> str: - """Нормализация kind источника: 'channel'|'group'|'forum' (см. _KIND_RU_TO_CODE).""" - if is_forum: - return "forum" - code = str(kind or "").strip().casefold() - if code in ("channel", "group", "forum"): - return code - return _KIND_RU_TO_CODE.get(code, "group") - - -def _running_tasks() -> list[dict]: - """Running-задачи, старые первыми (list_tasks сортирует по created_at).""" - return [t for t in discovery.list_tasks() if t["status"] == "running"] - - -def _we_are_in(dialog_id: str) -> bool: - """Уже состоим/отклонили: источник в dialogs или в чёрном списке. - - Повторная проверка «мы не состоим» перед авто-вступлением (диалог мог - появиться между оценкой кандидата и join). dialog_id — подписанный peer id, - как в dialogs.id (конвенция discovery_search, Task 4). - """ - in_dialogs = store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id]) - in_blacklist = store.scalar("SELECT 1 FROM disc_blacklist WHERE dialog_id = ? LIMIT 1", [dialog_id]) - return bool(in_dialogs or in_blacklist) - - -def _finish_done(task: dict) -> None: - """Задача выполнила план вступлений: status=done + лог done.""" - store.execute( - "UPDATE disc_tasks SET status = 'done', updated_at = ? WHERE id = ?", - [_now_ms(), task["id"]], - ) - discovery.add_log( - task["id"], - "done", - f"план выполнен: вступили {task['joined']} из {task['planJoins']}", - ) - - -def _close_search(task_id: str) -> None: - """Закрыть проход по ключам (пустой список ключей / индекс за границей).""" - discovery.advance_search(task_id) - task = discovery.get_task(task_id) - if task and task["searchDone"]: - discovery.add_log(task_id, "search", f"поиск завершён: {task['found']} кандидатов") - - -def _log_search_done(task_id: str) -> None: - """Лог завершения поиска, если advance_search перевёл задачу в search_done.""" - task = discovery.get_task(task_id) - if task and task["searchDone"]: - discovery.add_log(task_id, "search", f"поиск завершён: {task['found']} кандидатов") - - -def _finish_review( - task_id: str, - dialog_id: str, - *, - marks: list[str], - lang_ru: bool | None = None, - fit_ratio: float | None = None, - topics: list[dict] | None = None, -) -> None: - """Перевести кандидата в review с метками/оценкой (статус пишет лог review).""" - patch: dict = {"marks": [m for m in marks if m]} - if lang_ru is not None: - patch["langRu"] = lang_ru - if fit_ratio is not None: - patch["fitRatio"] = fit_ratio - if topics is not None: - patch["topics"] = topics - discovery.set_candidate(task_id, dialog_id, patch) - discovery.set_candidate_status(dialog_id, "review") - - -# ─── шаги tick ───────────────────────────────────────────────────────────── - -async def _search_step(task: dict) -> dict: - """Шаг поиска: один ключ keywords[searchIdx] → кандидаты + advance_search.""" - task_id = task["id"] - keywords = list(task.get("keywords") or []) - idx = int(task.get("searchIdx") or 0) - if not keywords or idx >= len(keywords): - # ключи закончились/пустой список: закрываем проход без сетевого вызова - _close_search(task_id) - return {"action": "search", "taskId": task_id} - keyword = keywords[idx] - try: - results = await tg.discovery_search(keyword) - except FloodWaitError: - # флуд: стоп авто-вступлений до конца суток; ключ не двигаем — повторим позже - ban_guard.note_flood() - discovery.add_log(task_id, "flood", f"поиск «{keyword}»: flood — стоп до конца суток") - return {"action": "flood", "taskId": task_id} - except Exception as exc: # noqa: BLE001 — сбой поиска не двигает индекс ключей - errors = _search_errors.get(task_id, 0) + 1 - if errors >= _SEARCH_ERRORS_TO_SKIP: - # 3 ошибки подряд одного ключа: пропускаем (битый ключ не должен - # зацикливать поиск и блокировать оценку/вступления других задач) - _search_errors.pop(task_id, None) - discovery.add_log(task_id, "error", f"поиск «{keyword}»: {exc} — ключ пропущен ({errors} ошибки подряд)") - discovery.advance_search(task_id) - _log_search_done(task_id) - return {"action": "error", "taskId": task_id} - _search_errors[task_id] = errors - discovery.add_log(task_id, "error", f"поиск «{keyword}»: {exc}") - return {"action": "error", "taskId": task_id} - _search_errors.pop(task_id, None) # успешный поиск — сброс счётчика ошибок ключа - for item in results: - kind_raw = str(item.get("kind") or "").strip().casefold() - name = item.get("name") or "" - if kind_raw == "чат": - # люди/личные чаты и боты глобальным поиском не предлагаются - discovery.add_log(task_id, "skip", f"{name}: личный чат/бот") - continue - discovery.add_candidate( - task_id, - str(item.get("id") or ""), - name, - item.get("username") or "", - _kind_code(kind_raw), - item.get("hue") or "#666", - ) - discovery.advance_search(task_id) - _log_search_done(task_id) - return {"action": "search", "taskId": task_id} - - -async def _eval_step(task: dict, cand: dict) -> dict: - """Шаг оценки первого кандидата status='new' (все ветки — одно действие).""" - task_id = task["id"] - dialog_id = cand["dialogId"] - marks: list[str] = [] - - # ── инфо об источнике: kind/forum, участники, имя/username ──────────── - info = await tg.discovery_info(dialog_id) - is_forum = bool(info.get("is_forum")) - resolved = info.get("kind") or is_forum - kind = _kind_code(info.get("kind", ""), is_forum) if resolved else (cand["kind"] or "channel") - cand_patch: dict = { - "kind": kind, - "hue": info.get("hue") or "", - "participants": info.get("participants"), - } - if resolved: - # имя/username обновляем только при успешном резолве: при fallback - # discovery_info возвращает name=dialog_id и не должен затирать имя - cand_patch["name"] = info.get("name") or "" - cand_patch["username"] = info.get("username") or "" - discovery.set_candidate(task_id, dialog_id, cand_patch) - participants = info.get("participants") - - # ── фильтр minSubscribers ────────────────────────────────────────────── - min_sub = int(task.get("minSubscribers") or 0) - if min_sub > 0: - if participants is None: - marks.append(_MARK_PARTICIPANTS) - elif int(participants) < min_sub: - discovery.delete_candidate(dialog_id) - discovery.add_log( - task_id, - "skip", - f"{dialog_id}: мало участников ({participants} < {min_sub})", - ) - discovery.bump_counter(task_id, "evaluated") - return {"action": "skip", "taskId": task_id} - - # ── чтение истории для оценки ────────────────────────────────────────── - read = await tg.discovery_read(dialog_id, int(task.get("sampleSize") or 10)) - if not read.get("ok"): - # история недоступна без членства: контент не оцениваем, фильтры помечаем - marks.append(_MARK_CHANNEL_NO_HISTORY if kind == "channel" else _MARK_CLOSED_GROUP) - if task.get("lang") == "ru": - marks.append(_MARK_LANG) - _finish_review(task_id, dialog_id, marks=marks) - discovery.bump_counter(task_id, "evaluated") - return {"action": "review", "taskId": task_id} - messages = read.get("messages") or [] - - # ── язык (только для ru-задач) ───────────────────────────────────────── - lang_ru: bool | None = None - if task.get("lang") == "ru": - lang_ru = eval_svc.detect_lang_ru([str(m.get("text") or "") for m in messages]) - if lang_ru is False: - discovery.delete_candidate(dialog_id) - discovery.add_log(task_id, "skip", f"{dialog_id}: язык не русский") - discovery.bump_counter(task_id, "evaluated") - return {"action": "skip", "taskId": task_id} - if lang_ru is None: - marks.append(_MARK_LANG) - - # ── объём выборки: меньше 3 содержательных — решает человек ──────────── - if len(messages) < _MIN_CONTENT: - marks.append(_MARK_FEW_MESSAGES) - _finish_review(task_id, dialog_id, marks=marks, lang_ru=lang_ru) - discovery.bump_counter(task_id, "evaluated") - return {"action": "review", "taskId": task_id} - - # ── оценка содержания (форумы — по темам) ────────────────────────────── - fit_count, total, fit_ratio, topics, ok = await _evaluate_content(task, kind, messages) - if ok: - _finish_review( - task_id, - dialog_id, - marks=marks, - lang_ru=lang_ru, - fit_ratio=fit_ratio, - topics=topics if kind == "forum" else None, - ) - discovery.bump_counter(task_id, "evaluated") - return {"action": "review", "taskId": task_id} - - discovery.delete_candidate(dialog_id) - discovery.add_log(task_id, "skip", f"{dialog_id}: мало подходящих ({fit_count} из {total})") - discovery.bump_counter(task_id, "evaluated") - return {"action": "skip", "taskId": task_id} - - -async def _evaluate_content(task: dict, kind: str, messages: list[dict]) -> tuple[int, int, float, list[dict], bool]: - """evaluate_sample по выборке кандидата. - - Не-форум: один прогон по всем сообщениям, topics пуст, вердикт — passed(). - Форум: прогон по каждой теме (group_by_topic), topics заполняется - ({topicId, title, fitCount, total, fitRatio, passed}), вердикт — есть хотя - бы одна проходная тема; fitRatio — агрегат fit из N по всей выборке. - """ - if kind == "forum": - topics: list[dict] = [] - fit_count = 0 - total = 0 - any_passed = False - for group in eval_svc.group_by_topic(messages): - ev = await eval_svc.evaluate_sample(task, group["messages"]) - t_ok = eval_svc.passed(ev, task) - fit_count += int(ev.get("fit_count") or 0) - total += int(ev.get("total") or 0) - topics.append( - { - "topicId": group["topic_id"], - "title": group["title"] or "", - "fitCount": int(ev.get("fit_count") or 0), - "total": int(ev.get("total") or 0), - "fitRatio": float(ev.get("fit_ratio") or 0.0), - "passed": bool(t_ok), - } - ) - any_passed = any_passed or bool(t_ok) - return fit_count, total, (fit_count / total) if total else 0.0, topics, any_passed - - ev = await eval_svc.evaluate_sample(task, messages) - return ( - int(ev.get("fit_count") or 0), - int(ev.get("total") or 0), - float(ev.get("fit_ratio") or 0.0), - [], - eval_svc.passed(ev, task), - ) - - -async def _join_step(task: dict, cand: dict) -> dict: - """Шаг авто-вступления одного кандидата status='review'.""" - task_id = task["id"] - dialog_id = cand["dialogId"] - - # между оценкой и вступлением могли вступить/отклонить источник - if _we_are_in(dialog_id): - already = bool(store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id])) - reason = ( - "уже вступили между оценкой и авто-вступлением" - if already - else "источник в чёрном списке (повторная проверка перед авто-вступлением)" - ) - with suppress(KeyError, ValueError): - discovery.mark_rejected(dialog_id, reason=reason) - return {"action": "reject", "taskId": task_id} - - # спейсинг авто-вступлений (сек из настроек discJoinDelayMin/Max) - await ban_guard.wait_join_delay() - - # за время паузы задача/кандидат/состояние BanGuard могли измениться: - # вступаем только если кандидат всё ещё есть и в review, задача ещё running - # с autoJoin, мы не состоим и авто-вступления по-прежнему разрешены (стоп- - # кран/flood/лимит могли включиться во время паузы) — иначе выходим без join - fresh = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id]) - task_now = discovery.get_task(task_id) - if ( - fresh is None - or fresh["status"] != "review" - or task_now is None - or task_now["status"] != "running" - or not task_now["autoJoin"] - or _we_are_in(dialog_id) - or not ban_guard.can_auto_join() - ): - return _ACTION_NONE - - username = str(fresh.get("username") or "").strip().lstrip("@") - try: - await tg.discovery_join(username) - except FloodWaitError: - ban_guard.note_flood() # идемпотентно: discovery_join тоже фиксирует флуд - discovery.add_log(task_id, "flood", f"авто-вступление {dialog_id}: flood — стоп до конца суток") - return {"action": "flood", "taskId": task_id} - except Exception as exc: # noqa: BLE001 — ретраи ограничены счётчиком join_failures - # между паузой и неудачным join кандидата могли отклонить/удалить: - # счётчик и удаление трогаем только у живой записи в статусе review - row_now = store.query_one( - "SELECT status FROM disc_candidates WHERE dialog_id = ?", [dialog_id] - ) - if not row_now or row_now["status"] != "review": - return _ACTION_NONE - failures = int(fresh.get("join_failures") or 0) + 1 - store.execute( - "UPDATE disc_candidates SET join_failures = ?, updated_at = ? " - "WHERE dialog_id = ? AND status = 'review'", - [failures, _now_ms(), dialog_id], - ) - if failures >= 3: - discovery.delete_candidate(dialog_id) - discovery.add_log(task_id, "skip", f"{dialog_id}: не удалось вступить (3 попытки): {exc}") - return {"action": "skip", "taskId": task_id} - discovery.add_log(task_id, "error", f"авто-вступление {dialog_id}: {exc}") - return {"action": "error", "taskId": task_id} - - discovery.mark_joined(dialog_id, auto=True) - tg.add_dialog_monitored( - dialog_id, - fresh.get("name"), - username, - fresh.get("kind"), - fresh.get("hue"), - ) - # разбор последних сообщений источника (спейсинг/read-ack внутри метода); - # вступление уже состоялось — сбой backfill не роняет шаг - try: - await tg.backfill_dialog(dialog_id) - except Exception as exc: # noqa: BLE001 - log.warning("join %s: backfill не удался: %s", dialog_id, exc) - discovery.remove_blacklist(dialog_id) - return {"action": "join", "taskId": task_id} - - -# ─── tick ────────────────────────────────────────────────────────────────── - -async def tick() -> dict: - """Одно действие discovery-воркера (см. docstring модуля).""" - if ban_guard.global_paused(): - return _ACTION_NONE - if ban_guard.flood_today(): - # флуд-блокировка дня: никаких сетевых действий (поиск/оценка/join), - # пока действует discFloodDay — воркер просто стоит - return _ACTION_NONE - running = _running_tasks() - if not running: - return _ACTION_NONE - - # 1. план достигнут — закрываем задачу (важно до поиска/оценки/join: - # задачу с выполненным планом нельзя продолжать обрабатывать) - for task in running: - if int(task["joined"]) >= int(task["planJoins"]): - _finish_done(task) - return {"action": "done", "taskId": task["id"]} - - # 2. поиск: следующая running-задача с незавершённым проходом по ключам - for task in running: - if not task["searchDone"]: - return await _search_step(task) - - # 3. оценка: первый кандидат status='new' (самая старая задача — первой) - for task in running: - new_cands = discovery.list_candidates(task["id"], status="new") - if new_cands: - return await _eval_step(task, new_cands[0]) - - # 4. авто-вступление: отдельный проход, приоритет ниже оценки - for task in running: - if not task["autoJoin"]: - continue - review_cands = discovery.list_candidates(task["id"], status="review") - if review_cands: - if not ban_guard.can_auto_join(): - return _ACTION_NONE # суточный лимит/флуд/пауза — join никому нельзя - return await _join_step(task, review_cands[0]) - - return _ACTION_NONE +"""Discovery worker: поиск → оценка → авто-вступление (Task 6). + +Фоновый цикл main._discovery_loop вызывает `tick()` каждые ~5 секунд; каждый +вызов выполняет ОДНО действие для самой старой running-задачи и возвращает +{"action": "search"|"review"|"skip"|"join"|"reject"|"flood"|"error"|"done"|"none", + "taskId": ...}. Если работы нет — {"action": "none"}. + +Приоритеты внутри tick: + 0. ban_guard.global_paused() — ручной стоп-кран: возвращаем none; + 1. задача достигла плана вступлений (joined >= planJoins) → status=done + (+ лог done) — занимаемый ею бюджет планов освобождается сразу; + 2. шаг поиска: search_done=False → следующий ключ keywords[search_idx], + tg.discovery_search, каждый результат — discovery.add_candidate, + discovery.advance_search; при переходе search_done=True — лог search + «поиск завершён: N кандидатов» (N = счётчик found задачи). Личные чаты/ + боты (kind «чат») пропускаются (лог skip). FloodWaitError → note_flood + + лог flood; прочие ошибки поиска → лог error; индекс ключей не двигается + (тик повторит ключ позже), но после 3 ошибок подряд ключ пропускается + (advance_search + лог error «ключ пропущен») — битый ключ не должен + зацикливать поиск навсегда; + 2a. флуд-стоп: пока действует flood-блокировка дня (ban_guard.flood_today()) + discovery-воркер полностью стоит (никаких сетевых действий поиска/оценки/ + вступлений) — это и есть «стоп до конца суток» из лога flood; + 3. шаг оценки: первый кандидат status='new' → tg.discovery_info (участники, + kind; форум — если is_forum), фильтр minSubscribers (меньше — delete + + лог skip; не получено — метка), tg.discovery_read: история недоступна → + review с меткой («канал…»/«закрытая группа…», контент не оцениваем), + язык (для ru-задач), evaluate_sample (форумы — по темам через + group_by_topic) → passed → review с fitRatio/topics, иначе + delete_candidate + лог skip «мало подходящих (X из N)»; + 4. авто-вступление (отдельный проход, приоритет ниже оценки): у running- + задачи с autoJoin и кандидатом status='review', если + ban_guard.can_auto_join() → повторная проверка «мы не состоим» + (dialogs/disc_blacklist — могли вступить между оценкой и join) → если + уже состоим/в чёрном списке — discovery.mark_rejected + лог; + ban_guard.wait_join_delay() (50–70 с — спейсинг авто-вступлений), ПОСЛЕ + паузы кандидат перечитывается — join выполняется, только если запись + ещё есть и в review, задача ещё running с autoJoin и мы не состоим + (иначе — тик выходит без join); + tg.discovery_join(username) → discovery.mark_joined(auto=True) → + tg.add_dialog_monitored(...) → tg.backfill_dialog(dialog_id) → + discovery.remove_blacklist. FloodWaitError → ban_guard.note_flood() + + лог flood (кандидат остаётся review); прочие ошибки join → + join_failures += 1, лог error, кандидат остаётся review для повтора + (ретраи ограничены: после 3-й неудачи кандидат удаляется, лог skip). + +Метки кандидата (marks) — список строк-чипов: + «участники не подтверждены», «язык не подтверждён», + «канал: история недоступна», «закрытая группа (история скрыта) — вступите сами», + «мало сообщений». + +Темы форума (topics) — список dict (заполняется только для kind='forum'): + {"topicId": str|int, "title": str, "fitCount": int, "total": int, + "fitRatio": float, "passed": bool} + fit каждой темы считается по своей выборке (evaluate_sample + passed); + общий вердикт форума — есть хотя бы одна проходная тема. fitRatio кандидата + — агрегат по всей выборке (fit из N). Для не-форумов topics не заполняется. +""" +from __future__ import annotations + +import logging +import time +from contextlib import suppress + +from telethon.errors.rpcerrorlist import FloodWaitError + +from ..db import store +from . import ban_guard, discovery +from . import discovery_eval as eval_svc +from .telegram import tg + +log = logging.getLogger("leadradar.discovery_worker") + +_ACTION_NONE = {"action": "none"} + +# минимальный объём содержательной выборки для вердикта оценки +_MIN_CONTENT = 3 + +# ошибки поиска одного ключа подряд, после которых ключ пропускается +_SEARCH_ERRORS_TO_SKIP = 3 +# счётчик ошибок поиска по задачам (память процесса; при рестарте сбрасывается) +_search_errors: dict[str, int] = {} + +# метки кандидата (marks) — чипы в UI +_MARK_PARTICIPANTS = "участники не подтверждены" +_MARK_LANG = "язык не подтверждён" +_MARK_CHANNEL_NO_HISTORY = "канал: история недоступна" +_MARK_CLOSED_GROUP = "закрытая группа (история скрыта) — вступите сами" +_MARK_FEW_MESSAGES = "мало сообщений" + +# kind из Telegram (_kind_of: канал/группа/чат) → код кандидата (channel/group/forum) +_KIND_RU_TO_CODE = {"канал": "channel", "группа": "group", "чат": "group"} + + +def _now_ms() -> int: + return time.time_ns() // 1_000_000 + + +def _kind_code(kind: str, is_forum: bool = False) -> str: + """Нормализация kind источника: 'channel'|'group'|'forum' (см. _KIND_RU_TO_CODE).""" + if is_forum: + return "forum" + code = str(kind or "").strip().casefold() + if code in ("channel", "group", "forum"): + return code + return _KIND_RU_TO_CODE.get(code, "group") + + +def _running_tasks() -> list[dict]: + """Running-задачи, старые первыми (list_tasks сортирует по created_at).""" + return [t for t in discovery.list_tasks() if t["status"] == "running"] + + +def _we_are_in(dialog_id: str) -> bool: + """Уже состоим/отклонили: источник в dialogs или в чёрном списке. + + Повторная проверка «мы не состоим» перед авто-вступлением (диалог мог + появиться между оценкой кандидата и join). dialog_id — подписанный peer id, + как в dialogs.id (конвенция discovery_search, Task 4). + """ + in_dialogs = store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id]) + in_blacklist = store.scalar("SELECT 1 FROM disc_blacklist WHERE dialog_id = ? LIMIT 1", [dialog_id]) + return bool(in_dialogs or in_blacklist) + + +def _finish_done(task: dict) -> None: + """Задача выполнила план вступлений: status=done + лог done.""" + store.execute( + "UPDATE disc_tasks SET status = 'done', updated_at = ? WHERE id = ?", + [_now_ms(), task["id"]], + ) + discovery.add_log( + task["id"], + "done", + f"план выполнен: вступили {task['joined']} из {task['planJoins']}", + ) + + +def _close_search(task_id: str) -> None: + """Закрыть проход по ключам (пустой список ключей / индекс за границей).""" + discovery.advance_search(task_id) + task = discovery.get_task(task_id) + if task and task["searchDone"]: + discovery.add_log(task_id, "search", f"поиск завершён: {task['found']} кандидатов") + + +def _log_search_done(task_id: str) -> None: + """Лог завершения поиска, если advance_search перевёл задачу в search_done.""" + task = discovery.get_task(task_id) + if task and task["searchDone"]: + discovery.add_log(task_id, "search", f"поиск завершён: {task['found']} кандидатов") + + +def _finish_review( + task_id: str, + dialog_id: str, + *, + marks: list[str], + lang_ru: bool | None = None, + fit_ratio: float | None = None, + topics: list[dict] | None = None, +) -> None: + """Перевести кандидата в review с метками/оценкой (статус пишет лог review).""" + patch: dict = {"marks": [m for m in marks if m]} + if lang_ru is not None: + patch["langRu"] = lang_ru + if fit_ratio is not None: + patch["fitRatio"] = fit_ratio + if topics is not None: + patch["topics"] = topics + discovery.set_candidate(task_id, dialog_id, patch) + discovery.set_candidate_status(dialog_id, "review") + + +# ─── шаги tick ───────────────────────────────────────────────────────────── + +async def _search_step(task: dict) -> dict: + """Шаг поиска: один ключ keywords[searchIdx] → кандидаты + advance_search.""" + task_id = task["id"] + keywords = list(task.get("keywords") or []) + idx = int(task.get("searchIdx") or 0) + if not keywords or idx >= len(keywords): + # ключи закончились/пустой список: закрываем проход без сетевого вызова + _close_search(task_id) + return {"action": "search", "taskId": task_id} + keyword = keywords[idx] + try: + results = await tg.discovery_search(keyword) + except FloodWaitError: + # флуд: стоп авто-вступлений до конца суток; ключ не двигаем — повторим позже + ban_guard.note_flood() + discovery.add_log(task_id, "flood", f"поиск «{keyword}»: flood — стоп до конца суток") + return {"action": "flood", "taskId": task_id} + except Exception as exc: # noqa: BLE001 — сбой поиска не двигает индекс ключей + errors = _search_errors.get(task_id, 0) + 1 + if errors >= _SEARCH_ERRORS_TO_SKIP: + # 3 ошибки подряд одного ключа: пропускаем (битый ключ не должен + # зацикливать поиск и блокировать оценку/вступления других задач) + _search_errors.pop(task_id, None) + discovery.add_log(task_id, "error", f"поиск «{keyword}»: {exc} — ключ пропущен ({errors} ошибки подряд)") + discovery.advance_search(task_id) + _log_search_done(task_id) + return {"action": "error", "taskId": task_id} + _search_errors[task_id] = errors + discovery.add_log(task_id, "error", f"поиск «{keyword}»: {exc}") + return {"action": "error", "taskId": task_id} + _search_errors.pop(task_id, None) # успешный поиск — сброс счётчика ошибок ключа + for item in results: + kind_raw = str(item.get("kind") or "").strip().casefold() + name = item.get("name") or "" + if kind_raw == "чат": + # люди/личные чаты и боты глобальным поиском не предлагаются + discovery.add_log(task_id, "skip", f"{name}: личный чат/бот") + continue + discovery.add_candidate( + task_id, + str(item.get("id") or ""), + name, + item.get("username") or "", + _kind_code(kind_raw), + item.get("hue") or "#666", + ) + discovery.advance_search(task_id) + _log_search_done(task_id) + return {"action": "search", "taskId": task_id} + + +async def _eval_step(task: dict, cand: dict) -> dict: + """Шаг оценки первого кандидата status='new' (все ветки — одно действие).""" + task_id = task["id"] + dialog_id = cand["dialogId"] + marks: list[str] = [] + + # ── инфо об источнике: kind/forum, участники, имя/username ──────────── + info = await tg.discovery_info(dialog_id) + is_forum = bool(info.get("is_forum")) + resolved = info.get("kind") or is_forum + kind = _kind_code(info.get("kind", ""), is_forum) if resolved else (cand["kind"] or "channel") + cand_patch: dict = { + "kind": kind, + "hue": info.get("hue") or "", + "participants": info.get("participants"), + } + if resolved: + # имя/username обновляем только при успешном резолве: при fallback + # discovery_info возвращает name=dialog_id и не должен затирать имя + cand_patch["name"] = info.get("name") or "" + cand_patch["username"] = info.get("username") or "" + discovery.set_candidate(task_id, dialog_id, cand_patch) + participants = info.get("participants") + + # ── фильтр minSubscribers ────────────────────────────────────────────── + min_sub = int(task.get("minSubscribers") or 0) + if min_sub > 0: + if participants is None: + marks.append(_MARK_PARTICIPANTS) + elif int(participants) < min_sub: + discovery.delete_candidate(dialog_id) + discovery.add_log( + task_id, + "skip", + f"{dialog_id}: мало участников ({participants} < {min_sub})", + ) + discovery.bump_counter(task_id, "evaluated") + return {"action": "skip", "taskId": task_id} + + # ── чтение истории для оценки ────────────────────────────────────────── + read = await tg.discovery_read(dialog_id, int(task.get("sampleSize") or 10)) + if not read.get("ok"): + # история недоступна без членства: контент не оцениваем, фильтры помечаем + marks.append(_MARK_CHANNEL_NO_HISTORY if kind == "channel" else _MARK_CLOSED_GROUP) + if task.get("lang") == "ru": + marks.append(_MARK_LANG) + _finish_review(task_id, dialog_id, marks=marks) + discovery.bump_counter(task_id, "evaluated") + return {"action": "review", "taskId": task_id} + messages = read.get("messages") or [] + + # ── язык (только для ru-задач) ───────────────────────────────────────── + lang_ru: bool | None = None + if task.get("lang") == "ru": + lang_ru = eval_svc.detect_lang_ru([str(m.get("text") or "") for m in messages]) + if lang_ru is False: + discovery.delete_candidate(dialog_id) + discovery.add_log(task_id, "skip", f"{dialog_id}: язык не русский") + discovery.bump_counter(task_id, "evaluated") + return {"action": "skip", "taskId": task_id} + if lang_ru is None: + marks.append(_MARK_LANG) + + # ── объём выборки: меньше 3 содержательных — решает человек ──────────── + if len(messages) < _MIN_CONTENT: + marks.append(_MARK_FEW_MESSAGES) + _finish_review(task_id, dialog_id, marks=marks, lang_ru=lang_ru) + discovery.bump_counter(task_id, "evaluated") + return {"action": "review", "taskId": task_id} + + # ── оценка содержания (форумы — по темам) ────────────────────────────── + fit_count, total, fit_ratio, topics, ok = await _evaluate_content(task, kind, messages) + if ok: + _finish_review( + task_id, + dialog_id, + marks=marks, + lang_ru=lang_ru, + fit_ratio=fit_ratio, + topics=topics if kind == "forum" else None, + ) + discovery.bump_counter(task_id, "evaluated") + return {"action": "review", "taskId": task_id} + + discovery.delete_candidate(dialog_id) + discovery.add_log(task_id, "skip", f"{dialog_id}: мало подходящих ({fit_count} из {total})") + discovery.bump_counter(task_id, "evaluated") + return {"action": "skip", "taskId": task_id} + + +async def _evaluate_content(task: dict, kind: str, messages: list[dict]) -> tuple[int, int, float, list[dict], bool]: + """evaluate_sample по выборке кандидата. + + Не-форум: один прогон по всем сообщениям, topics пуст, вердикт — passed(). + Форум: прогон по каждой теме (group_by_topic), topics заполняется + ({topicId, title, fitCount, total, fitRatio, passed}), вердикт — есть хотя + бы одна проходная тема; fitRatio — агрегат fit из N по всей выборке. + """ + if kind == "forum": + topics: list[dict] = [] + fit_count = 0 + total = 0 + any_passed = False + for group in eval_svc.group_by_topic(messages): + ev = await eval_svc.evaluate_sample(task, group["messages"]) + t_ok = eval_svc.passed(ev, task) + fit_count += int(ev.get("fit_count") or 0) + total += int(ev.get("total") or 0) + topics.append( + { + "topicId": group["topic_id"], + "title": group["title"] or "", + "fitCount": int(ev.get("fit_count") or 0), + "total": int(ev.get("total") or 0), + "fitRatio": float(ev.get("fit_ratio") or 0.0), + "passed": bool(t_ok), + } + ) + any_passed = any_passed or bool(t_ok) + return fit_count, total, (fit_count / total) if total else 0.0, topics, any_passed + + ev = await eval_svc.evaluate_sample(task, messages) + return ( + int(ev.get("fit_count") or 0), + int(ev.get("total") or 0), + float(ev.get("fit_ratio") or 0.0), + [], + eval_svc.passed(ev, task), + ) + + +async def _join_step(task: dict, cand: dict) -> dict: + """Шаг авто-вступления одного кандидата status='review'.""" + task_id = task["id"] + dialog_id = cand["dialogId"] + + # между оценкой и вступлением могли вступить/отклонить источник + if _we_are_in(dialog_id): + already = bool(store.scalar("SELECT 1 FROM dialogs WHERE id = ? LIMIT 1", [dialog_id])) + reason = ( + "уже вступили между оценкой и авто-вступлением" + if already + else "источник в чёрном списке (повторная проверка перед авто-вступлением)" + ) + with suppress(KeyError, ValueError): + discovery.mark_rejected(dialog_id, reason=reason) + return {"action": "reject", "taskId": task_id} + + # спейсинг авто-вступлений (сек из настроек discJoinDelayMin/Max) + await ban_guard.wait_join_delay() + + # за время паузы задача/кандидат/состояние BanGuard могли измениться: + # вступаем только если кандидат всё ещё есть и в review, задача ещё running + # с autoJoin, мы не состоим и авто-вступления по-прежнему разрешены (стоп- + # кран/flood/лимит могли включиться во время паузы) — иначе выходим без join + fresh = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id]) + task_now = discovery.get_task(task_id) + if ( + fresh is None + or fresh["status"] != "review" + or task_now is None + or task_now["status"] != "running" + or not task_now["autoJoin"] + or _we_are_in(dialog_id) + or not ban_guard.can_auto_join() + ): + return _ACTION_NONE + + username = str(fresh.get("username") or "").strip().lstrip("@") + try: + await tg.discovery_join(username) + except FloodWaitError: + ban_guard.note_flood() # идемпотентно: discovery_join тоже фиксирует флуд + discovery.add_log(task_id, "flood", f"авто-вступление {dialog_id}: flood — стоп до конца суток") + return {"action": "flood", "taskId": task_id} + except Exception as exc: # noqa: BLE001 — ретраи ограничены счётчиком join_failures + # между паузой и неудачным join кандидата могли отклонить/удалить: + # счётчик и удаление трогаем только у живой записи в статусе review + row_now = store.query_one( + "SELECT status FROM disc_candidates WHERE dialog_id = ?", [dialog_id] + ) + if not row_now or row_now["status"] != "review": + return _ACTION_NONE + failures = int(fresh.get("join_failures") or 0) + 1 + store.execute( + "UPDATE disc_candidates SET join_failures = ?, updated_at = ? " + "WHERE dialog_id = ? AND status = 'review'", + [failures, _now_ms(), dialog_id], + ) + if failures >= 3: + discovery.delete_candidate(dialog_id) + discovery.add_log(task_id, "skip", f"{dialog_id}: не удалось вступить (3 попытки): {exc}") + return {"action": "skip", "taskId": task_id} + discovery.add_log(task_id, "error", f"авто-вступление {dialog_id}: {exc}") + return {"action": "error", "taskId": task_id} + + discovery.mark_joined(dialog_id, auto=True) + tg.add_dialog_monitored( + dialog_id, + fresh.get("name"), + username, + fresh.get("kind"), + fresh.get("hue"), + ) + # разбор последних сообщений источника (спейсинг/read-ack внутри метода); + # вступление уже состоялось — сбой backfill не роняет шаг + try: + await tg.backfill_dialog(dialog_id) + except Exception as exc: # noqa: BLE001 + log.warning("join %s: backfill не удался: %s", dialog_id, exc) + discovery.remove_blacklist(dialog_id) + return {"action": "join", "taskId": task_id} + + +# ─── tick ────────────────────────────────────────────────────────────────── + +async def tick() -> dict: + """Одно действие discovery-воркера (см. docstring модуля).""" + if ban_guard.global_paused(): + return _ACTION_NONE + if ban_guard.flood_today(): + # флуд-блокировка дня: никаких сетевых действий (поиск/оценка/join), + # пока действует discFloodDay — воркер просто стоит + return _ACTION_NONE + running = _running_tasks() + if not running: + return _ACTION_NONE + + # 1. план достигнут — закрываем задачу (важно до поиска/оценки/join: + # задачу с выполненным планом нельзя продолжать обрабатывать) + for task in running: + if int(task["joined"]) >= int(task["planJoins"]): + _finish_done(task) + return {"action": "done", "taskId": task["id"]} + + # 2. поиск: следующая running-задача с незавершённым проходом по ключам + for task in running: + if not task["searchDone"]: + return await _search_step(task) + + # 3. оценка: первый кандидат status='new' (самая старая задача — первой) + for task in running: + new_cands = discovery.list_candidates(task["id"], status="new") + if new_cands: + return await _eval_step(task, new_cands[0]) + + # 4. авто-вступление: отдельный проход, приоритет ниже оценки + for task in running: + if not task["autoJoin"]: + continue + review_cands = discovery.list_candidates(task["id"], status="review") + if review_cands: + if not ban_guard.can_auto_join(): + return _ACTION_NONE # суточный лимит/флуд/пауза — join никому нельзя + return await _join_step(task, review_cands[0]) + + return _ACTION_NONE diff --git a/archive/leadradar-legacy/backend/app/services/files.py b/archive/leadradar-legacy/backend/app/services/files.py index c57dc80..1ba25c9 100644 --- a/archive/leadradar-legacy/backend/app/services/files.py +++ b/archive/leadradar-legacy/backend/app/services/files.py @@ -1,100 +1,100 @@ -"""Вложения проектных карточек. - -По решению: файлы хранятся в MinIO (креды в env), в БД — метаданные и ключ -объекта. Тип определяется автоматически по MIME и расширению. -""" -from __future__ import annotations - -import json -import time - -from ..db import store - -KIND_BY_EXT = { - "image": {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif", "heic"}, - "video": {"mp4", "mov", "avi", "mkv", "webm", "m4v"}, - "audio": {"mp3", "wav", "ogg", "m4a", "flac", "aac"}, - "archive": {"zip", "rar", "7z", "tar", "gz", "bz2"}, - "document": {"pdf", "doc", "docx", "xls", "xlsx", "csv", "txt", "md", "rtf", "ppt", "pptx", "odt", "ods"}, -} - -KIND_LABELS = { - "image": "Изображение", - "video": "Видео", - "audio": "Аудио", - "archive": "Архив", - "document": "Документ", - "other": "Файл", -} - - -def detect(name: str, mime: str = "") -> dict: - ext = (name.split(".")[-1] if "." in name else "").lower() - kind = "other" - if mime.startswith("image/"): - kind = "image" - elif mime.startswith("video/"): - kind = "video" - elif mime.startswith("audio/"): - kind = "audio" - else: - for k, exts in KIND_BY_EXT.items(): - if ext in exts: - kind = k - break - return {"kind": kind, "label": KIND_LABELS[kind]} - - -def _files_of(card_id: str) -> list[dict]: - from .projects import patch_card - - row = store.query_one("SELECT files FROM projects WHERE id = ?", [card_id]) - if not row: - raise KeyError(card_id) - return json.loads(row["files"] or "[]") - - -def add_file(card_id: str, name: str, data: bytes, mime: str = "") -> dict: - """Сохраняет тело в MinIO и метаданные в карточку.""" - from . import object_store - from .projects import patch_card - - files = _files_of(card_id) - info = detect(name, mime) - object_key = object_store.put(card_id, name, data, mime) - entry = { - "id": store.uid("pf_"), - "name": name, - "size": len(data), - "kind": info["kind"], - "label": info["label"], - "objectKey": object_key, - } - files.append(entry) - patch_card(card_id, {"files": files}) - return entry - - -def get_file_entry(card_id: str, file_id: str) -> tuple[dict, list[dict]]: - files = _files_of(card_id) - entry = next((f for f in files if f["id"] == file_id), None) - if not entry: - raise KeyError(file_id) - return entry, files - - -def remove_file(card_id: str, file_id: str) -> None: - from . import object_store - from .projects import patch_card - - files = _files_of(card_id) - entry = next((f for f in files if f["id"] == file_id), None) - if entry and entry.get("objectKey"): - object_store.remove(entry["objectKey"]) - patch_card(card_id, {"files": [f for f in files if f["id"] != file_id]}) - - -def object_storage_available() -> bool: - from . import object_store - - return object_store.configured() +"""Вложения проектных карточек. + +По решению: файлы хранятся в MinIO (креды в env), в БД — метаданные и ключ +объекта. Тип определяется автоматически по MIME и расширению. +""" +from __future__ import annotations + +import json +import time + +from ..db import store + +KIND_BY_EXT = { + "image": {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif", "heic"}, + "video": {"mp4", "mov", "avi", "mkv", "webm", "m4v"}, + "audio": {"mp3", "wav", "ogg", "m4a", "flac", "aac"}, + "archive": {"zip", "rar", "7z", "tar", "gz", "bz2"}, + "document": {"pdf", "doc", "docx", "xls", "xlsx", "csv", "txt", "md", "rtf", "ppt", "pptx", "odt", "ods"}, +} + +KIND_LABELS = { + "image": "Изображение", + "video": "Видео", + "audio": "Аудио", + "archive": "Архив", + "document": "Документ", + "other": "Файл", +} + + +def detect(name: str, mime: str = "") -> dict: + ext = (name.split(".")[-1] if "." in name else "").lower() + kind = "other" + if mime.startswith("image/"): + kind = "image" + elif mime.startswith("video/"): + kind = "video" + elif mime.startswith("audio/"): + kind = "audio" + else: + for k, exts in KIND_BY_EXT.items(): + if ext in exts: + kind = k + break + return {"kind": kind, "label": KIND_LABELS[kind]} + + +def _files_of(card_id: str) -> list[dict]: + from .projects import patch_card + + row = store.query_one("SELECT files FROM projects WHERE id = ?", [card_id]) + if not row: + raise KeyError(card_id) + return json.loads(row["files"] or "[]") + + +def add_file(card_id: str, name: str, data: bytes, mime: str = "") -> dict: + """Сохраняет тело в MinIO и метаданные в карточку.""" + from . import object_store + from .projects import patch_card + + files = _files_of(card_id) + info = detect(name, mime) + object_key = object_store.put(card_id, name, data, mime) + entry = { + "id": store.uid("pf_"), + "name": name, + "size": len(data), + "kind": info["kind"], + "label": info["label"], + "objectKey": object_key, + } + files.append(entry) + patch_card(card_id, {"files": files}) + return entry + + +def get_file_entry(card_id: str, file_id: str) -> tuple[dict, list[dict]]: + files = _files_of(card_id) + entry = next((f for f in files if f["id"] == file_id), None) + if not entry: + raise KeyError(file_id) + return entry, files + + +def remove_file(card_id: str, file_id: str) -> None: + from . import object_store + from .projects import patch_card + + files = _files_of(card_id) + entry = next((f for f in files if f["id"] == file_id), None) + if entry and entry.get("objectKey"): + object_store.remove(entry["objectKey"]) + patch_card(card_id, {"files": [f for f in files if f["id"] != file_id]}) + + +def object_storage_available() -> bool: + from . import object_store + + return object_store.configured() diff --git a/archive/leadradar-legacy/backend/app/services/fts.py b/archive/leadradar-legacy/backend/app/services/fts.py index 7789853..ca91fa3 100644 --- a/archive/leadradar-legacy/backend/app/services/fts.py +++ b/archive/leadradar-legacy/backend/app/services/fts.py @@ -1,103 +1,103 @@ -"""Полнотекстовый поиск DuckDB FTS (по решению — включаем). - -DuckDB FTS строит виртуальную таблицу-снимок: индекс пересоздаётся при -старте, раз в сутки по расписанию и вручную (POST /api/admin/fts/rebuild). -Чтобы свежесозданные карточки искались сразу, поиск совмещает FTS с -точечным LIKE-дополнением (см. services/leads.search). - -Рабочий синтаксис DuckDB >= 1.0: только PRAGMA create_fts_index -(вариант CREATE VIRTUAL TABLE ... USING fts(...) в этой версии падает). -Поиск — через табличную функцию fts_main_.match_bm25(rowid, query). -""" -from __future__ import annotations - -import logging - -from ..db import store - -log = logging.getLogger("leadradar.fts") - -OK_KEY = "ftsOk" - -# Источник, колонка-rowid, текстовые колонки (должны совпадать со схемой db.py) -_FTS_TARGETS = [ - ("leads", ("title", "summary", "source_msg", "contact")), - ("messages", ("text",)), - ("rejected_msgs", ("text",)), -] - - -def is_ready() -> bool: - return bool(store.get_setting(OK_KEY)) - - -def _set(ok: bool) -> None: - store.set_setting(OK_KEY, ok) - - -def install_extension() -> bool: - try: - store.execute("INSTALL fts") - store.execute("LOAD fts") - return True - except Exception as exc: # noqa: BLE001 - log.warning("FTS extension unavailable: %s", exc) - return False - - -def rebuild() -> bool: - """Пересоздаёт FTS-индексы по leads и messages (overwrite поверх старого). - - PRAGMA не поддерживает prepared-параметры, поэтому имена подставляются - напрямую — это внутренние константы из _FTS_TARGETS, не пользовательский ввод. - """ - try: - for table, cols in _FTS_TARGETS: - col_list = ", ".join(f"'{c}'" for c in cols) - store.execute( - f"PRAGMA create_fts_index('{table}', 'id', {col_list}, " - "stemmer='russian', overwrite=1)" - ) - _set(True) - log.info("FTS rebuild done") - return True - except Exception as exc: # noqa: BLE001 - log.warning("FTS rebuild failed: %s", exc) - _set(False) - return False - - -def search(query: str, limit: int = 50) -> dict: - """Возвращает {'leads': [ids], 'messages': [ids], 'rejected': [ids]} по FTS - (пусто при сбое).""" - out: dict = {"leads": [], "messages": [], "rejected": []} - if not is_ready(): - return out - q = _sanitize(query) - if not q: - return out - targets = ("leads", "messages", "rejected_msgs") - keys = {"leads": "leads", "messages": "messages", "rejected_msgs": "rejected"} - for table in targets: - try: - # fts_main_
создаётся PRAGMA create_fts_index над таблицей - fn = f"fts_main_{table}.match_bm25(id, ?)" - rows = store.query( - f"SELECT id, {fn} AS _s FROM {table} " - f"WHERE {fn} IS NOT NULL ORDER BY _s LIMIT ?", - [q, q, limit * 2], - ) - out[keys[table]] = [r["id"] for r in rows] - except Exception as exc: # noqa: BLE001 - log.warning("fts %s query failed: %s", table, exc) - return out - - -def _sanitize(query: str) -> str: - """Минимальная очистка запроса от служебных символов FTS.""" - import re - - q = query.strip().lower() - q = re.sub(r'["\'\\()!*+-]', " ", q) - q = " ".join(q.split()) - return q[:80] +"""Полнотекстовый поиск DuckDB FTS (по решению — включаем). + +DuckDB FTS строит виртуальную таблицу-снимок: индекс пересоздаётся при +старте, раз в сутки по расписанию и вручную (POST /api/admin/fts/rebuild). +Чтобы свежесозданные карточки искались сразу, поиск совмещает FTS с +точечным LIKE-дополнением (см. services/leads.search). + +Рабочий синтаксис DuckDB >= 1.0: только PRAGMA create_fts_index +(вариант CREATE VIRTUAL TABLE ... USING fts(...) в этой версии падает). +Поиск — через табличную функцию fts_main_
.match_bm25(rowid, query). +""" +from __future__ import annotations + +import logging + +from ..db import store + +log = logging.getLogger("leadradar.fts") + +OK_KEY = "ftsOk" + +# Источник, колонка-rowid, текстовые колонки (должны совпадать со схемой db.py) +_FTS_TARGETS = [ + ("leads", ("title", "summary", "source_msg", "contact")), + ("messages", ("text",)), + ("rejected_msgs", ("text",)), +] + + +def is_ready() -> bool: + return bool(store.get_setting(OK_KEY)) + + +def _set(ok: bool) -> None: + store.set_setting(OK_KEY, ok) + + +def install_extension() -> bool: + try: + store.execute("INSTALL fts") + store.execute("LOAD fts") + return True + except Exception as exc: # noqa: BLE001 + log.warning("FTS extension unavailable: %s", exc) + return False + + +def rebuild() -> bool: + """Пересоздаёт FTS-индексы по leads и messages (overwrite поверх старого). + + PRAGMA не поддерживает prepared-параметры, поэтому имена подставляются + напрямую — это внутренние константы из _FTS_TARGETS, не пользовательский ввод. + """ + try: + for table, cols in _FTS_TARGETS: + col_list = ", ".join(f"'{c}'" for c in cols) + store.execute( + f"PRAGMA create_fts_index('{table}', 'id', {col_list}, " + "stemmer='russian', overwrite=1)" + ) + _set(True) + log.info("FTS rebuild done") + return True + except Exception as exc: # noqa: BLE001 + log.warning("FTS rebuild failed: %s", exc) + _set(False) + return False + + +def search(query: str, limit: int = 50) -> dict: + """Возвращает {'leads': [ids], 'messages': [ids], 'rejected': [ids]} по FTS + (пусто при сбое).""" + out: dict = {"leads": [], "messages": [], "rejected": []} + if not is_ready(): + return out + q = _sanitize(query) + if not q: + return out + targets = ("leads", "messages", "rejected_msgs") + keys = {"leads": "leads", "messages": "messages", "rejected_msgs": "rejected"} + for table in targets: + try: + # fts_main_
создаётся PRAGMA create_fts_index над таблицей + fn = f"fts_main_{table}.match_bm25(id, ?)" + rows = store.query( + f"SELECT id, {fn} AS _s FROM {table} " + f"WHERE {fn} IS NOT NULL ORDER BY _s LIMIT ?", + [q, q, limit * 2], + ) + out[keys[table]] = [r["id"] for r in rows] + except Exception as exc: # noqa: BLE001 + log.warning("fts %s query failed: %s", table, exc) + return out + + +def _sanitize(query: str) -> str: + """Минимальная очистка запроса от служебных символов FTS.""" + import re + + q = query.strip().lower() + q = re.sub(r'["\'\\()!*+-]', " ", q) + q = " ".join(q.split()) + return q[:80] diff --git a/archive/leadradar-legacy/backend/app/services/leads.py b/archive/leadradar-legacy/backend/app/services/leads.py index b716c8a..b7af707 100644 --- a/archive/leadradar-legacy/backend/app/services/leads.py +++ b/archive/leadradar-legacy/backend/app/services/leads.py @@ -1,551 +1,551 @@ -"""Доски, колонки и карточки дашборда (п.4.4, 4.5, 4.7 ТЗ). - -Сюда же входят правила хранения (автоархив/очистка) и обучающие примеры -(действия пользователя -> learning_log). -""" -from __future__ import annotations - -import asyncio -import json -import logging -import time - -from .. import constants as C -from ..db import store -from ..sse import broker -from . import fts as fts_svc -from . import ml_client -from . import processing as processing_svc -from .pipeline import ( - build_contacts, - clean_block, - clean_short, - compose_summary, - lead_to_dict, - normalize_stack, - primary_contact, - qualify_contact, -) -from .rules import board_accepts, extract_amounts, has_active_rules, hits_for_board - -log = logging.getLogger("leadradar.leads") - -KANBAN_COLS = ("inbox",) # + доски - - -def _now() -> int: - return time.time_ns() // 1_000_000 - - -def _log_learning(lead_id: str, action: str, from_col: str | None, to_col: str | None) -> None: - store.execute( - "INSERT INTO learning_log(id, lead_id, action, from_col, to_col, created_at) VALUES (?, ?, ?, ?, ?, ?)", - [store.uid("lm_"), lead_id, action, from_col, to_col, _now()], - ) - - -# ─── Доски / колонки ────────────────────────────────────────────────────── - -def list_boards() -> list[dict]: - rows = store.query("SELECT * FROM boards ORDER BY suggested, pos") - return [ - { - "id": r["id"], - "name": r["name"], - "description": r["description"] or "", - "color": r["color"], - "width": r["width"], - "collapsed": bool(r["collapsed"]), - "keywords": json.loads(r["keywords"] or "[]"), - "prompt": r["prompt"] or "", - "visibleFields": json.loads(r["visible_fields"] or "[]"), - "suggested": bool(r["suggested"]), - "rules": json.loads(r["rules"] or "{}"), - "note": r["note"] or "", - } - for r in rows - ] - - -def board_by_id(board_id: str) -> dict | None: - return next((b for b in list_boards() if b["id"] == board_id), None) - - -def create_board( - name: str, - color: str | None = None, - keywords: list | None = None, - prompt: str = "", - description: str = "", - suggested: bool = False, - rules: dict | None = None, - note: str = "", -) -> dict: - """Создаёт колонку. suggested=TRUE — ИИ-предложение, ждёт решения пользователя.""" - board_id = store.uid("b_") - pos = int(store.scalar("SELECT COALESCE(MAX(pos), -1) + 1 FROM boards")) - store.execute( - "INSERT INTO boards(id, name, description, color, width, pos, keywords, prompt, visible_fields, collapsed, suggested, rules, note, created_at) " - "VALUES (?, ?, ?, ?, 'md', ?, ?, ?, '[\"budget\",\"stack\",\"contacts\"]', FALSE, ?, ?, ?, ?)", - [ - board_id, - name.strip() or "Новая колонка", - (description or "").strip(), - color or C.PALETTE[pos % len(C.PALETTE)], - pos, - json.dumps(keywords or [], ensure_ascii=False), - prompt or "", - bool(suggested), - json.dumps(rules or {}, ensure_ascii=False), - note or "", - _now(), - ], - ) - return {"id": board_id} - - -def patch_board(board_id: str, patch: dict) -> dict: - row = store.query_one("SELECT * FROM boards WHERE id = ?", [board_id]) - if not row: - raise KeyError(board_id) - allowed = {"name", "description", "color", "width", "collapsed", "prompt", "suggested", "note"} - for key in allowed: - if key in patch and patch[key] is not None: - store.execute(f"UPDATE boards SET {key} = ? WHERE id = ?", [patch[key], board_id]) - if "keywords" in patch and patch["keywords"] is not None: - store.execute("UPDATE boards SET keywords = ? WHERE id = ?", [json.dumps(patch["keywords"], ensure_ascii=False), board_id]) - if "visibleFields" in patch and patch["visibleFields"] is not None: - store.execute("UPDATE boards SET visible_fields = ? WHERE id = ?", [json.dumps(patch["visibleFields"], ensure_ascii=False), board_id]) - if "rules" in patch and patch["rules"] is not None: - store.execute("UPDATE boards SET rules = ? WHERE id = ?", [json.dumps(patch["rules"], ensure_ascii=False), board_id]) - return {"id": board_id} - - -def delete_board(board_id: str) -> int: - """Карточки доски уходят в «Неразобранное» (с пометкой новых).""" - leads = store.query("SELECT id FROM leads WHERE col = ?", [board_id]) - for l in leads: - store.execute("UPDATE leads SET col = 'inbox', is_new = TRUE, prev_col = 'inbox' WHERE id = ?", [l["id"]]) - store.execute("DELETE FROM boards WHERE id = ?", [board_id]) - return len(leads) - - -def reorder_boards(order: list[str]) -> None: - for i, board_id in enumerate(order): - store.execute("UPDATE boards SET pos = ? WHERE id = ?", [i, board_id]) - - -def get_col_state() -> dict: - return store.get_setting("colState") or {} - - -def set_col_state(col_id: str, state: dict) -> dict: - current = store.get_setting("colState") or {} - current[col_id] = state - store.set_setting("colState", current) - return current[col_id] - - -# ─── Лиды ───────────────────────────────────────────────────────────────── - -def list_leads(col: str | None = None) -> list[dict]: - if col: - rows = store.query("SELECT id FROM leads WHERE col = ? ORDER BY received_at DESC", [col]) - else: - rows = store.query("SELECT id FROM leads WHERE col NOT IN ('taken') ORDER BY received_at DESC") - return [lead_to_dict(r["id"]) for r in rows] - - -def get_lead(lead_id: str) -> dict | None: - return lead_to_dict(lead_id) or None - - -def _move(lead_id: str, to_col: str, action: str = "move") -> None: - lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id]) - if not lead or lead["col"] == to_col: - return - text = (lead["source_msg"] or "").strip() or (lead["title"] or "") - # при переносе пересчитываем «почему карточка в колонке» (для архив/корзина — пусто) - hits = hits_for_board(to_col, text) if to_col not in ("inbox", "trash", "archive") else [] - store.execute( - "UPDATE leads SET col = ?, is_new = FALSE, prev_col = ?, match_hits = ? WHERE id = ?", - [to_col, lead["col"], json.dumps(hits, ensure_ascii=False), lead_id], - ) - _log_learning(lead_id, action, lead["col"], to_col) - - -def move_lead(lead_id: str, to_col: str, teach: bool = True) -> None: - """Перенос между канбаном (Неразобранное и доски); архив/корзина не цели переноса. - - teach=False — «тихое» перемещение без обучения (используется при ручной - разметке в ML-лаборатории, где обучение кладётся явно одним событием). - """ - if to_col not in ("inbox",) and store.query_one("SELECT 1 FROM boards WHERE id = ?", [to_col]) is None: - raise ValueError("Переносить можно только на доски или в «Неразобранное»") - lead = store.query_one("SELECT source_msg, title, col FROM leads WHERE id = ?", [lead_id]) - _move(lead_id, to_col) - # ML обучается всегда: текст -> выбранная доска - if teach and lead and to_col != "inbox" and to_col != lead["col"]: - text = (lead["source_msg"] or "").strip() or (lead["title"] or "") - if text: - ml_client.push(text, to_col) - - -def trash_lead(lead_id: str, teach: bool = True) -> None: - lead = store.query_one("SELECT source_msg, title, col FROM leads WHERE id = ?", [lead_id]) - _move(lead_id, "trash", action="trash") - # «в корзину» = спам/не то: ML запоминает (обучение всегда) - if teach and lead and lead["col"] != "trash" and lead["col"] != "archive": - text = (lead["source_msg"] or "").strip() or (lead["title"] or "") - if text: - ml_client.push(text, "spam") - - -def restore_lead(lead_id: str) -> str: - """Возврат из архива/корзины — только на канбан.""" - lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id]) - if not lead: - raise KeyError(lead_id) - back = lead["prev_col"] if lead["prev_col"] in ("inbox",) or store.query_one("SELECT 1 FROM boards WHERE id = ?", [lead["prev_col"]]) else "inbox" - text = (lead["source_msg"] or "").strip() or (lead["title"] or "") - hits = hits_for_board(back, text) if back not in ("inbox", "trash", "archive") else [] - store.execute( - "UPDATE leads SET col = ?, is_new = TRUE, prev_col = 'inbox', archived_at = NULL, match_hits = ? WHERE id = ?", - [back, json.dumps(hits, ensure_ascii=False), lead_id], - ) - _log_learning(lead_id, "restore", lead["col"], back) - # возврат из корзины = не спам: снимаем метку - if lead["col"] == "trash": - text = (lead["source_msg"] or "").strip() or (lead["title"] or "") - if text: - ml_client.push(text, "spam", delta=-1.0) - return back - - -def _hard_delete(lead_id: str) -> None: - """Полное удаление карточки: leads + dedup (иначе «сирота» заблокирует - повторное создание той же карточки при перечитывании) + отвязка исходника.""" - store.execute("DELETE FROM leads WHERE id = ?", [lead_id]) - store.execute("DELETE FROM dedup WHERE lead_id = ?", [lead_id]) - store.execute("UPDATE messages SET lead_id = NULL WHERE lead_id = ?", [lead_id]) - - -def delete_forever(lead_id: str) -> None: - _hard_delete(lead_id) - - -def clear_col(col: str) -> int: - """Полная ручная очистка служебной колонки (корзина/архив) — безвозвратно.""" - if col not in ("trash", "archive"): - raise ValueError("Очищать можно только корзину или архив") - ids = [r["id"] for r in store.query("SELECT id FROM leads WHERE col = ?", [col])] - if not ids: - return 0 - store.execute("DELETE FROM leads WHERE col = ?", [col]) - for lead_id in ids: - _hard_delete(lead_id) - return len(ids) - - -def mark_seen(lead_id: str | None = None, col: str | None = None) -> None: - if lead_id: - store.execute("UPDATE leads SET is_new = FALSE WHERE id = ?", [lead_id]) - elif col: - store.execute("UPDATE leads SET is_new = FALSE WHERE col = ?", [col]) - else: - store.execute("UPDATE leads SET is_new = FALSE") - - -def add_comment(lead_id: str, text: str) -> list[dict]: - lead = store.query_one("SELECT comments FROM leads WHERE id = ?", [lead_id]) - comments = json.loads(lead["comments"] or "[]") - comments.append({"id": store.uid("cm_"), "by": "Вы", "text": text.strip(), "time": "только что"}) - store.execute("UPDATE leads SET comments = ? WHERE id = ?", [json.dumps(comments, ensure_ascii=False), lead_id]) - _log_learning(lead_id, "comment", None, None) - return comments - - -def counts() -> dict: - """Счётчики по колонкам, новые + статистика ML/ИИ (локальная, без HTTP).""" - out = {"new": 0} - rows = store.query("SELECT col, count(*) AS cnt, sum(CASE WHEN is_new THEN 1 ELSE 0 END) AS fresh FROM leads GROUP BY col") - for r in rows: - out[r["col"]] = {"count": r["cnt"], "new": r["fresh"] or 0} - out["new"] = sum((v["new"] for k, v in out.items() if isinstance(v, dict)), 0) - snap = ml_client.snapshot() - out["learning"] = snap["learning"] - out["ml"] = snap["ml"] - out["ai"] = snap["ai"] - return out - - -# ─── Пакетная переклассификация (Inbox) ────────────────────────────────── - -# Фоновая задача переклассификации (одна; повторный вызов возвращает busy) -_reclassify_task: object | None = None - - -def reclassify_busy() -> bool: - return bool(_reclassify_task and not _reclassify_task.done()) - - -async def reclassify_lead(lead_id: str) -> dict | None: - """Прогнать карточку «Неразобранного» через полный конвейер ИИ. - - Этап 2 (ИИ-фильтр) + классификатор; мусор/спам/служебные сообщения - отправляются в корзину (с обучением ML). Вернувшееся None — карточка - без исходного текста или не найденная. - """ - lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id]) - if not lead or not lead["source_msg"]: - return None - from .ai import budget_to_target, classify, clean_budget, filter_incoming - - text = lead["source_msg"] - try: - r2 = await filter_incoming(text) - except Exception as exc: # noqa: BLE001 - log.warning("reclassify filter fail %s: %s", lead_id, exc) - r2 = {"pass": True, "reason": None, "skipped": True} - if not r2["pass"]: - trash_lead(lead_id, teach=False) - ml_client.push(text, "spam", delta=ml_client.AI_WEIGHT) - return {"status": "trashed", "reason": str(r2.get("reason") or "не прошло ИИ-фильтр")[:120]} - raw = await classify(text) - if raw.get("is_spam"): - trash_lead(lead_id, teach=False) - ml_client.push(text, "spam", delta=ml_client.AI_WEIGHT) - return {"status": "trashed", "reason": "ИИ: не заявка/спам"} - board_id = str(raw.get("board") or "").strip() or None - # страховка: колонку с активными правилами может назначить только текст, - # прошедший эти правила (иначе ручная переклассификация закидывает хлам) - if board_id and not board_accepts(board_id, text): - board_id = None - budget = clean_budget(raw.get("budget")) - contacts = build_contacts(raw.get("contacts"), text) - contact = primary_contact(contacts)[:200] - if not contact: - # старый контакт оставляем только если он валидный (@, телефон, почта…), - # а не мусорная фраза из старого разбора - old = str(lead["contact"] or "").strip()[:200] - contact = old if old and qualify_contact(old) else "" - stack = normalize_stack(raw.get("stack")) - new_title = clean_short(raw.get("title") or "", 140) or clean_short(lead["title"], 140) - new_summary = clean_block(compose_summary(raw, text), 2000) or clean_block(lead["summary"], 2000) - if not budget: - # ИИ не выделил бюджет полем, но сумма с валютой есть в исходнике или - # в структурированной «О заявке» — показываем её на карточке. - for src in (text, new_summary): - amts = extract_amounts(src or "") - if amts: - a = amts[0] - budget = {"from": a["from"], "to": a["to"], "currency": a["cur"]} - break - conv = budget_to_target(budget) - hits = hits_for_board(board_id, text) if board_id else [] - store.execute( - "UPDATE leads SET col = ?, title = ?, summary = ?, is_vacancy = ?, is_vacancy_known = TRUE, " - "stack = ?, budget_from = ?, budget_to = ?, budget_cur = ?, " - "conv_from = ?, conv_to = ?, conv_cur = ?, contact = ?, contacts = ?, " - "match_hits = ?, is_new = TRUE " - "WHERE id = ?", - [ - board_id or "inbox", - new_title, - new_summary, - bool(raw.get("is_vacancy")), - json.dumps(stack, ensure_ascii=False), - budget.get("from") if budget else None, - budget.get("to") if budget else None, - budget.get("currency", "") if budget else "", - conv["convFrom"], conv["convTo"], conv["convCur"], - contact, - json.dumps(contacts, ensure_ascii=False), - json.dumps(hits, ensure_ascii=False), - lead_id, - ], - ) - # ИИ-решение при переклассификации — тоже обучающий сигнал для ML. - # Учим только «свободные» колонки (без активных правил, не suggested): - # именно их ML может назначать сама в своём пути. - if board_id: - br = store.query_one("SELECT suggested, rules FROM boards WHERE id = ?", [board_id]) - free = False - if br and not bool(br["suggested"]): - try: - br_rules = json.loads(br["rules"] or "{}") if br["rules"] else {} - except Exception: # noqa: BLE001 - br_rules = {} - free = not has_active_rules(br_rules) - if free: - ml_client.push(text, board_id, delta=ml_client.AI_WEIGHT) - # тип известен от ИИ — учим ML определять его сам (t:hire / t:order) - if not bool(raw.get("is_spam")): - ml_client.push( - text, - "t:hire" if bool(raw.get("is_vacancy")) else "t:order", - delta=ml_client.AI_WEIGHT, - ) - return {"status": "moved" if board_id else "kept"} - - -async def reclassify_inbox(ids: list[str] | None = None) -> dict: - """Переклассифицировать «Неразобранное» (все карточки или выбранные).""" - rows = store.query("SELECT id FROM leads WHERE col = 'inbox'") - if ids: - wanted = set(ids) - target = [r["id"] for r in rows if r["id"] in wanted] - else: - target = [r["id"] for r in rows] - res = {"attempted": len(target), "kept": 0, "moved": 0, "trashed": 0} - for lead_id in target: - try: - out = await reclassify_lead(lead_id) - except Exception as exc: # noqa: BLE001 - log.warning("reclassify %s failed: %s", lead_id, exc) - continue - if not out: - continue - status = out.get("status") - if status == "trashed": - res["trashed"] += 1 - elif status == "moved": - res["moved"] += 1 - else: - res["kept"] += 1 - await broker.publish("leads_reclassified", res) - return res - - -async def start_reclassify(ids: list[str] | None = None) -> dict: - """Запустить переклассификацию в фоне (одна задача за раз).""" - global _reclassify_task - if not store.get_setting("aiEnabled"): - return {"started": False, "busy": False, "attempted": 0, "reason": "ИИ выключен — переклассификация недоступна"} - if reclassify_busy(): - return {"started": False, "busy": True} - rows = store.query("SELECT id FROM leads WHERE col = 'inbox'") - if ids: - wanted = set(ids) - target = [r["id"] for r in rows if r["id"] in wanted] - else: - target = [r["id"] for r in rows] - if not target: - return {"started": False, "busy": False, "attempted": 0} - - async def _run() -> None: - try: - res = await reclassify_inbox(ids) - await broker.publish_toast( - f"Переклассификация готова: {res['trashed']} в корзину, " - f"{res['moved']} в колонки, {res['kept']} осталось в «Неразобранном»", - "sparkles", - ) - except Exception as exc: # noqa: BLE001 - log.warning("reclassify task error: %s", exc) - await broker.publish_toast("Переклассификация завершилась с ошибкой", "x") - - _reclassify_task = asyncio.create_task(_run()) - return {"started": True, "busy": False, "attempted": len(target)} - - -# ─── Правила хранения (тик раз в 30 секунд) ─────────────────────────────── - -def tick_storage() -> dict: - auto = bool(store.get_setting("autoArchive")) - after_days = int(store.get_setting("archiveAfterDays") or 14) - archive_clear = int(store.get_setting("archiveClearDays") or 90) - trash_clear = int(store.get_setting("trashClearDays") or 7) - now = _now() - archived = purged_arch = purged_trash = 0 - - if auto: - rows = store.query( - "SELECT id FROM leads WHERE col IN (SELECT id FROM boards UNION ALL SELECT 'inbox') " - "AND received_at < ?", - [now - after_days * C.DAY_MS], - ) - for r in rows: - store.execute( - "UPDATE leads SET col = 'archive', is_new = FALSE, archived_at = ? WHERE id = ?", - [now, r["id"]], - ) - archived += 1 - - old_arch = store.query("SELECT id FROM leads WHERE col = 'archive' AND archived_at IS NOT NULL AND archived_at < ?", [now - archive_clear * C.DAY_MS]) - for r in old_arch: - _hard_delete(r["id"]) - purged_arch += 1 - - old_trash = store.query("SELECT id FROM leads WHERE col = 'trash' AND received_at < ?", [now - trash_clear * C.DAY_MS]) - for r in old_trash: - _hard_delete(r["id"]) - purged_trash += 1 - - # отсев пайплайна живёт 3 суток, дальше удаляется автоматически - purged_rejected = processing_svc.purge_expired() - - return { - "archived": archived, - "purgedArchive": purged_arch, - "purgedTrash": purged_trash, - "purgedRejected": purged_rejected, - } - - -async def notify_tick_stats(stats: dict) -> None: - if stats["archived"]: - await broker.publish_toast(f"Автоархив: {stats['archived']} карточек", "clock") - if stats["purgedArchive"]: - await broker.publish_toast(f"Архив очищен: {stats['purgedArchive']} (90 дн.)", "trash") - if stats["purgedTrash"]: - await broker.publish_toast(f"Корзина очищена: {stats['purgedTrash']} (7 дн.)", "trash") - if stats.get("purgedRejected"): - await broker.publish_toast(f"Отсев очищен: {stats['purgedRejected']} записей (3 дн.)", "trash") - - -# ─── Поиск ──────────────────────────────────────────────────────────────── - -def search(q: str, limit: int = 12) -> dict: - qq = q.strip().lower() - if len(qq) < 2: - return {"leads": [], "messages": []} - pattern = f"%{qq}%" - - # FTS-кандидаты (снимок индекса) - fts_ids: list[str] = [] - if fts_svc.is_ready(): - try: - fts_ids = fts_svc.search(qq, limit=limit)["leads"] - except Exception: # noqa: BLE001 - fts_ids = [] - - # LIKE-дополнение (свежие записи после последнего rebuild) - like_ids = [ - r["id"] - for r in store.query( - "SELECT id FROM leads WHERE col != 'taken' AND (" - " lower(title) LIKE ? OR lower(summary) LIKE ? OR lower(contact) LIKE ? OR lower(source_msg) LIKE ?) " - "ORDER BY received_at DESC LIMIT ?", - [pattern, pattern, pattern, pattern, limit * 2], - ) - ] - - merged: list[str] = [] - for bid in [*fts_ids, *like_ids]: - if bid not in merged: - merged.append(bid) - - leads = [] - for lid in merged: - d = lead_to_dict(lid) - if d and d["col"] != "taken": - leads.append(d) - if len(leads) >= limit: - break - - msgs = store.query( - "SELECT id, dialog_id, text, msg_at FROM messages WHERE lower(text) LIKE ? ORDER BY msg_at DESC LIMIT 20", - [pattern], - ) - return {"leads": leads, "messages": msgs} +"""Доски, колонки и карточки дашборда (п.4.4, 4.5, 4.7 ТЗ). + +Сюда же входят правила хранения (автоархив/очистка) и обучающие примеры +(действия пользователя -> learning_log). +""" +from __future__ import annotations + +import asyncio +import json +import logging +import time + +from .. import constants as C +from ..db import store +from ..sse import broker +from . import fts as fts_svc +from . import ml_client +from . import processing as processing_svc +from .pipeline import ( + build_contacts, + clean_block, + clean_short, + compose_summary, + lead_to_dict, + normalize_stack, + primary_contact, + qualify_contact, +) +from .rules import board_accepts, extract_amounts, has_active_rules, hits_for_board + +log = logging.getLogger("leadradar.leads") + +KANBAN_COLS = ("inbox",) # + доски + + +def _now() -> int: + return time.time_ns() // 1_000_000 + + +def _log_learning(lead_id: str, action: str, from_col: str | None, to_col: str | None) -> None: + store.execute( + "INSERT INTO learning_log(id, lead_id, action, from_col, to_col, created_at) VALUES (?, ?, ?, ?, ?, ?)", + [store.uid("lm_"), lead_id, action, from_col, to_col, _now()], + ) + + +# ─── Доски / колонки ────────────────────────────────────────────────────── + +def list_boards() -> list[dict]: + rows = store.query("SELECT * FROM boards ORDER BY suggested, pos") + return [ + { + "id": r["id"], + "name": r["name"], + "description": r["description"] or "", + "color": r["color"], + "width": r["width"], + "collapsed": bool(r["collapsed"]), + "keywords": json.loads(r["keywords"] or "[]"), + "prompt": r["prompt"] or "", + "visibleFields": json.loads(r["visible_fields"] or "[]"), + "suggested": bool(r["suggested"]), + "rules": json.loads(r["rules"] or "{}"), + "note": r["note"] or "", + } + for r in rows + ] + + +def board_by_id(board_id: str) -> dict | None: + return next((b for b in list_boards() if b["id"] == board_id), None) + + +def create_board( + name: str, + color: str | None = None, + keywords: list | None = None, + prompt: str = "", + description: str = "", + suggested: bool = False, + rules: dict | None = None, + note: str = "", +) -> dict: + """Создаёт колонку. suggested=TRUE — ИИ-предложение, ждёт решения пользователя.""" + board_id = store.uid("b_") + pos = int(store.scalar("SELECT COALESCE(MAX(pos), -1) + 1 FROM boards")) + store.execute( + "INSERT INTO boards(id, name, description, color, width, pos, keywords, prompt, visible_fields, collapsed, suggested, rules, note, created_at) " + "VALUES (?, ?, ?, ?, 'md', ?, ?, ?, '[\"budget\",\"stack\",\"contacts\"]', FALSE, ?, ?, ?, ?)", + [ + board_id, + name.strip() or "Новая колонка", + (description or "").strip(), + color or C.PALETTE[pos % len(C.PALETTE)], + pos, + json.dumps(keywords or [], ensure_ascii=False), + prompt or "", + bool(suggested), + json.dumps(rules or {}, ensure_ascii=False), + note or "", + _now(), + ], + ) + return {"id": board_id} + + +def patch_board(board_id: str, patch: dict) -> dict: + row = store.query_one("SELECT * FROM boards WHERE id = ?", [board_id]) + if not row: + raise KeyError(board_id) + allowed = {"name", "description", "color", "width", "collapsed", "prompt", "suggested", "note"} + for key in allowed: + if key in patch and patch[key] is not None: + store.execute(f"UPDATE boards SET {key} = ? WHERE id = ?", [patch[key], board_id]) + if "keywords" in patch and patch["keywords"] is not None: + store.execute("UPDATE boards SET keywords = ? WHERE id = ?", [json.dumps(patch["keywords"], ensure_ascii=False), board_id]) + if "visibleFields" in patch and patch["visibleFields"] is not None: + store.execute("UPDATE boards SET visible_fields = ? WHERE id = ?", [json.dumps(patch["visibleFields"], ensure_ascii=False), board_id]) + if "rules" in patch and patch["rules"] is not None: + store.execute("UPDATE boards SET rules = ? WHERE id = ?", [json.dumps(patch["rules"], ensure_ascii=False), board_id]) + return {"id": board_id} + + +def delete_board(board_id: str) -> int: + """Карточки доски уходят в «Неразобранное» (с пометкой новых).""" + leads = store.query("SELECT id FROM leads WHERE col = ?", [board_id]) + for l in leads: + store.execute("UPDATE leads SET col = 'inbox', is_new = TRUE, prev_col = 'inbox' WHERE id = ?", [l["id"]]) + store.execute("DELETE FROM boards WHERE id = ?", [board_id]) + return len(leads) + + +def reorder_boards(order: list[str]) -> None: + for i, board_id in enumerate(order): + store.execute("UPDATE boards SET pos = ? WHERE id = ?", [i, board_id]) + + +def get_col_state() -> dict: + return store.get_setting("colState") or {} + + +def set_col_state(col_id: str, state: dict) -> dict: + current = store.get_setting("colState") or {} + current[col_id] = state + store.set_setting("colState", current) + return current[col_id] + + +# ─── Лиды ───────────────────────────────────────────────────────────────── + +def list_leads(col: str | None = None) -> list[dict]: + if col: + rows = store.query("SELECT id FROM leads WHERE col = ? ORDER BY received_at DESC", [col]) + else: + rows = store.query("SELECT id FROM leads WHERE col NOT IN ('taken') ORDER BY received_at DESC") + return [lead_to_dict(r["id"]) for r in rows] + + +def get_lead(lead_id: str) -> dict | None: + return lead_to_dict(lead_id) or None + + +def _move(lead_id: str, to_col: str, action: str = "move") -> None: + lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id]) + if not lead or lead["col"] == to_col: + return + text = (lead["source_msg"] or "").strip() or (lead["title"] or "") + # при переносе пересчитываем «почему карточка в колонке» (для архив/корзина — пусто) + hits = hits_for_board(to_col, text) if to_col not in ("inbox", "trash", "archive") else [] + store.execute( + "UPDATE leads SET col = ?, is_new = FALSE, prev_col = ?, match_hits = ? WHERE id = ?", + [to_col, lead["col"], json.dumps(hits, ensure_ascii=False), lead_id], + ) + _log_learning(lead_id, action, lead["col"], to_col) + + +def move_lead(lead_id: str, to_col: str, teach: bool = True) -> None: + """Перенос между канбаном (Неразобранное и доски); архив/корзина не цели переноса. + + teach=False — «тихое» перемещение без обучения (используется при ручной + разметке в ML-лаборатории, где обучение кладётся явно одним событием). + """ + if to_col not in ("inbox",) and store.query_one("SELECT 1 FROM boards WHERE id = ?", [to_col]) is None: + raise ValueError("Переносить можно только на доски или в «Неразобранное»") + lead = store.query_one("SELECT source_msg, title, col FROM leads WHERE id = ?", [lead_id]) + _move(lead_id, to_col) + # ML обучается всегда: текст -> выбранная доска + if teach and lead and to_col != "inbox" and to_col != lead["col"]: + text = (lead["source_msg"] or "").strip() or (lead["title"] or "") + if text: + ml_client.push(text, to_col) + + +def trash_lead(lead_id: str, teach: bool = True) -> None: + lead = store.query_one("SELECT source_msg, title, col FROM leads WHERE id = ?", [lead_id]) + _move(lead_id, "trash", action="trash") + # «в корзину» = спам/не то: ML запоминает (обучение всегда) + if teach and lead and lead["col"] != "trash" and lead["col"] != "archive": + text = (lead["source_msg"] or "").strip() or (lead["title"] or "") + if text: + ml_client.push(text, "spam") + + +def restore_lead(lead_id: str) -> str: + """Возврат из архива/корзины — только на канбан.""" + lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id]) + if not lead: + raise KeyError(lead_id) + back = lead["prev_col"] if lead["prev_col"] in ("inbox",) or store.query_one("SELECT 1 FROM boards WHERE id = ?", [lead["prev_col"]]) else "inbox" + text = (lead["source_msg"] or "").strip() or (lead["title"] or "") + hits = hits_for_board(back, text) if back not in ("inbox", "trash", "archive") else [] + store.execute( + "UPDATE leads SET col = ?, is_new = TRUE, prev_col = 'inbox', archived_at = NULL, match_hits = ? WHERE id = ?", + [back, json.dumps(hits, ensure_ascii=False), lead_id], + ) + _log_learning(lead_id, "restore", lead["col"], back) + # возврат из корзины = не спам: снимаем метку + if lead["col"] == "trash": + text = (lead["source_msg"] or "").strip() or (lead["title"] or "") + if text: + ml_client.push(text, "spam", delta=-1.0) + return back + + +def _hard_delete(lead_id: str) -> None: + """Полное удаление карточки: leads + dedup (иначе «сирота» заблокирует + повторное создание той же карточки при перечитывании) + отвязка исходника.""" + store.execute("DELETE FROM leads WHERE id = ?", [lead_id]) + store.execute("DELETE FROM dedup WHERE lead_id = ?", [lead_id]) + store.execute("UPDATE messages SET lead_id = NULL WHERE lead_id = ?", [lead_id]) + + +def delete_forever(lead_id: str) -> None: + _hard_delete(lead_id) + + +def clear_col(col: str) -> int: + """Полная ручная очистка служебной колонки (корзина/архив) — безвозвратно.""" + if col not in ("trash", "archive"): + raise ValueError("Очищать можно только корзину или архив") + ids = [r["id"] for r in store.query("SELECT id FROM leads WHERE col = ?", [col])] + if not ids: + return 0 + store.execute("DELETE FROM leads WHERE col = ?", [col]) + for lead_id in ids: + _hard_delete(lead_id) + return len(ids) + + +def mark_seen(lead_id: str | None = None, col: str | None = None) -> None: + if lead_id: + store.execute("UPDATE leads SET is_new = FALSE WHERE id = ?", [lead_id]) + elif col: + store.execute("UPDATE leads SET is_new = FALSE WHERE col = ?", [col]) + else: + store.execute("UPDATE leads SET is_new = FALSE") + + +def add_comment(lead_id: str, text: str) -> list[dict]: + lead = store.query_one("SELECT comments FROM leads WHERE id = ?", [lead_id]) + comments = json.loads(lead["comments"] or "[]") + comments.append({"id": store.uid("cm_"), "by": "Вы", "text": text.strip(), "time": "только что"}) + store.execute("UPDATE leads SET comments = ? WHERE id = ?", [json.dumps(comments, ensure_ascii=False), lead_id]) + _log_learning(lead_id, "comment", None, None) + return comments + + +def counts() -> dict: + """Счётчики по колонкам, новые + статистика ML/ИИ (локальная, без HTTP).""" + out = {"new": 0} + rows = store.query("SELECT col, count(*) AS cnt, sum(CASE WHEN is_new THEN 1 ELSE 0 END) AS fresh FROM leads GROUP BY col") + for r in rows: + out[r["col"]] = {"count": r["cnt"], "new": r["fresh"] or 0} + out["new"] = sum((v["new"] for k, v in out.items() if isinstance(v, dict)), 0) + snap = ml_client.snapshot() + out["learning"] = snap["learning"] + out["ml"] = snap["ml"] + out["ai"] = snap["ai"] + return out + + +# ─── Пакетная переклассификация (Inbox) ────────────────────────────────── + +# Фоновая задача переклассификации (одна; повторный вызов возвращает busy) +_reclassify_task: object | None = None + + +def reclassify_busy() -> bool: + return bool(_reclassify_task and not _reclassify_task.done()) + + +async def reclassify_lead(lead_id: str) -> dict | None: + """Прогнать карточку «Неразобранного» через полный конвейер ИИ. + + Этап 2 (ИИ-фильтр) + классификатор; мусор/спам/служебные сообщения + отправляются в корзину (с обучением ML). Вернувшееся None — карточка + без исходного текста или не найденная. + """ + lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id]) + if not lead or not lead["source_msg"]: + return None + from .ai import budget_to_target, classify, clean_budget, filter_incoming + + text = lead["source_msg"] + try: + r2 = await filter_incoming(text) + except Exception as exc: # noqa: BLE001 + log.warning("reclassify filter fail %s: %s", lead_id, exc) + r2 = {"pass": True, "reason": None, "skipped": True} + if not r2["pass"]: + trash_lead(lead_id, teach=False) + ml_client.push(text, "spam", delta=ml_client.AI_WEIGHT) + return {"status": "trashed", "reason": str(r2.get("reason") or "не прошло ИИ-фильтр")[:120]} + raw = await classify(text) + if raw.get("is_spam"): + trash_lead(lead_id, teach=False) + ml_client.push(text, "spam", delta=ml_client.AI_WEIGHT) + return {"status": "trashed", "reason": "ИИ: не заявка/спам"} + board_id = str(raw.get("board") or "").strip() or None + # страховка: колонку с активными правилами может назначить только текст, + # прошедший эти правила (иначе ручная переклассификация закидывает хлам) + if board_id and not board_accepts(board_id, text): + board_id = None + budget = clean_budget(raw.get("budget")) + contacts = build_contacts(raw.get("contacts"), text) + contact = primary_contact(contacts)[:200] + if not contact: + # старый контакт оставляем только если он валидный (@, телефон, почта…), + # а не мусорная фраза из старого разбора + old = str(lead["contact"] or "").strip()[:200] + contact = old if old and qualify_contact(old) else "" + stack = normalize_stack(raw.get("stack")) + new_title = clean_short(raw.get("title") or "", 140) or clean_short(lead["title"], 140) + new_summary = clean_block(compose_summary(raw, text), 2000) or clean_block(lead["summary"], 2000) + if not budget: + # ИИ не выделил бюджет полем, но сумма с валютой есть в исходнике или + # в структурированной «О заявке» — показываем её на карточке. + for src in (text, new_summary): + amts = extract_amounts(src or "") + if amts: + a = amts[0] + budget = {"from": a["from"], "to": a["to"], "currency": a["cur"]} + break + conv = budget_to_target(budget) + hits = hits_for_board(board_id, text) if board_id else [] + store.execute( + "UPDATE leads SET col = ?, title = ?, summary = ?, is_vacancy = ?, is_vacancy_known = TRUE, " + "stack = ?, budget_from = ?, budget_to = ?, budget_cur = ?, " + "conv_from = ?, conv_to = ?, conv_cur = ?, contact = ?, contacts = ?, " + "match_hits = ?, is_new = TRUE " + "WHERE id = ?", + [ + board_id or "inbox", + new_title, + new_summary, + bool(raw.get("is_vacancy")), + json.dumps(stack, ensure_ascii=False), + budget.get("from") if budget else None, + budget.get("to") if budget else None, + budget.get("currency", "") if budget else "", + conv["convFrom"], conv["convTo"], conv["convCur"], + contact, + json.dumps(contacts, ensure_ascii=False), + json.dumps(hits, ensure_ascii=False), + lead_id, + ], + ) + # ИИ-решение при переклассификации — тоже обучающий сигнал для ML. + # Учим только «свободные» колонки (без активных правил, не suggested): + # именно их ML может назначать сама в своём пути. + if board_id: + br = store.query_one("SELECT suggested, rules FROM boards WHERE id = ?", [board_id]) + free = False + if br and not bool(br["suggested"]): + try: + br_rules = json.loads(br["rules"] or "{}") if br["rules"] else {} + except Exception: # noqa: BLE001 + br_rules = {} + free = not has_active_rules(br_rules) + if free: + ml_client.push(text, board_id, delta=ml_client.AI_WEIGHT) + # тип известен от ИИ — учим ML определять его сам (t:hire / t:order) + if not bool(raw.get("is_spam")): + ml_client.push( + text, + "t:hire" if bool(raw.get("is_vacancy")) else "t:order", + delta=ml_client.AI_WEIGHT, + ) + return {"status": "moved" if board_id else "kept"} + + +async def reclassify_inbox(ids: list[str] | None = None) -> dict: + """Переклассифицировать «Неразобранное» (все карточки или выбранные).""" + rows = store.query("SELECT id FROM leads WHERE col = 'inbox'") + if ids: + wanted = set(ids) + target = [r["id"] for r in rows if r["id"] in wanted] + else: + target = [r["id"] for r in rows] + res = {"attempted": len(target), "kept": 0, "moved": 0, "trashed": 0} + for lead_id in target: + try: + out = await reclassify_lead(lead_id) + except Exception as exc: # noqa: BLE001 + log.warning("reclassify %s failed: %s", lead_id, exc) + continue + if not out: + continue + status = out.get("status") + if status == "trashed": + res["trashed"] += 1 + elif status == "moved": + res["moved"] += 1 + else: + res["kept"] += 1 + await broker.publish("leads_reclassified", res) + return res + + +async def start_reclassify(ids: list[str] | None = None) -> dict: + """Запустить переклассификацию в фоне (одна задача за раз).""" + global _reclassify_task + if not store.get_setting("aiEnabled"): + return {"started": False, "busy": False, "attempted": 0, "reason": "ИИ выключен — переклассификация недоступна"} + if reclassify_busy(): + return {"started": False, "busy": True} + rows = store.query("SELECT id FROM leads WHERE col = 'inbox'") + if ids: + wanted = set(ids) + target = [r["id"] for r in rows if r["id"] in wanted] + else: + target = [r["id"] for r in rows] + if not target: + return {"started": False, "busy": False, "attempted": 0} + + async def _run() -> None: + try: + res = await reclassify_inbox(ids) + await broker.publish_toast( + f"Переклассификация готова: {res['trashed']} в корзину, " + f"{res['moved']} в колонки, {res['kept']} осталось в «Неразобранном»", + "sparkles", + ) + except Exception as exc: # noqa: BLE001 + log.warning("reclassify task error: %s", exc) + await broker.publish_toast("Переклассификация завершилась с ошибкой", "x") + + _reclassify_task = asyncio.create_task(_run()) + return {"started": True, "busy": False, "attempted": len(target)} + + +# ─── Правила хранения (тик раз в 30 секунд) ─────────────────────────────── + +def tick_storage() -> dict: + auto = bool(store.get_setting("autoArchive")) + after_days = int(store.get_setting("archiveAfterDays") or 14) + archive_clear = int(store.get_setting("archiveClearDays") or 90) + trash_clear = int(store.get_setting("trashClearDays") or 7) + now = _now() + archived = purged_arch = purged_trash = 0 + + if auto: + rows = store.query( + "SELECT id FROM leads WHERE col IN (SELECT id FROM boards UNION ALL SELECT 'inbox') " + "AND received_at < ?", + [now - after_days * C.DAY_MS], + ) + for r in rows: + store.execute( + "UPDATE leads SET col = 'archive', is_new = FALSE, archived_at = ? WHERE id = ?", + [now, r["id"]], + ) + archived += 1 + + old_arch = store.query("SELECT id FROM leads WHERE col = 'archive' AND archived_at IS NOT NULL AND archived_at < ?", [now - archive_clear * C.DAY_MS]) + for r in old_arch: + _hard_delete(r["id"]) + purged_arch += 1 + + old_trash = store.query("SELECT id FROM leads WHERE col = 'trash' AND received_at < ?", [now - trash_clear * C.DAY_MS]) + for r in old_trash: + _hard_delete(r["id"]) + purged_trash += 1 + + # отсев пайплайна живёт 3 суток, дальше удаляется автоматически + purged_rejected = processing_svc.purge_expired() + + return { + "archived": archived, + "purgedArchive": purged_arch, + "purgedTrash": purged_trash, + "purgedRejected": purged_rejected, + } + + +async def notify_tick_stats(stats: dict) -> None: + if stats["archived"]: + await broker.publish_toast(f"Автоархив: {stats['archived']} карточек", "clock") + if stats["purgedArchive"]: + await broker.publish_toast(f"Архив очищен: {stats['purgedArchive']} (90 дн.)", "trash") + if stats["purgedTrash"]: + await broker.publish_toast(f"Корзина очищена: {stats['purgedTrash']} (7 дн.)", "trash") + if stats.get("purgedRejected"): + await broker.publish_toast(f"Отсев очищен: {stats['purgedRejected']} записей (3 дн.)", "trash") + + +# ─── Поиск ──────────────────────────────────────────────────────────────── + +def search(q: str, limit: int = 12) -> dict: + qq = q.strip().lower() + if len(qq) < 2: + return {"leads": [], "messages": []} + pattern = f"%{qq}%" + + # FTS-кандидаты (снимок индекса) + fts_ids: list[str] = [] + if fts_svc.is_ready(): + try: + fts_ids = fts_svc.search(qq, limit=limit)["leads"] + except Exception: # noqa: BLE001 + fts_ids = [] + + # LIKE-дополнение (свежие записи после последнего rebuild) + like_ids = [ + r["id"] + for r in store.query( + "SELECT id FROM leads WHERE col != 'taken' AND (" + " lower(title) LIKE ? OR lower(summary) LIKE ? OR lower(contact) LIKE ? OR lower(source_msg) LIKE ?) " + "ORDER BY received_at DESC LIMIT ?", + [pattern, pattern, pattern, pattern, limit * 2], + ) + ] + + merged: list[str] = [] + for bid in [*fts_ids, *like_ids]: + if bid not in merged: + merged.append(bid) + + leads = [] + for lid in merged: + d = lead_to_dict(lid) + if d and d["col"] != "taken": + leads.append(d) + if len(leads) >= limit: + break + + msgs = store.query( + "SELECT id, dialog_id, text, msg_at FROM messages WHERE lower(text) LIKE ? ORDER BY msg_at DESC LIMIT 20", + [pattern], + ) + return {"leads": leads, "messages": msgs} diff --git a/archive/leadradar-legacy/backend/app/services/ml_client.py b/archive/leadradar-legacy/backend/app/services/ml_client.py index 84bab54..f74b96d 100644 --- a/archive/leadradar-legacy/backend/app/services/ml_client.py +++ b/archive/leadradar-legacy/backend/app/services/ml_client.py @@ -1,162 +1,162 @@ -"""Клиент автономного ML-сервиса + локальный outbox обучения. - -Основное приложение НИКОГДА не обучает ML напрямую «в своей» базе: каждое -действие пользователя синхронно пишется в таблицу ml_outbox, а фоновый -воркер (main._ml_sync_loop) отправляет накопленное в ML-сервис батчами. -Использование ML в пайплайне включается настройкой `mlEnabled`; обучение -идёт всегда. -""" -from __future__ import annotations - -import logging -import time - -import httpx - -from .. import config -from ..db import store - -log = logging.getLogger("leadradar.mlclient") - -DECISIONS_ML = "mlDecisions" -DECISIONS_AI = "aiDecisions" - -# Веса обучающих сигналов: действия пользователя — истина (1.0), решения ИИ — -# гипотезы (меньше), чтобы реальные действия со временем перевешивали ошибки ИИ. -USER_WEIGHT = 1.0 -AI_WEIGHT = 0.4 -RULE_WEIGHT = 0.6 - -# кэш статуса сервиса (обновляется фоновым циклом; живёт не дольше 15 c) -_cached: dict = {"at": 0, "data": None, "reachable": False} - - -def _now() -> int: - return time.time_ns() // 1_000_000 - - -# ─── Обучение: всегда пишем в outbox ────────────────────────────────────── - -def push(text: str, label: str, delta: float = 1.0) -> None: - """Действие пользователя -> событие обучения (гарантированно, локально).""" - text = (text or "").strip() - label = str(label or "").strip() - if not text or not label: - return - store.execute( - "INSERT INTO ml_outbox(id, text, label, delta, created_at) VALUES (?, ?, ?, ?, ?)", - [store.uid("mle_"), text[:6000], label, delta, _now()], - ) - - -def outbox_len() -> int: - return int(store.scalar("SELECT count(*) FROM ml_outbox") or 0) - - -async def flush_outbox(batch: int = 100) -> int: - """Отправляет накопленные события в ML-сервис небольшими порциями. - - Один learn-batch из сотен сообщений надолго блокирует ML-сервис - (модель пишет каждый термин отдельным INSERT) и упирается в таймаут; - порции по 20 строк проходят быстро и не роняют сервис. - """ - chunk = 10 - total = 0 - while total < batch: - rows = store.query( - "SELECT id, text, label, delta FROM ml_outbox ORDER BY created_at LIMIT ?", - [chunk], - ) - if not rows: - break - items = [{"text": r["text"], "label": r["label"], "delta": r["delta"]} for r in rows] - try: - await _post("/learn-batch", {"items": items}, timeout=config.ML_TIMEOUT + 10) - except Exception as exc: # noqa: BLE001 - log.warning("ml outbox flush failed (%d rows): %s", len(rows), exc) - break - ids = [r["id"] for r in rows] - store.execute(f"DELETE FROM ml_outbox WHERE id IN ({','.join('?' * len(ids))})", ids) - total += len(rows) - log.info("ml outbox flushed: %d", len(rows)) - return total - - -# ─── HTTP ───────────────────────────────────────────────────────────────── - -async def _post(path: str, body: dict, timeout: float | None = None) -> dict: - async with httpx.AsyncClient(timeout=timeout or config.ML_TIMEOUT) as client: - resp = await client.post(config.ML_URL + path, json=body) - resp.raise_for_status() - return resp.json() - - -async def _get(path: str, timeout: float | None = None) -> dict: - async with httpx.AsyncClient(timeout=timeout or config.ML_TIMEOUT) as client: - resp = await client.get(config.ML_URL + path) - resp.raise_for_status() - return resp.json() - - -async def predict(text: str) -> dict: - """Предсказание. При сбое/недоступности сервиса — «не уверен» (решит ИИ).""" - try: - return await _post("/predict", {"text": (text or "")[:6000]}) - except Exception as exc: # noqa: BLE001 - log.debug("ml predict unavailable: %s", exc) - return {"take": False, "label": None, "scores": {}, "hits": 0, "ready": False} - - -async def reset_model() -> dict: - """Полный сброс ML-модели + очистка очереди обучения. - - Старая модель (в т.ч. «мусорные» классы удалённых колонок) стирается, - события обучения из outbox тоже удаляются — иначе они сразу «переобучат» - модель на старых данных. - """ - try: - await _post("/reset", {}, timeout=30) - except Exception as exc: # noqa: BLE001 - log.warning("ml reset failed: %s", exc) - return {"ok": False, "error": str(exc)} - store.execute("DELETE FROM ml_outbox") - await refresh_status() - return {"ok": True} - - -async def refresh_status() -> dict: - global _cached - try: - data = await _get("/status", timeout=3) - _cached = {"at": _now(), "data": data, "reachable": True} - except Exception as exc: # noqa: BLE001 - log.debug("ml status unavailable: %s", exc) - _cached = {"at": _now(), "data": _cached["data"], "reachable": False} - return _cached["data"] or {} - - -def snapshot() -> dict: - """Локальная статистика + последний известный статус ML-сервиса (без HTTP).""" - fresh = _cached["data"] or {} - return { - "ml": int(store.get_setting(DECISIONS_ML) or 0), - "ai": int(store.get_setting(DECISIONS_AI) or 0), - "learning": int(store.scalar("SELECT count(*) FROM learning_log") or 0), - "ready": bool(fresh.get("ready")), - "classes": fresh.get("classes", {}), - "learned": int(fresh.get("learned") or 0), - "reachable": _cached["reachable"], - "outbox": outbox_len(), - } - - -def track_decisions(ml: int = 0, ai: int = 0) -> None: - if ml: - store.set_setting(DECISIONS_ML, int(store.get_setting(DECISIONS_ML) or 0) + ml) - if ai: - store.set_setting(DECISIONS_AI, int(store.get_setting(DECISIONS_AI) or 0) + ai) - - -def is_enabled() -> bool: - """Использовать ли ML в пайплайне (настройка UI). Обучение — всегда.""" - return store.get_setting("mlEnabled") is not False and _cached["reachable"] +"""Клиент автономного ML-сервиса + локальный outbox обучения. + +Основное приложение НИКОГДА не обучает ML напрямую «в своей» базе: каждое +действие пользователя синхронно пишется в таблицу ml_outbox, а фоновый +воркер (main._ml_sync_loop) отправляет накопленное в ML-сервис батчами. +Использование ML в пайплайне включается настройкой `mlEnabled`; обучение +идёт всегда. +""" +from __future__ import annotations + +import logging +import time + +import httpx + +from .. import config +from ..db import store + +log = logging.getLogger("leadradar.mlclient") + +DECISIONS_ML = "mlDecisions" +DECISIONS_AI = "aiDecisions" + +# Веса обучающих сигналов: действия пользователя — истина (1.0), решения ИИ — +# гипотезы (меньше), чтобы реальные действия со временем перевешивали ошибки ИИ. +USER_WEIGHT = 1.0 +AI_WEIGHT = 0.4 +RULE_WEIGHT = 0.6 + +# кэш статуса сервиса (обновляется фоновым циклом; живёт не дольше 15 c) +_cached: dict = {"at": 0, "data": None, "reachable": False} + + +def _now() -> int: + return time.time_ns() // 1_000_000 + + +# ─── Обучение: всегда пишем в outbox ────────────────────────────────────── + +def push(text: str, label: str, delta: float = 1.0) -> None: + """Действие пользователя -> событие обучения (гарантированно, локально).""" + text = (text or "").strip() + label = str(label or "").strip() + if not text or not label: + return + store.execute( + "INSERT INTO ml_outbox(id, text, label, delta, created_at) VALUES (?, ?, ?, ?, ?)", + [store.uid("mle_"), text[:6000], label, delta, _now()], + ) + + +def outbox_len() -> int: + return int(store.scalar("SELECT count(*) FROM ml_outbox") or 0) + + +async def flush_outbox(batch: int = 100) -> int: + """Отправляет накопленные события в ML-сервис небольшими порциями. + + Один learn-batch из сотен сообщений надолго блокирует ML-сервис + (модель пишет каждый термин отдельным INSERT) и упирается в таймаут; + порции по 20 строк проходят быстро и не роняют сервис. + """ + chunk = 10 + total = 0 + while total < batch: + rows = store.query( + "SELECT id, text, label, delta FROM ml_outbox ORDER BY created_at LIMIT ?", + [chunk], + ) + if not rows: + break + items = [{"text": r["text"], "label": r["label"], "delta": r["delta"]} for r in rows] + try: + await _post("/learn-batch", {"items": items}, timeout=config.ML_TIMEOUT + 10) + except Exception as exc: # noqa: BLE001 + log.warning("ml outbox flush failed (%d rows): %s", len(rows), exc) + break + ids = [r["id"] for r in rows] + store.execute(f"DELETE FROM ml_outbox WHERE id IN ({','.join('?' * len(ids))})", ids) + total += len(rows) + log.info("ml outbox flushed: %d", len(rows)) + return total + + +# ─── HTTP ───────────────────────────────────────────────────────────────── + +async def _post(path: str, body: dict, timeout: float | None = None) -> dict: + async with httpx.AsyncClient(timeout=timeout or config.ML_TIMEOUT) as client: + resp = await client.post(config.ML_URL + path, json=body) + resp.raise_for_status() + return resp.json() + + +async def _get(path: str, timeout: float | None = None) -> dict: + async with httpx.AsyncClient(timeout=timeout or config.ML_TIMEOUT) as client: + resp = await client.get(config.ML_URL + path) + resp.raise_for_status() + return resp.json() + + +async def predict(text: str) -> dict: + """Предсказание. При сбое/недоступности сервиса — «не уверен» (решит ИИ).""" + try: + return await _post("/predict", {"text": (text or "")[:6000]}) + except Exception as exc: # noqa: BLE001 + log.debug("ml predict unavailable: %s", exc) + return {"take": False, "label": None, "scores": {}, "hits": 0, "ready": False} + + +async def reset_model() -> dict: + """Полный сброс ML-модели + очистка очереди обучения. + + Старая модель (в т.ч. «мусорные» классы удалённых колонок) стирается, + события обучения из outbox тоже удаляются — иначе они сразу «переобучат» + модель на старых данных. + """ + try: + await _post("/reset", {}, timeout=30) + except Exception as exc: # noqa: BLE001 + log.warning("ml reset failed: %s", exc) + return {"ok": False, "error": str(exc)} + store.execute("DELETE FROM ml_outbox") + await refresh_status() + return {"ok": True} + + +async def refresh_status() -> dict: + global _cached + try: + data = await _get("/status", timeout=3) + _cached = {"at": _now(), "data": data, "reachable": True} + except Exception as exc: # noqa: BLE001 + log.debug("ml status unavailable: %s", exc) + _cached = {"at": _now(), "data": _cached["data"], "reachable": False} + return _cached["data"] or {} + + +def snapshot() -> dict: + """Локальная статистика + последний известный статус ML-сервиса (без HTTP).""" + fresh = _cached["data"] or {} + return { + "ml": int(store.get_setting(DECISIONS_ML) or 0), + "ai": int(store.get_setting(DECISIONS_AI) or 0), + "learning": int(store.scalar("SELECT count(*) FROM learning_log") or 0), + "ready": bool(fresh.get("ready")), + "classes": fresh.get("classes", {}), + "learned": int(fresh.get("learned") or 0), + "reachable": _cached["reachable"], + "outbox": outbox_len(), + } + + +def track_decisions(ml: int = 0, ai: int = 0) -> None: + if ml: + store.set_setting(DECISIONS_ML, int(store.get_setting(DECISIONS_ML) or 0) + ml) + if ai: + store.set_setting(DECISIONS_AI, int(store.get_setting(DECISIONS_AI) or 0) + ai) + + +def is_enabled() -> bool: + """Использовать ли ML в пайплайне (настройка UI). Обучение — всегда.""" + return store.get_setting("mlEnabled") is not False and _cached["reachable"] diff --git a/archive/leadradar-legacy/backend/app/services/object_store.py b/archive/leadradar-legacy/backend/app/services/object_store.py index c582ee6..52b235e 100644 --- a/archive/leadradar-legacy/backend/app/services/object_store.py +++ b/archive/leadradar-legacy/backend/app/services/object_store.py @@ -1,108 +1,108 @@ -"""Хранение вложений: MinIO (S3-совместимое), креды в env. - -Если MinIO не настроен (локальная разработка без docker-compose), объекты -сохраняются в локальную папку DATA_DIR/attachments — API и карточки при этом -не меняются, в БД хранится только ключ объекта. -""" -from __future__ import annotations - -import io -import logging -from pathlib import Path - -from .. import config - -log = logging.getLogger("leadradar.objectstore") - - -def configured() -> bool: - return bool(config.MINIO_ENDPOINT and config.MINIO_ACCESS_KEY and config.MINIO_SECRET_KEY) - - -_client = None -_client_checked = False - - -def client(): - """MinIO-клиент (создаётся лениво). Бросает ошибку, если не настроен.""" - global _client, _client_checked - if _client is not None: - return _client - if not configured(): - raise RuntimeError( - "MinIO не настроен: задайте LEADRADAR_MINIO_ENDPOINT / _ACCESS_KEY / _SECRET_KEY / _BUCKET" - ) - from minio import Minio - from minio.error import S3Error - - _client = Minio( - config.MINIO_ENDPOINT, - access_key=config.MINIO_ACCESS_KEY, - secret_key=config.MINIO_SECRET_KEY, - secure=config.MINIO_SECURE, - ) - if not _client_checked: - _client_checked = True - try: - if not _client.bucket_exists(config.MINIO_BUCKET): - _client.make_bucket(config.MINIO_BUCKET) - except S3Error as exc: - log.warning("bucket check failed: %s", exc) - return _client - - -def _local_path(object_key: str) -> Path: - # object_key вида projects/{card_id}/{ts}_{name}; не даём выйти за пределы FILES_DIR - safe = Path(object_key).name - parent = Path(object_key).parent - return (config.FILES_DIR / parent / safe).resolve() - - -def put(card_id: str, name: str, data: bytes, content_type: str) -> str: - """Сохраняет объект (MinIO или локально), возвращает objectKey.""" - import time - - object_key = f"projects/{card_id}/{int(time.time() * 1000)}_{name}" - if configured(): - client().put_object( - config.MINIO_BUCKET, - object_key, - io.BytesIO(data), - length=len(data), - content_type=content_type or "application/octet-stream", - ) - else: - path = _local_path(object_key) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(data) - log.info("MinIO не настроен — файл сохранён локально: %s", path) - return object_key - - -def get(object_key: str): - """Возвращает поток (бинарный) для скачивания.""" - if configured(): - try: - return client().get_object(config.MINIO_BUCKET, object_key) - except Exception as exc: # noqa: BLE001 - log.warning("minio get failed: %s", exc) - raise - path = _local_path(object_key) - if not path.is_file(): - raise FileNotFoundError(object_key) - return io.BytesIO(path.read_bytes()) - - -def remove(object_key: str) -> None: - if configured(): - try: - client().remove_object(config.MINIO_BUCKET, object_key) - except Exception as exc: # noqa: BLE001 - log.warning("minio remove failed: %s", exc) - return - try: - path = _local_path(object_key) - if path.is_file(): - path.unlink() - except Exception as exc: # noqa: BLE001 - log.warning("local remove failed: %s", exc) +"""Хранение вложений: MinIO (S3-совместимое), креды в env. + +Если MinIO не настроен (локальная разработка без docker-compose), объекты +сохраняются в локальную папку DATA_DIR/attachments — API и карточки при этом +не меняются, в БД хранится только ключ объекта. +""" +from __future__ import annotations + +import io +import logging +from pathlib import Path + +from .. import config + +log = logging.getLogger("leadradar.objectstore") + + +def configured() -> bool: + return bool(config.MINIO_ENDPOINT and config.MINIO_ACCESS_KEY and config.MINIO_SECRET_KEY) + + +_client = None +_client_checked = False + + +def client(): + """MinIO-клиент (создаётся лениво). Бросает ошибку, если не настроен.""" + global _client, _client_checked + if _client is not None: + return _client + if not configured(): + raise RuntimeError( + "MinIO не настроен: задайте LEADRADAR_MINIO_ENDPOINT / _ACCESS_KEY / _SECRET_KEY / _BUCKET" + ) + from minio import Minio + from minio.error import S3Error + + _client = Minio( + config.MINIO_ENDPOINT, + access_key=config.MINIO_ACCESS_KEY, + secret_key=config.MINIO_SECRET_KEY, + secure=config.MINIO_SECURE, + ) + if not _client_checked: + _client_checked = True + try: + if not _client.bucket_exists(config.MINIO_BUCKET): + _client.make_bucket(config.MINIO_BUCKET) + except S3Error as exc: + log.warning("bucket check failed: %s", exc) + return _client + + +def _local_path(object_key: str) -> Path: + # object_key вида projects/{card_id}/{ts}_{name}; не даём выйти за пределы FILES_DIR + safe = Path(object_key).name + parent = Path(object_key).parent + return (config.FILES_DIR / parent / safe).resolve() + + +def put(card_id: str, name: str, data: bytes, content_type: str) -> str: + """Сохраняет объект (MinIO или локально), возвращает objectKey.""" + import time + + object_key = f"projects/{card_id}/{int(time.time() * 1000)}_{name}" + if configured(): + client().put_object( + config.MINIO_BUCKET, + object_key, + io.BytesIO(data), + length=len(data), + content_type=content_type or "application/octet-stream", + ) + else: + path = _local_path(object_key) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + log.info("MinIO не настроен — файл сохранён локально: %s", path) + return object_key + + +def get(object_key: str): + """Возвращает поток (бинарный) для скачивания.""" + if configured(): + try: + return client().get_object(config.MINIO_BUCKET, object_key) + except Exception as exc: # noqa: BLE001 + log.warning("minio get failed: %s", exc) + raise + path = _local_path(object_key) + if not path.is_file(): + raise FileNotFoundError(object_key) + return io.BytesIO(path.read_bytes()) + + +def remove(object_key: str) -> None: + if configured(): + try: + client().remove_object(config.MINIO_BUCKET, object_key) + except Exception as exc: # noqa: BLE001 + log.warning("minio remove failed: %s", exc) + return + try: + path = _local_path(object_key) + if path.is_file(): + path.unlink() + except Exception as exc: # noqa: BLE001 + log.warning("local remove failed: %s", exc) diff --git a/archive/leadradar-legacy/backend/app/services/pipeline.py b/archive/leadradar-legacy/backend/app/services/pipeline.py index c99c542..f2a1e7c 100644 --- a/archive/leadradar-legacy/backend/app/services/pipeline.py +++ b/archive/leadradar-legacy/backend/app/services/pipeline.py @@ -1,1183 +1,1183 @@ -"""Пайплайн входящих (п.4, п.5 ТЗ) — очередь + фоновый воркер. - -Все сообщения из групп/каналов попадают в очередь pipeline_msg и разбираются -фоновым воркером (main._pipeline_loop и ручной /api/admin/tick): - - queue(status='new') - -> этап 1: стоп-фразы и длина (без ИИ); не прошло -> в отсев (rejected) - -> дедупликация по нормализованному тексту (повтор -> в отсев) - -> ML-слой: если уверен, решает сам (спам -> отсев, - доска -> карточка сразу); иначе status='filtered' - queue(status='filtered') - -> этап 2: ИИ-фильтр (если включён; иначе пропуск) - -> ИИ-классификация (если ML не решил) - -> карточка в БД + SSE; строка очереди удаляется - -В БД оседают только данные, прошедшие фильтры (карточки + их исходные -сообщения). Каждый отброс с причиной пишется в rejected_msgs (вкладка -«Обработка»: очередь и отсев) и автоочищается раз в 3 суток. -""" -from __future__ import annotations - -import asyncio -import json -import logging -import re -import time - -from .. import constants as C -from ..db import store -from ..sse import broker -from . import ai as ai_service -from . import ml_client -from . import processing as processing_svc -from . import rules as rules_svc - -log = logging.getLogger("leadradar.pipeline") - -# воркер один: фоновый цикл и ручной /admin/tick не должны разбирать -# одну и ту же строку одновременно (иначе возможны дубли карточек) -_pump_lock = asyncio.Lock() - -# статусы очереди -ST_NEW = "new" -ST_AI = "filtered" - - -def _now() -> int: - return time.time_ns() // 1_000_000 - - -# ─── Очередь ───────────────────────────────────────────────────────────── - -def enqueue( - dialog_id: str, - ch_name: str, - ch_handle: str, - ch_hue: str, - msg_id: int | None, - text: str, - msg_at: int, - force: bool = False, -) -> None: - """Все входящие сообщения из мониторящихся диалогов. - - force=TRUE — сообщение возвращено пользователем из отсева: этап 1, - устарело и ML-решения для него игнорируются (см. _pump_unlocked). - """ - text = (text or "").strip() - if not text or not dialog_id: - return - now = _now() - # защита от повторов (Telethon иногда отдаёт событие дважды): - # строка живёт только пока сообщение в очереди/обработке - dup = None - if msg_id: - dup = store.scalar( - "SELECT 1 FROM pipeline_msg WHERE dialog_id = ? AND msg_id = ? LIMIT 1", - [dialog_id, msg_id], - ) - if not dup: - store.execute( - "INSERT INTO pipeline_msg(id, dialog_id, ch_name, ch_handle, ch_hue, text, msg_id, msg_at, status, force, created_at, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - [store.uid("p_"), dialog_id, ch_name, ch_handle, ch_hue, text[:6000], msg_id, msg_at, ST_NEW, bool(force), now, now], - ) - - -def queue_len() -> int: - return int(store.scalar("SELECT count(*) FROM pipeline_msg") or 0) - - -# ─── Этап 1: без ИИ ─────────────────────────────────────────────────────── - -def stage1_plain(text: str) -> dict: - """Этап 1 без ИИ: минимальная длина, стоп-фразы, отсев резюме, тип заявки. - - Возвращает {'pass', 'reason', 'stage', 'kind', 'kw'}: kind — какое именно - правило сработало (length|stop|resume|type), kw — конкретное слово/фраза - стоп-списка (для мониторинга отсева «почему сюда попало»). - """ - t = (text or "").strip() - min_len = int(store.get_setting("minLen") or C.DEFAULT_MIN_LEN) - if len(t) < min_len: - return {"pass": False, "reason": f"короче {min_len} символов", "stage": 1, "kind": "length", "kw": ""} - lower = t.casefold() - for phrase in store.get_setting("stopPhrases") or []: - if phrase and phrase.casefold() in lower: - return {"pass": False, "reason": f"стоп-фраза «{phrase}»", "stage": 1, "kind": "stop", "kw": phrase} - if bool(store.get_setting("blockResumes")): - # Система ищет вакансии/заказы (blockResumes включён) — резюме - # соискателей отсекаем сразу, до правил колонок и ИИ. - marker = _resume_reason(lower) - if marker: - return {"pass": False, "reason": f"резюме соискателя («{marker}»)", "stage": 1, "kind": "resume", "kw": marker} - # Тип заявок: «только вакансии» / «только фриланс и заказы». Считается на - # этапе 1 (до ML/ИИ, не тратит токены) по маркерам найма из «Сферы и ключей». - wanted = str(store.get_setting("wantedType") or "both").strip().lower() - if wanted in ("vacancy", "freelance"): - looks_vacancy = any(mk in lower for mk in _hire_markers()) - if wanted == "freelance" and looks_vacancy: - return {"pass": False, "reason": "ищете только разовые заявки — сообщение похоже на вакансию/занятость", "stage": 1, "kind": "type", "kw": ""} - if wanted == "vacancy" and not looks_vacancy: - return {"pass": False, "reason": "ищете только занятость — сообщение похоже на разовый заказ/услугу", "stage": 1, "kind": "type", "kw": ""} - return {"pass": True, "reason": None, "stage": 1, "kind": "", "kw": ""} - - -# ─── Карточка ───────────────────────────────────────────────────────────── - -# ── Нормализация ответов ИИ/локального разбора ──────────────────────────── - -_MD_LINK_RE = re.compile(r"\[([^\]]*)\]\([^)\s]+\)") # [текст](url) -> текст -_MD_BOLD2_RE = re.compile(r"__([^_\n]+?)__") -_MD_CODE_RE = re.compile(r"`([^`\n]+?)`") -_MD_STRIKE_RE = re.compile(r"~~([^~\n]+?)~~") -_BARE_URL_RE = re.compile(r"https?://[^\s<>\"']+") -# декоративные эмодзи/символы-маркеры (🔥 📍 💎 ‼ ℹ и т.п.) — мусор в структурированном тексте -_EMOJI_RE = re.compile( - "[" - "\\U0001F000-\\U0001FAFF" # доп. пиктограммы/эмодзи - "\\U0001F1E6-\\U0001F1FF" # региональные флаги - "\\U00002600-\\U000027BF" # разные символы (☀⭐⚠…) - "\\U00002B00-\\U00002BFF" # стрелки-символы - "\\U0000FE0F" # variation selector (цветные эмодзи) - "]+" -) - - -def clean_short(text, limit: int | None = None) -> str: - """Чистит текстовые поля (заголовок, суть) от markdown-разметки, - markdown-ссылок ([текст](url) -> текст), голых URL и служебных символов. - Используется и при сохранении, и при отдаче карточки, чтобы в UI не - показывались сырые куски исходника с разметкой. Схлопывает в одну строку - (для заголовка); для сути с сохранением переносов см. clean_block. - """ - return re.sub(r"\n+", " ", clean_block(text, limit)) - - -def clean_block(text, limit: int | None = None) -> str: - """Как clean_short, но сохраняет переносы строк: структурированная суть от - ИИ (список параметров/пунктов) остаётся читаемой на карточке. - """ - s = str(text or "") - s = _MD_LINK_RE.sub(lambda m: (m.group(1) or "").strip(), s) - s = _MD_BOLD.sub(r"\1", s) # **жирный** - s = _MD_BOLD2_RE.sub(r"\1", s) # __жирный__ - s = _MD_CODE_RE.sub(r"\1", s) # `код` - s = _MD_STRIKE_RE.sub(r"\1", s) # ~~зачёркнуто~~ - s = s.replace("||", "") # ||спойлер|| - s = _BARE_URL_RE.sub(" ", s) # голые ссылки - # решётка в составе названия (C#, F#, .NET# нет, но бывают хэштеги) — - # защищаем её от вырезания ниже, чтобы «C#» не превращалось в «C» - s = re.sub(r"(?i)\b([a-zа-яё])\#", "\\1\u2063", s) - s = s.replace("\u200b", "") # zero-width (фото-превью Telegram) - s = s.replace("\u00a0", " ") - s = re.sub(r"[*`#>~]+", " ", s) # служебные символы markdown - s = s.replace("\u2063", "#") - s = _EMOJI_RE.sub("", s) # декоративные эмодзи-маркеры - # маркеры списков и «декоративные» буллеты в начале строк убираем - s = re.sub(r"(?m)^[\s>#*\-–—•▪▫●○‣]+\s*", "", s) - s = re.sub(r"[ \t]+", " ", s) - s = re.sub(r"\n[ \t]+", "\n", s) - s = re.sub(r"\n{2,}", "\n", s) - s = s.strip(" \t\n\r-–—·•|:;,") - if limit and len(s) > limit: - # режем по границе последнего переноса/пробела до лимита - cut = s[:limit] - br = cut.rfind("\n") - sp = cut.rfind(" ") - at = br if br > limit // 2 else (sp if sp > limit // 2 else -1) - if at != -1: - cut = cut[:at] - s = cut.rstrip() + "…" - return s - - -def _skip_no_budget(raw: dict, text: str) -> bool: - """Глобальный фильтр «без указания суммы карточку не создаём». - - Включается отдельно для найма (вакансий) и для заказов (фриланс/услуги/ - товары) — настройки budgetRequiredHire / budgetRequiredOrder. Суммой - считаем распознанный бюджет или сумму в тексте (в любой валюте). Если - требуется и суммы нет — сообщение просто пропускается (карточка не - создаётся, dedup снимается, чтобы после выключения фильтра сообщение - можно было обработать заново). - """ - try: - req_hire = bool(store.get_setting("budgetRequiredHire")) - req_order = bool(store.get_setting("budgetRequiredOrder")) - except (TypeError, ValueError): - return False - if not (req_hire or req_order): - return False - is_hire = bool(raw.get("is_vacancy")) - if (is_hire and not req_hire) or (not is_hire and not req_order): - return False - if ai_service.clean_budget(raw.get("budget")): - return False - return not bool(rules_svc.extract_amounts(text or "")) - - -# Канонический текст блока «О заявке». Карточка всегда собирается из одних и -# тех же блоков (единая структура, разная длина): Компания → Формат → О задаче -# → Требования → Будет плюсом → Условия. Недостающие блоки пропускаются. - -def compose_summary(raw: dict, text: str = "") -> str: - """Собирает «О заявке» карточки из структурированных полей разбора. - - raw — результат ИИ-классификатора или локального разбора (_local_fields). - Если структурированных полей нет (старые/чужие ответы) — сохраняет - исходную summary как есть, чтобы не потерять данные. - """ - def _line(value: str) -> str: - return clean_short(value or "") - - def _items(key: str) -> list[str]: - out: list[str] = [] - for x in normalize_list(raw.get(key)): - s = _line(x) - if s: - out.append(s) - return out - - blocks: list[str] = [] - company = _line(raw.get("company")) - if company: - blocks.append(f"Компания: {company}") - fmt = _line(raw.get("format")) - if fmt: - blocks.append(f"Формат: {fmt}") - task = _line(raw.get("task")) - if task: - blocks.append(f"О задаче: {task}") - req = _items("requirements") - if req: - blocks.append("Требования: " + ", ".join(req[:14])) - plus = _items("plus") - if plus: - blocks.append("Будет плюсом: " + ", ".join(plus[:10])) - cond = _line(raw.get("conditions")) - if cond: - blocks.append(f"Условия: {cond}") - if blocks: - return "\n".join(blocks) - # структурированных полей нет. «summary» от модели часто является копией - # исходника/шумом — если в нём есть футеры/хэштеги-мусор, не используем его. - legacy = _line(raw.get("summary")) - if legacy and not any(h in legacy.casefold() for h in _FOOTER_HINTS): - return legacy - # локальный разбор без ИИ: «О задаче» из содержательных строк (без - # хэштег-строк и служебных футеров агрегаторов) - raw_lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()] - keep: list[str] = [] - for ln in raw_lines: - low = ln.casefold() - if ln.startswith("#") or low.startswith("**#") or any(h in low for h in _FOOTER_HINTS): - continue - cl = _clean_line(ln) - if cl: - keep.append(cl) - if keep: - short = _local_summary("\n".join(keep), keep) - if short: - return "О задаче: " + short - return clean_short(text or "") - - -# Футеры/служебные строки, по которым «суть» от модели — не структура, а шум -_FOOTER_HINTS = ( - "откликнуться через", "runello", "больше вакансий", "teletype", "при отклике укажите", - "больше заявок", "узнать подробнее", "написать в лс", "пишите в лс", -) - - -def _local_summary(body: str, lines: list[str], skip_first: int = 1) -> str: - """Суть карточки при локальном разборе (без ИИ): не весь исходник, а - очищенные содержательные строки после заголовка, без меток-полей и мусора. - Схлопываем в короткий абзац — карточка не выглядит как сырое сообщение. - """ - parts: list[str] = [] - for ln in lines[skip_first:]: - hit = _field_of(ln) - if hit: - continue # «Стек: …», «Бюджет: …» и т.п. уже разобраны в поля - cl = clean_short(ln) - if len(cl) < 2 or cl.casefold() in ("вакансия", "вакансию", "фриланс"): - continue - parts.append(cl) - if len(parts) >= 4: - break - out = " ".join(parts) - if len(out) < 40: - # мало содержательных строк — берём очищенное начало всего текста - out = clean_short(body, 360) - return out[:600] - - -def normalize_list(value) -> list[str]: - """ИИ иногда возвращает список, иногда строку («Java, Kotlin»). - Строку разбиваем на элементы и чистим.""" - if value is None: - return [] - parts = re.split(r"[;|\n]+", value) if isinstance(value, str) else value - out: list[str] = [] - for p in parts: - s = str(p).strip().strip('*`#').strip() - s = re.sub(r"\s+([.,])\s*$", r"\1", s).strip().strip(',').strip() - if s and len(s) > 1 and s not in out: - out.append(s) - return out - - -def normalize_stack(raw) -> list[str]: - """Стек: чинит «побуквенный» разбор (когда ИИ вернул строку, а не список).""" - out: list[str] = [] - for s in normalize_list(raw): - if len(s) < 2: - continue # одиночные буквы/мусор — не технология - out.append(s) - if len(out) >= 12: - break - return out - - -# Контакты: квалификация по типу. Храним список {type, value}. -_TG_BOT_HINTS = ("bot",) -_TG_SERVICE_NAMES = {"joinchat", "share", "s", "c", "addstickers", "addtheme", "proxy", "bg", "login"} -_SKIP_SITE_HOSTS = {"teletype.in", "forms.gle", "docs.google.com", "youtube.com", "youtu.be", "clck.ru"} - - -def qualify_contact(raw) -> dict | None: - """Классифицирует один сырой контакт → {type, value} или None. - - type: tg | phone | email | linkedin | whatsapp | site. - Отбрасываем ботов (@…bot), сервисные t.me-ссылки (joinchat/+/s/c…), - «постовые» сайты (teletype, google-формы и т.п.). - """ - s = str(raw or "").strip() - if not s or len(s) > 300: - return None - if s.startswith("@"): - name = s[1:].strip() - if re.fullmatch(r"[A-Za-z0-9_]{4,32}", name) and not name.lower().endswith("bot"): - return {"type": "tg", "value": "@" + name} - return None - low = s.lower() - m = re.match(r"https?://(?:www\.)?t\.me/([A-Za-z0-9_]{4,32})/?$", s) - if m: - name = m.group(1) - if name.lower() not in _TG_SERVICE_NAMES and not name.lower().endswith("bot"): - return {"type": "tg", "value": "@" + name} - return None - if re.fullmatch(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", s): - return {"type": "email", "value": s.lower()} - digits = re.sub(r"\D", "", s) - if re.fullmatch(r"\+?[\d\s\-()]{6,20}", s) and 10 <= len(digits) <= 15: - return {"type": "phone", "value": ("+" if s.startswith("+") else "") + digits} - if re.search(r"linkedin\.com/in/", low): - return {"type": "linkedin", "value": s} - if re.search(r"wa\.me|api\.whatsapp\.com", low): - return {"type": "whatsapp", "value": s} - if low.startswith("http"): - host = re.sub(r"https?://(?:www\.)?", "", low).split("/")[0].split("?")[0].split(":")[0] - if host in _SKIP_SITE_HOSTS or host.endswith(".teletype.in"): - return None - return {"type": "site", "value": s} - return None - - -def build_contacts(raw, text: str = "") -> list[dict]: - """Собирает квалифицированные контакты из ответа ИИ/локального разбора. - - Если контакты не нашлись, но есть текст — вытаскивает кандидатов из него - (@username, e-mail, телефон). Возвращает до 6 записей {type, value}. - """ - cands: list[str] = [] - if isinstance(raw, str): - cands = re.split(r"[;|\n]+", raw) - elif isinstance(raw, list): - for x in raw: - if isinstance(x, str): - cands.extend(re.split(r"[;|\n]+", x)) - elif isinstance(x, dict) and x.get("value"): - cands.append(str(x["value"])) - elif raw: - cands = [str(raw)] - if not cands and text: - cands = _contacts_from(text) - out: list[dict] = [] - seen: set[str] = set() - for c in cands: - q = qualify_contact(c) - if not q: - continue - key = q["value"].casefold() - if key in seen: - continue - seen.add(key) - out.append(q) - if len(out) >= 6: - break - return out - - -def primary_contact(contacts: list[dict]) -> str: - """Основной контакт для быстрого действия: tg → телефон → почта → …""" - order = {"tg": 0, "phone": 1, "whatsapp": 2, "email": 3, "linkedin": 4, "site": 5} - if not contacts: - return "" - best = min(contacts, key=lambda c: order.get(str(c.get("type")), 9)) - return str(best.get("value") or "") - - -def _store_lead( - digest: str, - dialog_id: str, - ch_name: str, - ch_handle: str, - ch_hue: str, - text: str, - raw: dict, - msg_at: int, - msg_id: int | None = None, -) -> dict | None: - now = _now() - lead_id = store.uid("l_") - board_id = str(raw.get("board") or "").strip() or None - # страховка: колонку с активными правилами может назначить только текст, - # прошедший эти правила (ИИ/ML не должны класть в неё нерелевантное) - if board_id and not rules_svc.board_accepts(board_id, text): - board_id = None - - stack = normalize_stack(raw.get("stack")) - budget = ai_service.clean_budget(raw.get("budget")) - - title = clean_short(raw.get("title") or "", 140) or clean_short(text, 140) - # «О заявке» всегда собирается из одинаковых блоков (см. compose_summary): - # Компания → Формат → О задаче → Требования → Будет плюсом → Условия. - summary = clean_block(compose_summary(raw, text), 2000) or clean_short(text, 2000) - if not budget: - # ИИ не выделил бюджет отдельным полем, но сумма с валютой есть в исходнике - # или в структурированной «О заявке» (часто уходит в «Условия») — показываем - # её на карточке. Тот же источник, что и фильтр «не создавать без суммы». - for src in (text, summary): - amts = rules_svc.extract_amounts(src or "") - if amts: - a = amts[0] - budget = {"from": a["from"], "to": a["to"], "currency": a["cur"]} - break - conv = ai_service.budget_to_target(budget) - contacts = build_contacts(raw.get("contacts"), text) - contact = primary_contact(contacts)[:200] - # по каким критериям фильтра колонки карточка сюда попала (пусто — колонка без правил) - match_hits = rules_svc.hits_for_board(board_id, text) if board_id else [] - - _cols = ( - "id", "col", "is_new", "is_vacancy", "is_vacancy_known", "title", "summary", "stack", - "budget_from", "budget_to", "budget_cur", "conv_from", "conv_to", "conv_cur", - "contact", "contacts", "ch_name", "ch_handle", "ch_hue", "time_label", - "received_at", "source_msg", "source_dialog_id", "source_msg_id", - "prev_col", "comments", "match_hits", "created_at", - ) - known_type = bool(raw.get("is_vacancy_known")) - store.execute( - "INSERT INTO leads(" + ",".join(_cols) + ") VALUES (" + ",".join(["?"] * len(_cols)) + ")", - [ - lead_id, - board_id or "inbox", - True, - bool(raw.get("is_vacancy")), - known_type, - title, - summary, - json.dumps(stack, ensure_ascii=False), - budget.get("from") if budget else None, - budget.get("to") if budget else None, - budget.get("currency", "") if budget else "", - conv["convFrom"], conv["convTo"], conv["convCur"], - contact, - json.dumps(contacts, ensure_ascii=False), - ch_name, ch_handle, ch_hue, - human_age(now, msg_at or now), - msg_at or now, - text[:4000], - dialog_id or "", - msg_id, - "inbox", - "[]", - json.dumps(match_hits, ensure_ascii=False), - now, - ], - ) - store.execute("UPDATE dedup SET lead_id = ? WHERE hash = ?", [lead_id, digest]) - _link_message(lead_id, dialog_id, msg_id, text, msg_at or now) - return lead_to_dict(lead_id) - - -def _link_message(lead_id: str, dialog_id: str, msg_id: int | None, text: str, msg_at: int) -> None: - """В messages оседает только исходник карточки (для предпросмотра/флагов).""" - if not dialog_id or msg_id is None: - return - store.execute( - "INSERT OR IGNORE INTO messages(id, dialog_id, text, msg_at) VALUES (?, ?, ?, ?)", - [f"m_{dialog_id}_{msg_id}", dialog_id, text[:4000], msg_at], - ) - store.execute("UPDATE messages SET lead_id = ? WHERE id = ?", [lead_id, f"m_{dialog_id}_{msg_id}"]) - - -def human_age(now_ms: int, ts: int) -> str: - delta = max(0, now_ms - ts) - minutes = delta // C.MIN_MS - if minutes < 60: - return "только что" if minutes < 1 else f"{minutes} мин" - hours = minutes // 60 - if hours < 24: - return f"{hours} ч" - days = hours // 24 - return f"{days} дн" - - -def lead_to_dict(lead_id: str) -> dict: - row = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id]) - if not row: - return {} - comments = json.loads(row["comments"] or "[]") - try: - match_hits = json.loads(row.get("match_hits") or "[]") - except Exception: # noqa: BLE001 - match_hits = [] - try: - contacts = json.loads(row.get("contacts") or "[]") - except Exception: # noqa: BLE001 - contacts = [] - if not contacts and (row.get("contact") or ""): - q = qualify_contact(row["contact"]) - contacts = [q] if q else [{"type": "other", "value": row["contact"]}] - return { - "id": row["id"], - "col": row["col"], - "isNew": bool(row["is_new"]), - "isVacancy": bool(row["is_vacancy"]), - "isVacancyKnown": bool(row.get("is_vacancy_known")), - "title": clean_short(row["title"], 140), - "summary": clean_block(row["summary"], 2000), - "stack": json.loads(row["stack"] or "[]"), - "budget": ( - {"from": row["budget_from"], "to": row["budget_to"], "cur": row["budget_cur"]} - if row["budget_cur"] - else None - ), - "converted": ( - {"from": row["conv_from"], "to": row["conv_to"], "cur": row["conv_cur"]} - if row["conv_cur"] - else None - ), - "contact": row["contact"], - "contacts": contacts, - "ch": {"name": row["ch_name"], "handle": row["ch_handle"], "hue": row["ch_hue"]}, - "time": row["time_label"], - "receivedAt": row["received_at"], - "sourceMsg": row["source_msg"], - "sourceDialogId": row["source_dialog_id"] or "", - "sourceMsgId": row["source_msg_id"], - "prevCol": row["prev_col"], - "matchHits": match_hits, - "comments": comments, - } - - -# Метки-поля, встречающиеся в объявлениях/вакансиях: «Стек: Java, Kotlin», -# «Грейд: Middle», «Контакты: @user», «Бюджет: 1 200–1 500$» и т.п. -_FIELD_LABELS = { - "stack": {"стек", "технологии", "технология", "скиллы", "скилы", "языки", "язык", "инструменты", "tools", "tech stack", "stack"}, - "grade": {"грейд", "уровень", "грейд/уровень", "seniority", "level"}, - "contacts": {"контакт", "контакты", "связь", "телеграм", "почта", "email", "контакты для связи"}, - "budget": {"бюджет", "оплата", "зп", "зарплата", "вилка", "оклад", "ставка", "цена", "цену", "гонорар", "pay", "salary"}, -} -_MD_EDGES = re.compile(r"^[\s*>#_~]+|[\s*>#_~]+$") -_MD_BOLD = re.compile(r"\*\*(.+?)\*\*") -_LABEL_RE = re.compile(r"^[\s*>#_~]*([А-Яа-яЁёA-Za-z][А-Яа-яЁёA-Za-z0-9 /+\-]{1,36}?)\s*[:|]\s*(.+)$") -_TOKEN_RE = re.compile(r"(?:[A-Za-zА-Яа-яЁё0-9][A-Za-zА-Яа-яЁё0-9#.+\-]*|\.[A-Za-zА-Яа-яЁё][A-Za-zА-Яа-яЁё0-9#.+\-]*)") -_CONTACT_RE = re.compile(r"@[A-Za-z0-9_]{3,}") -_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9\-]+(?:\.[A-Za-z0-9\-]+)+") -_PHONE_RE = re.compile(r"(?:\+7|8|7)[\s\-()]*\d{3}[\s\-()]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}") -_STOP_STACK = { - "и", "или", "на", "по", "с", "не", "а", "в", "о", "об", "от", "до", "для", "опыт", "знание", - "знания", "уметь", "умение", "умения", "работать", "работы", "работа", "работе", "требуется", - "приветствуется", "будет", "плюсом", "разработка", "разработке", "разработчик", "разработчика", - "вакансия", "вакансию", "вакансии", "команда", "команду", "команды", "проект", "проекта", "проекты", - "приветствуются", "желательно", "уверенное", "хорошее", "понимание", "навыки", "навык", "навыков", -} - - -# Маркеры «найма» и «уровней» не зашиты в код: они редактируются в UI -# «Сфера и ключи» (hireMarkers / levelTerms). Здесь только чтение настроек -# с фолбэком на дефолты из constants.py. - -def _hire_markers() -> set[str]: - raw = store.get_setting("hireMarkers") - if raw is None: - raw = C.DEFAULT_HIRE_MARKERS - if isinstance(raw, str): - raw = [raw] - return {str(x).strip().casefold() for x in raw if str(x).strip()} - - -def _level_terms() -> set[str]: - raw = store.get_setting("levelTerms") - if raw is None: - raw = C.DEFAULT_LEVEL_TERMS - if isinstance(raw, str): - raw = [raw] - return {str(x).strip().casefold() for x in raw if str(x).strip()} - - -def _resume_markers() -> set[str]: - raw = store.get_setting("resumeMarkers") - if raw is None: - raw = C.DEFAULT_RESUME_MARKERS - if isinstance(raw, str): - raw = [raw] - return {str(x).strip().casefold() for x in raw if str(x).strip()} - - -def _resume_reason(lower: str) -> str | None: - """Вернуть маркер, по которому текст опознан как резюме соискателя. - - Для слова «резюме» есть контекстный guard: объявление работодателя вида - «…вакансия…, присылайте резюме» содержит маркер найма (hireMarkers) ДО - слова «резюме» — это не резюме, а заявка, её не отсекаем. - """ - for marker in _resume_markers(): - pos = lower.find(marker) - if pos < 0: - continue - if marker == "резюме" and any(h in lower[:pos] for h in _hire_markers()): - continue - return marker - return None - - -def _norm_phone(p: str) -> str: - p = p.replace(" ", "").replace("\u00a0", "").replace("-", "").replace("(", "").replace(")", "") - return p[:18] - - -def _contacts_from(body: str) -> list[str]: - """Универсальные контакты из текста: @username, email, телефоны.""" - out: list[str] = [] - for m in _CONTACT_RE.findall(body): - if m not in out: - out.append(m) - for m in _EMAIL_RE.findall(body): - if m not in out: - out.append(m) - for m in _PHONE_RE.findall(body): - p = _norm_phone(m) - if p not in out: - out.append(p) - return out[:4] - - -def _clean_line(ln: str) -> str: - return _MD_EDGES.sub("", ln).strip() - - -def _field_of(line: str) -> tuple[str, str] | None: - """Если строка вида «Метка: значение» — вернуть (категория, значение).""" - m = _LABEL_RE.match(line) - if not m: - return None - label = m.group(1).strip().casefold() - value = m.group(2).strip() - if not value: - return None - for cat, syns in _FIELD_LABELS.items(): - if label in syns: - return cat, value - return None - - -def _pick_stack(value: str) -> list[str]: - """Слова из значения метки («Стек:», «Услуги:», «Материалы:» …). - - Универсально: не только технологии с латиницей, а любые значимые слова — - тип работ, услуги, товары, материалы (для не-IT сфер). - """ - out: list[str] = [] - for tok in _TOKEN_RE.findall(value): - low = tok.casefold() - if low in _STOP_STACK or len(tok) < 2: - continue - out.append(tok) - if len(out) >= 10: - break - return out - - -def _local_fields(text: str) -> dict: - """Локальный структуратор (без ИИ): заголовок, суть, стек, грейд, бюджет, - контакты, признак вакансии. Используется для быстрых путей (правила/ML), - чтобы карточка не выглядела как сырое сообщение. - """ - text = text or "" - hire = _hire_markers() - levels = _level_terms() - lines = [_clean_line(ln) for ln in text.splitlines()] - lines = [ln for ln in lines if ln] - body = "\n".join(lines) - lower = body.casefold() - - fields: dict[str, list[str]] = {"stack": [], "grade": [], "contacts": []} - budget_raw = "" - for ln in lines[1:]: - hit = _field_of(ln) - if not hit: - continue - cat, value = hit - if cat == "stack": - fields["stack"].extend(_pick_stack(value)) - elif cat == "grade": - for tok in _TOKEN_RE.findall(value): - low = tok.casefold() - if low in levels and low not in fields["grade"]: - fields["grade"].append(low) - elif cat == "contacts": - fields["contacts"].extend(_contacts_from(value)) - elif cat == "budget": - budget_raw = value - - # fallback-извлечения по всему тексту (если объявление без меток) - if not fields["contacts"]: - fields["contacts"] = _contacts_from(body) - if not fields["stack"]: - # метка «Стек: …» может стоять не в начале строки (однострочные объявления) - for m in re.finditer(r"(?im)\b(?:стек|технологии|скиллы|скилы|языки|язык|инструменты)\s*[:|]\s*([^\n]{2,120})", body): - fields["stack"].extend(_pick_stack(m.group(1))) - if fields["stack"]: - break - if not fields["grade"]: - for w in lower.split(): - w = w.strip(",;.:«»\"'()").casefold() - if w in levels: - fields["grade"].append(w) - break - if not budget_raw: - budget_raw = body - - budget = None - for amt in rules_svc.extract_amounts(budget_raw): - budget = {"from": amt["from"], "to": amt["to"], "currency": amt["cur"]} - break - if budget is None and lines: - for amt in rules_svc.extract_amounts(body): - budget = {"from": amt["from"], "to": amt["to"], "currency": amt["cur"]} - break - - title = clean_short(lines[0] if lines else body, 140) - if not title: - title = clean_short(body, 140) - summary = _local_summary(body, lines) - is_vacancy = any(mk in lower for mk in hire) - contact = "; ".join(fields["contacts"])[:200] - - stack = [] - for s in fields["stack"]: - if s.casefold() not in (x.casefold() for x in stack): - stack.append(s) - return { - "title": title, - "summary": summary, - "stack": stack[:12], - "grade": fields["grade"][:4], - "budget": budget, - "contacts": contact, - "is_vacancy": is_vacancy, - "is_vacancy_known": False, # маркерная оценка — не контекст; тип подтверждает ИИ - "board": None, - } - - -# ─── Фоновый воркер ─────────────────────────────────────────────────────── - -def _raw_spam(raw: dict) -> bool: - """Признак спама в ответе ИИ-классификатора (используется, чтобы не учить - ML на карточке, которую ИИ пометил мусором, но она всё же сохранена).""" - return bool((raw or {}).get("is_spam")) - - -def _drop_row(row: dict, with_dedup: bool = True) -> None: - if with_dedup: - digest = ai_service.normalize_dedup(row["text"]) - store.execute("DELETE FROM dedup WHERE hash = ? AND lead_id IS NULL", [digest]) - store.execute("DELETE FROM pipeline_msg WHERE id = ?", [row["id"]]) - - -def _stale_max_age() -> int: - """Срок актуальности входящего сообщения = срок до архива (в мс). - - Сообщение, опубликованное раньше этого срока, сразу ушло бы в архив — - поэтому оно не заводится в систему вообще (ни карточкой, ни в архив/ - корзину). Работает только при включённом автоархиве. - """ - if not store.get_setting("autoArchive"): - return 0 - days = int(store.get_setting("archiveAfterDays") or 14) - return days * C.DAY_MS - - -def _is_stale(msg_at: int | None) -> bool: - max_age = _stale_max_age() - if max_age <= 0 or not msg_at: - return False - return _now() - msg_at > max_age - - -def _drop_stale(row: dict) -> None: - """Устаревшее сообщение удаляем сразу, без создания карточки и без ML/ИИ.""" - days = int(store.get_setting("archiveAfterDays") or 14) - log.info("stage1 blocked (устарело, старше срока до архива): %s", row["text"][:80]) - processing_svc.record( - row, "stale", "stale", - f"сообщение старше {days} дн. (срок до автоархива) — не заводим в систему", - ) - _drop_row(row) - - -def _reject_stage1(row: dict, r1: dict) -> None: - """Отсев на этапе 1: пишем причину в мониторинг (вкладка «Обработка»).""" - kind = str(r1.get("kind") or "stop") - processing_svc.record(row, "stop", kind, r1["reason"], str(r1.get("kw") or "")) - - -def _reject_no_budget(row: dict, _is_hire: bool) -> None: - """Глобальный фильтр «без суммы»: сообщение пропущено, карточка не создана.""" - processing_svc.record( - row, "stop", "budget", - "включён фильтр «не создавать карточку без суммы» — в тексте не указан бюджет", - ) - - -# сигнатура последнего опубликованного состояния очереди/отсева (SSE-событие -# шлём только при изменении — иначе воркер спамил бы эфир каждые 2 секунды) -_last_pub_sig: tuple | None = None - - -async def _maybe_publish_stats() -> None: - """SSE pipeline_stats для вкладки «Обработка» при изменении очереди/отсева.""" - global _last_pub_sig - q = processing_svc.queue_counts() - sig = (q["new"], q["ai"], processing_svc.rejected_count()) - if sig == _last_pub_sig: - return - _last_pub_sig = sig - await broker.publish( - "pipeline_stats", - {"new": sig[0], "ai": sig[1], "total": sig[0] + sig[1], "rejected": sig[2]}, - ) - - -def _pump_gate() -> tuple[int, int]: - """Шлагбаум «остановиться после N карточек»: (limit, done). 0 = выключен.""" - try: - limit = int(store.get_setting("pumpGate") or 0) - done = int(store.get_setting("pumpGateDone") or 0) - except (TypeError, ValueError): - limit, done = 0, 0 - return max(0, limit), max(0, done) - - -async def pump_once(new_limit: int = 12, ai_limit: int = 4) -> dict: - """Один проход воркера по очереди. Вызывается из фонового цикла и /admin/tick. - - Если включён шлагбаум (pumpGate > 0), после N созданных карточек воркер - останавливается: очередь копится, новые карточки не создаются до снятия - шлагбаума (admin/pump-gate, limit=0 или новый лимит). Нужно для «прогона - с остановкой после первых N сообщений» (проверка результата пользователем). - """ - limit, done = _pump_gate() - if limit and done >= limit: - return {"paused": True, "done": done, "limit": limit} - if _pump_lock.locked(): - return {} # другой воркер уже разбирает очередь - async with _pump_lock: - res = await _pump_unlocked(new_limit, ai_limit) - if limit: - added = int(res.get("rulesStored") or 0) + int(res.get("mlStored") or 0) + int(res.get("aiStored") or 0) - done = done + added - store.set_setting("pumpGateDone", done) - res["gateDone"] = done - res["gateLimit"] = limit - if done >= limit: - res["paused"] = True - await broker.publish_toast( - f"Пауза: создано {done} карточек (лимит {limit}) — проверьте результат", "clock" - ) - await _maybe_publish_stats() - return res - - -async def _pump_unlocked(new_limit: int, ai_limit: int) -> dict: - res = {"staged": 0, "rulesStored": 0, "mlStored": 0, "mlDrop": 0, "typeDrop": 0, "aiStored": 0, "aiDrop": 0, "aiFail": 0, "noBudget": 0} - - # 1) new -> стоп-фразы/дедуп -> ML (или в очередь на ИИ) - rows = store.query( - "SELECT * FROM pipeline_msg WHERE status = ? ORDER BY created_at LIMIT ?", - [ST_NEW, new_limit], - ) - for row in rows: - force = bool(row.get("force")) - if not force and _is_stale(row["msg_at"]): - _drop_stale(row) - continue - if not force: - r1 = stage1_plain(row["text"]) - if not r1["pass"]: - log.info("stage1 blocked (%s): %s", r1["reason"], row["text"][:80]) - _reject_stage1(row, r1) - _drop_row(row) - continue - digest = ai_service.normalize_dedup(row["text"]) - if store.scalar("SELECT 1 FROM dedup WHERE hash = ?", [digest]): - processing_svc.record( - row, "dup", "dup", - "сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается", - ) - _drop_row(row) - continue - store.execute( - "INSERT OR IGNORE INTO dedup(hash, lead_id, created_at) VALUES (?, NULL, ?)", - [digest, _now()], - ) - res["staged"] += 1 - - # Смысловые колонки НЕ назначаем словарным фильтром до ИИ: словарный - # матч не понимает смысл (дайджест из 5 ролей, «desktop» в URL/футере - # и т.п.). Сначала смысл оценивает ML (см. ниже), не уверен — ИИ. - # Правила колонок остаются критериями для ИИ/проверкой при назначении - # и «почему карточка попала» (см. _store_lead: board_accepts + hits). - - # ML-слой: если включён настройкой и уверен — решает без ИИ. - # ML не назначает колонки с активными правилами и ИИ-предложения: - # они наполняются ИИ (с проверкой правил) или действиями пользователя. - # force (возврат из отсева) идёт мимо ML к ИИ-классификации: пользователь - # явно подтвердил, что сообщение релевантно, — ML не должен снова его - # отсеять как спам/не тот тип. - if ml_client.is_enabled() and not force: - dec = await ml_client.predict(row["text"]) - label = dec.get("label") - if dec.get("take") and label: - if label == "spam": - score = dec.get("scores", {}).get("spam", 0) - log.info("ml drop (spam, score %.2f): %s", score, row["text"][:120].replace("\n", " ")) - processing_svc.record( - row, "ml", "spam_ml", - f"ML уверен, что это спам/не заявка (score {float(score):.2f})", - ) - _drop_row(row) - res["mlDrop"] += 1 - continue - br = store.query_one("SELECT suggested, rules FROM boards WHERE id = ?", [label]) - allowed = False - if br and not bool(br["suggested"]): - try: - br_rules = json.loads(br["rules"] or "{}") if br["rules"] else {} - except Exception: # noqa: BLE001 - br_rules = {} - allowed = not rules_svc.has_active_rules(br_rules) - if allowed: - raw = _local_fields(row["text"]) - raw["board"] = label - # ML назначил колонку и уверен в типе — тип тоже его решение - typ = dec.get("type") or {} - if typ.get("take"): - raw["is_vacancy"] = typ.get("label") == "hire" - raw["is_vacancy_known"] = True - # ML «узнала» в тексте слова, характерные для колонки: - # докладываем их в стек (если локальный разбор их пропустил) - added = 0 - for term in (dec.get("terms") or []): - t = str(term).strip().strip("@+#.") - low = t.casefold() - if not t or len(t) < 2 or t.startswith("~"): - continue - if low in _STOP_STACK: - continue - if any(x.casefold() == low for x in raw.get("stack") or []): - continue - raw.setdefault("stack", []).append(t) - added += 1 - if added >= 4: - break - if _skip_no_budget(raw, row["text"]): - res["noBudget"] += 1 - _reject_no_budget(row, bool(raw.get("is_vacancy"))) - _drop_row(row) # без dedup: после выключения фильтра сообщение можно взять снова - continue - lead = _store_lead( - digest, row["dialog_id"], row["ch_name"], row["ch_handle"], row["ch_hue"], - row["text"], raw, row["msg_at"], row["msg_id"], - ) - _drop_row(row, with_dedup=False) # dedup уже привязан к lead - if lead: - res["mlStored"] += 1 - await broker.publish("new_lead", lead) - continue - # ML уверен в типе (t:hire/t:order), даже если колонку не назначил - typ = dec.get("type") or {} - if typ.get("take"): - is_hire = typ.get("label") == "hire" - want = str(store.get_setting("wantedType") or "both").strip().lower() - if want in ("vacancy", "freelance"): - bad = (want == "freelance" and is_hire) or (want == "vacancy" and not is_hire) - if bad: - # тип не под режим «что собираем» — не тратим ИИ - log.info("ml typeDrop (%s, хотят %s): %s", "найм" if is_hire else "разовое", want, row["text"][:120].replace("\n", " ")) - processing_svc.record( - row, "ml", "type", - f"ML: тип «{'найм/занятость' if is_hire else 'разовые заказы'}», а вы ищете только «{want}»", - ) - _drop_row(row) - res["typeDrop"] += 1 - continue - if not store.get_setting("aiEnabled"): - # ИИ выключен, но тип ML знает: создаём карточку сами (inbox) - raw = _local_fields(row["text"]) - raw["is_vacancy"] = is_hire - raw["is_vacancy_known"] = True - if _skip_no_budget(raw, row["text"]): - res["noBudget"] += 1 - _reject_no_budget(row, is_hire) - _drop_row(row) - continue - lead = _store_lead( - digest, row["dialog_id"], row["ch_name"], row["ch_handle"], row["ch_hue"], - row["text"], raw, row["msg_at"], row["msg_id"], - ) - _drop_row(row, with_dedup=False) - if lead: - res["mlStored"] += 1 - await broker.publish("new_lead", lead) - continue - store.execute( - "UPDATE pipeline_msg SET status = ?, updated_at = ? WHERE id = ?", - [ST_AI, _now(), row["id"]], - ) - - # 2) filtered -> ИИ-фильтр (если включён) + классификация - rows = store.query( - "SELECT * FROM pipeline_msg WHERE status = ? ORDER BY created_at LIMIT ?", - [ST_AI, ai_limit], - ) - for row in rows: - force = bool(row.get("force")) - if not force and _is_stale(row["msg_at"]): - _drop_stale(row) - continue - text = row["text"] - digest = ai_service.normalize_dedup(text) - # ИИ выключен (aiEnabled=false): классификацию/ИИ-фильтр не вызываем — - # карточку собирает локальный разбор. В колонки кладёт только ML. - if not store.get_setting("aiEnabled"): - raw = _local_fields(text) - if not force and _skip_no_budget(raw, text): - res["noBudget"] += 1 - _reject_no_budget(row, bool(raw.get("is_vacancy"))) - _drop_row(row) - continue - lead = _store_lead( - digest, row["dialog_id"], row["ch_name"], row["ch_handle"], row["ch_hue"], - text, raw, row["msg_at"], row["msg_id"], - ) - _drop_row(row, with_dedup=False) - if lead: - res["aiStored"] += 1 - await broker.publish("new_lead", lead) - continue - if force: - # возврат из отсева: ИИ-фильтр не пересматриваем, пользователь уже - # подтвердил релевантность — сразу классификация - r2 = {"pass": True, "reason": None, "skipped": True} - else: - try: - r2 = await ai_service.filter_incoming(text) - except Exception as exc: # noqa: BLE001 - log.warning("AI filter error, skip stage2: %s", exc) - r2 = {"pass": True, "reason": None, "skipped": True} - try: - raw = await ai_service.classify(text) if r2["pass"] else {} - if raw: - # тип определён ИИ по контексту (не маркерной эвристикой) - raw["is_vacancy_known"] = True - except Exception as exc: # noqa: BLE001 - log.warning("classification failed: %s", exc) - raw = {} - - is_spam = bool(raw.get("is_spam") or (not r2["pass"])) - if force and raw.get("is_spam"): - # пользователь вернул сообщение из отсева: вердикт «спам» отменяется, - # поля ИИ уже разложил — карточку создаём (без обучения «спаму») - raw["is_spam"] = False - is_spam = False - if is_spam: - # ИИ-решение «спам/мусор» — обучаем ML отличать спам (с весом гипотезы) - log.info("ai drop (не заявка: %s): %s", r2.get("reason") or raw.get("is_spam"), text[:120].replace("\n", " ")) - if not r2["pass"]: - processing_svc.record( - row, "ai", "filter_ai", - "ИИ-фильтр: " + (str(r2.get("reason") or "сообщение не относится к вашим интересам")), - ) - else: - processing_svc.record( - row, "ai", "spam_ai", - "ИИ: не заявка — спам, реклама, скам или служебное сообщение", - ) - ml_client.push(text, "spam", delta=ml_client.AI_WEIGHT) - _drop_row(row) - res["aiDrop"] += 1 - continue - if not raw: - # ИИ не дал разбора (недоступен/сбой) — но не спам: локальный разбор - raw = _local_fields(text) - res["aiFail"] += 1 - if not force and _skip_no_budget(raw, text): - res["noBudget"] += 1 - _reject_no_budget(row, bool(raw.get("is_vacancy"))) - _drop_row(row) - continue - lead = _store_lead( - digest, row["dialog_id"], row["ch_name"], row["ch_handle"], row["ch_hue"], - text, raw, row["msg_at"], row["msg_id"], - ) - _drop_row(row, with_dedup=False) - if lead: - res["aiStored"] += 1 - await broker.publish("new_lead", lead) - # ИИ назначил колонку — это обучающий сигнал для ML (кроме inbox: - # «не знаю» не учим). Карточка не в inbox -> учим текст -> колонка. - col = str(lead.get("col") or "") - if col and col not in ("inbox", "trash", "archive") and not _raw_spam(raw): - # ML в своём пути назначает только колонки без активных правил - # и не-suggested — такие же примеры и собираем, иначе модель - # будет «знать» колонку, но не сможет её применить. - br = store.query_one("SELECT suggested, rules FROM boards WHERE id = ?", [col]) - free = False - if br and not bool(br["suggested"]): - try: - br_rules = json.loads(br["rules"] or "{}") if br["rules"] else {} - except Exception: # noqa: BLE001 - br_rules = {} - free = not rules_svc.has_active_rules(br_rules) - if free: - ml_client.push(text, col, delta=ml_client.AI_WEIGHT) - # тип известен от ИИ по контексту — учим ML определять его сам - # (t:hire = занятость/найм, t:order = разовая сделка) - if raw.get("is_vacancy_known"): - ml_client.push( - text, - "t:hire" if bool(raw.get("is_vacancy")) else "t:order", - delta=ml_client.AI_WEIGHT, - ) - - ml_client.track_decisions(ml=res["mlStored"] + res["mlDrop"], ai=res["aiStored"] + res["aiDrop"]) - return res +"""Пайплайн входящих (п.4, п.5 ТЗ) — очередь + фоновый воркер. + +Все сообщения из групп/каналов попадают в очередь pipeline_msg и разбираются +фоновым воркером (main._pipeline_loop и ручной /api/admin/tick): + + queue(status='new') + -> этап 1: стоп-фразы и длина (без ИИ); не прошло -> в отсев (rejected) + -> дедупликация по нормализованному тексту (повтор -> в отсев) + -> ML-слой: если уверен, решает сам (спам -> отсев, + доска -> карточка сразу); иначе status='filtered' + queue(status='filtered') + -> этап 2: ИИ-фильтр (если включён; иначе пропуск) + -> ИИ-классификация (если ML не решил) + -> карточка в БД + SSE; строка очереди удаляется + +В БД оседают только данные, прошедшие фильтры (карточки + их исходные +сообщения). Каждый отброс с причиной пишется в rejected_msgs (вкладка +«Обработка»: очередь и отсев) и автоочищается раз в 3 суток. +""" +from __future__ import annotations + +import asyncio +import json +import logging +import re +import time + +from .. import constants as C +from ..db import store +from ..sse import broker +from . import ai as ai_service +from . import ml_client +from . import processing as processing_svc +from . import rules as rules_svc + +log = logging.getLogger("leadradar.pipeline") + +# воркер один: фоновый цикл и ручной /admin/tick не должны разбирать +# одну и ту же строку одновременно (иначе возможны дубли карточек) +_pump_lock = asyncio.Lock() + +# статусы очереди +ST_NEW = "new" +ST_AI = "filtered" + + +def _now() -> int: + return time.time_ns() // 1_000_000 + + +# ─── Очередь ───────────────────────────────────────────────────────────── + +def enqueue( + dialog_id: str, + ch_name: str, + ch_handle: str, + ch_hue: str, + msg_id: int | None, + text: str, + msg_at: int, + force: bool = False, +) -> None: + """Все входящие сообщения из мониторящихся диалогов. + + force=TRUE — сообщение возвращено пользователем из отсева: этап 1, + устарело и ML-решения для него игнорируются (см. _pump_unlocked). + """ + text = (text or "").strip() + if not text or not dialog_id: + return + now = _now() + # защита от повторов (Telethon иногда отдаёт событие дважды): + # строка живёт только пока сообщение в очереди/обработке + dup = None + if msg_id: + dup = store.scalar( + "SELECT 1 FROM pipeline_msg WHERE dialog_id = ? AND msg_id = ? LIMIT 1", + [dialog_id, msg_id], + ) + if not dup: + store.execute( + "INSERT INTO pipeline_msg(id, dialog_id, ch_name, ch_handle, ch_hue, text, msg_id, msg_at, status, force, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [store.uid("p_"), dialog_id, ch_name, ch_handle, ch_hue, text[:6000], msg_id, msg_at, ST_NEW, bool(force), now, now], + ) + + +def queue_len() -> int: + return int(store.scalar("SELECT count(*) FROM pipeline_msg") or 0) + + +# ─── Этап 1: без ИИ ─────────────────────────────────────────────────────── + +def stage1_plain(text: str) -> dict: + """Этап 1 без ИИ: минимальная длина, стоп-фразы, отсев резюме, тип заявки. + + Возвращает {'pass', 'reason', 'stage', 'kind', 'kw'}: kind — какое именно + правило сработало (length|stop|resume|type), kw — конкретное слово/фраза + стоп-списка (для мониторинга отсева «почему сюда попало»). + """ + t = (text or "").strip() + min_len = int(store.get_setting("minLen") or C.DEFAULT_MIN_LEN) + if len(t) < min_len: + return {"pass": False, "reason": f"короче {min_len} символов", "stage": 1, "kind": "length", "kw": ""} + lower = t.casefold() + for phrase in store.get_setting("stopPhrases") or []: + if phrase and phrase.casefold() in lower: + return {"pass": False, "reason": f"стоп-фраза «{phrase}»", "stage": 1, "kind": "stop", "kw": phrase} + if bool(store.get_setting("blockResumes")): + # Система ищет вакансии/заказы (blockResumes включён) — резюме + # соискателей отсекаем сразу, до правил колонок и ИИ. + marker = _resume_reason(lower) + if marker: + return {"pass": False, "reason": f"резюме соискателя («{marker}»)", "stage": 1, "kind": "resume", "kw": marker} + # Тип заявок: «только вакансии» / «только фриланс и заказы». Считается на + # этапе 1 (до ML/ИИ, не тратит токены) по маркерам найма из «Сферы и ключей». + wanted = str(store.get_setting("wantedType") or "both").strip().lower() + if wanted in ("vacancy", "freelance"): + looks_vacancy = any(mk in lower for mk in _hire_markers()) + if wanted == "freelance" and looks_vacancy: + return {"pass": False, "reason": "ищете только разовые заявки — сообщение похоже на вакансию/занятость", "stage": 1, "kind": "type", "kw": ""} + if wanted == "vacancy" and not looks_vacancy: + return {"pass": False, "reason": "ищете только занятость — сообщение похоже на разовый заказ/услугу", "stage": 1, "kind": "type", "kw": ""} + return {"pass": True, "reason": None, "stage": 1, "kind": "", "kw": ""} + + +# ─── Карточка ───────────────────────────────────────────────────────────── + +# ── Нормализация ответов ИИ/локального разбора ──────────────────────────── + +_MD_LINK_RE = re.compile(r"\[([^\]]*)\]\([^)\s]+\)") # [текст](url) -> текст +_MD_BOLD2_RE = re.compile(r"__([^_\n]+?)__") +_MD_CODE_RE = re.compile(r"`([^`\n]+?)`") +_MD_STRIKE_RE = re.compile(r"~~([^~\n]+?)~~") +_BARE_URL_RE = re.compile(r"https?://[^\s<>\"']+") +# декоративные эмодзи/символы-маркеры (🔥 📍 💎 ‼ ℹ и т.п.) — мусор в структурированном тексте +_EMOJI_RE = re.compile( + "[" + "\\U0001F000-\\U0001FAFF" # доп. пиктограммы/эмодзи + "\\U0001F1E6-\\U0001F1FF" # региональные флаги + "\\U00002600-\\U000027BF" # разные символы (☀⭐⚠…) + "\\U00002B00-\\U00002BFF" # стрелки-символы + "\\U0000FE0F" # variation selector (цветные эмодзи) + "]+" +) + + +def clean_short(text, limit: int | None = None) -> str: + """Чистит текстовые поля (заголовок, суть) от markdown-разметки, + markdown-ссылок ([текст](url) -> текст), голых URL и служебных символов. + Используется и при сохранении, и при отдаче карточки, чтобы в UI не + показывались сырые куски исходника с разметкой. Схлопывает в одну строку + (для заголовка); для сути с сохранением переносов см. clean_block. + """ + return re.sub(r"\n+", " ", clean_block(text, limit)) + + +def clean_block(text, limit: int | None = None) -> str: + """Как clean_short, но сохраняет переносы строк: структурированная суть от + ИИ (список параметров/пунктов) остаётся читаемой на карточке. + """ + s = str(text or "") + s = _MD_LINK_RE.sub(lambda m: (m.group(1) or "").strip(), s) + s = _MD_BOLD.sub(r"\1", s) # **жирный** + s = _MD_BOLD2_RE.sub(r"\1", s) # __жирный__ + s = _MD_CODE_RE.sub(r"\1", s) # `код` + s = _MD_STRIKE_RE.sub(r"\1", s) # ~~зачёркнуто~~ + s = s.replace("||", "") # ||спойлер|| + s = _BARE_URL_RE.sub(" ", s) # голые ссылки + # решётка в составе названия (C#, F#, .NET# нет, но бывают хэштеги) — + # защищаем её от вырезания ниже, чтобы «C#» не превращалось в «C» + s = re.sub(r"(?i)\b([a-zа-яё])\#", "\\1\u2063", s) + s = s.replace("\u200b", "") # zero-width (фото-превью Telegram) + s = s.replace("\u00a0", " ") + s = re.sub(r"[*`#>~]+", " ", s) # служебные символы markdown + s = s.replace("\u2063", "#") + s = _EMOJI_RE.sub("", s) # декоративные эмодзи-маркеры + # маркеры списков и «декоративные» буллеты в начале строк убираем + s = re.sub(r"(?m)^[\s>#*\-–—•▪▫●○‣]+\s*", "", s) + s = re.sub(r"[ \t]+", " ", s) + s = re.sub(r"\n[ \t]+", "\n", s) + s = re.sub(r"\n{2,}", "\n", s) + s = s.strip(" \t\n\r-–—·•|:;,") + if limit and len(s) > limit: + # режем по границе последнего переноса/пробела до лимита + cut = s[:limit] + br = cut.rfind("\n") + sp = cut.rfind(" ") + at = br if br > limit // 2 else (sp if sp > limit // 2 else -1) + if at != -1: + cut = cut[:at] + s = cut.rstrip() + "…" + return s + + +def _skip_no_budget(raw: dict, text: str) -> bool: + """Глобальный фильтр «без указания суммы карточку не создаём». + + Включается отдельно для найма (вакансий) и для заказов (фриланс/услуги/ + товары) — настройки budgetRequiredHire / budgetRequiredOrder. Суммой + считаем распознанный бюджет или сумму в тексте (в любой валюте). Если + требуется и суммы нет — сообщение просто пропускается (карточка не + создаётся, dedup снимается, чтобы после выключения фильтра сообщение + можно было обработать заново). + """ + try: + req_hire = bool(store.get_setting("budgetRequiredHire")) + req_order = bool(store.get_setting("budgetRequiredOrder")) + except (TypeError, ValueError): + return False + if not (req_hire or req_order): + return False + is_hire = bool(raw.get("is_vacancy")) + if (is_hire and not req_hire) or (not is_hire and not req_order): + return False + if ai_service.clean_budget(raw.get("budget")): + return False + return not bool(rules_svc.extract_amounts(text or "")) + + +# Канонический текст блока «О заявке». Карточка всегда собирается из одних и +# тех же блоков (единая структура, разная длина): Компания → Формат → О задаче +# → Требования → Будет плюсом → Условия. Недостающие блоки пропускаются. + +def compose_summary(raw: dict, text: str = "") -> str: + """Собирает «О заявке» карточки из структурированных полей разбора. + + raw — результат ИИ-классификатора или локального разбора (_local_fields). + Если структурированных полей нет (старые/чужие ответы) — сохраняет + исходную summary как есть, чтобы не потерять данные. + """ + def _line(value: str) -> str: + return clean_short(value or "") + + def _items(key: str) -> list[str]: + out: list[str] = [] + for x in normalize_list(raw.get(key)): + s = _line(x) + if s: + out.append(s) + return out + + blocks: list[str] = [] + company = _line(raw.get("company")) + if company: + blocks.append(f"Компания: {company}") + fmt = _line(raw.get("format")) + if fmt: + blocks.append(f"Формат: {fmt}") + task = _line(raw.get("task")) + if task: + blocks.append(f"О задаче: {task}") + req = _items("requirements") + if req: + blocks.append("Требования: " + ", ".join(req[:14])) + plus = _items("plus") + if plus: + blocks.append("Будет плюсом: " + ", ".join(plus[:10])) + cond = _line(raw.get("conditions")) + if cond: + blocks.append(f"Условия: {cond}") + if blocks: + return "\n".join(blocks) + # структурированных полей нет. «summary» от модели часто является копией + # исходника/шумом — если в нём есть футеры/хэштеги-мусор, не используем его. + legacy = _line(raw.get("summary")) + if legacy and not any(h in legacy.casefold() for h in _FOOTER_HINTS): + return legacy + # локальный разбор без ИИ: «О задаче» из содержательных строк (без + # хэштег-строк и служебных футеров агрегаторов) + raw_lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()] + keep: list[str] = [] + for ln in raw_lines: + low = ln.casefold() + if ln.startswith("#") or low.startswith("**#") or any(h in low for h in _FOOTER_HINTS): + continue + cl = _clean_line(ln) + if cl: + keep.append(cl) + if keep: + short = _local_summary("\n".join(keep), keep) + if short: + return "О задаче: " + short + return clean_short(text or "") + + +# Футеры/служебные строки, по которым «суть» от модели — не структура, а шум +_FOOTER_HINTS = ( + "откликнуться через", "runello", "больше вакансий", "teletype", "при отклике укажите", + "больше заявок", "узнать подробнее", "написать в лс", "пишите в лс", +) + + +def _local_summary(body: str, lines: list[str], skip_first: int = 1) -> str: + """Суть карточки при локальном разборе (без ИИ): не весь исходник, а + очищенные содержательные строки после заголовка, без меток-полей и мусора. + Схлопываем в короткий абзац — карточка не выглядит как сырое сообщение. + """ + parts: list[str] = [] + for ln in lines[skip_first:]: + hit = _field_of(ln) + if hit: + continue # «Стек: …», «Бюджет: …» и т.п. уже разобраны в поля + cl = clean_short(ln) + if len(cl) < 2 or cl.casefold() in ("вакансия", "вакансию", "фриланс"): + continue + parts.append(cl) + if len(parts) >= 4: + break + out = " ".join(parts) + if len(out) < 40: + # мало содержательных строк — берём очищенное начало всего текста + out = clean_short(body, 360) + return out[:600] + + +def normalize_list(value) -> list[str]: + """ИИ иногда возвращает список, иногда строку («Java, Kotlin»). + Строку разбиваем на элементы и чистим.""" + if value is None: + return [] + parts = re.split(r"[;|\n]+", value) if isinstance(value, str) else value + out: list[str] = [] + for p in parts: + s = str(p).strip().strip('*`#').strip() + s = re.sub(r"\s+([.,])\s*$", r"\1", s).strip().strip(',').strip() + if s and len(s) > 1 and s not in out: + out.append(s) + return out + + +def normalize_stack(raw) -> list[str]: + """Стек: чинит «побуквенный» разбор (когда ИИ вернул строку, а не список).""" + out: list[str] = [] + for s in normalize_list(raw): + if len(s) < 2: + continue # одиночные буквы/мусор — не технология + out.append(s) + if len(out) >= 12: + break + return out + + +# Контакты: квалификация по типу. Храним список {type, value}. +_TG_BOT_HINTS = ("bot",) +_TG_SERVICE_NAMES = {"joinchat", "share", "s", "c", "addstickers", "addtheme", "proxy", "bg", "login"} +_SKIP_SITE_HOSTS = {"teletype.in", "forms.gle", "docs.google.com", "youtube.com", "youtu.be", "clck.ru"} + + +def qualify_contact(raw) -> dict | None: + """Классифицирует один сырой контакт → {type, value} или None. + + type: tg | phone | email | linkedin | whatsapp | site. + Отбрасываем ботов (@…bot), сервисные t.me-ссылки (joinchat/+/s/c…), + «постовые» сайты (teletype, google-формы и т.п.). + """ + s = str(raw or "").strip() + if not s or len(s) > 300: + return None + if s.startswith("@"): + name = s[1:].strip() + if re.fullmatch(r"[A-Za-z0-9_]{4,32}", name) and not name.lower().endswith("bot"): + return {"type": "tg", "value": "@" + name} + return None + low = s.lower() + m = re.match(r"https?://(?:www\.)?t\.me/([A-Za-z0-9_]{4,32})/?$", s) + if m: + name = m.group(1) + if name.lower() not in _TG_SERVICE_NAMES and not name.lower().endswith("bot"): + return {"type": "tg", "value": "@" + name} + return None + if re.fullmatch(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", s): + return {"type": "email", "value": s.lower()} + digits = re.sub(r"\D", "", s) + if re.fullmatch(r"\+?[\d\s\-()]{6,20}", s) and 10 <= len(digits) <= 15: + return {"type": "phone", "value": ("+" if s.startswith("+") else "") + digits} + if re.search(r"linkedin\.com/in/", low): + return {"type": "linkedin", "value": s} + if re.search(r"wa\.me|api\.whatsapp\.com", low): + return {"type": "whatsapp", "value": s} + if low.startswith("http"): + host = re.sub(r"https?://(?:www\.)?", "", low).split("/")[0].split("?")[0].split(":")[0] + if host in _SKIP_SITE_HOSTS or host.endswith(".teletype.in"): + return None + return {"type": "site", "value": s} + return None + + +def build_contacts(raw, text: str = "") -> list[dict]: + """Собирает квалифицированные контакты из ответа ИИ/локального разбора. + + Если контакты не нашлись, но есть текст — вытаскивает кандидатов из него + (@username, e-mail, телефон). Возвращает до 6 записей {type, value}. + """ + cands: list[str] = [] + if isinstance(raw, str): + cands = re.split(r"[;|\n]+", raw) + elif isinstance(raw, list): + for x in raw: + if isinstance(x, str): + cands.extend(re.split(r"[;|\n]+", x)) + elif isinstance(x, dict) and x.get("value"): + cands.append(str(x["value"])) + elif raw: + cands = [str(raw)] + if not cands and text: + cands = _contacts_from(text) + out: list[dict] = [] + seen: set[str] = set() + for c in cands: + q = qualify_contact(c) + if not q: + continue + key = q["value"].casefold() + if key in seen: + continue + seen.add(key) + out.append(q) + if len(out) >= 6: + break + return out + + +def primary_contact(contacts: list[dict]) -> str: + """Основной контакт для быстрого действия: tg → телефон → почта → …""" + order = {"tg": 0, "phone": 1, "whatsapp": 2, "email": 3, "linkedin": 4, "site": 5} + if not contacts: + return "" + best = min(contacts, key=lambda c: order.get(str(c.get("type")), 9)) + return str(best.get("value") or "") + + +def _store_lead( + digest: str, + dialog_id: str, + ch_name: str, + ch_handle: str, + ch_hue: str, + text: str, + raw: dict, + msg_at: int, + msg_id: int | None = None, +) -> dict | None: + now = _now() + lead_id = store.uid("l_") + board_id = str(raw.get("board") or "").strip() or None + # страховка: колонку с активными правилами может назначить только текст, + # прошедший эти правила (ИИ/ML не должны класть в неё нерелевантное) + if board_id and not rules_svc.board_accepts(board_id, text): + board_id = None + + stack = normalize_stack(raw.get("stack")) + budget = ai_service.clean_budget(raw.get("budget")) + + title = clean_short(raw.get("title") or "", 140) or clean_short(text, 140) + # «О заявке» всегда собирается из одинаковых блоков (см. compose_summary): + # Компания → Формат → О задаче → Требования → Будет плюсом → Условия. + summary = clean_block(compose_summary(raw, text), 2000) or clean_short(text, 2000) + if not budget: + # ИИ не выделил бюджет отдельным полем, но сумма с валютой есть в исходнике + # или в структурированной «О заявке» (часто уходит в «Условия») — показываем + # её на карточке. Тот же источник, что и фильтр «не создавать без суммы». + for src in (text, summary): + amts = rules_svc.extract_amounts(src or "") + if amts: + a = amts[0] + budget = {"from": a["from"], "to": a["to"], "currency": a["cur"]} + break + conv = ai_service.budget_to_target(budget) + contacts = build_contacts(raw.get("contacts"), text) + contact = primary_contact(contacts)[:200] + # по каким критериям фильтра колонки карточка сюда попала (пусто — колонка без правил) + match_hits = rules_svc.hits_for_board(board_id, text) if board_id else [] + + _cols = ( + "id", "col", "is_new", "is_vacancy", "is_vacancy_known", "title", "summary", "stack", + "budget_from", "budget_to", "budget_cur", "conv_from", "conv_to", "conv_cur", + "contact", "contacts", "ch_name", "ch_handle", "ch_hue", "time_label", + "received_at", "source_msg", "source_dialog_id", "source_msg_id", + "prev_col", "comments", "match_hits", "created_at", + ) + known_type = bool(raw.get("is_vacancy_known")) + store.execute( + "INSERT INTO leads(" + ",".join(_cols) + ") VALUES (" + ",".join(["?"] * len(_cols)) + ")", + [ + lead_id, + board_id or "inbox", + True, + bool(raw.get("is_vacancy")), + known_type, + title, + summary, + json.dumps(stack, ensure_ascii=False), + budget.get("from") if budget else None, + budget.get("to") if budget else None, + budget.get("currency", "") if budget else "", + conv["convFrom"], conv["convTo"], conv["convCur"], + contact, + json.dumps(contacts, ensure_ascii=False), + ch_name, ch_handle, ch_hue, + human_age(now, msg_at or now), + msg_at or now, + text[:4000], + dialog_id or "", + msg_id, + "inbox", + "[]", + json.dumps(match_hits, ensure_ascii=False), + now, + ], + ) + store.execute("UPDATE dedup SET lead_id = ? WHERE hash = ?", [lead_id, digest]) + _link_message(lead_id, dialog_id, msg_id, text, msg_at or now) + return lead_to_dict(lead_id) + + +def _link_message(lead_id: str, dialog_id: str, msg_id: int | None, text: str, msg_at: int) -> None: + """В messages оседает только исходник карточки (для предпросмотра/флагов).""" + if not dialog_id or msg_id is None: + return + store.execute( + "INSERT OR IGNORE INTO messages(id, dialog_id, text, msg_at) VALUES (?, ?, ?, ?)", + [f"m_{dialog_id}_{msg_id}", dialog_id, text[:4000], msg_at], + ) + store.execute("UPDATE messages SET lead_id = ? WHERE id = ?", [lead_id, f"m_{dialog_id}_{msg_id}"]) + + +def human_age(now_ms: int, ts: int) -> str: + delta = max(0, now_ms - ts) + minutes = delta // C.MIN_MS + if minutes < 60: + return "только что" if minutes < 1 else f"{minutes} мин" + hours = minutes // 60 + if hours < 24: + return f"{hours} ч" + days = hours // 24 + return f"{days} дн" + + +def lead_to_dict(lead_id: str) -> dict: + row = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id]) + if not row: + return {} + comments = json.loads(row["comments"] or "[]") + try: + match_hits = json.loads(row.get("match_hits") or "[]") + except Exception: # noqa: BLE001 + match_hits = [] + try: + contacts = json.loads(row.get("contacts") or "[]") + except Exception: # noqa: BLE001 + contacts = [] + if not contacts and (row.get("contact") or ""): + q = qualify_contact(row["contact"]) + contacts = [q] if q else [{"type": "other", "value": row["contact"]}] + return { + "id": row["id"], + "col": row["col"], + "isNew": bool(row["is_new"]), + "isVacancy": bool(row["is_vacancy"]), + "isVacancyKnown": bool(row.get("is_vacancy_known")), + "title": clean_short(row["title"], 140), + "summary": clean_block(row["summary"], 2000), + "stack": json.loads(row["stack"] or "[]"), + "budget": ( + {"from": row["budget_from"], "to": row["budget_to"], "cur": row["budget_cur"]} + if row["budget_cur"] + else None + ), + "converted": ( + {"from": row["conv_from"], "to": row["conv_to"], "cur": row["conv_cur"]} + if row["conv_cur"] + else None + ), + "contact": row["contact"], + "contacts": contacts, + "ch": {"name": row["ch_name"], "handle": row["ch_handle"], "hue": row["ch_hue"]}, + "time": row["time_label"], + "receivedAt": row["received_at"], + "sourceMsg": row["source_msg"], + "sourceDialogId": row["source_dialog_id"] or "", + "sourceMsgId": row["source_msg_id"], + "prevCol": row["prev_col"], + "matchHits": match_hits, + "comments": comments, + } + + +# Метки-поля, встречающиеся в объявлениях/вакансиях: «Стек: Java, Kotlin», +# «Грейд: Middle», «Контакты: @user», «Бюджет: 1 200–1 500$» и т.п. +_FIELD_LABELS = { + "stack": {"стек", "технологии", "технология", "скиллы", "скилы", "языки", "язык", "инструменты", "tools", "tech stack", "stack"}, + "grade": {"грейд", "уровень", "грейд/уровень", "seniority", "level"}, + "contacts": {"контакт", "контакты", "связь", "телеграм", "почта", "email", "контакты для связи"}, + "budget": {"бюджет", "оплата", "зп", "зарплата", "вилка", "оклад", "ставка", "цена", "цену", "гонорар", "pay", "salary"}, +} +_MD_EDGES = re.compile(r"^[\s*>#_~]+|[\s*>#_~]+$") +_MD_BOLD = re.compile(r"\*\*(.+?)\*\*") +_LABEL_RE = re.compile(r"^[\s*>#_~]*([А-Яа-яЁёA-Za-z][А-Яа-яЁёA-Za-z0-9 /+\-]{1,36}?)\s*[:|]\s*(.+)$") +_TOKEN_RE = re.compile(r"(?:[A-Za-zА-Яа-яЁё0-9][A-Za-zА-Яа-яЁё0-9#.+\-]*|\.[A-Za-zА-Яа-яЁё][A-Za-zА-Яа-яЁё0-9#.+\-]*)") +_CONTACT_RE = re.compile(r"@[A-Za-z0-9_]{3,}") +_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9\-]+(?:\.[A-Za-z0-9\-]+)+") +_PHONE_RE = re.compile(r"(?:\+7|8|7)[\s\-()]*\d{3}[\s\-()]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}") +_STOP_STACK = { + "и", "или", "на", "по", "с", "не", "а", "в", "о", "об", "от", "до", "для", "опыт", "знание", + "знания", "уметь", "умение", "умения", "работать", "работы", "работа", "работе", "требуется", + "приветствуется", "будет", "плюсом", "разработка", "разработке", "разработчик", "разработчика", + "вакансия", "вакансию", "вакансии", "команда", "команду", "команды", "проект", "проекта", "проекты", + "приветствуются", "желательно", "уверенное", "хорошее", "понимание", "навыки", "навык", "навыков", +} + + +# Маркеры «найма» и «уровней» не зашиты в код: они редактируются в UI +# «Сфера и ключи» (hireMarkers / levelTerms). Здесь только чтение настроек +# с фолбэком на дефолты из constants.py. + +def _hire_markers() -> set[str]: + raw = store.get_setting("hireMarkers") + if raw is None: + raw = C.DEFAULT_HIRE_MARKERS + if isinstance(raw, str): + raw = [raw] + return {str(x).strip().casefold() for x in raw if str(x).strip()} + + +def _level_terms() -> set[str]: + raw = store.get_setting("levelTerms") + if raw is None: + raw = C.DEFAULT_LEVEL_TERMS + if isinstance(raw, str): + raw = [raw] + return {str(x).strip().casefold() for x in raw if str(x).strip()} + + +def _resume_markers() -> set[str]: + raw = store.get_setting("resumeMarkers") + if raw is None: + raw = C.DEFAULT_RESUME_MARKERS + if isinstance(raw, str): + raw = [raw] + return {str(x).strip().casefold() for x in raw if str(x).strip()} + + +def _resume_reason(lower: str) -> str | None: + """Вернуть маркер, по которому текст опознан как резюме соискателя. + + Для слова «резюме» есть контекстный guard: объявление работодателя вида + «…вакансия…, присылайте резюме» содержит маркер найма (hireMarkers) ДО + слова «резюме» — это не резюме, а заявка, её не отсекаем. + """ + for marker in _resume_markers(): + pos = lower.find(marker) + if pos < 0: + continue + if marker == "резюме" and any(h in lower[:pos] for h in _hire_markers()): + continue + return marker + return None + + +def _norm_phone(p: str) -> str: + p = p.replace(" ", "").replace("\u00a0", "").replace("-", "").replace("(", "").replace(")", "") + return p[:18] + + +def _contacts_from(body: str) -> list[str]: + """Универсальные контакты из текста: @username, email, телефоны.""" + out: list[str] = [] + for m in _CONTACT_RE.findall(body): + if m not in out: + out.append(m) + for m in _EMAIL_RE.findall(body): + if m not in out: + out.append(m) + for m in _PHONE_RE.findall(body): + p = _norm_phone(m) + if p not in out: + out.append(p) + return out[:4] + + +def _clean_line(ln: str) -> str: + return _MD_EDGES.sub("", ln).strip() + + +def _field_of(line: str) -> tuple[str, str] | None: + """Если строка вида «Метка: значение» — вернуть (категория, значение).""" + m = _LABEL_RE.match(line) + if not m: + return None + label = m.group(1).strip().casefold() + value = m.group(2).strip() + if not value: + return None + for cat, syns in _FIELD_LABELS.items(): + if label in syns: + return cat, value + return None + + +def _pick_stack(value: str) -> list[str]: + """Слова из значения метки («Стек:», «Услуги:», «Материалы:» …). + + Универсально: не только технологии с латиницей, а любые значимые слова — + тип работ, услуги, товары, материалы (для не-IT сфер). + """ + out: list[str] = [] + for tok in _TOKEN_RE.findall(value): + low = tok.casefold() + if low in _STOP_STACK or len(tok) < 2: + continue + out.append(tok) + if len(out) >= 10: + break + return out + + +def _local_fields(text: str) -> dict: + """Локальный структуратор (без ИИ): заголовок, суть, стек, грейд, бюджет, + контакты, признак вакансии. Используется для быстрых путей (правила/ML), + чтобы карточка не выглядела как сырое сообщение. + """ + text = text or "" + hire = _hire_markers() + levels = _level_terms() + lines = [_clean_line(ln) for ln in text.splitlines()] + lines = [ln for ln in lines if ln] + body = "\n".join(lines) + lower = body.casefold() + + fields: dict[str, list[str]] = {"stack": [], "grade": [], "contacts": []} + budget_raw = "" + for ln in lines[1:]: + hit = _field_of(ln) + if not hit: + continue + cat, value = hit + if cat == "stack": + fields["stack"].extend(_pick_stack(value)) + elif cat == "grade": + for tok in _TOKEN_RE.findall(value): + low = tok.casefold() + if low in levels and low not in fields["grade"]: + fields["grade"].append(low) + elif cat == "contacts": + fields["contacts"].extend(_contacts_from(value)) + elif cat == "budget": + budget_raw = value + + # fallback-извлечения по всему тексту (если объявление без меток) + if not fields["contacts"]: + fields["contacts"] = _contacts_from(body) + if not fields["stack"]: + # метка «Стек: …» может стоять не в начале строки (однострочные объявления) + for m in re.finditer(r"(?im)\b(?:стек|технологии|скиллы|скилы|языки|язык|инструменты)\s*[:|]\s*([^\n]{2,120})", body): + fields["stack"].extend(_pick_stack(m.group(1))) + if fields["stack"]: + break + if not fields["grade"]: + for w in lower.split(): + w = w.strip(",;.:«»\"'()").casefold() + if w in levels: + fields["grade"].append(w) + break + if not budget_raw: + budget_raw = body + + budget = None + for amt in rules_svc.extract_amounts(budget_raw): + budget = {"from": amt["from"], "to": amt["to"], "currency": amt["cur"]} + break + if budget is None and lines: + for amt in rules_svc.extract_amounts(body): + budget = {"from": amt["from"], "to": amt["to"], "currency": amt["cur"]} + break + + title = clean_short(lines[0] if lines else body, 140) + if not title: + title = clean_short(body, 140) + summary = _local_summary(body, lines) + is_vacancy = any(mk in lower for mk in hire) + contact = "; ".join(fields["contacts"])[:200] + + stack = [] + for s in fields["stack"]: + if s.casefold() not in (x.casefold() for x in stack): + stack.append(s) + return { + "title": title, + "summary": summary, + "stack": stack[:12], + "grade": fields["grade"][:4], + "budget": budget, + "contacts": contact, + "is_vacancy": is_vacancy, + "is_vacancy_known": False, # маркерная оценка — не контекст; тип подтверждает ИИ + "board": None, + } + + +# ─── Фоновый воркер ─────────────────────────────────────────────────────── + +def _raw_spam(raw: dict) -> bool: + """Признак спама в ответе ИИ-классификатора (используется, чтобы не учить + ML на карточке, которую ИИ пометил мусором, но она всё же сохранена).""" + return bool((raw or {}).get("is_spam")) + + +def _drop_row(row: dict, with_dedup: bool = True) -> None: + if with_dedup: + digest = ai_service.normalize_dedup(row["text"]) + store.execute("DELETE FROM dedup WHERE hash = ? AND lead_id IS NULL", [digest]) + store.execute("DELETE FROM pipeline_msg WHERE id = ?", [row["id"]]) + + +def _stale_max_age() -> int: + """Срок актуальности входящего сообщения = срок до архива (в мс). + + Сообщение, опубликованное раньше этого срока, сразу ушло бы в архив — + поэтому оно не заводится в систему вообще (ни карточкой, ни в архив/ + корзину). Работает только при включённом автоархиве. + """ + if not store.get_setting("autoArchive"): + return 0 + days = int(store.get_setting("archiveAfterDays") or 14) + return days * C.DAY_MS + + +def _is_stale(msg_at: int | None) -> bool: + max_age = _stale_max_age() + if max_age <= 0 or not msg_at: + return False + return _now() - msg_at > max_age + + +def _drop_stale(row: dict) -> None: + """Устаревшее сообщение удаляем сразу, без создания карточки и без ML/ИИ.""" + days = int(store.get_setting("archiveAfterDays") or 14) + log.info("stage1 blocked (устарело, старше срока до архива): %s", row["text"][:80]) + processing_svc.record( + row, "stale", "stale", + f"сообщение старше {days} дн. (срок до автоархива) — не заводим в систему", + ) + _drop_row(row) + + +def _reject_stage1(row: dict, r1: dict) -> None: + """Отсев на этапе 1: пишем причину в мониторинг (вкладка «Обработка»).""" + kind = str(r1.get("kind") or "stop") + processing_svc.record(row, "stop", kind, r1["reason"], str(r1.get("kw") or "")) + + +def _reject_no_budget(row: dict, _is_hire: bool) -> None: + """Глобальный фильтр «без суммы»: сообщение пропущено, карточка не создана.""" + processing_svc.record( + row, "stop", "budget", + "включён фильтр «не создавать карточку без суммы» — в тексте не указан бюджет", + ) + + +# сигнатура последнего опубликованного состояния очереди/отсева (SSE-событие +# шлём только при изменении — иначе воркер спамил бы эфир каждые 2 секунды) +_last_pub_sig: tuple | None = None + + +async def _maybe_publish_stats() -> None: + """SSE pipeline_stats для вкладки «Обработка» при изменении очереди/отсева.""" + global _last_pub_sig + q = processing_svc.queue_counts() + sig = (q["new"], q["ai"], processing_svc.rejected_count()) + if sig == _last_pub_sig: + return + _last_pub_sig = sig + await broker.publish( + "pipeline_stats", + {"new": sig[0], "ai": sig[1], "total": sig[0] + sig[1], "rejected": sig[2]}, + ) + + +def _pump_gate() -> tuple[int, int]: + """Шлагбаум «остановиться после N карточек»: (limit, done). 0 = выключен.""" + try: + limit = int(store.get_setting("pumpGate") or 0) + done = int(store.get_setting("pumpGateDone") or 0) + except (TypeError, ValueError): + limit, done = 0, 0 + return max(0, limit), max(0, done) + + +async def pump_once(new_limit: int = 12, ai_limit: int = 4) -> dict: + """Один проход воркера по очереди. Вызывается из фонового цикла и /admin/tick. + + Если включён шлагбаум (pumpGate > 0), после N созданных карточек воркер + останавливается: очередь копится, новые карточки не создаются до снятия + шлагбаума (admin/pump-gate, limit=0 или новый лимит). Нужно для «прогона + с остановкой после первых N сообщений» (проверка результата пользователем). + """ + limit, done = _pump_gate() + if limit and done >= limit: + return {"paused": True, "done": done, "limit": limit} + if _pump_lock.locked(): + return {} # другой воркер уже разбирает очередь + async with _pump_lock: + res = await _pump_unlocked(new_limit, ai_limit) + if limit: + added = int(res.get("rulesStored") or 0) + int(res.get("mlStored") or 0) + int(res.get("aiStored") or 0) + done = done + added + store.set_setting("pumpGateDone", done) + res["gateDone"] = done + res["gateLimit"] = limit + if done >= limit: + res["paused"] = True + await broker.publish_toast( + f"Пауза: создано {done} карточек (лимит {limit}) — проверьте результат", "clock" + ) + await _maybe_publish_stats() + return res + + +async def _pump_unlocked(new_limit: int, ai_limit: int) -> dict: + res = {"staged": 0, "rulesStored": 0, "mlStored": 0, "mlDrop": 0, "typeDrop": 0, "aiStored": 0, "aiDrop": 0, "aiFail": 0, "noBudget": 0} + + # 1) new -> стоп-фразы/дедуп -> ML (или в очередь на ИИ) + rows = store.query( + "SELECT * FROM pipeline_msg WHERE status = ? ORDER BY created_at LIMIT ?", + [ST_NEW, new_limit], + ) + for row in rows: + force = bool(row.get("force")) + if not force and _is_stale(row["msg_at"]): + _drop_stale(row) + continue + if not force: + r1 = stage1_plain(row["text"]) + if not r1["pass"]: + log.info("stage1 blocked (%s): %s", r1["reason"], row["text"][:80]) + _reject_stage1(row, r1) + _drop_row(row) + continue + digest = ai_service.normalize_dedup(row["text"]) + if store.scalar("SELECT 1 FROM dedup WHERE hash = ?", [digest]): + processing_svc.record( + row, "dup", "dup", + "сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается", + ) + _drop_row(row) + continue + store.execute( + "INSERT OR IGNORE INTO dedup(hash, lead_id, created_at) VALUES (?, NULL, ?)", + [digest, _now()], + ) + res["staged"] += 1 + + # Смысловые колонки НЕ назначаем словарным фильтром до ИИ: словарный + # матч не понимает смысл (дайджест из 5 ролей, «desktop» в URL/футере + # и т.п.). Сначала смысл оценивает ML (см. ниже), не уверен — ИИ. + # Правила колонок остаются критериями для ИИ/проверкой при назначении + # и «почему карточка попала» (см. _store_lead: board_accepts + hits). + + # ML-слой: если включён настройкой и уверен — решает без ИИ. + # ML не назначает колонки с активными правилами и ИИ-предложения: + # они наполняются ИИ (с проверкой правил) или действиями пользователя. + # force (возврат из отсева) идёт мимо ML к ИИ-классификации: пользователь + # явно подтвердил, что сообщение релевантно, — ML не должен снова его + # отсеять как спам/не тот тип. + if ml_client.is_enabled() and not force: + dec = await ml_client.predict(row["text"]) + label = dec.get("label") + if dec.get("take") and label: + if label == "spam": + score = dec.get("scores", {}).get("spam", 0) + log.info("ml drop (spam, score %.2f): %s", score, row["text"][:120].replace("\n", " ")) + processing_svc.record( + row, "ml", "spam_ml", + f"ML уверен, что это спам/не заявка (score {float(score):.2f})", + ) + _drop_row(row) + res["mlDrop"] += 1 + continue + br = store.query_one("SELECT suggested, rules FROM boards WHERE id = ?", [label]) + allowed = False + if br and not bool(br["suggested"]): + try: + br_rules = json.loads(br["rules"] or "{}") if br["rules"] else {} + except Exception: # noqa: BLE001 + br_rules = {} + allowed = not rules_svc.has_active_rules(br_rules) + if allowed: + raw = _local_fields(row["text"]) + raw["board"] = label + # ML назначил колонку и уверен в типе — тип тоже его решение + typ = dec.get("type") or {} + if typ.get("take"): + raw["is_vacancy"] = typ.get("label") == "hire" + raw["is_vacancy_known"] = True + # ML «узнала» в тексте слова, характерные для колонки: + # докладываем их в стек (если локальный разбор их пропустил) + added = 0 + for term in (dec.get("terms") or []): + t = str(term).strip().strip("@+#.") + low = t.casefold() + if not t or len(t) < 2 or t.startswith("~"): + continue + if low in _STOP_STACK: + continue + if any(x.casefold() == low for x in raw.get("stack") or []): + continue + raw.setdefault("stack", []).append(t) + added += 1 + if added >= 4: + break + if _skip_no_budget(raw, row["text"]): + res["noBudget"] += 1 + _reject_no_budget(row, bool(raw.get("is_vacancy"))) + _drop_row(row) # без dedup: после выключения фильтра сообщение можно взять снова + continue + lead = _store_lead( + digest, row["dialog_id"], row["ch_name"], row["ch_handle"], row["ch_hue"], + row["text"], raw, row["msg_at"], row["msg_id"], + ) + _drop_row(row, with_dedup=False) # dedup уже привязан к lead + if lead: + res["mlStored"] += 1 + await broker.publish("new_lead", lead) + continue + # ML уверен в типе (t:hire/t:order), даже если колонку не назначил + typ = dec.get("type") or {} + if typ.get("take"): + is_hire = typ.get("label") == "hire" + want = str(store.get_setting("wantedType") or "both").strip().lower() + if want in ("vacancy", "freelance"): + bad = (want == "freelance" and is_hire) or (want == "vacancy" and not is_hire) + if bad: + # тип не под режим «что собираем» — не тратим ИИ + log.info("ml typeDrop (%s, хотят %s): %s", "найм" if is_hire else "разовое", want, row["text"][:120].replace("\n", " ")) + processing_svc.record( + row, "ml", "type", + f"ML: тип «{'найм/занятость' if is_hire else 'разовые заказы'}», а вы ищете только «{want}»", + ) + _drop_row(row) + res["typeDrop"] += 1 + continue + if not store.get_setting("aiEnabled"): + # ИИ выключен, но тип ML знает: создаём карточку сами (inbox) + raw = _local_fields(row["text"]) + raw["is_vacancy"] = is_hire + raw["is_vacancy_known"] = True + if _skip_no_budget(raw, row["text"]): + res["noBudget"] += 1 + _reject_no_budget(row, is_hire) + _drop_row(row) + continue + lead = _store_lead( + digest, row["dialog_id"], row["ch_name"], row["ch_handle"], row["ch_hue"], + row["text"], raw, row["msg_at"], row["msg_id"], + ) + _drop_row(row, with_dedup=False) + if lead: + res["mlStored"] += 1 + await broker.publish("new_lead", lead) + continue + store.execute( + "UPDATE pipeline_msg SET status = ?, updated_at = ? WHERE id = ?", + [ST_AI, _now(), row["id"]], + ) + + # 2) filtered -> ИИ-фильтр (если включён) + классификация + rows = store.query( + "SELECT * FROM pipeline_msg WHERE status = ? ORDER BY created_at LIMIT ?", + [ST_AI, ai_limit], + ) + for row in rows: + force = bool(row.get("force")) + if not force and _is_stale(row["msg_at"]): + _drop_stale(row) + continue + text = row["text"] + digest = ai_service.normalize_dedup(text) + # ИИ выключен (aiEnabled=false): классификацию/ИИ-фильтр не вызываем — + # карточку собирает локальный разбор. В колонки кладёт только ML. + if not store.get_setting("aiEnabled"): + raw = _local_fields(text) + if not force and _skip_no_budget(raw, text): + res["noBudget"] += 1 + _reject_no_budget(row, bool(raw.get("is_vacancy"))) + _drop_row(row) + continue + lead = _store_lead( + digest, row["dialog_id"], row["ch_name"], row["ch_handle"], row["ch_hue"], + text, raw, row["msg_at"], row["msg_id"], + ) + _drop_row(row, with_dedup=False) + if lead: + res["aiStored"] += 1 + await broker.publish("new_lead", lead) + continue + if force: + # возврат из отсева: ИИ-фильтр не пересматриваем, пользователь уже + # подтвердил релевантность — сразу классификация + r2 = {"pass": True, "reason": None, "skipped": True} + else: + try: + r2 = await ai_service.filter_incoming(text) + except Exception as exc: # noqa: BLE001 + log.warning("AI filter error, skip stage2: %s", exc) + r2 = {"pass": True, "reason": None, "skipped": True} + try: + raw = await ai_service.classify(text) if r2["pass"] else {} + if raw: + # тип определён ИИ по контексту (не маркерной эвристикой) + raw["is_vacancy_known"] = True + except Exception as exc: # noqa: BLE001 + log.warning("classification failed: %s", exc) + raw = {} + + is_spam = bool(raw.get("is_spam") or (not r2["pass"])) + if force and raw.get("is_spam"): + # пользователь вернул сообщение из отсева: вердикт «спам» отменяется, + # поля ИИ уже разложил — карточку создаём (без обучения «спаму») + raw["is_spam"] = False + is_spam = False + if is_spam: + # ИИ-решение «спам/мусор» — обучаем ML отличать спам (с весом гипотезы) + log.info("ai drop (не заявка: %s): %s", r2.get("reason") or raw.get("is_spam"), text[:120].replace("\n", " ")) + if not r2["pass"]: + processing_svc.record( + row, "ai", "filter_ai", + "ИИ-фильтр: " + (str(r2.get("reason") or "сообщение не относится к вашим интересам")), + ) + else: + processing_svc.record( + row, "ai", "spam_ai", + "ИИ: не заявка — спам, реклама, скам или служебное сообщение", + ) + ml_client.push(text, "spam", delta=ml_client.AI_WEIGHT) + _drop_row(row) + res["aiDrop"] += 1 + continue + if not raw: + # ИИ не дал разбора (недоступен/сбой) — но не спам: локальный разбор + raw = _local_fields(text) + res["aiFail"] += 1 + if not force and _skip_no_budget(raw, text): + res["noBudget"] += 1 + _reject_no_budget(row, bool(raw.get("is_vacancy"))) + _drop_row(row) + continue + lead = _store_lead( + digest, row["dialog_id"], row["ch_name"], row["ch_handle"], row["ch_hue"], + text, raw, row["msg_at"], row["msg_id"], + ) + _drop_row(row, with_dedup=False) + if lead: + res["aiStored"] += 1 + await broker.publish("new_lead", lead) + # ИИ назначил колонку — это обучающий сигнал для ML (кроме inbox: + # «не знаю» не учим). Карточка не в inbox -> учим текст -> колонка. + col = str(lead.get("col") or "") + if col and col not in ("inbox", "trash", "archive") and not _raw_spam(raw): + # ML в своём пути назначает только колонки без активных правил + # и не-suggested — такие же примеры и собираем, иначе модель + # будет «знать» колонку, но не сможет её применить. + br = store.query_one("SELECT suggested, rules FROM boards WHERE id = ?", [col]) + free = False + if br and not bool(br["suggested"]): + try: + br_rules = json.loads(br["rules"] or "{}") if br["rules"] else {} + except Exception: # noqa: BLE001 + br_rules = {} + free = not rules_svc.has_active_rules(br_rules) + if free: + ml_client.push(text, col, delta=ml_client.AI_WEIGHT) + # тип известен от ИИ по контексту — учим ML определять его сам + # (t:hire = занятость/найм, t:order = разовая сделка) + if raw.get("is_vacancy_known"): + ml_client.push( + text, + "t:hire" if bool(raw.get("is_vacancy")) else "t:order", + delta=ml_client.AI_WEIGHT, + ) + + ml_client.track_decisions(ml=res["mlStored"] + res["mlDrop"], ai=res["aiStored"] + res["aiDrop"]) + return res diff --git a/archive/leadradar-legacy/backend/app/services/processing.py b/archive/leadradar-legacy/backend/app/services/processing.py index fffbcf2..637d10c 100644 --- a/archive/leadradar-legacy/backend/app/services/processing.py +++ b/archive/leadradar-legacy/backend/app/services/processing.py @@ -1,320 +1,320 @@ -"""Мониторинг пайплайна — вкладка «Обработка» (очередь и отсев). - -Очередь — сырые сообщения из каналов, ждущие разбора (pipeline_msg). -Отсев — сообщения, отброшенные на любом этапе: стоп-фразы/резюме/тип заявки/ -без суммы (source='stop'), устарело ('stale'), ML ('ml'), ИИ ('ai'), повтор -('dup'). Для каждой записи храним этап, причину и конкретное слово/фразу -(kw), если отсев по стоп-списку. - -Автоочистка отсева — раз в 3 суток (вызывается из leads.tick_storage), -плюс ручная очистка и удаление отдельных записей из UI. -""" -from __future__ import annotations - -import logging -import time - -from ..db import store -from . import fts as fts_svc - -log = logging.getLogger("leadradar.processing") - -# отсев живёт 3 суток, дальше удаляется автоматически -RETENTION_DAYS = 3 - -# человекочитаемые подписи этапов (для UI; source хранится отдельно) -_STAGE_LABELS = { - "length": "короткое сообщение", - "stop": "стоп-фраза", - "resume": "резюме соискателя", - "type": "тип заявки", - "budget": "нет суммы", - "stale": "устарело", - "spam_ml": "спам (ML)", - "spam_ai": "спам (ИИ)", - "filter_ai": "ИИ-фильтр", - "dup": "повтор", -} - -# «чьё» решение: используется в UI как источник метки -_SOURCE_LABELS = { - "stop": "правила", - "ml": "ML", - "ai": "ИИ", - "stale": "система", - "dup": "система", -} - -DEFAULT_LIMIT = 100 -MAX_LIMIT = 500 - - -def _now() -> int: - return time.time_ns() // 1_000_000 - - -def stage_label(stage: str) -> str: - return _STAGE_LABELS.get(stage, stage or "отсев") - - -def source_label(source: str) -> str: - return _SOURCE_LABELS.get(source, source or "система") - - -# ─── Запись отсева ──────────────────────────────────────────────────────── - -def record(row: dict, source: str, stage: str, reason: str, kw: str = "") -> None: - """Сохраняет отброшенное сообщение в таблицу отсева. - - Ид записи детерминирован по (dialog_id, msg_id): повторное отбрасывание - того же сообщения (перечитывание каналов) обновляет запись, а не копит - дубликаты в списке отсева. - """ - if not row or not (row.get("text") or "").strip(): - return - msg_id = row.get("msg_id") - dialog_id = row.get("dialog_id") or "" - rid = f"r_{dialog_id}_{msg_id}" if (msg_id is not None and dialog_id) else store.uid("r_") - now = _now() - store.execute( - "INSERT INTO rejected_msgs(id, dialog_id, msg_id, text, ch_name, ch_handle, ch_hue, stage, reason, kw, source, msg_at, rejected_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " - "ON CONFLICT(id) DO UPDATE SET " - "text = excluded.text, ch_name = excluded.ch_name, ch_handle = excluded.ch_handle, " - "ch_hue = excluded.ch_hue, stage = excluded.stage, reason = excluded.reason, kw = excluded.kw, " - "source = excluded.source, msg_at = excluded.msg_at, rejected_at = excluded.rejected_at", - [ - rid, - dialog_id, - msg_id, - str(row["text"])[:6000], - str(row.get("ch_name") or ""), - str(row.get("ch_handle") or ""), - str(row.get("ch_hue") or "#666"), - stage, - str(reason or "")[:500], - str(kw or "")[:200], - source, - row.get("msg_at"), - now, - ], - ) - - -def purge_expired(days: int = RETENTION_DAYS) -> int: - """Автоочистка отсева: записи старше N суток удаляются безвозвратно.""" - cut = _now() - days * 24 * 3600 * 1000 - rows = store.query( - "SELECT id FROM rejected_msgs WHERE rejected_at < ?", [cut] - ) - if not rows: - return 0 - ids = [r["id"] for r in rows] - store.execute( - "DELETE FROM rejected_msgs WHERE id IN (" + ",".join(["?"] * len(ids)) + ")", - ids, - ) - return len(ids) - - -def clear_all() -> int: - rows = store.query("SELECT count(*) AS c FROM rejected_msgs") - total = int(rows[0]["c"]) if rows else 0 - if total: - store.execute("DELETE FROM rejected_msgs") - return total - - -def return_to_queue(rej_id: str, reason: str = "") -> dict: - """Вернуть отсеянное сообщение в обработку (кнопка в «Обработке»). - - Строка очереди помечается force: этап 1, устарело, ML-решения и ИИ-отсев - для неё игнорируются — сообщение уходит на классификацию и создаёт карточку. - Запись в отсеве не удаляется, а помечается «возвращено» с причиной (аудит). - Если отсев был по решению «спам» (ML/ИИ) — снимаем у ML вес спама для текста. - """ - row = store.query_one("SELECT * FROM rejected_msgs WHERE id = ?", [rej_id]) - if not row: - raise KeyError(rej_id) - if bool(row.get("returned")): - raise ValueError("Сообщение уже возвращено в обработку") - if str(row.get("source") or "") == "dup": - raise ValueError("Повтор: карточка с таким текстом уже есть в системе — возвращать нечего") - dialog_id = str(row.get("dialog_id") or "") - msg_id = row.get("msg_id") - text = str(row.get("text") or "").strip() - if not text: - raise ValueError("В записи нет текста сообщения") - - if str(row.get("stage") or "") in ("spam_ml", "spam_ai", "filter_ai"): - from . import ml_client - - # реальное действие пользователя: этот текст НЕ спам - ml_client.push(text, "spam", delta=-1.0) - - now = _now() - store.execute( - "UPDATE rejected_msgs SET returned = TRUE, returned_at = ?, return_reason = ? WHERE id = ?", - [now, str(reason or "").strip()[:500], rej_id], - ) - - from .pipeline import enqueue # локальный импорт: pipeline импортирует processing - - if dialog_id and msg_id is not None: - enqueue( - dialog_id, - str(row.get("ch_name") or ""), - str(row.get("ch_handle") or ""), - str(row.get("ch_hue") or "#666"), - msg_id, - text, - row.get("msg_at") or now, - force=True, - ) - else: - # старые записи (до сохранения dialog_id/msg_id): текст сохранился, - # возвращаем без ссылки на исходное сообщение (force=True) - store.execute( - "INSERT INTO pipeline_msg(id, dialog_id, ch_name, ch_handle, ch_hue, text, msg_id, msg_at, status, force, created_at, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new', TRUE, ?, ?)", - [ - store.uid("p_"), - dialog_id, - str(row.get("ch_name") or ""), - str(row.get("ch_handle") or ""), - str(row.get("ch_hue") or "#666"), - text[:6000], - msg_id, - row.get("msg_at") or now, - now, - now, - ], - ) - return {"id": rej_id, "returned": True, "returnedAt": now} - - -def delete_one(rej_id: str) -> bool: - store.execute("DELETE FROM rejected_msgs WHERE id = ?", [rej_id]) - return True - - -def rejected_count() -> int: - return int(store.scalar("SELECT count(*) FROM rejected_msgs") or 0) - - -# ─── Очередь (pipeline_msg) ─────────────────────────────────────────────── - -def queue_counts() -> dict: - out = {"new": 0, "ai": 0} - for r in store.query("SELECT status, count(*) AS c FROM pipeline_msg GROUP BY status"): - if r["status"] == "new": - out["new"] = int(r["c"]) - elif r["status"] == "filtered": - out["ai"] = int(r["c"]) - out["total"] = out["new"] + out["ai"] - return out - - -def list_queue(limit: int = DEFAULT_LIMIT) -> list[dict]: - limit = min(max(1, limit), MAX_LIMIT) - rows = store.query( - "SELECT * FROM pipeline_msg ORDER BY created_at LIMIT ?", [limit] - ) - items = [] - for r in rows: - items.append( - { - "id": r["id"], - "dialogId": r.get("dialog_id") or "", - "msgId": r.get("msg_id"), - "text": r["text"], - "status": r["status"], # new | filtered - "ch": { - "name": r["ch_name"], - "handle": r["ch_handle"], - "hue": r["ch_hue"], - }, - "msgAt": r["msg_at"], - "queuedAt": r["created_at"], - } - ) - return items - - -# ─── Отсев (rejected_msgs) ──────────────────────────────────────────────── - -def list_rejected(q: str = "", offset: int = 0, limit: int = DEFAULT_LIMIT) -> dict: - offset = max(0, offset) - limit = min(max(1, limit), MAX_LIMIT) - qq = (q or "").strip().lower() - ids: list[str] = [] - - if qq: - # FTS-кандидаты + LIKE-дополнение (свежие записи после последнего rebuild) - if fts_svc.is_ready(): - try: - ids = fts_svc.search(qq, limit=limit)["rejected"] - except Exception: # noqa: BLE001 - ids = [] - pattern = f"%{qq}%" - like = store.query( - "SELECT id FROM rejected_msgs WHERE " - "lower(text) LIKE ? OR lower(reason) LIKE ? OR lower(kw) LIKE ? OR lower(ch_name) LIKE ? " - "ORDER BY rejected_at DESC LIMIT ?", - [pattern, pattern, pattern, pattern, limit * 2], - ) - for r in like: - if r["id"] not in ids: - ids.append(r["id"]) - total = len(ids) # итог по условию поиска (все кандидаты) - page = ids[offset : offset + limit] - else: - total = rejected_count() - page_rows = store.query( - "SELECT id FROM rejected_msgs ORDER BY rejected_at DESC LIMIT ? OFFSET ?", - [limit, offset], - ) - page = [r["id"] for r in page_rows] - - by_id: dict[str, dict] = {} - if page: - ph = ",".join(["?"] * len(page)) - rows = store.query( - f"SELECT * FROM rejected_msgs WHERE id IN ({ph})", page - ) - for r in rows: - by_id[r["id"]] = r - items = [] - for rid in page: - r = by_id.get(rid) - if not r: - continue - items.append( - { - "id": r["id"], - "dialogId": r.get("dialog_id") or "", - "msgId": r.get("msg_id"), - "text": r["text"], - "stage": r["stage"], - "stageLabel": stage_label(r["stage"]), - "reason": r["reason"], - "kw": r["kw"], - "source": r["source"], - "sourceLabel": source_label(r["source"]), - "ch": {"name": r["ch_name"], "handle": r["ch_handle"], "hue": r.get("ch_hue") or "#666"}, - "msgAt": r["msg_at"], - "rejectedAt": r["rejected_at"], - "returned": bool(r.get("returned")), - "returnedAt": r.get("returned_at"), - "returnReason": r.get("return_reason") or "", - } - ) - return {"items": items, "total": total, "offset": offset, "limit": limit} - - -def stats() -> dict: - q = queue_counts() - return { - "queue": q, - "rejected": rejected_count(), - } +"""Мониторинг пайплайна — вкладка «Обработка» (очередь и отсев). + +Очередь — сырые сообщения из каналов, ждущие разбора (pipeline_msg). +Отсев — сообщения, отброшенные на любом этапе: стоп-фразы/резюме/тип заявки/ +без суммы (source='stop'), устарело ('stale'), ML ('ml'), ИИ ('ai'), повтор +('dup'). Для каждой записи храним этап, причину и конкретное слово/фразу +(kw), если отсев по стоп-списку. + +Автоочистка отсева — раз в 3 суток (вызывается из leads.tick_storage), +плюс ручная очистка и удаление отдельных записей из UI. +""" +from __future__ import annotations + +import logging +import time + +from ..db import store +from . import fts as fts_svc + +log = logging.getLogger("leadradar.processing") + +# отсев живёт 3 суток, дальше удаляется автоматически +RETENTION_DAYS = 3 + +# человекочитаемые подписи этапов (для UI; source хранится отдельно) +_STAGE_LABELS = { + "length": "короткое сообщение", + "stop": "стоп-фраза", + "resume": "резюме соискателя", + "type": "тип заявки", + "budget": "нет суммы", + "stale": "устарело", + "spam_ml": "спам (ML)", + "spam_ai": "спам (ИИ)", + "filter_ai": "ИИ-фильтр", + "dup": "повтор", +} + +# «чьё» решение: используется в UI как источник метки +_SOURCE_LABELS = { + "stop": "правила", + "ml": "ML", + "ai": "ИИ", + "stale": "система", + "dup": "система", +} + +DEFAULT_LIMIT = 100 +MAX_LIMIT = 500 + + +def _now() -> int: + return time.time_ns() // 1_000_000 + + +def stage_label(stage: str) -> str: + return _STAGE_LABELS.get(stage, stage or "отсев") + + +def source_label(source: str) -> str: + return _SOURCE_LABELS.get(source, source or "система") + + +# ─── Запись отсева ──────────────────────────────────────────────────────── + +def record(row: dict, source: str, stage: str, reason: str, kw: str = "") -> None: + """Сохраняет отброшенное сообщение в таблицу отсева. + + Ид записи детерминирован по (dialog_id, msg_id): повторное отбрасывание + того же сообщения (перечитывание каналов) обновляет запись, а не копит + дубликаты в списке отсева. + """ + if not row or not (row.get("text") or "").strip(): + return + msg_id = row.get("msg_id") + dialog_id = row.get("dialog_id") or "" + rid = f"r_{dialog_id}_{msg_id}" if (msg_id is not None and dialog_id) else store.uid("r_") + now = _now() + store.execute( + "INSERT INTO rejected_msgs(id, dialog_id, msg_id, text, ch_name, ch_handle, ch_hue, stage, reason, kw, source, msg_at, rejected_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET " + "text = excluded.text, ch_name = excluded.ch_name, ch_handle = excluded.ch_handle, " + "ch_hue = excluded.ch_hue, stage = excluded.stage, reason = excluded.reason, kw = excluded.kw, " + "source = excluded.source, msg_at = excluded.msg_at, rejected_at = excluded.rejected_at", + [ + rid, + dialog_id, + msg_id, + str(row["text"])[:6000], + str(row.get("ch_name") or ""), + str(row.get("ch_handle") or ""), + str(row.get("ch_hue") or "#666"), + stage, + str(reason or "")[:500], + str(kw or "")[:200], + source, + row.get("msg_at"), + now, + ], + ) + + +def purge_expired(days: int = RETENTION_DAYS) -> int: + """Автоочистка отсева: записи старше N суток удаляются безвозвратно.""" + cut = _now() - days * 24 * 3600 * 1000 + rows = store.query( + "SELECT id FROM rejected_msgs WHERE rejected_at < ?", [cut] + ) + if not rows: + return 0 + ids = [r["id"] for r in rows] + store.execute( + "DELETE FROM rejected_msgs WHERE id IN (" + ",".join(["?"] * len(ids)) + ")", + ids, + ) + return len(ids) + + +def clear_all() -> int: + rows = store.query("SELECT count(*) AS c FROM rejected_msgs") + total = int(rows[0]["c"]) if rows else 0 + if total: + store.execute("DELETE FROM rejected_msgs") + return total + + +def return_to_queue(rej_id: str, reason: str = "") -> dict: + """Вернуть отсеянное сообщение в обработку (кнопка в «Обработке»). + + Строка очереди помечается force: этап 1, устарело, ML-решения и ИИ-отсев + для неё игнорируются — сообщение уходит на классификацию и создаёт карточку. + Запись в отсеве не удаляется, а помечается «возвращено» с причиной (аудит). + Если отсев был по решению «спам» (ML/ИИ) — снимаем у ML вес спама для текста. + """ + row = store.query_one("SELECT * FROM rejected_msgs WHERE id = ?", [rej_id]) + if not row: + raise KeyError(rej_id) + if bool(row.get("returned")): + raise ValueError("Сообщение уже возвращено в обработку") + if str(row.get("source") or "") == "dup": + raise ValueError("Повтор: карточка с таким текстом уже есть в системе — возвращать нечего") + dialog_id = str(row.get("dialog_id") or "") + msg_id = row.get("msg_id") + text = str(row.get("text") or "").strip() + if not text: + raise ValueError("В записи нет текста сообщения") + + if str(row.get("stage") or "") in ("spam_ml", "spam_ai", "filter_ai"): + from . import ml_client + + # реальное действие пользователя: этот текст НЕ спам + ml_client.push(text, "spam", delta=-1.0) + + now = _now() + store.execute( + "UPDATE rejected_msgs SET returned = TRUE, returned_at = ?, return_reason = ? WHERE id = ?", + [now, str(reason or "").strip()[:500], rej_id], + ) + + from .pipeline import enqueue # локальный импорт: pipeline импортирует processing + + if dialog_id and msg_id is not None: + enqueue( + dialog_id, + str(row.get("ch_name") or ""), + str(row.get("ch_handle") or ""), + str(row.get("ch_hue") or "#666"), + msg_id, + text, + row.get("msg_at") or now, + force=True, + ) + else: + # старые записи (до сохранения dialog_id/msg_id): текст сохранился, + # возвращаем без ссылки на исходное сообщение (force=True) + store.execute( + "INSERT INTO pipeline_msg(id, dialog_id, ch_name, ch_handle, ch_hue, text, msg_id, msg_at, status, force, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new', TRUE, ?, ?)", + [ + store.uid("p_"), + dialog_id, + str(row.get("ch_name") or ""), + str(row.get("ch_handle") or ""), + str(row.get("ch_hue") or "#666"), + text[:6000], + msg_id, + row.get("msg_at") or now, + now, + now, + ], + ) + return {"id": rej_id, "returned": True, "returnedAt": now} + + +def delete_one(rej_id: str) -> bool: + store.execute("DELETE FROM rejected_msgs WHERE id = ?", [rej_id]) + return True + + +def rejected_count() -> int: + return int(store.scalar("SELECT count(*) FROM rejected_msgs") or 0) + + +# ─── Очередь (pipeline_msg) ─────────────────────────────────────────────── + +def queue_counts() -> dict: + out = {"new": 0, "ai": 0} + for r in store.query("SELECT status, count(*) AS c FROM pipeline_msg GROUP BY status"): + if r["status"] == "new": + out["new"] = int(r["c"]) + elif r["status"] == "filtered": + out["ai"] = int(r["c"]) + out["total"] = out["new"] + out["ai"] + return out + + +def list_queue(limit: int = DEFAULT_LIMIT) -> list[dict]: + limit = min(max(1, limit), MAX_LIMIT) + rows = store.query( + "SELECT * FROM pipeline_msg ORDER BY created_at LIMIT ?", [limit] + ) + items = [] + for r in rows: + items.append( + { + "id": r["id"], + "dialogId": r.get("dialog_id") or "", + "msgId": r.get("msg_id"), + "text": r["text"], + "status": r["status"], # new | filtered + "ch": { + "name": r["ch_name"], + "handle": r["ch_handle"], + "hue": r["ch_hue"], + }, + "msgAt": r["msg_at"], + "queuedAt": r["created_at"], + } + ) + return items + + +# ─── Отсев (rejected_msgs) ──────────────────────────────────────────────── + +def list_rejected(q: str = "", offset: int = 0, limit: int = DEFAULT_LIMIT) -> dict: + offset = max(0, offset) + limit = min(max(1, limit), MAX_LIMIT) + qq = (q or "").strip().lower() + ids: list[str] = [] + + if qq: + # FTS-кандидаты + LIKE-дополнение (свежие записи после последнего rebuild) + if fts_svc.is_ready(): + try: + ids = fts_svc.search(qq, limit=limit)["rejected"] + except Exception: # noqa: BLE001 + ids = [] + pattern = f"%{qq}%" + like = store.query( + "SELECT id FROM rejected_msgs WHERE " + "lower(text) LIKE ? OR lower(reason) LIKE ? OR lower(kw) LIKE ? OR lower(ch_name) LIKE ? " + "ORDER BY rejected_at DESC LIMIT ?", + [pattern, pattern, pattern, pattern, limit * 2], + ) + for r in like: + if r["id"] not in ids: + ids.append(r["id"]) + total = len(ids) # итог по условию поиска (все кандидаты) + page = ids[offset : offset + limit] + else: + total = rejected_count() + page_rows = store.query( + "SELECT id FROM rejected_msgs ORDER BY rejected_at DESC LIMIT ? OFFSET ?", + [limit, offset], + ) + page = [r["id"] for r in page_rows] + + by_id: dict[str, dict] = {} + if page: + ph = ",".join(["?"] * len(page)) + rows = store.query( + f"SELECT * FROM rejected_msgs WHERE id IN ({ph})", page + ) + for r in rows: + by_id[r["id"]] = r + items = [] + for rid in page: + r = by_id.get(rid) + if not r: + continue + items.append( + { + "id": r["id"], + "dialogId": r.get("dialog_id") or "", + "msgId": r.get("msg_id"), + "text": r["text"], + "stage": r["stage"], + "stageLabel": stage_label(r["stage"]), + "reason": r["reason"], + "kw": r["kw"], + "source": r["source"], + "sourceLabel": source_label(r["source"]), + "ch": {"name": r["ch_name"], "handle": r["ch_handle"], "hue": r.get("ch_hue") or "#666"}, + "msgAt": r["msg_at"], + "rejectedAt": r["rejected_at"], + "returned": bool(r.get("returned")), + "returnedAt": r.get("returned_at"), + "returnReason": r.get("return_reason") or "", + } + ) + return {"items": items, "total": total, "offset": offset, "limit": limit} + + +def stats() -> dict: + q = queue_counts() + return { + "queue": q, + "rejected": rejected_count(), + } diff --git a/archive/leadradar-legacy/backend/app/services/projects.py b/archive/leadradar-legacy/backend/app/services/projects.py index 11ca40a..be8f535 100644 --- a/archive/leadradar-legacy/backend/app/services/projects.py +++ b/archive/leadradar-legacy/backend/app/services/projects.py @@ -1,282 +1,282 @@ -"""«Выбранные» — проектный канбан (п.4.8 ТЗ). - -Карточка живёт только здесь: лид уходит с дашборда безвозвратно (col='taken'), -в архив/корзину проектные карточки не попадают. Есть история движения, -вложения (файлы сейчас мета-мок, MinIO позже), ссылки, ТЗ и напоминания -для стадии «Отложено». -""" -from __future__ import annotations - -import json -import logging -import time - -from .. import constants as C -from ..db import store -from ..sse import broker - -log = logging.getLogger("leadradar.projects") - -STAGES = {s["id"] for s in C.PIPELINE_STAGES} - - -def _now() -> int: - return time.time_ns() // 1_000_000 - - -def _json(value: list, default: str = "[]") -> str: - return json.dumps(value or [], ensure_ascii=False) if value is not None else default - - -def _row_to_card(row: dict) -> dict: - history = json.loads(row["history"] or "[]") - return { - "id": row["id"], - "stage": row["stage"], - "local": bool(row["local"]), - "leadId": row["lead_id"], - "title": row["title"], - "summary": row["summary"], - "stack": json.loads(row["stack"] or "[]"), - "budget": ( - {"from": row["budget_from"], "to": row["budget_to"], "cur": row["budget_cur"]} - if row["budget_cur"] - else None - ), - "contact": row["contact"], - "comments": json.loads(row["comments"] or "[]"), - "links": json.loads(row["links"] or "[]"), - "files": json.loads(row["files"] or "[]"), - "tzText": row["tz_text"], - "history": history, - "reminder": {"at": row["reminder_at"]} if row["reminder_at"] else None, - "createdAt": row["created_at"], - "updatedAt": row["updated_at"], - } - - -def list_cards(stage: str | None = None) -> list[dict]: - if stage: - rows = store.query("SELECT * FROM projects WHERE stage = ? ORDER BY updated_at DESC", [stage]) - else: - rows = store.query("SELECT * FROM projects ORDER BY updated_at DESC") - return [_row_to_card(r) for r in rows] - - -def get_card(card_id: str) -> dict | None: - row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id]) - return _row_to_card(row) if row else None - - -def _insert(row_fields: dict) -> dict: - now = _now() - card_id = row_fields["id"] - store.execute( - "INSERT INTO projects(id, stage, local, lead_id, title, summary, stack, " - "budget_from, budget_to, budget_cur, contact, comments, links, files, tz_text, " - "history, reminder_at, reminder_fired, created_at, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, FALSE, ?, ?)", - [ - card_id, - row_fields.get("stage", "planned"), - bool(row_fields.get("local")), - row_fields.get("lead_id"), - row_fields.get("title", ""), - row_fields.get("summary", ""), - _json(row_fields.get("stack")), - row_fields.get("budget_from"), - row_fields.get("budget_to"), - row_fields.get("budget_cur", ""), - row_fields.get("contact", ""), - _json(row_fields.get("comments")), - _json(row_fields.get("links")), - _json(row_fields.get("files")), - row_fields.get("tz_text", ""), - _json(row_fields.get("history")), - now, - now, - ], - ) - return get_card(card_id) - - -def create_local_card(data: dict) -> dict: - """Ручное создание карточки — «локальная» (без лида).""" - budget = data.get("budget") if isinstance(data.get("budget"), dict) else None - return _insert( - { - "id": store.uid("pr_"), - "stage": data.get("stage") if data.get("stage") in STAGES else "planned", - "local": True, - "title": str(data.get("title", "")).strip(), - "summary": str(data.get("summary", "")), - "stack": data.get("stack") or [], - "budget_from": budget.get("from") if budget else None, - "budget_to": budget.get("to") if budget else None, - "budget_cur": budget.get("cur", "") if budget else "", - "contact": str(data.get("contact", "")), - "comments": data.get("comments") or [], - "links": data.get("links") or [], - "files": data.get("files") or [], - "tz_text": str(data.get("tzText", "")), - "history": [{"id": store.uid("h_"), "at": _now(), "type": "createdLocal"}], - } - ) - - -def take_lead_to_projects(lead_id: str) -> dict: - """«Взять в работу»: лид уходит с дашборда, создаётся проектная карточка.""" - lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id]) - if not lead: - raise KeyError(lead_id) - existing = store.query_one("SELECT id FROM projects WHERE lead_id = ?", [lead_id]) - if existing: - return get_card(existing["id"]) - budget = {"from": lead["budget_from"], "to": lead["budget_to"], "cur": lead["budget_cur"]} if lead["budget_cur"] else None - card = _insert( - { - "id": store.uid("pr_"), - "stage": "planned", - "local": False, - "lead_id": lead_id, - "title": lead["title"], - "summary": lead["summary"], - "stack": json.loads(lead["stack"] or "[]"), - "budget_from": budget["from"] if budget else None, - "budget_to": budget["to"] if budget else None, - "budget_cur": budget["cur"] if budget else "", - "contact": lead["contact"], - "comments": [{"id": store.uid("cm_"), "by": "Вы", "text": "Взял в работу из лида.", "time": "только что"}], - "tz_text": "", - "history": [{"id": store.uid("h_"), "at": _now(), "type": "created"}], - } - ) - # эксклюзивность: на дашборде лида больше нет, возврата нет - store.execute("UPDATE leads SET col = 'taken', is_new = FALSE WHERE id = ?", [lead_id]) - return card - - -def patch_card(card_id: str, patch: dict) -> dict: - row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id]) - if not row: - raise KeyError(card_id) - simple = { - "title": "title", - "summary": "summary", - "contact": "contact", - "tz_text": "tzText", - } - for col, key in simple.items(): - if key in patch: - store.execute(f"UPDATE projects SET {col} = ? WHERE id = ?", [str(patch[key]), card_id]) - if "stack" in patch: - store.execute("UPDATE projects SET stack = ? WHERE id = ?", [_json(patch["stack"]), card_id]) - if "budget" in patch: - b = patch["budget"] if isinstance(patch["budget"], dict) else None - store.execute( - "UPDATE projects SET budget_from = ?, budget_to = ?, budget_cur = ? WHERE id = ?", - [b.get("from") if b else None, b.get("to") if b else None, b.get("cur", "") if b else "", card_id], - ) - if "comments" in patch: - store.execute("UPDATE projects SET comments = ? WHERE id = ?", [_json(patch["comments"]), card_id]) - if "links" in patch: - store.execute("UPDATE projects SET links = ? WHERE id = ?", [_json(patch["links"]), card_id]) - if "files" in patch: - store.execute("UPDATE projects SET files = ? WHERE id = ?", [_json(patch["files"]), card_id]) - _bump(card_id) - return get_card(card_id) - - -def _bump(card_id: str) -> None: - store.execute("UPDATE projects SET updated_at = ? WHERE id = ?", [_now(), card_id]) - - -def add_comment(card_id: str, text: str) -> list[dict]: - row = store.query_one("SELECT comments FROM projects WHERE id = ?", [card_id]) - comments = json.loads(row["comments"] or "[]") - comments.append({"id": store.uid("cm_"), "by": "Вы", "text": text.strip(), "time": "только что"}) - patch_card(card_id, {"comments": comments}) - return comments - - -def move_stage(card_id: str, stage: str) -> dict: - if stage not in STAGES: - raise ValueError("Неизвестная стадия") - row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id]) - if not row: - raise KeyError(card_id) - history = json.loads(row["history"] or "[]") - now = _now() - store.execute( - "UPDATE projects SET stage = ?, reminder_at = NULL, reminder_fired = FALSE, updated_at = ? WHERE id = ?", - [stage, now, card_id], - ) - history.append({"id": store.uid("h_"), "at": now, "stage": stage}) - store.execute("UPDATE projects SET history = ? WHERE id = ?", [_json(history), card_id]) - return get_card(card_id) - - -def remove_card(card_id: str) -> None: - store.execute("DELETE FROM projects WHERE id = ?", [card_id]) - - -def clear_stage(stage: str) -> int: - """Полная ручная очистка стадии «Отклонено» (терминальные не восстанавливаются).""" - if stage not in ("rejected",): - raise ValueError("Очистка разрешена только для стадии «Отклонено»") - rows = store.query("SELECT id FROM projects WHERE stage = ?", [stage]) - if not rows: - return 0 - store.execute("DELETE FROM projects WHERE stage = ?", [stage]) - return len(rows) - - -# ─── Напоминания стадии «Отложено» ──────────────────────────────────────── - -def set_reminder(card_id: str, at: int) -> dict: - if not store.get_setting("remindersEnabled"): - raise PermissionError("Напоминания об отложенных выключены в настройках") - store.execute( - "UPDATE projects SET reminder_at = ?, reminder_fired = FALSE, updated_at = ? WHERE id = ?", - [at, _now(), card_id], - ) - return get_card(card_id) - - -def clear_reminder(card_id: str) -> None: - store.execute("UPDATE projects SET reminder_at = NULL, reminder_fired = FALSE WHERE id = ?", [card_id]) - - -def active_reminders() -> list[dict]: - rows = store.query( - "SELECT * FROM projects WHERE stage = 'hold' AND reminder_at IS NOT NULL ORDER BY reminder_at" - ) - return [{"id": r["id"], "title": r["title"], "at": r["reminder_at"]} for r in rows] - - -def snooze(card_id: str, ms: int = C.DAY_MS) -> None: - store.execute( - "UPDATE projects SET reminder_at = ?, reminder_fired = FALSE WHERE id = ?", - [_now() + ms, card_id], - ) - - -async def check_reminders() -> list[dict]: - """Наступившие напоминания -> события. Если выключено — чистим протухшие.""" - if not store.get_setting("remindersEnabled"): - # протухшие напоминания не храним: при включении старые не «выстрелят» - store.execute("UPDATE projects SET reminder_at = NULL, reminder_fired = FALSE WHERE reminder_at IS NOT NULL AND reminder_at <= ?", [_now()]) - return [] - now = _now() - rows = store.query( - "SELECT * FROM projects WHERE stage = 'hold' AND reminder_at IS NOT NULL " - "AND reminder_fired = FALSE AND reminder_at <= ?", - [now], - ) - due = [] - for r in rows: - store.execute("UPDATE projects SET reminder_fired = TRUE WHERE id = ?", [r["id"]]) - due.append({"id": r["id"], "title": r["title"], "stage": r["stage"]}) - for item in due: - await broker.publish("reminder_due", item) - return due +"""«Выбранные» — проектный канбан (п.4.8 ТЗ). + +Карточка живёт только здесь: лид уходит с дашборда безвозвратно (col='taken'), +в архив/корзину проектные карточки не попадают. Есть история движения, +вложения (файлы сейчас мета-мок, MinIO позже), ссылки, ТЗ и напоминания +для стадии «Отложено». +""" +from __future__ import annotations + +import json +import logging +import time + +from .. import constants as C +from ..db import store +from ..sse import broker + +log = logging.getLogger("leadradar.projects") + +STAGES = {s["id"] for s in C.PIPELINE_STAGES} + + +def _now() -> int: + return time.time_ns() // 1_000_000 + + +def _json(value: list, default: str = "[]") -> str: + return json.dumps(value or [], ensure_ascii=False) if value is not None else default + + +def _row_to_card(row: dict) -> dict: + history = json.loads(row["history"] or "[]") + return { + "id": row["id"], + "stage": row["stage"], + "local": bool(row["local"]), + "leadId": row["lead_id"], + "title": row["title"], + "summary": row["summary"], + "stack": json.loads(row["stack"] or "[]"), + "budget": ( + {"from": row["budget_from"], "to": row["budget_to"], "cur": row["budget_cur"]} + if row["budget_cur"] + else None + ), + "contact": row["contact"], + "comments": json.loads(row["comments"] or "[]"), + "links": json.loads(row["links"] or "[]"), + "files": json.loads(row["files"] or "[]"), + "tzText": row["tz_text"], + "history": history, + "reminder": {"at": row["reminder_at"]} if row["reminder_at"] else None, + "createdAt": row["created_at"], + "updatedAt": row["updated_at"], + } + + +def list_cards(stage: str | None = None) -> list[dict]: + if stage: + rows = store.query("SELECT * FROM projects WHERE stage = ? ORDER BY updated_at DESC", [stage]) + else: + rows = store.query("SELECT * FROM projects ORDER BY updated_at DESC") + return [_row_to_card(r) for r in rows] + + +def get_card(card_id: str) -> dict | None: + row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id]) + return _row_to_card(row) if row else None + + +def _insert(row_fields: dict) -> dict: + now = _now() + card_id = row_fields["id"] + store.execute( + "INSERT INTO projects(id, stage, local, lead_id, title, summary, stack, " + "budget_from, budget_to, budget_cur, contact, comments, links, files, tz_text, " + "history, reminder_at, reminder_fired, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, FALSE, ?, ?)", + [ + card_id, + row_fields.get("stage", "planned"), + bool(row_fields.get("local")), + row_fields.get("lead_id"), + row_fields.get("title", ""), + row_fields.get("summary", ""), + _json(row_fields.get("stack")), + row_fields.get("budget_from"), + row_fields.get("budget_to"), + row_fields.get("budget_cur", ""), + row_fields.get("contact", ""), + _json(row_fields.get("comments")), + _json(row_fields.get("links")), + _json(row_fields.get("files")), + row_fields.get("tz_text", ""), + _json(row_fields.get("history")), + now, + now, + ], + ) + return get_card(card_id) + + +def create_local_card(data: dict) -> dict: + """Ручное создание карточки — «локальная» (без лида).""" + budget = data.get("budget") if isinstance(data.get("budget"), dict) else None + return _insert( + { + "id": store.uid("pr_"), + "stage": data.get("stage") if data.get("stage") in STAGES else "planned", + "local": True, + "title": str(data.get("title", "")).strip(), + "summary": str(data.get("summary", "")), + "stack": data.get("stack") or [], + "budget_from": budget.get("from") if budget else None, + "budget_to": budget.get("to") if budget else None, + "budget_cur": budget.get("cur", "") if budget else "", + "contact": str(data.get("contact", "")), + "comments": data.get("comments") or [], + "links": data.get("links") or [], + "files": data.get("files") or [], + "tz_text": str(data.get("tzText", "")), + "history": [{"id": store.uid("h_"), "at": _now(), "type": "createdLocal"}], + } + ) + + +def take_lead_to_projects(lead_id: str) -> dict: + """«Взять в работу»: лид уходит с дашборда, создаётся проектная карточка.""" + lead = store.query_one("SELECT * FROM leads WHERE id = ?", [lead_id]) + if not lead: + raise KeyError(lead_id) + existing = store.query_one("SELECT id FROM projects WHERE lead_id = ?", [lead_id]) + if existing: + return get_card(existing["id"]) + budget = {"from": lead["budget_from"], "to": lead["budget_to"], "cur": lead["budget_cur"]} if lead["budget_cur"] else None + card = _insert( + { + "id": store.uid("pr_"), + "stage": "planned", + "local": False, + "lead_id": lead_id, + "title": lead["title"], + "summary": lead["summary"], + "stack": json.loads(lead["stack"] or "[]"), + "budget_from": budget["from"] if budget else None, + "budget_to": budget["to"] if budget else None, + "budget_cur": budget["cur"] if budget else "", + "contact": lead["contact"], + "comments": [{"id": store.uid("cm_"), "by": "Вы", "text": "Взял в работу из лида.", "time": "только что"}], + "tz_text": "", + "history": [{"id": store.uid("h_"), "at": _now(), "type": "created"}], + } + ) + # эксклюзивность: на дашборде лида больше нет, возврата нет + store.execute("UPDATE leads SET col = 'taken', is_new = FALSE WHERE id = ?", [lead_id]) + return card + + +def patch_card(card_id: str, patch: dict) -> dict: + row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id]) + if not row: + raise KeyError(card_id) + simple = { + "title": "title", + "summary": "summary", + "contact": "contact", + "tz_text": "tzText", + } + for col, key in simple.items(): + if key in patch: + store.execute(f"UPDATE projects SET {col} = ? WHERE id = ?", [str(patch[key]), card_id]) + if "stack" in patch: + store.execute("UPDATE projects SET stack = ? WHERE id = ?", [_json(patch["stack"]), card_id]) + if "budget" in patch: + b = patch["budget"] if isinstance(patch["budget"], dict) else None + store.execute( + "UPDATE projects SET budget_from = ?, budget_to = ?, budget_cur = ? WHERE id = ?", + [b.get("from") if b else None, b.get("to") if b else None, b.get("cur", "") if b else "", card_id], + ) + if "comments" in patch: + store.execute("UPDATE projects SET comments = ? WHERE id = ?", [_json(patch["comments"]), card_id]) + if "links" in patch: + store.execute("UPDATE projects SET links = ? WHERE id = ?", [_json(patch["links"]), card_id]) + if "files" in patch: + store.execute("UPDATE projects SET files = ? WHERE id = ?", [_json(patch["files"]), card_id]) + _bump(card_id) + return get_card(card_id) + + +def _bump(card_id: str) -> None: + store.execute("UPDATE projects SET updated_at = ? WHERE id = ?", [_now(), card_id]) + + +def add_comment(card_id: str, text: str) -> list[dict]: + row = store.query_one("SELECT comments FROM projects WHERE id = ?", [card_id]) + comments = json.loads(row["comments"] or "[]") + comments.append({"id": store.uid("cm_"), "by": "Вы", "text": text.strip(), "time": "только что"}) + patch_card(card_id, {"comments": comments}) + return comments + + +def move_stage(card_id: str, stage: str) -> dict: + if stage not in STAGES: + raise ValueError("Неизвестная стадия") + row = store.query_one("SELECT * FROM projects WHERE id = ?", [card_id]) + if not row: + raise KeyError(card_id) + history = json.loads(row["history"] or "[]") + now = _now() + store.execute( + "UPDATE projects SET stage = ?, reminder_at = NULL, reminder_fired = FALSE, updated_at = ? WHERE id = ?", + [stage, now, card_id], + ) + history.append({"id": store.uid("h_"), "at": now, "stage": stage}) + store.execute("UPDATE projects SET history = ? WHERE id = ?", [_json(history), card_id]) + return get_card(card_id) + + +def remove_card(card_id: str) -> None: + store.execute("DELETE FROM projects WHERE id = ?", [card_id]) + + +def clear_stage(stage: str) -> int: + """Полная ручная очистка стадии «Отклонено» (терминальные не восстанавливаются).""" + if stage not in ("rejected",): + raise ValueError("Очистка разрешена только для стадии «Отклонено»") + rows = store.query("SELECT id FROM projects WHERE stage = ?", [stage]) + if not rows: + return 0 + store.execute("DELETE FROM projects WHERE stage = ?", [stage]) + return len(rows) + + +# ─── Напоминания стадии «Отложено» ──────────────────────────────────────── + +def set_reminder(card_id: str, at: int) -> dict: + if not store.get_setting("remindersEnabled"): + raise PermissionError("Напоминания об отложенных выключены в настройках") + store.execute( + "UPDATE projects SET reminder_at = ?, reminder_fired = FALSE, updated_at = ? WHERE id = ?", + [at, _now(), card_id], + ) + return get_card(card_id) + + +def clear_reminder(card_id: str) -> None: + store.execute("UPDATE projects SET reminder_at = NULL, reminder_fired = FALSE WHERE id = ?", [card_id]) + + +def active_reminders() -> list[dict]: + rows = store.query( + "SELECT * FROM projects WHERE stage = 'hold' AND reminder_at IS NOT NULL ORDER BY reminder_at" + ) + return [{"id": r["id"], "title": r["title"], "at": r["reminder_at"]} for r in rows] + + +def snooze(card_id: str, ms: int = C.DAY_MS) -> None: + store.execute( + "UPDATE projects SET reminder_at = ?, reminder_fired = FALSE WHERE id = ?", + [_now() + ms, card_id], + ) + + +async def check_reminders() -> list[dict]: + """Наступившие напоминания -> события. Если выключено — чистим протухшие.""" + if not store.get_setting("remindersEnabled"): + # протухшие напоминания не храним: при включении старые не «выстрелят» + store.execute("UPDATE projects SET reminder_at = NULL, reminder_fired = FALSE WHERE reminder_at IS NOT NULL AND reminder_at <= ?", [_now()]) + return [] + now = _now() + rows = store.query( + "SELECT * FROM projects WHERE stage = 'hold' AND reminder_at IS NOT NULL " + "AND reminder_fired = FALSE AND reminder_at <= ?", + [now], + ) + due = [] + for r in rows: + store.execute("UPDATE projects SET reminder_fired = TRUE WHERE id = ?", [r["id"]]) + due.append({"id": r["id"], "title": r["title"], "stage": r["stage"]}) + for item in due: + await broker.publish("reminder_due", item) + return due diff --git a/archive/leadradar-legacy/backend/app/services/rates.py b/archive/leadradar-legacy/backend/app/services/rates.py index 6cb3799..948ab91 100644 --- a/archive/leadradar-legacy/backend/app/services/rates.py +++ b/archive/leadradar-legacy/backend/app/services/rates.py @@ -1,130 +1,130 @@ -"""Курсы валют: источник ЦБ РФ, 4 запроса в сутки (каждые 6 часов). - -По ТЗ до подключения сервиса используются мок-курсы; источник выбирается -в настройках (cbr | mock). Значения хранятся в БД вместе с временем -обновления и выдаются наружу для вкладки «Валюта и курсы». -""" -from __future__ import annotations - -import json -import logging -import time - -import httpx - -from .. import constants as C -from ..db import store - -log = logging.getLogger("leadradar.rates") - -_FETCH_INTERVAL_MS = 6 * C.HOUR_MS # 4 раза в сутки - - -def get_rates() -> dict: - row = store.query_one("SELECT rates, source, updated_at FROM rates WHERE id = 1") - rates = json.loads(row["rates"]) if row else dict(C.MOCK_RATES) - return { - "base": "RUB", - "rates": rates, - "source": row["source"] if row else "mock", - "updatedAt": row["updated_at"] if row else None, - } - - -def save_rates(rates: dict, source: str) -> None: - store.execute( - "INSERT INTO rates(id, rates, source, updated_at) VALUES (1, ?, ?, ?) " - "ON CONFLICT(id) DO UPDATE SET rates = excluded.rates, source = excluded.source, " - "updated_at = excluded.updated_at", - [json.dumps(rates), source, time.time_ns() // 1_000_000], - ) - - -async def fetch_cbr() -> dict | None: - """Запрос к ЦБ РФ (JSON-зеркало daily_json.js). Возвращает rates к RUB.""" - try: - async with httpx.AsyncClient(timeout=15) as client: - resp = await client.get(C.CBR_URL) - resp.raise_for_status() - payload = resp.json() - rates: dict = {"RUB": 1.0} - for code, item in payload.get("Valute", {}).items(): - # 1 единица валюты в рублях: Nominal может быть > 1 - nominal = int(item.get("Nominal", 1)) or 1 - value = float(item.get("Value", 0)) - rates[code] = round(value / nominal, 6) - return rates - except Exception as exc: # noqa: BLE001 - log.warning("CBR fetch failed: %s", exc) - return None - - -async def refresh_rates(force_source: str | None = None) -> bool: - """Ручное/фоновое обновление. Возвращает True при успехе (или при мок-режиме).""" - source = force_source or store.get_setting("rateSource") or "cbr" - if source == "mock": - save_rates(dict(C.MOCK_RATES), "mock") - recompute_conversions() - return True - rates = await fetch_cbr() - if rates is None: - return False - save_rates(rates, "cbr") - recompute_conversions() - return True - - -def should_fetch() -> bool: - row = store.query_one("SELECT updated_at, source FROM rates WHERE id = 1") - if not row: - return True - if row["source"] == "mock" and store.get_setting("rateSource") == "cbr": - return True - return time.time_ns() // 1_000_000 - row["updated_at"] >= _FETCH_INTERVAL_MS - - -def _resolve_rate(rates: dict, code: str) -> float | None: - """USDT приравниваем к USD (у ЦБ нет тикера USDT).""" - if code == "USDT" and "USD" in rates: - return float(rates["USD"]) - val = rates.get(code) - return float(val) if val is not None else None - - -def convert_amount(amount: float | int | None, from_cur: str, to_cur: str) -> float | None: - """amount в from_cur -> to_cur по актуальным курсам (к рублю).""" - if amount is None: - return None - rates = json.loads(store.query_one("SELECT rates FROM rates WHERE id = 1")["rates"]) - rf = _resolve_rate(rates, from_cur) - rt = _resolve_rate(rates, to_cur) - if rf is None or rt is None: - return None - return round(float(amount) * rf / rt, 2) - - -def recompute_conversions() -> int: - """Пересчёт старых карточек по актуальному курсу и целевой валюте. - - Пересчитываются карточки на канбане и в «Неразобранном»; архивные, - корзинные и ушедшие в «Выбранные» (taken) не трогаем. - """ - if not store.get_setting("conversionOn"): - return 0 - target = store.get_setting("targetCurrency") or "RUB" - rows = store.query( - "SELECT id, budget_from, budget_to, budget_cur FROM leads " - "WHERE budget_cur <> '' AND col NOT IN ('archive','trash','taken')", - ) - updated = 0 - for r in rows: - cf = convert_amount(r["budget_from"], r["budget_cur"], target) - ct = convert_amount(r["budget_to"] if r["budget_to"] is not None else r["budget_from"], r["budget_cur"], target) - if cf is None: - continue - store.execute( - "UPDATE leads SET conv_from = ?, conv_to = ?, conv_cur = ? WHERE id = ?", - [cf, ct, target, r["id"]], - ) - updated += 1 - return updated +"""Курсы валют: источник ЦБ РФ, 4 запроса в сутки (каждые 6 часов). + +По ТЗ до подключения сервиса используются мок-курсы; источник выбирается +в настройках (cbr | mock). Значения хранятся в БД вместе с временем +обновления и выдаются наружу для вкладки «Валюта и курсы». +""" +from __future__ import annotations + +import json +import logging +import time + +import httpx + +from .. import constants as C +from ..db import store + +log = logging.getLogger("leadradar.rates") + +_FETCH_INTERVAL_MS = 6 * C.HOUR_MS # 4 раза в сутки + + +def get_rates() -> dict: + row = store.query_one("SELECT rates, source, updated_at FROM rates WHERE id = 1") + rates = json.loads(row["rates"]) if row else dict(C.MOCK_RATES) + return { + "base": "RUB", + "rates": rates, + "source": row["source"] if row else "mock", + "updatedAt": row["updated_at"] if row else None, + } + + +def save_rates(rates: dict, source: str) -> None: + store.execute( + "INSERT INTO rates(id, rates, source, updated_at) VALUES (1, ?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET rates = excluded.rates, source = excluded.source, " + "updated_at = excluded.updated_at", + [json.dumps(rates), source, time.time_ns() // 1_000_000], + ) + + +async def fetch_cbr() -> dict | None: + """Запрос к ЦБ РФ (JSON-зеркало daily_json.js). Возвращает rates к RUB.""" + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(C.CBR_URL) + resp.raise_for_status() + payload = resp.json() + rates: dict = {"RUB": 1.0} + for code, item in payload.get("Valute", {}).items(): + # 1 единица валюты в рублях: Nominal может быть > 1 + nominal = int(item.get("Nominal", 1)) or 1 + value = float(item.get("Value", 0)) + rates[code] = round(value / nominal, 6) + return rates + except Exception as exc: # noqa: BLE001 + log.warning("CBR fetch failed: %s", exc) + return None + + +async def refresh_rates(force_source: str | None = None) -> bool: + """Ручное/фоновое обновление. Возвращает True при успехе (или при мок-режиме).""" + source = force_source or store.get_setting("rateSource") or "cbr" + if source == "mock": + save_rates(dict(C.MOCK_RATES), "mock") + recompute_conversions() + return True + rates = await fetch_cbr() + if rates is None: + return False + save_rates(rates, "cbr") + recompute_conversions() + return True + + +def should_fetch() -> bool: + row = store.query_one("SELECT updated_at, source FROM rates WHERE id = 1") + if not row: + return True + if row["source"] == "mock" and store.get_setting("rateSource") == "cbr": + return True + return time.time_ns() // 1_000_000 - row["updated_at"] >= _FETCH_INTERVAL_MS + + +def _resolve_rate(rates: dict, code: str) -> float | None: + """USDT приравниваем к USD (у ЦБ нет тикера USDT).""" + if code == "USDT" and "USD" in rates: + return float(rates["USD"]) + val = rates.get(code) + return float(val) if val is not None else None + + +def convert_amount(amount: float | int | None, from_cur: str, to_cur: str) -> float | None: + """amount в from_cur -> to_cur по актуальным курсам (к рублю).""" + if amount is None: + return None + rates = json.loads(store.query_one("SELECT rates FROM rates WHERE id = 1")["rates"]) + rf = _resolve_rate(rates, from_cur) + rt = _resolve_rate(rates, to_cur) + if rf is None or rt is None: + return None + return round(float(amount) * rf / rt, 2) + + +def recompute_conversions() -> int: + """Пересчёт старых карточек по актуальному курсу и целевой валюте. + + Пересчитываются карточки на канбане и в «Неразобранном»; архивные, + корзинные и ушедшие в «Выбранные» (taken) не трогаем. + """ + if not store.get_setting("conversionOn"): + return 0 + target = store.get_setting("targetCurrency") or "RUB" + rows = store.query( + "SELECT id, budget_from, budget_to, budget_cur FROM leads " + "WHERE budget_cur <> '' AND col NOT IN ('archive','trash','taken')", + ) + updated = 0 + for r in rows: + cf = convert_amount(r["budget_from"], r["budget_cur"], target) + ct = convert_amount(r["budget_to"] if r["budget_to"] is not None else r["budget_from"], r["budget_cur"], target) + if cf is None: + continue + store.execute( + "UPDATE leads SET conv_from = ?, conv_to = ?, conv_cur = ? WHERE id = ?", + [cf, ct, target, r["id"]], + ) + updated += 1 + return updated diff --git a/archive/leadradar-legacy/backend/app/services/rules.py b/archive/leadradar-legacy/backend/app/services/rules.py index fefac25..591f667 100644 --- a/archive/leadradar-legacy/backend/app/services/rules.py +++ b/archive/leadradar-legacy/backend/app/services/rules.py @@ -1,390 +1,390 @@ -"""Детерминированные правила колонок: направление, стек, слова, грейд, бюджет. - -Колонка — это набор опциональных фильтров, задаёт пользователь (или ИИ при -предложении). Если текст входящего сообщения соответствует правилам — карточка -уходит в эту колонку сразу, без ML/ИИ (ML/ИИ подключаются только когда правила -не сработали). Набор фильтров может меняться: пустая группа не участвует. -""" -from __future__ import annotations - -import json -import re - -from ..db import store - -_CUR_SYMBOLS = {"$": "USD", "€": "EUR", "₽": "RUB", "₮": "USDT", "£": "GBP", "¥": "CNY"} -_CUR_WORDS = {"usd": "USD", "eur": "EUR", "rub": "RUB", "usdt": "USDT", "gbp": "GBP", "cny": "CNY", "руб": "RUB", "долл": "USD", "бакс": "USD"} - -# Грейд/уровень: тег из правил расширяется синонимами, чтобы «middle» находил -# и «mid», и «мидл», а «сеньор» находил senior и т.п. -_GRADE_ALIASES = { - "junior": ["junior", "джун", "джуниор"], - "middle": ["middle", "mid", "мидл"], - "senior": ["senior", "сеньор", "сеньйор"], - "lead": ["lead", "тимлид", "тиэмлид", "team lead", "teamlead", "tech lead", "техлид"], - "architect": ["architect", "архитектор"], - "intern": ["intern", "стажёр", "стажер", "trainee"], -} - - -def _grade_terms(tags: list[str]) -> list[str]: - out: list[str] = [] - for t in tags: - key = str(t).strip().lower() - if not key: - continue - if key in _GRADE_ALIASES: - out.extend(_GRADE_ALIASES[key]) - else: - out.append(key) - return out - - -# Перед матчингом правил вырезаем ссылки и markdown-ссылки: иначе фильтр ловит -# слова из трекерных хвостов/служебных строк URL (например, «desktop» в -# utm_medium=member_desktop) и в колонку попадает мусор, не имеющий отношения -# к содержанию сообщения. -_MD_URL_RE = re.compile(r"\[[^\]]*\]\([^)\s]+\)") -_RAW_URL_RE = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+") - - -def content_text(text: str) -> str: - s = str(text or "") - s = _MD_URL_RE.sub(" ", s) - return _RAW_URL_RE.sub(" ", s) - -# ищем: 1) "от 1 200 до 1 500 $", 2) "1 200–1 500 $ / 1 200$", 3) "$1 200–1 500" -# суффикс «к/К» разрешён прямо в числе: «2к», «1.5к$» (множитель в _norm_amount) -_AMT = r"\d[\d\s\u00a0]*(?:[.,]\d+)?[кkКK]?" -_RANGE = rf"({_AMT})\s*(?:[-–—]\s*({_AMT}))?" - - -def _norm_amount(raw: str) -> float | None: - s = raw.replace("\u00a0", " ").replace(" ", "").replace(",", ".") - mult = 1.0 - # «2к»/«2К»/«1.5к» — тысячи; суффикс убираем до float (иначе парс падает) - if s and s[-1].lower() in ("k", "к"): - mult = 1000.0 - s = s[:-1] - try: - v = float(s) * mult - except ValueError: - return None - return v - - -def _cur_from_tail(text: str, m_start: int) -> str | None: - tail = text[m_start : m_start + 12].lower().strip() - for sym, code in _CUR_SYMBOLS.items(): - if tail.startswith(sym): - return code - # слово-валюта сразу после числа (с пробелом) - for word, code in _CUR_WORDS.items(): - if tail.startswith(word): - return code - # валюта перед числом ($/€/₽), например "$1 200" - head = text[max(0, m_start - 3) : m_start].strip() - for sym, code in _CUR_SYMBOLS.items(): - if head.endswith(sym): - return code - return None - - -def extract_amounts(text: str) -> list[dict]: - """Парсер сумм с сохранением смысла: - - диапазон «от A до B» / «A–B» → {'from': A, 'to': B, 'cur': ...}; - - «до B» (только верхняя граница) → {'from': None, 'to': B, 'cur': ...}; - - одна сумма / «от A» без верхней границы → {'from': A, 'to': A, 'cur': ...}. - Валюту ищем сразу после суммы (или перед ней для «$1 200»); суммы без валюты игнорируются. - """ - out: list[dict] = [] - t = text or "" - if not t.strip(): - return out - occupied: list[tuple[int, int]] = [] - - def _free(s: int, e: int) -> bool: - return not any(s < oe and e > os for os, oe in occupied) - - def _add(a, b, cur: str | None, s: int, e: int) -> None: - if cur and (a is not None or b is not None) and _free(s, e): - out.append({"from": a, "to": b, "cur": cur}) - occupied.append((s, e)) - - # 1) словесный диапазон «от A до B» (+ валюта после B) - for m in re.finditer(r"(?i)\bот\s+(" + _AMT + r")\s+до\s+(" + _AMT + r")", t): - a, b = _norm_amount(m.group(1)), _norm_amount(m.group(2)) - cur = _cur_from_tail(t, m.end(2)) - if a is not None and b is not None and cur: - _add(a, b, cur, m.start(1), m.end(2)) - # 2) «до B» (без пары «от … до») — верхняя граница - for m in re.finditer(r"(?i)\bдо\s+(" + _AMT + r")", t): - b = _norm_amount(m.group(1)) - cur = _cur_from_tail(t, m.end(1)) - if b is not None and cur: - _add(None, b, cur, m.start(1), m.end(1)) - # 3) «от A» без верхней границы — считаем одной суммой - for m in re.finditer(r"(?i)\bот\s+(" + _AMT + r")", t): - a = _norm_amount(m.group(1)) - cur = _cur_from_tail(t, m.end(1)) - if a is not None and cur: - _add(a, a, cur, m.start(1), m.end(1)) - # 4) числовые диапазоны «A–B» / «A - B» - for m in re.finditer(r"(?i)(" + _AMT + r")\s*(?:[-–—]|\s+до\s+)\s*(" + _AMT + r")", t): - a, b = _norm_amount(m.group(1)), _norm_amount(m.group(2)) - cur = _cur_from_tail(t, m.end(2)) or _cur_from_tail(t, m.end(1)) - if a is not None and b is not None and cur: - _add(a, b, cur, m.start(1), m.end(2)) - # 5) одиночные суммы с валютой (не вошедшие в конструкции выше) - for m in re.finditer(_AMT, t): - a = _norm_amount(m.group(0)) - cur = _cur_from_tail(t, m.end()) - if a is not None and cur: - _add(a, a, cur, m.start(), m.end()) - return out - - -def _amount_in_range(amounts: list[dict], budget: dict) -> bool: - from ..services.rates import convert_amount - - try: - lo = float(budget.get("from")) if budget.get("from") is not None else None - hi = float(budget.get("to")) if budget.get("to") is not None else None - except (TypeError, ValueError): - return False - if lo is None and hi is None: - return True - target_cur = str(budget.get("cur") or "USD").upper() - for amt in amounts: - raw_val = amt["from"] if amt["from"] is not None else amt.get("to") - if raw_val is None: - continue - try: - val = raw_val if amt["cur"] == target_cur else convert_amount(raw_val, amt["cur"], target_cur) - except Exception: # noqa: BLE001 - val = None - if val is None: - continue - if lo is not None and val < lo: - continue - if hi is not None and val > hi: - continue - return True - return False - - -def match_text(rules: dict, text: str) -> bool: - """Соответствует ли текст правилам колонки. Возвращает bool. - - Колонка — это набор опциональных фильтров: направление, стек, ключевые - слова, грейд/уровень, бюджет. Пустая группа не участвует; режим «все» — - должны совпасть все включённые группы, «любое» — хотя бы одна. - """ - rules = rules or {} - lower = content_text(text).lower() - direction = [str(x).strip().lower() for x in (rules.get("direction") or []) if str(x).strip()] - kw = [str(x).strip().lower() for x in (rules.get("keywords") or []) if str(x).strip()] - stack = [str(x).strip().lower() for x in (rules.get("stack") or []) if str(x).strip()] - grade = [str(x).strip().lower() for x in (rules.get("grade") or []) if str(x).strip()] - budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None - enabled = [bool(kw), bool(stack), bool(direction), bool(grade), bool(budget)] - - if not any(enabled): - return False - grade_terms = _grade_terms(grade) - groups = { - "keywords": any(k in lower for k in kw) if kw else True, - "stack": any(s in lower for s in stack) if stack else True, - "direction": any(d in lower for d in direction) if direction else True, - "grade": any(g in lower for g in grade_terms) if grade else True, - "budget": (_amount_in_range(extract_amounts(text), budget) if budget else True), - } - mode = str(rules.get("mode") or "all").lower() - if mode == "any": - # «любое»: совпасть должна хотя бы одна включённая (непустая) группа. - # Пустые группы равны True и не должны участвовать — иначе колонка - # с одним фильтром (например «стек: WPF») ловит вообще всё. - return any(groups[k] for k, on in zip(groups, enabled) if on and k in groups) - # all: каждая включённая группа обязана совпасть - return all(groups[k] for k, on in zip(groups, enabled) if on and k in groups) - - -def score_text(rules: dict, text: str) -> int: - """Число совпавших фильтров (для выбора лучшей ИИ-колонки).""" - if not text: - return 0 - lower = content_text(text).lower() - score = 0 - for group in ("direction", "keywords", "stack", "grade"): - if group == "grade": - for g in _grade_terms(rules.get(group) or []): - if g in lower: - score += 1 - else: - for w in (rules.get(group) or []): - if str(w).lower() in lower: - score += 1 - return score - - -def excluded_terms(rules: dict | None, text: str) -> list[str]: - """Какие слова-исключения колонки есть в тексте. - - Исключения — veto колонки: если в содержании сообщения (без ссылок и - служебных хвостов) встречается любое из них, карточка в колонку не - попадает, даже если все положительные условия совпали. - """ - rules = rules or {} - lower = content_text(text).lower() - out: list[str] = [] - for term in rules.get("exclude") or []: - t = str(term).strip() - if t and t.lower() in lower: - out.append(t) - return out - - -def is_excluded(rules: dict | None, text: str) -> bool: - return bool(excluded_terms(rules, text)) - - -def board_accepts(board_id: str, text: str) -> bool: - """Пропускает ли колонка этот текст. - - Колонка с активными правилами принимает только текст, прошедший её правила - (детерминированно); колонка без правил принимает любой текст — её наполняют - ИИ/ML/пользователь. Нужно как страховка: ИИ или ML не должны класть карточку - в «отфильтрованную» колонку, если текст под правила не подходит. Слова- - исключения работают как veto в любом случае. - """ - row = store.query_one("SELECT rules FROM boards WHERE id = ?", [board_id]) - if not row: - return False - rules = json.loads(row["rules"] or "{}") if row["rules"] else {} - if is_excluded(rules, text): - return False - if not has_active_rules(rules): - return True - return match_text(rules, text) - - -def hits(rules: dict, text: str) -> list[dict]: - """Какие именно критерии фильтра совпали с текстом. - - Возвращает список совпадений по группам фильтра колонки: - [{"label": "Стек", "term": "WPF"}, {"label": "Грейд", "term": "middle", "word": "mid"}, …]. - Нужно, чтобы на карточке показывать «по каким критериям она попала в колонку» - (список совпавших условий фильтра). Ключевое слово ищется по всему тексту - сообщения — включая стек, требования и «будет плюсом», как и наоборот. - """ - rules = rules or {} - lower = content_text(text).lower() - out: list[dict] = [] - for group, label in (("direction", "Направление"), ("keywords", "Слова"), ("stack", "Стек")): - for term in rules.get(group) or []: - t = str(term).strip() - if t and t.lower() in lower: - out.append({"label": label, "term": t}) - for term in rules.get("grade") or []: - for alias in _grade_terms([term]): - if alias in lower: - out.append({"label": "Грейд/уровень", "term": str(term).strip(), "word": alias}) - break - budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None - if budget and _amount_in_range(extract_amounts(text), budget): - out.append({"label": "Бюджет", "term": _budget_label(budget)}) - return out - - -def _budget_label(budget: dict) -> str: - lo, hi = budget.get("from"), budget.get("to") - cur = str(budget.get("cur") or "").upper() - if lo is not None and hi is not None: - return f"от {lo:g} до {hi:g} {cur}".strip() - if hi is not None: - return f"до {hi:g} {cur}".strip() - if lo is not None: - return f"от {lo:g} {cur}".strip() - return "бюджет" - - -def hits_for_board(board_id: str, text: str) -> list[dict]: - """Совпавшие критерии колонки с активными правилами; [] — правил нет.""" - row = store.query_one("SELECT rules FROM boards WHERE id = ?", [board_id]) - if not row: - return [] - rules = json.loads(row["rules"] or "{}") if row["rules"] else {} - if not has_active_rules(rules): - return [] - return hits(rules, text) - - -def has_active_rules(rules: dict | None) -> bool: - """Есть ли в правилах хотя бы одна реально работающая группа фильтров. - - Нужно для ML: колонка с активными правилами раскладывается только самими - правилами (детерминированно), ML её назначать не должен — иначе в колонку - попадает то, что под правила не подходит. - """ - rules = rules or {} - for key in ("direction", "keywords", "stack", "grade"): - if any(str(x).strip() for x in (rules.get(key) or [])): - return True - budget = rules.get("budget") - if isinstance(budget, dict) and ( - budget.get("from") is not None or budget.get("to") is not None - ): - return True - return False - - -def describe(rules: dict) -> str: - """Человекочитаемое описание правил (для обоснования и промпта).""" - rules = rules or {} - parts = [] - direction = rules.get("direction") or [] - stack = rules.get("stack") or [] - kw = rules.get("keywords") or [] - grade = rules.get("grade") or [] - if direction: - parts.append("направление: " + ", ".join(str(x) for x in direction[:6])) - if stack: - parts.append("стек: " + ", ".join(str(x) for x in stack[:8])) - if kw: - parts.append("слова: " + ", ".join(str(x) for x in kw[:8])) - if grade: - parts.append("грейд: " + ", ".join(str(x) for x in grade[:8])) - exc = rules.get("exclude") or [] - if exc: - parts.append("исключено: " + ", ".join(str(x) for x in exc[:8])) - budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None - if budget and (budget.get("from") is not None or budget.get("to") is not None): - lo = budget.get("from") if budget.get("from") is not None else "" - hi = budget.get("to") if budget.get("to") is not None else "" - parts.append(f"бюджет: {lo}–{hi} {budget.get('cur') or ''}".replace('– ', '–').replace(' –', '–')) - if not parts: - return "без правил (решает ИИ/ML)" - mode = "все условия" if str(rules.get("mode") or "all").lower() != "any" else "любое из условий" - return mode + " · " + "; ".join(parts) - - -def route(text: str) -> dict | None: - """Детерминированная маршрутизация по правилам активных колонок. - - Возвращает колонку, если правила однозначно совпали (при нескольких — - ту, где больше совпадений). Предложения ИИ в маршрутизации не участвуют. - """ - rows = store.query( - "SELECT * FROM boards WHERE suggested = FALSE AND rules <> '{}' AND rules <> '' ORDER BY pos" - ) - best: dict | None = None - best_score = 0 - for r in rows: - rules = json.loads(r["rules"] or "{}") - if not match_text(rules, text): - continue - sc = score_text(rules, text) or 1 - if sc > best_score: - best = {"id": r["id"], "name": r["name"], "color": r["color"], "rules": rules} - best_score = sc - return best +"""Детерминированные правила колонок: направление, стек, слова, грейд, бюджет. + +Колонка — это набор опциональных фильтров, задаёт пользователь (или ИИ при +предложении). Если текст входящего сообщения соответствует правилам — карточка +уходит в эту колонку сразу, без ML/ИИ (ML/ИИ подключаются только когда правила +не сработали). Набор фильтров может меняться: пустая группа не участвует. +""" +from __future__ import annotations + +import json +import re + +from ..db import store + +_CUR_SYMBOLS = {"$": "USD", "€": "EUR", "₽": "RUB", "₮": "USDT", "£": "GBP", "¥": "CNY"} +_CUR_WORDS = {"usd": "USD", "eur": "EUR", "rub": "RUB", "usdt": "USDT", "gbp": "GBP", "cny": "CNY", "руб": "RUB", "долл": "USD", "бакс": "USD"} + +# Грейд/уровень: тег из правил расширяется синонимами, чтобы «middle» находил +# и «mid», и «мидл», а «сеньор» находил senior и т.п. +_GRADE_ALIASES = { + "junior": ["junior", "джун", "джуниор"], + "middle": ["middle", "mid", "мидл"], + "senior": ["senior", "сеньор", "сеньйор"], + "lead": ["lead", "тимлид", "тиэмлид", "team lead", "teamlead", "tech lead", "техлид"], + "architect": ["architect", "архитектор"], + "intern": ["intern", "стажёр", "стажер", "trainee"], +} + + +def _grade_terms(tags: list[str]) -> list[str]: + out: list[str] = [] + for t in tags: + key = str(t).strip().lower() + if not key: + continue + if key in _GRADE_ALIASES: + out.extend(_GRADE_ALIASES[key]) + else: + out.append(key) + return out + + +# Перед матчингом правил вырезаем ссылки и markdown-ссылки: иначе фильтр ловит +# слова из трекерных хвостов/служебных строк URL (например, «desktop» в +# utm_medium=member_desktop) и в колонку попадает мусор, не имеющий отношения +# к содержанию сообщения. +_MD_URL_RE = re.compile(r"\[[^\]]*\]\([^)\s]+\)") +_RAW_URL_RE = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+") + + +def content_text(text: str) -> str: + s = str(text or "") + s = _MD_URL_RE.sub(" ", s) + return _RAW_URL_RE.sub(" ", s) + +# ищем: 1) "от 1 200 до 1 500 $", 2) "1 200–1 500 $ / 1 200$", 3) "$1 200–1 500" +# суффикс «к/К» разрешён прямо в числе: «2к», «1.5к$» (множитель в _norm_amount) +_AMT = r"\d[\d\s\u00a0]*(?:[.,]\d+)?[кkКK]?" +_RANGE = rf"({_AMT})\s*(?:[-–—]\s*({_AMT}))?" + + +def _norm_amount(raw: str) -> float | None: + s = raw.replace("\u00a0", " ").replace(" ", "").replace(",", ".") + mult = 1.0 + # «2к»/«2К»/«1.5к» — тысячи; суффикс убираем до float (иначе парс падает) + if s and s[-1].lower() in ("k", "к"): + mult = 1000.0 + s = s[:-1] + try: + v = float(s) * mult + except ValueError: + return None + return v + + +def _cur_from_tail(text: str, m_start: int) -> str | None: + tail = text[m_start : m_start + 12].lower().strip() + for sym, code in _CUR_SYMBOLS.items(): + if tail.startswith(sym): + return code + # слово-валюта сразу после числа (с пробелом) + for word, code in _CUR_WORDS.items(): + if tail.startswith(word): + return code + # валюта перед числом ($/€/₽), например "$1 200" + head = text[max(0, m_start - 3) : m_start].strip() + for sym, code in _CUR_SYMBOLS.items(): + if head.endswith(sym): + return code + return None + + +def extract_amounts(text: str) -> list[dict]: + """Парсер сумм с сохранением смысла: + - диапазон «от A до B» / «A–B» → {'from': A, 'to': B, 'cur': ...}; + - «до B» (только верхняя граница) → {'from': None, 'to': B, 'cur': ...}; + - одна сумма / «от A» без верхней границы → {'from': A, 'to': A, 'cur': ...}. + Валюту ищем сразу после суммы (или перед ней для «$1 200»); суммы без валюты игнорируются. + """ + out: list[dict] = [] + t = text or "" + if not t.strip(): + return out + occupied: list[tuple[int, int]] = [] + + def _free(s: int, e: int) -> bool: + return not any(s < oe and e > os for os, oe in occupied) + + def _add(a, b, cur: str | None, s: int, e: int) -> None: + if cur and (a is not None or b is not None) and _free(s, e): + out.append({"from": a, "to": b, "cur": cur}) + occupied.append((s, e)) + + # 1) словесный диапазон «от A до B» (+ валюта после B) + for m in re.finditer(r"(?i)\bот\s+(" + _AMT + r")\s+до\s+(" + _AMT + r")", t): + a, b = _norm_amount(m.group(1)), _norm_amount(m.group(2)) + cur = _cur_from_tail(t, m.end(2)) + if a is not None and b is not None and cur: + _add(a, b, cur, m.start(1), m.end(2)) + # 2) «до B» (без пары «от … до») — верхняя граница + for m in re.finditer(r"(?i)\bдо\s+(" + _AMT + r")", t): + b = _norm_amount(m.group(1)) + cur = _cur_from_tail(t, m.end(1)) + if b is not None and cur: + _add(None, b, cur, m.start(1), m.end(1)) + # 3) «от A» без верхней границы — считаем одной суммой + for m in re.finditer(r"(?i)\bот\s+(" + _AMT + r")", t): + a = _norm_amount(m.group(1)) + cur = _cur_from_tail(t, m.end(1)) + if a is not None and cur: + _add(a, a, cur, m.start(1), m.end(1)) + # 4) числовые диапазоны «A–B» / «A - B» + for m in re.finditer(r"(?i)(" + _AMT + r")\s*(?:[-–—]|\s+до\s+)\s*(" + _AMT + r")", t): + a, b = _norm_amount(m.group(1)), _norm_amount(m.group(2)) + cur = _cur_from_tail(t, m.end(2)) or _cur_from_tail(t, m.end(1)) + if a is not None and b is not None and cur: + _add(a, b, cur, m.start(1), m.end(2)) + # 5) одиночные суммы с валютой (не вошедшие в конструкции выше) + for m in re.finditer(_AMT, t): + a = _norm_amount(m.group(0)) + cur = _cur_from_tail(t, m.end()) + if a is not None and cur: + _add(a, a, cur, m.start(), m.end()) + return out + + +def _amount_in_range(amounts: list[dict], budget: dict) -> bool: + from ..services.rates import convert_amount + + try: + lo = float(budget.get("from")) if budget.get("from") is not None else None + hi = float(budget.get("to")) if budget.get("to") is not None else None + except (TypeError, ValueError): + return False + if lo is None and hi is None: + return True + target_cur = str(budget.get("cur") or "USD").upper() + for amt in amounts: + raw_val = amt["from"] if amt["from"] is not None else amt.get("to") + if raw_val is None: + continue + try: + val = raw_val if amt["cur"] == target_cur else convert_amount(raw_val, amt["cur"], target_cur) + except Exception: # noqa: BLE001 + val = None + if val is None: + continue + if lo is not None and val < lo: + continue + if hi is not None and val > hi: + continue + return True + return False + + +def match_text(rules: dict, text: str) -> bool: + """Соответствует ли текст правилам колонки. Возвращает bool. + + Колонка — это набор опциональных фильтров: направление, стек, ключевые + слова, грейд/уровень, бюджет. Пустая группа не участвует; режим «все» — + должны совпасть все включённые группы, «любое» — хотя бы одна. + """ + rules = rules or {} + lower = content_text(text).lower() + direction = [str(x).strip().lower() for x in (rules.get("direction") or []) if str(x).strip()] + kw = [str(x).strip().lower() for x in (rules.get("keywords") or []) if str(x).strip()] + stack = [str(x).strip().lower() for x in (rules.get("stack") or []) if str(x).strip()] + grade = [str(x).strip().lower() for x in (rules.get("grade") or []) if str(x).strip()] + budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None + enabled = [bool(kw), bool(stack), bool(direction), bool(grade), bool(budget)] + + if not any(enabled): + return False + grade_terms = _grade_terms(grade) + groups = { + "keywords": any(k in lower for k in kw) if kw else True, + "stack": any(s in lower for s in stack) if stack else True, + "direction": any(d in lower for d in direction) if direction else True, + "grade": any(g in lower for g in grade_terms) if grade else True, + "budget": (_amount_in_range(extract_amounts(text), budget) if budget else True), + } + mode = str(rules.get("mode") or "all").lower() + if mode == "any": + # «любое»: совпасть должна хотя бы одна включённая (непустая) группа. + # Пустые группы равны True и не должны участвовать — иначе колонка + # с одним фильтром (например «стек: WPF») ловит вообще всё. + return any(groups[k] for k, on in zip(groups, enabled) if on and k in groups) + # all: каждая включённая группа обязана совпасть + return all(groups[k] for k, on in zip(groups, enabled) if on and k in groups) + + +def score_text(rules: dict, text: str) -> int: + """Число совпавших фильтров (для выбора лучшей ИИ-колонки).""" + if not text: + return 0 + lower = content_text(text).lower() + score = 0 + for group in ("direction", "keywords", "stack", "grade"): + if group == "grade": + for g in _grade_terms(rules.get(group) or []): + if g in lower: + score += 1 + else: + for w in (rules.get(group) or []): + if str(w).lower() in lower: + score += 1 + return score + + +def excluded_terms(rules: dict | None, text: str) -> list[str]: + """Какие слова-исключения колонки есть в тексте. + + Исключения — veto колонки: если в содержании сообщения (без ссылок и + служебных хвостов) встречается любое из них, карточка в колонку не + попадает, даже если все положительные условия совпали. + """ + rules = rules or {} + lower = content_text(text).lower() + out: list[str] = [] + for term in rules.get("exclude") or []: + t = str(term).strip() + if t and t.lower() in lower: + out.append(t) + return out + + +def is_excluded(rules: dict | None, text: str) -> bool: + return bool(excluded_terms(rules, text)) + + +def board_accepts(board_id: str, text: str) -> bool: + """Пропускает ли колонка этот текст. + + Колонка с активными правилами принимает только текст, прошедший её правила + (детерминированно); колонка без правил принимает любой текст — её наполняют + ИИ/ML/пользователь. Нужно как страховка: ИИ или ML не должны класть карточку + в «отфильтрованную» колонку, если текст под правила не подходит. Слова- + исключения работают как veto в любом случае. + """ + row = store.query_one("SELECT rules FROM boards WHERE id = ?", [board_id]) + if not row: + return False + rules = json.loads(row["rules"] or "{}") if row["rules"] else {} + if is_excluded(rules, text): + return False + if not has_active_rules(rules): + return True + return match_text(rules, text) + + +def hits(rules: dict, text: str) -> list[dict]: + """Какие именно критерии фильтра совпали с текстом. + + Возвращает список совпадений по группам фильтра колонки: + [{"label": "Стек", "term": "WPF"}, {"label": "Грейд", "term": "middle", "word": "mid"}, …]. + Нужно, чтобы на карточке показывать «по каким критериям она попала в колонку» + (список совпавших условий фильтра). Ключевое слово ищется по всему тексту + сообщения — включая стек, требования и «будет плюсом», как и наоборот. + """ + rules = rules or {} + lower = content_text(text).lower() + out: list[dict] = [] + for group, label in (("direction", "Направление"), ("keywords", "Слова"), ("stack", "Стек")): + for term in rules.get(group) or []: + t = str(term).strip() + if t and t.lower() in lower: + out.append({"label": label, "term": t}) + for term in rules.get("grade") or []: + for alias in _grade_terms([term]): + if alias in lower: + out.append({"label": "Грейд/уровень", "term": str(term).strip(), "word": alias}) + break + budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None + if budget and _amount_in_range(extract_amounts(text), budget): + out.append({"label": "Бюджет", "term": _budget_label(budget)}) + return out + + +def _budget_label(budget: dict) -> str: + lo, hi = budget.get("from"), budget.get("to") + cur = str(budget.get("cur") or "").upper() + if lo is not None and hi is not None: + return f"от {lo:g} до {hi:g} {cur}".strip() + if hi is not None: + return f"до {hi:g} {cur}".strip() + if lo is not None: + return f"от {lo:g} {cur}".strip() + return "бюджет" + + +def hits_for_board(board_id: str, text: str) -> list[dict]: + """Совпавшие критерии колонки с активными правилами; [] — правил нет.""" + row = store.query_one("SELECT rules FROM boards WHERE id = ?", [board_id]) + if not row: + return [] + rules = json.loads(row["rules"] or "{}") if row["rules"] else {} + if not has_active_rules(rules): + return [] + return hits(rules, text) + + +def has_active_rules(rules: dict | None) -> bool: + """Есть ли в правилах хотя бы одна реально работающая группа фильтров. + + Нужно для ML: колонка с активными правилами раскладывается только самими + правилами (детерминированно), ML её назначать не должен — иначе в колонку + попадает то, что под правила не подходит. + """ + rules = rules or {} + for key in ("direction", "keywords", "stack", "grade"): + if any(str(x).strip() for x in (rules.get(key) or [])): + return True + budget = rules.get("budget") + if isinstance(budget, dict) and ( + budget.get("from") is not None or budget.get("to") is not None + ): + return True + return False + + +def describe(rules: dict) -> str: + """Человекочитаемое описание правил (для обоснования и промпта).""" + rules = rules or {} + parts = [] + direction = rules.get("direction") or [] + stack = rules.get("stack") or [] + kw = rules.get("keywords") or [] + grade = rules.get("grade") or [] + if direction: + parts.append("направление: " + ", ".join(str(x) for x in direction[:6])) + if stack: + parts.append("стек: " + ", ".join(str(x) for x in stack[:8])) + if kw: + parts.append("слова: " + ", ".join(str(x) for x in kw[:8])) + if grade: + parts.append("грейд: " + ", ".join(str(x) for x in grade[:8])) + exc = rules.get("exclude") or [] + if exc: + parts.append("исключено: " + ", ".join(str(x) for x in exc[:8])) + budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None + if budget and (budget.get("from") is not None or budget.get("to") is not None): + lo = budget.get("from") if budget.get("from") is not None else "" + hi = budget.get("to") if budget.get("to") is not None else "" + parts.append(f"бюджет: {lo}–{hi} {budget.get('cur') or ''}".replace('– ', '–').replace(' –', '–')) + if not parts: + return "без правил (решает ИИ/ML)" + mode = "все условия" if str(rules.get("mode") or "all").lower() != "any" else "любое из условий" + return mode + " · " + "; ".join(parts) + + +def route(text: str) -> dict | None: + """Детерминированная маршрутизация по правилам активных колонок. + + Возвращает колонку, если правила однозначно совпали (при нескольких — + ту, где больше совпадений). Предложения ИИ в маршрутизации не участвуют. + """ + rows = store.query( + "SELECT * FROM boards WHERE suggested = FALSE AND rules <> '{}' AND rules <> '' ORDER BY pos" + ) + best: dict | None = None + best_score = 0 + for r in rows: + rules = json.loads(r["rules"] or "{}") + if not match_text(rules, text): + continue + sc = score_text(rules, text) or 1 + if sc > best_score: + best = {"id": r["id"], "name": r["name"], "color": r["color"], "rules": rules} + best_score = sc + return best diff --git a/archive/leadradar-legacy/backend/app/services/suggest.py b/archive/leadradar-legacy/backend/app/services/suggest.py index b32a1d2..644563c 100644 --- a/archive/leadradar-legacy/backend/app/services/suggest.py +++ b/archive/leadradar-legacy/backend/app/services/suggest.py @@ -1,247 +1,247 @@ -"""ИИ-предложения колонок по анализу «Неразобранного». - -Колонка — не «на один скилл», а смысловая тема: направление + набор стека и -ключевых слов (+ бюджет при повторяемости). ИИ группирует карточки и создаёт -КОЛОНКИ-ПРЕДЛОЖЕНИЯ (suggested=TRUE) с обоснованием (note), по каким -критериям собрана. Пользователь открывает предложение, смотрит карточки и -решает: принять (можно переименовать/поправить правила) или удалить. -""" -from __future__ import annotations - -import logging -import time - -from ..db import store -from ..sse import broker -from . import ai as ai_svc - -log = logging.getLogger("leadradar.suggest") - -SUGGEST_PROMPT = """Ты — аналитик входящих заявок. Перед тобой пронумерованные сообщения (номера 1..N), которые не подошли ни под одну существующую колонку. Сгруппируй их в 2–4 ОСМЫСЛЕННЫЕ тематические колонки. -Сфера/что считается заявкой: -{domain} -Колонка — это не отдельный предмет и не каждое слово по отдельности, а направление с набором родственных признаков (тип работ/услуг, предметы, технологии, материалы). Пример: «Telegram-боты» — направление: чат-боты/автоматизация; предметы/технологии: Python, aiogram; слова: бот, telegram, автоответчик. -Правила: -- группируй повторяющиеся темы: в каждую колонку бери минимум 2 сообщения; -- не предлагай колонки, похожие на уже существующие; -- 2–4 колонки максимум; если ничего общего нет — верни пустой список. -Для каждой колонки верни: -- name — короткое название (2–4 слова); -- description — короткое описание колонки (1–2 предложения): что за заявки и для кого; -- direction — направление/тип задач (2–5 слов или фраз); -- stack — что фигурирует в заявках: предметы, услуги, технологии, материалы (1–6); -- grade — уровень/грейд, если ярко выражен (например: ["middle"]), иначе пустой список; -- keywords — 3–8 ключевых слов/фраз, по которым узнаётся такая заявка; -- messages — НОМЕРА сообщений из списка, которые относятся к этой колонке (минимум 2, максимум 12); -- reason — обоснование в 1 предложение: что за тема, сколько карточек и что в них общего. -Верни строго JSON: -{ "columns": [ { "name": "…", "description": "…", "direction": ["…"], "stack": ["…"], "grade": ["…"], "keywords": ["…"], "messages": [1, 5], "reason": "…" } ] }""" - -KEYWORDS_PROMPT = """Ты — аналитик входящих сообщений. Ниже — реальные сообщения, которые уже признаны заявками/лидами (и часть — обычный флуд каналов). -Сфера/что считается заявкой: -{domain} - -Выдели общие слова-МАРКЕРЫ, по которым сообщение можно отнести к заявкам этой сферы (а не к флуду): типовые предметы/услуги/работы, глаголы-действия («куплю», «нужен», «сниму», «отремонтировать»), статусные слова («срочно», «под ключ», «бюджет»). -Верни строго JSON с плоским списком от 8 до 40 ключевых слов/фраз (слова в нижнем регистре, без знаков препинания): -{ "keywords": ["…", "…"] }""" - -MIN_INBOX = 6 # минимум карточек в «Неразобранном» для анализа -MIN_INBOX_GROUP = 2 # минимум карточек в одной ИИ-колонке -MAX_TEXT = 12 # сколько сообщений берём в анализ (ИИ обрывает длинный JSON) -COOLDOWN_S = 20 * 60 # как часто автоматически переспрашиваем -KEY = "lastSuggestAt" - - -def _similar_exists(name: str) -> bool: - low = name.casefold() - rows = store.query("SELECT id, name FROM boards") - for r in rows: - n = str(r["name"]).casefold() - if low == n or low in n or n in low: - return True - return False - - -def _rules_for(item: dict) -> dict: - """Правила колонки из ИИ-ответа: направление/слова/стек/грейд (любое из условий).""" - return { - "mode": "any", - "direction": [str(x).strip().lower() for x in (item.get("direction") or []) if str(x).strip()][:6], - "keywords": [str(x).strip().lower() for x in (item.get("keywords") or []) if str(x).strip()][:8], - "stack": [str(x).strip().lower() for x in (item.get("stack") or []) if str(x).strip()][:8], - "grade": [str(x).strip().lower() for x in (item.get("grade") or []) if str(x).strip()][:6], - } - - -async def suggest_from_inbox(force: bool = False) -> dict: - """Анализ «Неразобранного» и создание колонок-предложений с обоснованием. - - ИИ группирует пронумерованные сообщения и для каждой колонки возвращает - номера сообщений (messages). Карточки раскладываются именно по этим - номерам, а не строгим match_text по сгенерированным правилам — иначе - колонка-предложение часто остаётся пустой (правила ИИ приблизительные). - """ - # Предложения колонок — это вызов ИИ: при выключенном ИИ (aiEnabled=false, - # режим «только фильтр + ML») автоцикл не должен дёргать провайдера. - if not force and not store.get_setting("aiEnabled"): - return {"ok": False, "reason": "ИИ выключен — предложения колонок недоступны"} - if not force: - # не плодим предложения, пока пользователь не разобрался со старыми - pending = store.scalar("SELECT count(*) FROM boards WHERE suggested = TRUE") - if pending: - return {"ok": False, "reason": f"сначала решите судьбу {pending} предложенных колонок"} - last = int(store.get_setting(KEY) or 0) - if time.time() - last < COOLDOWN_S: - return {"ok": False, "reason": "недавно предлагали — подождите", "cooldown": True} - inbox = store.query( - "SELECT id, source_msg FROM leads WHERE col = 'inbox' AND source_msg <> '' " - "ORDER BY received_at DESC LIMIT ?", - [MAX_TEXT], - ) - if len(inbox) < MIN_INBOX: - return {"ok": False, "reason": f"мало карточек в «Неразобранном» (нужно от {MIN_INBOX})"} - rows = [(str(r["id"]), str(r["source_msg"])) for r in inbox] - texts = [t[:180] for _, t in rows] - existing = [str(r["name"]) for r in store.query("SELECT name FROM boards WHERE suggested = FALSE ORDER BY pos")] - user = ( - f"Существующие колонки: {', '.join(existing) if existing else 'нет'}\n\n" - "Сообщения:\n" + "\n".join(f"{i + 1}. {t}" for i, t in enumerate(texts, start=1)) - ) - try: - out = await ai_svc.chat_json(ai_svc.fill_prompt(SUGGEST_PROMPT), user, max_retries=2, max_tokens=16000) - except Exception as exc: # noqa: BLE001 - log.warning("suggest failed: %s", exc) - return {"ok": False, "reason": f"ИИ недоступен: {exc}"} - cols = out.get("columns") if isinstance(out, dict) else None - if not isinstance(cols, list) or not cols: - log.info("suggest: ИИ не предложил колонок") - return {"ok": False, "reason": "ИИ не предложил колонок"} - - # диагностика: что именно предложил ИИ (имена + число карточек) - log.info( - "suggest: ИИ предложил %d кандидатов: %s", - len(cols), - "; ".join( - f"{str(c.get('name'))[:40]}(msg={c.get('messages')})" for c in cols if isinstance(c, dict) - )[:300], - ) - - # номера, на которые уже «потратились» предыдущие колонки этого прогона - used_msg: set[int] = set() - created = 0 - for item in cols[:4]: - if not isinstance(item, dict): - continue - name = str(item.get("name") or "").strip() - if len(name) < 2 or len(name) > 40: - continue - if _similar_exists(name): - continue - nums = [int(x) for x in (item.get("messages") or []) if str(x).isdigit()] - nums = [n for n in nums if 1 <= n <= len(rows) and n not in used_msg] - if len(nums) < MIN_INBOX_GROUP: - continue # колонка без явных карточек не создаётся - used_msg.update(nums) - rules = _rules_for(item) - note = _make_note(item, texts, rules, nums) - description = str(item.get("description") or "").strip()[:300] - board = _store_suggested(name, rules, note, description) - if not board: - continue - assigned = _assign_ids(rows, nums, board["id"]) - if not assigned: - # ничего не удалось положить — пустое предложение не нужно - _rollback_suggested(board["id"]) - continue - created += 1 - - if not created: - return {"ok": False, "reason": "похожие колонки уже есть или нечего сгруппировать"} - store.set_setting(KEY, int(time.time())) - await broker.publish("boards_changed", {}) - await broker.publish_toast(f"ИИ предложил колонок: {created} — откройте и решите", "sparkles") - return {"ok": True, "created": created} - - -async def suggest_domain_keywords() -> dict: - """«Предложить ключи ИИ»: по вашим карточкам выделяет общие слова-маркеры сферы. - - Возвращает кандидатов — пользователь смотрит, правит и сохраняет в настройках - «Сфера и ключи» (общие ключи) или использует для правил колонок. - """ - rows = store.query( - "SELECT source_msg FROM leads WHERE col <> 'trash' AND col <> 'archive' AND source_msg <> '' " - "ORDER BY received_at DESC LIMIT 40" - ) - texts = [str(r["source_msg"])[:350] for r in rows] - if len(texts) < 3: - return {"ok": False, "reason": "мало карточек — сначала накопите заявки (нужно хотя бы 3)"} - user = "\n".join(f"{i + 1}. {t}" for i, t in enumerate(texts)) - try: - out = await ai_svc.chat_json(ai_svc.fill_prompt(KEYWORDS_PROMPT), user, max_retries=2) - except Exception as exc: # noqa: BLE001 - log.warning("suggest keywords failed: %s", exc) - return {"ok": False, "reason": f"ИИ недоступен: {exc}"} - kws = out.get("keywords") if isinstance(out, dict) else None - if not isinstance(kws, list) or not kws: - return {"ok": False, "reason": "ИИ не смог выделить ключи — попробуйте ещё раз"} - clean: list[str] = [] - for k in kws: - s = str(k).strip().casefold().strip(".,;:«»\"'()!#") - if s and len(s) <= 40 and s not in clean: - clean.append(s) - return {"ok": True, "keywords": clean[:60]} - - -def _make_note(item: dict, texts: list[str], rules: dict, nums: list[int]) -> str: - reason = str(item.get("reason") or "").strip() - direction = " · ".join(str(x) for x in (item.get("direction") or [])[:3]) - base = f"Обоснование ИИ: {reason}" if reason else ( - f"Обоснование ИИ: направление — {direction}" if direction else - "Обоснование ИИ: повторяющиеся заявки одной темы" - ) - # сколько карточек из выборки реально попадёт в колонку (по номерам ИИ) - matched = sum(1 for n in nums if 1 <= n <= len(texts)) - if matched: - base += f" · карточек в колонке: {matched}" - return base[:400] - - -def _store_suggested(name: str, rules: dict, note: str, description: str = "") -> dict | None: - from . import leads as leads_svc - - keywords = rules.get("keywords") or [] - board = leads_svc.create_board( - name, suggested=True, keywords=keywords, rules=rules, note=note, description=description - ) - return leads_svc.board_by_id(board["id"]) - - -def _assign_ids(rows: list[tuple[str, str]], nums: list[int], board_id: str) -> int: - """Раскладывает в колонку-предложение карточки по номерам, которые назвал ИИ. - - rows — список (id, source_msg) в том же порядке, в каком сообщения уходили - в промпт (1..N). Возвращает число реально перенесённых карточек. - """ - assigned = 0 - for n in nums: - if not (1 <= n <= len(rows)): - continue - lead_id, _ = rows[n - 1] - still = store.scalar("SELECT 1 FROM leads WHERE id = ? AND col = 'inbox'", [lead_id]) - if not still: - continue # карточка уже разобрана другим предложением/пользователем - store.execute( - "UPDATE leads SET col = ?, is_new = TRUE, prev_col = 'inbox' WHERE id = ? AND col = 'inbox'", - [board_id, lead_id], - ) - assigned += 1 - return assigned - - -def _rollback_suggested(board_id: str) -> None: - """Удаляет пустую колонку-предложение (в неё ничего не попало).""" - from . import leads as leads_svc - - store.execute("UPDATE leads SET col = 'inbox' WHERE col = ?", [board_id]) - store.execute("DELETE FROM boards WHERE id = ?", [board_id]) +"""ИИ-предложения колонок по анализу «Неразобранного». + +Колонка — не «на один скилл», а смысловая тема: направление + набор стека и +ключевых слов (+ бюджет при повторяемости). ИИ группирует карточки и создаёт +КОЛОНКИ-ПРЕДЛОЖЕНИЯ (suggested=TRUE) с обоснованием (note), по каким +критериям собрана. Пользователь открывает предложение, смотрит карточки и +решает: принять (можно переименовать/поправить правила) или удалить. +""" +from __future__ import annotations + +import logging +import time + +from ..db import store +from ..sse import broker +from . import ai as ai_svc + +log = logging.getLogger("leadradar.suggest") + +SUGGEST_PROMPT = """Ты — аналитик входящих заявок. Перед тобой пронумерованные сообщения (номера 1..N), которые не подошли ни под одну существующую колонку. Сгруппируй их в 2–4 ОСМЫСЛЕННЫЕ тематические колонки. +Сфера/что считается заявкой: +{domain} +Колонка — это не отдельный предмет и не каждое слово по отдельности, а направление с набором родственных признаков (тип работ/услуг, предметы, технологии, материалы). Пример: «Telegram-боты» — направление: чат-боты/автоматизация; предметы/технологии: Python, aiogram; слова: бот, telegram, автоответчик. +Правила: +- группируй повторяющиеся темы: в каждую колонку бери минимум 2 сообщения; +- не предлагай колонки, похожие на уже существующие; +- 2–4 колонки максимум; если ничего общего нет — верни пустой список. +Для каждой колонки верни: +- name — короткое название (2–4 слова); +- description — короткое описание колонки (1–2 предложения): что за заявки и для кого; +- direction — направление/тип задач (2–5 слов или фраз); +- stack — что фигурирует в заявках: предметы, услуги, технологии, материалы (1–6); +- grade — уровень/грейд, если ярко выражен (например: ["middle"]), иначе пустой список; +- keywords — 3–8 ключевых слов/фраз, по которым узнаётся такая заявка; +- messages — НОМЕРА сообщений из списка, которые относятся к этой колонке (минимум 2, максимум 12); +- reason — обоснование в 1 предложение: что за тема, сколько карточек и что в них общего. +Верни строго JSON: +{ "columns": [ { "name": "…", "description": "…", "direction": ["…"], "stack": ["…"], "grade": ["…"], "keywords": ["…"], "messages": [1, 5], "reason": "…" } ] }""" + +KEYWORDS_PROMPT = """Ты — аналитик входящих сообщений. Ниже — реальные сообщения, которые уже признаны заявками/лидами (и часть — обычный флуд каналов). +Сфера/что считается заявкой: +{domain} + +Выдели общие слова-МАРКЕРЫ, по которым сообщение можно отнести к заявкам этой сферы (а не к флуду): типовые предметы/услуги/работы, глаголы-действия («куплю», «нужен», «сниму», «отремонтировать»), статусные слова («срочно», «под ключ», «бюджет»). +Верни строго JSON с плоским списком от 8 до 40 ключевых слов/фраз (слова в нижнем регистре, без знаков препинания): +{ "keywords": ["…", "…"] }""" + +MIN_INBOX = 6 # минимум карточек в «Неразобранном» для анализа +MIN_INBOX_GROUP = 2 # минимум карточек в одной ИИ-колонке +MAX_TEXT = 12 # сколько сообщений берём в анализ (ИИ обрывает длинный JSON) +COOLDOWN_S = 20 * 60 # как часто автоматически переспрашиваем +KEY = "lastSuggestAt" + + +def _similar_exists(name: str) -> bool: + low = name.casefold() + rows = store.query("SELECT id, name FROM boards") + for r in rows: + n = str(r["name"]).casefold() + if low == n or low in n or n in low: + return True + return False + + +def _rules_for(item: dict) -> dict: + """Правила колонки из ИИ-ответа: направление/слова/стек/грейд (любое из условий).""" + return { + "mode": "any", + "direction": [str(x).strip().lower() for x in (item.get("direction") or []) if str(x).strip()][:6], + "keywords": [str(x).strip().lower() for x in (item.get("keywords") or []) if str(x).strip()][:8], + "stack": [str(x).strip().lower() for x in (item.get("stack") or []) if str(x).strip()][:8], + "grade": [str(x).strip().lower() for x in (item.get("grade") or []) if str(x).strip()][:6], + } + + +async def suggest_from_inbox(force: bool = False) -> dict: + """Анализ «Неразобранного» и создание колонок-предложений с обоснованием. + + ИИ группирует пронумерованные сообщения и для каждой колонки возвращает + номера сообщений (messages). Карточки раскладываются именно по этим + номерам, а не строгим match_text по сгенерированным правилам — иначе + колонка-предложение часто остаётся пустой (правила ИИ приблизительные). + """ + # Предложения колонок — это вызов ИИ: при выключенном ИИ (aiEnabled=false, + # режим «только фильтр + ML») автоцикл не должен дёргать провайдера. + if not force and not store.get_setting("aiEnabled"): + return {"ok": False, "reason": "ИИ выключен — предложения колонок недоступны"} + if not force: + # не плодим предложения, пока пользователь не разобрался со старыми + pending = store.scalar("SELECT count(*) FROM boards WHERE suggested = TRUE") + if pending: + return {"ok": False, "reason": f"сначала решите судьбу {pending} предложенных колонок"} + last = int(store.get_setting(KEY) or 0) + if time.time() - last < COOLDOWN_S: + return {"ok": False, "reason": "недавно предлагали — подождите", "cooldown": True} + inbox = store.query( + "SELECT id, source_msg FROM leads WHERE col = 'inbox' AND source_msg <> '' " + "ORDER BY received_at DESC LIMIT ?", + [MAX_TEXT], + ) + if len(inbox) < MIN_INBOX: + return {"ok": False, "reason": f"мало карточек в «Неразобранном» (нужно от {MIN_INBOX})"} + rows = [(str(r["id"]), str(r["source_msg"])) for r in inbox] + texts = [t[:180] for _, t in rows] + existing = [str(r["name"]) for r in store.query("SELECT name FROM boards WHERE suggested = FALSE ORDER BY pos")] + user = ( + f"Существующие колонки: {', '.join(existing) if existing else 'нет'}\n\n" + "Сообщения:\n" + "\n".join(f"{i + 1}. {t}" for i, t in enumerate(texts, start=1)) + ) + try: + out = await ai_svc.chat_json(ai_svc.fill_prompt(SUGGEST_PROMPT), user, max_retries=2, max_tokens=16000) + except Exception as exc: # noqa: BLE001 + log.warning("suggest failed: %s", exc) + return {"ok": False, "reason": f"ИИ недоступен: {exc}"} + cols = out.get("columns") if isinstance(out, dict) else None + if not isinstance(cols, list) or not cols: + log.info("suggest: ИИ не предложил колонок") + return {"ok": False, "reason": "ИИ не предложил колонок"} + + # диагностика: что именно предложил ИИ (имена + число карточек) + log.info( + "suggest: ИИ предложил %d кандидатов: %s", + len(cols), + "; ".join( + f"{str(c.get('name'))[:40]}(msg={c.get('messages')})" for c in cols if isinstance(c, dict) + )[:300], + ) + + # номера, на которые уже «потратились» предыдущие колонки этого прогона + used_msg: set[int] = set() + created = 0 + for item in cols[:4]: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + if len(name) < 2 or len(name) > 40: + continue + if _similar_exists(name): + continue + nums = [int(x) for x in (item.get("messages") or []) if str(x).isdigit()] + nums = [n for n in nums if 1 <= n <= len(rows) and n not in used_msg] + if len(nums) < MIN_INBOX_GROUP: + continue # колонка без явных карточек не создаётся + used_msg.update(nums) + rules = _rules_for(item) + note = _make_note(item, texts, rules, nums) + description = str(item.get("description") or "").strip()[:300] + board = _store_suggested(name, rules, note, description) + if not board: + continue + assigned = _assign_ids(rows, nums, board["id"]) + if not assigned: + # ничего не удалось положить — пустое предложение не нужно + _rollback_suggested(board["id"]) + continue + created += 1 + + if not created: + return {"ok": False, "reason": "похожие колонки уже есть или нечего сгруппировать"} + store.set_setting(KEY, int(time.time())) + await broker.publish("boards_changed", {}) + await broker.publish_toast(f"ИИ предложил колонок: {created} — откройте и решите", "sparkles") + return {"ok": True, "created": created} + + +async def suggest_domain_keywords() -> dict: + """«Предложить ключи ИИ»: по вашим карточкам выделяет общие слова-маркеры сферы. + + Возвращает кандидатов — пользователь смотрит, правит и сохраняет в настройках + «Сфера и ключи» (общие ключи) или использует для правил колонок. + """ + rows = store.query( + "SELECT source_msg FROM leads WHERE col <> 'trash' AND col <> 'archive' AND source_msg <> '' " + "ORDER BY received_at DESC LIMIT 40" + ) + texts = [str(r["source_msg"])[:350] for r in rows] + if len(texts) < 3: + return {"ok": False, "reason": "мало карточек — сначала накопите заявки (нужно хотя бы 3)"} + user = "\n".join(f"{i + 1}. {t}" for i, t in enumerate(texts)) + try: + out = await ai_svc.chat_json(ai_svc.fill_prompt(KEYWORDS_PROMPT), user, max_retries=2) + except Exception as exc: # noqa: BLE001 + log.warning("suggest keywords failed: %s", exc) + return {"ok": False, "reason": f"ИИ недоступен: {exc}"} + kws = out.get("keywords") if isinstance(out, dict) else None + if not isinstance(kws, list) or not kws: + return {"ok": False, "reason": "ИИ не смог выделить ключи — попробуйте ещё раз"} + clean: list[str] = [] + for k in kws: + s = str(k).strip().casefold().strip(".,;:«»\"'()!#") + if s and len(s) <= 40 and s not in clean: + clean.append(s) + return {"ok": True, "keywords": clean[:60]} + + +def _make_note(item: dict, texts: list[str], rules: dict, nums: list[int]) -> str: + reason = str(item.get("reason") or "").strip() + direction = " · ".join(str(x) for x in (item.get("direction") or [])[:3]) + base = f"Обоснование ИИ: {reason}" if reason else ( + f"Обоснование ИИ: направление — {direction}" if direction else + "Обоснование ИИ: повторяющиеся заявки одной темы" + ) + # сколько карточек из выборки реально попадёт в колонку (по номерам ИИ) + matched = sum(1 for n in nums if 1 <= n <= len(texts)) + if matched: + base += f" · карточек в колонке: {matched}" + return base[:400] + + +def _store_suggested(name: str, rules: dict, note: str, description: str = "") -> dict | None: + from . import leads as leads_svc + + keywords = rules.get("keywords") or [] + board = leads_svc.create_board( + name, suggested=True, keywords=keywords, rules=rules, note=note, description=description + ) + return leads_svc.board_by_id(board["id"]) + + +def _assign_ids(rows: list[tuple[str, str]], nums: list[int], board_id: str) -> int: + """Раскладывает в колонку-предложение карточки по номерам, которые назвал ИИ. + + rows — список (id, source_msg) в том же порядке, в каком сообщения уходили + в промпт (1..N). Возвращает число реально перенесённых карточек. + """ + assigned = 0 + for n in nums: + if not (1 <= n <= len(rows)): + continue + lead_id, _ = rows[n - 1] + still = store.scalar("SELECT 1 FROM leads WHERE id = ? AND col = 'inbox'", [lead_id]) + if not still: + continue # карточка уже разобрана другим предложением/пользователем + store.execute( + "UPDATE leads SET col = ?, is_new = TRUE, prev_col = 'inbox' WHERE id = ? AND col = 'inbox'", + [board_id, lead_id], + ) + assigned += 1 + return assigned + + +def _rollback_suggested(board_id: str) -> None: + """Удаляет пустую колонку-предложение (в неё ничего не попало).""" + from . import leads as leads_svc + + store.execute("UPDATE leads SET col = 'inbox' WHERE col = ?", [board_id]) + store.execute("DELETE FROM boards WHERE id = ?", [board_id]) diff --git a/archive/leadradar-legacy/backend/app/services/telegram.py b/archive/leadradar-legacy/backend/app/services/telegram.py index b6b8844..fa0d7c7 100644 --- a/archive/leadradar-legacy/backend/app/services/telegram.py +++ b/archive/leadradar-legacy/backend/app/services/telegram.py @@ -1,883 +1,883 @@ -"""Telegram-интеграция (п.4.2, 4.3 ТЗ). - -api_id/api_hash берутся из настроек (введены в UI, НЕ из env). Сессия -Telethon сохраняется в файловой системе — повторная авторизация не нужна. -Вход: по телефону (+2FA) или по QR-ссылке. Сообщения из каналов с -включённым мониторингом кладутся в очередь pipeline, откуда их разбирает -фоновый воркер (стоп-фразы → ML → ИИ). В БД оседает только прошедшее -фильтры; исходное сообщение карточки хранит msg_id для «открыть исходник». -""" -from __future__ import annotations - -import asyncio -import logging -import math -import random -import threading -import time - -from telethon import TelegramClient, events, utils -from telethon.errors import SessionPasswordNeededError, PhoneCodeInvalidError, PhoneCodeExpiredError -from telethon.errors.rpcerrorlist import FloodWaitError -from telethon.tl import functions - -from .. import config -from ..constants import DIALOG_HUES -from ..crypto import decrypt_text -from ..db import store -from ..sse import broker -from . import ban_guard -from . import pipeline - -log = logging.getLogger("leadradar.tg") - - -BACKFILL_PER_MESSAGE = (1.5, 3.0) # секунды, чтобы не попасть под бан -BACKFILL_PER_DIALOG = (3.0, 6.0) - - -def _keys() -> dict: - raw = store.get_setting("tgKeys") or {} - api_hash = str(raw.get("apiHash", "")) - return { - "apiId": str(raw.get("apiId", "")).strip(), - "apiHash": (decrypt_text(api_hash) if api_hash.startswith("enc:") else api_hash), - } - - -# Главный event loop приложения: на нём живут Telethon-клиент и фоновые -# задачи. Синхронные роутеры FastAPI исполняются в threadpool (без running -# loop), поэтому корутины нужно планировать на этот loop через -# call_soon_threadsafe — Telethon не переносит клиент между циклами. -_MAIN_LOOP: asyncio.AbstractEventLoop | None = None - - -def register_main_loop(loop: asyncio.AbstractEventLoop) -> None: - global _MAIN_LOOP - _MAIN_LOOP = loop - - -def _spawn(coro) -> None: - """Запустить корутину из любого контекста: running loop, главный loop или поток. - - set_monitor/set_monitor_all вызываются из синхронных роутеров FastAPI, где - running loop отсутствует, поэтому прямой asyncio.create_task падает с - RuntimeError (отсюда Internal Server Error при включении канала). - """ - try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = _MAIN_LOOP - if loop is not None and loop.is_running(): - loop.call_soon_threadsafe(loop.create_task, coro) - return - threading.Thread( - target=lambda: asyncio.new_event_loop().run_until_complete(coro), - daemon=True, - ).start() - else: - loop.create_task(coro) - - -class TelegramManager: - def __init__(self) -> None: - self.client: TelegramClient | None = None - self.phase = "idle" # idle | phone | code | password | qr | ready - self.phone: str = "" - self._code_hash: str = "" - self._auth_lock = asyncio.Lock() - self._listener_task: asyncio.Task | None = None - self._monitored: set[str] = set() - self.error: str | None = None - self._disconnect_hook = None - # QR - self.qr_url: str = "" - self._qr_task: asyncio.Task | None = None - # сердцебиение статуса - self._last_connected: bool | None = None - # backfill каналов при первом подключении - self._backfilling: set[str] = set() - - # ── состояние для UI ────────────────────────────────────────────────── - - def status(self) -> dict: - row = store.query_one( - "SELECT count(*) AS d FROM dialogs WHERE monitor = TRUE" - ) - acc = store.get_setting("tgAccount") or "" - connected = bool(self.client and self.client.is_connected()) - listener_alive = bool(self._listener_task and not self._listener_task.done()) - return { - "phase": self.phase, - "connected": connected, - "listener": listener_alive, - "account": acc, - "monitored": int(row["d"]) if row else 0, - "keysSet": bool(_keys().get("apiId") and _keys().get("apiHash")), - "error": self.error, - "qrUrl": self.qr_url if self.phase == "qr" else None, - } - - def _client(self) -> TelegramClient: - keys = _keys() - api_id = str(keys.get("apiId") or "").strip() - api_hash = str(keys.get("apiHash") or "").strip() - if not api_id or not api_hash: - raise ValueError("Сначала сохраните Telegram api_id и api_hash в настройках") - if self.client is None: - session = str(config.SESSIONS_DIR / config.SESSION_PREFIX) - self.client = TelegramClient(session, int(api_id), api_hash) - return self.client - - # ── веб-авторизация ─────────────────────────────────────────────────── - - async def start_phone(self, phone: str) -> None: - async with self._auth_lock: - self.phone = phone - self.error = None - try: - client = self._client() - await client.connect() - sent = await client.send_code_request(phone) - self._code_hash = sent.phone_code_hash - self.phase = "code" - except Exception as exc: # noqa: BLE001 - self.phase = "idle" - self.error = str(exc) - raise - - async def submit_code(self, code: str) -> None: - async with self._auth_lock: - self.error = None - client = self._client() - try: - await client.sign_in(self.phone, code, phone_code_hash=self._code_hash) - await self._finalize() - except SessionPasswordNeededError: - self.phase = "password" - except PhoneCodeInvalidError: - self.error = "Неверный код" - raise ValueError("Неверный код") - except PhoneCodeExpiredError: - self.error = "Код истёк — запросите новый" - raise ValueError("Код истёк — запросите новый") - except Exception as exc: # noqa: BLE001 - self.error = str(exc) - raise - - async def submit_password(self, password: str) -> None: - async with self._auth_lock: - self.error = None - try: - await self.client.sign_in(password=password) - await self._finalize() - except Exception as exc: # noqa: BLE001 - self.error = "Неверный облачный пароль" - raise ValueError("Неверный облачный пароль") from exc - - async def _finalize(self, notify: bool = True) -> None: - me = await self.client.get_me() - store.set_setting("tgAccount", f"@{me.username or 'user'}") - self.phase = "ready" - self._start_listener() - await self.refresh_dialogs() - self._schedule_first_backfill() - if notify: - await broker.publish_toast("Telegram подключён, сессия сохранена", "send") - await self._publish_status() - - async def disconnect(self) -> None: - async with self._auth_lock: - if self._qr_task: - self._qr_task.cancel() - self._qr_task = None - if self._listener_task: - self._listener_task.cancel() - self._listener_task = None - if self.client: - try: - await self.client.disconnect() - except Exception: # noqa: BLE001 - pass - self.phase = "idle" - self._monitored.clear() - self.qr_url = "" - store.set_setting("tgAccount", "") - await broker.publish_toast("Telegram отключён", "logout") - await self._publish_status() - - async def auto_resume(self) -> None: - """При старте сервера: если сессия сохранена — подключиться автоматически.""" - try: - keys = _keys() - if not (keys.get("apiId") and keys.get("apiHash")): - return - client = self._client() - await client.connect() - if await client.is_user_authorized(): - await self._finalize(notify=False) - except Exception as exc: # noqa: BLE001 - log.info("auto_resume skipped: %s", exc) - self.phase = "idle" - - # ── прослушивание ───────────────────────────────────────────────────── - - def _start_listener(self) -> None: - if self._listener_task and not self._listener_task.done(): - return - self._reload_monitored() - self.client.add_event_handler(self._on_message, events.NewMessage()) - self._listener_task = asyncio.create_task(self.client.run_until_disconnected()) - - def _on_done(task: asyncio.Task) -> None: - if task.cancelled(): - log.info("listener stopped (cancelled)") - return - exc = task.exception() - if exc: - log.error("listener crashed: %s", exc) - else: - log.info("listener stopped") - - self._listener_task.add_done_callback(_on_done) - - def _reload_monitored(self) -> None: - rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE") - self._monitored = {r["id"] for r in rows} - - def _dialog_id(self, message) -> str: - try: - chat_id = message.chat_id - except Exception: # noqa: BLE001 - chat_id = message.peer_id - return str(chat_id) - - async def _on_message(self, event) -> None: - """Каждое сообщение мониторящегося диалога кладём в очередь pipeline.""" - try: - msg = event.message - if msg is None or msg.text is None or not msg.text.strip(): - return - dialog_id = self._dialog_id(event.message) - if dialog_id not in self._monitored: - return - chat = await event.get_chat() - ch_name = getattr(chat, "title", None) or getattr(chat, "first_name", "") or dialog_id - ch_handle = getattr(chat, "username", "") or "" - hue = dialog_hue(dialog_id, ch_name) - now = time.time_ns() // 1_000_000 - ts = int(msg.date.timestamp() * 1000) if getattr(msg, "date", None) else now - store.execute( - "UPDATE dialogs SET last_text = ?, last_at = ?, updated_at = ? WHERE id = ?", - [msg.text.strip()[:200], now, now, dialog_id], - ) - pipeline.enqueue(dialog_id, ch_name, ch_handle, hue, msg.id, msg.text, ts) - # помечаем сообщение прочитанным в Telegram, чтобы оно не висело - # «новым» в других клиентах/устройствах - try: - await self.client.send_read_acknowledge(chat) - except Exception: # noqa: BLE001 - log.debug("read ack failed", exc_info=True) - except Exception as exc: # noqa: BLE001 - log.exception("on_message failed: %s", exc) - - # ── QR-вход ─────────────────────────────────────────────────────────── - - async def qr_start(self) -> str: - async with self._auth_lock: - self.error = None - client = self._client() - await client.connect() - if await client.is_user_authorized(): - await self._finalize(notify=False) - return "" - if self._qr_task and not self._qr_task.done(): - return self.qr_url - qr = await client.qr_login() - self.qr_url = qr.url - self.phase = "qr" - self._qr_task = asyncio.create_task(self._wait_qr(qr)) - return self.qr_url - - async def _wait_qr(self, qr) -> None: - try: - await qr.wait() - await self._finalize(notify=True) - except Exception as exc: # noqa: BLE001 - log.warning("qr wait error: %s", exc) - self.error = str(exc) - self.phase = "idle" - finally: - self._qr_task = None - - # ── статус (сердцебиение) ───────────────────────────────────────────── - - async def _publish_status(self) -> None: - await broker.publish("system_status", self.status()) - - async def heartbeat(self) -> None: - """Периодический вызов из планировщика: уведомляем, если Telegram «уснул».""" - connected = bool(self.client and self.client.is_connected()) - if self.phase == "ready" and connected != self._last_connected: - self._last_connected = connected - await self._publish_status() - if not connected: - await broker.publish_toast("Telegram отключён — переподключение при следующей проверке", "bell") - if self.phase == "ready" and connected: - self._last_connected = True - - # ── backfill: при первом подключении по 10 последних сообщений ──────── - - def _schedule_first_backfill(self) -> None: - rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE AND backfilled = FALSE") - ids = [r["id"] for r in rows] - if ids: - asyncio.create_task(self._backfill_dialogs(ids)) - - async def _backfill_dialogs(self, dialog_ids: list[str], force: bool = False) -> None: - for dialog_id in dialog_ids: - if dialog_id in self._backfilling: - continue - try: - processed = await self.backfill_dialog(dialog_id, force=force) - if processed: - log.info("backfill %s: %d сообщений в очередь", dialog_id, processed) - except Exception as exc: # noqa: BLE001 - log.warning("backfill %s failed: %s", dialog_id, exc) - await asyncio.sleep(random.uniform(*BACKFILL_PER_DIALOG)) - - async def backfill_dialog(self, dialog_id: str, force: bool = False) -> int: - """Последние 10 сообщений канала: разбираем с паузами (анти-бан). - - force=True — «Перечитать» по кнопке: даже если канал уже разобран. - Повторы карточек не создаются (защита dedup по нормализованному тексту). - """ - if dialog_id in self._backfilling: - return 0 - client = self.client - if not client or not client.is_connected(): - return 0 - row = store.query_one("SELECT backfilled FROM dialogs WHERE id = ?", [dialog_id]) - if not row or (not force and bool(row["backfilled"])): - return 0 - self._backfilling.add(dialog_id) - processed = 0 - try: - entity = await client.get_entity(int(dialog_id)) - chat = await client.get_entity(int(dialog_id)) - name = getattr(chat, "title", None) or getattr(chat, "first_name", "") or dialog_id - handle = getattr(chat, "username", "") or "" - hue = dialog_hue(dialog_id, name) - msgs = await client.get_messages(entity, limit=10) - for m in reversed(msgs): # от старых к новым, как реальный поток - if m.text is None or not m.text.strip(): - continue - now = time.time_ns() // 1_000_000 - ts = int(m.date.timestamp() * 1000) if m.date else now - # в очередь уходит всё; отсев сделает воркер, в БД осядет только - # то, что прошло фильтры - pipeline.enqueue(dialog_id, name, handle, hue, m.id, m.text, ts) - processed += 1 - await asyncio.sleep(random.uniform(*BACKFILL_PER_MESSAGE)) - # «Перечитать» — вручную вытащили сообщения: снимаем «новое» в Telegram - try: - await client.send_read_acknowledge(entity) - except Exception: # noqa: BLE001 - log.debug("backfill read ack failed", exc_info=True) - store.execute("UPDATE dialogs SET backfilled = TRUE WHERE id = ?", [dialog_id]) - finally: - self._backfilling.discard(dialog_id) - return processed - - async def realtime_sweep(self) -> None: - """Страховка realtime: если событие потеряно (рестарт/разрыв), раз в 30 с - докачиваем непрочитанные сообщения включённых каналов, кладём в очередь - и помечаем прочитанными.""" - client = self.client - if not client or not client.is_connected(): - return - self._reload_monitored() - if not self._monitored: - return - try: - dialogs = await client.get_dialogs(limit=500) - except Exception as exc: # noqa: BLE001 - log.debug("realtime sweep: get_dialogs fail: %s", exc) - return - # синхронизация списка: новые каналы/группы появляются и включаются - # автоматически, удалённые исчезают (см. _persist_dialogs) - try: - entries = [] - for dlg in dialogs: - ent = dlg.entity - name = dlg.name or "" - entries.append( - ( - str(dlg.id), - name, - getattr(ent, "username", "") or "", - self._kind_of(ent), - dialog_hue(str(dlg.id), name), - ) - ) - if entries: - self._persist_dialogs(entries) - except Exception as exc: # noqa: BLE001 - log.debug("realtime sweep: sync dialogs fail: %s", exc) - for dlg in dialogs: - did = str(dlg.id) - if did not in self._monitored: - continue - unread = int(getattr(dlg, "unread_count", 0) or 0) - if unread <= 0: - continue - try: - msgs = await client.get_messages(dlg.entity, limit=min(unread + 2, 10)) - added = 0 - for m in reversed(msgs): # от старых к новым - if m.text is None or not m.text.strip(): - continue - if store.scalar( - "SELECT 1 FROM pipeline_msg WHERE dialog_id = ? AND msg_id = ?", [did, m.id] - ): - continue - ent = dlg.entity - name = getattr(ent, "title", None) or getattr(ent, "first_name", "") or did - handle = getattr(ent, "username", "") or "" - hue = dialog_hue(did, name) - now = time.time_ns() // 1_000_000 - ts = int(m.date.timestamp() * 1000) if getattr(m, "date", None) else now - pipeline.enqueue(did, name, handle, hue, m.id, m.text, ts) - added += 1 - if added: - log.info("realtime sweep %s: +%d в очередь (потерянные события)", did, added) - await client.send_read_acknowledge(dlg.entity) - except Exception as exc: # noqa: BLE001 - log.debug("realtime sweep dialog %s fail: %s", did, exc) - - # ── диалоги/каналы ──────────────────────────────────────────────────── - - @staticmethod - def _kind_of(entity) -> str: - if getattr(entity, "broadcast", False): - return "канал" - if getattr(entity, "megagroup", False) or getattr(entity, "gigagroup", False) or getattr(entity, "group", False): - return "группа" - return "чат" - - def _persist_dialogs(self, entries: list[tuple]) -> int: - """Синхронизация списка диалогов с Telegram. - - - новые чаты/каналы добавляются; авто-мониторинг новых управляется - настройкой autoMonitorNew (вкл — любой появившийся чат мониторится, - выкл — появляется отключённым); - - переименования/смена типа обновляются (monitor пользователя не трогаем); - - диалоги, которых больше нет в Telegram (вышел/удалил), удаляются. - """ - if not entries: - return 0 - now = time.time_ns() // 1_000_000 - auto_new = bool(store.get_setting("autoMonitorNew")) - seen: set[str] = set() - for dlg_id, name, handle, kind, hue in entries: - seen.add(dlg_id) - store.execute( - "INSERT INTO dialogs(id, name, handle, kind, hue, monitor, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?) " - "ON CONFLICT(id) DO UPDATE SET name = excluded.name, handle = excluded.handle, " - "kind = excluded.kind, hue = excluded.hue, updated_at = excluded.updated_at", - [dlg_id, name, handle, kind, hue, auto_new, now], - ) - stale = store.query( - "SELECT id FROM dialogs WHERE id NOT IN (" + ",".join(["?"] * len(seen)) + ")", - list(seen), - ) - if stale: - stale_ids = [r["id"] for r in stale] - store.execute( - "DELETE FROM dialogs WHERE id IN (" + ",".join(["?"] * len(stale_ids)) + ")", - stale_ids, - ) - log.info("dialogs sync: удалено устаревших источников: %d", len(stale_ids)) - self._reload_monitored() - return len(entries) - - async def refresh_dialogs(self) -> int: - client = self._client() - if not client.is_connected(): - await client.connect() - entries: list[tuple] = [] - async for dialog in client.iter_dialogs(limit=500): - entity = dialog.entity - name = dialog.name or "" - handle = getattr(entity, "username", "") or "" - kind = self._kind_of(entity) - hue = dialog_hue(str(dialog.id), name) - entries.append((str(dialog.id), name, handle, kind, hue)) - if entries: - self._persist_dialogs(entries) - return len(entries) - - def list_dialogs(self) -> list[dict]: - rows = store.query("SELECT * FROM dialogs ORDER BY monitor DESC, name") - return [ - { - "id": r["id"], - "name": r["name"], - "handle": r["handle"], - "type": r["kind"], - "hue": r["hue"], - "on": bool(r["monitor"]), - "last": {"text": r["last_text"], "time": r["last_at"]}, - } - for r in rows - ] - - def set_monitor(self, dialog_id: str, enabled: bool) -> None: - store.execute( - "UPDATE dialogs SET monitor = ?, updated_at = ? WHERE id = ?", - [enabled, time.time_ns() // 1_000_000, dialog_id], - ) - self._reload_monitored() - if enabled: - row = store.query_one("SELECT backfilled FROM dialogs WHERE id = ?", [dialog_id]) - if row and not bool(row["backfilled"]): - # первое включение мониторинга: разбираем последние 10 сообщений с паузами - _spawn(self.backfill_dialog(dialog_id)) - - def set_monitor_all(self, enabled: bool) -> int: - """Включить/выключить мониторинг сразу для всех диалогов. - - Первое включение каждого канала разбирается последовательно (с паузами), - чтобы не попасть под бан Telegram. - """ - now = time.time_ns() // 1_000_000 - rows = store.query("SELECT id, backfilled FROM dialogs") - to_backfill: list[str] = [] - for r in rows: - store.execute( - "UPDATE dialogs SET monitor = ?, updated_at = ? WHERE id = ?", - [enabled, now, r["id"]], - ) - if enabled and not bool(r["backfilled"]): - to_backfill.append(r["id"]) - self._reload_monitored() - if enabled and to_backfill: - _spawn(self._backfill_dialogs(to_backfill)) - return len(rows) - - def backfill_monitored(self) -> int: - """Кнопка «Перечитать»: последние 10 сообщений всех включённых каналов. - - Разбор идёт в фоне последовательно с паузами (анти-бан), даже если - канал уже разобран (force). Включённые каналы продолжают ловить новые - сообщения в реальном времени — это ручная догонялка. - """ - rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE") - ids = [r["id"] for r in rows] - if not ids: - return 0 - _spawn(self._backfill_dialogs(ids, force=True)) - return len(ids) - - async def dialog_messages(self, dialog_id: str, limit: int = 24) -> list[dict]: - """Последние сообщения диалога: свежие берём из Telegram, старые — из БД.""" - out: list[dict] = [] - client = self.client - try: - if client and client.is_connected(): - entity = await client.get_entity(int(dialog_id)) - msgs = await client.get_messages(entity, limit=min(limit, 30)) - now = time.time_ns() // 1_000_000 - for m in msgs: - if m.text: - row = store.query_one( - "SELECT id FROM messages WHERE id = ?", - [f"m_{dialog_id}_{m.id}"], - ) - lead_id = None - if row: - lead_id = row.get("lead_id") or None - if not row: - store.execute( - "INSERT OR IGNORE INTO messages(id, dialog_id, text, msg_at) VALUES (?, ?, ?, ?)", - [f"m_{dialog_id}_{m.id}", dialog_id, m.text[:4000], now], - ) - out.append({"id": m.id, "text": m.text, "time": m.date.timestamp() * 1000, "lead": bool(lead_id)}) - # вручную вытащили сообщения — снимаем «новое» в Telegram - try: - await client.send_read_acknowledge(entity) - except Exception: # noqa: BLE001 - log.debug("dialog_messages read ack failed", exc_info=True) - except Exception as exc: # noqa: BLE001 - log.warning("dialog_messages tg fail: %s", exc) - if not out: - rows = store.query( - "SELECT * FROM messages WHERE dialog_id = ? ORDER BY msg_at DESC LIMIT ?", - [dialog_id, limit], - ) - out = [{"id": r["id"], "text": r["text"], "time": r["msg_at"], "lead": bool(r["lead_id"])} for r in rows] - return out - - # ── discovery: поиск каналов и действия (Task 4) ────────────────────── - - async def discovery_search(self, q: str, limit: int = 30) -> list[dict]: - """Глобальный поиск каналов/групп по ключу (contacts.search). - - id возвращается подписанным (как в dialogs: каналы -100…, группы -id, - люди +id). Найденные entity кэшируются в сессию Telethon — тогда - discovery_info/read смогут получить участников/историю по id даже без - вступления (публичные источники). - """ - client = self.client - if not client or not client.is_connected(): - raise RuntimeError("Telegram не подключён") - found = await client(functions.contacts.SearchRequest(q=q, limit=limit)) - # пауза между поисковыми запросами (анти-бан, BanGuard) - await asyncio.sleep(ban_guard.search_pause()) - try: - client.session.process_entities(found) - except Exception: # noqa: BLE001 - log.debug("discovery_search: cache entities failed", exc_info=True) - out: list[dict] = [] - seen: set[str] = set() - for ent in (*found.chats, *found.users): - try: - dialog_id = str(utils.get_peer_id(ent)) - except (TypeError, ValueError): - continue - if dialog_id in seen: - continue - seen.add(dialog_id) - name = utils.get_display_name(ent) or dialog_id - out.append( - { - "id": dialog_id, - "name": name, - "username": getattr(ent, "username", "") or "", - "kind": self._kind_of(ent), - "hue": dialog_hue(dialog_id, name), - } - ) - if len(out) >= limit: - break - return out - - async def discovery_info(self, dialog_id: str) -> dict: - """Инфо об источнике: тип, username, участники, признак форума. - - participants берётся из full_chat (channels.getFullChannel для - каналов/супергрупп, messages.getFullChat для базовых групп); если - определить не удалось (нет членства/приватный/ошибка) — None, - исключение наружу не бросаем. - """ - result = { - "id": str(dialog_id), - "name": str(dialog_id), - "username": "", - "kind": "", - "hue": dialog_hue(str(dialog_id), str(dialog_id)), - "participants": None, - "is_forum": False, - } - client = self.client - if not client or not client.is_connected(): - return result - try: - entity = await client.get_entity(int(dialog_id)) - except Exception as exc: # noqa: BLE001 - log.warning("discovery_info %s: entity not resolved: %s", dialog_id, exc) - return result - name = utils.get_display_name(entity) or str(dialog_id) - kind = self._kind_of(entity) - result.update( - name=name, - username=getattr(entity, "username", "") or "", - kind=kind, - hue=dialog_hue(str(dialog_id), name), - is_forum=bool(getattr(entity, "forum", False)), - ) - try: - if kind in ("канал", "группа"): - full = await client(functions.channels.GetFullChannelRequest(entity)) - full_chat = full.full_chat - participants = int(getattr(full_chat, "participants_count", 0) or 0) - result["participants"] = participants or None - elif getattr(entity, "title", None): - # базовая группа (legacy): полный чат приносит список участников - full = await client(functions.messages.GetFullChatRequest(entity.id)) - members = getattr( - getattr(full.full_chat, "participants", None), "participants", None - ) - if members: - result["participants"] = len(members) - except Exception as exc: # noqa: BLE001 - log.debug("discovery_info %s: participants unavailable: %s", dialog_id, exc) - return result - - async def discovery_read(self, dialog_id: str, limit: int) -> dict: - """Последние сообщения источника для оценки (без создания карточек). - - Форум (entity.forum): читаем выборку по активным темам - (channels.getForumTopics + по каждой теме get_messages(reply_to=topic_id)) - и возвращаем плоский список с topic_id/topic_title. Для обычных - источников topic_id/topic_title = None. История недоступна - (приватный/закрытый источник без членства) — ok=False, - error="no_history". Исключения наружу не бросаем: ошибка в темах — - безопасный fallback на обычное чтение ленты. - """ - client = self.client - limit = max(int(limit or 0), 0) - if limit <= 0: - return {"ok": True, "error": None, "messages": []} - if not client or not client.is_connected(): - return {"ok": False, "error": "no_history", "messages": []} - try: - entity = await client.get_entity(int(dialog_id)) - except Exception as exc: # noqa: BLE001 - log.warning("discovery_read %s: entity not resolved: %s", dialog_id, exc) - return {"ok": False, "error": "no_history", "messages": []} - if getattr(entity, "forum", False): - try: - topic_msgs = await self._read_forum_topics(client, entity, limit) - except Exception as exc: # noqa: BLE001 - log.debug("discovery_read %s: forum topics fail, fallback to feed: %s", dialog_id, exc) - topic_msgs = [] - if topic_msgs: - return {"ok": True, "error": None, "messages": topic_msgs} - # обычная лента (каналы/группы; для форума — General-тема) - try: - msgs = await client.get_messages(entity, limit=limit) - except Exception as exc: # noqa: BLE001 - log.warning("discovery_read %s: history unavailable: %s", dialog_id, exc) - return {"ok": False, "error": "no_history", "messages": []} - now = time.time_ns() // 1_000_000 - out: list[dict] = [] - for m in msgs: - item = self._discovery_message_item(m, None, None, now) - if item: - out.append(item) - return {"ok": True, "error": None, "messages": out} - - async def _read_forum_topics(self, client, entity, limit: int) -> list[dict]: - """Выборка сообщений по активным темам форума. - - Возвращает плоский список {id, text, date_ms, topic_id, topic_title}. - Исключения не бросает: темы, которые не прочитались, пропускаются, - вызывающий решает, делать ли fallback на обычную ленту. - """ - out: list[dict] = [] - try: - res = await client( - functions.channels.GetForumTopicsRequest( - channel=entity, offset_date=0, offset_id=0, offset_topic=0, limit=5 - ) - ) - topics = list(getattr(res, "topics", None) or []) - except Exception as exc: # noqa: BLE001 - log.debug("discovery_read: getForumTopics fail: %s", exc) - return out - if not topics: - return out - # на тему минимум 3 сообщения (иначе тема почти всегда отсеется как - # «мало подходящих»), cap 10; суммарно выборка может слегка превысить limit - per_topic = min(max(3, math.ceil(limit / len(topics))), 10) - now = time.time_ns() // 1_000_000 - for topic in topics: - topic_id = int(getattr(topic, "id", 0) or 0) - topic_title = getattr(topic, "title", "") or "" - if not topic_id: - continue - try: - msgs = await client.get_messages(entity, limit=per_topic, reply_to=topic_id) - except Exception as exc: # noqa: BLE001 - log.debug("discovery_read: topic %s read fail: %s", topic_id, exc) - continue - for m in msgs: - item = self._discovery_message_item(m, topic_id, topic_title, now) - if item: - out.append(item) - return out - - @staticmethod - def _discovery_message_item(m, topic_id, topic_title, now: int) -> dict | None: - """Одно сообщение discovery_read: только непустой текст (как в - backfill/dialog_messages), поля {id, text, date_ms, topic_id, topic_title}.""" - text = getattr(m, "text", None) - if not text or not text.strip(): - return None - date = getattr(m, "date", None) - return { - "id": m.id, - "text": text, - "date_ms": int(date.timestamp() * 1000) if date else now, - "topic_id": topic_id, - "topic_title": topic_title, - } - - async def discovery_join(self, username: str) -> None: - """Вступить в канал/группу по @username (channels.JoinChannelRequest). - - Ручной join из API — вне квот, без пауз; паузу перед авто-вступлением - делает воркер (ban_guard.wait_join_delay). FloodWaitError фиксируется - в BanGuard (стоп авто-вступлений до конца суток) и пробрасывается - вызывающему. - """ - username = (username or "").strip().lstrip("@") - if not username: - raise ValueError("Не указан username для вступления") - client = self.client - if not client or not client.is_connected(): - raise RuntimeError("Telegram не подключён") - try: - entity = await client.get_entity(username) - await client(functions.channels.JoinChannelRequest(entity)) - except FloodWaitError: - ban_guard.note_flood() - log.warning("discovery_join %s: flood — авто-вступления стоп до конца суток", username) - raise - log.info("discovery_join: вступили в @%s", username) - - async def discovery_leave(self, dialog_id: str) -> None: - """Выйти из канала/группы (channels.LeaveChannelRequest).""" - client = self.client - if not client or not client.is_connected(): - raise RuntimeError("Telegram не подключён") - entity = await client.get_entity(int(dialog_id)) - await client(functions.channels.LeaveChannelRequest(entity)) - log.info("discovery_leave: вышли из %s", dialog_id) - - def add_dialog_monitored(self, dialog_id, name, username, kind, hue) -> None: - """Добавить источник в dialogs с monitor=TRUE (после вступления). - - INSERT/UPDATE без авто-логики (как set_monitor, но без запуска - backfill): backfilled=FALSE — разбор последних сообщений подхватит - обычный механизм при первом подключении/перечитывании. - """ - now = time.time_ns() // 1_000_000 - store.execute( - "INSERT INTO dialogs(id, name, handle, kind, hue, monitor, backfilled, updated_at) " - "VALUES (?, ?, ?, ?, ?, TRUE, FALSE, ?) " - "ON CONFLICT(id) DO UPDATE SET name = excluded.name, handle = excluded.handle, " - "kind = excluded.kind, hue = excluded.hue, monitor = TRUE, backfilled = FALSE, " - "updated_at = excluded.updated_at", - [ - str(dialog_id), - name or str(dialog_id), - username or "", - kind or "", - hue or "#666", - now, - ], - ) - self._reload_monitored() - - -def dialog_hue(dialog_id: str, name: str = "") -> str: - h = 0 - for ch in (dialog_id + name): - h = (h * 31 + ord(ch)) % len(DIALOG_HUES) - return DIALOG_HUES[h] - - -tg = TelegramManager() +"""Telegram-интеграция (п.4.2, 4.3 ТЗ). + +api_id/api_hash берутся из настроек (введены в UI, НЕ из env). Сессия +Telethon сохраняется в файловой системе — повторная авторизация не нужна. +Вход: по телефону (+2FA) или по QR-ссылке. Сообщения из каналов с +включённым мониторингом кладутся в очередь pipeline, откуда их разбирает +фоновый воркер (стоп-фразы → ML → ИИ). В БД оседает только прошедшее +фильтры; исходное сообщение карточки хранит msg_id для «открыть исходник». +""" +from __future__ import annotations + +import asyncio +import logging +import math +import random +import threading +import time + +from telethon import TelegramClient, events, utils +from telethon.errors import SessionPasswordNeededError, PhoneCodeInvalidError, PhoneCodeExpiredError +from telethon.errors.rpcerrorlist import FloodWaitError +from telethon.tl import functions + +from .. import config +from ..constants import DIALOG_HUES +from ..crypto import decrypt_text +from ..db import store +from ..sse import broker +from . import ban_guard +from . import pipeline + +log = logging.getLogger("leadradar.tg") + + +BACKFILL_PER_MESSAGE = (1.5, 3.0) # секунды, чтобы не попасть под бан +BACKFILL_PER_DIALOG = (3.0, 6.0) + + +def _keys() -> dict: + raw = store.get_setting("tgKeys") or {} + api_hash = str(raw.get("apiHash", "")) + return { + "apiId": str(raw.get("apiId", "")).strip(), + "apiHash": (decrypt_text(api_hash) if api_hash.startswith("enc:") else api_hash), + } + + +# Главный event loop приложения: на нём живут Telethon-клиент и фоновые +# задачи. Синхронные роутеры FastAPI исполняются в threadpool (без running +# loop), поэтому корутины нужно планировать на этот loop через +# call_soon_threadsafe — Telethon не переносит клиент между циклами. +_MAIN_LOOP: asyncio.AbstractEventLoop | None = None + + +def register_main_loop(loop: asyncio.AbstractEventLoop) -> None: + global _MAIN_LOOP + _MAIN_LOOP = loop + + +def _spawn(coro) -> None: + """Запустить корутину из любого контекста: running loop, главный loop или поток. + + set_monitor/set_monitor_all вызываются из синхронных роутеров FastAPI, где + running loop отсутствует, поэтому прямой asyncio.create_task падает с + RuntimeError (отсюда Internal Server Error при включении канала). + """ + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = _MAIN_LOOP + if loop is not None and loop.is_running(): + loop.call_soon_threadsafe(loop.create_task, coro) + return + threading.Thread( + target=lambda: asyncio.new_event_loop().run_until_complete(coro), + daemon=True, + ).start() + else: + loop.create_task(coro) + + +class TelegramManager: + def __init__(self) -> None: + self.client: TelegramClient | None = None + self.phase = "idle" # idle | phone | code | password | qr | ready + self.phone: str = "" + self._code_hash: str = "" + self._auth_lock = asyncio.Lock() + self._listener_task: asyncio.Task | None = None + self._monitored: set[str] = set() + self.error: str | None = None + self._disconnect_hook = None + # QR + self.qr_url: str = "" + self._qr_task: asyncio.Task | None = None + # сердцебиение статуса + self._last_connected: bool | None = None + # backfill каналов при первом подключении + self._backfilling: set[str] = set() + + # ── состояние для UI ────────────────────────────────────────────────── + + def status(self) -> dict: + row = store.query_one( + "SELECT count(*) AS d FROM dialogs WHERE monitor = TRUE" + ) + acc = store.get_setting("tgAccount") or "" + connected = bool(self.client and self.client.is_connected()) + listener_alive = bool(self._listener_task and not self._listener_task.done()) + return { + "phase": self.phase, + "connected": connected, + "listener": listener_alive, + "account": acc, + "monitored": int(row["d"]) if row else 0, + "keysSet": bool(_keys().get("apiId") and _keys().get("apiHash")), + "error": self.error, + "qrUrl": self.qr_url if self.phase == "qr" else None, + } + + def _client(self) -> TelegramClient: + keys = _keys() + api_id = str(keys.get("apiId") or "").strip() + api_hash = str(keys.get("apiHash") or "").strip() + if not api_id or not api_hash: + raise ValueError("Сначала сохраните Telegram api_id и api_hash в настройках") + if self.client is None: + session = str(config.SESSIONS_DIR / config.SESSION_PREFIX) + self.client = TelegramClient(session, int(api_id), api_hash) + return self.client + + # ── веб-авторизация ─────────────────────────────────────────────────── + + async def start_phone(self, phone: str) -> None: + async with self._auth_lock: + self.phone = phone + self.error = None + try: + client = self._client() + await client.connect() + sent = await client.send_code_request(phone) + self._code_hash = sent.phone_code_hash + self.phase = "code" + except Exception as exc: # noqa: BLE001 + self.phase = "idle" + self.error = str(exc) + raise + + async def submit_code(self, code: str) -> None: + async with self._auth_lock: + self.error = None + client = self._client() + try: + await client.sign_in(self.phone, code, phone_code_hash=self._code_hash) + await self._finalize() + except SessionPasswordNeededError: + self.phase = "password" + except PhoneCodeInvalidError: + self.error = "Неверный код" + raise ValueError("Неверный код") + except PhoneCodeExpiredError: + self.error = "Код истёк — запросите новый" + raise ValueError("Код истёк — запросите новый") + except Exception as exc: # noqa: BLE001 + self.error = str(exc) + raise + + async def submit_password(self, password: str) -> None: + async with self._auth_lock: + self.error = None + try: + await self.client.sign_in(password=password) + await self._finalize() + except Exception as exc: # noqa: BLE001 + self.error = "Неверный облачный пароль" + raise ValueError("Неверный облачный пароль") from exc + + async def _finalize(self, notify: bool = True) -> None: + me = await self.client.get_me() + store.set_setting("tgAccount", f"@{me.username or 'user'}") + self.phase = "ready" + self._start_listener() + await self.refresh_dialogs() + self._schedule_first_backfill() + if notify: + await broker.publish_toast("Telegram подключён, сессия сохранена", "send") + await self._publish_status() + + async def disconnect(self) -> None: + async with self._auth_lock: + if self._qr_task: + self._qr_task.cancel() + self._qr_task = None + if self._listener_task: + self._listener_task.cancel() + self._listener_task = None + if self.client: + try: + await self.client.disconnect() + except Exception: # noqa: BLE001 + pass + self.phase = "idle" + self._monitored.clear() + self.qr_url = "" + store.set_setting("tgAccount", "") + await broker.publish_toast("Telegram отключён", "logout") + await self._publish_status() + + async def auto_resume(self) -> None: + """При старте сервера: если сессия сохранена — подключиться автоматически.""" + try: + keys = _keys() + if not (keys.get("apiId") and keys.get("apiHash")): + return + client = self._client() + await client.connect() + if await client.is_user_authorized(): + await self._finalize(notify=False) + except Exception as exc: # noqa: BLE001 + log.info("auto_resume skipped: %s", exc) + self.phase = "idle" + + # ── прослушивание ───────────────────────────────────────────────────── + + def _start_listener(self) -> None: + if self._listener_task and not self._listener_task.done(): + return + self._reload_monitored() + self.client.add_event_handler(self._on_message, events.NewMessage()) + self._listener_task = asyncio.create_task(self.client.run_until_disconnected()) + + def _on_done(task: asyncio.Task) -> None: + if task.cancelled(): + log.info("listener stopped (cancelled)") + return + exc = task.exception() + if exc: + log.error("listener crashed: %s", exc) + else: + log.info("listener stopped") + + self._listener_task.add_done_callback(_on_done) + + def _reload_monitored(self) -> None: + rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE") + self._monitored = {r["id"] for r in rows} + + def _dialog_id(self, message) -> str: + try: + chat_id = message.chat_id + except Exception: # noqa: BLE001 + chat_id = message.peer_id + return str(chat_id) + + async def _on_message(self, event) -> None: + """Каждое сообщение мониторящегося диалога кладём в очередь pipeline.""" + try: + msg = event.message + if msg is None or msg.text is None or not msg.text.strip(): + return + dialog_id = self._dialog_id(event.message) + if dialog_id not in self._monitored: + return + chat = await event.get_chat() + ch_name = getattr(chat, "title", None) or getattr(chat, "first_name", "") or dialog_id + ch_handle = getattr(chat, "username", "") or "" + hue = dialog_hue(dialog_id, ch_name) + now = time.time_ns() // 1_000_000 + ts = int(msg.date.timestamp() * 1000) if getattr(msg, "date", None) else now + store.execute( + "UPDATE dialogs SET last_text = ?, last_at = ?, updated_at = ? WHERE id = ?", + [msg.text.strip()[:200], now, now, dialog_id], + ) + pipeline.enqueue(dialog_id, ch_name, ch_handle, hue, msg.id, msg.text, ts) + # помечаем сообщение прочитанным в Telegram, чтобы оно не висело + # «новым» в других клиентах/устройствах + try: + await self.client.send_read_acknowledge(chat) + except Exception: # noqa: BLE001 + log.debug("read ack failed", exc_info=True) + except Exception as exc: # noqa: BLE001 + log.exception("on_message failed: %s", exc) + + # ── QR-вход ─────────────────────────────────────────────────────────── + + async def qr_start(self) -> str: + async with self._auth_lock: + self.error = None + client = self._client() + await client.connect() + if await client.is_user_authorized(): + await self._finalize(notify=False) + return "" + if self._qr_task and not self._qr_task.done(): + return self.qr_url + qr = await client.qr_login() + self.qr_url = qr.url + self.phase = "qr" + self._qr_task = asyncio.create_task(self._wait_qr(qr)) + return self.qr_url + + async def _wait_qr(self, qr) -> None: + try: + await qr.wait() + await self._finalize(notify=True) + except Exception as exc: # noqa: BLE001 + log.warning("qr wait error: %s", exc) + self.error = str(exc) + self.phase = "idle" + finally: + self._qr_task = None + + # ── статус (сердцебиение) ───────────────────────────────────────────── + + async def _publish_status(self) -> None: + await broker.publish("system_status", self.status()) + + async def heartbeat(self) -> None: + """Периодический вызов из планировщика: уведомляем, если Telegram «уснул».""" + connected = bool(self.client and self.client.is_connected()) + if self.phase == "ready" and connected != self._last_connected: + self._last_connected = connected + await self._publish_status() + if not connected: + await broker.publish_toast("Telegram отключён — переподключение при следующей проверке", "bell") + if self.phase == "ready" and connected: + self._last_connected = True + + # ── backfill: при первом подключении по 10 последних сообщений ──────── + + def _schedule_first_backfill(self) -> None: + rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE AND backfilled = FALSE") + ids = [r["id"] for r in rows] + if ids: + asyncio.create_task(self._backfill_dialogs(ids)) + + async def _backfill_dialogs(self, dialog_ids: list[str], force: bool = False) -> None: + for dialog_id in dialog_ids: + if dialog_id in self._backfilling: + continue + try: + processed = await self.backfill_dialog(dialog_id, force=force) + if processed: + log.info("backfill %s: %d сообщений в очередь", dialog_id, processed) + except Exception as exc: # noqa: BLE001 + log.warning("backfill %s failed: %s", dialog_id, exc) + await asyncio.sleep(random.uniform(*BACKFILL_PER_DIALOG)) + + async def backfill_dialog(self, dialog_id: str, force: bool = False) -> int: + """Последние 10 сообщений канала: разбираем с паузами (анти-бан). + + force=True — «Перечитать» по кнопке: даже если канал уже разобран. + Повторы карточек не создаются (защита dedup по нормализованному тексту). + """ + if dialog_id in self._backfilling: + return 0 + client = self.client + if not client or not client.is_connected(): + return 0 + row = store.query_one("SELECT backfilled FROM dialogs WHERE id = ?", [dialog_id]) + if not row or (not force and bool(row["backfilled"])): + return 0 + self._backfilling.add(dialog_id) + processed = 0 + try: + entity = await client.get_entity(int(dialog_id)) + chat = await client.get_entity(int(dialog_id)) + name = getattr(chat, "title", None) or getattr(chat, "first_name", "") or dialog_id + handle = getattr(chat, "username", "") or "" + hue = dialog_hue(dialog_id, name) + msgs = await client.get_messages(entity, limit=10) + for m in reversed(msgs): # от старых к новым, как реальный поток + if m.text is None or not m.text.strip(): + continue + now = time.time_ns() // 1_000_000 + ts = int(m.date.timestamp() * 1000) if m.date else now + # в очередь уходит всё; отсев сделает воркер, в БД осядет только + # то, что прошло фильтры + pipeline.enqueue(dialog_id, name, handle, hue, m.id, m.text, ts) + processed += 1 + await asyncio.sleep(random.uniform(*BACKFILL_PER_MESSAGE)) + # «Перечитать» — вручную вытащили сообщения: снимаем «новое» в Telegram + try: + await client.send_read_acknowledge(entity) + except Exception: # noqa: BLE001 + log.debug("backfill read ack failed", exc_info=True) + store.execute("UPDATE dialogs SET backfilled = TRUE WHERE id = ?", [dialog_id]) + finally: + self._backfilling.discard(dialog_id) + return processed + + async def realtime_sweep(self) -> None: + """Страховка realtime: если событие потеряно (рестарт/разрыв), раз в 30 с + докачиваем непрочитанные сообщения включённых каналов, кладём в очередь + и помечаем прочитанными.""" + client = self.client + if not client or not client.is_connected(): + return + self._reload_monitored() + if not self._monitored: + return + try: + dialogs = await client.get_dialogs(limit=500) + except Exception as exc: # noqa: BLE001 + log.debug("realtime sweep: get_dialogs fail: %s", exc) + return + # синхронизация списка: новые каналы/группы появляются и включаются + # автоматически, удалённые исчезают (см. _persist_dialogs) + try: + entries = [] + for dlg in dialogs: + ent = dlg.entity + name = dlg.name or "" + entries.append( + ( + str(dlg.id), + name, + getattr(ent, "username", "") or "", + self._kind_of(ent), + dialog_hue(str(dlg.id), name), + ) + ) + if entries: + self._persist_dialogs(entries) + except Exception as exc: # noqa: BLE001 + log.debug("realtime sweep: sync dialogs fail: %s", exc) + for dlg in dialogs: + did = str(dlg.id) + if did not in self._monitored: + continue + unread = int(getattr(dlg, "unread_count", 0) or 0) + if unread <= 0: + continue + try: + msgs = await client.get_messages(dlg.entity, limit=min(unread + 2, 10)) + added = 0 + for m in reversed(msgs): # от старых к новым + if m.text is None or not m.text.strip(): + continue + if store.scalar( + "SELECT 1 FROM pipeline_msg WHERE dialog_id = ? AND msg_id = ?", [did, m.id] + ): + continue + ent = dlg.entity + name = getattr(ent, "title", None) or getattr(ent, "first_name", "") or did + handle = getattr(ent, "username", "") or "" + hue = dialog_hue(did, name) + now = time.time_ns() // 1_000_000 + ts = int(m.date.timestamp() * 1000) if getattr(m, "date", None) else now + pipeline.enqueue(did, name, handle, hue, m.id, m.text, ts) + added += 1 + if added: + log.info("realtime sweep %s: +%d в очередь (потерянные события)", did, added) + await client.send_read_acknowledge(dlg.entity) + except Exception as exc: # noqa: BLE001 + log.debug("realtime sweep dialog %s fail: %s", did, exc) + + # ── диалоги/каналы ──────────────────────────────────────────────────── + + @staticmethod + def _kind_of(entity) -> str: + if getattr(entity, "broadcast", False): + return "канал" + if getattr(entity, "megagroup", False) or getattr(entity, "gigagroup", False) or getattr(entity, "group", False): + return "группа" + return "чат" + + def _persist_dialogs(self, entries: list[tuple]) -> int: + """Синхронизация списка диалогов с Telegram. + + - новые чаты/каналы добавляются; авто-мониторинг новых управляется + настройкой autoMonitorNew (вкл — любой появившийся чат мониторится, + выкл — появляется отключённым); + - переименования/смена типа обновляются (monitor пользователя не трогаем); + - диалоги, которых больше нет в Telegram (вышел/удалил), удаляются. + """ + if not entries: + return 0 + now = time.time_ns() // 1_000_000 + auto_new = bool(store.get_setting("autoMonitorNew")) + seen: set[str] = set() + for dlg_id, name, handle, kind, hue in entries: + seen.add(dlg_id) + store.execute( + "INSERT INTO dialogs(id, name, handle, kind, hue, monitor, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET name = excluded.name, handle = excluded.handle, " + "kind = excluded.kind, hue = excluded.hue, updated_at = excluded.updated_at", + [dlg_id, name, handle, kind, hue, auto_new, now], + ) + stale = store.query( + "SELECT id FROM dialogs WHERE id NOT IN (" + ",".join(["?"] * len(seen)) + ")", + list(seen), + ) + if stale: + stale_ids = [r["id"] for r in stale] + store.execute( + "DELETE FROM dialogs WHERE id IN (" + ",".join(["?"] * len(stale_ids)) + ")", + stale_ids, + ) + log.info("dialogs sync: удалено устаревших источников: %d", len(stale_ids)) + self._reload_monitored() + return len(entries) + + async def refresh_dialogs(self) -> int: + client = self._client() + if not client.is_connected(): + await client.connect() + entries: list[tuple] = [] + async for dialog in client.iter_dialogs(limit=500): + entity = dialog.entity + name = dialog.name or "" + handle = getattr(entity, "username", "") or "" + kind = self._kind_of(entity) + hue = dialog_hue(str(dialog.id), name) + entries.append((str(dialog.id), name, handle, kind, hue)) + if entries: + self._persist_dialogs(entries) + return len(entries) + + def list_dialogs(self) -> list[dict]: + rows = store.query("SELECT * FROM dialogs ORDER BY monitor DESC, name") + return [ + { + "id": r["id"], + "name": r["name"], + "handle": r["handle"], + "type": r["kind"], + "hue": r["hue"], + "on": bool(r["monitor"]), + "last": {"text": r["last_text"], "time": r["last_at"]}, + } + for r in rows + ] + + def set_monitor(self, dialog_id: str, enabled: bool) -> None: + store.execute( + "UPDATE dialogs SET monitor = ?, updated_at = ? WHERE id = ?", + [enabled, time.time_ns() // 1_000_000, dialog_id], + ) + self._reload_monitored() + if enabled: + row = store.query_one("SELECT backfilled FROM dialogs WHERE id = ?", [dialog_id]) + if row and not bool(row["backfilled"]): + # первое включение мониторинга: разбираем последние 10 сообщений с паузами + _spawn(self.backfill_dialog(dialog_id)) + + def set_monitor_all(self, enabled: bool) -> int: + """Включить/выключить мониторинг сразу для всех диалогов. + + Первое включение каждого канала разбирается последовательно (с паузами), + чтобы не попасть под бан Telegram. + """ + now = time.time_ns() // 1_000_000 + rows = store.query("SELECT id, backfilled FROM dialogs") + to_backfill: list[str] = [] + for r in rows: + store.execute( + "UPDATE dialogs SET monitor = ?, updated_at = ? WHERE id = ?", + [enabled, now, r["id"]], + ) + if enabled and not bool(r["backfilled"]): + to_backfill.append(r["id"]) + self._reload_monitored() + if enabled and to_backfill: + _spawn(self._backfill_dialogs(to_backfill)) + return len(rows) + + def backfill_monitored(self) -> int: + """Кнопка «Перечитать»: последние 10 сообщений всех включённых каналов. + + Разбор идёт в фоне последовательно с паузами (анти-бан), даже если + канал уже разобран (force). Включённые каналы продолжают ловить новые + сообщения в реальном времени — это ручная догонялка. + """ + rows = store.query("SELECT id FROM dialogs WHERE monitor = TRUE") + ids = [r["id"] for r in rows] + if not ids: + return 0 + _spawn(self._backfill_dialogs(ids, force=True)) + return len(ids) + + async def dialog_messages(self, dialog_id: str, limit: int = 24) -> list[dict]: + """Последние сообщения диалога: свежие берём из Telegram, старые — из БД.""" + out: list[dict] = [] + client = self.client + try: + if client and client.is_connected(): + entity = await client.get_entity(int(dialog_id)) + msgs = await client.get_messages(entity, limit=min(limit, 30)) + now = time.time_ns() // 1_000_000 + for m in msgs: + if m.text: + row = store.query_one( + "SELECT id FROM messages WHERE id = ?", + [f"m_{dialog_id}_{m.id}"], + ) + lead_id = None + if row: + lead_id = row.get("lead_id") or None + if not row: + store.execute( + "INSERT OR IGNORE INTO messages(id, dialog_id, text, msg_at) VALUES (?, ?, ?, ?)", + [f"m_{dialog_id}_{m.id}", dialog_id, m.text[:4000], now], + ) + out.append({"id": m.id, "text": m.text, "time": m.date.timestamp() * 1000, "lead": bool(lead_id)}) + # вручную вытащили сообщения — снимаем «новое» в Telegram + try: + await client.send_read_acknowledge(entity) + except Exception: # noqa: BLE001 + log.debug("dialog_messages read ack failed", exc_info=True) + except Exception as exc: # noqa: BLE001 + log.warning("dialog_messages tg fail: %s", exc) + if not out: + rows = store.query( + "SELECT * FROM messages WHERE dialog_id = ? ORDER BY msg_at DESC LIMIT ?", + [dialog_id, limit], + ) + out = [{"id": r["id"], "text": r["text"], "time": r["msg_at"], "lead": bool(r["lead_id"])} for r in rows] + return out + + # ── discovery: поиск каналов и действия (Task 4) ────────────────────── + + async def discovery_search(self, q: str, limit: int = 30) -> list[dict]: + """Глобальный поиск каналов/групп по ключу (contacts.search). + + id возвращается подписанным (как в dialogs: каналы -100…, группы -id, + люди +id). Найденные entity кэшируются в сессию Telethon — тогда + discovery_info/read смогут получить участников/историю по id даже без + вступления (публичные источники). + """ + client = self.client + if not client or not client.is_connected(): + raise RuntimeError("Telegram не подключён") + found = await client(functions.contacts.SearchRequest(q=q, limit=limit)) + # пауза между поисковыми запросами (анти-бан, BanGuard) + await asyncio.sleep(ban_guard.search_pause()) + try: + client.session.process_entities(found) + except Exception: # noqa: BLE001 + log.debug("discovery_search: cache entities failed", exc_info=True) + out: list[dict] = [] + seen: set[str] = set() + for ent in (*found.chats, *found.users): + try: + dialog_id = str(utils.get_peer_id(ent)) + except (TypeError, ValueError): + continue + if dialog_id in seen: + continue + seen.add(dialog_id) + name = utils.get_display_name(ent) or dialog_id + out.append( + { + "id": dialog_id, + "name": name, + "username": getattr(ent, "username", "") or "", + "kind": self._kind_of(ent), + "hue": dialog_hue(dialog_id, name), + } + ) + if len(out) >= limit: + break + return out + + async def discovery_info(self, dialog_id: str) -> dict: + """Инфо об источнике: тип, username, участники, признак форума. + + participants берётся из full_chat (channels.getFullChannel для + каналов/супергрупп, messages.getFullChat для базовых групп); если + определить не удалось (нет членства/приватный/ошибка) — None, + исключение наружу не бросаем. + """ + result = { + "id": str(dialog_id), + "name": str(dialog_id), + "username": "", + "kind": "", + "hue": dialog_hue(str(dialog_id), str(dialog_id)), + "participants": None, + "is_forum": False, + } + client = self.client + if not client or not client.is_connected(): + return result + try: + entity = await client.get_entity(int(dialog_id)) + except Exception as exc: # noqa: BLE001 + log.warning("discovery_info %s: entity not resolved: %s", dialog_id, exc) + return result + name = utils.get_display_name(entity) or str(dialog_id) + kind = self._kind_of(entity) + result.update( + name=name, + username=getattr(entity, "username", "") or "", + kind=kind, + hue=dialog_hue(str(dialog_id), name), + is_forum=bool(getattr(entity, "forum", False)), + ) + try: + if kind in ("канал", "группа"): + full = await client(functions.channels.GetFullChannelRequest(entity)) + full_chat = full.full_chat + participants = int(getattr(full_chat, "participants_count", 0) or 0) + result["participants"] = participants or None + elif getattr(entity, "title", None): + # базовая группа (legacy): полный чат приносит список участников + full = await client(functions.messages.GetFullChatRequest(entity.id)) + members = getattr( + getattr(full.full_chat, "participants", None), "participants", None + ) + if members: + result["participants"] = len(members) + except Exception as exc: # noqa: BLE001 + log.debug("discovery_info %s: participants unavailable: %s", dialog_id, exc) + return result + + async def discovery_read(self, dialog_id: str, limit: int) -> dict: + """Последние сообщения источника для оценки (без создания карточек). + + Форум (entity.forum): читаем выборку по активным темам + (channels.getForumTopics + по каждой теме get_messages(reply_to=topic_id)) + и возвращаем плоский список с topic_id/topic_title. Для обычных + источников topic_id/topic_title = None. История недоступна + (приватный/закрытый источник без членства) — ok=False, + error="no_history". Исключения наружу не бросаем: ошибка в темах — + безопасный fallback на обычное чтение ленты. + """ + client = self.client + limit = max(int(limit or 0), 0) + if limit <= 0: + return {"ok": True, "error": None, "messages": []} + if not client or not client.is_connected(): + return {"ok": False, "error": "no_history", "messages": []} + try: + entity = await client.get_entity(int(dialog_id)) + except Exception as exc: # noqa: BLE001 + log.warning("discovery_read %s: entity not resolved: %s", dialog_id, exc) + return {"ok": False, "error": "no_history", "messages": []} + if getattr(entity, "forum", False): + try: + topic_msgs = await self._read_forum_topics(client, entity, limit) + except Exception as exc: # noqa: BLE001 + log.debug("discovery_read %s: forum topics fail, fallback to feed: %s", dialog_id, exc) + topic_msgs = [] + if topic_msgs: + return {"ok": True, "error": None, "messages": topic_msgs} + # обычная лента (каналы/группы; для форума — General-тема) + try: + msgs = await client.get_messages(entity, limit=limit) + except Exception as exc: # noqa: BLE001 + log.warning("discovery_read %s: history unavailable: %s", dialog_id, exc) + return {"ok": False, "error": "no_history", "messages": []} + now = time.time_ns() // 1_000_000 + out: list[dict] = [] + for m in msgs: + item = self._discovery_message_item(m, None, None, now) + if item: + out.append(item) + return {"ok": True, "error": None, "messages": out} + + async def _read_forum_topics(self, client, entity, limit: int) -> list[dict]: + """Выборка сообщений по активным темам форума. + + Возвращает плоский список {id, text, date_ms, topic_id, topic_title}. + Исключения не бросает: темы, которые не прочитались, пропускаются, + вызывающий решает, делать ли fallback на обычную ленту. + """ + out: list[dict] = [] + try: + res = await client( + functions.channels.GetForumTopicsRequest( + channel=entity, offset_date=0, offset_id=0, offset_topic=0, limit=5 + ) + ) + topics = list(getattr(res, "topics", None) or []) + except Exception as exc: # noqa: BLE001 + log.debug("discovery_read: getForumTopics fail: %s", exc) + return out + if not topics: + return out + # на тему минимум 3 сообщения (иначе тема почти всегда отсеется как + # «мало подходящих»), cap 10; суммарно выборка может слегка превысить limit + per_topic = min(max(3, math.ceil(limit / len(topics))), 10) + now = time.time_ns() // 1_000_000 + for topic in topics: + topic_id = int(getattr(topic, "id", 0) or 0) + topic_title = getattr(topic, "title", "") or "" + if not topic_id: + continue + try: + msgs = await client.get_messages(entity, limit=per_topic, reply_to=topic_id) + except Exception as exc: # noqa: BLE001 + log.debug("discovery_read: topic %s read fail: %s", topic_id, exc) + continue + for m in msgs: + item = self._discovery_message_item(m, topic_id, topic_title, now) + if item: + out.append(item) + return out + + @staticmethod + def _discovery_message_item(m, topic_id, topic_title, now: int) -> dict | None: + """Одно сообщение discovery_read: только непустой текст (как в + backfill/dialog_messages), поля {id, text, date_ms, topic_id, topic_title}.""" + text = getattr(m, "text", None) + if not text or not text.strip(): + return None + date = getattr(m, "date", None) + return { + "id": m.id, + "text": text, + "date_ms": int(date.timestamp() * 1000) if date else now, + "topic_id": topic_id, + "topic_title": topic_title, + } + + async def discovery_join(self, username: str) -> None: + """Вступить в канал/группу по @username (channels.JoinChannelRequest). + + Ручной join из API — вне квот, без пауз; паузу перед авто-вступлением + делает воркер (ban_guard.wait_join_delay). FloodWaitError фиксируется + в BanGuard (стоп авто-вступлений до конца суток) и пробрасывается + вызывающему. + """ + username = (username or "").strip().lstrip("@") + if not username: + raise ValueError("Не указан username для вступления") + client = self.client + if not client or not client.is_connected(): + raise RuntimeError("Telegram не подключён") + try: + entity = await client.get_entity(username) + await client(functions.channels.JoinChannelRequest(entity)) + except FloodWaitError: + ban_guard.note_flood() + log.warning("discovery_join %s: flood — авто-вступления стоп до конца суток", username) + raise + log.info("discovery_join: вступили в @%s", username) + + async def discovery_leave(self, dialog_id: str) -> None: + """Выйти из канала/группы (channels.LeaveChannelRequest).""" + client = self.client + if not client or not client.is_connected(): + raise RuntimeError("Telegram не подключён") + entity = await client.get_entity(int(dialog_id)) + await client(functions.channels.LeaveChannelRequest(entity)) + log.info("discovery_leave: вышли из %s", dialog_id) + + def add_dialog_monitored(self, dialog_id, name, username, kind, hue) -> None: + """Добавить источник в dialogs с monitor=TRUE (после вступления). + + INSERT/UPDATE без авто-логики (как set_monitor, но без запуска + backfill): backfilled=FALSE — разбор последних сообщений подхватит + обычный механизм при первом подключении/перечитывании. + """ + now = time.time_ns() // 1_000_000 + store.execute( + "INSERT INTO dialogs(id, name, handle, kind, hue, monitor, backfilled, updated_at) " + "VALUES (?, ?, ?, ?, ?, TRUE, FALSE, ?) " + "ON CONFLICT(id) DO UPDATE SET name = excluded.name, handle = excluded.handle, " + "kind = excluded.kind, hue = excluded.hue, monitor = TRUE, backfilled = FALSE, " + "updated_at = excluded.updated_at", + [ + str(dialog_id), + name or str(dialog_id), + username or "", + kind or "", + hue or "#666", + now, + ], + ) + self._reload_monitored() + + +def dialog_hue(dialog_id: str, name: str = "") -> str: + h = 0 + for ch in (dialog_id + name): + h = (h * 31 + ord(ch)) % len(DIALOG_HUES) + return DIALOG_HUES[h] + + +tg = TelegramManager() diff --git a/archive/leadradar-legacy/backend/app/sse.py b/archive/leadradar-legacy/backend/app/sse.py index d41187d..cb6cad8 100644 --- a/archive/leadradar-legacy/backend/app/sse.py +++ b/archive/leadradar-legacy/backend/app/sse.py @@ -1,51 +1,51 @@ -"""SSE-брокер событий. - -Сервер рассылает события подключённым браузерам: -new_lead, lead_updated, toast, reminder_due, project_updated, system_status. -Поток однонаправленный, по ТЗ — Server-Sent Events с автопереподключением. -""" -from __future__ import annotations - -import asyncio -import json -from typing import Any - - -class Broker: - def __init__(self) -> None: - self._subscribers: set[asyncio.Queue] = set() - self._lock = asyncio.Lock() - - async def subscribe(self) -> asyncio.Queue: - q: asyncio.Queue = asyncio.Queue(maxsize=200) - async with self._lock: - self._subscribers.add(q) - return q - - async def unsubscribe(self, q: asyncio.Queue) -> None: - async with self._lock: - self._subscribers.discard(q) - - async def publish(self, event_type: str, data: Any) -> None: - payload = f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" - async with self._lock: - subs = list(self._subscribers) - for q in subs: - try: - q.put_nowait(payload) - except asyncio.QueueFull: - # при переполнении сбрасываем очередь подписчика — браузер переподключится - try: - q.get_nowait() - except Exception: - pass - try: - q.put_nowait(payload) - except Exception: - pass - - async def publish_toast(self, text: str, icon: str = "check") -> None: - await self.publish("toast", {"text": text, "icon": icon}) - - -broker = Broker() +"""SSE-брокер событий. + +Сервер рассылает события подключённым браузерам: +new_lead, lead_updated, toast, reminder_due, project_updated, system_status. +Поток однонаправленный, по ТЗ — Server-Sent Events с автопереподключением. +""" +from __future__ import annotations + +import asyncio +import json +from typing import Any + + +class Broker: + def __init__(self) -> None: + self._subscribers: set[asyncio.Queue] = set() + self._lock = asyncio.Lock() + + async def subscribe(self) -> asyncio.Queue: + q: asyncio.Queue = asyncio.Queue(maxsize=200) + async with self._lock: + self._subscribers.add(q) + return q + + async def unsubscribe(self, q: asyncio.Queue) -> None: + async with self._lock: + self._subscribers.discard(q) + + async def publish(self, event_type: str, data: Any) -> None: + payload = f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + async with self._lock: + subs = list(self._subscribers) + for q in subs: + try: + q.put_nowait(payload) + except asyncio.QueueFull: + # при переполнении сбрасываем очередь подписчика — браузер переподключится + try: + q.get_nowait() + except Exception: + pass + try: + q.put_nowait(payload) + except Exception: + pass + + async def publish_toast(self, text: str, icon: str = "check") -> None: + await self.publish("toast", {"text": text, "icon": icon}) + + +broker = Broker() diff --git a/archive/leadradar-legacy/backend/devtests/boot_test.py b/archive/leadradar-legacy/backend/devtests/boot_test.py index 23c60c1..b4f8c43 100644 --- a/archive/leadradar-legacy/backend/devtests/boot_test.py +++ b/archive/leadradar-legacy/backend/devtests/boot_test.py @@ -1,57 +1,57 @@ -"""Временный boot-тест №2: шифрование, FTS, demo, check-message, recompute.""" -import os -import sys -import tempfile - -# devtests/ лежит внутри backend/ — кладём в path сам backend -tmp = tempfile.mkdtemp(prefix="leadradar_boot2_") -os.environ["LEADRADAR_DATA"] = tmp -os.environ["LEADRADAR_DEMO"] = "1" -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from fastapi.testclient import TestClient # noqa: E402 - -from app.main import app # noqa: E402 -from app.services import rates as R # noqa: E402 - - -def seed_lead(client): - # ручной лид через demo-эндпоинт (не требует ИИ/телеграма) - r = client.post("/api/demo/simulate-lead") - assert r.status_code == 200, r.text - return r.json() - - -with TestClient(app) as client: - assert client.get("/api/health").status_code == 200 - assert client.post("/api/auth/login", json={"login": "admin", "password": "admin"}).status_code == 200 - - # шифрование настроек aiConfigs (класс провайдера) - s = client.patch("/api/settings", json={"aiConfigs": {"deepseek": {"apiKey": "sk-abcdefgh1234"}}}) - assert s.status_code == 200, s.text - pub = client.get("/api/settings").json() - assert pub["aiConfigs"]["deepseek"]["keySet"] is True, pub["aiConfigs"] - - # FTS rebuild - f = client.post("/api/admin/fts/rebuild") - assert f.status_code == 200 and f.json()["ready"] is True, f.text - - # демо-лид + поиск - lead = seed_lead(client) - assert lead.get("id"), lead - q = client.get("/api/search", params={"q": "Python"}).json() - assert isinstance(q["leads"], list) - - # check-message (этап 1: стоп-фразы без ИИ) - cm = client.post("/api/admin/check-message", json={"text": "Ищу работу на неделю, вот моё резюме и портфолио"}) - assert cm.status_code == 200 and cm.json()["passed"] is False and cm.json()["stage1"]["pass"] is False, cm.text - - # пересчёт конверсий при смене валюты (архивные не трогаем) - R.save_rates({"RUB": 1.0, "USD": 100.0}, "mock") - client.patch("/api/settings", json={"targetCurrency": "RUB", "conversionOn": True}) - refreshed = client.get("/api/settings").json() - assert refreshed["targetCurrency"] == "RUB" - - tick = client.post("/api/admin/tick") - assert tick.status_code == 200 - print("BOOT2 OK") +"""Временный boot-тест №2: шифрование, FTS, demo, check-message, recompute.""" +import os +import sys +import tempfile + +# devtests/ лежит внутри backend/ — кладём в path сам backend +tmp = tempfile.mkdtemp(prefix="leadradar_boot2_") +os.environ["LEADRADAR_DATA"] = tmp +os.environ["LEADRADAR_DEMO"] = "1" +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from fastapi.testclient import TestClient # noqa: E402 + +from app.main import app # noqa: E402 +from app.services import rates as R # noqa: E402 + + +def seed_lead(client): + # ручной лид через demo-эндпоинт (не требует ИИ/телеграма) + r = client.post("/api/demo/simulate-lead") + assert r.status_code == 200, r.text + return r.json() + + +with TestClient(app) as client: + assert client.get("/api/health").status_code == 200 + assert client.post("/api/auth/login", json={"login": "admin", "password": "admin"}).status_code == 200 + + # шифрование настроек aiConfigs (класс провайдера) + s = client.patch("/api/settings", json={"aiConfigs": {"deepseek": {"apiKey": "sk-abcdefgh1234"}}}) + assert s.status_code == 200, s.text + pub = client.get("/api/settings").json() + assert pub["aiConfigs"]["deepseek"]["keySet"] is True, pub["aiConfigs"] + + # FTS rebuild + f = client.post("/api/admin/fts/rebuild") + assert f.status_code == 200 and f.json()["ready"] is True, f.text + + # демо-лид + поиск + lead = seed_lead(client) + assert lead.get("id"), lead + q = client.get("/api/search", params={"q": "Python"}).json() + assert isinstance(q["leads"], list) + + # check-message (этап 1: стоп-фразы без ИИ) + cm = client.post("/api/admin/check-message", json={"text": "Ищу работу на неделю, вот моё резюме и портфолио"}) + assert cm.status_code == 200 and cm.json()["passed"] is False and cm.json()["stage1"]["pass"] is False, cm.text + + # пересчёт конверсий при смене валюты (архивные не трогаем) + R.save_rates({"RUB": 1.0, "USD": 100.0}, "mock") + client.patch("/api/settings", json={"targetCurrency": "RUB", "conversionOn": True}) + refreshed = client.get("/api/settings").json() + assert refreshed["targetCurrency"] == "RUB" + + tick = client.post("/api/admin/tick") + assert tick.status_code == 200 + print("BOOT2 OK") diff --git a/archive/leadradar-legacy/backend/devtests/docker_smoke.py b/archive/leadradar-legacy/backend/devtests/docker_smoke.py index b4ac6c7..ad3e4fc 100644 --- a/archive/leadradar-legacy/backend/devtests/docker_smoke.py +++ b/archive/leadradar-legacy/backend/devtests/docker_smoke.py @@ -1,73 +1,73 @@ -"""Read-only смоук docker-инсталляции: ничего не создаёт в живой базе. - -Проверяет: health, SPA, вход, настройки/доски, поиск (ответ), admin-тик, -FTS, счётчики и связку с автономным ML-сервисом. Файловые проверки MinIO — -в opt-in режиме SMOKE_MUTATE=1 (создают проект, который надо удалять вручную). -""" -import os -import sys - -import httpx - -BASE = "http://localhost:8000" -MUTATE = os.getenv("SMOKE_MUTATE", "") == "1" -ok = True - - -def check(name, cond, extra=""): - global ok - if not cond: - ok = False - print("FAIL:", name, extra) - else: - print("ok:", name) - - -with httpx.Client(base_url=BASE, timeout=30) as c: - check("health", c.get("/api/health").status_code == 200) - page = c.get("/") - check("spa", page.status_code == 200 and 'id="app"' in page.text) - - r = c.post("/api/auth/login", json={"login": "admin", "password": "admin"}) - check("login", r.status_code == 200 and c.cookies.get("leadradar_session")) - - boards = c.get("/api/boards").json() - check("boards api", isinstance(boards, list)) - settings = c.get("/api/settings").json() - check("settings", settings.get("mlEnabled") is True and settings.get("targetCurrency") == "RUB") - - # демо-механика в проде выключена - demo = c.post("/api/demo/simulate-lead") - check("demo disabled", demo.status_code == 404) - - # поиск отвечает (лиды создаются только из Telegram) - q = c.get("/api/search", params={"q": "Python"}).json() - check("search", isinstance(q.get("leads"), list)) - - # тик (правила хранения + очередь), FTS, счётчики - tick = c.post("/api/admin/tick").json() - check("tick", "pipeline" in tick and "queue" in tick) - check("fts rebuild", c.post("/api/admin/fts/rebuild").json().get("ready") is True) - counts = c.get("/api/leads/counts").json() - check("counts", "ml" in counts and "ai" in counts and "learning" in counts) - - # автономный ML-сервис (отдельный контейнер) через API основного приложения - ml = c.get("/api/ml/status").json() - check("ml reachable", ml.get("reachable") is True, ml) - pred = c.post("/api/ml/predict", json={"text": "python backend fastapi бот телеграм"}).json() - check("ml predict", isinstance(pred.get("scores"), dict)) - - if MUTATE: - # opt-in: проект + файл через MinIO (после проверки проект надо удалить) - card = c.post("/api/projects", json={"title": "SMOKE-TMP-удалить"}).json() - check("project", bool(card.get("id"))) - r = c.post(f"/api/projects/{card['id']}/files", files=[("files", ("smoke.txt", b"docker smoke", "text/plain"))]) - check("upload to MinIO", r.status_code == 200 and len(r.json().get("items", [])) == 1, r.text) - fid = r.json()["items"][0]["id"] - dl = c.get(f"/api/projects/{card['id']}/files/{fid}/download") - check("download from MinIO", dl.status_code == 200 and dl.content == b"docker smoke", dl.text[:100]) - - check("unauth me", c.get("/api/auth/me").status_code == 200) # кука жива - -print("DOCKER SMOKE OK" if ok else "DOCKER SMOKE FAILED") -sys.exit(0 if ok else 1) +"""Read-only смоук docker-инсталляции: ничего не создаёт в живой базе. + +Проверяет: health, SPA, вход, настройки/доски, поиск (ответ), admin-тик, +FTS, счётчики и связку с автономным ML-сервисом. Файловые проверки MinIO — +в opt-in режиме SMOKE_MUTATE=1 (создают проект, который надо удалять вручную). +""" +import os +import sys + +import httpx + +BASE = "http://localhost:8000" +MUTATE = os.getenv("SMOKE_MUTATE", "") == "1" +ok = True + + +def check(name, cond, extra=""): + global ok + if not cond: + ok = False + print("FAIL:", name, extra) + else: + print("ok:", name) + + +with httpx.Client(base_url=BASE, timeout=30) as c: + check("health", c.get("/api/health").status_code == 200) + page = c.get("/") + check("spa", page.status_code == 200 and 'id="app"' in page.text) + + r = c.post("/api/auth/login", json={"login": "admin", "password": "admin"}) + check("login", r.status_code == 200 and c.cookies.get("leadradar_session")) + + boards = c.get("/api/boards").json() + check("boards api", isinstance(boards, list)) + settings = c.get("/api/settings").json() + check("settings", settings.get("mlEnabled") is True and settings.get("targetCurrency") == "RUB") + + # демо-механика в проде выключена + demo = c.post("/api/demo/simulate-lead") + check("demo disabled", demo.status_code == 404) + + # поиск отвечает (лиды создаются только из Telegram) + q = c.get("/api/search", params={"q": "Python"}).json() + check("search", isinstance(q.get("leads"), list)) + + # тик (правила хранения + очередь), FTS, счётчики + tick = c.post("/api/admin/tick").json() + check("tick", "pipeline" in tick and "queue" in tick) + check("fts rebuild", c.post("/api/admin/fts/rebuild").json().get("ready") is True) + counts = c.get("/api/leads/counts").json() + check("counts", "ml" in counts and "ai" in counts and "learning" in counts) + + # автономный ML-сервис (отдельный контейнер) через API основного приложения + ml = c.get("/api/ml/status").json() + check("ml reachable", ml.get("reachable") is True, ml) + pred = c.post("/api/ml/predict", json={"text": "python backend fastapi бот телеграм"}).json() + check("ml predict", isinstance(pred.get("scores"), dict)) + + if MUTATE: + # opt-in: проект + файл через MinIO (после проверки проект надо удалить) + card = c.post("/api/projects", json={"title": "SMOKE-TMP-удалить"}).json() + check("project", bool(card.get("id"))) + r = c.post(f"/api/projects/{card['id']}/files", files=[("files", ("smoke.txt", b"docker smoke", "text/plain"))]) + check("upload to MinIO", r.status_code == 200 and len(r.json().get("items", [])) == 1, r.text) + fid = r.json()["items"][0]["id"] + dl = c.get(f"/api/projects/{card['id']}/files/{fid}/download") + check("download from MinIO", dl.status_code == 200 and dl.content == b"docker smoke", dl.text[:100]) + + check("unauth me", c.get("/api/auth/me").status_code == 200) # кука жива + +print("DOCKER SMOKE OK" if ok else "DOCKER SMOKE FAILED") +sys.exit(0 if ok else 1) diff --git a/archive/leadradar-legacy/backend/devtests/e2e_test.py b/archive/leadradar-legacy/backend/devtests/e2e_test.py index b488c4f..75b2272 100644 --- a/archive/leadradar-legacy/backend/devtests/e2e_test.py +++ b/archive/leadradar-legacy/backend/devtests/e2e_test.py @@ -1,175 +1,175 @@ -"""Временный e2e-тест по HTTP: реальный uvicorn + фронтовые API-вызовы.""" -import os -import subprocess -import sys -import tempfile -import time - -import httpx - -# devtests/ лежит внутри backend/ — backend нужен как cwd для uvicorn -tmp = tempfile.mkdtemp(prefix="leadradar_e2e_") -env = dict(os.environ) -env["LEADRADAR_DATA"] = tmp -env["LEADRADAR_DEMO"] = "1" - -BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - -server = subprocess.Popen( - [sys.executable, "-m", "uvicorn", "app.main:app", "--port", "8077", "--log-level", "warning"], - cwd=BACKEND_DIR, - env=env, -) - -BASE = "http://127.0.0.1:8077" -ok = True - - -def check(name, cond, extra=""): - global ok - if not cond: - ok = False - print("FAIL:", name, extra) - else: - print("ok:", name) - - -try: - for _ in range(40): - try: - r = httpx.get(BASE + "/api/health", timeout=1) - if r.status_code == 200: - break - except Exception: - time.sleep(0.5) - else: - raise SystemExit("server did not start") - - with httpx.Client(base_url=BASE, timeout=20) as c: - check("health", c.get("/api/health").status_code == 200) - - # вход - r = c.post("/api/auth/login", json={"login": "admin", "password": "admin"}) - check("login", r.status_code == 200) - check("cookie set", bool(c.cookies.get("leadradar_session"))) - - me = c.get("/api/auth/me") - check("me", me.status_code == 200 and me.json().get("login") == "admin") - - # стартовые данные: колонок нет по умолчанию — создаём одну через API - boards = c.get("/api/boards").json() - check("no default boards", boards == []) - bid = c.post("/api/boards", json={"name": "Python"}).json()["id"] - check("board created", bool(bid)) - settings = c.get("/api/settings").json() - check("settings public", settings.get("targetCurrency") == "RUB" and settings.get("aiProvider") == "deepseek") - - # демо-лид -> поиск - lead = c.post("/api/demo/simulate-lead").json() - check("demo lead", bool(lead.get("id")), str(lead)[:120]) - lid = lead["id"] - q = c.get("/api/search", params={"q": "Python"}).json() - check("search", isinstance(q.get("leads"), list)) - - # перенос на доску - r = c.post(f"/api/leads/{lid}/move", json={"to": bid}) - check("move to board", r.status_code == 200 and r.json().get("col") == bid, r.text) - r = c.post(f"/api/leads/{lid}/comments", json={"text": "Комментарий из e2e"}) - check("comment", r.status_code == 200 and len(r.json().get("comments", [])) == 1) - - # демо-старение -> архив - r = c.post("/api/demo/age-lead") - check("age-lead", r.status_code == 200, r.text) - leads = c.get("/api/leads").json()["items"] - arch = c.get("/api/leads", params={"col": "archive"}).json()["items"] - check("lead archived", any(l["id"] == lid for l in arch), f"leads={len(leads)}") - - # восстановление - r = c.post(f"/api/leads/{lid}/restore") - check("restore", r.status_code == 200 and r.json().get("col") in ("inbox", bid), r.text) - - # проекты: взять в работу - card = c.post("/api/projects/take", json={"leadId": lid}).json() - check("take to projects", bool(card.get("id")), str(card)[:120]) - cid = card["id"] - leads_after = c.get("/api/leads").json()["items"] - check("lead gone from board", all(l["id"] != lid for l in leads_after)) - - # стадия + комментарий + ссылка + ТЗ - r = c.post(f"/api/projects/{cid}/move", json={"stage": "reply"}) - check("stage reply", r.status_code == 200 and r.json().get("stage") == "reply") - r = c.post(f"/api/projects/{cid}/comments", json={"text": "Откликнулся"}) - check("proj comment", r.status_code == 200) - r = c.post(f"/api/projects/{cid}/links", json={"name": "Макет", "url": "figma.com/x"}) - check("proj link", r.status_code == 200 and r.json().get("links", [])[0]["url"].startswith("https://")) - r = c.patch(f"/api/projects/{cid}", json={"tzText": "ТЗ: интеграция с amoCRM"}) - check("proj tz", r.status_code == 200 and r.json().get("tzText") == "ТЗ: интеграция с amoCRM") - - # файл (локальный fallback без MinIO) + скачивание - r = c.post(f"/api/projects/{cid}/files", files=[("files", ("tz.pdf", b"%PDF-1.4 test", "application/pdf"))]) - check("file upload", r.status_code == 200 and len(r.json().get("items", [])) == 1, r.text) - file_id = r.json()["items"][0]["id"] - dl = c.get(f"/api/projects/{cid}/files/{file_id}/download") - check("file download", dl.status_code == 200 and dl.content == b"%PDF-1.4 test", dl.text[:80]) - r = c.delete(f"/api/projects/{cid}/files/{file_id}") - check("file remove", r.status_code == 200) - - # локальная карточка - loc = c.post("/api/projects", json={"title": "", "stack": ["Go"]}).json() - check("local card", loc.get("local") is True and loc.get("title") == "") - - # напоминание (hold) - r = c.post(f"/api/projects/{cid}/move", json={"stage": "hold"}) - check("stage hold", r.status_code == 200) - at = int(time.time() * 1000) + 60000 - r = c.post(f"/api/projects/{cid}/reminder", json={"at": at}) - check("set reminder", r.status_code == 200 and r.json().get("reminder", {}).get("at") == at, r.text) - rem = c.get("/api/projects/reminders").json()["items"] - check("active reminders", any(x["id"] == cid for x in rem)) - - # настройки валюты и пересчёт - r = c.patch("/api/settings", json={"targetCurrency": "RUB", "conversionOn": True}) - check("settings patch", r.status_code == 200) - rates = c.get("/api/rates").json() - check("rates have USD", "USD" in rates.get("rates", {})) - - # mark-col-seen (новый эндпоинт) - r = c.post("/api/leads/mark-col-seen", json={"col": "inbox"}) - check("mark col seen", r.status_code == 200) - - # ручная полная очистка «Отклонено» (проектные карточки) - r = c.post(f"/api/projects/{cid}/move", json={"stage": "rejected"}) - check("stage rejected", r.status_code == 200) - r = c.post("/api/projects/clear-rejected") - check("clear rejected", r.status_code == 200 and r.json().get("cleared", 0) >= 1, r.text) - gone = c.get("/api/projects").json()["items"] - check("rejected gone", all(x["id"] != cid for x in gone)) - - # ручная полная очистка корзины (лиды дашборда) - d2 = c.post("/api/demo/simulate-lead").json() - c.post(f"/api/leads/{d2['id']}/trash") - trash_items = c.get("/api/leads", params={"col": "trash"}).json()["items"] - check("trash has lead", any(x["id"] == d2["id"] for x in trash_items)) - r = c.post("/api/leads/clear-col", json={"col": "trash"}) - check("clear trash", r.status_code == 200 and r.json().get("cleared", 0) >= 1, r.text) - trash_items = c.get("/api/leads", params={"col": "trash"}).json()["items"] - check("trash empty", len(trash_items) == 0) - # архив: тот же эндпоинт (сейчас пуст — просто валидируем) - r = c.post("/api/leads/clear-col", json={"col": "archive"}) - check("clear archive", r.status_code == 200) - - # FTS rebuild - r = c.post("/api/admin/fts/rebuild") - check("fts rebuild", r.status_code == 200 and r.json().get("ready") is True, r.text) - - # статика фронтенда из dist - page = c.get("/") - check("spa served", page.status_code == 200 and "
" in page.text) - - print("E2E OK" if ok else "E2E FAILED") -finally: - server.terminate() - try: - server.wait(timeout=10) - except Exception: - server.kill() +"""Временный e2e-тест по HTTP: реальный uvicorn + фронтовые API-вызовы.""" +import os +import subprocess +import sys +import tempfile +import time + +import httpx + +# devtests/ лежит внутри backend/ — backend нужен как cwd для uvicorn +tmp = tempfile.mkdtemp(prefix="leadradar_e2e_") +env = dict(os.environ) +env["LEADRADAR_DATA"] = tmp +env["LEADRADAR_DEMO"] = "1" + +BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +server = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "app.main:app", "--port", "8077", "--log-level", "warning"], + cwd=BACKEND_DIR, + env=env, +) + +BASE = "http://127.0.0.1:8077" +ok = True + + +def check(name, cond, extra=""): + global ok + if not cond: + ok = False + print("FAIL:", name, extra) + else: + print("ok:", name) + + +try: + for _ in range(40): + try: + r = httpx.get(BASE + "/api/health", timeout=1) + if r.status_code == 200: + break + except Exception: + time.sleep(0.5) + else: + raise SystemExit("server did not start") + + with httpx.Client(base_url=BASE, timeout=20) as c: + check("health", c.get("/api/health").status_code == 200) + + # вход + r = c.post("/api/auth/login", json={"login": "admin", "password": "admin"}) + check("login", r.status_code == 200) + check("cookie set", bool(c.cookies.get("leadradar_session"))) + + me = c.get("/api/auth/me") + check("me", me.status_code == 200 and me.json().get("login") == "admin") + + # стартовые данные: колонок нет по умолчанию — создаём одну через API + boards = c.get("/api/boards").json() + check("no default boards", boards == []) + bid = c.post("/api/boards", json={"name": "Python"}).json()["id"] + check("board created", bool(bid)) + settings = c.get("/api/settings").json() + check("settings public", settings.get("targetCurrency") == "RUB" and settings.get("aiProvider") == "deepseek") + + # демо-лид -> поиск + lead = c.post("/api/demo/simulate-lead").json() + check("demo lead", bool(lead.get("id")), str(lead)[:120]) + lid = lead["id"] + q = c.get("/api/search", params={"q": "Python"}).json() + check("search", isinstance(q.get("leads"), list)) + + # перенос на доску + r = c.post(f"/api/leads/{lid}/move", json={"to": bid}) + check("move to board", r.status_code == 200 and r.json().get("col") == bid, r.text) + r = c.post(f"/api/leads/{lid}/comments", json={"text": "Комментарий из e2e"}) + check("comment", r.status_code == 200 and len(r.json().get("comments", [])) == 1) + + # демо-старение -> архив + r = c.post("/api/demo/age-lead") + check("age-lead", r.status_code == 200, r.text) + leads = c.get("/api/leads").json()["items"] + arch = c.get("/api/leads", params={"col": "archive"}).json()["items"] + check("lead archived", any(l["id"] == lid for l in arch), f"leads={len(leads)}") + + # восстановление + r = c.post(f"/api/leads/{lid}/restore") + check("restore", r.status_code == 200 and r.json().get("col") in ("inbox", bid), r.text) + + # проекты: взять в работу + card = c.post("/api/projects/take", json={"leadId": lid}).json() + check("take to projects", bool(card.get("id")), str(card)[:120]) + cid = card["id"] + leads_after = c.get("/api/leads").json()["items"] + check("lead gone from board", all(l["id"] != lid for l in leads_after)) + + # стадия + комментарий + ссылка + ТЗ + r = c.post(f"/api/projects/{cid}/move", json={"stage": "reply"}) + check("stage reply", r.status_code == 200 and r.json().get("stage") == "reply") + r = c.post(f"/api/projects/{cid}/comments", json={"text": "Откликнулся"}) + check("proj comment", r.status_code == 200) + r = c.post(f"/api/projects/{cid}/links", json={"name": "Макет", "url": "figma.com/x"}) + check("proj link", r.status_code == 200 and r.json().get("links", [])[0]["url"].startswith("https://")) + r = c.patch(f"/api/projects/{cid}", json={"tzText": "ТЗ: интеграция с amoCRM"}) + check("proj tz", r.status_code == 200 and r.json().get("tzText") == "ТЗ: интеграция с amoCRM") + + # файл (локальный fallback без MinIO) + скачивание + r = c.post(f"/api/projects/{cid}/files", files=[("files", ("tz.pdf", b"%PDF-1.4 test", "application/pdf"))]) + check("file upload", r.status_code == 200 and len(r.json().get("items", [])) == 1, r.text) + file_id = r.json()["items"][0]["id"] + dl = c.get(f"/api/projects/{cid}/files/{file_id}/download") + check("file download", dl.status_code == 200 and dl.content == b"%PDF-1.4 test", dl.text[:80]) + r = c.delete(f"/api/projects/{cid}/files/{file_id}") + check("file remove", r.status_code == 200) + + # локальная карточка + loc = c.post("/api/projects", json={"title": "", "stack": ["Go"]}).json() + check("local card", loc.get("local") is True and loc.get("title") == "") + + # напоминание (hold) + r = c.post(f"/api/projects/{cid}/move", json={"stage": "hold"}) + check("stage hold", r.status_code == 200) + at = int(time.time() * 1000) + 60000 + r = c.post(f"/api/projects/{cid}/reminder", json={"at": at}) + check("set reminder", r.status_code == 200 and r.json().get("reminder", {}).get("at") == at, r.text) + rem = c.get("/api/projects/reminders").json()["items"] + check("active reminders", any(x["id"] == cid for x in rem)) + + # настройки валюты и пересчёт + r = c.patch("/api/settings", json={"targetCurrency": "RUB", "conversionOn": True}) + check("settings patch", r.status_code == 200) + rates = c.get("/api/rates").json() + check("rates have USD", "USD" in rates.get("rates", {})) + + # mark-col-seen (новый эндпоинт) + r = c.post("/api/leads/mark-col-seen", json={"col": "inbox"}) + check("mark col seen", r.status_code == 200) + + # ручная полная очистка «Отклонено» (проектные карточки) + r = c.post(f"/api/projects/{cid}/move", json={"stage": "rejected"}) + check("stage rejected", r.status_code == 200) + r = c.post("/api/projects/clear-rejected") + check("clear rejected", r.status_code == 200 and r.json().get("cleared", 0) >= 1, r.text) + gone = c.get("/api/projects").json()["items"] + check("rejected gone", all(x["id"] != cid for x in gone)) + + # ручная полная очистка корзины (лиды дашборда) + d2 = c.post("/api/demo/simulate-lead").json() + c.post(f"/api/leads/{d2['id']}/trash") + trash_items = c.get("/api/leads", params={"col": "trash"}).json()["items"] + check("trash has lead", any(x["id"] == d2["id"] for x in trash_items)) + r = c.post("/api/leads/clear-col", json={"col": "trash"}) + check("clear trash", r.status_code == 200 and r.json().get("cleared", 0) >= 1, r.text) + trash_items = c.get("/api/leads", params={"col": "trash"}).json()["items"] + check("trash empty", len(trash_items) == 0) + # архив: тот же эндпоинт (сейчас пуст — просто валидируем) + r = c.post("/api/leads/clear-col", json={"col": "archive"}) + check("clear archive", r.status_code == 200) + + # FTS rebuild + r = c.post("/api/admin/fts/rebuild") + check("fts rebuild", r.status_code == 200 and r.json().get("ready") is True, r.text) + + # статика фронтенда из dist + page = c.get("/") + check("spa served", page.status_code == 200 and "
" in page.text) + + print("E2E OK" if ok else "E2E FAILED") +finally: + server.terminate() + try: + server.wait(timeout=10) + except Exception: + server.kill() diff --git a/archive/leadradar-legacy/backend/devtests/pipeline_test.py b/archive/leadradar-legacy/backend/devtests/pipeline_test.py index 3edbba1..b27d3ff 100644 --- a/archive/leadradar-legacy/backend/devtests/pipeline_test.py +++ b/archive/leadradar-legacy/backend/devtests/pipeline_test.py @@ -1,164 +1,164 @@ -"""Тест: автономный ML-сервис + очередь входящих (этап1 -> ML -> ИИ) + outbox. - -Запускает локальный ML-сервис (mlservice/server.py) на порту 8121, учит его -напрямую по HTTP и проверяет весь контур основного приложения: - * обучение всегда идёт через outbox (даже при выключенном ML в пайплайне); - * /api/ml/predict, /api/ml/candidates, /api/ml/apply; - * очередь: stop-фраза -> удаляется; ML уверен -> карточка сразу на доску. -""" -import os -import subprocess -import sys -import tempfile -import time - -import httpx - -TMP = tempfile.mkdtemp(prefix="leadradar_pl_") -os.environ["LEADRADAR_DATA"] = os.path.join(TMP, "app") -os.environ["LEADRADAR_ML_URL"] = "http://127.0.0.1:8121" -os.environ["LEADRADAR_DEMO"] = "1" - -BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -ML_DIR = os.path.join(os.path.dirname(BACKEND_DIR), "mlservice") - -# ── локальный ML-сервис ─────────────────────────────────────────────────── -ml_env = dict(os.environ) -ml_env["ML_DATA"] = os.path.join(TMP, "ml.duckdb") -ml_server = subprocess.Popen( - [sys.executable, "-m", "uvicorn", "server:app", "--port", "8121", "--log-level", "warning"], - cwd=ML_DIR, - env=ml_env, -) - -try: - for _ in range(50): - try: - if httpx.get("http://127.0.0.1:8121/health", timeout=1).status_code == 200: - break - except Exception: - time.sleep(0.3) - else: - raise SystemExit("ml service did not start") - - sys.path.insert(0, BACKEND_DIR) - from fastapi.testclient import TestClient # noqa: E402 - - from app.main import app # noqa: E402 - from app.services import pipeline as pl # noqa: E402 - - ML = "http://127.0.0.1:8121" - - SPAM_TEXTS = [ - "Заработок на крипте 300 процентов в месяц гарантировано подпишись на канал", - "Инвестируй в наш фонд и получай пассивный доход каждый день без риска", - "Трейдинг бот приносит 1000 долларов в день забери свою прибыль сейчас", - "Бесплатный курс по заработку на бирже забери по ссылке внизу поста", - "Приглашаю в закрытый чат заработка пассивно без вложений начни сегодня", - "Схема быстрого заработка на арбитраже крипты без риска все проверено", - "Купи сигналы на форекс и зарабатывай миллионы пока все спят от нас", - "Пирамида дохода открыла набор новых участников успей вложиться", - ] - PY_TEXTS = [ - "Нужен Python разработчик для телеграм бота парсера маркетплейсов удаленно", - "Ищем middle python бекенд разработчика на fastapi для стартапа удаленка", - "Задача для python джуна написать скрипт парсинга авито с антидетектом", - "Python разработчик на django проект CRM интеграция с телеграм ботом", - "Нужен python программист для автоматизации отчетов и бота в телеграм", - "Срочно python разработчик aiogram телеграм бот для интернет магазина", - "Python backend для API на fastapi микросервисы postgres kafka", - "Разработчик python на скрапинг каталогов маркетплейсов выгрузка в excel", - "Ищем python специалиста для интеграции с мессенджерами и crm", - "Python разработчик на парсер и бота оплата достойная сразу в лс", - ] - FRONT_TEXTS = [ - "Frontend разработчик vuejs для корпоративного портала удаленная работа", - "Нужен верстальщик реакт для интернет магазина срочно до конца недели", - "Ищем frontend специалиста vue3 typescript компоненты дизайн система", - "Задача для фронтендера сверстать адаптивный лендинг на nuxtjs", - "Frontend разработчик react nextjs для панели администратора стартапа", - "Верстка писем и лендингов html css для маркетинговых рассылок заказ", - "Нужен vue разработчик доработка фронта для телеграм мини апп", - "Frontend инженер angular для банковского приложения гибрид офис", - ] - - # учим ML-сервис напрямую (как если бы фоновый воркер отправил outbox) - # — перенесено внутрь with: метки = id колонок, которые создаём через API - with TestClient(app) as client: - assert client.post("/api/auth/login", json={"login": "admin", "password": "admin"}).status_code == 200 - client.patch("/api/settings", json={"aiProvider": "ollama", "mlEnabled": True}) - # колонок по умолчанию нет — создаём Python и Frontend - py = client.post("/api/boards", json={"name": "Python"}).json()["id"] - fr = client.post("/api/boards", json={"name": "Frontend"}).json()["id"] - for t in PY_TEXTS + FRONT_TEXTS: - httpx.post(ML + "/learn", json={"label": py if t in PY_TEXTS else fr, "text": t}) - for t in SPAM_TEXTS: - httpx.post(ML + "/learn", json={"label": "spam", "text": t}) - - def wait_until(pred, seconds=8): - """Фоновый воркер разбирает очередь сам — опрашиваем до наступления условия.""" - deadline = time.time() + seconds - last = None - while time.time() < deadline: - last = client.post("/api/admin/tick").json() - if pred(): - return last - time.sleep(0.5) - raise AssertionError("условие не наступило: %s" % pred()) - - def leads(): - return client.get("/api/leads").json()["items"] - - # статус/предсказание через основное приложение - st = client.get("/api/ml/status").json() - assert st["reachable"] and st["service"]["ready"], st - p = client.post("/api/ml/predict", json={"text": "Python backend на fastapi парсер телеграм удаленно"}).json() - assert p["take"] and p["label"] == py, p - print("ml status/predict ok") - - # стоп-фраза -> удаляется из очереди, ничего не оседает - pl.enqueue("d1", "Канал А", "", "#333", 1, "Ищу работу на неделю, вот моё резюме и портфолио для отклика", 1_788_000_000_000) - assert pl.queue_len() == 1 - wait_until(lambda: pl.queue_len() == 0) - assert len(leads()) == 0 - print("stop-phrase drop ok") - - # ML уверен -> карточка сразу на доску (без ИИ), спам -> удаление - pl.enqueue("d1", "Канал А", "", "#333", 3, "Python backend на fastapi для стартапа, бот в телеграм, удалённо", 1_788_000_200_000) - pl.enqueue("d1", "Канал А", "", "#333", 4, "Заработок на крипте инвестируй в наш фонд пассивный доход каждый день", 1_788_000_300_000) - wait_until(lambda: pl.queue_len() == 0 and any(l["col"] == py for l in leads())) - card = [l for l in leads() if l["col"] == py and l["sourceMsgId"] == 3] - assert len(card) == 1, leads() - print("ml fast-path ok") - - # ручная разметка apply: spam -> карточка в корзину + обучение (outbox) - r = client.post("/api/ml/apply", json={"dialogId": "d1", "msgId": 3, "action": "spam"}).json() - assert r["learned"] is True and r["moved"] == "trash", r - assert client.get("/api/ml/status").json()["stats"]["outbox"] >= 1 - # flush -> обучение уехало в ML-сервис - f = client.post("/api/ml/flush").json() - assert f["outbox"] == 0 and f["flushed"] >= 1, f - print("apply + outbox ok") - - # обучение идёт всегда, даже если ML выключен в пайплайне - pl.enqueue("d1", "Канал А", "", "#333", 7, "Frontend vue разработка интерфейса компоненты верстка реакт удаленно", 1_788_000_500_000) - wait_until(lambda: any(l["col"] == fr for l in leads())) - lead = [l for l in leads() if l["col"] == fr][0] - boards = client.get("/api/boards").json() - client.patch("/api/settings", json={"mlEnabled": False}) - client.post(f"/api/leads/{lead['id']}/move", json={"to": py}) - assert client.get("/api/ml/status").json()["stats"]["outbox"] == 1 - print("learn-always (ml off) ok") - - # кандидаты канала: последние сообщения (после демо-лида их нет в TG -> fallback) - cand = client.post("/api/ml/candidates", json={"dialogId": "d1", "limit": 5}).json() - assert isinstance(cand.get("items"), list) - print("candidates ok") - - print("PIPELINE TEST OK") -finally: - ml_server.terminate() - try: - ml_server.wait(timeout=10) - except Exception: - ml_server.kill() +"""Тест: автономный ML-сервис + очередь входящих (этап1 -> ML -> ИИ) + outbox. + +Запускает локальный ML-сервис (mlservice/server.py) на порту 8121, учит его +напрямую по HTTP и проверяет весь контур основного приложения: + * обучение всегда идёт через outbox (даже при выключенном ML в пайплайне); + * /api/ml/predict, /api/ml/candidates, /api/ml/apply; + * очередь: stop-фраза -> удаляется; ML уверен -> карточка сразу на доску. +""" +import os +import subprocess +import sys +import tempfile +import time + +import httpx + +TMP = tempfile.mkdtemp(prefix="leadradar_pl_") +os.environ["LEADRADAR_DATA"] = os.path.join(TMP, "app") +os.environ["LEADRADAR_ML_URL"] = "http://127.0.0.1:8121" +os.environ["LEADRADAR_DEMO"] = "1" + +BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ML_DIR = os.path.join(os.path.dirname(BACKEND_DIR), "mlservice") + +# ── локальный ML-сервис ─────────────────────────────────────────────────── +ml_env = dict(os.environ) +ml_env["ML_DATA"] = os.path.join(TMP, "ml.duckdb") +ml_server = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "server:app", "--port", "8121", "--log-level", "warning"], + cwd=ML_DIR, + env=ml_env, +) + +try: + for _ in range(50): + try: + if httpx.get("http://127.0.0.1:8121/health", timeout=1).status_code == 200: + break + except Exception: + time.sleep(0.3) + else: + raise SystemExit("ml service did not start") + + sys.path.insert(0, BACKEND_DIR) + from fastapi.testclient import TestClient # noqa: E402 + + from app.main import app # noqa: E402 + from app.services import pipeline as pl # noqa: E402 + + ML = "http://127.0.0.1:8121" + + SPAM_TEXTS = [ + "Заработок на крипте 300 процентов в месяц гарантировано подпишись на канал", + "Инвестируй в наш фонд и получай пассивный доход каждый день без риска", + "Трейдинг бот приносит 1000 долларов в день забери свою прибыль сейчас", + "Бесплатный курс по заработку на бирже забери по ссылке внизу поста", + "Приглашаю в закрытый чат заработка пассивно без вложений начни сегодня", + "Схема быстрого заработка на арбитраже крипты без риска все проверено", + "Купи сигналы на форекс и зарабатывай миллионы пока все спят от нас", + "Пирамида дохода открыла набор новых участников успей вложиться", + ] + PY_TEXTS = [ + "Нужен Python разработчик для телеграм бота парсера маркетплейсов удаленно", + "Ищем middle python бекенд разработчика на fastapi для стартапа удаленка", + "Задача для python джуна написать скрипт парсинга авито с антидетектом", + "Python разработчик на django проект CRM интеграция с телеграм ботом", + "Нужен python программист для автоматизации отчетов и бота в телеграм", + "Срочно python разработчик aiogram телеграм бот для интернет магазина", + "Python backend для API на fastapi микросервисы postgres kafka", + "Разработчик python на скрапинг каталогов маркетплейсов выгрузка в excel", + "Ищем python специалиста для интеграции с мессенджерами и crm", + "Python разработчик на парсер и бота оплата достойная сразу в лс", + ] + FRONT_TEXTS = [ + "Frontend разработчик vuejs для корпоративного портала удаленная работа", + "Нужен верстальщик реакт для интернет магазина срочно до конца недели", + "Ищем frontend специалиста vue3 typescript компоненты дизайн система", + "Задача для фронтендера сверстать адаптивный лендинг на nuxtjs", + "Frontend разработчик react nextjs для панели администратора стартапа", + "Верстка писем и лендингов html css для маркетинговых рассылок заказ", + "Нужен vue разработчик доработка фронта для телеграм мини апп", + "Frontend инженер angular для банковского приложения гибрид офис", + ] + + # учим ML-сервис напрямую (как если бы фоновый воркер отправил outbox) + # — перенесено внутрь with: метки = id колонок, которые создаём через API + with TestClient(app) as client: + assert client.post("/api/auth/login", json={"login": "admin", "password": "admin"}).status_code == 200 + client.patch("/api/settings", json={"aiProvider": "ollama", "mlEnabled": True}) + # колонок по умолчанию нет — создаём Python и Frontend + py = client.post("/api/boards", json={"name": "Python"}).json()["id"] + fr = client.post("/api/boards", json={"name": "Frontend"}).json()["id"] + for t in PY_TEXTS + FRONT_TEXTS: + httpx.post(ML + "/learn", json={"label": py if t in PY_TEXTS else fr, "text": t}) + for t in SPAM_TEXTS: + httpx.post(ML + "/learn", json={"label": "spam", "text": t}) + + def wait_until(pred, seconds=8): + """Фоновый воркер разбирает очередь сам — опрашиваем до наступления условия.""" + deadline = time.time() + seconds + last = None + while time.time() < deadline: + last = client.post("/api/admin/tick").json() + if pred(): + return last + time.sleep(0.5) + raise AssertionError("условие не наступило: %s" % pred()) + + def leads(): + return client.get("/api/leads").json()["items"] + + # статус/предсказание через основное приложение + st = client.get("/api/ml/status").json() + assert st["reachable"] and st["service"]["ready"], st + p = client.post("/api/ml/predict", json={"text": "Python backend на fastapi парсер телеграм удаленно"}).json() + assert p["take"] and p["label"] == py, p + print("ml status/predict ok") + + # стоп-фраза -> удаляется из очереди, ничего не оседает + pl.enqueue("d1", "Канал А", "", "#333", 1, "Ищу работу на неделю, вот моё резюме и портфолио для отклика", 1_788_000_000_000) + assert pl.queue_len() == 1 + wait_until(lambda: pl.queue_len() == 0) + assert len(leads()) == 0 + print("stop-phrase drop ok") + + # ML уверен -> карточка сразу на доску (без ИИ), спам -> удаление + pl.enqueue("d1", "Канал А", "", "#333", 3, "Python backend на fastapi для стартапа, бот в телеграм, удалённо", 1_788_000_200_000) + pl.enqueue("d1", "Канал А", "", "#333", 4, "Заработок на крипте инвестируй в наш фонд пассивный доход каждый день", 1_788_000_300_000) + wait_until(lambda: pl.queue_len() == 0 and any(l["col"] == py for l in leads())) + card = [l for l in leads() if l["col"] == py and l["sourceMsgId"] == 3] + assert len(card) == 1, leads() + print("ml fast-path ok") + + # ручная разметка apply: spam -> карточка в корзину + обучение (outbox) + r = client.post("/api/ml/apply", json={"dialogId": "d1", "msgId": 3, "action": "spam"}).json() + assert r["learned"] is True and r["moved"] == "trash", r + assert client.get("/api/ml/status").json()["stats"]["outbox"] >= 1 + # flush -> обучение уехало в ML-сервис + f = client.post("/api/ml/flush").json() + assert f["outbox"] == 0 and f["flushed"] >= 1, f + print("apply + outbox ok") + + # обучение идёт всегда, даже если ML выключен в пайплайне + pl.enqueue("d1", "Канал А", "", "#333", 7, "Frontend vue разработка интерфейса компоненты верстка реакт удаленно", 1_788_000_500_000) + wait_until(lambda: any(l["col"] == fr for l in leads())) + lead = [l for l in leads() if l["col"] == fr][0] + boards = client.get("/api/boards").json() + client.patch("/api/settings", json={"mlEnabled": False}) + client.post(f"/api/leads/{lead['id']}/move", json={"to": py}) + assert client.get("/api/ml/status").json()["stats"]["outbox"] == 1 + print("learn-always (ml off) ok") + + # кандидаты канала: последние сообщения (после демо-лида их нет в TG -> fallback) + cand = client.post("/api/ml/candidates", json={"dialogId": "d1", "limit": 5}).json() + assert isinstance(cand.get("items"), list) + print("candidates ok") + + print("PIPELINE TEST OK") +finally: + ml_server.terminate() + try: + ml_server.wait(timeout=10) + except Exception: + ml_server.kill() diff --git a/archive/leadradar-legacy/backend/requirements.txt b/archive/leadradar-legacy/backend/requirements.txt index 54b4c0c..577422f 100644 --- a/archive/leadradar-legacy/backend/requirements.txt +++ b/archive/leadradar-legacy/backend/requirements.txt @@ -1,9 +1,9 @@ -fastapi==0.115.12 -uvicorn[standard]==0.34.2 -duckdb==1.2.1 -telethon==1.37.0 -httpx==0.28.1 -python-multipart==0.0.20 -minio==7.2.15 -cryptography==44.0.3 -qrcode==7.4.2 +fastapi==0.115.12 +uvicorn[standard]==0.34.2 +duckdb==1.2.1 +telethon==1.37.0 +httpx==0.28.1 +python-multipart==0.0.20 +minio==7.2.15 +cryptography==44.0.3 +qrcode==7.4.2 diff --git a/archive/leadradar-legacy/docker-compose.yml b/archive/leadradar-legacy/docker-compose.yml index 32146a3..12098ce 100644 --- a/archive/leadradar-legacy/docker-compose.yml +++ b/archive/leadradar-legacy/docker-compose.yml @@ -1,47 +1,47 @@ -services: - app: - build: - context: . - dockerfile: backend/Dockerfile - container_name: leadradar - restart: unless-stopped - env_file: - - .env - environment: - # По ТЗ в env — несекретные параметры + MinIO/ключ шифрования (см. договорённости) - LEADRADAR_DATA: /data - LEADRADAR_PORT: "8000" - LEADRADAR_MINIO_ENDPOINT: "minio:9000" - LEADRADAR_ML_URL: "http://ml:8100" - volumes: - - ./data:/data # DuckDB + telegram-сессии + вложения - depends_on: - - minio - - ml - ports: - - "${LEADRADAR_PORT:-8000}:8000" - - ml: - build: ./mlservice - container_name: leadradar-ml - restart: unless-stopped - environment: - ML_DATA: /data/ml.duckdb # собственная модель (volume), наружу порт не публикуется - volumes: - - ./data/ml:/data - - minio: - image: minio/minio:latest - container_name: leadradar-minio - command: server /data --console-address ":9001" - restart: unless-stopped - env_file: - - .env - environment: - MINIO_ROOT_USER: ${LEADRADAR_MINIO_ACCESS_KEY:-leadradar} - MINIO_ROOT_PASSWORD: ${LEADRADAR_MINIO_SECRET_KEY:-leadradar-secret} - volumes: - - ./data/minio:/data - ports: - - "9000:9000" - - "9001:9001" +services: + app: + build: + context: . + dockerfile: backend/Dockerfile + container_name: leadradar + restart: unless-stopped + env_file: + - .env + environment: + # По ТЗ в env — несекретные параметры + MinIO/ключ шифрования (см. договорённости) + LEADRADAR_DATA: /data + LEADRADAR_PORT: "8000" + LEADRADAR_MINIO_ENDPOINT: "minio:9000" + LEADRADAR_ML_URL: "http://ml:8100" + volumes: + - ./data:/data # DuckDB + telegram-сессии + вложения + depends_on: + - minio + - ml + ports: + - "${LEADRADAR_PORT:-8000}:8000" + + ml: + build: ./mlservice + container_name: leadradar-ml + restart: unless-stopped + environment: + ML_DATA: /data/ml.duckdb # собственная модель (volume), наружу порт не публикуется + volumes: + - ./data/ml:/data + + minio: + image: minio/minio:latest + container_name: leadradar-minio + command: server /data --console-address ":9001" + restart: unless-stopped + env_file: + - .env + environment: + MINIO_ROOT_USER: ${LEADRADAR_MINIO_ACCESS_KEY:-leadradar} + MINIO_ROOT_PASSWORD: ${LEADRADAR_MINIO_SECRET_KEY:-leadradar-secret} + volumes: + - ./data/minio:/data + ports: + - "9000:9000" + - "9001:9001" diff --git a/archive/leadradar-legacy/mlservice/.dockerignore b/archive/leadradar-legacy/mlservice/.dockerignore index 7c62190..de89fad 100644 --- a/archive/leadradar-legacy/mlservice/.dockerignore +++ b/archive/leadradar-legacy/mlservice/.dockerignore @@ -1,3 +1,3 @@ -__pycache__ -*.pyc -data +__pycache__ +*.pyc +data diff --git a/archive/leadradar-legacy/mlservice/Dockerfile b/archive/leadradar-legacy/mlservice/Dockerfile index a67448e..6b29848 100644 --- a/archive/leadradar-legacy/mlservice/Dockerfile +++ b/archive/leadradar-legacy/mlservice/Dockerfile @@ -1,13 +1,13 @@ -FROM python:3.12-slim -WORKDIR /ml - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY model.py server.py ./ - -ENV ML_DATA=/data \ - PYTHONUNBUFFERED=1 - -EXPOSE 8100 -CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8100"] +FROM python:3.12-slim +WORKDIR /ml + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY model.py server.py ./ + +ENV ML_DATA=/data \ + PYTHONUNBUFFERED=1 + +EXPOSE 8100 +CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8100"] diff --git a/archive/leadradar-legacy/mlservice/model.py b/archive/leadradar-legacy/mlservice/model.py index 492206c..7097385 100644 --- a/archive/leadradar-legacy/mlservice/model.py +++ b/archive/leadradar-legacy/mlservice/model.py @@ -1,353 +1,353 @@ -"""Автономная ML-модель (наивный Байес по словам) для LeadRadar. - -Хранилище — собственный DuckDB-файл (volume). Модель живёт в отдельном -контейнере и общается с основным приложением по HTTP: - * /learn, /learn-batch — обучение (всегда, независимо от настроек UI); - * /predict — предсказание (используется, только если mlEnabled); - * /status — готовность и статистика. -""" -from __future__ import annotations - -import os -import re -import threading -import time - -import duckdb - -# Пороги уверенности: консервативные на старте, смягчаются по мере накопления -# опыта (см. _adaptive_margin) — ML постепенно берёт на себя больше работы. -MIN_TOTAL = 20 # суммарно примеров по всем классам, чтобы модель «включилась» -MIN_WINNER = 6 # минимум примеров у класса-победителя -MIN_WINNER_SPAM = 4 -MIN_HITS = 2 # минимум различных терминов, встреченных у победителя -MARGIN = 0.9 # ln-отрыв от второго класса на старте - -# Самооценка «справляется ли ML»: каждый реальный (пользовательский) обучающий -# сигнал сверяется с текущим предсказанием модели. В /status отдаётся окно -# последних решений — по нему UI подсказывает, что ИИ можно отключить. -EVAL_WINDOW = 50 # сколько последних решений показываем -EVAL_KEEP = 200 # сколько храним в БД модели - -TOKEN_RE = re.compile(r"[a-zа-яё0-9@+.#]+", re.I) -# Ссылки и markdown-ссылки не должны влиять ни на обучение, ни на предсказание: -# иначе модель учит мусор из URL (utm, source, campaign…) и режет по нему заявки. -_LINK_RE = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+|\[[^\]]*\]\([^)\s]+\)") - -DATA_PATH = os.getenv("ML_DATA", "./data/ml.duckdb") -_lock = threading.RLock() -_con: duckdb.DuckDBPyConnection | None = None - - -def _adaptive_margin(total: float) -> float: - """Отрыв от второго класса, требуемый для самостоятельного решения. - - Чем больше примеров ML уже видела, тем ниже порог — модель набирается - опыта и постепенно заменяет ИИ на типовых сообщениях. На старте порог - консервативный (0.9), после ~400 примеров — 0.35. - """ - if total >= 400: - return 0.35 - if total >= 150: - return 0.5 - if total >= 60: - return 0.7 - return MARGIN - - -def _db() -> duckdb.DuckDBPyConnection: - global _con - with _lock: - if _con is None: - os.makedirs(os.path.dirname(DATA_PATH) or ".", exist_ok=True) - _con = duckdb.connect(DATA_PATH) - _con.execute( - "CREATE TABLE IF NOT EXISTS classes (label VARCHAR PRIMARY KEY, n DOUBLE NOT NULL DEFAULT 0, updated_at BIGINT)" - ) - _con.execute( - "CREATE TABLE IF NOT EXISTS terms (label VARCHAR NOT NULL, term VARCHAR NOT NULL, count DOUBLE NOT NULL DEFAULT 0, " - "PRIMARY KEY (label, term))" - ) - _con.execute( - "CREATE TABLE IF NOT EXISTS eval_log (created_at BIGINT NOT NULL, " - "expected VARCHAR NOT NULL, predicted VARCHAR NOT NULL DEFAULT '', correct BOOLEAN NOT NULL)" - ) - return _con - - -def tokenize(text: str) -> list[str]: - text = _LINK_RE.sub(" ", str(text or "")) - words = [w.lower() for w in TOKEN_RE.findall(text)] - out: list[str] = [] - for w in words: - if len(w) >= 3: - out.append(w) - if len(w) >= 6: - out.append("~" + w[:4]) - return out - - -def totals() -> dict[str, float]: - with _lock: - rows = _db().execute("SELECT label, n FROM classes WHERE n > 0").fetchall() - return {r[0]: float(r[1]) for r in rows} - - -def ready() -> bool: - t = totals() - total = sum(t.values()) - if total < MIN_TOTAL: - return False - non_spam = {k: v for k, v in t.items() if k != "spam"} - return t.get("spam", 0) >= MIN_WINNER_SPAM and sum(non_spam.values()) >= MIN_WINNER - - -def _upsert_one(db, label: str, text: str, delta: float) -> None: - """Обновление одного обучающего примера (вызывается внутри транзакции). - - Термины пишутся пакетно (executemany), а не по одному INSERT — на большой - модели построчная вставка занимает десятки секунд и блокирует /status и - /predict, из-за чего сервис «выглядит недоступным». - """ - label = str(label) - if not text or not label: - return - now_ms = int(time.time() * 1000) - db.execute( - "INSERT INTO classes(label, n, updated_at) VALUES (?, ?, ?) " - "ON CONFLICT(label) DO UPDATE SET n = classes.n + ?, updated_at = ?", - [label, delta, now_ms, delta, now_ms], - ) - terms = tokenize(text) - if terms: - db.executemany( - "INSERT INTO terms(label, term, count) VALUES (?, ?, ?) " - "ON CONFLICT(label, term) DO UPDATE SET count = terms.count + ?", - [(label, t, delta, delta) for t in terms], - ) - if delta < 0: - db.execute("DELETE FROM terms WHERE label = ? AND count <= 0", [label]) - db.execute("DELETE FROM classes WHERE n <= 0", []) - - -def learn(label: str, text: str, delta: float = 1.0) -> None: - """Увеличить вес класса/терминов (delta>0) или «разучить» (delta<0).""" - _maybe_eval(label, text, delta) - with _lock: - db = _db() - db.execute("BEGIN") - try: - _upsert_one(db, label, text, delta) - db.execute("COMMIT") - except Exception: - db.execute("ROLLBACK") - raise - - -def learn_batch(items: list[dict]) -> int: - """Пакетное обучение: одна транзакция + пакетные вставки терминов.""" - if not items: - return 0 - # самооценка по реальным действиям пользователя — до применения примеров - for it in items: - _maybe_eval( - str(it.get("label") or ""), - str(it.get("text") or ""), - float(it.get("delta", 1.0)), - ) - with _lock: - db = _db() - db.execute("BEGIN") - try: - for it in items: - _upsert_one( - db, - str(it.get("label") or ""), - str(it.get("text") or ""), - float(it.get("delta", 1.0)), - ) - db.execute("COMMIT") - except Exception: - db.execute("ROLLBACK") - raise - return len(items) - - -# Классы типа заявки (ML учит их по ИИ-решениям/действиям, чтобы со временем -# сам определять «занятость vs разовая сделка» без вызова ИИ) -TYPE_HIRE = "t:hire" -TYPE_ORDER = "t:order" -# минимум примеров типа, чтобы ML начал выдавать тип -MIN_TYPE_WINNER = 4 - - -def predict(text: str) -> dict: - tokens = tokenize(text) - t = totals() - if not t or not tokens: - return {"take": False, "label": None, "scores": {}, "hits": 0, "ready": ready(), "type": None} - # Модель «включается» только с опытом: пока примеров мало (ready=False), - # она ничего не решает и не может ошибочно удалить заявку как спам. - if not ready(): - return {"take": False, "label": None, "scores": {}, "hits": 0, "ready": False, "type": None} - total = sum(t.values()) - type_classes = {k: v for k, v in t.items() if k in (TYPE_HIRE, TYPE_ORDER)} - - with _lock: - db = _db() - scores: dict[str, float] = {} - hits: dict[str, int] = {} - for label, n in t.items(): - rows = db.execute("SELECT term, count FROM terms WHERE label = ? AND count > 0", [label]).fetchall() - weights = {r[0]: float(r[1]) for r in rows} - score = 0.0 - hit = 0 - for term in set(tokens): - w = weights.get(term) - if w: - score += 1.0 if w < 1 else (1.0 + (w - 1.0) / (w + 1.0)) - hit += 1 - if hit: - scores[label] = score - hits[label] = hit - - margin = _adaptive_margin(total) - prior = {label: n / total for label, n in t.items()} - - # ── тип заявки: только t:hire / t:order ─────────────────────────────── - type_decision = None - if len(type_classes) >= 2: - ranked_t = sorted( - ((k, scores.get(k, 0.0)) for k in type_classes), - key=lambda kv: -kv[1], - ) - bt_label, bt_score = ranked_t[0] - st = ranked_t[1] if len(ranked_t) > 1 else None - bt_total = bt_score + 3.0 * prior.get(bt_label, 0.0) - st_total = (st[1] + 3.0 * prior.get(st[0], 0.0)) if st else 0.0 - if ( - bt_score > 0 - and t.get(bt_label, 0) >= MIN_TYPE_WINNER - and (bt_total - st_total) >= margin - ): - type_decision = { - "take": True, - "label": "hire" if bt_label == TYPE_HIRE else "order", - "value": bt_label, - "margin": round(margin, 2), - } - - # ── колонка/спам: без t:* классов ───────────────────────────────────── - regular = {k: v for k, v in t.items() if not k.startswith("t:")} - if not regular or not scores: - return { - "take": False, "label": None, "scores": {}, "hits": 0, "ready": ready(), - "type": type_decision, - } - ranked = sorted(((k, scores[k]) for k in regular if k in scores), key=lambda kv: -kv[1]) - if not ranked: - return { - "take": False, "label": None, "scores": {}, "hits": 0, "ready": ready(), - "type": type_decision, - } - best_label, best_score = ranked[0] - second = ranked[1] if len(ranked) > 1 else None - best_total = best_score + 3.0 * prior.get(best_label, 0.0) - second_total = (second[1] + 3.0 * prior.get(second[0], 0.0)) if second else 0.0 - - is_spam = best_label == "spam" - min_winner = MIN_WINNER_SPAM if is_spam else MIN_WINNER - take = ( - t.get(best_label, 0) >= min_winner - and hits[best_label] >= MIN_HITS - and (best_total - second_total) >= margin - ) - # термины, которые модель «узнала» в тексте у класса-победителя: - # подсказка для структурирования карточки (стек/услуги/материалы) без ИИ - matched_terms: list[str] = [] - if take and best_label != "spam": - with _lock: - db = _db() - rows = db.execute( - "SELECT term, count FROM terms WHERE label = ? AND count > 0", [best_label] - ).fetchall() - weights = {r[0]: float(r[1]) for r in rows} - seen = set() - for term in tokens: - if term.startswith("~"): - continue # хвостовой токен (~pyth) — не нужен как подсказка стека - if term in weights and term not in seen and term not in best_label: - seen.add(term) - matched_terms.append(term) - matched_terms = sorted(seen, key=lambda x: -weights.get(x, 0))[:8] - out_scores = {label: round(s, 3) for label, s in sorted(scores.items(), key=lambda kv: -kv[1])[:5]} - return { - "take": bool(take), - "label": best_label if take else None, - "scores": out_scores, - "hits": hits[best_label], - "ready": ready(), - "margin": round(margin, 2), - "terms": matched_terms, - "type": type_decision, - } - - -def _maybe_eval(label: str, text: str, delta: float) -> None: - """Самооценка перед обучением на реальном действии пользователя. - - delta=1.0 — пользовательское действие (перенос на доску, корзина, возврат, - ручная разметка): это «правильный ответ по карточке». Если модель уже - включена (ready) и уверенно взяла решение — сверяем его с действием: - совпало → в копилку верных, нет → в копилку ошибок. Гипотезы ИИ - (delta<1), типы t:* и «разучивание» (delta<0) не оцениваются. - """ - if delta != 1.0 or not (text or "").strip() or str(label or "").startswith("t:"): - return - if not ready(): - return - pr = predict(text) - if not pr.get("take") or not pr.get("label"): - return # модель не уверена — такое сообщение ушло бы ИИ, не считаем ошибкой - correct = bool(pr["label"] == label) - with _lock: - db = _db() - db.execute( - "INSERT INTO eval_log(created_at, expected, predicted, correct) VALUES (?, ?, ?, ?)", - [int(time.time() * 1000), str(label), str(pr["label"]), correct], - ) - db.execute( - f"DELETE FROM eval_log WHERE created_at < " - f"(SELECT created_at FROM eval_log ORDER BY created_at DESC LIMIT 1 OFFSET {EVAL_KEEP})" - ) - - -def status() -> dict: - t = totals() - # окно самооценки: последние решения модели, подтверждённые действиями - # пользователя (перенос на доску, корзина, возврат, ручная разметка) - ev = {"count": 0, "correct": 0, "accuracy": 0.0} - with _lock: - rows = _db().execute( - "SELECT count(*) AS c, coalesce(sum(CASE WHEN correct THEN 1 ELSE 0 END), 0) AS ok " - "FROM (SELECT correct FROM eval_log ORDER BY created_at DESC LIMIT ?)", - [EVAL_WINDOW], - ).fetchone() - if rows and rows[0]: - ev["count"] = int(rows[0]) - ev["correct"] = int(rows[1]) - ev["accuracy"] = round(ev["correct"] / ev["count"], 3) if ev["count"] else 0.0 - return { - "ready": ready(), - "classes": {label: round(n, 2) for label, n in sorted(t.items(), key=lambda kv: -kv[1])}, - "learned": int(sum(t.values())), - "eval": ev, - } - - -def reset() -> None: - with _lock: - db = _db() - db.execute("DELETE FROM terms", []) - db.execute("DELETE FROM classes", []) - db.execute("DELETE FROM eval_log", []) +"""Автономная ML-модель (наивный Байес по словам) для LeadRadar. + +Хранилище — собственный DuckDB-файл (volume). Модель живёт в отдельном +контейнере и общается с основным приложением по HTTP: + * /learn, /learn-batch — обучение (всегда, независимо от настроек UI); + * /predict — предсказание (используется, только если mlEnabled); + * /status — готовность и статистика. +""" +from __future__ import annotations + +import os +import re +import threading +import time + +import duckdb + +# Пороги уверенности: консервативные на старте, смягчаются по мере накопления +# опыта (см. _adaptive_margin) — ML постепенно берёт на себя больше работы. +MIN_TOTAL = 20 # суммарно примеров по всем классам, чтобы модель «включилась» +MIN_WINNER = 6 # минимум примеров у класса-победителя +MIN_WINNER_SPAM = 4 +MIN_HITS = 2 # минимум различных терминов, встреченных у победителя +MARGIN = 0.9 # ln-отрыв от второго класса на старте + +# Самооценка «справляется ли ML»: каждый реальный (пользовательский) обучающий +# сигнал сверяется с текущим предсказанием модели. В /status отдаётся окно +# последних решений — по нему UI подсказывает, что ИИ можно отключить. +EVAL_WINDOW = 50 # сколько последних решений показываем +EVAL_KEEP = 200 # сколько храним в БД модели + +TOKEN_RE = re.compile(r"[a-zа-яё0-9@+.#]+", re.I) +# Ссылки и markdown-ссылки не должны влиять ни на обучение, ни на предсказание: +# иначе модель учит мусор из URL (utm, source, campaign…) и режет по нему заявки. +_LINK_RE = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+|\[[^\]]*\]\([^)\s]+\)") + +DATA_PATH = os.getenv("ML_DATA", "./data/ml.duckdb") +_lock = threading.RLock() +_con: duckdb.DuckDBPyConnection | None = None + + +def _adaptive_margin(total: float) -> float: + """Отрыв от второго класса, требуемый для самостоятельного решения. + + Чем больше примеров ML уже видела, тем ниже порог — модель набирается + опыта и постепенно заменяет ИИ на типовых сообщениях. На старте порог + консервативный (0.9), после ~400 примеров — 0.35. + """ + if total >= 400: + return 0.35 + if total >= 150: + return 0.5 + if total >= 60: + return 0.7 + return MARGIN + + +def _db() -> duckdb.DuckDBPyConnection: + global _con + with _lock: + if _con is None: + os.makedirs(os.path.dirname(DATA_PATH) or ".", exist_ok=True) + _con = duckdb.connect(DATA_PATH) + _con.execute( + "CREATE TABLE IF NOT EXISTS classes (label VARCHAR PRIMARY KEY, n DOUBLE NOT NULL DEFAULT 0, updated_at BIGINT)" + ) + _con.execute( + "CREATE TABLE IF NOT EXISTS terms (label VARCHAR NOT NULL, term VARCHAR NOT NULL, count DOUBLE NOT NULL DEFAULT 0, " + "PRIMARY KEY (label, term))" + ) + _con.execute( + "CREATE TABLE IF NOT EXISTS eval_log (created_at BIGINT NOT NULL, " + "expected VARCHAR NOT NULL, predicted VARCHAR NOT NULL DEFAULT '', correct BOOLEAN NOT NULL)" + ) + return _con + + +def tokenize(text: str) -> list[str]: + text = _LINK_RE.sub(" ", str(text or "")) + words = [w.lower() for w in TOKEN_RE.findall(text)] + out: list[str] = [] + for w in words: + if len(w) >= 3: + out.append(w) + if len(w) >= 6: + out.append("~" + w[:4]) + return out + + +def totals() -> dict[str, float]: + with _lock: + rows = _db().execute("SELECT label, n FROM classes WHERE n > 0").fetchall() + return {r[0]: float(r[1]) for r in rows} + + +def ready() -> bool: + t = totals() + total = sum(t.values()) + if total < MIN_TOTAL: + return False + non_spam = {k: v for k, v in t.items() if k != "spam"} + return t.get("spam", 0) >= MIN_WINNER_SPAM and sum(non_spam.values()) >= MIN_WINNER + + +def _upsert_one(db, label: str, text: str, delta: float) -> None: + """Обновление одного обучающего примера (вызывается внутри транзакции). + + Термины пишутся пакетно (executemany), а не по одному INSERT — на большой + модели построчная вставка занимает десятки секунд и блокирует /status и + /predict, из-за чего сервис «выглядит недоступным». + """ + label = str(label) + if not text or not label: + return + now_ms = int(time.time() * 1000) + db.execute( + "INSERT INTO classes(label, n, updated_at) VALUES (?, ?, ?) " + "ON CONFLICT(label) DO UPDATE SET n = classes.n + ?, updated_at = ?", + [label, delta, now_ms, delta, now_ms], + ) + terms = tokenize(text) + if terms: + db.executemany( + "INSERT INTO terms(label, term, count) VALUES (?, ?, ?) " + "ON CONFLICT(label, term) DO UPDATE SET count = terms.count + ?", + [(label, t, delta, delta) for t in terms], + ) + if delta < 0: + db.execute("DELETE FROM terms WHERE label = ? AND count <= 0", [label]) + db.execute("DELETE FROM classes WHERE n <= 0", []) + + +def learn(label: str, text: str, delta: float = 1.0) -> None: + """Увеличить вес класса/терминов (delta>0) или «разучить» (delta<0).""" + _maybe_eval(label, text, delta) + with _lock: + db = _db() + db.execute("BEGIN") + try: + _upsert_one(db, label, text, delta) + db.execute("COMMIT") + except Exception: + db.execute("ROLLBACK") + raise + + +def learn_batch(items: list[dict]) -> int: + """Пакетное обучение: одна транзакция + пакетные вставки терминов.""" + if not items: + return 0 + # самооценка по реальным действиям пользователя — до применения примеров + for it in items: + _maybe_eval( + str(it.get("label") or ""), + str(it.get("text") or ""), + float(it.get("delta", 1.0)), + ) + with _lock: + db = _db() + db.execute("BEGIN") + try: + for it in items: + _upsert_one( + db, + str(it.get("label") or ""), + str(it.get("text") or ""), + float(it.get("delta", 1.0)), + ) + db.execute("COMMIT") + except Exception: + db.execute("ROLLBACK") + raise + return len(items) + + +# Классы типа заявки (ML учит их по ИИ-решениям/действиям, чтобы со временем +# сам определять «занятость vs разовая сделка» без вызова ИИ) +TYPE_HIRE = "t:hire" +TYPE_ORDER = "t:order" +# минимум примеров типа, чтобы ML начал выдавать тип +MIN_TYPE_WINNER = 4 + + +def predict(text: str) -> dict: + tokens = tokenize(text) + t = totals() + if not t or not tokens: + return {"take": False, "label": None, "scores": {}, "hits": 0, "ready": ready(), "type": None} + # Модель «включается» только с опытом: пока примеров мало (ready=False), + # она ничего не решает и не может ошибочно удалить заявку как спам. + if not ready(): + return {"take": False, "label": None, "scores": {}, "hits": 0, "ready": False, "type": None} + total = sum(t.values()) + type_classes = {k: v for k, v in t.items() if k in (TYPE_HIRE, TYPE_ORDER)} + + with _lock: + db = _db() + scores: dict[str, float] = {} + hits: dict[str, int] = {} + for label, n in t.items(): + rows = db.execute("SELECT term, count FROM terms WHERE label = ? AND count > 0", [label]).fetchall() + weights = {r[0]: float(r[1]) for r in rows} + score = 0.0 + hit = 0 + for term in set(tokens): + w = weights.get(term) + if w: + score += 1.0 if w < 1 else (1.0 + (w - 1.0) / (w + 1.0)) + hit += 1 + if hit: + scores[label] = score + hits[label] = hit + + margin = _adaptive_margin(total) + prior = {label: n / total for label, n in t.items()} + + # ── тип заявки: только t:hire / t:order ─────────────────────────────── + type_decision = None + if len(type_classes) >= 2: + ranked_t = sorted( + ((k, scores.get(k, 0.0)) for k in type_classes), + key=lambda kv: -kv[1], + ) + bt_label, bt_score = ranked_t[0] + st = ranked_t[1] if len(ranked_t) > 1 else None + bt_total = bt_score + 3.0 * prior.get(bt_label, 0.0) + st_total = (st[1] + 3.0 * prior.get(st[0], 0.0)) if st else 0.0 + if ( + bt_score > 0 + and t.get(bt_label, 0) >= MIN_TYPE_WINNER + and (bt_total - st_total) >= margin + ): + type_decision = { + "take": True, + "label": "hire" if bt_label == TYPE_HIRE else "order", + "value": bt_label, + "margin": round(margin, 2), + } + + # ── колонка/спам: без t:* классов ───────────────────────────────────── + regular = {k: v for k, v in t.items() if not k.startswith("t:")} + if not regular or not scores: + return { + "take": False, "label": None, "scores": {}, "hits": 0, "ready": ready(), + "type": type_decision, + } + ranked = sorted(((k, scores[k]) for k in regular if k in scores), key=lambda kv: -kv[1]) + if not ranked: + return { + "take": False, "label": None, "scores": {}, "hits": 0, "ready": ready(), + "type": type_decision, + } + best_label, best_score = ranked[0] + second = ranked[1] if len(ranked) > 1 else None + best_total = best_score + 3.0 * prior.get(best_label, 0.0) + second_total = (second[1] + 3.0 * prior.get(second[0], 0.0)) if second else 0.0 + + is_spam = best_label == "spam" + min_winner = MIN_WINNER_SPAM if is_spam else MIN_WINNER + take = ( + t.get(best_label, 0) >= min_winner + and hits[best_label] >= MIN_HITS + and (best_total - second_total) >= margin + ) + # термины, которые модель «узнала» в тексте у класса-победителя: + # подсказка для структурирования карточки (стек/услуги/материалы) без ИИ + matched_terms: list[str] = [] + if take and best_label != "spam": + with _lock: + db = _db() + rows = db.execute( + "SELECT term, count FROM terms WHERE label = ? AND count > 0", [best_label] + ).fetchall() + weights = {r[0]: float(r[1]) for r in rows} + seen = set() + for term in tokens: + if term.startswith("~"): + continue # хвостовой токен (~pyth) — не нужен как подсказка стека + if term in weights and term not in seen and term not in best_label: + seen.add(term) + matched_terms.append(term) + matched_terms = sorted(seen, key=lambda x: -weights.get(x, 0))[:8] + out_scores = {label: round(s, 3) for label, s in sorted(scores.items(), key=lambda kv: -kv[1])[:5]} + return { + "take": bool(take), + "label": best_label if take else None, + "scores": out_scores, + "hits": hits[best_label], + "ready": ready(), + "margin": round(margin, 2), + "terms": matched_terms, + "type": type_decision, + } + + +def _maybe_eval(label: str, text: str, delta: float) -> None: + """Самооценка перед обучением на реальном действии пользователя. + + delta=1.0 — пользовательское действие (перенос на доску, корзина, возврат, + ручная разметка): это «правильный ответ по карточке». Если модель уже + включена (ready) и уверенно взяла решение — сверяем его с действием: + совпало → в копилку верных, нет → в копилку ошибок. Гипотезы ИИ + (delta<1), типы t:* и «разучивание» (delta<0) не оцениваются. + """ + if delta != 1.0 or not (text or "").strip() or str(label or "").startswith("t:"): + return + if not ready(): + return + pr = predict(text) + if not pr.get("take") or not pr.get("label"): + return # модель не уверена — такое сообщение ушло бы ИИ, не считаем ошибкой + correct = bool(pr["label"] == label) + with _lock: + db = _db() + db.execute( + "INSERT INTO eval_log(created_at, expected, predicted, correct) VALUES (?, ?, ?, ?)", + [int(time.time() * 1000), str(label), str(pr["label"]), correct], + ) + db.execute( + f"DELETE FROM eval_log WHERE created_at < " + f"(SELECT created_at FROM eval_log ORDER BY created_at DESC LIMIT 1 OFFSET {EVAL_KEEP})" + ) + + +def status() -> dict: + t = totals() + # окно самооценки: последние решения модели, подтверждённые действиями + # пользователя (перенос на доску, корзина, возврат, ручная разметка) + ev = {"count": 0, "correct": 0, "accuracy": 0.0} + with _lock: + rows = _db().execute( + "SELECT count(*) AS c, coalesce(sum(CASE WHEN correct THEN 1 ELSE 0 END), 0) AS ok " + "FROM (SELECT correct FROM eval_log ORDER BY created_at DESC LIMIT ?)", + [EVAL_WINDOW], + ).fetchone() + if rows and rows[0]: + ev["count"] = int(rows[0]) + ev["correct"] = int(rows[1]) + ev["accuracy"] = round(ev["correct"] / ev["count"], 3) if ev["count"] else 0.0 + return { + "ready": ready(), + "classes": {label: round(n, 2) for label, n in sorted(t.items(), key=lambda kv: -kv[1])}, + "learned": int(sum(t.values())), + "eval": ev, + } + + +def reset() -> None: + with _lock: + db = _db() + db.execute("DELETE FROM terms", []) + db.execute("DELETE FROM classes", []) + db.execute("DELETE FROM eval_log", []) diff --git a/archive/leadradar-legacy/mlservice/requirements.txt b/archive/leadradar-legacy/mlservice/requirements.txt index ff62167..a6eb2b0 100644 --- a/archive/leadradar-legacy/mlservice/requirements.txt +++ b/archive/leadradar-legacy/mlservice/requirements.txt @@ -1,3 +1,3 @@ -fastapi==0.115.12 -uvicorn[standard]==0.34.2 -duckdb==1.2.1 +fastapi==0.115.12 +uvicorn[standard]==0.34.2 +duckdb==1.2.1 diff --git a/archive/leadradar-legacy/mlservice/server.py b/archive/leadradar-legacy/mlservice/server.py index eebc31e..3d1dcd3 100644 --- a/archive/leadradar-legacy/mlservice/server.py +++ b/archive/leadradar-legacy/mlservice/server.py @@ -1,70 +1,70 @@ -"""HTTP-API автономного ML-сервиса LeadRadar. - -Запуск: uvicorn server:app --host 0.0.0.0 --port 8100 -(в compose сервис `ml`, наружу порт не публикуется). -""" -from __future__ import annotations - -from fastapi import FastAPI, HTTPException -from pydantic import BaseModel - -import model as m - -app = FastAPI(title="LeadRadar ML", version="1.0.0") - - -class LearnItem(BaseModel): - text: str - label: str - delta: float = 1.0 - - -class PredictBody(BaseModel): - text: str - - -class BatchBody(BaseModel): - items: list[LearnItem] - - -@app.get("/health") -def health() -> dict: - return {"ok": True, "service": "leadradar-ml"} - - -@app.get("/status") -def status() -> dict: - return m.status() - - -@app.post("/learn") -def learn(body: LearnItem) -> dict: - if not body.text.strip() or not body.label.strip(): - raise HTTPException(400, "text и label обязательны") - m.learn(body.label, body.text, body.delta) - return {"ok": True} - - -@app.post("/learn-batch") -def learn_batch(body: BatchBody) -> dict: - m.learn_batch([it.model_dump() for it in body.items]) - return {"ok": True, "learned": len(body.items)} - - -@app.post("/predict") -def predict(body: PredictBody) -> dict: - if not body.text.strip(): - raise HTTPException(400, "text обязателен") - return m.predict(body.text) - - -@app.post("/reset") -def reset() -> dict: - m.reset() - return {"ok": True} - - -@app.on_event("startup") -def _startup() -> None: - # прогреваем соединение с БД модели - m.status() +"""HTTP-API автономного ML-сервиса LeadRadar. + +Запуск: uvicorn server:app --host 0.0.0.0 --port 8100 +(в compose сервис `ml`, наружу порт не публикуется). +""" +from __future__ import annotations + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel + +import model as m + +app = FastAPI(title="LeadRadar ML", version="1.0.0") + + +class LearnItem(BaseModel): + text: str + label: str + delta: float = 1.0 + + +class PredictBody(BaseModel): + text: str + + +class BatchBody(BaseModel): + items: list[LearnItem] + + +@app.get("/health") +def health() -> dict: + return {"ok": True, "service": "leadradar-ml"} + + +@app.get("/status") +def status() -> dict: + return m.status() + + +@app.post("/learn") +def learn(body: LearnItem) -> dict: + if not body.text.strip() or not body.label.strip(): + raise HTTPException(400, "text и label обязательны") + m.learn(body.label, body.text, body.delta) + return {"ok": True} + + +@app.post("/learn-batch") +def learn_batch(body: BatchBody) -> dict: + m.learn_batch([it.model_dump() for it in body.items]) + return {"ok": True, "learned": len(body.items)} + + +@app.post("/predict") +def predict(body: PredictBody) -> dict: + if not body.text.strip(): + raise HTTPException(400, "text обязателен") + return m.predict(body.text) + + +@app.post("/reset") +def reset() -> dict: + m.reset() + return {"ok": True} + + +@app.on_event("startup") +def _startup() -> None: + # прогреваем соединение с БД модели + m.status() diff --git a/archive/style-guide-original/Стиль_кода.docx b/archive/style-guide-original/Стиль_кода.docx index 5f965bbf18050ca23c3b80b5dce127ba24b3d4a4..11b643836a926db60d77fc25304c444d96b8ac26 100644 GIT binary patch delta 168 zcmaF7IR4V&_zkB2o6Y_&H~Y_cdAs=~rk;J4Gq(pm@0UZN5h;Tjlt9c-ysk`GHsfhy{UIXuCGAu=w`v&PRlq?rwKmC{ixizHgHV5Q_q_ L*!F#!#8u@15w=6V delta 202 zcmcb#IR5eC_zkB28F`yc|1Ss9ml=7tn_gn-*$1Q#PGA;V3ZyTavefKl!wb|R%gYbM0zfPX#6sI;d4.sqlite (Ruling 4). Контекст сборки — корень репозитория: csproj - # ссылается на src/contracts/Deal.Proto.csproj (общий проект кодогенерации, Task 1). - ml-service: - build: - context: .. - dockerfile: src/ml-service/Deal.Ml/Dockerfile - container_name: deal-ml-service - <<: *service-hardening - mem_limit: 768m - cpus: 1.0 - environment: - GRPC_PORT: "5103" - DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token} - DEAL_ML_DATA_DIR: /data/ml # файлы моделей data/ml/.sqlite на volume deal_ml_data (Ruling 4/12) - DEAL_LOGS_DIR: /tmp/logs - ports: - - "5103:5103" - volumes: - - deal_ml_data:/data/ml - healthcheck: - test: ["CMD", "/bin/grpc_health_probe", "-addr=localhost:5103"] - interval: 5s - timeout: 3s - retries: 10 - - # storage-service — общий gRPC-сервис данных (вложения источников). Отдельный процесс, отдельное sln - # (src/storage-service/Deal.Storage.sln). Загрузка/выгрузка/стат/удаление объектов; тип объекта - # определяет сервис. Бэкенд — MinIO. Защита — общий service-token (DEAL_SERVICE_TOKEN). - storage-service: - build: - context: .. - dockerfile: src/storage-service/Deal.Storage/Dockerfile - container_name: deal-storage-service - <<: *service-hardening - mem_limit: 512m - cpus: 1.0 - environment: - GRPC_PORT: "5104" - DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token} - DEAL_STORAGE_ENDPOINT: minio:9000 - DEAL_STORAGE_ACCESS_KEY: deal_minio - DEAL_STORAGE_SECRET_KEY: deal_minio_secret - DEAL_STORAGE_BUCKET: deal-attachments - DEAL_STORAGE_SECURE: "false" - DEAL_LOGS_DIR: /tmp/logs - ports: - - "5104:5104" - depends_on: - - minio - healthcheck: - test: ["CMD", "/bin/grpc_health_probe", "-addr=localhost:5104"] - interval: 5s - timeout: 3s - retries: 10 - - # Prometheus (профиль observability, этап 12/пакет A) — сбор /metrics всех 4 процессов (:9464) - # внутри dev-сети. Подъём: docker compose -f deploy/compose.dev.yml --profile observability up -d. - # Конфиг — общий deploy/observability/prometheus.yml (те же имена сервисов и таргеты). UI — 9090. - prometheus: - image: prom/prometheus:v3.5.0 - container_name: deal-prometheus - profiles: ["observability"] - command: - - --config.file=/etc/prometheus/prometheus.yml - - --storage.tsdb.path=/prometheus - - --storage.tsdb.retention.time=15d - ports: - - "9090:9090" - volumes: - - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro - - ./observability/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro - - deal_prometheus_data:/prometheus - -volumes: - deal_pgdata: - deal_minio_data: - deal_tg_sessions: - deal_ml_data: - deal_api_data: - deal_prometheus_data: +# compose.dev.yml — dev-стек Дейла (этап 6, Task 20; Ruling 12). +# +# Полный стек: postgres + minio (хранилища), три автономных сервиса этапа 6 +# (telegram-service :5101, ai-service :5102, ml-service :5103, storage-service :5104) и core (deal-api: +# HTTP :5080 + gRPC-ингресс :5082). +# +# Режимы интеграций core — флагами SERVICES__{ML,AI,TELEGRAM}__USELOCAL (Ruling 6): +# * этот файл — сквозной gRPC-режим ВСЕГО стека «по-настоящему»: UseLocal=false заданы env +# core-сервиса ниже, интеграции ходят в ml/ai/telegram-сервисы этой же compose-сети +# (MlOutboxFlushScheduler → TrainBatch, GrpcAiClassifier/GrpcAiTools, GrpcTelegramClient). +# Подъём всего стека одной командой: docker compose -f deploy/compose.dev.yml up -d --build +# (см. также scripts/dev-smoke.sh — сквозная проверка с авто-очисткой). +# * Local-заглушки (дефолт КОДА — appsettings.json, UseLocal:true) сохраняются для запуска core +# с хоста БЕЗ сервисов (обычная dev-разработка, curl-приёмки этапов 2–5): поднимаются только +# postgres/minio, core — dotnet run из src/core/Deal.Api. Код-дефолт не меняется — режим +# переключает этот compose-файл (env ниже). +# +# Секреты dev (переопределяются .env/экспортом, шаблон ${VAR:-default}): +# DEAL_SERVICE_TOKEN — единый service-token всех процессов (metadata gRPC, Ruling 1/2/13) +# DEAL_ENCRYPTION_KEY — ключ AES-256-GCM шифрования секретов настроек core (32 байта base64) +# DEAL_TELEGRAM_SESSION_KEY — ключ AES-256-GCM файлов сессий telegram-service (32 байта base64) +# минио/БД creds — dev-значения ниже (в проде — этап 7, compose-prod + mTLS). +# +# Core в этом файле — «весь стек в docker» (эндпоинты — имена compose-сети). Альтернатива — core +# с хоста (Ruling 12): поднимаются только postgres/minio и (по желанию) сервисы, core запускается +# dotnet run, и сервисы ходят в ингресс хоста через DEAL_CORE_INGRESS=http://host.docker.internal:5082 +# (см. env telegram-service ниже). + +# Общий hardening прикладных сервисов (BL-IMG-HARDEN): read-only root FS + tmpfs /tmp, +# запрет новых привилегий и сброс capabilities. Данные — только в именованных volume. +x-service-hardening: &service-hardening + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + +services: + postgres: + image: postgres:16-alpine + container_name: deal-postgres + environment: + POSTGRES_DB: deal + POSTGRES_USER: deal + POSTGRES_PASSWORD: deal_dev_password + ports: + - "5433:5432" # 5432 может быть занят LeadRadar-стеком + volumes: + - deal_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U deal -d deal"] + interval: 5s + timeout: 3s + retries: 10 + + # MinIO для файлов проектных карточек (Ruling 4, план Task 6; образца-прототипа object_store.py). + # Опционально: API работает и без него — LocalFileStorage (data/attachments) выбирается, пока MinIO не + # сконфигурирован (Storage__Minio__* / DEAL_MINIO_*). Бакет deal-files создаётся приложением лениво при + # первом upload (Ruling 4: EnsureBucket на первом put), отдельный init-контейнер не нужен. + # Консоль: http://localhost:9001 (root deal_minio / deal_minio_secret). + # Если 9000/9001 заняты LeadRadar-minio — поменяйте на 9100/9101 (и endpoint в конфигурации API). + minio: + image: minio/minio + container_name: deal-minio + environment: + MINIO_ROOT_USER: deal_minio + MINIO_ROOT_PASSWORD: deal_minio_secret + ports: + - "9000:9000" # S3 API + - "9001:9001" # Web-консоль + volumes: + - deal_minio_data:/data + command: server /data --console-address ":9001" + + # core (deal-api) — HTTP :5080 + gRPC-ингресс telegram-service :5082 (план Task 12/20, Ruling 7/12). + # Здесь core поднимается в сквозном gRPC-режиме (UseLocal=false ниже): сервисы этой же сети + # вызываются по-настоящему; MlOutbox копится в БД и выгружается MlOutboxFlushScheduler (10 с). + # Local-режим (дефолт appsettings.json) остаётся для запуска core с хоста без сервисов. + core: + build: + context: .. + dockerfile: src/core/Deal.Api/Dockerfile + container_name: deal-core + <<: *service-hardening + mem_limit: 1g + cpus: 1.5 + environment: + ASPNETCORE_ENVIRONMENT: Development + ASPNETCORE_URLS: http://0.0.0.0:5080 + GRPC_INGRESS_PORT: "5082" + ConnectionStrings__DealPostgres: Host=postgres;Port=5432;Database=deal;Username=deal;Password=deal_dev_password + DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token} + DEAL_ENCRYPTION_KEY: ${DEAL_ENCRYPTION_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=} + # gRPC-режим всего стека (Ruling 6): Local-заглушки кода (appsettings default true) + # переключаются на gRPC-клиентов сервисов compose-сети. Хост-запуск core без compose + # остаётся на Local (код-дефолт не менялся). + Services__Ml__UseLocal: "false" + Services__Ai__UseLocal: "false" + Services__Telegram__UseLocal: "false" + Services__Ml__Endpoint: http://ml-service:5103 + Services__Ai__Endpoint: http://ai-service:5102 + Services__Telegram__Endpoint: http://telegram-service:5101 + Services__Storage__Endpoint: http://storage-service:5104 + Storage__Minio__Endpoint: minio:9000 + Storage__Minio__AccessKey: deal_minio + Storage__Minio__SecretKey: deal_minio_secret + Storage__Minio__Bucket: deal-files + Storage__Minio__Secure: "false" + ports: + - "5080:5080" + - "5082:5082" # gRPC-ингресс telegram-service (сервисы ходят на http://core:5082 внутри сети) + volumes: + - deal_api_data:/app/data # data/encryption.key + data/attachments (Local-фолбэк файлов) + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "/bin/grpc_health_probe", "-addr=localhost:5082"] + interval: 10s + timeout: 3s + retries: 10 + start_period: 15s + + # telegram-service — gRPC-сервер Telegram (этап 6, Ruling 12; план Task 2). Отдельный процесс, + # отдельное sln (src/telegram-service/Deal.Telegram.sln). Команды ядра приходят на localhost:5101 + # (SERVICES__Telegram__Endpoint — задачи 9–12), исходящий поток — в core-ингресс :5082 + # (SERVICES__CORE__INGRESS, Ruling 7; внутри compose — http://core:5082; при core на хосте задайте + # DEAL_CORE_INGRESS=http://host.docker.internal:5082). Plaintext gRPC без mTLS (Ruling 2 — dev): + # защита — общий service-token (DEAL_SERVICE_TOKEN, env хоста и контейнеров). Сессии тенантов — + # файлы /data/sessions (Ruling 3), шифруются AES-256-GCM ключом DEAL_TELEGRAM_SESSION_KEY + # (fail-closed: без ключа сервис не стартует). Контекст сборки — корень репозитория: csproj + # ссылается на src/contracts/Deal.Proto.csproj (общий проект кодогенерации, Task 1). + telegram-service: + build: + context: .. + dockerfile: src/telegram-service/Deal.Telegram/Dockerfile + container_name: deal-telegram-service + <<: *service-hardening + mem_limit: 512m + cpus: 1.0 + environment: + GRPC_PORT: "5101" + DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token} + DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:-ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=} + DEAL_TELEGRAM_SESSION_DIR: /data/sessions + DEAL_LOGS_DIR: /tmp/logs + SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082} + ports: + - "5101:5101" + volumes: + - deal_tg_sessions:/data/sessions + healthcheck: + test: ["CMD", "/bin/grpc_health_probe", "-addr=localhost:5101"] + interval: 5s + timeout: 3s + retries: 10 + + # ai-service — gRPC-сервер ИИ-фасада (этап 6, Ruling 12; план Task 4). Отдельный процесс, + # отдельное sln (src/ai-service/Deal.Ai.sln). Промпты/классификацию/фильтр/ключи ядро шлёт на + # localhost:5102 (SERVICES__Ai__Endpoint — задачи 7/8/15). Plaintext gRPC без mTLS (Ruling 2 — + # dev): защита — общий service-token (DEAL_SERVICE_TOKEN, env хоста и контейнеров). Сервис без БД + # (Ruling 5): конфиг провайдера (id/base/model/apiKey/api_style) приходит в теле каждого запроса + # от ядра, персистентных файлов нет — volume не нужен. Контекст сборки — корень репозитория: + # csproj ссылается на src/contracts/Deal.Proto.csproj (общий проект кодогенерации, Task 1). + ai-service: + build: + context: .. + dockerfile: src/ai-service/Deal.Ai/Dockerfile + container_name: deal-ai-service + <<: *service-hardening + mem_limit: 512m + cpus: 1.0 + environment: + GRPC_PORT: "5102" + DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token} + DEAL_LOGS_DIR: /tmp/logs + ports: + - "5102:5102" + healthcheck: + test: ["CMD", "/bin/grpc_health_probe", "-addr=localhost:5102"] + interval: 5s + timeout: 3s + retries: 10 + + # ml-service — gRPC-сервер ML (этап 6, Ruling 12; план Task 3). Отдельный процесс, отдельное sln + # (src/ml-service/Deal.Ml.sln). Обучение и предсказания ядро шлёт на localhost:5103 + # (SERVICES__Ml__Endpoint — задачи 5/6/16). Plaintext gRPC без mTLS (Ruling 2 — dev): защита — + # общий service-token (DEAL_SERVICE_TOKEN, env хоста и контейнеров). Модели тенантов — файлы + # SQLite /data/ml/.sqlite (Ruling 4). Контекст сборки — корень репозитория: csproj + # ссылается на src/contracts/Deal.Proto.csproj (общий проект кодогенерации, Task 1). + ml-service: + build: + context: .. + dockerfile: src/ml-service/Deal.Ml/Dockerfile + container_name: deal-ml-service + <<: *service-hardening + mem_limit: 768m + cpus: 1.0 + environment: + GRPC_PORT: "5103" + DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token} + DEAL_ML_DATA_DIR: /data/ml # файлы моделей data/ml/.sqlite на volume deal_ml_data (Ruling 4/12) + DEAL_LOGS_DIR: /tmp/logs + ports: + - "5103:5103" + volumes: + - deal_ml_data:/data/ml + healthcheck: + test: ["CMD", "/bin/grpc_health_probe", "-addr=localhost:5103"] + interval: 5s + timeout: 3s + retries: 10 + + # storage-service — общий gRPC-сервис данных (вложения источников). Отдельный процесс, отдельное sln + # (src/storage-service/Deal.Storage.sln). Загрузка/выгрузка/стат/удаление объектов; тип объекта + # определяет сервис. Бэкенд — MinIO. Защита — общий service-token (DEAL_SERVICE_TOKEN). + storage-service: + build: + context: .. + dockerfile: src/storage-service/Deal.Storage/Dockerfile + container_name: deal-storage-service + <<: *service-hardening + mem_limit: 512m + cpus: 1.0 + environment: + GRPC_PORT: "5104" + DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token} + DEAL_STORAGE_ENDPOINT: minio:9000 + DEAL_STORAGE_ACCESS_KEY: deal_minio + DEAL_STORAGE_SECRET_KEY: deal_minio_secret + DEAL_STORAGE_BUCKET: deal-attachments + DEAL_STORAGE_SECURE: "false" + DEAL_LOGS_DIR: /tmp/logs + ports: + - "5104:5104" + depends_on: + - minio + healthcheck: + test: ["CMD", "/bin/grpc_health_probe", "-addr=localhost:5104"] + interval: 5s + timeout: 3s + retries: 10 + + # Prometheus (профиль observability, этап 12/пакет A) — сбор /metrics всех 4 процессов (:9464) + # внутри dev-сети. Подъём: docker compose -f deploy/compose.dev.yml --profile observability up -d. + # Конфиг — общий deploy/observability/prometheus.yml (те же имена сервисов и таргеты). UI — 9090. + prometheus: + image: prom/prometheus:v3.5.0 + container_name: deal-prometheus + profiles: ["observability"] + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --storage.tsdb.retention.time=15d + ports: + - "9090:9090" + volumes: + - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./observability/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro + - deal_prometheus_data:/prometheus + +volumes: + deal_pgdata: + deal_minio_data: + deal_tg_sessions: + deal_ml_data: + deal_api_data: + deal_prometheus_data: diff --git a/deploy/compose.prod.yml b/deploy/compose.prod.yml index 2e47e72..b2c1df5 100644 --- a/deploy/compose.prod.yml +++ b/deploy/compose.prod.yml @@ -1,350 +1,350 @@ -# compose.prod.yml — PROD-стек «Дейла» (этап 7, Ruling 6/7/9; план Task 14). -# -# ⚠ НЕ поднимать на dev-машине вместо compose.dev.yml: это production-конфигурация с -# fail-fast на секреты (см. ниже) и без dev-дефолтов. -# -# Состав (всё в одной внутренней сети compose, наружу — ТОЛЬКО caddy :80/:443): -# postgres, minio — хранилища БЕЗ host-портов (volume'ы); -# core (:5080 HTTP + :5082 gRPC-ингресс), telegram-service (:5101), ai-service (:5102), -# ml-service (:5103) — процессы «Дейла»; mTLS-транспорт — по env Ruling 6 (см. ниже); -# caddy — edge: TLS-терминация, статика фронта, reverse_proxy /api → core. -# loki/promtail/grafana/prometheus — observability (Ruling 7; метрики — этап 12, пакет A): ПРОФИЛЬ -# `observability` — поднимается только: docker compose --profile observability up -d -# (или ... up -d --profile observability). Prometheus scrape'ит /metrics -# (порт 9464) всех 4 процессов; Grafana — логи (Loki) и метрики (Prometheus). -# -# Секреты — ТОЛЬКО из env: шаблон deploy/.env.prod.example → скопируйте в deploy/.env.prod, -# заполните значения и запускайте с --env-file: -# cp deploy/.env.prod.example deploy/.env.prod # и заполнить (без дефолтных паролей!) -# docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml up -d --build -# docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml \ -# --profile observability up -d --build # + Loki/Promtail/Grafana/Prometheus -# Проверка без движка: docker compose --env-file ... -f deploy/compose.prod.yml config -# Ключевые обязательные (без значения `config` упадёт с подсказкой): DEAL_PG_PASSWORD, -# DEAL_ENCRYPTION_KEY, DEAL_SERVICE_TOKEN, DEAL_TELEGRAM_SESSION_KEY, MINIO_ROOT_USER, -# MINIO_ROOT_PASSWORD, DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD (пустые — оператор не создаётся, -# см. Ruling 1), DEAL_ALLOWED_ORIGINS. -# -# PROD-флаги кода (Ruling 5/9/10): RateLimit__Enabled=true (лимиты/защита входа), куки -# Cookies__Secure/OperatorCookies__Secure=true (TLS терминирует caddy), ForwardedHeaders__Enabled=true -# (доверенный прокси — caddy; KnownNetworks — приватные docker-сети хоста: core наружу не публикуется, -# единственный вход в него — caddy), Security__AllowedOrigins — origin фронта. -# -# mTLS внутреннего gRPC (Ruling 6, Task 13/14): DEAL_MTLS_ENABLED=0 — plaintext + service-token -# (как dev, но внутри изолированной сети); DEAL_MTLS_ENABLED=1 — Kestrel сервисов/ингресса требует -# клиентский сертификат. Сертификаты: deploy/certs (scripts/mtls-certs.sh) монтируются в -# /etc/deal/certs. ВАЖНО при включении mTLS: -# * схемы внутренних endpoint'ов поменять на https:// (DEAL_ML_ENDPOINT/DEAL_AI_ENDPOINT/ -# DEAL_TELEGRAM_ENDPOINT/DEAL_CORE_INGRESS в .env.prod); -# * healthcheck'и ниже сами переключаются на TLS-пробу grpc_health_probe (клиентский сертификат из -# /etc/deal/certs; PEM-артефакты deal-client.crt/.key генерирует scripts/mtls-certs.sh); -# * DEAL_MTLS_CERT_PASSWORD — пароль PFX (серверных и клиентского). -# -# Логи (Ruling 7): процессы пишут Serilog JSON в stdout (docker-логи) + rolling-файл -# data/logs/deal-<процесс>.json в контейнере. У core /app/data — volume deal_api_data (файлы-логи -# переживают пересоздание); у сервисов канал наблюдаемости — docker-логи → promtail (файлы сервисов -# эфемерны). Уровень — env DEAL_LOG_LEVEL. -# -# Frontend: статика монтируется из ../src/frontend/dist (СОБРАТЬ ДО ПОДЪЁМА: cd src/frontend && -# npm run build). Конфигурация caddy — deploy/caddy/Caddyfile (см. его шапку про домен/TLS). - -# Общий hardening прикладных сервисов (BL-IMG-HARDEN): read-only root FS + tmpfs /tmp, -# запрет новых привилегий и сброс capabilities. Данные/логи — только в именованных volume или tmpfs. -x-service-hardening: &service-hardening - read_only: true - tmpfs: - - /tmp - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - -services: - postgres: - image: postgres:16-alpine - environment: - POSTGRES_DB: deal - POSTGRES_USER: deal - POSTGRES_PASSWORD: ${DEAL_PG_PASSWORD:?DEAL_PG_PASSWORD не задан (пароль Postgres deal)} - volumes: - - deal_pgdata:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U deal -d deal"] - interval: 5s - timeout: 3s - retries: 10 - restart: unless-stopped - - # MinIO (файлы проектных карточек; бакет deal-files создаётся лениво при первом upload, Ruling 4). - # БЕЗ host-портов: наружу файлы ходят только через core→caddy. Healthcheck нет: minio-образ не несёт - # curl/mc; на core падение minio = фолбэк LocalFileStorage (данные — в volume, подъём лечит). - minio: - # Тег зафиксирован (как остальные образы); при обновлении — свежий RELEASE-тег из quay.io/minio/minio. - image: minio/minio:RELEASE.2025-04-22T22-12-26Z - environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER не задан} - MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан} - volumes: - - deal_minio_data:/data - command: server /data - restart: unless-stopped - - # core (deal-api): HTTP :5080 + gRPC-ингресс telegram-service :5082. Сквозной gRPC-режим - # (UseLocal=false), как compose.dev.yml; rate limiting/куки-Secure/прокси-заголовки — PROD (см. шапку). - core: - build: - context: .. - dockerfile: src/core/Deal.Api/Dockerfile - <<: *service-hardening - mem_limit: 1g - cpus: 1.5 - environment: - ASPNETCORE_ENVIRONMENT: Production - ASPNETCORE_URLS: http://0.0.0.0:5080 - GRPC_INGRESS_PORT: "5082" - ConnectionStrings__DealPostgres: "Host=postgres;Port=5432;Database=deal;Username=deal;Password=${DEAL_PG_PASSWORD:?DEAL_PG_PASSWORD не задан}" - DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан (service-token gRPC)} - DEAL_ENCRYPTION_KEY: ${DEAL_ENCRYPTION_KEY:?DEAL_ENCRYPTION_KEY не задан (AES-256-GCM, 32 байта base64)} - DEAL_OPERATOR_LOGIN: ${DEAL_OPERATOR_LOGIN:-} - DEAL_OPERATOR_PASSWORD: ${DEAL_OPERATOR_PASSWORD:-} - DEAL_DEFAULT_AI_BUDGET: ${DEAL_DEFAULT_AI_BUDGET:-} - # Безопасность PROD (Rulings 5/9/10): лимиты вкл., куки Secure, доверенный прокси (caddy). - RateLimit__Enabled: "true" - Cookies__Secure: "true" - OperatorCookies__Secure: "true" - ForwardedHeaders__Enabled: "true" - # Доверенные подсети прокси: приватные docker-сети хоста (caddy — единственный вход в core, - # host-портов у core нет). Свою подсеть можно уточнить: docker network inspect <сеть>. - ForwardedHeaders__KnownNetworks__0: 172.16.0.0/12 - Security__AllowedOrigins: ${DEAL_ALLOWED_ORIGINS:?DEAL_ALLOWED_ORIGINS не задан (origin фронта, напр. https://deal.example)} - # Интеграции — gRPC-режим всего стека (как dev); схемы endpoint'ов — http:// (mTLS off) либо - # https:// при DEAL_MTLS_ENABLED=1 (см. .env.prod.example). - Services__Ml__UseLocal: "false" - Services__Ml__Endpoint: ${DEAL_ML_ENDPOINT:-http://ml-service:5103} - Services__Ai__UseLocal: "false" - Services__Ai__Endpoint: ${DEAL_AI_ENDPOINT:-http://ai-service:5102} - Services__Telegram__UseLocal: "false" - Services__Telegram__Endpoint: ${DEAL_TELEGRAM_ENDPOINT:-http://telegram-service:5101} - # Файлы — MinIO (внутренний http; TLS minio — вне этапа, при желании Storage__Minio__Secure=true - # + endpoint https и сертификаты). - Storage__Minio__Endpoint: minio:9000 - Storage__Minio__AccessKey: ${MINIO_ROOT_USER:?MINIO_ROOT_USER не задан} - Storage__Minio__SecretKey: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан} - Storage__Minio__Bucket: deal-files - Storage__Minio__Secure: "false" - # mTLS внутреннего gRPC (Ruling 6; пути — /etc/deal/certs, см. volume ниже). - DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} - DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem - DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/core-server.pfx - DEAL_MTLS_SERVER_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-} - DEAL_MTLS_CLIENT_CERT_PFX: /etc/deal/certs/deal-client.pfx - DEAL_MTLS_CLIENT_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-} - volumes: - # data/: ключ шифрования (или DEAL_ENCRYPTION_KEY), LocalFileStorage-фолбэк, логи data/logs. - - deal_api_data:/app/data - # deploy/certs (scripts/mtls-certs.sh): нужны только при DEAL_MTLS_ENABLED=1. - - ${DEAL_CERTS_DIR:-./certs}:/etc/deal/certs:ro - depends_on: - postgres: - condition: service_healthy - healthcheck: - # gRPC-health ингресса :5082 (health освобождён от service-token). Под mTLS — TLS-проба - # с клиентским сертификатом deal-client и CA (PEM-артефакты генерирует scripts/mtls-certs.sh). - test: ["CMD-SHELL", "if [ \"$$DEAL_MTLS_ENABLED\" = \"1\" ]; then /bin/grpc_health_probe -addr=localhost:5082 -tls -tls-ca-cert=/etc/deal/certs/ca.pem -tls-client-cert=/etc/deal/certs/deal-client.crt -tls-client-key=/etc/deal/certs/deal-client.key -tls-server-name=localhost; else /bin/grpc_health_probe -addr=localhost:5082; fi"] - interval: 10s - timeout: 3s - retries: 10 - start_period: 15s - restart: unless-stopped - - # telegram-service — gRPC-сервер Telegram (:5101; plaintext либо mTLS — как core). Сессии — - # /data/sessions (volume deal_tg_sessions, AES-GCM ключом DEAL_TELEGRAM_SESSION_KEY, fail-closed). - telegram-service: - build: - context: .. - dockerfile: src/telegram-service/Deal.Telegram/Dockerfile - <<: *service-hardening - mem_limit: 512m - cpus: 1.0 - environment: - GRPC_PORT: "5101" - DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан} - DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:?DEAL_TELEGRAM_SESSION_KEY не задан (ключ AES-GCM сессий)} - DEAL_TELEGRAM_SESSION_DIR: /data/sessions - DEAL_LOGS_DIR: /tmp/logs - SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082} - DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} - DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem - DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/telegram-service-server.pfx - DEAL_MTLS_SERVER_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-} - DEAL_MTLS_CLIENT_CERT_PFX: /etc/deal/certs/deal-client.pfx - DEAL_MTLS_CLIENT_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-} - volumes: - - deal_tg_sessions:/data/sessions - - ${DEAL_CERTS_DIR:-./certs}:/etc/deal/certs:ro - healthcheck: - test: ["CMD-SHELL", "if [ \"$$DEAL_MTLS_ENABLED\" = \"1\" ]; then /bin/grpc_health_probe -addr=localhost:5101 -tls -tls-ca-cert=/etc/deal/certs/ca.pem -tls-client-cert=/etc/deal/certs/deal-client.crt -tls-client-key=/etc/deal/certs/deal-client.key -tls-server-name=localhost; else /bin/grpc_health_probe -addr=localhost:5101; fi"] - interval: 5s - timeout: 3s - retries: 10 - restart: unless-stopped - - # ai-service — gRPC-сервер ИИ-фасада (:5102). Без БД и volume'ов (конфиг провайдера — в теле RPC). - ai-service: - build: - context: .. - dockerfile: src/ai-service/Deal.Ai/Dockerfile - <<: *service-hardening - mem_limit: 512m - cpus: 1.0 - environment: - GRPC_PORT: "5102" - DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан} - DEAL_LOGS_DIR: /tmp/logs - DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} - DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem - DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ai-service-server.pfx - DEAL_MTLS_SERVER_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-} - DEAL_MTLS_CLIENT_CERT_PFX: /etc/deal/certs/deal-client.pfx - DEAL_MTLS_CLIENT_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-} - volumes: - - ${DEAL_CERTS_DIR:-./certs}:/etc/deal/certs:ro - healthcheck: - test: ["CMD-SHELL", "if [ \"$$DEAL_MTLS_ENABLED\" = \"1\" ]; then /bin/grpc_health_probe -addr=localhost:5102 -tls -tls-ca-cert=/etc/deal/certs/ca.pem -tls-client-cert=/etc/deal/certs/deal-client.crt -tls-client-key=/etc/deal/certs/deal-client.key -tls-server-name=localhost; else /bin/grpc_health_probe -addr=localhost:5102; fi"] - interval: 5s - timeout: 3s - retries: 10 - restart: unless-stopped - - # ml-service — gRPC-сервер ML (:5103). Модели — /data/ml (volume deal_ml_data, SQLite на тенанта). - ml-service: - build: - context: .. - dockerfile: src/ml-service/Deal.Ml/Dockerfile - <<: *service-hardening - mem_limit: 768m - cpus: 1.0 - environment: - GRPC_PORT: "5103" - DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан} - DEAL_ML_DATA_DIR: /data/ml - DEAL_LOGS_DIR: /tmp/logs - DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} - DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem - DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ml-service-server.pfx - DEAL_MTLS_SERVER_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-} - DEAL_MTLS_CLIENT_CERT_PFX: /etc/deal/certs/deal-client.pfx - DEAL_MTLS_CLIENT_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-} - volumes: - - deal_ml_data:/data/ml - - ${DEAL_CERTS_DIR:-./certs}:/etc/deal/certs:ro - healthcheck: - test: ["CMD-SHELL", "if [ \"$$DEAL_MTLS_ENABLED\" = \"1\" ]; then /bin/grpc_health_probe -addr=localhost:5103 -tls -tls-ca-cert=/etc/deal/certs/ca.pem -tls-client-cert=/etc/deal/certs/deal-client.crt -tls-client-key=/etc/deal/certs/deal-client.key -tls-server-name=localhost; else /bin/grpc_health_probe -addr=localhost:5103; fi"] - interval: 5s - timeout: 3s - retries: 10 - restart: unless-stopped - - # caddy — edge: наружу только :80/:443. TLS — плейсхолдер tls internal (см. Caddyfile: домен, - # реальный сертификат/Cloudflare, CSP/HSTS). Статика — ../src/frontend/dist (СОБРАТЬ ДО up). - caddy: - image: caddy:2.9.1 - ports: - - "80:80" - - "443:443" - volumes: - - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro - - ../src/frontend/dist:/srv:ro - # /data — локальный CA и сертификаты Caddy (переживают пересоздание контейнера). - - deal_caddy_data:/data - - deal_caddy_config:/config - depends_on: - core: - condition: service_healthy - restart: unless-stopped - - # ── Observability (Ruling 7/9; метрики — этап 12, пакет A) — ПРОФИЛЬ: --profile observability ── - # Логи: docker-логи (stdout контейнеров) → promtail → loki → grafana (127.0.0.1:3001). - # Метрики: /metrics процессов (:9464) → prometheus (127.0.0.1:9090) → grafana (датасорс uid - # `prometheus`). Доступ — оператору по SSH-туннелю; наружу grafana/prometheus НЕ публикуются. - # Версии образов — фиксированные (при подъёме обновите до актуальных patch-релизов). - - loki: - image: grafana/loki:3.4.2 - profiles: ["observability"] - command: -config.file=/etc/loki/loki.yml - volumes: - - ./observability/loki.yml:/etc/loki/loki.yml:ro - - deal_loki_data:/loki - restart: unless-stopped - - # Prometheus — сбор метрик Deal-процессов (этап 12, пакет A): scrape /metrics каждого процесса - # (:9464, HTTP/1.1) внутри compose-сети. Конфиг — deploy/observability/prometheus.yml (таргеты - # core/telegram-service/ai-service/ml-service). UI — только loopback 127.0.0.1:9090 (оператору по - # SSH-туннелю, как Grafana); наружу порт не публикуется. Retention 15 суток (volume deal_prometheus_data). - prometheus: - image: prom/prometheus:v3.5.0 - profiles: ["observability"] - command: - - --config.file=/etc/prometheus/prometheus.yml - - --storage.tsdb.path=/prometheus - - --storage.tsdb.retention.time=15d - ports: - # Только loopback хоста — наружу не публикуется (Ruling 9). - - "127.0.0.1:9090:9090" - volumes: - - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro - - ./observability/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro - - deal_prometheus_data:/prometheus - restart: unless-stopped - - promtail: - image: grafana/promtail:3.4.2 - profiles: ["observability"] - command: -config.file=/etc/promtail/promtail.yml - volumes: - - ./observability/promtail.yml:/etc/promtail/promtail.yml:ro - # docker.sock — чтение docker-логов всех контейнеров (docker_sd). Rootless-докер: путь сокета - # свой (напр. /run/user//docker.sock) — поправьте монтирование и promtail.yml. - - /var/run/docker.sock:/var/run/docker.sock:ro - - deal_promtail_data:/var/lib/promtail - depends_on: - loki: - condition: service_started - restart: unless-stopped - - grafana: - image: grafana/grafana:11.5.2 - profiles: ["observability"] - environment: - # Пароль задайте в .env.prod перед подъёмом профиля (пусто — grafana не получит admin-пароль). - GF_SECURITY_ADMIN_USER: ${DEAL_GRAFANA_ADMIN_USER:-admin} - GF_SECURITY_ADMIN_PASSWORD: ${DEAL_GRAFANA_ADMIN_PASSWORD:-} - GF_USERS_ALLOW_SIGN_UP: "false" - GF_AUTH_ANONYMOUS_ENABLED: "false" - ports: - # Только loopback хоста — оператору по SSH-туннелю (Ruling 9); наружу не публикуется. - - "127.0.0.1:3001:3000" - volumes: - - ./observability/grafana/provisioning:/etc/grafana/provisioning:ro - - ./observability/grafana/dashboards:/var/lib/grafana/dashboards:ro - - deal_grafana_data:/var/lib/grafana - depends_on: - loki: - condition: service_started - prometheus: - condition: service_started - restart: unless-stopped - -volumes: - deal_pgdata: - deal_minio_data: - deal_tg_sessions: - deal_ml_data: - deal_api_data: - deal_caddy_data: - deal_caddy_config: - deal_loki_data: - deal_promtail_data: - deal_grafana_data: - deal_prometheus_data: +# compose.prod.yml — PROD-стек «Дейла» (этап 7, Ruling 6/7/9; план Task 14). +# +# ⚠ НЕ поднимать на dev-машине вместо compose.dev.yml: это production-конфигурация с +# fail-fast на секреты (см. ниже) и без dev-дефолтов. +# +# Состав (всё в одной внутренней сети compose, наружу — ТОЛЬКО caddy :80/:443): +# postgres, minio — хранилища БЕЗ host-портов (volume'ы); +# core (:5080 HTTP + :5082 gRPC-ингресс), telegram-service (:5101), ai-service (:5102), +# ml-service (:5103) — процессы «Дейла»; mTLS-транспорт — по env Ruling 6 (см. ниже); +# caddy — edge: TLS-терминация, статика фронта, reverse_proxy /api → core. +# loki/promtail/grafana/prometheus — observability (Ruling 7; метрики — этап 12, пакет A): ПРОФИЛЬ +# `observability` — поднимается только: docker compose --profile observability up -d +# (или ... up -d --profile observability). Prometheus scrape'ит /metrics +# (порт 9464) всех 4 процессов; Grafana — логи (Loki) и метрики (Prometheus). +# +# Секреты — ТОЛЬКО из env: шаблон deploy/.env.prod.example → скопируйте в deploy/.env.prod, +# заполните значения и запускайте с --env-file: +# cp deploy/.env.prod.example deploy/.env.prod # и заполнить (без дефолтных паролей!) +# docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml up -d --build +# docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml \ +# --profile observability up -d --build # + Loki/Promtail/Grafana/Prometheus +# Проверка без движка: docker compose --env-file ... -f deploy/compose.prod.yml config +# Ключевые обязательные (без значения `config` упадёт с подсказкой): DEAL_PG_PASSWORD, +# DEAL_ENCRYPTION_KEY, DEAL_SERVICE_TOKEN, DEAL_TELEGRAM_SESSION_KEY, MINIO_ROOT_USER, +# MINIO_ROOT_PASSWORD, DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD (пустые — оператор не создаётся, +# см. Ruling 1), DEAL_ALLOWED_ORIGINS. +# +# PROD-флаги кода (Ruling 5/9/10): RateLimit__Enabled=true (лимиты/защита входа), куки +# Cookies__Secure/OperatorCookies__Secure=true (TLS терминирует caddy), ForwardedHeaders__Enabled=true +# (доверенный прокси — caddy; KnownNetworks — приватные docker-сети хоста: core наружу не публикуется, +# единственный вход в него — caddy), Security__AllowedOrigins — origin фронта. +# +# mTLS внутреннего gRPC (Ruling 6, Task 13/14): DEAL_MTLS_ENABLED=0 — plaintext + service-token +# (как dev, но внутри изолированной сети); DEAL_MTLS_ENABLED=1 — Kestrel сервисов/ингресса требует +# клиентский сертификат. Сертификаты: deploy/certs (scripts/mtls-certs.sh) монтируются в +# /etc/deal/certs. ВАЖНО при включении mTLS: +# * схемы внутренних endpoint'ов поменять на https:// (DEAL_ML_ENDPOINT/DEAL_AI_ENDPOINT/ +# DEAL_TELEGRAM_ENDPOINT/DEAL_CORE_INGRESS в .env.prod); +# * healthcheck'и ниже сами переключаются на TLS-пробу grpc_health_probe (клиентский сертификат из +# /etc/deal/certs; PEM-артефакты deal-client.crt/.key генерирует scripts/mtls-certs.sh); +# * DEAL_MTLS_CERT_PASSWORD — пароль PFX (серверных и клиентского). +# +# Логи (Ruling 7): процессы пишут Serilog JSON в stdout (docker-логи) + rolling-файл +# data/logs/deal-<процесс>.json в контейнере. У core /app/data — volume deal_api_data (файлы-логи +# переживают пересоздание); у сервисов канал наблюдаемости — docker-логи → promtail (файлы сервисов +# эфемерны). Уровень — env DEAL_LOG_LEVEL. +# +# Frontend: статика монтируется из ../src/frontend/dist (СОБРАТЬ ДО ПОДЪЁМА: cd src/frontend && +# npm run build). Конфигурация caddy — deploy/caddy/Caddyfile (см. его шапку про домен/TLS). + +# Общий hardening прикладных сервисов (BL-IMG-HARDEN): read-only root FS + tmpfs /tmp, +# запрет новых привилегий и сброс capabilities. Данные/логи — только в именованных volume или tmpfs. +x-service-hardening: &service-hardening + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: deal + POSTGRES_USER: deal + POSTGRES_PASSWORD: ${DEAL_PG_PASSWORD:?DEAL_PG_PASSWORD не задан (пароль Postgres deal)} + volumes: + - deal_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U deal -d deal"] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + + # MinIO (файлы проектных карточек; бакет deal-files создаётся лениво при первом upload, Ruling 4). + # БЕЗ host-портов: наружу файлы ходят только через core→caddy. Healthcheck нет: minio-образ не несёт + # curl/mc; на core падение minio = фолбэк LocalFileStorage (данные — в volume, подъём лечит). + minio: + # Тег зафиксирован (как остальные образы); при обновлении — свежий RELEASE-тег из quay.io/minio/minio. + image: minio/minio:RELEASE.2025-04-22T22-12-26Z + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER не задан} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан} + volumes: + - deal_minio_data:/data + command: server /data + restart: unless-stopped + + # core (deal-api): HTTP :5080 + gRPC-ингресс telegram-service :5082. Сквозной gRPC-режим + # (UseLocal=false), как compose.dev.yml; rate limiting/куки-Secure/прокси-заголовки — PROD (см. шапку). + core: + build: + context: .. + dockerfile: src/core/Deal.Api/Dockerfile + <<: *service-hardening + mem_limit: 1g + cpus: 1.5 + environment: + ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_URLS: http://0.0.0.0:5080 + GRPC_INGRESS_PORT: "5082" + ConnectionStrings__DealPostgres: "Host=postgres;Port=5432;Database=deal;Username=deal;Password=${DEAL_PG_PASSWORD:?DEAL_PG_PASSWORD не задан}" + DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан (service-token gRPC)} + DEAL_ENCRYPTION_KEY: ${DEAL_ENCRYPTION_KEY:?DEAL_ENCRYPTION_KEY не задан (AES-256-GCM, 32 байта base64)} + DEAL_OPERATOR_LOGIN: ${DEAL_OPERATOR_LOGIN:-} + DEAL_OPERATOR_PASSWORD: ${DEAL_OPERATOR_PASSWORD:-} + DEAL_DEFAULT_AI_BUDGET: ${DEAL_DEFAULT_AI_BUDGET:-} + # Безопасность PROD (Rulings 5/9/10): лимиты вкл., куки Secure, доверенный прокси (caddy). + RateLimit__Enabled: "true" + Cookies__Secure: "true" + OperatorCookies__Secure: "true" + ForwardedHeaders__Enabled: "true" + # Доверенные подсети прокси: приватные docker-сети хоста (caddy — единственный вход в core, + # host-портов у core нет). Свою подсеть можно уточнить: docker network inspect <сеть>. + ForwardedHeaders__KnownNetworks__0: 172.16.0.0/12 + Security__AllowedOrigins: ${DEAL_ALLOWED_ORIGINS:?DEAL_ALLOWED_ORIGINS не задан (origin фронта, напр. https://deal.example)} + # Интеграции — gRPC-режим всего стека (как dev); схемы endpoint'ов — http:// (mTLS off) либо + # https:// при DEAL_MTLS_ENABLED=1 (см. .env.prod.example). + Services__Ml__UseLocal: "false" + Services__Ml__Endpoint: ${DEAL_ML_ENDPOINT:-http://ml-service:5103} + Services__Ai__UseLocal: "false" + Services__Ai__Endpoint: ${DEAL_AI_ENDPOINT:-http://ai-service:5102} + Services__Telegram__UseLocal: "false" + Services__Telegram__Endpoint: ${DEAL_TELEGRAM_ENDPOINT:-http://telegram-service:5101} + # Файлы — MinIO (внутренний http; TLS minio — вне этапа, при желании Storage__Minio__Secure=true + # + endpoint https и сертификаты). + Storage__Minio__Endpoint: minio:9000 + Storage__Minio__AccessKey: ${MINIO_ROOT_USER:?MINIO_ROOT_USER не задан} + Storage__Minio__SecretKey: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан} + Storage__Minio__Bucket: deal-files + Storage__Minio__Secure: "false" + # mTLS внутреннего gRPC (Ruling 6; пути — /etc/deal/certs, см. volume ниже). + DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} + DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem + DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/core-server.pfx + DEAL_MTLS_SERVER_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-} + DEAL_MTLS_CLIENT_CERT_PFX: /etc/deal/certs/deal-client.pfx + DEAL_MTLS_CLIENT_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-} + volumes: + # data/: ключ шифрования (или DEAL_ENCRYPTION_KEY), LocalFileStorage-фолбэк, логи data/logs. + - deal_api_data:/app/data + # deploy/certs (scripts/mtls-certs.sh): нужны только при DEAL_MTLS_ENABLED=1. + - ${DEAL_CERTS_DIR:-./certs}:/etc/deal/certs:ro + depends_on: + postgres: + condition: service_healthy + healthcheck: + # gRPC-health ингресса :5082 (health освобождён от service-token). Под mTLS — TLS-проба + # с клиентским сертификатом deal-client и CA (PEM-артефакты генерирует scripts/mtls-certs.sh). + test: ["CMD-SHELL", "if [ \"$$DEAL_MTLS_ENABLED\" = \"1\" ]; then /bin/grpc_health_probe -addr=localhost:5082 -tls -tls-ca-cert=/etc/deal/certs/ca.pem -tls-client-cert=/etc/deal/certs/deal-client.crt -tls-client-key=/etc/deal/certs/deal-client.key -tls-server-name=localhost; else /bin/grpc_health_probe -addr=localhost:5082; fi"] + interval: 10s + timeout: 3s + retries: 10 + start_period: 15s + restart: unless-stopped + + # telegram-service — gRPC-сервер Telegram (:5101; plaintext либо mTLS — как core). Сессии — + # /data/sessions (volume deal_tg_sessions, AES-GCM ключом DEAL_TELEGRAM_SESSION_KEY, fail-closed). + telegram-service: + build: + context: .. + dockerfile: src/telegram-service/Deal.Telegram/Dockerfile + <<: *service-hardening + mem_limit: 512m + cpus: 1.0 + environment: + GRPC_PORT: "5101" + DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан} + DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:?DEAL_TELEGRAM_SESSION_KEY не задан (ключ AES-GCM сессий)} + DEAL_TELEGRAM_SESSION_DIR: /data/sessions + DEAL_LOGS_DIR: /tmp/logs + SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082} + DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} + DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem + DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/telegram-service-server.pfx + DEAL_MTLS_SERVER_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-} + DEAL_MTLS_CLIENT_CERT_PFX: /etc/deal/certs/deal-client.pfx + DEAL_MTLS_CLIENT_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-} + volumes: + - deal_tg_sessions:/data/sessions + - ${DEAL_CERTS_DIR:-./certs}:/etc/deal/certs:ro + healthcheck: + test: ["CMD-SHELL", "if [ \"$$DEAL_MTLS_ENABLED\" = \"1\" ]; then /bin/grpc_health_probe -addr=localhost:5101 -tls -tls-ca-cert=/etc/deal/certs/ca.pem -tls-client-cert=/etc/deal/certs/deal-client.crt -tls-client-key=/etc/deal/certs/deal-client.key -tls-server-name=localhost; else /bin/grpc_health_probe -addr=localhost:5101; fi"] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + + # ai-service — gRPC-сервер ИИ-фасада (:5102). Без БД и volume'ов (конфиг провайдера — в теле RPC). + ai-service: + build: + context: .. + dockerfile: src/ai-service/Deal.Ai/Dockerfile + <<: *service-hardening + mem_limit: 512m + cpus: 1.0 + environment: + GRPC_PORT: "5102" + DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан} + DEAL_LOGS_DIR: /tmp/logs + DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} + DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem + DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ai-service-server.pfx + DEAL_MTLS_SERVER_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-} + DEAL_MTLS_CLIENT_CERT_PFX: /etc/deal/certs/deal-client.pfx + DEAL_MTLS_CLIENT_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-} + volumes: + - ${DEAL_CERTS_DIR:-./certs}:/etc/deal/certs:ro + healthcheck: + test: ["CMD-SHELL", "if [ \"$$DEAL_MTLS_ENABLED\" = \"1\" ]; then /bin/grpc_health_probe -addr=localhost:5102 -tls -tls-ca-cert=/etc/deal/certs/ca.pem -tls-client-cert=/etc/deal/certs/deal-client.crt -tls-client-key=/etc/deal/certs/deal-client.key -tls-server-name=localhost; else /bin/grpc_health_probe -addr=localhost:5102; fi"] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + + # ml-service — gRPC-сервер ML (:5103). Модели — /data/ml (volume deal_ml_data, SQLite на тенанта). + ml-service: + build: + context: .. + dockerfile: src/ml-service/Deal.Ml/Dockerfile + <<: *service-hardening + mem_limit: 768m + cpus: 1.0 + environment: + GRPC_PORT: "5103" + DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан} + DEAL_ML_DATA_DIR: /data/ml + DEAL_LOGS_DIR: /tmp/logs + DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} + DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem + DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ml-service-server.pfx + DEAL_MTLS_SERVER_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-} + DEAL_MTLS_CLIENT_CERT_PFX: /etc/deal/certs/deal-client.pfx + DEAL_MTLS_CLIENT_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-} + volumes: + - deal_ml_data:/data/ml + - ${DEAL_CERTS_DIR:-./certs}:/etc/deal/certs:ro + healthcheck: + test: ["CMD-SHELL", "if [ \"$$DEAL_MTLS_ENABLED\" = \"1\" ]; then /bin/grpc_health_probe -addr=localhost:5103 -tls -tls-ca-cert=/etc/deal/certs/ca.pem -tls-client-cert=/etc/deal/certs/deal-client.crt -tls-client-key=/etc/deal/certs/deal-client.key -tls-server-name=localhost; else /bin/grpc_health_probe -addr=localhost:5103; fi"] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + + # caddy — edge: наружу только :80/:443. TLS — плейсхолдер tls internal (см. Caddyfile: домен, + # реальный сертификат/Cloudflare, CSP/HSTS). Статика — ../src/frontend/dist (СОБРАТЬ ДО up). + caddy: + image: caddy:2.9.1 + ports: + - "80:80" + - "443:443" + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - ../src/frontend/dist:/srv:ro + # /data — локальный CA и сертификаты Caddy (переживают пересоздание контейнера). + - deal_caddy_data:/data + - deal_caddy_config:/config + depends_on: + core: + condition: service_healthy + restart: unless-stopped + + # ── Observability (Ruling 7/9; метрики — этап 12, пакет A) — ПРОФИЛЬ: --profile observability ── + # Логи: docker-логи (stdout контейнеров) → promtail → loki → grafana (127.0.0.1:3001). + # Метрики: /metrics процессов (:9464) → prometheus (127.0.0.1:9090) → grafana (датасорс uid + # `prometheus`). Доступ — оператору по SSH-туннелю; наружу grafana/prometheus НЕ публикуются. + # Версии образов — фиксированные (при подъёме обновите до актуальных patch-релизов). + + loki: + image: grafana/loki:3.4.2 + profiles: ["observability"] + command: -config.file=/etc/loki/loki.yml + volumes: + - ./observability/loki.yml:/etc/loki/loki.yml:ro + - deal_loki_data:/loki + restart: unless-stopped + + # Prometheus — сбор метрик Deal-процессов (этап 12, пакет A): scrape /metrics каждого процесса + # (:9464, HTTP/1.1) внутри compose-сети. Конфиг — deploy/observability/prometheus.yml (таргеты + # core/telegram-service/ai-service/ml-service). UI — только loopback 127.0.0.1:9090 (оператору по + # SSH-туннелю, как Grafana); наружу порт не публикуется. Retention 15 суток (volume deal_prometheus_data). + prometheus: + image: prom/prometheus:v3.5.0 + profiles: ["observability"] + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --storage.tsdb.retention.time=15d + ports: + # Только loopback хоста — наружу не публикуется (Ruling 9). + - "127.0.0.1:9090:9090" + volumes: + - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./observability/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro + - deal_prometheus_data:/prometheus + restart: unless-stopped + + promtail: + image: grafana/promtail:3.4.2 + profiles: ["observability"] + command: -config.file=/etc/promtail/promtail.yml + volumes: + - ./observability/promtail.yml:/etc/promtail/promtail.yml:ro + # docker.sock — чтение docker-логов всех контейнеров (docker_sd). Rootless-докер: путь сокета + # свой (напр. /run/user//docker.sock) — поправьте монтирование и promtail.yml. + - /var/run/docker.sock:/var/run/docker.sock:ro + - deal_promtail_data:/var/lib/promtail + depends_on: + loki: + condition: service_started + restart: unless-stopped + + grafana: + image: grafana/grafana:11.5.2 + profiles: ["observability"] + environment: + # Пароль задайте в .env.prod перед подъёмом профиля (пусто — grafana не получит admin-пароль). + GF_SECURITY_ADMIN_USER: ${DEAL_GRAFANA_ADMIN_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${DEAL_GRAFANA_ADMIN_PASSWORD:-} + GF_USERS_ALLOW_SIGN_UP: "false" + GF_AUTH_ANONYMOUS_ENABLED: "false" + ports: + # Только loopback хоста — оператору по SSH-туннелю (Ruling 9); наружу не публикуется. + - "127.0.0.1:3001:3000" + volumes: + - ./observability/grafana/provisioning:/etc/grafana/provisioning:ro + - ./observability/grafana/dashboards:/var/lib/grafana/dashboards:ro + - deal_grafana_data:/var/lib/grafana + depends_on: + loki: + condition: service_started + prometheus: + condition: service_started + restart: unless-stopped + +volumes: + deal_pgdata: + deal_minio_data: + deal_tg_sessions: + deal_ml_data: + deal_api_data: + deal_caddy_data: + deal_caddy_config: + deal_loki_data: + deal_promtail_data: + deal_grafana_data: + deal_prometheus_data: diff --git a/deploy/observability/grafana/dashboards/Deal-Auth.json b/deploy/observability/grafana/dashboards/Deal-Auth.json index 46d0c79..fcf3272 100644 --- a/deploy/observability/grafana/dashboards/Deal-Auth.json +++ b/deploy/observability/grafana/dashboards/Deal-Auth.json @@ -1,388 +1,388 @@ -{ - "annotations": { - "list": [] - }, - "editable": true, - "graphTooltip": 0, - "id": null, - "links": [], - "panels": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Успешные входы (HTTP 200) по access-логу core (HttpAccessLogMiddleware: поля Path/StatusCode). Тенант — POST /api/auth/login, оператор — POST /api/operator/auth/login.", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 10, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 0 - }, - "id": 1, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum(count_over_time({service=\"core\"} | json | Path=\"/api/auth/login\" | StatusCode=200 [1m]))", - "legendFormat": "тенант", - "refId": "A" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum(count_over_time({service=\"core\"} | json | Path=\"/api/operator/auth/login\" | StatusCode=200 [1m]))", - "legendFormat": "оператор", - "refId": "B" - } - ], - "title": "Успешные входы, /мин", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Неудачные входы по HTTP-коду ответа: 401 — неверные учётные данные, 403 — тенант приостановлен, 429 — сработал LoginAttemptGuard (5 неудач ip|login за 15 мин). По access-логу core: поля Path/StatusCode.", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "bars", - "fillOpacity": 60, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 0 - }, - "id": 2, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum by (StatusCode) (count_over_time({service=\"core\"} | json | Path=~\"/api/auth/login|/api/operator/auth/login\" | StatusCode >= 400 [1m]))", - "legendFormat": "HTTP {{StatusCode}}", - "refId": "A" - } - ], - "title": "Неудачные входы по коду ответа, /мин", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Выходы из системы (POST /api/auth/logout — тенант, POST /api/operator/auth/logout — оператор) по access-логу core. Ручка всегда отвечает 200.", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 10, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 8 - }, - "id": 3, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum by (Path) (count_over_time({service=\"core\"} | json | Path=~\"/api/auth/logout|/api/operator/auth/logout\" [1m]))", - "legendFormat": "{{Path}}", - "refId": "A" - } - ], - "title": "Выходы (logout), /мин", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Активация приглашений (POST /api/join) по коду ответа: 200 — успешная активация, 4xx — неверный/просроченный код, занятый email или приостановленный тенант.", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "bars", - "fillOpacity": 60, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 8 - }, - "id": 4, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum by (StatusCode) (count_over_time({service=\"core\"} | json | Path=\"/api/join\" [1m]))", - "legendFormat": "HTTP {{StatusCode}}", - "refId": "A" - } - ], - "title": "Активация инвайтов (/api/join), /мин", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Успешные входы тенанта и оператора за последний час.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 0, - "y": 16 - }, - "id": 5, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum(count_over_time({service=\"core\"} | json | Path=~\"/api/auth/login|/api/operator/auth/login\" | StatusCode=200 [1h]))", - "legendFormat": "успешных входов", - "refId": "A" - } - ], - "title": "Успешные входы за 1ч", - "type": "stat" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Неудачные входы тенанта и оператора за последний час; >0 — повод посмотреть Explore (текст ответа) и аудит-ленту оператора.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 1 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 6, - "y": 16 - }, - "id": 6, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum(count_over_time({service=\"core\"} | json | Path=~\"/api/auth/login|/api/operator/auth/login\" | StatusCode >= 400 [1h]))", - "legendFormat": "неудачных входов", - "refId": "A" - } - ], - "title": "Неудачные входы за 1ч", - "type": "stat" - }, - { - "gridPos": { - "h": 5, - "w": 12, - "x": 12, - "y": 16 - }, - "id": 7, - "options": { - "content": "**Кто именно входил — в аудите, не в логах.** Access-лог core не пишет идентичность (login/tenant/operator) — только метод/путь/код, поэтому панели выше различают лишь контур (тенант vs оператор) по префиксу пути.\n\nПолная лента входов/выходов/неудач с актором, тенантом и IP — в PostgreSQL (`public.audit_log`, append-only, события `tenant_login_ok`/`tenant_login_failed`/`operator_login_*`/`*_logout`/`invite_activated`) через операторские ручки `GET /api/operator/audit` или экран «Аудит» в оператор-консоли.", - "mode": "markdown" - }, - "title": "Где искать актора", - "type": "text" - } - ], - "refresh": "1m", - "schemaVersion": 39, - "tags": [ - "deal" - ], - "templating": { - "list": [] - }, - "time": { - "from": "now-24h", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Deal-Auth", - "uid": "deal-auth", - "version": 1, - "weekStart": "" -} +{ + "annotations": { + "list": [] + }, + "editable": true, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Успешные входы (HTTP 200) по access-логу core (HttpAccessLogMiddleware: поля Path/StatusCode). Тенант — POST /api/auth/login, оператор — POST /api/operator/auth/login.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum(count_over_time({service=\"core\"} | json | Path=\"/api/auth/login\" | StatusCode=200 [1m]))", + "legendFormat": "тенант", + "refId": "A" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum(count_over_time({service=\"core\"} | json | Path=\"/api/operator/auth/login\" | StatusCode=200 [1m]))", + "legendFormat": "оператор", + "refId": "B" + } + ], + "title": "Успешные входы, /мин", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Неудачные входы по HTTP-коду ответа: 401 — неверные учётные данные, 403 — тенант приостановлен, 429 — сработал LoginAttemptGuard (5 неудач ip|login за 15 мин). По access-логу core: поля Path/StatusCode.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "bars", + "fillOpacity": 60, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum by (StatusCode) (count_over_time({service=\"core\"} | json | Path=~\"/api/auth/login|/api/operator/auth/login\" | StatusCode >= 400 [1m]))", + "legendFormat": "HTTP {{StatusCode}}", + "refId": "A" + } + ], + "title": "Неудачные входы по коду ответа, /мин", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Выходы из системы (POST /api/auth/logout — тенант, POST /api/operator/auth/logout — оператор) по access-логу core. Ручка всегда отвечает 200.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum by (Path) (count_over_time({service=\"core\"} | json | Path=~\"/api/auth/logout|/api/operator/auth/logout\" [1m]))", + "legendFormat": "{{Path}}", + "refId": "A" + } + ], + "title": "Выходы (logout), /мин", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Активация приглашений (POST /api/join) по коду ответа: 200 — успешная активация, 4xx — неверный/просроченный код, занятый email или приостановленный тенант.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "bars", + "fillOpacity": 60, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum by (StatusCode) (count_over_time({service=\"core\"} | json | Path=\"/api/join\" [1m]))", + "legendFormat": "HTTP {{StatusCode}}", + "refId": "A" + } + ], + "title": "Активация инвайтов (/api/join), /мин", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Успешные входы тенанта и оператора за последний час.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum(count_over_time({service=\"core\"} | json | Path=~\"/api/auth/login|/api/operator/auth/login\" | StatusCode=200 [1h]))", + "legendFormat": "успешных входов", + "refId": "A" + } + ], + "title": "Успешные входы за 1ч", + "type": "stat" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Неудачные входы тенанта и оператора за последний час; >0 — повод посмотреть Explore (текст ответа) и аудит-ленту оператора.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 16 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum(count_over_time({service=\"core\"} | json | Path=~\"/api/auth/login|/api/operator/auth/login\" | StatusCode >= 400 [1h]))", + "legendFormat": "неудачных входов", + "refId": "A" + } + ], + "title": "Неудачные входы за 1ч", + "type": "stat" + }, + { + "gridPos": { + "h": 5, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 7, + "options": { + "content": "**Кто именно входил — в аудите, не в логах.** Access-лог core не пишет идентичность (login/tenant/operator) — только метод/путь/код, поэтому панели выше различают лишь контур (тенант vs оператор) по префиксу пути.\n\nПолная лента входов/выходов/неудач с актором, тенантом и IP — в PostgreSQL (`public.audit_log`, append-only, события `tenant_login_ok`/`tenant_login_failed`/`operator_login_*`/`*_logout`/`invite_activated`) через операторские ручки `GET /api/operator/audit` или экран «Аудит» в оператор-консоли.", + "mode": "markdown" + }, + "title": "Где искать актора", + "type": "text" + } + ], + "refresh": "1m", + "schemaVersion": 39, + "tags": [ + "deal" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-24h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Deal-Auth", + "uid": "deal-auth", + "version": 1, + "weekStart": "" +} diff --git a/deploy/observability/grafana/dashboards/Deal-Errors.json b/deploy/observability/grafana/dashboards/Deal-Errors.json index d18c72d..20836f2 100644 --- a/deploy/observability/grafana/dashboards/Deal-Errors.json +++ b/deploy/observability/grafana/dashboards/Deal-Errors.json @@ -1,410 +1,410 @@ -{ - "annotations": { - "list": [] - }, - "editable": true, - "graphTooltip": 0, - "id": null, - "links": [], - "panels": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "HTTP-ответы 5xx по путям (access-лог core, поле StatusCode). Ручка, отдающая 5xx, обычно соответствует исключению ниже (в той же панели логов).", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "bars", - "fillOpacity": 60, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 0 - }, - "id": 1, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum by (Path) (count_over_time({service=\"core\"} | json | StatusCode >= 500 [1m]))", - "legendFormat": "{{Path}}", - "refId": "A" - } - ], - "title": "HTTP 5xx по путям (core), /мин", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Строки с необработанным исключением (Serilog-поле \"@x\") по deal-процессам. Каждое такое событие пишется access-логом/аудитом и означает баг либо недоступную зависимость.", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "bars", - "fillOpacity": 60, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 0 - }, - "id": 2, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum by (service) (count_over_time({service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"\\\"@x\\\":\\\"\" [1m]))", - "legendFormat": "{{service}}", - "refId": "A" - } - ], - "title": "Исключения по сервисам, /мин", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Строки уровня Error/Fatal (Serilog-поле @l) по deal-процессам. Дополняет панель исключений: ловит и логированные, но не брошенные ошибки.", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 20, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 8 - }, - "id": 3, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum by (service) (count_over_time({service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"@l\\\":\\\"(Error|Fatal)\" [1m]))", - "legendFormat": "{{service}}", - "refId": "A" - } - ], - "title": "Error/Fatal по сервисам, /мин", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "gRPC-вызовы с не-OK статусом (access-лог RpcCallLoggingInterceptor: поля RpcMethod/GrpcStatus). Cancelled/DeadlineExceeded — клиентские отмены, Unavailable/Unknown — сбои. gRPC-health не логируется.", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "bars", - "fillOpacity": 60, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 8 - }, - "id": 4, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum by (service) (count_over_time({service=~\"core|telegram-service|ai-service|ml-service\"} |= \"gRPC \" | json | GrpcStatus != \"OK\" [1m]))", - "legendFormat": "{{service}}", - "refId": "A" - } - ], - "title": "gRPC-вызовы с ошибкой по сервисам, /мин", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Суммарно Error/Fatal по всем deal-процессам за 5 минут; >0 — повод открыть панель логов ниже.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 1 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 5, - "w": 12, - "x": 0, - "y": 16 - }, - "id": 5, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum(count_over_time({service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"@l\\\":\\\"(Error|Fatal)\" [5m]))", - "legendFormat": "ошибок за 5м", - "refId": "A" - } - ], - "title": "Error/Fatal за 5 мин (все сервисы)", - "type": "stat" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "HTTP 5xx (core) за последний час.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 1 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 5, - "w": 12, - "x": 12, - "y": 16 - }, - "id": 6, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum(count_over_time({service=\"core\"} | json | StatusCode >= 500 [1h]))", - "legendFormat": "HTTP 5xx за 1ч", - "refId": "A" - } - ], - "title": "HTTP 5xx за 1 час (core)", - "type": "stat" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Строки Error/Fatal и записи с исключением (\"@x\") по всем deal-процессам. Разверните строку для текста исключения и стека.", - "gridPos": { - "h": 10, - "w": 24, - "x": 0, - "y": 21 - }, - "id": 7, - "options": { - "dedupStrategy": "none", - "enableLogDetails": true, - "showTime": true, - "sortOrder": "Descending", - "wrapLogMessage": true - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "{service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"@l\\\":\\\"(Error|Fatal)\"", - "refId": "A" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "{service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"\\\"@x\\\":\\\"\"", - "refId": "B" - } - ], - "title": "Лента ошибок и исключений", - "type": "logs" - } - ], - "refresh": "30s", - "schemaVersion": 39, - "tags": [ - "deal" - ], - "templating": { - "list": [] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Deal-Errors", - "uid": "deal-errors", - "version": 1, - "weekStart": "" -} +{ + "annotations": { + "list": [] + }, + "editable": true, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "HTTP-ответы 5xx по путям (access-лог core, поле StatusCode). Ручка, отдающая 5xx, обычно соответствует исключению ниже (в той же панели логов).", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "bars", + "fillOpacity": 60, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum by (Path) (count_over_time({service=\"core\"} | json | StatusCode >= 500 [1m]))", + "legendFormat": "{{Path}}", + "refId": "A" + } + ], + "title": "HTTP 5xx по путям (core), /мин", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Строки с необработанным исключением (Serilog-поле \"@x\") по deal-процессам. Каждое такое событие пишется access-логом/аудитом и означает баг либо недоступную зависимость.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "bars", + "fillOpacity": 60, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum by (service) (count_over_time({service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"\\\"@x\\\":\\\"\" [1m]))", + "legendFormat": "{{service}}", + "refId": "A" + } + ], + "title": "Исключения по сервисам, /мин", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Строки уровня Error/Fatal (Serilog-поле @l) по deal-процессам. Дополняет панель исключений: ловит и логированные, но не брошенные ошибки.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 20, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum by (service) (count_over_time({service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"@l\\\":\\\"(Error|Fatal)\" [1m]))", + "legendFormat": "{{service}}", + "refId": "A" + } + ], + "title": "Error/Fatal по сервисам, /мин", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "gRPC-вызовы с не-OK статусом (access-лог RpcCallLoggingInterceptor: поля RpcMethod/GrpcStatus). Cancelled/DeadlineExceeded — клиентские отмены, Unavailable/Unknown — сбои. gRPC-health не логируется.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "bars", + "fillOpacity": 60, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum by (service) (count_over_time({service=~\"core|telegram-service|ai-service|ml-service\"} |= \"gRPC \" | json | GrpcStatus != \"OK\" [1m]))", + "legendFormat": "{{service}}", + "refId": "A" + } + ], + "title": "gRPC-вызовы с ошибкой по сервисам, /мин", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Суммарно Error/Fatal по всем deal-процессам за 5 минут; >0 — повод открыть панель логов ниже.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum(count_over_time({service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"@l\\\":\\\"(Error|Fatal)\" [5m]))", + "legendFormat": "ошибок за 5м", + "refId": "A" + } + ], + "title": "Error/Fatal за 5 мин (все сервисы)", + "type": "stat" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "HTTP 5xx (core) за последний час.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum(count_over_time({service=\"core\"} | json | StatusCode >= 500 [1h]))", + "legendFormat": "HTTP 5xx за 1ч", + "refId": "A" + } + ], + "title": "HTTP 5xx за 1 час (core)", + "type": "stat" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Строки Error/Fatal и записи с исключением (\"@x\") по всем deal-процессам. Разверните строку для текста исключения и стека.", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 21 + }, + "id": 7, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "{service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"@l\\\":\\\"(Error|Fatal)\"", + "refId": "A" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "{service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"\\\"@x\\\":\\\"\"", + "refId": "B" + } + ], + "title": "Лента ошибок и исключений", + "type": "logs" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [ + "deal" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Deal-Errors", + "uid": "deal-errors", + "version": 1, + "weekStart": "" +} diff --git a/deploy/observability/grafana/dashboards/Deal-Health.json b/deploy/observability/grafana/dashboards/Deal-Health.json index 4e8c086..df4a016 100644 --- a/deploy/observability/grafana/dashboards/Deal-Health.json +++ b/deploy/observability/grafana/dashboards/Deal-Health.json @@ -1,228 +1,228 @@ -{ - "annotations": { - "list": [] - }, - "editable": true, - "graphTooltip": 0, - "id": null, - "links": [], - "panels": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Строк логов в минуту по контейнерам deal-процессов (core/telegram-service/ai-service/ml-service). Контейнер без логов = нет данных (сервис молчит или лежит).", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 15, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 0 - }, - "id": 1, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "count_over_time({service=\"core\"}[1m])", - "legendFormat": "core", - "refId": "A" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "count_over_time({service=\"telegram-service\"}[1m])", - "legendFormat": "telegram-service", - "refId": "B" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "count_over_time({service=\"ai-service\"}[1m])", - "legendFormat": "ai-service", - "refId": "C" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "count_over_time({service=\"ml-service\"}[1m])", - "legendFormat": "ml-service", - "refId": "D" - } - ], - "title": "Активность логов deal-процессов (строк/мин)", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Строки уровня Error/Fatal (Serilog CompactJsonFormatter: \"@l\":\"Error\"/\"Fatal\") по deal-процессам. Поиск по логам детально — Explore с датасорсом Loki.", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "bars", - "fillOpacity": 60, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 0 - }, - "id": 2, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "count_over_time({service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"@l\\\":\\\"(Error|Fatal)\" [1m])", - "legendFormat": "{{service}}", - "refId": "A" - } - ], - "title": "Ошибки и фатальные по deal-процессам (строк/мин)", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Суммарно Error/Fatal по всем deal-процессам за 5 минут; >0 — повод открыть Explore и посмотреть текст ошибки.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 1 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 24, - "x": 0, - "y": 8 - }, - "id": 3, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum(count_over_time({service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"@l\\\":\\\"(Error|Fatal)\" [5m]))", - "legendFormat": "ошибок за 5м", - "refId": "A" - } - ], - "title": "Ошибки за 5 минут (все deal-процессы)", - "type": "stat" - } - ], - "refresh": "30s", - "schemaVersion": 39, - "tags": [ - "deal" - ], - "templating": { - "list": [] - }, - "time": { - "from": "now-15m", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Deal-Health", - "uid": "deal-health", - "version": 1, - "weekStart": "" -} +{ + "annotations": { + "list": [] + }, + "editable": true, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Строк логов в минуту по контейнерам deal-процессов (core/telegram-service/ai-service/ml-service). Контейнер без логов = нет данных (сервис молчит или лежит).", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 15, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "count_over_time({service=\"core\"}[1m])", + "legendFormat": "core", + "refId": "A" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "count_over_time({service=\"telegram-service\"}[1m])", + "legendFormat": "telegram-service", + "refId": "B" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "count_over_time({service=\"ai-service\"}[1m])", + "legendFormat": "ai-service", + "refId": "C" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "count_over_time({service=\"ml-service\"}[1m])", + "legendFormat": "ml-service", + "refId": "D" + } + ], + "title": "Активность логов deal-процессов (строк/мин)", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Строки уровня Error/Fatal (Serilog CompactJsonFormatter: \"@l\":\"Error\"/\"Fatal\") по deal-процессам. Поиск по логам детально — Explore с датасорсом Loki.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "bars", + "fillOpacity": 60, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "count_over_time({service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"@l\\\":\\\"(Error|Fatal)\" [1m])", + "legendFormat": "{{service}}", + "refId": "A" + } + ], + "title": "Ошибки и фатальные по deal-процессам (строк/мин)", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Суммарно Error/Fatal по всем deal-процессам за 5 минут; >0 — повод открыть Explore и посмотреть текст ошибки.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum(count_over_time({service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"@l\\\":\\\"(Error|Fatal)\" [5m]))", + "legendFormat": "ошибок за 5м", + "refId": "A" + } + ], + "title": "Ошибки за 5 минут (все deal-процессы)", + "type": "stat" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [ + "deal" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Deal-Health", + "uid": "deal-health", + "version": 1, + "weekStart": "" +} diff --git a/deploy/observability/grafana/dashboards/Deal-Logs.json b/deploy/observability/grafana/dashboards/Deal-Logs.json index 36d80aa..3dcfcb6 100644 --- a/deploy/observability/grafana/dashboards/Deal-Logs.json +++ b/deploy/observability/grafana/dashboards/Deal-Logs.json @@ -1,303 +1,303 @@ -{ - "annotations": { - "list": [] - }, - "editable": true, - "graphTooltip": 0, - "id": null, - "links": [], - "panels": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Живая лента логов deal-процессов: фильтры по сервису и уровню сверху. Разверните строку — детали Serilog-события (@mt, свойства, исключение). Query-строка/тела запросов в логи не пишутся (Ruling 13).", - "gridPos": { - "h": 12, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 1, - "options": { - "dedupStrategy": "none", - "enableLogDetails": true, - "showTime": true, - "sortOrder": "Descending", - "wrapLogMessage": true - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "{service=~\"$service\", level=~\"$level\"}", - "refId": "A" - } - ], - "title": "Логи deal-процессов", - "type": "logs" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Объём логов в разрезе уровня Serilog (поле @l, поднято в метку level пайплайном Promtail). Высокий Error/Fatal — детали в дашборде Deal-Errors.", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 10, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 12 - }, - "id": 2, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum by (level) (count_over_time({service=~\"$service\", level=~\"$level\"} [1m]))", - "legendFormat": "{{level}}", - "refId": "A" - } - ], - "title": "Строк логов по уровням, /мин", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Объём логов по сервисам (метка service из лейблов Promtail). Заметный провал до нуля — сервис молчит или лежит (см. также Deal-Health).", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 10, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 12 - }, - "id": 3, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum by (service) (count_over_time({service=~\"$service\"} [1m]))", - "legendFormat": "{{service}}", - "refId": "A" - } - ], - "title": "Строк логов по сервисам, /мин", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "События с идентификатором тенанта (Serilog-поле TenantId) в логах фоновых сервисов: AI/ML-вызовы («Аудит: tenant …»), Telegram (backfill/realtime). Показывает, какие тенанты активны, по данным логов.", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 10, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 20 - }, - "id": 4, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "topk(10, sum by (TenantId) (count_over_time({service=~\"ai-service|ml-service|telegram-service\"} | json | TenantId =~ \".+\" [5m])))", - "legendFormat": "{{TenantId}}", - "refId": "A" - } - ], - "title": "Активность по тенантам (AI/ML/Telegram, top-10), /5мин", - "type": "timeseries" - }, - { - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 20 - }, - "id": 5, - "options": { - "content": "**Действия пользователей логируются не сюда, а в аудит.** Создание/перемещение/удаление карточек, контейнеры, настройки, каналы, входы/выходы/инвайты пишутся append-only в `public.audit_log` (AuditService) и доступны через `GET /api/operator/audit` / экран «Аудит» оператор-консоли.\n\nВ логах (этат дашборд) по действиям доступны: access-логи HTTP/gRPC (метод/путь/код), аудит-строки фоновых сервисов (`Аудит: tenant …`) и идентификатор тенанта (`TenantId`). Поиск по ключевой фразе добавьте в ленту: `{service=~\"$service\"} |= \"фраза\"`.", - "mode": "markdown" - }, - "title": "Где искать действия пользователей", - "type": "text" - } - ], - "refresh": "30s", - "schemaVersion": 39, - "tags": [ - "deal" - ], - "templating": { - "list": [ - { - "allValue": ".*", - "current": { - "selected": true, - "text": [ - "All" - ], - "value": [ - "$__all" - ] - }, - "datasource": { - "type": "loki", - "uid": "loki" - }, - "definition": "label_values(service)", - "includeAll": true, - "label": "Сервис", - "multi": true, - "name": "service", - "options": [], - "query": "label_values(service)", - "refresh": 2, - "regex": "", - "sort": 1, - "type": "query" - }, - { - "allValue": ".*", - "current": { - "selected": true, - "text": [ - "All" - ], - "value": [ - "$__all" - ] - }, - "definition": "Information,Warning,Error,Fatal", - "includeAll": true, - "label": "Уровень", - "multi": true, - "name": "level", - "options": [ - { - "selected": false, - "text": "Information", - "value": "Information" - }, - { - "selected": false, - "text": "Warning", - "value": "Warning" - }, - { - "selected": false, - "text": "Error", - "value": "Error" - }, - { - "selected": false, - "text": "Fatal", - "value": "Fatal" - } - ], - "query": "Information,Warning,Error,Fatal", - "type": "custom" - } - ] - }, - "time": { - "from": "now-1h", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Deal-Logs", - "uid": "deal-logs", - "version": 1, - "weekStart": "" -} +{ + "annotations": { + "list": [] + }, + "editable": true, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Живая лента логов deal-процессов: фильтры по сервису и уровню сверху. Разверните строку — детали Serilog-события (@mt, свойства, исключение). Query-строка/тела запросов в логи не пишутся (Ruling 13).", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "{service=~\"$service\", level=~\"$level\"}", + "refId": "A" + } + ], + "title": "Логи deal-процессов", + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Объём логов в разрезе уровня Serilog (поле @l, поднято в метку level пайплайном Promtail). Высокий Error/Fatal — детали в дашборде Deal-Errors.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 12 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum by (level) (count_over_time({service=~\"$service\", level=~\"$level\"} [1m]))", + "legendFormat": "{{level}}", + "refId": "A" + } + ], + "title": "Строк логов по уровням, /мин", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Объём логов по сервисам (метка service из лейблов Promtail). Заметный провал до нуля — сервис молчит или лежит (см. также Deal-Health).", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 12 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum by (service) (count_over_time({service=~\"$service\"} [1m]))", + "legendFormat": "{{service}}", + "refId": "A" + } + ], + "title": "Строк логов по сервисам, /мин", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "События с идентификатором тенанта (Serilog-поле TenantId) в логах фоновых сервисов: AI/ML-вызовы («Аудит: tenant …»), Telegram (backfill/realtime). Показывает, какие тенанты активны, по данным логов.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 20 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "topk(10, sum by (TenantId) (count_over_time({service=~\"ai-service|ml-service|telegram-service\"} | json | TenantId =~ \".+\" [5m])))", + "legendFormat": "{{TenantId}}", + "refId": "A" + } + ], + "title": "Активность по тенантам (AI/ML/Telegram, top-10), /5мин", + "type": "timeseries" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 20 + }, + "id": 5, + "options": { + "content": "**Действия пользователей логируются не сюда, а в аудит.** Создание/перемещение/удаление карточек, контейнеры, настройки, каналы, входы/выходы/инвайты пишутся append-only в `public.audit_log` (AuditService) и доступны через `GET /api/operator/audit` / экран «Аудит» оператор-консоли.\n\nВ логах (этат дашборд) по действиям доступны: access-логи HTTP/gRPC (метод/путь/код), аудит-строки фоновых сервисов (`Аудит: tenant …`) и идентификатор тенанта (`TenantId`). Поиск по ключевой фразе добавьте в ленту: `{service=~\"$service\"} |= \"фраза\"`.", + "mode": "markdown" + }, + "title": "Где искать действия пользователей", + "type": "text" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [ + "deal" + ], + "templating": { + "list": [ + { + "allValue": ".*", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "definition": "label_values(service)", + "includeAll": true, + "label": "Сервис", + "multi": true, + "name": "service", + "options": [], + "query": "label_values(service)", + "refresh": 2, + "regex": "", + "sort": 1, + "type": "query" + }, + { + "allValue": ".*", + "current": { + "selected": true, + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "definition": "Information,Warning,Error,Fatal", + "includeAll": true, + "label": "Уровень", + "multi": true, + "name": "level", + "options": [ + { + "selected": false, + "text": "Information", + "value": "Information" + }, + { + "selected": false, + "text": "Warning", + "value": "Warning" + }, + { + "selected": false, + "text": "Error", + "value": "Error" + }, + { + "selected": false, + "text": "Fatal", + "value": "Fatal" + } + ], + "query": "Information,Warning,Error,Fatal", + "type": "custom" + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Deal-Logs", + "uid": "deal-logs", + "version": 1, + "weekStart": "" +} diff --git a/deploy/observability/grafana/dashboards/Deal-Metrics-Overview.json b/deploy/observability/grafana/dashboards/Deal-Metrics-Overview.json index edd1d78..743b449 100644 --- a/deploy/observability/grafana/dashboards/Deal-Metrics-Overview.json +++ b/deploy/observability/grafana/dashboards/Deal-Metrics-Overview.json @@ -1,606 +1,606 @@ -{ - "annotations": { - "list": [] - }, - "editable": true, - "graphTooltip": 1, - "id": null, - "links": [], - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "description": "Суммарная нагрузка всех deal-процессов (HTTP + gRPC) в секунду. Источник — OTel-инструментация ASP.NET Core (метрика http.server.request.duration, счётчик _count), scrape /metrics :9464.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 0, - "y": 0 - }, - "id": 1, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "sum(rate(http_server_request_duration_seconds_count[$__rate_interval]))", - "legendFormat": "RPS", - "refId": "A" - } - ], - "title": "RPS (HTTP+gRPC) сейчас", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "description": "Активные непросроченные сессии пользователей тенантов и операторов (public.sessions + public.operator_sessions). Агрегат по всем тенантам — в метке нет tenantId (низкая кардинальность).", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 6, - "y": 0 - }, - "id": 2, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "deal_sessions_active", - "legendFormat": "сессии", - "refId": "A" - } - ], - "title": "Активные сессии", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "description": "Суммарная глубина очереди пайплайна (new+filtered) по всем тенантам; считает фоновый DealMetricsCollector через PipelineProcessingService.QueueCountsAsync.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 12, - "y": 0 - }, - "id": 3, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "deal_pipeline_queue_depth", - "legendFormat": "очередь", - "refId": "A" - } - ], - "title": "Очередь пайплайна", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "description": "Суммарная глубина очереди обучения ML (MlOutbox) по всем тенантам; считает фоновый DealMetricsCollector через IMlLearningStore.CountOutboxAsync. Рост при недоступности ml-service.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 18, - "y": 0 - }, - "id": 4, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "deal_ml_outbox_depth", - "legendFormat": "outbox", - "refId": "A" - } - ], - "title": "Очередь ML-outbox", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "description": "RPS по сервисам (метка service из таргетов Prometheus). Включает HTTP-запросы core и gRPC-вызовы всех 4 процессов (gRPC идёт как ASP.NET Core-запрос с route = имя метода).", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 10, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 5 - }, - "id": 5, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "sum by (service) (rate(http_server_request_duration_seconds_count[$__rate_interval]))", - "legendFormat": "{{service}}", - "refId": "A" - } - ], - "title": "RPS по сервисам", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "description": "p95 длительности запроса по сервисам (OTel-гистограмма http.server.request.duration). Рост при недоступности БД/внешних вызовов (ИИ-провайдер, ml/telegram-service).", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 10, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 5 - }, - "id": 6, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "histogram_quantile(0.95, sum by (le, service) (rate(http_server_request_duration_seconds_bucket[$__rate_interval])))", - "legendFormat": "{{service}}", - "refId": "A" - } - ], - "title": "Длительность запроса p95 по сервисам", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "description": "Доля HTTP/gRPC-запросов с кодом ответа 5xx в секунду по сервисам (метка http_response_status_code).", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 10, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 13 - }, - "id": 7, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "sum by (service) (rate(http_server_request_duration_seconds_count{http_response_status_code=~\"5..\"}[$__rate_interval]))", - "legendFormat": "{{service}}", - "refId": "A" - } - ], - "title": "Ошибки 5xx по сервисам", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "description": "Токены платного ИИ (по видам prompt/completion) и оценка токенов локальных ML-вызовов, токенов/сек. Инкремент — общая с ядром точка записи token_usage_events (TokenUsageRecorder).", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 10, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 13 - }, - "id": 8, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "sum by (type) (rate(deal_ai_tokens_total[$__rate_interval]))", - "legendFormat": "ai/{{type}}", - "refId": "A" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "sum(rate(deal_ml_tokens_total[$__rate_interval]))", - "legendFormat": "ml/prompt", - "refId": "B" - } - ], - "title": "Токены AI/ML, tokens/s", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "description": "Вызовы платного ИИ и локального ML в секунду (соответствуют событиям token_usage_events: kind=ai|ml).", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 10, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 21 - }, - "id": 9, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "sum(rate(deal_ai_calls_total[$__rate_interval]))", - "legendFormat": "AI-вызовы", - "refId": "A" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "sum(rate(deal_ml_calls_total[$__rate_interval]))", - "legendFormat": "ML-вызовы", - "refId": "B" - } - ], - "title": "Вызовы AI/ML, calls/s", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "description": "Топ-10 типов событий аудита по частоте (метки event/actor низкокардинальные; tenantId/userId в метрики не попадают). Полная лента с актором — public.audit_log через GET /api/operator/audit.", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 10, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 21 - }, - "id": 10, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "topk(10, sum by (event) (rate(deal_audit_events_total[$__rate_interval])))", - "legendFormat": "{{event}}", - "refId": "A" - } - ], - "title": "События аудита по типам (top-10)", - "type": "timeseries" - } - ], - "refresh": "30s", - "schemaVersion": 39, - "tags": [ - "deal", - "metrics" - ], - "templating": { - "list": [] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Deal-Metrics-Overview", - "uid": "deal-metrics", - "version": 1, - "weekStart": "" -} +{ + "annotations": { + "list": [] + }, + "editable": true, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Суммарная нагрузка всех deal-процессов (HTTP + gRPC) в секунду. Источник — OTel-инструментация ASP.NET Core (метрика http.server.request.duration, счётчик _count), scrape /metrics :9464.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(http_server_request_duration_seconds_count[$__rate_interval]))", + "legendFormat": "RPS", + "refId": "A" + } + ], + "title": "RPS (HTTP+gRPC) сейчас", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Активные непросроченные сессии пользователей тенантов и операторов (public.sessions + public.operator_sessions). Агрегат по всем тенантам — в метке нет tenantId (низкая кардинальность).", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "deal_sessions_active", + "legendFormat": "сессии", + "refId": "A" + } + ], + "title": "Активные сессии", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Суммарная глубина очереди пайплайна (new+filtered) по всем тенантам; считает фоновый DealMetricsCollector через PipelineProcessingService.QueueCountsAsync.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "deal_pipeline_queue_depth", + "legendFormat": "очередь", + "refId": "A" + } + ], + "title": "Очередь пайплайна", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Суммарная глубина очереди обучения ML (MlOutbox) по всем тенантам; считает фоновый DealMetricsCollector через IMlLearningStore.CountOutboxAsync. Рост при недоступности ml-service.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "deal_ml_outbox_depth", + "legendFormat": "outbox", + "refId": "A" + } + ], + "title": "Очередь ML-outbox", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "RPS по сервисам (метка service из таргетов Prometheus). Включает HTTP-запросы core и gRPC-вызовы всех 4 процессов (gRPC идёт как ASP.NET Core-запрос с route = имя метода).", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 5 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum by (service) (rate(http_server_request_duration_seconds_count[$__rate_interval]))", + "legendFormat": "{{service}}", + "refId": "A" + } + ], + "title": "RPS по сервисам", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "p95 длительности запроса по сервисам (OTel-гистограмма http.server.request.duration). Рост при недоступности БД/внешних вызовов (ИИ-провайдер, ml/telegram-service).", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 5 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by (le, service) (rate(http_server_request_duration_seconds_bucket[$__rate_interval])))", + "legendFormat": "{{service}}", + "refId": "A" + } + ], + "title": "Длительность запроса p95 по сервисам", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Доля HTTP/gRPC-запросов с кодом ответа 5xx в секунду по сервисам (метка http_response_status_code).", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 13 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum by (service) (rate(http_server_request_duration_seconds_count{http_response_status_code=~\"5..\"}[$__rate_interval]))", + "legendFormat": "{{service}}", + "refId": "A" + } + ], + "title": "Ошибки 5xx по сервисам", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Токены платного ИИ (по видам prompt/completion) и оценка токенов локальных ML-вызовов, токенов/сек. Инкремент — общая с ядром точка записи token_usage_events (TokenUsageRecorder).", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 13 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum by (type) (rate(deal_ai_tokens_total[$__rate_interval]))", + "legendFormat": "ai/{{type}}", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(deal_ml_tokens_total[$__rate_interval]))", + "legendFormat": "ml/prompt", + "refId": "B" + } + ], + "title": "Токены AI/ML, tokens/s", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Вызовы платного ИИ и локального ML в секунду (соответствуют событиям token_usage_events: kind=ai|ml).", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 21 + }, + "id": 9, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(deal_ai_calls_total[$__rate_interval]))", + "legendFormat": "AI-вызовы", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(deal_ml_calls_total[$__rate_interval]))", + "legendFormat": "ML-вызовы", + "refId": "B" + } + ], + "title": "Вызовы AI/ML, calls/s", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Топ-10 типов событий аудита по частоте (метки event/actor низкокардинальные; tenantId/userId в метрики не попадают). Полная лента с актором — public.audit_log через GET /api/operator/audit.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 21 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "topk(10, sum by (event) (rate(deal_audit_events_total[$__rate_interval])))", + "legendFormat": "{{event}}", + "refId": "A" + } + ], + "title": "События аудита по типам (top-10)", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [ + "deal", + "metrics" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Deal-Metrics-Overview", + "uid": "deal-metrics", + "version": 1, + "weekStart": "" +} diff --git a/deploy/observability/grafana/dashboards/Deal-Rps.json b/deploy/observability/grafana/dashboards/Deal-Rps.json index d337836..ec41ecf 100644 --- a/deploy/observability/grafana/dashboards/Deal-Rps.json +++ b/deploy/observability/grafana/dashboards/Deal-Rps.json @@ -1,375 +1,375 @@ -{ - "annotations": { - "list": [] - }, - "editable": true, - "graphTooltip": 0, - "id": null, - "links": [], - "panels": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "HTTP-запросы core в секунду (access-лог HttpAccessLogMiddleware; строка «HTTP <метод> <путь> -> <код>»). gRPC-ингресс здесь не виден — его считают панели gRPC (HTTP-статус gRPC-вызовов всегда 200).", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 10, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 0 - }, - "id": 1, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum(rate({service=\"core\"} |= \"HTTP \" [1m]))", - "legendFormat": "HTTP core", - "refId": "A" - } - ], - "title": "HTTP RPS (core)", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "RPC-вызовы gRPC в секунду по deal-процессам (access-лог RpcCallLoggingInterceptor; строка «gRPC <метод>: <статус>»). gRPC-health не логируется — фоновые liveness-пробы в счёт не попадают.", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 10, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 0 - }, - "id": 2, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum by (service) (rate({service=~\"core|telegram-service|ai-service|ml-service\"} |= \"gRPC \" [1m]))", - "legendFormat": "{{service}}", - "refId": "A" - } - ], - "title": "gRPC RPS по сервисам", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Топ-10 HTTP-путей core по частоте вызовов, запросов/сек. По access-логу core (поле Path, query-строка не логируется).", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 10, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 8 - }, - "id": 3, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "topk(10, sum by (Path) (rate({service=\"core\"} | json | Path =~ \".+\" [5m])))", - "legendFormat": "{{Path}}", - "refId": "A" - } - ], - "title": "HTTP RPS по путям (top-10, core)", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Перцентили длительности запроса core (access-лог core, поле DurationMs — миллисекунды всего пути). p95 резко растёт при недоступности БД/внешних вызовов.", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 10, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "ms" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 8 - }, - "id": 4, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "quantile_over_time(0.5, {service=\"core\"} | json | DurationMs =~ \"[0-9]+\" | unwrap DurationMs [1m])", - "legendFormat": "p50", - "refId": "A" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "quantile_over_time(0.95, {service=\"core\"} | json | DurationMs =~ \"[0-9]+\" | unwrap DurationMs [1m])", - "legendFormat": "p95", - "refId": "B" - } - ], - "title": "Длительность HTTP-запросов core (p50/p95), мс", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Топ-10 gRPC-методов по частоте вызовов (access-лог gRPC, поле RpcMethod — полное имя метода с пакетом).", - "fieldConfig": { - "defaults": { - "custom": { - "drawStyle": "line", - "fillOpacity": 10, - "lineWidth": 1, - "showPoints": "never" - }, - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 24, - "x": 0, - "y": 16 - }, - "id": 5, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "topk(10, sum by (RpcMethod) (rate({service=~\"core|telegram-service|ai-service|ml-service\"} |= \"gRPC \" | json | RpcMethod =~ \".+\" [5m])))", - "legendFormat": "{{RpcMethod}}", - "refId": "A" - } - ], - "title": "gRPC-методы по частоте (top-10)", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "description": "Суммарная нагрузка (HTTP + gRPC) в секунду по всем deal-процессам за последнюю минуту.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { - "h": 5, - "w": 12, - "x": 0, - "y": 24 - }, - "id": 6, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "auto" - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "sum(rate({service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"(HTTP|gRPC) \" [1m]))", - "legendFormat": "RPS", - "refId": "A" - } - ], - "title": "RPS (HTTP+gRPC) сейчас", - "type": "stat" - }, - { - "gridPos": { - "h": 5, - "w": 12, - "x": 12, - "y": 24 - }, - "id": 7, - "options": { - "content": "RPS/нагрузка получены из access-логов (Promtail → Loki) как ленточное дополнение к метрикам. С этапа 12 (пакет A) RPS/латентность/ошибки есть и в OTel-метриках → Prometheus: дашборд Deal-Metrics-Overview (источник — Prometheus). Строка на запрос: HTTP-путь и gRPC-метод пишутся HTTP access-логом и RpcCallLoggingInterceptor без query/заголовков/тел — секретов в этих панелях нет.", - "mode": "markdown" - }, - "title": "Откуда эти цифры", - "type": "text" - } - ], - "refresh": "1m", - "schemaVersion": 39, - "tags": [ - "deal" - ], - "templating": { - "list": [] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": {}, - "timezone": "", - "title": "Deal-Rps", - "uid": "deal-rps", - "version": 1, - "weekStart": "" -} +{ + "annotations": { + "list": [] + }, + "editable": true, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "HTTP-запросы core в секунду (access-лог HttpAccessLogMiddleware; строка «HTTP <метод> <путь> -> <код>»). gRPC-ингресс здесь не виден — его считают панели gRPC (HTTP-статус gRPC-вызовов всегда 200).", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum(rate({service=\"core\"} |= \"HTTP \" [1m]))", + "legendFormat": "HTTP core", + "refId": "A" + } + ], + "title": "HTTP RPS (core)", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "RPC-вызовы gRPC в секунду по deal-процессам (access-лог RpcCallLoggingInterceptor; строка «gRPC <метод>: <статус>»). gRPC-health не логируется — фоновые liveness-пробы в счёт не попадают.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum by (service) (rate({service=~\"core|telegram-service|ai-service|ml-service\"} |= \"gRPC \" [1m]))", + "legendFormat": "{{service}}", + "refId": "A" + } + ], + "title": "gRPC RPS по сервисам", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Топ-10 HTTP-путей core по частоте вызовов, запросов/сек. По access-логу core (поле Path, query-строка не логируется).", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "topk(10, sum by (Path) (rate({service=\"core\"} | json | Path =~ \".+\" [5m])))", + "legendFormat": "{{Path}}", + "refId": "A" + } + ], + "title": "HTTP RPS по путям (top-10, core)", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Перцентили длительности запроса core (access-лог core, поле DurationMs — миллисекунды всего пути). p95 резко растёт при недоступности БД/внешних вызовов.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "quantile_over_time(0.5, {service=\"core\"} | json | DurationMs =~ \"[0-9]+\" | unwrap DurationMs [1m])", + "legendFormat": "p50", + "refId": "A" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "quantile_over_time(0.95, {service=\"core\"} | json | DurationMs =~ \"[0-9]+\" | unwrap DurationMs [1m])", + "legendFormat": "p95", + "refId": "B" + } + ], + "title": "Длительность HTTP-запросов core (p50/p95), мс", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Топ-10 gRPC-методов по частоте вызовов (access-лог gRPC, поле RpcMethod — полное имя метода с пакетом).", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "topk(10, sum by (RpcMethod) (rate({service=~\"core|telegram-service|ai-service|ml-service\"} |= \"gRPC \" | json | RpcMethod =~ \".+\" [5m])))", + "legendFormat": "{{RpcMethod}}", + "refId": "A" + } + ], + "title": "gRPC-методы по частоте (top-10)", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Суммарная нагрузка (HTTP + gRPC) в секунду по всем deal-процессам за последнюю минуту.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "sum(rate({service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"(HTTP|gRPC) \" [1m]))", + "legendFormat": "RPS", + "refId": "A" + } + ], + "title": "RPS (HTTP+gRPC) сейчас", + "type": "stat" + }, + { + "gridPos": { + "h": 5, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 7, + "options": { + "content": "RPS/нагрузка получены из access-логов (Promtail → Loki) как ленточное дополнение к метрикам. С этапа 12 (пакет A) RPS/латентность/ошибки есть и в OTel-метриках → Prometheus: дашборд Deal-Metrics-Overview (источник — Prometheus). Строка на запрос: HTTP-путь и gRPC-метод пишутся HTTP access-логом и RpcCallLoggingInterceptor без query/заголовков/тел — секретов в этих панелях нет.", + "mode": "markdown" + }, + "title": "Откуда эти цифры", + "type": "text" + } + ], + "refresh": "1m", + "schemaVersion": 39, + "tags": [ + "deal" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Deal-Rps", + "uid": "deal-rps", + "version": 1, + "weekStart": "" +} diff --git a/deploy/observability/grafana/provisioning/dashboards/dashboards.yml b/deploy/observability/grafana/provisioning/dashboards/dashboards.yml index 2b83a1e..8abc478 100644 --- a/deploy/observability/grafana/provisioning/dashboards/dashboards.yml +++ b/deploy/observability/grafana/provisioning/dashboards/dashboards.yml @@ -1,16 +1,16 @@ -# Grafana — provisioning папки дашбордов (Ruling 7/9, план Task 14). -# Дашборды из каталога /var/lib/grafana/dashboards (монтируется из deploy/observability/grafana/dashboards) -# появляются в Grafana автоматически; правка — файлами в репозитории, не через UI. - -apiVersion: 1 - -providers: - - name: deal - orgId: 1 - folder: Дейл - type: file - disableDeletion: true - updateIntervalSeconds: 30 - allowUiUpdates: false - options: - path: /var/lib/grafana/dashboards +# Grafana — provisioning папки дашбордов (Ruling 7/9, план Task 14). +# Дашборды из каталога /var/lib/grafana/dashboards (монтируется из deploy/observability/grafana/dashboards) +# появляются в Grafana автоматически; правка — файлами в репозитории, не через UI. + +apiVersion: 1 + +providers: + - name: deal + orgId: 1 + folder: Дейл + type: file + disableDeletion: true + updateIntervalSeconds: 30 + allowUiUpdates: false + options: + path: /var/lib/grafana/dashboards diff --git a/deploy/observability/grafana/provisioning/datasources/datasources.yml b/deploy/observability/grafana/provisioning/datasources/datasources.yml index 0dcc5b7..d657bd1 100644 --- a/deploy/observability/grafana/provisioning/datasources/datasources.yml +++ b/deploy/observability/grafana/provisioning/datasources/datasources.yml @@ -1,28 +1,28 @@ -# Grafana — provisioning датасорсов Loki (логи) и Prometheus (метрики) — Ruling 7/9; метрики — этап 12, пакет A. -# Монтируется в /etc/grafana/provisioning/datasources (compose.prod.yml). - -apiVersion: 1 - -datasources: - - name: Loki - # UID фиксирован: на него ссылаются панели дашбордов Deal-Health/Auth/Errors/Rps/Logs (datasource uid: loki). - uid: loki - type: loki - access: proxy - url: http://loki:3100 - isDefault: true - jsonData: - maxLines: 1000 - - - name: Prometheus - # UID фиксирован: на него ссылаются панели дашборда Deal-Metrics-Overview (datasource uid: prometheus). - # URL — сервис compose-профиля observability; метрики scrape'ит сам Prometheus с /metrics процессов. - uid: prometheus - type: prometheus - access: proxy - url: http://prometheus:9090 - isDefault: false - jsonData: - # Prometheus хранит OTel-гистограммы в нативных bucket'ах — используем нативные histogram_quantile. - httpMethod: POST - timeInterval: 15s +# Grafana — provisioning датасорсов Loki (логи) и Prometheus (метрики) — Ruling 7/9; метрики — этап 12, пакет A. +# Монтируется в /etc/grafana/provisioning/datasources (compose.prod.yml). + +apiVersion: 1 + +datasources: + - name: Loki + # UID фиксирован: на него ссылаются панели дашбордов Deal-Health/Auth/Errors/Rps/Logs (datasource uid: loki). + uid: loki + type: loki + access: proxy + url: http://loki:3100 + isDefault: true + jsonData: + maxLines: 1000 + + - name: Prometheus + # UID фиксирован: на него ссылаются панели дашборда Deal-Metrics-Overview (datasource uid: prometheus). + # URL — сервис compose-профиля observability; метрики scrape'ит сам Prometheus с /metrics процессов. + uid: prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: false + jsonData: + # Prometheus хранит OTel-гистограммы в нативных bucket'ах — используем нативные histogram_quantile. + httpMethod: POST + timeInterval: 15s diff --git a/deploy/observability/loki.yml b/deploy/observability/loki.yml index 0e601fd..204630c 100644 --- a/deploy/observability/loki.yml +++ b/deploy/observability/loki.yml @@ -1,45 +1,45 @@ -# Loki — хранилище логов deal-стека (Ruling 7/9, план Task 14). Single-binary режим, файловое -# хранилище (без object-store): данные — /loki на volume deal_loki_data. -# -# Retention — 7 суток (168h): включается compactor'ом (retention_enabled) + limits_config. -# Это сознательно «логи на неделю»: долгосрочное хранение/выгрузка — rolling-файлы Serilog -# data/logs/deal-*.json (у core — на volume /app/data, см. compose.prod.yml). - -auth_enabled: false - -server: - http_listen_port: 3100 - -common: - instance_addr: 127.0.0.1 - path_prefix: /loki - storage: - filesystem: - chunks_directory: /loki/chunks - rules_directory: /loki/rules - replication_factor: 1 - ring: - kvstore: - store: inmemory - -schema_config: - configs: - - from: 2024-01-01 - store: tsdb - object_store: filesystem - schema: v13 - index: - prefix: index_ - period: 24h - -# Период хранения: 7 суток (168 часов). События старше удаляются compactor'ом (см. ниже). -limits_config: - retention_period: 168h - -compactor: - working_directory: /loki/compactor - compaction_interval: 10m - retention_enabled: true - retention_delete_delay: 2h - # Loki 3.x: при retention_enabled требуется store для delete-запросов (файловое хранилище). - delete_request_store: filesystem +# Loki — хранилище логов deal-стека (Ruling 7/9, план Task 14). Single-binary режим, файловое +# хранилище (без object-store): данные — /loki на volume deal_loki_data. +# +# Retention — 7 суток (168h): включается compactor'ом (retention_enabled) + limits_config. +# Это сознательно «логи на неделю»: долгосрочное хранение/выгрузка — rolling-файлы Serilog +# data/logs/deal-*.json (у core — на volume /app/data, см. compose.prod.yml). + +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + instance_addr: 127.0.0.1 + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +# Период хранения: 7 суток (168 часов). События старше удаляются compactor'ом (см. ниже). +limits_config: + retention_period: 168h + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + retention_delete_delay: 2h + # Loki 3.x: при retention_enabled требуется store для delete-запросов (файловое хранилище). + delete_request_store: filesystem diff --git a/deploy/observability/prometheus-rules.yml b/deploy/observability/prometheus-rules.yml index f8bbbfe..3ad8a02 100644 --- a/deploy/observability/prometheus-rules.yml +++ b/deploy/observability/prometheus-rules.yml @@ -1,81 +1,81 @@ -# Правила алертов Prometheus для процессов Deal (этап 12, пакет A; Ruling 7 этапа 7). -# -# Подключаются в deploy/observability/prometheus.yml через `rule_files`. Источник метрик — эндпоинты -# /metrics процессов (OTel → экспортёр Prometheus), метка target `service` (core/telegram-service/ -# ai-service/ml-service). Имена метрик — в legacy-схеме (global.metric_name_validation_scheme: legacy), -# поэтому точки OTel-имён экранированы в `_` (`deal.sessions.active` → `deal_sessions_active`). -# -# Пороги подобраны консервативно и осознанно «мягкие»: это задел оператору, а не жёсткий SLO — -# уточняйте под реальный профиль нагрузки. Токен-бюджет в Prometheus НЕ алертится намеренно: метрики -# бюджета (`deal_*`) нет (метки низкокардинальные, без tenantId/бюджета). Исчерпание ИИ-бюджета -# доставляется тенанту SSE-тостом (BudgetAlertScheduler), см. техдок §6/§7/§10. - -groups: - - name: deal-availability - rules: - # Сервис не отвечает на scrape (недоступен): конфиг включает 4 процесса Deal + сам Prometheus. - - alert: DealServiceDown - expr: up{job="deal"} == 0 - for: 2m - labels: - severity: critical - annotations: - summary: "Сервис Deal недоступен — {{ $labels.service }}" - description: "Prometheus не может снять /metrics с {{ $labels.instance }} (service={{ $labels.service }}) более 2 минут." - - # Пропажа метрик ядра: scrape ядра или datasource недоступен, хотя Prometheus жив. - - alert: DealCoreMetricsAbsent - expr: absent(deal_sessions_active) - for: 5m - labels: - severity: critical - annotations: - summary: "Нет метрик ядра Deal" - description: "Метрика deal_sessions_active отсутствует более 5 минут — не скрейпится ядро (core:9464) или datasource недоступен." - - - name: deal-errors - rules: - # Рост доли 5xx: gRPC-вызовы тоже попадают в http_server_request_duration_seconds_* (gRPC-ингресс). - - alert: DealHigh5xxRatio - expr: | - sum by (service) (rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m])) - / - clamp_min(sum by (service) (rate(http_server_request_duration_seconds_count[5m])), 0.001) > 0.05 - for: 10m - labels: - severity: warning - annotations: - summary: "Рост 5xx в сервисе {{ $labels.service }}" - description: "Доля 5xx в {{ $labels.service }} выше 5% за 5 минут (текущее значение {{ $value | humanizePercentage }})." - - # Абсолютный всплеск 5xx — ловит рост ошибок и на низком трафике, где доля не показательна. - - alert: Deal5xxBurst - expr: sum by (service) (rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m])) > 1 - for: 5m - labels: - severity: warning - annotations: - summary: "Всплеск 5xx в сервисе {{ $labels.service }}" - description: "Более 1 ответа 5xx/с в {{ $labels.service }} за 5 минут ({{ $value }}/с)." - - - name: deal-queues - rules: - # Лаг очереди пайплайна: воркер ходит каждые 2 с, устойчивая глубина = воркер не успевает/залип. - - alert: DealPipelineQueueBacklog - expr: deal_pipeline_queue_depth > 100 - for: 15m - labels: - severity: warning - annotations: - summary: "Растёт очередь пайплайна Deal" - description: "Суммарная глубина очереди пайплайна (new+filtered) держится выше 100 более 15 минут ({{ $value }})." - - # Лаг ML-outbox: outbox-цикл идёт каждые 10 с; устойчивый рост = ml-service недоступен/отстаёт. - - alert: DealMlOutboxBacklog - expr: deal_ml_outbox_depth > 100 - for: 15m - labels: - severity: warning - annotations: - summary: "Растёт очередь обучения ML (outbox)" - description: "Суммарная глубина MlOutbox держится выше 100 более 15 минут ({{ $value }})." +# Правила алертов Prometheus для процессов Deal (этап 12, пакет A; Ruling 7 этапа 7). +# +# Подключаются в deploy/observability/prometheus.yml через `rule_files`. Источник метрик — эндпоинты +# /metrics процессов (OTel → экспортёр Prometheus), метка target `service` (core/telegram-service/ +# ai-service/ml-service). Имена метрик — в legacy-схеме (global.metric_name_validation_scheme: legacy), +# поэтому точки OTel-имён экранированы в `_` (`deal.sessions.active` → `deal_sessions_active`). +# +# Пороги подобраны консервативно и осознанно «мягкие»: это задел оператору, а не жёсткий SLO — +# уточняйте под реальный профиль нагрузки. Токен-бюджет в Prometheus НЕ алертится намеренно: метрики +# бюджета (`deal_*`) нет (метки низкокардинальные, без tenantId/бюджета). Исчерпание ИИ-бюджета +# доставляется тенанту SSE-тостом (BudgetAlertScheduler), см. техдок §6/§7/§10. + +groups: + - name: deal-availability + rules: + # Сервис не отвечает на scrape (недоступен): конфиг включает 4 процесса Deal + сам Prometheus. + - alert: DealServiceDown + expr: up{job="deal"} == 0 + for: 2m + labels: + severity: critical + annotations: + summary: "Сервис Deal недоступен — {{ $labels.service }}" + description: "Prometheus не может снять /metrics с {{ $labels.instance }} (service={{ $labels.service }}) более 2 минут." + + # Пропажа метрик ядра: scrape ядра или datasource недоступен, хотя Prometheus жив. + - alert: DealCoreMetricsAbsent + expr: absent(deal_sessions_active) + for: 5m + labels: + severity: critical + annotations: + summary: "Нет метрик ядра Deal" + description: "Метрика deal_sessions_active отсутствует более 5 минут — не скрейпится ядро (core:9464) или datasource недоступен." + + - name: deal-errors + rules: + # Рост доли 5xx: gRPC-вызовы тоже попадают в http_server_request_duration_seconds_* (gRPC-ингресс). + - alert: DealHigh5xxRatio + expr: | + sum by (service) (rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m])) + / + clamp_min(sum by (service) (rate(http_server_request_duration_seconds_count[5m])), 0.001) > 0.05 + for: 10m + labels: + severity: warning + annotations: + summary: "Рост 5xx в сервисе {{ $labels.service }}" + description: "Доля 5xx в {{ $labels.service }} выше 5% за 5 минут (текущее значение {{ $value | humanizePercentage }})." + + # Абсолютный всплеск 5xx — ловит рост ошибок и на низком трафике, где доля не показательна. + - alert: Deal5xxBurst + expr: sum by (service) (rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m])) > 1 + for: 5m + labels: + severity: warning + annotations: + summary: "Всплеск 5xx в сервисе {{ $labels.service }}" + description: "Более 1 ответа 5xx/с в {{ $labels.service }} за 5 минут ({{ $value }}/с)." + + - name: deal-queues + rules: + # Лаг очереди пайплайна: воркер ходит каждые 2 с, устойчивая глубина = воркер не успевает/залип. + - alert: DealPipelineQueueBacklog + expr: deal_pipeline_queue_depth > 100 + for: 15m + labels: + severity: warning + annotations: + summary: "Растёт очередь пайплайна Deal" + description: "Суммарная глубина очереди пайплайна (new+filtered) держится выше 100 более 15 минут ({{ $value }})." + + # Лаг ML-outbox: outbox-цикл идёт каждые 10 с; устойчивый рост = ml-service недоступен/отстаёт. + - alert: DealMlOutboxBacklog + expr: deal_ml_outbox_depth > 100 + for: 15m + labels: + severity: warning + annotations: + summary: "Растёт очередь обучения ML (outbox)" + description: "Суммарная глубина MlOutbox держится выше 100 более 15 минут ({{ $value }})." diff --git a/deploy/observability/prometheus.yml b/deploy/observability/prometheus.yml index 1e75322..498ab5a 100644 --- a/deploy/observability/prometheus.yml +++ b/deploy/observability/prometheus.yml @@ -1,48 +1,48 @@ -# Prometheus — сбор метрик Deal-процессов (этап 12, пакет A; Ruling 7 этапа 7). -# -# Источник — эндпоинты /metrics процессов Deal (OpenTelemetry → экспортёр Prometheus). Процессы -# слушают метрики на ОТДЕЛЬНОМ HTTP/1.1 Kestrel-эндпоинте :9464 (gRPC-порты :5101–5103/:5082 — HTTP/2, -# обычный GET scrape по ним невозможен). Порт метрик наружу не публикуется: scrape идёт внутри -# compose-сети от этого контейнера. Метка service (целевая) используется панелями Grafana -# Deal-Metrics-Overview для разбивки RPS/латентности/ошибок по процессам. -# -# Поднимается профилем observability (deploy/compose.prod.yml); конфиг монтируется в -# /etc/prometheus/prometheus.yml. Retention задаётся ключом --storage.tsdb.retention.time (15 суток). - -global: - scrape_interval: 15s - evaluation_interval: 15s - # Имена метрик — в legacy-схеме Prometheus (точки в OTel-именах экранируются в _): без этой опции - # scrape-запрос Prometheus 3 запрашивает UTF-8-схему, и метрики сохраняются с точками - # (`deal.sessions.active`), что ломает привычные PromQL-имена (`deal_sessions_active`). - metric_name_validation_scheme: legacy - -# Правила алертов (этап 12) — монтируются рядом с этим конфигом (/etc/prometheus/prometheus-rules.yml). -# Путь относительный — резолвится от каталога конфига (/etc/prometheus). Правила см. в -# deploy/observability/prometheus-rules.yml. -rule_files: - - prometheus-rules.yml - -scrape_configs: - # Само-мониторинг Prometheus (доступность самого коллектора). - - job_name: prometheus - static_configs: - - targets: ["localhost:9090"] - - # 4 процесса «Дейла»: core (:5080 HTTP + :5082 gRPC-ингресс) и три gRPC-сервиса. - # У каждого — свой /metrics на :9464; target-метка service попадает во все серии. - - job_name: deal - metrics_path: /metrics - static_configs: - - targets: ["core:9464"] - labels: - service: core - - targets: ["telegram-service:9464"] - labels: - service: telegram-service - - targets: ["ai-service:9464"] - labels: - service: ai-service - - targets: ["ml-service:9464"] - labels: - service: ml-service +# Prometheus — сбор метрик Deal-процессов (этап 12, пакет A; Ruling 7 этапа 7). +# +# Источник — эндпоинты /metrics процессов Deal (OpenTelemetry → экспортёр Prometheus). Процессы +# слушают метрики на ОТДЕЛЬНОМ HTTP/1.1 Kestrel-эндпоинте :9464 (gRPC-порты :5101–5103/:5082 — HTTP/2, +# обычный GET scrape по ним невозможен). Порт метрик наружу не публикуется: scrape идёт внутри +# compose-сети от этого контейнера. Метка service (целевая) используется панелями Grafana +# Deal-Metrics-Overview для разбивки RPS/латентности/ошибок по процессам. +# +# Поднимается профилем observability (deploy/compose.prod.yml); конфиг монтируется в +# /etc/prometheus/prometheus.yml. Retention задаётся ключом --storage.tsdb.retention.time (15 суток). + +global: + scrape_interval: 15s + evaluation_interval: 15s + # Имена метрик — в legacy-схеме Prometheus (точки в OTel-именах экранируются в _): без этой опции + # scrape-запрос Prometheus 3 запрашивает UTF-8-схему, и метрики сохраняются с точками + # (`deal.sessions.active`), что ломает привычные PromQL-имена (`deal_sessions_active`). + metric_name_validation_scheme: legacy + +# Правила алертов (этап 12) — монтируются рядом с этим конфигом (/etc/prometheus/prometheus-rules.yml). +# Путь относительный — резолвится от каталога конфига (/etc/prometheus). Правила см. в +# deploy/observability/prometheus-rules.yml. +rule_files: + - prometheus-rules.yml + +scrape_configs: + # Само-мониторинг Prometheus (доступность самого коллектора). + - job_name: prometheus + static_configs: + - targets: ["localhost:9090"] + + # 4 процесса «Дейла»: core (:5080 HTTP + :5082 gRPC-ингресс) и три gRPC-сервиса. + # У каждого — свой /metrics на :9464; target-метка service попадает во все серии. + - job_name: deal + metrics_path: /metrics + static_configs: + - targets: ["core:9464"] + labels: + service: core + - targets: ["telegram-service:9464"] + labels: + service: telegram-service + - targets: ["ai-service:9464"] + labels: + service: ai-service + - targets: ["ml-service:9464"] + labels: + service: ml-service diff --git a/deploy/observability/promtail.yml b/deploy/observability/promtail.yml index 4de9965..216b81d 100644 --- a/deploy/observability/promtail.yml +++ b/deploy/observability/promtail.yml @@ -1,52 +1,52 @@ -# Promtail — сбор логов контейнеров deal-стека для Loki (Ruling 7/9, план Task 14). -# -# Источник — docker-логи ВСЕХ контейнеров хоста через docker_sd (нужен доступ к docker.sock, см. -# compose.prod.yml). Контейнеры deal-процессов пишут в stdout одну JSON-строку на событие -# (Serilog CompactJsonFormatter, Ruling 7) — лог можно парсить в Loki через «| json» без доп. шаблонов. -# relabel добавляет метки: container (имя контейнера) и service (имя compose-сервиса из стандартной -# метки com.docker.compose.service) — по ним строятся дашборды Grafana (Deal-Health, Deal-Auth, -# Deal-Errors, Deal-Rps, Deal-Logs). Pipeline дополнительно поднимает поле Serilog "@l" в метку -# level (Information/Warning/Error/Fatal) — по ней фильтруют панели Deal-Logs. - -server: - http_listen_port: 9080 - grpc_listen_port: 0 - -# Позиции чтения — на volume deal_promtail_data (переживают рестарт контейнера). -positions: - filename: /var/lib/promtail/positions.yaml - -clients: - - url: http://loki:3100/loki/api/v1/push - -scrape_configs: - - job_name: docker - docker_sd_configs: - - host: unix:///var/run/docker.sock - refresh_interval: 5s - relabel_configs: - # Имя контейнера (docker присваивает <проект>-<сервис>- или container_name). - - source_labels: ["__meta_docker_container_name"] - regex: "/(.*)" - target_label: container - # Имя compose-сервиса (core/telegram-service/ai-service/ml-service/...) из метки compose. - - source_labels: ["__meta_docker_container_label_com_docker_compose_service"] - target_label: service - # Поток stdout/stderr — отдельной меткой (у Serilog весь вывод — stdout). - - source_labels: ["__meta_docker_container_log_stream"] - target_label: stream - pipeline_stages: - # Пайплайн применяется ТОЛЬКО к deal-процессам (match по метке service из relabel выше): - # их логи — одна JSON-строка Serilog CompactJsonFormatter на событие. Прочие контейнеры - # хоста (postgres/minio/caddy/loki/...) пишут не-JSON — их пайплайн не трогает. - - match: - selector: '{service=~"core|telegram-service|ai-service|ml-service"}' - stages: - # Поле Serilog "@l" (ключ со спецсимволом @ — литерал JMESPath в одинарных кавычках). - - json: - expressions: - level: '"@l"' - # drop_malformed не задан (false) — строка без валидного JSON не роняется, а проходит - # без метки level (деградация мягкая). - - labels: - level: +# Promtail — сбор логов контейнеров deal-стека для Loki (Ruling 7/9, план Task 14). +# +# Источник — docker-логи ВСЕХ контейнеров хоста через docker_sd (нужен доступ к docker.sock, см. +# compose.prod.yml). Контейнеры deal-процессов пишут в stdout одну JSON-строку на событие +# (Serilog CompactJsonFormatter, Ruling 7) — лог можно парсить в Loki через «| json» без доп. шаблонов. +# relabel добавляет метки: container (имя контейнера) и service (имя compose-сервиса из стандартной +# метки com.docker.compose.service) — по ним строятся дашборды Grafana (Deal-Health, Deal-Auth, +# Deal-Errors, Deal-Rps, Deal-Logs). Pipeline дополнительно поднимает поле Serilog "@l" в метку +# level (Information/Warning/Error/Fatal) — по ней фильтруют панели Deal-Logs. + +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +# Позиции чтения — на volume deal_promtail_data (переживают рестарт контейнера). +positions: + filename: /var/lib/promtail/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + relabel_configs: + # Имя контейнера (docker присваивает <проект>-<сервис>- или container_name). + - source_labels: ["__meta_docker_container_name"] + regex: "/(.*)" + target_label: container + # Имя compose-сервиса (core/telegram-service/ai-service/ml-service/...) из метки compose. + - source_labels: ["__meta_docker_container_label_com_docker_compose_service"] + target_label: service + # Поток stdout/stderr — отдельной меткой (у Serilog весь вывод — stdout). + - source_labels: ["__meta_docker_container_log_stream"] + target_label: stream + pipeline_stages: + # Пайплайн применяется ТОЛЬКО к deal-процессам (match по метке service из relabel выше): + # их логи — одна JSON-строка Serilog CompactJsonFormatter на событие. Прочие контейнеры + # хоста (postgres/minio/caddy/loki/...) пишут не-JSON — их пайплайн не трогает. + - match: + selector: '{service=~"core|telegram-service|ai-service|ml-service"}' + stages: + # Поле Serilog "@l" (ключ со спецсимволом @ — литерал JMESPath в одинарных кавычках). + - json: + expressions: + level: '"@l"' + # drop_malformed не задан (false) — строка без валидного JSON не роняется, а проходит + # без метки level (деградация мягкая). + - labels: + level: diff --git a/scripts/backup.sh b/scripts/backup.sh index 86da13f..2f7c8d3 100644 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -1,196 +1,196 @@ -#!/usr/bin/env bash -# backup.sh — ежедневный бэкап «Дейла» (этап 7, Task 15; Ruling 8). -# -# Покрывает 4 источника данных Ruling 8: -# 1) Postgres (БД deal: схемы public + tenant_*) — pg_dump -Fc (custom, сжатие) → -# $BACKUP_DIR/pg/backup-YYYYMMDD-HHMMSS.dump. По умолчанию дамп снимается внутри контейнера -# (docker exec deal-postgres, локальный socket — пароль не нужен); при заданном DEAL_PG_HOST — -# прямое pg_dump с хоста (DEAL_PG_PORT / DEAL_PG_USER / DEAL_PG_PASSWORD). -# 2) MinIO (бакет deal-files — вложения карточек) — mc mirror бакета → -# $BACKUP_DIR/minio/backup-YYYYMMDD-HHMMSS/ (бэкап = выгрузка ИЗ MinIO в BACKUP_DIR). -# Исполнение mc: клиент с хоста, если есть в PATH; иначе — разовый контейнер minio/mc -# (DEAL_MC_IMAGE) в docker-сети контейнера MinIO. Секреты передаются env-алиасом MC_HOST_deal, -# mc-конфиг на диск не пишется. При Local-хранилище файлов (MinIO не поднят) — DEAL_MINIO_SKIP=1. -# 3) файловые данные на хосте — tar относительных каталогов DEAL_TAR_DIRS внутри DEAL_DATA_DIR -# (по умолчанию: attachments, telegram_sessions, ml) → -# $BACKUP_DIR/data/backup-YYYYMMDD-HHMMSS.tar.gz. Для развёртывания на docker-томах задайте -# DEAL_TAR_VOLUMES (список имён volume'ов) — тар выполнит busybox-контейнер (Ruling 8). -# 4) retention — удаление снапшотов старше RETENTION_DAYS дней (дата YYYYMMDD из имени файла/каталога, -# по умолчанию 14; при ежедневном запуске хранится ~15 копий — эквивалент find -mtime +N). -# -# Планировщик — ВНЕ контейнера (Ruling 8): скрипт сам ничего не ставит. Запуск — от пользователя -# с доступом к docker. Примеры: -# cron (ежедневно в 02:00; «0 2 * * *»): -# 0 2 * * * /opt/deal/scripts/backup.sh >> /opt/deal/data/backups/cron.log 2>&1 -# systemd (аналог): -# [Unit] Description=Deal daily backup -# [Timer] OnCalendar=*-*-* 02:00:00 -# Persistent=true -# [Install] WantedBy=timers.target -# [Service] (unit backup.service) Type=oneshot; ExecStart=/opt/deal/scripts/backup.sh -# prod (VPS): секреты из deploy/.env.prod (MINIO_ROOT_USER/MINIO_ROOT_PASSWORD/DEAL_PG_PASSWORD -# читаются скриптом); BACKUP_DIR вынести из data/. compose.prod НЕ публикует порты MinIO — -# хостовый mc не достанет MinIO: mc исполняется docker-контейнером (дефолт), endpoint по умолчанию -# http://minio:9000 (алиас compose-сервиса; deal-minio в prod-сети нет). Нестандартная схема — -# DEAL_MINIO_ENDPOINT: -# 0 2 * * * cd /opt/deal && BACKUP_DIR=/var/backups/deal \ -# bash scripts/backup.sh >> /var/backups/deal/cron.log 2>&1 -# dev: хостовый mc + опубликованный порт 9000 → http://localhost:9000 (дефолт host-режима); -# docker-режим в dev — тоже http://minio:9000 (алиас сервиса compose.dev). -# -# Код возврата: 0 — все шаги ok (MinIO — осознанно пропущен при DEAL_MINIO_SKIP=1); 1 — сбой любого шага. -# Лог — консоль и $BACKUP_DIR/logs/backup-YYYYMM.log. Секреты в лог не попадают. -# Требования: bash, GNU date (coreutils), docker (шаги БД/томов/MinIO-docker), mc (MinIO с хоста). -# Полный список env — scripts/deal-backup-lib.sh и техдок §13.9. Запускать НЕ параллельно. -set -euo pipefail - -# Общие env-дефолты и хелперы (BACKUP_DIR, RETENTION_DAYS, resolve_pg_container, select_mc_mode, mc_cmd…) -# shellcheck source=deal-backup-lib.sh -. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/deal-backup-lib.sh" - -# --- Валидация и подготовка ---------------------------------------------------------- -case "$RETENTION_DAYS" in - '' | *[!0-9]*) - die "RETENTION_DAYS должно быть целым числом дней (сейчас: '$RETENTION_DAYS')" - ;; -esac - -TS=$(date +%Y%m%d-%H%M%S) -mkdir -p "$BACKUP_DIR" "$BACKUP_DIR/logs" "$BACKUP_DIR/pg" "$BACKUP_DIR/minio" "$BACKUP_DIR/data" - -# trap-очистка: незавершённые артефакты ТОЛЬКО текущего прогона (имя с суффиксом .part) -cleanup() { - rm -rf "$BACKUP_DIR/pg/.backup-$TS.dump.part" \ - "$BACKUP_DIR/minio/.backup-$TS.part" \ - "$BACKUP_DIR/data/backup-$TS"*.part 2>/dev/null || true -} -trap 'exit 130' INT TERM -trap cleanup EXIT - -# --- Шаги ---------------------------------------------------------------------------- -backup_pg() { # Postgres: pg_dump -Fc (public + tenant_*) - local part final - part="$BACKUP_DIR/pg/.backup-$TS.dump.part" - final="$BACKUP_DIR/pg/backup-$TS.dump" - if [ -n "$DEAL_PG_HOST" ]; then - have pg_dump || die "задан DEAL_PG_HOST='$DEAL_PG_HOST', но pg_dump не найден в PATH" - log "Postgres: прямое pg_dump $DEAL_PG_HOST:$DEAL_PG_PORT/$DEAL_PG_DB (user: $DEAL_PG_USER)" - PGPASSWORD="$DEAL_PG_PASSWORD" pg_dump -h "$DEAL_PG_HOST" -p "$DEAL_PG_PORT" \ - -U "$DEAL_PG_USER" -d "$DEAL_PG_DB" -Fc -f "$part" || die "pg_dump завершился с ошибкой" - else - docker_ok || die "Docker недоступен и DEAL_PG_HOST не задан: укажите DEAL_PG_HOST/DEAL_PG_PORT/DEAL_PG_USER (и DEAL_PG_PASSWORD) для прямого pg_dump" - resolve_pg_container \ - || die "контейнер Postgres не найден (искал deal-postgres и compose-сервис 'postgres'); задайте DEAL_PG_CONTAINER или DEAL_PG_HOST" - log "Postgres: docker exec $PG_CONTAINER pg_dump -Fc (БД $DEAL_PG_DB)" - docker exec "$PG_CONTAINER" pg_dump -U "$DEAL_PG_USER" -d "$DEAL_PG_DB" -Fc >"$part" \ - || die "pg_dump (docker exec) завершился с ошибкой" - fi - [ -s "$part" ] || die "pg_dump не дал данных (файл пуст) — бэкап БД прерван" - mv -f "$part" "$final" || die "не удалось переместить $part" - log "Postgres: OK $final ($(du -h "$final" | cut -f1))" -} - -backup_minio() { # MinIO: mc mirror бакета deal-files → BACKUP_DIR/minio/backup-/ - local part final - select_mc_mode - [ "$MC_MODE" = "skip" ] && return 0 - part="$BACKUP_DIR/minio/.backup-$TS.part" - final="$BACKUP_DIR/minio/backup-$TS" - rm -rf "$part" - # Пустой бакет: mc mirror целевую директорию не создаёт (копировать нечего) — - # готовим $part заранее, чтобы снапшот-каталог существовал и mv ниже не упал. - mkdir -p "$part" - log "MinIO: mc mirror $DEAL_MINIO_BUCKET → $final (режим mc: $MC_MODE)" - mc_cmd mirror --overwrite "deal/$DEAL_MINIO_BUCKET" "$part" \ - || die "mc mirror завершился с ошибкой" - mv -f "$part" "$final" || die "не удалось переместить $part" - log "MinIO: OK $final ($(du -sh "$final" | cut -f1))" -} - -backup_data() { # файловые данные: docker-volume'ы (DEAL_TAR_VOLUMES) или host-каталоги DEAL_TAR_DIRS - local vol part final d list missing - if [ -n "$DEAL_TAR_VOLUMES" ]; then - docker_ok || die "задан DEAL_TAR_VOLUMES, но Docker недоступен" - for vol in $DEAL_TAR_VOLUMES; do - docker volume inspect "$vol" >/dev/null 2>&1 \ - || die "docker volume '$vol' не существует (docker volume ls)" - part="$BACKUP_DIR/data/backup-$TS.$vol.tar.gz.part" - final="$BACKUP_DIR/data/backup-$TS.$vol.tar.gz" - log "Data: tar docker volume $vol (busybox) → $final" - docker run --rm \ - -v "$vol:/data:ro" -v "$(host_docker_path "$BACKUP_DIR/data"):/out" \ - busybox tar -czf "/out/backup-$TS.$vol.tar.gz.part" -C /data . \ - || die "tar docker volume '$vol' завершился с ошибкой" - mv -f "$part" "$final" || die "не удалось переместить $part" - log "Data: OK $final ($(du -h "$final" | cut -f1))" - done - return 0 - fi - - [ -d "$DEAL_DATA_DIR" ] || die "DEAL_DATA_DIR='$DEAL_DATA_DIR' не существует" - list="" - missing="" - for d in $DEAL_TAR_DIRS; do - if [ -d "$DEAL_DATA_DIR/$d" ]; then - list="$list $d" - else - missing="$missing $d" - fi - done - if [ -n "$missing" ]; then - log "Data: WARNING — каталоги не найдены и пропущены:$missing (создайте их или уберите из DEAL_TAR_DIRS)" - fi - if [ -z "$list" ]; then - log "Data: тарировать нечего (все DEAL_TAR_DIRS отсутствуют) — архив не создаю" - return 0 - fi - part="$BACKUP_DIR/data/backup-$TS.tar.gz.part" - final="$BACKUP_DIR/data/backup-$TS.tar.gz" - # $list без кавычек — намеренно: список относительных путей через пробел - tar -czf "$part" -C "$DEAL_DATA_DIR" $list || die "tar каталогов данных завершился с ошибкой" - mv -f "$part" "$final" || die "не удалось переместить $part" - log "Data: OK $final ($(du -h "$final" | cut -f1)), содержимое:$list" -} - -run_retention() { # удаление снапшотов старше RETENTION_DAYS (дата YYYYMMDD из имени) - local cutoff kept deleted kind f base ts ymd - cutoff=$(date -d "$RETENTION_DAYS days ago" +%Y%m%d 2>/dev/null) || { - log "Retention: WARNING — GNU date ('date -d') недоступен, retention пропущен (удалите старые снапшоты вручную)" - return 0 - } - kept=0 - deleted=0 - log "Retention: удаляю снапшоты старше $RETENTION_DAYS дней (дата имени < $cutoff)" - for kind in pg minio data; do - [ -d "$BACKUP_DIR/$kind" ] || continue - for f in "$BACKUP_DIR/$kind"/backup-*; do - [ -e "$f" ] || continue - base=${f##*/} - ts=${base#backup-} - ymd=$(printf '%s' "$ts" | cut -c1-8) - if [ "$ymd" \< "$cutoff" ]; then - rm -rf "$f" - log "Retention: удалён $kind/$base" - deleted=$((deleted + 1)) - else - kept=$((kept + 1)) - fi - done - done - log "Retention: удалено $deleted, оставлено $kept снапшотов" -} - -# --- Основной поток ------------------------------------------------------------------- -main() { - log "=== Бэкап «Дейла»: старт (BACKUP_DIR=$BACKUP_DIR, retention=$RETENTION_DAYS дн.) ===" - backup_pg || die "шаг Postgres завершился с ошибкой" - backup_minio || die "шаг MinIO завершился с ошибкой" - backup_data || die "шаг Data завершился с ошибкой" - run_retention - log "=== Бэкап завершён успешно: $BACKUP_DIR ===" -} - -LOG_FILE="$BACKUP_DIR/logs/backup-$(date +%Y%m).log" -main "$@" 2>&1 | tee -a "$LOG_FILE" -status=$? -exit "$status" +#!/usr/bin/env bash +# backup.sh — ежедневный бэкап «Дейла» (этап 7, Task 15; Ruling 8). +# +# Покрывает 4 источника данных Ruling 8: +# 1) Postgres (БД deal: схемы public + tenant_*) — pg_dump -Fc (custom, сжатие) → +# $BACKUP_DIR/pg/backup-YYYYMMDD-HHMMSS.dump. По умолчанию дамп снимается внутри контейнера +# (docker exec deal-postgres, локальный socket — пароль не нужен); при заданном DEAL_PG_HOST — +# прямое pg_dump с хоста (DEAL_PG_PORT / DEAL_PG_USER / DEAL_PG_PASSWORD). +# 2) MinIO (бакет deal-files — вложения карточек) — mc mirror бакета → +# $BACKUP_DIR/minio/backup-YYYYMMDD-HHMMSS/ (бэкап = выгрузка ИЗ MinIO в BACKUP_DIR). +# Исполнение mc: клиент с хоста, если есть в PATH; иначе — разовый контейнер minio/mc +# (DEAL_MC_IMAGE) в docker-сети контейнера MinIO. Секреты передаются env-алиасом MC_HOST_deal, +# mc-конфиг на диск не пишется. При Local-хранилище файлов (MinIO не поднят) — DEAL_MINIO_SKIP=1. +# 3) файловые данные на хосте — tar относительных каталогов DEAL_TAR_DIRS внутри DEAL_DATA_DIR +# (по умолчанию: attachments, telegram_sessions, ml) → +# $BACKUP_DIR/data/backup-YYYYMMDD-HHMMSS.tar.gz. Для развёртывания на docker-томах задайте +# DEAL_TAR_VOLUMES (список имён volume'ов) — тар выполнит busybox-контейнер (Ruling 8). +# 4) retention — удаление снапшотов старше RETENTION_DAYS дней (дата YYYYMMDD из имени файла/каталога, +# по умолчанию 14; при ежедневном запуске хранится ~15 копий — эквивалент find -mtime +N). +# +# Планировщик — ВНЕ контейнера (Ruling 8): скрипт сам ничего не ставит. Запуск — от пользователя +# с доступом к docker. Примеры: +# cron (ежедневно в 02:00; «0 2 * * *»): +# 0 2 * * * /opt/deal/scripts/backup.sh >> /opt/deal/data/backups/cron.log 2>&1 +# systemd (аналог): +# [Unit] Description=Deal daily backup +# [Timer] OnCalendar=*-*-* 02:00:00 +# Persistent=true +# [Install] WantedBy=timers.target +# [Service] (unit backup.service) Type=oneshot; ExecStart=/opt/deal/scripts/backup.sh +# prod (VPS): секреты из deploy/.env.prod (MINIO_ROOT_USER/MINIO_ROOT_PASSWORD/DEAL_PG_PASSWORD +# читаются скриптом); BACKUP_DIR вынести из data/. compose.prod НЕ публикует порты MinIO — +# хостовый mc не достанет MinIO: mc исполняется docker-контейнером (дефолт), endpoint по умолчанию +# http://minio:9000 (алиас compose-сервиса; deal-minio в prod-сети нет). Нестандартная схема — +# DEAL_MINIO_ENDPOINT: +# 0 2 * * * cd /opt/deal && BACKUP_DIR=/var/backups/deal \ +# bash scripts/backup.sh >> /var/backups/deal/cron.log 2>&1 +# dev: хостовый mc + опубликованный порт 9000 → http://localhost:9000 (дефолт host-режима); +# docker-режим в dev — тоже http://minio:9000 (алиас сервиса compose.dev). +# +# Код возврата: 0 — все шаги ok (MinIO — осознанно пропущен при DEAL_MINIO_SKIP=1); 1 — сбой любого шага. +# Лог — консоль и $BACKUP_DIR/logs/backup-YYYYMM.log. Секреты в лог не попадают. +# Требования: bash, GNU date (coreutils), docker (шаги БД/томов/MinIO-docker), mc (MinIO с хоста). +# Полный список env — scripts/deal-backup-lib.sh и техдок §13.9. Запускать НЕ параллельно. +set -euo pipefail + +# Общие env-дефолты и хелперы (BACKUP_DIR, RETENTION_DAYS, resolve_pg_container, select_mc_mode, mc_cmd…) +# shellcheck source=deal-backup-lib.sh +. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/deal-backup-lib.sh" + +# --- Валидация и подготовка ---------------------------------------------------------- +case "$RETENTION_DAYS" in + '' | *[!0-9]*) + die "RETENTION_DAYS должно быть целым числом дней (сейчас: '$RETENTION_DAYS')" + ;; +esac + +TS=$(date +%Y%m%d-%H%M%S) +mkdir -p "$BACKUP_DIR" "$BACKUP_DIR/logs" "$BACKUP_DIR/pg" "$BACKUP_DIR/minio" "$BACKUP_DIR/data" + +# trap-очистка: незавершённые артефакты ТОЛЬКО текущего прогона (имя с суффиксом .part) +cleanup() { + rm -rf "$BACKUP_DIR/pg/.backup-$TS.dump.part" \ + "$BACKUP_DIR/minio/.backup-$TS.part" \ + "$BACKUP_DIR/data/backup-$TS"*.part 2>/dev/null || true +} +trap 'exit 130' INT TERM +trap cleanup EXIT + +# --- Шаги ---------------------------------------------------------------------------- +backup_pg() { # Postgres: pg_dump -Fc (public + tenant_*) + local part final + part="$BACKUP_DIR/pg/.backup-$TS.dump.part" + final="$BACKUP_DIR/pg/backup-$TS.dump" + if [ -n "$DEAL_PG_HOST" ]; then + have pg_dump || die "задан DEAL_PG_HOST='$DEAL_PG_HOST', но pg_dump не найден в PATH" + log "Postgres: прямое pg_dump $DEAL_PG_HOST:$DEAL_PG_PORT/$DEAL_PG_DB (user: $DEAL_PG_USER)" + PGPASSWORD="$DEAL_PG_PASSWORD" pg_dump -h "$DEAL_PG_HOST" -p "$DEAL_PG_PORT" \ + -U "$DEAL_PG_USER" -d "$DEAL_PG_DB" -Fc -f "$part" || die "pg_dump завершился с ошибкой" + else + docker_ok || die "Docker недоступен и DEAL_PG_HOST не задан: укажите DEAL_PG_HOST/DEAL_PG_PORT/DEAL_PG_USER (и DEAL_PG_PASSWORD) для прямого pg_dump" + resolve_pg_container \ + || die "контейнер Postgres не найден (искал deal-postgres и compose-сервис 'postgres'); задайте DEAL_PG_CONTAINER или DEAL_PG_HOST" + log "Postgres: docker exec $PG_CONTAINER pg_dump -Fc (БД $DEAL_PG_DB)" + docker exec "$PG_CONTAINER" pg_dump -U "$DEAL_PG_USER" -d "$DEAL_PG_DB" -Fc >"$part" \ + || die "pg_dump (docker exec) завершился с ошибкой" + fi + [ -s "$part" ] || die "pg_dump не дал данных (файл пуст) — бэкап БД прерван" + mv -f "$part" "$final" || die "не удалось переместить $part" + log "Postgres: OK $final ($(du -h "$final" | cut -f1))" +} + +backup_minio() { # MinIO: mc mirror бакета deal-files → BACKUP_DIR/minio/backup-/ + local part final + select_mc_mode + [ "$MC_MODE" = "skip" ] && return 0 + part="$BACKUP_DIR/minio/.backup-$TS.part" + final="$BACKUP_DIR/minio/backup-$TS" + rm -rf "$part" + # Пустой бакет: mc mirror целевую директорию не создаёт (копировать нечего) — + # готовим $part заранее, чтобы снапшот-каталог существовал и mv ниже не упал. + mkdir -p "$part" + log "MinIO: mc mirror $DEAL_MINIO_BUCKET → $final (режим mc: $MC_MODE)" + mc_cmd mirror --overwrite "deal/$DEAL_MINIO_BUCKET" "$part" \ + || die "mc mirror завершился с ошибкой" + mv -f "$part" "$final" || die "не удалось переместить $part" + log "MinIO: OK $final ($(du -sh "$final" | cut -f1))" +} + +backup_data() { # файловые данные: docker-volume'ы (DEAL_TAR_VOLUMES) или host-каталоги DEAL_TAR_DIRS + local vol part final d list missing + if [ -n "$DEAL_TAR_VOLUMES" ]; then + docker_ok || die "задан DEAL_TAR_VOLUMES, но Docker недоступен" + for vol in $DEAL_TAR_VOLUMES; do + docker volume inspect "$vol" >/dev/null 2>&1 \ + || die "docker volume '$vol' не существует (docker volume ls)" + part="$BACKUP_DIR/data/backup-$TS.$vol.tar.gz.part" + final="$BACKUP_DIR/data/backup-$TS.$vol.tar.gz" + log "Data: tar docker volume $vol (busybox) → $final" + docker run --rm \ + -v "$vol:/data:ro" -v "$(host_docker_path "$BACKUP_DIR/data"):/out" \ + busybox tar -czf "/out/backup-$TS.$vol.tar.gz.part" -C /data . \ + || die "tar docker volume '$vol' завершился с ошибкой" + mv -f "$part" "$final" || die "не удалось переместить $part" + log "Data: OK $final ($(du -h "$final" | cut -f1))" + done + return 0 + fi + + [ -d "$DEAL_DATA_DIR" ] || die "DEAL_DATA_DIR='$DEAL_DATA_DIR' не существует" + list="" + missing="" + for d in $DEAL_TAR_DIRS; do + if [ -d "$DEAL_DATA_DIR/$d" ]; then + list="$list $d" + else + missing="$missing $d" + fi + done + if [ -n "$missing" ]; then + log "Data: WARNING — каталоги не найдены и пропущены:$missing (создайте их или уберите из DEAL_TAR_DIRS)" + fi + if [ -z "$list" ]; then + log "Data: тарировать нечего (все DEAL_TAR_DIRS отсутствуют) — архив не создаю" + return 0 + fi + part="$BACKUP_DIR/data/backup-$TS.tar.gz.part" + final="$BACKUP_DIR/data/backup-$TS.tar.gz" + # $list без кавычек — намеренно: список относительных путей через пробел + tar -czf "$part" -C "$DEAL_DATA_DIR" $list || die "tar каталогов данных завершился с ошибкой" + mv -f "$part" "$final" || die "не удалось переместить $part" + log "Data: OK $final ($(du -h "$final" | cut -f1)), содержимое:$list" +} + +run_retention() { # удаление снапшотов старше RETENTION_DAYS (дата YYYYMMDD из имени) + local cutoff kept deleted kind f base ts ymd + cutoff=$(date -d "$RETENTION_DAYS days ago" +%Y%m%d 2>/dev/null) || { + log "Retention: WARNING — GNU date ('date -d') недоступен, retention пропущен (удалите старые снапшоты вручную)" + return 0 + } + kept=0 + deleted=0 + log "Retention: удаляю снапшоты старше $RETENTION_DAYS дней (дата имени < $cutoff)" + for kind in pg minio data; do + [ -d "$BACKUP_DIR/$kind" ] || continue + for f in "$BACKUP_DIR/$kind"/backup-*; do + [ -e "$f" ] || continue + base=${f##*/} + ts=${base#backup-} + ymd=$(printf '%s' "$ts" | cut -c1-8) + if [ "$ymd" \< "$cutoff" ]; then + rm -rf "$f" + log "Retention: удалён $kind/$base" + deleted=$((deleted + 1)) + else + kept=$((kept + 1)) + fi + done + done + log "Retention: удалено $deleted, оставлено $kept снапшотов" +} + +# --- Основной поток ------------------------------------------------------------------- +main() { + log "=== Бэкап «Дейла»: старт (BACKUP_DIR=$BACKUP_DIR, retention=$RETENTION_DAYS дн.) ===" + backup_pg || die "шаг Postgres завершился с ошибкой" + backup_minio || die "шаг MinIO завершился с ошибкой" + backup_data || die "шаг Data завершился с ошибкой" + run_retention + log "=== Бэкап завершён успешно: $BACKUP_DIR ===" +} + +LOG_FILE="$BACKUP_DIR/logs/backup-$(date +%Y%m).log" +main "$@" 2>&1 | tee -a "$LOG_FILE" +status=$? +exit "$status" diff --git a/scripts/build.sh b/scripts/build.sh index 31e3dda..2d3297f 100644 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,15 +1,15 @@ -#!/usr/bin/env sh -# build.sh — сборка всех решений Дейла (core + сервисы). CI-шаг «build». -set -e - -REPO_DIR=$(cd "$(dirname "$0")/.." && pwd) - -for SLN in \ - src/core/Deal.sln \ - src/telegram-service/Deal.Telegram.sln \ - src/ai-service/Deal.Ai.sln \ - src/ml-service/Deal.Ml.sln \ - src/storage-service/Deal.Storage.sln; do - echo "== build: $SLN ==" - (cd "$REPO_DIR" && dotnet build "$SLN" -v q --nologo) -done +#!/usr/bin/env sh +# build.sh — сборка всех решений Дейла (core + сервисы). CI-шаг «build». +set -e + +REPO_DIR=$(cd "$(dirname "$0")/.." && pwd) + +for SLN in \ + src/core/Deal.sln \ + src/telegram-service/Deal.Telegram.sln \ + src/ai-service/Deal.Ai.sln \ + src/ml-service/Deal.Ml.sln \ + src/storage-service/Deal.Storage.sln; do + echo "== build: $SLN ==" + (cd "$REPO_DIR" && dotnet build "$SLN" -v q --nologo) +done diff --git a/scripts/ci.sh b/scripts/ci.sh index fe46042..a876f87 100644 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -1,45 +1,45 @@ -#!/usr/bin/env sh -# ci.sh — полный прогон Дейла (BL-CI): сборка всех решений, тесты, скан уязвимых -# NuGet-зависимостей, сборка и линтер фронта. Для локального запуска и CI-пайплайна. -# sh scripts/ci.sh -# Секреты и внешние сервисы не требуются: тесты — юнит (InMemory/fakes), БД/сеть не нужны. -set -e - -REPO_DIR=$(cd "$(dirname "$0")/.." && pwd) - -echo "== CI: сборка ==" -sh "$REPO_DIR/scripts/build.sh" - -echo -echo "== CI: тесты ==" -sh "$REPO_DIR/scripts/test.sh" - -echo -echo "== CI: скан уязвимых NuGet-зависимостей ==" -for SLN in \ - src/core/Deal.sln \ - src/telegram-service/Deal.Telegram.sln \ - src/ai-service/Deal.Ai.sln \ - src/ml-service/Deal.Ml.sln \ - src/storage-service/Deal.Storage.sln; do - echo "-- $SLN" - OUTPUT=$(cd "$REPO_DIR" && dotnet list "$SLN" package --vulnerable --include-transitive 2>&1 || true) - if printf '%s' "$OUTPUT" | grep -qi "has the following vulnerable"; then - printf '%s\n' "$OUTPUT" - echo "ОШИБКА: уязвимые зависимости в $SLN" - exit 1 - fi -done - -echo -echo "== CI: сборка фронта ==" -if command -v npm >/dev/null 2>&1; then - (cd "$REPO_DIR/src/frontend" && { - if [ -f package-lock.json ]; then npm ci; else npm install; fi - } && npm run build) -else - echo " пропуск: npm не установлен (сборка фронта не запущена)" -fi - -echo -echo "== CI ОК ==" +#!/usr/bin/env sh +# ci.sh — полный прогон Дейла (BL-CI): сборка всех решений, тесты, скан уязвимых +# NuGet-зависимостей, сборка и линтер фронта. Для локального запуска и CI-пайплайна. +# sh scripts/ci.sh +# Секреты и внешние сервисы не требуются: тесты — юнит (InMemory/fakes), БД/сеть не нужны. +set -e + +REPO_DIR=$(cd "$(dirname "$0")/.." && pwd) + +echo "== CI: сборка ==" +sh "$REPO_DIR/scripts/build.sh" + +echo +echo "== CI: тесты ==" +sh "$REPO_DIR/scripts/test.sh" + +echo +echo "== CI: скан уязвимых NuGet-зависимостей ==" +for SLN in \ + src/core/Deal.sln \ + src/telegram-service/Deal.Telegram.sln \ + src/ai-service/Deal.Ai.sln \ + src/ml-service/Deal.Ml.sln \ + src/storage-service/Deal.Storage.sln; do + echo "-- $SLN" + OUTPUT=$(cd "$REPO_DIR" && dotnet list "$SLN" package --vulnerable --include-transitive 2>&1 || true) + if printf '%s' "$OUTPUT" | grep -qi "has the following vulnerable"; then + printf '%s\n' "$OUTPUT" + echo "ОШИБКА: уязвимые зависимости в $SLN" + exit 1 + fi +done + +echo +echo "== CI: сборка фронта ==" +if command -v npm >/dev/null 2>&1; then + (cd "$REPO_DIR/src/frontend" && { + if [ -f package-lock.json ]; then npm ci; else npm install; fi + } && npm run build) +else + echo " пропуск: npm не установлен (сборка фронта не запущена)" +fi + +echo +echo "== CI ОК ==" diff --git a/scripts/deal-backup-lib.sh b/scripts/deal-backup-lib.sh index ca35118..590874c 100644 --- a/scripts/deal-backup-lib.sh +++ b/scripts/deal-backup-lib.sh @@ -1,184 +1,184 @@ -#!/usr/bin/env bash -# -# deal-backup-lib.sh — общие для scripts/backup.sh и scripts/restore.sh env-дефолты и хелперы -# (этап 7 Task 15, Ruling 8). Скрипт НЕ исполняется напрямую: подключается из backup.sh/restore.sh -# . "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/deal-backup-lib.sh" -# -# Параметры — окружение (секреты скрипты НЕ логируют; в лог — только имена файлов и размеры): -# -# BACKUP_DIR / DEAL_BACKUP_DIR корень бэкапов (по умолчанию <репозиторий>/data/backups) -# RETENTION_DAYS / DEAL_RETENTION_DAYS -# сколько дней хранить снапшоты (удаление по дате YYYYMMDD в имени; 14) -# DEAL_DATA_DIR корень host-каталогов данных (по умолчанию <репозиторий>/data) -# DEAL_TAR_DIRS относительные к DEAL_DATA_DIR каталоги для тара (по умолчанию -# "attachments ml telegram_sessions"; контейнерный путь каталога -# сессий telegram-service — /data/sessions) -# DEAL_TAR_VOLUMES если задан (список docker volume'ов через пробел) — файловые данные -# берутся из томов busybox-контейнером (Ruling 8), DEAL_TAR_DIRS -# игнорируется -# -# Postgres (docker-режим по умолчанию — exec в контейнер; прямой pg_dump — при заданном DEAL_PG_HOST): -# DEAL_PG_HOST / DEAL_PG_PORT (5432) / DEAL_PG_USER (deal) / DEAL_PG_PASSWORD / DEAL_PG_DB (deal) -# DEAL_PG_CONTAINER имя контейнера (авто: deal-postgres → compose-сервис 'postgres') -# -# MinIO / mc (бэкап = выгрузка ИЗ MinIO в BACKUP_DIR; restore — обратно): -# DEAL_MINIO_ENDPOINT пусто → host-mc: http://localhost:9000 (dev: опубликованный порт); -# docker-mc: http://minio:9000 (алиас compose-сервиса — работает и в -# compose.dev, и в compose.prod). prod (compose.prod) порты MinIO НЕ -# публикует — хостовый mc его не достанет: нужен docker-режим (дефолт, -# endpoint minio:9000) либо достижимый DEAL_MINIO_ENDPOINT. -# DEAL_MINIO_ACCESS_KEY (fallback: MINIO_ROOT_USER; иначе deal_minio) -# DEAL_MINIO_SECRET_KEY (fallback: MINIO_ROOT_PASSWORD; иначе deal_minio_secret) -# DEAL_MINIO_BUCKET (deal-files) -# DEAL_MINIO_SKIP 1 → шаг MinIO пропускается (Local-хранилище без MinIO) -# DEAL_MINIO_MIRROR_REMOVE restore: 1 → mc mirror --remove (бакет = снимок 1:1) -# DEAL_MC_IMAGE docker-режим mc (minio/mc; в проде зафиксируйте тег, напр. -# minio/mc:RELEASE.2025-04-16T15-23-31Z) -# DEAL_MC_CONTAINER контейнер MinIO для сети (авто: deal-minio → compose-сервис 'minio') -set -u - -# --- Корень репозитория и каталоги ----------------------------------------------- -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -REPO_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) - -BACKUP_DIR=${BACKUP_DIR:-${DEAL_BACKUP_DIR:-"$REPO_DIR/data/backups"}} -RETENTION_DAYS=${RETENTION_DAYS:-${DEAL_RETENTION_DAYS:-14}} -DEAL_DATA_DIR=${DEAL_DATA_DIR:-"$REPO_DIR/data"} -DEAL_TAR_DIRS=${DEAL_TAR_DIRS:-"attachments ml telegram_sessions"} -DEAL_TAR_VOLUMES=${DEAL_TAR_VOLUMES:-""} - -# --- Postgres ---------------------------------------------------------------------- -DEAL_PG_DB=${DEAL_PG_DB:-deal} -DEAL_PG_USER=${DEAL_PG_USER:-deal} -DEAL_PG_HOST=${DEAL_PG_HOST:-""} -DEAL_PG_PORT=${DEAL_PG_PORT:-5432} -DEAL_PG_PASSWORD=${DEAL_PG_PASSWORD:-""} -DEAL_PG_CONTAINER=${DEAL_PG_CONTAINER:-""} - -# --- MinIO / mc -------------------------------------------------------------------- -DEAL_MINIO_BUCKET=${DEAL_MINIO_BUCKET:-deal-files} -DEAL_MINIO_ENDPOINT=${DEAL_MINIO_ENDPOINT:-""} -DEAL_MINIO_ACCESS_KEY=${DEAL_MINIO_ACCESS_KEY:-${MINIO_ROOT_USER:-deal_minio}} -DEAL_MINIO_SECRET_KEY=${DEAL_MINIO_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-deal_minio_secret}} -DEAL_MINIO_SKIP=${DEAL_MINIO_SKIP:-0} -DEAL_MINIO_MIRROR_REMOVE=${DEAL_MINIO_MIRROR_REMOVE:-0} -DEAL_MC_IMAGE=${DEAL_MC_IMAGE:-minio/mc} -DEAL_MC_CONTAINER=${DEAL_MC_CONTAINER:-""} - -# Найденные рантайм-значения (заполняют функции ниже) -PG_CONTAINER="" -MINIO_CONTAINER="" -MC_MODE="" # skip | host (клиент mc) | docker (разовый контейнер minio/mc) -MC_ENDPOINT="$DEAL_MINIO_ENDPOINT" - -# --- Утилиты ----------------------------------------------------------------------- -say() { printf '%s\n' "$*"; } -log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*"; } -die() { log "ОШИБКА: $*"; exit 1; } -have() { command -v "$1" >/dev/null 2>&1; } -docker_ok() { docker info >/dev/null 2>&1; } - -# --- Windows (Git Bash/MSYS): docker не понимает /c/... пути и ломает контейнерные -# /out, /in (MSYS конвертирует их в C:/Program Files/Git/...). Отключаем конвертацию -# аргументов и переводим host-пути в Windows-вид (C:/...) для -v/-cp. На Linux хелпер -# возвращает путь как есть — поведение не меняется. -if uname -s 2>/dev/null | grep -qiE 'mingw|msys|cygwin'; then - export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' - host_docker_path() { cygpath -m "$1"; } -else - host_docker_path() { printf '%s' "$1"; } -fi - -container_running() { # имя контейнера → 0, если запущен - [ -n "${1:-}" ] && [ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null)" = "true" ] -} - -compose_container() { # метка com.docker.compose.service → имя running-контейнера (или пусто) - docker ps --filter "label=com.docker.compose.service=$1" --format '{{.Names}}' 2>/dev/null | head -n 1 || true -} - -# --- Поиск контейнеров Postgres / MinIO -------------------------------------------- -resolve_pg_container() { - local c - if [ -n "$DEAL_PG_CONTAINER" ]; then - container_running "$DEAL_PG_CONTAINER" \ - || die "контейнер DEAL_PG_CONTAINER='$DEAL_PG_CONTAINER' не найден/не запущен" - PG_CONTAINER="$DEAL_PG_CONTAINER" - return 0 - fi - if container_running deal-postgres; then PG_CONTAINER=deal-postgres; return 0; fi - c=$(compose_container postgres) - if [ -n "$c" ] && container_running "$c"; then PG_CONTAINER="$c"; return 0; fi - return 1 -} - -resolve_minio_container() { - local c - if [ -n "$DEAL_MC_CONTAINER" ]; then - container_running "$DEAL_MC_CONTAINER" \ - || die "контейнер DEAL_MC_CONTAINER='$DEAL_MC_CONTAINER' не найден/не запущен" - MINIO_CONTAINER="$DEAL_MC_CONTAINER" - return 0 - fi - if container_running deal-minio; then MINIO_CONTAINER=deal-minio; return 0; fi - c=$(compose_container minio) - if [ -n "$c" ] && container_running "$c"; then MINIO_CONTAINER="$c"; return 0; fi - return 1 -} - -minio_network() { # первая docker-сеть контейнера MinIO (для разового контейнера mc) - docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' \ - "$MINIO_CONTAINER" 2>/dev/null | awk '{ print $1 }' -} - -# --- mc: выбор режима и запуск ------------------------------------------------------- -select_mc_mode() { # заполняет MC_MODE / MC_ENDPOINT - if [ "$DEAL_MINIO_SKIP" = "1" ] || [ "$DEAL_MINIO_SKIP" = "true" ]; then - MC_MODE=skip - elif have mc; then - MC_MODE=host - [ -n "$MC_ENDPOINT" ] || MC_ENDPOINT=http://localhost:9000 - elif docker_ok; then - MC_MODE=docker - # minio — алиас compose-сервиса: резолвится и в compose.dev (container_name deal-minio), и в - # compose.prod (container_name нет, сервис 'minio'); deal-minio:9000 в prod-сети не существует. - [ -n "$MC_ENDPOINT" ] || MC_ENDPOINT=http://minio:9000 - else - die "MinIO: клиент mc не найден и docker недоступен — установите mc или задайте DEAL_MINIO_SKIP=1" - fi -} - -mc_cmd() { # выполняет «mc <аргументы…>» в выбранном режиме; alias 'deal' — только env MC_HOST_deal - # (секрет в конфиг mc на диск НЕ пишется; значение env нигде не логируется) - # MC_HOST_deal — единый URL http(s)://user:pass@host:port: схему берём из MC_ENDPOINT (если задана), - # иначе http. Убираем схему из host:port — иначе URL получает двойную схему и mc его отвергает. - local host_value endpoint scheme - endpoint="$MC_ENDPOINT" - scheme=http - case "$endpoint" in - https://*) scheme=https; endpoint=${endpoint#https://} ;; - http://*) endpoint=${endpoint#http://} ;; - esac - host_value="$scheme://$DEAL_MINIO_ACCESS_KEY:$DEAL_MINIO_SECRET_KEY@$endpoint" - case "$MC_MODE" in - skip) - log "MinIO: шаг пропущен (DEAL_MINIO_SKIP=1)" - return 0 - ;; - host) - MC_HOST_deal="$host_value" mc "$@" - ;; - docker) - resolve_minio_container \ - || die "MinIO: контейнер не найден (искал deal-minio и compose-сервис 'minio') — поднимите стек, задайте DEAL_MINIO_ENDPOINT или DEAL_MINIO_SKIP=1" - log "MinIO: разовый контейнер $DEAL_MC_IMAGE (сеть: $(minio_network), бакет $DEAL_MINIO_BUCKET)" - docker run --rm -i --network "$(minio_network)" \ - -e "MC_HOST_deal=$host_value" \ - -v "$(host_docker_path "$BACKUP_DIR"):$BACKUP_DIR" \ - "$DEAL_MC_IMAGE" "$@" - ;; - *) - die "MinIO: режим mc не выбран (MC_MODE='$MC_MODE') — вызовите select_mc_mode" - ;; - esac -} +#!/usr/bin/env bash +# +# deal-backup-lib.sh — общие для scripts/backup.sh и scripts/restore.sh env-дефолты и хелперы +# (этап 7 Task 15, Ruling 8). Скрипт НЕ исполняется напрямую: подключается из backup.sh/restore.sh +# . "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/deal-backup-lib.sh" +# +# Параметры — окружение (секреты скрипты НЕ логируют; в лог — только имена файлов и размеры): +# +# BACKUP_DIR / DEAL_BACKUP_DIR корень бэкапов (по умолчанию <репозиторий>/data/backups) +# RETENTION_DAYS / DEAL_RETENTION_DAYS +# сколько дней хранить снапшоты (удаление по дате YYYYMMDD в имени; 14) +# DEAL_DATA_DIR корень host-каталогов данных (по умолчанию <репозиторий>/data) +# DEAL_TAR_DIRS относительные к DEAL_DATA_DIR каталоги для тара (по умолчанию +# "attachments ml telegram_sessions"; контейнерный путь каталога +# сессий telegram-service — /data/sessions) +# DEAL_TAR_VOLUMES если задан (список docker volume'ов через пробел) — файловые данные +# берутся из томов busybox-контейнером (Ruling 8), DEAL_TAR_DIRS +# игнорируется +# +# Postgres (docker-режим по умолчанию — exec в контейнер; прямой pg_dump — при заданном DEAL_PG_HOST): +# DEAL_PG_HOST / DEAL_PG_PORT (5432) / DEAL_PG_USER (deal) / DEAL_PG_PASSWORD / DEAL_PG_DB (deal) +# DEAL_PG_CONTAINER имя контейнера (авто: deal-postgres → compose-сервис 'postgres') +# +# MinIO / mc (бэкап = выгрузка ИЗ MinIO в BACKUP_DIR; restore — обратно): +# DEAL_MINIO_ENDPOINT пусто → host-mc: http://localhost:9000 (dev: опубликованный порт); +# docker-mc: http://minio:9000 (алиас compose-сервиса — работает и в +# compose.dev, и в compose.prod). prod (compose.prod) порты MinIO НЕ +# публикует — хостовый mc его не достанет: нужен docker-режим (дефолт, +# endpoint minio:9000) либо достижимый DEAL_MINIO_ENDPOINT. +# DEAL_MINIO_ACCESS_KEY (fallback: MINIO_ROOT_USER; иначе deal_minio) +# DEAL_MINIO_SECRET_KEY (fallback: MINIO_ROOT_PASSWORD; иначе deal_minio_secret) +# DEAL_MINIO_BUCKET (deal-files) +# DEAL_MINIO_SKIP 1 → шаг MinIO пропускается (Local-хранилище без MinIO) +# DEAL_MINIO_MIRROR_REMOVE restore: 1 → mc mirror --remove (бакет = снимок 1:1) +# DEAL_MC_IMAGE docker-режим mc (minio/mc; в проде зафиксируйте тег, напр. +# minio/mc:RELEASE.2025-04-16T15-23-31Z) +# DEAL_MC_CONTAINER контейнер MinIO для сети (авто: deal-minio → compose-сервис 'minio') +set -u + +# --- Корень репозитория и каталоги ----------------------------------------------- +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +REPO_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) + +BACKUP_DIR=${BACKUP_DIR:-${DEAL_BACKUP_DIR:-"$REPO_DIR/data/backups"}} +RETENTION_DAYS=${RETENTION_DAYS:-${DEAL_RETENTION_DAYS:-14}} +DEAL_DATA_DIR=${DEAL_DATA_DIR:-"$REPO_DIR/data"} +DEAL_TAR_DIRS=${DEAL_TAR_DIRS:-"attachments ml telegram_sessions"} +DEAL_TAR_VOLUMES=${DEAL_TAR_VOLUMES:-""} + +# --- Postgres ---------------------------------------------------------------------- +DEAL_PG_DB=${DEAL_PG_DB:-deal} +DEAL_PG_USER=${DEAL_PG_USER:-deal} +DEAL_PG_HOST=${DEAL_PG_HOST:-""} +DEAL_PG_PORT=${DEAL_PG_PORT:-5432} +DEAL_PG_PASSWORD=${DEAL_PG_PASSWORD:-""} +DEAL_PG_CONTAINER=${DEAL_PG_CONTAINER:-""} + +# --- MinIO / mc -------------------------------------------------------------------- +DEAL_MINIO_BUCKET=${DEAL_MINIO_BUCKET:-deal-files} +DEAL_MINIO_ENDPOINT=${DEAL_MINIO_ENDPOINT:-""} +DEAL_MINIO_ACCESS_KEY=${DEAL_MINIO_ACCESS_KEY:-${MINIO_ROOT_USER:-deal_minio}} +DEAL_MINIO_SECRET_KEY=${DEAL_MINIO_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-deal_minio_secret}} +DEAL_MINIO_SKIP=${DEAL_MINIO_SKIP:-0} +DEAL_MINIO_MIRROR_REMOVE=${DEAL_MINIO_MIRROR_REMOVE:-0} +DEAL_MC_IMAGE=${DEAL_MC_IMAGE:-minio/mc} +DEAL_MC_CONTAINER=${DEAL_MC_CONTAINER:-""} + +# Найденные рантайм-значения (заполняют функции ниже) +PG_CONTAINER="" +MINIO_CONTAINER="" +MC_MODE="" # skip | host (клиент mc) | docker (разовый контейнер minio/mc) +MC_ENDPOINT="$DEAL_MINIO_ENDPOINT" + +# --- Утилиты ----------------------------------------------------------------------- +say() { printf '%s\n' "$*"; } +log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*"; } +die() { log "ОШИБКА: $*"; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } +docker_ok() { docker info >/dev/null 2>&1; } + +# --- Windows (Git Bash/MSYS): docker не понимает /c/... пути и ломает контейнерные +# /out, /in (MSYS конвертирует их в C:/Program Files/Git/...). Отключаем конвертацию +# аргументов и переводим host-пути в Windows-вид (C:/...) для -v/-cp. На Linux хелпер +# возвращает путь как есть — поведение не меняется. +if uname -s 2>/dev/null | grep -qiE 'mingw|msys|cygwin'; then + export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' + host_docker_path() { cygpath -m "$1"; } +else + host_docker_path() { printf '%s' "$1"; } +fi + +container_running() { # имя контейнера → 0, если запущен + [ -n "${1:-}" ] && [ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null)" = "true" ] +} + +compose_container() { # метка com.docker.compose.service → имя running-контейнера (или пусто) + docker ps --filter "label=com.docker.compose.service=$1" --format '{{.Names}}' 2>/dev/null | head -n 1 || true +} + +# --- Поиск контейнеров Postgres / MinIO -------------------------------------------- +resolve_pg_container() { + local c + if [ -n "$DEAL_PG_CONTAINER" ]; then + container_running "$DEAL_PG_CONTAINER" \ + || die "контейнер DEAL_PG_CONTAINER='$DEAL_PG_CONTAINER' не найден/не запущен" + PG_CONTAINER="$DEAL_PG_CONTAINER" + return 0 + fi + if container_running deal-postgres; then PG_CONTAINER=deal-postgres; return 0; fi + c=$(compose_container postgres) + if [ -n "$c" ] && container_running "$c"; then PG_CONTAINER="$c"; return 0; fi + return 1 +} + +resolve_minio_container() { + local c + if [ -n "$DEAL_MC_CONTAINER" ]; then + container_running "$DEAL_MC_CONTAINER" \ + || die "контейнер DEAL_MC_CONTAINER='$DEAL_MC_CONTAINER' не найден/не запущен" + MINIO_CONTAINER="$DEAL_MC_CONTAINER" + return 0 + fi + if container_running deal-minio; then MINIO_CONTAINER=deal-minio; return 0; fi + c=$(compose_container minio) + if [ -n "$c" ] && container_running "$c"; then MINIO_CONTAINER="$c"; return 0; fi + return 1 +} + +minio_network() { # первая docker-сеть контейнера MinIO (для разового контейнера mc) + docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' \ + "$MINIO_CONTAINER" 2>/dev/null | awk '{ print $1 }' +} + +# --- mc: выбор режима и запуск ------------------------------------------------------- +select_mc_mode() { # заполняет MC_MODE / MC_ENDPOINT + if [ "$DEAL_MINIO_SKIP" = "1" ] || [ "$DEAL_MINIO_SKIP" = "true" ]; then + MC_MODE=skip + elif have mc; then + MC_MODE=host + [ -n "$MC_ENDPOINT" ] || MC_ENDPOINT=http://localhost:9000 + elif docker_ok; then + MC_MODE=docker + # minio — алиас compose-сервиса: резолвится и в compose.dev (container_name deal-minio), и в + # compose.prod (container_name нет, сервис 'minio'); deal-minio:9000 в prod-сети не существует. + [ -n "$MC_ENDPOINT" ] || MC_ENDPOINT=http://minio:9000 + else + die "MinIO: клиент mc не найден и docker недоступен — установите mc или задайте DEAL_MINIO_SKIP=1" + fi +} + +mc_cmd() { # выполняет «mc <аргументы…>» в выбранном режиме; alias 'deal' — только env MC_HOST_deal + # (секрет в конфиг mc на диск НЕ пишется; значение env нигде не логируется) + # MC_HOST_deal — единый URL http(s)://user:pass@host:port: схему берём из MC_ENDPOINT (если задана), + # иначе http. Убираем схему из host:port — иначе URL получает двойную схему и mc его отвергает. + local host_value endpoint scheme + endpoint="$MC_ENDPOINT" + scheme=http + case "$endpoint" in + https://*) scheme=https; endpoint=${endpoint#https://} ;; + http://*) endpoint=${endpoint#http://} ;; + esac + host_value="$scheme://$DEAL_MINIO_ACCESS_KEY:$DEAL_MINIO_SECRET_KEY@$endpoint" + case "$MC_MODE" in + skip) + log "MinIO: шаг пропущен (DEAL_MINIO_SKIP=1)" + return 0 + ;; + host) + MC_HOST_deal="$host_value" mc "$@" + ;; + docker) + resolve_minio_container \ + || die "MinIO: контейнер не найден (искал deal-minio и compose-сервис 'minio') — поднимите стек, задайте DEAL_MINIO_ENDPOINT или DEAL_MINIO_SKIP=1" + log "MinIO: разовый контейнер $DEAL_MC_IMAGE (сеть: $(minio_network), бакет $DEAL_MINIO_BUCKET)" + docker run --rm -i --network "$(minio_network)" \ + -e "MC_HOST_deal=$host_value" \ + -v "$(host_docker_path "$BACKUP_DIR"):$BACKUP_DIR" \ + "$DEAL_MC_IMAGE" "$@" + ;; + *) + die "MinIO: режим mc не выбран (MC_MODE='$MC_MODE') — вызовите select_mc_mode" + ;; + esac +} diff --git a/scripts/dedup_summary_inheritdoc.py b/scripts/dedup_summary_inheritdoc.py index 2cc5769..9cedda8 100644 --- a/scripts/dedup_summary_inheritdoc.py +++ b/scripts/dedup_summary_inheritdoc.py @@ -1,190 +1,190 @@ -#!/usr/bin/env python3 -"""Заменить дублирующий реализации на (код-стайл Дейла). - -Консервативно: - * собираются тексты членов интерфейсов (блоки, состоящие только из ); - * в типах с интерфейсной базой такой же по тексту блок-док заменяется на `/// `; - * блоки с ///... и summary самих типов не трогаются. - -Запуск: python scripts/dedup_summary_inheritdoc.py [--apply] -Без --apply — dry-run со счётчиками. -""" -from __future__ import annotations - -import re -import sys -from pathlib import Path - -REPO = Path(__file__).resolve().parent.parent -ROOTS = [ - REPO / "src" / "core", - REPO / "src" / "telegram-service", - REPO / "src" / "ai-service", - REPO / "src" / "ml-service", - REPO / "src" / "storage-service", - REPO / "src" / "grpc-hosting", -] - -INTERFACE_RE = re.compile( - r"^\s*(?:public|internal|protected|private)?\s*" - r"(?:static\s+|sealed\s+|abstract\s+|partial\s+|unsafe\s+)*interface\s+\w+" -) -TYPE_BASE_RE = re.compile( - r"^\s*(?:public|internal|protected|private)?\s*" - r"(?:static\s+|sealed\s+|abstract\s+|partial\s+|unsafe\s+)*" - r"(?:class|struct|record)\s+\w+[^{;]*:\s*(.+?)(?:\{|$)" -) -IFACE_NAME_RE = re.compile(r"\bI[A-Z]\w*") -EXTRA_TAG_RE = re.compile(r"<(?:param|typeparam|returns|remarks|exception|example|value)\b", re.IGNORECASE) -SUMMARY_OPEN_RE = re.compile(r"", re.IGNORECASE) -SUMMARY_CLOSE_RE = re.compile(r"", re.IGNORECASE) - - -def iter_files() -> list[Path]: - files: list[Path] = [] - for root in ROOTS: - if not root.exists(): - continue - for path in root.rglob("*.cs"): - parts = set(path.parts) - if "obj" in parts or "bin" in parts: - continue - if "tests" in path.as_posix().lower(): - continue - files.append(path) - return sorted(files) - - -def normalize(block: list[str]) -> str: - text = " ".join(line.strip().lstrip("/").strip() for line in block) - text = SUMMARY_CLOSE_RE.sub("", SUMMARY_OPEN_RE.sub("", text)) - return re.sub(r"\s+", " ", text).strip() - - -def is_summary_only(block: list[str]) -> bool: - joined = "\n".join(block) - return ( - bool(SUMMARY_OPEN_RE.search(joined)) - and bool(SUMMARY_CLOSE_RE.search(joined)) - and not EXTRA_TAG_RE.search(joined) - ) - - -def depth_snapshot(lines: list[str]) -> list[int]: - """Глубина фигурных скобок перед каждой строкой (приблизительно, без строк/комментариев).""" - depths: list[int] = [] - depth = 0 - for line in lines: - depths.append(depth) - depth += line.count("{") - line.count("}") - return depths - - -def type_scopes(lines: list[str]): - """Список (start_line, end_line, kind) для интерфейсов и типов с интерфейсной базой. - - kind: "interface" | "type". Границы — по фигурным скобкам объявления. - """ - depths = depth_snapshot(lines) - scopes: list[tuple[int, int, str]] = [] - stack: list[tuple[int, str, int]] = [] # (depth, kind, start_line) - for idx, line in enumerate(lines): - depth_before = depths[idx] - if INTERFACE_RE.match(line): - stack.append((depth_before, "interface", idx)) - else: - m = TYPE_BASE_RE.match(line) - if m and IFACE_NAME_RE.search(m.group(1)): - stack.append((depth_before, "type", idx)) - # закрыть блоки на строке закрывающей скобки (глубина после неё возвращается к уровню объявления) - while ( - stack - and depths[idx] <= stack[-1][0] - and lines[idx].strip().startswith("}") - and idx > stack[-1][2] - ): - depth, kind, start = stack.pop() - scopes.append((start, idx, kind)) - for depth, kind, start in stack: - scopes.append((start, len(lines) - 1, kind)) - return scopes - - -def containing_kinds(scope_kinds: list[tuple[int, int, str]], line_idx: int, depth: int) -> set[str]: - kinds: set[str] = set() - for start, end, kind in scope_kinds: - if start <= line_idx <= end and start != line_idx: - kinds.add(kind) - return kinds - - -def collect_interface_summaries(files: list[Path]) -> set[str]: - summaries: set[str] = set() - for path in files: - lines = path.read_text(encoding="utf-8").splitlines() - depths = depth_snapshot(lines) - scopes = type_scopes(lines) - idx = 0 - while idx < len(lines): - if not lines[idx].lstrip().startswith("///"): - idx += 1 - continue - start = idx - while idx < len(lines) and lines[idx].lstrip().startswith("///"): - idx += 1 - block = lines[start:idx] - kinds = containing_kinds(scopes, start, depths[start]) - if "interface" in kinds and is_summary_only(block): - summaries.add(normalize(block)) - return summaries - - -def process_file(path: Path, iface_summaries: set[str]) -> tuple[int, list[str]]: - lines = path.read_text(encoding="utf-8").splitlines() - depths = depth_snapshot(lines) - scopes = type_scopes(lines) - result: list[str] = [] - replaced = 0 - idx = 0 - while idx < len(lines): - if not lines[idx].lstrip().startswith("///"): - result.append(lines[idx]) - idx += 1 - continue - start = idx - while idx < len(lines) and lines[idx].lstrip().startswith("///"): - idx += 1 - block = lines[start:idx] - kinds = containing_kinds(scopes, start, depths[start]) - if "type" in kinds and is_summary_only(block) and normalize(block) in iface_summaries: - indent = block[0][: len(block[0]) - len(block[0].lstrip())] - result.append(f"{indent}/// ") - replaced += 1 - else: - result.extend(block) - return replaced, result - - -def main() -> int: - apply = "--apply" in sys.argv - files = iter_files() - iface_summaries = collect_interface_summaries(files) - total = 0 - changed = 0 - for path in files: - replaced, new_lines = process_file(path, iface_summaries) - if not replaced: - continue - total += replaced - changed += 1 - if apply: - original = path.read_text(encoding="utf-8") - text = "\n".join(new_lines) + ("\n" if original.endswith("\n") else "") - path.write_text(text, encoding="utf-8") - mode = "применено" if apply else "dry-run" - print(f"{mode}: замен {total} в {changed} файлах (интерфейсных summary: {len(iface_summaries)})") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +#!/usr/bin/env python3 +"""Заменить дублирующий реализации на (код-стайл Дейла). + +Консервативно: + * собираются тексты членов интерфейсов (блоки, состоящие только из ); + * в типах с интерфейсной базой такой же по тексту блок-док заменяется на `/// `; + * блоки с ///... и summary самих типов не трогаются. + +Запуск: python scripts/dedup_summary_inheritdoc.py [--apply] +Без --apply — dry-run со счётчиками. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +ROOTS = [ + REPO / "src" / "core", + REPO / "src" / "telegram-service", + REPO / "src" / "ai-service", + REPO / "src" / "ml-service", + REPO / "src" / "storage-service", + REPO / "src" / "grpc-hosting", +] + +INTERFACE_RE = re.compile( + r"^\s*(?:public|internal|protected|private)?\s*" + r"(?:static\s+|sealed\s+|abstract\s+|partial\s+|unsafe\s+)*interface\s+\w+" +) +TYPE_BASE_RE = re.compile( + r"^\s*(?:public|internal|protected|private)?\s*" + r"(?:static\s+|sealed\s+|abstract\s+|partial\s+|unsafe\s+)*" + r"(?:class|struct|record)\s+\w+[^{;]*:\s*(.+?)(?:\{|$)" +) +IFACE_NAME_RE = re.compile(r"\bI[A-Z]\w*") +EXTRA_TAG_RE = re.compile(r"<(?:param|typeparam|returns|remarks|exception|example|value)\b", re.IGNORECASE) +SUMMARY_OPEN_RE = re.compile(r"", re.IGNORECASE) +SUMMARY_CLOSE_RE = re.compile(r"", re.IGNORECASE) + + +def iter_files() -> list[Path]: + files: list[Path] = [] + for root in ROOTS: + if not root.exists(): + continue + for path in root.rglob("*.cs"): + parts = set(path.parts) + if "obj" in parts or "bin" in parts: + continue + if "tests" in path.as_posix().lower(): + continue + files.append(path) + return sorted(files) + + +def normalize(block: list[str]) -> str: + text = " ".join(line.strip().lstrip("/").strip() for line in block) + text = SUMMARY_CLOSE_RE.sub("", SUMMARY_OPEN_RE.sub("", text)) + return re.sub(r"\s+", " ", text).strip() + + +def is_summary_only(block: list[str]) -> bool: + joined = "\n".join(block) + return ( + bool(SUMMARY_OPEN_RE.search(joined)) + and bool(SUMMARY_CLOSE_RE.search(joined)) + and not EXTRA_TAG_RE.search(joined) + ) + + +def depth_snapshot(lines: list[str]) -> list[int]: + """Глубина фигурных скобок перед каждой строкой (приблизительно, без строк/комментариев).""" + depths: list[int] = [] + depth = 0 + for line in lines: + depths.append(depth) + depth += line.count("{") - line.count("}") + return depths + + +def type_scopes(lines: list[str]): + """Список (start_line, end_line, kind) для интерфейсов и типов с интерфейсной базой. + + kind: "interface" | "type". Границы — по фигурным скобкам объявления. + """ + depths = depth_snapshot(lines) + scopes: list[tuple[int, int, str]] = [] + stack: list[tuple[int, str, int]] = [] # (depth, kind, start_line) + for idx, line in enumerate(lines): + depth_before = depths[idx] + if INTERFACE_RE.match(line): + stack.append((depth_before, "interface", idx)) + else: + m = TYPE_BASE_RE.match(line) + if m and IFACE_NAME_RE.search(m.group(1)): + stack.append((depth_before, "type", idx)) + # закрыть блоки на строке закрывающей скобки (глубина после неё возвращается к уровню объявления) + while ( + stack + and depths[idx] <= stack[-1][0] + and lines[idx].strip().startswith("}") + and idx > stack[-1][2] + ): + depth, kind, start = stack.pop() + scopes.append((start, idx, kind)) + for depth, kind, start in stack: + scopes.append((start, len(lines) - 1, kind)) + return scopes + + +def containing_kinds(scope_kinds: list[tuple[int, int, str]], line_idx: int, depth: int) -> set[str]: + kinds: set[str] = set() + for start, end, kind in scope_kinds: + if start <= line_idx <= end and start != line_idx: + kinds.add(kind) + return kinds + + +def collect_interface_summaries(files: list[Path]) -> set[str]: + summaries: set[str] = set() + for path in files: + lines = path.read_text(encoding="utf-8").splitlines() + depths = depth_snapshot(lines) + scopes = type_scopes(lines) + idx = 0 + while idx < len(lines): + if not lines[idx].lstrip().startswith("///"): + idx += 1 + continue + start = idx + while idx < len(lines) and lines[idx].lstrip().startswith("///"): + idx += 1 + block = lines[start:idx] + kinds = containing_kinds(scopes, start, depths[start]) + if "interface" in kinds and is_summary_only(block): + summaries.add(normalize(block)) + return summaries + + +def process_file(path: Path, iface_summaries: set[str]) -> tuple[int, list[str]]: + lines = path.read_text(encoding="utf-8").splitlines() + depths = depth_snapshot(lines) + scopes = type_scopes(lines) + result: list[str] = [] + replaced = 0 + idx = 0 + while idx < len(lines): + if not lines[idx].lstrip().startswith("///"): + result.append(lines[idx]) + idx += 1 + continue + start = idx + while idx < len(lines) and lines[idx].lstrip().startswith("///"): + idx += 1 + block = lines[start:idx] + kinds = containing_kinds(scopes, start, depths[start]) + if "type" in kinds and is_summary_only(block) and normalize(block) in iface_summaries: + indent = block[0][: len(block[0]) - len(block[0].lstrip())] + result.append(f"{indent}/// ") + replaced += 1 + else: + result.extend(block) + return replaced, result + + +def main() -> int: + apply = "--apply" in sys.argv + files = iter_files() + iface_summaries = collect_interface_summaries(files) + total = 0 + changed = 0 + for path in files: + replaced, new_lines = process_file(path, iface_summaries) + if not replaced: + continue + total += replaced + changed += 1 + if apply: + original = path.read_text(encoding="utf-8") + text = "\n".join(new_lines) + ("\n" if original.endswith("\n") else "") + path.write_text(text, encoding="utf-8") + mode = "применено" if apply else "dry-run" + print(f"{mode}: замен {total} в {changed} файлах (интерфейсных summary: {len(iface_summaries)})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/dev-smoke.sh b/scripts/dev-smoke.sh index 8cf2b06..4bf0d92 100644 --- a/scripts/dev-smoke.sh +++ b/scripts/dev-smoke.sh @@ -1,216 +1,216 @@ -#!/usr/bin/env sh -# dev-smoke.sh — сквозной smoke полного dev-стека Дейла (план Task 20, Ruling 12). -# -# Поднимает ВЕСЬ стек deploy/compose.dev.yml (postgres+minio+ml/ai/telegram+core) в сквозном -# gRPC-режиме (Services__*__UseLocal=false заданы в compose) и проверяет: -# 1) health ВСЕХ контейнеров (docker healthchecks: postgres/core/ml/ai/telegram, minio — running); -# 2) auth: login admin/admin (дефолт TenantBootstrapService; при переопределённом пароле — правка); -# 3) /api/tg/status — живой статус через GrpcTelegramClient (idle-форма: telegram-service без сессии); -# 4) POST /api/cards → локальная карточка (планово, c_…); -# 5) POST /api/cards/{id}/trash — обучающий сигнал spam 1.0 → строка MlOutbox; -# 6) флашер MlOutboxFlushScheduler (10 с) → TrainBatch в ml-service: /api/ml/status показывает -# reachable:true, stats.outbox:0 и класс "spam" в модели сервиса (learned ≥ 1). -# -# Живые проверки Telegram-входа (api_id/api_hash/QR) и реальных LLM-вызовов — НЕ выполняются -# (нужны креды; см. техдок §13, «ручные проверки»). Скрипт сам гасит стек: trap → docker compose down -# (volumes БЕЗ -v — данные dev сохраняются между прогонами). Запуск: scripts/dev-smoke.sh -set -u - -REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" -COMPOSE_FILE="deploy/compose.dev.yml" -BASE_URL="http://localhost:5080" - -CONTAINERS="deal-postgres deal-minio deal-telegram-service deal-ai-service deal-ml-service deal-core" -UP_TIMEOUT_TICKS=120 # 120 × 5 с = 10 мин максимум на здоровье всех контейнеров -TICK_SECONDS=5 -ML_POLL_TICKS=60 # 60 × 5 с = 5 мин на флаш outbox + refresh статуса сервиса - -WORK=$(mktemp -d) -JAR="$WORK/cookies.txt" -OUT="$WORK/out.txt" -PASS=0 -FAIL=0 -FAILED_NAMES="" - -check() { # имя, ожидание HTTP-кода, [фрагменты...] - local name="$1" code="$2" - shift 2 - if grep -q "\[HTTP:$code\]" "$OUT"; then - for frag in "$@"; do - if ! grep -qF "$frag" "$OUT"; then - echo " [FAIL] $name (нет фрагмента: $frag)" - cat "$OUT" - FAIL=$((FAIL + 1)) - FAILED_NAMES="$FAILED_NAMES|$name" - return - fi - done - echo " [PASS] $name" - PASS=$((PASS + 1)) - else - echo " [FAIL] $name (ожидался HTTP $code)" - cat "$OUT" - FAIL=$((FAIL + 1)) - FAILED_NAMES="$FAILED_NAMES|$name" - fi -} - -container_ok() { # имя → 0: running и (если есть healthcheck) healthy; 1: плох; 2: нет контейнера - local name="$1" state health - state=$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null) || return 2 - [ "$state" = "running" ] || return 1 - health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$name" 2>/dev/null) - [ "$health" = "none" ] || [ "$health" = "healthy" ] || return 1 - return 0 -} - -cleanup() { - echo - echo "== очистка: docker compose down (volumes сохраняются) ==" - (cd "$REPO_DIR" && docker compose -f "$COMPOSE_FILE" down) 2>/dev/null || true - rm -rf "$WORK" - echo "== очистка завершена ==" -} -trap cleanup EXIT INT TERM - -if ! docker info >/dev/null 2>&1; then - echo "Docker engine недоступен (Docker Desktop выключен?) — запустите Docker и повторите smoke." - exit 1 -fi - -cd "$REPO_DIR" || exit 1 - -echo "== 0. compose config валиден ==" -if docker compose -f "$COMPOSE_FILE" config --quiet; then - echo " [PASS] config" - PASS=$((PASS + 1)) -else - echo " [FAIL] docker compose config (файл невалиден)" - exit 1 -fi - -echo -echo "== 1. подъём стека (первый прогон собирает 4 docker-образа — долго) ==" -docker compose -f "$COMPOSE_FILE" up -d --build || { - echo " [FAIL] docker compose up (см. вывод выше)" - exit 1 -} -echo " [ok] docker compose up -d --build завершён" - -echo -echo "== 2. ожидание health всех контейнеров ==" -ALL_OK="" -i=0 -while [ $i -lt $UP_TIMEOUT_TICKS ]; do - ALL_OK=1 - for c in $CONTAINERS; do - if ! container_ok "$c"; then ALL_OK=""; break; fi - done - [ -n "$ALL_OK" ] && break - i=$((i + 1)) - sleep $TICK_SECONDS -done - -for c in $CONTAINERS; do - if container_ok "$c"; then - echo " [PASS] $c готов" - PASS=$((PASS + 1)) - else - echo " [FAIL] $c не поднялся/не healthy" - FAIL=$((FAIL + 1)) - FAILED_NAMES="$FAILED_NAMES|$c" - fi -done -if [ -z "$ALL_OK" ]; then - echo " подробности:" - docker compose -f "$COMPOSE_FILE" ps - docker compose -f "$COMPOSE_FILE" logs --tail=40 core - exit 1 -fi - -echo -echo "== 3. HTTP-проверки core :5080 ==" -echo -echo "-- 3.1 login admin/admin --" -curl -s -m 10 -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \ - -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT" -check "login → 200 ok:true" 200 '"ok":true' - -echo -echo "-- 3.2 GET /api/tg/status — живой статус через GrpcTelegramClient (telegram-service без сессии) --" -curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/tg/status" > "$OUT" -# Структурная проверка полей §4.9 (точные значения phase/monitored/keysSet зависят от состояния -# volume БД: fresh-стек ожидает idle/monitored:0/keysSet:false; после реального Telegram-входа может -# быть ready и ненулевой monitored). Живость gRPC-стека доказывают health контейнеров и флашер ML. -check "status — idle-форма/поля §4.9 (200)" 200 \ - '"phase":"' '"connected":' '"monitored":' '"keysSet":' - -echo -echo "-- 3.3 GET /api/containers?space=dashboard → реестр колонок --" -curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/containers?space=dashboard" > "$OUT" -check "containers dashboard → 200, есть inbox" 200 '"id":"inbox"' - -echo -echo "-- 3.4 POST /api/cards → локальная карточка (c_…, planned) --" -curl -s -m 20 -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/cards" \ - -H "Content-Type: application/json" \ - -d '{"title":"SMOKE card","containerId":"planned"}' > "$OUT" -check "create card → 200, карточка c_… в planned" 200 '"containerId":"planned"' -CARD_ID=$(sed -n 's/.*"id":"\(c_[a-zA-Z0-9_]*\)".*/\1/p' "$OUT" | head -1) -if [ -z "$CARD_ID" ]; then - echo " [FAIL] id карточки не извлечён из ответа create card" - FAIL=$((FAIL + 1)) - FAILED_NAMES="$FAILED_NAMES|create card id" -else - echo " карточка: $CARD_ID" -fi - -echo -echo "-- 3.4a GET /api/cards?containerId=planned → карточка в стадии --" -if [ -n "$CARD_ID" ]; then - curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/cards?containerId=planned" > "$OUT" - check "list planned → 200, карточка найдена" 200 "\"id\":\"$CARD_ID\"" -fi - -echo -echo "-- 3.5 POST /api/cards/$CARD_ID/trash → обучающий сигнал spam (MlOutbox) --" -if [ -n "$CARD_ID" ]; then - curl -s -m 20 -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/cards/$CARD_ID/trash" > "$OUT" - check "trash → 200 ok:true" 200 '"ok":true' -fi - -echo -echo "-- 3.6 флашер MlOutbox: ждём TrainBatch → ml-service (outbox:0 + класс spam в модели) --" -FLUSHED="" -i=0 -while [ $i -lt $ML_POLL_TICKS ]; do - curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT" - # reachable — живой Status RPC ml-service; outbox — локальная очередь (убывает после успешного - # TrainBatch); "spam" — класс в модели сервиса (появляется после рефреша кэша статуса, 15 с). - if grep -q '"reachable":true' "$OUT" && grep -q '"outbox":0' "$OUT" && grep -qF '"spam":' "$OUT"; then - FLUSHED=1 - break - fi - i=$((i + 1)) - sleep $TICK_SECONDS -done -if [ -n "$FLUSHED" ]; then - echo " [PASS] ML-флашер: outbox выгружен в ml-service (класс spam обучен)" - PASS=$((PASS + 1)) - cat "$OUT" -else - echo " [FAIL] ML-флашер: outbox не выгружен / ml-service недоступен за отведённое время" - cat "$OUT" - FAIL=$((FAIL + 1)) - FAILED_NAMES="$FAILED_NAMES|ml flusher" - docker compose -f "$COMPOSE_FILE" logs --tail=40 core | grep -i "флашер\|outbox\|TrainBatch" || true -fi - -echo -echo "== ИТОГ: PASS=$PASS FAIL=$FAIL ==" -if [ "$FAIL" = "0" ]; then - echo "SMOKE ПРОЙДЕН — полный dev-стек работает по gRPC" - exit 0 -fi -echo "Провалы:$FAILED_NAMES" -exit 1 +#!/usr/bin/env sh +# dev-smoke.sh — сквозной smoke полного dev-стека Дейла (план Task 20, Ruling 12). +# +# Поднимает ВЕСЬ стек deploy/compose.dev.yml (postgres+minio+ml/ai/telegram+core) в сквозном +# gRPC-режиме (Services__*__UseLocal=false заданы в compose) и проверяет: +# 1) health ВСЕХ контейнеров (docker healthchecks: postgres/core/ml/ai/telegram, minio — running); +# 2) auth: login admin/admin (дефолт TenantBootstrapService; при переопределённом пароле — правка); +# 3) /api/tg/status — живой статус через GrpcTelegramClient (idle-форма: telegram-service без сессии); +# 4) POST /api/cards → локальная карточка (планово, c_…); +# 5) POST /api/cards/{id}/trash — обучающий сигнал spam 1.0 → строка MlOutbox; +# 6) флашер MlOutboxFlushScheduler (10 с) → TrainBatch в ml-service: /api/ml/status показывает +# reachable:true, stats.outbox:0 и класс "spam" в модели сервиса (learned ≥ 1). +# +# Живые проверки Telegram-входа (api_id/api_hash/QR) и реальных LLM-вызовов — НЕ выполняются +# (нужны креды; см. техдок §13, «ручные проверки»). Скрипт сам гасит стек: trap → docker compose down +# (volumes БЕЗ -v — данные dev сохраняются между прогонами). Запуск: scripts/dev-smoke.sh +set -u + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +COMPOSE_FILE="deploy/compose.dev.yml" +BASE_URL="http://localhost:5080" + +CONTAINERS="deal-postgres deal-minio deal-telegram-service deal-ai-service deal-ml-service deal-core" +UP_TIMEOUT_TICKS=120 # 120 × 5 с = 10 мин максимум на здоровье всех контейнеров +TICK_SECONDS=5 +ML_POLL_TICKS=60 # 60 × 5 с = 5 мин на флаш outbox + refresh статуса сервиса + +WORK=$(mktemp -d) +JAR="$WORK/cookies.txt" +OUT="$WORK/out.txt" +PASS=0 +FAIL=0 +FAILED_NAMES="" + +check() { # имя, ожидание HTTP-кода, [фрагменты...] + local name="$1" code="$2" + shift 2 + if grep -q "\[HTTP:$code\]" "$OUT"; then + for frag in "$@"; do + if ! grep -qF "$frag" "$OUT"; then + echo " [FAIL] $name (нет фрагмента: $frag)" + cat "$OUT" + FAIL=$((FAIL + 1)) + FAILED_NAMES="$FAILED_NAMES|$name" + return + fi + done + echo " [PASS] $name" + PASS=$((PASS + 1)) + else + echo " [FAIL] $name (ожидался HTTP $code)" + cat "$OUT" + FAIL=$((FAIL + 1)) + FAILED_NAMES="$FAILED_NAMES|$name" + fi +} + +container_ok() { # имя → 0: running и (если есть healthcheck) healthy; 1: плох; 2: нет контейнера + local name="$1" state health + state=$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null) || return 2 + [ "$state" = "running" ] || return 1 + health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$name" 2>/dev/null) + [ "$health" = "none" ] || [ "$health" = "healthy" ] || return 1 + return 0 +} + +cleanup() { + echo + echo "== очистка: docker compose down (volumes сохраняются) ==" + (cd "$REPO_DIR" && docker compose -f "$COMPOSE_FILE" down) 2>/dev/null || true + rm -rf "$WORK" + echo "== очистка завершена ==" +} +trap cleanup EXIT INT TERM + +if ! docker info >/dev/null 2>&1; then + echo "Docker engine недоступен (Docker Desktop выключен?) — запустите Docker и повторите smoke." + exit 1 +fi + +cd "$REPO_DIR" || exit 1 + +echo "== 0. compose config валиден ==" +if docker compose -f "$COMPOSE_FILE" config --quiet; then + echo " [PASS] config" + PASS=$((PASS + 1)) +else + echo " [FAIL] docker compose config (файл невалиден)" + exit 1 +fi + +echo +echo "== 1. подъём стека (первый прогон собирает 4 docker-образа — долго) ==" +docker compose -f "$COMPOSE_FILE" up -d --build || { + echo " [FAIL] docker compose up (см. вывод выше)" + exit 1 +} +echo " [ok] docker compose up -d --build завершён" + +echo +echo "== 2. ожидание health всех контейнеров ==" +ALL_OK="" +i=0 +while [ $i -lt $UP_TIMEOUT_TICKS ]; do + ALL_OK=1 + for c in $CONTAINERS; do + if ! container_ok "$c"; then ALL_OK=""; break; fi + done + [ -n "$ALL_OK" ] && break + i=$((i + 1)) + sleep $TICK_SECONDS +done + +for c in $CONTAINERS; do + if container_ok "$c"; then + echo " [PASS] $c готов" + PASS=$((PASS + 1)) + else + echo " [FAIL] $c не поднялся/не healthy" + FAIL=$((FAIL + 1)) + FAILED_NAMES="$FAILED_NAMES|$c" + fi +done +if [ -z "$ALL_OK" ]; then + echo " подробности:" + docker compose -f "$COMPOSE_FILE" ps + docker compose -f "$COMPOSE_FILE" logs --tail=40 core + exit 1 +fi + +echo +echo "== 3. HTTP-проверки core :5080 ==" +echo +echo "-- 3.1 login admin/admin --" +curl -s -m 10 -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \ + -H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT" +check "login → 200 ok:true" 200 '"ok":true' + +echo +echo "-- 3.2 GET /api/tg/status — живой статус через GrpcTelegramClient (telegram-service без сессии) --" +curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/tg/status" > "$OUT" +# Структурная проверка полей §4.9 (точные значения phase/monitored/keysSet зависят от состояния +# volume БД: fresh-стек ожидает idle/monitored:0/keysSet:false; после реального Telegram-входа может +# быть ready и ненулевой monitored). Живость gRPC-стека доказывают health контейнеров и флашер ML. +check "status — idle-форма/поля §4.9 (200)" 200 \ + '"phase":"' '"connected":' '"monitored":' '"keysSet":' + +echo +echo "-- 3.3 GET /api/containers?space=dashboard → реестр колонок --" +curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/containers?space=dashboard" > "$OUT" +check "containers dashboard → 200, есть inbox" 200 '"id":"inbox"' + +echo +echo "-- 3.4 POST /api/cards → локальная карточка (c_…, planned) --" +curl -s -m 20 -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/cards" \ + -H "Content-Type: application/json" \ + -d '{"title":"SMOKE card","containerId":"planned"}' > "$OUT" +check "create card → 200, карточка c_… в planned" 200 '"containerId":"planned"' +CARD_ID=$(sed -n 's/.*"id":"\(c_[a-zA-Z0-9_]*\)".*/\1/p' "$OUT" | head -1) +if [ -z "$CARD_ID" ]; then + echo " [FAIL] id карточки не извлечён из ответа create card" + FAIL=$((FAIL + 1)) + FAILED_NAMES="$FAILED_NAMES|create card id" +else + echo " карточка: $CARD_ID" +fi + +echo +echo "-- 3.4a GET /api/cards?containerId=planned → карточка в стадии --" +if [ -n "$CARD_ID" ]; then + curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/cards?containerId=planned" > "$OUT" + check "list planned → 200, карточка найдена" 200 "\"id\":\"$CARD_ID\"" +fi + +echo +echo "-- 3.5 POST /api/cards/$CARD_ID/trash → обучающий сигнал spam (MlOutbox) --" +if [ -n "$CARD_ID" ]; then + curl -s -m 20 -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/cards/$CARD_ID/trash" > "$OUT" + check "trash → 200 ok:true" 200 '"ok":true' +fi + +echo +echo "-- 3.6 флашер MlOutbox: ждём TrainBatch → ml-service (outbox:0 + класс spam в модели) --" +FLUSHED="" +i=0 +while [ $i -lt $ML_POLL_TICKS ]; do + curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT" + # reachable — живой Status RPC ml-service; outbox — локальная очередь (убывает после успешного + # TrainBatch); "spam" — класс в модели сервиса (появляется после рефреша кэша статуса, 15 с). + if grep -q '"reachable":true' "$OUT" && grep -q '"outbox":0' "$OUT" && grep -qF '"spam":' "$OUT"; then + FLUSHED=1 + break + fi + i=$((i + 1)) + sleep $TICK_SECONDS +done +if [ -n "$FLUSHED" ]; then + echo " [PASS] ML-флашер: outbox выгружен в ml-service (класс spam обучен)" + PASS=$((PASS + 1)) + cat "$OUT" +else + echo " [FAIL] ML-флашер: outbox не выгружен / ml-service недоступен за отведённое время" + cat "$OUT" + FAIL=$((FAIL + 1)) + FAILED_NAMES="$FAILED_NAMES|ml flusher" + docker compose -f "$COMPOSE_FILE" logs --tail=40 core | grep -i "флашер\|outbox\|TrainBatch" || true +fi + +echo +echo "== ИТОГ: PASS=$PASS FAIL=$FAIL ==" +if [ "$FAIL" = "0" ]; then + echo "SMOKE ПРОЙДЕН — полный dev-стек работает по gRPC" + exit 0 +fi +echo "Провалы:$FAILED_NAMES" +exit 1 diff --git a/scripts/fix_private_docs.py b/scripts/fix_private_docs.py index 3a8fa04..d2fadf0 100644 --- a/scripts/fix_private_docs.py +++ b/scripts/fix_private_docs.py @@ -1,155 +1,155 @@ -"""Понижение XML-док комментариев с private/internal членов до обычных `//` (код-стайл «Дейл» §5). - -Правило: XML-doc — только на public/protected. Приватные детали реализации — при необходимости короткий -обычный комментарий. Скрипт сохраняет текст (теги / и т.п. разворачиваются в читаемый вид). - -Режимы: - python scripts/fix_private_docs.py --preview [N] # показать N примеров (по умолчанию 10) - python scripts/fix_private_docs.py --apply # применить - python scripts/fix_private_docs.py --check # сколько блоков будет изменено -""" -import os -import re -import sys - -ROOT = r"C:\telbase\src" -EXC = ("bin", "obj", "node_modules", ".git") -DOC_RE = re.compile(r"^(\s*)///[ \t]?(.*)$") -ACC_RE = re.compile(r"\b(public|protected|private|internal)\b") - - -def demote(block_content): - """block_content: список строк после '///'. Возвращает список строк текста обычного комментария.""" - t = "\n".join(block_content) - t = re.sub(r"\s*(.*?)\s*", r"\1", t, flags=re.S) - t = re.sub(r"\s*(.*?)\s*", r"\1", t, flags=re.S) - t = re.sub(r"", r"\1", t) - t = re.sub(r"", r"\1", t) - t = re.sub(r"\s*(.*?)\s*", r"\1: \2", t, flags=re.S) - t = re.sub(r"\s*(.*?)\s*", r"\1: \2", t, flags=re.S) - t = re.sub(r"\s*(.*?)\s*", r"Возвращает: \1", t, flags=re.S) - t = re.sub( - r"\s*(.*?)\s*", - r"Исключение \1: \2", - t, - flags=re.S, - ) - t = re.sub(r"", r"\1", t) - t = re.sub(r"", "", t) - t = re.sub(r"", "", t) - t = re.sub(r"", "", t) - t = re.sub(r"<[^>]+>", "", t) - out = [] - for line in t.split("\n"): - s = line.strip() - if s: - out.append(s) - return out - - -def iter_cs(): - for dp, dn, fn in os.walk(ROOT): - dn[:] = [d for d in dn if d not in EXC] - for f in fn: - if f.endswith(".cs"): - yield os.path.join(dp, f) - - -def find_blocks(lines): - """Возвращает список (start, end, block_content, decl_index, decl).""" - res = [] - i = 0 - while i < len(lines): - m = DOC_RE.match(lines[i].rstrip("\r")) - if not m: - i += 1 - continue - start = i - block = [] - while i < len(lines): - m2 = DOC_RE.match(lines[i].rstrip("\r")) - if not m2: - break - block.append(m2.group(2)) - i += 1 - end = i # exclusive - j = i - decl = "" - while j < len(lines): - s = lines[j].strip() - if s == "" or s.startswith("["): - j += 1 - continue - decl = s - break - if not decl: - continue - if not any("" in b or "" in b for b in block): - continue - am = ACC_RE.search(decl) - if am and am.group(1) in ("private", "internal"): - res.append((start, end, block, decl)) - return res - - -def process_file(path, apply): - raw = open(path, "rb").read().decode("utf-8") - lines = raw.splitlines(keepends=True) - plain = [l.rstrip("\r\n") for l in lines] - endings = [l[len(l.rstrip("\r\n")):] for l in lines] - blocks = find_blocks(plain) - if not blocks: - return 0, [] - changes = [] - for start, end, block, decl in blocks: - indent = DOC_RE.match(plain[start]).group(1) - comments = demote(block) - new_lines = [indent + "//" + (" " + c if c else "") for c in comments] - changes.append((start, end, new_lines, decl)) - # применяем с конца - for start, end, new_lines, decl in reversed(changes): - ending = endings[start] if endings[start] else (endings[end - 1] if end > start else "\n") - replacement = [nl + ending for nl in new_lines] - lines[start:end] = replacement - if apply: - open(path, "w", encoding="utf-8", newline="").write("".join(lines)) - return len(changes), [(b[0], b[3], demote(b[2])) for b in [(s, e, bl, d) for s, e, bl, d in blocks]] - - -def main(): - if "--preview" in sys.argv: - n = 10 - idx = sys.argv.index("--preview") - if idx + 1 < len(sys.argv) and sys.argv[idx + 1].isdigit(): - n = int(sys.argv[idx + 1]) - shown = 0 - for path in iter_cs(): - raw = open(path, "rb").read().decode("utf-8") - plain = [l.rstrip("\r\n") for l in raw.split("\n")] - for start, end, block, decl in find_blocks(plain): - print("FILE:", os.path.relpath(path, r"C:\telbase"), f"line {start + 1}") - print(" DECL:", decl[:100]) - print(" BEFORE:") - for b in plain[start:end]: - print(" ", b) - print(" AFTER:") - for c in demote(block): - print(" // " + c) - print() - shown += 1 - if shown >= n: - return - return - apply = "--apply" in sys.argv - total = 0 - files = 0 - for path in iter_cs(): - cnt, _ = process_file(path, apply) - if cnt: - files += 1 - total += cnt - print(("APPLIED" if apply else "DRY-RUN"), "blocks:", total, "files:", files) - - -if __name__ == "__main__": - main() +"""Понижение XML-док комментариев с private/internal членов до обычных `//` (код-стайл «Дейл» §5). + +Правило: XML-doc — только на public/protected. Приватные детали реализации — при необходимости короткий +обычный комментарий. Скрипт сохраняет текст (теги / и т.п. разворачиваются в читаемый вид). + +Режимы: + python scripts/fix_private_docs.py --preview [N] # показать N примеров (по умолчанию 10) + python scripts/fix_private_docs.py --apply # применить + python scripts/fix_private_docs.py --check # сколько блоков будет изменено +""" +import os +import re +import sys + +ROOT = r"C:\telbase\src" +EXC = ("bin", "obj", "node_modules", ".git") +DOC_RE = re.compile(r"^(\s*)///[ \t]?(.*)$") +ACC_RE = re.compile(r"\b(public|protected|private|internal)\b") + + +def demote(block_content): + """block_content: список строк после '///'. Возвращает список строк текста обычного комментария.""" + t = "\n".join(block_content) + t = re.sub(r"\s*(.*?)\s*", r"\1", t, flags=re.S) + t = re.sub(r"\s*(.*?)\s*", r"\1", t, flags=re.S) + t = re.sub(r"", r"\1", t) + t = re.sub(r"", r"\1", t) + t = re.sub(r"\s*(.*?)\s*", r"\1: \2", t, flags=re.S) + t = re.sub(r"\s*(.*?)\s*", r"\1: \2", t, flags=re.S) + t = re.sub(r"\s*(.*?)\s*", r"Возвращает: \1", t, flags=re.S) + t = re.sub( + r"\s*(.*?)\s*", + r"Исключение \1: \2", + t, + flags=re.S, + ) + t = re.sub(r"", r"\1", t) + t = re.sub(r"", "", t) + t = re.sub(r"", "", t) + t = re.sub(r"", "", t) + t = re.sub(r"<[^>]+>", "", t) + out = [] + for line in t.split("\n"): + s = line.strip() + if s: + out.append(s) + return out + + +def iter_cs(): + for dp, dn, fn in os.walk(ROOT): + dn[:] = [d for d in dn if d not in EXC] + for f in fn: + if f.endswith(".cs"): + yield os.path.join(dp, f) + + +def find_blocks(lines): + """Возвращает список (start, end, block_content, decl_index, decl).""" + res = [] + i = 0 + while i < len(lines): + m = DOC_RE.match(lines[i].rstrip("\r")) + if not m: + i += 1 + continue + start = i + block = [] + while i < len(lines): + m2 = DOC_RE.match(lines[i].rstrip("\r")) + if not m2: + break + block.append(m2.group(2)) + i += 1 + end = i # exclusive + j = i + decl = "" + while j < len(lines): + s = lines[j].strip() + if s == "" or s.startswith("["): + j += 1 + continue + decl = s + break + if not decl: + continue + if not any("" in b or "" in b for b in block): + continue + am = ACC_RE.search(decl) + if am and am.group(1) in ("private", "internal"): + res.append((start, end, block, decl)) + return res + + +def process_file(path, apply): + raw = open(path, "rb").read().decode("utf-8") + lines = raw.splitlines(keepends=True) + plain = [l.rstrip("\r\n") for l in lines] + endings = [l[len(l.rstrip("\r\n")):] for l in lines] + blocks = find_blocks(plain) + if not blocks: + return 0, [] + changes = [] + for start, end, block, decl in blocks: + indent = DOC_RE.match(plain[start]).group(1) + comments = demote(block) + new_lines = [indent + "//" + (" " + c if c else "") for c in comments] + changes.append((start, end, new_lines, decl)) + # применяем с конца + for start, end, new_lines, decl in reversed(changes): + ending = endings[start] if endings[start] else (endings[end - 1] if end > start else "\n") + replacement = [nl + ending for nl in new_lines] + lines[start:end] = replacement + if apply: + open(path, "w", encoding="utf-8", newline="").write("".join(lines)) + return len(changes), [(b[0], b[3], demote(b[2])) for b in [(s, e, bl, d) for s, e, bl, d in blocks]] + + +def main(): + if "--preview" in sys.argv: + n = 10 + idx = sys.argv.index("--preview") + if idx + 1 < len(sys.argv) and sys.argv[idx + 1].isdigit(): + n = int(sys.argv[idx + 1]) + shown = 0 + for path in iter_cs(): + raw = open(path, "rb").read().decode("utf-8") + plain = [l.rstrip("\r\n") for l in raw.split("\n")] + for start, end, block, decl in find_blocks(plain): + print("FILE:", os.path.relpath(path, r"C:\telbase"), f"line {start + 1}") + print(" DECL:", decl[:100]) + print(" BEFORE:") + for b in plain[start:end]: + print(" ", b) + print(" AFTER:") + for c in demote(block): + print(" // " + c) + print() + shown += 1 + if shown >= n: + return + return + apply = "--apply" in sys.argv + total = 0 + files = 0 + for path in iter_cs(): + cnt, _ = process_file(path, apply) + if cnt: + files += 1 + total += cnt + print(("APPLIED" if apply else "DRY-RUN"), "blocks:", total, "files:", files) + + +if __name__ == "__main__": + main() diff --git a/scripts/fix_summary_blocks.py b/scripts/fix_summary_blocks.py index 926bf3c..dcfa8f5 100644 --- a/scripts/fix_summary_blocks.py +++ b/scripts/fix_summary_blocks.py @@ -1,108 +1,108 @@ -"""Приведение XML-док к блочному виду (действующий код-стайл «Дейл»). - -Правило: открывающий и закрывающий — каждый на своей строке. -Однострочная запись `/// текст` не допускается. - -Режимы: - python scripts/fix_summary_blocks.py --check # только посчитать, что изменится - python scripts/fix_summary_blocks.py --apply # применить -""" -import os -import re -import sys - -ROOT = r"C:\telbase\src" -EXC = ("bin", "obj", "node_modules", ".git") -LINE_RE = re.compile(r"^(\s*)(///)([ \t]?)(.*)$") - - -def transform(body: str): - m = LINE_RE.match(body) - if not m: - return [body] - indent, _, _, content = m.groups() - if "" not in content and "" not in content: - return [body] - base = indent + "///" - - def tag(text=""): - return base + (" " + text if text else "") - - if "" in content and "" in content: - pre, rest = content.split("", 1) - between, post = rest.split("", 1) - res = [] - if pre.strip(): - res.append(tag(pre.strip())) - res.append(tag("")) - if between.strip(): - res.append(tag(between.strip())) - res.append(tag("")) - if post.strip(): - res.append(tag(post.strip())) - return res - - if "" in content: - pre, after = content.split("", 1) - res = [] - if pre.strip(): - res.append(tag(pre.strip())) - res.append(tag("")) - if after.strip(): - res.append(tag(after.strip())) - return res - - if "" in content: - pre, after = content.split("", 1) - res = [] - if pre.strip(): - res.append(tag(pre.strip())) - res.append(tag("")) - if after.strip(): - res.append(tag(after.strip())) - return res - - return [body] - - -def iter_cs(): - for dp, dn, fn in os.walk(ROOT): - dn[:] = [d for d in dn if d not in EXC] - for f in fn: - if f.endswith(".cs"): - yield os.path.join(dp, f) - - -def process(path, apply): - raw = open(path, "rb").read().decode("utf-8") - lines = raw.splitlines(keepends=True) - out = [] - changed = False - for raw_line in lines: - if raw_line.endswith("\r\n"): - body, ending = raw_line[:-2], "\r\n" - elif raw_line.endswith("\n"): - body, ending = raw_line[:-1], "\n" - else: - body, ending = raw_line, "" - new_bodies = transform(body) - if new_bodies != [body]: - changed = True - out.extend(b + ending for b in new_bodies) - if changed and apply: - open(path, "w", encoding="utf-8", newline="").write("".join(out)) - return changed - - -def main(): - apply = "--apply" in sys.argv - total = 0 - files = 0 - for path in iter_cs(): - if process(path, apply): - files += 1 - print(("APPLIED" if apply else "DRY-RUN"), "files changed:", files) - - -if __name__ == "__main__": - main() +"""Приведение XML-док к блочному виду (действующий код-стайл «Дейл»). + +Правило: открывающий и закрывающий — каждый на своей строке. +Однострочная запись `/// текст` не допускается. + +Режимы: + python scripts/fix_summary_blocks.py --check # только посчитать, что изменится + python scripts/fix_summary_blocks.py --apply # применить +""" +import os +import re +import sys + +ROOT = r"C:\telbase\src" +EXC = ("bin", "obj", "node_modules", ".git") +LINE_RE = re.compile(r"^(\s*)(///)([ \t]?)(.*)$") + + +def transform(body: str): + m = LINE_RE.match(body) + if not m: + return [body] + indent, _, _, content = m.groups() + if "" not in content and "" not in content: + return [body] + base = indent + "///" + + def tag(text=""): + return base + (" " + text if text else "") + + if "" in content and "" in content: + pre, rest = content.split("", 1) + between, post = rest.split("", 1) + res = [] + if pre.strip(): + res.append(tag(pre.strip())) + res.append(tag("")) + if between.strip(): + res.append(tag(between.strip())) + res.append(tag("")) + if post.strip(): + res.append(tag(post.strip())) + return res + + if "" in content: + pre, after = content.split("", 1) + res = [] + if pre.strip(): + res.append(tag(pre.strip())) + res.append(tag("")) + if after.strip(): + res.append(tag(after.strip())) + return res + + if "" in content: + pre, after = content.split("", 1) + res = [] + if pre.strip(): + res.append(tag(pre.strip())) + res.append(tag("")) + if after.strip(): + res.append(tag(after.strip())) + return res + + return [body] + + +def iter_cs(): + for dp, dn, fn in os.walk(ROOT): + dn[:] = [d for d in dn if d not in EXC] + for f in fn: + if f.endswith(".cs"): + yield os.path.join(dp, f) + + +def process(path, apply): + raw = open(path, "rb").read().decode("utf-8") + lines = raw.splitlines(keepends=True) + out = [] + changed = False + for raw_line in lines: + if raw_line.endswith("\r\n"): + body, ending = raw_line[:-2], "\r\n" + elif raw_line.endswith("\n"): + body, ending = raw_line[:-1], "\n" + else: + body, ending = raw_line, "" + new_bodies = transform(body) + if new_bodies != [body]: + changed = True + out.extend(b + ending for b in new_bodies) + if changed and apply: + open(path, "w", encoding="utf-8", newline="").write("".join(out)) + return changed + + +def main(): + apply = "--apply" in sys.argv + total = 0 + files = 0 + for path in iter_cs(): + if process(path, apply): + files += 1 + print(("APPLIED" if apply else "DRY-RUN"), "files changed:", files) + + +if __name__ == "__main__": + main() diff --git a/scripts/loadtest/README.md b/scripts/loadtest/README.md index 0a313d6..3bc4e8a 100644 --- a/scripts/loadtest/README.md +++ b/scripts/loadtest/README.md @@ -1,54 +1,54 @@ -# Нагрузочный смоук-тест API Deal (`scripts/loadtest/`) - -Минимальный прогон «есть ли жизнь и как быстро отвечает» по читающим ручкам API -(этап 12, пакет C). Внешних сервисов и записывающих операций нет — только логин -и чтение, БД не мутируется. - -## Сценарий - -1. `POST /api/auth/login` `{login, password}` — сессия (`deal_session`). -2. В цикле: `GET /api/containers`, затем `GET /api/cards`. -3. Метрики: число запросов, ошибки (не-200), RPS, средняя/p95/max латентность. - -Учётные данные по умолчанию — `admin` / `admin` (dev-дефолт bootstrap тенанта). - -## Вариант A — bash + curl (зависимостей нет, кроме curl) - -```bash -bash scripts/loadtest/api-loadtest.sh - -# пример с параметрами -BASE_URL=http://localhost:5080 LOGIN=admin PASSWORD=admin \ - DURATION_SECONDS=20 PAUSE_SECONDS=0.1 \ - bash scripts/loadtest/api-loadtest.sh -``` - -Переменные окружения: `BASE_URL` (по умолчанию `http://localhost:5080`), -`LOGIN`/`PASSWORD`, `DURATION_SECONDS` (15), `PAUSE_SECONDS` (0.2), `CURL_TIMEOUT` (5). -Код возврата: `0` — без ошибок, `1` — ошибка логина/конфигурации, `2` — были не-200. - -## Вариант B — k6 (если установлен) - -```bash -k6 run scripts/loadtest/deal-loadtest.js -BASE_URL=http://localhost:5080 VUS=10 DURATION=30s \ - k6 run scripts/loadtest/deal-loadtest.js -``` - -Скрипт логинится один раз в `setup()` и раздаёт куку виртуальным пользователям; -пороги проваливают прогон при доле ошибок > 1% или p95 > 500 мс. - -## Как поднять API для прогона - -- **dev (полный стек)**: `docker compose -f deploy/compose.dev.yml up -d --build`, - затем прогон против `http://localhost:5080`; после — `docker compose -f deploy/compose.dev.yml down`. -- **dev (только core + Postgres)**: см. техдок §13 «Быстрый старт» (Postgres :5433, API :5080). -- Прогон короткий (по умолчанию 15–20 с) — это смоук, а не полноценный бенчмарк. - -## Замечания - -- В PROD включён rate-limit (`api` — 600/мин на тенанта, `auth` — 10/мин на IP), - поэтому длительные прогоны делайте на dev-стенде или поднимите лимит. -- Метрики перфа сервисов дополнительно смотрите в Prometheus/Grafana (профиль - `observability`): `http_server_request_duration_seconds_*`, RPS/p95 на дашборде - `Deal-Metrics-Overview` (см. техдок §7). +# Нагрузочный смоук-тест API Deal (`scripts/loadtest/`) + +Минимальный прогон «есть ли жизнь и как быстро отвечает» по читающим ручкам API +(этап 12, пакет C). Внешних сервисов и записывающих операций нет — только логин +и чтение, БД не мутируется. + +## Сценарий + +1. `POST /api/auth/login` `{login, password}` — сессия (`deal_session`). +2. В цикле: `GET /api/containers`, затем `GET /api/cards`. +3. Метрики: число запросов, ошибки (не-200), RPS, средняя/p95/max латентность. + +Учётные данные по умолчанию — `admin` / `admin` (dev-дефолт bootstrap тенанта). + +## Вариант A — bash + curl (зависимостей нет, кроме curl) + +```bash +bash scripts/loadtest/api-loadtest.sh + +# пример с параметрами +BASE_URL=http://localhost:5080 LOGIN=admin PASSWORD=admin \ + DURATION_SECONDS=20 PAUSE_SECONDS=0.1 \ + bash scripts/loadtest/api-loadtest.sh +``` + +Переменные окружения: `BASE_URL` (по умолчанию `http://localhost:5080`), +`LOGIN`/`PASSWORD`, `DURATION_SECONDS` (15), `PAUSE_SECONDS` (0.2), `CURL_TIMEOUT` (5). +Код возврата: `0` — без ошибок, `1` — ошибка логина/конфигурации, `2` — были не-200. + +## Вариант B — k6 (если установлен) + +```bash +k6 run scripts/loadtest/deal-loadtest.js +BASE_URL=http://localhost:5080 VUS=10 DURATION=30s \ + k6 run scripts/loadtest/deal-loadtest.js +``` + +Скрипт логинится один раз в `setup()` и раздаёт куку виртуальным пользователям; +пороги проваливают прогон при доле ошибок > 1% или p95 > 500 мс. + +## Как поднять API для прогона + +- **dev (полный стек)**: `docker compose -f deploy/compose.dev.yml up -d --build`, + затем прогон против `http://localhost:5080`; после — `docker compose -f deploy/compose.dev.yml down`. +- **dev (только core + Postgres)**: см. техдок §13 «Быстрый старт» (Postgres :5433, API :5080). +- Прогон короткий (по умолчанию 15–20 с) — это смоук, а не полноценный бенчмарк. + +## Замечания + +- В PROD включён rate-limit (`api` — 600/мин на тенанта, `auth` — 10/мин на IP), + поэтому длительные прогоны делайте на dev-стенде или поднимите лимит. +- Метрики перфа сервисов дополнительно смотрите в Prometheus/Grafana (профиль + `observability`): `http_server_request_duration_seconds_*`, RPS/p95 на дашборде + `Deal-Metrics-Overview` (см. техдок §7). diff --git a/scripts/loadtest/api-loadtest.sh b/scripts/loadtest/api-loadtest.sh index d0be2d0..e9c30fd 100644 --- a/scripts/loadtest/api-loadtest.sh +++ b/scripts/loadtest/api-loadtest.sh @@ -1,98 +1,98 @@ -#!/usr/bin/env bash -# Простой нагрузочный смоук-тест API Deal (этап 12, пакет C). -# -# Что делает: логинится (по умолчанию admin/admin), затем в цикле дёргает два -# читающих эндпоинта — GET /api/containers и GET /api/cards — до истечения -# DURATION_SECONDS, замеряя HTTP-код и время ответа. В конце печатает сводку: -# число запросов, ошибки (не 200), RPS, средняя/p95-латентность (сек). -# -# Зависимости: только bash и curl. Ничего не пишет в БД; читающие ручки. -# -# Запуск (из корня репозитория, dev-API на :5080): -# bash scripts/loadtest/api-loadtest.sh -# BASE_URL=http://localhost:5080 LOGIN=admin PASSWORD=admin \ -# DURATION_SECONDS=20 PAUSE_SECONDS=0.1 bash scripts/loadtest/api-loadtest.sh -# -# Переменные окружения: -# BASE_URL базовый URL API (по умолчанию http://localhost:5080) -# LOGIN/PASSWORD учётные данные тенанта (по умолчанию admin/admin — dev-дефолт bootstrap) -# DURATION_SECONDS длительность прогона, с (по умолчанию 15) -# PAUSE_SECONDS пауза между итерациями, с (по умолчанию 0.2) -# CURL_TIMEOUT таймаут запроса, с (по умолчанию 5) -# -# Внимание: в PROD включён rate-limit (api-политика — 600/мин на тенанта). Для -# нагрузочного прогона используйте dev-стенд или поднимите лимит. -set -euo pipefail - -BASE_URL="${BASE_URL:-http://localhost:5080}" -LOGIN="${LOGIN:-admin}" -PASSWORD="${PASSWORD:-admin}" -DURATION_SECONDS="${DURATION_SECONDS:-15}" -PAUSE_SECONDS="${PAUSE_SECONDS:-0.2}" -CURL_TIMEOUT="${CURL_TIMEOUT:-5}" - -command -v curl >/dev/null 2>&1 || { echo "Ошибка: нужен curl" >&2; exit 127; } - -tmp_dir="$(mktemp -d)" -cookie_jar="${tmp_dir}/cookies.txt" -lat_file="${tmp_dir}/latencies.txt" -trap 'rm -rf "${tmp_dir}"' EXIT - -login_code="$(curl -sS -o "${tmp_dir}/login.json" -w '%{http_code}' \ - -c "${cookie_jar}" -H 'Content-Type: application/json' \ - -d "{\"login\":\"${LOGIN}\",\"password\":\"${PASSWORD}\"}" \ - --max-time "${CURL_TIMEOUT}" "${BASE_URL}/api/auth/login" || true)" -[ -n "${login_code}" ] || login_code="000" - -if [ "${login_code}" != "200" ]; then - echo "LOGIN FAILED (HTTP ${login_code}): $(cat "${tmp_dir}/login.json" 2>/dev/null)" >&2 - echo "Проверьте BASE_URL=${BASE_URL} и что API поднят." >&2 - exit 1 -fi -echo "login OK (${LOGIN}) → прогон ${DURATION_SECONDS} с, пауза ${PAUSE_SECONDS} с" - -started_at="$(date +%s)" -end_at=$((started_at + DURATION_SECONDS)) -total=0 -errors=0 - -while [ "$(date +%s)" -lt "${end_at}" ]; do - for path in /api/containers /api/cards; do - result="$(curl -sS -o /dev/null -w '%{http_code} %{time_total}' \ - -b "${cookie_jar}" --max-time "${CURL_TIMEOUT}" "${BASE_URL}${path}" || true)" - [ -n "${result}" ] || result="000 0" - code="${result%% *}" - elapsed="${result##* }" - total=$((total + 1)) - if [ "${code}" != "200" ]; then - errors=$((errors + 1)) - echo " ! ${path} → HTTP ${code}" >&2 - fi - echo "${elapsed}" >> "${lat_file}" - done - sleep "${PAUSE_SECONDS}" -done - -elapsed_total=$(( $(date +%s) - started_at )) -[ "${elapsed_total}" -lt 1 ] && elapsed_total=1 - -if [ "${total}" -eq 0 ]; then - echo "Запросов не сделано (DURATION_SECONDS слишком мал?)" >&2 - exit 1 -fi - -avg="$(awk '{sum += $1} END {printf "%.4f", sum / NR}' "${lat_file}")" -p95_index=$(( (total * 95 + 99) / 100 )) -p95="$(sort -n "${lat_file}" | sed -n "${p95_index}p")" -max="$(sort -n "${lat_file}" | tail -n 1)" -rps="$(awk -v n="${total}" -v d="${elapsed_total}" 'BEGIN {printf "%.2f", n / d}')" - -echo "---------------------------------------------" -echo "запросов: ${total}" -echo "ошибок: ${errors}" -echo "длительность: ${elapsed_total} с" -echo "RPS: ${rps}" -echo "латентность: avg=${avg}s p95=${p95}s max=${max}s" -echo "---------------------------------------------" - -[ "${errors}" -eq 0 ] || exit 2 +#!/usr/bin/env bash +# Простой нагрузочный смоук-тест API Deal (этап 12, пакет C). +# +# Что делает: логинится (по умолчанию admin/admin), затем в цикле дёргает два +# читающих эндпоинта — GET /api/containers и GET /api/cards — до истечения +# DURATION_SECONDS, замеряя HTTP-код и время ответа. В конце печатает сводку: +# число запросов, ошибки (не 200), RPS, средняя/p95-латентность (сек). +# +# Зависимости: только bash и curl. Ничего не пишет в БД; читающие ручки. +# +# Запуск (из корня репозитория, dev-API на :5080): +# bash scripts/loadtest/api-loadtest.sh +# BASE_URL=http://localhost:5080 LOGIN=admin PASSWORD=admin \ +# DURATION_SECONDS=20 PAUSE_SECONDS=0.1 bash scripts/loadtest/api-loadtest.sh +# +# Переменные окружения: +# BASE_URL базовый URL API (по умолчанию http://localhost:5080) +# LOGIN/PASSWORD учётные данные тенанта (по умолчанию admin/admin — dev-дефолт bootstrap) +# DURATION_SECONDS длительность прогона, с (по умолчанию 15) +# PAUSE_SECONDS пауза между итерациями, с (по умолчанию 0.2) +# CURL_TIMEOUT таймаут запроса, с (по умолчанию 5) +# +# Внимание: в PROD включён rate-limit (api-политика — 600/мин на тенанта). Для +# нагрузочного прогона используйте dev-стенд или поднимите лимит. +set -euo pipefail + +BASE_URL="${BASE_URL:-http://localhost:5080}" +LOGIN="${LOGIN:-admin}" +PASSWORD="${PASSWORD:-admin}" +DURATION_SECONDS="${DURATION_SECONDS:-15}" +PAUSE_SECONDS="${PAUSE_SECONDS:-0.2}" +CURL_TIMEOUT="${CURL_TIMEOUT:-5}" + +command -v curl >/dev/null 2>&1 || { echo "Ошибка: нужен curl" >&2; exit 127; } + +tmp_dir="$(mktemp -d)" +cookie_jar="${tmp_dir}/cookies.txt" +lat_file="${tmp_dir}/latencies.txt" +trap 'rm -rf "${tmp_dir}"' EXIT + +login_code="$(curl -sS -o "${tmp_dir}/login.json" -w '%{http_code}' \ + -c "${cookie_jar}" -H 'Content-Type: application/json' \ + -d "{\"login\":\"${LOGIN}\",\"password\":\"${PASSWORD}\"}" \ + --max-time "${CURL_TIMEOUT}" "${BASE_URL}/api/auth/login" || true)" +[ -n "${login_code}" ] || login_code="000" + +if [ "${login_code}" != "200" ]; then + echo "LOGIN FAILED (HTTP ${login_code}): $(cat "${tmp_dir}/login.json" 2>/dev/null)" >&2 + echo "Проверьте BASE_URL=${BASE_URL} и что API поднят." >&2 + exit 1 +fi +echo "login OK (${LOGIN}) → прогон ${DURATION_SECONDS} с, пауза ${PAUSE_SECONDS} с" + +started_at="$(date +%s)" +end_at=$((started_at + DURATION_SECONDS)) +total=0 +errors=0 + +while [ "$(date +%s)" -lt "${end_at}" ]; do + for path in /api/containers /api/cards; do + result="$(curl -sS -o /dev/null -w '%{http_code} %{time_total}' \ + -b "${cookie_jar}" --max-time "${CURL_TIMEOUT}" "${BASE_URL}${path}" || true)" + [ -n "${result}" ] || result="000 0" + code="${result%% *}" + elapsed="${result##* }" + total=$((total + 1)) + if [ "${code}" != "200" ]; then + errors=$((errors + 1)) + echo " ! ${path} → HTTP ${code}" >&2 + fi + echo "${elapsed}" >> "${lat_file}" + done + sleep "${PAUSE_SECONDS}" +done + +elapsed_total=$(( $(date +%s) - started_at )) +[ "${elapsed_total}" -lt 1 ] && elapsed_total=1 + +if [ "${total}" -eq 0 ]; then + echo "Запросов не сделано (DURATION_SECONDS слишком мал?)" >&2 + exit 1 +fi + +avg="$(awk '{sum += $1} END {printf "%.4f", sum / NR}' "${lat_file}")" +p95_index=$(( (total * 95 + 99) / 100 )) +p95="$(sort -n "${lat_file}" | sed -n "${p95_index}p")" +max="$(sort -n "${lat_file}" | tail -n 1)" +rps="$(awk -v n="${total}" -v d="${elapsed_total}" 'BEGIN {printf "%.2f", n / d}')" + +echo "---------------------------------------------" +echo "запросов: ${total}" +echo "ошибок: ${errors}" +echo "длительность: ${elapsed_total} с" +echo "RPS: ${rps}" +echo "латентность: avg=${avg}s p95=${p95}s max=${max}s" +echo "---------------------------------------------" + +[ "${errors}" -eq 0 ] || exit 2 diff --git a/scripts/loadtest/deal-loadtest.js b/scripts/loadtest/deal-loadtest.js index 012cc71..73fc83d 100644 --- a/scripts/loadtest/deal-loadtest.js +++ b/scripts/loadtest/deal-loadtest.js @@ -1,56 +1,56 @@ -// Нагрузочный смоук-тест API Deal на k6 (этап 12, пакет C) — альтернатива -// scripts/loadtest/api-loadtest.sh, если в системе есть k6. -// -// Логин — в setup() (один раз), затем виртуальные пользователи в цикле читают -// GET /api/containers и GET /api/cards. Пороги (thresholds) проваливают прогон -// при доле ошибок > 1% или p95 > 500 мс. -// -// Запуск (API dev на :5080): -// k6 run scripts/loadtest/deal-loadtest.js -// BASE_URL=http://localhost:5080 VUS=10 DURATION=30s k6 run scripts/loadtest/deal-loadtest.js -// -// Переменные окружения: BASE_URL, LOGIN, PASSWORD, VUS, DURATION. -// Внимание: в PROD включён rate-limit (api — 600/мин на тенанта) — гоняйте на dev. - -import http from 'k6/http'; -import { check, sleep } from 'k6'; - -const BASE_URL = __ENV.BASE_URL || 'http://localhost:5080'; -const LOGIN = __ENV.LOGIN || 'admin'; -const PASSWORD = __ENV.PASSWORD || 'admin'; - -export const options = { - vus: __ENV.VUS ? Number(__ENV.VUS) : 5, - duration: __ENV.DURATION || '20s', - thresholds: { - http_req_failed: ['rate<0.01'], - http_req_duration: ['p(95)<500'], - }, -}; - -export function setup() { - const res = http.post( - `${BASE_URL}/api/auth/login`, - JSON.stringify({ login: LOGIN, password: PASSWORD }), - { headers: { 'Content-Type': 'application/json' } } - ); - if (res.status !== 200) { - throw new Error(`login failed: HTTP ${res.status} — ${res.body}`); - } - const cookies = res.cookies['deal_session']; - const session = cookies && cookies.length ? cookies[0].value : ''; - if (!session) { - throw new Error('login succeeded but deal_session cookie is missing'); - } - return { session }; -} - -export default function (data) { - const params = { headers: { Cookie: `deal_session=${data.session}` } }; - const paths = ['/api/containers', '/api/cards']; - for (const path of paths) { - const res = http.get(`${BASE_URL}${path}`, params); - check(res, { [`${path} → 200`]: (r) => r.status === 200 }); - } - sleep(0.2); -} +// Нагрузочный смоук-тест API Deal на k6 (этап 12, пакет C) — альтернатива +// scripts/loadtest/api-loadtest.sh, если в системе есть k6. +// +// Логин — в setup() (один раз), затем виртуальные пользователи в цикле читают +// GET /api/containers и GET /api/cards. Пороги (thresholds) проваливают прогон +// при доле ошибок > 1% или p95 > 500 мс. +// +// Запуск (API dev на :5080): +// k6 run scripts/loadtest/deal-loadtest.js +// BASE_URL=http://localhost:5080 VUS=10 DURATION=30s k6 run scripts/loadtest/deal-loadtest.js +// +// Переменные окружения: BASE_URL, LOGIN, PASSWORD, VUS, DURATION. +// Внимание: в PROD включён rate-limit (api — 600/мин на тенанта) — гоняйте на dev. + +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:5080'; +const LOGIN = __ENV.LOGIN || 'admin'; +const PASSWORD = __ENV.PASSWORD || 'admin'; + +export const options = { + vus: __ENV.VUS ? Number(__ENV.VUS) : 5, + duration: __ENV.DURATION || '20s', + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(95)<500'], + }, +}; + +export function setup() { + const res = http.post( + `${BASE_URL}/api/auth/login`, + JSON.stringify({ login: LOGIN, password: PASSWORD }), + { headers: { 'Content-Type': 'application/json' } } + ); + if (res.status !== 200) { + throw new Error(`login failed: HTTP ${res.status} — ${res.body}`); + } + const cookies = res.cookies['deal_session']; + const session = cookies && cookies.length ? cookies[0].value : ''; + if (!session) { + throw new Error('login succeeded but deal_session cookie is missing'); + } + return { session }; +} + +export default function (data) { + const params = { headers: { Cookie: `deal_session=${data.session}` } }; + const paths = ['/api/containers', '/api/cards']; + for (const path of paths) { + const res = http.get(`${BASE_URL}${path}`, params); + check(res, { [`${path} → 200`]: (r) => r.status === 200 }); + } + sleep(0.2); +} diff --git a/scripts/mtls-certs.sh b/scripts/mtls-certs.sh index 0f63045..e955f8b 100644 --- a/scripts/mtls-certs.sh +++ b/scripts/mtls-certs.sh @@ -1,210 +1,210 @@ -#!/usr/bin/env sh -# -# mtls-certs.sh — генерация dev-CA и сертификатов mTLS внутреннего gRPC «Дейла» (этап 7, Ruling 6, Task 13). -# -# Требуется openssl. Вывод — deploy/certs/: -# ca.pem / ca.key — dev-CA (ca.pem — runtime всех процессов, ca.key — только подпись новых -# сертификатов; хранится в deploy/certs, в репозиторий/образ не попадает, -# см. .dockerignore; права 600); -# -server.pfx — серверный сертификат процесса: core, telegram-service, ai-service, -# ml-service (SAN: localhost + имя compose-сервиса + host.docker.internal); -# deal-client.pfx — общий клиентский сертификат исходящих каналов (core → сервисы, -# telegram-service → core-ингресс); -# deal-client.crt/.key — тот же клиентский сертификат в PEM (без пароля): нужен docker -# healthcheck'ам compose.prod при mTLS — grpc_health_probe принимает -# только PEM (флаги -tls-client-cert/-tls-client-key; см. Task 14). -# -# Сертификаты dev (срок 825 дней — как Let's Encrypt; CA — 10 лет). Пароль PFX — env DEAL_MTLS_CERT_PASSWORD -# (по умолчанию deal_mtls_dev_password — dev); в PROD задайте свой и пропишите его же в -# DEAL_MTLS_SERVER_CERT_PASSWORD / DEAL_MTLS_CLIENT_CERT_PASSWORD процессов (compose-prod — Task 14). -# Повторный запуск без -f отказывается перезаписывать существующую CA (смена CA ломает доверие всех -# сертификатов — перегенерация нужна осознанная). Subject-имена задаются config-файлами openssl, а не -# -subj, — скрипт работает и в Git Bash (MSYS не «съедает» аргументы вида /CN=...). -# -# Использование: -# scripts/mtls-certs.sh [-f] [DEAL_MTLS_CERT_PASSWORD=...] -# -# После генерации процессы поднимаются с env (пути — как смонтировано в compose-prod, Task 14): -# DEAL_MTLS_ENABLED=1 -# DEAL_MTLS_CA_PEM=/etc/deal/certs/ca.pem -# DEAL_MTLS_SERVER_CERT_PFX=/etc/deal/certs/-server.pfx -# DEAL_MTLS_SERVER_CERT_PASSWORD=<тот же пароль> -# DEAL_MTLS_CLIENT_CERT_PFX=/etc/deal/certs/deal-client.pfx -# DEAL_MTLS_CLIENT_CERT_PASSWORD=<тот же пароль> -# -# Dev-стек (deploy/compose.dev.yml) остаётся plaintext + service-token — DEAL_MTLS_ENABLED не задаётся. - -set -eu - -# Корень репозитория (каталог скрипта/..). -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -ROOT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) -CERT_DIR="$ROOT_DIR/deploy/certs" - -# Пароль PFX: env DEAL_MTLS_CERT_PASSWORD (dev-дефолт — фиксированное значение; PROD — свой). -CERT_PASSWORD="${DEAL_MTLS_CERT_PASSWORD:-deal_mtls_dev_password}" - -# Сроки сертификатов (дней): CA — 10 лет, листовые — 825 (как Let's Encrypt). -CA_DAYS=3650 -LEAF_DAYS=825 - -# Пространство имён сертификатов: SAN листовых — localhost + имя compose-сервиса + host.docker.internal -# (dev-прогон сервиса в контейнере против core на хосте, Ruling 12) + loopback-IP. -SERVICES="core telegram-service ai-service ml-service" - -FORCE=0 -for arg in "$@"; do - case "$arg" in - -f|--force) FORCE=1 ;; - *) - echo "mtls-certs: неизвестный аргумент: $arg (ожидалось -f/--force)" >&2 - exit 2 - ;; - esac -done - -if ! command -v openssl >/dev/null 2>&1; then - echo "mtls-certs: openssl не найден — установите openssl (например: apt install openssl / brew install openssl)." >&2 - exit 1 -fi - -mkdir -p "$CERT_DIR" - -if [ -f "$CERT_DIR/ca.pem" ]; then - if [ "$FORCE" -eq 0 ]; then - echo "mtls-certs: $CERT_DIR/ca.pem уже существует — повторный запуск не перезаписывает CA" >&2 - echo "mtls-certs: (смена CA ломает доверие выданных сертификатов). Перегенерировать всё: $0 -f" >&2 - exit 1 - fi - # Принудительная перегенерация: старые сертификаты удаляются целиком (их доверие утеряно вместе с CA). - rm -f "$CERT_DIR/ca.pem" "$CERT_DIR/ca.key" "$CERT_DIR/ca.srl" "$CERT_DIR"/*-server.pfx \ - "$CERT_DIR/deal-client.pfx" "$CERT_DIR/deal-client.crt" "$CERT_DIR/deal-client.key" -fi - -# Временный каталог для CSR/конфигов/ключей (всегда чистится, в т.ч. при ошибке). -TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/deal-mtls.XXXXXX") -trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM - -echo "mtls-certs: генерирую dev-CA (CN=Deal mTLS Dev CA, ${CA_DAYS} дн.) в $CERT_DIR ..." -cat > "$TMP_DIR/ca.cnf" <<'EOF' -[req] -distinguished_name = ca_dn -prompt = no -x509_extensions = ca_ext - -[ca_dn] -CN = Deal mTLS Dev CA - -[ca_ext] -basicConstraints = critical,CA:TRUE -keyUsage = critical,keyCertSign,cRLSign,digitalSignature -subjectKeyIdentifier = hash -EOF -openssl req -x509 -newkey rsa:2048 -sha256 -days "$CA_DAYS" -nodes \ - -config "$TMP_DIR/ca.cnf" \ - -keyout "$CERT_DIR/ca.key" \ - -out "$CERT_DIR/ca.pem" -chmod 600 "$CERT_DIR/ca.key" -chmod 644 "$CERT_DIR/ca.pem" - -# ── Серверные сертификаты процессов (SAN: localhost + compose-имя + host.docker.internal) ── - -for service in $SERVICES; do - echo "mtls-certs: серверный сертификат $service (${LEAF_DAYS} дн., SAN: localhost,$service,host.docker.internal) ..." - cat > "$TMP_DIR/server-req.cnf" < "$TMP_DIR/server-ext.cnf" < "$TMP_DIR/client-req.cnf" <<'EOF' -[req] -distinguished_name = client_dn -prompt = no - -[client_dn] -CN = deal-client -EOF -cat > "$TMP_DIR/client-ext.cnf" <<'EOF' -[client] -basicConstraints = critical,CA:FALSE -keyUsage = critical,digitalSignature -extendedKeyUsage = clientAuth -EOF -openssl req -new -newkey rsa:2048 -nodes \ - -config "$TMP_DIR/client-req.cnf" \ - -keyout "$TMP_DIR/client.key" \ - -out "$TMP_DIR/client.csr" -openssl x509 -req \ - -in "$TMP_DIR/client.csr" \ - -CA "$CERT_DIR/ca.pem" \ - -CAkey "$CERT_DIR/ca.key" \ - -CAcreateserial \ - -out "$TMP_DIR/client.crt" \ - -days "$LEAF_DAYS" -sha256 \ - -extfile "$TMP_DIR/client-ext.cnf" \ - -extensions client -openssl pkcs12 -export \ - -out "$CERT_DIR/deal-client.pfx" \ - -inkey "$TMP_DIR/client.key" \ - -in "$TMP_DIR/client.crt" \ - -name "deal-client" \ - -passout "pass:$CERT_PASSWORD" - -# PEM-копии клиентского сертификата для grpc_health_probe compose.prod при mTLS (Task 14): утилита -# принимает только PEM и без пароля. Сертификат публичный; приватный ключ — права 600, как ca.key. -openssl pkcs12 -in "$CERT_DIR/deal-client.pfx" -clcerts -nokeys -passin "pass:$CERT_PASSWORD" \ - -out "$CERT_DIR/deal-client.crt" -openssl pkcs12 -in "$CERT_DIR/deal-client.pfx" -nocerts -nodes -passin "pass:$CERT_PASSWORD" \ - -out "$CERT_DIR/deal-client.key" -chmod 644 "$CERT_DIR/deal-client.crt" -chmod 600 "$CERT_DIR/deal-client.key" - -echo -echo "mtls-certs: готово. Файлы в $CERT_DIR:" -ls -1 "$CERT_DIR" -echo -echo "Пароль PFX (DEAL_MTLS_CERT_PASSWORD): $CERT_PASSWORD" -echo "Проверка (SAN/подпись): openssl pkcs12 -in $CERT_DIR/-server.pfx -nokeys | openssl verify -CAfile $CERT_DIR/ca.pem" -echo -echo "Включение mTLS (dev остаётся plaintext — флаг не задаётся; PROD env передаёт compose-prod, Task 14):" -echo " DEAL_MTLS_ENABLED=1" -echo " DEAL_MTLS_CA_PEM=$CERT_DIR/ca.pem" -echo " DEAL_MTLS_SERVER_CERT_PFX=$CERT_DIR/-server.pfx # для каждого процесса свой" -echo " DEAL_MTLS_SERVER_CERT_PASSWORD=$CERT_PASSWORD" -echo " DEAL_MTLS_CLIENT_CERT_PFX=$CERT_DIR/deal-client.pfx" -echo " DEAL_MTLS_CLIENT_CERT_PASSWORD=$CERT_PASSWORD" +#!/usr/bin/env sh +# +# mtls-certs.sh — генерация dev-CA и сертификатов mTLS внутреннего gRPC «Дейла» (этап 7, Ruling 6, Task 13). +# +# Требуется openssl. Вывод — deploy/certs/: +# ca.pem / ca.key — dev-CA (ca.pem — runtime всех процессов, ca.key — только подпись новых +# сертификатов; хранится в deploy/certs, в репозиторий/образ не попадает, +# см. .dockerignore; права 600); +# -server.pfx — серверный сертификат процесса: core, telegram-service, ai-service, +# ml-service (SAN: localhost + имя compose-сервиса + host.docker.internal); +# deal-client.pfx — общий клиентский сертификат исходящих каналов (core → сервисы, +# telegram-service → core-ингресс); +# deal-client.crt/.key — тот же клиентский сертификат в PEM (без пароля): нужен docker +# healthcheck'ам compose.prod при mTLS — grpc_health_probe принимает +# только PEM (флаги -tls-client-cert/-tls-client-key; см. Task 14). +# +# Сертификаты dev (срок 825 дней — как Let's Encrypt; CA — 10 лет). Пароль PFX — env DEAL_MTLS_CERT_PASSWORD +# (по умолчанию deal_mtls_dev_password — dev); в PROD задайте свой и пропишите его же в +# DEAL_MTLS_SERVER_CERT_PASSWORD / DEAL_MTLS_CLIENT_CERT_PASSWORD процессов (compose-prod — Task 14). +# Повторный запуск без -f отказывается перезаписывать существующую CA (смена CA ломает доверие всех +# сертификатов — перегенерация нужна осознанная). Subject-имена задаются config-файлами openssl, а не +# -subj, — скрипт работает и в Git Bash (MSYS не «съедает» аргументы вида /CN=...). +# +# Использование: +# scripts/mtls-certs.sh [-f] [DEAL_MTLS_CERT_PASSWORD=...] +# +# После генерации процессы поднимаются с env (пути — как смонтировано в compose-prod, Task 14): +# DEAL_MTLS_ENABLED=1 +# DEAL_MTLS_CA_PEM=/etc/deal/certs/ca.pem +# DEAL_MTLS_SERVER_CERT_PFX=/etc/deal/certs/-server.pfx +# DEAL_MTLS_SERVER_CERT_PASSWORD=<тот же пароль> +# DEAL_MTLS_CLIENT_CERT_PFX=/etc/deal/certs/deal-client.pfx +# DEAL_MTLS_CLIENT_CERT_PASSWORD=<тот же пароль> +# +# Dev-стек (deploy/compose.dev.yml) остаётся plaintext + service-token — DEAL_MTLS_ENABLED не задаётся. + +set -eu + +# Корень репозитория (каталог скрипта/..). +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +CERT_DIR="$ROOT_DIR/deploy/certs" + +# Пароль PFX: env DEAL_MTLS_CERT_PASSWORD (dev-дефолт — фиксированное значение; PROD — свой). +CERT_PASSWORD="${DEAL_MTLS_CERT_PASSWORD:-deal_mtls_dev_password}" + +# Сроки сертификатов (дней): CA — 10 лет, листовые — 825 (как Let's Encrypt). +CA_DAYS=3650 +LEAF_DAYS=825 + +# Пространство имён сертификатов: SAN листовых — localhost + имя compose-сервиса + host.docker.internal +# (dev-прогон сервиса в контейнере против core на хосте, Ruling 12) + loopback-IP. +SERVICES="core telegram-service ai-service ml-service" + +FORCE=0 +for arg in "$@"; do + case "$arg" in + -f|--force) FORCE=1 ;; + *) + echo "mtls-certs: неизвестный аргумент: $arg (ожидалось -f/--force)" >&2 + exit 2 + ;; + esac +done + +if ! command -v openssl >/dev/null 2>&1; then + echo "mtls-certs: openssl не найден — установите openssl (например: apt install openssl / brew install openssl)." >&2 + exit 1 +fi + +mkdir -p "$CERT_DIR" + +if [ -f "$CERT_DIR/ca.pem" ]; then + if [ "$FORCE" -eq 0 ]; then + echo "mtls-certs: $CERT_DIR/ca.pem уже существует — повторный запуск не перезаписывает CA" >&2 + echo "mtls-certs: (смена CA ломает доверие выданных сертификатов). Перегенерировать всё: $0 -f" >&2 + exit 1 + fi + # Принудительная перегенерация: старые сертификаты удаляются целиком (их доверие утеряно вместе с CA). + rm -f "$CERT_DIR/ca.pem" "$CERT_DIR/ca.key" "$CERT_DIR/ca.srl" "$CERT_DIR"/*-server.pfx \ + "$CERT_DIR/deal-client.pfx" "$CERT_DIR/deal-client.crt" "$CERT_DIR/deal-client.key" +fi + +# Временный каталог для CSR/конфигов/ключей (всегда чистится, в т.ч. при ошибке). +TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/deal-mtls.XXXXXX") +trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM + +echo "mtls-certs: генерирую dev-CA (CN=Deal mTLS Dev CA, ${CA_DAYS} дн.) в $CERT_DIR ..." +cat > "$TMP_DIR/ca.cnf" <<'EOF' +[req] +distinguished_name = ca_dn +prompt = no +x509_extensions = ca_ext + +[ca_dn] +CN = Deal mTLS Dev CA + +[ca_ext] +basicConstraints = critical,CA:TRUE +keyUsage = critical,keyCertSign,cRLSign,digitalSignature +subjectKeyIdentifier = hash +EOF +openssl req -x509 -newkey rsa:2048 -sha256 -days "$CA_DAYS" -nodes \ + -config "$TMP_DIR/ca.cnf" \ + -keyout "$CERT_DIR/ca.key" \ + -out "$CERT_DIR/ca.pem" +chmod 600 "$CERT_DIR/ca.key" +chmod 644 "$CERT_DIR/ca.pem" + +# ── Серверные сертификаты процессов (SAN: localhost + compose-имя + host.docker.internal) ── + +for service in $SERVICES; do + echo "mtls-certs: серверный сертификат $service (${LEAF_DAYS} дн., SAN: localhost,$service,host.docker.internal) ..." + cat > "$TMP_DIR/server-req.cnf" < "$TMP_DIR/server-ext.cnf" < "$TMP_DIR/client-req.cnf" <<'EOF' +[req] +distinguished_name = client_dn +prompt = no + +[client_dn] +CN = deal-client +EOF +cat > "$TMP_DIR/client-ext.cnf" <<'EOF' +[client] +basicConstraints = critical,CA:FALSE +keyUsage = critical,digitalSignature +extendedKeyUsage = clientAuth +EOF +openssl req -new -newkey rsa:2048 -nodes \ + -config "$TMP_DIR/client-req.cnf" \ + -keyout "$TMP_DIR/client.key" \ + -out "$TMP_DIR/client.csr" +openssl x509 -req \ + -in "$TMP_DIR/client.csr" \ + -CA "$CERT_DIR/ca.pem" \ + -CAkey "$CERT_DIR/ca.key" \ + -CAcreateserial \ + -out "$TMP_DIR/client.crt" \ + -days "$LEAF_DAYS" -sha256 \ + -extfile "$TMP_DIR/client-ext.cnf" \ + -extensions client +openssl pkcs12 -export \ + -out "$CERT_DIR/deal-client.pfx" \ + -inkey "$TMP_DIR/client.key" \ + -in "$TMP_DIR/client.crt" \ + -name "deal-client" \ + -passout "pass:$CERT_PASSWORD" + +# PEM-копии клиентского сертификата для grpc_health_probe compose.prod при mTLS (Task 14): утилита +# принимает только PEM и без пароля. Сертификат публичный; приватный ключ — права 600, как ca.key. +openssl pkcs12 -in "$CERT_DIR/deal-client.pfx" -clcerts -nokeys -passin "pass:$CERT_PASSWORD" \ + -out "$CERT_DIR/deal-client.crt" +openssl pkcs12 -in "$CERT_DIR/deal-client.pfx" -nocerts -nodes -passin "pass:$CERT_PASSWORD" \ + -out "$CERT_DIR/deal-client.key" +chmod 644 "$CERT_DIR/deal-client.crt" +chmod 600 "$CERT_DIR/deal-client.key" + +echo +echo "mtls-certs: готово. Файлы в $CERT_DIR:" +ls -1 "$CERT_DIR" +echo +echo "Пароль PFX (DEAL_MTLS_CERT_PASSWORD): $CERT_PASSWORD" +echo "Проверка (SAN/подпись): openssl pkcs12 -in $CERT_DIR/-server.pfx -nokeys | openssl verify -CAfile $CERT_DIR/ca.pem" +echo +echo "Включение mTLS (dev остаётся plaintext — флаг не задаётся; PROD env передаёт compose-prod, Task 14):" +echo " DEAL_MTLS_ENABLED=1" +echo " DEAL_MTLS_CA_PEM=$CERT_DIR/ca.pem" +echo " DEAL_MTLS_SERVER_CERT_PFX=$CERT_DIR/-server.pfx # для каждого процесса свой" +echo " DEAL_MTLS_SERVER_CERT_PASSWORD=$CERT_PASSWORD" +echo " DEAL_MTLS_CLIENT_CERT_PFX=$CERT_DIR/deal-client.pfx" +echo " DEAL_MTLS_CLIENT_CERT_PASSWORD=$CERT_PASSWORD" diff --git a/scripts/restore.sh b/scripts/restore.sh index d0e1b6f..4dcd40c 100644 --- a/scripts/restore.sh +++ b/scripts/restore.sh @@ -1,183 +1,183 @@ -#!/usr/bin/env bash -# restore.sh — восстановление «Дейла» из снапшотов scripts/backup.sh (этап 7 Task 15, Ruling 8). -# -# ВАЖНО — порядок применения (БД/тома не должны быть заняты; скрипт сервисы НЕ останавливает): -# 1) остановите core и сервисы: -# dev: docker compose -f deploy/compose.dev.yml stop core telegram-service ml-service -# prod: docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml stop -# 2) bash scripts/restore.sh [all|pg|minio|data] [TS] -# TS — метка времени снапшота вида 20260908-021500 (из имени файла backup-…); -# без TS берётся самый свежий снапшот Postgres, остальные шаги — тот же TS; -# 3) поднимите сервисы обратно (docker compose … start core telegram-service ml-service). -# -# Шаги (зеркально backup.sh, те же env — см. scripts/deal-backup-lib.sh и техдок §13.9): -# pg — БД deal целиком (public + tenant_*): docker-режим — dropdb+createdb, затем pg_restore -# из $BACKUP_DIR/pg/backup-.dump (docker exec deal-postgres); при DEAL_PG_HOST — -# прямое pg_restore --clean --if-exists поверх существующей БД; -# minio — mc mirror $BACKUP_DIR/minio/backup-/ → бакет deal-files (восстановление = загрузка -# В MinIO); DEAL_MINIO_MIRROR_REMOVE=1 добавляет --remove (бакет = снимок 1:1); -# data — распаковка $BACKUP_DIR/data/backup-.tar.gz в DEAL_DATA_DIR; при DEAL_TAR_VOLUMES — -# архивы backup-..tar.gz распаковываются в соответствующие docker volume'ы -# (busybox-контейнер). -# -# ⚠ Восстановление — OVERLAY (дописывание поверх текущих данных), лишние объекты НЕ удаляются: -# файл/объект, которого нет в снапшоте, останется. Для бакета MinIO строгий снимок 1:1 — -# DEAL_MINIO_MIRROR_REMOVE=1 (mc mirror --remove); для data-каталогов/томов при необходимости -# очистите целевой каталог/том вручную ПЕРЕД распаковкой. -# -# Код возврата: 0 — все запрошенные шаги ok; 1 — сбой. Лог — консоль и -# $BACKUP_DIR/logs/restore-YYYYMM.log. Секреты в лог не попадают. -# Реальный прогон и тест восстановления — ⚠ Manual (нужен поднятый docker-стек). -set -euo pipefail - -# Общие env-дефолты и хелперы (BACKUP_DIR, resolve_pg_container, select_mc_mode, mc_cmd…) -# shellcheck source=deal-backup-lib.sh -. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/deal-backup-lib.sh" - -# --- Аргументы и валидация ------------------------------------------------------------- -say_usage() { - say "использование: restore.sh [all|pg|minio|data] [TS]" - say " TS — метка снапшота YYYYMMDD-HHMMSS (по умолчанию — самый свежий)" -} - -STEP=${1:-all} -TS_ARG=${2:-""} -case "$STEP" in - all | pg | minio | data) ;; - *) say_usage; die "неизвестный шаг '$STEP'" ;; -esac -case "$TS_ARG" in - '') ;; - [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) ;; - *) say_usage; die "TS должен быть вида YYYYMMDD-HHMMSS (сейчас: '$TS_ARG')" ;; -esac - -mkdir -p "$BACKUP_DIR/logs" - -latest_pg_ts() { # самая свежая метка среди дампов pg - ls -1 "$BACKUP_DIR"/pg/backup-*.dump 2>/dev/null \ - | sort | tail -n 1 | sed 's#.*/backup-##; s/\.dump$//' -} - -resolve_pg_ts() { # выбирает TS_ARG: из аргумента или самый свежий pg-снапшот - if [ -n "$TS_ARG" ]; then return 0; fi - TS_ARG=$(latest_pg_ts) - if [ -z "$TS_ARG" ]; then - die "в $BACKUP_DIR/pg нет дампов backup-*.dump — сначала выполните scripts/backup.sh" - fi - log "Выбран снапшот: $TS_ARG" -} - -# --- Шаги -------------------------------------------------------------------------------- -restore_pg() { # БД deal целиком (public + tenant_*) - local dump - resolve_pg_ts - dump="$BACKUP_DIR/pg/backup-$TS_ARG.dump" - [ -f "$dump" ] || die "дамп не найден: $dump" - if [ -n "$DEAL_PG_HOST" ]; then - have pg_restore || die "задан DEAL_PG_HOST, но pg_restore не найден в PATH" - log "Postgres: прямое pg_restore --clean --if-exists → $DEAL_PG_HOST:$DEAL_PG_PORT/$DEAL_PG_DB" - PGPASSWORD="$DEAL_PG_PASSWORD" pg_restore --clean --if-exists --no-owner --exit-on-error \ - -h "$DEAL_PG_HOST" -p "$DEAL_PG_PORT" -U "$DEAL_PG_USER" -d "$DEAL_PG_DB" "$dump" \ - || die "pg_restore завершился с ошибкой" - else - docker_ok || die "Docker недоступен и DEAL_PG_HOST не задан — укажите DEAL_PG_HOST… для прямого pg_restore" - resolve_pg_container \ - || die "контейнер Postgres не найден (искал deal-postgres и compose-сервис 'postgres'); задайте DEAL_PG_CONTAINER или DEAL_PG_HOST" - log "Postgres: docker cp $dump → $PG_CONTAINER, dropdb + createdb + pg_restore" - docker cp "$(host_docker_path "$dump")" "$PG_CONTAINER:/tmp/deal-restore.dump" || die "docker cp дампа не удался" - # БД пересоздаётся целиком — снимок всех схем консистентен (core/сервисы должны быть остановлены!) - docker exec "$PG_CONTAINER" dropdb -U "$DEAL_PG_USER" --if-exists "$DEAL_PG_DB" \ - || die "dropdb не удался (есть активные подключения? остановите core/сервисы)" - docker exec "$PG_CONTAINER" createdb -U "$DEAL_PG_USER" -O "$DEAL_PG_USER" "$DEAL_PG_DB" \ - || die "createdb не удался" - docker exec "$PG_CONTAINER" pg_restore -U "$DEAL_PG_USER" -d "$DEAL_PG_DB" --no-owner --exit-on-error \ - /tmp/deal-restore.dump || die "pg_restore завершился с ошибкой" - docker exec "$PG_CONTAINER" rm -f /tmp/deal-restore.dump || true - fi - log "Postgres: OK (БД $DEAL_PG_DB из $dump)" -} - -restore_minio() { # mc mirror снапшота → бакет deal-files - local snap - select_mc_mode - [ "$MC_MODE" = "skip" ] && return 0 - if [ -n "$TS_ARG" ]; then - snap="$BACKUP_DIR/minio/backup-$TS_ARG" - [ -d "$snap" ] || die "снапшот MinIO не найден: $snap" - else - snap=$(ls -1d "$BACKUP_DIR"/minio/backup-* 2>/dev/null | sort | tail -n 1) - [ -n "$snap" ] || die "в $BACKUP_DIR/minio нет снапшотов backup-*" - fi - log "MinIO: mc mirror $snap → бакет $DEAL_MINIO_BUCKET (режим mc: $MC_MODE)" - if [ "$DEAL_MINIO_MIRROR_REMOVE" = "1" ] || [ "$DEAL_MINIO_MIRROR_REMOVE" = "true" ]; then - mc_cmd mirror --overwrite --remove "$snap" "deal/$DEAL_MINIO_BUCKET" \ - || die "mc mirror (restore) завершился с ошибкой" - else - mc_cmd mirror --overwrite "$snap" "deal/$DEAL_MINIO_BUCKET" \ - || die "mc mirror (restore) завершился с ошибкой" - fi - log "MinIO: OK ($snap → $DEAL_MINIO_BUCKET)" -} - -restore_data() { # распаковка архивов данных: docker volume'ы (DEAL_TAR_VOLUMES) или DEAL_DATA_DIR - local vol arc - if [ -n "$DEAL_TAR_VOLUMES" ]; then - docker_ok || die "задан DEAL_TAR_VOLUMES, но Docker недоступен" - for vol in $DEAL_TAR_VOLUMES; do - docker volume inspect "$vol" >/dev/null 2>&1 \ - || die "docker volume '$vol' не существует (docker volume ls)" - if [ -n "$TS_ARG" ]; then - arc="$BACKUP_DIR/data/backup-$TS_ARG.$vol.tar.gz" - [ -f "$arc" ] || arc="" - else - arc=$(ls -1 "$BACKUP_DIR"/data/backup-*."$vol".tar.gz 2>/dev/null | sort | tail -n 1) - fi - if [ -z "$arc" ] || [ ! -f "$arc" ]; then - log "Data: WARNING — архив тома '$vol' не найден, пропускаю" - continue - fi - log "Data: распаковка $(basename "$arc") → docker volume $vol (busybox)" - docker run --rm \ - -v "$(host_docker_path "$BACKUP_DIR/data"):/in:ro" -v "$vol:/data" \ - busybox tar -xzf "/in/$(basename "$arc")" -C /data \ - || die "распаковка в volume '$vol' завершилась с ошибкой" - log "Data: OK ($(basename "$arc") → $vol)" - done - return 0 - fi - - [ -d "$DEAL_DATA_DIR" ] || die "DEAL_DATA_DIR='$DEAL_DATA_DIR' не существует (создайте каталог)" - if [ -n "$TS_ARG" ]; then - arc="$BACKUP_DIR/data/backup-$TS_ARG.tar.gz" - else - # только host-архивы вида backup-YYYYMMDD-HHMMSS.tar.gz (архивы томов содержат имя volume) - arc=$(ls -1 "$BACKUP_DIR"/data/backup-*.tar.gz 2>/dev/null \ - | grep -E 'backup-[0-9]{8}-[0-9]{6}\.tar\.gz$' | sort | tail -n 1) - fi - [ -n "$arc" ] && [ -f "$arc" ] || die "архив данных не найден в $BACKUP_DIR/data (backup-.tar.gz)" - log "Data: распаковка $(basename "$arc") → $DEAL_DATA_DIR" - tar -xzf "$arc" -C "$DEAL_DATA_DIR" || die "распаковка архива данных завершилась с ошибкой" - log "Data: OK ($(basename "$arc") → $DEAL_DATA_DIR)" -} - -# --- Основной поток ------------------------------------------------------------------------ -main() { - log "=== Восстановление «Дейла»: старт (шаг: $STEP, BACKUP_DIR=$BACKUP_DIR) ===" - case "$STEP" in - all) - restore_pg || return 1 - restore_minio || return 1 - restore_data || return 1 - ;; - pg) restore_pg || return 1 ;; - minio) restore_minio || return 1 ;; - data) restore_data || return 1 ;; - esac - log "=== Восстановление завершено (шаг: $STEP, TS: ${TS_ARG:-последний}) ===" -} - -LOG_FILE="$BACKUP_DIR/logs/restore-$(date +%Y%m).log" -main "$@" 2>&1 | tee -a "$LOG_FILE" -status=$? -exit "$status" +#!/usr/bin/env bash +# restore.sh — восстановление «Дейла» из снапшотов scripts/backup.sh (этап 7 Task 15, Ruling 8). +# +# ВАЖНО — порядок применения (БД/тома не должны быть заняты; скрипт сервисы НЕ останавливает): +# 1) остановите core и сервисы: +# dev: docker compose -f deploy/compose.dev.yml stop core telegram-service ml-service +# prod: docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml stop +# 2) bash scripts/restore.sh [all|pg|minio|data] [TS] +# TS — метка времени снапшота вида 20260908-021500 (из имени файла backup-…); +# без TS берётся самый свежий снапшот Postgres, остальные шаги — тот же TS; +# 3) поднимите сервисы обратно (docker compose … start core telegram-service ml-service). +# +# Шаги (зеркально backup.sh, те же env — см. scripts/deal-backup-lib.sh и техдок §13.9): +# pg — БД deal целиком (public + tenant_*): docker-режим — dropdb+createdb, затем pg_restore +# из $BACKUP_DIR/pg/backup-.dump (docker exec deal-postgres); при DEAL_PG_HOST — +# прямое pg_restore --clean --if-exists поверх существующей БД; +# minio — mc mirror $BACKUP_DIR/minio/backup-/ → бакет deal-files (восстановление = загрузка +# В MinIO); DEAL_MINIO_MIRROR_REMOVE=1 добавляет --remove (бакет = снимок 1:1); +# data — распаковка $BACKUP_DIR/data/backup-.tar.gz в DEAL_DATA_DIR; при DEAL_TAR_VOLUMES — +# архивы backup-..tar.gz распаковываются в соответствующие docker volume'ы +# (busybox-контейнер). +# +# ⚠ Восстановление — OVERLAY (дописывание поверх текущих данных), лишние объекты НЕ удаляются: +# файл/объект, которого нет в снапшоте, останется. Для бакета MinIO строгий снимок 1:1 — +# DEAL_MINIO_MIRROR_REMOVE=1 (mc mirror --remove); для data-каталогов/томов при необходимости +# очистите целевой каталог/том вручную ПЕРЕД распаковкой. +# +# Код возврата: 0 — все запрошенные шаги ok; 1 — сбой. Лог — консоль и +# $BACKUP_DIR/logs/restore-YYYYMM.log. Секреты в лог не попадают. +# Реальный прогон и тест восстановления — ⚠ Manual (нужен поднятый docker-стек). +set -euo pipefail + +# Общие env-дефолты и хелперы (BACKUP_DIR, resolve_pg_container, select_mc_mode, mc_cmd…) +# shellcheck source=deal-backup-lib.sh +. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/deal-backup-lib.sh" + +# --- Аргументы и валидация ------------------------------------------------------------- +say_usage() { + say "использование: restore.sh [all|pg|minio|data] [TS]" + say " TS — метка снапшота YYYYMMDD-HHMMSS (по умолчанию — самый свежий)" +} + +STEP=${1:-all} +TS_ARG=${2:-""} +case "$STEP" in + all | pg | minio | data) ;; + *) say_usage; die "неизвестный шаг '$STEP'" ;; +esac +case "$TS_ARG" in + '') ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) ;; + *) say_usage; die "TS должен быть вида YYYYMMDD-HHMMSS (сейчас: '$TS_ARG')" ;; +esac + +mkdir -p "$BACKUP_DIR/logs" + +latest_pg_ts() { # самая свежая метка среди дампов pg + ls -1 "$BACKUP_DIR"/pg/backup-*.dump 2>/dev/null \ + | sort | tail -n 1 | sed 's#.*/backup-##; s/\.dump$//' +} + +resolve_pg_ts() { # выбирает TS_ARG: из аргумента или самый свежий pg-снапшот + if [ -n "$TS_ARG" ]; then return 0; fi + TS_ARG=$(latest_pg_ts) + if [ -z "$TS_ARG" ]; then + die "в $BACKUP_DIR/pg нет дампов backup-*.dump — сначала выполните scripts/backup.sh" + fi + log "Выбран снапшот: $TS_ARG" +} + +# --- Шаги -------------------------------------------------------------------------------- +restore_pg() { # БД deal целиком (public + tenant_*) + local dump + resolve_pg_ts + dump="$BACKUP_DIR/pg/backup-$TS_ARG.dump" + [ -f "$dump" ] || die "дамп не найден: $dump" + if [ -n "$DEAL_PG_HOST" ]; then + have pg_restore || die "задан DEAL_PG_HOST, но pg_restore не найден в PATH" + log "Postgres: прямое pg_restore --clean --if-exists → $DEAL_PG_HOST:$DEAL_PG_PORT/$DEAL_PG_DB" + PGPASSWORD="$DEAL_PG_PASSWORD" pg_restore --clean --if-exists --no-owner --exit-on-error \ + -h "$DEAL_PG_HOST" -p "$DEAL_PG_PORT" -U "$DEAL_PG_USER" -d "$DEAL_PG_DB" "$dump" \ + || die "pg_restore завершился с ошибкой" + else + docker_ok || die "Docker недоступен и DEAL_PG_HOST не задан — укажите DEAL_PG_HOST… для прямого pg_restore" + resolve_pg_container \ + || die "контейнер Postgres не найден (искал deal-postgres и compose-сервис 'postgres'); задайте DEAL_PG_CONTAINER или DEAL_PG_HOST" + log "Postgres: docker cp $dump → $PG_CONTAINER, dropdb + createdb + pg_restore" + docker cp "$(host_docker_path "$dump")" "$PG_CONTAINER:/tmp/deal-restore.dump" || die "docker cp дампа не удался" + # БД пересоздаётся целиком — снимок всех схем консистентен (core/сервисы должны быть остановлены!) + docker exec "$PG_CONTAINER" dropdb -U "$DEAL_PG_USER" --if-exists "$DEAL_PG_DB" \ + || die "dropdb не удался (есть активные подключения? остановите core/сервисы)" + docker exec "$PG_CONTAINER" createdb -U "$DEAL_PG_USER" -O "$DEAL_PG_USER" "$DEAL_PG_DB" \ + || die "createdb не удался" + docker exec "$PG_CONTAINER" pg_restore -U "$DEAL_PG_USER" -d "$DEAL_PG_DB" --no-owner --exit-on-error \ + /tmp/deal-restore.dump || die "pg_restore завершился с ошибкой" + docker exec "$PG_CONTAINER" rm -f /tmp/deal-restore.dump || true + fi + log "Postgres: OK (БД $DEAL_PG_DB из $dump)" +} + +restore_minio() { # mc mirror снапшота → бакет deal-files + local snap + select_mc_mode + [ "$MC_MODE" = "skip" ] && return 0 + if [ -n "$TS_ARG" ]; then + snap="$BACKUP_DIR/minio/backup-$TS_ARG" + [ -d "$snap" ] || die "снапшот MinIO не найден: $snap" + else + snap=$(ls -1d "$BACKUP_DIR"/minio/backup-* 2>/dev/null | sort | tail -n 1) + [ -n "$snap" ] || die "в $BACKUP_DIR/minio нет снапшотов backup-*" + fi + log "MinIO: mc mirror $snap → бакет $DEAL_MINIO_BUCKET (режим mc: $MC_MODE)" + if [ "$DEAL_MINIO_MIRROR_REMOVE" = "1" ] || [ "$DEAL_MINIO_MIRROR_REMOVE" = "true" ]; then + mc_cmd mirror --overwrite --remove "$snap" "deal/$DEAL_MINIO_BUCKET" \ + || die "mc mirror (restore) завершился с ошибкой" + else + mc_cmd mirror --overwrite "$snap" "deal/$DEAL_MINIO_BUCKET" \ + || die "mc mirror (restore) завершился с ошибкой" + fi + log "MinIO: OK ($snap → $DEAL_MINIO_BUCKET)" +} + +restore_data() { # распаковка архивов данных: docker volume'ы (DEAL_TAR_VOLUMES) или DEAL_DATA_DIR + local vol arc + if [ -n "$DEAL_TAR_VOLUMES" ]; then + docker_ok || die "задан DEAL_TAR_VOLUMES, но Docker недоступен" + for vol in $DEAL_TAR_VOLUMES; do + docker volume inspect "$vol" >/dev/null 2>&1 \ + || die "docker volume '$vol' не существует (docker volume ls)" + if [ -n "$TS_ARG" ]; then + arc="$BACKUP_DIR/data/backup-$TS_ARG.$vol.tar.gz" + [ -f "$arc" ] || arc="" + else + arc=$(ls -1 "$BACKUP_DIR"/data/backup-*."$vol".tar.gz 2>/dev/null | sort | tail -n 1) + fi + if [ -z "$arc" ] || [ ! -f "$arc" ]; then + log "Data: WARNING — архив тома '$vol' не найден, пропускаю" + continue + fi + log "Data: распаковка $(basename "$arc") → docker volume $vol (busybox)" + docker run --rm \ + -v "$(host_docker_path "$BACKUP_DIR/data"):/in:ro" -v "$vol:/data" \ + busybox tar -xzf "/in/$(basename "$arc")" -C /data \ + || die "распаковка в volume '$vol' завершилась с ошибкой" + log "Data: OK ($(basename "$arc") → $vol)" + done + return 0 + fi + + [ -d "$DEAL_DATA_DIR" ] || die "DEAL_DATA_DIR='$DEAL_DATA_DIR' не существует (создайте каталог)" + if [ -n "$TS_ARG" ]; then + arc="$BACKUP_DIR/data/backup-$TS_ARG.tar.gz" + else + # только host-архивы вида backup-YYYYMMDD-HHMMSS.tar.gz (архивы томов содержат имя volume) + arc=$(ls -1 "$BACKUP_DIR"/data/backup-*.tar.gz 2>/dev/null \ + | grep -E 'backup-[0-9]{8}-[0-9]{6}\.tar\.gz$' | sort | tail -n 1) + fi + [ -n "$arc" ] && [ -f "$arc" ] || die "архив данных не найден в $BACKUP_DIR/data (backup-.tar.gz)" + log "Data: распаковка $(basename "$arc") → $DEAL_DATA_DIR" + tar -xzf "$arc" -C "$DEAL_DATA_DIR" || die "распаковка архива данных завершилась с ошибкой" + log "Data: OK ($(basename "$arc") → $DEAL_DATA_DIR)" +} + +# --- Основной поток ------------------------------------------------------------------------ +main() { + log "=== Восстановление «Дейла»: старт (шаг: $STEP, BACKUP_DIR=$BACKUP_DIR) ===" + case "$STEP" in + all) + restore_pg || return 1 + restore_minio || return 1 + restore_data || return 1 + ;; + pg) restore_pg || return 1 ;; + minio) restore_minio || return 1 ;; + data) restore_data || return 1 ;; + esac + log "=== Восстановление завершено (шаг: $STEP, TS: ${TS_ARG:-последний}) ===" +} + +LOG_FILE="$BACKUP_DIR/logs/restore-$(date +%Y%m).log" +main "$@" 2>&1 | tee -a "$LOG_FILE" +status=$? +exit "$status" diff --git a/scripts/test.sh b/scripts/test.sh index f18e314..476e511 100644 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -1,23 +1,23 @@ -#!/usr/bin/env sh -# test.sh — тесты всех сервисов Дейла + линтер локализации фронта. CI-шаг «test». -set -e - -REPO_DIR=$(cd "$(dirname "$0")/.." && pwd) - -for PROJECT in \ - src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj \ - src/telegram-service/Deal.Telegram.Tests/Deal.Telegram.Tests.csproj \ - src/ai-service/Deal.Ai.Tests/Deal.Ai.Tests.csproj \ - src/ml-service/Deal.Ml.Tests/Deal.Ml.Tests.csproj \ - src/storage-service/Deal.Storage.Tests/Deal.Storage.Tests.csproj; do - echo "== test: $PROJECT ==" - (cd "$REPO_DIR" && dotnet test "$PROJECT" --nologo --logger "console;verbosity=minimal") -done - -echo -echo "== frontend: lint:i18n ==" -if command -v npm >/dev/null 2>&1; then - (cd "$REPO_DIR/src/frontend" && npm run lint:i18n) -else - echo " пропуск: npm не установлен (линтер локализации не запущен)" -fi +#!/usr/bin/env sh +# test.sh — тесты всех сервисов Дейла + линтер локализации фронта. CI-шаг «test». +set -e + +REPO_DIR=$(cd "$(dirname "$0")/.." && pwd) + +for PROJECT in \ + src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj \ + src/telegram-service/Deal.Telegram.Tests/Deal.Telegram.Tests.csproj \ + src/ai-service/Deal.Ai.Tests/Deal.Ai.Tests.csproj \ + src/ml-service/Deal.Ml.Tests/Deal.Ml.Tests.csproj \ + src/storage-service/Deal.Storage.Tests/Deal.Storage.Tests.csproj; do + echo "== test: $PROJECT ==" + (cd "$REPO_DIR" && dotnet test "$PROJECT" --nologo --logger "console;verbosity=minimal") +done + +echo +echo "== frontend: lint:i18n ==" +if command -v npm >/dev/null 2>&1; then + (cd "$REPO_DIR/src/frontend" && npm run lint:i18n) +else + echo " пропуск: npm не установлен (линтер локализации не запущен)" +fi diff --git a/src/README.md b/src/README.md index 6879543..28bac02 100644 --- a/src/README.md +++ b/src/README.md @@ -1,10 +1,10 @@ -# Дейл (Deal) — исходники - -- `core/` — модульный монолит .NET (бизнес-логика, API) -- `ml-service/` — ML (.NET + ONNX), отдельный процесс -- `ai-service/` — LLM-фасад, отдельный процесс -- `telegram-service/` — ферма сессий Telegram, отдельный процесс -- `contracts/` — общие .proto (gRPC) -- `frontend/` — Vue (переехал из LeadRadar как есть) - -Подробности: `docs/architecture/2026-09-05-deal-architecture-design.md` +# Дейл (Deal) — исходники + +- `core/` — модульный монолит .NET (бизнес-логика, API) +- `ml-service/` — ML (.NET + ONNX), отдельный процесс +- `ai-service/` — LLM-фасад, отдельный процесс +- `telegram-service/` — ферма сессий Telegram, отдельный процесс +- `contracts/` — общие .proto (gRPC) +- `frontend/` — Vue (переехал из LeadRadar как есть) + +Подробности: `docs/architecture/2026-09-05-deal-architecture-design.md` diff --git a/src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs b/src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs index a6a0111..b83a33f 100644 --- a/src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs +++ b/src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs @@ -1,576 +1,576 @@ -using System.Text.Json.Nodes; -using Deal.Ai.Llm; -using Deal.Grpc.Ai; -using Grpc.Core; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Ai.Tests.Ai; - -/// -/// In-proc gRPC-тесты AiService поверх фейк-провайдера -/// -public sealed class AiRpcTests -{ - // Имя провайдера сценариев (для текста ошибки UNAVAILABLE). - private const string ProviderId = "deepseek"; - - // Usage API-ответа сценариев (проверка проброса в reply). - private static readonly ProviderUsage SampleUsage = new(11, 5, 16); - - private const string UnavailableDetail = - "ИИ (DeepSeek) не ответил корректно — повторите попытку через несколько секунд"; - - /// - /// Filter: модель пропускает сообщение — pass=true с причиной и usage API-ответа. - /// - [Fact] - public async Task Filter_ModelPasses_ReturnsPassReasonAndUsage() - { - FakeProviderClient fake = Success("""{"pass": true, "reason": "похоже на заявку"}"""); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - FilterReply reply = await client.FilterAsync( - new FilterRequest - { - Prompt = "Фильтр: {domain}", - Text = "Ищем разработчика на проект", - ProviderConfig = RequestConfig(), - }, - Options()); - - Assert.True(reply.Pass); - Assert.Equal("похоже на заявку", reply.Reason); - AssertUsage(reply.Usage, SampleUsage); - - FakeProviderCall call = Assert.Single(fake.Calls); - Assert.Equal("Фильтр: {domain}", call.SystemPrompt); - Assert.Equal("Сообщение:\nИщем разработчика на проект", call.UserText); - }); - } - - /// - /// Filter: модель отклоняет сообщение — pass=false с причиной отказа. - /// - [Fact] - public async Task Filter_ModelRejects_ReturnsPassFalse() - { - FakeProviderClient fake = Success("""{"pass": false, "reason": "реклама канала"}"""); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - FilterReply reply = await client.FilterAsync( - new FilterRequest { Prompt = "Фильтр", Text = "Подпишись на канал", ProviderConfig = RequestConfig() }, - Options()); - - Assert.False(reply.Pass); - Assert.Equal("реклама канала", reply.Reason); - }); - } - - /// - /// Filter: модель не вернула pass — по умолчанию пропуск - /// - [Fact] - public async Task Filter_MissingPassField_DefaultsToPass() - { - FakeProviderClient fake = Success("""{"note": "ничего не понял"}"""); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - FilterReply reply = await client.FilterAsync( - new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() }, - Options()); - - Assert.True(reply.Pass); - Assert.False(reply.HasReason); - }); - } - - /// - /// Filter: провайдер недоступен после ретраев - /// - [Fact] - public async Task Filter_ProviderUnavailable_ThrowsUnavailableWithDetail() - { - FakeProviderClient fake = Failing(); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - RpcException exception = await Assert.ThrowsAsync(() => - client.FilterAsync( - new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() }, - Options()).ResponseAsync); - - Assert.Equal(StatusCode.Unavailable, exception.StatusCode); - Assert.Equal(UnavailableDetail, exception.Status.Detail); - Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count); - }); - } - - /// - /// Filter: ответ модели без JSON после ретраев — тоже UNAVAILABLE - /// - [Fact] - public async Task Filter_AnswerWithoutJson_ThrowsUnavailable() - { - FakeProviderClient fake = new((_, _, _) => - Task.FromResult(new ProviderChatResult("какой-то текст без json", Usage: null))); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - RpcException exception = await Assert.ThrowsAsync(() => - client.FilterAsync( - new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() }, - Options()).ResponseAsync); - - Assert.Equal(StatusCode.Unavailable, exception.StatusCode); - }); - } - - /// - /// Classify: модель вернула JSON — ok=true, json = извлечённый ответ строкой - /// - [Fact] - public async Task Classify_ModelAnsweredJson_ReturnsOkAndJson() - { - const string modelJson = - """{"board": "dev", "title": "Нужен python-разработчик", "budget": null, "contacts": []}"""; - FakeProviderClient fake = Success(modelJson); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - ClassifyReply reply = await client.ClassifyAsync( - new ClassifyRequest - { - SystemPrompt = "Система карточки", - UserContext = "Доски:\n- dev: разработка\nНовое сообщение: ищем middle", - ProviderConfig = RequestConfig(), - }, - Options()); - - Assert.True(reply.Ok); - Assert.True(reply.HasJson); - AssertUsage(reply.Usage, SampleUsage); - - JsonObject json = JsonNode.Parse(reply.Json)!.AsObject(); - Assert.Equal("dev", (string?)json["board"]); - Assert.Equal("Нужен python-разработчик", (string?)json["title"]); - - FakeProviderCall call = Assert.Single(fake.Calls); - Assert.Equal("Система карточки", call.SystemPrompt); - Assert.Equal("Доски:\n- dev: разработка\nНовое сообщение: ищем middle", call.UserText); - }); - } - - /// - /// Classify: модель отвечала, но без разбираемого JSON после ретраев → ok=false, НЕ RPC-ошибка; usage последней попытки в ответе - /// - [Fact] - public async Task Classify_AnswerWithoutJson_ReturnsOkFalseWithUsage() - { - const string systemPrompt = "Система карточки"; - const string userContext = "Доски и сообщение для разбора"; - const string garbage = "простите, я не умею в json"; - FakeProviderClient fake = new((_, _, _) => - Task.FromResult(new ProviderChatResult(garbage, Usage: null))); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - ClassifyReply reply = await client.ClassifyAsync( - new ClassifyRequest - { - SystemPrompt = systemPrompt, - UserContext = userContext, - ProviderConfig = RequestConfig(), - }, - Options()); - - Assert.False(reply.Ok); - Assert.False(reply.HasJson); - Assert.Equal(EstimateTokens(systemPrompt.Length + userContext.Length), reply.Usage.Prompt); - Assert.Equal(EstimateTokens(garbage.Length), reply.Usage.Completion); - Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count); - }); - } - - /// - /// Classify: провайдер недоступен — UNAVAILABLE - /// - [Fact] - public async Task Classify_ProviderUnavailable_ThrowsUnavailable() - { - FakeProviderClient fake = Failing(); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - RpcException exception = await Assert.ThrowsAsync(() => - client.ClassifyAsync( - new ClassifyRequest { SystemPrompt = "S", UserContext = "U", ProviderConfig = RequestConfig() }, - Options()).ResponseAsync); - - Assert.Equal(StatusCode.Unavailable, exception.StatusCode); - Assert.Equal(UnavailableDetail, exception.Status.Detail); - }); - } - - /// - /// GenerateKeywords - /// - [Fact] - public async Task GenerateKeywords_ModelReturnedKeywords_ReturnsList() - { - FakeProviderClient fake = Success("""{"keywords": ["стройка", "ремонт квартир", "подряды"]}"""); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - GenerateKeywordsReply reply = await client.GenerateKeywordsAsync( - new GenerateKeywordsRequest - { - Description = "Поиск каналов со стройкой и ремонтом", - ProviderConfig = RequestConfig(), - }, - Options()); - - Assert.Equal(["стройка", "ремонт квартир", "подряды"], reply.Keywords); - AssertUsage(reply.Usage, SampleUsage); - - FakeProviderCall call = Assert.Single(fake.Calls); - Assert.Contains("эксперт по поиску Telegram-каналов", call.SystemPrompt, StringComparison.Ordinal); - Assert.Contains("Верни строго JSON", call.SystemPrompt, StringComparison.Ordinal); - Assert.Equal("Описание ниши/задачи:\nПоиск каналов со стройкой и ремонтом", call.UserText); - }); - } - - /// - /// GenerateKeywords - /// - [Fact] - public async Task GenerateKeywords_NonStringItems_Skipped() - { - FakeProviderClient fake = Success("""{"keywords": ["php", 42, "c#"]}"""); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - GenerateKeywordsReply reply = await client.GenerateKeywordsAsync( - new GenerateKeywordsRequest { Description = "Разработка", ProviderConfig = RequestConfig() }, - Options()); - - Assert.Equal(["php", "c#"], reply.Keywords); - }); - } - - /// - /// GenerateKeywords - /// - [Fact] - public async Task GenerateKeywords_NoKeywordsField_ReturnsEmpty() - { - FakeProviderClient fake = Success("""{"note": "не нашёл ключей"}"""); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - GenerateKeywordsReply reply = await client.GenerateKeywordsAsync( - new GenerateKeywordsRequest { Description = "Разработка", ProviderConfig = RequestConfig() }, - Options()); - - Assert.Empty(reply.Keywords); - }); - } - - /// - /// EvaluateFit: fit=1 + причина; промпт собран сервисом из описания и ключей, сообщение — как «Сообщение:\n…». - /// - [Fact] - public async Task EvaluateFit_ModelFits_ReturnsFitAndReason() - { - FakeProviderClient fake = Success("""{"fit": 1, "reason": "строительная тематика"}"""); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - EvaluateFitReply reply = await client.EvaluateFitAsync( - new EvaluateFitRequest - { - Text = "Нужна бригада отделочников на объект", - Description = "Поиск подрядчиков на отделку", - ProviderConfig = RequestConfig(), - }, - Options()); - - Assert.True(reply.Fit); - Assert.Equal("строительная тематика", reply.Reason); - AssertUsage(reply.Usage, SampleUsage); - - FakeProviderCall call = Assert.Single(fake.Calls); - Assert.Equal("Сообщение:\nНужна бригада отделочников на объект", call.UserText); - Assert.Contains("Оцени, относится ли сообщение к сфере/задаче", call.SystemPrompt, StringComparison.Ordinal); - Assert.Contains("Описание: Поиск подрядчиков на отделку.", call.SystemPrompt, StringComparison.Ordinal); - }); - } - - /// - /// EvaluateFit: ключи задачи подставляются в промпт строкой через запятую. - /// - [Fact] - public async Task EvaluateFit_KeywordsJoinedIntoPrompt() - { - FakeProviderClient fake = Success("""{"fit": 0, "reason": "не совпадает"}"""); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - await client.EvaluateFitAsync( - new EvaluateFitRequest - { - Text = "Текст", - Description = "Описание", - Keywords = { "стройка", " ремонт ", " ", "подряд" }, - ProviderConfig = RequestConfig(), - }, - Options()); - - string systemPrompt = Assert.Single(fake.Calls).SystemPrompt; - Assert.Contains("Ключи: стройка, ремонт, подряд.", systemPrompt, StringComparison.Ordinal); - }); - } - - /// - /// EvaluateFit: fit=0 без причины модели — причина по умолчанию «не подходит»; строковое «нет» трактуется как ложь. - /// - [Fact] - public async Task EvaluateFit_ModelNoFit_ReturnsDefaultReason() - { - FakeProviderClient fake = Success("""{"fit": 0}"""); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - EvaluateFitReply reply = await client.EvaluateFitAsync( - new EvaluateFitRequest { Text = "Текст", Description = "Описание", ProviderConfig = RequestConfig() }, - Options()); - - Assert.False(reply.Fit); - Assert.Equal("не подходит", reply.Reason); - }); - } - - /// - /// EvaluateFit: fit строкой «нет» — false, причина из модели. - /// - [Fact] - public async Task EvaluateFit_StringFalsyFit_ReturnsFalse() - { - FakeProviderClient fake = Success("""{"fit": "нет", "reason": "другая сфера"}"""); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - EvaluateFitReply reply = await client.EvaluateFitAsync( - new EvaluateFitRequest { Text = "Текст", Description = "Описание", ProviderConfig = RequestConfig() }, - Options()); - - Assert.False(reply.Fit); - Assert.Equal("другая сфера", reply.Reason); - }); - } - - /// - /// EvaluateFit: длинная причина модели усекается до 200 символов. - /// - [Fact] - public async Task EvaluateFit_LongReason_IsTruncatedTo200() - { - string longReason = new('а', 250); - FakeProviderClient fake = Success($$"""{"fit": 1, "reason": "{{longReason}}"}"""); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - EvaluateFitReply reply = await client.EvaluateFitAsync( - new EvaluateFitRequest { Text = "Текст", Description = "Описание", ProviderConfig = RequestConfig() }, - Options()); - - Assert.Equal(200, reply.Reason.Length); - Assert.StartsWith("ааааа", reply.Reason, StringComparison.Ordinal); - }); - } - - /// - /// Конфиг-валидация - /// - [Fact] - public async Task Classify_WithoutProviderConfig_IsInvalidArgument() - { - FakeProviderClient fake = Success("{}"); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - RpcException exception = await Assert.ThrowsAsync(() => - client.ClassifyAsync( - new ClassifyRequest { SystemPrompt = "S", UserContext = "U" }, - Options()).ResponseAsync); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - Assert.Empty(fake.Calls); - }); - } - - /// - /// Конфиг-валидация - /// - [Fact] - public async Task GenerateKeywords_EmptyModel_IsInvalidArgument() - { - FakeProviderClient fake = Success("{}"); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - RpcException exception = await Assert.ThrowsAsync(() => - client.GenerateKeywordsAsync( - new GenerateKeywordsRequest - { - Description = "Описание", - ProviderConfig = new ProviderConfig { BaseUrl = "https://api.example.com/v1" }, - }, - Options()).ResponseAsync); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - Assert.Empty(fake.Calls); - }); - } - - /// - /// Серверный лимит text - /// - [Fact] - public async Task Filter_TooLongText_IsInvalidArgument() - { - FakeProviderClient fake = Success("""{"pass": true}"""); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - RpcException exception = await Assert.ThrowsAsync(() => - client.FilterAsync( - new FilterRequest - { - Prompt = "Фильтр", - Text = new string('а', 4001), - ProviderConfig = RequestConfig(), - }, - Options()).ResponseAsync); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - Assert.Empty(fake.Calls); - }); - } - - /// - /// Серверный лимит description - /// - [Fact] - public async Task GenerateKeywords_TooLongDescription_IsInvalidArgument() - { - FakeProviderClient fake = Success("{}"); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - - RpcException exception = await Assert.ThrowsAsync(() => - client.GenerateKeywordsAsync( - new GenerateKeywordsRequest - { - Description = new string('а', 4001), - ProviderConfig = RequestConfig(), - }, - Options()).ResponseAsync); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - Assert.Empty(fake.Calls); - }); - } - - /// - /// Обязательный tenant-id в metadata - /// - [Fact] - public async Task Classify_WithoutTenantId_IsUnauthenticated() - { - FakeProviderClient fake = Success("{}"); - await AiTestHost.RunAsync(Host(fake), async channel => - { - var client = new AiService.AiServiceClient(channel); - Metadata metadata = AiTestHost.CallMetadata(AiTestHost.DefaultToken, tenantId: null); - - RpcException exception = await Assert.ThrowsAsync(() => - client.ClassifyAsync( - new ClassifyRequest { SystemPrompt = "S", UserContext = "U", ProviderConfig = RequestConfig() }, - AiTestHost.CallOptions(metadata)).ResponseAsync); - - Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); - Assert.Empty(fake.Calls); - }); - } - - // Конфиг DI сценария: фейк-провайдер + мгновенные паузы ретраев. - // fake: Фейк-провайдер вызовов. - private static Action Host(FakeProviderClient fake) - => services => - { - AiTestHost.UseFakeProvider(services, fake); - AiTestHost.DisableRetryDelays(services); - }; - - // CallOptions с service-token и tenant-id сценария. - private static CallOptions Options() - => AiTestHost.CallOptions( - AiTestHost.CallMetadata(AiTestHost.DefaultToken, AiTestHost.DefaultTenantId)); - - // Конфиг провайдера запроса (зеркало того, что ядро кладёт в тело запроса). - private static ProviderConfig RequestConfig() - => new() - { - ProviderId = ProviderId, - BaseUrl = "https://api.example.com/v1", - Model = "deepseek-v4-flash", - ApiKey = "test-key", - }; - - // Фейк, всегда отвечающий заданным JSON-текстом с usage API-ответа. - // jsonText: JSON-текст ответа модели. - private static FakeProviderClient Success(string jsonText) - => new((_, _, _) => Task.FromResult(new ProviderChatResult(jsonText, SampleUsage))); - - // Фейк, всегда падающий транспортной ошибкой (повод для ретрая). - private static FakeProviderClient Failing() - => new((_, _, _) => Task.FromException(new LlmHttpException("сеть недоступна"))); - - // Оценка токенов по символам (зеркало TokenEstimator; для проверки usage в ответе). - // charCount: Число символов. - private static uint EstimateTokens(int charCount) - => (uint)((charCount + 3) / 4); - - // Проверяет проброс usage провайдера в gRPC-ответ. - // usage: Usage ответа RPC. - // expected: Ожидаемое значение. - private static void AssertUsage(Deal.Grpc.Ai.Usage usage, ProviderUsage expected) - { - Assert.Equal((uint)expected.PromptTokens, usage.Prompt); - Assert.Equal((uint)expected.CompletionTokens, usage.Completion); - Assert.Equal((uint)expected.TotalTokens, usage.Total); - } -} +using System.Text.Json.Nodes; +using Deal.Ai.Llm; +using Deal.Grpc.Ai; +using Grpc.Core; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Ai.Tests.Ai; + +/// +/// In-proc gRPC-тесты AiService поверх фейк-провайдера +/// +public sealed class AiRpcTests +{ + // Имя провайдера сценариев (для текста ошибки UNAVAILABLE). + private const string ProviderId = "deepseek"; + + // Usage API-ответа сценариев (проверка проброса в reply). + private static readonly ProviderUsage SampleUsage = new(11, 5, 16); + + private const string UnavailableDetail = + "ИИ (DeepSeek) не ответил корректно — повторите попытку через несколько секунд"; + + /// + /// Filter: модель пропускает сообщение — pass=true с причиной и usage API-ответа. + /// + [Fact] + public async Task Filter_ModelPasses_ReturnsPassReasonAndUsage() + { + FakeProviderClient fake = Success("""{"pass": true, "reason": "похоже на заявку"}"""); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + FilterReply reply = await client.FilterAsync( + new FilterRequest + { + Prompt = "Фильтр: {domain}", + Text = "Ищем разработчика на проект", + ProviderConfig = RequestConfig(), + }, + Options()); + + Assert.True(reply.Pass); + Assert.Equal("похоже на заявку", reply.Reason); + AssertUsage(reply.Usage, SampleUsage); + + FakeProviderCall call = Assert.Single(fake.Calls); + Assert.Equal("Фильтр: {domain}", call.SystemPrompt); + Assert.Equal("Сообщение:\nИщем разработчика на проект", call.UserText); + }); + } + + /// + /// Filter: модель отклоняет сообщение — pass=false с причиной отказа. + /// + [Fact] + public async Task Filter_ModelRejects_ReturnsPassFalse() + { + FakeProviderClient fake = Success("""{"pass": false, "reason": "реклама канала"}"""); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + FilterReply reply = await client.FilterAsync( + new FilterRequest { Prompt = "Фильтр", Text = "Подпишись на канал", ProviderConfig = RequestConfig() }, + Options()); + + Assert.False(reply.Pass); + Assert.Equal("реклама канала", reply.Reason); + }); + } + + /// + /// Filter: модель не вернула pass — по умолчанию пропуск + /// + [Fact] + public async Task Filter_MissingPassField_DefaultsToPass() + { + FakeProviderClient fake = Success("""{"note": "ничего не понял"}"""); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + FilterReply reply = await client.FilterAsync( + new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() }, + Options()); + + Assert.True(reply.Pass); + Assert.False(reply.HasReason); + }); + } + + /// + /// Filter: провайдер недоступен после ретраев + /// + [Fact] + public async Task Filter_ProviderUnavailable_ThrowsUnavailableWithDetail() + { + FakeProviderClient fake = Failing(); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + RpcException exception = await Assert.ThrowsAsync(() => + client.FilterAsync( + new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() }, + Options()).ResponseAsync); + + Assert.Equal(StatusCode.Unavailable, exception.StatusCode); + Assert.Equal(UnavailableDetail, exception.Status.Detail); + Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count); + }); + } + + /// + /// Filter: ответ модели без JSON после ретраев — тоже UNAVAILABLE + /// + [Fact] + public async Task Filter_AnswerWithoutJson_ThrowsUnavailable() + { + FakeProviderClient fake = new((_, _, _) => + Task.FromResult(new ProviderChatResult("какой-то текст без json", Usage: null))); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + RpcException exception = await Assert.ThrowsAsync(() => + client.FilterAsync( + new FilterRequest { Prompt = "Фильтр", Text = "Текст", ProviderConfig = RequestConfig() }, + Options()).ResponseAsync); + + Assert.Equal(StatusCode.Unavailable, exception.StatusCode); + }); + } + + /// + /// Classify: модель вернула JSON — ok=true, json = извлечённый ответ строкой + /// + [Fact] + public async Task Classify_ModelAnsweredJson_ReturnsOkAndJson() + { + const string modelJson = + """{"board": "dev", "title": "Нужен python-разработчик", "budget": null, "contacts": []}"""; + FakeProviderClient fake = Success(modelJson); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + ClassifyReply reply = await client.ClassifyAsync( + new ClassifyRequest + { + SystemPrompt = "Система карточки", + UserContext = "Доски:\n- dev: разработка\nНовое сообщение: ищем middle", + ProviderConfig = RequestConfig(), + }, + Options()); + + Assert.True(reply.Ok); + Assert.True(reply.HasJson); + AssertUsage(reply.Usage, SampleUsage); + + JsonObject json = JsonNode.Parse(reply.Json)!.AsObject(); + Assert.Equal("dev", (string?)json["board"]); + Assert.Equal("Нужен python-разработчик", (string?)json["title"]); + + FakeProviderCall call = Assert.Single(fake.Calls); + Assert.Equal("Система карточки", call.SystemPrompt); + Assert.Equal("Доски:\n- dev: разработка\nНовое сообщение: ищем middle", call.UserText); + }); + } + + /// + /// Classify: модель отвечала, но без разбираемого JSON после ретраев → ok=false, НЕ RPC-ошибка; usage последней попытки в ответе + /// + [Fact] + public async Task Classify_AnswerWithoutJson_ReturnsOkFalseWithUsage() + { + const string systemPrompt = "Система карточки"; + const string userContext = "Доски и сообщение для разбора"; + const string garbage = "простите, я не умею в json"; + FakeProviderClient fake = new((_, _, _) => + Task.FromResult(new ProviderChatResult(garbage, Usage: null))); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + ClassifyReply reply = await client.ClassifyAsync( + new ClassifyRequest + { + SystemPrompt = systemPrompt, + UserContext = userContext, + ProviderConfig = RequestConfig(), + }, + Options()); + + Assert.False(reply.Ok); + Assert.False(reply.HasJson); + Assert.Equal(EstimateTokens(systemPrompt.Length + userContext.Length), reply.Usage.Prompt); + Assert.Equal(EstimateTokens(garbage.Length), reply.Usage.Completion); + Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count); + }); + } + + /// + /// Classify: провайдер недоступен — UNAVAILABLE + /// + [Fact] + public async Task Classify_ProviderUnavailable_ThrowsUnavailable() + { + FakeProviderClient fake = Failing(); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + RpcException exception = await Assert.ThrowsAsync(() => + client.ClassifyAsync( + new ClassifyRequest { SystemPrompt = "S", UserContext = "U", ProviderConfig = RequestConfig() }, + Options()).ResponseAsync); + + Assert.Equal(StatusCode.Unavailable, exception.StatusCode); + Assert.Equal(UnavailableDetail, exception.Status.Detail); + }); + } + + /// + /// GenerateKeywords + /// + [Fact] + public async Task GenerateKeywords_ModelReturnedKeywords_ReturnsList() + { + FakeProviderClient fake = Success("""{"keywords": ["стройка", "ремонт квартир", "подряды"]}"""); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + GenerateKeywordsReply reply = await client.GenerateKeywordsAsync( + new GenerateKeywordsRequest + { + Description = "Поиск каналов со стройкой и ремонтом", + ProviderConfig = RequestConfig(), + }, + Options()); + + Assert.Equal(["стройка", "ремонт квартир", "подряды"], reply.Keywords); + AssertUsage(reply.Usage, SampleUsage); + + FakeProviderCall call = Assert.Single(fake.Calls); + Assert.Contains("эксперт по поиску Telegram-каналов", call.SystemPrompt, StringComparison.Ordinal); + Assert.Contains("Верни строго JSON", call.SystemPrompt, StringComparison.Ordinal); + Assert.Equal("Описание ниши/задачи:\nПоиск каналов со стройкой и ремонтом", call.UserText); + }); + } + + /// + /// GenerateKeywords + /// + [Fact] + public async Task GenerateKeywords_NonStringItems_Skipped() + { + FakeProviderClient fake = Success("""{"keywords": ["php", 42, "c#"]}"""); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + GenerateKeywordsReply reply = await client.GenerateKeywordsAsync( + new GenerateKeywordsRequest { Description = "Разработка", ProviderConfig = RequestConfig() }, + Options()); + + Assert.Equal(["php", "c#"], reply.Keywords); + }); + } + + /// + /// GenerateKeywords + /// + [Fact] + public async Task GenerateKeywords_NoKeywordsField_ReturnsEmpty() + { + FakeProviderClient fake = Success("""{"note": "не нашёл ключей"}"""); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + GenerateKeywordsReply reply = await client.GenerateKeywordsAsync( + new GenerateKeywordsRequest { Description = "Разработка", ProviderConfig = RequestConfig() }, + Options()); + + Assert.Empty(reply.Keywords); + }); + } + + /// + /// EvaluateFit: fit=1 + причина; промпт собран сервисом из описания и ключей, сообщение — как «Сообщение:\n…». + /// + [Fact] + public async Task EvaluateFit_ModelFits_ReturnsFitAndReason() + { + FakeProviderClient fake = Success("""{"fit": 1, "reason": "строительная тематика"}"""); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + EvaluateFitReply reply = await client.EvaluateFitAsync( + new EvaluateFitRequest + { + Text = "Нужна бригада отделочников на объект", + Description = "Поиск подрядчиков на отделку", + ProviderConfig = RequestConfig(), + }, + Options()); + + Assert.True(reply.Fit); + Assert.Equal("строительная тематика", reply.Reason); + AssertUsage(reply.Usage, SampleUsage); + + FakeProviderCall call = Assert.Single(fake.Calls); + Assert.Equal("Сообщение:\nНужна бригада отделочников на объект", call.UserText); + Assert.Contains("Оцени, относится ли сообщение к сфере/задаче", call.SystemPrompt, StringComparison.Ordinal); + Assert.Contains("Описание: Поиск подрядчиков на отделку.", call.SystemPrompt, StringComparison.Ordinal); + }); + } + + /// + /// EvaluateFit: ключи задачи подставляются в промпт строкой через запятую. + /// + [Fact] + public async Task EvaluateFit_KeywordsJoinedIntoPrompt() + { + FakeProviderClient fake = Success("""{"fit": 0, "reason": "не совпадает"}"""); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + await client.EvaluateFitAsync( + new EvaluateFitRequest + { + Text = "Текст", + Description = "Описание", + Keywords = { "стройка", " ремонт ", " ", "подряд" }, + ProviderConfig = RequestConfig(), + }, + Options()); + + string systemPrompt = Assert.Single(fake.Calls).SystemPrompt; + Assert.Contains("Ключи: стройка, ремонт, подряд.", systemPrompt, StringComparison.Ordinal); + }); + } + + /// + /// EvaluateFit: fit=0 без причины модели — причина по умолчанию «не подходит»; строковое «нет» трактуется как ложь. + /// + [Fact] + public async Task EvaluateFit_ModelNoFit_ReturnsDefaultReason() + { + FakeProviderClient fake = Success("""{"fit": 0}"""); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + EvaluateFitReply reply = await client.EvaluateFitAsync( + new EvaluateFitRequest { Text = "Текст", Description = "Описание", ProviderConfig = RequestConfig() }, + Options()); + + Assert.False(reply.Fit); + Assert.Equal("не подходит", reply.Reason); + }); + } + + /// + /// EvaluateFit: fit строкой «нет» — false, причина из модели. + /// + [Fact] + public async Task EvaluateFit_StringFalsyFit_ReturnsFalse() + { + FakeProviderClient fake = Success("""{"fit": "нет", "reason": "другая сфера"}"""); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + EvaluateFitReply reply = await client.EvaluateFitAsync( + new EvaluateFitRequest { Text = "Текст", Description = "Описание", ProviderConfig = RequestConfig() }, + Options()); + + Assert.False(reply.Fit); + Assert.Equal("другая сфера", reply.Reason); + }); + } + + /// + /// EvaluateFit: длинная причина модели усекается до 200 символов. + /// + [Fact] + public async Task EvaluateFit_LongReason_IsTruncatedTo200() + { + string longReason = new('а', 250); + FakeProviderClient fake = Success($$"""{"fit": 1, "reason": "{{longReason}}"}"""); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + EvaluateFitReply reply = await client.EvaluateFitAsync( + new EvaluateFitRequest { Text = "Текст", Description = "Описание", ProviderConfig = RequestConfig() }, + Options()); + + Assert.Equal(200, reply.Reason.Length); + Assert.StartsWith("ааааа", reply.Reason, StringComparison.Ordinal); + }); + } + + /// + /// Конфиг-валидация + /// + [Fact] + public async Task Classify_WithoutProviderConfig_IsInvalidArgument() + { + FakeProviderClient fake = Success("{}"); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + RpcException exception = await Assert.ThrowsAsync(() => + client.ClassifyAsync( + new ClassifyRequest { SystemPrompt = "S", UserContext = "U" }, + Options()).ResponseAsync); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + Assert.Empty(fake.Calls); + }); + } + + /// + /// Конфиг-валидация + /// + [Fact] + public async Task GenerateKeywords_EmptyModel_IsInvalidArgument() + { + FakeProviderClient fake = Success("{}"); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + RpcException exception = await Assert.ThrowsAsync(() => + client.GenerateKeywordsAsync( + new GenerateKeywordsRequest + { + Description = "Описание", + ProviderConfig = new ProviderConfig { BaseUrl = "https://api.example.com/v1" }, + }, + Options()).ResponseAsync); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + Assert.Empty(fake.Calls); + }); + } + + /// + /// Серверный лимит text + /// + [Fact] + public async Task Filter_TooLongText_IsInvalidArgument() + { + FakeProviderClient fake = Success("""{"pass": true}"""); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + RpcException exception = await Assert.ThrowsAsync(() => + client.FilterAsync( + new FilterRequest + { + Prompt = "Фильтр", + Text = new string('а', 4001), + ProviderConfig = RequestConfig(), + }, + Options()).ResponseAsync); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + Assert.Empty(fake.Calls); + }); + } + + /// + /// Серверный лимит description + /// + [Fact] + public async Task GenerateKeywords_TooLongDescription_IsInvalidArgument() + { + FakeProviderClient fake = Success("{}"); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + + RpcException exception = await Assert.ThrowsAsync(() => + client.GenerateKeywordsAsync( + new GenerateKeywordsRequest + { + Description = new string('а', 4001), + ProviderConfig = RequestConfig(), + }, + Options()).ResponseAsync); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + Assert.Empty(fake.Calls); + }); + } + + /// + /// Обязательный tenant-id в metadata + /// + [Fact] + public async Task Classify_WithoutTenantId_IsUnauthenticated() + { + FakeProviderClient fake = Success("{}"); + await AiTestHost.RunAsync(Host(fake), async channel => + { + var client = new AiService.AiServiceClient(channel); + Metadata metadata = AiTestHost.CallMetadata(AiTestHost.DefaultToken, tenantId: null); + + RpcException exception = await Assert.ThrowsAsync(() => + client.ClassifyAsync( + new ClassifyRequest { SystemPrompt = "S", UserContext = "U", ProviderConfig = RequestConfig() }, + AiTestHost.CallOptions(metadata)).ResponseAsync); + + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + Assert.Empty(fake.Calls); + }); + } + + // Конфиг DI сценария: фейк-провайдер + мгновенные паузы ретраев. + // fake: Фейк-провайдер вызовов. + private static Action Host(FakeProviderClient fake) + => services => + { + AiTestHost.UseFakeProvider(services, fake); + AiTestHost.DisableRetryDelays(services); + }; + + // CallOptions с service-token и tenant-id сценария. + private static CallOptions Options() + => AiTestHost.CallOptions( + AiTestHost.CallMetadata(AiTestHost.DefaultToken, AiTestHost.DefaultTenantId)); + + // Конфиг провайдера запроса (зеркало того, что ядро кладёт в тело запроса). + private static ProviderConfig RequestConfig() + => new() + { + ProviderId = ProviderId, + BaseUrl = "https://api.example.com/v1", + Model = "deepseek-v4-flash", + ApiKey = "test-key", + }; + + // Фейк, всегда отвечающий заданным JSON-текстом с usage API-ответа. + // jsonText: JSON-текст ответа модели. + private static FakeProviderClient Success(string jsonText) + => new((_, _, _) => Task.FromResult(new ProviderChatResult(jsonText, SampleUsage))); + + // Фейк, всегда падающий транспортной ошибкой (повод для ретрая). + private static FakeProviderClient Failing() + => new((_, _, _) => Task.FromException(new LlmHttpException("сеть недоступна"))); + + // Оценка токенов по символам (зеркало TokenEstimator; для проверки usage в ответе). + // charCount: Число символов. + private static uint EstimateTokens(int charCount) + => (uint)((charCount + 3) / 4); + + // Проверяет проброс usage провайдера в gRPC-ответ. + // usage: Usage ответа RPC. + // expected: Ожидаемое значение. + private static void AssertUsage(Deal.Grpc.Ai.Usage usage, ProviderUsage expected) + { + Assert.Equal((uint)expected.PromptTokens, usage.Prompt); + Assert.Equal((uint)expected.CompletionTokens, usage.Completion); + Assert.Equal((uint)expected.TotalTokens, usage.Total); + } +} diff --git a/src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs b/src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs index a7ec9b3..eb55c9f 100644 --- a/src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs +++ b/src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs @@ -1,139 +1,139 @@ -using System.Net; -using System.Net.Sockets; -using Deal.Ai.Llm; -using Deal.Grpc.Hosting.Interceptors; -using Deal.Grpc.Hosting.Models; -using Deal.Grpc.Hosting.Options; -using Deal.Grpc.Hosting.Services; -using Grpc.Core; -using Grpc.Net.Client; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Ai.Tests.Ai; - -internal static class AiTestHost -{ - /// - /// Env-ключ ожидаемого service-token - /// - public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - - public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey; - - public const string TenantIdMetadataKey = AiServiceImpl.TenantIdMetadataKey; - - /// - /// Токен сценариев теста. - /// - public const string DefaultToken = "deal-ai-test-token"; - - /// - /// Id тенанта сценариев по умолчанию. - /// - public const string DefaultTenantId = "tenant-test"; - - /// - /// Deadline RPC-вызовов теста - /// - public const int RpcDeadlineSeconds = 15; - - /// - /// Прогоняет сценарий против хоста со стандартным токеном и фейк-подменой фасада. - /// - /// Настройка DI сценария (фейк-провайдер, мгновенные ретраи). - /// Сценарий с gRPC-каналом к хосту. - public static Task RunAsync(Action configureServices, Func scenario) - => RunAsync(DefaultToken, configureServices, scenario); - - /// - /// Прогоняет сценарий против поднятого хоста с заданным env-токеном и подменами DI. - /// - /// Значение env DEAL_SERVICE_TOKEN (null — убрать переменную). - /// Настройка DI сценария (может быть пустой). - /// Сценарий с gRPC-каналом к хосту. - public static async Task RunAsync( - string? serviceToken, - Action configureServices, - Func scenario) - { - string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); - - WebApplication? app = null; - GrpcChannel? channel = null; - try - { - int port = FreeTcpPort(); - app = AiServiceHost.Create(port, configureServices: configureServices); - await app.StartAsync(); - - channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}"); - await scenario(channel); - } - finally - { - if (channel is not null) - { - channel.Dispose(); - } - - if (app is not null) - { - await app.StopAsync(); - await app.DisposeAsync(); - } - - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); - } - } - - /// - /// Подменяет HTTP-фасад вызовов модели фейком сценария - /// - /// Фейк-провайдер сценария. - public static void UseFakeProvider(IServiceCollection services, FakeProviderClient fake) - => services.AddSingleton(fake); - - /// - /// Делает паузы ретраев мгновенными - /// - public static void DisableRetryDelays(IServiceCollection services) - => services.AddSingleton>(static (_, _) => Task.CompletedTask); - - /// - /// Строит metadata вызова - /// - /// Значение заголовка service-token. - /// Id тенанта (null — без заголовка tenant-id). - public static Metadata CallMetadata(string? serviceToken, string? tenantId = null) - { - var metadata = new Metadata(); - if (serviceToken is not null) - { - metadata.Add(ServiceTokenMetadataKey, serviceToken); - } - - if (tenantId is not null) - { - metadata.Add(TenantIdMetadataKey, tenantId); - } - - return metadata; - } - - /// - /// CallOptions RPC - /// - /// Metadata вызова. - public static CallOptions CallOptions(Metadata metadata) - => new(metadata, deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); - - // Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста). - private static int FreeTcpPort() - { - using var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - return ((IPEndPoint)listener.LocalEndpoint).Port; - } -} +using System.Net; +using System.Net.Sockets; +using Deal.Ai.Llm; +using Deal.Grpc.Hosting.Interceptors; +using Deal.Grpc.Hosting.Models; +using Deal.Grpc.Hosting.Options; +using Deal.Grpc.Hosting.Services; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Ai.Tests.Ai; + +internal static class AiTestHost +{ + /// + /// Env-ключ ожидаемого service-token + /// + public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; + + public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey; + + public const string TenantIdMetadataKey = AiServiceImpl.TenantIdMetadataKey; + + /// + /// Токен сценариев теста. + /// + public const string DefaultToken = "deal-ai-test-token"; + + /// + /// Id тенанта сценариев по умолчанию. + /// + public const string DefaultTenantId = "tenant-test"; + + /// + /// Deadline RPC-вызовов теста + /// + public const int RpcDeadlineSeconds = 15; + + /// + /// Прогоняет сценарий против хоста со стандартным токеном и фейк-подменой фасада. + /// + /// Настройка DI сценария (фейк-провайдер, мгновенные ретраи). + /// Сценарий с gRPC-каналом к хосту. + public static Task RunAsync(Action configureServices, Func scenario) + => RunAsync(DefaultToken, configureServices, scenario); + + /// + /// Прогоняет сценарий против поднятого хоста с заданным env-токеном и подменами DI. + /// + /// Значение env DEAL_SERVICE_TOKEN (null — убрать переменную). + /// Настройка DI сценария (может быть пустой). + /// Сценарий с gRPC-каналом к хосту. + public static async Task RunAsync( + string? serviceToken, + Action configureServices, + Func scenario) + { + string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); + Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); + + WebApplication? app = null; + GrpcChannel? channel = null; + try + { + int port = FreeTcpPort(); + app = AiServiceHost.Create(port, configureServices: configureServices); + await app.StartAsync(); + + channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}"); + await scenario(channel); + } + finally + { + if (channel is not null) + { + channel.Dispose(); + } + + if (app is not null) + { + await app.StopAsync(); + await app.DisposeAsync(); + } + + Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); + } + } + + /// + /// Подменяет HTTP-фасад вызовов модели фейком сценария + /// + /// Фейк-провайдер сценария. + public static void UseFakeProvider(IServiceCollection services, FakeProviderClient fake) + => services.AddSingleton(fake); + + /// + /// Делает паузы ретраев мгновенными + /// + public static void DisableRetryDelays(IServiceCollection services) + => services.AddSingleton>(static (_, _) => Task.CompletedTask); + + /// + /// Строит metadata вызова + /// + /// Значение заголовка service-token. + /// Id тенанта (null — без заголовка tenant-id). + public static Metadata CallMetadata(string? serviceToken, string? tenantId = null) + { + var metadata = new Metadata(); + if (serviceToken is not null) + { + metadata.Add(ServiceTokenMetadataKey, serviceToken); + } + + if (tenantId is not null) + { + metadata.Add(TenantIdMetadataKey, tenantId); + } + + return metadata; + } + + /// + /// CallOptions RPC + /// + /// Metadata вызова. + public static CallOptions CallOptions(Metadata metadata) + => new(metadata, deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); + + // Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста). + private static int FreeTcpPort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } +} diff --git a/src/ai-service/Deal.Ai.Tests/Ai/FakeProviderClient.cs b/src/ai-service/Deal.Ai.Tests/Ai/FakeProviderClient.cs index 4434d17..122bdf1 100644 --- a/src/ai-service/Deal.Ai.Tests/Ai/FakeProviderClient.cs +++ b/src/ai-service/Deal.Ai.Tests/Ai/FakeProviderClient.cs @@ -1,39 +1,39 @@ -using Deal.Ai.Llm; - -namespace Deal.Ai.Tests.Ai; - -// Зафиксированный вызов фейк-провайдера (для проверки собранных RPC промптов). -// SystemPrompt: Системный промпт вызова. -// UserText: Пользовательское сообщение вызова. -// Config: Конфиг провайдера вызова. -internal sealed record FakeProviderCall(string SystemPrompt, string UserText, LlmConfig Config); - -internal sealed class FakeProviderClient : IProviderClient -{ - private readonly Func> _handler; - - /// - /// Создаёт фейк с поведением сценария. - /// - /// Поведение вызова: (systemPrompt, userText, config) → результат/сбой. - public FakeProviderClient(Func> handler) - { - _handler = handler; - } - - /// - /// Все вызовы фейка в порядке поступления - /// - public List Calls { get; } = []; - - /// - public Task ChatAsync( - LlmConfig config, - string systemPrompt, - string userText, - CancellationToken cancellationToken) - { - Calls.Add(new FakeProviderCall(systemPrompt, userText, config)); - return _handler(systemPrompt, userText, config); - } -} +using Deal.Ai.Llm; + +namespace Deal.Ai.Tests.Ai; + +// Зафиксированный вызов фейк-провайдера (для проверки собранных RPC промптов). +// SystemPrompt: Системный промпт вызова. +// UserText: Пользовательское сообщение вызова. +// Config: Конфиг провайдера вызова. +internal sealed record FakeProviderCall(string SystemPrompt, string UserText, LlmConfig Config); + +internal sealed class FakeProviderClient : IProviderClient +{ + private readonly Func> _handler; + + /// + /// Создаёт фейк с поведением сценария. + /// + /// Поведение вызова: (systemPrompt, userText, config) → результат/сбой. + public FakeProviderClient(Func> handler) + { + _handler = handler; + } + + /// + /// Все вызовы фейка в порядке поступления + /// + public List Calls { get; } = []; + + /// + public Task ChatAsync( + LlmConfig config, + string systemPrompt, + string userText, + CancellationToken cancellationToken) + { + Calls.Add(new FakeProviderCall(systemPrompt, userText, config)); + return _handler(systemPrompt, userText, config); + } +} diff --git a/src/ai-service/Deal.Ai.Tests/Ai/ProviderCallerTests.cs b/src/ai-service/Deal.Ai.Tests/Ai/ProviderCallerTests.cs index 155df62..1f2a3fc 100644 --- a/src/ai-service/Deal.Ai.Tests/Ai/ProviderCallerTests.cs +++ b/src/ai-service/Deal.Ai.Tests/Ai/ProviderCallerTests.cs @@ -1,150 +1,150 @@ -using Deal.Ai.Llm; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Ai.Tests.Ai; - -/// -/// Unit-тесты оркестратора вызовов модели -/// -public sealed class ProviderCallerTests -{ - /// - /// Успех с первого вызова - /// - [Fact] - public async Task ChatJsonAsync_FirstAttemptSuccess_ReturnsJsonAndUsage() - { - var fake = new FakeProviderClient((_, _, _) => Task.FromResult( - new ProviderChatResult("""{"pass": false}""", new ProviderUsage(11, 5, 16)))); - ProviderCaller caller = CreateCaller(fake); - - LlmCallResult result = await caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None); - - Assert.False((bool)result.Json["pass"]!); - Assert.Equal("""{"pass":false}""", result.JsonText); - Assert.Equal(16, result.Usage.TotalTokens); - Assert.Single(fake.Calls); - } - - /// - /// Марdown-обёртка ответа модели снимается фасадом. - /// - [Fact] - public async Task ChatJsonAsync_MarkdownWrappedJson_Extracts() - { - var fake = new FakeProviderClient((_, _, _) => Task.FromResult( - new ProviderChatResult("```json\n{\"fit\": 1}\n```", Usage: null))); - ProviderCaller caller = CreateCaller(fake); - - LlmCallResult result = await caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None); - - Assert.Equal(1, (int)result.Json["fit"]!); - Assert.Single(fake.Calls); - } - - /// - /// Без usage API-ответа фасад оценивает токены по символам - /// - [Fact] - public async Task ChatJsonAsync_WithoutProviderUsage_EstimatesTokens() - { - var fake = new FakeProviderClient((_, _, _) => Task.FromResult( - new ProviderChatResult("{}", Usage: null))); - ProviderCaller caller = CreateCaller(fake); - - LlmCallResult result = await caller.ChatJsonAsync(Config(), "abcdefgh", "user", CancellationToken.None); - - // prompt-текст = 8 + 4 = 12 символов → 3 токена; completion = "{}" = 2 символа → 1 токен. - Assert.Equal(3, result.Usage.PromptTokens); - Assert.Equal(1, result.Usage.CompletionTokens); - Assert.Equal(4, result.Usage.TotalTokens); - } - - /// - /// Сбой на первых двух попытках и успех на третьей - /// - [Fact] - public async Task ChatJsonAsync_TwoFailuresThenSuccess_RetriesAndSucceeds() - { - int attempts = 0; - var fake = new FakeProviderClient((_, _, _) => - { - attempts++; - return attempts < 3 - ? Task.FromException(new LlmHttpException("таймаут")) - : Task.FromResult(new ProviderChatResult("""{"ok": 1}""", Usage: null)); - }); - ProviderCaller caller = CreateCaller(fake); - - LlmCallResult result = await caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None); - - Assert.Equal(1, (int)result.Json["ok"]!); - Assert.Equal(3, attempts); - Assert.Equal(3, fake.Calls.Count); - } - - /// - /// Все попытки — транспортный сбой - /// - [Fact] - public async Task ChatJsonAsync_AllTransportFailures_ThrowsProviderUnavailable() - { - var fake = new FakeProviderClient((_, _, _) => - Task.FromException(new LlmHttpException("сеть недоступна"))); - ProviderCaller caller = CreateCaller(fake); - - LlmCallException exception = await Assert.ThrowsAsync(() => - caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None)); - - Assert.Equal(LlmCallFailureKind.ProviderUnavailable, exception.Kind); - Assert.Null(exception.Usage); - Assert.Equal( - "ИИ (DeepSeek) не ответил корректно — повторите попытку через несколько секунд", - exception.Message); - Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count); - } - - /// - /// Модель отвечала, но ни одна попытка не дала разбираемый JSON - /// - [Fact] - public async Task ChatJsonAsync_AllAnswersNotJson_ThrowsAnswerNotJsonWithUsage() - { - var fake = new FakeProviderClient((_, _, _) => - Task.FromResult(new ProviderChatResult("сожалею, не понимаю", Usage: null))); - ProviderCaller caller = CreateCaller(fake); - - LlmCallException exception = await Assert.ThrowsAsync(() => - caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None)); - - Assert.Equal(LlmCallFailureKind.AnswerNotJson, exception.Kind); - Assert.NotNull(exception.Usage); - Assert.True(exception.Usage!.CompletionTokens > 0); - Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count); - } - - /// - /// Отмена (deadline RPC) прерывает вызов без «упаковки» в ошибку провайдера - /// - [Fact] - public async Task ChatJsonAsync_Cancelled_PropagatesCancellation() - { - using var cancellation = new CancellationTokenSource(); - cancellation.Cancel(); - var fake = new FakeProviderClient( - (_, _, _) => Task.FromException(new OperationCanceledException())); - ProviderCaller caller = CreateCaller(fake); - - await Assert.ThrowsAnyAsync(() => - caller.ChatJsonAsync(Config(), "system", "user", cancellation.Token)); - } - - // Создаёт оркестратор с фейком и мгновенными паузами ретраев. - // fake: Фейк-провайдер. - private static ProviderCaller CreateCaller(FakeProviderClient fake) - => new(fake, static (_, _) => Task.CompletedTask, NullLogger.Instance); - - // Типовой конфиг провайдера сценариев. - private static LlmConfig Config() - => new("deepseek", "https://api.example.com/v1", "deepseek-v4-flash", "test-key", null); -} +using Deal.Ai.Llm; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Ai.Tests.Ai; + +/// +/// Unit-тесты оркестратора вызовов модели +/// +public sealed class ProviderCallerTests +{ + /// + /// Успех с первого вызова + /// + [Fact] + public async Task ChatJsonAsync_FirstAttemptSuccess_ReturnsJsonAndUsage() + { + var fake = new FakeProviderClient((_, _, _) => Task.FromResult( + new ProviderChatResult("""{"pass": false}""", new ProviderUsage(11, 5, 16)))); + ProviderCaller caller = CreateCaller(fake); + + LlmCallResult result = await caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None); + + Assert.False((bool)result.Json["pass"]!); + Assert.Equal("""{"pass":false}""", result.JsonText); + Assert.Equal(16, result.Usage.TotalTokens); + Assert.Single(fake.Calls); + } + + /// + /// Марdown-обёртка ответа модели снимается фасадом. + /// + [Fact] + public async Task ChatJsonAsync_MarkdownWrappedJson_Extracts() + { + var fake = new FakeProviderClient((_, _, _) => Task.FromResult( + new ProviderChatResult("```json\n{\"fit\": 1}\n```", Usage: null))); + ProviderCaller caller = CreateCaller(fake); + + LlmCallResult result = await caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None); + + Assert.Equal(1, (int)result.Json["fit"]!); + Assert.Single(fake.Calls); + } + + /// + /// Без usage API-ответа фасад оценивает токены по символам + /// + [Fact] + public async Task ChatJsonAsync_WithoutProviderUsage_EstimatesTokens() + { + var fake = new FakeProviderClient((_, _, _) => Task.FromResult( + new ProviderChatResult("{}", Usage: null))); + ProviderCaller caller = CreateCaller(fake); + + LlmCallResult result = await caller.ChatJsonAsync(Config(), "abcdefgh", "user", CancellationToken.None); + + // prompt-текст = 8 + 4 = 12 символов → 3 токена; completion = "{}" = 2 символа → 1 токен. + Assert.Equal(3, result.Usage.PromptTokens); + Assert.Equal(1, result.Usage.CompletionTokens); + Assert.Equal(4, result.Usage.TotalTokens); + } + + /// + /// Сбой на первых двух попытках и успех на третьей + /// + [Fact] + public async Task ChatJsonAsync_TwoFailuresThenSuccess_RetriesAndSucceeds() + { + int attempts = 0; + var fake = new FakeProviderClient((_, _, _) => + { + attempts++; + return attempts < 3 + ? Task.FromException(new LlmHttpException("таймаут")) + : Task.FromResult(new ProviderChatResult("""{"ok": 1}""", Usage: null)); + }); + ProviderCaller caller = CreateCaller(fake); + + LlmCallResult result = await caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None); + + Assert.Equal(1, (int)result.Json["ok"]!); + Assert.Equal(3, attempts); + Assert.Equal(3, fake.Calls.Count); + } + + /// + /// Все попытки — транспортный сбой + /// + [Fact] + public async Task ChatJsonAsync_AllTransportFailures_ThrowsProviderUnavailable() + { + var fake = new FakeProviderClient((_, _, _) => + Task.FromException(new LlmHttpException("сеть недоступна"))); + ProviderCaller caller = CreateCaller(fake); + + LlmCallException exception = await Assert.ThrowsAsync(() => + caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None)); + + Assert.Equal(LlmCallFailureKind.ProviderUnavailable, exception.Kind); + Assert.Null(exception.Usage); + Assert.Equal( + "ИИ (DeepSeek) не ответил корректно — повторите попытку через несколько секунд", + exception.Message); + Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count); + } + + /// + /// Модель отвечала, но ни одна попытка не дала разбираемый JSON + /// + [Fact] + public async Task ChatJsonAsync_AllAnswersNotJson_ThrowsAnswerNotJsonWithUsage() + { + var fake = new FakeProviderClient((_, _, _) => + Task.FromResult(new ProviderChatResult("сожалею, не понимаю", Usage: null))); + ProviderCaller caller = CreateCaller(fake); + + LlmCallException exception = await Assert.ThrowsAsync(() => + caller.ChatJsonAsync(Config(), "system", "user", CancellationToken.None)); + + Assert.Equal(LlmCallFailureKind.AnswerNotJson, exception.Kind); + Assert.NotNull(exception.Usage); + Assert.True(exception.Usage!.CompletionTokens > 0); + Assert.Equal(LlmRetryPolicy.AttemptCount, fake.Calls.Count); + } + + /// + /// Отмена (deadline RPC) прерывает вызов без «упаковки» в ошибку провайдера + /// + [Fact] + public async Task ChatJsonAsync_Cancelled_PropagatesCancellation() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var fake = new FakeProviderClient( + (_, _, _) => Task.FromException(new OperationCanceledException())); + ProviderCaller caller = CreateCaller(fake); + + await Assert.ThrowsAnyAsync(() => + caller.ChatJsonAsync(Config(), "system", "user", cancellation.Token)); + } + + // Создаёт оркестратор с фейком и мгновенными паузами ретраев. + // fake: Фейк-провайдер. + private static ProviderCaller CreateCaller(FakeProviderClient fake) + => new(fake, static (_, _) => Task.CompletedTask, NullLogger.Instance); + + // Типовой конфиг провайдера сценариев. + private static LlmConfig Config() + => new("deepseek", "https://api.example.com/v1", "deepseek-v4-flash", "test-key", null); +} diff --git a/src/ai-service/Deal.Ai.Tests/Ai/TokenEstimatorTests.cs b/src/ai-service/Deal.Ai.Tests/Ai/TokenEstimatorTests.cs index 5085d8f..c602dc5 100644 --- a/src/ai-service/Deal.Ai.Tests/Ai/TokenEstimatorTests.cs +++ b/src/ai-service/Deal.Ai.Tests/Ai/TokenEstimatorTests.cs @@ -1,64 +1,64 @@ -using Deal.Ai.Llm; - -namespace Deal.Ai.Tests.Ai; - -/// -/// Unit-тесты оценки токенов -/// -public sealed class TokenEstimatorTests -{ - /// - /// Usage провайдера проходит без изменений, включая total ≠ сумме - /// - [Fact] - public void Resolve_WithProviderUsage_PassesThrough() - { - var provider = new ProviderUsage(PromptTokens: 11, CompletionTokens: 5, TotalTokens: 16); - - LlmUsage usage = TokenEstimator.Resolve(provider, "system", "text"); - - Assert.Equal(11, usage.PromptTokens); - Assert.Equal(5, usage.CompletionTokens); - Assert.Equal(16, usage.TotalTokens); - } - - /// - /// Нет usage провайдера — оценка по символам - /// - [Fact] - public void Resolve_WithoutProviderUsage_EstimatesByChars() - { - // prompt-текст = 8 символов → 2 токена; completion-текст = 13 символов → ceil(13/4) = 4. - LlmUsage usage = TokenEstimator.Resolve(null, "12345678", """{"pass": true}"""); - - Assert.Equal(2, usage.PromptTokens); - Assert.Equal(4, usage.CompletionTokens); - Assert.Equal(6, usage.TotalTokens); - } - - /// - /// Округление вверх - /// - [Fact] - public void Resolve_WithoutProviderUsage_RoundsUp() - { - LlmUsage fourChars = TokenEstimator.Resolve(null, "abcd", string.Empty); - LlmUsage fiveChars = TokenEstimator.Resolve(null, "abcde", string.Empty); - - Assert.Equal(1, fourChars.PromptTokens); - Assert.Equal(2, fiveChars.PromptTokens); - } - - /// - /// Пустые тексты дают нулевую оценку - /// - [Fact] - public void Resolve_EmptyTexts_ReturnsZeroTokens() - { - LlmUsage usage = TokenEstimator.Resolve(null, string.Empty, string.Empty); - - Assert.Equal(0, usage.PromptTokens); - Assert.Equal(0, usage.CompletionTokens); - Assert.Equal(0, usage.TotalTokens); - } -} +using Deal.Ai.Llm; + +namespace Deal.Ai.Tests.Ai; + +/// +/// Unit-тесты оценки токенов +/// +public sealed class TokenEstimatorTests +{ + /// + /// Usage провайдера проходит без изменений, включая total ≠ сумме + /// + [Fact] + public void Resolve_WithProviderUsage_PassesThrough() + { + var provider = new ProviderUsage(PromptTokens: 11, CompletionTokens: 5, TotalTokens: 16); + + LlmUsage usage = TokenEstimator.Resolve(provider, "system", "text"); + + Assert.Equal(11, usage.PromptTokens); + Assert.Equal(5, usage.CompletionTokens); + Assert.Equal(16, usage.TotalTokens); + } + + /// + /// Нет usage провайдера — оценка по символам + /// + [Fact] + public void Resolve_WithoutProviderUsage_EstimatesByChars() + { + // prompt-текст = 8 символов → 2 токена; completion-текст = 13 символов → ceil(13/4) = 4. + LlmUsage usage = TokenEstimator.Resolve(null, "12345678", """{"pass": true}"""); + + Assert.Equal(2, usage.PromptTokens); + Assert.Equal(4, usage.CompletionTokens); + Assert.Equal(6, usage.TotalTokens); + } + + /// + /// Округление вверх + /// + [Fact] + public void Resolve_WithoutProviderUsage_RoundsUp() + { + LlmUsage fourChars = TokenEstimator.Resolve(null, "abcd", string.Empty); + LlmUsage fiveChars = TokenEstimator.Resolve(null, "abcde", string.Empty); + + Assert.Equal(1, fourChars.PromptTokens); + Assert.Equal(2, fiveChars.PromptTokens); + } + + /// + /// Пустые тексты дают нулевую оценку + /// + [Fact] + public void Resolve_EmptyTexts_ReturnsZeroTokens() + { + LlmUsage usage = TokenEstimator.Resolve(null, string.Empty, string.Empty); + + Assert.Equal(0, usage.PromptTokens); + Assert.Equal(0, usage.CompletionTokens); + Assert.Equal(0, usage.TotalTokens); + } +} diff --git a/src/ai-service/Deal.Ai.Tests/AssemblyInfo.cs b/src/ai-service/Deal.Ai.Tests/AssemblyInfo.cs index 83c835f..f83789a 100644 --- a/src/ai-service/Deal.Ai.Tests/AssemblyInfo.cs +++ b/src/ai-service/Deal.Ai.Tests/AssemblyInfo.cs @@ -1,5 +1,5 @@ -// In-proc gRPC-тесты ai-service поднимают реальные Kestrel-хосты и меняют процесс-глобальную -// env-переменную DEAL_SERVICE_TOKEN на время сценария (AiTestHost и AiServiceHostTests). -// Параллельный прогон классов дал бы гонки на env — тесты сериализованы (тот же шаблон, что -// AssemblyInfo тестов ml/telegram-сервисов). -[assembly: CollectionBehavior(DisableTestParallelization = true)] +// In-proc gRPC-тесты ai-service поднимают реальные Kestrel-хосты и меняют процесс-глобальную +// env-переменную DEAL_SERVICE_TOKEN на время сценария (AiTestHost и AiServiceHostTests). +// Параллельный прогон классов дал бы гонки на env — тесты сериализованы (тот же шаблон, что +// AssemblyInfo тестов ml/telegram-сервисов). +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/src/ai-service/Deal.Ai.Tests/Deal.Ai.Tests.csproj b/src/ai-service/Deal.Ai.Tests/Deal.Ai.Tests.csproj index 1940d9f..c4ac237 100644 --- a/src/ai-service/Deal.Ai.Tests/Deal.Ai.Tests.csproj +++ b/src/ai-service/Deal.Ai.Tests/Deal.Ai.Tests.csproj @@ -1,40 +1,40 @@ - - - - - false - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ai-service/Deal.Ai.Tests/Grpc/AiServiceHostTests.cs b/src/ai-service/Deal.Ai.Tests/Grpc/AiServiceHostTests.cs index 3b87edd..e5c48b9 100644 --- a/src/ai-service/Deal.Ai.Tests/Grpc/AiServiceHostTests.cs +++ b/src/ai-service/Deal.Ai.Tests/Grpc/AiServiceHostTests.cs @@ -1,188 +1,188 @@ -using System.Net; -using System.Net.Sockets; -using Deal.Grpc.Ai; -using Grpc.Core; -using Grpc.Health.V1; -using Grpc.Net.Client; -using Microsoft.AspNetCore.Builder; - -namespace Deal.Ai.Tests.Grpc; - -/// -/// Интеграционные тесты хоста ai-service. -/// -public sealed class AiServiceHostTests -{ - // Env-ключ ожидаемого токена (зеркало ServiceTokenInterceptor). - private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - - // Ключ gRPC-metadata (зеркало ServiceTokenInterceptor.ServiceTokenMetadataKey). - private const string ServiceTokenMetadataKey = "service-token"; - - // Токен сценариев теста. - private const string ValidToken = "task4-test-token"; - - private const string TenantId = "tenant-test"; - - // Deadline RPC-вызовов теста (сек). - private const int RpcDeadlineSeconds = 10; - - /// - /// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура и health-сервис работают. - /// - [Fact] - public async Task HealthCheck_ReturnsServing() - { - await RunHostScenarioAsync( - ValidToken, - async channel => - { - var health = new Health.HealthClient(channel); - HealthCheckResponse response = await health.CheckAsync( - new HealthCheckRequest(), - deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); - - Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, response.Status); - }); - } - - /// - /// Запрос без metadata «service-token» → UNAUTHENTICATED. - /// - [Fact] - public async Task Classify_WithoutToken_IsUnauthenticated() - { - await AssertDealRpcRejectedAsync( - ValidToken, - tokenHeader: null, - expected: StatusCode.Unauthenticated); - } - - /// - /// Запрос с неверным токеном → UNAUTHENTICATED. - /// - [Fact] - public async Task Classify_WithWrongToken_IsUnauthenticated() - { - await AssertDealRpcRejectedAsync( - ValidToken, - tokenHeader: "wrong-token", - expected: StatusCode.Unauthenticated); - } - - /// - /// Верный токен проходит интерцептор к методу — кодогенерация и маппинг сервиса работают - /// - [Fact] - public async Task Classify_WithValidToken_ReachesServiceAndValidatesConfig() - { - await AssertDealRpcRejectedAsync( - ValidToken, - tokenHeader: ValidToken, - expected: StatusCode.InvalidArgument); - } - - /// - /// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, в т.ч. - /// - [Fact] - public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing() - { - await RunHostScenarioAsync( - serviceToken: null, - async channel => - { - var health = new Health.HealthClient(channel); - HealthCheckResponse healthResponse = await health.CheckAsync( - new HealthCheckRequest(), - deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); - Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, healthResponse.Status); - - // Пустое значение metadata — попытка обойти fail-closed (равно незаданному env-токену). - await AssertRejectedAsync(channel, string.Empty, StatusCode.Unauthenticated); - await AssertRejectedAsync(channel, ValidToken, StatusCode.Unauthenticated); - }); - } - - // Прогоняет Classify с заданным заголовком «service-token» (null — без заголовка) и проверяет - // ожидаемый код статуса RPC-исключения. - // serviceToken: Ожидаемый токен хоста (env DEAL_SERVICE_TOKEN). - // tokenHeader: Значение metadata «service-token» запроса либо null (нет заголовка). - // expected: Ожидаемый StatusCode ответа. - private static async Task AssertDealRpcRejectedAsync( - string? serviceToken, - string? tokenHeader, - StatusCode expected) - { - await RunHostScenarioAsync( - serviceToken, - channel => AssertRejectedAsync(channel, tokenHeader, expected)); - } - - private static async Task AssertRejectedAsync( - GrpcChannel channel, - string? tokenHeader, - StatusCode expected) - { - var client = new AiService.AiServiceClient(channel); - Metadata metadata = new() { { "tenant-id", TenantId } }; - if (tokenHeader is not null) - { - metadata.Add(ServiceTokenMetadataKey, tokenHeader); - } - - var callOptions = new CallOptions( - metadata, - deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); - - AsyncUnaryCall call = client.ClassifyAsync(new ClassifyRequest(), callOptions); - RpcException exception = await Assert.ThrowsAsync(() => call.ResponseAsync); - - Assert.Equal(expected, exception.StatusCode); - } - - // Поднимает хост на эфемерном порту с заданным env-токеном, выполняет сценарий и гарантированно - // гасит хост/канал и восстанавливает исходный env. - // serviceToken: Значение env DEAL_SERVICE_TOKEN для сценария (null — убрать). - // scenario: Сценарий с каналом к поднятому хосту. - private static async Task RunHostScenarioAsync(string? serviceToken, Func scenario) - { - string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); - - WebApplication? app = null; - GrpcChannel? channel = null; - try - { - int port = FreeTcpPort(); - app = AiServiceHost.Create(port); - await app.StartAsync(); - - channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}"); - await scenario(channel); - } - finally - { - if (channel is not null) - { - channel.Dispose(); - } - - if (app is not null) - { - await app.StopAsync(); - await app.DisposeAsync(); - } - - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); - } - } - - // Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста). - private static int FreeTcpPort() - { - using var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - return ((IPEndPoint)listener.LocalEndpoint).Port; - } -} +using System.Net; +using System.Net.Sockets; +using Deal.Grpc.Ai; +using Grpc.Core; +using Grpc.Health.V1; +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; + +namespace Deal.Ai.Tests.Grpc; + +/// +/// Интеграционные тесты хоста ai-service. +/// +public sealed class AiServiceHostTests +{ + // Env-ключ ожидаемого токена (зеркало ServiceTokenInterceptor). + private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; + + // Ключ gRPC-metadata (зеркало ServiceTokenInterceptor.ServiceTokenMetadataKey). + private const string ServiceTokenMetadataKey = "service-token"; + + // Токен сценариев теста. + private const string ValidToken = "task4-test-token"; + + private const string TenantId = "tenant-test"; + + // Deadline RPC-вызовов теста (сек). + private const int RpcDeadlineSeconds = 10; + + /// + /// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура и health-сервис работают. + /// + [Fact] + public async Task HealthCheck_ReturnsServing() + { + await RunHostScenarioAsync( + ValidToken, + async channel => + { + var health = new Health.HealthClient(channel); + HealthCheckResponse response = await health.CheckAsync( + new HealthCheckRequest(), + deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); + + Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, response.Status); + }); + } + + /// + /// Запрос без metadata «service-token» → UNAUTHENTICATED. + /// + [Fact] + public async Task Classify_WithoutToken_IsUnauthenticated() + { + await AssertDealRpcRejectedAsync( + ValidToken, + tokenHeader: null, + expected: StatusCode.Unauthenticated); + } + + /// + /// Запрос с неверным токеном → UNAUTHENTICATED. + /// + [Fact] + public async Task Classify_WithWrongToken_IsUnauthenticated() + { + await AssertDealRpcRejectedAsync( + ValidToken, + tokenHeader: "wrong-token", + expected: StatusCode.Unauthenticated); + } + + /// + /// Верный токен проходит интерцептор к методу — кодогенерация и маппинг сервиса работают + /// + [Fact] + public async Task Classify_WithValidToken_ReachesServiceAndValidatesConfig() + { + await AssertDealRpcRejectedAsync( + ValidToken, + tokenHeader: ValidToken, + expected: StatusCode.InvalidArgument); + } + + /// + /// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, в т.ч. + /// + [Fact] + public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing() + { + await RunHostScenarioAsync( + serviceToken: null, + async channel => + { + var health = new Health.HealthClient(channel); + HealthCheckResponse healthResponse = await health.CheckAsync( + new HealthCheckRequest(), + deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); + Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, healthResponse.Status); + + // Пустое значение metadata — попытка обойти fail-closed (равно незаданному env-токену). + await AssertRejectedAsync(channel, string.Empty, StatusCode.Unauthenticated); + await AssertRejectedAsync(channel, ValidToken, StatusCode.Unauthenticated); + }); + } + + // Прогоняет Classify с заданным заголовком «service-token» (null — без заголовка) и проверяет + // ожидаемый код статуса RPC-исключения. + // serviceToken: Ожидаемый токен хоста (env DEAL_SERVICE_TOKEN). + // tokenHeader: Значение metadata «service-token» запроса либо null (нет заголовка). + // expected: Ожидаемый StatusCode ответа. + private static async Task AssertDealRpcRejectedAsync( + string? serviceToken, + string? tokenHeader, + StatusCode expected) + { + await RunHostScenarioAsync( + serviceToken, + channel => AssertRejectedAsync(channel, tokenHeader, expected)); + } + + private static async Task AssertRejectedAsync( + GrpcChannel channel, + string? tokenHeader, + StatusCode expected) + { + var client = new AiService.AiServiceClient(channel); + Metadata metadata = new() { { "tenant-id", TenantId } }; + if (tokenHeader is not null) + { + metadata.Add(ServiceTokenMetadataKey, tokenHeader); + } + + var callOptions = new CallOptions( + metadata, + deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); + + AsyncUnaryCall call = client.ClassifyAsync(new ClassifyRequest(), callOptions); + RpcException exception = await Assert.ThrowsAsync(() => call.ResponseAsync); + + Assert.Equal(expected, exception.StatusCode); + } + + // Поднимает хост на эфемерном порту с заданным env-токеном, выполняет сценарий и гарантированно + // гасит хост/канал и восстанавливает исходный env. + // serviceToken: Значение env DEAL_SERVICE_TOKEN для сценария (null — убрать). + // scenario: Сценарий с каналом к поднятому хосту. + private static async Task RunHostScenarioAsync(string? serviceToken, Func scenario) + { + string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); + Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); + + WebApplication? app = null; + GrpcChannel? channel = null; + try + { + int port = FreeTcpPort(); + app = AiServiceHost.Create(port); + await app.StartAsync(); + + channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}"); + await scenario(channel); + } + finally + { + if (channel is not null) + { + channel.Dispose(); + } + + if (app is not null) + { + await app.StopAsync(); + await app.DisposeAsync(); + } + + Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); + } + } + + // Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста). + private static int FreeTcpPort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } +} diff --git a/src/ai-service/Deal.Ai.Tests/Support/JsonExtractorTests.cs b/src/ai-service/Deal.Ai.Tests/Support/JsonExtractorTests.cs index c64216a..e75f51e 100644 --- a/src/ai-service/Deal.Ai.Tests/Support/JsonExtractorTests.cs +++ b/src/ai-service/Deal.Ai.Tests/Support/JsonExtractorTests.cs @@ -1,98 +1,98 @@ -using System.Text.Json.Nodes; -using Deal.Ai.Llm; - -namespace Deal.Ai.Tests.Support; - -/// -/// Unit-тесты извлечения JSON из ответа модели -/// -public sealed class JsonExtractorTests -{ - /// - /// Чистая JSON-строка объекта разбирается как есть. - /// - [Fact] - public void TryExtractObject_PlainObject_Parses() - { - JsonObject? json = JsonExtractor.TryExtractObject("""{"pass": true, "reason": "ок"}"""); - - Assert.NotNull(json); - Assert.True((bool)json!["pass"]!); - } - - /// - /// Markdown-обёртка ```json снимается - /// - [Fact] - public void TryExtractObject_JsonFence_Unwraps() - { - JsonObject? json = JsonExtractor.TryExtractObject("```json\n{\"fit\": 1}\n```"); - - Assert.NotNull(json); - Assert.Equal(1, (int)json!["fit"]!); - } - - /// - /// Обёртка без метки языка и с прозой вокруг — тоже снимается. - /// - [Fact] - public void TryExtractObject_PlainFenceWithProse_Unwraps() - { - const string answer = "Вот результат:\n```\n{\"keywords\": [\"php\"]}\n```\nНадеюсь, поможет."; - JsonObject? json = JsonExtractor.TryExtractObject(answer); - - Assert.NotNull(json); - Assert.Single((JsonArray)json!["keywords"]!); - } - - /// - /// JSON внутри произвольного текста берётся срезом между первой «{» и последней «}». - /// - [Fact] - public void TryExtractObject_JsonInsideProse_Slices() - { - JsonObject? json = JsonExtractor.TryExtractObject("модель решила: {\"pass\": false} конец"); - - Assert.NotNull(json); - Assert.False((bool)json!["pass"]!); - } - - /// - /// Текст без JSON — null - /// - [Fact] - public void TryExtractObject_NoJson_ReturnsNull() - { - Assert.Null(JsonExtractor.TryExtractObject("извините, не могу ответить")); - } - - /// - /// Незакрытая обёртка и битый JSON — null, а не исключение. - /// - [Fact] - public void TryExtractObject_BrokenJson_ReturnsNull() - { - Assert.Null(JsonExtractor.TryExtractObject("```json\n{\"pass\": tru")); - Assert.Null(JsonExtractor.TryExtractObject("{\"pass\": ")); - } - - /// - /// JSON верхнего уровня не объект - /// - [Fact] - public void TryExtractObject_NonObjectRoot_ReturnsNull() - { - Assert.Null(JsonExtractor.TryExtractObject("[\"php\", \"c#\"]")); - Assert.Null(JsonExtractor.TryExtractObject("\"просто строка\"")); - } - - /// - /// Пустой/пробельный текст — null. - /// - [Fact] - public void TryExtractObject_Empty_ReturnsNull() - { - Assert.Null(JsonExtractor.TryExtractObject(string.Empty)); - Assert.Null(JsonExtractor.TryExtractObject(" ")); - } -} +using System.Text.Json.Nodes; +using Deal.Ai.Llm; + +namespace Deal.Ai.Tests.Support; + +/// +/// Unit-тесты извлечения JSON из ответа модели +/// +public sealed class JsonExtractorTests +{ + /// + /// Чистая JSON-строка объекта разбирается как есть. + /// + [Fact] + public void TryExtractObject_PlainObject_Parses() + { + JsonObject? json = JsonExtractor.TryExtractObject("""{"pass": true, "reason": "ок"}"""); + + Assert.NotNull(json); + Assert.True((bool)json!["pass"]!); + } + + /// + /// Markdown-обёртка ```json снимается + /// + [Fact] + public void TryExtractObject_JsonFence_Unwraps() + { + JsonObject? json = JsonExtractor.TryExtractObject("```json\n{\"fit\": 1}\n```"); + + Assert.NotNull(json); + Assert.Equal(1, (int)json!["fit"]!); + } + + /// + /// Обёртка без метки языка и с прозой вокруг — тоже снимается. + /// + [Fact] + public void TryExtractObject_PlainFenceWithProse_Unwraps() + { + const string answer = "Вот результат:\n```\n{\"keywords\": [\"php\"]}\n```\nНадеюсь, поможет."; + JsonObject? json = JsonExtractor.TryExtractObject(answer); + + Assert.NotNull(json); + Assert.Single((JsonArray)json!["keywords"]!); + } + + /// + /// JSON внутри произвольного текста берётся срезом между первой «{» и последней «}». + /// + [Fact] + public void TryExtractObject_JsonInsideProse_Slices() + { + JsonObject? json = JsonExtractor.TryExtractObject("модель решила: {\"pass\": false} конец"); + + Assert.NotNull(json); + Assert.False((bool)json!["pass"]!); + } + + /// + /// Текст без JSON — null + /// + [Fact] + public void TryExtractObject_NoJson_ReturnsNull() + { + Assert.Null(JsonExtractor.TryExtractObject("извините, не могу ответить")); + } + + /// + /// Незакрытая обёртка и битый JSON — null, а не исключение. + /// + [Fact] + public void TryExtractObject_BrokenJson_ReturnsNull() + { + Assert.Null(JsonExtractor.TryExtractObject("```json\n{\"pass\": tru")); + Assert.Null(JsonExtractor.TryExtractObject("{\"pass\": ")); + } + + /// + /// JSON верхнего уровня не объект + /// + [Fact] + public void TryExtractObject_NonObjectRoot_ReturnsNull() + { + Assert.Null(JsonExtractor.TryExtractObject("[\"php\", \"c#\"]")); + Assert.Null(JsonExtractor.TryExtractObject("\"просто строка\"")); + } + + /// + /// Пустой/пробельный текст — null. + /// + [Fact] + public void TryExtractObject_Empty_ReturnsNull() + { + Assert.Null(JsonExtractor.TryExtractObject(string.Empty)); + Assert.Null(JsonExtractor.TryExtractObject(" ")); + } +} diff --git a/src/ai-service/Deal.Ai.Tests/Support/LlmHttpClientTests.cs b/src/ai-service/Deal.Ai.Tests/Support/LlmHttpClientTests.cs index 97f5016..02669d6 100644 --- a/src/ai-service/Deal.Ai.Tests/Support/LlmHttpClientTests.cs +++ b/src/ai-service/Deal.Ai.Tests/Support/LlmHttpClientTests.cs @@ -1,218 +1,218 @@ -using System.Net; -using System.Text.Json.Nodes; -using Deal.Ai.Llm; - -namespace Deal.Ai.Tests.Support; - -/// -/// HTTP-тесты LlmHttpClient на заглушке HttpMessageHandler -/// -public sealed class LlmHttpClientTests -{ - // Таймауты тестового клиента (короткие — чтобы таймаут-сценарий не ждал 90/60 с). - private static readonly TimeSpan TestCallTimeout = TimeSpan.FromMilliseconds(60); - - // Задержка заглушки для таймаут-сценария (должна превышать TestCallTimeout). - private static readonly TimeSpan TestHandlerDelay = TimeSpan.FromMilliseconds(300); - - // База OpenAI-совместимого провайдера сценария (как у openai/ollama/lmstudio — с /v1). - private const string OpenAiBaseUrl = "https://api.example.com/v1"; - - // База Anthropic-провайдера сценария (без /v1 — путь добавляет клиент). - private const string AnthropicBaseUrl = "https://api.anthropic.com"; - - // Ответ OpenAI-совместимого API с текстом и usage. - private const string OpenAiJsonReply = - """ - { - "choices": [ - { - "message": { "role": "assistant", "content": "{\"fit\": 1}" } - } - ], - "usage": { "prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30 } - } - """; - - // Ответ Anthropic Messages API: два text-блока и usage input/output. - private const string AnthropicJsonReply = - """ - { - "content": [ - { "type": "text", "text": "{\"fit\": 1}" }, - { "type": "text", "text": " ещё текст" } - ], - "usage": { "input_tokens": 4, "output_tokens": 6 } - } - """; - - /// - /// OpenAI-совместимый вызов - /// - [Fact] - public async Task ChatAsync_OpenAiStyle_BuildsWireRequest() - { - var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply)); - LlmHttpClient client = CreateClient(handler); - - ProviderChatResult result = await client.ChatAsync( - OpenAiConfig(apiKey: "secret-key"), - "Система", - "Сообщение", - CancellationToken.None); - - CapturedHttpRequest request = handler.LastRequest; - Assert.StartsWith("POST https://api.example.com/v1/chat/completions", request.Url); - Assert.Equal("Bearer secret-key", StubHttpMessageHandler.BearerOf(request)); - - JsonObject body = StubHttpMessageHandler.BodyOf(request); - Assert.Equal("deepseek-v4-flash", (string?)body["model"]); - Assert.Equal(0.2, (double?)body["temperature"]); - Assert.Equal(8000, (int?)body["max_tokens"]); - - JsonArray messages = (JsonArray)body["messages"]!; - Assert.Equal("system", (string?)messages[0]!["role"]); - Assert.Equal("Система", (string?)messages[0]!["content"]); - Assert.Equal("user", (string?)messages[1]!["role"]); - Assert.Equal("Сообщение", (string?)messages[1]!["content"]); - - Assert.Equal("{\"fit\": 1}", result.Text); - Assert.Equal(new ProviderUsage(10, 20, 30), result.Usage); - } - - /// - /// Локальный OpenAI-совместимый провайдер без ключа - /// - [Fact] - public async Task ChatAsync_OpenAiStyleWithoutApiKey_SkipsAuthorization() - { - var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply)); - LlmHttpClient client = CreateClient(handler); - - await client.ChatAsync(OpenAiConfig(apiKey: null), "Система", "Сообщение", CancellationToken.None); - - Assert.Null(StubHttpMessageHandler.BearerOf(handler.LastRequest)); - } - - /// - /// Модель вернула только reasoning без ответа — сбой попытки. - /// - [Fact] - public async Task ChatAsync_OpenAiReasoningOnly_Throws() - { - const string reasoningOnlyReply = - """{ "choices": [ { "message": { "role": "assistant", "reasoning_content": "хм, подумаю" } } ] }"""; - var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(reasoningOnlyReply)); - LlmHttpClient client = CreateClient(handler); - - LlmHttpException exception = await Assert.ThrowsAsync(() => - client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None)); - - Assert.Contains("reasoning", exception.Message, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Anthropic-вызов - /// - [Fact] - public async Task ChatAsync_AnthropicStyle_BuildsWireRequest() - { - var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(AnthropicJsonReply)); - LlmHttpClient client = CreateClient(handler); - - ProviderChatResult result = await client.ChatAsync( - AnthropicConfig(), - "Система", - "Сообщение", - CancellationToken.None); - - CapturedHttpRequest request = handler.LastRequest; - Assert.StartsWith("POST https://api.anthropic.com/v1/messages", request.Url); - Assert.Equal("anthropic-key", request.Headers["x-api-key"]); - Assert.Equal("2023-06-01", request.Headers["anthropic-version"]); - - JsonObject body = StubHttpMessageHandler.BodyOf(request); - Assert.Equal("claude-sonnet-5", (string?)body["model"]); - Assert.Equal(8000, (int?)body["max_tokens"]); - Assert.Equal("Система", (string?)body["system"]); - Assert.DoesNotContain("temperature", body); - - JsonArray messages = (JsonArray)body["messages"]!; - Assert.Single(messages); - Assert.Equal("user", (string?)messages[0]!["role"]); - Assert.Equal("Сообщение", (string?)messages[0]!["content"]); - - Assert.Equal("{\"fit\": 1} ещё текст", result.Text); - Assert.Equal(new ProviderUsage(4, 6, 10), result.Usage); - } - - /// - /// HTTP-ошибка провайдера — сбой попытки с кодом статуса - /// - [Fact] - public async Task ChatAsync_HttpError_Throws() - { - var handler = new StubHttpMessageHandler(StubHttpMessageHandler.Status(HttpStatusCode.InternalServerError)); - LlmHttpClient client = CreateClient(handler); - - LlmHttpException exception = await Assert.ThrowsAsync(() => - client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None)); - - Assert.Contains("500", exception.Message, StringComparison.Ordinal); - } - - /// - /// Неожиданная форма ответа - /// - [Fact] - public async Task ChatAsync_UnexpectedBody_Throws() - { - var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("upstream error", System.Text.Encoding.UTF8, "text/html"), - }); - LlmHttpClient client = CreateClient(handler); - - await Assert.ThrowsAsync(() => - client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None)); - } - - /// - /// Таймаут попытки - /// - [Fact] - public async Task ChatAsync_Timeout_Throws() - { - var handler = new StubHttpMessageHandler( - StubHttpMessageHandler.JsonOk(OpenAiJsonReply), - delay: TestHandlerDelay); - LlmHttpClient client = new( - TestHttpClient(handler), - TestCallTimeout, - TestCallTimeout); - - LlmHttpException exception = await Assert.ThrowsAsync(() => - client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None)); - - Assert.Contains("Таймаут", exception.Message, StringComparison.Ordinal); - } - - // Конфиг OpenAI-совместимого вызова сценария. - // apiKey: Ключ API (null — локальный провайдер без ключа). - private static LlmConfig OpenAiConfig(string? apiKey = "test-key") - => new("deepseek", OpenAiBaseUrl, "deepseek-v4-flash", apiKey, ApiStyle: null); - - // Конфиг Anthropic-вызова сценария. - private static LlmConfig AnthropicConfig() - => new("anthropic", AnthropicBaseUrl, "claude-sonnet-5", "anthropic-key", LlmConfig.AnthropicApiStyle); - - // Клиент сценария над заглушкой (без общего таймаута — управление на попытку). - // handler: Заглушка обработчика. - private static LlmHttpClient CreateClient(StubHttpMessageHandler handler) - => new(TestHttpClient(handler)); - - // HttpClient над заглушкой без общего таймаута (таймаут попытки задаёт клиент). - // handler: Заглушка обработчика. - private static HttpClient TestHttpClient(StubHttpMessageHandler handler) - => new(handler) { Timeout = Timeout.InfiniteTimeSpan }; -} +using System.Net; +using System.Text.Json.Nodes; +using Deal.Ai.Llm; + +namespace Deal.Ai.Tests.Support; + +/// +/// HTTP-тесты LlmHttpClient на заглушке HttpMessageHandler +/// +public sealed class LlmHttpClientTests +{ + // Таймауты тестового клиента (короткие — чтобы таймаут-сценарий не ждал 90/60 с). + private static readonly TimeSpan TestCallTimeout = TimeSpan.FromMilliseconds(60); + + // Задержка заглушки для таймаут-сценария (должна превышать TestCallTimeout). + private static readonly TimeSpan TestHandlerDelay = TimeSpan.FromMilliseconds(300); + + // База OpenAI-совместимого провайдера сценария (как у openai/ollama/lmstudio — с /v1). + private const string OpenAiBaseUrl = "https://api.example.com/v1"; + + // База Anthropic-провайдера сценария (без /v1 — путь добавляет клиент). + private const string AnthropicBaseUrl = "https://api.anthropic.com"; + + // Ответ OpenAI-совместимого API с текстом и usage. + private const string OpenAiJsonReply = + """ + { + "choices": [ + { + "message": { "role": "assistant", "content": "{\"fit\": 1}" } + } + ], + "usage": { "prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30 } + } + """; + + // Ответ Anthropic Messages API: два text-блока и usage input/output. + private const string AnthropicJsonReply = + """ + { + "content": [ + { "type": "text", "text": "{\"fit\": 1}" }, + { "type": "text", "text": " ещё текст" } + ], + "usage": { "input_tokens": 4, "output_tokens": 6 } + } + """; + + /// + /// OpenAI-совместимый вызов + /// + [Fact] + public async Task ChatAsync_OpenAiStyle_BuildsWireRequest() + { + var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply)); + LlmHttpClient client = CreateClient(handler); + + ProviderChatResult result = await client.ChatAsync( + OpenAiConfig(apiKey: "secret-key"), + "Система", + "Сообщение", + CancellationToken.None); + + CapturedHttpRequest request = handler.LastRequest; + Assert.StartsWith("POST https://api.example.com/v1/chat/completions", request.Url); + Assert.Equal("Bearer secret-key", StubHttpMessageHandler.BearerOf(request)); + + JsonObject body = StubHttpMessageHandler.BodyOf(request); + Assert.Equal("deepseek-v4-flash", (string?)body["model"]); + Assert.Equal(0.2, (double?)body["temperature"]); + Assert.Equal(8000, (int?)body["max_tokens"]); + + JsonArray messages = (JsonArray)body["messages"]!; + Assert.Equal("system", (string?)messages[0]!["role"]); + Assert.Equal("Система", (string?)messages[0]!["content"]); + Assert.Equal("user", (string?)messages[1]!["role"]); + Assert.Equal("Сообщение", (string?)messages[1]!["content"]); + + Assert.Equal("{\"fit\": 1}", result.Text); + Assert.Equal(new ProviderUsage(10, 20, 30), result.Usage); + } + + /// + /// Локальный OpenAI-совместимый провайдер без ключа + /// + [Fact] + public async Task ChatAsync_OpenAiStyleWithoutApiKey_SkipsAuthorization() + { + var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply)); + LlmHttpClient client = CreateClient(handler); + + await client.ChatAsync(OpenAiConfig(apiKey: null), "Система", "Сообщение", CancellationToken.None); + + Assert.Null(StubHttpMessageHandler.BearerOf(handler.LastRequest)); + } + + /// + /// Модель вернула только reasoning без ответа — сбой попытки. + /// + [Fact] + public async Task ChatAsync_OpenAiReasoningOnly_Throws() + { + const string reasoningOnlyReply = + """{ "choices": [ { "message": { "role": "assistant", "reasoning_content": "хм, подумаю" } } ] }"""; + var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(reasoningOnlyReply)); + LlmHttpClient client = CreateClient(handler); + + LlmHttpException exception = await Assert.ThrowsAsync(() => + client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None)); + + Assert.Contains("reasoning", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Anthropic-вызов + /// + [Fact] + public async Task ChatAsync_AnthropicStyle_BuildsWireRequest() + { + var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(AnthropicJsonReply)); + LlmHttpClient client = CreateClient(handler); + + ProviderChatResult result = await client.ChatAsync( + AnthropicConfig(), + "Система", + "Сообщение", + CancellationToken.None); + + CapturedHttpRequest request = handler.LastRequest; + Assert.StartsWith("POST https://api.anthropic.com/v1/messages", request.Url); + Assert.Equal("anthropic-key", request.Headers["x-api-key"]); + Assert.Equal("2023-06-01", request.Headers["anthropic-version"]); + + JsonObject body = StubHttpMessageHandler.BodyOf(request); + Assert.Equal("claude-sonnet-5", (string?)body["model"]); + Assert.Equal(8000, (int?)body["max_tokens"]); + Assert.Equal("Система", (string?)body["system"]); + Assert.DoesNotContain("temperature", body); + + JsonArray messages = (JsonArray)body["messages"]!; + Assert.Single(messages); + Assert.Equal("user", (string?)messages[0]!["role"]); + Assert.Equal("Сообщение", (string?)messages[0]!["content"]); + + Assert.Equal("{\"fit\": 1} ещё текст", result.Text); + Assert.Equal(new ProviderUsage(4, 6, 10), result.Usage); + } + + /// + /// HTTP-ошибка провайдера — сбой попытки с кодом статуса + /// + [Fact] + public async Task ChatAsync_HttpError_Throws() + { + var handler = new StubHttpMessageHandler(StubHttpMessageHandler.Status(HttpStatusCode.InternalServerError)); + LlmHttpClient client = CreateClient(handler); + + LlmHttpException exception = await Assert.ThrowsAsync(() => + client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None)); + + Assert.Contains("500", exception.Message, StringComparison.Ordinal); + } + + /// + /// Неожиданная форма ответа + /// + [Fact] + public async Task ChatAsync_UnexpectedBody_Throws() + { + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("upstream error", System.Text.Encoding.UTF8, "text/html"), + }); + LlmHttpClient client = CreateClient(handler); + + await Assert.ThrowsAsync(() => + client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None)); + } + + /// + /// Таймаут попытки + /// + [Fact] + public async Task ChatAsync_Timeout_Throws() + { + var handler = new StubHttpMessageHandler( + StubHttpMessageHandler.JsonOk(OpenAiJsonReply), + delay: TestHandlerDelay); + LlmHttpClient client = new( + TestHttpClient(handler), + TestCallTimeout, + TestCallTimeout); + + LlmHttpException exception = await Assert.ThrowsAsync(() => + client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None)); + + Assert.Contains("Таймаут", exception.Message, StringComparison.Ordinal); + } + + // Конфиг OpenAI-совместимого вызова сценария. + // apiKey: Ключ API (null — локальный провайдер без ключа). + private static LlmConfig OpenAiConfig(string? apiKey = "test-key") + => new("deepseek", OpenAiBaseUrl, "deepseek-v4-flash", apiKey, ApiStyle: null); + + // Конфиг Anthropic-вызова сценария. + private static LlmConfig AnthropicConfig() + => new("anthropic", AnthropicBaseUrl, "claude-sonnet-5", "anthropic-key", LlmConfig.AnthropicApiStyle); + + // Клиент сценария над заглушкой (без общего таймаута — управление на попытку). + // handler: Заглушка обработчика. + private static LlmHttpClient CreateClient(StubHttpMessageHandler handler) + => new(TestHttpClient(handler)); + + // HttpClient над заглушкой без общего таймаута (таймаут попытки задаёт клиент). + // handler: Заглушка обработчика. + private static HttpClient TestHttpClient(StubHttpMessageHandler handler) + => new(handler) { Timeout = Timeout.InfiniteTimeSpan }; +} diff --git a/src/ai-service/Deal.Ai.Tests/Support/StubHttpMessageHandler.cs b/src/ai-service/Deal.Ai.Tests/Support/StubHttpMessageHandler.cs index db55682..ab73621 100644 --- a/src/ai-service/Deal.Ai.Tests/Support/StubHttpMessageHandler.cs +++ b/src/ai-service/Deal.Ai.Tests/Support/StubHttpMessageHandler.cs @@ -1,106 +1,106 @@ -using System.Net; -using System.Text; -using System.Text.Json.Nodes; - -namespace Deal.Ai.Tests.Support; - -// Снимок HTTP-запроса, записанный заглушкой обработчика (для проверок wire-контракта). -// Url: Полный URL запроса. -// Headers: Заголовки запроса (включая заголовки содержимого). -// Body: Тело запроса (JSON-строка) либо null. -internal sealed record CapturedHttpRequest( - string Url, - IReadOnlyDictionary Headers, - string? Body); - -internal sealed class StubHttpMessageHandler : HttpMessageHandler -{ - private readonly Func _responder; - private readonly TimeSpan? _delay; - - /// - /// Создаёт заглушку с ответчиком сценария. - /// - /// Ответчик: request → response. - /// Необязательная задержка ответа (тест таймаута). - public StubHttpMessageHandler(Func responder, TimeSpan? delay = null) - { - _responder = responder; - _delay = delay; - } - - /// - /// Запросы заглушки в порядке поступления. - /// - public List Requests { get; } = []; - - /// - /// Последний запрос заглушки. - /// - public CapturedHttpRequest LastRequest => Requests[^1]; - - /// - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - string? body = request.Content is null - ? null - : await request.Content.ReadAsStringAsync(cancellationToken); - - var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (KeyValuePair> header in request.Headers) - { - headers[header.Key] = string.Join(", ", header.Value); - } - - if (request.Content?.Headers is { } contentHeaders) - { - foreach (KeyValuePair> header in contentHeaders) - { - headers[header.Key] = string.Join(", ", header.Value); - } - } - - Requests.Add(new CapturedHttpRequest( - request.Method + " " + (request.RequestUri?.ToString() ?? string.Empty), - headers, - body)); - - if (_delay is { } delay) - { - await Task.Delay(delay, cancellationToken); - } - - return _responder(request); - } - - /// - /// Ответчик: JSON-тело с HTTP 200. - /// - /// Тело ответа. - public static Func JsonOk(string json) - => _ => new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(json, Encoding.UTF8, "application/json"), - }; - - /// - /// Ответчик: заданный HTTP-статус без тела. - /// - /// Код ответа. - public static Func Status(HttpStatusCode statusCode) - => _ => new HttpResponseMessage(statusCode); - - /// - /// Разбирает тело запроса как JSON-объект - /// - /// Снимок запроса. - public static JsonObject BodyOf(CapturedHttpRequest request) - => JsonNode.Parse(request.Body!)!.AsObject(); - - /// - /// Снимает заголовок авторизации Bearer - /// - /// Снимок запроса. - public static string? BearerOf(CapturedHttpRequest request) - => request.Headers.TryGetValue("Authorization", out string? value) ? value : null; -} +using System.Net; +using System.Text; +using System.Text.Json.Nodes; + +namespace Deal.Ai.Tests.Support; + +// Снимок HTTP-запроса, записанный заглушкой обработчика (для проверок wire-контракта). +// Url: Полный URL запроса. +// Headers: Заголовки запроса (включая заголовки содержимого). +// Body: Тело запроса (JSON-строка) либо null. +internal sealed record CapturedHttpRequest( + string Url, + IReadOnlyDictionary Headers, + string? Body); + +internal sealed class StubHttpMessageHandler : HttpMessageHandler +{ + private readonly Func _responder; + private readonly TimeSpan? _delay; + + /// + /// Создаёт заглушку с ответчиком сценария. + /// + /// Ответчик: request → response. + /// Необязательная задержка ответа (тест таймаута). + public StubHttpMessageHandler(Func responder, TimeSpan? delay = null) + { + _responder = responder; + _delay = delay; + } + + /// + /// Запросы заглушки в порядке поступления. + /// + public List Requests { get; } = []; + + /// + /// Последний запрос заглушки. + /// + public CapturedHttpRequest LastRequest => Requests[^1]; + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + string? body = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair> header in request.Headers) + { + headers[header.Key] = string.Join(", ", header.Value); + } + + if (request.Content?.Headers is { } contentHeaders) + { + foreach (KeyValuePair> header in contentHeaders) + { + headers[header.Key] = string.Join(", ", header.Value); + } + } + + Requests.Add(new CapturedHttpRequest( + request.Method + " " + (request.RequestUri?.ToString() ?? string.Empty), + headers, + body)); + + if (_delay is { } delay) + { + await Task.Delay(delay, cancellationToken); + } + + return _responder(request); + } + + /// + /// Ответчик: JSON-тело с HTTP 200. + /// + /// Тело ответа. + public static Func JsonOk(string json) + => _ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + /// + /// Ответчик: заданный HTTP-статус без тела. + /// + /// Код ответа. + public static Func Status(HttpStatusCode statusCode) + => _ => new HttpResponseMessage(statusCode); + + /// + /// Разбирает тело запроса как JSON-объект + /// + /// Снимок запроса. + public static JsonObject BodyOf(CapturedHttpRequest request) + => JsonNode.Parse(request.Body!)!.AsObject(); + + /// + /// Снимает заголовок авторизации Bearer + /// + /// Снимок запроса. + public static string? BearerOf(CapturedHttpRequest request) + => request.Headers.TryGetValue("Authorization", out string? value) ? value : null; +} diff --git a/src/ai-service/Deal.Ai.sln b/src/ai-service/Deal.Ai.sln index dde61c2..b4c63fb 100644 --- a/src/ai-service/Deal.Ai.sln +++ b/src/ai-service/Deal.Ai.sln @@ -1,76 +1,76 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Ai", "Deal.Ai\Deal.Ai.csproj", "{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Proto", "..\contracts\Deal.Proto.csproj", "{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Ai.Tests", "Deal.Ai.Tests\Deal.Ai.Tests.csproj", "{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Grpc.Hosting", "..\grpc-hosting\Deal.Grpc.Hosting\Deal.Grpc.Hosting.csproj", "{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|x64.ActiveCfg = Debug|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|x64.Build.0 = Debug|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|x86.ActiveCfg = Debug|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|x86.Build.0 = Debug|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|Any CPU.Build.0 = Release|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|x64.ActiveCfg = Release|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|x64.Build.0 = Release|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|x86.ActiveCfg = Release|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|x86.Build.0 = Release|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|x64.ActiveCfg = Debug|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|x64.Build.0 = Debug|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|x86.ActiveCfg = Debug|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|x86.Build.0 = Debug|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|Any CPU.Build.0 = Release|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|x64.ActiveCfg = Release|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|x64.Build.0 = Release|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|x86.ActiveCfg = Release|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|x86.Build.0 = Release|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|x64.ActiveCfg = Debug|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|x64.Build.0 = Debug|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|x86.ActiveCfg = Debug|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|x86.Build.0 = Debug|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|Any CPU.Build.0 = Release|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|x64.ActiveCfg = Release|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|x64.Build.0 = Release|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|x86.ActiveCfg = Release|Any CPU - {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|x86.Build.0 = Release|Any CPU - {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|x64.ActiveCfg = Debug|Any CPU - {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|x64.Build.0 = Debug|Any CPU - {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|x86.ActiveCfg = Debug|Any CPU - {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|x86.Build.0 = Debug|Any CPU - {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|Any CPU.Build.0 = Release|Any CPU - {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|x64.ActiveCfg = Release|Any CPU - {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|x64.Build.0 = Release|Any CPU - {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|x86.ActiveCfg = Release|Any CPU - {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Ai", "Deal.Ai\Deal.Ai.csproj", "{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Proto", "..\contracts\Deal.Proto.csproj", "{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Ai.Tests", "Deal.Ai.Tests\Deal.Ai.Tests.csproj", "{5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Grpc.Hosting", "..\grpc-hosting\Deal.Grpc.Hosting\Deal.Grpc.Hosting.csproj", "{9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|x64.ActiveCfg = Debug|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|x64.Build.0 = Debug|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|x86.ActiveCfg = Debug|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Debug|x86.Build.0 = Debug|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|Any CPU.Build.0 = Release|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|x64.ActiveCfg = Release|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|x64.Build.0 = Release|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|x86.ActiveCfg = Release|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C01}.Release|x86.Build.0 = Release|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|x64.ActiveCfg = Debug|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|x64.Build.0 = Debug|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|x86.ActiveCfg = Debug|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Debug|x86.Build.0 = Debug|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|Any CPU.Build.0 = Release|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|x64.ActiveCfg = Release|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|x64.Build.0 = Release|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|x86.ActiveCfg = Release|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C02}.Release|x86.Build.0 = Release|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|x64.ActiveCfg = Debug|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|x64.Build.0 = Debug|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|x86.ActiveCfg = Debug|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Debug|x86.Build.0 = Debug|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|Any CPU.Build.0 = Release|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|x64.ActiveCfg = Release|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|x64.Build.0 = Release|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|x86.ActiveCfg = Release|Any CPU + {5D42B2A8-6E3F-4A71-9B2C-3D8E1F4A6C03}.Release|x86.Build.0 = Release|Any CPU + {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|x64.ActiveCfg = Debug|Any CPU + {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|x64.Build.0 = Debug|Any CPU + {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|x86.ActiveCfg = Debug|Any CPU + {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Debug|x86.Build.0 = Debug|Any CPU + {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|Any CPU.Build.0 = Release|Any CPU + {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|x64.ActiveCfg = Release|Any CPU + {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|x64.Build.0 = Release|Any CPU + {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|x86.ActiveCfg = Release|Any CPU + {9A1CB276-18E5-4A6A-B7EC-BC345AFE547C}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/src/ai-service/Deal.Ai/AiServiceHost.cs b/src/ai-service/Deal.Ai/AiServiceHost.cs index 2ae225d..237bfb3 100644 --- a/src/ai-service/Deal.Ai/AiServiceHost.cs +++ b/src/ai-service/Deal.Ai/AiServiceHost.cs @@ -1,57 +1,57 @@ -using Deal.Grpc.Hosting.Interceptors; -using Deal.Grpc.Hosting.Models; -using Deal.Grpc.Hosting.Options; -using Deal.Grpc.Hosting.Services; - -namespace Deal.Ai; - -/// -/// Собирает WebApplication gRPC-хоста ai-service. -/// -public static class AiServiceHost -{ - /// - /// Создаёт (не запускает) хост - /// - /// TCP-порт Kestrel. - /// Аргументы командной строки (Program.cs); в тестах не нужны. - /// Опциональный хук DI для тестов (подмена LLM-фасада фейками: регистрация, добавленная здесь, побеждает — DI резолвит последнюю; см. AiServiceHostTests/AiRpcTests). - /// Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog. Тесты хост поднимают БЕЗ этого хука — логирование файлов/консоли тестам не нужно. - /// Собранный хост; запуск — StartAsync/RunAsync у вызывающего. - public static WebApplication Create( - int grpcPort, - string[]? args = null, - Action? configureServices = null, - Action? configureBuilder = null) - { - WebApplicationBuilder builder = WebApplication.CreateBuilder(args ?? []); - - MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder); - GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates); - builder.Services.AddDealGrpcServer(); - builder.Services.AddReadyHealthCheck("хост ai-service готов"); - - builder.Services.AddHttpClient(static httpClient => - httpClient.Timeout = Timeout.InfiniteTimeSpan); - builder.Services.AddSingleton( - static sp => sp.GetRequiredService()); - builder.Services.AddSingleton>(DefaultRetryDelayAsync); - builder.Services.AddSingleton(); - - configureServices?.Invoke(builder.Services); - configureBuilder?.Invoke(builder); - - WebApplication app = builder.Build(); - - app.MapGrpcService(); - app.MapGrpcHealthChecksService(); - - return app; - } - - // Пауза между ретраями по умолчанию (Task.Delay; тесты подменяют через DI). - // delay: Длительность паузы. - // cancellationToken: Токен отмены. - private static Task DefaultRetryDelayAsync(TimeSpan delay, CancellationToken cancellationToken) - => Task.Delay(delay, cancellationToken); -} +using Deal.Grpc.Hosting.Interceptors; +using Deal.Grpc.Hosting.Models; +using Deal.Grpc.Hosting.Options; +using Deal.Grpc.Hosting.Services; + +namespace Deal.Ai; + +/// +/// Собирает WebApplication gRPC-хоста ai-service. +/// +public static class AiServiceHost +{ + /// + /// Создаёт (не запускает) хост + /// + /// TCP-порт Kestrel. + /// Аргументы командной строки (Program.cs); в тестах не нужны. + /// Опциональный хук DI для тестов (подмена LLM-фасада фейками: регистрация, добавленная здесь, побеждает — DI резолвит последнюю; см. AiServiceHostTests/AiRpcTests). + /// Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog. Тесты хост поднимают БЕЗ этого хука — логирование файлов/консоли тестам не нужно. + /// Собранный хост; запуск — StartAsync/RunAsync у вызывающего. + public static WebApplication Create( + int grpcPort, + string[]? args = null, + Action? configureServices = null, + Action? configureBuilder = null) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(args ?? []); + + MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder); + GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates); + builder.Services.AddDealGrpcServer(); + builder.Services.AddReadyHealthCheck("хост ai-service готов"); + + builder.Services.AddHttpClient(static httpClient => + httpClient.Timeout = Timeout.InfiniteTimeSpan); + builder.Services.AddSingleton( + static sp => sp.GetRequiredService()); + builder.Services.AddSingleton>(DefaultRetryDelayAsync); + builder.Services.AddSingleton(); + + configureServices?.Invoke(builder.Services); + configureBuilder?.Invoke(builder); + + WebApplication app = builder.Build(); + + app.MapGrpcService(); + app.MapGrpcHealthChecksService(); + + return app; + } + + // Пауза между ретраями по умолчанию (Task.Delay; тесты подменяют через DI). + // delay: Длительность паузы. + // cancellationToken: Токен отмены. + private static Task DefaultRetryDelayAsync(TimeSpan delay, CancellationToken cancellationToken) + => Task.Delay(delay, cancellationToken); +} diff --git a/src/ai-service/Deal.Ai/AiServiceImpl.cs b/src/ai-service/Deal.Ai/AiServiceImpl.cs index 07568db..439bff2 100644 --- a/src/ai-service/Deal.Ai/AiServiceImpl.cs +++ b/src/ai-service/Deal.Ai/AiServiceImpl.cs @@ -1,448 +1,448 @@ -using System.Globalization; -using System.Text.Json.Nodes; -using Deal.Ai.Llm; -using Deal.Grpc.Ai; -using Grpc.Core; - -namespace Deal.Ai; - -/// -/// Реализация серверной стороны Deal.Grpc.Ai.AiService — команды ядра в ai-service. -/// -public sealed class AiServiceImpl : AiService.AiServiceBase -{ - public const string TenantIdMetadataKey = "tenant-id"; - - // Деталь отказа: tenant-id отсутствует в metadata (UNAUTHENTICATED, шаблон MlServiceImpl). - private const string TenantIdMissingDetail = "tenant-id отсутствует в metadata"; - - // Деталь отказа: не задан конфиг ИИ-провайдера (INVALID_ARGUMENT). - private const string ProviderConfigMissingDetail = "Не задан конфиг ИИ-провайдера (provider_config)"; - - // Деталь отказа: пустой base_url конфига (INVALID_ARGUMENT). - private const string ProviderBaseUrlEmptyDetail = "Конфиг ИИ-провайдера: пустой base_url"; - - // Деталь отказа: пустая model конфига (INVALID_ARGUMENT). - private const string ProviderModelEmptyDetail = "Конфиг ИИ-провайдера: пустая model"; - - // Деталь отказа: текст сообщения длиннее контрактного лимита (INVALID_ARGUMENT). - private const string MessageTextTooLongDetail = "Слишком длинный текст сообщения"; - - // Деталь отказа: промпт длиннее защитного лимита (INVALID_ARGUMENT). - private const string PromptTooLongDetail = "Слишком длинный промпт"; - - // Деталь отказа: описание ниши/задачи длиннее контрактного лимита (INVALID_ARGUMENT). - private const string DescriptionTooLongDetail = "Слишком длинное описание ниши/задачи"; - - // Деталь отказа: контекст Classify длиннее защитного лимита (INVALID_ARGUMENT). - private const string UserContextTooLongDetail = "Слишком длинный контекст разбора"; - - // Деталь отказа: слишком много ключей задачи в EvaluateFit (INVALID_ARGUMENT). - private const string TooManyKeywordsDetail = "Слишком много ключей задачи"; - - // Деталь отказа: ключ задачи длиннее лимита (INVALID_ARGUMENT). - private const string KeywordTooLongDetail = "Слишком длинный ключ задачи"; - - private const int MaxTextLength = 4000; - - private const int MaxDescriptionLength = 4000; - - // Защитный потолок длины промпта (Filter.prompt/Classify.system_prompt; лимит не декларирован). - private const int MaxPromptLength = 20000; - - // Защитный потолок длины user-контекста Classify (лимит не декларирован). - private const int MaxUserContextLength = 20000; - - private const int MaxKeywordsCount = 200; - - private const int MaxKeywordLength = 200; - - private const string FilterUserPrefix = "Сообщение:\n"; - - private const string KeywordsUserPrefix = "Описание ниши/задачи:\n"; - - private const string GenerateKeywordsSystemPrompt = - "Ты — эксперт по поиску Telegram-каналов и групп. По описанию ниши/задачи " - + "составь поисковые ключевые слова, по которым в глобальном поиске Telegram " - + "находят подходящие источники. Верни строго JSON вида " - + "{\"keywords\": [\"...\", \"...\"]}. Требования к списку:\n" - + "- 10–16 ключей;\n" - + "- примерно поровну русских и английских (английские — популярные в нише термины);\n" - + "- короткие фразы 1–4 слова;\n" - + "- без #, @, кавычек и лишней пунктуации;\n" - + "- конкретные для ниши, включая сленг заказчиков и подрядчиков;\n" - + "- без дублей и близких по смыслу повторов."; - - private const string EvaluateFitSystemPromptTemplate = - "Оцени, относится ли сообщение к сфере/задаче. Описание: {0}. Ключи: {1}. " - + "Верни JSON {{\"fit\": 0|1, \"reason\": \"краткая причина\"}}."; - - private const int MaxEvalReasonLength = 200; - - private const string FitReasonDefault = "подходит"; - - private const string NotFitReasonDefault = "не подходит"; - - private const string PassFieldName = "pass"; - - // Имя поля причины в JSON-ответе модели. - private const string ReasonFieldName = "reason"; - - private const string FitFieldName = "fit"; - - // Имя поля списка ключевых слов в JSON-ответе модели. - private const string KeywordsFieldName = "keywords"; - - private static readonly IReadOnlySet FalsyAnswerValues = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "0", - "false", - "no", - "нет", - "null", - "none", - }; - - private readonly ProviderCaller _caller; - private readonly ILogger _logger; - - /// - /// Создаёт gRPC-сервис команд ядра поверх LLM-фасада. - /// - /// Оркестратор вызовов модели (ретраи + извлечение JSON + usage). - /// Логгер аудита. - public AiServiceImpl(ProviderCaller caller, ILogger logger) - { - _caller = caller; - _logger = logger; - } - - /// - /// Filter — ИИ-фильтр входящих сообщений - /// - public override async Task Filter(FilterRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - EnsureLengthAtMost(request.Text, MaxTextLength, MessageTextTooLongDetail); - EnsureLengthAtMost(request.Prompt, MaxPromptLength, PromptTooLongDetail); - LlmConfig config = ResolveConfig(request.ProviderConfig); - try - { - LlmCallResult result = await _caller - .ChatJsonAsync(config, request.Prompt, FilterUserPrefix + request.Text, context.CancellationToken) - .ConfigureAwait(false); - - bool pass = ReadBoolField(result.Json, PassFieldName, defaultValue: true); - string? reason = ReadStringField(result.Json, ReasonFieldName); - - var reply = new FilterReply { Pass = pass, Usage = ToUsage(result.Usage) }; - if (reason is not null) - { - reply.Reason = reason; - } - - _logger.LogInformation( - "Аудит: tenant {TenantId} filter → pass={Pass}, usage={TotalTokens}", - tenantId, - pass, - result.Usage.TotalTokens); - return reply; - } - catch (LlmCallException callError) - { - LogAiUnavailable("filter", tenantId, config, callError); - throw ToUnavailable(callError); - } - } - - /// - /// Classify — полный разбор лида - /// - public override async Task Classify(ClassifyRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - EnsureLengthAtMost(request.SystemPrompt, MaxPromptLength, PromptTooLongDetail); - EnsureLengthAtMost(request.UserContext, MaxUserContextLength, UserContextTooLongDetail); - LlmConfig config = ResolveConfig(request.ProviderConfig); - try - { - LlmCallResult result = await _caller - .ChatJsonAsync(config, request.SystemPrompt, request.UserContext, context.CancellationToken) - .ConfigureAwait(false); - - var reply = new ClassifyReply { Ok = true, Json = result.JsonText, Usage = ToUsage(result.Usage) }; - - _logger.LogInformation( - "Аудит: tenant {TenantId} classify → ok=true, usage={TotalTokens}", - tenantId, - result.Usage.TotalTokens); - return reply; - } - catch (LlmCallException callError) when (callError.Kind == LlmCallFailureKind.AnswerNotJson) - { - // «Ответ без разбираемого JSON» — контрактная форма ClassifyReply.ok=false (README ai.proto). - _logger.LogWarning( - "Аудит: tenant {TenantId} classify → ok=false ({Provider}): ответ модели без JSON", - tenantId, - config.DisplayName); - - var reply = new ClassifyReply { Ok = false, Usage = ToUsage(callError.Usage) }; - return reply; - } - catch (LlmCallException callError) - { - LogAiUnavailable("classify", tenantId, config, callError); - throw ToUnavailable(callError); - } - } - - /// - /// GenerateKeywords — ключевые слова discovery-задачи по описанию - /// - public override async Task GenerateKeywords(GenerateKeywordsRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - EnsureLengthAtMost(request.Description, MaxDescriptionLength, DescriptionTooLongDetail); - LlmConfig config = ResolveConfig(request.ProviderConfig); - try - { - LlmCallResult result = await _caller - .ChatJsonAsync( - config, - GenerateKeywordsSystemPrompt, - KeywordsUserPrefix + request.Description, - context.CancellationToken) - .ConfigureAwait(false); - - var reply = new GenerateKeywordsReply { Usage = ToUsage(result.Usage) }; - reply.Keywords.AddRange(ReadKeywords(result.Json)); - - _logger.LogInformation( - "Аудит: tenant {TenantId} generate_keywords → keywords={Count}, usage={TotalTokens}", - tenantId, - reply.Keywords.Count, - result.Usage.TotalTokens); - return reply; - } - catch (LlmCallException callError) - { - LogAiUnavailable("generate_keywords", tenantId, config, callError); - throw ToUnavailable(callError); - } - } - - /// - /// EvaluateFit — оценка соответствия сообщения задаче поиска - /// - public override async Task EvaluateFit(EvaluateFitRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - EnsureLengthAtMost(request.Text, MaxTextLength, MessageTextTooLongDetail); - EnsureLengthAtMost(request.Description, MaxDescriptionLength, DescriptionTooLongDetail); - EnsureKeywordsWithinBounds(request.Keywords); - LlmConfig config = ResolveConfig(request.ProviderConfig); - try - { - string systemPrompt = string.Format( - CultureInfo.InvariantCulture, - EvaluateFitSystemPromptTemplate, - request.Description, - JoinKeywords(request.Keywords)); - - LlmCallResult result = await _caller - .ChatJsonAsync(config, systemPrompt, FilterUserPrefix + request.Text, context.CancellationToken) - .ConfigureAwait(false); - - bool fit = ReadBoolField(result.Json, FitFieldName, defaultValue: false); - string reason = ReadFitReason(result.Json, fit); - - var reply = new EvaluateFitReply { Fit = fit, Reason = reason, Usage = ToUsage(result.Usage) }; - - _logger.LogInformation( - "Аудит: tenant {TenantId} evaluate_fit → fit={Fit}, usage={TotalTokens}", - tenantId, - fit, - result.Usage.TotalTokens); - return reply; - } - catch (LlmCallException callError) - { - LogAiUnavailable("evaluate_fit", tenantId, config, callError); - throw ToUnavailable(callError); - } - } - - private static string RequireTenantId(ServerCallContext context) - { - string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey); - if (string.IsNullOrWhiteSpace(tenantId)) - { - throw new RpcException(new Status(StatusCode.Unauthenticated, TenantIdMissingDetail)); - } - - return tenantId; - } - - // INVALID_ARGUMENT при превышении лимита длины текстового поля (серверный enforcement ai.proto). - // value: Значение поля запроса (в proto строка не бывает null). - // maxLength: Допустимый максимум символов. - // detail: Текст отказа (detail RPC). - private static void EnsureLengthAtMost( - string value, - int maxLength, - string detail) - { - if (value.Length > maxLength) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, detail)); - } - } - - // Проверяет число и длины ключей задачи EvaluateFit (защита промпта от раздувания). - // keywords: Ключи задачи из запроса. - private static void EnsureKeywordsWithinBounds(IEnumerable keywords) - { - int count = 0; - foreach (string keyword in keywords) - { - if (++count > MaxKeywordsCount) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, TooManyKeywordsDetail)); - } - - if (keyword.Length > MaxKeywordLength) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, KeywordTooLongDetail)); - } - } - } - - // Проверяет конфиг провайдера и строит рабочий конфиг вызова. Конфиг невалиден (пустые - // base_url/model) — INVALID_ARGUMENT: без конфига вызов модели невозможен. - // providerConfig: Конфиг из тела запроса (ядро всегда заполняет). - private static LlmConfig ResolveConfig(ProviderConfig? providerConfig) - { - if (providerConfig is null) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, ProviderConfigMissingDetail)); - } - - if (string.IsNullOrWhiteSpace(providerConfig.BaseUrl)) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, ProviderBaseUrlEmptyDetail)); - } - - if (string.IsNullOrWhiteSpace(providerConfig.Model)) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, ProviderModelEmptyDetail)); - } - - return new LlmConfig( - providerConfig.ProviderId, - providerConfig.BaseUrl, - providerConfig.Model, - providerConfig.ApiKey, - providerConfig.ApiStyle); - } - - private void LogAiUnavailable( - string method, - string tenantId, - LlmConfig config, - LlmCallException callError) - => _logger.LogWarning( - "Аудит: tenant {TenantId} {Method} → ИИ ({Provider}) недоступен ({Kind})", - tenantId, - method, - config.DisplayName, - callError.Kind); - - private static RpcException ToUnavailable(LlmCallException callError) - => new(new Status(StatusCode.Unavailable, callError.Message)); - - private static bool ReadBoolField( - JsonObject json, - string fieldName, - bool defaultValue) - { - if (json[fieldName] is not JsonValue value) - { - return defaultValue; - } - - if (value.TryGetValue(out bool flag)) - { - return flag; - } - - if (value.TryGetValue(out string? raw) && raw is not null) - { - string text = raw.Trim(); - return text.Length == 0 ? defaultValue : !FalsyAnswerValues.Contains(text); - } - - if (value.TryGetValue(out int number)) - { - return number != 0; - } - - return value.TryGetValue(out double fractional) && fractional != 0; - } - - // Читает строковое поле ответа модели (null — поля нет/не строка/пусто). - // json: Корневой объект ответа модели. - // fieldName: Имя поля. - private static string? ReadStringField(JsonObject json, string fieldName) - { - if (json[fieldName] is not JsonValue value || !value.TryGetValue(out string? text)) - { - return null; - } - - string trimmed = text.Trim(); - return trimmed.Length == 0 ? null : trimmed; - } - - private static string ReadFitReason(JsonObject json, bool fit) - { - string reason = ReadStringField(json, ReasonFieldName) ?? (fit ? FitReasonDefault : NotFitReasonDefault); - return reason.Length <= MaxEvalReasonLength ? reason : reason[..MaxEvalReasonLength]; - } - - // Читает список ключевых слов из ответа модели (не-строки пропускаются; чистку — ядро). - // json: Корневой объект ответа модели. - private static IEnumerable ReadKeywords(JsonObject json) - { - if (json[KeywordsFieldName] is not JsonArray keywords) - { - yield break; - } - - foreach (JsonNode? keywordNode in keywords) - { - if (keywordNode is JsonValue value && value.TryGetValue(out string? keyword)) - { - string trimmed = keyword.Trim(); - if (trimmed.Length > 0) - { - yield return trimmed; - } - } - } - } - - private static string JoinKeywords(IEnumerable keywords) - => string.Join( - ", ", - keywords.Where(keyword => !string.IsNullOrWhiteSpace(keyword)).Select(keyword => keyword.Trim())); - - // Маппит итоговую оценку токенов в gRPC-usage (null → нули — контрактный ответ без usage). - // usage: Оценка токенов вызова. - private static Usage ToUsage(LlmUsage? usage) - => new() - { - Prompt = usage is null ? 0U : (uint)usage.PromptTokens, - Completion = usage is null ? 0U : (uint)usage.CompletionTokens, - Total = usage is null ? 0U : (uint)usage.TotalTokens, - }; -} +using System.Globalization; +using System.Text.Json.Nodes; +using Deal.Ai.Llm; +using Deal.Grpc.Ai; +using Grpc.Core; + +namespace Deal.Ai; + +/// +/// Реализация серверной стороны Deal.Grpc.Ai.AiService — команды ядра в ai-service. +/// +public sealed class AiServiceImpl : AiService.AiServiceBase +{ + public const string TenantIdMetadataKey = "tenant-id"; + + // Деталь отказа: tenant-id отсутствует в metadata (UNAUTHENTICATED, шаблон MlServiceImpl). + private const string TenantIdMissingDetail = "tenant-id отсутствует в metadata"; + + // Деталь отказа: не задан конфиг ИИ-провайдера (INVALID_ARGUMENT). + private const string ProviderConfigMissingDetail = "Не задан конфиг ИИ-провайдера (provider_config)"; + + // Деталь отказа: пустой base_url конфига (INVALID_ARGUMENT). + private const string ProviderBaseUrlEmptyDetail = "Конфиг ИИ-провайдера: пустой base_url"; + + // Деталь отказа: пустая model конфига (INVALID_ARGUMENT). + private const string ProviderModelEmptyDetail = "Конфиг ИИ-провайдера: пустая model"; + + // Деталь отказа: текст сообщения длиннее контрактного лимита (INVALID_ARGUMENT). + private const string MessageTextTooLongDetail = "Слишком длинный текст сообщения"; + + // Деталь отказа: промпт длиннее защитного лимита (INVALID_ARGUMENT). + private const string PromptTooLongDetail = "Слишком длинный промпт"; + + // Деталь отказа: описание ниши/задачи длиннее контрактного лимита (INVALID_ARGUMENT). + private const string DescriptionTooLongDetail = "Слишком длинное описание ниши/задачи"; + + // Деталь отказа: контекст Classify длиннее защитного лимита (INVALID_ARGUMENT). + private const string UserContextTooLongDetail = "Слишком длинный контекст разбора"; + + // Деталь отказа: слишком много ключей задачи в EvaluateFit (INVALID_ARGUMENT). + private const string TooManyKeywordsDetail = "Слишком много ключей задачи"; + + // Деталь отказа: ключ задачи длиннее лимита (INVALID_ARGUMENT). + private const string KeywordTooLongDetail = "Слишком длинный ключ задачи"; + + private const int MaxTextLength = 4000; + + private const int MaxDescriptionLength = 4000; + + // Защитный потолок длины промпта (Filter.prompt/Classify.system_prompt; лимит не декларирован). + private const int MaxPromptLength = 20000; + + // Защитный потолок длины user-контекста Classify (лимит не декларирован). + private const int MaxUserContextLength = 20000; + + private const int MaxKeywordsCount = 200; + + private const int MaxKeywordLength = 200; + + private const string FilterUserPrefix = "Сообщение:\n"; + + private const string KeywordsUserPrefix = "Описание ниши/задачи:\n"; + + private const string GenerateKeywordsSystemPrompt = + "Ты — эксперт по поиску Telegram-каналов и групп. По описанию ниши/задачи " + + "составь поисковые ключевые слова, по которым в глобальном поиске Telegram " + + "находят подходящие источники. Верни строго JSON вида " + + "{\"keywords\": [\"...\", \"...\"]}. Требования к списку:\n" + + "- 10–16 ключей;\n" + + "- примерно поровну русских и английских (английские — популярные в нише термины);\n" + + "- короткие фразы 1–4 слова;\n" + + "- без #, @, кавычек и лишней пунктуации;\n" + + "- конкретные для ниши, включая сленг заказчиков и подрядчиков;\n" + + "- без дублей и близких по смыслу повторов."; + + private const string EvaluateFitSystemPromptTemplate = + "Оцени, относится ли сообщение к сфере/задаче. Описание: {0}. Ключи: {1}. " + + "Верни JSON {{\"fit\": 0|1, \"reason\": \"краткая причина\"}}."; + + private const int MaxEvalReasonLength = 200; + + private const string FitReasonDefault = "подходит"; + + private const string NotFitReasonDefault = "не подходит"; + + private const string PassFieldName = "pass"; + + // Имя поля причины в JSON-ответе модели. + private const string ReasonFieldName = "reason"; + + private const string FitFieldName = "fit"; + + // Имя поля списка ключевых слов в JSON-ответе модели. + private const string KeywordsFieldName = "keywords"; + + private static readonly IReadOnlySet FalsyAnswerValues = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "0", + "false", + "no", + "нет", + "null", + "none", + }; + + private readonly ProviderCaller _caller; + private readonly ILogger _logger; + + /// + /// Создаёт gRPC-сервис команд ядра поверх LLM-фасада. + /// + /// Оркестратор вызовов модели (ретраи + извлечение JSON + usage). + /// Логгер аудита. + public AiServiceImpl(ProviderCaller caller, ILogger logger) + { + _caller = caller; + _logger = logger; + } + + /// + /// Filter — ИИ-фильтр входящих сообщений + /// + public override async Task Filter(FilterRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + EnsureLengthAtMost(request.Text, MaxTextLength, MessageTextTooLongDetail); + EnsureLengthAtMost(request.Prompt, MaxPromptLength, PromptTooLongDetail); + LlmConfig config = ResolveConfig(request.ProviderConfig); + try + { + LlmCallResult result = await _caller + .ChatJsonAsync(config, request.Prompt, FilterUserPrefix + request.Text, context.CancellationToken) + .ConfigureAwait(false); + + bool pass = ReadBoolField(result.Json, PassFieldName, defaultValue: true); + string? reason = ReadStringField(result.Json, ReasonFieldName); + + var reply = new FilterReply { Pass = pass, Usage = ToUsage(result.Usage) }; + if (reason is not null) + { + reply.Reason = reason; + } + + _logger.LogInformation( + "Аудит: tenant {TenantId} filter → pass={Pass}, usage={TotalTokens}", + tenantId, + pass, + result.Usage.TotalTokens); + return reply; + } + catch (LlmCallException callError) + { + LogAiUnavailable("filter", tenantId, config, callError); + throw ToUnavailable(callError); + } + } + + /// + /// Classify — полный разбор лида + /// + public override async Task Classify(ClassifyRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + EnsureLengthAtMost(request.SystemPrompt, MaxPromptLength, PromptTooLongDetail); + EnsureLengthAtMost(request.UserContext, MaxUserContextLength, UserContextTooLongDetail); + LlmConfig config = ResolveConfig(request.ProviderConfig); + try + { + LlmCallResult result = await _caller + .ChatJsonAsync(config, request.SystemPrompt, request.UserContext, context.CancellationToken) + .ConfigureAwait(false); + + var reply = new ClassifyReply { Ok = true, Json = result.JsonText, Usage = ToUsage(result.Usage) }; + + _logger.LogInformation( + "Аудит: tenant {TenantId} classify → ok=true, usage={TotalTokens}", + tenantId, + result.Usage.TotalTokens); + return reply; + } + catch (LlmCallException callError) when (callError.Kind == LlmCallFailureKind.AnswerNotJson) + { + // «Ответ без разбираемого JSON» — контрактная форма ClassifyReply.ok=false (README ai.proto). + _logger.LogWarning( + "Аудит: tenant {TenantId} classify → ok=false ({Provider}): ответ модели без JSON", + tenantId, + config.DisplayName); + + var reply = new ClassifyReply { Ok = false, Usage = ToUsage(callError.Usage) }; + return reply; + } + catch (LlmCallException callError) + { + LogAiUnavailable("classify", tenantId, config, callError); + throw ToUnavailable(callError); + } + } + + /// + /// GenerateKeywords — ключевые слова discovery-задачи по описанию + /// + public override async Task GenerateKeywords(GenerateKeywordsRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + EnsureLengthAtMost(request.Description, MaxDescriptionLength, DescriptionTooLongDetail); + LlmConfig config = ResolveConfig(request.ProviderConfig); + try + { + LlmCallResult result = await _caller + .ChatJsonAsync( + config, + GenerateKeywordsSystemPrompt, + KeywordsUserPrefix + request.Description, + context.CancellationToken) + .ConfigureAwait(false); + + var reply = new GenerateKeywordsReply { Usage = ToUsage(result.Usage) }; + reply.Keywords.AddRange(ReadKeywords(result.Json)); + + _logger.LogInformation( + "Аудит: tenant {TenantId} generate_keywords → keywords={Count}, usage={TotalTokens}", + tenantId, + reply.Keywords.Count, + result.Usage.TotalTokens); + return reply; + } + catch (LlmCallException callError) + { + LogAiUnavailable("generate_keywords", tenantId, config, callError); + throw ToUnavailable(callError); + } + } + + /// + /// EvaluateFit — оценка соответствия сообщения задаче поиска + /// + public override async Task EvaluateFit(EvaluateFitRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + EnsureLengthAtMost(request.Text, MaxTextLength, MessageTextTooLongDetail); + EnsureLengthAtMost(request.Description, MaxDescriptionLength, DescriptionTooLongDetail); + EnsureKeywordsWithinBounds(request.Keywords); + LlmConfig config = ResolveConfig(request.ProviderConfig); + try + { + string systemPrompt = string.Format( + CultureInfo.InvariantCulture, + EvaluateFitSystemPromptTemplate, + request.Description, + JoinKeywords(request.Keywords)); + + LlmCallResult result = await _caller + .ChatJsonAsync(config, systemPrompt, FilterUserPrefix + request.Text, context.CancellationToken) + .ConfigureAwait(false); + + bool fit = ReadBoolField(result.Json, FitFieldName, defaultValue: false); + string reason = ReadFitReason(result.Json, fit); + + var reply = new EvaluateFitReply { Fit = fit, Reason = reason, Usage = ToUsage(result.Usage) }; + + _logger.LogInformation( + "Аудит: tenant {TenantId} evaluate_fit → fit={Fit}, usage={TotalTokens}", + tenantId, + fit, + result.Usage.TotalTokens); + return reply; + } + catch (LlmCallException callError) + { + LogAiUnavailable("evaluate_fit", tenantId, config, callError); + throw ToUnavailable(callError); + } + } + + private static string RequireTenantId(ServerCallContext context) + { + string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey); + if (string.IsNullOrWhiteSpace(tenantId)) + { + throw new RpcException(new Status(StatusCode.Unauthenticated, TenantIdMissingDetail)); + } + + return tenantId; + } + + // INVALID_ARGUMENT при превышении лимита длины текстового поля (серверный enforcement ai.proto). + // value: Значение поля запроса (в proto строка не бывает null). + // maxLength: Допустимый максимум символов. + // detail: Текст отказа (detail RPC). + private static void EnsureLengthAtMost( + string value, + int maxLength, + string detail) + { + if (value.Length > maxLength) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, detail)); + } + } + + // Проверяет число и длины ключей задачи EvaluateFit (защита промпта от раздувания). + // keywords: Ключи задачи из запроса. + private static void EnsureKeywordsWithinBounds(IEnumerable keywords) + { + int count = 0; + foreach (string keyword in keywords) + { + if (++count > MaxKeywordsCount) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, TooManyKeywordsDetail)); + } + + if (keyword.Length > MaxKeywordLength) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, KeywordTooLongDetail)); + } + } + } + + // Проверяет конфиг провайдера и строит рабочий конфиг вызова. Конфиг невалиден (пустые + // base_url/model) — INVALID_ARGUMENT: без конфига вызов модели невозможен. + // providerConfig: Конфиг из тела запроса (ядро всегда заполняет). + private static LlmConfig ResolveConfig(ProviderConfig? providerConfig) + { + if (providerConfig is null) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, ProviderConfigMissingDetail)); + } + + if (string.IsNullOrWhiteSpace(providerConfig.BaseUrl)) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, ProviderBaseUrlEmptyDetail)); + } + + if (string.IsNullOrWhiteSpace(providerConfig.Model)) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, ProviderModelEmptyDetail)); + } + + return new LlmConfig( + providerConfig.ProviderId, + providerConfig.BaseUrl, + providerConfig.Model, + providerConfig.ApiKey, + providerConfig.ApiStyle); + } + + private void LogAiUnavailable( + string method, + string tenantId, + LlmConfig config, + LlmCallException callError) + => _logger.LogWarning( + "Аудит: tenant {TenantId} {Method} → ИИ ({Provider}) недоступен ({Kind})", + tenantId, + method, + config.DisplayName, + callError.Kind); + + private static RpcException ToUnavailable(LlmCallException callError) + => new(new Status(StatusCode.Unavailable, callError.Message)); + + private static bool ReadBoolField( + JsonObject json, + string fieldName, + bool defaultValue) + { + if (json[fieldName] is not JsonValue value) + { + return defaultValue; + } + + if (value.TryGetValue(out bool flag)) + { + return flag; + } + + if (value.TryGetValue(out string? raw) && raw is not null) + { + string text = raw.Trim(); + return text.Length == 0 ? defaultValue : !FalsyAnswerValues.Contains(text); + } + + if (value.TryGetValue(out int number)) + { + return number != 0; + } + + return value.TryGetValue(out double fractional) && fractional != 0; + } + + // Читает строковое поле ответа модели (null — поля нет/не строка/пусто). + // json: Корневой объект ответа модели. + // fieldName: Имя поля. + private static string? ReadStringField(JsonObject json, string fieldName) + { + if (json[fieldName] is not JsonValue value || !value.TryGetValue(out string? text)) + { + return null; + } + + string trimmed = text.Trim(); + return trimmed.Length == 0 ? null : trimmed; + } + + private static string ReadFitReason(JsonObject json, bool fit) + { + string reason = ReadStringField(json, ReasonFieldName) ?? (fit ? FitReasonDefault : NotFitReasonDefault); + return reason.Length <= MaxEvalReasonLength ? reason : reason[..MaxEvalReasonLength]; + } + + // Читает список ключевых слов из ответа модели (не-строки пропускаются; чистку — ядро). + // json: Корневой объект ответа модели. + private static IEnumerable ReadKeywords(JsonObject json) + { + if (json[KeywordsFieldName] is not JsonArray keywords) + { + yield break; + } + + foreach (JsonNode? keywordNode in keywords) + { + if (keywordNode is JsonValue value && value.TryGetValue(out string? keyword)) + { + string trimmed = keyword.Trim(); + if (trimmed.Length > 0) + { + yield return trimmed; + } + } + } + } + + private static string JoinKeywords(IEnumerable keywords) + => string.Join( + ", ", + keywords.Where(keyword => !string.IsNullOrWhiteSpace(keyword)).Select(keyword => keyword.Trim())); + + // Маппит итоговую оценку токенов в gRPC-usage (null → нули — контрактный ответ без usage). + // usage: Оценка токенов вызова. + private static Usage ToUsage(LlmUsage? usage) + => new() + { + Prompt = usage is null ? 0U : (uint)usage.PromptTokens, + Completion = usage is null ? 0U : (uint)usage.CompletionTokens, + Total = usage is null ? 0U : (uint)usage.TotalTokens, + }; +} diff --git a/src/ai-service/Deal.Ai/AssemblyInfo.cs b/src/ai-service/Deal.Ai/AssemblyInfo.cs index ce470ff..87348c5 100644 --- a/src/ai-service/Deal.Ai/AssemblyInfo.cs +++ b/src/ai-service/Deal.Ai/AssemblyInfo.cs @@ -1,5 +1,5 @@ -using System.Runtime.CompilerServices; - -// Тесты (Deal.Ai.Tests) проверяют таймауты HTTP-попыток фасада через конструктор LlmHttpClient с -// короткими таймаутами (внутренний) — публичный контракт ради тестов не расширяется. -[assembly: InternalsVisibleTo("Deal.Ai.Tests")] +using System.Runtime.CompilerServices; + +// Тесты (Deal.Ai.Tests) проверяют таймауты HTTP-попыток фасада через конструктор LlmHttpClient с +// короткими таймаутами (внутренний) — публичный контракт ради тестов не расширяется. +[assembly: InternalsVisibleTo("Deal.Ai.Tests")] diff --git a/src/ai-service/Deal.Ai/Deal.Ai.csproj b/src/ai-service/Deal.Ai/Deal.Ai.csproj index 882f4f2..c3e316e 100644 --- a/src/ai-service/Deal.Ai/Deal.Ai.csproj +++ b/src/ai-service/Deal.Ai/Deal.Ai.csproj @@ -1,43 +1,43 @@ - - - - - Deal.Ai - Deal.Ai - - - - - - - - - - - - - - - - - - - - + + + + + Deal.Ai + Deal.Ai + + + + + + + + + + + + + + + + + + + + diff --git a/src/ai-service/Deal.Ai/Dockerfile b/src/ai-service/Deal.Ai/Dockerfile index 96dd7e8..6cb874b 100644 --- a/src/ai-service/Deal.Ai/Dockerfile +++ b/src/ai-service/Deal.Ai/Dockerfile @@ -1,43 +1,43 @@ -# ai-service: gRPC-хост ИИ-фасада (план Task 4; Ruling 12 — запись в deploy/compose.dev.yml). -# -# КОНТЕКСТ СБОРКИ — корень репозитория: Deal.Ai.csproj ссылается на src/contracts/Deal.Proto.csproj -# (общий проект кодогенерации, Task 1) вне каталога сервиса, поэтому нельзя собирать из -# src/ai-service. Запуск из корня: docker build -f src/ai-service/Deal.Ai/Dockerfile . -# Порт — env GRPC_PORT (Program.cs), в compose.dev.yml задан 5102. - -# --- Этап сборки: restore + publish --- -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /repo - -# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников). -COPY src/contracts/Deal.Proto.csproj src/contracts/ -COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/ -COPY src/ai-service/Directory.Build.props src/ai-service/ -COPY src/ai-service/Deal.Ai/Deal.Ai.csproj src/ai-service/Deal.Ai/ -RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj - -# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса. -COPY src/contracts/ src/contracts/ -COPY src/grpc-hosting/ src/grpc-hosting/ -COPY src/ai-service/Deal.Ai/ src/ai-service/Deal.Ai/ -RUN dotnet publish src/ai-service/Deal.Ai/Deal.Ai.csproj -c Release -o /app/publish - -# --- Runtime-этап --- -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final -WORKDIR /app -EXPOSE 5102 -COPY --from=build /app/publish . - -# grpc_health_probe — healthcheck контейнера (Ruling 12): gRPC-health освобождён от service-token -# (см. ServiceTokenInterceptor), поэтому проба идёт без metadata. -COPY --from=ghcr.io/grpc-ecosystem/grpc-health-probe:v0.4.35 /ko-app/grpc-health-probe /bin/grpc_health_probe - -# Volume-ов нет: ai-service без БД и без персистентных файлов (Ruling 5) — конфиг провайдера -# (id/base/model/apiKey/api_style) приходит в теле каждого запроса от ядра, ответы не хранятся. - -# Non-root: процесс работает пользователем deal (UID 10001); персистентных файлов нет. -RUN useradd --uid 10001 --shell /usr/sbin/nologin --no-create-home deal -ENV HOME=/tmp -USER deal - -ENTRYPOINT ["dotnet", "Deal.Ai.dll"] +# ai-service: gRPC-хост ИИ-фасада (план Task 4; Ruling 12 — запись в deploy/compose.dev.yml). +# +# КОНТЕКСТ СБОРКИ — корень репозитория: Deal.Ai.csproj ссылается на src/contracts/Deal.Proto.csproj +# (общий проект кодогенерации, Task 1) вне каталога сервиса, поэтому нельзя собирать из +# src/ai-service. Запуск из корня: docker build -f src/ai-service/Deal.Ai/Dockerfile . +# Порт — env GRPC_PORT (Program.cs), в compose.dev.yml задан 5102. + +# --- Этап сборки: restore + publish --- +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /repo + +# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников). +COPY src/contracts/Deal.Proto.csproj src/contracts/ +COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/ +COPY src/ai-service/Directory.Build.props src/ai-service/ +COPY src/ai-service/Deal.Ai/Deal.Ai.csproj src/ai-service/Deal.Ai/ +RUN dotnet restore src/ai-service/Deal.Ai/Deal.Ai.csproj + +# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса. +COPY src/contracts/ src/contracts/ +COPY src/grpc-hosting/ src/grpc-hosting/ +COPY src/ai-service/Deal.Ai/ src/ai-service/Deal.Ai/ +RUN dotnet publish src/ai-service/Deal.Ai/Deal.Ai.csproj -c Release -o /app/publish + +# --- Runtime-этап --- +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +EXPOSE 5102 +COPY --from=build /app/publish . + +# grpc_health_probe — healthcheck контейнера (Ruling 12): gRPC-health освобождён от service-token +# (см. ServiceTokenInterceptor), поэтому проба идёт без metadata. +COPY --from=ghcr.io/grpc-ecosystem/grpc-health-probe:v0.4.35 /ko-app/grpc-health-probe /bin/grpc_health_probe + +# Volume-ов нет: ai-service без БД и без персистентных файлов (Ruling 5) — конфиг провайдера +# (id/base/model/apiKey/api_style) приходит в теле каждого запроса от ядра, ответы не хранятся. + +# Non-root: процесс работает пользователем deal (UID 10001); персистентных файлов нет. +RUN useradd --uid 10001 --shell /usr/sbin/nologin --no-create-home deal +ENV HOME=/tmp +USER deal + +ENTRYPOINT ["dotnet", "Deal.Ai.dll"] diff --git a/src/ai-service/Deal.Ai/Llm/IProviderClient.cs b/src/ai-service/Deal.Ai/Llm/IProviderClient.cs index 104d074..250c579 100644 --- a/src/ai-service/Deal.Ai/Llm/IProviderClient.cs +++ b/src/ai-service/Deal.Ai/Llm/IProviderClient.cs @@ -1,20 +1,20 @@ -namespace Deal.Ai.Llm; - -/// -/// Абстракция одного HTTP-вызова LLM-провайдера. -/// -public interface IProviderClient -{ - /// - /// Выполняет один вызов модели по системному и пользовательскому сообщениям. - /// - /// Конфиг активного провайдера (стиль API выбирается по ApiStyle). - /// Системный промпт (заполненный ядром либо фиксированный сервиса). - /// Пользовательское сообщение/контекст. - /// Текст ответа модели и usage API-ответа (null — провайдер usage не вернул). - public Task ChatAsync( - LlmConfig config, - string systemPrompt, - string userText, - CancellationToken cancellationToken); -} +namespace Deal.Ai.Llm; + +/// +/// Абстракция одного HTTP-вызова LLM-провайдера. +/// +public interface IProviderClient +{ + /// + /// Выполняет один вызов модели по системному и пользовательскому сообщениям. + /// + /// Конфиг активного провайдера (стиль API выбирается по ApiStyle). + /// Системный промпт (заполненный ядром либо фиксированный сервиса). + /// Пользовательское сообщение/контекст. + /// Текст ответа модели и usage API-ответа (null — провайдер usage не вернул). + public Task ChatAsync( + LlmConfig config, + string systemPrompt, + string userText, + CancellationToken cancellationToken); +} diff --git a/src/ai-service/Deal.Ai/Llm/JsonExtractor.cs b/src/ai-service/Deal.Ai/Llm/JsonExtractor.cs index 16ad6ff..39ab1c8 100644 --- a/src/ai-service/Deal.Ai/Llm/JsonExtractor.cs +++ b/src/ai-service/Deal.Ai/Llm/JsonExtractor.cs @@ -1,72 +1,72 @@ -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace Deal.Ai.Llm; - -/// -/// Извлечение JSON-объекта из ответа модели -/// -public static class JsonExtractor -{ - // Маркер markdown-обёртки кода в ответах моделей. - private const string CodeFence = "```"; - - // Метка языка после открывающей обёртки (```json). - private const string JsonFenceLabel = "json"; - - /// - /// Пытается извлечь JSON-объект из текста ответа модели. - /// - /// Сырой ответ модели. - /// Объект JSON либо null (нет JSON/не объект/не разбирается). - public static JsonObject? TryExtractObject(string text) - { - if (string.IsNullOrWhiteSpace(text)) - { - return null; - } - - string candidate = UnwrapFence(text.Trim()) ?? text.Trim(); - - int openBrace = candidate.IndexOf('{'); - int closeBrace = candidate.LastIndexOf('}'); - if (openBrace < 0 || closeBrace <= openBrace) - { - return null; - } - - string slice = candidate[openBrace..(closeBrace + 1)]; - try - { - return JsonNode.Parse(slice) as JsonObject; - } - catch (JsonException) - { - return null; - } - } - - private static string? UnwrapFence(string raw) - { - int open = raw.IndexOf(CodeFence, StringComparison.Ordinal); - if (open < 0) - { - return null; - } - - int contentStart = open + CodeFence.Length; - int close = raw.IndexOf(CodeFence, contentStart, StringComparison.Ordinal); - if (close < 0) - { - return null; - } - - string inner = raw[contentStart..close].Trim(); - if (inner.StartsWith(JsonFenceLabel, StringComparison.OrdinalIgnoreCase)) - { - inner = inner[JsonFenceLabel.Length..].TrimStart(); - } - - return inner; - } -} +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Deal.Ai.Llm; + +/// +/// Извлечение JSON-объекта из ответа модели +/// +public static class JsonExtractor +{ + // Маркер markdown-обёртки кода в ответах моделей. + private const string CodeFence = "```"; + + // Метка языка после открывающей обёртки (```json). + private const string JsonFenceLabel = "json"; + + /// + /// Пытается извлечь JSON-объект из текста ответа модели. + /// + /// Сырой ответ модели. + /// Объект JSON либо null (нет JSON/не объект/не разбирается). + public static JsonObject? TryExtractObject(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + string candidate = UnwrapFence(text.Trim()) ?? text.Trim(); + + int openBrace = candidate.IndexOf('{'); + int closeBrace = candidate.LastIndexOf('}'); + if (openBrace < 0 || closeBrace <= openBrace) + { + return null; + } + + string slice = candidate[openBrace..(closeBrace + 1)]; + try + { + return JsonNode.Parse(slice) as JsonObject; + } + catch (JsonException) + { + return null; + } + } + + private static string? UnwrapFence(string raw) + { + int open = raw.IndexOf(CodeFence, StringComparison.Ordinal); + if (open < 0) + { + return null; + } + + int contentStart = open + CodeFence.Length; + int close = raw.IndexOf(CodeFence, contentStart, StringComparison.Ordinal); + if (close < 0) + { + return null; + } + + string inner = raw[contentStart..close].Trim(); + if (inner.StartsWith(JsonFenceLabel, StringComparison.OrdinalIgnoreCase)) + { + inner = inner[JsonFenceLabel.Length..].TrimStart(); + } + + return inner; + } +} diff --git a/src/ai-service/Deal.Ai/Llm/LlmCallException.cs b/src/ai-service/Deal.Ai/Llm/LlmCallException.cs index 242fa6a..0bab605 100644 --- a/src/ai-service/Deal.Ai/Llm/LlmCallException.cs +++ b/src/ai-service/Deal.Ai/Llm/LlmCallException.cs @@ -1,36 +1,36 @@ -namespace Deal.Ai.Llm; - -/// -/// Итоговая ошибка вызова после исчерпания ретраев. -/// -public sealed class LlmCallException : Exception -{ - // Шаблон текста ошибки (имя провайдера из конфига). - private const string DetailTemplate = "ИИ ({0}) не ответил корректно — повторите попытку через несколько секунд"; - - /// - /// Создаёт итоговую ошибку вызова после исчерпания попыток. - /// - /// Причина исчерпания (см. ). - /// Отображаемое имя провайдера (без ключей). - /// Usage последней ответившей попытки (для Kind=AnswerNotJson; иначе null). - public LlmCallException( - LlmCallFailureKind kind, - string providerDisplayName, - LlmUsage? usage = null) - : base(string.Format(DetailTemplate, providerDisplayName)) - { - Kind = kind; - Usage = usage; - } - - /// - /// Причина исчерпания попыток вызова. - /// - public LlmCallFailureKind Kind { get; } - - /// - /// Usage последней попытки, вернувшей текст модели - /// - public LlmUsage? Usage { get; } -} +namespace Deal.Ai.Llm; + +/// +/// Итоговая ошибка вызова после исчерпания ретраев. +/// +public sealed class LlmCallException : Exception +{ + // Шаблон текста ошибки (имя провайдера из конфига). + private const string DetailTemplate = "ИИ ({0}) не ответил корректно — повторите попытку через несколько секунд"; + + /// + /// Создаёт итоговую ошибку вызова после исчерпания попыток. + /// + /// Причина исчерпания (см. ). + /// Отображаемое имя провайдера (без ключей). + /// Usage последней ответившей попытки (для Kind=AnswerNotJson; иначе null). + public LlmCallException( + LlmCallFailureKind kind, + string providerDisplayName, + LlmUsage? usage = null) + : base(string.Format(DetailTemplate, providerDisplayName)) + { + Kind = kind; + Usage = usage; + } + + /// + /// Причина исчерпания попыток вызова. + /// + public LlmCallFailureKind Kind { get; } + + /// + /// Usage последней попытки, вернувшей текст модели + /// + public LlmUsage? Usage { get; } +} diff --git a/src/ai-service/Deal.Ai/Llm/LlmCallFailureKind.cs b/src/ai-service/Deal.Ai/Llm/LlmCallFailureKind.cs index 652ef88..a30aaaa 100644 --- a/src/ai-service/Deal.Ai/Llm/LlmCallFailureKind.cs +++ b/src/ai-service/Deal.Ai/Llm/LlmCallFailureKind.cs @@ -1,17 +1,17 @@ -namespace Deal.Ai.Llm; - -/// -/// Причина исчерпания попыток вызова -/// -public enum LlmCallFailureKind -{ - /// - /// Провайдер не ответил после ретраев - /// - ProviderUnavailable, - - /// - /// Модель отвечала текстом, но JSON не извлечён после ретраев - /// - AnswerNotJson, -} +namespace Deal.Ai.Llm; + +/// +/// Причина исчерпания попыток вызова +/// +public enum LlmCallFailureKind +{ + /// + /// Провайдер не ответил после ретраев + /// + ProviderUnavailable, + + /// + /// Модель отвечала текстом, но JSON не извлечён после ретраев + /// + AnswerNotJson, +} diff --git a/src/ai-service/Deal.Ai/Llm/LlmCallResult.cs b/src/ai-service/Deal.Ai/Llm/LlmCallResult.cs index 797941c..cc887a0 100644 --- a/src/ai-service/Deal.Ai/Llm/LlmCallResult.cs +++ b/src/ai-service/Deal.Ai/Llm/LlmCallResult.cs @@ -1,16 +1,16 @@ -using System.Text.Json.Nodes; - -namespace Deal.Ai.Llm; - -/// -/// Успешный результат вызова модели -/// -/// Корневой объект JSON-ответа модели. -/// Итоговая оценка токенов вызова. -public sealed record LlmCallResult(JsonObject Json, LlmUsage Usage) -{ - /// - /// Извлечённый ответ модели компактной json-строкой - /// - public string JsonText => Json.ToJsonString(); -} +using System.Text.Json.Nodes; + +namespace Deal.Ai.Llm; + +/// +/// Успешный результат вызова модели +/// +/// Корневой объект JSON-ответа модели. +/// Итоговая оценка токенов вызова. +public sealed record LlmCallResult(JsonObject Json, LlmUsage Usage) +{ + /// + /// Извлечённый ответ модели компактной json-строкой + /// + public string JsonText => Json.ToJsonString(); +} diff --git a/src/ai-service/Deal.Ai/Llm/LlmConfig.cs b/src/ai-service/Deal.Ai/Llm/LlmConfig.cs index 5dbbb5e..264fb71 100644 --- a/src/ai-service/Deal.Ai/Llm/LlmConfig.cs +++ b/src/ai-service/Deal.Ai/Llm/LlmConfig.cs @@ -1,41 +1,41 @@ -namespace Deal.Ai.Llm; - -/// -/// Эффективный конфиг LLM-провайдера на один вызов -/// -public sealed record LlmConfig( - string ProviderId, - string BaseUrl, - string Model, - string? ApiKey, - string? ApiStyle) -{ - /// - /// Значение api_style для Anthropic Messages API - /// - public const string AnthropicApiStyle = "anthropic"; - - private static readonly IReadOnlyDictionary KnownProviderNames = - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["deepseek"] = "DeepSeek", - ["openai"] = "OpenAI", - ["openrouter"] = "OpenRouter", - ["anthropic"] = "Anthropic Claude", - ["ollama"] = "Ollama (локально)", - ["lmstudio"] = "LM Studio (локально)", - ["custom"] = "Другой (OpenAI-совместимый)", - }; - - /// - /// Истинно, когда конфиг задаёт Anthropic Messages API - /// - public bool IsAnthropic => string.Equals(ApiStyle, AnthropicApiStyle, StringComparison.OrdinalIgnoreCase); - - /// - /// Имя провайдера для сообщений об ошибках и логов - /// - public string DisplayName => KnownProviderNames.TryGetValue(ProviderId, out string? name) - ? name - : ProviderId; -} +namespace Deal.Ai.Llm; + +/// +/// Эффективный конфиг LLM-провайдера на один вызов +/// +public sealed record LlmConfig( + string ProviderId, + string BaseUrl, + string Model, + string? ApiKey, + string? ApiStyle) +{ + /// + /// Значение api_style для Anthropic Messages API + /// + public const string AnthropicApiStyle = "anthropic"; + + private static readonly IReadOnlyDictionary KnownProviderNames = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["deepseek"] = "DeepSeek", + ["openai"] = "OpenAI", + ["openrouter"] = "OpenRouter", + ["anthropic"] = "Anthropic Claude", + ["ollama"] = "Ollama (локально)", + ["lmstudio"] = "LM Studio (локально)", + ["custom"] = "Другой (OpenAI-совместимый)", + }; + + /// + /// Истинно, когда конфиг задаёт Anthropic Messages API + /// + public bool IsAnthropic => string.Equals(ApiStyle, AnthropicApiStyle, StringComparison.OrdinalIgnoreCase); + + /// + /// Имя провайдера для сообщений об ошибках и логов + /// + public string DisplayName => KnownProviderNames.TryGetValue(ProviderId, out string? name) + ? name + : ProviderId; +} diff --git a/src/ai-service/Deal.Ai/Llm/LlmHttpClient.cs b/src/ai-service/Deal.Ai/Llm/LlmHttpClient.cs index ea88e16..6dcc7f4 100644 --- a/src/ai-service/Deal.Ai/Llm/LlmHttpClient.cs +++ b/src/ai-service/Deal.Ai/Llm/LlmHttpClient.cs @@ -1,303 +1,303 @@ -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json.Nodes; - -namespace Deal.Ai.Llm; - -/// -/// HTTP-реализация -/// -public sealed class LlmHttpClient : IProviderClient -{ - // Относительный путь OpenAI-совместимого эндпоинта (база уже без хвостового «/»). - private const string OpenAiCompletionsPath = "/chat/completions"; - - // Относительный путь Anthropic Messages API. - private const string AnthropicMessagesPath = "/v1/messages"; - - // Заголовок версии Anthropic API. - private const string AnthropicVersionHeader = "anthropic-version"; - - private const string AnthropicVersionValue = "2023-06-01"; - - // Заголовок ключа Anthropic API. - private const string AnthropicApiKeyHeader = "x-api-key"; - - private const double Temperature = 0.2; - - private const int MaxResponseTokens = 8000; - - private static readonly TimeSpan OpenAiCallTimeout = TimeSpan.FromSeconds(90); - - private static readonly TimeSpan AnthropicCallTimeout = TimeSpan.FromSeconds(60); - - private readonly HttpClient _httpClient; - private readonly TimeSpan _openAiCallTimeout; - private readonly TimeSpan _anthropicCallTimeout; - - /// - /// Создаёт HTTP-клиент провайдеров с типовыми таймаутами - /// - /// HttpClient (регистрируется в DI; таймаут управляется на попытку). - public LlmHttpClient(HttpClient httpClient) - : this(httpClient, OpenAiCallTimeout, AnthropicCallTimeout) - { - } - - // Создаёт HTTP-клиент с таймаутами для тестов (без реальной сети). - // httpClient: HttpClient над фейковым обработчиком. - // openAiCallTimeout: Таймаут OpenAI-совместимой попытки. - // anthropicCallTimeout: Таймаут Anthropic-попытки. - internal LlmHttpClient( - HttpClient httpClient, - TimeSpan openAiCallTimeout, - TimeSpan anthropicCallTimeout) - { - _httpClient = httpClient; - _openAiCallTimeout = openAiCallTimeout; - _anthropicCallTimeout = anthropicCallTimeout; - } - - /// - /// Выполняет один вызов модели по выбранной схеме API. - /// - /// Конфиг провайдера (стиль — ApiStyle). - /// Системный промпт. - /// Пользовательское сообщение/контекст. - /// Текст ответа и usage API-ответа (null при его отсутствии). - public async Task ChatAsync( - LlmConfig config, - string systemPrompt, - string userText, - CancellationToken cancellationToken) - { - TimeSpan timeout = config.IsAnthropic ? _anthropicCallTimeout : _openAiCallTimeout; - using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutSource.CancelAfter(timeout); - - try - { - using HttpRequestMessage request = config.IsAnthropic - ? BuildAnthropicRequest(config, systemPrompt, userText) - : BuildOpenAiRequest(config, systemPrompt, userText); - - using HttpResponseMessage response = await _httpClient - .SendAsync(request, timeoutSource.Token) - .ConfigureAwait(false); - - return await ReadResponseAsync(response, config.IsAnthropic, timeoutSource.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - throw new LlmHttpException($"Таймаут вызова ИИ-провайдера: {timeout.TotalSeconds:0} с"); - } - catch (HttpRequestException httpError) - { - throw new LlmHttpException($"Сетевая ошибка вызова ИИ-провайдера: {httpError.Message}", httpError); - } - } - - private static HttpRequestMessage BuildOpenAiRequest( - LlmConfig config, - string systemPrompt, - string userText) - { - var request = new HttpRequestMessage(HttpMethod.Post, NormalizeBaseUrl(config.BaseUrl) + OpenAiCompletionsPath); - if (!string.IsNullOrEmpty(config.ApiKey)) - { - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.ApiKey); - } - - var body = new JsonObject - { - ["model"] = config.Model, - ["messages"] = new JsonArray( - ChatMessage("system", systemPrompt), - ChatMessage("user", userText)), - ["temperature"] = Temperature, - ["max_tokens"] = MaxResponseTokens, - }; - - request.Content = JsonBody(body); - return request; - } - - private static HttpRequestMessage BuildAnthropicRequest( - LlmConfig config, - string systemPrompt, - string userText) - { - var request = new HttpRequestMessage(HttpMethod.Post, NormalizeBaseUrl(config.BaseUrl) + AnthropicMessagesPath); - request.Headers.TryAddWithoutValidation(AnthropicApiKeyHeader, config.ApiKey ?? string.Empty); - request.Headers.TryAddWithoutValidation(AnthropicVersionHeader, AnthropicVersionValue); - - var body = new JsonObject - { - ["model"] = config.Model, - ["max_tokens"] = MaxResponseTokens, - ["system"] = systemPrompt, - ["messages"] = new JsonArray(ChatMessage("user", userText)), - }; - - request.Content = JsonBody(body); - return request; - } - - // Читает ответ HTTP: статус ≠ 2xx или неожиданная форма — LlmHttpException (повод - // для ретрая); иначе текст ответа + usage из тела (см. парсеры стилей). - // response: Ответ провайдера. - // isAnthropic: Стиль ответа (Anthropic vs OpenAI-совместимый). - // cancellationToken: Токен отмены попытки. - private static async Task ReadResponseAsync( - HttpResponseMessage response, - bool isAnthropic, - CancellationToken cancellationToken) - { - if (!response.IsSuccessStatusCode) - { - throw new LlmHttpException( - $"ИИ-провайдер вернул HTTP {(int)response.StatusCode} {response.ReasonPhrase}"); - } - - string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - return isAnthropic ? ReadAnthropicBody(body) : ReadOpenAiBody(body); - } - - private static ProviderChatResult ReadOpenAiBody(string body) - { - JsonObject? payload = ParseObjectOrThrow(body); - JsonArray? choices = payload["choices"] as JsonArray; - if (choices is null || choices.Count == 0) - { - // Тип сбоя без содержимого тела (замечание code-review: тело ответа наружу/в лог не уходит). - throw UnexpectedApiResponse("пустой или отсутствующий список choices"); - } - - // choices[i] — объект выбора {message, finish_reason, …}; текст — в message.content. - JsonObject? message = (choices[0] as JsonObject)?["message"] as JsonObject; - string? content = ReadStringField(message, "content"); - if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, "reasoning_content"))) - { - throw new LlmHttpException("Модель вернула только reasoning без ответа"); - } - - return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload["usage"])); - } - - private static ProviderChatResult ReadAnthropicBody(string body) - { - JsonObject? payload = ParseObjectOrThrow(body); - - var text = new StringBuilder(); - if (payload["content"] is JsonArray contentBlocks) - { - foreach (JsonNode? blockNode in contentBlocks) - { - if (blockNode is JsonObject block) - { - text.Append(ReadStringField(block, "text")); - } - } - } - - return new ProviderChatResult(text.ToString(), ReadAnthropicUsage(payload["usage"])); - } - - // Возвращает usage OpenAI-совместимого ответа (prompt/completion/total_tokens) либо null. - // usageNode: Узел usage ответа. - private static ProviderUsage? ReadOpenAiUsage(JsonNode? usageNode) - { - if (usageNode is not JsonObject usage) - { - return null; - } - - int? promptTokens = ReadIntField(usage, "prompt_tokens"); - int? completionTokens = ReadIntField(usage, "completion_tokens"); - int? totalTokens = ReadIntField(usage, "total_tokens"); - return promptTokens is null || completionTokens is null || totalTokens is null - ? null - : new ProviderUsage(promptTokens.Value, completionTokens.Value, totalTokens.Value); - } - - // Возвращает usage Anthropic-ответа (input/output → total = сумма) либо null. - // usageNode: Узел usage ответа. - private static ProviderUsage? ReadAnthropicUsage(JsonNode? usageNode) - { - if (usageNode is not JsonObject usage) - { - return null; - } - - int? inputTokens = ReadIntField(usage, "input_tokens"); - int? outputTokens = ReadIntField(usage, "output_tokens"); - return inputTokens is null || outputTokens is null - ? null - : new ProviderUsage(inputTokens.Value, outputTokens.Value, inputTokens.Value + outputTokens.Value); - } - - // Читает тело как JSON-объект; при не-JSON/не-объекте — исключение (неожиданный ответ API). - // body: Тело ответа. - private static JsonObject ParseObjectOrThrow(string body) - { - try - { - if (JsonNode.Parse(body) is JsonObject payload) - { - return payload; - } - } - catch (System.Text.Json.JsonException) - { - // Ниже — общий текст «неожиданный ответ API». - } - - throw UnexpectedApiResponse("тело не является JSON-объектом"); - } - - private static LlmHttpException UnexpectedApiResponse(string failureKind) - => new($"Неожиданный ответ ИИ-провайдера: {failureKind}"); - - // Создаёт сообщение чата {role, content} (формат обоих API). - // role: Роль сообщения. - // content: Текст сообщения. - private static JsonObject ChatMessage(string role, string content) - => new() - { - ["role"] = role, - ["content"] = content, - }; - - // Создаёт JSON-содержимое запроса (application/json). - // body: Объект тела запроса. - private static StringContent JsonBody(JsonObject body) - => new(body.ToJsonString(), Encoding.UTF8, "application/json"); - - // Читает строковое поле объекта (null — поля нет/не строка). - // parent: Объект ответа. - // fieldName: Имя поля. - private static string? ReadStringField(JsonObject? parent, string fieldName) - { - if (parent is null || parent[fieldName] is not JsonValue value) - { - return null; - } - - return value.TryGetValue(out string? result) ? result : null; - } - - // Читает целочисленное поле объекта (null — поля нет/не число). - // parent: Объект ответа. - // fieldName: Имя поля. - private static int? ReadIntField(JsonObject parent, string fieldName) - { - if (parent[fieldName] is not JsonValue value || !value.TryGetValue(out int result)) - { - return null; - } - - return result; - } - - private static string NormalizeBaseUrl(string baseUrl) => baseUrl.TrimEnd('/'); -} +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json.Nodes; + +namespace Deal.Ai.Llm; + +/// +/// HTTP-реализация +/// +public sealed class LlmHttpClient : IProviderClient +{ + // Относительный путь OpenAI-совместимого эндпоинта (база уже без хвостового «/»). + private const string OpenAiCompletionsPath = "/chat/completions"; + + // Относительный путь Anthropic Messages API. + private const string AnthropicMessagesPath = "/v1/messages"; + + // Заголовок версии Anthropic API. + private const string AnthropicVersionHeader = "anthropic-version"; + + private const string AnthropicVersionValue = "2023-06-01"; + + // Заголовок ключа Anthropic API. + private const string AnthropicApiKeyHeader = "x-api-key"; + + private const double Temperature = 0.2; + + private const int MaxResponseTokens = 8000; + + private static readonly TimeSpan OpenAiCallTimeout = TimeSpan.FromSeconds(90); + + private static readonly TimeSpan AnthropicCallTimeout = TimeSpan.FromSeconds(60); + + private readonly HttpClient _httpClient; + private readonly TimeSpan _openAiCallTimeout; + private readonly TimeSpan _anthropicCallTimeout; + + /// + /// Создаёт HTTP-клиент провайдеров с типовыми таймаутами + /// + /// HttpClient (регистрируется в DI; таймаут управляется на попытку). + public LlmHttpClient(HttpClient httpClient) + : this(httpClient, OpenAiCallTimeout, AnthropicCallTimeout) + { + } + + // Создаёт HTTP-клиент с таймаутами для тестов (без реальной сети). + // httpClient: HttpClient над фейковым обработчиком. + // openAiCallTimeout: Таймаут OpenAI-совместимой попытки. + // anthropicCallTimeout: Таймаут Anthropic-попытки. + internal LlmHttpClient( + HttpClient httpClient, + TimeSpan openAiCallTimeout, + TimeSpan anthropicCallTimeout) + { + _httpClient = httpClient; + _openAiCallTimeout = openAiCallTimeout; + _anthropicCallTimeout = anthropicCallTimeout; + } + + /// + /// Выполняет один вызов модели по выбранной схеме API. + /// + /// Конфиг провайдера (стиль — ApiStyle). + /// Системный промпт. + /// Пользовательское сообщение/контекст. + /// Текст ответа и usage API-ответа (null при его отсутствии). + public async Task ChatAsync( + LlmConfig config, + string systemPrompt, + string userText, + CancellationToken cancellationToken) + { + TimeSpan timeout = config.IsAnthropic ? _anthropicCallTimeout : _openAiCallTimeout; + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(timeout); + + try + { + using HttpRequestMessage request = config.IsAnthropic + ? BuildAnthropicRequest(config, systemPrompt, userText) + : BuildOpenAiRequest(config, systemPrompt, userText); + + using HttpResponseMessage response = await _httpClient + .SendAsync(request, timeoutSource.Token) + .ConfigureAwait(false); + + return await ReadResponseAsync(response, config.IsAnthropic, timeoutSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new LlmHttpException($"Таймаут вызова ИИ-провайдера: {timeout.TotalSeconds:0} с"); + } + catch (HttpRequestException httpError) + { + throw new LlmHttpException($"Сетевая ошибка вызова ИИ-провайдера: {httpError.Message}", httpError); + } + } + + private static HttpRequestMessage BuildOpenAiRequest( + LlmConfig config, + string systemPrompt, + string userText) + { + var request = new HttpRequestMessage(HttpMethod.Post, NormalizeBaseUrl(config.BaseUrl) + OpenAiCompletionsPath); + if (!string.IsNullOrEmpty(config.ApiKey)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.ApiKey); + } + + var body = new JsonObject + { + ["model"] = config.Model, + ["messages"] = new JsonArray( + ChatMessage("system", systemPrompt), + ChatMessage("user", userText)), + ["temperature"] = Temperature, + ["max_tokens"] = MaxResponseTokens, + }; + + request.Content = JsonBody(body); + return request; + } + + private static HttpRequestMessage BuildAnthropicRequest( + LlmConfig config, + string systemPrompt, + string userText) + { + var request = new HttpRequestMessage(HttpMethod.Post, NormalizeBaseUrl(config.BaseUrl) + AnthropicMessagesPath); + request.Headers.TryAddWithoutValidation(AnthropicApiKeyHeader, config.ApiKey ?? string.Empty); + request.Headers.TryAddWithoutValidation(AnthropicVersionHeader, AnthropicVersionValue); + + var body = new JsonObject + { + ["model"] = config.Model, + ["max_tokens"] = MaxResponseTokens, + ["system"] = systemPrompt, + ["messages"] = new JsonArray(ChatMessage("user", userText)), + }; + + request.Content = JsonBody(body); + return request; + } + + // Читает ответ HTTP: статус ≠ 2xx или неожиданная форма — LlmHttpException (повод + // для ретрая); иначе текст ответа + usage из тела (см. парсеры стилей). + // response: Ответ провайдера. + // isAnthropic: Стиль ответа (Anthropic vs OpenAI-совместимый). + // cancellationToken: Токен отмены попытки. + private static async Task ReadResponseAsync( + HttpResponseMessage response, + bool isAnthropic, + CancellationToken cancellationToken) + { + if (!response.IsSuccessStatusCode) + { + throw new LlmHttpException( + $"ИИ-провайдер вернул HTTP {(int)response.StatusCode} {response.ReasonPhrase}"); + } + + string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return isAnthropic ? ReadAnthropicBody(body) : ReadOpenAiBody(body); + } + + private static ProviderChatResult ReadOpenAiBody(string body) + { + JsonObject? payload = ParseObjectOrThrow(body); + JsonArray? choices = payload["choices"] as JsonArray; + if (choices is null || choices.Count == 0) + { + // Тип сбоя без содержимого тела (замечание code-review: тело ответа наружу/в лог не уходит). + throw UnexpectedApiResponse("пустой или отсутствующий список choices"); + } + + // choices[i] — объект выбора {message, finish_reason, …}; текст — в message.content. + JsonObject? message = (choices[0] as JsonObject)?["message"] as JsonObject; + string? content = ReadStringField(message, "content"); + if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, "reasoning_content"))) + { + throw new LlmHttpException("Модель вернула только reasoning без ответа"); + } + + return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload["usage"])); + } + + private static ProviderChatResult ReadAnthropicBody(string body) + { + JsonObject? payload = ParseObjectOrThrow(body); + + var text = new StringBuilder(); + if (payload["content"] is JsonArray contentBlocks) + { + foreach (JsonNode? blockNode in contentBlocks) + { + if (blockNode is JsonObject block) + { + text.Append(ReadStringField(block, "text")); + } + } + } + + return new ProviderChatResult(text.ToString(), ReadAnthropicUsage(payload["usage"])); + } + + // Возвращает usage OpenAI-совместимого ответа (prompt/completion/total_tokens) либо null. + // usageNode: Узел usage ответа. + private static ProviderUsage? ReadOpenAiUsage(JsonNode? usageNode) + { + if (usageNode is not JsonObject usage) + { + return null; + } + + int? promptTokens = ReadIntField(usage, "prompt_tokens"); + int? completionTokens = ReadIntField(usage, "completion_tokens"); + int? totalTokens = ReadIntField(usage, "total_tokens"); + return promptTokens is null || completionTokens is null || totalTokens is null + ? null + : new ProviderUsage(promptTokens.Value, completionTokens.Value, totalTokens.Value); + } + + // Возвращает usage Anthropic-ответа (input/output → total = сумма) либо null. + // usageNode: Узел usage ответа. + private static ProviderUsage? ReadAnthropicUsage(JsonNode? usageNode) + { + if (usageNode is not JsonObject usage) + { + return null; + } + + int? inputTokens = ReadIntField(usage, "input_tokens"); + int? outputTokens = ReadIntField(usage, "output_tokens"); + return inputTokens is null || outputTokens is null + ? null + : new ProviderUsage(inputTokens.Value, outputTokens.Value, inputTokens.Value + outputTokens.Value); + } + + // Читает тело как JSON-объект; при не-JSON/не-объекте — исключение (неожиданный ответ API). + // body: Тело ответа. + private static JsonObject ParseObjectOrThrow(string body) + { + try + { + if (JsonNode.Parse(body) is JsonObject payload) + { + return payload; + } + } + catch (System.Text.Json.JsonException) + { + // Ниже — общий текст «неожиданный ответ API». + } + + throw UnexpectedApiResponse("тело не является JSON-объектом"); + } + + private static LlmHttpException UnexpectedApiResponse(string failureKind) + => new($"Неожиданный ответ ИИ-провайдера: {failureKind}"); + + // Создаёт сообщение чата {role, content} (формат обоих API). + // role: Роль сообщения. + // content: Текст сообщения. + private static JsonObject ChatMessage(string role, string content) + => new() + { + ["role"] = role, + ["content"] = content, + }; + + // Создаёт JSON-содержимое запроса (application/json). + // body: Объект тела запроса. + private static StringContent JsonBody(JsonObject body) + => new(body.ToJsonString(), Encoding.UTF8, "application/json"); + + // Читает строковое поле объекта (null — поля нет/не строка). + // parent: Объект ответа. + // fieldName: Имя поля. + private static string? ReadStringField(JsonObject? parent, string fieldName) + { + if (parent is null || parent[fieldName] is not JsonValue value) + { + return null; + } + + return value.TryGetValue(out string? result) ? result : null; + } + + // Читает целочисленное поле объекта (null — поля нет/не число). + // parent: Объект ответа. + // fieldName: Имя поля. + private static int? ReadIntField(JsonObject parent, string fieldName) + { + if (parent[fieldName] is not JsonValue value || !value.TryGetValue(out int result)) + { + return null; + } + + return result; + } + + private static string NormalizeBaseUrl(string baseUrl) => baseUrl.TrimEnd('/'); +} diff --git a/src/ai-service/Deal.Ai/Llm/LlmHttpException.cs b/src/ai-service/Deal.Ai/Llm/LlmHttpException.cs index 086ffa1..361a600 100644 --- a/src/ai-service/Deal.Ai/Llm/LlmHttpException.cs +++ b/src/ai-service/Deal.Ai/Llm/LlmHttpException.cs @@ -1,26 +1,26 @@ -namespace Deal.Ai.Llm; - -/// -/// Ошибка одной HTTP-попытки вызова провайдера -/// -public sealed class LlmHttpException : Exception -{ - /// - /// Создаёт ошибку HTTP-попытки с текстом причины. - /// - /// Краткое описание (без ключей и секретов). - public LlmHttpException(string message) - : base(message) - { - } - - /// - /// Создаёт ошибку HTTP-попытки с текстом причины и внутренним исключением. - /// - /// Краткое описание (без ключей и секретов). - /// Исключение-источник (транспортная ошибка). - public LlmHttpException(string message, Exception innerException) - : base(message, innerException) - { - } -} +namespace Deal.Ai.Llm; + +/// +/// Ошибка одной HTTP-попытки вызова провайдера +/// +public sealed class LlmHttpException : Exception +{ + /// + /// Создаёт ошибку HTTP-попытки с текстом причины. + /// + /// Краткое описание (без ключей и секретов). + public LlmHttpException(string message) + : base(message) + { + } + + /// + /// Создаёт ошибку HTTP-попытки с текстом причины и внутренним исключением. + /// + /// Краткое описание (без ключей и секретов). + /// Исключение-источник (транспортная ошибка). + public LlmHttpException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/ai-service/Deal.Ai/Llm/LlmRetryPolicy.cs b/src/ai-service/Deal.Ai/Llm/LlmRetryPolicy.cs index 6c1c04d..471bcd9 100644 --- a/src/ai-service/Deal.Ai/Llm/LlmRetryPolicy.cs +++ b/src/ai-service/Deal.Ai/Llm/LlmRetryPolicy.cs @@ -1,26 +1,26 @@ -namespace Deal.Ai.Llm; - -/// -/// Политика ретраев вызова LLM -/// -public static class LlmRetryPolicy -{ - /// - /// Число дополнительных попыток после первой - /// - public const int RetryCount = 2; - - /// - /// Общее число попыток вызова. - /// - public const int AttemptCount = RetryCount + 1; - - /// - /// Паузы между попытками - /// - public static readonly IReadOnlyList RetryDelays = - [ - TimeSpan.FromMilliseconds(800), - TimeSpan.FromSeconds(2), - ]; -} +namespace Deal.Ai.Llm; + +/// +/// Политика ретраев вызова LLM +/// +public static class LlmRetryPolicy +{ + /// + /// Число дополнительных попыток после первой + /// + public const int RetryCount = 2; + + /// + /// Общее число попыток вызова. + /// + public const int AttemptCount = RetryCount + 1; + + /// + /// Паузы между попытками + /// + public static readonly IReadOnlyList RetryDelays = + [ + TimeSpan.FromMilliseconds(800), + TimeSpan.FromSeconds(2), + ]; +} diff --git a/src/ai-service/Deal.Ai/Llm/LlmUsage.cs b/src/ai-service/Deal.Ai/Llm/LlmUsage.cs index ba6b778..1a4c368 100644 --- a/src/ai-service/Deal.Ai/Llm/LlmUsage.cs +++ b/src/ai-service/Deal.Ai/Llm/LlmUsage.cs @@ -1,9 +1,9 @@ -namespace Deal.Ai.Llm; - -/// -/// Итоговая оценка токенов вызова для gRPC-ответа -/// -/// Токены запроса (система + пользователь). -/// Токены ответа модели. -/// Суммарно (по данным провайдера — как есть). -public sealed record LlmUsage(int PromptTokens, int CompletionTokens, int TotalTokens); +namespace Deal.Ai.Llm; + +/// +/// Итоговая оценка токенов вызова для gRPC-ответа +/// +/// Токены запроса (система + пользователь). +/// Токены ответа модели. +/// Суммарно (по данным провайдера — как есть). +public sealed record LlmUsage(int PromptTokens, int CompletionTokens, int TotalTokens); diff --git a/src/ai-service/Deal.Ai/Llm/ProviderCaller.cs b/src/ai-service/Deal.Ai/Llm/ProviderCaller.cs index d1a8a87..2e9566d 100644 --- a/src/ai-service/Deal.Ai/Llm/ProviderCaller.cs +++ b/src/ai-service/Deal.Ai/Llm/ProviderCaller.cs @@ -1,90 +1,90 @@ -using System.Text.Json.Nodes; - -namespace Deal.Ai.Llm; - -/// -/// Оркестратор вызова модели с ретраями и извлечением JSON -/// -public sealed class ProviderCaller -{ - private readonly IProviderClient _client; - private readonly ILogger _logger; - private readonly Func _retryDelayAsync; - - /// - /// Создаёт оркестратор вызовов с заданной функцией паузы между попытками. - /// - /// HTTP-клиент одной попытки (в тестах — фейк). - /// Функция паузы между попытками (в тестах — мгновенная). - /// Логгер (ключи и текст промптов в логи не попадают). - public ProviderCaller( - IProviderClient client, - Func retryDelayAsync, - ILogger logger) - { - _client = client; - _retryDelayAsync = retryDelayAsync; - _logger = logger; - } - - /// - /// Вызывает модель по системному и пользовательскому сообщениям и извлекает JSON-объект ответа. - /// - /// Конфиг активного провайдера. - /// Системный промпт (заполненный ядром или фиксированный сервиса). - /// Пользовательское сообщение/контекст. - /// Извлечённый JSON-объект и итоговую оценку токенов. - /// Все попытки исчерпаны (см. ). - public async Task ChatJsonAsync( - LlmConfig config, - string systemPrompt, - string userText, - CancellationToken cancellationToken) - { - string promptText = systemPrompt + userText; - string? lastModelText = null; - ProviderUsage? lastUsage = null; - LlmHttpException? lastHttpError = null; - - for (int attempt = 0; attempt < LlmRetryPolicy.AttemptCount; attempt++) - { - try - { - ProviderChatResult result = await _client - .ChatAsync(config, systemPrompt, userText, cancellationToken) - .ConfigureAwait(false); - - lastModelText = result.Text; - lastUsage = result.Usage; - - JsonObject? json = JsonExtractor.TryExtractObject(result.Text); - if (json is not null) - { - return new LlmCallResult(json, TokenEstimator.Resolve(result.Usage, promptText, result.Text)); - } - } - catch (LlmHttpException httpError) - { - lastHttpError = httpError; - } - - if (attempt < LlmRetryPolicy.RetryCount) - { - await _retryDelayAsync(LlmRetryPolicy.RetryDelays[attempt], cancellationToken).ConfigureAwait(false); - } - } - - if (lastModelText is not null) - { - LlmUsage usage = TokenEstimator.Resolve(lastUsage, promptText, lastModelText); - throw new LlmCallException(LlmCallFailureKind.AnswerNotJson, config.DisplayName, usage); - } - - _logger.LogWarning( - "ИИ ({Provider}) не ответил корректно после {Attempts} попыток: {Error}", - config.DisplayName, - LlmRetryPolicy.AttemptCount, - lastHttpError?.Message ?? "пустой ответ"); - throw new LlmCallException(LlmCallFailureKind.ProviderUnavailable, config.DisplayName); - } -} +using System.Text.Json.Nodes; + +namespace Deal.Ai.Llm; + +/// +/// Оркестратор вызова модели с ретраями и извлечением JSON +/// +public sealed class ProviderCaller +{ + private readonly IProviderClient _client; + private readonly ILogger _logger; + private readonly Func _retryDelayAsync; + + /// + /// Создаёт оркестратор вызовов с заданной функцией паузы между попытками. + /// + /// HTTP-клиент одной попытки (в тестах — фейк). + /// Функция паузы между попытками (в тестах — мгновенная). + /// Логгер (ключи и текст промптов в логи не попадают). + public ProviderCaller( + IProviderClient client, + Func retryDelayAsync, + ILogger logger) + { + _client = client; + _retryDelayAsync = retryDelayAsync; + _logger = logger; + } + + /// + /// Вызывает модель по системному и пользовательскому сообщениям и извлекает JSON-объект ответа. + /// + /// Конфиг активного провайдера. + /// Системный промпт (заполненный ядром или фиксированный сервиса). + /// Пользовательское сообщение/контекст. + /// Извлечённый JSON-объект и итоговую оценку токенов. + /// Все попытки исчерпаны (см. ). + public async Task ChatJsonAsync( + LlmConfig config, + string systemPrompt, + string userText, + CancellationToken cancellationToken) + { + string promptText = systemPrompt + userText; + string? lastModelText = null; + ProviderUsage? lastUsage = null; + LlmHttpException? lastHttpError = null; + + for (int attempt = 0; attempt < LlmRetryPolicy.AttemptCount; attempt++) + { + try + { + ProviderChatResult result = await _client + .ChatAsync(config, systemPrompt, userText, cancellationToken) + .ConfigureAwait(false); + + lastModelText = result.Text; + lastUsage = result.Usage; + + JsonObject? json = JsonExtractor.TryExtractObject(result.Text); + if (json is not null) + { + return new LlmCallResult(json, TokenEstimator.Resolve(result.Usage, promptText, result.Text)); + } + } + catch (LlmHttpException httpError) + { + lastHttpError = httpError; + } + + if (attempt < LlmRetryPolicy.RetryCount) + { + await _retryDelayAsync(LlmRetryPolicy.RetryDelays[attempt], cancellationToken).ConfigureAwait(false); + } + } + + if (lastModelText is not null) + { + LlmUsage usage = TokenEstimator.Resolve(lastUsage, promptText, lastModelText); + throw new LlmCallException(LlmCallFailureKind.AnswerNotJson, config.DisplayName, usage); + } + + _logger.LogWarning( + "ИИ ({Provider}) не ответил корректно после {Attempts} попыток: {Error}", + config.DisplayName, + LlmRetryPolicy.AttemptCount, + lastHttpError?.Message ?? "пустой ответ"); + throw new LlmCallException(LlmCallFailureKind.ProviderUnavailable, config.DisplayName); + } +} diff --git a/src/ai-service/Deal.Ai/Llm/ProviderChatResult.cs b/src/ai-service/Deal.Ai/Llm/ProviderChatResult.cs index 384df24..cb17f2e 100644 --- a/src/ai-service/Deal.Ai/Llm/ProviderChatResult.cs +++ b/src/ai-service/Deal.Ai/Llm/ProviderChatResult.cs @@ -1,8 +1,8 @@ -namespace Deal.Ai.Llm; - -/// -/// Результат одной успешной HTTP-попытки вызова модели -/// -/// Текст ответа модели (может быть не-JSON — разбор в ). -/// Usage из API-ответа провайдера; null — провайдер его не вернул (оценка по символам). -public sealed record ProviderChatResult(string Text, ProviderUsage? Usage); +namespace Deal.Ai.Llm; + +/// +/// Результат одной успешной HTTP-попытки вызова модели +/// +/// Текст ответа модели (может быть не-JSON — разбор в ). +/// Usage из API-ответа провайдера; null — провайдер его не вернул (оценка по символам). +public sealed record ProviderChatResult(string Text, ProviderUsage? Usage); diff --git a/src/ai-service/Deal.Ai/Llm/ProviderUsage.cs b/src/ai-service/Deal.Ai/Llm/ProviderUsage.cs index a7d4207..9988155 100644 --- a/src/ai-service/Deal.Ai/Llm/ProviderUsage.cs +++ b/src/ai-service/Deal.Ai/Llm/ProviderUsage.cs @@ -1,9 +1,9 @@ -namespace Deal.Ai.Llm; - -/// -/// Usage токенов из API-ответа провайдера. -/// -/// Токены запроса (система + пользователь). -/// Токены ответа модели. -/// Суммарно по данным провайдера (Anthropic: input + output). -public sealed record ProviderUsage(int PromptTokens, int CompletionTokens, int TotalTokens); +namespace Deal.Ai.Llm; + +/// +/// Usage токенов из API-ответа провайдера. +/// +/// Токены запроса (система + пользователь). +/// Токены ответа модели. +/// Суммарно по данным провайдера (Anthropic: input + output). +public sealed record ProviderUsage(int PromptTokens, int CompletionTokens, int TotalTokens); diff --git a/src/ai-service/Deal.Ai/Llm/TokenEstimator.cs b/src/ai-service/Deal.Ai/Llm/TokenEstimator.cs index 1ffa1bc..f17394e 100644 --- a/src/ai-service/Deal.Ai/Llm/TokenEstimator.cs +++ b/src/ai-service/Deal.Ai/Llm/TokenEstimator.cs @@ -1,35 +1,35 @@ -namespace Deal.Ai.Llm; - -/// -/// Оценка токенов вызова -/// -public static class TokenEstimator -{ - private const int EstimatedCharsPerToken = 4; - - /// - /// Сводит usage вызова - /// - /// Usage из API-ответа (null — провайдер его не вернул). - /// Полный текст запроса (система + пользователь) для оценки. - /// Текст ответа модели для оценки. - public static LlmUsage Resolve( - ProviderUsage? providerUsage, - string promptText, - string completionText) - { - if (providerUsage is not null) - { - return new LlmUsage(providerUsage.PromptTokens, providerUsage.CompletionTokens, providerUsage.TotalTokens); - } - - int promptTokens = EstimateTokens(promptText.Length); - int completionTokens = EstimateTokens(completionText.Length); - return new LlmUsage(promptTokens, completionTokens, promptTokens + completionTokens); - } - - // Оценка токенов по числу символов: ≈ceil(chars/4). - // charCount: Число символов текста. - private static int EstimateTokens(int charCount) - => (charCount + EstimatedCharsPerToken - 1) / EstimatedCharsPerToken; -} +namespace Deal.Ai.Llm; + +/// +/// Оценка токенов вызова +/// +public static class TokenEstimator +{ + private const int EstimatedCharsPerToken = 4; + + /// + /// Сводит usage вызова + /// + /// Usage из API-ответа (null — провайдер его не вернул). + /// Полный текст запроса (система + пользователь) для оценки. + /// Текст ответа модели для оценки. + public static LlmUsage Resolve( + ProviderUsage? providerUsage, + string promptText, + string completionText) + { + if (providerUsage is not null) + { + return new LlmUsage(providerUsage.PromptTokens, providerUsage.CompletionTokens, providerUsage.TotalTokens); + } + + int promptTokens = EstimateTokens(promptText.Length); + int completionTokens = EstimateTokens(completionText.Length); + return new LlmUsage(promptTokens, completionTokens, promptTokens + completionTokens); + } + + // Оценка токенов по числу символов: ≈ceil(chars/4). + // charCount: Число символов текста. + private static int EstimateTokens(int charCount) + => (charCount + EstimatedCharsPerToken - 1) / EstimatedCharsPerToken; +} diff --git a/src/ai-service/Deal.Ai/Program.cs b/src/ai-service/Deal.Ai/Program.cs index 7c2ffda..08b6a63 100644 --- a/src/ai-service/Deal.Ai/Program.cs +++ b/src/ai-service/Deal.Ai/Program.cs @@ -1,36 +1,36 @@ -// - -using Deal.Ai; -using Deal.Grpc.Hosting.Interceptors; -using Deal.Grpc.Hosting.Models; -using Deal.Grpc.Hosting.Options; -using Deal.Grpc.Hosting.Services; - -const int defaultGrpcPort = 5102; -const string aiProcessName = "ai"; - -int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort); -int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); - -WebApplication app = AiServiceHost.Create( - grpcPort, - configureBuilder: builder => - { - DealLogging.Configure(builder, aiProcessName); - DealMetricsHosting.AddDealMetrics(builder, metricsPort); - }); - -DealMetricsHosting.MapDealMetrics(app); - -MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration); - -// Fail-closed (замечание code-review): отсутствие/опечатка DEAL_MTLS_ENABLED не должны давать -// «тихого» plaintext в Production; Development (и прочие не-prod окружения) — как раньше. -GrpcHostEnvironment.RequireMtlsInProduction(mtlsOptions); - -app.Logger.LogInformation( - "ai-service стартует: gRPC {Transport} 0.0.0.0:{Port} (health /grpc.health.v1.Health/Check)", - mtlsOptions.Enabled ? "mTLS (TLS + клиентский сертификат)" : "plaintext + service-token", - grpcPort); - -await app.RunAsync(); +// + +using Deal.Ai; +using Deal.Grpc.Hosting.Interceptors; +using Deal.Grpc.Hosting.Models; +using Deal.Grpc.Hosting.Options; +using Deal.Grpc.Hosting.Services; + +const int defaultGrpcPort = 5102; +const string aiProcessName = "ai"; + +int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort); +int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); + +WebApplication app = AiServiceHost.Create( + grpcPort, + configureBuilder: builder => + { + DealLogging.Configure(builder, aiProcessName); + DealMetricsHosting.AddDealMetrics(builder, metricsPort); + }); + +DealMetricsHosting.MapDealMetrics(app); + +MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration); + +// Fail-closed (замечание code-review): отсутствие/опечатка DEAL_MTLS_ENABLED не должны давать +// «тихого» plaintext в Production; Development (и прочие не-prod окружения) — как раньше. +GrpcHostEnvironment.RequireMtlsInProduction(mtlsOptions); + +app.Logger.LogInformation( + "ai-service стартует: gRPC {Transport} 0.0.0.0:{Port} (health /grpc.health.v1.Health/Check)", + mtlsOptions.Enabled ? "mTLS (TLS + клиентский сертификат)" : "plaintext + service-token", + grpcPort); + +await app.RunAsync(); diff --git a/src/ai-service/Directory.Build.props b/src/ai-service/Directory.Build.props index b2a7175..0d969a1 100644 --- a/src/ai-service/Directory.Build.props +++ b/src/ai-service/Directory.Build.props @@ -1,11 +1,11 @@ - - - net10.0 - latest - enable - enable - true - latest - true - - + + + net10.0 + latest + enable + enable + true + latest + true + + diff --git a/src/contracts/Deal.Proto.csproj b/src/contracts/Deal.Proto.csproj index b70c81a..fb55a0f 100644 --- a/src/contracts/Deal.Proto.csproj +++ b/src/contracts/Deal.Proto.csproj @@ -1,48 +1,48 @@ - - - - - net10.0 - latest - enable - enable - true - latest - true - Deal.Proto - Deal.Proto - false - - - - - - - - - - - - - - - - - - - - - + + + + + net10.0 + latest + enable + enable + true + latest + true + Deal.Proto + Deal.Proto + false + + + + + + + + + + + + + + + + + + + + + diff --git a/src/contracts/README.md b/src/contracts/README.md index 6ff600a..b56824c 100644 --- a/src/contracts/README.md +++ b/src/contracts/README.md @@ -1,204 +1,204 @@ -# Контракты gRPC этапа 6 (`src/contracts`) - -Контракты между ядром Deal и сервисами telegram/ml/ai (отдельные процессы). -Источники: дизайн-док §6.2 (L145–152), план «Дейл — Этап 6: Сервисы …» -(Task 1, Rulings 1/3/5/7), api-map §3.3/§3.7/§3.8/§4.8/§4.9/§4.10, прототип -`backend/app/services/*.py` и `mlservice/model.py`. - -Контракты — **единственный «язык» между процессами** (Ruling 1): сервисы не -делят с ядром ничего, кроме этих `.proto` и NuGet. - -| Файл | Пакет | `csharp_namespace` | Сервисы | -|---|---|---|---| -| `telegram.proto` | `deal.telegram.v1` | `Deal.Grpc.Telegram` | `TelegramService`, `IngressService` | -| `ml.proto` | `deal.ml.v1` | `Deal.Grpc.Ml` | `MlService` | -| `ai.proto` | `deal.ai.v1` | `Deal.Grpc.Ai` | `AiService` | - -## Кодогенерация - -- `Deal.Proto.csproj` — общий classlib: компилирует все три `.proto` через - `Grpc.Tools` (``), т.е. генерирует - и клиент, и сервер каждого сервиса в одном проходе (неиспользуемая сторона - игнорируется потребителем). Сборка проекта = валидация `.proto` (protoc) и - первый прогон кодогенерации (Task 1); Task 2–4 подключают файлы к сервисам - `ProjectReference`-ом либо ``. -- Пакеты: `Grpc.Tools` (PrivateAssets), `Google.Protobuf`, `Grpc.Core.Api`. - Транспорты — по месту: `Grpc.AspNetCore` (серверы), `Grpc.Net.Client`/клиенты. -- Сгенерированные типы: `obj/…/{Telegram,TelegramGrpc,Ml,MlGrpc,Ai,AiGrpc}.cs`, - пространства имён из `option csharp_namespace`. -- Проекты-потребители: core (клиенты ML/AI; telegram-клиент-гейт + сервер - ингресса), telegram-service/ml-service/ai-service — добавляются в Task 2–4+. - -## Metadata (все RPC, обязательны) - -| Заголовок | Значение | Отказ | -|---|---|---| -| `tenant-id` | id тенанта (строка). **Единственный** источник принадлежности; полю в теле не доверяем | отсутствует → `UNAUTHENTICATED` (для ингресса core: `SetTenant` из metadata) | -| `service-token` | общий токен сервисов (env `DEAL_SERVICE_TOKEN`, общий в compose) | пустой/неверный → `UNAUTHENTICATED` | - -Интерцептор `service-token` — общий шаблон в каждом процессе (Ruling 1/2); -каждый сервис дополнительно проверяет принадлежность по своей модели (сессия/ -модель тенанта есть, иначе `NOT_FOUND`/`FAILED_PRECONDITION`). - -## Коды ошибок (общие) - -Ошибки домена — gRPC-статусы, `detail` = текст причины 1:1 с прототипом -(строки «Telegram не подключён», «Неверный код», «Код истёк — запросите новый», -«Неверный облачный пароль», «ИИ (имя) не ответил корректно — …» и т.д.): - -| Статус | Когда | -|---|---| -| `INVALID_ARGUMENT` | невалидный ввод/код/пароль/username, пустой текст | -| `NOT_FOUND` | диалог/сущность/сессия тенанта не найдены | -| `FAILED_PRECONDITION` | операция невозможна в текущей фазе (нет сессии и т.п.) | -| `RESOURCE_EXHAUSTED` | FloodWait Telegram (detail начинается с префикса `flood`) | -| `UNAVAILABLE` | недоступен Telegram/LLM-провайдер/хранилище модели (безопасный повтор/фолбэк) | -| `UNAUTHENTICATED` | неверный/отсутствующий `service-token` | - -«Мягкие» сценарии НЕ являются ошибками RPC: Predict неготовой модели («не -уверен»), ReadForEval без истории (`ok=false, error="no_history"`), сброс с -`ok=false,error`, `filter` с решением pass/reason. - -## Deadlines (клиент) - -| Сфера | Рекомендация | Обоснование | -|---|---|---| -| telegram: GetStatus/SetMonitor/SetMonitorAll/Logout | 10 с | локальная сеть/статус | -| telegram: StartPhone/StartQr/SendCode/SendPassword/Search/GetInfo/ReadRecent/ReadForEval/Join/Leave | 60 с | сетевые операции Telegram (паузы анти-бана 2–4 с поиск) | -| telegram: RefreshDialogs/Backfill | 120 с | iter_dialogs 500; backfill 10 сообщ. × 1.5–3 с + 3–6 с/диалог | -| ingress: PushMessage/SyncDialogs/ReportStatus | 10 с | локальная сеть; упущенное догоняет realtime-sweep | -| ml: Predict | 5 с | локальная модель | -| ml: Status/Reset | 10 с | локально | -| ml: TrainBatch | 30 с | батч ≤100, 1 транзакция | -| ai: все RPC | 120 с | провайдер 90/60 с + ретраи 0.8/2 с | - ---- - -## `telegram.proto` - -Два сервиса: команды ядра → сервис (`TelegramService`) и поток сервис → ядро -(`IngressService`, gRPC-сервер в Deal.Api :5082, Ruling 7). - -### Словари значений - -- `phase`: `idle | phone | code | password | qr | ready` (status() L85). -- `kind`: `channel | group | forum | chat` (канон контракта; 1:1 `_kind_of` - L461–466: broadcast → channel, megagroup/gigagroup/group → group, остальное → - chat; `forum` — отдельный флаг `is_forum` в GetInfo, в каталоге форум приходит - как group). Discovery-коды `channel/group/forum` ядро получает из kind+is_forum. -- `error`/`qr_url`/`account` — `optional` (presence): пустое = нет ошибки/URL. - -### Маппинги на HTTP-контракт (заметка для T13/14, код не меняется) - -Внутренний канон контракта `kind` — EN (`channel/group/forum/chat`). Граница -HTTP-эндпоинтов (api-map §4.8 L349, замороженный контракт фронта) НЕ 1:1: -- `GET /api/tg/dialogs` → `item.type` остаётся **русским** («канал»/«группа»/ - «чат»), как в прототипе (`refresh_dialogs`/`list_dialogs`): на границе - эндпоинта каналов (T13/14) нужен обратный маппинг EN → RU - (channel→«канал», group/forum→«группа», chat→«чат»; forum в списке диалогов - не встречается — каталог приносит его как group). -- Discovery: `candidate.type` (`kind`) — **EN** (`channel/group/forum`), как в - прототипе (db.py L162–166, `_kind_code`); маппинг на границе НЕ нужен. - -### TelegramService (ядро — клиент, сервис — сервер) - -| RPC | Запрос | Ответ | Ошибки / примечания | -|---|---|---|---| -| `GetStatus` | `GetStatusRequest` (пуст) | `GetStatusReply{phase,connected,listener,account,error?,qr_url?}` | нет сессии → FAILED_PRECONDITION «Telegram не подключён». live-поля для `GET /api/tg/status`; monitored/keysSet ядро считает само (Ruling 8) | -| `StartPhone` | `StartPhoneRequest{phone, api_id, api_hash}` | `StartPhoneReply{phase}` | ключи tgKeys передаёт ядро (Ruling 3); нет ключей → INVALID_ARGUMENT «Сначала сохраните…»; ответ фаза `code` | -| `StartQr` | `StartQrRequest{api_id, api_hash}` | `StartQrReply{phase, qr_url}` | фаза `qr` + url; уже авторизован → `ready`, url пуст | -| `SendCode` | `SendCodeRequest{code}` | `SendCodeReply{phase}` | «Неверный код»/«Код истёк…» → INVALID_ARGUMENT; 2FA → фаза `password` | -| `SendPassword` | `SendPasswordRequest{password}` | `SendPasswordReply{phase}` | «Неверный облачный пароль» → INVALID_ARGUMENT; ответ `ready` | -| `Logout` | `LogoutRequest` (пуст) | `LogoutReply{ok}` | отключение + удаление сессии тенанта | -| `RefreshDialogs` | `RefreshDialogsRequest` (пуст) | `RefreshDialogsReply{entries: DialogEntry[]}` | каталог диалогов; применять ядру через Ingress.SyncDialogs-семантику (`SyncFromTelegram`) | -| `SetMonitor` | `SetMonitorRequest{dialog_id, enabled}` | `SetMonitorReply{ok, enabled}` | зеркало monitored в сервисе; первый backfill запускает ядро | -| `SetMonitorAll` | `SetMonitorAllRequest{enabled}` | `SetMonitorAllReply{ok, count, enabled}` | count = диалогов в каталоге | -| `Backfill` | `BackfillRequest{dialog_id, force}` | `BackfillReply{processed}` | последние ~10 сообщений → поток PushMessage; паузы 1.5–3 с/сообщ. + mark-as-read; force = «Перечитать» | -| `ReadRecent` | `ReadRecentRequest{dialog_id, limit 1..50}` | `ReadRecentReply{messages: PreviewMessage[]}` | свежие из TG; lead и фолбэк на БД — в ядре | -| `Search` | `SearchRequest{query, limit}` | `SearchReply{results: DialogEntry[]}` | пауза анти-бана внутри сервиса; личные/ботов отсеивает ядро (Ruling 10) | -| `GetInfo` | `GetInfoRequest{dialog_id}` | `GetInfoReply{info: ChannelInfo}` | ChannelInfo{id,name,username,kind,hue,participants?,is_forum}; сбои full_chat не роняют RPC | -| `ReadForEval` | `ReadForEvalRequest{dialog_id, limit}` | `ReadForEvalReply{ok, error?, messages: EvalMessage[]}` | форумы — по темам; история скрыта → ok=false,error="no_history" (НЕ ошибка RPC) | -| `Join` | `JoinRequest{username}` | `JoinReply{ok}` | FloodWait → RESOURCE_EXHAUSTED (`flood`); ручной join вне квот | -| `Leave` | `LeaveRequest{dialog_id}` | `LeaveReply{ok}` | нет членства → NOT_FOUND | - -Общие сообщения: -- `DialogEntry{id,name,username,kind,hue}` — каталог/поиск (id подписанный: - каналы `-100…`, группы `-…`, личные `+…`; hue — палитра DIALOG_HUES). -- `ChannelInfo` = DialogEntry + `participants?` + `is_forum`. -- `PreviewMessage{id(text), text, time(ms)}` — превью (api-map §4.8 L351; `id` - строкой: int-сообщения TG и фолбэк `m__`). -- `EvalMessage{id(int64), text, date_ms, topic_id?, topic_title?}` — выборка - оценки кандидата (`_discovery_message_item`). - -### IngressService (сервис — клиент, ядро — сервер в Deal.Api, порт `GRPC_INGRESS_PORT` :5082) - -| RPC | Запрос | Ответ | Примечания | -|---|---|---|---| -| `PushMessage` | `PushMessageRequest{dialog_id, channel_name, channel_handle, channel_hue, msg_id?, text, msg_at?}` | `PushMessageReply{accepted, duplicate}` | 1:1 QueuedMessage/demo-ingest: EnqueueAsync + превью в TgMessages; дубль dialog+msgId → duplicate=true, очередь не растёт; нет msg_at → ядро подставит now; hue считает сервис | -| `SyncDialogs` | `SyncDialogsRequest{entries: DialogEntry[]}` | `SyncDialogsReply{monitored_ids[]}` | ядро: SyncFromTelegram (autoMonitorNew/обновление/удаление); ответ — актуальный зеркальный список monitored сервиса | -| `ReportStatus` | `ReportStatusRequest{phase,connected,listener,account,error?,qr_url?}` | `ReportStatusReply{ok}` | ядро: KV tgStatus/tgAccount + SSE system_status/тосты на переходах фаз | - ---- - -## `ml.proto` - -`MlService` — пул инкрементальных наивно-байесовских моделей per-tenant -(файл `data/ml/.sqlite`). Поля 1:1 с `MlPredictResultDto`/ -`MlServiceStatusDto` и model.py. - -| RPC | Запрос | Ответ | Примечания | -|---|---|---|---| -| `Predict` | `PredictRequest{text}` | `PredictReply{take,label?,scores: map,hits,ready,margin?,terms[],type?}` | «не уверен» при неготовой модели/пустом тексте — не ошибка; scores ≤5 лучших (round 3); margin адаптивный 0.9/0.7/0.5/0.35; terms ≤8; type — t:hire/t:order | -| `Status` | `StatusRequest` (пуст) | `StatusReply{ready,classes: map,learned,eval: ModelEval}` | classes «label → вес» round 2; модель создаётся лениво | -| `Reset` | `ResetRequest` (пуст) | `ResetReply{ok, error?}` | очистка classes/terms/eval_log + пересоздание файла; ok=false — мягкая ошибка (ядро чистит ml_outbox только при ok) | -| `TrainBatch` | `TrainBatchRequest{items: TrainExample[]}` | `TrainBatchReply{learned}` | 1 транзакция + пакетные вставки терминов (= learn_batch); learned = применено примеров | - -Сообщения: -- `TypeDecision{take,label("hire"|"order"),value("t:hire"|"t:order"),margin}` — - решение о типе заявки. -- `ModelEval{count,correct,accuracy}` — окно самооценки (EVAL_WINDOW последних - подтверждённых решений). -- `TrainExample{text,label,delta}` — строка обучения (ml_outbox): delta 1.0 - пользователь / −1.0 снять / 0.4–0.6 ИИ-правила. - -Пороги и константы (Ruling 4): MIN_TOTAL 20, MIN_WINNER 6, MIN_WINNER_SPAM 4, -MIN_HITS 2, MARGIN 0.9, типы `t:*` с MIN_TYPE_WINNER 4 — живут в ml-service -(реализация Task 5/6), в контракт не входят. - ---- - -## `ai.proto` - -`AiService` — фасад LLM-провайдеров без БД (Ruling 5). Ядро передаёт в теле -каждого запроса заполненные промпты/контекст + конфиг активного провайдера -(`provider_config`); сервис возвращает ответ модели + оценку токенов. - -| RPC | Запрос | Ответ | Примечания | -|---|---|---|---| -| `Filter` | `FilterRequest{prompt, text, provider_config}` | `FilterReply{pass, reason?, usage}` | prompt = заполненный aiFilterPrompt (ядро); «фильтр не применялся» обрабатывает ядро до вызова | -| `Classify` | `ClassifyRequest{system_prompt, user_context, provider_config}` | `ClassifyReply{ok, json?, usage}` | system_prompt = aiPrompt+cardPrompt, user_context = «Доски + примеры + Сообщение» (собирает ядро); json — сырой ответ модели строкой; строгий маппинг в карточку — ядро | -| `GenerateKeywords` | `GenerateKeywordsRequest{description, provider_config}` | `GenerateKeywordsReply{keywords[], usage}` | фикс. промпт (routes L36–47); очистку `_clean_keywords` и мягкие ошибки делает ядро (Ruling 11) | -| `EvaluateFit` | `EvaluateFitRequest{text, description, keywords[], provider_config}` | `EvaluateFitReply{fit, reason?, usage}` | промпт discovery_eval L50–54; ядро зовёт при aiEnabled, сбой → эвристика | - -`provider_config` — конфиг активного провайдера на запрос (Ruling 5 «в теле -каждого запроса»): ядро собирает эффективный конфиг (настройка `aiConfigs` -тенанта хранит `{apiKey, baseUrl, model}` в camelCase, ключ шифруется AES-GCM; -`apiStyle` — из каталога `AiProviders`) и передаёт в теле; сервис настроек не -хранит. Форма — сообщение `ProviderConfig`: - -| Поле | Обязательность | Описание | -|---|---|---| -| `provider_id` | да | Id провайдера (ключ `aiConfigs`/каталога: deepseek/openai/anthropic/ollama/lmstudio/custom…) | -| `base_url` | да | Эффективный базовый URL API (`aiConfigs.baseUrl` или дефолт каталога) | -| `api_key` | опц. | Ключ открытым текстом (расшифрован ядром); пуст у локальных провайдеров — заголовок не шлётся | -| `model` | да | Активная модель (`aiConfigs.model` или первая из каталога) | -| `api_style` | опц. | Стиль API: пуст — OpenAI-совместимый (`{base}/chat/completions`, Bearer); `"anthropic"` — Messages API (`{base}/v1/messages`, x-api-key + anthropic-version) | - -Сообщения: -- `Usage{prompt, completion, total}` — оценка токенов в каждом reply (из usage - API-ответа; при отсутствии ≈chars/4; ядро копит в KV `aiTokenUsage`). - -Ошибки: провайдер не ответил корректно после ретраев → `UNAVAILABLE` с detail -«ИИ (имя) не ответил корректно — повторите попытку через несколько секунд». -`ClassifyReply.ok=false` — ответ без разбираемого JSON (не RPC-ошибка); ядро -трактует как «не разобрано» и использует локальный путь. +# Контракты gRPC этапа 6 (`src/contracts`) + +Контракты между ядром Deal и сервисами telegram/ml/ai (отдельные процессы). +Источники: дизайн-док §6.2 (L145–152), план «Дейл — Этап 6: Сервисы …» +(Task 1, Rulings 1/3/5/7), api-map §3.3/§3.7/§3.8/§4.8/§4.9/§4.10, прототип +`backend/app/services/*.py` и `mlservice/model.py`. + +Контракты — **единственный «язык» между процессами** (Ruling 1): сервисы не +делят с ядром ничего, кроме этих `.proto` и NuGet. + +| Файл | Пакет | `csharp_namespace` | Сервисы | +|---|---|---|---| +| `telegram.proto` | `deal.telegram.v1` | `Deal.Grpc.Telegram` | `TelegramService`, `IngressService` | +| `ml.proto` | `deal.ml.v1` | `Deal.Grpc.Ml` | `MlService` | +| `ai.proto` | `deal.ai.v1` | `Deal.Grpc.Ai` | `AiService` | + +## Кодогенерация + +- `Deal.Proto.csproj` — общий classlib: компилирует все три `.proto` через + `Grpc.Tools` (``), т.е. генерирует + и клиент, и сервер каждого сервиса в одном проходе (неиспользуемая сторона + игнорируется потребителем). Сборка проекта = валидация `.proto` (protoc) и + первый прогон кодогенерации (Task 1); Task 2–4 подключают файлы к сервисам + `ProjectReference`-ом либо ``. +- Пакеты: `Grpc.Tools` (PrivateAssets), `Google.Protobuf`, `Grpc.Core.Api`. + Транспорты — по месту: `Grpc.AspNetCore` (серверы), `Grpc.Net.Client`/клиенты. +- Сгенерированные типы: `obj/…/{Telegram,TelegramGrpc,Ml,MlGrpc,Ai,AiGrpc}.cs`, + пространства имён из `option csharp_namespace`. +- Проекты-потребители: core (клиенты ML/AI; telegram-клиент-гейт + сервер + ингресса), telegram-service/ml-service/ai-service — добавляются в Task 2–4+. + +## Metadata (все RPC, обязательны) + +| Заголовок | Значение | Отказ | +|---|---|---| +| `tenant-id` | id тенанта (строка). **Единственный** источник принадлежности; полю в теле не доверяем | отсутствует → `UNAUTHENTICATED` (для ингресса core: `SetTenant` из metadata) | +| `service-token` | общий токен сервисов (env `DEAL_SERVICE_TOKEN`, общий в compose) | пустой/неверный → `UNAUTHENTICATED` | + +Интерцептор `service-token` — общий шаблон в каждом процессе (Ruling 1/2); +каждый сервис дополнительно проверяет принадлежность по своей модели (сессия/ +модель тенанта есть, иначе `NOT_FOUND`/`FAILED_PRECONDITION`). + +## Коды ошибок (общие) + +Ошибки домена — gRPC-статусы, `detail` = текст причины 1:1 с прототипом +(строки «Telegram не подключён», «Неверный код», «Код истёк — запросите новый», +«Неверный облачный пароль», «ИИ (имя) не ответил корректно — …» и т.д.): + +| Статус | Когда | +|---|---| +| `INVALID_ARGUMENT` | невалидный ввод/код/пароль/username, пустой текст | +| `NOT_FOUND` | диалог/сущность/сессия тенанта не найдены | +| `FAILED_PRECONDITION` | операция невозможна в текущей фазе (нет сессии и т.п.) | +| `RESOURCE_EXHAUSTED` | FloodWait Telegram (detail начинается с префикса `flood`) | +| `UNAVAILABLE` | недоступен Telegram/LLM-провайдер/хранилище модели (безопасный повтор/фолбэк) | +| `UNAUTHENTICATED` | неверный/отсутствующий `service-token` | + +«Мягкие» сценарии НЕ являются ошибками RPC: Predict неготовой модели («не +уверен»), ReadForEval без истории (`ok=false, error="no_history"`), сброс с +`ok=false,error`, `filter` с решением pass/reason. + +## Deadlines (клиент) + +| Сфера | Рекомендация | Обоснование | +|---|---|---| +| telegram: GetStatus/SetMonitor/SetMonitorAll/Logout | 10 с | локальная сеть/статус | +| telegram: StartPhone/StartQr/SendCode/SendPassword/Search/GetInfo/ReadRecent/ReadForEval/Join/Leave | 60 с | сетевые операции Telegram (паузы анти-бана 2–4 с поиск) | +| telegram: RefreshDialogs/Backfill | 120 с | iter_dialogs 500; backfill 10 сообщ. × 1.5–3 с + 3–6 с/диалог | +| ingress: PushMessage/SyncDialogs/ReportStatus | 10 с | локальная сеть; упущенное догоняет realtime-sweep | +| ml: Predict | 5 с | локальная модель | +| ml: Status/Reset | 10 с | локально | +| ml: TrainBatch | 30 с | батч ≤100, 1 транзакция | +| ai: все RPC | 120 с | провайдер 90/60 с + ретраи 0.8/2 с | + +--- + +## `telegram.proto` + +Два сервиса: команды ядра → сервис (`TelegramService`) и поток сервис → ядро +(`IngressService`, gRPC-сервер в Deal.Api :5082, Ruling 7). + +### Словари значений + +- `phase`: `idle | phone | code | password | qr | ready` (status() L85). +- `kind`: `channel | group | forum | chat` (канон контракта; 1:1 `_kind_of` + L461–466: broadcast → channel, megagroup/gigagroup/group → group, остальное → + chat; `forum` — отдельный флаг `is_forum` в GetInfo, в каталоге форум приходит + как group). Discovery-коды `channel/group/forum` ядро получает из kind+is_forum. +- `error`/`qr_url`/`account` — `optional` (presence): пустое = нет ошибки/URL. + +### Маппинги на HTTP-контракт (заметка для T13/14, код не меняется) + +Внутренний канон контракта `kind` — EN (`channel/group/forum/chat`). Граница +HTTP-эндпоинтов (api-map §4.8 L349, замороженный контракт фронта) НЕ 1:1: +- `GET /api/tg/dialogs` → `item.type` остаётся **русским** («канал»/«группа»/ + «чат»), как в прототипе (`refresh_dialogs`/`list_dialogs`): на границе + эндпоинта каналов (T13/14) нужен обратный маппинг EN → RU + (channel→«канал», group/forum→«группа», chat→«чат»; forum в списке диалогов + не встречается — каталог приносит его как group). +- Discovery: `candidate.type` (`kind`) — **EN** (`channel/group/forum`), как в + прототипе (db.py L162–166, `_kind_code`); маппинг на границе НЕ нужен. + +### TelegramService (ядро — клиент, сервис — сервер) + +| RPC | Запрос | Ответ | Ошибки / примечания | +|---|---|---|---| +| `GetStatus` | `GetStatusRequest` (пуст) | `GetStatusReply{phase,connected,listener,account,error?,qr_url?}` | нет сессии → FAILED_PRECONDITION «Telegram не подключён». live-поля для `GET /api/tg/status`; monitored/keysSet ядро считает само (Ruling 8) | +| `StartPhone` | `StartPhoneRequest{phone, api_id, api_hash}` | `StartPhoneReply{phase}` | ключи tgKeys передаёт ядро (Ruling 3); нет ключей → INVALID_ARGUMENT «Сначала сохраните…»; ответ фаза `code` | +| `StartQr` | `StartQrRequest{api_id, api_hash}` | `StartQrReply{phase, qr_url}` | фаза `qr` + url; уже авторизован → `ready`, url пуст | +| `SendCode` | `SendCodeRequest{code}` | `SendCodeReply{phase}` | «Неверный код»/«Код истёк…» → INVALID_ARGUMENT; 2FA → фаза `password` | +| `SendPassword` | `SendPasswordRequest{password}` | `SendPasswordReply{phase}` | «Неверный облачный пароль» → INVALID_ARGUMENT; ответ `ready` | +| `Logout` | `LogoutRequest` (пуст) | `LogoutReply{ok}` | отключение + удаление сессии тенанта | +| `RefreshDialogs` | `RefreshDialogsRequest` (пуст) | `RefreshDialogsReply{entries: DialogEntry[]}` | каталог диалогов; применять ядру через Ingress.SyncDialogs-семантику (`SyncFromTelegram`) | +| `SetMonitor` | `SetMonitorRequest{dialog_id, enabled}` | `SetMonitorReply{ok, enabled}` | зеркало monitored в сервисе; первый backfill запускает ядро | +| `SetMonitorAll` | `SetMonitorAllRequest{enabled}` | `SetMonitorAllReply{ok, count, enabled}` | count = диалогов в каталоге | +| `Backfill` | `BackfillRequest{dialog_id, force}` | `BackfillReply{processed}` | последние ~10 сообщений → поток PushMessage; паузы 1.5–3 с/сообщ. + mark-as-read; force = «Перечитать» | +| `ReadRecent` | `ReadRecentRequest{dialog_id, limit 1..50}` | `ReadRecentReply{messages: PreviewMessage[]}` | свежие из TG; lead и фолбэк на БД — в ядре | +| `Search` | `SearchRequest{query, limit}` | `SearchReply{results: DialogEntry[]}` | пауза анти-бана внутри сервиса; личные/ботов отсеивает ядро (Ruling 10) | +| `GetInfo` | `GetInfoRequest{dialog_id}` | `GetInfoReply{info: ChannelInfo}` | ChannelInfo{id,name,username,kind,hue,participants?,is_forum}; сбои full_chat не роняют RPC | +| `ReadForEval` | `ReadForEvalRequest{dialog_id, limit}` | `ReadForEvalReply{ok, error?, messages: EvalMessage[]}` | форумы — по темам; история скрыта → ok=false,error="no_history" (НЕ ошибка RPC) | +| `Join` | `JoinRequest{username}` | `JoinReply{ok}` | FloodWait → RESOURCE_EXHAUSTED (`flood`); ручной join вне квот | +| `Leave` | `LeaveRequest{dialog_id}` | `LeaveReply{ok}` | нет членства → NOT_FOUND | + +Общие сообщения: +- `DialogEntry{id,name,username,kind,hue}` — каталог/поиск (id подписанный: + каналы `-100…`, группы `-…`, личные `+…`; hue — палитра DIALOG_HUES). +- `ChannelInfo` = DialogEntry + `participants?` + `is_forum`. +- `PreviewMessage{id(text), text, time(ms)}` — превью (api-map §4.8 L351; `id` + строкой: int-сообщения TG и фолбэк `m__`). +- `EvalMessage{id(int64), text, date_ms, topic_id?, topic_title?}` — выборка + оценки кандидата (`_discovery_message_item`). + +### IngressService (сервис — клиент, ядро — сервер в Deal.Api, порт `GRPC_INGRESS_PORT` :5082) + +| RPC | Запрос | Ответ | Примечания | +|---|---|---|---| +| `PushMessage` | `PushMessageRequest{dialog_id, channel_name, channel_handle, channel_hue, msg_id?, text, msg_at?}` | `PushMessageReply{accepted, duplicate}` | 1:1 QueuedMessage/demo-ingest: EnqueueAsync + превью в TgMessages; дубль dialog+msgId → duplicate=true, очередь не растёт; нет msg_at → ядро подставит now; hue считает сервис | +| `SyncDialogs` | `SyncDialogsRequest{entries: DialogEntry[]}` | `SyncDialogsReply{monitored_ids[]}` | ядро: SyncFromTelegram (autoMonitorNew/обновление/удаление); ответ — актуальный зеркальный список monitored сервиса | +| `ReportStatus` | `ReportStatusRequest{phase,connected,listener,account,error?,qr_url?}` | `ReportStatusReply{ok}` | ядро: KV tgStatus/tgAccount + SSE system_status/тосты на переходах фаз | + +--- + +## `ml.proto` + +`MlService` — пул инкрементальных наивно-байесовских моделей per-tenant +(файл `data/ml/.sqlite`). Поля 1:1 с `MlPredictResultDto`/ +`MlServiceStatusDto` и model.py. + +| RPC | Запрос | Ответ | Примечания | +|---|---|---|---| +| `Predict` | `PredictRequest{text}` | `PredictReply{take,label?,scores: map,hits,ready,margin?,terms[],type?}` | «не уверен» при неготовой модели/пустом тексте — не ошибка; scores ≤5 лучших (round 3); margin адаптивный 0.9/0.7/0.5/0.35; terms ≤8; type — t:hire/t:order | +| `Status` | `StatusRequest` (пуст) | `StatusReply{ready,classes: map,learned,eval: ModelEval}` | classes «label → вес» round 2; модель создаётся лениво | +| `Reset` | `ResetRequest` (пуст) | `ResetReply{ok, error?}` | очистка classes/terms/eval_log + пересоздание файла; ok=false — мягкая ошибка (ядро чистит ml_outbox только при ok) | +| `TrainBatch` | `TrainBatchRequest{items: TrainExample[]}` | `TrainBatchReply{learned}` | 1 транзакция + пакетные вставки терминов (= learn_batch); learned = применено примеров | + +Сообщения: +- `TypeDecision{take,label("hire"|"order"),value("t:hire"|"t:order"),margin}` — + решение о типе заявки. +- `ModelEval{count,correct,accuracy}` — окно самооценки (EVAL_WINDOW последних + подтверждённых решений). +- `TrainExample{text,label,delta}` — строка обучения (ml_outbox): delta 1.0 + пользователь / −1.0 снять / 0.4–0.6 ИИ-правила. + +Пороги и константы (Ruling 4): MIN_TOTAL 20, MIN_WINNER 6, MIN_WINNER_SPAM 4, +MIN_HITS 2, MARGIN 0.9, типы `t:*` с MIN_TYPE_WINNER 4 — живут в ml-service +(реализация Task 5/6), в контракт не входят. + +--- + +## `ai.proto` + +`AiService` — фасад LLM-провайдеров без БД (Ruling 5). Ядро передаёт в теле +каждого запроса заполненные промпты/контекст + конфиг активного провайдера +(`provider_config`); сервис возвращает ответ модели + оценку токенов. + +| RPC | Запрос | Ответ | Примечания | +|---|---|---|---| +| `Filter` | `FilterRequest{prompt, text, provider_config}` | `FilterReply{pass, reason?, usage}` | prompt = заполненный aiFilterPrompt (ядро); «фильтр не применялся» обрабатывает ядро до вызова | +| `Classify` | `ClassifyRequest{system_prompt, user_context, provider_config}` | `ClassifyReply{ok, json?, usage}` | system_prompt = aiPrompt+cardPrompt, user_context = «Доски + примеры + Сообщение» (собирает ядро); json — сырой ответ модели строкой; строгий маппинг в карточку — ядро | +| `GenerateKeywords` | `GenerateKeywordsRequest{description, provider_config}` | `GenerateKeywordsReply{keywords[], usage}` | фикс. промпт (routes L36–47); очистку `_clean_keywords` и мягкие ошибки делает ядро (Ruling 11) | +| `EvaluateFit` | `EvaluateFitRequest{text, description, keywords[], provider_config}` | `EvaluateFitReply{fit, reason?, usage}` | промпт discovery_eval L50–54; ядро зовёт при aiEnabled, сбой → эвристика | + +`provider_config` — конфиг активного провайдера на запрос (Ruling 5 «в теле +каждого запроса»): ядро собирает эффективный конфиг (настройка `aiConfigs` +тенанта хранит `{apiKey, baseUrl, model}` в camelCase, ключ шифруется AES-GCM; +`apiStyle` — из каталога `AiProviders`) и передаёт в теле; сервис настроек не +хранит. Форма — сообщение `ProviderConfig`: + +| Поле | Обязательность | Описание | +|---|---|---| +| `provider_id` | да | Id провайдера (ключ `aiConfigs`/каталога: deepseek/openai/anthropic/ollama/lmstudio/custom…) | +| `base_url` | да | Эффективный базовый URL API (`aiConfigs.baseUrl` или дефолт каталога) | +| `api_key` | опц. | Ключ открытым текстом (расшифрован ядром); пуст у локальных провайдеров — заголовок не шлётся | +| `model` | да | Активная модель (`aiConfigs.model` или первая из каталога) | +| `api_style` | опц. | Стиль API: пуст — OpenAI-совместимый (`{base}/chat/completions`, Bearer); `"anthropic"` — Messages API (`{base}/v1/messages`, x-api-key + anthropic-version) | + +Сообщения: +- `Usage{prompt, completion, total}` — оценка токенов в каждом reply (из usage + API-ответа; при отсутствии ≈chars/4; ядро копит в KV `aiTokenUsage`). + +Ошибки: провайдер не ответил корректно после ретраев → `UNAVAILABLE` с detail +«ИИ (имя) не ответил корректно — повторите попытку через несколько секунд». +`ClassifyReply.ok=false` — ответ без разбираемого JSON (не RPC-ошибка); ядро +трактует как «не разобрано» и использует локальный путь. diff --git a/src/contracts/sources.proto b/src/contracts/sources.proto index fe9f647..e2a7f3e 100644 --- a/src/contracts/sources.proto +++ b/src/contracts/sources.proto @@ -1,91 +1,91 @@ -// -// Контракт входящего потока источников: сервис-источник → ядро. -// -// Любой источник (telegram, whatsapp, avito, сайт, файл, excel) приводит свои -// данные к единому контракту SourceItem и шлёт их одним вызовом PushSource. -// Ядро не знает о природе источника: вид задаётся полем source.kind. -// -// tenant-id — id тенанта (metadata; единственный источник принадлежности); -// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/ -// пустой → UNAUTHENTICATED. -// -// Файлы вложений источник сам выгружает в сервис данных (storage.proto) и -// передаёт здесь ссылкой в content.data. -syntax = "proto3"; - -package deal.sources.v1; - -option csharp_namespace = "Deal.Grpc.Sources"; - -service SourceIngressService { - // Запись источника → очередь конвейера ядра. Дубль (вид+оригинал+внешний id) - // уже в очереди — очередь не растёт (duplicate=true). - rpc PushSource(PushSourceRequest) returns (PushSourceReply); -} - -message PushSourceRequest { - SourceRefProto source = 1; - SourceContentProto content = 2; - // True — вернуть из отсева: правила/устарелость/ИИ-фильтр пропускаются. - bool force = 3; -} - -message SourceRefProto { - // Дискриминатор источника, задаёт владелец (telegram/file/local/...). - string kind = 1; - // Идентификатор записи в источнике (сообщение/строка/файл). - optional string external_id = 2; - // Подпись источника для интерфейса. - optional string display_name = 3; - // Ссылка на оригинал (url, deep-link, путь). - optional string origin_ref = 4; - optional string author = 5; - // Время получения записи, epoch-ms. - int64 received_at = 6; - // Прочие метаданные источника (в т.ч. цвет интерфейса — hue). - map extra = 7; -} - -message SourceContentProto { - optional string text = 1; - optional string html = 2; - optional string author = 3; - optional string subject = 4; - // Вложения: ссылки на объекты сервиса данных. - repeated DataRefProto data = 5; - // Ссылки, не являющиеся файлами. - repeated string links = 6; - repeated ContactRefProto contacts = 7; - map extra = 8; -} - -message DataRefProto { - string id = 1; - string ref = 2; - optional string kind = 3; - optional string mime_type = 4; - optional string file_name = 5; - optional int64 size = 6; - optional int32 width = 7; - optional int32 height = 8; - optional double duration_sec = 9; - optional string preview_ref = 10; - optional string caption = 11; - optional int32 order = 12; - map meta = 13; -} - -message ContactRefProto { - optional string kind = 1; - optional string name = 2; - optional string phone = 3; - optional string email = 4; - optional string url = 5; -} - -message PushSourceReply { - // True — запись принята (пустой текст и пустой контент — accepted=false). - bool accepted = 1; - // True — дубль уже в очереди (очередь не выросла). - bool duplicate = 2; -} +// +// Контракт входящего потока источников: сервис-источник → ядро. +// +// Любой источник (telegram, whatsapp, avito, сайт, файл, excel) приводит свои +// данные к единому контракту SourceItem и шлёт их одним вызовом PushSource. +// Ядро не знает о природе источника: вид задаётся полем source.kind. +// +// tenant-id — id тенанта (metadata; единственный источник принадлежности); +// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/ +// пустой → UNAUTHENTICATED. +// +// Файлы вложений источник сам выгружает в сервис данных (storage.proto) и +// передаёт здесь ссылкой в content.data. +syntax = "proto3"; + +package deal.sources.v1; + +option csharp_namespace = "Deal.Grpc.Sources"; + +service SourceIngressService { + // Запись источника → очередь конвейера ядра. Дубль (вид+оригинал+внешний id) + // уже в очереди — очередь не растёт (duplicate=true). + rpc PushSource(PushSourceRequest) returns (PushSourceReply); +} + +message PushSourceRequest { + SourceRefProto source = 1; + SourceContentProto content = 2; + // True — вернуть из отсева: правила/устарелость/ИИ-фильтр пропускаются. + bool force = 3; +} + +message SourceRefProto { + // Дискриминатор источника, задаёт владелец (telegram/file/local/...). + string kind = 1; + // Идентификатор записи в источнике (сообщение/строка/файл). + optional string external_id = 2; + // Подпись источника для интерфейса. + optional string display_name = 3; + // Ссылка на оригинал (url, deep-link, путь). + optional string origin_ref = 4; + optional string author = 5; + // Время получения записи, epoch-ms. + int64 received_at = 6; + // Прочие метаданные источника (в т.ч. цвет интерфейса — hue). + map extra = 7; +} + +message SourceContentProto { + optional string text = 1; + optional string html = 2; + optional string author = 3; + optional string subject = 4; + // Вложения: ссылки на объекты сервиса данных. + repeated DataRefProto data = 5; + // Ссылки, не являющиеся файлами. + repeated string links = 6; + repeated ContactRefProto contacts = 7; + map extra = 8; +} + +message DataRefProto { + string id = 1; + string ref = 2; + optional string kind = 3; + optional string mime_type = 4; + optional string file_name = 5; + optional int64 size = 6; + optional int32 width = 7; + optional int32 height = 8; + optional double duration_sec = 9; + optional string preview_ref = 10; + optional string caption = 11; + optional int32 order = 12; + map meta = 13; +} + +message ContactRefProto { + optional string kind = 1; + optional string name = 2; + optional string phone = 3; + optional string email = 4; + optional string url = 5; +} + +message PushSourceReply { + // True — запись принята (пустой текст и пустой контент — accepted=false). + bool accepted = 1; + // True — дубль уже в очереди (очередь не выросла). + bool duplicate = 2; +} diff --git a/src/contracts/storage.proto b/src/contracts/storage.proto index edf3bc7..4c57760 100644 --- a/src/contracts/storage.proto +++ b/src/contracts/storage.proto @@ -1,78 +1,78 @@ -// -// Контракт общего сервиса данных: загрузка/выгрузка/стат/удаление объектов вложений. -// -// tenant-id — id тенанта (metadata); объект хранится в пространстве тенанта; -// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/пустой → -// UNAUTHENTICATED (интерцептор хоста). -// -// Тип объекта (image/video/audio/document/archive/other) определяет сервис — -// контракт типы вложений не задаёт. -syntax = "proto3"; - -package deal.storage.v1; - -option csharp_namespace = "Deal.Grpc.Storage"; - -service StorageService { - // Поток: первое сообщение несёт meta, далее — data. - rpc Upload(stream UploadRequest) returns (UploadReply); - - // Поток содержимого объекта частями. - rpc Download(DownloadRequest) returns (stream DownloadChunk); - - // Дескриптор объекта (без содержимого). - rpc Stat(StatRequest) returns (StatReply); - - // Удаление объекта (отсутствующий — успех). - rpc Delete(DeleteRequest) returns (DeleteReply); -} - -message UploadRequest { - UploadMeta meta = 1; - bytes data = 2; -} - -message UploadMeta { - string tenant_id = 1; - string file_name = 2; - string content_type = 3; -} - -message UploadReply { - // Идентификатор объекта — ключ в хранилище. - string id = 1; - // Ссылка на скачивание/отображение. - string ref = 2; - // Тип, определённый сервисом (image/video/audio/document/archive/other). - string kind = 3; - string mime_type = 4; - string file_name = 5; - int64 size = 6; - optional int32 width = 7; - optional int32 height = 8; - optional double duration_sec = 9; - optional string preview_ref = 10; -} - -message DownloadRequest { - string id = 1; -} - -message DownloadChunk { - bytes data = 1; -} - -message StatRequest { - string id = 1; -} - -message StatReply { - bool found = 1; - UploadReply info = 2; -} - -message DeleteRequest { - string id = 1; -} - -message DeleteReply {} +// +// Контракт общего сервиса данных: загрузка/выгрузка/стат/удаление объектов вложений. +// +// tenant-id — id тенанта (metadata); объект хранится в пространстве тенанта; +// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/пустой → +// UNAUTHENTICATED (интерцептор хоста). +// +// Тип объекта (image/video/audio/document/archive/other) определяет сервис — +// контракт типы вложений не задаёт. +syntax = "proto3"; + +package deal.storage.v1; + +option csharp_namespace = "Deal.Grpc.Storage"; + +service StorageService { + // Поток: первое сообщение несёт meta, далее — data. + rpc Upload(stream UploadRequest) returns (UploadReply); + + // Поток содержимого объекта частями. + rpc Download(DownloadRequest) returns (stream DownloadChunk); + + // Дескриптор объекта (без содержимого). + rpc Stat(StatRequest) returns (StatReply); + + // Удаление объекта (отсутствующий — успех). + rpc Delete(DeleteRequest) returns (DeleteReply); +} + +message UploadRequest { + UploadMeta meta = 1; + bytes data = 2; +} + +message UploadMeta { + string tenant_id = 1; + string file_name = 2; + string content_type = 3; +} + +message UploadReply { + // Идентификатор объекта — ключ в хранилище. + string id = 1; + // Ссылка на скачивание/отображение. + string ref = 2; + // Тип, определённый сервисом (image/video/audio/document/archive/other). + string kind = 3; + string mime_type = 4; + string file_name = 5; + int64 size = 6; + optional int32 width = 7; + optional int32 height = 8; + optional double duration_sec = 9; + optional string preview_ref = 10; +} + +message DownloadRequest { + string id = 1; +} + +message DownloadChunk { + bytes data = 1; +} + +message StatRequest { + string id = 1; +} + +message StatReply { + bool found = 1; + UploadReply info = 2; +} + +message DeleteRequest { + string id = 1; +} + +message DeleteReply {} diff --git a/src/core/Deal.Api/Configuration/CookieOptions.cs b/src/core/Deal.Api/Configuration/CookieOptions.cs index ef70cb6..9ad1871 100644 --- a/src/core/Deal.Api/Configuration/CookieOptions.cs +++ b/src/core/Deal.Api/Configuration/CookieOptions.cs @@ -1,24 +1,24 @@ -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Api.Configuration; - -/// -/// Настройки httpOnly-куки сессии. -/// -public sealed class CookieOptions -{ - /// - /// Имя куки. - /// - public string Name { get; set; } = "deal_session"; - - /// - /// Срок жизни куки в днях; совпадает со сроком жизни сессии. - /// - public int Days { get; set; } = AuthService.SessionLifetimeDays; - - /// - /// Флаг Secure куки. - /// - public bool Secure { get; set; } -} +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Api.Configuration; + +/// +/// Настройки httpOnly-куки сессии. +/// +public sealed class CookieOptions +{ + /// + /// Имя куки. + /// + public string Name { get; set; } = "deal_session"; + + /// + /// Срок жизни куки в днях; совпадает со сроком жизни сессии. + /// + public int Days { get; set; } = AuthService.SessionLifetimeDays; + + /// + /// Флаг Secure куки. + /// + public bool Secure { get; set; } +} diff --git a/src/core/Deal.Api/Configuration/DataRetentionOptions.cs b/src/core/Deal.Api/Configuration/DataRetentionOptions.cs index 4cb40b8..0015b5b 100644 --- a/src/core/Deal.Api/Configuration/DataRetentionOptions.cs +++ b/src/core/Deal.Api/Configuration/DataRetentionOptions.cs @@ -1,17 +1,17 @@ -namespace Deal.Api.Configuration; - -/// -/// Настройки авто-очистки данных -/// -public sealed class DataRetentionOptions -{ - /// - /// Включён ли фоновый цикл авто-очистки - /// - public bool Enabled { get; set; } = true; - - /// - /// Срок хранения записей аудита в днях - /// - public int AuditRetentionDays { get; set; } = 180; -} +namespace Deal.Api.Configuration; + +/// +/// Настройки авто-очистки данных +/// +public sealed class DataRetentionOptions +{ + /// + /// Включён ли фоновый цикл авто-очистки + /// + public bool Enabled { get; set; } = true; + + /// + /// Срок хранения записей аудита в днях + /// + public int AuditRetentionDays { get; set; } = 180; +} diff --git a/src/core/Deal.Api/Configuration/ForwardedHeadersConfig.cs b/src/core/Deal.Api/Configuration/ForwardedHeadersConfig.cs index 47b655e..5a49989 100644 --- a/src/core/Deal.Api/Configuration/ForwardedHeadersConfig.cs +++ b/src/core/Deal.Api/Configuration/ForwardedHeadersConfig.cs @@ -1,22 +1,22 @@ -namespace Deal.Api.Configuration; - -/// -/// Настройки доверия прокси-заголовкам -/// -public sealed class ForwardedHeadersConfig -{ - /// - /// Включена ли обработка прокси-заголовков - /// - public bool Enabled { get; set; } - - /// - /// Доверенные прокси-адреса - /// - public string[] KnownProxies { get; set; } = []; - - /// - /// Доверенные подсети прокси в CIDR - /// - public string[] KnownNetworks { get; set; } = []; -} +namespace Deal.Api.Configuration; + +/// +/// Настройки доверия прокси-заголовкам +/// +public sealed class ForwardedHeadersConfig +{ + /// + /// Включена ли обработка прокси-заголовков + /// + public bool Enabled { get; set; } + + /// + /// Доверенные прокси-адреса + /// + public string[] KnownProxies { get; set; } = []; + + /// + /// Доверенные подсети прокси в CIDR + /// + public string[] KnownNetworks { get; set; } = []; +} diff --git a/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs b/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs index d9d41f5..6300bb5 100644 --- a/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs +++ b/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs @@ -1,24 +1,24 @@ -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Api.Configuration; - -/// -/// Настройки httpOnly-куки сессии оператора. -/// -public sealed class OperatorCookieOptions -{ - /// - /// Имя куки. - /// - public string Name { get; set; } = "deal_operator_session"; - - /// - /// Срок жизни куки в часах; совпадает со сроком жизни сессии оператора. - /// - public int Hours { get; set; } = OperatorAuthService.SessionLifetimeHours; - - /// - /// Флаг Secure куки. - /// - public bool Secure { get; set; } -} +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Api.Configuration; + +/// +/// Настройки httpOnly-куки сессии оператора. +/// +public sealed class OperatorCookieOptions +{ + /// + /// Имя куки. + /// + public string Name { get; set; } = "deal_operator_session"; + + /// + /// Срок жизни куки в часах; совпадает со сроком жизни сессии оператора. + /// + public int Hours { get; set; } = OperatorAuthService.SessionLifetimeHours; + + /// + /// Флаг Secure куки. + /// + public bool Secure { get; set; } +} diff --git a/src/core/Deal.Api/Configuration/RateLimitOptions.cs b/src/core/Deal.Api/Configuration/RateLimitOptions.cs index 8507b8d..85afac5 100644 --- a/src/core/Deal.Api/Configuration/RateLimitOptions.cs +++ b/src/core/Deal.Api/Configuration/RateLimitOptions.cs @@ -1,37 +1,37 @@ -namespace Deal.Api.Configuration; - -/// -/// Настройки rate limiting и защиты входа -/// -public sealed class RateLimitOptions -{ - /// - /// Включены ли rate limiting и LoginAttemptGuard. - /// - public bool Enabled { get; set; } - - /// - /// Лимит политики "auth" - /// - public int AuthPerMinute { get; set; } = 10; - - /// - /// Лимит политики "api" - /// - public int ApiPerMinute { get; set; } = 600; - - /// - /// Лимит gRPC-ингресса - /// - public int GrpcIngressPerMinute { get; set; } = 600; - - /// - /// Порог неудачных попыток входа ключа ip|login до блокировки - /// - public int LoginAttemptsMax { get; set; } = 5; - - /// - /// Окно учёта неудачных попыток входа в минутах - /// - public int LoginAttemptWindowMin { get; set; } = 15; -} +namespace Deal.Api.Configuration; + +/// +/// Настройки rate limiting и защиты входа +/// +public sealed class RateLimitOptions +{ + /// + /// Включены ли rate limiting и LoginAttemptGuard. + /// + public bool Enabled { get; set; } + + /// + /// Лимит политики "auth" + /// + public int AuthPerMinute { get; set; } = 10; + + /// + /// Лимит политики "api" + /// + public int ApiPerMinute { get; set; } = 600; + + /// + /// Лимит gRPC-ингресса + /// + public int GrpcIngressPerMinute { get; set; } = 600; + + /// + /// Порог неудачных попыток входа ключа ip|login до блокировки + /// + public int LoginAttemptsMax { get; set; } = 5; + + /// + /// Окно учёта неудачных попыток входа в минутах + /// + public int LoginAttemptWindowMin { get; set; } = 15; +} diff --git a/src/core/Deal.Api/Configuration/SecurityOptions.cs b/src/core/Deal.Api/Configuration/SecurityOptions.cs index 57a869a..f935df0 100644 --- a/src/core/Deal.Api/Configuration/SecurityOptions.cs +++ b/src/core/Deal.Api/Configuration/SecurityOptions.cs @@ -1,12 +1,12 @@ -namespace Deal.Api.Configuration; - -/// -/// Настройки безопасности HTTP -/// -public sealed class SecurityOptions -{ - /// - /// Явный allowlist Origin/CORS - /// - public string[] AllowedOrigins { get; set; } = []; -} +namespace Deal.Api.Configuration; + +/// +/// Настройки безопасности HTTP +/// +public sealed class SecurityOptions +{ + /// + /// Явный allowlist Origin/CORS + /// + public string[] AllowedOrigins { get; set; } = []; +} diff --git a/src/core/Deal.Api/Deal.Api.csproj b/src/core/Deal.Api/Deal.Api.csproj index ae64312..7383b7e 100644 --- a/src/core/Deal.Api/Deal.Api.csproj +++ b/src/core/Deal.Api/Deal.Api.csproj @@ -1,57 +1,57 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - - - - net10.0 - enable - enable - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/core/Deal.Api/Dockerfile b/src/core/Deal.Api/Dockerfile index 53f2eb0..3d24197 100644 --- a/src/core/Deal.Api/Dockerfile +++ b/src/core/Deal.Api/Dockerfile @@ -1,43 +1,43 @@ -# deal-api (core): HTTP-портал :5080 + gRPC-ингресс telegram-service :5082 (план Task 20; Ruling 12). -# -# КОНТЕКСТ СБОРКИ — корень репозитория: Deal.Api ссылается на проекты всего src/core (модули, -# Infrastructure, SharedKernel/Contracts) и на src/contracts/Deal.Proto.csproj (кодогенерация .proto, -# Task 1). Запуск из корня: docker build -f src/core/Deal.Api/Dockerfile . -# Порт HTTP — env ASPNETCORE_URLS; порт ингресса — env GRPC_INGRESS_PORT (Program.cs), в compose.dev.yml 5082. -# -# Примечание о кэше слоёв: restore-слой копирует весь src/core (csproj-файлы отдельных проектов не -# вычленяются — у Deal.Api ~10 ProjectReference внутри каталога; для dev-compose это приемлемо). -# .dockerignore исключает **/bin и **/obj из контекста сборки. - -# --- Этап сборки: restore + publish --- -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /repo - -COPY src/contracts/ src/contracts/ -COPY src/core/ src/core/ -RUN dotnet restore src/core/Deal.Api/Deal.Api.csproj -RUN dotnet publish src/core/Deal.Api/Deal.Api.csproj -c Release -o /app/publish - -# --- Runtime-этап --- -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final -WORKDIR /app -EXPOSE 5080 5082 -COPY --from=build /app/publish . - -# grpc_health_probe — healthcheck контейнера (Ruling 12): gRPC-health ингресса (:5082) освобождён -# от service-token (см. IngressServiceTokenInterceptor), поэтому проба идёт без metadata. -COPY --from=ghcr.io/grpc-ecosystem/grpc-health-probe:v0.4.35 /ko-app/grpc-health-probe /bin/grpc_health_probe - -# data/encryption.key (шифрование секретов) и LocalFileStorage data/attachments живут под ContentRoot -# (/app/data); каталог монтируется volume-ом deal_api_data из compose.dev.yml. В полном dev-стеке файлы -# идут в MinIO (Storage__Minio__* env, см. compose.dev.yml) — volume остаётся для ключа шифрования. - -# Non-root: процесс работает пользователем deal (UID 10001); /app/data — каталог данных (volume). -# HOME=/tmp — рабочий каталог для временных/DP-файлов (совместимо с read_only root FS + tmpfs /tmp). -RUN useradd --uid 10001 --shell /usr/sbin/nologin --no-create-home deal \ - && mkdir -p /app/data \ - && chown -R deal:deal /app/data -ENV HOME=/tmp -USER deal - -ENTRYPOINT ["dotnet", "Deal.Api.dll"] +# deal-api (core): HTTP-портал :5080 + gRPC-ингресс telegram-service :5082 (план Task 20; Ruling 12). +# +# КОНТЕКСТ СБОРКИ — корень репозитория: Deal.Api ссылается на проекты всего src/core (модули, +# Infrastructure, SharedKernel/Contracts) и на src/contracts/Deal.Proto.csproj (кодогенерация .proto, +# Task 1). Запуск из корня: docker build -f src/core/Deal.Api/Dockerfile . +# Порт HTTP — env ASPNETCORE_URLS; порт ингресса — env GRPC_INGRESS_PORT (Program.cs), в compose.dev.yml 5082. +# +# Примечание о кэше слоёв: restore-слой копирует весь src/core (csproj-файлы отдельных проектов не +# вычленяются — у Deal.Api ~10 ProjectReference внутри каталога; для dev-compose это приемлемо). +# .dockerignore исключает **/bin и **/obj из контекста сборки. + +# --- Этап сборки: restore + publish --- +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /repo + +COPY src/contracts/ src/contracts/ +COPY src/core/ src/core/ +RUN dotnet restore src/core/Deal.Api/Deal.Api.csproj +RUN dotnet publish src/core/Deal.Api/Deal.Api.csproj -c Release -o /app/publish + +# --- Runtime-этап --- +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +EXPOSE 5080 5082 +COPY --from=build /app/publish . + +# grpc_health_probe — healthcheck контейнера (Ruling 12): gRPC-health ингресса (:5082) освобождён +# от service-token (см. IngressServiceTokenInterceptor), поэтому проба идёт без metadata. +COPY --from=ghcr.io/grpc-ecosystem/grpc-health-probe:v0.4.35 /ko-app/grpc-health-probe /bin/grpc_health_probe + +# data/encryption.key (шифрование секретов) и LocalFileStorage data/attachments живут под ContentRoot +# (/app/data); каталог монтируется volume-ом deal_api_data из compose.dev.yml. В полном dev-стеке файлы +# идут в MinIO (Storage__Minio__* env, см. compose.dev.yml) — volume остаётся для ключа шифрования. + +# Non-root: процесс работает пользователем deal (UID 10001); /app/data — каталог данных (volume). +# HOME=/tmp — рабочий каталог для временных/DP-файлов (совместимо с read_only root FS + tmpfs /tmp). +RUN useradd --uid 10001 --shell /usr/sbin/nologin --no-create-home deal \ + && mkdir -p /app/data \ + && chown -R deal:deal /app/data +ENV HOME=/tmp +USER deal + +ENTRYPOINT ["dotnet", "Deal.Api.dll"] diff --git a/src/core/Deal.Api/Dtos/AdminTickResultDto.cs b/src/core/Deal.Api/Dtos/AdminTickResultDto.cs index ff93b7e..d5bcc54 100644 --- a/src/core/Deal.Api/Dtos/AdminTickResultDto.cs +++ b/src/core/Deal.Api/Dtos/AdminTickResultDto.cs @@ -1,16 +1,16 @@ -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Api.Dtos; - -/// -/// Ответ POST /api/admin/tick — форма {storage, reminders, pipeline, queue}. -/// -/// Статистика тика правил хранения (включая purgedRejected — очистку отсева 3 суток). -/// «Выстрелившие» напоминания {id,title,containerId} — список SSE reminder_due тика. -/// Счётчики pump: словарь ключей; пуст, если pump не дал результата. -/// Строк очереди входящих после прохода pump (queue_len). -public sealed record AdminTickResultDto( - StorageTickStatsDto Storage, - IReadOnlyList Reminders, - IReadOnlyDictionary Pipeline, - int Queue); +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Api.Dtos; + +/// +/// Ответ POST /api/admin/tick — форма {storage, reminders, pipeline, queue}. +/// +/// Статистика тика правил хранения (включая purgedRejected — очистку отсева 3 суток). +/// «Выстрелившие» напоминания {id,title,containerId} — список SSE reminder_due тика. +/// Счётчики pump: словарь ключей; пуст, если pump не дал результата. +/// Строк очереди входящих после прохода pump (queue_len). +public sealed record AdminTickResultDto( + StorageTickStatsDto Storage, + IReadOnlyList Reminders, + IReadOnlyDictionary Pipeline, + int Queue); diff --git a/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs b/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs index cd1f27e..6651c19 100644 --- a/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs +++ b/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs @@ -1,153 +1,153 @@ -using System.Text.Json; -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Api.Endpoints; - -/// -/// Эндпоинт проверки подключения AI-провайдера -/// -public static class AiCheckEndpoint -{ - private const string ApiGroupPrefix = "/api"; - - // Путь проверки подключения AI-провайдера. - private const string AiCheckPath = "/ai/check"; - - private const string OpenApiTag = "settings"; - - /// - /// Регистрирует POST /api/ai/check. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapAiCheckEndpoint(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag); - group.MapPost(AiCheckPath, CheckAsync); - return app; - } - - // POST /api/ai/check: проверка соединения с активным AI-провайдером тенанта. - private static async Task CheckAsync(HttpContext context, CancellationToken ct) - { - if (context.GetCurrentUser() is null) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - // Резолв после 401-гейта: ISettingsStore — scoped на TenantDbContext (tenant-контекст запроса). - ISettingsStore store = context.RequestServices.GetRequiredService(); - ISecretCipher secretCipher = context.RequestServices.GetRequiredService(); - IAiConnectionChecker checker = context.RequestServices.GetRequiredService(); - - AiCheckRequest checkRequest = await BuildActiveCheckRequestAsync(store, secretCipher, ct); - AiCheckResultDto result = await checker.CheckAsync(checkRequest, ct); - return Results.Ok(result); - } - - private static async Task BuildActiveCheckRequestAsync( - ISettingsStore store, - ISecretCipher secretCipher, - CancellationToken ct) - { - string providerId = await ReadActiveProviderIdAsync(store, ct); - AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId); - - // Неизвестный id (ручное вмешательство в БД — PATCH-гейт SettingsService не даёт сохранить): - // HTTP не выполняется — ответит SSRF-гейт checker (allowlist). - if (meta is null) - { - return new AiCheckRequest(providerId, string.Empty, string.Empty, string.Empty, IsLocal: false, ApiStyle: null); - } - - AiConfigSetting config = await ReadEffectiveConfigAsync(store, providerId, ct); - string apiKey = secretCipher.Decrypt(config.ApiKey); - string baseUrl = string.IsNullOrEmpty(config.BaseUrl) ? meta.Base : config.BaseUrl; - string model = string.IsNullOrEmpty(config.Model) - ? meta.Models.FirstOrDefault() ?? string.Empty - : config.Model; - - return new AiCheckRequest(providerId, baseUrl, model, apiKey, meta.Local, meta.ApiStyle); - } - - // Читает активный провайдер: сохранённый aiProvider или дефолт (повреждённое значение — дефолт). - // store: KV-хранилище настроек тенанта. - // ct: Токен отмены. - // Возвращает: id провайдера. - private static async Task ReadActiveProviderIdAsync(ISettingsStore store, CancellationToken ct) - { - SettingValue? row = await store.GetAsync(SettingsKeys.AiProvider, ct); - if (row is null) - { - return SettingsDefaults.AiProvider; - } - - try - { - using JsonDocument document = JsonDocument.Parse(row.ValueJson); - if (document.RootElement.ValueKind == JsonValueKind.String) - { - return document.RootElement.GetString() ?? SettingsDefaults.AiProvider; - } - } - catch (JsonException) - { - // Повреждённая строка — дефолт (мягкая семантика, как в SettingsService). - } - - return SettingsDefaults.AiProvider; - } - - // Эффективный конфиг провайдера: дефолт SettingsDefaults, перекрытый сохранённым aiConfigs. - // store: KV-хранилище настроек тенанта. - // providerId: Активный провайдер (id из каталога). - // ct: Токен отмены. - // Возвращает: Конфиг {apiKey, baseUrl, model}; повреждённая строка aiConfigs — дефолт. - private static async Task ReadEffectiveConfigAsync( - ISettingsStore store, - string providerId, - CancellationToken ct) - { - AiConfigSetting defaults = SettingsDefaults.AiConfigs[providerId]; - SettingValue? row = await store.GetAsync(SettingsKeys.AiConfigs, ct); - if (row is null) - { - return defaults; - } - - try - { - using JsonDocument document = JsonDocument.Parse(row.ValueJson); - JsonElement root = document.RootElement; - if (root.ValueKind == JsonValueKind.Object - && root.TryGetProperty(providerId, out JsonElement entry) - && entry.ValueKind == JsonValueKind.Object) - { - return new AiConfigSetting( - ApiKey: ReadField(entry, "apiKey") ?? defaults.ApiKey, - BaseUrl: ReadField(entry, "baseUrl") ?? defaults.BaseUrl, - Model: ReadField(entry, "model") ?? defaults.Model); - } - } - catch (JsonException) - { - // Повреждённая строка — дефолт (не роняем проверку). - } - - return defaults; - } - - // Читает строковое поле объекта конфигурации (имена полей camelCase, как пишет SettingsService). - // entry: JSON-объект конфигурации провайдера. - // field: Имя поля (apiKey/baseUrl/model). - // Возвращает: Значение или null, если поле отсутствует/не строка. - private static string? ReadField(JsonElement entry, string field) - { - return entry.TryGetProperty(field, out JsonElement value) && value.ValueKind == JsonValueKind.String - ? value.GetString() - : null; - } -} +using System.Text.Json; +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Api.Endpoints; + +/// +/// Эндпоинт проверки подключения AI-провайдера +/// +public static class AiCheckEndpoint +{ + private const string ApiGroupPrefix = "/api"; + + // Путь проверки подключения AI-провайдера. + private const string AiCheckPath = "/ai/check"; + + private const string OpenApiTag = "settings"; + + /// + /// Регистрирует POST /api/ai/check. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapAiCheckEndpoint(this IEndpointRouteBuilder app) + { + var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag); + group.MapPost(AiCheckPath, CheckAsync); + return app; + } + + // POST /api/ai/check: проверка соединения с активным AI-провайдером тенанта. + private static async Task CheckAsync(HttpContext context, CancellationToken ct) + { + if (context.GetCurrentUser() is null) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + // Резолв после 401-гейта: ISettingsStore — scoped на TenantDbContext (tenant-контекст запроса). + ISettingsStore store = context.RequestServices.GetRequiredService(); + ISecretCipher secretCipher = context.RequestServices.GetRequiredService(); + IAiConnectionChecker checker = context.RequestServices.GetRequiredService(); + + AiCheckRequest checkRequest = await BuildActiveCheckRequestAsync(store, secretCipher, ct); + AiCheckResultDto result = await checker.CheckAsync(checkRequest, ct); + return Results.Ok(result); + } + + private static async Task BuildActiveCheckRequestAsync( + ISettingsStore store, + ISecretCipher secretCipher, + CancellationToken ct) + { + string providerId = await ReadActiveProviderIdAsync(store, ct); + AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId); + + // Неизвестный id (ручное вмешательство в БД — PATCH-гейт SettingsService не даёт сохранить): + // HTTP не выполняется — ответит SSRF-гейт checker (allowlist). + if (meta is null) + { + return new AiCheckRequest(providerId, string.Empty, string.Empty, string.Empty, IsLocal: false, ApiStyle: null); + } + + AiConfigSetting config = await ReadEffectiveConfigAsync(store, providerId, ct); + string apiKey = secretCipher.Decrypt(config.ApiKey); + string baseUrl = string.IsNullOrEmpty(config.BaseUrl) ? meta.Base : config.BaseUrl; + string model = string.IsNullOrEmpty(config.Model) + ? meta.Models.FirstOrDefault() ?? string.Empty + : config.Model; + + return new AiCheckRequest(providerId, baseUrl, model, apiKey, meta.Local, meta.ApiStyle); + } + + // Читает активный провайдер: сохранённый aiProvider или дефолт (повреждённое значение — дефолт). + // store: KV-хранилище настроек тенанта. + // ct: Токен отмены. + // Возвращает: id провайдера. + private static async Task ReadActiveProviderIdAsync(ISettingsStore store, CancellationToken ct) + { + SettingValue? row = await store.GetAsync(SettingsKeys.AiProvider, ct); + if (row is null) + { + return SettingsDefaults.AiProvider; + } + + try + { + using JsonDocument document = JsonDocument.Parse(row.ValueJson); + if (document.RootElement.ValueKind == JsonValueKind.String) + { + return document.RootElement.GetString() ?? SettingsDefaults.AiProvider; + } + } + catch (JsonException) + { + // Повреждённая строка — дефолт (мягкая семантика, как в SettingsService). + } + + return SettingsDefaults.AiProvider; + } + + // Эффективный конфиг провайдера: дефолт SettingsDefaults, перекрытый сохранённым aiConfigs. + // store: KV-хранилище настроек тенанта. + // providerId: Активный провайдер (id из каталога). + // ct: Токен отмены. + // Возвращает: Конфиг {apiKey, baseUrl, model}; повреждённая строка aiConfigs — дефолт. + private static async Task ReadEffectiveConfigAsync( + ISettingsStore store, + string providerId, + CancellationToken ct) + { + AiConfigSetting defaults = SettingsDefaults.AiConfigs[providerId]; + SettingValue? row = await store.GetAsync(SettingsKeys.AiConfigs, ct); + if (row is null) + { + return defaults; + } + + try + { + using JsonDocument document = JsonDocument.Parse(row.ValueJson); + JsonElement root = document.RootElement; + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty(providerId, out JsonElement entry) + && entry.ValueKind == JsonValueKind.Object) + { + return new AiConfigSetting( + ApiKey: ReadField(entry, "apiKey") ?? defaults.ApiKey, + BaseUrl: ReadField(entry, "baseUrl") ?? defaults.BaseUrl, + Model: ReadField(entry, "model") ?? defaults.Model); + } + } + catch (JsonException) + { + // Повреждённая строка — дефолт (не роняем проверку). + } + + return defaults; + } + + // Читает строковое поле объекта конфигурации (имена полей camelCase, как пишет SettingsService). + // entry: JSON-объект конфигурации провайдера. + // field: Имя поля (apiKey/baseUrl/model). + // Возвращает: Значение или null, если поле отсутствует/не строка. + private static string? ReadField(JsonElement entry, string field) + { + return entry.TryGetProperty(field, out JsonElement value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } +} diff --git a/src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs b/src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs index 197edc7..d560804 100644 --- a/src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs @@ -1,73 +1,73 @@ -using Deal.Api.Events; -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; - -namespace Deal.Api.Endpoints; - -/// -/// Эндпоинты ИИ-предложений -/// -public static class AiSuggestEndpoints -{ - private const string AiGroupPrefix = "/api/ai"; - - private const string SuggestColumnsPath = "/suggest-columns"; - - private const string SuggestKeywordsPath = "/suggest-keywords"; - - private const string OpenApiTag = "dashboard"; - - private const string ToastEventType = "toast"; - - private const string SuggestToastTextFormat = "ИИ предложил колонок: {0} — откройте и решите"; - - private const string SparklesIcon = "sparkles"; - - /// - /// Регистрирует POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapAiSuggestEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(AiGroupPrefix).WithTags(OpenApiTag); - group.MapPost(SuggestColumnsPath, SuggestColumnsAsync); - group.MapPost(SuggestKeywordsPath, SuggestKeywordsAsync); - return app; - } - - private static async Task SuggestColumnsAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - IColumnSuggester suggester = context.RequestServices.GetRequiredService(); - SuggestColumnsResultDto result = await suggester.SuggestColumnsAsync(ct); - if (result.Ok) - { - SseBroker broker = context.RequestServices.GetRequiredService(); - broker.Publish( - context.GetCurrentUser()!.TenantId, - ToastEventType, - new { text = string.Format(SuggestToastTextFormat, result.Created), icon = SparklesIcon }); - } - - return Results.Ok(result); - } - - private static async Task SuggestKeywordsAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - IColumnSuggester suggester = context.RequestServices.GetRequiredService(); - SuggestKeywordsResultDto result = await suggester.SuggestKeywordsAsync(ct); - return Results.Ok(result); - } -} +using Deal.Api.Events; +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; + +namespace Deal.Api.Endpoints; + +/// +/// Эндпоинты ИИ-предложений +/// +public static class AiSuggestEndpoints +{ + private const string AiGroupPrefix = "/api/ai"; + + private const string SuggestColumnsPath = "/suggest-columns"; + + private const string SuggestKeywordsPath = "/suggest-keywords"; + + private const string OpenApiTag = "dashboard"; + + private const string ToastEventType = "toast"; + + private const string SuggestToastTextFormat = "ИИ предложил колонок: {0} — откройте и решите"; + + private const string SparklesIcon = "sparkles"; + + /// + /// Регистрирует POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapAiSuggestEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup(AiGroupPrefix).WithTags(OpenApiTag); + group.MapPost(SuggestColumnsPath, SuggestColumnsAsync); + group.MapPost(SuggestKeywordsPath, SuggestKeywordsAsync); + return app; + } + + private static async Task SuggestColumnsAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + IColumnSuggester suggester = context.RequestServices.GetRequiredService(); + SuggestColumnsResultDto result = await suggester.SuggestColumnsAsync(ct); + if (result.Ok) + { + SseBroker broker = context.RequestServices.GetRequiredService(); + broker.Publish( + context.GetCurrentUser()!.TenantId, + ToastEventType, + new { text = string.Format(SuggestToastTextFormat, result.Created), icon = SparklesIcon }); + } + + return Results.Ok(result); + } + + private static async Task SuggestKeywordsAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + IColumnSuggester suggester = context.RequestServices.GetRequiredService(); + SuggestKeywordsResultDto result = await suggester.SuggestKeywordsAsync(ct); + return Results.Ok(result); + } +} diff --git a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs index e1b6379..0e2054e 100644 --- a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs @@ -1,484 +1,484 @@ -using System.Text.Json; -using Deal.Api.Endpoints.RequestModels; -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Api.Endpoints; - -/// -/// Детальные операции карточки -/// -public static class CardDetailsEndpoints -{ - // Префикс группы (общий с CardsEndpoints). - private const string CardsGroupPrefix = "/api/cards"; - - // Статический сегмент «взять в работу» (регистрируется до /{cardId}). - private const string TakePath = "/take"; - - // Статический сегмент очистки «Отклонено» (регистрируется до /{cardId}). - private const string ClearRejectedPath = "/clear-rejected"; - - // Параметрический сегмент карточки (PATCH /{cardId}). - private const string CardIdPath = "/{cardId}"; - - // Вложенный путь добавления ссылки. - private const string LinksPath = "/{cardId}/links"; - - // Вложенный путь удаления ссылки. - private const string LinkItemPath = "/{cardId}/links/{linkId}"; - - // Вложенный путь загрузки вложений (multipart, поле files). - private const string FilesPath = "/{cardId}/files"; - - // Вложенный путь скачивания вложения (поток + attachment). - private const string FileDownloadPath = "/{cardId}/files/{fileId}/download"; - - // Вложенный путь удаления вложения. - private const string FileItemPath = "/{cardId}/files/{fileId}"; - - // Вложенный путь установки/снятия напоминания. - private const string ReminderPath = "/{cardId}/reminder"; - - // Вложенный путь ленивой загрузки содержимого источника. - private const string SourcePath = "/{cardId}/source"; - - // Вложенный путь «напомнить позже». - private const string ReminderSnoozePath = "/{cardId}/reminder/snooze"; - - // OpenAPI-тег группы. - private const string OpenApiTag = "cards"; - - // 404: карточка не найдена. - private const string CardNotFoundDetail = "Карточка не найдена"; - - // 400: тело PATCH не JSON-объект. - private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом"; - - // 400: POST файлов без multipart/form-data. - private const string FormExpectedDetail = "Ожидается multipart/form-data"; - - // 400: POST напоминания без поля at. - private const string ReminderAtMissingDetail = "Поле at (epoch-ms) обязательно"; - - // 404 download: объекта нет в хранилище. - private const string FileNotFoundInStorageDetail = "Файл не найден в MinIO"; - - // 410 download: у записи файла нет objectKey. - private const string FileNotSavedDetail = "Файл не сохранён в объектном хранилище"; - - // Content-Type скачивания по умолчанию. - private const string DownloadContentTypeFallback = "application/octet-stream"; - - // Символ, убираемый из имени файла для Content-Disposition. - private const string FileNameQuoteCharacter = "\""; - - /// - /// Регистрирует уникальные подпути /api/cards - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapCardDetailsEndpoints(this IEndpointRouteBuilder app) - { - var cards = app.MapGroup(CardsGroupPrefix).WithTags(OpenApiTag); - - // Статические сегменты (/take, /clear-rejected) ДО /{cardId}; вложенные — за /{cardId}. - cards.MapPost("", CreateCardAsync); - cards.MapPost(TakePath, TakeCardAsync); - cards.MapPost(ClearRejectedPath, ClearRejectedAsync); - cards.MapPatch(CardIdPath, PatchCardAsync); - cards.MapPost(LinksPath, AddLinkAsync); - cards.MapDelete(LinkItemPath, RemoveLinkAsync); - cards.MapPost(FilesPath, UploadFilesAsync); - cards.MapGet(FileDownloadPath, DownloadFileAsync); - cards.MapDelete(FileItemPath, RemoveFileAsync); - cards.MapGet(SourcePath, ReadSourceAsync); - cards.MapPost(ReminderPath, SetReminderAsync); - cards.MapDelete(ReminderPath, ClearReminderAsync); - cards.MapPost(ReminderSnoozePath, SnoozeReminderAsync); - - return app; - } - - // POST /api/cards: ручное создание «локальной» карточки. Ответ — созданная карточка. - private static async Task CreateCardAsync( - CreateCardRequest body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService service = context.RequestServices.GetRequiredService(); - CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct); - - await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, new { cardId = created.Id }, ct); - return await ReadCardAsync(context, created.Id, ct); - } - - // POST /api/cards/take {cardId}: «взять в работу» — перенос карточки в planned. Ответ — карточка. - private static async Task TakeCardAsync( - TakeCardRequest body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService service = context.RequestServices.GetRequiredService(); - CardDto? card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct); - return card is null - ? EndpointResults.NotFound(CardNotFoundDetail) - : await ReadCardAsync(context, card.Id, ct); - } - - // POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено». - private static async Task ClearRejectedAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService service = context.RequestServices.GetRequiredService(); - int cleared = await service.ClearRejectedAsync(ct); - return Results.Ok(new { ok = true, cleared }); - } - - // PATCH /api/cards/{cardId}: точечная правка полей (title/summary/contact/tzText/stack/budget). - // Тело читается как произвольный JSON-объект (presence-aware): явный null чистящих полей - // (budget:null, stack:null) не теряется типизированным биндингом. Ответ — обновлённая карточка. - private static async Task PatchCardAsync( - string cardId, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - IReadOnlyDictionary? body = await ReadPatchBodyAsync(context, ct); - if (body is null) - { - return EndpointResults.BadRequest(InvalidBodyDetail); - } - - CardsService service = context.RequestServices.GetRequiredService(); - CardDto? card = await service.PatchCardAsync(cardId, body, ct); - return card is null - ? EndpointResults.NotFound(CardNotFoundDetail) - : await ReadCardAsync(context, cardId, ct); - } - - // POST /api/cards/{cardId}/links {name?,url}: добавить ссылку. Ответ — карточка. - private static async Task AddLinkAsync( - string cardId, - CardLinkRequest body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService service = context.RequestServices.GetRequiredService(); - CardResultDto result = await service.AddLinkAsync( - cardId, - body.Name ?? string.Empty, - body.Url ?? string.Empty, - ct); - if (result.Error is not null) - { - return EndpointResults.BadRequest(result.Error); - } - - return result.Card is null - ? EndpointResults.NotFound(CardNotFoundDetail) - : await ReadCardAsync(context, cardId, ct); - } - - // DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка. - private static async Task RemoveLinkAsync( - string cardId, - string linkId, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService service = context.RequestServices.GetRequiredService(); - CardResultDto result = await service.RemoveLinkAsync(cardId, linkId, ct); - return result.Card is null - ? EndpointResults.NotFound(CardNotFoundDetail) - : await ReadCardAsync(context, cardId, ct); - } - - // POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files). - // Ответ — обновлённая карточка (с новыми files). Карточки нет → 404 до записи объектов. Каждый файл: - // имя/ContentType/поток/длина → CardsService.AddFileAsync. Ранний null — гонка (404). - private static async Task UploadFilesAsync( - string cardId, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService cardsService = context.RequestServices.GetRequiredService(); - if (await cardsService.GetCardAsync(cardId, ct) is null) - { - return EndpointResults.NotFound(CardNotFoundDetail); - } - - IFormCollection form; - try - { - form = await context.Request.ReadFormAsync(ct); - } - catch (InvalidOperationException) - { - // Тело не multipart/form-data — ReadFormAsync бросает; фронт так не шлёт. - return EndpointResults.BadRequest(FormExpectedDetail); - } - - foreach (IFormFile file in form.Files) - { - await using Stream content = file.OpenReadStream(); - CardFileDto? entry = await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct); - if (entry is null) - { - return EndpointResults.NotFound(CardNotFoundDetail); - } - } - - return await ReadCardAsync(context, cardId, ct); - } - - // GET /api/cards/{cardId}/files/{fileId}/download: поток содержимого вложения. - // Карточки/записи нет → 404; пустой objectKey → 410; объекта нет в хранилище/сбой → 404. Ответ — поток - // с Content-Length/Content-Type из дескриптора; Content-Disposition attachment, имя без кавычек. - private static async Task DownloadFileAsync( - string cardId, - string fileId, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService cardsService = context.RequestServices.GetRequiredService(); - CardFileDto? entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct); - if (entry is null) - { - return EndpointResults.NotFound(CardNotFoundDetail); - } - - if (string.IsNullOrWhiteSpace(entry.ObjectKey)) - { - return EndpointResults.Gone(FileNotSavedDetail); - } - - IFileStorage storage = context.RequestServices.GetRequiredService(); - FileMeta? meta; - Stream? stream; - try - { - meta = await storage.StatAsync(entry.ObjectKey, ct); - stream = meta is null ? null : await storage.GetAsync(entry.ObjectKey, ct); - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - throw; - } - catch (Exception) - { - return EndpointResults.NotFound(FileNotFoundInStorageDetail); - } - - if (meta is null || stream is null) - { - return EndpointResults.NotFound(FileNotFoundInStorageDetail); - } - - context.Response.ContentLength = meta.Size; - string contentType = string.IsNullOrWhiteSpace(meta.ContentType) - ? DownloadContentTypeFallback - : meta.ContentType; - return Results.Stream(stream, contentType, fileDownloadName: ToDownloadFileName(entry.Name)); - } - - // DELETE /api/cards/{cardId}/files/{fileId}: открепить файл. Ответ — карточка. - private static async Task RemoveFileAsync( - string cardId, - string fileId, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService cardsService = context.RequestServices.GetRequiredService(); - CardDto? card = await cardsService.RemoveFileAsync(cardId, fileId, ct); - return card is null - ? EndpointResults.NotFound(CardNotFoundDetail) - : await ReadCardAsync(context, cardId, ct); - } - - // POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка. - private static async Task SetReminderAsync( - string cardId, - ReminderSetRequest body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - if (body.At is null) - { - return EndpointResults.BadRequest(ReminderAtMissingDetail); - } - - CardsService service = context.RequestServices.GetRequiredService(); - CardResultDto result = await service.SetReminderAsync(cardId, body.At.Value, ct); - if (result.Error is not null) - { - return EndpointResults.BadRequest(result.Error); - } - - return result.Card is null - ? EndpointResults.NotFound(CardNotFoundDetail) - : await ReadCardAsync(context, cardId, ct); - } - - // DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка. - private static async Task ClearReminderAsync( - string cardId, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService service = context.RequestServices.GetRequiredService(); - return await service.ClearReminderAsync(cardId, ct) - ? await ReadCardAsync(context, cardId, ct) - : EndpointResults.NotFound(CardNotFoundDetail); - } - - // POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка. - private static async Task SnoozeReminderAsync( - string cardId, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService service = context.RequestServices.GetRequiredService(); - return await service.SnoozeReminderAsync(cardId, ct) - ? await ReadCardAsync(context, cardId, ct) - : EndpointResults.NotFound(CardNotFoundDetail); - } - - // Читает карточку через единый сервис и возвращает её как ответ (404 — карточки нет). - // context: Контекст запроса (для резолва CardsService). - // cardId: Id карточки. - // ct: Токен отмены. - // Возвращает: 200 с единой карточкой либо 404. - // Читает содержимое источника карточки: провайдер по виду источника либо сохранённое в карточке. - // context: Контекст запроса (для резолва CardsService). - // cardId: Id карточки. - // ct: Токен отмены. - // Возвращает: 200 с SourceContent либо 404. - private static async Task ReadSourceAsync( - HttpContext context, - string cardId, - CancellationToken ct) - { - CardsService cardsService = context.RequestServices.GetRequiredService(); - CardDto? card = await cardsService.GetCardAsync(cardId, ct); - if (card is null) - { - return EndpointResults.NotFound(CardNotFoundDetail); - } - - SourceContent content = await cardsService.ResolveSourceAsync(card, ct); - return Results.Ok(content); - } - - private static async Task ReadCardAsync( - HttpContext context, - string cardId, - CancellationToken ct) - { - CardsService cardsService = context.RequestServices.GetRequiredService(); - CardDto? card = await cardsService.GetCardAsync(cardId, ct); - return card is null - ? EndpointResults.NotFound(CardNotFoundDetail) - : Results.Ok(card); - } - - // Имя файла для Content-Disposition без кавычек «"». - // name: Имя файла как в метаданных записи. - // Возвращает: Имя, безопасное для заголовка. - private static string ToDownloadFileName(string name) => name.Replace(FileNameQuoteCharacter, string.Empty); - - private static CardLocalCreateDto ToCreateLocalDto(CreateCardRequest body) - { - return new CardLocalCreateDto( - Title: body.Title, - Summary: body.Summary, - Stack: body.Stack, - Budget: body.Budget, - Contact: body.Contact, - TzText: body.TzText, - ContainerId: body.ContainerId ?? body.Stage); - } - - // Читает тело PATCH как произвольный JSON-объект: ключ → JsonElement (presence-aware). - // context: Контекст запроса. - // ct: Токен отмены. - // Возвращает: Словарь ключей тела либо null — тело не JSON-объект. - private static async Task?> ReadPatchBodyAsync(HttpContext context, CancellationToken ct) - { - try - { - return await JsonSerializer.DeserializeAsync>( - context.Request.Body, - options: null, - cancellationToken: ct); - } - catch (JsonException) - { - return null; - } - } -} +using System.Text.Json; +using Deal.Api.Endpoints.RequestModels; +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Api.Endpoints; + +/// +/// Детальные операции карточки +/// +public static class CardDetailsEndpoints +{ + // Префикс группы (общий с CardsEndpoints). + private const string CardsGroupPrefix = "/api/cards"; + + // Статический сегмент «взять в работу» (регистрируется до /{cardId}). + private const string TakePath = "/take"; + + // Статический сегмент очистки «Отклонено» (регистрируется до /{cardId}). + private const string ClearRejectedPath = "/clear-rejected"; + + // Параметрический сегмент карточки (PATCH /{cardId}). + private const string CardIdPath = "/{cardId}"; + + // Вложенный путь добавления ссылки. + private const string LinksPath = "/{cardId}/links"; + + // Вложенный путь удаления ссылки. + private const string LinkItemPath = "/{cardId}/links/{linkId}"; + + // Вложенный путь загрузки вложений (multipart, поле files). + private const string FilesPath = "/{cardId}/files"; + + // Вложенный путь скачивания вложения (поток + attachment). + private const string FileDownloadPath = "/{cardId}/files/{fileId}/download"; + + // Вложенный путь удаления вложения. + private const string FileItemPath = "/{cardId}/files/{fileId}"; + + // Вложенный путь установки/снятия напоминания. + private const string ReminderPath = "/{cardId}/reminder"; + + // Вложенный путь ленивой загрузки содержимого источника. + private const string SourcePath = "/{cardId}/source"; + + // Вложенный путь «напомнить позже». + private const string ReminderSnoozePath = "/{cardId}/reminder/snooze"; + + // OpenAPI-тег группы. + private const string OpenApiTag = "cards"; + + // 404: карточка не найдена. + private const string CardNotFoundDetail = "Карточка не найдена"; + + // 400: тело PATCH не JSON-объект. + private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом"; + + // 400: POST файлов без multipart/form-data. + private const string FormExpectedDetail = "Ожидается multipart/form-data"; + + // 400: POST напоминания без поля at. + private const string ReminderAtMissingDetail = "Поле at (epoch-ms) обязательно"; + + // 404 download: объекта нет в хранилище. + private const string FileNotFoundInStorageDetail = "Файл не найден в MinIO"; + + // 410 download: у записи файла нет objectKey. + private const string FileNotSavedDetail = "Файл не сохранён в объектном хранилище"; + + // Content-Type скачивания по умолчанию. + private const string DownloadContentTypeFallback = "application/octet-stream"; + + // Символ, убираемый из имени файла для Content-Disposition. + private const string FileNameQuoteCharacter = "\""; + + /// + /// Регистрирует уникальные подпути /api/cards + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapCardDetailsEndpoints(this IEndpointRouteBuilder app) + { + var cards = app.MapGroup(CardsGroupPrefix).WithTags(OpenApiTag); + + // Статические сегменты (/take, /clear-rejected) ДО /{cardId}; вложенные — за /{cardId}. + cards.MapPost("", CreateCardAsync); + cards.MapPost(TakePath, TakeCardAsync); + cards.MapPost(ClearRejectedPath, ClearRejectedAsync); + cards.MapPatch(CardIdPath, PatchCardAsync); + cards.MapPost(LinksPath, AddLinkAsync); + cards.MapDelete(LinkItemPath, RemoveLinkAsync); + cards.MapPost(FilesPath, UploadFilesAsync); + cards.MapGet(FileDownloadPath, DownloadFileAsync); + cards.MapDelete(FileItemPath, RemoveFileAsync); + cards.MapGet(SourcePath, ReadSourceAsync); + cards.MapPost(ReminderPath, SetReminderAsync); + cards.MapDelete(ReminderPath, ClearReminderAsync); + cards.MapPost(ReminderSnoozePath, SnoozeReminderAsync); + + return app; + } + + // POST /api/cards: ручное создание «локальной» карточки. Ответ — созданная карточка. + private static async Task CreateCardAsync( + CreateCardRequest body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService service = context.RequestServices.GetRequiredService(); + CardDto created = await service.CreateLocalCardAsync(ToCreateLocalDto(body), ct); + + await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCreated, new { cardId = created.Id }, ct); + return await ReadCardAsync(context, created.Id, ct); + } + + // POST /api/cards/take {cardId}: «взять в работу» — перенос карточки в planned. Ответ — карточка. + private static async Task TakeCardAsync( + TakeCardRequest body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService service = context.RequestServices.GetRequiredService(); + CardDto? card = await service.TakeCardAsync(body.CardId ?? body.LeadId ?? string.Empty, ct); + return card is null + ? EndpointResults.NotFound(CardNotFoundDetail) + : await ReadCardAsync(context, card.Id, ct); + } + + // POST /api/cards/clear-rejected: полная очистка терминальной стадии «Отклонено». + private static async Task ClearRejectedAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService service = context.RequestServices.GetRequiredService(); + int cleared = await service.ClearRejectedAsync(ct); + return Results.Ok(new { ok = true, cleared }); + } + + // PATCH /api/cards/{cardId}: точечная правка полей (title/summary/contact/tzText/stack/budget). + // Тело читается как произвольный JSON-объект (presence-aware): явный null чистящих полей + // (budget:null, stack:null) не теряется типизированным биндингом. Ответ — обновлённая карточка. + private static async Task PatchCardAsync( + string cardId, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + IReadOnlyDictionary? body = await ReadPatchBodyAsync(context, ct); + if (body is null) + { + return EndpointResults.BadRequest(InvalidBodyDetail); + } + + CardsService service = context.RequestServices.GetRequiredService(); + CardDto? card = await service.PatchCardAsync(cardId, body, ct); + return card is null + ? EndpointResults.NotFound(CardNotFoundDetail) + : await ReadCardAsync(context, cardId, ct); + } + + // POST /api/cards/{cardId}/links {name?,url}: добавить ссылку. Ответ — карточка. + private static async Task AddLinkAsync( + string cardId, + CardLinkRequest body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService service = context.RequestServices.GetRequiredService(); + CardResultDto result = await service.AddLinkAsync( + cardId, + body.Name ?? string.Empty, + body.Url ?? string.Empty, + ct); + if (result.Error is not null) + { + return EndpointResults.BadRequest(result.Error); + } + + return result.Card is null + ? EndpointResults.NotFound(CardNotFoundDetail) + : await ReadCardAsync(context, cardId, ct); + } + + // DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка. + private static async Task RemoveLinkAsync( + string cardId, + string linkId, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService service = context.RequestServices.GetRequiredService(); + CardResultDto result = await service.RemoveLinkAsync(cardId, linkId, ct); + return result.Card is null + ? EndpointResults.NotFound(CardNotFoundDetail) + : await ReadCardAsync(context, cardId, ct); + } + + // POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files). + // Ответ — обновлённая карточка (с новыми files). Карточки нет → 404 до записи объектов. Каждый файл: + // имя/ContentType/поток/длина → CardsService.AddFileAsync. Ранний null — гонка (404). + private static async Task UploadFilesAsync( + string cardId, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService cardsService = context.RequestServices.GetRequiredService(); + if (await cardsService.GetCardAsync(cardId, ct) is null) + { + return EndpointResults.NotFound(CardNotFoundDetail); + } + + IFormCollection form; + try + { + form = await context.Request.ReadFormAsync(ct); + } + catch (InvalidOperationException) + { + // Тело не multipart/form-data — ReadFormAsync бросает; фронт так не шлёт. + return EndpointResults.BadRequest(FormExpectedDetail); + } + + foreach (IFormFile file in form.Files) + { + await using Stream content = file.OpenReadStream(); + CardFileDto? entry = await cardsService.AddFileAsync(cardId, file.FileName, file.ContentType, content, file.Length, ct); + if (entry is null) + { + return EndpointResults.NotFound(CardNotFoundDetail); + } + } + + return await ReadCardAsync(context, cardId, ct); + } + + // GET /api/cards/{cardId}/files/{fileId}/download: поток содержимого вложения. + // Карточки/записи нет → 404; пустой objectKey → 410; объекта нет в хранилище/сбой → 404. Ответ — поток + // с Content-Length/Content-Type из дескриптора; Content-Disposition attachment, имя без кавычек. + private static async Task DownloadFileAsync( + string cardId, + string fileId, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService cardsService = context.RequestServices.GetRequiredService(); + CardFileDto? entry = await cardsService.GetFileEntryAsync(cardId, fileId, ct); + if (entry is null) + { + return EndpointResults.NotFound(CardNotFoundDetail); + } + + if (string.IsNullOrWhiteSpace(entry.ObjectKey)) + { + return EndpointResults.Gone(FileNotSavedDetail); + } + + IFileStorage storage = context.RequestServices.GetRequiredService(); + FileMeta? meta; + Stream? stream; + try + { + meta = await storage.StatAsync(entry.ObjectKey, ct); + stream = meta is null ? null : await storage.GetAsync(entry.ObjectKey, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception) + { + return EndpointResults.NotFound(FileNotFoundInStorageDetail); + } + + if (meta is null || stream is null) + { + return EndpointResults.NotFound(FileNotFoundInStorageDetail); + } + + context.Response.ContentLength = meta.Size; + string contentType = string.IsNullOrWhiteSpace(meta.ContentType) + ? DownloadContentTypeFallback + : meta.ContentType; + return Results.Stream(stream, contentType, fileDownloadName: ToDownloadFileName(entry.Name)); + } + + // DELETE /api/cards/{cardId}/files/{fileId}: открепить файл. Ответ — карточка. + private static async Task RemoveFileAsync( + string cardId, + string fileId, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService cardsService = context.RequestServices.GetRequiredService(); + CardDto? card = await cardsService.RemoveFileAsync(cardId, fileId, ct); + return card is null + ? EndpointResults.NotFound(CardNotFoundDetail) + : await ReadCardAsync(context, cardId, ct); + } + + // POST /api/cards/{cardId}/reminder {at: epoch-ms}: установить напоминание. Ответ — карточка. + private static async Task SetReminderAsync( + string cardId, + ReminderSetRequest body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + if (body.At is null) + { + return EndpointResults.BadRequest(ReminderAtMissingDetail); + } + + CardsService service = context.RequestServices.GetRequiredService(); + CardResultDto result = await service.SetReminderAsync(cardId, body.At.Value, ct); + if (result.Error is not null) + { + return EndpointResults.BadRequest(result.Error); + } + + return result.Card is null + ? EndpointResults.NotFound(CardNotFoundDetail) + : await ReadCardAsync(context, cardId, ct); + } + + // DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка. + private static async Task ClearReminderAsync( + string cardId, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService service = context.RequestServices.GetRequiredService(); + return await service.ClearReminderAsync(cardId, ct) + ? await ReadCardAsync(context, cardId, ct) + : EndpointResults.NotFound(CardNotFoundDetail); + } + + // POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка. + private static async Task SnoozeReminderAsync( + string cardId, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService service = context.RequestServices.GetRequiredService(); + return await service.SnoozeReminderAsync(cardId, ct) + ? await ReadCardAsync(context, cardId, ct) + : EndpointResults.NotFound(CardNotFoundDetail); + } + + // Читает карточку через единый сервис и возвращает её как ответ (404 — карточки нет). + // context: Контекст запроса (для резолва CardsService). + // cardId: Id карточки. + // ct: Токен отмены. + // Возвращает: 200 с единой карточкой либо 404. + // Читает содержимое источника карточки: провайдер по виду источника либо сохранённое в карточке. + // context: Контекст запроса (для резолва CardsService). + // cardId: Id карточки. + // ct: Токен отмены. + // Возвращает: 200 с SourceContent либо 404. + private static async Task ReadSourceAsync( + HttpContext context, + string cardId, + CancellationToken ct) + { + CardsService cardsService = context.RequestServices.GetRequiredService(); + CardDto? card = await cardsService.GetCardAsync(cardId, ct); + if (card is null) + { + return EndpointResults.NotFound(CardNotFoundDetail); + } + + SourceContent content = await cardsService.ResolveSourceAsync(card, ct); + return Results.Ok(content); + } + + private static async Task ReadCardAsync( + HttpContext context, + string cardId, + CancellationToken ct) + { + CardsService cardsService = context.RequestServices.GetRequiredService(); + CardDto? card = await cardsService.GetCardAsync(cardId, ct); + return card is null + ? EndpointResults.NotFound(CardNotFoundDetail) + : Results.Ok(card); + } + + // Имя файла для Content-Disposition без кавычек «"». + // name: Имя файла как в метаданных записи. + // Возвращает: Имя, безопасное для заголовка. + private static string ToDownloadFileName(string name) => name.Replace(FileNameQuoteCharacter, string.Empty); + + private static CardLocalCreateDto ToCreateLocalDto(CreateCardRequest body) + { + return new CardLocalCreateDto( + Title: body.Title, + Summary: body.Summary, + Stack: body.Stack, + Budget: body.Budget, + Contact: body.Contact, + TzText: body.TzText, + ContainerId: body.ContainerId ?? body.Stage); + } + + // Читает тело PATCH как произвольный JSON-объект: ключ → JsonElement (presence-aware). + // context: Контекст запроса. + // ct: Токен отмены. + // Возвращает: Словарь ключей тела либо null — тело не JSON-объект. + private static async Task?> ReadPatchBodyAsync(HttpContext context, CancellationToken ct) + { + try + { + return await JsonSerializer.DeserializeAsync>( + context.Request.Body, + options: null, + cancellationToken: ct); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs index 6be52f1..c2f41e1 100644 --- a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs @@ -1,461 +1,461 @@ -using Deal.Api.Endpoints.RequestModels; -using Deal.Api.Events; -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Api.Endpoints; - -/// -/// Эндпоинты карточек и поиска -/// -public static class CardsEndpoints -{ - // Префикс группы карточек. - private const string CardsGroupPrefix = "/api/cards"; - - // Префикс группы поиска (единственный эндпоинт группы — /api/search). - private const string ApiGroupPrefix = "/api"; - - // Путь поиска (GET). - private const string SearchPath = "/search"; - - private const string OpenApiTag = "dashboard"; - - private const string CardNotFoundDetail = "Карточка не найдена"; - - // 400 GET /cards: containerId не существует. - private const string UnknownColumnDetail = "Неизвестный контейнер"; - - private const string UserActor = "user"; - - private const string ReclassifiedEventType = "cards_reclassified"; - - // Контекст ручного перехода карточки: пользователь, обучение ML по цели переноса. - private static readonly TransitionContext UserMoveContext = new() { Actor = UserActor, Learn = true }; - - /// - /// Регистрирует группы /api/cards и /api - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapCardsEndpoints(this IEndpointRouteBuilder app) - { - var leads = app.MapGroup(CardsGroupPrefix).WithTags(OpenApiTag); - - // Статические сегменты ДО /cards/{cardId}: ASP.NET Core отдаёт приоритет литералам, порядок регистрации - // сохранён для читаемости. - leads.MapGet("", ListCardsAsync); - leads.MapGet("/counts", CountsAsync); - leads.MapPost("/mark-all-seen", MarkAllSeenAsync); - leads.MapPost("/mark-col-seen", MarkColSeenAsync); - leads.MapPost("/clear-col", ClearColAsync); - leads.MapPost("/reclassify", ReclassifyAsync); - leads.MapGet("/{cardId}", GetCardAsync); - leads.MapPost("/{cardId}/reclassify", ReclassifyOneAsync); - leads.MapPost("/{cardId}/move", MoveAsync); - leads.MapPost("/{cardId}/trash", TrashAsync); - leads.MapPost("/{cardId}/restore", RestoreAsync); - leads.MapDelete("/{cardId}", DeleteAsync); - leads.MapPost("/{cardId}/comments", AddCommentAsync); - - app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag).MapGet(SearchPath, SearchAsync); - - return app; - } - - // GET /api/cards?containerId=: карточки контейнера (или все карточки дашборда); 400 «Неизвестный контейнер». - // Параметр col принят как алиас containerId (совместимость со старым фронтом). - private static async Task ListCardsAsync( - string? containerId, - string? col, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - string? target = string.IsNullOrEmpty(containerId) ? col : containerId; - CardsService cardsService = context.RequestServices.GetRequiredService(); - if (!string.IsNullOrEmpty(target) && !await IsKnownContainerAsync(target, context, ct)) - { - return EndpointResults.BadRequest(UnknownColumnDetail); - } - - IReadOnlyList cards = await cardsService.ListCardsAsync(target, ct); - return Results.Ok(new { items = cards }); - } - - private static async Task CountsAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService cardsService = context.RequestServices.GetRequiredService(); - CardCountsDto counts = await cardsService.CountsAsync(ct); - - var wire = new Dictionary { ["new"] = counts.New }; - foreach ((string col, CardColumnCountDto column) in counts.Columns) - { - wire[col] = column; - } - - wire["learning"] = counts.Learning; - wire["ml"] = counts.Ml; - wire["ai"] = counts.Ai; - return Results.Ok(wire); - } - - private static async Task GetCardAsync( - string cardId, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService cardsService = context.RequestServices.GetRequiredService(); - CardDto? card = await cardsService.GetCardAsync(cardId, ct); - return card is null - ? EndpointResults.NotFound(CardNotFoundDetail) - : Results.Ok(card); - } - - private static async Task MarkAllSeenAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService cardsService = context.RequestServices.GetRequiredService(); - await cardsService.MarkSeenAsync(cardId: null, col: null, ct); - return Results.Ok(new { ok = true }); - } - - private static async Task MarkColSeenAsync( - MarkColBody body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - if (body.Col is null) - { - return EndpointResults.BadRequest(UnknownColumnDetail); - } - - CardsService cardsService = context.RequestServices.GetRequiredService(); - await cardsService.MarkSeenAsync(cardId: null, col: body.Col, ct); - return Results.Ok(new { ok = true }); - } - - private static async Task MoveAsync( - string cardId, - MoveBody body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - ICardMover mover = context.RequestServices.GetRequiredService(); - CardMoveResultDto outcome = await mover.MoveAsync(cardId, body.To ?? string.Empty, UserMoveContext, ct); - if (outcome.Error is not null) - { - return EndpointResults.BadRequest(outcome.Error); - } - - if (!outcome.Exists) - { - return EndpointResults.NotFound(CardNotFoundDetail); - } - - await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct); - - CardsService cardsService = context.RequestServices.GetRequiredService(); - CardDto? unified = await cardsService.GetCardAsync(cardId, ct); - return unified is null - ? EndpointResults.NotFound(CardNotFoundDetail) - : Results.Ok(unified); - } - - private static async Task TrashAsync( - string cardId, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService cardsService = context.RequestServices.GetRequiredService(); - CardDto? card = await cardsService.TrashCardAsync(cardId, ct); - if (card is null) - { - return EndpointResults.NotFound(CardNotFoundDetail); - } - - await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct); - return Results.Ok(new { ok = true }); - } - - private static async Task RestoreAsync( - string cardId, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService cardsService = context.RequestServices.GetRequiredService(); - string? col = await cardsService.RestoreCardAsync(cardId, ct); - if (col is null) - { - return EndpointResults.NotFound(CardNotFoundDetail); - } - - await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct); - return Results.Ok(new { ok = true, col }); - } - - private static async Task DeleteAsync( - string cardId, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService cardsService = context.RequestServices.GetRequiredService(); - bool deleted = await cardsService.DeleteForeverAsync(cardId, ct); - if (!deleted) - { - return EndpointResults.NotFound(CardNotFoundDetail); - } - - await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct); - return Results.Ok(new { ok = true }); - } - - private static async Task ClearColAsync( - ClearColBody body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService cardsService = context.RequestServices.GetRequiredService(); - ClearColResultDto result = await cardsService.ClearColAsync(body.Col ?? string.Empty, ct); - return result.Error is not null - ? EndpointResults.BadRequest(result.Error) - : Results.Ok(new { ok = true, cleared = result.Cleared }); - } - - private static async Task AddCommentAsync( - string cardId, - CommentBody body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService cardsService = context.RequestServices.GetRequiredService(); - AddCommentResultDto result = await cardsService.AddCommentAsync(cardId, body.Text ?? string.Empty, ct); - if (result.Error is not null) - { - return EndpointResults.BadRequest(result.Error); - } - - if (result.Comments is null) - { - return EndpointResults.NotFound(CardNotFoundDetail); - } - - await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct); - return Results.Ok(new { comments = result.Comments }); - } - - // POST /api/cards/reclassify: переклассификация «Неразобранного» (все карточки либо ids). - // Тело ids опционально (фронт шлёт запрос без тела — все карточки inbox). Проход синхронный; при занятом - // проходе ответ {started:false, busy:true}. Поля started/busy/attempted сохранены ради совместимости, - // добавлены reclassified/moved/kept/trashed/skipped/usedAi/reason. Аудит — card_reclassified; после - // успешного прохода (reclassified > 0) публикуется SSE cards_reclassified {reclassified,moved}. - // body: Тело запроса (ids — опционально). - // context: Контекст запроса. - // ct: Токен отмены. - private static async Task ReclassifyAsync( - ReclassifyBody? body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardReclassifier reclassifier = context.RequestServices.GetRequiredService(); - SseBroker broker = context.RequestServices.GetRequiredService(); - Guid tenantId = context.GetCurrentUser()!.TenantId; - // Синхронный IProgress: события прогресса уходят в SSE в порядке прохода, до финального события. - var progress = new InlineProgress(value => broker.Publish( - tenantId, - ReclassifiedEventType, - new - { - progress = true, - done = value.Done, - total = value.Total, - moved = value.Moved, - kept = value.Kept, - trashed = value.Trashed, - skipped = value.Skipped, - })); - ReclassifyResultDto result = await reclassifier.ReclassifyInboxAsync(body?.Ids, ct, progress); - await AppendReclassifyAuditAsync(context, result, ct); - PublishReclassified(context, result); - return Results.Ok(ToReclassifyWire(result)); - } - - // POST /api/cards/{cardId}/reclassify: переклассификация одной карточки; 404 «Карточка не найдена». - // cardId: Id карточки (c_...). - // context: Контекст запроса. - // ct: Токен отмены. - private static async Task ReclassifyOneAsync( - string cardId, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService cardsService = context.RequestServices.GetRequiredService(); - CardDto? card = await cardsService.GetCardAsync(cardId, ct); - if (card is null) - { - return EndpointResults.NotFound(CardNotFoundDetail); - } - - CardReclassifier reclassifier = context.RequestServices.GetRequiredService(); - ReclassifyResultDto result = await reclassifier.ReclassifyCardAsync(card, ct); - await AppendReclassifyAuditAsync(context, result, ct); - PublishReclassified(context, result); - return Results.Ok(ToReclassifyWire(result)); - } - - // Wire-форма итога переклассификации (camelCase; сохранены started/busy/attempted). - // result: Итог прохода. - // Возвращает: Объект ответа эндпоинта. - private static object ToReclassifyWire(ReclassifyResultDto result) => new - { - started = result.Started, - busy = result.Busy, - attempted = result.Attempted, - reclassified = result.Reclassified, - moved = result.Moved, - kept = result.Kept, - trashed = result.Trashed, - skipped = result.Skipped, - usedAi = result.UsedAi, - reason = result.Reason, - }; - - private static void PublishReclassified(HttpContext context, ReclassifyResultDto result) - { - if (!result.Started || result.Reclassified == 0) - { - return; - } - - SseBroker broker = context.RequestServices.GetRequiredService(); - broker.Publish( - context.GetCurrentUser()!.TenantId, - ReclassifiedEventType, - new { reclassified = result.Reclassified, moved = result.Moved, progress = false }); - } - - // Аудит переклассификации: пишется только когда проход реально что-то изменил. - // context: Контекст запроса. - // result: Итог прохода. - // ct: Токен отмены. - private static Task AppendReclassifyAuditAsync( - HttpContext context, - ReclassifyResultDto result, - CancellationToken ct) - { - if (result.Reclassified == 0) - { - return Task.CompletedTask; - } - - return AuditAppender.AppendTenantAsync( - context, - AuditEvents.CardReclassified, - new { attempted = result.Attempted, reclassified = result.Reclassified, moved = result.Moved, trashed = result.Trashed }, - ct); - } - - private static async Task SearchAsync( - string? q, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - CardsService cardsService = context.RequestServices.GetRequiredService(); - IReadOnlyList leads = await cardsService.SearchCardsAsync(q, ct); - - // messages всегда []: фронт их не читает. - return Results.Ok(new { cards = leads, messages = Array.Empty() }); - } - - // ── Внутреннее ───────────────────────────────────────────────────────── - - // Существует ли контейнер с таким id (служебная зона/стадия/доска). - // col: Значение query-параметра containerId (непустое). - // context: Контекст запроса (для резолва ContainersService). - // ct: Токен отмены. - // Возвращает: True — контейнер допустим для фильтра. - private static async Task IsKnownContainerAsync( - string col, - HttpContext context, - CancellationToken ct) - { - ContainersService containers = context.RequestServices.GetRequiredService(); - return await containers.GetAsync(col, ct) is not null; - } -} +using Deal.Api.Endpoints.RequestModels; +using Deal.Api.Events; +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Modules.Cards.Application.Abstractions; +using Deal.Modules.Cards.Application.Dtos; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Api.Endpoints; + +/// +/// Эндпоинты карточек и поиска +/// +public static class CardsEndpoints +{ + // Префикс группы карточек. + private const string CardsGroupPrefix = "/api/cards"; + + // Префикс группы поиска (единственный эндпоинт группы — /api/search). + private const string ApiGroupPrefix = "/api"; + + // Путь поиска (GET). + private const string SearchPath = "/search"; + + private const string OpenApiTag = "dashboard"; + + private const string CardNotFoundDetail = "Карточка не найдена"; + + // 400 GET /cards: containerId не существует. + private const string UnknownColumnDetail = "Неизвестный контейнер"; + + private const string UserActor = "user"; + + private const string ReclassifiedEventType = "cards_reclassified"; + + // Контекст ручного перехода карточки: пользователь, обучение ML по цели переноса. + private static readonly TransitionContext UserMoveContext = new() { Actor = UserActor, Learn = true }; + + /// + /// Регистрирует группы /api/cards и /api + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapCardsEndpoints(this IEndpointRouteBuilder app) + { + var leads = app.MapGroup(CardsGroupPrefix).WithTags(OpenApiTag); + + // Статические сегменты ДО /cards/{cardId}: ASP.NET Core отдаёт приоритет литералам, порядок регистрации + // сохранён для читаемости. + leads.MapGet("", ListCardsAsync); + leads.MapGet("/counts", CountsAsync); + leads.MapPost("/mark-all-seen", MarkAllSeenAsync); + leads.MapPost("/mark-col-seen", MarkColSeenAsync); + leads.MapPost("/clear-col", ClearColAsync); + leads.MapPost("/reclassify", ReclassifyAsync); + leads.MapGet("/{cardId}", GetCardAsync); + leads.MapPost("/{cardId}/reclassify", ReclassifyOneAsync); + leads.MapPost("/{cardId}/move", MoveAsync); + leads.MapPost("/{cardId}/trash", TrashAsync); + leads.MapPost("/{cardId}/restore", RestoreAsync); + leads.MapDelete("/{cardId}", DeleteAsync); + leads.MapPost("/{cardId}/comments", AddCommentAsync); + + app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag).MapGet(SearchPath, SearchAsync); + + return app; + } + + // GET /api/cards?containerId=: карточки контейнера (или все карточки дашборда); 400 «Неизвестный контейнер». + // Параметр col принят как алиас containerId (совместимость со старым фронтом). + private static async Task ListCardsAsync( + string? containerId, + string? col, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + string? target = string.IsNullOrEmpty(containerId) ? col : containerId; + CardsService cardsService = context.RequestServices.GetRequiredService(); + if (!string.IsNullOrEmpty(target) && !await IsKnownContainerAsync(target, context, ct)) + { + return EndpointResults.BadRequest(UnknownColumnDetail); + } + + IReadOnlyList cards = await cardsService.ListCardsAsync(target, ct); + return Results.Ok(new { items = cards }); + } + + private static async Task CountsAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService cardsService = context.RequestServices.GetRequiredService(); + CardCountsDto counts = await cardsService.CountsAsync(ct); + + var wire = new Dictionary { ["new"] = counts.New }; + foreach ((string col, CardColumnCountDto column) in counts.Columns) + { + wire[col] = column; + } + + wire["learning"] = counts.Learning; + wire["ml"] = counts.Ml; + wire["ai"] = counts.Ai; + return Results.Ok(wire); + } + + private static async Task GetCardAsync( + string cardId, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService cardsService = context.RequestServices.GetRequiredService(); + CardDto? card = await cardsService.GetCardAsync(cardId, ct); + return card is null + ? EndpointResults.NotFound(CardNotFoundDetail) + : Results.Ok(card); + } + + private static async Task MarkAllSeenAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService cardsService = context.RequestServices.GetRequiredService(); + await cardsService.MarkSeenAsync(cardId: null, col: null, ct); + return Results.Ok(new { ok = true }); + } + + private static async Task MarkColSeenAsync( + MarkColBody body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + if (body.Col is null) + { + return EndpointResults.BadRequest(UnknownColumnDetail); + } + + CardsService cardsService = context.RequestServices.GetRequiredService(); + await cardsService.MarkSeenAsync(cardId: null, col: body.Col, ct); + return Results.Ok(new { ok = true }); + } + + private static async Task MoveAsync( + string cardId, + MoveBody body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + ICardMover mover = context.RequestServices.GetRequiredService(); + CardMoveResultDto outcome = await mover.MoveAsync(cardId, body.To ?? string.Empty, UserMoveContext, ct); + if (outcome.Error is not null) + { + return EndpointResults.BadRequest(outcome.Error); + } + + if (!outcome.Exists) + { + return EndpointResults.NotFound(CardNotFoundDetail); + } + + await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct); + + CardsService cardsService = context.RequestServices.GetRequiredService(); + CardDto? unified = await cardsService.GetCardAsync(cardId, ct); + return unified is null + ? EndpointResults.NotFound(CardNotFoundDetail) + : Results.Ok(unified); + } + + private static async Task TrashAsync( + string cardId, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService cardsService = context.RequestServices.GetRequiredService(); + CardDto? card = await cardsService.TrashCardAsync(cardId, ct); + if (card is null) + { + return EndpointResults.NotFound(CardNotFoundDetail); + } + + await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct); + return Results.Ok(new { ok = true }); + } + + private static async Task RestoreAsync( + string cardId, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService cardsService = context.RequestServices.GetRequiredService(); + string? col = await cardsService.RestoreCardAsync(cardId, ct); + if (col is null) + { + return EndpointResults.NotFound(CardNotFoundDetail); + } + + await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct); + return Results.Ok(new { ok = true, col }); + } + + private static async Task DeleteAsync( + string cardId, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService cardsService = context.RequestServices.GetRequiredService(); + bool deleted = await cardsService.DeleteForeverAsync(cardId, ct); + if (!deleted) + { + return EndpointResults.NotFound(CardNotFoundDetail); + } + + await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct); + return Results.Ok(new { ok = true }); + } + + private static async Task ClearColAsync( + ClearColBody body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService cardsService = context.RequestServices.GetRequiredService(); + ClearColResultDto result = await cardsService.ClearColAsync(body.Col ?? string.Empty, ct); + return result.Error is not null + ? EndpointResults.BadRequest(result.Error) + : Results.Ok(new { ok = true, cleared = result.Cleared }); + } + + private static async Task AddCommentAsync( + string cardId, + CommentBody body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService cardsService = context.RequestServices.GetRequiredService(); + AddCommentResultDto result = await cardsService.AddCommentAsync(cardId, body.Text ?? string.Empty, ct); + if (result.Error is not null) + { + return EndpointResults.BadRequest(result.Error); + } + + if (result.Comments is null) + { + return EndpointResults.NotFound(CardNotFoundDetail); + } + + await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct); + return Results.Ok(new { comments = result.Comments }); + } + + // POST /api/cards/reclassify: переклассификация «Неразобранного» (все карточки либо ids). + // Тело ids опционально (фронт шлёт запрос без тела — все карточки inbox). Проход синхронный; при занятом + // проходе ответ {started:false, busy:true}. Поля started/busy/attempted сохранены ради совместимости, + // добавлены reclassified/moved/kept/trashed/skipped/usedAi/reason. Аудит — card_reclassified; после + // успешного прохода (reclassified > 0) публикуется SSE cards_reclassified {reclassified,moved}. + // body: Тело запроса (ids — опционально). + // context: Контекст запроса. + // ct: Токен отмены. + private static async Task ReclassifyAsync( + ReclassifyBody? body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardReclassifier reclassifier = context.RequestServices.GetRequiredService(); + SseBroker broker = context.RequestServices.GetRequiredService(); + Guid tenantId = context.GetCurrentUser()!.TenantId; + // Синхронный IProgress: события прогресса уходят в SSE в порядке прохода, до финального события. + var progress = new InlineProgress(value => broker.Publish( + tenantId, + ReclassifiedEventType, + new + { + progress = true, + done = value.Done, + total = value.Total, + moved = value.Moved, + kept = value.Kept, + trashed = value.Trashed, + skipped = value.Skipped, + })); + ReclassifyResultDto result = await reclassifier.ReclassifyInboxAsync(body?.Ids, ct, progress); + await AppendReclassifyAuditAsync(context, result, ct); + PublishReclassified(context, result); + return Results.Ok(ToReclassifyWire(result)); + } + + // POST /api/cards/{cardId}/reclassify: переклассификация одной карточки; 404 «Карточка не найдена». + // cardId: Id карточки (c_...). + // context: Контекст запроса. + // ct: Токен отмены. + private static async Task ReclassifyOneAsync( + string cardId, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService cardsService = context.RequestServices.GetRequiredService(); + CardDto? card = await cardsService.GetCardAsync(cardId, ct); + if (card is null) + { + return EndpointResults.NotFound(CardNotFoundDetail); + } + + CardReclassifier reclassifier = context.RequestServices.GetRequiredService(); + ReclassifyResultDto result = await reclassifier.ReclassifyCardAsync(card, ct); + await AppendReclassifyAuditAsync(context, result, ct); + PublishReclassified(context, result); + return Results.Ok(ToReclassifyWire(result)); + } + + // Wire-форма итога переклассификации (camelCase; сохранены started/busy/attempted). + // result: Итог прохода. + // Возвращает: Объект ответа эндпоинта. + private static object ToReclassifyWire(ReclassifyResultDto result) => new + { + started = result.Started, + busy = result.Busy, + attempted = result.Attempted, + reclassified = result.Reclassified, + moved = result.Moved, + kept = result.Kept, + trashed = result.Trashed, + skipped = result.Skipped, + usedAi = result.UsedAi, + reason = result.Reason, + }; + + private static void PublishReclassified(HttpContext context, ReclassifyResultDto result) + { + if (!result.Started || result.Reclassified == 0) + { + return; + } + + SseBroker broker = context.RequestServices.GetRequiredService(); + broker.Publish( + context.GetCurrentUser()!.TenantId, + ReclassifiedEventType, + new { reclassified = result.Reclassified, moved = result.Moved, progress = false }); + } + + // Аудит переклассификации: пишется только когда проход реально что-то изменил. + // context: Контекст запроса. + // result: Итог прохода. + // ct: Токен отмены. + private static Task AppendReclassifyAuditAsync( + HttpContext context, + ReclassifyResultDto result, + CancellationToken ct) + { + if (result.Reclassified == 0) + { + return Task.CompletedTask; + } + + return AuditAppender.AppendTenantAsync( + context, + AuditEvents.CardReclassified, + new { attempted = result.Attempted, reclassified = result.Reclassified, moved = result.Moved, trashed = result.Trashed }, + ct); + } + + private static async Task SearchAsync( + string? q, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + CardsService cardsService = context.RequestServices.GetRequiredService(); + IReadOnlyList leads = await cardsService.SearchCardsAsync(q, ct); + + // messages всегда []: фронт их не читает. + return Results.Ok(new { cards = leads, messages = Array.Empty() }); + } + + // ── Внутреннее ───────────────────────────────────────────────────────── + + // Существует ли контейнер с таким id (служебная зона/стадия/доска). + // col: Значение query-параметра containerId (непустое). + // context: Контекст запроса (для резолва ContainersService). + // ct: Токен отмены. + // Возвращает: True — контейнер допустим для фильтра. + private static async Task IsKnownContainerAsync( + string col, + HttpContext context, + CancellationToken ct) + { + ContainersService containers = context.RequestServices.GetRequiredService(); + return await containers.GetAsync(col, ct) is not null; + } +} diff --git a/src/core/Deal.Api/Endpoints/ChangePasswordRequest.cs b/src/core/Deal.Api/Endpoints/ChangePasswordRequest.cs index 18c45f6..2121241 100644 --- a/src/core/Deal.Api/Endpoints/ChangePasswordRequest.cs +++ b/src/core/Deal.Api/Endpoints/ChangePasswordRequest.cs @@ -1,8 +1,8 @@ -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/auth/change-password. -/// -/// Текущий пароль. -/// Новый пароль (минимум 8 символов). -public sealed record ChangePasswordRequest(string OldPassword, string NewPassword); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/auth/change-password. +/// +/// Текущий пароль. +/// Новый пароль (минимум 8 символов). +public sealed record ChangePasswordRequest(string OldPassword, string NewPassword); diff --git a/src/core/Deal.Api/Endpoints/CheckMessageRequest.cs b/src/core/Deal.Api/Endpoints/CheckMessageRequest.cs index e8f4ae9..938b5f8 100644 --- a/src/core/Deal.Api/Endpoints/CheckMessageRequest.cs +++ b/src/core/Deal.Api/Endpoints/CheckMessageRequest.cs @@ -1,7 +1,7 @@ -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/admin/check-message. -/// -/// Текст сообщения для проверки фильтром. -public sealed record CheckMessageRequest(string Text); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/admin/check-message. +/// +/// Текст сообщения для проверки фильтром. +public sealed record CheckMessageRequest(string Text); diff --git a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs index e26250f..bbab4aa 100644 --- a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs @@ -1,312 +1,312 @@ -using System.Text.Json; -using Deal.Api.Endpoints.RequestModels; -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Api.Endpoints; - -/// -/// Эндпоинты контейнеров -/// -public static class ContainersEndpoints -{ - // Префикс группы контейнеров. - private const string ContainersGroupPrefix = "/api/containers"; - - // OpenAPI-тег группы. - private const string OpenApiTag = "containers"; - - // 404 PATCH/accept: контейнер не найден. - private const string ContainerNotFoundDetail = "Контейнер не найден"; - - // 400: отсутствующий/явный null name контейнера. - private const string ContainerNameRequiredDetail = "Укажите название колонки"; - - // 400 reorder: отсутствующий/явный null order. - private const string ContainerOrderRequiredDetail = "Не указан порядок колонок"; - - // 400 PATCH: тело не JSON-объект. - private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом"; - - // Опции разбора PATCH-тела: web-дефолты (camelCase + регистронезависимость). - private static readonly JsonSerializerOptions RequestJsonOptions = new(JsonSerializerDefaults.Web); - - /// - /// Регистрирует группы /api/containers - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapContainersEndpoints(this IEndpointRouteBuilder app) - { - var containers = app.MapGroup(ContainersGroupPrefix).WithTags(OpenApiTag); - containers.MapGet("", ListContainersAsync); - containers.MapPost("", CreateContainerAsync); - containers.MapPost("/reorder", ReorderContainersAsync); - containers.MapGet("/state", GetColumnsStateAsync); - containers.MapPatch("/{containerId}/state", PatchColumnStateAsync); - containers.MapPost("/{containerId}/accept", AcceptSuggestedAsync); - containers.MapPatch("/{containerId}", PatchContainerAsync); - containers.MapDelete("/{containerId}", DeleteContainerAsync); - return app; - } - - // GET /api/containers?space=: список контейнеров пространства (или всех) со счётчиками. - private static async Task ListContainersAsync( - string? space, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - ContainersService containers = context.RequestServices.GetRequiredService(); - return Results.Ok(new { items = await containers.ListAsync(space, ct) }); - } - - // POST /api/containers: создать контейнер; ответ {id}. - private static async Task CreateContainerAsync( - ContainerCreateRequest body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - if (body.Name is null) - { - return EndpointResults.BadRequest(ContainerNameRequiredDetail); - } - - ContainersService containers = context.RequestServices.GetRequiredService(); - ContainerDto created = await containers.CreateAsync( - new ContainerCreateDto( - Name: body.Name, - Description: body.Description ?? string.Empty, - Color: body.Color, - Space: body.Space ?? ContainerSpaces.Dashboard, - Kind: body.Kind ?? ContainerKinds.Board, - Suggested: body.Suggested ?? false, - Rules: NormalizeWireRules(body.Rules), - Note: body.Note ?? string.Empty), - ct); - - await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, new { id = created.Id, name = created.Name }, ct); - return Results.Ok(new { id = created.Id }); - } - - // PATCH /api/containers/{id}: частичное обновление; ответ {id}; 404 «Контейнер не найден». - private static async Task PatchContainerAsync( - string containerId, - JsonElement body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - if (body.ValueKind != JsonValueKind.Object) - { - return EndpointResults.BadRequest(InvalidBodyDetail); - } - - foreach (JsonProperty property in body.EnumerateObject()) - { - if (string.Equals(property.Name, "name", StringComparison.OrdinalIgnoreCase) - && property.Value.ValueKind == JsonValueKind.Null) - { - return EndpointResults.BadRequest(ContainerNameRequiredDetail); - } - } - - ContainerPatchRequest? patchBody; - try - { - patchBody = body.Deserialize(RequestJsonOptions); - } - catch (JsonException) - { - return EndpointResults.BadRequest(InvalidBodyDetail); - } - - if (patchBody is null) - { - return EndpointResults.BadRequest(InvalidBodyDetail); - } - - ContainersService containers = context.RequestServices.GetRequiredService(); - ContainerDto? updated = await containers.PatchAsync( - containerId, - new ContainerPatchDto( - patchBody.Name, - patchBody.Description, - patchBody.Color, - patchBody.Collapsed, - patchBody.Suggested, - patchBody.Note, - NormalizeWireRules(patchBody.Rules), - patchBody.Policy), - ct); - if (updated is null) - { - return EndpointResults.NotFound(ContainerNotFoundDetail); - } - - await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct); - return Results.Ok(new { id = updated.Id }); - } - - // POST /api/containers/{id}/accept: принять ИИ-предложение (suggested=false). - private static async Task AcceptSuggestedAsync( - string containerId, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - ContainersService containers = context.RequestServices.GetRequiredService(); - ContainerDto? accepted = await containers.AcceptSuggestedAsync(containerId, ct); - if (accepted is null) - { - return EndpointResults.NotFound(ContainerNotFoundDetail); - } - - await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct); - return Results.Ok(accepted); - } - - // DELETE /api/containers/{id}: удалить контейнер; карточки → «Неразобранное» новыми. - private static async Task DeleteContainerAsync( - string containerId, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - ContainersService containers = context.RequestServices.GetRequiredService(); - int moved = await containers.DeleteAsync(containerId, ct); - - await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, new { id = containerId }, ct); - return Results.Ok(new { ok = true, movedToInbox = moved }); - } - - // POST /api/containers/reorder: порядок контейнеров пространства; ответ {ok:true}. - private static async Task ReorderContainersAsync( - OrderBody body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - if (body.Order is null) - { - return EndpointResults.BadRequest(ContainerOrderRequiredDetail); - } - - ContainersService containers = context.RequestServices.GetRequiredService(); - await containers.ReorderAsync(body.Space ?? ContainerSpaces.Dashboard, body.Order, ct); - return Results.Ok(new { ok = true }); - } - - // GET /api/containers/state: свёрнутость/ширина всех колонок (colState). - private static async Task GetColumnsStateAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - ContainersService containers = context.RequestServices.GetRequiredService(); - IReadOnlyDictionary state = await containers.GetColStateAsync(ct); - - var wire = new Dictionary(StringComparer.Ordinal); - foreach ((string colId, ColumnStateDto colState) in state) - { - wire[colId] = ToWireState(colState); - } - - return Results.Ok(wire); - } - - // PATCH /api/containers/{id}/state: merge патча в состояние колонки; ответ — состояние этой колонки. - private static async Task PatchColumnStateAsync( - string containerId, - ColStateBody body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - ContainersService containers = context.RequestServices.GetRequiredService(); - ColumnStateDto merged = await containers.PatchColStateAsync( - containerId, - new ColumnStateDto(body.Collapsed, body.Width), - ct); - return Results.Ok(ToWireState(merged)); - } - - // Правила из wire → каноничный ContainerRulesDto: отсутствующие группы становятся пустыми списками. - // rules: Правила из тела запроса (null — «не меняются/нет правил»). - // Возвращает: Каноничные правила с не-null группами; null — правил в теле нет. - private static ContainerRulesDto? NormalizeWireRules(ContainerRulesDto? rules) - { - if (rules is null) - { - return null; - } - - return rules with - { - Mode = rules.Mode ?? string.Empty, - Direction = rules.Direction ?? Array.Empty(), - Keywords = rules.Keywords ?? Array.Empty(), - Stack = rules.Stack ?? Array.Empty(), - Grade = rules.Grade ?? Array.Empty(), - Exclude = rules.Exclude ?? Array.Empty(), - Budget = rules.Budget is null ? null : rules.Budget with { Cur = rules.Budget.Cur ?? string.Empty }, - Levels = rules.Levels ?? Array.Empty(), - Locations = rules.Locations ?? Array.Empty(), - Types = rules.Types ?? Array.Empty(), - Prices = rules.Prices is null ? null : rules.Prices with { Cur = rules.Prices.Cur ?? string.Empty }, - }; - } - - // Состояние колонки → wire-объект только с заданными полями (collapsed/width), без null. - // state: Состояние колонки (могут быть null-поля). - // Возвращает: Словарь из не-null полей состояния. - private static Dictionary ToWireState(ColumnStateDto state) - { - var wire = new Dictionary(); - if (state.Collapsed is { } collapsed) - { - wire["collapsed"] = collapsed; - } - - if (state.Width is not null) - { - wire["width"] = state.Width; - } - - return wire; - } -} +using System.Text.Json; +using Deal.Api.Endpoints.RequestModels; +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Api.Endpoints; + +/// +/// Эндпоинты контейнеров +/// +public static class ContainersEndpoints +{ + // Префикс группы контейнеров. + private const string ContainersGroupPrefix = "/api/containers"; + + // OpenAPI-тег группы. + private const string OpenApiTag = "containers"; + + // 404 PATCH/accept: контейнер не найден. + private const string ContainerNotFoundDetail = "Контейнер не найден"; + + // 400: отсутствующий/явный null name контейнера. + private const string ContainerNameRequiredDetail = "Укажите название колонки"; + + // 400 reorder: отсутствующий/явный null order. + private const string ContainerOrderRequiredDetail = "Не указан порядок колонок"; + + // 400 PATCH: тело не JSON-объект. + private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом"; + + // Опции разбора PATCH-тела: web-дефолты (camelCase + регистронезависимость). + private static readonly JsonSerializerOptions RequestJsonOptions = new(JsonSerializerDefaults.Web); + + /// + /// Регистрирует группы /api/containers + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapContainersEndpoints(this IEndpointRouteBuilder app) + { + var containers = app.MapGroup(ContainersGroupPrefix).WithTags(OpenApiTag); + containers.MapGet("", ListContainersAsync); + containers.MapPost("", CreateContainerAsync); + containers.MapPost("/reorder", ReorderContainersAsync); + containers.MapGet("/state", GetColumnsStateAsync); + containers.MapPatch("/{containerId}/state", PatchColumnStateAsync); + containers.MapPost("/{containerId}/accept", AcceptSuggestedAsync); + containers.MapPatch("/{containerId}", PatchContainerAsync); + containers.MapDelete("/{containerId}", DeleteContainerAsync); + return app; + } + + // GET /api/containers?space=: список контейнеров пространства (или всех) со счётчиками. + private static async Task ListContainersAsync( + string? space, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + ContainersService containers = context.RequestServices.GetRequiredService(); + return Results.Ok(new { items = await containers.ListAsync(space, ct) }); + } + + // POST /api/containers: создать контейнер; ответ {id}. + private static async Task CreateContainerAsync( + ContainerCreateRequest body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + if (body.Name is null) + { + return EndpointResults.BadRequest(ContainerNameRequiredDetail); + } + + ContainersService containers = context.RequestServices.GetRequiredService(); + ContainerDto created = await containers.CreateAsync( + new ContainerCreateDto( + Name: body.Name, + Description: body.Description ?? string.Empty, + Color: body.Color, + Space: body.Space ?? ContainerSpaces.Dashboard, + Kind: body.Kind ?? ContainerKinds.Board, + Suggested: body.Suggested ?? false, + Rules: NormalizeWireRules(body.Rules), + Note: body.Note ?? string.Empty), + ct); + + await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerCreated, new { id = created.Id, name = created.Name }, ct); + return Results.Ok(new { id = created.Id }); + } + + // PATCH /api/containers/{id}: частичное обновление; ответ {id}; 404 «Контейнер не найден». + private static async Task PatchContainerAsync( + string containerId, + JsonElement body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + if (body.ValueKind != JsonValueKind.Object) + { + return EndpointResults.BadRequest(InvalidBodyDetail); + } + + foreach (JsonProperty property in body.EnumerateObject()) + { + if (string.Equals(property.Name, "name", StringComparison.OrdinalIgnoreCase) + && property.Value.ValueKind == JsonValueKind.Null) + { + return EndpointResults.BadRequest(ContainerNameRequiredDetail); + } + } + + ContainerPatchRequest? patchBody; + try + { + patchBody = body.Deserialize(RequestJsonOptions); + } + catch (JsonException) + { + return EndpointResults.BadRequest(InvalidBodyDetail); + } + + if (patchBody is null) + { + return EndpointResults.BadRequest(InvalidBodyDetail); + } + + ContainersService containers = context.RequestServices.GetRequiredService(); + ContainerDto? updated = await containers.PatchAsync( + containerId, + new ContainerPatchDto( + patchBody.Name, + patchBody.Description, + patchBody.Color, + patchBody.Collapsed, + patchBody.Suggested, + patchBody.Note, + NormalizeWireRules(patchBody.Rules), + patchBody.Policy), + ct); + if (updated is null) + { + return EndpointResults.NotFound(ContainerNotFoundDetail); + } + + await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct); + return Results.Ok(new { id = updated.Id }); + } + + // POST /api/containers/{id}/accept: принять ИИ-предложение (suggested=false). + private static async Task AcceptSuggestedAsync( + string containerId, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + ContainersService containers = context.RequestServices.GetRequiredService(); + ContainerDto? accepted = await containers.AcceptSuggestedAsync(containerId, ct); + if (accepted is null) + { + return EndpointResults.NotFound(ContainerNotFoundDetail); + } + + await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct); + return Results.Ok(accepted); + } + + // DELETE /api/containers/{id}: удалить контейнер; карточки → «Неразобранное» новыми. + private static async Task DeleteContainerAsync( + string containerId, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + ContainersService containers = context.RequestServices.GetRequiredService(); + int moved = await containers.DeleteAsync(containerId, ct); + + await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerDeleted, new { id = containerId }, ct); + return Results.Ok(new { ok = true, movedToInbox = moved }); + } + + // POST /api/containers/reorder: порядок контейнеров пространства; ответ {ok:true}. + private static async Task ReorderContainersAsync( + OrderBody body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + if (body.Order is null) + { + return EndpointResults.BadRequest(ContainerOrderRequiredDetail); + } + + ContainersService containers = context.RequestServices.GetRequiredService(); + await containers.ReorderAsync(body.Space ?? ContainerSpaces.Dashboard, body.Order, ct); + return Results.Ok(new { ok = true }); + } + + // GET /api/containers/state: свёрнутость/ширина всех колонок (colState). + private static async Task GetColumnsStateAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + ContainersService containers = context.RequestServices.GetRequiredService(); + IReadOnlyDictionary state = await containers.GetColStateAsync(ct); + + var wire = new Dictionary(StringComparer.Ordinal); + foreach ((string colId, ColumnStateDto colState) in state) + { + wire[colId] = ToWireState(colState); + } + + return Results.Ok(wire); + } + + // PATCH /api/containers/{id}/state: merge патча в состояние колонки; ответ — состояние этой колонки. + private static async Task PatchColumnStateAsync( + string containerId, + ColStateBody body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + ContainersService containers = context.RequestServices.GetRequiredService(); + ColumnStateDto merged = await containers.PatchColStateAsync( + containerId, + new ColumnStateDto(body.Collapsed, body.Width), + ct); + return Results.Ok(ToWireState(merged)); + } + + // Правила из wire → каноничный ContainerRulesDto: отсутствующие группы становятся пустыми списками. + // rules: Правила из тела запроса (null — «не меняются/нет правил»). + // Возвращает: Каноничные правила с не-null группами; null — правил в теле нет. + private static ContainerRulesDto? NormalizeWireRules(ContainerRulesDto? rules) + { + if (rules is null) + { + return null; + } + + return rules with + { + Mode = rules.Mode ?? string.Empty, + Direction = rules.Direction ?? Array.Empty(), + Keywords = rules.Keywords ?? Array.Empty(), + Stack = rules.Stack ?? Array.Empty(), + Grade = rules.Grade ?? Array.Empty(), + Exclude = rules.Exclude ?? Array.Empty(), + Budget = rules.Budget is null ? null : rules.Budget with { Cur = rules.Budget.Cur ?? string.Empty }, + Levels = rules.Levels ?? Array.Empty(), + Locations = rules.Locations ?? Array.Empty(), + Types = rules.Types ?? Array.Empty(), + Prices = rules.Prices is null ? null : rules.Prices with { Cur = rules.Prices.Cur ?? string.Empty }, + }; + } + + // Состояние колонки → wire-объект только с заданными полями (collapsed/width), без null. + // state: Состояние колонки (могут быть null-поля). + // Возвращает: Словарь из не-null полей состояния. + private static Dictionary ToWireState(ColumnStateDto state) + { + var wire = new Dictionary(); + if (state.Collapsed is { } collapsed) + { + wire["collapsed"] = collapsed; + } + + if (state.Width is not null) + { + wire["width"] = state.Width; + } + + return wire; + } +} diff --git a/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs b/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs index 0046f91..25a9d39 100644 --- a/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs @@ -1,508 +1,508 @@ -using System.Text.Json; -using Deal.Api.Endpoints.RequestModels; -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Telegram.Application; - -namespace Deal.Api.Endpoints; - -/// -/// Эндпоинты /api/discovery -/// -public static class DiscoveryEndpoints -{ - private const string DiscoveryGroupPrefix = "/api/discovery"; - - private const string DiscoveryOpenApiTag = "discovery"; - - // Путь списка задач (GET). - private const string TasksPath = "/tasks"; - - // Путь одной задачи: обновление (PATCH) / удаление (DELETE). - private const string TaskPath = "/tasks/{task_id}"; - - // Путь запуска поиска (POST). - private const string TaskStartPath = "/tasks/{task_id}/start"; - - // Путь паузы поиска (POST). - private const string TaskPausePath = "/tasks/{task_id}/pause"; - - // Путь ИИ-генерации ключевых слов по описанию задачи (POST). - private const string TaskGenerateKeywordsPath = "/tasks/{task_id}/generate-keywords"; - - // Путь списка кандидатов задачи со статус-фильтром (GET). - private const string TaskCandidatesPath = "/tasks/{task_id}/candidates"; - - // Путь ручного вступления в кандидата (POST). - private const string CandidateJoinPath = "/candidates/{dialog_id}/join"; - - // Путь отклонения кандидата в чёрный список (POST). - private const string CandidateRejectPath = "/candidates/{dialog_id}/reject"; - - // Путь чёрного списка (GET) и снятия записи (DELETE). - private const string BlacklistPath = "/blacklist"; - - // Путь записи чёрного списка (DELETE). - private const string BlacklistItemPath = "/blacklist/{dialog_id}"; - - // Путь лога задачи (GET). - private const string TaskLogPath = "/tasks/{task_id}/log"; - - private const string TaskNotFoundDetail = "Задача не найдена"; - - private const string CandidateNotFoundDetail = "Кандидат не найден"; - - private const string AlreadyJoinedDetail = "Уже вступили в этот источник"; - - private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов"; - - private const string JoinFailedFormat = "Не удалось вступить в @{0}: {1}"; - - private const string ManualRejectReason = "отклонено вручную"; - - private const string AiDisabledDetail = "ИИ выключен в настройках (aiEnabled)"; - - private const string NoDescriptionDetail = "У задачи нет описания — по нему генерируются ключи"; - - private const int KeywordsLimit = 30; - - private const int KeywordLengthLimit = 60; - - /// - /// Регистрирует группу /api/discovery - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapDiscoveryEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(DiscoveryGroupPrefix).WithTags(DiscoveryOpenApiTag); - - group.MapGet(TasksPath, ListTasksAsync); - group.MapPost(TasksPath, CreateTaskAsync); - group.MapPatch(TaskPath, PatchTaskAsync); - group.MapDelete(TaskPath, DeleteTaskAsync); - group.MapPost(TaskStartPath, StartTaskAsync); - group.MapPost(TaskPausePath, PauseTaskAsync); - group.MapPost(TaskGenerateKeywordsPath, GenerateKeywordsAsync); - group.MapGet(TaskCandidatesPath, ListCandidatesAsync); - group.MapPost(CandidateJoinPath, JoinCandidateAsync); - group.MapPost(CandidateRejectPath, RejectCandidateAsync); - group.MapGet(BlacklistPath, ListBlacklistAsync); - group.MapDelete(BlacklistItemPath, RemoveBlacklistAsync); - group.MapGet(TaskLogPath, TaskLogAsync); - - return app; - } - - private static async Task ListTasksAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); - IReadOnlyList items = await tasks.ListAsync(ct); - return Results.Ok(new { items }); - } - - private static async Task CreateTaskAsync( - DiscoveryTaskCreateBody body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - try - { - DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); - DiscoveryTaskDto task = await tasks.CreateAsync(ToDraft(body), ct); - return Results.Ok(task); - } - catch (DiscoveryValidationException exception) - { - return EndpointResults.BadRequest(exception.Message); - } - } - - private static async Task PatchTaskAsync( - string task_id, - DiscoveryTaskPatchBody body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - try - { - DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); - DiscoveryTaskDto? task = await tasks.PatchAsync(task_id, ToPatch(body), ct); - return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); - } - catch (DiscoveryValidationException exception) - { - return EndpointResults.BadRequest(exception.Message); - } - } - - private static async Task DeleteTaskAsync( - string task_id, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); - bool deleted = await tasks.DeleteAsync(task_id, ct); - return deleted ? Results.Ok(new { ok = true }) : EndpointResults.NotFound(TaskNotFoundDetail); - } - - private static async Task StartTaskAsync( - string task_id, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - try - { - DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); - DiscoveryTaskDto? task = await tasks.StartAsync(task_id, ct); - return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); - } - catch (DiscoveryValidationException exception) - { - return EndpointResults.BadRequest(exception.Message); - } - } - - private static async Task PauseTaskAsync( - string task_id, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); - DiscoveryTaskDto? task = await tasks.PauseAsync(task_id, ct); - return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); - } - - private static async Task GenerateKeywordsAsync( - string task_id, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); - DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); - if (task is null) - { - return EndpointResults.NotFound(TaskNotFoundDetail); - } - - ISettingsStore settings = context.RequestServices.GetRequiredService(); - if (!await ReadAiEnabledAsync(settings, ct)) - { - return Results.Ok(new { keywords = Array.Empty(), error = AiDisabledDetail }); - } - - string description = (task.Description ?? string.Empty).Trim(); - if (description.Length == 0) - { - return Results.Ok(new { keywords = Array.Empty(), error = NoDescriptionDetail }); - } - - try - { - IAiTools aiTools = context.RequestServices.GetRequiredService(); - AiGenerateKeywordsResultDto result = await aiTools.GenerateKeywordsAsync(description, ct); - if (result.Ok) - { - return Results.Ok(new { keywords = CleanKeywords(result.Keywords) }); - } - - return Results.Ok(new { keywords = Array.Empty(), error = result.Error ?? ServiceUnavailableText }); - } - catch (NotSupportedException exception) - { - return Results.Ok(new { keywords = Array.Empty(), error = exception.Message }); - } - } - - private static async Task ListCandidatesAsync( - string task_id, - string? status, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); - DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); - if (task is null) - { - return EndpointResults.NotFound(TaskNotFoundDetail); - } - - DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService(); - IReadOnlyList items = await candidates.ListAsync(task_id, status, ct); - return Results.Ok(new { items }); - } - - private static async Task JoinCandidateAsync( - string dialog_id, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService(); - DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct); - if (row is null) - { - return EndpointResults.NotFound(CandidateNotFoundDetail); - } - - if (row.Status == DiscoveryCandidateStatuses.Joined) - { - return EndpointResults.BadRequest(AlreadyJoinedDetail); - } - - string username = (row.Username ?? string.Empty).Trim().TrimStart('@'); - try - { - ITelegramGateway gateway = context.RequestServices.GetRequiredService(); - await gateway.JoinAsync(username, ct); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - string reason = TelegramEndpoints.GatewayErrorText(exception); - return EndpointResults.BadRequest(string.Format(JoinFailedFormat, username, reason)); - } - - DialogsService dialogs = context.RequestServices.GetRequiredService(); - await dialogs.AddDiscoveredMonitoredAsync(dialog_id, row.Name, username, row.Kind, row.Hue, ct); - - context.RequestServices.GetRequiredService().ScheduleFirstBackfill(dialog_id); - - DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService(); - await blacklist.RemoveAsync(dialog_id, ct); - - try - { - DiscoveryCandidateDto? joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct); - return joined is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(joined); - } - catch (DiscoveryValidationException exception) - { - return EndpointResults.BadRequest(exception.Message); - } - } - - private static async Task RejectCandidateAsync( - string dialog_id, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService(); - DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct); - if (row is null) - { - return EndpointResults.NotFound(CandidateNotFoundDetail); - } - - if (row.Status == DiscoveryCandidateStatuses.Joined) - { - return EndpointResults.BadRequest(JoinedRejectDetail); - } - - try - { - DiscoveryCandidateDto? rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct); - return rejected is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(rejected); - } - catch (DiscoveryValidationException exception) - { - return EndpointResults.BadRequest(exception.Message); - } - } - - private static async Task ListBlacklistAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService(); - IReadOnlyList items = await blacklist.ListAsync(ct); - return Results.Ok(new { items }); - } - - private static async Task RemoveBlacklistAsync( - string dialog_id, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService(); - await blacklist.RemoveAsync(dialog_id, ct); - return Results.Ok(new { ok = true }); - } - - private static async Task TaskLogAsync( - string task_id, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); - DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); - if (task is null) - { - return EndpointResults.NotFound(TaskNotFoundDetail); - } - - DiscoveryLogService log = context.RequestServices.GetRequiredService(); - IReadOnlyList items = await log.TaskLogAsync(task_id, ct); - return Results.Ok(new { items }); - } - - /// Сырые ключи ответа модели (null — пусто). - /// Очищенные ключи (не более 30, каждый ≤60 символов). - public static IReadOnlyList CleanKeywords(IEnumerable? raw) - { - if (raw is null) - { - return Array.Empty(); - } - - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - var outList = new List(); - foreach (string? item in raw) - { - if (item is null) - { - continue; - } - - string keyword = item.Trim(); - if (keyword.Length == 0 || keyword.Length > KeywordLengthLimit || !seen.Add(keyword)) - { - continue; - } - - outList.Add(keyword); - if (outList.Count >= KeywordsLimit) - { - break; - } - } - - return outList; - } - - private const string ServiceUnavailableText = "ИИ недоступен — повторите попытку через несколько секунд"; - - private static async Task ReadAiEnabledAsync(ISettingsStore settings, CancellationToken ct) - { - SettingValue? row = await settings.GetAsync(SettingsKeys.AiEnabled, ct); - if (row is null) - { - return SettingsDefaults.AiEnabled; - } - - try - { - using JsonDocument document = JsonDocument.Parse(row.ValueJson); - return document.RootElement.ValueKind is JsonValueKind.True or JsonValueKind.False - ? document.RootElement.GetBoolean() - : SettingsDefaults.AiEnabled; - } - catch (JsonException) - { - return SettingsDefaults.AiEnabled; - } - } - - // Маппит тело создания в черновик сервиса (значения пограничные передаются как есть — дефолты в сервисе). - // body: Тело запроса (wire-поля camelCase). - // Возвращает: Черновик создания задачи (DiscoveryTaskDraft). - private static DiscoveryTaskDraft ToDraft(DiscoveryTaskCreateBody body) - { - return new DiscoveryTaskDraft - { - Name = body.Name, - Description = body.Description ?? string.Empty, - Keywords = body.Keywords, - MinSubscribers = body.MinSubscribers, - Lang = body.Lang, - Threshold = body.Threshold, - SampleSize = body.SampleSize, - PlanJoins = body.PlanJoins, - AutoJoin = body.AutoJoin, - }; - } - - private static DiscoveryTaskPatch ToPatch(DiscoveryTaskPatchBody body) - { - return new DiscoveryTaskPatch - { - Name = body.Name, - Description = body.Description, - Keywords = body.Keywords, - MinSubscribers = body.MinSubscribers, - Lang = body.Lang, - Threshold = body.Threshold, - SampleSize = body.SampleSize, - PlanJoins = body.PlanJoins, - AutoJoin = body.AutoJoin, - }; - } -} +using System.Text.Json; +using Deal.Api.Endpoints.RequestModels; +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Telegram.Application; + +namespace Deal.Api.Endpoints; + +/// +/// Эндпоинты /api/discovery +/// +public static class DiscoveryEndpoints +{ + private const string DiscoveryGroupPrefix = "/api/discovery"; + + private const string DiscoveryOpenApiTag = "discovery"; + + // Путь списка задач (GET). + private const string TasksPath = "/tasks"; + + // Путь одной задачи: обновление (PATCH) / удаление (DELETE). + private const string TaskPath = "/tasks/{task_id}"; + + // Путь запуска поиска (POST). + private const string TaskStartPath = "/tasks/{task_id}/start"; + + // Путь паузы поиска (POST). + private const string TaskPausePath = "/tasks/{task_id}/pause"; + + // Путь ИИ-генерации ключевых слов по описанию задачи (POST). + private const string TaskGenerateKeywordsPath = "/tasks/{task_id}/generate-keywords"; + + // Путь списка кандидатов задачи со статус-фильтром (GET). + private const string TaskCandidatesPath = "/tasks/{task_id}/candidates"; + + // Путь ручного вступления в кандидата (POST). + private const string CandidateJoinPath = "/candidates/{dialog_id}/join"; + + // Путь отклонения кандидата в чёрный список (POST). + private const string CandidateRejectPath = "/candidates/{dialog_id}/reject"; + + // Путь чёрного списка (GET) и снятия записи (DELETE). + private const string BlacklistPath = "/blacklist"; + + // Путь записи чёрного списка (DELETE). + private const string BlacklistItemPath = "/blacklist/{dialog_id}"; + + // Путь лога задачи (GET). + private const string TaskLogPath = "/tasks/{task_id}/log"; + + private const string TaskNotFoundDetail = "Задача не найдена"; + + private const string CandidateNotFoundDetail = "Кандидат не найден"; + + private const string AlreadyJoinedDetail = "Уже вступили в этот источник"; + + private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов"; + + private const string JoinFailedFormat = "Не удалось вступить в @{0}: {1}"; + + private const string ManualRejectReason = "отклонено вручную"; + + private const string AiDisabledDetail = "ИИ выключен в настройках (aiEnabled)"; + + private const string NoDescriptionDetail = "У задачи нет описания — по нему генерируются ключи"; + + private const int KeywordsLimit = 30; + + private const int KeywordLengthLimit = 60; + + /// + /// Регистрирует группу /api/discovery + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapDiscoveryEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup(DiscoveryGroupPrefix).WithTags(DiscoveryOpenApiTag); + + group.MapGet(TasksPath, ListTasksAsync); + group.MapPost(TasksPath, CreateTaskAsync); + group.MapPatch(TaskPath, PatchTaskAsync); + group.MapDelete(TaskPath, DeleteTaskAsync); + group.MapPost(TaskStartPath, StartTaskAsync); + group.MapPost(TaskPausePath, PauseTaskAsync); + group.MapPost(TaskGenerateKeywordsPath, GenerateKeywordsAsync); + group.MapGet(TaskCandidatesPath, ListCandidatesAsync); + group.MapPost(CandidateJoinPath, JoinCandidateAsync); + group.MapPost(CandidateRejectPath, RejectCandidateAsync); + group.MapGet(BlacklistPath, ListBlacklistAsync); + group.MapDelete(BlacklistItemPath, RemoveBlacklistAsync); + group.MapGet(TaskLogPath, TaskLogAsync); + + return app; + } + + private static async Task ListTasksAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); + IReadOnlyList items = await tasks.ListAsync(ct); + return Results.Ok(new { items }); + } + + private static async Task CreateTaskAsync( + DiscoveryTaskCreateBody body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + try + { + DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); + DiscoveryTaskDto task = await tasks.CreateAsync(ToDraft(body), ct); + return Results.Ok(task); + } + catch (DiscoveryValidationException exception) + { + return EndpointResults.BadRequest(exception.Message); + } + } + + private static async Task PatchTaskAsync( + string task_id, + DiscoveryTaskPatchBody body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + try + { + DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); + DiscoveryTaskDto? task = await tasks.PatchAsync(task_id, ToPatch(body), ct); + return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); + } + catch (DiscoveryValidationException exception) + { + return EndpointResults.BadRequest(exception.Message); + } + } + + private static async Task DeleteTaskAsync( + string task_id, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); + bool deleted = await tasks.DeleteAsync(task_id, ct); + return deleted ? Results.Ok(new { ok = true }) : EndpointResults.NotFound(TaskNotFoundDetail); + } + + private static async Task StartTaskAsync( + string task_id, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + try + { + DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); + DiscoveryTaskDto? task = await tasks.StartAsync(task_id, ct); + return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); + } + catch (DiscoveryValidationException exception) + { + return EndpointResults.BadRequest(exception.Message); + } + } + + private static async Task PauseTaskAsync( + string task_id, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); + DiscoveryTaskDto? task = await tasks.PauseAsync(task_id, ct); + return task is null ? EndpointResults.NotFound(TaskNotFoundDetail) : Results.Ok(task); + } + + private static async Task GenerateKeywordsAsync( + string task_id, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); + DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); + if (task is null) + { + return EndpointResults.NotFound(TaskNotFoundDetail); + } + + ISettingsStore settings = context.RequestServices.GetRequiredService(); + if (!await ReadAiEnabledAsync(settings, ct)) + { + return Results.Ok(new { keywords = Array.Empty(), error = AiDisabledDetail }); + } + + string description = (task.Description ?? string.Empty).Trim(); + if (description.Length == 0) + { + return Results.Ok(new { keywords = Array.Empty(), error = NoDescriptionDetail }); + } + + try + { + IAiTools aiTools = context.RequestServices.GetRequiredService(); + AiGenerateKeywordsResultDto result = await aiTools.GenerateKeywordsAsync(description, ct); + if (result.Ok) + { + return Results.Ok(new { keywords = CleanKeywords(result.Keywords) }); + } + + return Results.Ok(new { keywords = Array.Empty(), error = result.Error ?? ServiceUnavailableText }); + } + catch (NotSupportedException exception) + { + return Results.Ok(new { keywords = Array.Empty(), error = exception.Message }); + } + } + + private static async Task ListCandidatesAsync( + string task_id, + string? status, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); + DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); + if (task is null) + { + return EndpointResults.NotFound(TaskNotFoundDetail); + } + + DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService(); + IReadOnlyList items = await candidates.ListAsync(task_id, status, ct); + return Results.Ok(new { items }); + } + + private static async Task JoinCandidateAsync( + string dialog_id, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService(); + DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct); + if (row is null) + { + return EndpointResults.NotFound(CandidateNotFoundDetail); + } + + if (row.Status == DiscoveryCandidateStatuses.Joined) + { + return EndpointResults.BadRequest(AlreadyJoinedDetail); + } + + string username = (row.Username ?? string.Empty).Trim().TrimStart('@'); + try + { + ITelegramGateway gateway = context.RequestServices.GetRequiredService(); + await gateway.JoinAsync(username, ct); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + string reason = TelegramEndpoints.GatewayErrorText(exception); + return EndpointResults.BadRequest(string.Format(JoinFailedFormat, username, reason)); + } + + DialogsService dialogs = context.RequestServices.GetRequiredService(); + await dialogs.AddDiscoveredMonitoredAsync(dialog_id, row.Name, username, row.Kind, row.Hue, ct); + + context.RequestServices.GetRequiredService().ScheduleFirstBackfill(dialog_id); + + DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService(); + await blacklist.RemoveAsync(dialog_id, ct); + + try + { + DiscoveryCandidateDto? joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct); + return joined is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(joined); + } + catch (DiscoveryValidationException exception) + { + return EndpointResults.BadRequest(exception.Message); + } + } + + private static async Task RejectCandidateAsync( + string dialog_id, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService(); + DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct); + if (row is null) + { + return EndpointResults.NotFound(CandidateNotFoundDetail); + } + + if (row.Status == DiscoveryCandidateStatuses.Joined) + { + return EndpointResults.BadRequest(JoinedRejectDetail); + } + + try + { + DiscoveryCandidateDto? rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct); + return rejected is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(rejected); + } + catch (DiscoveryValidationException exception) + { + return EndpointResults.BadRequest(exception.Message); + } + } + + private static async Task ListBlacklistAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService(); + IReadOnlyList items = await blacklist.ListAsync(ct); + return Results.Ok(new { items }); + } + + private static async Task RemoveBlacklistAsync( + string dialog_id, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + DiscoveryBlacklistService blacklist = context.RequestServices.GetRequiredService(); + await blacklist.RemoveAsync(dialog_id, ct); + return Results.Ok(new { ok = true }); + } + + private static async Task TaskLogAsync( + string task_id, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + DiscoveryTasksService tasks = context.RequestServices.GetRequiredService(); + DiscoveryTaskDto? task = await tasks.GetAsync(task_id, ct); + if (task is null) + { + return EndpointResults.NotFound(TaskNotFoundDetail); + } + + DiscoveryLogService log = context.RequestServices.GetRequiredService(); + IReadOnlyList items = await log.TaskLogAsync(task_id, ct); + return Results.Ok(new { items }); + } + + /// Сырые ключи ответа модели (null — пусто). + /// Очищенные ключи (не более 30, каждый ≤60 символов). + public static IReadOnlyList CleanKeywords(IEnumerable? raw) + { + if (raw is null) + { + return Array.Empty(); + } + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var outList = new List(); + foreach (string? item in raw) + { + if (item is null) + { + continue; + } + + string keyword = item.Trim(); + if (keyword.Length == 0 || keyword.Length > KeywordLengthLimit || !seen.Add(keyword)) + { + continue; + } + + outList.Add(keyword); + if (outList.Count >= KeywordsLimit) + { + break; + } + } + + return outList; + } + + private const string ServiceUnavailableText = "ИИ недоступен — повторите попытку через несколько секунд"; + + private static async Task ReadAiEnabledAsync(ISettingsStore settings, CancellationToken ct) + { + SettingValue? row = await settings.GetAsync(SettingsKeys.AiEnabled, ct); + if (row is null) + { + return SettingsDefaults.AiEnabled; + } + + try + { + using JsonDocument document = JsonDocument.Parse(row.ValueJson); + return document.RootElement.ValueKind is JsonValueKind.True or JsonValueKind.False + ? document.RootElement.GetBoolean() + : SettingsDefaults.AiEnabled; + } + catch (JsonException) + { + return SettingsDefaults.AiEnabled; + } + } + + // Маппит тело создания в черновик сервиса (значения пограничные передаются как есть — дефолты в сервисе). + // body: Тело запроса (wire-поля camelCase). + // Возвращает: Черновик создания задачи (DiscoveryTaskDraft). + private static DiscoveryTaskDraft ToDraft(DiscoveryTaskCreateBody body) + { + return new DiscoveryTaskDraft + { + Name = body.Name, + Description = body.Description ?? string.Empty, + Keywords = body.Keywords, + MinSubscribers = body.MinSubscribers, + Lang = body.Lang, + Threshold = body.Threshold, + SampleSize = body.SampleSize, + PlanJoins = body.PlanJoins, + AutoJoin = body.AutoJoin, + }; + } + + private static DiscoveryTaskPatch ToPatch(DiscoveryTaskPatchBody body) + { + return new DiscoveryTaskPatch + { + Name = body.Name, + Description = body.Description, + Keywords = body.Keywords, + MinSubscribers = body.MinSubscribers, + Lang = body.Lang, + Threshold = body.Threshold, + SampleSize = body.SampleSize, + PlanJoins = body.PlanJoins, + AutoJoin = body.AutoJoin, + }; + } +} diff --git a/src/core/Deal.Api/Endpoints/EventsEndpoint.cs b/src/core/Deal.Api/Endpoints/EventsEndpoint.cs index 3474c91..9810065 100644 --- a/src/core/Deal.Api/Endpoints/EventsEndpoint.cs +++ b/src/core/Deal.Api/Endpoints/EventsEndpoint.cs @@ -1,107 +1,107 @@ -using Deal.Api.Events; -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; - -namespace Deal.Api.Endpoints; - -/// -/// SSE-поток событий канбана -/// -public static class EventsEndpoint -{ - private const string EventsPath = "/api/events"; - - private const string OpenApiTag = "events"; - - private const string EventStreamContentType = "text/event-stream"; - - private const string NoCacheHeaderValue = "no-cache"; - - private const string NoBufferingHeaderValue = "no"; - - // Ping-комментарий: строки протокола SSE, начинающиеся с ':', клиент игнорирует. - private const string PingComment = ": ping\n\n"; - - private static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(15); - - /// - /// Регистрирует GET /api/events. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapEventsEndpoint(this IEndpointRouteBuilder app) - { - app.MapGet(EventsPath, StreamEventsAsync).WithTags(OpenApiTag); - return app; - } - - // GET /api/events: поток text/event-stream канала тенанта сессии. - // context: Контекст запроса (сессия — HttpContext.Items). - // ct: Отмена запроса: клиент отвалился — завершаем поток и отписываемся. - private static async Task StreamEventsAsync(HttpContext context, CancellationToken ct) - { - CurrentUser? user = context.GetCurrentUser(); - if (user is null) - { - await EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail).ExecuteAsync(context); - return; - } - - SseBroker broker = context.RequestServices.GetRequiredService(); - SseSubscription subscription = broker.Subscribe(user.TenantId); - try - { - HttpResponse response = context.Response; - response.ContentType = EventStreamContentType; - response.Headers["Cache-Control"] = NoCacheHeaderValue; - response.Headers["X-Accel-Buffering"] = NoBufferingHeaderValue; - - while (true) - { - using var pingTimeout = CancellationTokenSource.CreateLinkedTokenSource(ct); - pingTimeout.CancelAfter(PingInterval); - try - { - await subscription.Events.WaitToReadAsync(pingTimeout.Token); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - // Тишина 15 с — ping держит соединение; отмену клиента ловит внешний catch. - await WriteFrameAsync(response, PingComment, ct); - continue; - } - - while (subscription.Events.TryRead(out SseEvent? sseEvent)) - { - await WriteFrameAsync(response, sseEvent.RenderFrame(), ct); - } - } - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - // Клиент закрыл соединение — штатное завершение потока. - } - catch (IOException) - { - // Сброс соединения клиентом (закрытая вкладка/обрыв сети): ответ уже не доставить. - } - finally - { - broker.Unsubscribe(user.TenantId, subscription.Id); - } - } - - // Пишет frame в поток ответа и сбрасывает буфер — события уходят сразу (не пачкой). - // response: Ответ (stream уже начат). - // frame: Frame протокола SSE. - // ct: Токен отмены запроса. - private static async Task WriteFrameAsync( - HttpResponse response, - string frame, - CancellationToken ct) - { - await response.WriteAsync(frame, ct); - await response.Body.FlushAsync(ct); - } -} +using Deal.Api.Events; +using Deal.Api.Extensions; +using Deal.Api.Models; +using Deal.Api.Services; + +namespace Deal.Api.Endpoints; + +/// +/// SSE-поток событий канбана +/// +public static class EventsEndpoint +{ + private const string EventsPath = "/api/events"; + + private const string OpenApiTag = "events"; + + private const string EventStreamContentType = "text/event-stream"; + + private const string NoCacheHeaderValue = "no-cache"; + + private const string NoBufferingHeaderValue = "no"; + + // Ping-комментарий: строки протокола SSE, начинающиеся с ':', клиент игнорирует. + private const string PingComment = ": ping\n\n"; + + private static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(15); + + /// + /// Регистрирует GET /api/events. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapEventsEndpoint(this IEndpointRouteBuilder app) + { + app.MapGet(EventsPath, StreamEventsAsync).WithTags(OpenApiTag); + return app; + } + + // GET /api/events: поток text/event-stream канала тенанта сессии. + // context: Контекст запроса (сессия — HttpContext.Items). + // ct: Отмена запроса: клиент отвалился — завершаем поток и отписываемся. + private static async Task StreamEventsAsync(HttpContext context, CancellationToken ct) + { + CurrentUser? user = context.GetCurrentUser(); + if (user is null) + { + await EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail).ExecuteAsync(context); + return; + } + + SseBroker broker = context.RequestServices.GetRequiredService(); + SseSubscription subscription = broker.Subscribe(user.TenantId); + try + { + HttpResponse response = context.Response; + response.ContentType = EventStreamContentType; + response.Headers["Cache-Control"] = NoCacheHeaderValue; + response.Headers["X-Accel-Buffering"] = NoBufferingHeaderValue; + + while (true) + { + using var pingTimeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + pingTimeout.CancelAfter(PingInterval); + try + { + await subscription.Events.WaitToReadAsync(pingTimeout.Token); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + // Тишина 15 с — ping держит соединение; отмену клиента ловит внешний catch. + await WriteFrameAsync(response, PingComment, ct); + continue; + } + + while (subscription.Events.TryRead(out SseEvent? sseEvent)) + { + await WriteFrameAsync(response, sseEvent.RenderFrame(), ct); + } + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Клиент закрыл соединение — штатное завершение потока. + } + catch (IOException) + { + // Сброс соединения клиентом (закрытая вкладка/обрыв сети): ответ уже не доставить. + } + finally + { + broker.Unsubscribe(user.TenantId, subscription.Id); + } + } + + // Пишет frame в поток ответа и сбрасывает буфер — события уходят сразу (не пачкой). + // response: Ответ (stream уже начат). + // frame: Frame протокола SSE. + // ct: Токен отмены запроса. + private static async Task WriteFrameAsync( + HttpResponse response, + string frame, + CancellationToken ct) + { + await response.WriteAsync(frame, ct); + await response.Body.FlushAsync(ct); + } +} diff --git a/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs b/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs index aa78c98..7942b4c 100644 --- a/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs @@ -1,47 +1,47 @@ -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Services; - -namespace Deal.Api.Endpoints; - -/// -/// Тестер конвейера: сухой прогон текста через стоп-фильтры, ML и ИИ -/// -public static class FilterTesterEndpoints -{ - private const string ApiGroupPrefix = "/api"; - - private const string CheckMessagePath = "/admin/check-message"; - - private const string OpenApiTag = "settings"; - - /// - /// Регистрирует POST /api/admin/check-message. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapFilterTesterEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag); - group.MapPost(CheckMessagePath, CheckAsync); - return app; - } - - // POST /api/admin/check-message: прогон текста по этапам конвейера без записи карточки. - private static async Task CheckAsync( - CheckMessageRequest body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - // Резолв после 401-гейта: воркер scoped на TenantDbContext (tenant-контекст запроса). - PipelineWorkerService worker = context.RequestServices.GetRequiredService(); - PipelineDryRunDto result = await worker.DryRunAsync(body.Text ?? string.Empty, ct); - return Results.Ok(result); - } -} +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; + +namespace Deal.Api.Endpoints; + +/// +/// Тестер конвейера: сухой прогон текста через стоп-фильтры, ML и ИИ +/// +public static class FilterTesterEndpoints +{ + private const string ApiGroupPrefix = "/api"; + + private const string CheckMessagePath = "/admin/check-message"; + + private const string OpenApiTag = "settings"; + + /// + /// Регистрирует POST /api/admin/check-message. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapFilterTesterEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag); + group.MapPost(CheckMessagePath, CheckAsync); + return app; + } + + // POST /api/admin/check-message: прогон текста по этапам конвейера без записи карточки. + private static async Task CheckAsync( + CheckMessageRequest body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + // Резолв после 401-гейта: воркер scoped на TenantDbContext (tenant-контекст запроса). + PipelineWorkerService worker = context.RequestServices.GetRequiredService(); + PipelineDryRunDto result = await worker.DryRunAsync(body.Text ?? string.Empty, ct); + return Results.Ok(result); + } +} diff --git a/src/core/Deal.Api/Endpoints/JoinEndpoint.cs b/src/core/Deal.Api/Endpoints/JoinEndpoint.cs index 2ea1e24..a46a872 100644 --- a/src/core/Deal.Api/Endpoints/JoinEndpoint.cs +++ b/src/core/Deal.Api/Endpoints/JoinEndpoint.cs @@ -1,98 +1,98 @@ -using Deal.Api.Services; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Api.Endpoints; - -/// -/// Публичный эндпоинт активации инвайта -/// -public static class JoinEndpoint -{ - // Путь ручки (вне группы /api/operator — публичная). - private const string JoinPath = "/api/join"; - - // OpenAPI-тег. - private const string JoinOpenApiTag = "join"; - - // Текст 400: приглашение с таким кодом не найдено. - private const string InviteNotFoundDetail = "Приглашение не найдено"; - - private const string InviteExpiredDetail = "Срок действия приглашения истёк"; - - // Текст 400: приглашение уже активировано (повторная активация тем же кодом). - private const string InviteUsedDetail = "Приглашение уже использовано"; - - // Текст 400: приглашение отозвано оператором. - private const string InviteRevokedDetail = "Приглашение отозвано"; - - private const string EmailMismatchDetail = "Email не совпадает с приглашением"; - - private const string EmailTakenDetail = "Этот email уже зарегистрирован"; - - private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)"; - - // Текст 400: целевой тенант инвайта не существует (Security review). - private const string TenantNotFoundDetail = "Тенант приглашения не найден"; - - // Текст 400: целевой тенант инвайта приостановлен (Security review). - private const string TenantSuspendedDetail = "Тенант приглашения приостановлен"; - - /// - /// Регистрирует POST /api/join. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapJoinEndpoint(this IEndpointRouteBuilder app) - { - app.MapPost(JoinPath, JoinAsync).WithTags(JoinOpenApiTag); - return app; - } - - private static async Task JoinAsync( - JoinRequest body, - JoinService joinService, - AuditService auditService, - HttpContext context, - CancellationToken ct) - { - var result = await joinService.ActivateAsync(body.Code, body.Email, body.Name, body.Password, ct); - if (!result.Ok || result.Login is null || result.UserId is null || result.TenantId is null) - { - return EndpointResults.BadRequest(DetailFor(result.Error)); - } - - await auditService.AppendAsync(new AuditRecordDto( - AuditEvents.InviteJoined, - AuditActorTypes.Tenant, - ActorId: result.UserId, - TenantId: result.TenantId, - Ip: ClientIp(context), - // Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review). - DetailJson: AuditService.ToDetailJson(new { email = result.Login, codeHash = SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty) })), ct); - - return Results.Ok(new { ok = true, login = result.Login }); - } - - // Фиксированный текст 400 по коду ошибки JoinService (все отказы активации — 400). - // error: Код ошибки JoinResultDto. - // Возвращает: Текст детали ошибки. - private static string DetailFor(string? error) => - error switch - { - JoinResultDto.ErrorExpired => InviteExpiredDetail, - JoinResultDto.ErrorUsed => InviteUsedDetail, - JoinResultDto.ErrorRevoked => InviteRevokedDetail, - JoinResultDto.ErrorEmailMismatch => EmailMismatchDetail, - JoinResultDto.ErrorEmailTaken => EmailTakenDetail, - JoinResultDto.ErrorPasswordTooShort => PasswordTooShortDetail, - JoinResultDto.ErrorTenantNotFound => TenantNotFoundDetail, - JoinResultDto.ErrorTenantSuspended => TenantSuspendedDetail, - _ => InviteNotFoundDetail, - }; - - // IP-адрес клиента для аудита (без порта; null, если недоступен). - // context: Контекст запроса. - // Возвращает: Строковое представление IP или null. - private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); -} +using Deal.Api.Services; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Api.Endpoints; + +/// +/// Публичный эндпоинт активации инвайта +/// +public static class JoinEndpoint +{ + // Путь ручки (вне группы /api/operator — публичная). + private const string JoinPath = "/api/join"; + + // OpenAPI-тег. + private const string JoinOpenApiTag = "join"; + + // Текст 400: приглашение с таким кодом не найдено. + private const string InviteNotFoundDetail = "Приглашение не найдено"; + + private const string InviteExpiredDetail = "Срок действия приглашения истёк"; + + // Текст 400: приглашение уже активировано (повторная активация тем же кодом). + private const string InviteUsedDetail = "Приглашение уже использовано"; + + // Текст 400: приглашение отозвано оператором. + private const string InviteRevokedDetail = "Приглашение отозвано"; + + private const string EmailMismatchDetail = "Email не совпадает с приглашением"; + + private const string EmailTakenDetail = "Этот email уже зарегистрирован"; + + private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)"; + + // Текст 400: целевой тенант инвайта не существует (Security review). + private const string TenantNotFoundDetail = "Тенант приглашения не найден"; + + // Текст 400: целевой тенант инвайта приостановлен (Security review). + private const string TenantSuspendedDetail = "Тенант приглашения приостановлен"; + + /// + /// Регистрирует POST /api/join. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapJoinEndpoint(this IEndpointRouteBuilder app) + { + app.MapPost(JoinPath, JoinAsync).WithTags(JoinOpenApiTag); + return app; + } + + private static async Task JoinAsync( + JoinRequest body, + JoinService joinService, + AuditService auditService, + HttpContext context, + CancellationToken ct) + { + var result = await joinService.ActivateAsync(body.Code, body.Email, body.Name, body.Password, ct); + if (!result.Ok || result.Login is null || result.UserId is null || result.TenantId is null) + { + return EndpointResults.BadRequest(DetailFor(result.Error)); + } + + await auditService.AppendAsync(new AuditRecordDto( + AuditEvents.InviteJoined, + AuditActorTypes.Tenant, + ActorId: result.UserId, + TenantId: result.TenantId, + Ip: ClientIp(context), + // Код инвайта — capability-токен: в аудит пишется только SHA-256-хэш (Security review). + DetailJson: AuditService.ToDetailJson(new { email = result.Login, codeHash = SessionTokens.HashToken(body.Code?.Trim() ?? string.Empty) })), ct); + + return Results.Ok(new { ok = true, login = result.Login }); + } + + // Фиксированный текст 400 по коду ошибки JoinService (все отказы активации — 400). + // error: Код ошибки JoinResultDto. + // Возвращает: Текст детали ошибки. + private static string DetailFor(string? error) => + error switch + { + JoinResultDto.ErrorExpired => InviteExpiredDetail, + JoinResultDto.ErrorUsed => InviteUsedDetail, + JoinResultDto.ErrorRevoked => InviteRevokedDetail, + JoinResultDto.ErrorEmailMismatch => EmailMismatchDetail, + JoinResultDto.ErrorEmailTaken => EmailTakenDetail, + JoinResultDto.ErrorPasswordTooShort => PasswordTooShortDetail, + JoinResultDto.ErrorTenantNotFound => TenantNotFoundDetail, + JoinResultDto.ErrorTenantSuspended => TenantSuspendedDetail, + _ => InviteNotFoundDetail, + }; + + // IP-адрес клиента для аудита (без порта; null, если недоступен). + // context: Контекст запроса. + // Возвращает: Строковое представление IP или null. + private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); +} diff --git a/src/core/Deal.Api/Endpoints/JoinRequest.cs b/src/core/Deal.Api/Endpoints/JoinRequest.cs index 7eeae08..6a31b04 100644 --- a/src/core/Deal.Api/Endpoints/JoinRequest.cs +++ b/src/core/Deal.Api/Endpoints/JoinRequest.cs @@ -1,10 +1,10 @@ -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/join — активация инвайта. -/// -/// Код приглашения (16 url-safe символов). -/// Email активирующего; обязан совпасть с email приглашения (нормализует JoinService). -/// Имя нового тенанта (только когда у инвайта нет целевого тенанта); null — имя = email. -/// Пароль пользователя (минимум 8 символов). -public sealed record JoinRequest(string? Code, string? Email, string? Name, string? Password); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/join — активация инвайта. +/// +/// Код приглашения (16 url-safe символов). +/// Email активирующего; обязан совпасть с email приглашения (нормализует JoinService). +/// Имя нового тенанта (только когда у инвайта нет целевого тенанта); null — имя = email. +/// Пароль пользователя (минимум 8 символов). +public sealed record JoinRequest(string? Code, string? Email, string? Name, string? Password); diff --git a/src/core/Deal.Api/Endpoints/LoginRequest.cs b/src/core/Deal.Api/Endpoints/LoginRequest.cs index e5c26f3..e89c835 100644 --- a/src/core/Deal.Api/Endpoints/LoginRequest.cs +++ b/src/core/Deal.Api/Endpoints/LoginRequest.cs @@ -1,8 +1,8 @@ -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/auth/login. -/// -/// Логин пользователя. -/// Пароль в открытом виде. -public sealed record LoginRequest(string Login, string Password); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/auth/login. +/// +/// Логин пользователя. +/// Пароль в открытом виде. +public sealed record LoginRequest(string Login, string Password); diff --git a/src/core/Deal.Api/Endpoints/MlApplyRequest.cs b/src/core/Deal.Api/Endpoints/MlApplyRequest.cs index 2b48e40..aaa1c4b 100644 --- a/src/core/Deal.Api/Endpoints/MlApplyRequest.cs +++ b/src/core/Deal.Api/Endpoints/MlApplyRequest.cs @@ -1,9 +1,9 @@ -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/ml/apply. -/// -/// Id диалога/канала Telegram, где лежит исходное сообщение. -/// Id сообщения внутри диалога. -/// Ручное решение: spam | board:<id> | skip. -public sealed record MlApplyRequest(string DialogId, int MsgId, string Action); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/ml/apply. +/// +/// Id диалога/канала Telegram, где лежит исходное сообщение. +/// Id сообщения внутри диалога. +/// Ручное решение: spam | board:<id> | skip. +public sealed record MlApplyRequest(string DialogId, int MsgId, string Action); diff --git a/src/core/Deal.Api/Endpoints/MlCandidatesRequest.cs b/src/core/Deal.Api/Endpoints/MlCandidatesRequest.cs index 0478842..ab556cf 100644 --- a/src/core/Deal.Api/Endpoints/MlCandidatesRequest.cs +++ b/src/core/Deal.Api/Endpoints/MlCandidatesRequest.cs @@ -1,8 +1,8 @@ -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/ml/candidates. -/// -/// Id диалога/канала Telegram; пусто — выборка по всем источникам тенанта. -/// Сколько последних сообщений вернуть (кламп 1..60, дефолт 10). -public sealed record MlCandidatesRequest(string DialogId, int Limit); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/ml/candidates. +/// +/// Id диалога/канала Telegram; пусто — выборка по всем источникам тенанта. +/// Сколько последних сообщений вернуть (кламп 1..60, дефолт 10). +public sealed record MlCandidatesRequest(string DialogId, int Limit); diff --git a/src/core/Deal.Api/Endpoints/MlEndpoints.cs b/src/core/Deal.Api/Endpoints/MlEndpoints.cs index 1a67c02..64a4a4d 100644 --- a/src/core/Deal.Api/Endpoints/MlEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/MlEndpoints.cs @@ -1,163 +1,163 @@ -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Services; - -namespace Deal.Api.Endpoints; - -/// -/// Эндпоинты ML-панели -/// -public static class MlEndpoints -{ - private const string MlGroupPrefix = "/api/ml"; - - private const string MlOpenApiTag = "ml"; - - // Путь статуса ML (GET). - private const string StatusPath = "/status"; - - // Путь сброса модели (POST). - private const string ResetPath = "/reset"; - - // Путь проверки ML на тексте (POST). - private const string PredictPath = "/predict"; - - // Путь разбора сообщений канала (POST). - private const string CandidatesPath = "/candidates"; - - // Путь ручного решения по сообщению (POST). - private const string ApplyPath = "/apply"; - - private const int MinPredictTextLength = 2; - - private const int PredictTextPreviewLength = 200; - - private const string EnterTextDetail = "Введите текст"; - - private const string MessageNotFoundDetail = "Исходное сообщение не найдено"; - - /// - /// Регистрирует группу /api/ml - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapMlEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(MlGroupPrefix).WithTags(MlOpenApiTag); - - group.MapGet(StatusPath, StatusAsync); - group.MapPost(ResetPath, ResetAsync); - group.MapPost(PredictPath, PredictAsync); - group.MapPost(CandidatesPath, CandidatesAsync); - group.MapPost(ApplyPath, ApplyAsync); - - return app; - } - - private static async Task StatusAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - IMlClient mlClient = context.RequestServices.GetRequiredService(); - return Results.Ok(await mlClient.StatusAsync(ct)); - } - - private static async Task ResetAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - IMlClient mlClient = context.RequestServices.GetRequiredService(); - return Results.Ok(await mlClient.ResetAsync(ct)); - } - - private static async Task PredictAsync( - MlPredictRequest body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - string text = (body.Text ?? string.Empty).Trim(); - if (text.Length < MinPredictTextLength) - { - return EndpointResults.BadRequest(EnterTextDetail); - } - - IMlClient mlClient = context.RequestServices.GetRequiredService(); - MlPredictResultDto result = await mlClient.PredictAsync(text, ct); - - string preview = text.Length <= PredictTextPreviewLength - ? text - : text[..PredictTextPreviewLength]; - return Results.Ok(new - { - text = preview, - take = result.Take, - label = result.Label, - scores = result.Scores, - hits = result.Hits, - ready = result.Ready, - margin = result.Margin, - terms = result.Terms, - type = result.Type, - }); - } - - private static async Task CandidatesAsync( - MlCandidatesRequest body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - MlReviewService review = context.RequestServices.GetRequiredService(); - IReadOnlyList items = await review.CandidatesAsync(body.DialogId, body.Limit, ct); - return Results.Ok(new { items }); - } - - private static async Task ApplyAsync( - MlApplyRequest body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - MlReviewService review = context.RequestServices.GetRequiredService(); - MlApplyResult? result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct); - if (result is null) - { - return EndpointResults.NotFound(MessageNotFoundDetail); - } - - if (result.Error is not null) - { - return EndpointResults.BadRequest(result.Error); - } - - return Results.Ok(new - { - ok = result.Ok, - learned = result.Learned, - moved = result.Moved, - leadId = result.LeadId, - }); - } -} +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; + +namespace Deal.Api.Endpoints; + +/// +/// Эндпоинты ML-панели +/// +public static class MlEndpoints +{ + private const string MlGroupPrefix = "/api/ml"; + + private const string MlOpenApiTag = "ml"; + + // Путь статуса ML (GET). + private const string StatusPath = "/status"; + + // Путь сброса модели (POST). + private const string ResetPath = "/reset"; + + // Путь проверки ML на тексте (POST). + private const string PredictPath = "/predict"; + + // Путь разбора сообщений канала (POST). + private const string CandidatesPath = "/candidates"; + + // Путь ручного решения по сообщению (POST). + private const string ApplyPath = "/apply"; + + private const int MinPredictTextLength = 2; + + private const int PredictTextPreviewLength = 200; + + private const string EnterTextDetail = "Введите текст"; + + private const string MessageNotFoundDetail = "Исходное сообщение не найдено"; + + /// + /// Регистрирует группу /api/ml + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapMlEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup(MlGroupPrefix).WithTags(MlOpenApiTag); + + group.MapGet(StatusPath, StatusAsync); + group.MapPost(ResetPath, ResetAsync); + group.MapPost(PredictPath, PredictAsync); + group.MapPost(CandidatesPath, CandidatesAsync); + group.MapPost(ApplyPath, ApplyAsync); + + return app; + } + + private static async Task StatusAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + IMlClient mlClient = context.RequestServices.GetRequiredService(); + return Results.Ok(await mlClient.StatusAsync(ct)); + } + + private static async Task ResetAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + IMlClient mlClient = context.RequestServices.GetRequiredService(); + return Results.Ok(await mlClient.ResetAsync(ct)); + } + + private static async Task PredictAsync( + MlPredictRequest body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + string text = (body.Text ?? string.Empty).Trim(); + if (text.Length < MinPredictTextLength) + { + return EndpointResults.BadRequest(EnterTextDetail); + } + + IMlClient mlClient = context.RequestServices.GetRequiredService(); + MlPredictResultDto result = await mlClient.PredictAsync(text, ct); + + string preview = text.Length <= PredictTextPreviewLength + ? text + : text[..PredictTextPreviewLength]; + return Results.Ok(new + { + text = preview, + take = result.Take, + label = result.Label, + scores = result.Scores, + hits = result.Hits, + ready = result.Ready, + margin = result.Margin, + terms = result.Terms, + type = result.Type, + }); + } + + private static async Task CandidatesAsync( + MlCandidatesRequest body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + MlReviewService review = context.RequestServices.GetRequiredService(); + IReadOnlyList items = await review.CandidatesAsync(body.DialogId, body.Limit, ct); + return Results.Ok(new { items }); + } + + private static async Task ApplyAsync( + MlApplyRequest body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + MlReviewService review = context.RequestServices.GetRequiredService(); + MlApplyResult? result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct); + if (result is null) + { + return EndpointResults.NotFound(MessageNotFoundDetail); + } + + if (result.Error is not null) + { + return EndpointResults.BadRequest(result.Error); + } + + return Results.Ok(new + { + ok = result.Ok, + learned = result.Learned, + moved = result.Moved, + leadId = result.LeadId, + }); + } +} diff --git a/src/core/Deal.Api/Endpoints/MlPredictRequest.cs b/src/core/Deal.Api/Endpoints/MlPredictRequest.cs index 213b54b..8ee514e 100644 --- a/src/core/Deal.Api/Endpoints/MlPredictRequest.cs +++ b/src/core/Deal.Api/Endpoints/MlPredictRequest.cs @@ -1,7 +1,7 @@ -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/ml/predict. -/// -/// Текст сообщения для проверки ML. -public sealed record MlPredictRequest(string Text); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/ml/predict. +/// +/// Текст сообщения для проверки ML. +public sealed record MlPredictRequest(string Text); diff --git a/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs index 85d7f84..3f9b896 100644 --- a/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs @@ -1,155 +1,155 @@ -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Api.Endpoints; - -/// -/// Операторские read-only эндпоинты аналитики -/// -public static class OperatorAnalyticsEndpoints -{ - private const string AnalyticsGroupPrefix = "/api/operator/analytics"; - - // OpenAPI-тег группы. - private const string OperatorOpenApiTag = "operator"; - - // Группировка расхода токенов по умолчанию (сутки). - private const string DefaultGroupBy = TokenUsageGroupBys.Day; - - // 400 tokens: неизвестная группировка. - private const string InvalidGroupByDetail = "Неизвестная группировка (day|tenant|provider|model)"; - - /// - /// Регистрирует группу /api/operator/analytics - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapOperatorAnalyticsEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(AnalyticsGroupPrefix).WithTags(OperatorOpenApiTag); - group.MapGet("/overview", OverviewAsync); - group.MapGet("/tokens", TokensAsync); - group.MapGet("/activity", ActivityAsync); - group.MapGet("/suspicious", SuspiciousAsync); - return app; - } - - private static async Task SuspiciousAsync( - DateTimeOffset? from, - DateTimeOffset? to, - HttpContext context, - SuspiciousActivityService suspiciousService, - CancellationToken ct) - { - if (context.GetCurrentOperator() is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - SuspiciousActivityDto report = await suspiciousService.AnalyzeAsync(from, to, ct); - return Results.Ok(report); - } - - // GET /api/operator/analytics/overview?from=&to=: сводка (тенанты, токены, события, входы/выходы). - // from: Начало периода (включительно; ISO-8601). - // to: Конец периода (включительно; ISO-8601). - // context: Контекст запроса. - // analyticsService: Сервис аналитики (scoped). - // ct: Токен отмены. - // Возвращает: 200 сводка или 401 без операторской сессии. - private static async Task OverviewAsync( - DateTimeOffset? from, - DateTimeOffset? to, - HttpContext context, - AnalyticsService analyticsService, - CancellationToken ct) - { - if (context.GetCurrentOperator() is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - AnalyticsOverviewDto overview = await analyticsService.OverviewAsync(from, to, ct); - return Results.Ok(overview); - } - - // GET /api/operator/analytics/tokens?groupBy=&tenantId=&from=&to=: агрегаты расхода токенов. - // groupBy: Группировка day|tenant|provider|model (дефолт day). - // tenantId: Тенант (равенство; пусто — все тенанты). - // from: Начало периода (включительно; ISO-8601). - // to: Конец периода (включительно; ISO-8601). - // context: Контекст запроса. - // analyticsService: Сервис аналитики (scoped). - // ct: Токен отмены. - // Возвращает: 200 агрегаты, 400 неизвестная группировка или 401 без операторской сессии. - private static async Task TokensAsync( - string? groupBy, - Guid? tenantId, - DateTimeOffset? from, - DateTimeOffset? to, - HttpContext context, - AnalyticsService analyticsService, - CancellationToken ct) - { - if (context.GetCurrentOperator() is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - string normalizedGroupBy = string.IsNullOrWhiteSpace(groupBy) ? DefaultGroupBy : groupBy; - if (!IsKnownGroupBy(normalizedGroupBy)) - { - return EndpointResults.BadRequest(InvalidGroupByDetail); - } - - AnalyticsTokensDto tokens = await analyticsService.TokensAsync(normalizedGroupBy, tenantId, from, to, ct); - return Results.Ok(tokens); - } - - // GET /api/operator/analytics/activity?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=: лента действий. - // eventType: Тип события (равенство; пусто — без фильтра). - // actorType: Тип актора operator|tenant|system (равенство). - // actorId: Идентификатор актора (равенство). - // tenantId: Тенант (равенство). - // from: Нижняя граница At (включительно; ISO-8601). - // to: Верхняя граница At (включительно; ISO-8601). - // limit: Размер страницы (дефолт 100, кламп 1..500). - // offset: Смещение страницы (≥0). - // context: Контекст запроса. - // analyticsService: Сервис аналитики (scoped). - // ct: Токен отмены. - // Возвращает: 200 {items, total, limit, offset} или 401 без операторской сессии. - private static async Task ActivityAsync( - string? eventType, - string? actorType, - Guid? actorId, - Guid? tenantId, - DateTimeOffset? from, - DateTimeOffset? to, - int? limit, - int? offset, - HttpContext context, - AnalyticsService analyticsService, - CancellationToken ct) - { - if (context.GetCurrentOperator() is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - AnalyticsActivityDto activity = await analyticsService.ActivityAsync( - eventType, actorType, actorId, tenantId, from, to, limit, offset, ct); - return Results.Ok(activity); - } - - /// - /// Известна ли группировка расхода токенов - /// - /// Значение группировки. - /// True — поддерживаемая группировка. - public static bool IsKnownGroupBy(string groupBy) => - groupBy is TokenUsageGroupBys.Day or TokenUsageGroupBys.Tenant - or TokenUsageGroupBys.Provider or TokenUsageGroupBys.Model; -} +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Api.Endpoints; + +/// +/// Операторские read-only эндпоинты аналитики +/// +public static class OperatorAnalyticsEndpoints +{ + private const string AnalyticsGroupPrefix = "/api/operator/analytics"; + + // OpenAPI-тег группы. + private const string OperatorOpenApiTag = "operator"; + + // Группировка расхода токенов по умолчанию (сутки). + private const string DefaultGroupBy = TokenUsageGroupBys.Day; + + // 400 tokens: неизвестная группировка. + private const string InvalidGroupByDetail = "Неизвестная группировка (day|tenant|provider|model)"; + + /// + /// Регистрирует группу /api/operator/analytics + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapOperatorAnalyticsEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup(AnalyticsGroupPrefix).WithTags(OperatorOpenApiTag); + group.MapGet("/overview", OverviewAsync); + group.MapGet("/tokens", TokensAsync); + group.MapGet("/activity", ActivityAsync); + group.MapGet("/suspicious", SuspiciousAsync); + return app; + } + + private static async Task SuspiciousAsync( + DateTimeOffset? from, + DateTimeOffset? to, + HttpContext context, + SuspiciousActivityService suspiciousService, + CancellationToken ct) + { + if (context.GetCurrentOperator() is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + SuspiciousActivityDto report = await suspiciousService.AnalyzeAsync(from, to, ct); + return Results.Ok(report); + } + + // GET /api/operator/analytics/overview?from=&to=: сводка (тенанты, токены, события, входы/выходы). + // from: Начало периода (включительно; ISO-8601). + // to: Конец периода (включительно; ISO-8601). + // context: Контекст запроса. + // analyticsService: Сервис аналитики (scoped). + // ct: Токен отмены. + // Возвращает: 200 сводка или 401 без операторской сессии. + private static async Task OverviewAsync( + DateTimeOffset? from, + DateTimeOffset? to, + HttpContext context, + AnalyticsService analyticsService, + CancellationToken ct) + { + if (context.GetCurrentOperator() is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + AnalyticsOverviewDto overview = await analyticsService.OverviewAsync(from, to, ct); + return Results.Ok(overview); + } + + // GET /api/operator/analytics/tokens?groupBy=&tenantId=&from=&to=: агрегаты расхода токенов. + // groupBy: Группировка day|tenant|provider|model (дефолт day). + // tenantId: Тенант (равенство; пусто — все тенанты). + // from: Начало периода (включительно; ISO-8601). + // to: Конец периода (включительно; ISO-8601). + // context: Контекст запроса. + // analyticsService: Сервис аналитики (scoped). + // ct: Токен отмены. + // Возвращает: 200 агрегаты, 400 неизвестная группировка или 401 без операторской сессии. + private static async Task TokensAsync( + string? groupBy, + Guid? tenantId, + DateTimeOffset? from, + DateTimeOffset? to, + HttpContext context, + AnalyticsService analyticsService, + CancellationToken ct) + { + if (context.GetCurrentOperator() is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + string normalizedGroupBy = string.IsNullOrWhiteSpace(groupBy) ? DefaultGroupBy : groupBy; + if (!IsKnownGroupBy(normalizedGroupBy)) + { + return EndpointResults.BadRequest(InvalidGroupByDetail); + } + + AnalyticsTokensDto tokens = await analyticsService.TokensAsync(normalizedGroupBy, tenantId, from, to, ct); + return Results.Ok(tokens); + } + + // GET /api/operator/analytics/activity?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=: лента действий. + // eventType: Тип события (равенство; пусто — без фильтра). + // actorType: Тип актора operator|tenant|system (равенство). + // actorId: Идентификатор актора (равенство). + // tenantId: Тенант (равенство). + // from: Нижняя граница At (включительно; ISO-8601). + // to: Верхняя граница At (включительно; ISO-8601). + // limit: Размер страницы (дефолт 100, кламп 1..500). + // offset: Смещение страницы (≥0). + // context: Контекст запроса. + // analyticsService: Сервис аналитики (scoped). + // ct: Токен отмены. + // Возвращает: 200 {items, total, limit, offset} или 401 без операторской сессии. + private static async Task ActivityAsync( + string? eventType, + string? actorType, + Guid? actorId, + Guid? tenantId, + DateTimeOffset? from, + DateTimeOffset? to, + int? limit, + int? offset, + HttpContext context, + AnalyticsService analyticsService, + CancellationToken ct) + { + if (context.GetCurrentOperator() is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + AnalyticsActivityDto activity = await analyticsService.ActivityAsync( + eventType, actorType, actorId, tenantId, from, to, limit, offset, ct); + return Results.Ok(activity); + } + + /// + /// Известна ли группировка расхода токенов + /// + /// Значение группировки. + /// True — поддерживаемая группировка. + public static bool IsKnownGroupBy(string groupBy) => + groupBy is TokenUsageGroupBys.Day or TokenUsageGroupBys.Tenant + or TokenUsageGroupBys.Provider or TokenUsageGroupBys.Model; +} diff --git a/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs index 9d55690..f1fdefd 100644 --- a/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs @@ -1,73 +1,73 @@ -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Api.Endpoints; - -/// -/// Операторский эндпоинт чтения аудита -/// -public static class OperatorAuditEndpoints -{ - private const string OperatorGroupPrefix = "/api/operator"; - - // Путь ленты аудита относительно группы. - private const string AuditPath = "/audit"; - - private const string OperatorOpenApiTag = "operator"; - - /// - /// Регистрирует группу /api/operator - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapOperatorAuditEndpoints(this IEndpointRouteBuilder app) - { - app.MapGroup(OperatorGroupPrefix).WithTags(OperatorOpenApiTag).MapGet(AuditPath, ListAsync); - return app; - } - - private static async Task ListAsync( - string? eventType, - string? actorType, - Guid? actorId, - Guid? tenantId, - DateTimeOffset? from, - DateTimeOffset? to, - int? limit, - int? offset, - HttpContext context, - AuditService auditService, - CancellationToken ct) - { - var operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - var filter = new AuditQueryDto( - eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset)); - IReadOnlyList items = await auditService.QueryAsync(filter, ct); - int total = await auditService.CountAsync(filter, ct); - return Results.Ok(new { items, total }); - } - - /// - /// Нормализует limit запроса - /// - /// Запрошенный размер выборки (null — не задан). - /// Значение для фильтра. - public static int NormalizeLimit(int? limit) => - limit is null - ? AuditService.DefaultQueryLimit - : Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit.Value)); - - /// - /// Нормализует offset запроса - /// - /// Запрошенное смещение (null — не задано). - /// Неотрицательное смещение. - public static int NormalizeOffset(int? offset) => Math.Max(0, offset ?? 0); -} +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Api.Endpoints; + +/// +/// Операторский эндпоинт чтения аудита +/// +public static class OperatorAuditEndpoints +{ + private const string OperatorGroupPrefix = "/api/operator"; + + // Путь ленты аудита относительно группы. + private const string AuditPath = "/audit"; + + private const string OperatorOpenApiTag = "operator"; + + /// + /// Регистрирует группу /api/operator + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapOperatorAuditEndpoints(this IEndpointRouteBuilder app) + { + app.MapGroup(OperatorGroupPrefix).WithTags(OperatorOpenApiTag).MapGet(AuditPath, ListAsync); + return app; + } + + private static async Task ListAsync( + string? eventType, + string? actorType, + Guid? actorId, + Guid? tenantId, + DateTimeOffset? from, + DateTimeOffset? to, + int? limit, + int? offset, + HttpContext context, + AuditService auditService, + CancellationToken ct) + { + var operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + var filter = new AuditQueryDto( + eventType, actorType, tenantId, from, to, NormalizeLimit(limit), actorId, NormalizeOffset(offset)); + IReadOnlyList items = await auditService.QueryAsync(filter, ct); + int total = await auditService.CountAsync(filter, ct); + return Results.Ok(new { items, total }); + } + + /// + /// Нормализует limit запроса + /// + /// Запрошенный размер выборки (null — не задан). + /// Значение для фильтра. + public static int NormalizeLimit(int? limit) => + limit is null + ? AuditService.DefaultQueryLimit + : Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit.Value)); + + /// + /// Нормализует offset запроса + /// + /// Запрошенное смещение (null — не задано). + /// Неотрицательное смещение. + public static int NormalizeOffset(int? offset) => Math.Max(0, offset ?? 0); +} diff --git a/src/core/Deal.Api/Endpoints/OperatorHealthEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorHealthEndpoints.cs index 4f7d89a..2ff66e6 100644 --- a/src/core/Deal.Api/Endpoints/OperatorHealthEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorHealthEndpoints.cs @@ -1,182 +1,182 @@ -using Deal.Api.Extensions; -using Deal.Api.Observability; -using Deal.Api.Services; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Api.Endpoints; - -/// -/// Операторский health -/// -public static class OperatorHealthEndpoints -{ - private const string OperatorGroupPrefix = "/api/operator"; - - // Путь health-ручки. - private const string HealthPath = "/health"; - - // OpenAPI-тег группы. - private const string OperatorOpenApiTag = "operator-health"; - - // Статус БД/сервиса: доступна/здоров (ok). - private const string StatusOk = "ok"; - - // Статус сервиса: БД/сервис недоступен (down). - private const string StatusDown = "down"; - - // Статус сервиса: ответил, но не SERVING (grpc NOT_SERVING/SERVICE_UNKNOWN). - private const string StatusUnhealthy = "unhealthy"; - - private const string StatusLocal = "local"; - - // Режим сервиса: Local-адаптеры (UseLocal=true). - private const string ModeLocal = "local"; - - // Режим сервиса: gRPC-клиент (UseLocal=false). - private const string ModeGrpc = "grpc"; - - // Имя ml-service в ответе (порядок секций — как в стартовых логах Program.cs). - private const string MlServiceName = "ml"; - - // Имя ai-service в ответе. - private const string AiServiceName = "ai"; - - // Имя telegram-service в ответе. - private const string TelegramServiceName = "telegram"; - - // Таймаут проверки БД, миллисекунд (health не должен висеть на мёртвом хосте Postgres). - private const int DatabaseProbeTimeoutMilliseconds = 5000; - - /// - /// Регистрирует GET /api/operator/health - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapOperatorHealthEndpoints(this IEndpointRouteBuilder app) - { - app.MapGroup(OperatorGroupPrefix).WithTags(OperatorOpenApiTag).MapGet(HealthPath, GetAsync); - return app; - } - - // GET /api/operator/health: {ok, core:{db}, services:[{name,mode,status,reachable}]} (всегда 200). - private static async Task GetAsync( - HttpContext context, - DealDbContext dbContext, - ServiceHealthProbe healthProbe, - RuntimeDepthsCollector depthsCollector, - MlServiceOptions mlOptions, - AiServiceOptions aiOptions, - TelegramServiceOptions telegramOptions, - CancellationToken ct) - { - var operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - // Параллельно: БД (SELECT 1), health-пробы сервисов (до 3 с на сервис) и снимок глубин очередей/сессий. - Task databaseTask = ProbeDatabaseAsync(dbContext, ct); - Task> servicesTask = ProbeServicesAsync(healthProbe, mlOptions, aiOptions, telegramOptions, ct); - Task depthsTask = depthsCollector.CollectAsync(ct); - await Task.WhenAll(databaseTask, servicesTask, depthsTask); - - string databaseStatus = await databaseTask; - List services = await servicesTask; - RuntimeDepthsDto depths = await depthsTask; - bool ok = databaseStatus == StatusOk - && services.All(service => service.Mode == ModeLocal || service.Status == StatusOk); - return Results.Ok(new - { - ok, - core = new { db = databaseStatus }, - services, - queues = new { pipeline = depths.PipelineQueue, mlOutbox = depths.MlOutbox }, - sessions = new { active = depths.ActiveSessions }, - }); - } - - // Проверяет доступность БД core: SELECT 1 через DealDbContext (public-схема) с таймаутом 5 с. - // dbContext: Системный контекст. - // ct: Токен отмены. - // Возвращает: Статус БД: ok/down (сбой не роняет ручку). - private static async Task ProbeDatabaseAsync(DealDbContext dbContext, CancellationToken ct) - { - try - { - using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); - timeout.CancelAfter(DatabaseProbeTimeoutMilliseconds); - await dbContext.Database.ExecuteSqlRawAsync("SELECT 1", timeout.Token); - return StatusOk; - } - catch (Exception) - { - // Postgres недоступен (контейнер не поднят/сеть) — оператор видит core.db=down, ручка жива. - return StatusDown; - } - } - - // Пробы сервисов ml/ai/telegram по их конфигурации: Local-режим — пометка local без вызова; - // gRPC-режим — health-проба к Services:*:Endpoint (ServiceHealthProbe). - // healthProbe: Проба grpc.health.v1. - // mlOptions: Конфигурация ml-service. - // aiOptions: Конфигурация ai-service. - // telegramOptions: Конфигурация telegram-service. - // ct: Токен отмены. - // Возвращает: Записи состояния сервисов в порядке ml → ai → telegram. - private static async Task> ProbeServicesAsync( - ServiceHealthProbe healthProbe, - MlServiceOptions mlOptions, - AiServiceOptions aiOptions, - TelegramServiceOptions telegramOptions, - CancellationToken ct) - { - var services = new List(capacity: 3); - await ProbeServiceAsync(services, healthProbe, MlServiceName, mlOptions.UseLocal, mlOptions.Endpoint, ct); - await ProbeServiceAsync(services, healthProbe, AiServiceName, aiOptions.UseLocal, aiOptions.Endpoint, ct); - await ProbeServiceAsync(services, healthProbe, TelegramServiceName, telegramOptions.UseLocal, telegramOptions.Endpoint, ct); - return services; - } - - // Одна запись состояния сервиса в списке services (см. ProbeServicesAsync). - // services: Куда добавить запись. - // healthProbe: Проба grpc.health.v1. - // name: Имя сервиса в ответе. - // useLocal: True — Local-режим (UseLocal=true): реальный сервис не подключён. - // endpoint: Базовый адрес сервиса (для gRPC-режима). - // ct: Токен отмены. - private static async Task ProbeServiceAsync( - List services, - ServiceHealthProbe healthProbe, - string name, - bool useLocal, - string endpoint, - CancellationToken ct) - { - if (useLocal) - { - services.Add(new ServiceEntryDto(name, ModeLocal, StatusLocal, Reachable: false)); - return; - } - - ServiceHealthResult result = await healthProbe.ProbeAsync(endpoint, ct); - string status = (result.Reachable, result.Serving) switch - { - (true, true) => StatusOk, - (true, false) => StatusUnhealthy, - _ => StatusDown, - }; - services.Add(new ServiceEntryDto(name, ModeGrpc, status, result.Reachable)); - } - - // Запись состояния сервиса в ответе /api/operator/health (приватная форма сериализации). - // Name: Имя сервиса (ml/ai/telegram). - // Mode: Режим: local (UseLocal=true) | grpc (UseLocal=false). - // Status: Состояние: ok | unhealthy | down (в Local-режиме — local). - // Reachable: True — сервис ответил на health-пробу (в Local-режиме всегда false). - private sealed record ServiceEntryDto(string Name, string Mode, string Status, bool Reachable); -} +using Deal.Api.Extensions; +using Deal.Api.Observability; +using Deal.Api.Services; +using Deal.Infrastructure.Integrations.Models; +using Deal.Infrastructure.Integrations.Options; +using Deal.Infrastructure.Integrations.Services; +using Deal.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Api.Endpoints; + +/// +/// Операторский health +/// +public static class OperatorHealthEndpoints +{ + private const string OperatorGroupPrefix = "/api/operator"; + + // Путь health-ручки. + private const string HealthPath = "/health"; + + // OpenAPI-тег группы. + private const string OperatorOpenApiTag = "operator-health"; + + // Статус БД/сервиса: доступна/здоров (ok). + private const string StatusOk = "ok"; + + // Статус сервиса: БД/сервис недоступен (down). + private const string StatusDown = "down"; + + // Статус сервиса: ответил, но не SERVING (grpc NOT_SERVING/SERVICE_UNKNOWN). + private const string StatusUnhealthy = "unhealthy"; + + private const string StatusLocal = "local"; + + // Режим сервиса: Local-адаптеры (UseLocal=true). + private const string ModeLocal = "local"; + + // Режим сервиса: gRPC-клиент (UseLocal=false). + private const string ModeGrpc = "grpc"; + + // Имя ml-service в ответе (порядок секций — как в стартовых логах Program.cs). + private const string MlServiceName = "ml"; + + // Имя ai-service в ответе. + private const string AiServiceName = "ai"; + + // Имя telegram-service в ответе. + private const string TelegramServiceName = "telegram"; + + // Таймаут проверки БД, миллисекунд (health не должен висеть на мёртвом хосте Postgres). + private const int DatabaseProbeTimeoutMilliseconds = 5000; + + /// + /// Регистрирует GET /api/operator/health + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapOperatorHealthEndpoints(this IEndpointRouteBuilder app) + { + app.MapGroup(OperatorGroupPrefix).WithTags(OperatorOpenApiTag).MapGet(HealthPath, GetAsync); + return app; + } + + // GET /api/operator/health: {ok, core:{db}, services:[{name,mode,status,reachable}]} (всегда 200). + private static async Task GetAsync( + HttpContext context, + DealDbContext dbContext, + ServiceHealthProbe healthProbe, + RuntimeDepthsCollector depthsCollector, + MlServiceOptions mlOptions, + AiServiceOptions aiOptions, + TelegramServiceOptions telegramOptions, + CancellationToken ct) + { + var operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + // Параллельно: БД (SELECT 1), health-пробы сервисов (до 3 с на сервис) и снимок глубин очередей/сессий. + Task databaseTask = ProbeDatabaseAsync(dbContext, ct); + Task> servicesTask = ProbeServicesAsync(healthProbe, mlOptions, aiOptions, telegramOptions, ct); + Task depthsTask = depthsCollector.CollectAsync(ct); + await Task.WhenAll(databaseTask, servicesTask, depthsTask); + + string databaseStatus = await databaseTask; + List services = await servicesTask; + RuntimeDepthsDto depths = await depthsTask; + bool ok = databaseStatus == StatusOk + && services.All(service => service.Mode == ModeLocal || service.Status == StatusOk); + return Results.Ok(new + { + ok, + core = new { db = databaseStatus }, + services, + queues = new { pipeline = depths.PipelineQueue, mlOutbox = depths.MlOutbox }, + sessions = new { active = depths.ActiveSessions }, + }); + } + + // Проверяет доступность БД core: SELECT 1 через DealDbContext (public-схема) с таймаутом 5 с. + // dbContext: Системный контекст. + // ct: Токен отмены. + // Возвращает: Статус БД: ok/down (сбой не роняет ручку). + private static async Task ProbeDatabaseAsync(DealDbContext dbContext, CancellationToken ct) + { + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(DatabaseProbeTimeoutMilliseconds); + await dbContext.Database.ExecuteSqlRawAsync("SELECT 1", timeout.Token); + return StatusOk; + } + catch (Exception) + { + // Postgres недоступен (контейнер не поднят/сеть) — оператор видит core.db=down, ручка жива. + return StatusDown; + } + } + + // Пробы сервисов ml/ai/telegram по их конфигурации: Local-режим — пометка local без вызова; + // gRPC-режим — health-проба к Services:*:Endpoint (ServiceHealthProbe). + // healthProbe: Проба grpc.health.v1. + // mlOptions: Конфигурация ml-service. + // aiOptions: Конфигурация ai-service. + // telegramOptions: Конфигурация telegram-service. + // ct: Токен отмены. + // Возвращает: Записи состояния сервисов в порядке ml → ai → telegram. + private static async Task> ProbeServicesAsync( + ServiceHealthProbe healthProbe, + MlServiceOptions mlOptions, + AiServiceOptions aiOptions, + TelegramServiceOptions telegramOptions, + CancellationToken ct) + { + var services = new List(capacity: 3); + await ProbeServiceAsync(services, healthProbe, MlServiceName, mlOptions.UseLocal, mlOptions.Endpoint, ct); + await ProbeServiceAsync(services, healthProbe, AiServiceName, aiOptions.UseLocal, aiOptions.Endpoint, ct); + await ProbeServiceAsync(services, healthProbe, TelegramServiceName, telegramOptions.UseLocal, telegramOptions.Endpoint, ct); + return services; + } + + // Одна запись состояния сервиса в списке services (см. ProbeServicesAsync). + // services: Куда добавить запись. + // healthProbe: Проба grpc.health.v1. + // name: Имя сервиса в ответе. + // useLocal: True — Local-режим (UseLocal=true): реальный сервис не подключён. + // endpoint: Базовый адрес сервиса (для gRPC-режима). + // ct: Токен отмены. + private static async Task ProbeServiceAsync( + List services, + ServiceHealthProbe healthProbe, + string name, + bool useLocal, + string endpoint, + CancellationToken ct) + { + if (useLocal) + { + services.Add(new ServiceEntryDto(name, ModeLocal, StatusLocal, Reachable: false)); + return; + } + + ServiceHealthResult result = await healthProbe.ProbeAsync(endpoint, ct); + string status = (result.Reachable, result.Serving) switch + { + (true, true) => StatusOk, + (true, false) => StatusUnhealthy, + _ => StatusDown, + }; + services.Add(new ServiceEntryDto(name, ModeGrpc, status, result.Reachable)); + } + + // Запись состояния сервиса в ответе /api/operator/health (приватная форма сериализации). + // Name: Имя сервиса (ml/ai/telegram). + // Mode: Режим: local (UseLocal=true) | grpc (UseLocal=false). + // Status: Состояние: ok | unhealthy | down (в Local-режиме — local). + // Reachable: True — сервис ответил на health-пробу (в Local-режиме всегда false). + private sealed record ServiceEntryDto(string Name, string Mode, string Status, bool Reachable); +} diff --git a/src/core/Deal.Api/Endpoints/OperatorInviteCreateRequest.cs b/src/core/Deal.Api/Endpoints/OperatorInviteCreateRequest.cs index 0c0aaa0..f8720bd 100644 --- a/src/core/Deal.Api/Endpoints/OperatorInviteCreateRequest.cs +++ b/src/core/Deal.Api/Endpoints/OperatorInviteCreateRequest.cs @@ -1,8 +1,8 @@ -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/operator/invites -/// -/// Email приглашённого (регистр/пробелы не важны — нормализует InvitesService). -/// Целевой тенант; null — при активации будет создан новый тенант. -public sealed record OperatorInviteCreateRequest(string? Email, Guid? TenantId); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/operator/invites +/// +/// Email приглашённого (регистр/пробелы не важны — нормализует InvitesService). +/// Целевой тенант; null — при активации будет создан новый тенант. +public sealed record OperatorInviteCreateRequest(string? Email, Guid? TenantId); diff --git a/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs index c916590..54f10e8 100644 --- a/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs @@ -1,138 +1,138 @@ -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Api.Endpoints; - -/// -/// Операторские эндпоинты приглашений -/// -public static class OperatorInvitesEndpoints -{ - // Текст 400: email пустой/некорректного формата. - private const string InvalidEmailDetail = "Некорректный email"; - - private const string DuplicateActiveDetail = "Для этого email уже есть активное приглашение"; - - // Текст 404: приглашение с таким кодом не найдено. - private const string InviteNotFoundDetail = "Приглашение не найдено"; - - // Текст 400: отзыв приглашения не в статусе pending (уже отозвано/использовано/истекло). - private const string InviteNotPendingDetail = "Отозвать можно только ожидающее активации приглашение"; - - private const string InvitesGroupPrefix = "/api/operator/invites"; - - // Относительный путь отзыва приглашения. - private const string RevokePath = "/{code}/revoke"; - - // OpenAPI-тег группы. - private const string InvitesOpenApiTag = "operator-invites"; - - /// - /// Регистрирует группу /api/operator/invites - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapOperatorInvitesEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(InvitesGroupPrefix).WithTags(InvitesOpenApiTag); - - group.MapGet("", ListAsync); - group.MapPost("", CreateAsync); - group.MapPost(RevokePath, RevokeAsync); - - return app; - } - - // GET /api/operator/invites: список приглашений (новые сверху, со статусами; expired проставляется лениво). - private static async Task ListAsync( - HttpContext context, - InvitesService invitesService, - CancellationToken ct) - { - if (context.GetCurrentOperator() is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - IReadOnlyList items = await invitesService.ListAsync(ct); - return Results.Ok(new { items }); - } - - // POST /api/operator/invites: создание приглашения; результат пишется в аудит (invite_created). - private static async Task CreateAsync( - OperatorInviteCreateRequest body, - HttpContext context, - InvitesService invitesService, - AuditService auditService, - CancellationToken ct) - { - var operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - InviteCreateResultDto result = await invitesService.CreateInviteAsync( - operatorIdentity.OperatorId, body.Email, body.TenantId, ct); - if (!result.Ok || result.Invite is null) - { - string detail = result.Error == InviteCreateResultDto.ErrorDuplicateActive - ? DuplicateActiveDetail - : InvalidEmailDetail; - return EndpointResults.BadRequest(detail); - } - - await auditService.AppendAsync(new AuditRecordDto( - AuditEvents.InviteCreated, - AuditActorTypes.Operator, - ActorId: operatorIdentity.OperatorId, - TenantId: null, - Ip: ClientIp(context), - // Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется - // только его SHA-256-хэш, чтобы утечка ленты не давала рабочие коды (Security review). - DetailJson: AuditService.ToDetailJson(new { email = result.Invite.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct); - - InviteDto invite = result.Invite; - return Results.Ok(new { invite.Code, invite.Email, invite.TenantId, invite.ExpiresAt, invite.Status }); - } - - // POST /api/operator/invites/{code}/revoke: отзыв ожидающего приглашения; результат пишется в аудит (invite_revoked). - private static async Task RevokeAsync( - string code, - HttpContext context, - InvitesService invitesService, - AuditService auditService, - CancellationToken ct) - { - var operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - InviteRevokeResultDto result = await invitesService.RevokeAsync(code, ct); - if (!result.Ok) - { - return result.Error == InviteRevokeResultDto.ErrorNotFound - ? EndpointResults.NotFound(InviteNotFoundDetail) - : EndpointResults.BadRequest(InviteNotPendingDetail); - } - - await auditService.AppendAsync(new AuditRecordDto( - AuditEvents.InviteRevoked, - AuditActorTypes.Operator, - ActorId: operatorIdentity.OperatorId, - TenantId: null, - Ip: ClientIp(context), - DetailJson: AuditService.ToDetailJson(new { email = result.Invite!.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct); - - return Results.Ok(new { ok = true }); - } - - // IP-адрес клиента для аудита (без порта; null, если недоступен). - // context: Контекст запроса. - // Возвращает: Строковое представление IP или null. - private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); -} +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Api.Endpoints; + +/// +/// Операторские эндпоинты приглашений +/// +public static class OperatorInvitesEndpoints +{ + // Текст 400: email пустой/некорректного формата. + private const string InvalidEmailDetail = "Некорректный email"; + + private const string DuplicateActiveDetail = "Для этого email уже есть активное приглашение"; + + // Текст 404: приглашение с таким кодом не найдено. + private const string InviteNotFoundDetail = "Приглашение не найдено"; + + // Текст 400: отзыв приглашения не в статусе pending (уже отозвано/использовано/истекло). + private const string InviteNotPendingDetail = "Отозвать можно только ожидающее активации приглашение"; + + private const string InvitesGroupPrefix = "/api/operator/invites"; + + // Относительный путь отзыва приглашения. + private const string RevokePath = "/{code}/revoke"; + + // OpenAPI-тег группы. + private const string InvitesOpenApiTag = "operator-invites"; + + /// + /// Регистрирует группу /api/operator/invites + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapOperatorInvitesEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup(InvitesGroupPrefix).WithTags(InvitesOpenApiTag); + + group.MapGet("", ListAsync); + group.MapPost("", CreateAsync); + group.MapPost(RevokePath, RevokeAsync); + + return app; + } + + // GET /api/operator/invites: список приглашений (новые сверху, со статусами; expired проставляется лениво). + private static async Task ListAsync( + HttpContext context, + InvitesService invitesService, + CancellationToken ct) + { + if (context.GetCurrentOperator() is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + IReadOnlyList items = await invitesService.ListAsync(ct); + return Results.Ok(new { items }); + } + + // POST /api/operator/invites: создание приглашения; результат пишется в аудит (invite_created). + private static async Task CreateAsync( + OperatorInviteCreateRequest body, + HttpContext context, + InvitesService invitesService, + AuditService auditService, + CancellationToken ct) + { + var operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + InviteCreateResultDto result = await invitesService.CreateInviteAsync( + operatorIdentity.OperatorId, body.Email, body.TenantId, ct); + if (!result.Ok || result.Invite is null) + { + string detail = result.Error == InviteCreateResultDto.ErrorDuplicateActive + ? DuplicateActiveDetail + : InvalidEmailDetail; + return EndpointResults.BadRequest(detail); + } + + await auditService.AppendAsync(new AuditRecordDto( + AuditEvents.InviteCreated, + AuditActorTypes.Operator, + ActorId: operatorIdentity.OperatorId, + TenantId: null, + Ip: ClientIp(context), + // Код инвайта — capability-токен (по нему активируется приглашение): в аудит пишется + // только его SHA-256-хэш, чтобы утечка ленты не давала рабочие коды (Security review). + DetailJson: AuditService.ToDetailJson(new { email = result.Invite.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct); + + InviteDto invite = result.Invite; + return Results.Ok(new { invite.Code, invite.Email, invite.TenantId, invite.ExpiresAt, invite.Status }); + } + + // POST /api/operator/invites/{code}/revoke: отзыв ожидающего приглашения; результат пишется в аудит (invite_revoked). + private static async Task RevokeAsync( + string code, + HttpContext context, + InvitesService invitesService, + AuditService auditService, + CancellationToken ct) + { + var operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + InviteRevokeResultDto result = await invitesService.RevokeAsync(code, ct); + if (!result.Ok) + { + return result.Error == InviteRevokeResultDto.ErrorNotFound + ? EndpointResults.NotFound(InviteNotFoundDetail) + : EndpointResults.BadRequest(InviteNotPendingDetail); + } + + await auditService.AppendAsync(new AuditRecordDto( + AuditEvents.InviteRevoked, + AuditActorTypes.Operator, + ActorId: operatorIdentity.OperatorId, + TenantId: null, + Ip: ClientIp(context), + DetailJson: AuditService.ToDetailJson(new { email = result.Invite!.Email, codeHash = SessionTokens.HashToken(result.Invite.Code) })), ct); + + return Results.Ok(new { ok = true }); + } + + // IP-адрес клиента для аудита (без порта; null, если недоступен). + // context: Контекст запроса. + // Возвращает: Строковое представление IP или null. + private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); +} diff --git a/src/core/Deal.Api/Endpoints/OperatorLimitUpdateRequest.cs b/src/core/Deal.Api/Endpoints/OperatorLimitUpdateRequest.cs index 0f8ffd8..299d647 100644 --- a/src/core/Deal.Api/Endpoints/OperatorLimitUpdateRequest.cs +++ b/src/core/Deal.Api/Endpoints/OperatorLimitUpdateRequest.cs @@ -1,8 +1,8 @@ -namespace Deal.Api.Endpoints; - -/// -/// Тело PATCH /api/operator/tenants/{id}/limit -/// -/// Новый бюджет периода в токенах (≥0; 0 — ИИ запрещён); null — оставить текущий. -/// Новый тип периода (константа TenantLimitPeriods: month|day); null — оставить текущий. -public sealed record OperatorLimitUpdateRequest(long? Budget, string? Period); +namespace Deal.Api.Endpoints; + +/// +/// Тело PATCH /api/operator/tenants/{id}/limit +/// +/// Новый бюджет периода в токенах (≥0; 0 — ИИ запрещён); null — оставить текущий. +/// Новый тип периода (константа TenantLimitPeriods: month|day); null — оставить текущий. +public sealed record OperatorLimitUpdateRequest(long? Budget, string? Period); diff --git a/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs index ae74af6..9f733a4 100644 --- a/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs @@ -1,222 +1,222 @@ -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Api.Endpoints; - -/// -/// Операторские эндпоинты лимитов ИИ-бюджета -/// -public static class OperatorLimitsEndpoints -{ - // Текст 400: PATCH без полей (null-тело/пустой объект). - private const string EmptyUpdateDetail = "Укажите новый бюджет или период"; - - // Текст 400: бюджет отрицательный (порог лимита не позволяет). - private const string NegativeBudgetDetail = "Бюджет должен быть неотрицательным"; - - // Текст 400: период не month и не day (константы TenantLimitPeriods). - private const string InvalidPeriodDetail = "Период должен быть month или day"; - - // Текст 404: тенант с таким id не найден в реестре. - private const string TenantNotFoundDetail = "Тенант не найден"; - - // Верхняя граница процента расхода (диапазон 0..100) — константа расчёта CalculatePercent. - private const int PercentMax = 100; - - private const string OperatorGroupPrefix = "/api/operator"; - - private const string TenantsGroupPrefix = "/api/operator/tenants"; - - // Путь сводки лимитов по всем тенантам. - private const string SummaryPath = "/limits"; - - // Относительный путь лимита тенанта (просмотр/смена). - private const string TenantLimitPath = "/{id:guid}/limit"; - - // OpenAPI-тег группы сводки лимитов. - private const string LimitsOpenApiTag = "operator-limits"; - - private static readonly TokenBudgetService BudgetService = new(); - - /// - /// Регистрирует ручки лимитов - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapOperatorLimitsEndpoints(this IEndpointRouteBuilder app) - { - app.MapGroup(OperatorGroupPrefix).WithTags(LimitsOpenApiTag).MapGet(SummaryPath, ListSummaryAsync); - var tenantsGroup = app.MapGroup(TenantsGroupPrefix).WithTags(LimitsOpenApiTag); - tenantsGroup.MapGet(TenantLimitPath, GetLimitAsync); - tenantsGroup.MapPatch(TenantLimitPath, PatchLimitAsync); - return app; - } - - private static async Task ListSummaryAsync( - HttpContext context, - ITenantRepository tenantRepository, - ITenantLimitStore limitStore, - CancellationToken ct) - { - var operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - IReadOnlyList tenants = await tenantRepository.ListAsync(ct); - var items = new List(tenants.Count); - foreach (TenantRecordDto tenant in tenants) - { - BudgetStateDto state = await limitStore.GetStateAsync(tenant.Id, ct); - items.Add(new - { - tenantId = tenant.Id, - name = tenant.Name, - budget = state.BudgetTokens, - period = state.Period, - used = state.UsedTokens, - percent = CalculatePercent(state.UsedTokens, state.BudgetTokens), - status = state.Status, - }); - } - - return Results.Ok(new { items }); - } - - // GET /api/operator/tenants/{id}/limit: детали лимита тенанта (форма BuildDetailDto). - private static async Task GetLimitAsync( - Guid id, - HttpContext context, - ITenantRepository tenantRepository, - ITenantLimitStore limitStore, - CancellationToken ct) - { - var operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - TenantRecordDto? tenant = await tenantRepository.FindByIdAsync(id, ct); - if (tenant is null) - { - return EndpointResults.NotFound(TenantNotFoundDetail); - } - - BudgetStateDto state = await limitStore.GetStateAsync(id, ct); - return Results.Ok(BuildDetailDto(tenant.Name, state)); - } - - // PATCH /api/operator/tenants/{id}/limit: смена бюджета/периода (сброс флагов + аудит tenant_limit_changed). - private static async Task PatchLimitAsync( - Guid id, - OperatorLimitUpdateRequest? body, - HttpContext context, - ITenantRepository tenantRepository, - ITenantLimitStore limitStore, - AuditService auditService, - CancellationToken ct) - { - var operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - if (body is null || (body.Budget is null && string.IsNullOrWhiteSpace(body.Period))) - { - return EndpointResults.BadRequest(EmptyUpdateDetail); - } - - if (body.Budget is < 0) - { - return EndpointResults.BadRequest(NegativeBudgetDetail); - } - - if (body.Period is not null - && body.Period != TenantLimitPeriods.Month - && body.Period != TenantLimitPeriods.Day) - { - return EndpointResults.BadRequest(InvalidPeriodDetail); - } - - TenantRecordDto? tenant = await tenantRepository.FindByIdAsync(id, ct); - if (tenant is null) - { - return EndpointResults.NotFound(TenantNotFoundDetail); - } - - // Текущее состояние — источник значений не заданных в PATCH полей (период/бюджет меняются по отдельности). - BudgetStateDto current = await limitStore.GetStateAsync(id, ct); - long newBudget = body.Budget ?? current.BudgetTokens; - string newPeriod = body.Period ?? current.Period; - if (newBudget == current.BudgetTokens && newPeriod == current.Period) - { - // Идемпотентный повторный PATCH: без изменения хранилища и без дубля аудита. - return Results.Ok(BuildDetailDto(tenant.Name, current)); - } - - BudgetStateDto updated = await limitStore.UpdateBudgetAsync(id, newBudget, newPeriod, ct); - await auditService.AppendAsync(new AuditRecordDto( - AuditEvents.TenantLimitChanged, - AuditActorTypes.Operator, - ActorId: operatorIdentity.OperatorId, - TenantId: id, - Ip: ClientIp(context), - DetailJson: AuditService.ToDetailJson(new - { - tenantId = id, - oldBudget = current.BudgetTokens, - oldPeriod = current.Period, - budgetTokens = newBudget, - period = newPeriod, - })), ct); - - return Results.Ok(BuildDetailDto(tenant.Name, updated)); - } - - /// - /// Процент расхода бюджета для операторской сводки/деталей - /// - /// Использовано токенов с начала периода. - /// Бюджет периода. - /// Процент в диапазоне 0..100 (расход сверх бюджета показывается как 100). - public static int CalculatePercent(long usedTokens, long budgetTokens) - { - if (budgetTokens <= 0 || usedTokens >= budgetTokens) - { - return PercentMax; - } - - return (int)(usedTokens * (double)PercentMax / budgetTokens); - } - - // Форма деталей лимита тенанта (GET и ответ PATCH — единая). - // name: Имя тенанта (реестр). - // state: Состояние бюджета (после ленивого reset). - // Возвращает: Объект ответа: лимит + расход + флаги порогов + статус тенанта. - private static object BuildDetailDto(string name, BudgetStateDto state) => new - { - tenantId = state.TenantId, - name, - status = state.Status, - allowed = state.Allowed, - budget = state.BudgetTokens, - period = state.Period, - periodStart = state.PeriodStart, - used = state.UsedTokens, - remaining = BudgetService.RemainingTokens(state.UsedTokens, state.BudgetTokens), - percent = CalculatePercent(state.UsedTokens, state.BudgetTokens), - warned80 = state.Warned80, - notifiedExhausted = state.NotifiedExhausted, - }; - - // IP-адрес клиента для аудита (без порта; null, если недоступен). - // context: Контекст запроса. - // Возвращает: Строковое представление IP или null. - private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); -} +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Api.Endpoints; + +/// +/// Операторские эндпоинты лимитов ИИ-бюджета +/// +public static class OperatorLimitsEndpoints +{ + // Текст 400: PATCH без полей (null-тело/пустой объект). + private const string EmptyUpdateDetail = "Укажите новый бюджет или период"; + + // Текст 400: бюджет отрицательный (порог лимита не позволяет). + private const string NegativeBudgetDetail = "Бюджет должен быть неотрицательным"; + + // Текст 400: период не month и не day (константы TenantLimitPeriods). + private const string InvalidPeriodDetail = "Период должен быть month или day"; + + // Текст 404: тенант с таким id не найден в реестре. + private const string TenantNotFoundDetail = "Тенант не найден"; + + // Верхняя граница процента расхода (диапазон 0..100) — константа расчёта CalculatePercent. + private const int PercentMax = 100; + + private const string OperatorGroupPrefix = "/api/operator"; + + private const string TenantsGroupPrefix = "/api/operator/tenants"; + + // Путь сводки лимитов по всем тенантам. + private const string SummaryPath = "/limits"; + + // Относительный путь лимита тенанта (просмотр/смена). + private const string TenantLimitPath = "/{id:guid}/limit"; + + // OpenAPI-тег группы сводки лимитов. + private const string LimitsOpenApiTag = "operator-limits"; + + private static readonly TokenBudgetService BudgetService = new(); + + /// + /// Регистрирует ручки лимитов + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapOperatorLimitsEndpoints(this IEndpointRouteBuilder app) + { + app.MapGroup(OperatorGroupPrefix).WithTags(LimitsOpenApiTag).MapGet(SummaryPath, ListSummaryAsync); + var tenantsGroup = app.MapGroup(TenantsGroupPrefix).WithTags(LimitsOpenApiTag); + tenantsGroup.MapGet(TenantLimitPath, GetLimitAsync); + tenantsGroup.MapPatch(TenantLimitPath, PatchLimitAsync); + return app; + } + + private static async Task ListSummaryAsync( + HttpContext context, + ITenantRepository tenantRepository, + ITenantLimitStore limitStore, + CancellationToken ct) + { + var operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + IReadOnlyList tenants = await tenantRepository.ListAsync(ct); + var items = new List(tenants.Count); + foreach (TenantRecordDto tenant in tenants) + { + BudgetStateDto state = await limitStore.GetStateAsync(tenant.Id, ct); + items.Add(new + { + tenantId = tenant.Id, + name = tenant.Name, + budget = state.BudgetTokens, + period = state.Period, + used = state.UsedTokens, + percent = CalculatePercent(state.UsedTokens, state.BudgetTokens), + status = state.Status, + }); + } + + return Results.Ok(new { items }); + } + + // GET /api/operator/tenants/{id}/limit: детали лимита тенанта (форма BuildDetailDto). + private static async Task GetLimitAsync( + Guid id, + HttpContext context, + ITenantRepository tenantRepository, + ITenantLimitStore limitStore, + CancellationToken ct) + { + var operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + TenantRecordDto? tenant = await tenantRepository.FindByIdAsync(id, ct); + if (tenant is null) + { + return EndpointResults.NotFound(TenantNotFoundDetail); + } + + BudgetStateDto state = await limitStore.GetStateAsync(id, ct); + return Results.Ok(BuildDetailDto(tenant.Name, state)); + } + + // PATCH /api/operator/tenants/{id}/limit: смена бюджета/периода (сброс флагов + аудит tenant_limit_changed). + private static async Task PatchLimitAsync( + Guid id, + OperatorLimitUpdateRequest? body, + HttpContext context, + ITenantRepository tenantRepository, + ITenantLimitStore limitStore, + AuditService auditService, + CancellationToken ct) + { + var operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + if (body is null || (body.Budget is null && string.IsNullOrWhiteSpace(body.Period))) + { + return EndpointResults.BadRequest(EmptyUpdateDetail); + } + + if (body.Budget is < 0) + { + return EndpointResults.BadRequest(NegativeBudgetDetail); + } + + if (body.Period is not null + && body.Period != TenantLimitPeriods.Month + && body.Period != TenantLimitPeriods.Day) + { + return EndpointResults.BadRequest(InvalidPeriodDetail); + } + + TenantRecordDto? tenant = await tenantRepository.FindByIdAsync(id, ct); + if (tenant is null) + { + return EndpointResults.NotFound(TenantNotFoundDetail); + } + + // Текущее состояние — источник значений не заданных в PATCH полей (период/бюджет меняются по отдельности). + BudgetStateDto current = await limitStore.GetStateAsync(id, ct); + long newBudget = body.Budget ?? current.BudgetTokens; + string newPeriod = body.Period ?? current.Period; + if (newBudget == current.BudgetTokens && newPeriod == current.Period) + { + // Идемпотентный повторный PATCH: без изменения хранилища и без дубля аудита. + return Results.Ok(BuildDetailDto(tenant.Name, current)); + } + + BudgetStateDto updated = await limitStore.UpdateBudgetAsync(id, newBudget, newPeriod, ct); + await auditService.AppendAsync(new AuditRecordDto( + AuditEvents.TenantLimitChanged, + AuditActorTypes.Operator, + ActorId: operatorIdentity.OperatorId, + TenantId: id, + Ip: ClientIp(context), + DetailJson: AuditService.ToDetailJson(new + { + tenantId = id, + oldBudget = current.BudgetTokens, + oldPeriod = current.Period, + budgetTokens = newBudget, + period = newPeriod, + })), ct); + + return Results.Ok(BuildDetailDto(tenant.Name, updated)); + } + + /// + /// Процент расхода бюджета для операторской сводки/деталей + /// + /// Использовано токенов с начала периода. + /// Бюджет периода. + /// Процент в диапазоне 0..100 (расход сверх бюджета показывается как 100). + public static int CalculatePercent(long usedTokens, long budgetTokens) + { + if (budgetTokens <= 0 || usedTokens >= budgetTokens) + { + return PercentMax; + } + + return (int)(usedTokens * (double)PercentMax / budgetTokens); + } + + // Форма деталей лимита тенанта (GET и ответ PATCH — единая). + // name: Имя тенанта (реестр). + // state: Состояние бюджета (после ленивого reset). + // Возвращает: Объект ответа: лимит + расход + флаги порогов + статус тенанта. + private static object BuildDetailDto(string name, BudgetStateDto state) => new + { + tenantId = state.TenantId, + name, + status = state.Status, + allowed = state.Allowed, + budget = state.BudgetTokens, + period = state.Period, + periodStart = state.PeriodStart, + used = state.UsedTokens, + remaining = BudgetService.RemainingTokens(state.UsedTokens, state.BudgetTokens), + percent = CalculatePercent(state.UsedTokens, state.BudgetTokens), + warned80 = state.Warned80, + notifiedExhausted = state.NotifiedExhausted, + }; + + // IP-адрес клиента для аудита (без порта; null, если недоступен). + // context: Контекст запроса. + // Возвращает: Строковое представление IP или null. + private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); +} diff --git a/src/core/Deal.Api/Endpoints/OperatorMaintenanceEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorMaintenanceEndpoints.cs index c073e99..2a1f5f8 100644 --- a/src/core/Deal.Api/Endpoints/OperatorMaintenanceEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorMaintenanceEndpoints.cs @@ -1,57 +1,57 @@ -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Infrastructure.Tenancy; - -namespace Deal.Api.Endpoints; - -/// -/// Операторские maintenance-ручки -/// -public static class OperatorMaintenanceEndpoints -{ - // Префикс группы операторских maintenance-ручек. - private const string MaintenanceGroupPrefix = "/api/operator/maintenance"; - - // Относительный путь пакетной миграции схем тенантов. - private const string MigrateTenantsPath = "/tenants/migrate"; - - // OpenAPI-тег группы. - private const string MaintenanceOpenApiTag = "operator-maintenance"; - - /// - /// Регистрирует группу /api/operator/maintenance - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapOperatorMaintenanceEndpoints(this IEndpointRouteBuilder app) - { - app.MapGroup(MaintenanceGroupPrefix) - .WithTags(MaintenanceOpenApiTag) - .MapPost(MigrateTenantsPath, MigrateTenantsAsync); - return app; - } - - // POST /api/operator/maintenance/tenants/migrate: миграция схем всех тенантов (сводка прогресса). - private static async Task MigrateTenantsAsync( - HttpContext context, - TenantSchemaMigrationService migrationService, - CancellationToken ct) - { - var operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - TenantMigrationSummary summary = await migrationService.MigrateAllAsync(ct); - return Results.Ok(new - { - ok = summary.Ok, - total = summary.Total, - migrated = summary.Migrated, - failed = summary.Failed, - failedSchemas = summary.FailedSchemas, - durationMs = summary.DurationMs, - }); - } -} +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Infrastructure.Tenancy; + +namespace Deal.Api.Endpoints; + +/// +/// Операторские maintenance-ручки +/// +public static class OperatorMaintenanceEndpoints +{ + // Префикс группы операторских maintenance-ручек. + private const string MaintenanceGroupPrefix = "/api/operator/maintenance"; + + // Относительный путь пакетной миграции схем тенантов. + private const string MigrateTenantsPath = "/tenants/migrate"; + + // OpenAPI-тег группы. + private const string MaintenanceOpenApiTag = "operator-maintenance"; + + /// + /// Регистрирует группу /api/operator/maintenance + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapOperatorMaintenanceEndpoints(this IEndpointRouteBuilder app) + { + app.MapGroup(MaintenanceGroupPrefix) + .WithTags(MaintenanceOpenApiTag) + .MapPost(MigrateTenantsPath, MigrateTenantsAsync); + return app; + } + + // POST /api/operator/maintenance/tenants/migrate: миграция схем всех тенантов (сводка прогресса). + private static async Task MigrateTenantsAsync( + HttpContext context, + TenantSchemaMigrationService migrationService, + CancellationToken ct) + { + var operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + TenantMigrationSummary summary = await migrationService.MigrateAllAsync(ct); + return Results.Ok(new + { + ok = summary.Ok, + total = summary.Total, + migrated = summary.Migrated, + failed = summary.Failed, + failedSchemas = summary.FailedSchemas, + durationMs = summary.DurationMs, + }); + } +} diff --git a/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs index d4e0657..9c9d9cb 100644 --- a/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs @@ -1,141 +1,141 @@ -using Deal.Api.Endpoints.RequestModels; -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Api.Telegram; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Api.Endpoints; - -/// -/// Операторские ручки глобальных -/// -public static class OperatorSettingsEndpoints -{ - // Префикс группы операторских настроек. - private const string SettingsGroupPrefix = "/api/operator/settings"; - - // OpenAPI-тег группы. - private const string SettingsOpenApiTag = "operator-settings"; - - // Относительный путь глобальных ключей Telegram (GET/PUT). - private const string TelegramKeysPath = "/telegram-keys"; - - // Текст 400: пустое тело PUT (ни одного поля). - private const string EmptyBodyDetail = "Укажите api_id и api_hash"; - - // Текст 400: частичное обновление, но ключей ещё нет — нужны оба поля. - private const string MissingKeysDetail = "Ключи ещё не заданы — укажите и api_id, и api_hash"; - - // Текст 400: api_id не 5..9 цифр. - private const string InvalidApiIdDetail = "api_id должен состоять из 5–9 цифр"; - - // Текст 400: api_hash пустой/маска/с префиксом enc:. - private const string InvalidApiHashDetail = "Укажите непустой api_hash"; - - /// - /// Регистрирует группу /api/operator/settings - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapOperatorSettingsEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag); - group.MapGet(TelegramKeysPath, GetTelegramKeysAsync); - group.MapPut(TelegramKeysPath, PutTelegramKeysAsync); - return app; - } - - // GET /api/operator/settings/telegram-keys: маскированные глобальные ключи Telegram. - // context: Контекст запроса. - // keys: Сервис глобальных ключей Telegram (scoped). - // ct: Токен отмены. - // Возвращает: 200 маскированный снимок или 401 без операторской сессии. - private static async Task GetTelegramKeysAsync( - HttpContext context, - TelegramKeysService keys, - CancellationToken ct) - { - if (context.GetCurrentOperator() is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct); - return Results.Ok(snapshot); - } - - // PUT /api/operator/settings/telegram-keys: частичное сохранение глобальных ключей Telegram - // оператором. - // Поля можно передавать по отдельности: непереданное поле (null) сохраняет текущее значение, - // явное значение (в т.ч. пустая строка) валидируется. Если ключей ещё нет, оба поля обязательны. - // body: Тело {apiId?, apiHash?} (хотя бы одно поле). - // context: Контекст запроса. - // keys: Сервис глобальных ключей Telegram (scoped). - // auditService: Сервис аудита (событие telegram_keys_changed). - // ct: Токен отмены. - // Возвращает: 200 маскированный снимок, 400 при невалидных/недостающих полях или 401 без операторской сессии. - private static async Task PutTelegramKeysAsync( - OperatorTelegramKeysRequest? body, - HttpContext context, - TelegramKeysService keys, - AuditService auditService, - CancellationToken ct) - { - var operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - if (body is null) - { - return EndpointResults.BadRequest(EmptyBodyDetail); - } - - // null — поле не передано (сохраняем текущее); непустая строка/плейсхолдер — валидируем явно. - string? apiId = body.ApiId?.Trim(); - string? apiHash = body.ApiHash?.Trim(); - if (apiId is null && apiHash is null) - { - return EndpointResults.BadRequest(EmptyBodyDetail); - } - - if (apiId is not null && !TelegramKeysService.IsValidApiId(apiId)) - { - return EndpointResults.BadRequest(InvalidApiIdDetail); - } - - if (apiHash is not null && !TelegramKeysService.IsValidApiHash(apiHash)) - { - return EndpointResults.BadRequest(InvalidApiHashDetail); - } - - // Частичное обновление: недостающее поле берём из текущих ключей; если ключей ещё нет — нужны оба. - TgKeysSnapshot current = await keys.GetAsync(ct); - string effectiveApiId = apiId ?? current.ApiId; - string effectiveApiHash = apiHash ?? current.ApiHash; - if (effectiveApiId.Length == 0 || effectiveApiHash.Length == 0) - { - return EndpointResults.BadRequest(MissingKeysDetail); - } - - await keys.SaveAsync(effectiveApiId, effectiveApiHash, ct); - - await auditService.AppendAsync(new AuditRecordDto( - AuditEvents.TelegramKeysChanged, - AuditActorTypes.Operator, - ActorId: operatorIdentity.OperatorId, - TenantId: null, - Ip: ClientIp(context), - DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct); - - TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct); - return Results.Ok(snapshot); - } - - // IP-адрес клиента для аудита (без порта; null, если недоступен). - // context: Контекст запроса. - // Возвращает: Строковое представление IP или null. - private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); -} +using Deal.Api.Endpoints.RequestModels; +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Api.Telegram; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Api.Endpoints; + +/// +/// Операторские ручки глобальных +/// +public static class OperatorSettingsEndpoints +{ + // Префикс группы операторских настроек. + private const string SettingsGroupPrefix = "/api/operator/settings"; + + // OpenAPI-тег группы. + private const string SettingsOpenApiTag = "operator-settings"; + + // Относительный путь глобальных ключей Telegram (GET/PUT). + private const string TelegramKeysPath = "/telegram-keys"; + + // Текст 400: пустое тело PUT (ни одного поля). + private const string EmptyBodyDetail = "Укажите api_id и api_hash"; + + // Текст 400: частичное обновление, но ключей ещё нет — нужны оба поля. + private const string MissingKeysDetail = "Ключи ещё не заданы — укажите и api_id, и api_hash"; + + // Текст 400: api_id не 5..9 цифр. + private const string InvalidApiIdDetail = "api_id должен состоять из 5–9 цифр"; + + // Текст 400: api_hash пустой/маска/с префиксом enc:. + private const string InvalidApiHashDetail = "Укажите непустой api_hash"; + + /// + /// Регистрирует группу /api/operator/settings + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapOperatorSettingsEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag); + group.MapGet(TelegramKeysPath, GetTelegramKeysAsync); + group.MapPut(TelegramKeysPath, PutTelegramKeysAsync); + return app; + } + + // GET /api/operator/settings/telegram-keys: маскированные глобальные ключи Telegram. + // context: Контекст запроса. + // keys: Сервис глобальных ключей Telegram (scoped). + // ct: Токен отмены. + // Возвращает: 200 маскированный снимок или 401 без операторской сессии. + private static async Task GetTelegramKeysAsync( + HttpContext context, + TelegramKeysService keys, + CancellationToken ct) + { + if (context.GetCurrentOperator() is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct); + return Results.Ok(snapshot); + } + + // PUT /api/operator/settings/telegram-keys: частичное сохранение глобальных ключей Telegram + // оператором. + // Поля можно передавать по отдельности: непереданное поле (null) сохраняет текущее значение, + // явное значение (в т.ч. пустая строка) валидируется. Если ключей ещё нет, оба поля обязательны. + // body: Тело {apiId?, apiHash?} (хотя бы одно поле). + // context: Контекст запроса. + // keys: Сервис глобальных ключей Telegram (scoped). + // auditService: Сервис аудита (событие telegram_keys_changed). + // ct: Токен отмены. + // Возвращает: 200 маскированный снимок, 400 при невалидных/недостающих полях или 401 без операторской сессии. + private static async Task PutTelegramKeysAsync( + OperatorTelegramKeysRequest? body, + HttpContext context, + TelegramKeysService keys, + AuditService auditService, + CancellationToken ct) + { + var operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + if (body is null) + { + return EndpointResults.BadRequest(EmptyBodyDetail); + } + + // null — поле не передано (сохраняем текущее); непустая строка/плейсхолдер — валидируем явно. + string? apiId = body.ApiId?.Trim(); + string? apiHash = body.ApiHash?.Trim(); + if (apiId is null && apiHash is null) + { + return EndpointResults.BadRequest(EmptyBodyDetail); + } + + if (apiId is not null && !TelegramKeysService.IsValidApiId(apiId)) + { + return EndpointResults.BadRequest(InvalidApiIdDetail); + } + + if (apiHash is not null && !TelegramKeysService.IsValidApiHash(apiHash)) + { + return EndpointResults.BadRequest(InvalidApiHashDetail); + } + + // Частичное обновление: недостающее поле берём из текущих ключей; если ключей ещё нет — нужны оба. + TgKeysSnapshot current = await keys.GetAsync(ct); + string effectiveApiId = apiId ?? current.ApiId; + string effectiveApiHash = apiHash ?? current.ApiHash; + if (effectiveApiId.Length == 0 || effectiveApiHash.Length == 0) + { + return EndpointResults.BadRequest(MissingKeysDetail); + } + + await keys.SaveAsync(effectiveApiId, effectiveApiHash, ct); + + await auditService.AppendAsync(new AuditRecordDto( + AuditEvents.TelegramKeysChanged, + AuditActorTypes.Operator, + ActorId: operatorIdentity.OperatorId, + TenantId: null, + Ip: ClientIp(context), + DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct); + + TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct); + return Results.Ok(snapshot); + } + + // IP-адрес клиента для аудита (без порта; null, если недоступен). + // context: Контекст запроса. + // Возвращает: Строковое представление IP или null. + private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); +} diff --git a/src/core/Deal.Api/Endpoints/OperatorTenantCreateRequest.cs b/src/core/Deal.Api/Endpoints/OperatorTenantCreateRequest.cs index ecde959..3dda3bf 100644 --- a/src/core/Deal.Api/Endpoints/OperatorTenantCreateRequest.cs +++ b/src/core/Deal.Api/Endpoints/OperatorTenantCreateRequest.cs @@ -1,8 +1,8 @@ -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/operator/tenants -/// -/// Имя тенанта (обязательно; пробелы по краям обрезаются). -/// Email владельца (опционально): создаёт сразу пользователя-владельца с одноразовым паролем. -public sealed record OperatorTenantCreateRequest(string? Name, string? Email); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/operator/tenants +/// +/// Имя тенанта (обязательно; пробелы по краям обрезаются). +/// Email владельца (опционально): создаёт сразу пользователя-владельца с одноразовым паролем. +public sealed record OperatorTenantCreateRequest(string? Name, string? Email); diff --git a/src/core/Deal.Api/Endpoints/OperatorTenantImpersonateRequest.cs b/src/core/Deal.Api/Endpoints/OperatorTenantImpersonateRequest.cs index f18217f..b6fc84c 100644 --- a/src/core/Deal.Api/Endpoints/OperatorTenantImpersonateRequest.cs +++ b/src/core/Deal.Api/Endpoints/OperatorTenantImpersonateRequest.cs @@ -1,7 +1,7 @@ -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/operator/tenants/{id}/impersonate -/// -/// Логин пользователя, под которым оператор входит (impersonation); null/пустой — берётся первый пользователь тенанта (по времени создания). -public sealed record OperatorTenantImpersonateRequest(string? Login); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/operator/tenants/{id}/impersonate +/// +/// Логин пользователя, под которым оператор входит (impersonation); null/пустой — берётся первый пользователь тенанта (по времени создания). +public sealed record OperatorTenantImpersonateRequest(string? Login); diff --git a/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs index 215cea4..bafc30c 100644 --- a/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs @@ -1,270 +1,270 @@ -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.Extensions.Options; -using CookieOptions = Deal.Api.Configuration.CookieOptions; - -namespace Deal.Api.Endpoints; - -/// -/// Операторские эндпоинты тенантов -/// -public static class OperatorTenantsEndpoints -{ - // Текст 400: имя тенанта пустое/пробельное (create). - private const string TenantNameRequiredDetail = "Имя тенанта обязательно"; - - // Текст 400: email пустой/некорректного формата (create с владельцем). - private const string InvalidEmailDetail = "Некорректный email"; - - // Текст 400: пользователь с таким email уже зарегистрирован (users.login unique, create с владельцем). - private const string EmailTakenDetail = "Этот email уже зарегистрирован"; - - // Текст 404: тенант с таким id не найден. - private const string TenantNotFoundDetail = "Тенант не найден"; - - // Текст 404: пользователь с таким login не найден в тенанте. - private const string UserNotFoundInTenantDetail = "Пользователь не найден в тенанте"; - - // Текст 400: в тенанте нет пользователей, а login не указан (impersonation без выбора). - private const string TenantHasNoUsersDetail = "В тенанте нет пользователей для входа"; - - private const string TenantsGroupPrefix = "/api/operator/tenants"; - - // Относительный путь деталей тенанта. - private const string TenantByIdPath = "/{id:guid}"; - - // Относительный путь приостановки тенанта. - private const string SuspendPath = "/{id:guid}/suspend"; - - // Относительный путь возобновления тенанта. - private const string UnsuspendPath = "/{id:guid}/unsuspend"; - - // Относительный путь impersonation пользователя тенанта. - private const string ImpersonatePath = "/{id:guid}/impersonate"; - - // OpenAPI-тег группы. - private const string TenantsOpenApiTag = "operator-tenants"; - - /// - /// Регистрирует группу /api/operator/tenants - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapOperatorTenantsEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(TenantsGroupPrefix).WithTags(TenantsOpenApiTag); - - group.MapGet("", ListAsync); - group.MapPost("", CreateAsync); - group.MapGet(TenantByIdPath, GetByIdAsync); - group.MapPost(SuspendPath, SuspendAsync); - group.MapPost(UnsuspendPath, UnsuspendAsync); - group.MapPost(ImpersonatePath, ImpersonateAsync); - - return app; - } - - private static async Task ListAsync( - HttpContext context, - TenantAdminService tenantAdminService, - CancellationToken ct) - { - var operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - IReadOnlyList items = await tenantAdminService.ListAsync(ct); - return Results.Ok(new { items }); - } - - private static async Task CreateAsync( - OperatorTenantCreateRequest body, - HttpContext context, - TenantAdminService tenantAdminService, - AuditService auditService, - CancellationToken ct) - { - var operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - TenantCreateResultDto result = await tenantAdminService.CreateAsync(body.Name, body.Email, ct); - if (!result.Ok || result.Tenant is null) - { - return result.Error switch - { - TenantCreateResultDto.ErrorInvalidEmail => EndpointResults.BadRequest(InvalidEmailDetail), - TenantCreateResultDto.ErrorEmailTaken => EndpointResults.BadRequest(EmailTakenDetail), - _ => EndpointResults.BadRequest(TenantNameRequiredDetail), - }; - } - - // Одноразовый пароль владельца в аудит/логи не пишется (правило секретов); raw — только в ответе ниже. - TenantRecordDto createdTenant = result.Tenant; - await auditService.AppendAsync(new AuditRecordDto( - AuditEvents.TenantCreated, - AuditActorTypes.Operator, - ActorId: operatorIdentity.OperatorId, - TenantId: createdTenant.Id, - Ip: ClientIp(context), - DetailJson: AuditService.ToDetailJson(new { tenantId = createdTenant.Id, name = createdTenant.Name, email = result.OwnerLogin })), ct); - - if (result.OwnerLogin is not null) - { - return Results.Ok(new - { - createdTenant.Id, - createdTenant.Name, - createdTenant.Status, - createdTenant.CreatedAt, - ownerEmail = result.OwnerLogin, - initialPassword = result.InitialPassword, - }); - } - - return Results.Ok(new { createdTenant.Id, createdTenant.Name, createdTenant.Status, createdTenant.CreatedAt }); - } - - // GET /api/operator/tenants/{id}: детали тенанта и его пользователи. - private static async Task GetByIdAsync( - Guid id, - HttpContext context, - TenantAdminService tenantAdminService, - CancellationToken ct) - { - var operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - TenantDetailDto? tenant = await tenantAdminService.GetAsync(id, ct); - if (tenant is null) - { - return EndpointResults.NotFound(TenantNotFoundDetail); - } - - return Results.Ok(tenant); - } - - // POST /api/operator/tenants/{id}/suspend: приостановка тенанта; аудит tenant_status_changed. - private static Task SuspendAsync( - Guid id, - HttpContext context, - TenantAdminService tenantAdminService, - AuditService auditService, - CancellationToken ct) => - ApplyStatusAsync(id, TenantStatuses.Suspended, context, tenantAdminService, auditService, ct); - - // POST /api/operator/tenants/{id}/unsuspend: возобновление тенанта; аудит tenant_status_changed. - private static Task UnsuspendAsync( - Guid id, - HttpContext context, - TenantAdminService tenantAdminService, - AuditService auditService, - CancellationToken ct) => - ApplyStatusAsync(id, TenantStatuses.Active, context, tenantAdminService, auditService, ct); - - // Общая логика suspend/unsuspend: проверка оператора, смена статуса, аудит при реальном изменении. - // id: Идентификатор тенанта. - // status: Новый статус — константа TenantStatuses. - // context: Контекст запроса (операторская сессия, IP). - // tenantAdminService: Сервис реестра тенантов. - // auditService: Сервис аудита (запись tenant_status_changed при изменении). - // ct: Токен отмены. - // Возвращает: 200 {ok,status} или 401/404 {detail}. - private static async Task ApplyStatusAsync( - Guid id, - string status, - HttpContext context, - TenantAdminService tenantAdminService, - AuditService auditService, - CancellationToken ct) - { - var operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - TenantStatusChangeResultDto result = await tenantAdminService.ChangeStatusAsync(id, status, ct); - if (!result.Ok || result.Tenant is null) - { - return EndpointResults.NotFound(TenantNotFoundDetail); - } - - if (result.Changed) - { - await auditService.AppendAsync(new AuditRecordDto( - AuditEvents.TenantStatusChanged, - AuditActorTypes.Operator, - ActorId: operatorIdentity.OperatorId, - TenantId: result.Tenant.Id, - Ip: ClientIp(context), - DetailJson: AuditService.ToDetailJson(new { tenantId = result.Tenant.Id, status = result.Tenant.Status })), ct); - } - - return Results.Ok(new { ok = true, status = result.Tenant.Status }); - } - - // POST /api/operator/tenants/{id}/impersonate: tenant-сессия пользователя тенанта; аудит impersonation_started. - private static async Task ImpersonateAsync( - Guid id, - OperatorTenantImpersonateRequest? body, - HttpContext context, - AuthService authService, - AuditService auditService, - IOptions cookieOptions, - CancellationToken ct) - { - var operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - ImpersonationResultDto result = await authService.ImpersonateAsync( - id, body?.Login, operatorIdentity.OperatorId, ct); - if (!result.Ok || result.SessionToken is null || result.Login is null || result.TenantId is null) - { - return result.Error switch - { - ImpersonationResultDto.ErrorTenantNotFound => EndpointResults.NotFound(TenantNotFoundDetail), - ImpersonationResultDto.ErrorUserNotFound => EndpointResults.NotFound(UserNotFoundInTenantDetail), - _ => EndpointResults.BadRequest(TenantHasNoUsersDetail), - }; - } - - await auditService.AppendAsync(new AuditRecordDto( - AuditEvents.ImpersonationStarted, - AuditActorTypes.Operator, - ActorId: operatorIdentity.OperatorId, - TenantId: result.TenantId, - Ip: ClientIp(context), - DetailJson: AuditService.ToDetailJson(new { targetLogin = result.Login, tenantId = result.TenantId })), ct); - - // Токен — это tenant-сессия (как после /api/auth/login): СТАВИМ ту же httpOnly-куку deal_session - // на ответ, чтобы браузер оператора сразу получил tenant-сессию (JS не может записать httpOnly-куку). - // Завершение — POST /api/auth/logout (пишет impersonation_stopped). - SessionCookieWriter.Append(context, cookieOptions.Value, result.SessionToken); - - return Results.Ok(new - { - sessionToken = result.SessionToken, - expiresAt = result.ExpiresAt, - tenantId = result.TenantId, - login = result.Login, - }); - } - - // IP-адрес клиента для аудита (без порта; null, если недоступен). - // context: Контекст запроса. - // Возвращает: Строковое представление IP или null. - private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); -} +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; +using Microsoft.Extensions.Options; +using CookieOptions = Deal.Api.Configuration.CookieOptions; + +namespace Deal.Api.Endpoints; + +/// +/// Операторские эндпоинты тенантов +/// +public static class OperatorTenantsEndpoints +{ + // Текст 400: имя тенанта пустое/пробельное (create). + private const string TenantNameRequiredDetail = "Имя тенанта обязательно"; + + // Текст 400: email пустой/некорректного формата (create с владельцем). + private const string InvalidEmailDetail = "Некорректный email"; + + // Текст 400: пользователь с таким email уже зарегистрирован (users.login unique, create с владельцем). + private const string EmailTakenDetail = "Этот email уже зарегистрирован"; + + // Текст 404: тенант с таким id не найден. + private const string TenantNotFoundDetail = "Тенант не найден"; + + // Текст 404: пользователь с таким login не найден в тенанте. + private const string UserNotFoundInTenantDetail = "Пользователь не найден в тенанте"; + + // Текст 400: в тенанте нет пользователей, а login не указан (impersonation без выбора). + private const string TenantHasNoUsersDetail = "В тенанте нет пользователей для входа"; + + private const string TenantsGroupPrefix = "/api/operator/tenants"; + + // Относительный путь деталей тенанта. + private const string TenantByIdPath = "/{id:guid}"; + + // Относительный путь приостановки тенанта. + private const string SuspendPath = "/{id:guid}/suspend"; + + // Относительный путь возобновления тенанта. + private const string UnsuspendPath = "/{id:guid}/unsuspend"; + + // Относительный путь impersonation пользователя тенанта. + private const string ImpersonatePath = "/{id:guid}/impersonate"; + + // OpenAPI-тег группы. + private const string TenantsOpenApiTag = "operator-tenants"; + + /// + /// Регистрирует группу /api/operator/tenants + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapOperatorTenantsEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup(TenantsGroupPrefix).WithTags(TenantsOpenApiTag); + + group.MapGet("", ListAsync); + group.MapPost("", CreateAsync); + group.MapGet(TenantByIdPath, GetByIdAsync); + group.MapPost(SuspendPath, SuspendAsync); + group.MapPost(UnsuspendPath, UnsuspendAsync); + group.MapPost(ImpersonatePath, ImpersonateAsync); + + return app; + } + + private static async Task ListAsync( + HttpContext context, + TenantAdminService tenantAdminService, + CancellationToken ct) + { + var operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + IReadOnlyList items = await tenantAdminService.ListAsync(ct); + return Results.Ok(new { items }); + } + + private static async Task CreateAsync( + OperatorTenantCreateRequest body, + HttpContext context, + TenantAdminService tenantAdminService, + AuditService auditService, + CancellationToken ct) + { + var operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + TenantCreateResultDto result = await tenantAdminService.CreateAsync(body.Name, body.Email, ct); + if (!result.Ok || result.Tenant is null) + { + return result.Error switch + { + TenantCreateResultDto.ErrorInvalidEmail => EndpointResults.BadRequest(InvalidEmailDetail), + TenantCreateResultDto.ErrorEmailTaken => EndpointResults.BadRequest(EmailTakenDetail), + _ => EndpointResults.BadRequest(TenantNameRequiredDetail), + }; + } + + // Одноразовый пароль владельца в аудит/логи не пишется (правило секретов); raw — только в ответе ниже. + TenantRecordDto createdTenant = result.Tenant; + await auditService.AppendAsync(new AuditRecordDto( + AuditEvents.TenantCreated, + AuditActorTypes.Operator, + ActorId: operatorIdentity.OperatorId, + TenantId: createdTenant.Id, + Ip: ClientIp(context), + DetailJson: AuditService.ToDetailJson(new { tenantId = createdTenant.Id, name = createdTenant.Name, email = result.OwnerLogin })), ct); + + if (result.OwnerLogin is not null) + { + return Results.Ok(new + { + createdTenant.Id, + createdTenant.Name, + createdTenant.Status, + createdTenant.CreatedAt, + ownerEmail = result.OwnerLogin, + initialPassword = result.InitialPassword, + }); + } + + return Results.Ok(new { createdTenant.Id, createdTenant.Name, createdTenant.Status, createdTenant.CreatedAt }); + } + + // GET /api/operator/tenants/{id}: детали тенанта и его пользователи. + private static async Task GetByIdAsync( + Guid id, + HttpContext context, + TenantAdminService tenantAdminService, + CancellationToken ct) + { + var operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + TenantDetailDto? tenant = await tenantAdminService.GetAsync(id, ct); + if (tenant is null) + { + return EndpointResults.NotFound(TenantNotFoundDetail); + } + + return Results.Ok(tenant); + } + + // POST /api/operator/tenants/{id}/suspend: приостановка тенанта; аудит tenant_status_changed. + private static Task SuspendAsync( + Guid id, + HttpContext context, + TenantAdminService tenantAdminService, + AuditService auditService, + CancellationToken ct) => + ApplyStatusAsync(id, TenantStatuses.Suspended, context, tenantAdminService, auditService, ct); + + // POST /api/operator/tenants/{id}/unsuspend: возобновление тенанта; аудит tenant_status_changed. + private static Task UnsuspendAsync( + Guid id, + HttpContext context, + TenantAdminService tenantAdminService, + AuditService auditService, + CancellationToken ct) => + ApplyStatusAsync(id, TenantStatuses.Active, context, tenantAdminService, auditService, ct); + + // Общая логика suspend/unsuspend: проверка оператора, смена статуса, аудит при реальном изменении. + // id: Идентификатор тенанта. + // status: Новый статус — константа TenantStatuses. + // context: Контекст запроса (операторская сессия, IP). + // tenantAdminService: Сервис реестра тенантов. + // auditService: Сервис аудита (запись tenant_status_changed при изменении). + // ct: Токен отмены. + // Возвращает: 200 {ok,status} или 401/404 {detail}. + private static async Task ApplyStatusAsync( + Guid id, + string status, + HttpContext context, + TenantAdminService tenantAdminService, + AuditService auditService, + CancellationToken ct) + { + var operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + TenantStatusChangeResultDto result = await tenantAdminService.ChangeStatusAsync(id, status, ct); + if (!result.Ok || result.Tenant is null) + { + return EndpointResults.NotFound(TenantNotFoundDetail); + } + + if (result.Changed) + { + await auditService.AppendAsync(new AuditRecordDto( + AuditEvents.TenantStatusChanged, + AuditActorTypes.Operator, + ActorId: operatorIdentity.OperatorId, + TenantId: result.Tenant.Id, + Ip: ClientIp(context), + DetailJson: AuditService.ToDetailJson(new { tenantId = result.Tenant.Id, status = result.Tenant.Status })), ct); + } + + return Results.Ok(new { ok = true, status = result.Tenant.Status }); + } + + // POST /api/operator/tenants/{id}/impersonate: tenant-сессия пользователя тенанта; аудит impersonation_started. + private static async Task ImpersonateAsync( + Guid id, + OperatorTenantImpersonateRequest? body, + HttpContext context, + AuthService authService, + AuditService auditService, + IOptions cookieOptions, + CancellationToken ct) + { + var operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + ImpersonationResultDto result = await authService.ImpersonateAsync( + id, body?.Login, operatorIdentity.OperatorId, ct); + if (!result.Ok || result.SessionToken is null || result.Login is null || result.TenantId is null) + { + return result.Error switch + { + ImpersonationResultDto.ErrorTenantNotFound => EndpointResults.NotFound(TenantNotFoundDetail), + ImpersonationResultDto.ErrorUserNotFound => EndpointResults.NotFound(UserNotFoundInTenantDetail), + _ => EndpointResults.BadRequest(TenantHasNoUsersDetail), + }; + } + + await auditService.AppendAsync(new AuditRecordDto( + AuditEvents.ImpersonationStarted, + AuditActorTypes.Operator, + ActorId: operatorIdentity.OperatorId, + TenantId: result.TenantId, + Ip: ClientIp(context), + DetailJson: AuditService.ToDetailJson(new { targetLogin = result.Login, tenantId = result.TenantId })), ct); + + // Токен — это tenant-сессия (как после /api/auth/login): СТАВИМ ту же httpOnly-куку deal_session + // на ответ, чтобы браузер оператора сразу получил tenant-сессию (JS не может записать httpOnly-куку). + // Завершение — POST /api/auth/logout (пишет impersonation_stopped). + SessionCookieWriter.Append(context, cookieOptions.Value, result.SessionToken); + + return Results.Ok(new + { + sessionToken = result.SessionToken, + expiresAt = result.ExpiresAt, + tenantId = result.TenantId, + login = result.Login, + }); + } + + // IP-адрес клиента для аудита (без порта; null, если недоступен). + // context: Контекст запроса. + // Возвращает: Строковое представление IP или null. + private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); +} diff --git a/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs b/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs index 916eeec..f7ea7cb 100644 --- a/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs @@ -1,153 +1,153 @@ -using Deal.Api.Endpoints.RequestModels; -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Services; - -namespace Deal.Api.Endpoints; - -/// -/// Эндпоинты вкладки «Обработка» -/// -public static class PipelineEndpoints -{ - private const string PipelineGroupPrefix = "/api/pipeline"; - - private const string OpenApiTag = "processing"; - - // Путь сводки вкладки «Обработка» (GET). - private const string StatsPath = "/stats"; - - // Путь сырых сообщений очереди (GET). - private const string QueuePath = "/queue"; - - // Путь страницы отсева (GET). - private const string RejectedPath = "/rejected"; - - // Путь полной очистки отсева (POST). - private const string RejectedClearPath = "/rejected/clear"; - - // Путь удаления одной записи отсева (DELETE). - private const string RejectedIdPath = "/rejected/{rejId}"; - - // Путь возврата записи отсева в обработку (POST). - private const string RejectedReturnPath = "/rejected/{rejId}/return"; - - private const string RejectedNotFoundDetail = "Запись не найдена"; - - private const int DefaultPageSize = 100; - - /// - /// Регистрирует группу /api/pipeline - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapPipelineEndpoints(this IEndpointRouteBuilder app) - { - var pipeline = app.MapGroup(PipelineGroupPrefix).WithTags(OpenApiTag); - - pipeline.MapGet(StatsPath, StatsAsync); - pipeline.MapGet(QueuePath, QueueAsync); - pipeline.MapGet(RejectedPath, RejectedAsync); - pipeline.MapPost(RejectedClearPath, ClearAsync); - pipeline.MapDelete(RejectedIdPath, DeleteAsync); - pipeline.MapPost(RejectedReturnPath, ReturnAsync); - - return app; - } - - private static async Task StatsAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - PipelineProcessingService processing = context.RequestServices.GetRequiredService(); - return Results.Ok(await processing.StatsAsync(ct)); - } - - private static async Task QueueAsync( - int? limit, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - PipelineProcessingService processing = context.RequestServices.GetRequiredService(); - IReadOnlyList items = await processing.ListQueueAsync(limit ?? DefaultPageSize, ct); - QueueCountsDto counts = await processing.QueueCountsAsync(ct); - int rejected = await processing.RejectedCountAsync(ct); - return Results.Ok(new { items, counts, rejected }); - } - - private static async Task RejectedAsync( - string? q, - int? offset, - int? limit, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - PipelineProcessingService processing = context.RequestServices.GetRequiredService(); - RejectedPageDto page = await processing.ListRejectedAsync(q ?? string.Empty, offset ?? 0, limit ?? DefaultPageSize, ct); - return Results.Ok(page); - } - - private static async Task ClearAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - PipelineProcessingService processing = context.RequestServices.GetRequiredService(); - int cleared = await processing.ClearAsync(ct); - return Results.Ok(new { ok = true, cleared }); - } - - private static async Task DeleteAsync( - string rejId, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - PipelineProcessingService processing = context.RequestServices.GetRequiredService(); - await processing.DeleteAsync(rejId, ct); - return Results.Ok(new { ok = true }); - } - - private static async Task ReturnAsync( - string rejId, - ReturnReasonRequest body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - PipelineProcessingService processing = context.RequestServices.GetRequiredService(); - RejectReturnResultDto? result = await processing.ReturnAsync(rejId, body.Reason ?? string.Empty, ct); - if (result is null) - { - return EndpointResults.NotFound(RejectedNotFoundDetail); - } - - return result.Error is not null - ? EndpointResults.BadRequest(result.Error) - : Results.Ok(new { id = result.Id, returned = result.Returned, returnedAt = result.ReturnedAtMs }); - } -} +using Deal.Api.Endpoints.RequestModels; +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; + +namespace Deal.Api.Endpoints; + +/// +/// Эндпоинты вкладки «Обработка» +/// +public static class PipelineEndpoints +{ + private const string PipelineGroupPrefix = "/api/pipeline"; + + private const string OpenApiTag = "processing"; + + // Путь сводки вкладки «Обработка» (GET). + private const string StatsPath = "/stats"; + + // Путь сырых сообщений очереди (GET). + private const string QueuePath = "/queue"; + + // Путь страницы отсева (GET). + private const string RejectedPath = "/rejected"; + + // Путь полной очистки отсева (POST). + private const string RejectedClearPath = "/rejected/clear"; + + // Путь удаления одной записи отсева (DELETE). + private const string RejectedIdPath = "/rejected/{rejId}"; + + // Путь возврата записи отсева в обработку (POST). + private const string RejectedReturnPath = "/rejected/{rejId}/return"; + + private const string RejectedNotFoundDetail = "Запись не найдена"; + + private const int DefaultPageSize = 100; + + /// + /// Регистрирует группу /api/pipeline + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapPipelineEndpoints(this IEndpointRouteBuilder app) + { + var pipeline = app.MapGroup(PipelineGroupPrefix).WithTags(OpenApiTag); + + pipeline.MapGet(StatsPath, StatsAsync); + pipeline.MapGet(QueuePath, QueueAsync); + pipeline.MapGet(RejectedPath, RejectedAsync); + pipeline.MapPost(RejectedClearPath, ClearAsync); + pipeline.MapDelete(RejectedIdPath, DeleteAsync); + pipeline.MapPost(RejectedReturnPath, ReturnAsync); + + return app; + } + + private static async Task StatsAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + PipelineProcessingService processing = context.RequestServices.GetRequiredService(); + return Results.Ok(await processing.StatsAsync(ct)); + } + + private static async Task QueueAsync( + int? limit, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + PipelineProcessingService processing = context.RequestServices.GetRequiredService(); + IReadOnlyList items = await processing.ListQueueAsync(limit ?? DefaultPageSize, ct); + QueueCountsDto counts = await processing.QueueCountsAsync(ct); + int rejected = await processing.RejectedCountAsync(ct); + return Results.Ok(new { items, counts, rejected }); + } + + private static async Task RejectedAsync( + string? q, + int? offset, + int? limit, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + PipelineProcessingService processing = context.RequestServices.GetRequiredService(); + RejectedPageDto page = await processing.ListRejectedAsync(q ?? string.Empty, offset ?? 0, limit ?? DefaultPageSize, ct); + return Results.Ok(page); + } + + private static async Task ClearAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + PipelineProcessingService processing = context.RequestServices.GetRequiredService(); + int cleared = await processing.ClearAsync(ct); + return Results.Ok(new { ok = true, cleared }); + } + + private static async Task DeleteAsync( + string rejId, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + PipelineProcessingService processing = context.RequestServices.GetRequiredService(); + await processing.DeleteAsync(rejId, ct); + return Results.Ok(new { ok = true }); + } + + private static async Task ReturnAsync( + string rejId, + ReturnReasonRequest body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + PipelineProcessingService processing = context.RequestServices.GetRequiredService(); + RejectReturnResultDto? result = await processing.ReturnAsync(rejId, body.Reason ?? string.Empty, ct); + if (result is null) + { + return EndpointResults.NotFound(RejectedNotFoundDetail); + } + + return result.Error is not null + ? EndpointResults.BadRequest(result.Error) + : Results.Ok(new { id = result.Id, returned = result.Returned, returnedAt = result.ReturnedAtMs }); + } +} diff --git a/src/core/Deal.Api/Endpoints/RatesEndpoints.cs b/src/core/Deal.Api/Endpoints/RatesEndpoints.cs index afd9874..2af935a 100644 --- a/src/core/Deal.Api/Endpoints/RatesEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/RatesEndpoints.cs @@ -1,71 +1,71 @@ -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Services; - -namespace Deal.Api.Endpoints; - -/// -/// HTTP-эндпоинты курсов валют -/// -public static class RatesEndpoints -{ - private const string ApiGroupPrefix = "/api"; - - // Путь текущих курсов (GET). - private const string RatesPath = "/rates"; - - // Путь принудительного обновления (POST). - private const string RatesRefreshPath = "/rates/refresh"; - - private const string OpenApiTag = "settings"; - - /// - /// Регистрирует GET /api/rates и POST /api/rates/refresh. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapRatesEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag); - group.MapGet(RatesPath, GetRatesAsync); - group.MapPost(RatesRefreshPath, RefreshRatesAsync); - return app; - } - - // GET /api/rates: текущий кэш курсов тенанта (+ ленивый фоновый refresh при необходимости). - private static async Task GetRatesAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - // Резолв после 401-гейта: RatesService — scoped на TenantDbContext (tenant-контекст запроса). - RatesService ratesService = context.RequestServices.GetRequiredService(); - - RatesDto current = await ratesService.GetAsync(ct); - - if (await ratesService.ShouldFetchAsync(ct)) - { - context.RequestServices.GetRequiredService().Schedule(); - } - - return Results.Ok(current); - } - - private static async Task RefreshRatesAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - RatesService ratesService = context.RequestServices.GetRequiredService(); - - // Синхронно: refresh по текущей настройке rateSource; при сбое ЦБ ok=false и кэш не тронут. - bool ok = await ratesService.RefreshAsync(ct); - RatesDto current = await ratesService.GetAsync(ct); - return Results.Ok(new { ok, rates = current }); - } -} +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Api.Endpoints; + +/// +/// HTTP-эндпоинты курсов валют +/// +public static class RatesEndpoints +{ + private const string ApiGroupPrefix = "/api"; + + // Путь текущих курсов (GET). + private const string RatesPath = "/rates"; + + // Путь принудительного обновления (POST). + private const string RatesRefreshPath = "/rates/refresh"; + + private const string OpenApiTag = "settings"; + + /// + /// Регистрирует GET /api/rates и POST /api/rates/refresh. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapRatesEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup(ApiGroupPrefix).WithTags(OpenApiTag); + group.MapGet(RatesPath, GetRatesAsync); + group.MapPost(RatesRefreshPath, RefreshRatesAsync); + return app; + } + + // GET /api/rates: текущий кэш курсов тенанта (+ ленивый фоновый refresh при необходимости). + private static async Task GetRatesAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + // Резолв после 401-гейта: RatesService — scoped на TenantDbContext (tenant-контекст запроса). + RatesService ratesService = context.RequestServices.GetRequiredService(); + + RatesDto current = await ratesService.GetAsync(ct); + + if (await ratesService.ShouldFetchAsync(ct)) + { + context.RequestServices.GetRequiredService().Schedule(); + } + + return Results.Ok(current); + } + + private static async Task RefreshRatesAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + RatesService ratesService = context.RequestServices.GetRequiredService(); + + // Синхронно: refresh по текущей настройке rateSource; при сбое ЦБ ok=false и кэш не тронут. + bool ok = await ratesService.RefreshAsync(ct); + RatesDto current = await ratesService.GetAsync(ct); + return Results.Ok(new { ok, rates = current }); + } +} diff --git a/src/core/Deal.Api/Endpoints/RequestModels/CardLinkRequest.cs b/src/core/Deal.Api/Endpoints/RequestModels/CardLinkRequest.cs index 30c31c3..a867ae3 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/CardLinkRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/CardLinkRequest.cs @@ -1,8 +1,8 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/cards/{cardId}/links — добавление ссылки карточке. -/// -/// Название ссылки; пустое → name = url. -/// URL ссылки (без схемы — добавится https://). -public sealed record CardLinkRequest(string? Name = null, string? Url = null); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/cards/{cardId}/links — добавление ссылки карточке. +/// +/// Название ссылки; пустое → name = url. +/// URL ссылки (без схемы — добавится https://). +public sealed record CardLinkRequest(string? Name = null, string? Url = null); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/ClearColBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/ClearColBody.cs index 349ba19..65b86eb 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ClearColBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ClearColBody.cs @@ -1,6 +1,6 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/cards/clear-col — полная очистка служебной колонки. -/// -public sealed record ClearColBody(string? Col); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/cards/clear-col — полная очистка служебной колонки. +/// +public sealed record ClearColBody(string? Col); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/ColStateBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/ColStateBody.cs index 1ac5bcd..b04044d 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ColStateBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ColStateBody.cs @@ -1,6 +1,6 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело PATCH /api/containers/{containerId}/state — смена состояния колонки. -/// -public sealed record ColStateBody(bool? Collapsed, string? Width); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело PATCH /api/containers/{containerId}/state — смена состояния колонки. +/// +public sealed record ColStateBody(bool? Collapsed, string? Width); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/CommentBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/CommentBody.cs index c064769..db469c1 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/CommentBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/CommentBody.cs @@ -1,6 +1,6 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/cards/{cardId}/comments — добавление комментария. -/// -public sealed record CommentBody(string? Text); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/cards/{cardId}/comments — добавление комментария. +/// +public sealed record CommentBody(string? Text); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/ContainerCreateRequest.cs b/src/core/Deal.Api/Endpoints/RequestModels/ContainerCreateRequest.cs index 467131a..2ca67f4 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ContainerCreateRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ContainerCreateRequest.cs @@ -1,24 +1,24 @@ -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/containers — создание контейнера. -/// -/// Имя контейнера (обязательно). -/// Описание (опционально). -/// Цвет (опционально; null — палитра). -/// Пространство (dashboard/selected; по умолчанию dashboard). -/// Вид (board/stage/service/terminal; по умолчанию board). -/// Признак ИИ-предложения. -/// Правила попадания. -/// Заметка/обоснование. -public sealed record ContainerCreateRequest( - string? Name, - string? Description, - string? Color, - string? Space, - string? Kind, - bool? Suggested, - ContainerRulesDto? Rules, - string? Note); +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/containers — создание контейнера. +/// +/// Имя контейнера (обязательно). +/// Описание (опционально). +/// Цвет (опционально; null — палитра). +/// Пространство (dashboard/selected; по умолчанию dashboard). +/// Вид (board/stage/service/terminal; по умолчанию board). +/// Признак ИИ-предложения. +/// Правила попадания. +/// Заметка/обоснование. +public sealed record ContainerCreateRequest( + string? Name, + string? Description, + string? Color, + string? Space, + string? Kind, + bool? Suggested, + ContainerRulesDto? Rules, + string? Note); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/ContainerPatchRequest.cs b/src/core/Deal.Api/Endpoints/RequestModels/ContainerPatchRequest.cs index 61726dd..1469142 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ContainerPatchRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ContainerPatchRequest.cs @@ -1,24 +1,24 @@ -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело PATCH /api/containers/{id} — частичное обновление контейнера. -/// -/// Новое имя (null — не менять). -/// Новое описание (null — не менять). -/// Новый цвет (null — не менять). -/// Новая свёрнутость (null — не менять). -/// Признак ИИ-предложения (null — не менять). -/// Новая заметка (null — не менять). -/// Новые правила (null — не менять). -/// Новая политика (null — не менять). -public sealed record ContainerPatchRequest( - string? Name, - string? Description, - string? Color, - bool? Collapsed, - bool? Suggested, - string? Note, - ContainerRulesDto? Rules, - ContainerPolicyDto? Policy); +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело PATCH /api/containers/{id} — частичное обновление контейнера. +/// +/// Новое имя (null — не менять). +/// Новое описание (null — не менять). +/// Новый цвет (null — не менять). +/// Новая свёрнутость (null — не менять). +/// Признак ИИ-предложения (null — не менять). +/// Новая заметка (null — не менять). +/// Новые правила (null — не менять). +/// Новая политика (null — не менять). +public sealed record ContainerPatchRequest( + string? Name, + string? Description, + string? Color, + bool? Collapsed, + bool? Suggested, + string? Note, + ContainerRulesDto? Rules, + ContainerPolicyDto? Policy); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/CreateCardRequest.cs b/src/core/Deal.Api/Endpoints/RequestModels/CreateCardRequest.cs index d1836de..2c59b62 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/CreateCardRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/CreateCardRequest.cs @@ -1,24 +1,24 @@ -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/cards — ручное -/// -/// Заголовок карточки (Trim в сервисе; пустой допустим). -/// Краткое содержание карточки. -/// Стек/направления (null — пустой стек). -/// Бюджет (from/to/cur); null — бюджета нет. -/// Контактная строка карточки. -/// Текст технического задания. -/// Желаемый контейнер; null/неизвестный → planned. -/// Алиас containerId (совместимость со старым фронтом). -public sealed record CreateCardRequest( - string Title = "", - string Summary = "", - IReadOnlyList? Stack = null, - CardBudgetDto? Budget = null, - string Contact = "", - string TzText = "", - string? ContainerId = null, - string? Stage = null); +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/cards — ручное +/// +/// Заголовок карточки (Trim в сервисе; пустой допустим). +/// Краткое содержание карточки. +/// Стек/направления (null — пустой стек). +/// Бюджет (from/to/cur); null — бюджета нет. +/// Контактная строка карточки. +/// Текст технического задания. +/// Желаемый контейнер; null/неизвестный → planned. +/// Алиас containerId (совместимость со старым фронтом). +public sealed record CreateCardRequest( + string Title = "", + string Summary = "", + IReadOnlyList? Stack = null, + CardBudgetDto? Budget = null, + string Contact = "", + string TzText = "", + string? ContainerId = null, + string? Stage = null); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskCreateBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskCreateBody.cs index 9bf8f96..b24cee1 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskCreateBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskCreateBody.cs @@ -1,49 +1,49 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/discovery/tasks. -/// -public sealed record DiscoveryTaskCreateBody -{ - /// - /// Название задачи - /// - public string Name { get; init; } = string.Empty; - - /// - /// Описание ниши/цели - /// - public string? Description { get; init; } - - public IReadOnlyList? Keywords { get; init; } - - /// - /// Минимальное число участников источника; null → 0. - /// - public int? MinSubscribers { get; init; } - - /// - /// Язык источников - /// - public string? Lang { get; init; } - - /// - /// Порог подходящих сообщений оценки, % - /// - public int? Threshold { get; init; } - - /// - /// Размер выборки сообщений оценки - /// - public int? SampleSize { get; init; } - - /// - /// План авто-вступлений - /// - public int? PlanJoins { get; init; } - - /// - /// Авто-вступления воркером; null → false. - /// - public bool? AutoJoin { get; init; } -} +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/discovery/tasks. +/// +public sealed record DiscoveryTaskCreateBody +{ + /// + /// Название задачи + /// + public string Name { get; init; } = string.Empty; + + /// + /// Описание ниши/цели + /// + public string? Description { get; init; } + + public IReadOnlyList? Keywords { get; init; } + + /// + /// Минимальное число участников источника; null → 0. + /// + public int? MinSubscribers { get; init; } + + /// + /// Язык источников + /// + public string? Lang { get; init; } + + /// + /// Порог подходящих сообщений оценки, % + /// + public int? Threshold { get; init; } + + /// + /// Размер выборки сообщений оценки + /// + public int? SampleSize { get; init; } + + /// + /// План авто-вступлений + /// + public int? PlanJoins { get; init; } + + /// + /// Авто-вступления воркером; null → false. + /// + public bool? AutoJoin { get; init; } +} diff --git a/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskPatchBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskPatchBody.cs index 6462d1c..82b4a5a 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskPatchBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskPatchBody.cs @@ -1,52 +1,52 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело PATCH /api/discovery/tasks/{task_id}. -/// -public sealed record DiscoveryTaskPatchBody -{ - /// - /// Новое название. - /// - public string? Name { get; init; } - - /// - /// Новое описание - /// - public string? Description { get; init; } - - /// - /// Новые ключевые слова - /// - public IReadOnlyList? Keywords { get; init; } - - /// - /// Новый минимум участников - /// - public int? MinSubscribers { get; init; } - - /// - /// Новый язык: «ru»|«any» - /// - public string? Lang { get; init; } - - /// - /// Новый порог оценки, % - /// - public int? Threshold { get; init; } - - /// - /// Новый размер выборки - /// - public int? SampleSize { get; init; } - - /// - /// Новый план авто-вступлений - /// - public int? PlanJoins { get; init; } - - /// - /// Новый флаг авто-вступлений - /// - public bool? AutoJoin { get; init; } -} +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело PATCH /api/discovery/tasks/{task_id}. +/// +public sealed record DiscoveryTaskPatchBody +{ + /// + /// Новое название. + /// + public string? Name { get; init; } + + /// + /// Новое описание + /// + public string? Description { get; init; } + + /// + /// Новые ключевые слова + /// + public IReadOnlyList? Keywords { get; init; } + + /// + /// Новый минимум участников + /// + public int? MinSubscribers { get; init; } + + /// + /// Новый язык: «ru»|«any» + /// + public string? Lang { get; init; } + + /// + /// Новый порог оценки, % + /// + public int? Threshold { get; init; } + + /// + /// Новый размер выборки + /// + public int? SampleSize { get; init; } + + /// + /// Новый план авто-вступлений + /// + public int? PlanJoins { get; init; } + + /// + /// Новый флаг авто-вступлений + /// + public bool? AutoJoin { get; init; } +} diff --git a/src/core/Deal.Api/Endpoints/RequestModels/MarkColBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/MarkColBody.cs index 0576fae..54a5dff 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/MarkColBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/MarkColBody.cs @@ -1,6 +1,6 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/cards/mark-col-seen — снять «новое» с колонки. -/// -public sealed record MarkColBody(string? Col); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/cards/mark-col-seen — снять «новое» с колонки. +/// +public sealed record MarkColBody(string? Col); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/MoveBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/MoveBody.cs index dd2494b..fc23ae4 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/MoveBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/MoveBody.cs @@ -1,6 +1,6 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/cards/{lead_id}/move — перенос карточки. -/// -public sealed record MoveBody(string? To); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/cards/{lead_id}/move — перенос карточки. +/// +public sealed record MoveBody(string? To); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/OperatorTelegramKeysRequest.cs b/src/core/Deal.Api/Endpoints/RequestModels/OperatorTelegramKeysRequest.cs index bcf109e..0b93806 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/OperatorTelegramKeysRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/OperatorTelegramKeysRequest.cs @@ -1,8 +1,8 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело PUT /api/operator/settings/telegram-keys -/// -/// api_id приложения Telegram (5..9 цифр); null — не менялось. -/// api_hash приложения Telegram (непустой секрет; хранится зашифрованным); null — не менялось. -public sealed record OperatorTelegramKeysRequest(string? ApiId, string? ApiHash); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело PUT /api/operator/settings/telegram-keys +/// +/// api_id приложения Telegram (5..9 цифр); null — не менялось. +/// api_hash приложения Telegram (непустой секрет; хранится зашифрованным); null — не менялось. +public sealed record OperatorTelegramKeysRequest(string? ApiId, string? ApiHash); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/OrderBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/OrderBody.cs index 79e4cf7..5072c9d 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/OrderBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/OrderBody.cs @@ -1,8 +1,8 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/containers/reorder — новый порядок контейнеров пространства. -/// -/// Пространство (dashboard/selected); null — dashboard. -/// Id контейнеров в новом порядке. -public sealed record OrderBody(string? Space, IReadOnlyList? Order); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/containers/reorder — новый порядок контейнеров пространства. +/// +/// Пространство (dashboard/selected); null — dashboard. +/// Id контейнеров в новом порядке. +public sealed record OrderBody(string? Space, IReadOnlyList? Order); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/ReclassifyBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/ReclassifyBody.cs index 13d7c1a..4b062ea 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ReclassifyBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ReclassifyBody.cs @@ -1,6 +1,6 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/cards/reclassify — ИИ-переклассификация «Неразобранного». -/// -public sealed record ReclassifyBody(IReadOnlyList? Ids); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/cards/reclassify — ИИ-переклассификация «Неразобранного». +/// +public sealed record ReclassifyBody(IReadOnlyList? Ids); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/ReminderSetRequest.cs b/src/core/Deal.Api/Endpoints/RequestModels/ReminderSetRequest.cs index 3bf12ae..e4f2ffa 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ReminderSetRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ReminderSetRequest.cs @@ -1,7 +1,7 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/cards/{cardId}/reminder — установка напоминания hold-карточке. -/// -/// Время напоминания, epoch-ms. -public sealed record ReminderSetRequest(long? At); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/cards/{cardId}/reminder — установка напоминания hold-карточке. +/// +/// Время напоминания, epoch-ms. +public sealed record ReminderSetRequest(long? At); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/ReturnReasonRequest.cs b/src/core/Deal.Api/Endpoints/RequestModels/ReturnReasonRequest.cs index 1c5a79b..dc92504 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ReturnReasonRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ReturnReasonRequest.cs @@ -1,6 +1,6 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/pipeline/rejected/{rejId}/return — причина возврата в обработку. -/// -public sealed record ReturnReasonRequest(string? Reason); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/pipeline/rejected/{rejId}/return — причина возврата в обработку. +/// +public sealed record ReturnReasonRequest(string? Reason); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/TakeCardRequest.cs b/src/core/Deal.Api/Endpoints/RequestModels/TakeCardRequest.cs index 2db06a5..ffa5879 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/TakeCardRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/TakeCardRequest.cs @@ -1,8 +1,8 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/cards/take — «взять в работу» карточки с дашборда. -/// -/// Id карточки, берущейся в работу. -/// Алиас cardId. -public sealed record TakeCardRequest(string? CardId = null, string? LeadId = null); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/cards/take — «взять в работу» карточки с дашборда. +/// +/// Id карточки, берущейся в работу. +/// Алиас cardId. +public sealed record TakeCardRequest(string? CardId = null, string? LeadId = null); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/TgMonitorBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/TgMonitorBody.cs index 058a067..1d2352a 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/TgMonitorBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/TgMonitorBody.cs @@ -1,7 +1,7 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/tg/dialogs/{dialog_id}/monitor и /monitor-all. -/// -/// True — мониторить (сообщения → PushMessage в ядро), false — выключить. -public sealed record TgMonitorBody(bool Enabled); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/tg/dialogs/{dialog_id}/monitor и /monitor-all. +/// +/// True — мониторить (сообщения → PushMessage в ядро), false — выключить. +public sealed record TgMonitorBody(bool Enabled); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/TgPreviewBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/TgPreviewBody.cs index b590594..cb74f03 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/TgPreviewBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/TgPreviewBody.cs @@ -1,8 +1,8 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/tg/dialogs/preview — последние сообщения диалога. -/// -/// Id диалога (подписанный). -/// Сколько последних сообщений; дефолт 24, кламп 1..50. -public sealed record TgPreviewBody(string DialogId, int? Limit); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/tg/dialogs/preview — последние сообщения диалога. +/// +/// Id диалога (подписанный). +/// Сколько последних сообщений; дефолт 24, кламп 1..50. +public sealed record TgPreviewBody(string DialogId, int? Limit); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/TgSendCodeRequest.cs b/src/core/Deal.Api/Endpoints/RequestModels/TgSendCodeRequest.cs index 08054fa..c96669d 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/TgSendCodeRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/TgSendCodeRequest.cs @@ -1,7 +1,7 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/tg/send-code — SMS-код входа. -/// -/// Код из SMS/Telegram-сообщения. -public sealed record TgSendCodeRequest(string Code); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/tg/send-code — SMS-код входа. +/// +/// Код из SMS/Telegram-сообщения. +public sealed record TgSendCodeRequest(string Code); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/TgSendPasswordRequest.cs b/src/core/Deal.Api/Endpoints/RequestModels/TgSendPasswordRequest.cs index 6e9279a..70cb202 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/TgSendPasswordRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/TgSendPasswordRequest.cs @@ -1,7 +1,7 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/tg/send-password — облачный пароль 2FA. -/// -/// Пароль облачной защиты. -public sealed record TgSendPasswordRequest(string Password); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/tg/send-password — облачный пароль 2FA. +/// +/// Пароль облачной защиты. +public sealed record TgSendPasswordRequest(string Password); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/TgStartPhoneRequest.cs b/src/core/Deal.Api/Endpoints/RequestModels/TgStartPhoneRequest.cs index efe83e5..daefbc3 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/TgStartPhoneRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/TgStartPhoneRequest.cs @@ -1,7 +1,7 @@ -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/tg/start-phone — вход по номеру телефона. -/// -/// Номер в международном формате. -public sealed record TgStartPhoneRequest(string Phone); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/tg/start-phone — вход по номеру телефона. +/// +/// Номер в международном формате. +public sealed record TgStartPhoneRequest(string Phone); diff --git a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs index 1545384..8e2715e 100644 --- a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs @@ -1,101 +1,101 @@ -using System.Text.Json; -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Api.Endpoints; - -/// -/// HTTP-эндпоинты настроек тенанта -/// -public static class SettingsEndpoints -{ - private const string ApiGroupPrefix = "/api"; - private const string SettingsPath = "/settings"; - private const string SettingsOpenApiTag = "settings"; - private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом"; - - /// - /// Регистрирует GET/PATCH /api/settings. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(ApiGroupPrefix).WithTags(SettingsOpenApiTag); - - group.MapGet(SettingsPath, GetSettingsAsync); - group.MapPatch(SettingsPath, PatchSettingsAsync); - - return app; - } - - // GET /api/settings: публичный снимок настроек текущего тенанта. - private static async Task GetSettingsAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - SettingsService settingsService = context.RequestServices.GetRequiredService(); - return Results.Ok(await settingsService.GetPublicAsync(ct)); - } - - // PATCH /api/settings: частичное обновление настроек; ответ — полный снимок после применения. - private static async Task PatchSettingsAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - Dictionary? body; - try - { - body = await JsonSerializer.DeserializeAsync>( - context.Request.Body, - options: null, - cancellationToken: ct); - } - catch (JsonException) - { - return EndpointResults.BadRequest(InvalidBodyDetail); - } - - if (body is null) - { - return EndpointResults.BadRequest(InvalidBodyDetail); - } - - SettingsService settingsService = context.RequestServices.GetRequiredService(); - PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct); - - await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct); - - if (ShouldScheduleRatesRefresh(body)) - { - context.RequestServices.GetRequiredService().Schedule(); - } - - return Results.Ok(result); - } - - private static bool ShouldScheduleRatesRefresh(Dictionary body) - { - if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element)) - { - return false; - } - - return element.ValueKind switch - { - JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()), - JsonValueKind.Null => false, - _ => true, - }; - } -} +using System.Text.Json; +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Api.Endpoints; + +/// +/// HTTP-эндпоинты настроек тенанта +/// +public static class SettingsEndpoints +{ + private const string ApiGroupPrefix = "/api"; + private const string SettingsPath = "/settings"; + private const string SettingsOpenApiTag = "settings"; + private const string InvalidBodyDetail = "Тело запроса должно быть JSON-объектом"; + + /// + /// Регистрирует GET/PATCH /api/settings. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup(ApiGroupPrefix).WithTags(SettingsOpenApiTag); + + group.MapGet(SettingsPath, GetSettingsAsync); + group.MapPatch(SettingsPath, PatchSettingsAsync); + + return app; + } + + // GET /api/settings: публичный снимок настроек текущего тенанта. + private static async Task GetSettingsAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + SettingsService settingsService = context.RequestServices.GetRequiredService(); + return Results.Ok(await settingsService.GetPublicAsync(ct)); + } + + // PATCH /api/settings: частичное обновление настроек; ответ — полный снимок после применения. + private static async Task PatchSettingsAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + Dictionary? body; + try + { + body = await JsonSerializer.DeserializeAsync>( + context.Request.Body, + options: null, + cancellationToken: ct); + } + catch (JsonException) + { + return EndpointResults.BadRequest(InvalidBodyDetail); + } + + if (body is null) + { + return EndpointResults.BadRequest(InvalidBodyDetail); + } + + SettingsService settingsService = context.RequestServices.GetRequiredService(); + PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct); + + await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct); + + if (ShouldScheduleRatesRefresh(body)) + { + context.RequestServices.GetRequiredService().Schedule(); + } + + return Results.Ok(result); + } + + private static bool ShouldScheduleRatesRefresh(Dictionary body) + { + if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element)) + { + return false; + } + + return element.ValueKind switch + { + JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()), + JsonValueKind.Null => false, + _ => true, + }; + } +} diff --git a/src/core/Deal.Api/Endpoints/StorageEndpoints.cs b/src/core/Deal.Api/Endpoints/StorageEndpoints.cs index 6379c14..6872008 100644 --- a/src/core/Deal.Api/Endpoints/StorageEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/StorageEndpoints.cs @@ -1,56 +1,56 @@ -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Infrastructure.Services; - -namespace Deal.Api.Endpoints; - -/// -/// Служебные storage-эндпоинты -/// -public static class StorageEndpoints -{ - private const string AdminGroupPrefix = "/api"; - - private const string TickPath = "/admin/tick"; - - private const string FtsRebuildPath = "/admin/fts/rebuild"; - - private const string OpenApiTag = "dashboard"; - - /// - /// Регистрирует POST /api/admin/tick и POST /api/admin/fts/rebuild. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapStorageEndpoints(this IEndpointRouteBuilder app) - { - var admin = app.MapGroup(AdminGroupPrefix).WithTags(OpenApiTag); - admin.MapPost(TickPath, AdminTickAsync); - admin.MapPost(FtsRebuildPath, FtsRebuildAsync); - return app; - } - - private static async Task AdminTickAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - AdminTickOrchestrator orchestrator = context.RequestServices.GetRequiredService(); - Guid tenantId = context.GetCurrentUser()!.TenantId; - return Results.Ok(await orchestrator.TickAsync(tenantId, ct)); - } - - private static async Task FtsRebuildAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - FtsMaintenance fts = context.RequestServices.GetRequiredService(); - bool ok = await fts.RebuildAsync(ct); - return Results.Ok(new { ok, ready = ok }); - } -} +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Infrastructure.Services; + +namespace Deal.Api.Endpoints; + +/// +/// Служебные storage-эндпоинты +/// +public static class StorageEndpoints +{ + private const string AdminGroupPrefix = "/api"; + + private const string TickPath = "/admin/tick"; + + private const string FtsRebuildPath = "/admin/fts/rebuild"; + + private const string OpenApiTag = "dashboard"; + + /// + /// Регистрирует POST /api/admin/tick и POST /api/admin/fts/rebuild. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapStorageEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup(AdminGroupPrefix).WithTags(OpenApiTag); + admin.MapPost(TickPath, AdminTickAsync); + admin.MapPost(FtsRebuildPath, FtsRebuildAsync); + return app; + } + + private static async Task AdminTickAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + AdminTickOrchestrator orchestrator = context.RequestServices.GetRequiredService(); + Guid tenantId = context.GetCurrentUser()!.TenantId; + return Results.Ok(await orchestrator.TickAsync(tenantId, ct)); + } + + private static async Task FtsRebuildAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + FtsMaintenance fts = context.RequestServices.GetRequiredService(); + bool ok = await fts.RebuildAsync(ct); + return Results.Ok(new { ok, ready = ok }); + } +} diff --git a/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs b/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs index 6b72878..3af092e 100644 --- a/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs @@ -1,501 +1,501 @@ -using Deal.Api.Endpoints.RequestModels; -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Api.Telegram; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Telegram.Application.Models; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Api.Endpoints; - -/// -/// Эндпоинты /api/tg -/// -public static class TelegramEndpoints -{ - private const string TgGroupPrefix = "/api/tg"; - - private const string TgOpenApiTag = "telegram"; - - // Путь статуса аккаунта/фазы входа (GET). - private const string StatusPath = "/status"; - - // Путь входа по телефону (POST). - private const string StartPhonePath = "/start-phone"; - - // Путь начала QR-входа (POST). - private const string StartQrPath = "/start-qr"; - - // Путь отправки SMS-кода (POST). - private const string SendCodePath = "/send-code"; - - // Путь облачного пароля 2FA (POST). - private const string SendPasswordPath = "/send-password"; - - // Путь отключения аккаунта (POST). - private const string LogoutPath = "/logout"; - - // Путь списка диалогов из БД (GET). - private const string DialogsPath = "/dialogs"; - - // Путь синхронизации каталога из Telegram (POST). - private const string DialogsRefreshPath = "/dialogs/refresh"; - - // Путь мониторинга всех каналов (POST). - private const string DialogsMonitorAllPath = "/dialogs/monitor-all"; - - // Путь «Перечитать» всех включённых каналов (POST). - private const string DialogsBackfillAllPath = "/dialogs/backfill-all"; - - // Путь превью последних сообщений диалога (POST). - private const string DialogsPreviewPath = "/dialogs/preview"; - - // Путь включения/выключения мониторинга одного диалога (POST, параметр dialog_id). - private const string DialogMonitorPath = "/dialogs/{dialog_id}/monitor"; - - private const string DialogBackfillPath = "/dialogs/{dialog_id}/backfill"; - - // Деталь 400: сохранённый api_id не является корректным числом (Security review: int.TryParse). - private const string InvalidApiIdDetail = "Некорректный api_id — проверьте глобальные ключи Telegram"; - - private const string NoKeysDetail = "Ключи Telegram не заданы оператором"; - - // Деталь недоступного telegram-service/неподключённого аккаунта (глобальная строка контракта). - private const string NotConnectedDetail = "Telegram не подключён"; - - private const string NotConnectedReason = "not-connected"; - - private const string ReadyPhase = "ready"; - - private const int PreviewDefaultLimit = 24; - - private const int PreviewLimitMin = 1; - - private const int PreviewLimitMax = 50; - - /// - /// Регистрирует группу /api/tg - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapTelegramEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(TgGroupPrefix).WithTags(TgOpenApiTag); - - group.MapGet(StatusPath, StatusAsync); - group.MapPost(StartPhonePath, StartPhoneAsync); - group.MapPost(StartQrPath, StartQrAsync); - group.MapPost(SendCodePath, SendCodeAsync); - group.MapPost(SendPasswordPath, SendPasswordAsync); - group.MapPost(LogoutPath, LogoutAsync); - group.MapGet(DialogsPath, DialogsAsync); - group.MapPost(DialogsRefreshPath, DialogsRefreshAsync); - group.MapPost(DialogsMonitorAllPath, MonitorAllAsync); - group.MapPost(DialogsBackfillAllPath, BackfillAllAsync); - group.MapPost(DialogsPreviewPath, PreviewAsync); - group.MapPost(DialogMonitorPath, SetMonitorAsync); - group.MapPost(DialogBackfillPath, BackfillDialogAsync); - - return app; - } - - private static async Task StatusAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - TgStatusService statusService = context.RequestServices.GetRequiredService(); - return Results.Ok(await statusService.GetAsync(ct)); - } - - private static async Task StartPhoneAsync( - TgStartPhoneRequest body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - TgKeysSnapshot? keys = await ReadKeysOrNullAsync(context, ct); - if (keys is null) - { - return EndpointResults.BadRequest(NoKeysDetail); - } - - try - { - ITelegramGateway gateway = context.RequestServices.GetRequiredService(); - if (!TryParseApiId(keys.ApiId, out int apiId)) - { - return EndpointResults.BadRequest(InvalidApiIdDetail); - } - - TelegramAuthResultDto result = await gateway.StartPhoneAsync( - (body.Phone ?? string.Empty).Trim(), apiId, keys.ApiHash, ct); - return Results.Ok(new { phase = result.Phase }); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - return GatewayError(exception); - } - } - - private static async Task StartQrAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - TgKeysSnapshot? keys = await ReadKeysOrNullAsync(context, ct); - if (keys is null) - { - return EndpointResults.BadRequest(NoKeysDetail); - } - - try - { - ITelegramGateway gateway = context.RequestServices.GetRequiredService(); - if (!TryParseApiId(keys.ApiId, out int apiId)) - { - return EndpointResults.BadRequest(InvalidApiIdDetail); - } - - TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct); - if (result.Phase == ReadyPhase) - { - await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase = result.Phase }, ct); - } - - return Results.Ok(new { phase = result.Phase, qrUrl = result.QrUrl ?? string.Empty }); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - return GatewayError(exception); - } - } - - private static async Task SendCodeAsync( - TgSendCodeRequest body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - try - { - ITelegramGateway gateway = context.RequestServices.GetRequiredService(); - string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct); - if (phase == ReadyPhase) - { - await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct); - } - - return Results.Ok(new { phase }); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - return GatewayError(exception); - } - } - - private static async Task SendPasswordAsync( - TgSendPasswordRequest body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - try - { - ITelegramGateway gateway = context.RequestServices.GetRequiredService(); - string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct); - if (phase == ReadyPhase) - { - await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct); - } - - return Results.Ok(new { phase }); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - return GatewayError(exception); - } - } - - private static async Task LogoutAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - try - { - ITelegramGateway gateway = context.RequestServices.GetRequiredService(); - await gateway.LogoutAsync(ct); - return Results.Ok(new { ok = true }); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - return GatewayError(exception); - } - } - - private static async Task DialogsAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - DialogsService dialogs = context.RequestServices.GetRequiredService(); - IReadOnlyList rows = await dialogs.ListAsync(ct); - - var items = new List(rows.Count); - foreach (TelegramDialogDto dialog in rows) - { - items.Add(new - { - id = dialog.Id, - name = dialog.Name, - handle = dialog.Handle, - type = ToRussianDialogType(dialog.Type), - hue = dialog.Hue, - on = dialog.On, - last = dialog.Last is null - ? null - : new { text = dialog.Last.Text, time = dialog.Last.TimeMs }, - }); - } - - return Results.Ok(new { items }); - } - - private static async Task DialogsRefreshAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - DialogsService dialogs = context.RequestServices.GetRequiredService(); - ITelegramGateway gateway = context.RequestServices.GetRequiredService(); - - bool connected; - try - { - connected = (await gateway.StatusAsync(ct)).Connected; - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - connected = false; - } - - if (!connected) - { - return Results.Ok(new { ok = false, reason = NotConnectedReason, count = 0 }); - } - - try - { - IReadOnlyList entries = await gateway.RefreshDialogsAsync(ct); - int count = await dialogs.SyncFromTelegramAsync(entries, ct); - return Results.Ok(new { ok = true, count }); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - // Сбой чтения каталога (flood/временный) — та же мягкая ветка: каталог остаётся как в БД. - return Results.Ok(new { ok = false, reason = NotConnectedReason, count = 0 }); - } - } - - private static async Task MonitorAllAsync( - TgMonitorBody body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - try - { - DialogsService dialogs = context.RequestServices.GetRequiredService(); - TelegramMonitorAllDto result = await dialogs.SetMonitorAllAsync(body.Enabled, ct); - if (body.Enabled && result.BackfillNeededIds.Count > 0) - { - context.RequestServices.GetRequiredService().ScheduleFirstBackfills(result.BackfillNeededIds); - } - - if (body.Enabled) - { - await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { all = true, count = result.Count }, ct); - } - - return Results.Ok(new { ok = true, count = result.Count, enabled = body.Enabled }); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - return GatewayError(exception); - } - } - - private static async Task BackfillAllAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - DialogsService dialogs = context.RequestServices.GetRequiredService(); - int count = (await dialogs.ListMonitoredIdsAsync(ct)).Count; - if (count > 0) - { - context.RequestServices.GetRequiredService().ScheduleReadRecent(); - } - - return Results.Ok(new { ok = true, count }); - } - - private static async Task SetMonitorAsync( - string dialog_id, - TgMonitorBody body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - try - { - DialogsService dialogs = context.RequestServices.GetRequiredService(); - TelegramMonitorToggleDto result = await dialogs.SetMonitorAsync(dialog_id, body.Enabled, ct); - if (result.BackfillNeeded) - { - context.RequestServices.GetRequiredService().ScheduleFirstBackfill(dialog_id); - } - - if (result.Enabled) - { - await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { dialogId = dialog_id }, ct); - } - - return Results.Ok(new { ok = true, enabled = result.Enabled }); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - return GatewayError(exception); - } - } - - private static async Task BackfillDialogAsync( - string dialog_id, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - try - { - DialogsService dialogs = context.RequestServices.GetRequiredService(); - int processed = await dialogs.BackfillOneAsync(dialog_id, force: false, ct); - return Results.Ok(new { ok = true, processed }); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - return GatewayError(exception); - } - } - - private static async Task PreviewAsync( - TgPreviewBody body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - DialogsService dialogs = context.RequestServices.GetRequiredService(); - int limit = Math.Clamp(body.Limit ?? PreviewDefaultLimit, PreviewLimitMin, PreviewLimitMax); - IReadOnlyList items = await dialogs.PreviewAsync(body.DialogId, limit, ct); - return Results.Ok(new { items }); - } - - // Читает глобальные ключи приложения; null — заданы не оба (ветка 400 «Ключи Telegram не заданы оператором»). - // context: Контекст запроса. - // ct: Токен отмены. - // Возвращает: Снимок ключей либо null. - private static async Task ReadKeysOrNullAsync(HttpContext context, CancellationToken ct) - { - TelegramKeysService keysService = context.RequestServices.GetRequiredService(); - TgKeysSnapshot keys = await keysService.GetAsync(ct); - return keys.KeysSet ? keys : null; - } - - private static IResult GatewayError(Exception exception) - { - return EndpointResults.BadRequest(GatewayErrorText(exception)); - } - - /// - /// Текст причины ошибки гейта для {detail}. - /// - /// Исключение вызова гейта. - /// Текст причины. - public static string GatewayErrorText(Exception exception) - { - return exception switch - { - // Доменная RPC-ошибка: detail от telegram-service («Неверный код», «Telegram не подключён», …). - global::Grpc.Core.RpcException rpc when !string.IsNullOrEmpty(rpc.Status.Detail) => rpc.Status.Detail, - _ => NotConnectedDetail, - }; - } - - /// - /// Русская форма типа источника на границе эндпоинта. - /// - /// Тип источника (EN-канон каталога либо уже русская подпись). - /// Русская подпись: channel→«канал», group/forum→«группа», chat→«чат»; иное — как есть. - public static string ToRussianDialogType(string kind) - { - return kind switch - { - "channel" => "канал", - "group" or "forum" => "группа", - "chat" => "чат", - _ => kind, - }; - } - - // Безопасный разбор сохранённого api_id: невалидное значение — не исключение, а false - // (400 на HTTP-слое; Security review: int.Parse по пользовательской настройке давал FormatException). - // apiId: Строка api_id из настроек (цифры 6..9, см. SettingsService). - // value: Разобранное значение (валидно только при возврате true). - // Возвращает: True — значение корректно и влезает в int. - private static bool TryParseApiId(string apiId, out int value) - { - return int.TryParse(apiId, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out value); - } -} +using Deal.Api.Endpoints.RequestModels; +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Api.Telegram; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Telegram.Application; +using Deal.Modules.Telegram.Application.Models; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Api.Endpoints; + +/// +/// Эндпоинты /api/tg +/// +public static class TelegramEndpoints +{ + private const string TgGroupPrefix = "/api/tg"; + + private const string TgOpenApiTag = "telegram"; + + // Путь статуса аккаунта/фазы входа (GET). + private const string StatusPath = "/status"; + + // Путь входа по телефону (POST). + private const string StartPhonePath = "/start-phone"; + + // Путь начала QR-входа (POST). + private const string StartQrPath = "/start-qr"; + + // Путь отправки SMS-кода (POST). + private const string SendCodePath = "/send-code"; + + // Путь облачного пароля 2FA (POST). + private const string SendPasswordPath = "/send-password"; + + // Путь отключения аккаунта (POST). + private const string LogoutPath = "/logout"; + + // Путь списка диалогов из БД (GET). + private const string DialogsPath = "/dialogs"; + + // Путь синхронизации каталога из Telegram (POST). + private const string DialogsRefreshPath = "/dialogs/refresh"; + + // Путь мониторинга всех каналов (POST). + private const string DialogsMonitorAllPath = "/dialogs/monitor-all"; + + // Путь «Перечитать» всех включённых каналов (POST). + private const string DialogsBackfillAllPath = "/dialogs/backfill-all"; + + // Путь превью последних сообщений диалога (POST). + private const string DialogsPreviewPath = "/dialogs/preview"; + + // Путь включения/выключения мониторинга одного диалога (POST, параметр dialog_id). + private const string DialogMonitorPath = "/dialogs/{dialog_id}/monitor"; + + private const string DialogBackfillPath = "/dialogs/{dialog_id}/backfill"; + + // Деталь 400: сохранённый api_id не является корректным числом (Security review: int.TryParse). + private const string InvalidApiIdDetail = "Некорректный api_id — проверьте глобальные ключи Telegram"; + + private const string NoKeysDetail = "Ключи Telegram не заданы оператором"; + + // Деталь недоступного telegram-service/неподключённого аккаунта (глобальная строка контракта). + private const string NotConnectedDetail = "Telegram не подключён"; + + private const string NotConnectedReason = "not-connected"; + + private const string ReadyPhase = "ready"; + + private const int PreviewDefaultLimit = 24; + + private const int PreviewLimitMin = 1; + + private const int PreviewLimitMax = 50; + + /// + /// Регистрирует группу /api/tg + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapTelegramEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup(TgGroupPrefix).WithTags(TgOpenApiTag); + + group.MapGet(StatusPath, StatusAsync); + group.MapPost(StartPhonePath, StartPhoneAsync); + group.MapPost(StartQrPath, StartQrAsync); + group.MapPost(SendCodePath, SendCodeAsync); + group.MapPost(SendPasswordPath, SendPasswordAsync); + group.MapPost(LogoutPath, LogoutAsync); + group.MapGet(DialogsPath, DialogsAsync); + group.MapPost(DialogsRefreshPath, DialogsRefreshAsync); + group.MapPost(DialogsMonitorAllPath, MonitorAllAsync); + group.MapPost(DialogsBackfillAllPath, BackfillAllAsync); + group.MapPost(DialogsPreviewPath, PreviewAsync); + group.MapPost(DialogMonitorPath, SetMonitorAsync); + group.MapPost(DialogBackfillPath, BackfillDialogAsync); + + return app; + } + + private static async Task StatusAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + TgStatusService statusService = context.RequestServices.GetRequiredService(); + return Results.Ok(await statusService.GetAsync(ct)); + } + + private static async Task StartPhoneAsync( + TgStartPhoneRequest body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + TgKeysSnapshot? keys = await ReadKeysOrNullAsync(context, ct); + if (keys is null) + { + return EndpointResults.BadRequest(NoKeysDetail); + } + + try + { + ITelegramGateway gateway = context.RequestServices.GetRequiredService(); + if (!TryParseApiId(keys.ApiId, out int apiId)) + { + return EndpointResults.BadRequest(InvalidApiIdDetail); + } + + TelegramAuthResultDto result = await gateway.StartPhoneAsync( + (body.Phone ?? string.Empty).Trim(), apiId, keys.ApiHash, ct); + return Results.Ok(new { phase = result.Phase }); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return GatewayError(exception); + } + } + + private static async Task StartQrAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + TgKeysSnapshot? keys = await ReadKeysOrNullAsync(context, ct); + if (keys is null) + { + return EndpointResults.BadRequest(NoKeysDetail); + } + + try + { + ITelegramGateway gateway = context.RequestServices.GetRequiredService(); + if (!TryParseApiId(keys.ApiId, out int apiId)) + { + return EndpointResults.BadRequest(InvalidApiIdDetail); + } + + TelegramAuthResultDto result = await gateway.StartQrAsync(apiId, keys.ApiHash, ct); + if (result.Phase == ReadyPhase) + { + await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase = result.Phase }, ct); + } + + return Results.Ok(new { phase = result.Phase, qrUrl = result.QrUrl ?? string.Empty }); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return GatewayError(exception); + } + } + + private static async Task SendCodeAsync( + TgSendCodeRequest body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + try + { + ITelegramGateway gateway = context.RequestServices.GetRequiredService(); + string phase = await gateway.SendCodeAsync((body.Code ?? string.Empty).Trim(), ct); + if (phase == ReadyPhase) + { + await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct); + } + + return Results.Ok(new { phase }); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return GatewayError(exception); + } + } + + private static async Task SendPasswordAsync( + TgSendPasswordRequest body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + try + { + ITelegramGateway gateway = context.RequestServices.GetRequiredService(); + string phase = await gateway.SendPasswordAsync(body.Password ?? string.Empty, ct); + if (phase == ReadyPhase) + { + await AuditAppender.AppendTenantAsync(context, AuditEvents.TelegramLinked, new { phase }, ct); + } + + return Results.Ok(new { phase }); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return GatewayError(exception); + } + } + + private static async Task LogoutAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + try + { + ITelegramGateway gateway = context.RequestServices.GetRequiredService(); + await gateway.LogoutAsync(ct); + return Results.Ok(new { ok = true }); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return GatewayError(exception); + } + } + + private static async Task DialogsAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + DialogsService dialogs = context.RequestServices.GetRequiredService(); + IReadOnlyList rows = await dialogs.ListAsync(ct); + + var items = new List(rows.Count); + foreach (TelegramDialogDto dialog in rows) + { + items.Add(new + { + id = dialog.Id, + name = dialog.Name, + handle = dialog.Handle, + type = ToRussianDialogType(dialog.Type), + hue = dialog.Hue, + on = dialog.On, + last = dialog.Last is null + ? null + : new { text = dialog.Last.Text, time = dialog.Last.TimeMs }, + }); + } + + return Results.Ok(new { items }); + } + + private static async Task DialogsRefreshAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + DialogsService dialogs = context.RequestServices.GetRequiredService(); + ITelegramGateway gateway = context.RequestServices.GetRequiredService(); + + bool connected; + try + { + connected = (await gateway.StatusAsync(ct)).Connected; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + connected = false; + } + + if (!connected) + { + return Results.Ok(new { ok = false, reason = NotConnectedReason, count = 0 }); + } + + try + { + IReadOnlyList entries = await gateway.RefreshDialogsAsync(ct); + int count = await dialogs.SyncFromTelegramAsync(entries, ct); + return Results.Ok(new { ok = true, count }); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + // Сбой чтения каталога (flood/временный) — та же мягкая ветка: каталог остаётся как в БД. + return Results.Ok(new { ok = false, reason = NotConnectedReason, count = 0 }); + } + } + + private static async Task MonitorAllAsync( + TgMonitorBody body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + try + { + DialogsService dialogs = context.RequestServices.GetRequiredService(); + TelegramMonitorAllDto result = await dialogs.SetMonitorAllAsync(body.Enabled, ct); + if (body.Enabled && result.BackfillNeededIds.Count > 0) + { + context.RequestServices.GetRequiredService().ScheduleFirstBackfills(result.BackfillNeededIds); + } + + if (body.Enabled) + { + await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { all = true, count = result.Count }, ct); + } + + return Results.Ok(new { ok = true, count = result.Count, enabled = body.Enabled }); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return GatewayError(exception); + } + } + + private static async Task BackfillAllAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + DialogsService dialogs = context.RequestServices.GetRequiredService(); + int count = (await dialogs.ListMonitoredIdsAsync(ct)).Count; + if (count > 0) + { + context.RequestServices.GetRequiredService().ScheduleReadRecent(); + } + + return Results.Ok(new { ok = true, count }); + } + + private static async Task SetMonitorAsync( + string dialog_id, + TgMonitorBody body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + try + { + DialogsService dialogs = context.RequestServices.GetRequiredService(); + TelegramMonitorToggleDto result = await dialogs.SetMonitorAsync(dialog_id, body.Enabled, ct); + if (result.BackfillNeeded) + { + context.RequestServices.GetRequiredService().ScheduleFirstBackfill(dialog_id); + } + + if (result.Enabled) + { + await AuditAppender.AppendTenantAsync(context, AuditEvents.ChannelEnabled, new { dialogId = dialog_id }, ct); + } + + return Results.Ok(new { ok = true, enabled = result.Enabled }); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return GatewayError(exception); + } + } + + private static async Task BackfillDialogAsync( + string dialog_id, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + try + { + DialogsService dialogs = context.RequestServices.GetRequiredService(); + int processed = await dialogs.BackfillOneAsync(dialog_id, force: false, ct); + return Results.Ok(new { ok = true, processed }); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return GatewayError(exception); + } + } + + private static async Task PreviewAsync( + TgPreviewBody body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + DialogsService dialogs = context.RequestServices.GetRequiredService(); + int limit = Math.Clamp(body.Limit ?? PreviewDefaultLimit, PreviewLimitMin, PreviewLimitMax); + IReadOnlyList items = await dialogs.PreviewAsync(body.DialogId, limit, ct); + return Results.Ok(new { items }); + } + + // Читает глобальные ключи приложения; null — заданы не оба (ветка 400 «Ключи Telegram не заданы оператором»). + // context: Контекст запроса. + // ct: Токен отмены. + // Возвращает: Снимок ключей либо null. + private static async Task ReadKeysOrNullAsync(HttpContext context, CancellationToken ct) + { + TelegramKeysService keysService = context.RequestServices.GetRequiredService(); + TgKeysSnapshot keys = await keysService.GetAsync(ct); + return keys.KeysSet ? keys : null; + } + + private static IResult GatewayError(Exception exception) + { + return EndpointResults.BadRequest(GatewayErrorText(exception)); + } + + /// + /// Текст причины ошибки гейта для {detail}. + /// + /// Исключение вызова гейта. + /// Текст причины. + public static string GatewayErrorText(Exception exception) + { + return exception switch + { + // Доменная RPC-ошибка: detail от telegram-service («Неверный код», «Telegram не подключён», …). + global::Grpc.Core.RpcException rpc when !string.IsNullOrEmpty(rpc.Status.Detail) => rpc.Status.Detail, + _ => NotConnectedDetail, + }; + } + + /// + /// Русская форма типа источника на границе эндпоинта. + /// + /// Тип источника (EN-канон каталога либо уже русская подпись). + /// Русская подпись: channel→«канал», group/forum→«группа», chat→«чат»; иное — как есть. + public static string ToRussianDialogType(string kind) + { + return kind switch + { + "channel" => "канал", + "group" or "forum" => "группа", + "chat" => "чат", + _ => kind, + }; + } + + // Безопасный разбор сохранённого api_id: невалидное значение — не исключение, а false + // (400 на HTTP-слое; Security review: int.Parse по пользовательской настройке давал FormatException). + // apiId: Строка api_id из настроек (цифры 6..9, см. SettingsService). + // value: Разобранное значение (валидно только при возврате true). + // Возвращает: True — значение корректно и влезает в int. + private static bool TryParseApiId(string apiId, out int value) + { + return int.TryParse(apiId, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out value); + } +} diff --git a/src/core/Deal.Api/Endpoints/TelegramQrImageEndpoint.cs b/src/core/Deal.Api/Endpoints/TelegramQrImageEndpoint.cs index 7b7bff0..f5f9de9 100644 --- a/src/core/Deal.Api/Endpoints/TelegramQrImageEndpoint.cs +++ b/src/core/Deal.Api/Endpoints/TelegramQrImageEndpoint.cs @@ -1,70 +1,70 @@ -using Deal.Api.Extensions; -using Deal.Api.Services; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Net.Codecrete.QrCodeGenerator; - -namespace Deal.Api.Endpoints; - -/// -/// GET /api/tg/qr-image -/// -public static class TelegramQrImageEndpoint -{ - // Префикс группы /api/tg (общий с TelegramEndpoints). - private const string QrGroupPrefix = "/api/tg"; - - // Путь SVG QR-кода (GET; фронт добавляет ?t=N от кэша). - private const string QrImagePath = "/qr-image"; - - private const string QrImageOpenApiTag = "telegram"; - - private const string QrNotActiveDetail = "QR не активен — начните вход по QR"; - - private const string SvgMediaType = "image/svg+xml"; - - private const int QrBorderModules = 1; - - /// - /// Регистрирует GET /api/tg/qr-image. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapTelegramQrImageEndpoint(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(QrGroupPrefix).WithTags(QrImageOpenApiTag); - group.MapGet(QrImagePath, QrImageAsync); - return app; - } - - private static async Task QrImageAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - TelegramAccountStatusDto live; - try - { - ITelegramGateway gateway = context.RequestServices.GetRequiredService(); - live = await gateway.StatusAsync(ct); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - return EndpointResults.NotFound(QrNotActiveDetail); - } - - if (!string.Equals(live.Phase, "qr", StringComparison.Ordinal) || string.IsNullOrEmpty(live.QrUrl)) - { - return EndpointResults.NotFound(QrNotActiveDetail); - } - - QrCode qr = QrCode.EncodeText(live.QrUrl, QrCode.Ecc.Medium); - string svg = qr.ToSvgString(QrBorderModules); - - context.Response.Headers.CacheControl = "no-store"; - context.Response.Headers.ContentDisposition = "inline"; - return Results.Text(svg, SvgMediaType); - } -} +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Net.Codecrete.QrCodeGenerator; + +namespace Deal.Api.Endpoints; + +/// +/// GET /api/tg/qr-image +/// +public static class TelegramQrImageEndpoint +{ + // Префикс группы /api/tg (общий с TelegramEndpoints). + private const string QrGroupPrefix = "/api/tg"; + + // Путь SVG QR-кода (GET; фронт добавляет ?t=N от кэша). + private const string QrImagePath = "/qr-image"; + + private const string QrImageOpenApiTag = "telegram"; + + private const string QrNotActiveDetail = "QR не активен — начните вход по QR"; + + private const string SvgMediaType = "image/svg+xml"; + + private const int QrBorderModules = 1; + + /// + /// Регистрирует GET /api/tg/qr-image. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapTelegramQrImageEndpoint(this IEndpointRouteBuilder app) + { + var group = app.MapGroup(QrGroupPrefix).WithTags(QrImageOpenApiTag); + group.MapGet(QrImagePath, QrImageAsync); + return app; + } + + private static async Task QrImageAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + TelegramAccountStatusDto live; + try + { + ITelegramGateway gateway = context.RequestServices.GetRequiredService(); + live = await gateway.StatusAsync(ct); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return EndpointResults.NotFound(QrNotActiveDetail); + } + + if (!string.Equals(live.Phase, "qr", StringComparison.Ordinal) || string.IsNullOrEmpty(live.QrUrl)) + { + return EndpointResults.NotFound(QrNotActiveDetail); + } + + QrCode qr = QrCode.EncodeText(live.QrUrl, QrCode.Ecc.Medium); + string svg = qr.ToSvgString(QrBorderModules); + + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers.ContentDisposition = "inline"; + return Results.Text(svg, SvgMediaType); + } +} diff --git a/src/core/Deal.Api/Events/SseBroker.cs b/src/core/Deal.Api/Events/SseBroker.cs index 5e51a61..327c7d5 100644 --- a/src/core/Deal.Api/Events/SseBroker.cs +++ b/src/core/Deal.Api/Events/SseBroker.cs @@ -1,117 +1,117 @@ -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Threading.Channels; - -namespace Deal.Api.Events; - -/// -/// Singleton SSE-брокер -/// -public sealed class SseBroker -{ - /// - /// Ёмкость очереди подписчика - /// - public const int SubscriberQueueCapacity = 200; - - // Опции сериализации полезной нагрузки в data: — как у HTTP-ответов приложения: - // web-дефолты (camelCase) и без \u-экранирования не-ASCII (Program.cs ConfigureHttpJsonOptions). - private static readonly JsonSerializerOptions PublishJsonOptions = new(JsonSerializerDefaults.Web) - { - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - }; - - private readonly object _gate = new(); - - // Подписчики по тенантам: tenantId → (subscriptionId → канал). - private readonly Dictionary>> _subscribersByTenant = new(); - - /// - /// Подписывает клиента на канал тенанта - /// - /// Тенант сессии запроса. - /// Подписка: идентификатор для отписки и читатель канала событий. - public SseSubscription Subscribe(Guid tenantId) - { - var channel = Channel.CreateBounded(new BoundedChannelOptions(SubscriberQueueCapacity) - { - FullMode = BoundedChannelFullMode.DropOldest, - SingleReader = true, - SingleWriter = false, - }); - - var subscription = new SseSubscription(Guid.NewGuid(), tenantId, channel.Reader); - lock (_gate) - { - if (!_subscribersByTenant.TryGetValue(tenantId, out Dictionary>? subscribers)) - { - subscribers = new Dictionary>(); - _subscribersByTenant.Add(tenantId, subscribers); - } - - subscribers.Add(subscription.Id, channel); - } - - return subscription; - } - - /// - /// Отписывает клиента по завершении SSE-соединения. - /// - /// Тенант канала подписки. - /// Идентификатор подписки из . - public void Unsubscribe(Guid tenantId, Guid subscriptionId) - { - lock (_gate) - { - if (!_subscribersByTenant.TryGetValue(tenantId, out Dictionary>? subscribers)) - { - return; - } - - subscribers.Remove(subscriptionId); - if (subscribers.Count == 0) - { - // Пустой канал тенанта убираем — публикации без подписчиков становятся no-op. - _subscribersByTenant.Remove(tenantId); - } - } - } - - /// - /// Публикует событие в канал тенанта. - /// - /// Тенант-получатель; без подписчиков — no-op, не падает. - /// Тип события. - /// Полезная нагрузка — сериализуется в JSON (camelCase, без \u). - public void Publish( - Guid tenantId, - string eventType, - object payload) => - Publish(tenantId, new SseEvent(eventType, JsonSerializer.Serialize(payload, PublishJsonOptions))); - - /// - /// Публикует готовое событие - /// - /// Тенант-получатель; без подписчиков — no-op, не падает. - /// Событие с уже сериализованной нагрузкой. - public void Publish(Guid tenantId, SseEvent sseEvent) - { - Channel[] channels; - lock (_gate) - { - if (!_subscribersByTenant.TryGetValue(tenantId, out Dictionary>? subscribers)) - { - return; - } - - channels = subscribers.Values.ToArray(); - } - - foreach (Channel channel in channels) - { - // DropOldest + bounded: TryWrite всегда завершается успешно (кроме закрытого канала). - channel.Writer.TryWrite(sseEvent); - } - } -} +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Threading.Channels; + +namespace Deal.Api.Events; + +/// +/// Singleton SSE-брокер +/// +public sealed class SseBroker +{ + /// + /// Ёмкость очереди подписчика + /// + public const int SubscriberQueueCapacity = 200; + + // Опции сериализации полезной нагрузки в data: — как у HTTP-ответов приложения: + // web-дефолты (camelCase) и без \u-экранирования не-ASCII (Program.cs ConfigureHttpJsonOptions). + private static readonly JsonSerializerOptions PublishJsonOptions = new(JsonSerializerDefaults.Web) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + private readonly object _gate = new(); + + // Подписчики по тенантам: tenantId → (subscriptionId → канал). + private readonly Dictionary>> _subscribersByTenant = new(); + + /// + /// Подписывает клиента на канал тенанта + /// + /// Тенант сессии запроса. + /// Подписка: идентификатор для отписки и читатель канала событий. + public SseSubscription Subscribe(Guid tenantId) + { + var channel = Channel.CreateBounded(new BoundedChannelOptions(SubscriberQueueCapacity) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false, + }); + + var subscription = new SseSubscription(Guid.NewGuid(), tenantId, channel.Reader); + lock (_gate) + { + if (!_subscribersByTenant.TryGetValue(tenantId, out Dictionary>? subscribers)) + { + subscribers = new Dictionary>(); + _subscribersByTenant.Add(tenantId, subscribers); + } + + subscribers.Add(subscription.Id, channel); + } + + return subscription; + } + + /// + /// Отписывает клиента по завершении SSE-соединения. + /// + /// Тенант канала подписки. + /// Идентификатор подписки из . + public void Unsubscribe(Guid tenantId, Guid subscriptionId) + { + lock (_gate) + { + if (!_subscribersByTenant.TryGetValue(tenantId, out Dictionary>? subscribers)) + { + return; + } + + subscribers.Remove(subscriptionId); + if (subscribers.Count == 0) + { + // Пустой канал тенанта убираем — публикации без подписчиков становятся no-op. + _subscribersByTenant.Remove(tenantId); + } + } + } + + /// + /// Публикует событие в канал тенанта. + /// + /// Тенант-получатель; без подписчиков — no-op, не падает. + /// Тип события. + /// Полезная нагрузка — сериализуется в JSON (camelCase, без \u). + public void Publish( + Guid tenantId, + string eventType, + object payload) => + Publish(tenantId, new SseEvent(eventType, JsonSerializer.Serialize(payload, PublishJsonOptions))); + + /// + /// Публикует готовое событие + /// + /// Тенант-получатель; без подписчиков — no-op, не падает. + /// Событие с уже сериализованной нагрузкой. + public void Publish(Guid tenantId, SseEvent sseEvent) + { + Channel[] channels; + lock (_gate) + { + if (!_subscribersByTenant.TryGetValue(tenantId, out Dictionary>? subscribers)) + { + return; + } + + channels = subscribers.Values.ToArray(); + } + + foreach (Channel channel in channels) + { + // DropOldest + bounded: TryWrite всегда завершается успешно (кроме закрытого канала). + channel.Writer.TryWrite(sseEvent); + } + } +} diff --git a/src/core/Deal.Api/Events/SseEvent.cs b/src/core/Deal.Api/Events/SseEvent.cs index d0a8099..b9ab053 100644 --- a/src/core/Deal.Api/Events/SseEvent.cs +++ b/src/core/Deal.Api/Events/SseEvent.cs @@ -1,15 +1,15 @@ -namespace Deal.Api.Events; - -/// -/// Событие SSE-потока -/// -/// Тип события — фронт слушает addEventListener по имени: на — new_card (полный объект карточки) и toast {text, icon}. -/// Полезная нагрузка, сериализованная в JSON (camelCase, без \u-экранирования). -public sealed record SseEvent(string Type, string Json) -{ - /// - /// Отрисовывает frame протокола SSE - /// - /// Готовый frame для отправки в поток ответа. - public string RenderFrame() => $"event: {Type}\ndata: {Json}\n\n"; -} +namespace Deal.Api.Events; + +/// +/// Событие SSE-потока +/// +/// Тип события — фронт слушает addEventListener по имени: на — new_card (полный объект карточки) и toast {text, icon}. +/// Полезная нагрузка, сериализованная в JSON (camelCase, без \u-экранирования). +public sealed record SseEvent(string Type, string Json) +{ + /// + /// Отрисовывает frame протокола SSE + /// + /// Готовый frame для отправки в поток ответа. + public string RenderFrame() => $"event: {Type}\ndata: {Json}\n\n"; +} diff --git a/src/core/Deal.Api/Events/SseSubscription.cs b/src/core/Deal.Api/Events/SseSubscription.cs index 604154e..f0ca173 100644 --- a/src/core/Deal.Api/Events/SseSubscription.cs +++ b/src/core/Deal.Api/Events/SseSubscription.cs @@ -1,11 +1,11 @@ -using System.Threading.Channels; - -namespace Deal.Api.Events; - -/// -/// Активная подписка на канал SSE тенанта. -/// -/// Идентификатор подписки — передаётся в . -/// Тенант канала: тенант сессии при подписке. -/// Канал событий подписчика: bounded-очередь ≤200 с вытеснением старых. -public sealed record SseSubscription(Guid Id, Guid TenantId, ChannelReader Events); +using System.Threading.Channels; + +namespace Deal.Api.Events; + +/// +/// Активная подписка на канал SSE тенанта. +/// +/// Идентификатор подписки — передаётся в . +/// Тенант канала: тенант сессии при подписке. +/// Канал событий подписчика: bounded-очередь ≤200 с вытеснением старых. +public sealed record SseSubscription(Guid Id, Guid TenantId, ChannelReader Events); diff --git a/src/core/Deal.Api/Events/StorageToastPublisher.cs b/src/core/Deal.Api/Events/StorageToastPublisher.cs index 12c59b3..e4d3f50 100644 --- a/src/core/Deal.Api/Events/StorageToastPublisher.cs +++ b/src/core/Deal.Api/Events/StorageToastPublisher.cs @@ -1,63 +1,63 @@ -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Api.Events; - -/// -/// Публикация SSE-тостов статистики тика правил хранения в канал тенанта. -/// -public sealed class StorageToastPublisher -{ - private const string ToastEventType = "toast"; - - private const string AutoArchiveToastText = "Автоархив: {0} карточек"; - - private const string ArchiveClearedToastText = "Архив очищен: {0} (90 дн.)"; - - private const string TrashClearedToastText = "Корзина очищена: {0} (7 дн.)"; - - private const string RejectedPurgedToastText = "Отсев очищен: {0} записей (3 дн.)"; - - private const string ClockIcon = "clock"; - - private const string TrashIcon = "trash"; - - private readonly SseBroker _broker; - - /// - /// Создаёт публикатора тостов тика. - /// - /// SSE-брокер каналов тенантов (singleton). - public StorageToastPublisher(SseBroker broker) - { - ArgumentNullException.ThrowIfNull(broker); - _broker = broker; - } - - /// - /// Публикует тосты статистики тика по ненулевым счётчикам. - /// - /// Тенант-получатель тостов (сессия запроса / канал тенанта цикла). - /// Статистика только что выполненного тика. - public void PublishTickToasts(Guid tenantId, StorageTickStatsDto stats) - { - if (stats.Archived > 0) - { - _broker.Publish(tenantId, ToastEventType, new { text = string.Format(AutoArchiveToastText, stats.Archived), icon = ClockIcon }); - } - - if (stats.PurgedArchive > 0) - { - _broker.Publish(tenantId, ToastEventType, new { text = string.Format(ArchiveClearedToastText, stats.PurgedArchive), icon = TrashIcon }); - } - - if (stats.PurgedTrash > 0) - { - _broker.Publish(tenantId, ToastEventType, new { text = string.Format(TrashClearedToastText, stats.PurgedTrash), icon = TrashIcon }); - } - - if (stats.PurgedRejected > 0) - { - _broker.Publish(tenantId, ToastEventType, new { text = string.Format(RejectedPurgedToastText, stats.PurgedRejected), icon = TrashIcon }); - } - } -} +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Api.Events; + +/// +/// Публикация SSE-тостов статистики тика правил хранения в канал тенанта. +/// +public sealed class StorageToastPublisher +{ + private const string ToastEventType = "toast"; + + private const string AutoArchiveToastText = "Автоархив: {0} карточек"; + + private const string ArchiveClearedToastText = "Архив очищен: {0} (90 дн.)"; + + private const string TrashClearedToastText = "Корзина очищена: {0} (7 дн.)"; + + private const string RejectedPurgedToastText = "Отсев очищен: {0} записей (3 дн.)"; + + private const string ClockIcon = "clock"; + + private const string TrashIcon = "trash"; + + private readonly SseBroker _broker; + + /// + /// Создаёт публикатора тостов тика. + /// + /// SSE-брокер каналов тенантов (singleton). + public StorageToastPublisher(SseBroker broker) + { + ArgumentNullException.ThrowIfNull(broker); + _broker = broker; + } + + /// + /// Публикует тосты статистики тика по ненулевым счётчикам. + /// + /// Тенант-получатель тостов (сессия запроса / канал тенанта цикла). + /// Статистика только что выполненного тика. + public void PublishTickToasts(Guid tenantId, StorageTickStatsDto stats) + { + if (stats.Archived > 0) + { + _broker.Publish(tenantId, ToastEventType, new { text = string.Format(AutoArchiveToastText, stats.Archived), icon = ClockIcon }); + } + + if (stats.PurgedArchive > 0) + { + _broker.Publish(tenantId, ToastEventType, new { text = string.Format(ArchiveClearedToastText, stats.PurgedArchive), icon = TrashIcon }); + } + + if (stats.PurgedTrash > 0) + { + _broker.Publish(tenantId, ToastEventType, new { text = string.Format(TrashClearedToastText, stats.PurgedTrash), icon = TrashIcon }); + } + + if (stats.PurgedRejected > 0) + { + _broker.Publish(tenantId, ToastEventType, new { text = string.Format(RejectedPurgedToastText, stats.PurgedRejected), icon = TrashIcon }); + } + } +} diff --git a/src/core/Deal.Api/Extensions/AuthHelpers.cs b/src/core/Deal.Api/Extensions/AuthHelpers.cs index f1b8d40..c572ba9 100644 --- a/src/core/Deal.Api/Extensions/AuthHelpers.cs +++ b/src/core/Deal.Api/Extensions/AuthHelpers.cs @@ -1,57 +1,57 @@ -using Deal.Api.Models; - -namespace Deal.Api.Extensions; - -/// -/// Хелперы доступа к текущему пользователю запроса -/// -public static class AuthHelpers -{ - public const string CurrentUserItemKey = "CurrentUser"; - - public const string CurrentOperatorItemKey = "CurrentOperator"; - - /// - /// Сообщение 401 для эндпоинтов, требующих авторизации. - /// - public const string UnauthorizedDetail = "Требуется авторизация"; - - /// - /// Сообщение 401 для ручек /api/operator/* без разрешённой операторской сессии. - /// - public const string OperatorUnauthorizedDetail = "Требуется вход оператора"; - - /// - /// Кладёт пользователя в HttpContext.Items. - /// - /// Пользователь, разрешённый по сессии. - public static void SetCurrentUser(this HttpContext context, CurrentUser user) => - context.Items[CurrentUserItemKey] = user; - - /// - /// Возвращает текущего пользователя запроса или null, если сессия не разрешена. - /// - /// Пользователь или null. - public static CurrentUser? GetCurrentUser(this HttpContext context) => - context.Items[CurrentUserItemKey] as CurrentUser; - - /// - /// Проверяет, разрешена ли для запроса пользовательская сессия. - /// - /// True — текущий пользователь установлен. - public static bool HasUser(this HttpContext context) => context.GetCurrentUser() is not null; - - /// - /// Кладёт оператора в HttpContext.Items. - /// - /// Оператор, разрешённый по операторской сессии. - public static void SetCurrentOperator(this HttpContext context, CurrentOperator operatorIdentity) => - context.Items[CurrentOperatorItemKey] = operatorIdentity; - - /// - /// Возвращает текущего оператора запроса или null, если операторская сессия не разрешена. - /// - /// Оператор или null (ручка /api/operator/* отвечает 401). - public static CurrentOperator? GetCurrentOperator(this HttpContext context) => - context.Items[CurrentOperatorItemKey] as CurrentOperator; -} +using Deal.Api.Models; + +namespace Deal.Api.Extensions; + +/// +/// Хелперы доступа к текущему пользователю запроса +/// +public static class AuthHelpers +{ + public const string CurrentUserItemKey = "CurrentUser"; + + public const string CurrentOperatorItemKey = "CurrentOperator"; + + /// + /// Сообщение 401 для эндпоинтов, требующих авторизации. + /// + public const string UnauthorizedDetail = "Требуется авторизация"; + + /// + /// Сообщение 401 для ручек /api/operator/* без разрешённой операторской сессии. + /// + public const string OperatorUnauthorizedDetail = "Требуется вход оператора"; + + /// + /// Кладёт пользователя в HttpContext.Items. + /// + /// Пользователь, разрешённый по сессии. + public static void SetCurrentUser(this HttpContext context, CurrentUser user) => + context.Items[CurrentUserItemKey] = user; + + /// + /// Возвращает текущего пользователя запроса или null, если сессия не разрешена. + /// + /// Пользователь или null. + public static CurrentUser? GetCurrentUser(this HttpContext context) => + context.Items[CurrentUserItemKey] as CurrentUser; + + /// + /// Проверяет, разрешена ли для запроса пользовательская сессия. + /// + /// True — текущий пользователь установлен. + public static bool HasUser(this HttpContext context) => context.GetCurrentUser() is not null; + + /// + /// Кладёт оператора в HttpContext.Items. + /// + /// Оператор, разрешённый по операторской сессии. + public static void SetCurrentOperator(this HttpContext context, CurrentOperator operatorIdentity) => + context.Items[CurrentOperatorItemKey] = operatorIdentity; + + /// + /// Возвращает текущего оператора запроса или null, если операторская сессия не разрешена. + /// + /// Оператор или null (ручка /api/operator/* отвечает 401). + public static CurrentOperator? GetCurrentOperator(this HttpContext context) => + context.Items[CurrentOperatorItemKey] as CurrentOperator; +} diff --git a/src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs b/src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs index 266b549..5bdcb1b 100644 --- a/src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs +++ b/src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs @@ -1,182 +1,182 @@ -using Deal.Api.Events; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Api.Hosting; - -/// -/// Фоновый цикл SSE-алертов ИИ-бюджета -/// -public sealed class BudgetAlertScheduler : IHostedService -{ - /// - /// Период проходов цикла — 60 с. - /// - public const int AlertPeriodSeconds = 60; - - private const string ToastEventType = "toast"; - - private const string Warned80ToastText = "ИИ-бюджет израсходован на 80%"; - - private const string ExhaustedToastText = "ИИ-бюджет исчерпан — обработка в локальном режиме"; - - private const string BellIcon = "bell"; - - private static readonly TimeSpan AlertPeriod = TimeSpan.FromSeconds(AlertPeriodSeconds); - - private readonly IServiceScopeFactory _scopeFactory; - private readonly SseBroker _broker; - private readonly ILogger _logger; - - // Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен). - private readonly CancellationTokenSource _shutdownCts = new(); - - private Timer? _timer; - private Task? _currentIteration; - private int _iterationInProgress; - - /// - /// Создаёт планировщик фоновых алертов ИИ-бюджета. - /// - /// Фабрика scope: проход цикла и проверка каждого тенанта — в собственных scope. - /// SSE-брокер каналов тенантов (публикация тостов; без подписчиков — no-op). - /// Логгер ошибок цикла. - public BudgetAlertScheduler( - IServiceScopeFactory scopeFactory, - SseBroker broker, - ILogger logger) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(broker); - ArgumentNullException.ThrowIfNull(logger); - _scopeFactory = scopeFactory; - _broker = broker; - _logger = logger; - } - - /// - public Task StartAsync(CancellationToken ct) - { - // Первый проход — сразу после старта (как StorageTickScheduler), далее каждые 60 с. - _timer = new Timer( - static state => ((BudgetAlertScheduler)state!).RunIteration(), - this, - TimeSpan.Zero, - AlertPeriod); - return Task.CompletedTask; - } - - /// - public async Task StopAsync(CancellationToken ct) - { - // Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита - // остановки хоста (HostOptions.ShutdownTimeout). - _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); - _timer?.Dispose(); - _timer = null; - - _shutdownCts.Cancel(); - Task? iteration = Volatile.Read(ref _currentIteration); - if (iteration is not null) - { - try - { - await iteration.WaitAsync(ct); - } - catch (OperationCanceledException) - { - // Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри - // RunCycleCoreAsync, незавершённый проход безопасно завершится на отменённом токене. - } - } - } - - /// - /// Один проход цикла - /// - /// Задача прохода (завершается без исключений). - public Task RunCycleAsync(CancellationToken ct) - { - if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0) - { - return Task.CompletedTask; - } - - Task iteration = RunCycleCoreAsync(ct); - Volatile.Write(ref _currentIteration, iteration); - return iteration; - } - - // Запускает проход из callback таймера (guard — внутри RunCycleAsync). - private void RunIteration() - { - _ = RunCycleAsync(_shutdownCts.Token); - } - - // Тело прохода: проверка порогов каждого тенанта реестра; guard сбрасывается в finally. - // ct: Токен отмены (остановка хоста). - private async Task RunCycleCoreAsync(CancellationToken ct) - { - try - { - await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope(); - ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService(); - IReadOnlyList tenants = await tenantRepository.ListAsync(ct); - - foreach (TenantRecordDto tenant in tenants) - { - await CheckTenantAsync(tenant, ct); - } - } - catch (OperationCanceledException) - { - // Остановка хоста: проход прерван по токену — штатный выход, не ошибка. - } - catch (Exception exception) - { - // Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика. - _logger.LogError(exception, "Цикл бюджет-алертов: проход по тенантам не удался"); - } - finally - { - Interlocked.Exchange(ref _iterationInProgress, 0); - } - } - - // Проверка порогов одного тенанта в собственном scope: TryMark* (CAS) → тост в канал тенанта. - // Список тенантов и лимиты живут в публичной схеме — tenant-контекст не заполняется (в отличие от - // тиков StorageTickScheduler, работающих со схемой тенанта). Порядок: сначала 80% (Warned80), затем 100% - // (NotifiedExhausted) — при скачке через оба порога за период выходят оба тоста по одному разу. Сбой одного - // тенанта не валит проход: ошибка логируется, остальные тенанты обрабатываются; отмена (OCE) пробрасывается - // наверх — проход завершается. - // tenant: Тенант реестра (Id в формате Guid). - // ct: Токен отмены прохода. - private async Task CheckTenantAsync(TenantRecordDto tenant, CancellationToken ct) - { - await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope(); - try - { - ITenantLimitStore limitStore = tenantScope.ServiceProvider.GetRequiredService(); - - if (await limitStore.TryMarkWarnedAsync(tenant.Id, ct)) - { - _logger.LogDebug("Бюджет тенанта {TenantId}: порог 80% пересечён — SSE-тост", tenant.Id); - _broker.Publish(tenant.Id, ToastEventType, new { text = Warned80ToastText, icon = BellIcon }); - } - - if (await limitStore.TryMarkNotifiedExhaustedAsync(tenant.Id, ct)) - { - _logger.LogDebug("Бюджет тенанта {TenantId}: исчерпан — SSE-тост", tenant.Id); - _broker.Publish(tenant.Id, ToastEventType, new { text = ExhaustedToastText, icon = BellIcon }); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - _logger.LogWarning(exception, "Цикл бюджет-алертов: проверка тенанта {TenantId} не удалась", tenant.Id); - } - } -} +using Deal.Api.Events; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Api.Hosting; + +/// +/// Фоновый цикл SSE-алертов ИИ-бюджета +/// +public sealed class BudgetAlertScheduler : IHostedService +{ + /// + /// Период проходов цикла — 60 с. + /// + public const int AlertPeriodSeconds = 60; + + private const string ToastEventType = "toast"; + + private const string Warned80ToastText = "ИИ-бюджет израсходован на 80%"; + + private const string ExhaustedToastText = "ИИ-бюджет исчерпан — обработка в локальном режиме"; + + private const string BellIcon = "bell"; + + private static readonly TimeSpan AlertPeriod = TimeSpan.FromSeconds(AlertPeriodSeconds); + + private readonly IServiceScopeFactory _scopeFactory; + private readonly SseBroker _broker; + private readonly ILogger _logger; + + // Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен). + private readonly CancellationTokenSource _shutdownCts = new(); + + private Timer? _timer; + private Task? _currentIteration; + private int _iterationInProgress; + + /// + /// Создаёт планировщик фоновых алертов ИИ-бюджета. + /// + /// Фабрика scope: проход цикла и проверка каждого тенанта — в собственных scope. + /// SSE-брокер каналов тенантов (публикация тостов; без подписчиков — no-op). + /// Логгер ошибок цикла. + public BudgetAlertScheduler( + IServiceScopeFactory scopeFactory, + SseBroker broker, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(scopeFactory); + ArgumentNullException.ThrowIfNull(broker); + ArgumentNullException.ThrowIfNull(logger); + _scopeFactory = scopeFactory; + _broker = broker; + _logger = logger; + } + + /// + public Task StartAsync(CancellationToken ct) + { + // Первый проход — сразу после старта (как StorageTickScheduler), далее каждые 60 с. + _timer = new Timer( + static state => ((BudgetAlertScheduler)state!).RunIteration(), + this, + TimeSpan.Zero, + AlertPeriod); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken ct) + { + // Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита + // остановки хоста (HostOptions.ShutdownTimeout). + _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _timer?.Dispose(); + _timer = null; + + _shutdownCts.Cancel(); + Task? iteration = Volatile.Read(ref _currentIteration); + if (iteration is not null) + { + try + { + await iteration.WaitAsync(ct); + } + catch (OperationCanceledException) + { + // Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри + // RunCycleCoreAsync, незавершённый проход безопасно завершится на отменённом токене. + } + } + } + + /// + /// Один проход цикла + /// + /// Задача прохода (завершается без исключений). + public Task RunCycleAsync(CancellationToken ct) + { + if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0) + { + return Task.CompletedTask; + } + + Task iteration = RunCycleCoreAsync(ct); + Volatile.Write(ref _currentIteration, iteration); + return iteration; + } + + // Запускает проход из callback таймера (guard — внутри RunCycleAsync). + private void RunIteration() + { + _ = RunCycleAsync(_shutdownCts.Token); + } + + // Тело прохода: проверка порогов каждого тенанта реестра; guard сбрасывается в finally. + // ct: Токен отмены (остановка хоста). + private async Task RunCycleCoreAsync(CancellationToken ct) + { + try + { + await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope(); + ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService(); + IReadOnlyList tenants = await tenantRepository.ListAsync(ct); + + foreach (TenantRecordDto tenant in tenants) + { + await CheckTenantAsync(tenant, ct); + } + } + catch (OperationCanceledException) + { + // Остановка хоста: проход прерван по токену — штатный выход, не ошибка. + } + catch (Exception exception) + { + // Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика. + _logger.LogError(exception, "Цикл бюджет-алертов: проход по тенантам не удался"); + } + finally + { + Interlocked.Exchange(ref _iterationInProgress, 0); + } + } + + // Проверка порогов одного тенанта в собственном scope: TryMark* (CAS) → тост в канал тенанта. + // Список тенантов и лимиты живут в публичной схеме — tenant-контекст не заполняется (в отличие от + // тиков StorageTickScheduler, работающих со схемой тенанта). Порядок: сначала 80% (Warned80), затем 100% + // (NotifiedExhausted) — при скачке через оба порога за период выходят оба тоста по одному разу. Сбой одного + // тенанта не валит проход: ошибка логируется, остальные тенанты обрабатываются; отмена (OCE) пробрасывается + // наверх — проход завершается. + // tenant: Тенант реестра (Id в формате Guid). + // ct: Токен отмены прохода. + private async Task CheckTenantAsync(TenantRecordDto tenant, CancellationToken ct) + { + await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope(); + try + { + ITenantLimitStore limitStore = tenantScope.ServiceProvider.GetRequiredService(); + + if (await limitStore.TryMarkWarnedAsync(tenant.Id, ct)) + { + _logger.LogDebug("Бюджет тенанта {TenantId}: порог 80% пересечён — SSE-тост", tenant.Id); + _broker.Publish(tenant.Id, ToastEventType, new { text = Warned80ToastText, icon = BellIcon }); + } + + if (await limitStore.TryMarkNotifiedExhaustedAsync(tenant.Id, ct)) + { + _logger.LogDebug("Бюджет тенанта {TenantId}: исчерпан — SSE-тост", tenant.Id); + _broker.Publish(tenant.Id, ToastEventType, new { text = ExhaustedToastText, icon = BellIcon }); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Цикл бюджет-алертов: проверка тенанта {TenantId} не удалась", tenant.Id); + } + } +} diff --git a/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs b/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs index 7177219..4ca6223 100644 --- a/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs +++ b/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs @@ -1,159 +1,159 @@ -using Deal.Api.Configuration; -using Deal.Modules.Tenants.Application.Abstractions; - -namespace Deal.Api.Hosting; - -/// -/// Фоновый цикл авто-очистки данных -/// -public sealed class DataRetentionScheduler : IHostedService -{ - /// - /// Период проходов цикла — 24 часа - /// - public const int RunPeriodHours = 24; - - /// - /// Задержка первого прохода — 60 с - /// - public const int InitialDelaySeconds = 60; - - /// - /// Фолбэк срока хранения аудита в днях, если конфигурация задала неположительное значение. - /// - public const int DefaultAuditRetentionDays = 180; - - private readonly IServiceScopeFactory _scopeFactory; - private readonly DataRetentionOptions _options; - private readonly ILogger _logger; - - // Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен). - private readonly CancellationTokenSource _shutdownCts = new(); - - private Timer? _timer; - private Task? _currentIteration; - private int _iterationInProgress; - - /// - /// Создаёт фоновый цикл авто-очистки. - /// - /// Фабрика scope: проход — в собственном scope (scoped-хранилища). - /// Настройки retention (секция DataRetention). - /// Логгер ошибок/итогов цикла. - public DataRetentionScheduler( - IServiceScopeFactory scopeFactory, - DataRetentionOptions options, - ILogger logger) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(options); - ArgumentNullException.ThrowIfNull(logger); - _scopeFactory = scopeFactory; - _options = options; - _logger = logger; - } - - /// - public Task StartAsync(CancellationToken ct) - { - if (!_options.Enabled) - { - _logger.LogInformation("Авто-очистка данных выключена (DataRetention:Enabled=false)"); - return Task.CompletedTask; - } - - _timer = new Timer( - static state => ((DataRetentionScheduler)state!).RunIteration(), - this, - TimeSpan.FromSeconds(InitialDelaySeconds), - TimeSpan.FromHours(RunPeriodHours)); - return Task.CompletedTask; - } - - /// - public async Task StopAsync(CancellationToken ct) - { - _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); - _timer?.Dispose(); - _timer = null; - - _shutdownCts.Cancel(); - Task? iteration = Volatile.Read(ref _currentIteration); - if (iteration is not null) - { - try - { - await iteration.WaitAsync(ct); - } - catch (OperationCanceledException) - { - // Лимит остановки истёк — ошибки прохода проглочены внутри RunCycleCoreAsync. - } - } - } - - /// - /// Один проход очистки - /// - /// Задача прохода (завершается без исключений). - public Task RunCycleAsync(CancellationToken ct) - { - if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0) - { - return Task.CompletedTask; - } - - Task iteration = RunCycleCoreAsync(ct); - Volatile.Write(ref _currentIteration, iteration); - return iteration; - } - - // Запускает проход из callback таймера (guard — внутри RunCycleAsync). - private void RunIteration() - { - _ = RunCycleAsync(_shutdownCts.Token); - } - - // Тело прохода: аудит по retention + сброс лимитов прошедших периодов + уборка счётчиков. - // ct: Токен отмены (остановка хоста). - private async Task RunCycleCoreAsync(CancellationToken ct) - { - try - { - await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); - DateTimeOffset now = DateTimeOffset.UtcNow; - - int retentionDays = _options.AuditRetentionDays > 0 - ? _options.AuditRetentionDays - : DefaultAuditRetentionDays; - var auditStore = scope.ServiceProvider.GetRequiredService(); - int purgedAudit = await auditStore.PurgeOlderThanAsync(now.AddDays(-retentionDays), ct); - - var limitStore = scope.ServiceProvider.GetRequiredService(); - int resetLimits = await limitStore.ResetExpiredPeriodsAsync(now, ct); - - var counterStore = scope.ServiceProvider.GetRequiredService(); - int purgedCounters = await counterStore.DeleteExpiredAsync(now, ct); - - _logger.LogInformation( - "Авто-очистка данных: аудит {Audit} записей старше {Days} дн., сброшено лимитов {Limits}, " - + "убрано счётчиков {Counters}", - purgedAudit, - retentionDays, - resetLimits, - purgedCounters); - } - catch (OperationCanceledException) - { - // Остановка хоста: проход прерван по токену — штатный выход, не ошибка. - } - catch (Exception exception) - { - _logger.LogError(exception, "Авто-очистка данных: проход не удался"); - } - finally - { - Interlocked.Exchange(ref _iterationInProgress, 0); - } - } -} +using Deal.Api.Configuration; +using Deal.Modules.Tenants.Application.Abstractions; + +namespace Deal.Api.Hosting; + +/// +/// Фоновый цикл авто-очистки данных +/// +public sealed class DataRetentionScheduler : IHostedService +{ + /// + /// Период проходов цикла — 24 часа + /// + public const int RunPeriodHours = 24; + + /// + /// Задержка первого прохода — 60 с + /// + public const int InitialDelaySeconds = 60; + + /// + /// Фолбэк срока хранения аудита в днях, если конфигурация задала неположительное значение. + /// + public const int DefaultAuditRetentionDays = 180; + + private readonly IServiceScopeFactory _scopeFactory; + private readonly DataRetentionOptions _options; + private readonly ILogger _logger; + + // Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен). + private readonly CancellationTokenSource _shutdownCts = new(); + + private Timer? _timer; + private Task? _currentIteration; + private int _iterationInProgress; + + /// + /// Создаёт фоновый цикл авто-очистки. + /// + /// Фабрика scope: проход — в собственном scope (scoped-хранилища). + /// Настройки retention (секция DataRetention). + /// Логгер ошибок/итогов цикла. + public DataRetentionScheduler( + IServiceScopeFactory scopeFactory, + DataRetentionOptions options, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(scopeFactory); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(logger); + _scopeFactory = scopeFactory; + _options = options; + _logger = logger; + } + + /// + public Task StartAsync(CancellationToken ct) + { + if (!_options.Enabled) + { + _logger.LogInformation("Авто-очистка данных выключена (DataRetention:Enabled=false)"); + return Task.CompletedTask; + } + + _timer = new Timer( + static state => ((DataRetentionScheduler)state!).RunIteration(), + this, + TimeSpan.FromSeconds(InitialDelaySeconds), + TimeSpan.FromHours(RunPeriodHours)); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken ct) + { + _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _timer?.Dispose(); + _timer = null; + + _shutdownCts.Cancel(); + Task? iteration = Volatile.Read(ref _currentIteration); + if (iteration is not null) + { + try + { + await iteration.WaitAsync(ct); + } + catch (OperationCanceledException) + { + // Лимит остановки истёк — ошибки прохода проглочены внутри RunCycleCoreAsync. + } + } + } + + /// + /// Один проход очистки + /// + /// Задача прохода (завершается без исключений). + public Task RunCycleAsync(CancellationToken ct) + { + if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0) + { + return Task.CompletedTask; + } + + Task iteration = RunCycleCoreAsync(ct); + Volatile.Write(ref _currentIteration, iteration); + return iteration; + } + + // Запускает проход из callback таймера (guard — внутри RunCycleAsync). + private void RunIteration() + { + _ = RunCycleAsync(_shutdownCts.Token); + } + + // Тело прохода: аудит по retention + сброс лимитов прошедших периодов + уборка счётчиков. + // ct: Токен отмены (остановка хоста). + private async Task RunCycleCoreAsync(CancellationToken ct) + { + try + { + await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); + DateTimeOffset now = DateTimeOffset.UtcNow; + + int retentionDays = _options.AuditRetentionDays > 0 + ? _options.AuditRetentionDays + : DefaultAuditRetentionDays; + var auditStore = scope.ServiceProvider.GetRequiredService(); + int purgedAudit = await auditStore.PurgeOlderThanAsync(now.AddDays(-retentionDays), ct); + + var limitStore = scope.ServiceProvider.GetRequiredService(); + int resetLimits = await limitStore.ResetExpiredPeriodsAsync(now, ct); + + var counterStore = scope.ServiceProvider.GetRequiredService(); + int purgedCounters = await counterStore.DeleteExpiredAsync(now, ct); + + _logger.LogInformation( + "Авто-очистка данных: аудит {Audit} записей старше {Days} дн., сброшено лимитов {Limits}, " + + "убрано счётчиков {Counters}", + purgedAudit, + retentionDays, + resetLimits, + purgedCounters); + } + catch (OperationCanceledException) + { + // Остановка хоста: проход прерван по токену — штатный выход, не ошибка. + } + catch (Exception exception) + { + _logger.LogError(exception, "Авто-очистка данных: проход не удался"); + } + finally + { + Interlocked.Exchange(ref _iterationInProgress, 0); + } + } +} diff --git a/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs b/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs index aeb8f9a..007d098 100644 --- a/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs +++ b/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs @@ -1,171 +1,171 @@ -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; - -namespace Deal.Api.Hosting; - -/// -/// Фоновый цикл Discovery-воркера по всем тенантам. -/// -public sealed class DiscoveryWorkerScheduler : IHostedService -{ - private const int DiscoveryPeriodSeconds = 5; - - private static readonly TimeSpan DiscoveryPeriod = TimeSpan.FromSeconds(DiscoveryPeriodSeconds); - - private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; - - // Отмена при остановке хоста: прерывает текущий проход (EF-запросы тика наблюдают токен). - private readonly CancellationTokenSource _shutdownCts = new(); - - private Timer? _timer; - private Task? _currentIteration; - private int _iterationInProgress; - - /// - /// Создаёт планировщик фонового цикла Discovery. - /// - /// Фабрика scope: проход цикла и тик каждого тенанта — в собственных scope. - /// Логгер ошибок цикла. - public DiscoveryWorkerScheduler(IServiceScopeFactory scopeFactory, ILogger logger) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(logger); - _scopeFactory = scopeFactory; - _logger = logger; - } - - /// - public Task StartAsync(CancellationToken ct) - { - // Первый проход — сразу после старта (как StorageTickScheduler), далее каждые 5 с. - _timer = new Timer( - static state => ((DiscoveryWorkerScheduler)state!).RunIteration(), - this, - TimeSpan.Zero, - DiscoveryPeriod); - return Task.CompletedTask; - } - - /// - public async Task StopAsync(CancellationToken ct) - { - // Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита - // остановки хоста (HostOptions.ShutdownTimeout). - _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); - _timer?.Dispose(); - _timer = null; - - _shutdownCts.Cancel(); - Task? iteration = Volatile.Read(ref _currentIteration); - if (iteration is not null) - { - try - { - await iteration.WaitAsync(ct); - } - catch (OperationCanceledException) - { - // Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри - // RunCycleAsync, незавершённый проход безопасно завершится на отменённом токене. - } - } - } - - /// - /// Один проход цикла - /// - /// Задача прохода (завершается без исключений). - public Task RunCycleAsync(CancellationToken ct) - { - if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0) - { - return Task.CompletedTask; - } - - Task iteration = RunCycleCoreAsync(ct); - Volatile.Write(ref _currentIteration, iteration); - return iteration; - } - - // Запускает проход из callback таймера (guard — внутри RunCycleAsync). - private void RunIteration() - { - _ = RunCycleAsync(_shutdownCts.Token); - } - - // Тело прохода: тик Discovery каждого тенанта реестра; guard сбрасывается в finally. - // ct: Токен отмены (остановка хоста). - private async Task RunCycleCoreAsync(CancellationToken ct) - { - try - { - await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope(); - ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService(); - IReadOnlyList tenants = await tenantRepository.ListAsync(ct); - - foreach (TenantRecordDto tenant in tenants) - { - await TickTenantAsync(tenant, ct); - } - } - catch (OperationCanceledException) - { - // Остановка хоста: проход прерван по токену — штатный выход, не ошибка. - } - catch (Exception exception) - { - // Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика. - _logger.LogError(exception, "Цикл Discovery: проход по тенантам не удался"); - } - finally - { - Interlocked.Exchange(ref _iterationInProgress, 0); - } - } - - // Тик одного тенанта в собственном scope: SetTenant → один тик воркера; Reset в finally. - // Контекст AsyncLocal сбрасывается в finally, чтобы не переживать scope тенанта (как - // SessionMiddleware). Тик одного тенанта не валит проход: ошибка логируется, остальные тенанты - // обрабатываются; отмена (OCE) пробрасывается наверх — проход завершается. Результат «none» — тихий - // no-op (пауза/нет работы); прочие действия логируются в Debug (рабочие события — в DiscLog задачи). - // tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>). - // ct: Токен отмены прохода. - private async Task TickTenantAsync(TenantRecordDto tenant, CancellationToken ct) - { - await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope(); - ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); - try - { - tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); - - // Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта. - DiscoveryWorkerService worker = tenantScope.ServiceProvider.GetRequiredService(); - DiscoveryWorkerOutcome outcome = await worker.TickOnceAsync(ct); - if (outcome.Action != DiscoveryWorkerService.ActionNone) - { - _logger.LogDebug( - "Discovery-тик {TenantId}: {Action} (задача {TaskId})", - tenant.Id, - outcome.Action, - outcome.TaskId ?? "-"); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - _logger.LogWarning(exception, "Цикл Discovery: тик тенанта {TenantId} не удался", tenant.Id); - } - finally - { - tenantContext.Reset(); - } - } -} +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; + +namespace Deal.Api.Hosting; + +/// +/// Фоновый цикл Discovery-воркера по всем тенантам. +/// +public sealed class DiscoveryWorkerScheduler : IHostedService +{ + private const int DiscoveryPeriodSeconds = 5; + + private static readonly TimeSpan DiscoveryPeriod = TimeSpan.FromSeconds(DiscoveryPeriodSeconds); + + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + // Отмена при остановке хоста: прерывает текущий проход (EF-запросы тика наблюдают токен). + private readonly CancellationTokenSource _shutdownCts = new(); + + private Timer? _timer; + private Task? _currentIteration; + private int _iterationInProgress; + + /// + /// Создаёт планировщик фонового цикла Discovery. + /// + /// Фабрика scope: проход цикла и тик каждого тенанта — в собственных scope. + /// Логгер ошибок цикла. + public DiscoveryWorkerScheduler(IServiceScopeFactory scopeFactory, ILogger logger) + { + ArgumentNullException.ThrowIfNull(scopeFactory); + ArgumentNullException.ThrowIfNull(logger); + _scopeFactory = scopeFactory; + _logger = logger; + } + + /// + public Task StartAsync(CancellationToken ct) + { + // Первый проход — сразу после старта (как StorageTickScheduler), далее каждые 5 с. + _timer = new Timer( + static state => ((DiscoveryWorkerScheduler)state!).RunIteration(), + this, + TimeSpan.Zero, + DiscoveryPeriod); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken ct) + { + // Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита + // остановки хоста (HostOptions.ShutdownTimeout). + _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _timer?.Dispose(); + _timer = null; + + _shutdownCts.Cancel(); + Task? iteration = Volatile.Read(ref _currentIteration); + if (iteration is not null) + { + try + { + await iteration.WaitAsync(ct); + } + catch (OperationCanceledException) + { + // Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри + // RunCycleAsync, незавершённый проход безопасно завершится на отменённом токене. + } + } + } + + /// + /// Один проход цикла + /// + /// Задача прохода (завершается без исключений). + public Task RunCycleAsync(CancellationToken ct) + { + if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0) + { + return Task.CompletedTask; + } + + Task iteration = RunCycleCoreAsync(ct); + Volatile.Write(ref _currentIteration, iteration); + return iteration; + } + + // Запускает проход из callback таймера (guard — внутри RunCycleAsync). + private void RunIteration() + { + _ = RunCycleAsync(_shutdownCts.Token); + } + + // Тело прохода: тик Discovery каждого тенанта реестра; guard сбрасывается в finally. + // ct: Токен отмены (остановка хоста). + private async Task RunCycleCoreAsync(CancellationToken ct) + { + try + { + await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope(); + ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService(); + IReadOnlyList tenants = await tenantRepository.ListAsync(ct); + + foreach (TenantRecordDto tenant in tenants) + { + await TickTenantAsync(tenant, ct); + } + } + catch (OperationCanceledException) + { + // Остановка хоста: проход прерван по токену — штатный выход, не ошибка. + } + catch (Exception exception) + { + // Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика. + _logger.LogError(exception, "Цикл Discovery: проход по тенантам не удался"); + } + finally + { + Interlocked.Exchange(ref _iterationInProgress, 0); + } + } + + // Тик одного тенанта в собственном scope: SetTenant → один тик воркера; Reset в finally. + // Контекст AsyncLocal сбрасывается в finally, чтобы не переживать scope тенанта (как + // SessionMiddleware). Тик одного тенанта не валит проход: ошибка логируется, остальные тенанты + // обрабатываются; отмена (OCE) пробрасывается наверх — проход завершается. Результат «none» — тихий + // no-op (пауза/нет работы); прочие действия логируются в Debug (рабочие события — в DiscLog задачи). + // tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>). + // ct: Токен отмены прохода. + private async Task TickTenantAsync(TenantRecordDto tenant, CancellationToken ct) + { + await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope(); + ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); + try + { + tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); + + // Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта. + DiscoveryWorkerService worker = tenantScope.ServiceProvider.GetRequiredService(); + DiscoveryWorkerOutcome outcome = await worker.TickOnceAsync(ct); + if (outcome.Action != DiscoveryWorkerService.ActionNone) + { + _logger.LogDebug( + "Discovery-тик {TenantId}: {Action} (задача {TaskId})", + tenant.Id, + outcome.Action, + outcome.TaskId ?? "-"); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Цикл Discovery: тик тенанта {TenantId} не удался", tenant.Id); + } + finally + { + tenantContext.Reset(); + } + } +} diff --git a/src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs b/src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs index 2d723eb..975cabe 100644 --- a/src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs +++ b/src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs @@ -1,204 +1,204 @@ -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; - -namespace Deal.Api.Hosting; - -/// -/// Фоновый флашер очереди обучения ML — выгрузка MlOutbox в ml-service батчами. -/// -public sealed class MlOutboxFlushScheduler : IHostedService -{ - /// - /// Период циклов выгрузки — 10 с. - /// - public const int FlushPeriodSeconds = 10; - - /// - /// Размер порции за один TrainBatch — 10 строк. - /// - public const int BatchSize = 10; - - /// - /// Потолок выгрузки за один цикл тенанта — 100 строк. - /// - public const int MaxPerCycle = 100; - - private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; - - // Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен). - private readonly CancellationTokenSource _shutdownCts = new(); - - private Timer? _timer; - private Task? _currentIteration; - private int _iterationInProgress; - - /// - /// Создаёт планировщик фоновой выгрузки очереди обучения ML. - /// - /// Фабрика scope: проход цикла и флаш каждого тенанта — в собственных scope. - /// Логгер сбоев цикла. - public MlOutboxFlushScheduler(IServiceScopeFactory scopeFactory, ILogger logger) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(logger); - _scopeFactory = scopeFactory; - _logger = logger; - } - - /// - public Task StartAsync(CancellationToken ct) - { - // Первый проход — сразу после старта (как PipelineWorkerScheduler), далее каждые 10 с. - _timer = new Timer( - static state => ((MlOutboxFlushScheduler)state!).RunIteration(), - this, - TimeSpan.Zero, - TimeSpan.FromSeconds(FlushPeriodSeconds)); - return Task.CompletedTask; - } - - /// - public async Task StopAsync(CancellationToken ct) - { - // Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита - // остановки хоста (HostOptions.ShutdownTimeout). - _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); - _timer?.Dispose(); - _timer = null; - - _shutdownCts.Cancel(); - Task? iteration = Volatile.Read(ref _currentIteration); - if (iteration is not null) - { - try - { - await iteration.WaitAsync(ct); - } - catch (OperationCanceledException) - { - // Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри - // FlushCycleCoreAsync, незавершённый проход безопасно завершится на отменённом токене. - } - } - } - - /// - /// Один проход цикла - /// - /// Задача прохода (завершается без исключений). - public Task RunCycleAsync(CancellationToken ct) - { - if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0) - { - return Task.CompletedTask; - } - - Task iteration = FlushCycleCoreAsync(ct); - Volatile.Write(ref _currentIteration, iteration); - return iteration; - } - - // Запускает проход из callback таймера (guard — внутри RunCycleAsync). - private void RunIteration() - { - _ = RunCycleAsync(_shutdownCts.Token); - } - - // Тело прохода: выгрузка очереди каждого тенанта реестра; guard сбрасывается в finally. - // ct: Токен отмены (остановка хоста). - private async Task FlushCycleCoreAsync(CancellationToken ct) - { - try - { - await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope(); - ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService(); - IReadOnlyList tenants = await tenantRepository.ListAsync(ct); - - foreach (TenantRecordDto tenant in tenants) - { - await FlushTenantAsync(tenant, ct); - } - } - catch (OperationCanceledException) - { - // Остановка хоста: проход прерван по токену — штатный выход, не ошибка. - } - catch (Exception exception) - { - // Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика. - _logger.LogError(exception, "Флашер ML-outbox: проход по тенантам не удался"); - } - finally - { - Interlocked.Exchange(ref _iterationInProgress, 0); - } - } - - private async Task FlushTenantAsync(TenantRecordDto tenant, CancellationToken ct) - { - await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope(); - ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); - try - { - tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); - - // Resolve ПОСЛЕ SetTenant: IMlLearningStore строится от схемы текущего тенанта (как в цикле pump). - IMlLearningStore learningStore = tenantScope.ServiceProvider.GetRequiredService(); - IMlTrainClient trainClient = tenantScope.ServiceProvider.GetRequiredService(); - - int total = 0; - while (total < MaxPerCycle) - { - IReadOnlyList rows = await learningStore.TakeOutboxBatchAsync(BatchSize, ct); - if (rows.Count == 0) - { - break; - } - - try - { - await trainClient.TrainBatchAsync(rows, ct); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - _logger.LogWarning( - exception, - "Флашер ML-outbox: отправка {RowCount} строк тенанта {TenantId} не удалась — строки остались", - rows.Count, - tenant.Id); - break; - } - - await learningStore.DeleteOutboxAsync(rows.Select(row => row.Id).ToList(), ct); - total += rows.Count; - } - - if (total > 0) - { - _logger.LogInformation("Флашер ML-outbox: выгружено {RowCount} строк тенанта {TenantId}", total, tenant.Id); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - _logger.LogWarning(exception, "Флашер ML-outbox: выгрузка тенанта {TenantId} не удалась", tenant.Id); - } - finally - { - tenantContext.Reset(); - } - } -} +using Deal.Infrastructure.Integrations.Abstractions; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; + +namespace Deal.Api.Hosting; + +/// +/// Фоновый флашер очереди обучения ML — выгрузка MlOutbox в ml-service батчами. +/// +public sealed class MlOutboxFlushScheduler : IHostedService +{ + /// + /// Период циклов выгрузки — 10 с. + /// + public const int FlushPeriodSeconds = 10; + + /// + /// Размер порции за один TrainBatch — 10 строк. + /// + public const int BatchSize = 10; + + /// + /// Потолок выгрузки за один цикл тенанта — 100 строк. + /// + public const int MaxPerCycle = 100; + + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + // Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен). + private readonly CancellationTokenSource _shutdownCts = new(); + + private Timer? _timer; + private Task? _currentIteration; + private int _iterationInProgress; + + /// + /// Создаёт планировщик фоновой выгрузки очереди обучения ML. + /// + /// Фабрика scope: проход цикла и флаш каждого тенанта — в собственных scope. + /// Логгер сбоев цикла. + public MlOutboxFlushScheduler(IServiceScopeFactory scopeFactory, ILogger logger) + { + ArgumentNullException.ThrowIfNull(scopeFactory); + ArgumentNullException.ThrowIfNull(logger); + _scopeFactory = scopeFactory; + _logger = logger; + } + + /// + public Task StartAsync(CancellationToken ct) + { + // Первый проход — сразу после старта (как PipelineWorkerScheduler), далее каждые 10 с. + _timer = new Timer( + static state => ((MlOutboxFlushScheduler)state!).RunIteration(), + this, + TimeSpan.Zero, + TimeSpan.FromSeconds(FlushPeriodSeconds)); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken ct) + { + // Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита + // остановки хоста (HostOptions.ShutdownTimeout). + _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _timer?.Dispose(); + _timer = null; + + _shutdownCts.Cancel(); + Task? iteration = Volatile.Read(ref _currentIteration); + if (iteration is not null) + { + try + { + await iteration.WaitAsync(ct); + } + catch (OperationCanceledException) + { + // Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри + // FlushCycleCoreAsync, незавершённый проход безопасно завершится на отменённом токене. + } + } + } + + /// + /// Один проход цикла + /// + /// Задача прохода (завершается без исключений). + public Task RunCycleAsync(CancellationToken ct) + { + if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0) + { + return Task.CompletedTask; + } + + Task iteration = FlushCycleCoreAsync(ct); + Volatile.Write(ref _currentIteration, iteration); + return iteration; + } + + // Запускает проход из callback таймера (guard — внутри RunCycleAsync). + private void RunIteration() + { + _ = RunCycleAsync(_shutdownCts.Token); + } + + // Тело прохода: выгрузка очереди каждого тенанта реестра; guard сбрасывается в finally. + // ct: Токен отмены (остановка хоста). + private async Task FlushCycleCoreAsync(CancellationToken ct) + { + try + { + await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope(); + ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService(); + IReadOnlyList tenants = await tenantRepository.ListAsync(ct); + + foreach (TenantRecordDto tenant in tenants) + { + await FlushTenantAsync(tenant, ct); + } + } + catch (OperationCanceledException) + { + // Остановка хоста: проход прерван по токену — штатный выход, не ошибка. + } + catch (Exception exception) + { + // Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика. + _logger.LogError(exception, "Флашер ML-outbox: проход по тенантам не удался"); + } + finally + { + Interlocked.Exchange(ref _iterationInProgress, 0); + } + } + + private async Task FlushTenantAsync(TenantRecordDto tenant, CancellationToken ct) + { + await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope(); + ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); + try + { + tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); + + // Resolve ПОСЛЕ SetTenant: IMlLearningStore строится от схемы текущего тенанта (как в цикле pump). + IMlLearningStore learningStore = tenantScope.ServiceProvider.GetRequiredService(); + IMlTrainClient trainClient = tenantScope.ServiceProvider.GetRequiredService(); + + int total = 0; + while (total < MaxPerCycle) + { + IReadOnlyList rows = await learningStore.TakeOutboxBatchAsync(BatchSize, ct); + if (rows.Count == 0) + { + break; + } + + try + { + await trainClient.TrainBatchAsync(rows, ct); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Флашер ML-outbox: отправка {RowCount} строк тенанта {TenantId} не удалась — строки остались", + rows.Count, + tenant.Id); + break; + } + + await learningStore.DeleteOutboxAsync(rows.Select(row => row.Id).ToList(), ct); + total += rows.Count; + } + + if (total > 0) + { + _logger.LogInformation("Флашер ML-outbox: выгружено {RowCount} строк тенанта {TenantId}", total, tenant.Id); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Флашер ML-outbox: выгрузка тенанта {TenantId} не удалась", tenant.Id); + } + finally + { + tenantContext.Reset(); + } + } +} diff --git a/src/core/Deal.Api/Hosting/StorageTickScheduler.cs b/src/core/Deal.Api/Hosting/StorageTickScheduler.cs index bb7dd97..f98b463 100644 --- a/src/core/Deal.Api/Hosting/StorageTickScheduler.cs +++ b/src/core/Deal.Api/Hosting/StorageTickScheduler.cs @@ -1,199 +1,199 @@ -using Deal.Api.Events; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; - -namespace Deal.Api.Hosting; - -/// -/// Фоновый цикл правил хранения по всем тенантам. -/// -public sealed class StorageTickScheduler : IHostedService -{ - private const int TickPeriodSeconds = 30; - - private const string ReminderDueEventType = "reminder_due"; - - private static readonly TimeSpan TickPeriod = TimeSpan.FromSeconds(TickPeriodSeconds); - - private readonly IServiceScopeFactory _scopeFactory; - private readonly StorageToastPublisher _toastPublisher; - private readonly SseBroker _broker; - private readonly ILogger _logger; - - // Отмена при остановке хоста: прерывает текущий проход (EF-запросы тика наблюдают токен). - private readonly CancellationTokenSource _shutdownCts = new(); - - private Timer? _timer; - private Task? _currentIteration; - private int _iterationInProgress; - - /// - /// Создаёт планировщик фонового цикла правил хранения. - /// - /// Фабрика scope: проход цикла и тик каждого тенанта — в собственных scope. - /// Публикатор SSE-тостов статистики тика (общий с POST /api/admin/tick). - /// SSE-брокер каналов тенантов: reminder_due «выстреливших» напоминаний в канал тенанта. - /// Логгер ошибок цикла. - public StorageTickScheduler( - IServiceScopeFactory scopeFactory, - StorageToastPublisher toastPublisher, - SseBroker broker, - ILogger logger) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(toastPublisher); - ArgumentNullException.ThrowIfNull(broker); - ArgumentNullException.ThrowIfNull(logger); - _scopeFactory = scopeFactory; - _toastPublisher = toastPublisher; - _broker = broker; - _logger = logger; - } - - /// - public Task StartAsync(CancellationToken ct) - { - _timer = new Timer( - static state => ((StorageTickScheduler)state!).RunIteration(), - this, - TimeSpan.Zero, - TickPeriod); - return Task.CompletedTask; - } - - /// - public async Task StopAsync(CancellationToken ct) - { - // Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита - // остановки хоста (HostOptions.ShutdownTimeout). - _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); - _timer?.Dispose(); - _timer = null; - - _shutdownCts.Cancel(); - Task? iteration = Volatile.Read(ref _currentIteration); - if (iteration is not null) - { - try - { - await iteration.WaitAsync(ct); - } - catch (OperationCanceledException) - { - // Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри - // RunCycleAsync, незавершённый проход безопасно завершится на отменённом токене. - } - } - } - - /// - /// Один проход цикла - /// - /// Задача прохода (завершается без исключений). - public Task RunCycleAsync(CancellationToken ct) - { - if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0) - { - return Task.CompletedTask; - } - - Task iteration = RunCycleCoreAsync(ct); - Volatile.Write(ref _currentIteration, iteration); - return iteration; - } - - // Запускает проход из callback таймера (guard — внутри RunCycleAsync). - private void RunIteration() - { - _ = RunCycleAsync(_shutdownCts.Token); - } - - // Тело прохода: тик каждого тенанта реестра; guard сбрасывается в finally. - // ct: Токен отмены (остановка хоста). - private async Task RunCycleCoreAsync(CancellationToken ct) - { - try - { - await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope(); - ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService(); - IReadOnlyList tenants = await tenantRepository.ListAsync(ct); - - foreach (TenantRecordDto tenant in tenants) - { - await TickTenantAsync(tenant, ct); - } - } - catch (OperationCanceledException) - { - // Остановка хоста: проход прерван по токену — штатный выход, не ошибка. - } - catch (Exception exception) - { - // Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика. - _logger.LogError(exception, "Цикл правил хранения: проход по тенантам не удался"); - } - finally - { - Interlocked.Exchange(ref _iterationInProgress, 0); - } - } - - private async Task TickTenantAsync(TenantRecordDto tenant, CancellationToken ct) - { - await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope(); - ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); - try - { - tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); - - // Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта. - StorageTickService tickService = tenantScope.ServiceProvider.GetRequiredService(); - StorageTickStatsDto stats = await tickService.TickAsync(ct); - - PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService(); - int purgedRejected = await processing.PurgeExpiredAsync(ct); - StorageTickStatsDto mergedStats = stats with { PurgedRejected = purgedRejected }; - - _toastPublisher.PublishTickToasts(tenant.Id, mergedStats); - - IReadOnlyList dueReminders; - try - { - CardsService reminders = tenantScope.ServiceProvider.GetRequiredService(); - dueReminders = await reminders.CheckDueRemindersAsync(ct); - } - catch (OperationCanceledException) - { - // Остановка хоста — прерываем проход штатно (не «сбой проверки напоминаний»). - throw; - } - catch (Exception exception) - { - _logger.LogWarning(exception, "Цикл правил хранения: проверка напоминаний тенанта {TenantId} не удалась", tenant.Id); - dueReminders = Array.Empty(); - } - - foreach (CardReminderDueDto due in dueReminders) - { - _broker.Publish(tenant.Id, ReminderDueEventType, due); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - _logger.LogWarning(exception, "Цикл правил хранения: тик тенанта {TenantId} не удался", tenant.Id); - } - finally - { - tenantContext.Reset(); - } - } -} +using Deal.Api.Events; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; + +namespace Deal.Api.Hosting; + +/// +/// Фоновый цикл правил хранения по всем тенантам. +/// +public sealed class StorageTickScheduler : IHostedService +{ + private const int TickPeriodSeconds = 30; + + private const string ReminderDueEventType = "reminder_due"; + + private static readonly TimeSpan TickPeriod = TimeSpan.FromSeconds(TickPeriodSeconds); + + private readonly IServiceScopeFactory _scopeFactory; + private readonly StorageToastPublisher _toastPublisher; + private readonly SseBroker _broker; + private readonly ILogger _logger; + + // Отмена при остановке хоста: прерывает текущий проход (EF-запросы тика наблюдают токен). + private readonly CancellationTokenSource _shutdownCts = new(); + + private Timer? _timer; + private Task? _currentIteration; + private int _iterationInProgress; + + /// + /// Создаёт планировщик фонового цикла правил хранения. + /// + /// Фабрика scope: проход цикла и тик каждого тенанта — в собственных scope. + /// Публикатор SSE-тостов статистики тика (общий с POST /api/admin/tick). + /// SSE-брокер каналов тенантов: reminder_due «выстреливших» напоминаний в канал тенанта. + /// Логгер ошибок цикла. + public StorageTickScheduler( + IServiceScopeFactory scopeFactory, + StorageToastPublisher toastPublisher, + SseBroker broker, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(scopeFactory); + ArgumentNullException.ThrowIfNull(toastPublisher); + ArgumentNullException.ThrowIfNull(broker); + ArgumentNullException.ThrowIfNull(logger); + _scopeFactory = scopeFactory; + _toastPublisher = toastPublisher; + _broker = broker; + _logger = logger; + } + + /// + public Task StartAsync(CancellationToken ct) + { + _timer = new Timer( + static state => ((StorageTickScheduler)state!).RunIteration(), + this, + TimeSpan.Zero, + TickPeriod); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken ct) + { + // Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита + // остановки хоста (HostOptions.ShutdownTimeout). + _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _timer?.Dispose(); + _timer = null; + + _shutdownCts.Cancel(); + Task? iteration = Volatile.Read(ref _currentIteration); + if (iteration is not null) + { + try + { + await iteration.WaitAsync(ct); + } + catch (OperationCanceledException) + { + // Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри + // RunCycleAsync, незавершённый проход безопасно завершится на отменённом токене. + } + } + } + + /// + /// Один проход цикла + /// + /// Задача прохода (завершается без исключений). + public Task RunCycleAsync(CancellationToken ct) + { + if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0) + { + return Task.CompletedTask; + } + + Task iteration = RunCycleCoreAsync(ct); + Volatile.Write(ref _currentIteration, iteration); + return iteration; + } + + // Запускает проход из callback таймера (guard — внутри RunCycleAsync). + private void RunIteration() + { + _ = RunCycleAsync(_shutdownCts.Token); + } + + // Тело прохода: тик каждого тенанта реестра; guard сбрасывается в finally. + // ct: Токен отмены (остановка хоста). + private async Task RunCycleCoreAsync(CancellationToken ct) + { + try + { + await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope(); + ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService(); + IReadOnlyList tenants = await tenantRepository.ListAsync(ct); + + foreach (TenantRecordDto tenant in tenants) + { + await TickTenantAsync(tenant, ct); + } + } + catch (OperationCanceledException) + { + // Остановка хоста: проход прерван по токену — штатный выход, не ошибка. + } + catch (Exception exception) + { + // Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика. + _logger.LogError(exception, "Цикл правил хранения: проход по тенантам не удался"); + } + finally + { + Interlocked.Exchange(ref _iterationInProgress, 0); + } + } + + private async Task TickTenantAsync(TenantRecordDto tenant, CancellationToken ct) + { + await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope(); + ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); + try + { + tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); + + // Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта. + StorageTickService tickService = tenantScope.ServiceProvider.GetRequiredService(); + StorageTickStatsDto stats = await tickService.TickAsync(ct); + + PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService(); + int purgedRejected = await processing.PurgeExpiredAsync(ct); + StorageTickStatsDto mergedStats = stats with { PurgedRejected = purgedRejected }; + + _toastPublisher.PublishTickToasts(tenant.Id, mergedStats); + + IReadOnlyList dueReminders; + try + { + CardsService reminders = tenantScope.ServiceProvider.GetRequiredService(); + dueReminders = await reminders.CheckDueRemindersAsync(ct); + } + catch (OperationCanceledException) + { + // Остановка хоста — прерываем проход штатно (не «сбой проверки напоминаний»). + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Цикл правил хранения: проверка напоминаний тенанта {TenantId} не удалась", tenant.Id); + dueReminders = Array.Empty(); + } + + foreach (CardReminderDueDto due in dueReminders) + { + _broker.Publish(tenant.Id, ReminderDueEventType, due); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Цикл правил хранения: тик тенанта {TenantId} не удался", tenant.Id); + } + finally + { + tenantContext.Reset(); + } + } +} diff --git a/src/core/Deal.Api/Logging/DealLogging.cs b/src/core/Deal.Api/Logging/DealLogging.cs index b6d7853..7bd21e6 100644 --- a/src/core/Deal.Api/Logging/DealLogging.cs +++ b/src/core/Deal.Api/Logging/DealLogging.cs @@ -1,104 +1,104 @@ -using Serilog; -using Serilog.Events; -using Serilog.Formatting.Compact; - -namespace Deal.Api.Logging; - -internal static class DealLogging -{ - // Env-ключ минимального уровня Serilog (Debug/Information/Warning/Error; дефолт Information). - private const string MinimumLevelEnvKey = "DEAL_LOG_LEVEL"; - - // Env-ключ каталога rolling-файлов (дефолт data/logs под ContentRoot). - private const string LogsDirectoryEnvKey = "DEAL_LOGS_DIR"; - - // Каталог логов по умолчанию (относительно ContentRoot): core — внутри volume /app/data. - private const string DefaultLogsSubdirectory = "data/logs"; - - // Шаблон имени rolling-файла (Serilog добавляет дату перед расширением): deal-core-20260908.json. - private const string LogFileNameTemplate = "deal-{0}-.json"; - - // Сколько rolling-файлов хранится (суток); старшие удаляются Serilog автоматически. - private const int RetainedFileCount = 30; - - // Текстовая разметка консоли в Development (цвета — дефолтной темой Serilog). - private const string DevelopmentConsoleTemplate = - "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"; - - // Категория EF Core: команды SQL логируются на Warning+ (шум запросов не попадает в Loki). - private const string EntityFrameworkCoreCategory = "Microsoft.EntityFrameworkCore"; - - // Категория Grpc.AspNetCore: не ниже Information (внутренние Debug-события вызовов не дублируют access-лог). - private const string GrpcCategory = "Grpc"; - - // Дефолтный уровень при пустом/невалидном env DEAL_LOG_LEVEL. - private const LogEventLevel DefaultMinimumLevel = LogEventLevel.Information; - - /// - /// Подключает Serilog к хосту - /// - /// Билдер WebApplication процесса (до Build). - /// Имя процесса для имени файла-лога (core/telegram/ai/ml). - public static void Configure(WebApplicationBuilder builder, string processName) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentException.ThrowIfNullOrWhiteSpace(processName); - builder.Host.UseSerilog((context, loggerConfiguration) => - Apply(loggerConfiguration, context.HostingEnvironment, context.Configuration, processName)); - } - - // Собирает LoggerConfiguration процесса: уровень/фильтры, rolling-файл, консоль. - // loggerConfiguration: Конфигурация Serilog (до CreateLogger). - // environment: Окружение хоста (Development — текстовая консоль). - // configuration: Конфигурация хоста (env DEAL_LOG_*). - // processName: Имя процесса (суффикс имени rolling-файла). - private static void Apply( - LoggerConfiguration loggerConfiguration, - IHostEnvironment environment, - IConfiguration configuration, - string processName) - { - loggerConfiguration - .MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey])) - .MinimumLevel.Override(EntityFrameworkCoreCategory, LogEventLevel.Warning) - .MinimumLevel.Override(GrpcCategory, LogEventLevel.Information) - .Enrich.FromLogContext(); - - string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]); - Directory.CreateDirectory(logsDirectory); - string logFilePath = Path.Combine( - logsDirectory, - string.Format(LogFileNameTemplate, processName)); - loggerConfiguration.WriteTo.File( - new CompactJsonFormatter(), - logFilePath, - rollingInterval: RollingInterval.Day, - retainedFileCountLimit: RetainedFileCount); - - if (environment.IsDevelopment()) - { - loggerConfiguration.WriteTo.Console(outputTemplate: DevelopmentConsoleTemplate); - } - else - { - loggerConfiguration.WriteTo.Console(new CompactJsonFormatter()); - } - } - - // Каталог rolling-файлов: env DEAL_LOGS_DIR либо data/logs под ContentRoot процесса. - // contentRootPath: ContentRoot хоста (/app в контейнере). - // configuredDirectory: Значение env DEAL_LOGS_DIR (null/пусто — дефолт). - // Возвращает: Абсолютный путь каталога логов. - private static string ResolveLogsDirectory(string contentRootPath, string? configuredDirectory) - => string.IsNullOrWhiteSpace(configuredDirectory) - ? Path.Combine(contentRootPath, DefaultLogsSubdirectory) - : configuredDirectory.Trim(); - - // Разбирает env DEAL_LOG_LEVEL; пустое/невалидное значение — DefaultMinimumLevel. - // rawValue: Сырое значение env. - // Возвращает: Уровень Serilog. - private static LogEventLevel ParseMinimumLevel(string? rawValue) - => Enum.TryParse(rawValue, ignoreCase: true, out LogEventLevel parsedLevel) - ? parsedLevel - : DefaultMinimumLevel; -} +using Serilog; +using Serilog.Events; +using Serilog.Formatting.Compact; + +namespace Deal.Api.Logging; + +internal static class DealLogging +{ + // Env-ключ минимального уровня Serilog (Debug/Information/Warning/Error; дефолт Information). + private const string MinimumLevelEnvKey = "DEAL_LOG_LEVEL"; + + // Env-ключ каталога rolling-файлов (дефолт data/logs под ContentRoot). + private const string LogsDirectoryEnvKey = "DEAL_LOGS_DIR"; + + // Каталог логов по умолчанию (относительно ContentRoot): core — внутри volume /app/data. + private const string DefaultLogsSubdirectory = "data/logs"; + + // Шаблон имени rolling-файла (Serilog добавляет дату перед расширением): deal-core-20260908.json. + private const string LogFileNameTemplate = "deal-{0}-.json"; + + // Сколько rolling-файлов хранится (суток); старшие удаляются Serilog автоматически. + private const int RetainedFileCount = 30; + + // Текстовая разметка консоли в Development (цвета — дефолтной темой Serilog). + private const string DevelopmentConsoleTemplate = + "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"; + + // Категория EF Core: команды SQL логируются на Warning+ (шум запросов не попадает в Loki). + private const string EntityFrameworkCoreCategory = "Microsoft.EntityFrameworkCore"; + + // Категория Grpc.AspNetCore: не ниже Information (внутренние Debug-события вызовов не дублируют access-лог). + private const string GrpcCategory = "Grpc"; + + // Дефолтный уровень при пустом/невалидном env DEAL_LOG_LEVEL. + private const LogEventLevel DefaultMinimumLevel = LogEventLevel.Information; + + /// + /// Подключает Serilog к хосту + /// + /// Билдер WebApplication процесса (до Build). + /// Имя процесса для имени файла-лога (core/telegram/ai/ml). + public static void Configure(WebApplicationBuilder builder, string processName) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(processName); + builder.Host.UseSerilog((context, loggerConfiguration) => + Apply(loggerConfiguration, context.HostingEnvironment, context.Configuration, processName)); + } + + // Собирает LoggerConfiguration процесса: уровень/фильтры, rolling-файл, консоль. + // loggerConfiguration: Конфигурация Serilog (до CreateLogger). + // environment: Окружение хоста (Development — текстовая консоль). + // configuration: Конфигурация хоста (env DEAL_LOG_*). + // processName: Имя процесса (суффикс имени rolling-файла). + private static void Apply( + LoggerConfiguration loggerConfiguration, + IHostEnvironment environment, + IConfiguration configuration, + string processName) + { + loggerConfiguration + .MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey])) + .MinimumLevel.Override(EntityFrameworkCoreCategory, LogEventLevel.Warning) + .MinimumLevel.Override(GrpcCategory, LogEventLevel.Information) + .Enrich.FromLogContext(); + + string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]); + Directory.CreateDirectory(logsDirectory); + string logFilePath = Path.Combine( + logsDirectory, + string.Format(LogFileNameTemplate, processName)); + loggerConfiguration.WriteTo.File( + new CompactJsonFormatter(), + logFilePath, + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: RetainedFileCount); + + if (environment.IsDevelopment()) + { + loggerConfiguration.WriteTo.Console(outputTemplate: DevelopmentConsoleTemplate); + } + else + { + loggerConfiguration.WriteTo.Console(new CompactJsonFormatter()); + } + } + + // Каталог rolling-файлов: env DEAL_LOGS_DIR либо data/logs под ContentRoot процесса. + // contentRootPath: ContentRoot хоста (/app в контейнере). + // configuredDirectory: Значение env DEAL_LOGS_DIR (null/пусто — дефолт). + // Возвращает: Абсолютный путь каталога логов. + private static string ResolveLogsDirectory(string contentRootPath, string? configuredDirectory) + => string.IsNullOrWhiteSpace(configuredDirectory) + ? Path.Combine(contentRootPath, DefaultLogsSubdirectory) + : configuredDirectory.Trim(); + + // Разбирает env DEAL_LOG_LEVEL; пустое/невалидное значение — DefaultMinimumLevel. + // rawValue: Сырое значение env. + // Возвращает: Уровень Serilog. + private static LogEventLevel ParseMinimumLevel(string? rawValue) + => Enum.TryParse(rawValue, ignoreCase: true, out LogEventLevel parsedLevel) + ? parsedLevel + : DefaultMinimumLevel; +} diff --git a/src/core/Deal.Api/Middleware/HttpAccessLogMiddleware.cs b/src/core/Deal.Api/Middleware/HttpAccessLogMiddleware.cs index 68df7f5..e83744f 100644 --- a/src/core/Deal.Api/Middleware/HttpAccessLogMiddleware.cs +++ b/src/core/Deal.Api/Middleware/HttpAccessLogMiddleware.cs @@ -1,111 +1,111 @@ -using System.Diagnostics; -using Deal.Api.Extensions; -using Deal.Api.Models; - -namespace Deal.Api.Middleware; - -/// -/// Access-лог HTTP-запросов core -/// -public sealed class HttpAccessLogMiddleware -{ - // Content-Type gRPC-запросов (HTTP/2) — их логирует RpcCallLoggingInterceptor. - private const string GrpcContentType = "application/grpc"; - - private readonly RequestDelegate _next; - private readonly ILogger _logger; - - /// - /// Создаёт middleware access-лога HTTP-запросов. - /// - /// Следующий обработчик конвейера. - /// Логгер. - public HttpAccessLogMiddleware(RequestDelegate next, ILogger logger) - { - ArgumentNullException.ThrowIfNull(next); - ArgumentNullException.ThrowIfNull(logger); - _next = next; - _logger = logger; - } - - /// - /// Обрабатывает запрос - /// - public async Task InvokeAsync(HttpContext context) - { - if (context.Request.ContentType?.StartsWith(GrpcContentType, StringComparison.OrdinalIgnoreCase) == true) - { - await _next(context); - return; - } - - long startedAt = Stopwatch.GetTimestamp(); - try - { - await _next(context); - LogCall(context, startedAt, null); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - // Необработанное исключение: фиксируем строку 500 (статус ещё не выставлен) и пробрасываем — - // обработку ошибки продолжает хостинг (DeveloperExceptionPage в dev/закрытие соединения в prod). - LogCall(context, startedAt, exception); - throw; - } - } - - // Пишет одну строку access-лога запроса (успех либо исключение). - // context: Контекст запроса (метод/путь/статус ответа, актор из HttpContext.Items). - // startedAt: Метка времени старта запроса (Stopwatch.GetTimestamp). - // exception: Необработанное исключение (null — запрос завершился штатно). - private void LogCall( - HttpContext context, - long startedAt, - Exception? exception) - { - long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds; - string method = context.Request.Method; - string path = context.Request.Path.Value ?? "/"; - (string actor, string tenantId) = ResolveActor(context); - if (exception is null) - { - _logger.LogInformation( - "HTTP {Method} {Path} -> {StatusCode} за {DurationMs} мс; actor={Actor} tenant={TenantId}", - method, - path, - context.Response.StatusCode, - elapsedMs, - actor, - tenantId); - } - else - { - _logger.LogError( - exception, - "HTTP {Method} {Path} -> исключение за {DurationMs} мс; actor={Actor} tenant={TenantId}", - method, - path, - elapsedMs, - actor, - tenantId); - } - } - - // Актор запроса: пользователь тенанта либо оператор; нет сессии — "-". - // context: Контекст запроса (Items заполняют Session/OperatorSession middleware ниже по конвейеру). - // Возвращает: Пару (актор, id тенанта) для структурированного access-лога. - private static (string Actor, string TenantId) ResolveActor(HttpContext context) - { - if (context.GetCurrentUser() is { } user) - { - return (user.Login, user.TenantId.ToString("N")); - } - - if (context.GetCurrentOperator() is { } operatorIdentity) - { - return (operatorIdentity.Login, "-"); - } - - return ("-", "-"); - } -} +using System.Diagnostics; +using Deal.Api.Extensions; +using Deal.Api.Models; + +namespace Deal.Api.Middleware; + +/// +/// Access-лог HTTP-запросов core +/// +public sealed class HttpAccessLogMiddleware +{ + // Content-Type gRPC-запросов (HTTP/2) — их логирует RpcCallLoggingInterceptor. + private const string GrpcContentType = "application/grpc"; + + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + /// + /// Создаёт middleware access-лога HTTP-запросов. + /// + /// Следующий обработчик конвейера. + /// Логгер. + public HttpAccessLogMiddleware(RequestDelegate next, ILogger logger) + { + ArgumentNullException.ThrowIfNull(next); + ArgumentNullException.ThrowIfNull(logger); + _next = next; + _logger = logger; + } + + /// + /// Обрабатывает запрос + /// + public async Task InvokeAsync(HttpContext context) + { + if (context.Request.ContentType?.StartsWith(GrpcContentType, StringComparison.OrdinalIgnoreCase) == true) + { + await _next(context); + return; + } + + long startedAt = Stopwatch.GetTimestamp(); + try + { + await _next(context); + LogCall(context, startedAt, null); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + // Необработанное исключение: фиксируем строку 500 (статус ещё не выставлен) и пробрасываем — + // обработку ошибки продолжает хостинг (DeveloperExceptionPage в dev/закрытие соединения в prod). + LogCall(context, startedAt, exception); + throw; + } + } + + // Пишет одну строку access-лога запроса (успех либо исключение). + // context: Контекст запроса (метод/путь/статус ответа, актор из HttpContext.Items). + // startedAt: Метка времени старта запроса (Stopwatch.GetTimestamp). + // exception: Необработанное исключение (null — запрос завершился штатно). + private void LogCall( + HttpContext context, + long startedAt, + Exception? exception) + { + long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds; + string method = context.Request.Method; + string path = context.Request.Path.Value ?? "/"; + (string actor, string tenantId) = ResolveActor(context); + if (exception is null) + { + _logger.LogInformation( + "HTTP {Method} {Path} -> {StatusCode} за {DurationMs} мс; actor={Actor} tenant={TenantId}", + method, + path, + context.Response.StatusCode, + elapsedMs, + actor, + tenantId); + } + else + { + _logger.LogError( + exception, + "HTTP {Method} {Path} -> исключение за {DurationMs} мс; actor={Actor} tenant={TenantId}", + method, + path, + elapsedMs, + actor, + tenantId); + } + } + + // Актор запроса: пользователь тенанта либо оператор; нет сессии — "-". + // context: Контекст запроса (Items заполняют Session/OperatorSession middleware ниже по конвейеру). + // Возвращает: Пару (актор, id тенанта) для структурированного access-лога. + private static (string Actor, string TenantId) ResolveActor(HttpContext context) + { + if (context.GetCurrentUser() is { } user) + { + return (user.Login, user.TenantId.ToString("N")); + } + + if (context.GetCurrentOperator() is { } operatorIdentity) + { + return (operatorIdentity.Login, "-"); + } + + return ("-", "-"); + } +} diff --git a/src/core/Deal.Api/Middleware/OriginGuardMiddleware.cs b/src/core/Deal.Api/Middleware/OriginGuardMiddleware.cs index 0dfc24c..08894db 100644 --- a/src/core/Deal.Api/Middleware/OriginGuardMiddleware.cs +++ b/src/core/Deal.Api/Middleware/OriginGuardMiddleware.cs @@ -1,78 +1,78 @@ -using Deal.Api.Configuration; -using Microsoft.Net.Http.Headers; - -namespace Deal.Api.Middleware; - -/// -/// Origin-проверка мутаций /api -/// -public sealed class OriginGuardMiddleware -{ - /// - /// Текст 403 Origin-проверки - /// - public const string OriginRejectedDetail = "Запрос отклонён: недопустимый Origin"; - - private const string ApiPathPrefix = "/api"; - - private readonly RequestDelegate _next; - private readonly HashSet _allowedOrigins; - - public OriginGuardMiddleware(RequestDelegate next, SecurityOptions securityOptions) - { - ArgumentNullException.ThrowIfNull(next); - ArgumentNullException.ThrowIfNull(securityOptions); - _next = next; - // Регистронезависимо: схема/хост origin'ов регистронечувствительны, браузеры шлют нижний регистр. - _allowedOrigins = new HashSet(securityOptions.AllowedOrigins, StringComparer.OrdinalIgnoreCase); - } - - /// - /// Обрабатывает запрос - /// - public async Task InvokeAsync(HttpContext context) - { - HttpRequest request = context.Request; - if (request.Path.StartsWithSegments(ApiPathPrefix) - && !HttpMethods.IsGet(request.Method) - && !HttpMethods.IsHead(request.Method) - && !HttpMethods.IsOptions(request.Method) - && request.Headers.TryGetValue(HeaderNames.Origin, out var originValues)) - { - string origin = originValues.ToString(); - if (!string.IsNullOrWhiteSpace(origin) && !IsTrustedOrigin(origin, request)) - { - context.Response.StatusCode = StatusCodes.Status403Forbidden; - await context.Response.WriteAsJsonAsync(new { detail = OriginRejectedDetail }, context.RequestAborted); - return; - } - } - - await _next(context); - } - - // Проверяет Origin: allowlist конфига либо «свой» origin запроса (схема + Host). - // origin: Значение заголовка Origin запроса. - // request: Запрос (схема/Host — с учётом прокси-заголовков, если UseForwardedHeaders включён). - // Возвращает: true — Origin доверенный, запрос можно пропускать. - private bool IsTrustedOrigin(string origin, HttpRequest request) - { - if (_allowedOrigins.Contains(origin)) - { - return true; - } - - // «Свой» origin: схема + Host-заголовок запроса (без завершающего слэша — как шлёт браузер). - HostString host = request.Host; - if (host.HasValue) - { - string selfOrigin = request.Scheme + "://" + host.Value; - if (string.Equals(origin, selfOrigin, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - return false; - } -} +using Deal.Api.Configuration; +using Microsoft.Net.Http.Headers; + +namespace Deal.Api.Middleware; + +/// +/// Origin-проверка мутаций /api +/// +public sealed class OriginGuardMiddleware +{ + /// + /// Текст 403 Origin-проверки + /// + public const string OriginRejectedDetail = "Запрос отклонён: недопустимый Origin"; + + private const string ApiPathPrefix = "/api"; + + private readonly RequestDelegate _next; + private readonly HashSet _allowedOrigins; + + public OriginGuardMiddleware(RequestDelegate next, SecurityOptions securityOptions) + { + ArgumentNullException.ThrowIfNull(next); + ArgumentNullException.ThrowIfNull(securityOptions); + _next = next; + // Регистронезависимо: схема/хост origin'ов регистронечувствительны, браузеры шлют нижний регистр. + _allowedOrigins = new HashSet(securityOptions.AllowedOrigins, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Обрабатывает запрос + /// + public async Task InvokeAsync(HttpContext context) + { + HttpRequest request = context.Request; + if (request.Path.StartsWithSegments(ApiPathPrefix) + && !HttpMethods.IsGet(request.Method) + && !HttpMethods.IsHead(request.Method) + && !HttpMethods.IsOptions(request.Method) + && request.Headers.TryGetValue(HeaderNames.Origin, out var originValues)) + { + string origin = originValues.ToString(); + if (!string.IsNullOrWhiteSpace(origin) && !IsTrustedOrigin(origin, request)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsJsonAsync(new { detail = OriginRejectedDetail }, context.RequestAborted); + return; + } + } + + await _next(context); + } + + // Проверяет Origin: allowlist конфига либо «свой» origin запроса (схема + Host). + // origin: Значение заголовка Origin запроса. + // request: Запрос (схема/Host — с учётом прокси-заголовков, если UseForwardedHeaders включён). + // Возвращает: true — Origin доверенный, запрос можно пропускать. + private bool IsTrustedOrigin(string origin, HttpRequest request) + { + if (_allowedOrigins.Contains(origin)) + { + return true; + } + + // «Свой» origin: схема + Host-заголовок запроса (без завершающего слэша — как шлёт браузер). + HostString host = request.Host; + if (host.HasValue) + { + string selfOrigin = request.Scheme + "://" + host.Value; + if (string.Equals(origin, selfOrigin, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} diff --git a/src/core/Deal.Api/Middleware/RateLimitPolicies.cs b/src/core/Deal.Api/Middleware/RateLimitPolicies.cs index b984df1..eb724d2 100644 --- a/src/core/Deal.Api/Middleware/RateLimitPolicies.cs +++ b/src/core/Deal.Api/Middleware/RateLimitPolicies.cs @@ -1,138 +1,138 @@ -using System.Threading.RateLimiting; -using Deal.Api.Configuration; -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Microsoft.AspNetCore.RateLimiting; - -namespace Deal.Api.Middleware; - -/// -/// Регистрация встроенного rate limiter ASP.NET Core и его политики -/// -public static class RateLimitPolicies -{ - /// - /// Имя политики входа - /// - public const string AuthPolicy = "auth"; - - /// - /// Имя API-политики - /// - public const string ApiPolicy = "api"; - - /// - /// Текст 429 rate limiter - /// - public const string RejectedDetail = "Слишком много запросов. Повторите позже"; - - // Префикс ключа хранилища для HTTP-политик: изолирует счётчики от gRPC-ингресса и login-guard. - private const string HttpKeyPrefix = "http:"; - - // Пространство ключа глобального лимитера: отдельные счётчики от именованной "api"-политики - // (как и раньше — независимые FixedWindowRateLimiter'ы, лимиты не схлопываются в один бакет). - private const string GlobalScope = "global"; - - // Префикс партиции API-политики для анонима (IP не разрешён в сессию). - private const string IpKeyPrefix = "ip:"; - - // Префикс партиции API-политики для аутентифицированного пользователя (по тенанту). - private const string TenantKeyPrefix = "tenant:"; - - // Ключ партиции, когда IP клиента недоступен (служебные/тестовые вызовы). - private const string UnknownClientKey = "unknown"; - - // Длина окна политик — 1 минута (имена настроек — «PerMinute»). - private static readonly TimeSpan Window = TimeSpan.FromMinutes(1); - - /// - /// Регистрирует AddRateLimiter с политиками "auth"/"api" и глобальным лимитером API-партиции. - /// - /// Настройки rate limiting (секция RateLimit). - public static IServiceCollection AddDealRateLimiter(this IServiceCollection services, RateLimitOptions options) - { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(options); - - int apiPerMinute = options.ApiPerMinute; - services.AddRateLimiter(limiter => - { - limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests; - limiter.OnRejected = OnRejectedAsync; - limiter.AddPolicy(AuthPolicy, context => AuthPartition(context, options.AuthPerMinute)); - limiter.AddPolicy(ApiPolicy, context => ApiPartition(context, ApiPolicy, apiPerMinute)); - // Глобальный лимитер — та же API-партиция (своё пространство ключей): эндпоинты без - // RequireRateLimiting ограничены по тенанту/IP (весь /api, кроме ручек входа и gRPC). - limiter.GlobalLimiter = PartitionedRateLimiter.Create( - context => ApiPartition(context, GlobalScope, apiPerMinute)); - }); - - return services; - } - - // Партиция политики "auth": фиксированное окно в минуту на IP клиента. - // context: Контекст запроса (адрес клиента). - // permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:AuthPerMinute). - // Возвращает: Партиция лимитера с ключом IP. - private static RateLimitPartition AuthPartition(HttpContext context, int permitsPerMinute) - => StorePartition(context, AuthPolicy, ClientKey(context), permitsPerMinute); - - // Партиция политики "api": ключ — тенант сессии (CurrentUser) либо IP анонима. - // context: Контекст запроса (CurrentUser кладёт SessionMiddleware до UseRateLimiter). - // scope: Пространство ключей (имя политики или "global" для глобального лимитера). - // permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:ApiPerMinute). - // Возвращает: Партиция лимитера с ключом tenant:{id} либо IP. - private static RateLimitPartition ApiPartition( - HttpContext context, - string scope, - int permitsPerMinute) - { - string partitionKey = context.GetCurrentUser() is { } user - ? TenantKeyPrefix + user.TenantId.ToString("N") - : ClientKey(context); - return StorePartition(context, scope, partitionKey, permitsPerMinute); - } - - // Строит партицию на хранилище Postgres: ключ = http:{scope}:{partitionKey}, лимитер — лениво. - // context: Контекст запроса (источник IServiceScopeFactory для лениво создаваемого лимитера). - // scope: Пространство ключей (политика/global). - // partitionKey: Ключ партиции (IP/тенант). - // permitsPerMinute: Разрешено запросов в минуту. - // Возвращает: Партиция store-backed лимитера (ленивое создание и кеш — менеджер партиций). - private static RateLimitPartition StorePartition( - HttpContext context, - string scope, - string partitionKey, - int permitsPerMinute) - { - string storeKey = HttpKeyPrefix + scope + ":" + partitionKey; - return RateLimitPartition.Get(storeKey, key => new StoreBackedFixedWindowRateLimiter( - context.RequestServices.GetRequiredService(), - key, - permitsPerMinute, - Window, - () => DateTimeOffset.UtcNow)); - } - - // Ключ клиента: строка IP (или unknown, если адрес недоступен). - // context: Контекст запроса. - // Возвращает: Ключ партиции по IP. - private static string ClientKey(HttpContext context) - { - string? ip = context.Connection.RemoteIpAddress?.ToString(); - return string.IsNullOrEmpty(ip) ? UnknownClientKey : IpKeyPrefix + ip; - } - - private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken) - { - HttpContext http = context.HttpContext; - // Событие подозрительной активности: сработал rate limiter (актор — тенант либо IP анонима). - SuspiciousActivityReporter? reporter = http.RequestServices.GetService(); - string actor = http.GetCurrentUser() is { } user ? user.TenantId.ToString("N") : ClientKey(http); - reporter?.Report(SuspiciousActivityReporter.RateLimitKind, actor); - - http.Response.StatusCode = StatusCodes.Status429TooManyRequests; - await http.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken); - } -} +using System.Threading.RateLimiting; +using Deal.Api.Configuration; +using Deal.Api.Extensions; +using Deal.Api.Models; +using Deal.Api.Services; +using Microsoft.AspNetCore.RateLimiting; + +namespace Deal.Api.Middleware; + +/// +/// Регистрация встроенного rate limiter ASP.NET Core и его политики +/// +public static class RateLimitPolicies +{ + /// + /// Имя политики входа + /// + public const string AuthPolicy = "auth"; + + /// + /// Имя API-политики + /// + public const string ApiPolicy = "api"; + + /// + /// Текст 429 rate limiter + /// + public const string RejectedDetail = "Слишком много запросов. Повторите позже"; + + // Префикс ключа хранилища для HTTP-политик: изолирует счётчики от gRPC-ингресса и login-guard. + private const string HttpKeyPrefix = "http:"; + + // Пространство ключа глобального лимитера: отдельные счётчики от именованной "api"-политики + // (как и раньше — независимые FixedWindowRateLimiter'ы, лимиты не схлопываются в один бакет). + private const string GlobalScope = "global"; + + // Префикс партиции API-политики для анонима (IP не разрешён в сессию). + private const string IpKeyPrefix = "ip:"; + + // Префикс партиции API-политики для аутентифицированного пользователя (по тенанту). + private const string TenantKeyPrefix = "tenant:"; + + // Ключ партиции, когда IP клиента недоступен (служебные/тестовые вызовы). + private const string UnknownClientKey = "unknown"; + + // Длина окна политик — 1 минута (имена настроек — «PerMinute»). + private static readonly TimeSpan Window = TimeSpan.FromMinutes(1); + + /// + /// Регистрирует AddRateLimiter с политиками "auth"/"api" и глобальным лимитером API-партиции. + /// + /// Настройки rate limiting (секция RateLimit). + public static IServiceCollection AddDealRateLimiter(this IServiceCollection services, RateLimitOptions options) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(options); + + int apiPerMinute = options.ApiPerMinute; + services.AddRateLimiter(limiter => + { + limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + limiter.OnRejected = OnRejectedAsync; + limiter.AddPolicy(AuthPolicy, context => AuthPartition(context, options.AuthPerMinute)); + limiter.AddPolicy(ApiPolicy, context => ApiPartition(context, ApiPolicy, apiPerMinute)); + // Глобальный лимитер — та же API-партиция (своё пространство ключей): эндпоинты без + // RequireRateLimiting ограничены по тенанту/IP (весь /api, кроме ручек входа и gRPC). + limiter.GlobalLimiter = PartitionedRateLimiter.Create( + context => ApiPartition(context, GlobalScope, apiPerMinute)); + }); + + return services; + } + + // Партиция политики "auth": фиксированное окно в минуту на IP клиента. + // context: Контекст запроса (адрес клиента). + // permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:AuthPerMinute). + // Возвращает: Партиция лимитера с ключом IP. + private static RateLimitPartition AuthPartition(HttpContext context, int permitsPerMinute) + => StorePartition(context, AuthPolicy, ClientKey(context), permitsPerMinute); + + // Партиция политики "api": ключ — тенант сессии (CurrentUser) либо IP анонима. + // context: Контекст запроса (CurrentUser кладёт SessionMiddleware до UseRateLimiter). + // scope: Пространство ключей (имя политики или "global" для глобального лимитера). + // permitsPerMinute: Разрешено запросов в минуту на партицию (RateLimit:ApiPerMinute). + // Возвращает: Партиция лимитера с ключом tenant:{id} либо IP. + private static RateLimitPartition ApiPartition( + HttpContext context, + string scope, + int permitsPerMinute) + { + string partitionKey = context.GetCurrentUser() is { } user + ? TenantKeyPrefix + user.TenantId.ToString("N") + : ClientKey(context); + return StorePartition(context, scope, partitionKey, permitsPerMinute); + } + + // Строит партицию на хранилище Postgres: ключ = http:{scope}:{partitionKey}, лимитер — лениво. + // context: Контекст запроса (источник IServiceScopeFactory для лениво создаваемого лимитера). + // scope: Пространство ключей (политика/global). + // partitionKey: Ключ партиции (IP/тенант). + // permitsPerMinute: Разрешено запросов в минуту. + // Возвращает: Партиция store-backed лимитера (ленивое создание и кеш — менеджер партиций). + private static RateLimitPartition StorePartition( + HttpContext context, + string scope, + string partitionKey, + int permitsPerMinute) + { + string storeKey = HttpKeyPrefix + scope + ":" + partitionKey; + return RateLimitPartition.Get(storeKey, key => new StoreBackedFixedWindowRateLimiter( + context.RequestServices.GetRequiredService(), + key, + permitsPerMinute, + Window, + () => DateTimeOffset.UtcNow)); + } + + // Ключ клиента: строка IP (или unknown, если адрес недоступен). + // context: Контекст запроса. + // Возвращает: Ключ партиции по IP. + private static string ClientKey(HttpContext context) + { + string? ip = context.Connection.RemoteIpAddress?.ToString(); + return string.IsNullOrEmpty(ip) ? UnknownClientKey : IpKeyPrefix + ip; + } + + private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken) + { + HttpContext http = context.HttpContext; + // Событие подозрительной активности: сработал rate limiter (актор — тенант либо IP анонима). + SuspiciousActivityReporter? reporter = http.RequestServices.GetService(); + string actor = http.GetCurrentUser() is { } user ? user.TenantId.ToString("N") : ClientKey(http); + reporter?.Report(SuspiciousActivityReporter.RateLimitKind, actor); + + http.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await http.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken); + } +} diff --git a/src/core/Deal.Api/Models/CurrentOperator.cs b/src/core/Deal.Api/Models/CurrentOperator.cs index ddc2a09..0b907f1 100644 --- a/src/core/Deal.Api/Models/CurrentOperator.cs +++ b/src/core/Deal.Api/Models/CurrentOperator.cs @@ -1,9 +1,9 @@ -namespace Deal.Api.Models; - -/// -/// Текущий оператор запроса — кладёт OperatorSessionMiddleware в HttpContext.Items. -/// -/// Идентификатор оператора. -/// Логин в нижнем регистре. -/// Статус учётной записи (всегда "active" — разрешаются только активные). -public sealed record CurrentOperator(Guid OperatorId, string Login, string Status); +namespace Deal.Api.Models; + +/// +/// Текущий оператор запроса — кладёт OperatorSessionMiddleware в HttpContext.Items. +/// +/// Идентификатор оператора. +/// Логин в нижнем регистре. +/// Статус учётной записи (всегда "active" — разрешаются только активные). +public sealed record CurrentOperator(Guid OperatorId, string Login, string Status); diff --git a/src/core/Deal.Api/Models/CurrentUser.cs b/src/core/Deal.Api/Models/CurrentUser.cs index 0b3f5a6..b0f8f57 100644 --- a/src/core/Deal.Api/Models/CurrentUser.cs +++ b/src/core/Deal.Api/Models/CurrentUser.cs @@ -1,10 +1,10 @@ -namespace Deal.Api.Models; - -/// -/// Текущий пользователь запроса — кладётся SessionMiddleware в HttpContext.Items. -/// -/// Идентификатор пользователя. -/// Логин в нижнем регистре. -/// Идентификатор тенанта, которому принадлежит пользователь. -/// Статус учётной записи. -public sealed record CurrentUser(Guid UserId, string Login, Guid TenantId, string Status); +namespace Deal.Api.Models; + +/// +/// Текущий пользователь запроса — кладётся SessionMiddleware в HttpContext.Items. +/// +/// Идентификатор пользователя. +/// Логин в нижнем регистре. +/// Идентификатор тенанта, которому принадлежит пользователь. +/// Статус учётной записи. +public sealed record CurrentUser(Guid UserId, string Login, Guid TenantId, string Status); diff --git a/src/core/Deal.Api/Observability/DealMetricsCollector.cs b/src/core/Deal.Api/Observability/DealMetricsCollector.cs index 15fcf8a..2b44021 100644 --- a/src/core/Deal.Api/Observability/DealMetricsCollector.cs +++ b/src/core/Deal.Api/Observability/DealMetricsCollector.cs @@ -1,119 +1,119 @@ -using Deal.SharedKernel.Observability; - -namespace Deal.Api.Observability; - -/// -/// Фоновый сборщик gauge-метрик ядра -/// -public sealed class DealMetricsCollector : IHostedService -{ - /// - /// Период сбора gauge-метрик — 15 с - /// - public const int CollectionPeriodSeconds = 15; - - private readonly RuntimeDepthsCollector _depthsCollector; - private readonly ILogger _logger; - - // Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен). - private readonly CancellationTokenSource _shutdownCts = new(); - - private Timer? _timer; - private Task? _currentIteration; - private int _iterationInProgress; - - /// - /// Создаёт фоновый сборщик gauge-метрик. - /// - /// Сборщик глубин очередей/сессий (общий с операторским health). - /// Логгер ошибок цикла. - public DealMetricsCollector(RuntimeDepthsCollector depthsCollector, ILogger logger) - { - ArgumentNullException.ThrowIfNull(depthsCollector); - ArgumentNullException.ThrowIfNull(logger); - _depthsCollector = depthsCollector; - _logger = logger; - } - - /// - public Task StartAsync(CancellationToken ct) - { - _timer = new Timer( - static state => ((DealMetricsCollector)state!).RunIteration(), - this, - TimeSpan.Zero, - TimeSpan.FromSeconds(CollectionPeriodSeconds)); - return Task.CompletedTask; - } - - /// - public async Task StopAsync(CancellationToken ct) - { - _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); - _timer?.Dispose(); - _timer = null; - - _shutdownCts.Cancel(); - Task? iteration = Volatile.Read(ref _currentIteration); - if (iteration is not null) - { - try - { - await iteration.WaitAsync(ct); - } - catch (OperationCanceledException) - { - // Лимит остановки истёк — ошибки прохода проглочены внутри CollectCycleAsync. - } - } - } - - /// - /// Один проход сбора - /// - /// Задача прохода (завершается без исключений). - public Task RunCycleAsync(CancellationToken ct) - { - if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0) - { - return Task.CompletedTask; - } - - Task iteration = CollectCycleAsync(ct); - Volatile.Write(ref _currentIteration, iteration); - return iteration; - } - - // Запускает проход из callback таймера (guard — внутри RunCycleAsync). - private void RunIteration() - { - _ = RunCycleAsync(_shutdownCts.Token); - } - - // Тело прохода: снимок глубин/сессий и публикация в meter Deal; guard сбрасывается в finally. - // ct: Токен отмены (остановка хоста). - private async Task CollectCycleAsync(CancellationToken ct) - { - try - { - RuntimeDepthsDto depths = await _depthsCollector.CollectAsync(ct); - DealMetrics.SetPipelineQueueDepth(depths.PipelineQueue); - DealMetrics.SetMlOutboxDepth(depths.MlOutbox); - DealMetrics.SetActiveSessions(depths.ActiveSessions); - DealMetrics.ReplaceBudgetRatios( - (depths.Budgets ?? []).Select(budget => (budget.TenantId, budget.UsedRatio))); - } - catch (OperationCanceledException) - { - // Остановка хоста: проход прерван по токену — штатный выход, не ошибка. - } - catch (Exception exception) - { - _logger.LogError(exception, "Сборщик метрик: проход по тенантам не удался"); - } - finally - { - Interlocked.Exchange(ref _iterationInProgress, 0); - } - } -} +using Deal.SharedKernel.Observability; + +namespace Deal.Api.Observability; + +/// +/// Фоновый сборщик gauge-метрик ядра +/// +public sealed class DealMetricsCollector : IHostedService +{ + /// + /// Период сбора gauge-метрик — 15 с + /// + public const int CollectionPeriodSeconds = 15; + + private readonly RuntimeDepthsCollector _depthsCollector; + private readonly ILogger _logger; + + // Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен). + private readonly CancellationTokenSource _shutdownCts = new(); + + private Timer? _timer; + private Task? _currentIteration; + private int _iterationInProgress; + + /// + /// Создаёт фоновый сборщик gauge-метрик. + /// + /// Сборщик глубин очередей/сессий (общий с операторским health). + /// Логгер ошибок цикла. + public DealMetricsCollector(RuntimeDepthsCollector depthsCollector, ILogger logger) + { + ArgumentNullException.ThrowIfNull(depthsCollector); + ArgumentNullException.ThrowIfNull(logger); + _depthsCollector = depthsCollector; + _logger = logger; + } + + /// + public Task StartAsync(CancellationToken ct) + { + _timer = new Timer( + static state => ((DealMetricsCollector)state!).RunIteration(), + this, + TimeSpan.Zero, + TimeSpan.FromSeconds(CollectionPeriodSeconds)); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken ct) + { + _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _timer?.Dispose(); + _timer = null; + + _shutdownCts.Cancel(); + Task? iteration = Volatile.Read(ref _currentIteration); + if (iteration is not null) + { + try + { + await iteration.WaitAsync(ct); + } + catch (OperationCanceledException) + { + // Лимит остановки истёк — ошибки прохода проглочены внутри CollectCycleAsync. + } + } + } + + /// + /// Один проход сбора + /// + /// Задача прохода (завершается без исключений). + public Task RunCycleAsync(CancellationToken ct) + { + if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0) + { + return Task.CompletedTask; + } + + Task iteration = CollectCycleAsync(ct); + Volatile.Write(ref _currentIteration, iteration); + return iteration; + } + + // Запускает проход из callback таймера (guard — внутри RunCycleAsync). + private void RunIteration() + { + _ = RunCycleAsync(_shutdownCts.Token); + } + + // Тело прохода: снимок глубин/сессий и публикация в meter Deal; guard сбрасывается в finally. + // ct: Токен отмены (остановка хоста). + private async Task CollectCycleAsync(CancellationToken ct) + { + try + { + RuntimeDepthsDto depths = await _depthsCollector.CollectAsync(ct); + DealMetrics.SetPipelineQueueDepth(depths.PipelineQueue); + DealMetrics.SetMlOutboxDepth(depths.MlOutbox); + DealMetrics.SetActiveSessions(depths.ActiveSessions); + DealMetrics.ReplaceBudgetRatios( + (depths.Budgets ?? []).Select(budget => (budget.TenantId, budget.UsedRatio))); + } + catch (OperationCanceledException) + { + // Остановка хоста: проход прерван по токену — штатный выход, не ошибка. + } + catch (Exception exception) + { + _logger.LogError(exception, "Сборщик метрик: проход по тенантам не удался"); + } + finally + { + Interlocked.Exchange(ref _iterationInProgress, 0); + } + } +} diff --git a/src/core/Deal.Api/Observability/DealMetricsHosting.cs b/src/core/Deal.Api/Observability/DealMetricsHosting.cs index 7c4771c..59d5ae8 100644 --- a/src/core/Deal.Api/Observability/DealMetricsHosting.cs +++ b/src/core/Deal.Api/Observability/DealMetricsHosting.cs @@ -1,62 +1,62 @@ -using Deal.SharedKernel.Observability; -using Microsoft.AspNetCore.Server.Kestrel.Core; -using OpenTelemetry.Metrics; - -namespace Deal.Api.Observability; - -/// -/// Настройка метрик ядра Deal.Api -/// -public static class DealMetricsHosting -{ - /// - /// Порт эндпоинта /metrics по умолчанию - /// - public const int DefaultMetricsPort = 9464; - - // Env-ключ порта метрик (переопределяет DefaultMetricsPort). - private const string MetricsPortEnvKey = "METRICS_PORT"; - - /// - /// Порт эндпоинта метрик - /// - /// Дефолтный порт (обычно ). - /// Порт HTTP/1.1-эндпоинта метрик. - public static int ResolveMetricsPort(int defaultPort) - => int.TryParse(Environment.GetEnvironmentVariable(MetricsPortEnvKey), out int port) && port > 0 - ? port - : defaultPort; - - /// - /// Регистрирует OTel-метрики и Kestrel-эндпоинт метрик - /// - /// Билдер ядра (Program.cs, до Build). - /// Порт HTTP/1.1-эндпоинта метрик. - public static void AddDealMetrics(WebApplicationBuilder builder, int metricsPort) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.WebHost.ConfigureKestrel(kestrel => - { - kestrel.ListenAnyIP(metricsPort, listen => listen.Protocols = HttpProtocols.Http1); - }); - - builder.Services - .AddOpenTelemetry() - .WithMetrics(metrics => metrics - .AddMeter(DealMetrics.MeterName) - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddPrometheusExporter()); - } - - /// - /// Мапит эндпоинт /metrics - /// - /// Собранное приложение ядра. - public static void MapDealMetrics(WebApplication app) - { - ArgumentNullException.ThrowIfNull(app); - app.MapPrometheusScrapingEndpoint(); - } -} +using Deal.SharedKernel.Observability; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using OpenTelemetry.Metrics; + +namespace Deal.Api.Observability; + +/// +/// Настройка метрик ядра Deal.Api +/// +public static class DealMetricsHosting +{ + /// + /// Порт эндпоинта /metrics по умолчанию + /// + public const int DefaultMetricsPort = 9464; + + // Env-ключ порта метрик (переопределяет DefaultMetricsPort). + private const string MetricsPortEnvKey = "METRICS_PORT"; + + /// + /// Порт эндпоинта метрик + /// + /// Дефолтный порт (обычно ). + /// Порт HTTP/1.1-эндпоинта метрик. + public static int ResolveMetricsPort(int defaultPort) + => int.TryParse(Environment.GetEnvironmentVariable(MetricsPortEnvKey), out int port) && port > 0 + ? port + : defaultPort; + + /// + /// Регистрирует OTel-метрики и Kestrel-эндпоинт метрик + /// + /// Билдер ядра (Program.cs, до Build). + /// Порт HTTP/1.1-эндпоинта метрик. + public static void AddDealMetrics(WebApplicationBuilder builder, int metricsPort) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.WebHost.ConfigureKestrel(kestrel => + { + kestrel.ListenAnyIP(metricsPort, listen => listen.Protocols = HttpProtocols.Http1); + }); + + builder.Services + .AddOpenTelemetry() + .WithMetrics(metrics => metrics + .AddMeter(DealMetrics.MeterName) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddPrometheusExporter()); + } + + /// + /// Мапит эндпоинт /metrics + /// + /// Собранное приложение ядра. + public static void MapDealMetrics(WebApplication app) + { + ArgumentNullException.ThrowIfNull(app); + app.MapPrometheusScrapingEndpoint(); + } +} diff --git a/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs b/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs index efc4184..1b7c48f 100644 --- a/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs +++ b/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs @@ -1,192 +1,192 @@ -using Deal.Infrastructure.Persistence; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Api.Observability; - -/// -/// Сборщик глубин очередей и активных сессий -/// -public sealed class RuntimeDepthsCollector -{ - private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; - - /// - /// Создаёт сборщик глубин. - /// - /// Фабрика scope: проход и тенант — в собственных scope. - /// Логгер ошибок секций. - public RuntimeDepthsCollector(IServiceScopeFactory scopeFactory, ILogger logger) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(logger); - _scopeFactory = scopeFactory; - _logger = logger; - } - - /// - /// Собирает снимок глубин очередей и числа активных сессий - /// - /// Снимок: суммарные глубины pipeline/ML-outbox и число активных сессий. - public async Task CollectAsync(CancellationToken ct) - { - await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope(); - int sessions = await CountActiveSessionsAsync(cycleScope, ct); - IReadOnlyList tenants = await ListTenantsAsync(cycleScope, ct); - (long queue, long outbox) = await SumTenantDepthsAsync(tenants, ct); - IReadOnlyList budgets = await CollectBudgetsAsync(cycleScope, tenants, ct); - return new RuntimeDepthsDto(queue, outbox, sessions, budgets); - } - - // Считает активные непросроченные сессии пользователей и операторов (public-схема). - // cycleScope: Scope прохода (DealDbContext — без tenant-контекста). - // ct: Токен отмены. - // Возвращает: Число активных сессий; сбой секции — 0 (снимок остаётся полезным). - private async Task CountActiveSessionsAsync(AsyncServiceScope cycleScope, CancellationToken ct) - { - try - { - DealDbContext dbContext = cycleScope.ServiceProvider.GetRequiredService(); - DateTimeOffset now = DateTimeOffset.UtcNow; - int tenantSessions = await dbContext.Sessions.CountAsync(session => session.ExpiresAt > now, ct); - int operatorSessions = await dbContext.OperatorSessions.CountAsync(session => session.ExpiresAt > now, ct); - return tenantSessions + operatorSessions; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - _logger.LogWarning(exception, "Сборщик глубин: подсчёт активных сессий не удался"); - return 0; - } - } - - // Список тенантов реестра (без tenant-контекста — public-схема). - // cycleScope: Scope прохода. - // ct: Токен отмены. - // Возвращает: Тенанты реестра; недоступность реестра — пустой список. - private async Task> ListTenantsAsync(AsyncServiceScope cycleScope, CancellationToken ct) - { - try - { - ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService(); - return await tenantRepository.ListAsync(ct); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - _logger.LogWarning(exception, "Сборщик глубин: обход реестра тенантов не удался"); - return []; - } - } - - // Суммирует глубины очередей переданных тенантов. - // tenants: Тенанты реестра. - // ct: Токен отмены. - // Возвращает: Пара (сумма очереди пайплайна, сумма MlOutbox). - private async Task<(long Queue, long Outbox)> SumTenantDepthsAsync( - IReadOnlyList tenants, - CancellationToken ct) - { - long queueDepth = 0; - long outboxDepth = 0; - foreach (TenantRecordDto tenant in tenants) - { - (int queue, int outbox) = await CollectTenantAsync(tenant, ct); - queueDepth += queue; - outboxDepth += outbox; - } - - return (queueDepth, outboxDepth); - } - - // Собирает долю израсходованного ИИ-бюджета по тенантам. - // cycleScope: Scope прохода (public-схема: tenant_limits — без tenant-контекста). - // tenants: Тенанты реестра. - // ct: Токен отмены. - // Возвращает: Доли 0..1 по тенантам; сбой одного тенанта — он пропускается. - private async Task> CollectBudgetsAsync( - AsyncServiceScope cycleScope, - IReadOnlyList tenants, - CancellationToken ct) - { - var budgets = new List(tenants.Count); - try - { - ITenantLimitStore limits = cycleScope.ServiceProvider.GetRequiredService(); - foreach (TenantRecordDto tenant in tenants) - { - try - { - BudgetStateDto state = await limits.GetStateAsync(tenant.Id, ct); - double ratio = state.BudgetTokens > 0 - ? Math.Clamp(state.UsedTokens / (double)state.BudgetTokens, 0.0, 1.0) - : 0.0; - budgets.Add(new TenantBudgetUsageDto(tenant.Id.ToString("N"), ratio)); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - _logger.LogWarning(exception, "Сборщик глубин: бюджет тенанта {TenantId} не прочитан", tenant.Id); - } - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - _logger.LogWarning(exception, "Сборщик глубин: секция бюджета не удалась"); - } - - return budgets; - } - - // Считает глубины очередей одного тенанта в собственном scope (SetTenant → сервисы → Reset). - // tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>). - // ct: Токен отмены прохода. - // Возвращает: Пара (глубина очереди пайплайна, глубина MlOutbox) для тенанта; сбой — (0, 0). - private async Task<(int Queue, int Outbox)> CollectTenantAsync(TenantRecordDto tenant, CancellationToken ct) - { - await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope(); - ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); - try - { - tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); - PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService(); - QueueCountsDto counts = await processing.QueueCountsAsync(ct); - IMlLearningStore learningStore = tenantScope.ServiceProvider.GetRequiredService(); - int outbox = await learningStore.CountOutboxAsync(ct); - return (counts.Total, outbox); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - _logger.LogWarning(exception, "Сборщик глубин: подсчёт очередей тенанта {TenantId} не удался", tenant.Id); - return (0, 0); - } - finally - { - tenantContext.Reset(); - } - } -} +using Deal.Infrastructure.Persistence; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Api.Observability; + +/// +/// Сборщик глубин очередей и активных сессий +/// +public sealed class RuntimeDepthsCollector +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + /// + /// Создаёт сборщик глубин. + /// + /// Фабрика scope: проход и тенант — в собственных scope. + /// Логгер ошибок секций. + public RuntimeDepthsCollector(IServiceScopeFactory scopeFactory, ILogger logger) + { + ArgumentNullException.ThrowIfNull(scopeFactory); + ArgumentNullException.ThrowIfNull(logger); + _scopeFactory = scopeFactory; + _logger = logger; + } + + /// + /// Собирает снимок глубин очередей и числа активных сессий + /// + /// Снимок: суммарные глубины pipeline/ML-outbox и число активных сессий. + public async Task CollectAsync(CancellationToken ct) + { + await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope(); + int sessions = await CountActiveSessionsAsync(cycleScope, ct); + IReadOnlyList tenants = await ListTenantsAsync(cycleScope, ct); + (long queue, long outbox) = await SumTenantDepthsAsync(tenants, ct); + IReadOnlyList budgets = await CollectBudgetsAsync(cycleScope, tenants, ct); + return new RuntimeDepthsDto(queue, outbox, sessions, budgets); + } + + // Считает активные непросроченные сессии пользователей и операторов (public-схема). + // cycleScope: Scope прохода (DealDbContext — без tenant-контекста). + // ct: Токен отмены. + // Возвращает: Число активных сессий; сбой секции — 0 (снимок остаётся полезным). + private async Task CountActiveSessionsAsync(AsyncServiceScope cycleScope, CancellationToken ct) + { + try + { + DealDbContext dbContext = cycleScope.ServiceProvider.GetRequiredService(); + DateTimeOffset now = DateTimeOffset.UtcNow; + int tenantSessions = await dbContext.Sessions.CountAsync(session => session.ExpiresAt > now, ct); + int operatorSessions = await dbContext.OperatorSessions.CountAsync(session => session.ExpiresAt > now, ct); + return tenantSessions + operatorSessions; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Сборщик глубин: подсчёт активных сессий не удался"); + return 0; + } + } + + // Список тенантов реестра (без tenant-контекста — public-схема). + // cycleScope: Scope прохода. + // ct: Токен отмены. + // Возвращает: Тенанты реестра; недоступность реестра — пустой список. + private async Task> ListTenantsAsync(AsyncServiceScope cycleScope, CancellationToken ct) + { + try + { + ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService(); + return await tenantRepository.ListAsync(ct); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Сборщик глубин: обход реестра тенантов не удался"); + return []; + } + } + + // Суммирует глубины очередей переданных тенантов. + // tenants: Тенанты реестра. + // ct: Токен отмены. + // Возвращает: Пара (сумма очереди пайплайна, сумма MlOutbox). + private async Task<(long Queue, long Outbox)> SumTenantDepthsAsync( + IReadOnlyList tenants, + CancellationToken ct) + { + long queueDepth = 0; + long outboxDepth = 0; + foreach (TenantRecordDto tenant in tenants) + { + (int queue, int outbox) = await CollectTenantAsync(tenant, ct); + queueDepth += queue; + outboxDepth += outbox; + } + + return (queueDepth, outboxDepth); + } + + // Собирает долю израсходованного ИИ-бюджета по тенантам. + // cycleScope: Scope прохода (public-схема: tenant_limits — без tenant-контекста). + // tenants: Тенанты реестра. + // ct: Токен отмены. + // Возвращает: Доли 0..1 по тенантам; сбой одного тенанта — он пропускается. + private async Task> CollectBudgetsAsync( + AsyncServiceScope cycleScope, + IReadOnlyList tenants, + CancellationToken ct) + { + var budgets = new List(tenants.Count); + try + { + ITenantLimitStore limits = cycleScope.ServiceProvider.GetRequiredService(); + foreach (TenantRecordDto tenant in tenants) + { + try + { + BudgetStateDto state = await limits.GetStateAsync(tenant.Id, ct); + double ratio = state.BudgetTokens > 0 + ? Math.Clamp(state.UsedTokens / (double)state.BudgetTokens, 0.0, 1.0) + : 0.0; + budgets.Add(new TenantBudgetUsageDto(tenant.Id.ToString("N"), ratio)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Сборщик глубин: бюджет тенанта {TenantId} не прочитан", tenant.Id); + } + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Сборщик глубин: секция бюджета не удалась"); + } + + return budgets; + } + + // Считает глубины очередей одного тенанта в собственном scope (SetTenant → сервисы → Reset). + // tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>). + // ct: Токен отмены прохода. + // Возвращает: Пара (глубина очереди пайплайна, глубина MlOutbox) для тенанта; сбой — (0, 0). + private async Task<(int Queue, int Outbox)> CollectTenantAsync(TenantRecordDto tenant, CancellationToken ct) + { + await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope(); + ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); + try + { + tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); + PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService(); + QueueCountsDto counts = await processing.QueueCountsAsync(ct); + IMlLearningStore learningStore = tenantScope.ServiceProvider.GetRequiredService(); + int outbox = await learningStore.CountOutboxAsync(ct); + return (counts.Total, outbox); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Сборщик глубин: подсчёт очередей тенанта {TenantId} не удался", tenant.Id); + return (0, 0); + } + finally + { + tenantContext.Reset(); + } + } +} diff --git a/src/core/Deal.Api/Observability/RuntimeDepthsDto.cs b/src/core/Deal.Api/Observability/RuntimeDepthsDto.cs index 1df166f..0c1106b 100644 --- a/src/core/Deal.Api/Observability/RuntimeDepthsDto.cs +++ b/src/core/Deal.Api/Observability/RuntimeDepthsDto.cs @@ -1,14 +1,14 @@ -namespace Deal.Api.Observability; - -/// -/// Снимок глубин очередей и активных сессий ядра — агрегат по всем тенантам. -/// -/// Суммарная глубина очереди пайплайна (new+filtered) по всем тенантам. -/// Суммарная глубина очереди обучения ML (MlOutbox) по всем тенантам. -/// Число активных непросроченных сессий пользователей и операторов. -/// Использование ИИ-бюджета по тенантам (пусто — данных нет). -public sealed record RuntimeDepthsDto( - long PipelineQueue, - long MlOutbox, - int ActiveSessions, - IReadOnlyList? Budgets = null); +namespace Deal.Api.Observability; + +/// +/// Снимок глубин очередей и активных сессий ядра — агрегат по всем тенантам. +/// +/// Суммарная глубина очереди пайплайна (new+filtered) по всем тенантам. +/// Суммарная глубина очереди обучения ML (MlOutbox) по всем тенантам. +/// Число активных непросроченных сессий пользователей и операторов. +/// Использование ИИ-бюджета по тенантам (пусто — данных нет). +public sealed record RuntimeDepthsDto( + long PipelineQueue, + long MlOutbox, + int ActiveSessions, + IReadOnlyList? Budgets = null); diff --git a/src/core/Deal.Api/Observability/TenantBudgetUsageDto.cs b/src/core/Deal.Api/Observability/TenantBudgetUsageDto.cs index 9aef560..9aeaa4b 100644 --- a/src/core/Deal.Api/Observability/TenantBudgetUsageDto.cs +++ b/src/core/Deal.Api/Observability/TenantBudgetUsageDto.cs @@ -1,8 +1,8 @@ -namespace Deal.Api.Observability; - -/// -/// Использование ИИ-бюджета тенанта — для метрики алертинга. -/// -/// Id тенанта (Guid в формате «N»). -/// Доля израсходованного бюджета периода 0..1; бюджет не задан — 0. -public sealed record TenantBudgetUsageDto(string TenantId, double UsedRatio); +namespace Deal.Api.Observability; + +/// +/// Использование ИИ-бюджета тенанта — для метрики алертинга. +/// +/// Id тенанта (Guid в формате «N»). +/// Доля израсходованного бюджета периода 0..1; бюджет не задан — 0. +public sealed record TenantBudgetUsageDto(string TenantId, double UsedRatio); diff --git a/src/core/Deal.Api/Properties/launchSettings.json b/src/core/Deal.Api/Properties/launchSettings.json index 8561be6..7d34bb9 100644 --- a/src/core/Deal.Api/Properties/launchSettings.json +++ b/src/core/Deal.Api/Properties/launchSettings.json @@ -1,14 +1,14 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:5191", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5191", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/core/Deal.Api/Services/AdminTickOrchestrator.cs b/src/core/Deal.Api/Services/AdminTickOrchestrator.cs index 794babc..bc7fcd1 100644 --- a/src/core/Deal.Api/Services/AdminTickOrchestrator.cs +++ b/src/core/Deal.Api/Services/AdminTickOrchestrator.cs @@ -1,137 +1,137 @@ -using Deal.Api.Dtos; -using Deal.Api.Events; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Services; - -namespace Deal.Api.Services; - -/// -/// Оркестратор ручного тика POST /api/admin/tick. -/// -/// Тик правил хранения канбана (StorageTickService модуля Kanban). -/// Очистка отсева и счётчики очереди (модуль Pipeline). -/// Один проход pump по очереди входящих (модуль Pipeline). -/// Проверка наступивших напоминаний «Отложено». -/// Публикатор SSE-тостов статистики тика. -/// SSE-брокер канала тенанта (публикация reminder_due/new_card). -/// Общий воркер-гейт pump тенанта. -/// Логгер сбоя проверки напоминаний/pump (тик продолжается без этих веток). -public sealed class AdminTickOrchestrator( - StorageTickService storageTick, - PipelineProcessingService processing, - PipelineWorkerService worker, - CardsService reminders, - StorageToastPublisher toastPublisher, - SseBroker broker, - PipelinePumpGate pumpGate, - ILogger logger) -{ - private const string NewCardEventType = "new_card"; - - private const string ReminderDueEventType = "reminder_due"; - - private static readonly string[] PipelineCounterKeys = - [ - "staged", "rulesStored", "mlStored", "mlDrop", "typeDrop", "aiStored", "aiDrop", "aiFail", "noBudget", - ]; - - /// - /// Выполняет один ручной тик тенанта - /// - /// Тенант-получатель (сессия запроса; канал SSE-публикаций). - /// Ответ {storage, reminders, pipeline, queue}; сбой проверки напоминаний/pump не выбрасывается наружу. - public async Task TickAsync(Guid tenantId, CancellationToken ct) - { - StorageTickStatsDto storage = await storageTick.TickAsync(ct); - - int purgedRejected = await processing.PurgeExpiredAsync(ct); - StorageTickStatsDto mergedStorage = storage with { PurgedRejected = purgedRejected }; - - toastPublisher.PublishTickToasts(tenantId, mergedStorage); - - IReadOnlyList dueReminders; - try - { - dueReminders = await reminders.CheckDueRemindersAsync(ct); - } - catch (OperationCanceledException) - { - // Запрос отменён — прерываем тик штатно (не «сбой проверки напоминаний»). - throw; - } - catch (Exception exception) - { - logger.LogWarning(exception, "POST /api/admin/tick: проверка напоминаний не удалась — reminders ответа пуст"); - dueReminders = Array.Empty(); - } - - foreach (CardReminderDueDto due in dueReminders) - { - broker.Publish(tenantId, ReminderDueEventType, due); - } - - PipelinePumpResult? pump = await PumpOnceSafelyAsync(tenantId, ct); - - if (pump is not null) - { - foreach (CardDto card in pump.CreatedCards) - { - broker.Publish(tenantId, NewCardEventType, card); - } - } - - QueueCountsDto queueCounts = await processing.QueueCountsAsync(ct); - - return new AdminTickResultDto( - mergedStorage, - dueReminders, - pump is null ? new Dictionary() : ToPipelineWireDict(pump), - queueCounts.Total); - } - - private async Task PumpOnceSafelyAsync(Guid tenantId, CancellationToken ct) - { - if (!pumpGate.TryEnter(tenantId)) - { - return null; - } - - try - { - return await worker.PumpOnceAsync(ct); - } - catch (OperationCanceledException) - { - // Запрос отменён — прерываем тик штатно (не «сбой pump»). - throw; - } - catch (Exception exception) - { - logger.LogWarning(exception, "POST /api/admin/tick: проход pump не удался — тик возвращает storage без pipeline"); - return null; - } - finally - { - pumpGate.Exit(tenantId); - } - } - - private static IReadOnlyDictionary ToPipelineWireDict(PipelinePumpResult pump) - { - int[] counters = - [ - pump.Staged, pump.RulesStored, pump.MlStored, pump.MlDrop, pump.TypeDrop, - pump.AiStored, pump.AiDrop, pump.AiFail, pump.NoBudget, - ]; - - var wire = new Dictionary(PipelineCounterKeys.Length, StringComparer.Ordinal); - for (int index = 0; index < PipelineCounterKeys.Length; index++) - { - wire[PipelineCounterKeys[index]] = counters[index]; - } - - return wire; - } -} +using Deal.Api.Dtos; +using Deal.Api.Events; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; + +namespace Deal.Api.Services; + +/// +/// Оркестратор ручного тика POST /api/admin/tick. +/// +/// Тик правил хранения канбана (StorageTickService модуля Kanban). +/// Очистка отсева и счётчики очереди (модуль Pipeline). +/// Один проход pump по очереди входящих (модуль Pipeline). +/// Проверка наступивших напоминаний «Отложено». +/// Публикатор SSE-тостов статистики тика. +/// SSE-брокер канала тенанта (публикация reminder_due/new_card). +/// Общий воркер-гейт pump тенанта. +/// Логгер сбоя проверки напоминаний/pump (тик продолжается без этих веток). +public sealed class AdminTickOrchestrator( + StorageTickService storageTick, + PipelineProcessingService processing, + PipelineWorkerService worker, + CardsService reminders, + StorageToastPublisher toastPublisher, + SseBroker broker, + PipelinePumpGate pumpGate, + ILogger logger) +{ + private const string NewCardEventType = "new_card"; + + private const string ReminderDueEventType = "reminder_due"; + + private static readonly string[] PipelineCounterKeys = + [ + "staged", "rulesStored", "mlStored", "mlDrop", "typeDrop", "aiStored", "aiDrop", "aiFail", "noBudget", + ]; + + /// + /// Выполняет один ручной тик тенанта + /// + /// Тенант-получатель (сессия запроса; канал SSE-публикаций). + /// Ответ {storage, reminders, pipeline, queue}; сбой проверки напоминаний/pump не выбрасывается наружу. + public async Task TickAsync(Guid tenantId, CancellationToken ct) + { + StorageTickStatsDto storage = await storageTick.TickAsync(ct); + + int purgedRejected = await processing.PurgeExpiredAsync(ct); + StorageTickStatsDto mergedStorage = storage with { PurgedRejected = purgedRejected }; + + toastPublisher.PublishTickToasts(tenantId, mergedStorage); + + IReadOnlyList dueReminders; + try + { + dueReminders = await reminders.CheckDueRemindersAsync(ct); + } + catch (OperationCanceledException) + { + // Запрос отменён — прерываем тик штатно (не «сбой проверки напоминаний»). + throw; + } + catch (Exception exception) + { + logger.LogWarning(exception, "POST /api/admin/tick: проверка напоминаний не удалась — reminders ответа пуст"); + dueReminders = Array.Empty(); + } + + foreach (CardReminderDueDto due in dueReminders) + { + broker.Publish(tenantId, ReminderDueEventType, due); + } + + PipelinePumpResult? pump = await PumpOnceSafelyAsync(tenantId, ct); + + if (pump is not null) + { + foreach (CardDto card in pump.CreatedCards) + { + broker.Publish(tenantId, NewCardEventType, card); + } + } + + QueueCountsDto queueCounts = await processing.QueueCountsAsync(ct); + + return new AdminTickResultDto( + mergedStorage, + dueReminders, + pump is null ? new Dictionary() : ToPipelineWireDict(pump), + queueCounts.Total); + } + + private async Task PumpOnceSafelyAsync(Guid tenantId, CancellationToken ct) + { + if (!pumpGate.TryEnter(tenantId)) + { + return null; + } + + try + { + return await worker.PumpOnceAsync(ct); + } + catch (OperationCanceledException) + { + // Запрос отменён — прерываем тик штатно (не «сбой pump»). + throw; + } + catch (Exception exception) + { + logger.LogWarning(exception, "POST /api/admin/tick: проход pump не удался — тик возвращает storage без pipeline"); + return null; + } + finally + { + pumpGate.Exit(tenantId); + } + } + + private static IReadOnlyDictionary ToPipelineWireDict(PipelinePumpResult pump) + { + int[] counters = + [ + pump.Staged, pump.RulesStored, pump.MlStored, pump.MlDrop, pump.TypeDrop, + pump.AiStored, pump.AiDrop, pump.AiFail, pump.NoBudget, + ]; + + var wire = new Dictionary(PipelineCounterKeys.Length, StringComparer.Ordinal); + for (int index = 0; index < PipelineCounterKeys.Length; index++) + { + wire[PipelineCounterKeys[index]] = counters[index]; + } + + return wire; + } +} diff --git a/src/core/Deal.Api/Services/AuditAppender.cs b/src/core/Deal.Api/Services/AuditAppender.cs index 2700a50..817f2ef 100644 --- a/src/core/Deal.Api/Services/AuditAppender.cs +++ b/src/core/Deal.Api/Services/AuditAppender.cs @@ -1,81 +1,81 @@ -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Api.Services; - -/// -/// Хелпер записи аудита действий пользователей тенанта и операторов -/// -public static class AuditAppender -{ - /// - /// Пишет событие действия пользователя тенанта - /// - /// Тип события — константа . - /// Минимальные детали события (обычно анонимный объект) или null. - public static async Task AppendTenantAsync( - HttpContext context, - string eventType, - object? details, - CancellationToken ct) - { - CurrentUser? user = context.GetCurrentUser(); - if (user is null) - { - return; - } - - AuditService audit = context.RequestServices.GetRequiredService(); - await audit.AppendAsync( - new AuditRecordDto( - eventType, - AuditActorTypes.Tenant, - ActorId: user.UserId, - TenantId: user.TenantId, - Ip: ClientIp(context), - DetailJson: DetailJson(details)), - ct); - } - - /// - /// Пишет событие действия оператора - /// - /// Тип события — константа . - /// Минимальные детали события (обычно анонимный объект) или null. - public static async Task AppendOperatorAsync( - HttpContext context, - string eventType, - object? details, - CancellationToken ct) - { - CurrentOperator? operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return; - } - - AuditService audit = context.RequestServices.GetRequiredService(); - await audit.AppendAsync( - new AuditRecordDto( - eventType, - AuditActorTypes.Operator, - ActorId: operatorIdentity.OperatorId, - TenantId: null, - Ip: ClientIp(context), - DetailJson: DetailJson(details)), - ct); - } - - // Сериализует детали события (null — деталей нет). - // details: Объект деталей или null. - // Возвращает: JSON деталей (camelCase) или null. - private static string? DetailJson(object? details) => - details is null ? null : AuditService.ToDetailJson(details); - - // IP-адрес клиента для аудита (без порта; null, если недоступен). - // context: Контекст запроса. - // Возвращает: Строковое представление IP или null. - private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); -} +using Deal.Api.Extensions; +using Deal.Api.Models; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Api.Services; + +/// +/// Хелпер записи аудита действий пользователей тенанта и операторов +/// +public static class AuditAppender +{ + /// + /// Пишет событие действия пользователя тенанта + /// + /// Тип события — константа . + /// Минимальные детали события (обычно анонимный объект) или null. + public static async Task AppendTenantAsync( + HttpContext context, + string eventType, + object? details, + CancellationToken ct) + { + CurrentUser? user = context.GetCurrentUser(); + if (user is null) + { + return; + } + + AuditService audit = context.RequestServices.GetRequiredService(); + await audit.AppendAsync( + new AuditRecordDto( + eventType, + AuditActorTypes.Tenant, + ActorId: user.UserId, + TenantId: user.TenantId, + Ip: ClientIp(context), + DetailJson: DetailJson(details)), + ct); + } + + /// + /// Пишет событие действия оператора + /// + /// Тип события — константа . + /// Минимальные детали события (обычно анонимный объект) или null. + public static async Task AppendOperatorAsync( + HttpContext context, + string eventType, + object? details, + CancellationToken ct) + { + CurrentOperator? operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return; + } + + AuditService audit = context.RequestServices.GetRequiredService(); + await audit.AppendAsync( + new AuditRecordDto( + eventType, + AuditActorTypes.Operator, + ActorId: operatorIdentity.OperatorId, + TenantId: null, + Ip: ClientIp(context), + DetailJson: DetailJson(details)), + ct); + } + + // Сериализует детали события (null — деталей нет). + // details: Объект деталей или null. + // Возвращает: JSON деталей (camelCase) или null. + private static string? DetailJson(object? details) => + details is null ? null : AuditService.ToDetailJson(details); + + // IP-адрес клиента для аудита (без порта; null, если недоступен). + // context: Контекст запроса. + // Возвращает: Строковое представление IP или null. + private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); +} diff --git a/src/core/Deal.Api/Services/EndpointResults.cs b/src/core/Deal.Api/Services/EndpointResults.cs index 0bc95bf..64252e1 100644 --- a/src/core/Deal.Api/Services/EndpointResults.cs +++ b/src/core/Deal.Api/Services/EndpointResults.cs @@ -1,57 +1,57 @@ -using Deal.Api.Extensions; - -namespace Deal.Api.Services; - -/// -/// Общие ответы ошибок минимальных API -/// -public static class EndpointResults -{ - /// - /// 401-ответ: сессия не разрешена/учётные данные не приняты. - /// - /// Текст ошибки (напр. ). - /// JSON-ответ {detail} со статусом 401. - public static IResult Unauthorized(string detail) => - Results.Json(new { detail }, statusCode: StatusCodes.Status401Unauthorized); - - /// - /// 400-ответ: некорректный запрос - /// - /// Текст ошибки. - /// JSON-ответ {detail} со статусом 400. - public static IResult BadRequest(string detail) => - Results.Json(new { detail }, statusCode: StatusCodes.Status400BadRequest); - - /// - /// 404-ответ: ресурс не найден. - /// - /// Текст ошибки. - /// JSON-ответ {detail} со статусом 404. - public static IResult NotFound(string detail) => - Results.Json(new { detail }, statusCode: StatusCodes.Status404NotFound); - - /// - /// 403-ответ: запрос понятен, но доступ запрещён - /// - /// Текст ошибки. - /// JSON-ответ {detail} со статусом 403. - public static IResult Forbidden(string detail) => - Results.Json(new { detail }, statusCode: StatusCodes.Status403Forbidden); - - /// - /// 429-ответ: превышен лимит запросов/попыток входа. - /// - /// Текст ошибки. - /// JSON-ответ {detail} со статусом 429. - public static IResult TooManyRequests(string detail) => - Results.Json(new { detail }, statusCode: StatusCodes.Status429TooManyRequests); - - /// - /// 410-ответ: ресурс больше недоступен. - /// - /// Текст ошибки. - /// JSON-ответ {detail} со статусом 410. - public static IResult Gone(string detail) => - Results.Json(new { detail }, statusCode: StatusCodes.Status410Gone); -} +using Deal.Api.Extensions; + +namespace Deal.Api.Services; + +/// +/// Общие ответы ошибок минимальных API +/// +public static class EndpointResults +{ + /// + /// 401-ответ: сессия не разрешена/учётные данные не приняты. + /// + /// Текст ошибки (напр. ). + /// JSON-ответ {detail} со статусом 401. + public static IResult Unauthorized(string detail) => + Results.Json(new { detail }, statusCode: StatusCodes.Status401Unauthorized); + + /// + /// 400-ответ: некорректный запрос + /// + /// Текст ошибки. + /// JSON-ответ {detail} со статусом 400. + public static IResult BadRequest(string detail) => + Results.Json(new { detail }, statusCode: StatusCodes.Status400BadRequest); + + /// + /// 404-ответ: ресурс не найден. + /// + /// Текст ошибки. + /// JSON-ответ {detail} со статусом 404. + public static IResult NotFound(string detail) => + Results.Json(new { detail }, statusCode: StatusCodes.Status404NotFound); + + /// + /// 403-ответ: запрос понятен, но доступ запрещён + /// + /// Текст ошибки. + /// JSON-ответ {detail} со статусом 403. + public static IResult Forbidden(string detail) => + Results.Json(new { detail }, statusCode: StatusCodes.Status403Forbidden); + + /// + /// 429-ответ: превышен лимит запросов/попыток входа. + /// + /// Текст ошибки. + /// JSON-ответ {detail} со статусом 429. + public static IResult TooManyRequests(string detail) => + Results.Json(new { detail }, statusCode: StatusCodes.Status429TooManyRequests); + + /// + /// 410-ответ: ресурс больше недоступен. + /// + /// Текст ошибки. + /// JSON-ответ {detail} со статусом 410. + public static IResult Gone(string detail) => + Results.Json(new { detail }, statusCode: StatusCodes.Status410Gone); +} diff --git a/src/core/Deal.Api/Services/InlineProgress.cs b/src/core/Deal.Api/Services/InlineProgress.cs index 192658f..a71a722 100644 --- a/src/core/Deal.Api/Services/InlineProgress.cs +++ b/src/core/Deal.Api/Services/InlineProgress.cs @@ -1,11 +1,11 @@ -namespace Deal.Api.Services; - -/// -/// Синхронный : обработчик вызывается в потоке отчёта (без захвата контекста). -/// -/// Обработчик очередного значения прогресса. -public sealed class InlineProgress(Action handler) : IProgress -{ - /// - public void Report(T value) => handler(value); -} +namespace Deal.Api.Services; + +/// +/// Синхронный : обработчик вызывается в потоке отчёта (без захвата контекста). +/// +/// Обработчик очередного значения прогресса. +public sealed class InlineProgress(Action handler) : IProgress +{ + /// + public void Report(T value) => handler(value); +} diff --git a/src/core/Deal.Api/Services/LoginAttemptGuard.cs b/src/core/Deal.Api/Services/LoginAttemptGuard.cs index 7861f1e..715836b 100644 --- a/src/core/Deal.Api/Services/LoginAttemptGuard.cs +++ b/src/core/Deal.Api/Services/LoginAttemptGuard.cs @@ -1,137 +1,137 @@ -using Deal.Api.Configuration; -using Deal.Modules.Tenants.Application.Abstractions; - -namespace Deal.Api.Services; - -/// -/// Прикладной guard неудачных попыток входа -/// -public sealed class LoginAttemptGuard -{ - /// - /// Текст 429 при блокировке ключа. - /// - public const string BlockedDetail = "Слишком много попыток входа. Попробуйте через 15 минут"; - - // Разделитель ключа ip|login. - private const char KeySeparator = '|'; - - // Фолбэк IP в ключе, когда адрес клиента недоступен. - private const string UnknownIp = "unknown"; - - // Префикс ключа хранилища: изолирует счётчики попыток входа от rate-limit-политик/ингресса. - private const string KeyPrefix = "login:"; - - // Нижняя граница окна в минутах (защита от нулевого окна в конфигурации). - private const int MinimumWindowMinutes = 1; - - private readonly bool _enabled; - private readonly int _maxFailures; - private readonly long _windowTicks; - private readonly IRateLimitCounterStore _store; - private readonly Func _clock; - - /// - /// Создаёт гвард с часами UTC-«сейчас» - /// - /// Настройки rate limiting (секция RateLimit). - /// Хранилище счётчиков фиксированного окна (public.rate_limit_counters). - public LoginAttemptGuard(RateLimitOptions options, IRateLimitCounterStore store) - : this(options, store, () => DateTimeOffset.UtcNow) - { - } - - /// - /// Создаёт гвард с инъекцией часов - /// - /// Настройки rate limiting (секция RateLimit). - /// Хранилище счётчиков фиксированного окна (public.rate_limit_counters). - /// Источник «сейчас». - public LoginAttemptGuard( - RateLimitOptions options, - IRateLimitCounterStore store, - Func clock) - { - ArgumentNullException.ThrowIfNull(options); - ArgumentNullException.ThrowIfNull(store); - ArgumentNullException.ThrowIfNull(clock); - - _enabled = options.Enabled; - _maxFailures = Math.Max(1, options.LoginAttemptsMax); - _windowTicks = TimeSpan.FromMinutes(Math.Max(MinimumWindowMinutes, options.LoginAttemptWindowMin)).Ticks; - _store = store; - _clock = clock; - } - - /// - /// Заблокирован ли ключ ip|login - /// - /// IP клиента (null/пустой — фолбэк unknown). - /// Нормализованный логин; пустой/пробельный — блокировке не подлежит. - /// true — следующий вход ключа отклоняется 429 до проверки учётных данных. - public async Task IsBlockedAsync( - string? ip, - string? login, - CancellationToken ct) - { - if (!_enabled || string.IsNullOrWhiteSpace(login)) - { - return false; - } - - int failures = await _store.GetCountAsync(Key(ip, login), CurrentWindowStart(), ct); - return failures >= _maxFailures; - } - - /// - /// Записывает неудачную попытку входа ключа - /// - /// IP клиента (null/пустой — фолбэк unknown). - /// Нормализованный логин; пустой/пробельный — не записывается. - public async Task RecordFailureAsync( - string? ip, - string? login, - CancellationToken ct) - { - if (!_enabled || string.IsNullOrWhiteSpace(login)) - { - return; - } - - DateTimeOffset windowStart = CurrentWindowStart(); - await _store.IncrementAsync(Key(ip, login), windowStart, windowStart.AddTicks(_windowTicks), 1, ct); - } - - /// - /// Сбрасывает счётчик ключа. - /// - /// IP клиента (null/пустой — фолбэк unknown). - /// Логин успешно вошедшего (нормализованный). - public async Task ResetAsync( - string? ip, - string? login, - CancellationToken ct) - { - if (!_enabled || string.IsNullOrWhiteSpace(login)) - { - return; - } - - await _store.ResetAsync(Key(ip, login), ct); - } - - // Начало текущего фиксированного окна (выровнено по границам — единое для всех инстансов). - // Возвращает: Момент начала окна (UTC). - private DateTimeOffset CurrentWindowStart() - { - long ticks = _clock().UtcTicks; - return new DateTimeOffset(ticks - (ticks % _windowTicks), TimeSpan.Zero); - } - - // Ключ записи хранилища: login:ip|login (пустой IP — unknown). - // ip: IP клиента. - // login: Логин (не пустой — проверено вызывающим). - // Возвращает: Ключ окна. - private static string Key(string? ip, string login) => - KeyPrefix + (string.IsNullOrEmpty(ip) ? UnknownIp : ip) + KeySeparator + login; -} +using Deal.Api.Configuration; +using Deal.Modules.Tenants.Application.Abstractions; + +namespace Deal.Api.Services; + +/// +/// Прикладной guard неудачных попыток входа +/// +public sealed class LoginAttemptGuard +{ + /// + /// Текст 429 при блокировке ключа. + /// + public const string BlockedDetail = "Слишком много попыток входа. Попробуйте через 15 минут"; + + // Разделитель ключа ip|login. + private const char KeySeparator = '|'; + + // Фолбэк IP в ключе, когда адрес клиента недоступен. + private const string UnknownIp = "unknown"; + + // Префикс ключа хранилища: изолирует счётчики попыток входа от rate-limit-политик/ингресса. + private const string KeyPrefix = "login:"; + + // Нижняя граница окна в минутах (защита от нулевого окна в конфигурации). + private const int MinimumWindowMinutes = 1; + + private readonly bool _enabled; + private readonly int _maxFailures; + private readonly long _windowTicks; + private readonly IRateLimitCounterStore _store; + private readonly Func _clock; + + /// + /// Создаёт гвард с часами UTC-«сейчас» + /// + /// Настройки rate limiting (секция RateLimit). + /// Хранилище счётчиков фиксированного окна (public.rate_limit_counters). + public LoginAttemptGuard(RateLimitOptions options, IRateLimitCounterStore store) + : this(options, store, () => DateTimeOffset.UtcNow) + { + } + + /// + /// Создаёт гвард с инъекцией часов + /// + /// Настройки rate limiting (секция RateLimit). + /// Хранилище счётчиков фиксированного окна (public.rate_limit_counters). + /// Источник «сейчас». + public LoginAttemptGuard( + RateLimitOptions options, + IRateLimitCounterStore store, + Func clock) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(clock); + + _enabled = options.Enabled; + _maxFailures = Math.Max(1, options.LoginAttemptsMax); + _windowTicks = TimeSpan.FromMinutes(Math.Max(MinimumWindowMinutes, options.LoginAttemptWindowMin)).Ticks; + _store = store; + _clock = clock; + } + + /// + /// Заблокирован ли ключ ip|login + /// + /// IP клиента (null/пустой — фолбэк unknown). + /// Нормализованный логин; пустой/пробельный — блокировке не подлежит. + /// true — следующий вход ключа отклоняется 429 до проверки учётных данных. + public async Task IsBlockedAsync( + string? ip, + string? login, + CancellationToken ct) + { + if (!_enabled || string.IsNullOrWhiteSpace(login)) + { + return false; + } + + int failures = await _store.GetCountAsync(Key(ip, login), CurrentWindowStart(), ct); + return failures >= _maxFailures; + } + + /// + /// Записывает неудачную попытку входа ключа + /// + /// IP клиента (null/пустой — фолбэк unknown). + /// Нормализованный логин; пустой/пробельный — не записывается. + public async Task RecordFailureAsync( + string? ip, + string? login, + CancellationToken ct) + { + if (!_enabled || string.IsNullOrWhiteSpace(login)) + { + return; + } + + DateTimeOffset windowStart = CurrentWindowStart(); + await _store.IncrementAsync(Key(ip, login), windowStart, windowStart.AddTicks(_windowTicks), 1, ct); + } + + /// + /// Сбрасывает счётчик ключа. + /// + /// IP клиента (null/пустой — фолбэк unknown). + /// Логин успешно вошедшего (нормализованный). + public async Task ResetAsync( + string? ip, + string? login, + CancellationToken ct) + { + if (!_enabled || string.IsNullOrWhiteSpace(login)) + { + return; + } + + await _store.ResetAsync(Key(ip, login), ct); + } + + // Начало текущего фиксированного окна (выровнено по границам — единое для всех инстансов). + // Возвращает: Момент начала окна (UTC). + private DateTimeOffset CurrentWindowStart() + { + long ticks = _clock().UtcTicks; + return new DateTimeOffset(ticks - (ticks % _windowTicks), TimeSpan.Zero); + } + + // Ключ записи хранилища: login:ip|login (пустой IP — unknown). + // ip: IP клиента. + // login: Логин (не пустой — проверено вызывающим). + // Возвращает: Ключ окна. + private static string Key(string? ip, string login) => + KeyPrefix + (string.IsNullOrEmpty(ip) ? UnknownIp : ip) + KeySeparator + login; +} diff --git a/src/core/Deal.Api/Services/PipelinePumpGate.cs b/src/core/Deal.Api/Services/PipelinePumpGate.cs index ba9cb88..f281992 100644 --- a/src/core/Deal.Api/Services/PipelinePumpGate.cs +++ b/src/core/Deal.Api/Services/PipelinePumpGate.cs @@ -1,25 +1,25 @@ -using System.Collections.Concurrent; - -namespace Deal.Api.Services; - -/// -/// Общий воркер-гейт pump одного тенанта -/// -public sealed class PipelinePumpGate -{ - // Тенанты с выполняющимся в данный момент pump (ключ — TenantId; значение — заглушка, важен факт наличия ключа). - private readonly ConcurrentDictionary _activePumps = new(); - - /// - /// Пытается захватить pump тенанта - /// - /// Тенант (Guid из реестра/сессии — один и тот же для тика и цикла). - /// true — захват выполнен (после прохода обязателен парный в finally); false — pump этого тенанта уже идёт. - public bool TryEnter(Guid tenantId) => _activePumps.TryAdd(tenantId, 0); - - /// - /// Освобождает pump тенанта после прохода - /// - /// Тенант захваченного гейта. - public void Exit(Guid tenantId) => _activePumps.TryRemove(tenantId, out _); -} +using System.Collections.Concurrent; + +namespace Deal.Api.Services; + +/// +/// Общий воркер-гейт pump одного тенанта +/// +public sealed class PipelinePumpGate +{ + // Тенанты с выполняющимся в данный момент pump (ключ — TenantId; значение — заглушка, важен факт наличия ключа). + private readonly ConcurrentDictionary _activePumps = new(); + + /// + /// Пытается захватить pump тенанта + /// + /// Тенант (Guid из реестра/сессии — один и тот же для тика и цикла). + /// true — захват выполнен (после прохода обязателен парный в finally); false — pump этого тенанта уже идёт. + public bool TryEnter(Guid tenantId) => _activePumps.TryAdd(tenantId, 0); + + /// + /// Освобождает pump тенанта после прохода + /// + /// Тенант захваченного гейта. + public void Exit(Guid tenantId) => _activePumps.TryRemove(tenantId, out _); +} diff --git a/src/core/Deal.Api/Services/PipelineWorkerScheduler.cs b/src/core/Deal.Api/Services/PipelineWorkerScheduler.cs index 9ec88c9..88e06be 100644 --- a/src/core/Deal.Api/Services/PipelineWorkerScheduler.cs +++ b/src/core/Deal.Api/Services/PipelineWorkerScheduler.cs @@ -1,195 +1,195 @@ -using Deal.Api.Events; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; - -namespace Deal.Api.Services; - -/// -/// Фоновый цикл разбора очереди входящих по всем тенантам. -/// -public sealed class PipelineWorkerScheduler : IHostedService -{ - private const int PipelinePeriodSeconds = 2; - - private static readonly TimeSpan PipelinePeriod = TimeSpan.FromSeconds(PipelinePeriodSeconds); - - private const string NewCardEventType = "new_card"; - - private readonly IServiceScopeFactory _scopeFactory; - private readonly PipelinePumpGate _pumpGate; - private readonly SseBroker _broker; - private readonly ILogger _logger; - - // Отмена при остановке хоста: прерывает текущий проход (EF-запросы pump наблюдают токен). - private readonly CancellationTokenSource _shutdownCts = new(); - - private Timer? _timer; - private Task? _currentIteration; - private int _iterationInProgress; - - /// - /// Создаёт планировщик фонового цикла разбора очереди. - /// - /// Фабрика scope: проход цикла и pump каждого тенанта — в собственных scope. - /// Общий воркер-гейт pump тенанта (singleton; общий с POST /api/admin/tick). - /// SSE-брокер каналов тенантов (публикация new_card по созданным карточкам). - /// Логгер ошибок цикла. - public PipelineWorkerScheduler( - IServiceScopeFactory scopeFactory, - PipelinePumpGate pumpGate, - SseBroker broker, - ILogger logger) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(pumpGate); - ArgumentNullException.ThrowIfNull(broker); - ArgumentNullException.ThrowIfNull(logger); - _scopeFactory = scopeFactory; - _pumpGate = pumpGate; - _broker = broker; - _logger = logger; - } - - /// - public Task StartAsync(CancellationToken ct) - { - _timer = new Timer( - static state => ((PipelineWorkerScheduler)state!).RunIteration(), - this, - TimeSpan.Zero, - PipelinePeriod); - return Task.CompletedTask; - } - - /// - public async Task StopAsync(CancellationToken ct) - { - // Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита - // остановки хоста (HostOptions.ShutdownTimeout). - _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); - _timer?.Dispose(); - _timer = null; - - _shutdownCts.Cancel(); - Task? iteration = Volatile.Read(ref _currentIteration); - if (iteration is not null) - { - try - { - await iteration.WaitAsync(ct); - } - catch (OperationCanceledException) - { - // Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри - // RunCycleCoreAsync, незавершённый проход безопасно завершится на отменённом токене. - } - } - } - - /// - /// Один проход цикла - /// - /// Задача прохода (завершается без исключений). - public Task RunCycleAsync(CancellationToken ct) - { - if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0) - { - return Task.CompletedTask; - } - - Task iteration = RunCycleCoreAsync(ct); - Volatile.Write(ref _currentIteration, iteration); - return iteration; - } - - // Запускает проход из callback таймера (guard — внутри RunCycleAsync). - private void RunIteration() - { - _ = RunCycleAsync(_shutdownCts.Token); - } - - // Тело прохода: pump каждого тенанта реестра; guard сбрасывается в finally. - // ct: Токен отмены (остановка хоста). - private async Task RunCycleCoreAsync(CancellationToken ct) - { - try - { - await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope(); - ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService(); - IReadOnlyList tenants = await tenantRepository.ListAsync(ct); - - foreach (TenantRecordDto tenant in tenants) - { - await PumpTenantAsync(tenant, ct); - } - } - catch (OperationCanceledException) - { - // Остановка хоста: проход прерван по токену — штатный выход, не ошибка. - } - catch (Exception exception) - { - // Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика. - _logger.LogError(exception, "Цикл разбора очереди: проход по тенантам не удался"); - } - finally - { - Interlocked.Exchange(ref _iterationInProgress, 0); - } - } - - // Pump одного тенанта в собственном scope: SetTenant → гейт → PumpOnce → SSE new_card; Reset в finally. - // Контекст AsyncLocal сбрасывается в finally, чтобы не переживать scope тенанта (как - // SessionMiddleware). Pump одного тенанта не валит проход: ошибка логируется, остальные тенанты - // обрабатываются; отмена (OCE) пробрасывается наверх — проход завершается. Занятый гейт тенанта — - // не ошибка: проход пропускается молча (очередь разберёт следующий цикл/ручной тик). - // tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>). - // ct: Токен отмены прохода. - private async Task PumpTenantAsync(TenantRecordDto tenant, CancellationToken ct) - { - await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope(); - ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); - try - { - tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); - - if (!_pumpGate.TryEnter(tenant.Id)) - { - return; - } - - try - { - // Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта. - PipelineWorkerService worker = tenantScope.ServiceProvider.GetRequiredService(); - PipelinePumpResult pump = await worker.PumpOnceAsync(ct); - - foreach (CardDto card in pump.CreatedCards) - { - _broker.Publish(tenant.Id, NewCardEventType, card); - } - } - finally - { - _pumpGate.Exit(tenant.Id); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - _logger.LogWarning(exception, "Цикл разбора очереди: pump тенанта {TenantId} не удался", tenant.Id); - } - finally - { - tenantContext.Reset(); - } - } -} +using Deal.Api.Events; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; + +namespace Deal.Api.Services; + +/// +/// Фоновый цикл разбора очереди входящих по всем тенантам. +/// +public sealed class PipelineWorkerScheduler : IHostedService +{ + private const int PipelinePeriodSeconds = 2; + + private static readonly TimeSpan PipelinePeriod = TimeSpan.FromSeconds(PipelinePeriodSeconds); + + private const string NewCardEventType = "new_card"; + + private readonly IServiceScopeFactory _scopeFactory; + private readonly PipelinePumpGate _pumpGate; + private readonly SseBroker _broker; + private readonly ILogger _logger; + + // Отмена при остановке хоста: прерывает текущий проход (EF-запросы pump наблюдают токен). + private readonly CancellationTokenSource _shutdownCts = new(); + + private Timer? _timer; + private Task? _currentIteration; + private int _iterationInProgress; + + /// + /// Создаёт планировщик фонового цикла разбора очереди. + /// + /// Фабрика scope: проход цикла и pump каждого тенанта — в собственных scope. + /// Общий воркер-гейт pump тенанта (singleton; общий с POST /api/admin/tick). + /// SSE-брокер каналов тенантов (публикация new_card по созданным карточкам). + /// Логгер ошибок цикла. + public PipelineWorkerScheduler( + IServiceScopeFactory scopeFactory, + PipelinePumpGate pumpGate, + SseBroker broker, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(scopeFactory); + ArgumentNullException.ThrowIfNull(pumpGate); + ArgumentNullException.ThrowIfNull(broker); + ArgumentNullException.ThrowIfNull(logger); + _scopeFactory = scopeFactory; + _pumpGate = pumpGate; + _broker = broker; + _logger = logger; + } + + /// + public Task StartAsync(CancellationToken ct) + { + _timer = new Timer( + static state => ((PipelineWorkerScheduler)state!).RunIteration(), + this, + TimeSpan.Zero, + PipelinePeriod); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken ct) + { + // Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита + // остановки хоста (HostOptions.ShutdownTimeout). + _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _timer?.Dispose(); + _timer = null; + + _shutdownCts.Cancel(); + Task? iteration = Volatile.Read(ref _currentIteration); + if (iteration is not null) + { + try + { + await iteration.WaitAsync(ct); + } + catch (OperationCanceledException) + { + // Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри + // RunCycleCoreAsync, незавершённый проход безопасно завершится на отменённом токене. + } + } + } + + /// + /// Один проход цикла + /// + /// Задача прохода (завершается без исключений). + public Task RunCycleAsync(CancellationToken ct) + { + if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0) + { + return Task.CompletedTask; + } + + Task iteration = RunCycleCoreAsync(ct); + Volatile.Write(ref _currentIteration, iteration); + return iteration; + } + + // Запускает проход из callback таймера (guard — внутри RunCycleAsync). + private void RunIteration() + { + _ = RunCycleAsync(_shutdownCts.Token); + } + + // Тело прохода: pump каждого тенанта реестра; guard сбрасывается в finally. + // ct: Токен отмены (остановка хоста). + private async Task RunCycleCoreAsync(CancellationToken ct) + { + try + { + await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope(); + ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService(); + IReadOnlyList tenants = await tenantRepository.ListAsync(ct); + + foreach (TenantRecordDto tenant in tenants) + { + await PumpTenantAsync(tenant, ct); + } + } + catch (OperationCanceledException) + { + // Остановка хоста: проход прерван по токену — штатный выход, не ошибка. + } + catch (Exception exception) + { + // Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика. + _logger.LogError(exception, "Цикл разбора очереди: проход по тенантам не удался"); + } + finally + { + Interlocked.Exchange(ref _iterationInProgress, 0); + } + } + + // Pump одного тенанта в собственном scope: SetTenant → гейт → PumpOnce → SSE new_card; Reset в finally. + // Контекст AsyncLocal сбрасывается в finally, чтобы не переживать scope тенанта (как + // SessionMiddleware). Pump одного тенанта не валит проход: ошибка логируется, остальные тенанты + // обрабатываются; отмена (OCE) пробрасывается наверх — проход завершается. Занятый гейт тенанта — + // не ошибка: проход пропускается молча (очередь разберёт следующий цикл/ручной тик). + // tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>). + // ct: Токен отмены прохода. + private async Task PumpTenantAsync(TenantRecordDto tenant, CancellationToken ct) + { + await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope(); + ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); + try + { + tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); + + if (!_pumpGate.TryEnter(tenant.Id)) + { + return; + } + + try + { + // Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта. + PipelineWorkerService worker = tenantScope.ServiceProvider.GetRequiredService(); + PipelinePumpResult pump = await worker.PumpOnceAsync(ct); + + foreach (CardDto card in pump.CreatedCards) + { + _broker.Publish(tenant.Id, NewCardEventType, card); + } + } + finally + { + _pumpGate.Exit(tenant.Id); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Цикл разбора очереди: pump тенанта {TenantId} не удался", tenant.Id); + } + finally + { + tenantContext.Reset(); + } + } +} diff --git a/src/core/Deal.Api/Services/RatesRefreshScheduler.cs b/src/core/Deal.Api/Services/RatesRefreshScheduler.cs index 2f27900..ed53a04 100644 --- a/src/core/Deal.Api/Services/RatesRefreshScheduler.cs +++ b/src/core/Deal.Api/Services/RatesRefreshScheduler.cs @@ -1,60 +1,60 @@ -using Deal.Modules.Settings.Application.Services; - -namespace Deal.Api.Services; - -/// -/// Фоновое обновление кэша курсов вне жизненного цикла запроса. -/// -public sealed class RatesRefreshScheduler -{ - private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; - private int _refreshInProgress; - - /// - /// Создаёт планировщик фоновых обновлений курсов. - /// - /// Фабрика scope для фоновой работы (root-провайдер). - /// Логгер ошибок фонового обновления. - public RatesRefreshScheduler(IServiceScopeFactory scopeFactory, ILogger logger) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(logger); - _scopeFactory = scopeFactory; - _logger = logger; - } - - /// - /// Планирует фоновый RefreshAsync - /// - public void Schedule() - { - if (Interlocked.CompareExchange(ref _refreshInProgress, 1, 0) != 0) - { - return; - } - - // Task.Run захватывает ExecutionContext текущего tenant-запроса: внутри фоновой работы - // AsyncLocal ITenantContext несёт тенанта, и новый scope строит TenantDbContext с его схемой. - _ = Task.Run(RunAsync); - } - - // Выполняет обновление в собственном scope; любые ошибки — warning в лог. - private async Task RunAsync() - { - try - { - await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); - RatesService ratesService = scope.ServiceProvider.GetRequiredService(); - await ratesService.RefreshAsync(CancellationToken.None); - } - catch (Exception exception) - { - _logger.LogWarning(exception, "Фоновое обновление курсов валют не удалось"); - } - finally - { - Interlocked.Exchange(ref _refreshInProgress, 0); - } - } -} +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Api.Services; + +/// +/// Фоновое обновление кэша курсов вне жизненного цикла запроса. +/// +public sealed class RatesRefreshScheduler +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private int _refreshInProgress; + + /// + /// Создаёт планировщик фоновых обновлений курсов. + /// + /// Фабрика scope для фоновой работы (root-провайдер). + /// Логгер ошибок фонового обновления. + public RatesRefreshScheduler(IServiceScopeFactory scopeFactory, ILogger logger) + { + ArgumentNullException.ThrowIfNull(scopeFactory); + ArgumentNullException.ThrowIfNull(logger); + _scopeFactory = scopeFactory; + _logger = logger; + } + + /// + /// Планирует фоновый RefreshAsync + /// + public void Schedule() + { + if (Interlocked.CompareExchange(ref _refreshInProgress, 1, 0) != 0) + { + return; + } + + // Task.Run захватывает ExecutionContext текущего tenant-запроса: внутри фоновой работы + // AsyncLocal ITenantContext несёт тенанта, и новый scope строит TenantDbContext с его схемой. + _ = Task.Run(RunAsync); + } + + // Выполняет обновление в собственном scope; любые ошибки — warning в лог. + private async Task RunAsync() + { + try + { + await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); + RatesService ratesService = scope.ServiceProvider.GetRequiredService(); + await ratesService.RefreshAsync(CancellationToken.None); + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Фоновое обновление курсов валют не удалось"); + } + finally + { + Interlocked.Exchange(ref _refreshInProgress, 0); + } + } +} diff --git a/src/core/Deal.Api/Services/SessionCookieWriter.cs b/src/core/Deal.Api/Services/SessionCookieWriter.cs index 8b9533c..a3d5fd0 100644 --- a/src/core/Deal.Api/Services/SessionCookieWriter.cs +++ b/src/core/Deal.Api/Services/SessionCookieWriter.cs @@ -1,33 +1,33 @@ -using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions; -using CookieOptions = Deal.Api.Configuration.CookieOptions; - -namespace Deal.Api.Services; - -/// -/// Единая запись httpOnly-куки сессии тенант-пользователя -/// -public static class SessionCookieWriter -{ - /// - /// Выставляет httpOnly-куку сессии на текущем ответе. - /// - /// Настройки куки из конфигурации (секция Cookies). - /// Raw-токен сессии. - public static void Append( - HttpContext context, - CookieOptions options, - string rawToken) - { - context.Response.Cookies.Append( - options.Name, - rawToken, - new AspNetCoreCookieOptions - { - HttpOnly = true, - SameSite = SameSiteMode.Lax, - Path = "/", - MaxAge = TimeSpan.FromDays(options.Days), - Secure = options.Secure, - }); - } -} +using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions; +using CookieOptions = Deal.Api.Configuration.CookieOptions; + +namespace Deal.Api.Services; + +/// +/// Единая запись httpOnly-куки сессии тенант-пользователя +/// +public static class SessionCookieWriter +{ + /// + /// Выставляет httpOnly-куку сессии на текущем ответе. + /// + /// Настройки куки из конфигурации (секция Cookies). + /// Raw-токен сессии. + public static void Append( + HttpContext context, + CookieOptions options, + string rawToken) + { + context.Response.Cookies.Append( + options.Name, + rawToken, + new AspNetCoreCookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Lax, + Path = "/", + MaxAge = TimeSpan.FromDays(options.Days), + Secure = options.Secure, + }); + } +} diff --git a/src/core/Deal.Api/Services/StoreBackedFixedWindowRateLimiter.cs b/src/core/Deal.Api/Services/StoreBackedFixedWindowRateLimiter.cs index e25f5eb..f23fa3f 100644 --- a/src/core/Deal.Api/Services/StoreBackedFixedWindowRateLimiter.cs +++ b/src/core/Deal.Api/Services/StoreBackedFixedWindowRateLimiter.cs @@ -1,109 +1,109 @@ -using System.Threading.RateLimiting; -using Deal.Modules.Tenants.Application.Abstractions; - -namespace Deal.Api.Services; - -/// -/// RateLimiter фиксированного окна с состоянием в Postgres -/// -public sealed class StoreBackedFixedWindowRateLimiter : RateLimiter -{ - private readonly IServiceScopeFactory _scopeFactory; - private readonly string _key; - private readonly int _permitLimit; - private readonly long _windowTicks; - private readonly Func _clock; - private readonly TimeSpan _idleDuration; - - /// - /// Создаёт лимитер одной партиции. - /// - /// Фабрика scope: хранилище счётчиков резолвится на каждое приобретение. - /// Ключ счётчика в хранилище (уникален для политики и партиции). - /// Разрешённое число запросов в окне (>0). - /// Длина фиксированного окна. - /// Источник «сейчас» (UTC) — тесты фиксируют границы окна. - public StoreBackedFixedWindowRateLimiter( - IServiceScopeFactory scopeFactory, - string key, - int permitLimit, - TimeSpan window, - Func clock) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentException.ThrowIfNullOrEmpty(key); - ArgumentOutOfRangeException.ThrowIfLessThan(permitLimit, 1); - ArgumentNullException.ThrowIfNull(clock); - if (window <= TimeSpan.Zero) - { - throw new ArgumentOutOfRangeException(nameof(window), window, "Окно лимитера должно быть положительным."); - } - - _scopeFactory = scopeFactory; - _key = key; - _permitLimit = permitLimit; - _windowTicks = window.Ticks; - _clock = clock; - _idleDuration = window; - } - - /// - public override TimeSpan? IdleDuration => _idleDuration; - - /// - public override RateLimiterStatistics? GetStatistics() => null; - - /// - protected override RateLimitLease AttemptAcquireCore(int permitCount) - => AcquireCoreAsync(permitCount, CancellationToken.None).AsTask().GetAwaiter().GetResult(); - - /// - protected override async ValueTask AcquireAsyncCore(int permitCount, CancellationToken cancellationToken) - => await AcquireCoreAsync(permitCount, cancellationToken); - - // Проверяет/потребляет лимит окна: инкремент в хранилище, разрешение — пока счётчик ≤ порога. - // permitCount: Сколько единиц запрашивается (0 — только проверка исчерпания). - // ct: Токен отмены. - // Возвращает: Лиз: acquired — запрос разрешён. - private async ValueTask AcquireCoreAsync(int permitCount, CancellationToken ct) - { - ArgumentOutOfRangeException.ThrowIfNegative(permitCount); - - DateTimeOffset windowStart = AlignWindow(_clock()); - await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); - IRateLimitCounterStore store = scope.ServiceProvider.GetRequiredService(); - if (permitCount == 0) - { - // Проверка без потребления (контракт RateLimiter.AttemptAcquire(0)). - int current = await store.GetCountAsync(_key, windowStart, ct); - return new FixedWindowLease(current < _permitLimit); - } - - DateTimeOffset windowEnd = windowStart.AddTicks(_windowTicks); - int count = await store.IncrementAsync(_key, windowStart, windowEnd, permitCount, ct); - return new FixedWindowLease(count <= _permitLimit); - } - - // Выравнивает момент по границам окна (одинаковые окна у всех инстансов и ключей). - // now: Текущий момент. - // Возвращает: Начало окна, содержащего now. - private DateTimeOffset AlignWindow(DateTimeOffset now) - => new(now.UtcTicks - (now.UtcTicks % _windowTicks), TimeSpan.Zero); - - // Лиз фиксированного окна: разрешён либо нет; метаданных (Retry-After и пр.) не несёт. - private sealed class FixedWindowLease(bool acquired) : RateLimitLease - { - /// - public override bool IsAcquired => acquired; - - /// - public override IEnumerable MetadataNames => []; - - /// - public override bool TryGetMetadata(string metadataName, out object? metadata) - { - metadata = null; - return false; - } - } -} +using System.Threading.RateLimiting; +using Deal.Modules.Tenants.Application.Abstractions; + +namespace Deal.Api.Services; + +/// +/// RateLimiter фиксированного окна с состоянием в Postgres +/// +public sealed class StoreBackedFixedWindowRateLimiter : RateLimiter +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly string _key; + private readonly int _permitLimit; + private readonly long _windowTicks; + private readonly Func _clock; + private readonly TimeSpan _idleDuration; + + /// + /// Создаёт лимитер одной партиции. + /// + /// Фабрика scope: хранилище счётчиков резолвится на каждое приобретение. + /// Ключ счётчика в хранилище (уникален для политики и партиции). + /// Разрешённое число запросов в окне (>0). + /// Длина фиксированного окна. + /// Источник «сейчас» (UTC) — тесты фиксируют границы окна. + public StoreBackedFixedWindowRateLimiter( + IServiceScopeFactory scopeFactory, + string key, + int permitLimit, + TimeSpan window, + Func clock) + { + ArgumentNullException.ThrowIfNull(scopeFactory); + ArgumentException.ThrowIfNullOrEmpty(key); + ArgumentOutOfRangeException.ThrowIfLessThan(permitLimit, 1); + ArgumentNullException.ThrowIfNull(clock); + if (window <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(window), window, "Окно лимитера должно быть положительным."); + } + + _scopeFactory = scopeFactory; + _key = key; + _permitLimit = permitLimit; + _windowTicks = window.Ticks; + _clock = clock; + _idleDuration = window; + } + + /// + public override TimeSpan? IdleDuration => _idleDuration; + + /// + public override RateLimiterStatistics? GetStatistics() => null; + + /// + protected override RateLimitLease AttemptAcquireCore(int permitCount) + => AcquireCoreAsync(permitCount, CancellationToken.None).AsTask().GetAwaiter().GetResult(); + + /// + protected override async ValueTask AcquireAsyncCore(int permitCount, CancellationToken cancellationToken) + => await AcquireCoreAsync(permitCount, cancellationToken); + + // Проверяет/потребляет лимит окна: инкремент в хранилище, разрешение — пока счётчик ≤ порога. + // permitCount: Сколько единиц запрашивается (0 — только проверка исчерпания). + // ct: Токен отмены. + // Возвращает: Лиз: acquired — запрос разрешён. + private async ValueTask AcquireCoreAsync(int permitCount, CancellationToken ct) + { + ArgumentOutOfRangeException.ThrowIfNegative(permitCount); + + DateTimeOffset windowStart = AlignWindow(_clock()); + await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); + IRateLimitCounterStore store = scope.ServiceProvider.GetRequiredService(); + if (permitCount == 0) + { + // Проверка без потребления (контракт RateLimiter.AttemptAcquire(0)). + int current = await store.GetCountAsync(_key, windowStart, ct); + return new FixedWindowLease(current < _permitLimit); + } + + DateTimeOffset windowEnd = windowStart.AddTicks(_windowTicks); + int count = await store.IncrementAsync(_key, windowStart, windowEnd, permitCount, ct); + return new FixedWindowLease(count <= _permitLimit); + } + + // Выравнивает момент по границам окна (одинаковые окна у всех инстансов и ключей). + // now: Текущий момент. + // Возвращает: Начало окна, содержащего now. + private DateTimeOffset AlignWindow(DateTimeOffset now) + => new(now.UtcTicks - (now.UtcTicks % _windowTicks), TimeSpan.Zero); + + // Лиз фиксированного окна: разрешён либо нет; метаданных (Retry-After и пр.) не несёт. + private sealed class FixedWindowLease(bool acquired) : RateLimitLease + { + /// + public override bool IsAcquired => acquired; + + /// + public override IEnumerable MetadataNames => []; + + /// + public override bool TryGetMetadata(string metadataName, out object? metadata) + { + metadata = null; + return false; + } + } +} diff --git a/src/core/Deal.Api/Services/SuspiciousActivityReporter.cs b/src/core/Deal.Api/Services/SuspiciousActivityReporter.cs index bc6b7bb..ef8d2a5 100644 --- a/src/core/Deal.Api/Services/SuspiciousActivityReporter.cs +++ b/src/core/Deal.Api/Services/SuspiciousActivityReporter.cs @@ -1,38 +1,38 @@ -using System.Diagnostics; -using Deal.SharedKernel.Observability; -using Microsoft.Extensions.Logging; - -namespace Deal.Api.Services; - -/// -/// Признаки подозрительной активности и их учёт (метрика + предупреждающий лог). -/// -/// Логгер событий. -public sealed class SuspiciousActivityReporter(ILogger logger) -{ - /// - /// Вид: сработал rate limiter (слишком много запросов). - /// - public const string RateLimitKind = "rate_limit"; - - /// - /// Вид: вход заблокирован после серии неудачных попыток. - /// - public const string LoginBlockedKind = "login_blocked"; - - // Актор неизвестен (нет ни тенанта, ни IP). - private const string UnknownActor = "-"; - - /// - /// Учитывает событие подозрительной активности. - /// - /// Вид события (константы класса). - /// Актор: id тенанта либо IP; null/пусто — «-». - public void Report(string kind, string? actor) - { - ArgumentException.ThrowIfNullOrWhiteSpace(kind); - string resolvedActor = string.IsNullOrWhiteSpace(actor) ? UnknownActor : actor; - DealMetrics.SecurityEvents.Add(1, new TagList { { DealMetrics.SecurityKindTagName, kind } }); - logger.LogWarning("Подозрительная активность: {Kind}, actor={Actor}", kind, resolvedActor); - } -} +using System.Diagnostics; +using Deal.SharedKernel.Observability; +using Microsoft.Extensions.Logging; + +namespace Deal.Api.Services; + +/// +/// Признаки подозрительной активности и их учёт (метрика + предупреждающий лог). +/// +/// Логгер событий. +public sealed class SuspiciousActivityReporter(ILogger logger) +{ + /// + /// Вид: сработал rate limiter (слишком много запросов). + /// + public const string RateLimitKind = "rate_limit"; + + /// + /// Вид: вход заблокирован после серии неудачных попыток. + /// + public const string LoginBlockedKind = "login_blocked"; + + // Актор неизвестен (нет ни тенанта, ни IP). + private const string UnknownActor = "-"; + + /// + /// Учитывает событие подозрительной активности. + /// + /// Вид события (константы класса). + /// Актор: id тенанта либо IP; null/пусто — «-». + public void Report(string kind, string? actor) + { + ArgumentException.ThrowIfNullOrWhiteSpace(kind); + string resolvedActor = string.IsNullOrWhiteSpace(actor) ? UnknownActor : actor; + DealMetrics.SecurityEvents.Add(1, new TagList { { DealMetrics.SecurityKindTagName, kind } }); + logger.LogWarning("Подозрительная активность: {Kind}, actor={Actor}", kind, resolvedActor); + } +} diff --git a/src/core/Deal.Api/Services/TelegramBackfillScheduler.cs b/src/core/Deal.Api/Services/TelegramBackfillScheduler.cs index 97cbe71..cb9eca2 100644 --- a/src/core/Deal.Api/Services/TelegramBackfillScheduler.cs +++ b/src/core/Deal.Api/Services/TelegramBackfillScheduler.cs @@ -1,124 +1,124 @@ -using Deal.Modules.Telegram.Application; - -namespace Deal.Api.Services; - -/// -/// Фоновые спуски перечитывания каналов вне жизненного цикла запроса. -/// -/// Фабрика scope для фоновой работы (root-провайдер). -/// Логгер ошибок/аудита фоновых спусков. -public sealed class TelegramBackfillScheduler( - IServiceScopeFactory scopeFactory, - IHostApplicationLifetime applicationLifetime, - ILogger logger) -{ - // Флаг in-flight «Перечитать» всех каналов (Interlocked): повторный вызов не плодит - // параллельные полные перечитывания (Security review, как RatesRefreshScheduler). - private int _readRecentInProgress; - - /// - /// Запускает фоновое «Перечитать» всех включённых каналов. - /// - public void ScheduleReadRecent() - { - if (Interlocked.CompareExchange(ref _readRecentInProgress, 1, 0) != 0) - { - logger.LogInformation("Фоновое «Перечитать» уже выполняется — повторный запуск пропущен"); - return; - } - - RunBackground("backfill_monitored", RunReadRecentAsync); - } - - /// - /// Запускает фоновый первый разбор одного канала. - /// - /// Id диалога, включённого впервые (Backfilled=false). - public void ScheduleFirstBackfill(string dialogId) - { - ScheduleFirstBackfills([dialogId]); - } - - /// - /// Запускает фоновый первый разбор списка каналов. - /// - /// Id неразобранных диалогов (пусто — no-op). - public void ScheduleFirstBackfills(IReadOnlyCollection dialogIds) - { - if (dialogIds.Count == 0) - { - return; - } - - RunBackground("first_backfill", (provider, runLogger, ct) => RunFirstBackfillsAsync(provider, runLogger, dialogIds, ct)); - } - - // Выполняет работу в собственном scope; любые ошибки — warning в лог (как RatesRefreshScheduler). - // Работа отменяется при остановке хоста (IHostApplicationLifetime), чтобы graceful shutdown не ждал - // фоновых разборов вне жизненного цикла (Security review). - // operation: Имя операции (лог-аудит). - // work: Фоновая работа (scope + логгер + токен). - private void RunBackground(string operation, Func, CancellationToken, Task> work) - { - // Task.Run захватывает ExecutionContext текущего tenant-запроса: внутри фоновой работы AsyncLocal - // ITenantContext несёт тенанта, и новый scope строит TenantDbContext с его схемой (как RatesRefreshScheduler). - _ = Task.Run(async () => - { - // Токен останавливающегося хоста: долгий разбор прерывается на graceful shutdown. - using var linked = CancellationTokenSource.CreateLinkedTokenSource(applicationLifetime.ApplicationStopping); - try - { - await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope(); - await work(scope.ServiceProvider, logger, linked.Token); - } - catch (OperationCanceledException) when (applicationLifetime.ApplicationStopping.IsCancellationRequested) - { - logger.LogInformation("Фоновая операция Telegram {Operation} прервана остановкой хоста", operation); - } - catch (Exception exception) - { - logger.LogWarning(exception, "Фоновая операция Telegram {Operation} не удалась", operation); - } - finally - { - Interlocked.Exchange(ref _readRecentInProgress, 0); - } - }); - } - - // «Перечитать» всех включённых каналов в фоне (ReadRecentAsync продолжает при частичном падении). - private static async Task RunReadRecentAsync( - IServiceProvider provider, - ILogger runLogger, - CancellationToken ct) - { - DialogsService dialogs = provider.GetRequiredService(); - int count = await dialogs.ReadRecentAsync(ct); - runLogger.LogInformation("Фоновое «Перечитать»: {Count} каналов отправлено на разбор", count); - } - - // Первый разбор неразобранных каналов: Backfill(force=false) + флаг разбора после успеха. - private static async Task RunFirstBackfillsAsync( - IServiceProvider provider, - ILogger runLogger, - IReadOnlyCollection dialogIds, - CancellationToken ct) - { - DialogsService dialogs = provider.GetRequiredService(); - foreach (string dialogId in dialogIds) - { - try - { - int processed = await dialogs.BackfillOneAsync(dialogId, force: false, ct); - if (processed > 0) - { - runLogger.LogInformation("Первый разбор {DialogId}: {Processed} сообщений в очередь", dialogId, processed); - } - } - catch (Exception exception) when (exception is not OperationCanceledException || !ct.IsCancellationRequested) - { - runLogger.LogWarning(exception, "Первый разбор {DialogId} не удался", dialogId); - } - } - } -} +using Deal.Modules.Telegram.Application; + +namespace Deal.Api.Services; + +/// +/// Фоновые спуски перечитывания каналов вне жизненного цикла запроса. +/// +/// Фабрика scope для фоновой работы (root-провайдер). +/// Логгер ошибок/аудита фоновых спусков. +public sealed class TelegramBackfillScheduler( + IServiceScopeFactory scopeFactory, + IHostApplicationLifetime applicationLifetime, + ILogger logger) +{ + // Флаг in-flight «Перечитать» всех каналов (Interlocked): повторный вызов не плодит + // параллельные полные перечитывания (Security review, как RatesRefreshScheduler). + private int _readRecentInProgress; + + /// + /// Запускает фоновое «Перечитать» всех включённых каналов. + /// + public void ScheduleReadRecent() + { + if (Interlocked.CompareExchange(ref _readRecentInProgress, 1, 0) != 0) + { + logger.LogInformation("Фоновое «Перечитать» уже выполняется — повторный запуск пропущен"); + return; + } + + RunBackground("backfill_monitored", RunReadRecentAsync); + } + + /// + /// Запускает фоновый первый разбор одного канала. + /// + /// Id диалога, включённого впервые (Backfilled=false). + public void ScheduleFirstBackfill(string dialogId) + { + ScheduleFirstBackfills([dialogId]); + } + + /// + /// Запускает фоновый первый разбор списка каналов. + /// + /// Id неразобранных диалогов (пусто — no-op). + public void ScheduleFirstBackfills(IReadOnlyCollection dialogIds) + { + if (dialogIds.Count == 0) + { + return; + } + + RunBackground("first_backfill", (provider, runLogger, ct) => RunFirstBackfillsAsync(provider, runLogger, dialogIds, ct)); + } + + // Выполняет работу в собственном scope; любые ошибки — warning в лог (как RatesRefreshScheduler). + // Работа отменяется при остановке хоста (IHostApplicationLifetime), чтобы graceful shutdown не ждал + // фоновых разборов вне жизненного цикла (Security review). + // operation: Имя операции (лог-аудит). + // work: Фоновая работа (scope + логгер + токен). + private void RunBackground(string operation, Func, CancellationToken, Task> work) + { + // Task.Run захватывает ExecutionContext текущего tenant-запроса: внутри фоновой работы AsyncLocal + // ITenantContext несёт тенанта, и новый scope строит TenantDbContext с его схемой (как RatesRefreshScheduler). + _ = Task.Run(async () => + { + // Токен останавливающегося хоста: долгий разбор прерывается на graceful shutdown. + using var linked = CancellationTokenSource.CreateLinkedTokenSource(applicationLifetime.ApplicationStopping); + try + { + await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope(); + await work(scope.ServiceProvider, logger, linked.Token); + } + catch (OperationCanceledException) when (applicationLifetime.ApplicationStopping.IsCancellationRequested) + { + logger.LogInformation("Фоновая операция Telegram {Operation} прервана остановкой хоста", operation); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Фоновая операция Telegram {Operation} не удалась", operation); + } + finally + { + Interlocked.Exchange(ref _readRecentInProgress, 0); + } + }); + } + + // «Перечитать» всех включённых каналов в фоне (ReadRecentAsync продолжает при частичном падении). + private static async Task RunReadRecentAsync( + IServiceProvider provider, + ILogger runLogger, + CancellationToken ct) + { + DialogsService dialogs = provider.GetRequiredService(); + int count = await dialogs.ReadRecentAsync(ct); + runLogger.LogInformation("Фоновое «Перечитать»: {Count} каналов отправлено на разбор", count); + } + + // Первый разбор неразобранных каналов: Backfill(force=false) + флаг разбора после успеха. + private static async Task RunFirstBackfillsAsync( + IServiceProvider provider, + ILogger runLogger, + IReadOnlyCollection dialogIds, + CancellationToken ct) + { + DialogsService dialogs = provider.GetRequiredService(); + foreach (string dialogId in dialogIds) + { + try + { + int processed = await dialogs.BackfillOneAsync(dialogId, force: false, ct); + if (processed > 0) + { + runLogger.LogInformation("Первый разбор {DialogId}: {Processed} сообщений в очередь", dialogId, processed); + } + } + catch (Exception exception) when (exception is not OperationCanceledException || !ct.IsCancellationRequested) + { + runLogger.LogWarning(exception, "Первый разбор {DialogId} не удался", dialogId); + } + } + } +} diff --git a/src/core/Deal.Api/Sources/IngressTenantResolver.cs b/src/core/Deal.Api/Sources/IngressTenantResolver.cs index 6cd3a4c..462590f 100644 --- a/src/core/Deal.Api/Sources/IngressTenantResolver.cs +++ b/src/core/Deal.Api/Sources/IngressTenantResolver.cs @@ -1,64 +1,64 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Grpc.Core; - -namespace Deal.Api.Sources; - -/// -/// Определение тенанта gRPC-вызова входящего потока по metadata. -/// -/// Фабрика scope для чтения реестра тенантов. -/// Логгер отказов. -public sealed class IngressTenantResolver( - IServiceScopeFactory scopeFactory, - ILogger logger) -{ - /// - /// Ключ metadata с id тенанта. - /// - public const string TenantIdMetadataKey = "tenant-id"; - - private const string MissingTenantIdDetail = "tenant-id отсутствует в metadata"; - - /// - /// Читает обязательный id тенанта из metadata. - /// - /// Контекст вызова. - /// Значение tenant-id. - /// tenant-id отсутствует (UNAUTHENTICATED). - public static string RequireTenantIdMetadata(ServerCallContext context) - { - string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey); - if (string.IsNullOrWhiteSpace(tenantId)) - { - throw new RpcException(new Status(StatusCode.Unauthenticated, MissingTenantIdDetail)); - } - - return tenantId; - } - - /// - /// Находит тенанта вызова в реестре. - /// - /// Контекст вызова. - /// Запись тенанта либо null — id неизвестен/не Guid/нет в реестре. - public async Task ResolveAsync(ServerCallContext context) - { - string tenantId = RequireTenantIdMetadata(context); - if (!Guid.TryParse(tenantId, out Guid tenantGuid)) - { - logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (id не Guid)", context.Method, tenantId); - return null; - } - - await using AsyncServiceScope registryScope = scopeFactory.CreateAsyncScope(); - ITenantRepository repository = registryScope.ServiceProvider.GetRequiredService(); - TenantRecordDto? tenant = await repository.FindByIdAsync(tenantGuid, context.CancellationToken).ConfigureAwait(false); - if (tenant is null) - { - logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (нет в реестре)", context.Method, tenantId); - } - - return tenant; - } -} +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Grpc.Core; + +namespace Deal.Api.Sources; + +/// +/// Определение тенанта gRPC-вызова входящего потока по metadata. +/// +/// Фабрика scope для чтения реестра тенантов. +/// Логгер отказов. +public sealed class IngressTenantResolver( + IServiceScopeFactory scopeFactory, + ILogger logger) +{ + /// + /// Ключ metadata с id тенанта. + /// + public const string TenantIdMetadataKey = "tenant-id"; + + private const string MissingTenantIdDetail = "tenant-id отсутствует в metadata"; + + /// + /// Читает обязательный id тенанта из metadata. + /// + /// Контекст вызова. + /// Значение tenant-id. + /// tenant-id отсутствует (UNAUTHENTICATED). + public static string RequireTenantIdMetadata(ServerCallContext context) + { + string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey); + if (string.IsNullOrWhiteSpace(tenantId)) + { + throw new RpcException(new Status(StatusCode.Unauthenticated, MissingTenantIdDetail)); + } + + return tenantId; + } + + /// + /// Находит тенанта вызова в реестре. + /// + /// Контекст вызова. + /// Запись тенанта либо null — id неизвестен/не Guid/нет в реестре. + public async Task ResolveAsync(ServerCallContext context) + { + string tenantId = RequireTenantIdMetadata(context); + if (!Guid.TryParse(tenantId, out Guid tenantGuid)) + { + logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (id не Guid)", context.Method, tenantId); + return null; + } + + await using AsyncServiceScope registryScope = scopeFactory.CreateAsyncScope(); + ITenantRepository repository = registryScope.ServiceProvider.GetRequiredService(); + TenantRecordDto? tenant = await repository.FindByIdAsync(tenantGuid, context.CancellationToken).ConfigureAwait(false); + if (tenant is null) + { + logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (нет в реестре)", context.Method, tenantId); + } + + return tenant; + } +} diff --git a/src/core/Deal.Api/Sources/SourceIngressGrpcService.cs b/src/core/Deal.Api/Sources/SourceIngressGrpcService.cs index 061e42f..93b3eb1 100644 --- a/src/core/Deal.Api/Sources/SourceIngressGrpcService.cs +++ b/src/core/Deal.Api/Sources/SourceIngressGrpcService.cs @@ -1,103 +1,103 @@ -using Deal.Grpc.Sources; -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Grpc.Core; - -namespace Deal.Api.Sources; - -/// -/// gRPC-сервер входящего потока источников (generic-контракт). -/// -/// Фабрика tenant-scope приёма. -/// Определение тенанта вызова. -/// Логгер приёма. -public sealed class SourceIngressGrpcService( - IServiceScopeFactory scopeFactory, - IngressTenantResolver tenants, - ILogger logger) : SourceIngressService.SourceIngressServiceBase -{ - /// - /// PushSource — запись источника в очередь пайплайна тенанта. - /// - /// Запись источника (source + content + force). - /// Контекст вызова (metadata tenant-id + service-token). - /// accepted — запись принята (либо дубль), duplicate — уже была в очереди. - public override async Task PushSource(PushSourceRequest request, ServerCallContext context) - { - TenantRecordDto? tenant = await tenants.ResolveAsync(context).ConfigureAwait(false); - if (tenant is null) - { - return new PushSourceReply(); - } - - await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope(); - ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); - try - { - tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); - SourceItem item = SourceProtoMapper.ToItem(request); - PipelineIngestService ingest = tenantScope.ServiceProvider.GetRequiredService(); - - PipelineIngestResultDto result = await ingest.EnqueueAsync( - new QueuedMessage { Item = item, Force = request.Force }, - context.CancellationToken).ConfigureAwait(false); - - foreach (ISourceIngestObserver observer in tenantScope.ServiceProvider.GetServices()) - { - await NotifySafelyAsync(observer, item, tenant.Id, context.CancellationToken).ConfigureAwait(false); - } - - logger.LogInformation( - "Аудит: PushSource {TenantId} {Kind} {ExternalId} → {Outcome}", - tenant.Id, - item.Source.Kind, - item.Source.ExternalId ?? "-", - result.Duplicate ? "duplicate" : result.Id is null ? "no-op" : "queued"); - - return new PushSourceReply - { - Accepted = result.Id is not null || result.Duplicate, - Duplicate = result.Duplicate, - }; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - logger.LogWarning(exception, "Аудит: PushSource {TenantId} → не принято (сбой схемы/БД)", tenant.Id); - return new PushSourceReply(); - } - finally - { - tenantContext.Reset(); - } - } - - // Наблюдатель — вторичная обработка: его сбой не влияет на приём записи. - private async Task NotifySafelyAsync( - ISourceIngestObserver observer, - SourceItem item, - Guid tenantId, - CancellationToken ct) - { - try - { - await observer.OnIngestedAsync(item, ct).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - logger.LogDebug(exception, "PushSource {TenantId}: наблюдатель {Observer} не отработал (приём не затронут)", tenantId, observer.GetType().Name); - } - } -} +using Deal.Grpc.Sources; +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; +using Grpc.Core; + +namespace Deal.Api.Sources; + +/// +/// gRPC-сервер входящего потока источников (generic-контракт). +/// +/// Фабрика tenant-scope приёма. +/// Определение тенанта вызова. +/// Логгер приёма. +public sealed class SourceIngressGrpcService( + IServiceScopeFactory scopeFactory, + IngressTenantResolver tenants, + ILogger logger) : SourceIngressService.SourceIngressServiceBase +{ + /// + /// PushSource — запись источника в очередь пайплайна тенанта. + /// + /// Запись источника (source + content + force). + /// Контекст вызова (metadata tenant-id + service-token). + /// accepted — запись принята (либо дубль), duplicate — уже была в очереди. + public override async Task PushSource(PushSourceRequest request, ServerCallContext context) + { + TenantRecordDto? tenant = await tenants.ResolveAsync(context).ConfigureAwait(false); + if (tenant is null) + { + return new PushSourceReply(); + } + + await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope(); + ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); + try + { + tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); + SourceItem item = SourceProtoMapper.ToItem(request); + PipelineIngestService ingest = tenantScope.ServiceProvider.GetRequiredService(); + + PipelineIngestResultDto result = await ingest.EnqueueAsync( + new QueuedMessage { Item = item, Force = request.Force }, + context.CancellationToken).ConfigureAwait(false); + + foreach (ISourceIngestObserver observer in tenantScope.ServiceProvider.GetServices()) + { + await NotifySafelyAsync(observer, item, tenant.Id, context.CancellationToken).ConfigureAwait(false); + } + + logger.LogInformation( + "Аудит: PushSource {TenantId} {Kind} {ExternalId} → {Outcome}", + tenant.Id, + item.Source.Kind, + item.Source.ExternalId ?? "-", + result.Duplicate ? "duplicate" : result.Id is null ? "no-op" : "queued"); + + return new PushSourceReply + { + Accepted = result.Id is not null || result.Duplicate, + Duplicate = result.Duplicate, + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Аудит: PushSource {TenantId} → не принято (сбой схемы/БД)", tenant.Id); + return new PushSourceReply(); + } + finally + { + tenantContext.Reset(); + } + } + + // Наблюдатель — вторичная обработка: его сбой не влияет на приём записи. + private async Task NotifySafelyAsync( + ISourceIngestObserver observer, + SourceItem item, + Guid tenantId, + CancellationToken ct) + { + try + { + await observer.OnIngestedAsync(item, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + logger.LogDebug(exception, "PushSource {TenantId}: наблюдатель {Observer} не отработал (приём не затронут)", tenantId, observer.GetType().Name); + } + } +} diff --git a/src/core/Deal.Api/Sources/SourceProtoMapper.cs b/src/core/Deal.Api/Sources/SourceProtoMapper.cs index 43d4f87..c912960 100644 --- a/src/core/Deal.Api/Sources/SourceProtoMapper.cs +++ b/src/core/Deal.Api/Sources/SourceProtoMapper.cs @@ -1,85 +1,85 @@ -using Deal.Grpc.Sources; -using Deal.Modules.Cards.Application.Sources; - -namespace Deal.Api.Sources; - -/// -/// Маппинг generic-контракта источника в доменную модель. -/// -public static class SourceProtoMapper -{ - /// - /// Преобразует protobuf-запись источника в доменную. - /// - /// Запрос PushSource. - /// Запись источника домена. - public static SourceItem ToItem(PushSourceRequest request) => new() - { - Source = ToRef(request.Source), - Content = ToContent(request.Content), - }; - - private static SourceRef ToRef(SourceRefProto proto) => new() - { - Kind = proto.Kind, - ExternalId = proto.HasExternalId ? proto.ExternalId : null, - DisplayName = proto.HasDisplayName ? proto.DisplayName : null, - OriginRef = proto.HasOriginRef ? proto.OriginRef : null, - Author = proto.HasAuthor ? proto.Author : null, - ReceivedAt = proto.ReceivedAt != 0 ? DateTimeOffset.FromUnixTimeMilliseconds(proto.ReceivedAt) : DateTimeOffset.UtcNow, - Extra = proto.Extra.Count > 0 ? new Dictionary(proto.Extra) : null, - }; - - private static SourceContent ToContent(SourceContentProto proto) - { - var data = new List(proto.Data.Count); - foreach (DataRefProto item in proto.Data) - { - data.Add(ToData(item)); - } - - var contacts = new List(proto.Contacts.Count); - foreach (ContactRefProto contact in proto.Contacts) - { - contacts.Add(ToContact(contact)); - } - - return new SourceContent - { - Text = proto.HasText ? proto.Text : null, - Html = proto.HasHtml ? proto.Html : null, - Author = proto.HasAuthor ? proto.Author : null, - Subject = proto.HasSubject ? proto.Subject : null, - Data = data, - Links = proto.Links.Count > 0 ? proto.Links.ToList() : null, - Contacts = contacts.Count > 0 ? contacts : null, - Extra = proto.Extra.Count > 0 ? new Dictionary(proto.Extra) : null, - }; - } - - private static DataRef ToData(DataRefProto proto) => new() - { - Id = proto.Id, - Ref = proto.Ref, - Kind = proto.HasKind ? proto.Kind : null, - MimeType = proto.HasMimeType ? proto.MimeType : null, - FileName = proto.HasFileName ? proto.FileName : null, - Size = proto.HasSize ? proto.Size : null, - Width = proto.HasWidth ? proto.Width : null, - Height = proto.HasHeight ? proto.Height : null, - DurationSec = proto.HasDurationSec ? proto.DurationSec : null, - PreviewRef = proto.HasPreviewRef ? proto.PreviewRef : null, - Caption = proto.HasCaption ? proto.Caption : null, - Order = proto.HasOrder ? proto.Order : null, - Meta = proto.Meta.Count > 0 ? new Dictionary(proto.Meta) : null, - }; - - private static ContactRef ToContact(ContactRefProto proto) => new() - { - Kind = proto.HasKind ? proto.Kind : null, - Name = proto.HasName ? proto.Name : null, - Phone = proto.HasPhone ? proto.Phone : null, - Email = proto.HasEmail ? proto.Email : null, - Url = proto.HasUrl ? proto.Url : null, - }; -} +using Deal.Grpc.Sources; +using Deal.Modules.Cards.Application.Sources; + +namespace Deal.Api.Sources; + +/// +/// Маппинг generic-контракта источника в доменную модель. +/// +public static class SourceProtoMapper +{ + /// + /// Преобразует protobuf-запись источника в доменную. + /// + /// Запрос PushSource. + /// Запись источника домена. + public static SourceItem ToItem(PushSourceRequest request) => new() + { + Source = ToRef(request.Source), + Content = ToContent(request.Content), + }; + + private static SourceRef ToRef(SourceRefProto proto) => new() + { + Kind = proto.Kind, + ExternalId = proto.HasExternalId ? proto.ExternalId : null, + DisplayName = proto.HasDisplayName ? proto.DisplayName : null, + OriginRef = proto.HasOriginRef ? proto.OriginRef : null, + Author = proto.HasAuthor ? proto.Author : null, + ReceivedAt = proto.ReceivedAt != 0 ? DateTimeOffset.FromUnixTimeMilliseconds(proto.ReceivedAt) : DateTimeOffset.UtcNow, + Extra = proto.Extra.Count > 0 ? new Dictionary(proto.Extra) : null, + }; + + private static SourceContent ToContent(SourceContentProto proto) + { + var data = new List(proto.Data.Count); + foreach (DataRefProto item in proto.Data) + { + data.Add(ToData(item)); + } + + var contacts = new List(proto.Contacts.Count); + foreach (ContactRefProto contact in proto.Contacts) + { + contacts.Add(ToContact(contact)); + } + + return new SourceContent + { + Text = proto.HasText ? proto.Text : null, + Html = proto.HasHtml ? proto.Html : null, + Author = proto.HasAuthor ? proto.Author : null, + Subject = proto.HasSubject ? proto.Subject : null, + Data = data, + Links = proto.Links.Count > 0 ? proto.Links.ToList() : null, + Contacts = contacts.Count > 0 ? contacts : null, + Extra = proto.Extra.Count > 0 ? new Dictionary(proto.Extra) : null, + }; + } + + private static DataRef ToData(DataRefProto proto) => new() + { + Id = proto.Id, + Ref = proto.Ref, + Kind = proto.HasKind ? proto.Kind : null, + MimeType = proto.HasMimeType ? proto.MimeType : null, + FileName = proto.HasFileName ? proto.FileName : null, + Size = proto.HasSize ? proto.Size : null, + Width = proto.HasWidth ? proto.Width : null, + Height = proto.HasHeight ? proto.Height : null, + DurationSec = proto.HasDurationSec ? proto.DurationSec : null, + PreviewRef = proto.HasPreviewRef ? proto.PreviewRef : null, + Caption = proto.HasCaption ? proto.Caption : null, + Order = proto.HasOrder ? proto.Order : null, + Meta = proto.Meta.Count > 0 ? new Dictionary(proto.Meta) : null, + }; + + private static ContactRef ToContact(ContactRefProto proto) => new() + { + Kind = proto.HasKind ? proto.Kind : null, + Name = proto.HasName ? proto.Name : null, + Phone = proto.HasPhone ? proto.Phone : null, + Email = proto.HasEmail ? proto.Email : null, + Url = proto.HasUrl ? proto.Url : null, + }; +} diff --git a/src/core/Deal.Api/Telegram/IngressRateLimitInterceptor.cs b/src/core/Deal.Api/Telegram/IngressRateLimitInterceptor.cs index 912f0d6..60619c6 100644 --- a/src/core/Deal.Api/Telegram/IngressRateLimitInterceptor.cs +++ b/src/core/Deal.Api/Telegram/IngressRateLimitInterceptor.cs @@ -1,92 +1,92 @@ -using System.Threading.RateLimiting; -using Deal.Api.Middleware; -using Deal.Api.Services; -using Grpc.Core; -using Grpc.Core.Interceptors; - -namespace Deal.Api.Telegram; - -/// -/// Серверный интерцептор rate limit gRPC-ингресса -/// -public sealed class IngressRateLimitInterceptor : Interceptor -{ - // Ключ партиции вызовов без metadata tenant-id (общий «мусорный» бакет — сервис всё равно отклонит). - private const string MissingTenantIdKey = "missing-tenant-id"; - - // Префикс ключа хранилища: изолирует счётчики ингресса от HTTP-политик и login-guard. - private const string StoreKeyPrefix = "grpc:ingress:"; - - // Окно политики ингресса — 1 минута (имя настройки — GrpcIngressPerMinute). - private static readonly TimeSpan Window = TimeSpan.FromMinutes(1); - - private readonly PartitionedRateLimiter _limiter; - - /// - /// Создаёт интерцептор с общим лимитером ингресса - /// - /// Singleton-лимитер, зарегистрированный хостом. - public IngressRateLimitInterceptor(PartitionedRateLimiter limiter) - { - _limiter = limiter ?? throw new ArgumentNullException(nameof(limiter)); - } - - /// - /// Создаёт лимитер ингресса - /// - /// Фабрика scope: store-backed лимитер резолвит хранилище на каждое приобретение. - /// Разрешено вызовов на тенанта в минуту (RateLimit:GrpcIngressPerMinute). - /// Общий PartitionedRateLimiter по строковому tenant-id. - public static PartitionedRateLimiter CreateLimiter(IServiceScopeFactory scopeFactory, int permitsPerMinute) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentOutOfRangeException.ThrowIfLessThan(permitsPerMinute, 1); - return PartitionedRateLimiter.Create( - tenantId => - { - string key = StoreKeyPrefix + tenantId; - return RateLimitPartition.Get(key, storeKey => new StoreBackedFixedWindowRateLimiter( - scopeFactory, - storeKey, - permitsPerMinute, - Window, - () => DateTimeOffset.UtcNow)); - }); - } - - /// - /// Проверка лимита для unary-RPC - /// - /// Тип запроса gRPC. - /// Тип ответа gRPC. - /// Тело запроса. - /// Контекст вызова (metadata tenant-id/service-token). - /// Следующий обработчик в цепочке. - /// Ответ нижестоящего обработчика. - public override async Task UnaryServerHandler( - TRequest request, - ServerCallContext context, - UnaryServerMethod continuation) - { - if (context.Method.StartsWith(IngressServiceTokenInterceptor.HealthMethodPrefix, StringComparison.Ordinal)) - { - return await continuation(request, context).ConfigureAwait(false); - } - - string? tenantId = context.RequestHeaders.GetValue(TelegramIngressService.TenantIdMetadataKey); - // permitCount указывается явно: AcquireAsync(TResource, int permitCount, CancellationToken). - using RateLimitLease lease = await _limiter - .AcquireAsync(tenantId ?? MissingTenantIdKey, permitCount: 1, context.CancellationToken) - .ConfigureAwait(false); - if (!lease.IsAcquired) - { - throw Rejection(); - } - - return await continuation(request, context).ConfigureAwait(false); - } - - // Создаёт отказ RESOURCE_EXHAUSTED с текстом 429-детали политик (единый detail rate limit). - private static RpcException Rejection() - => new(new Status(StatusCode.ResourceExhausted, RateLimitPolicies.RejectedDetail)); -} +using System.Threading.RateLimiting; +using Deal.Api.Middleware; +using Deal.Api.Services; +using Grpc.Core; +using Grpc.Core.Interceptors; + +namespace Deal.Api.Telegram; + +/// +/// Серверный интерцептор rate limit gRPC-ингресса +/// +public sealed class IngressRateLimitInterceptor : Interceptor +{ + // Ключ партиции вызовов без metadata tenant-id (общий «мусорный» бакет — сервис всё равно отклонит). + private const string MissingTenantIdKey = "missing-tenant-id"; + + // Префикс ключа хранилища: изолирует счётчики ингресса от HTTP-политик и login-guard. + private const string StoreKeyPrefix = "grpc:ingress:"; + + // Окно политики ингресса — 1 минута (имя настройки — GrpcIngressPerMinute). + private static readonly TimeSpan Window = TimeSpan.FromMinutes(1); + + private readonly PartitionedRateLimiter _limiter; + + /// + /// Создаёт интерцептор с общим лимитером ингресса + /// + /// Singleton-лимитер, зарегистрированный хостом. + public IngressRateLimitInterceptor(PartitionedRateLimiter limiter) + { + _limiter = limiter ?? throw new ArgumentNullException(nameof(limiter)); + } + + /// + /// Создаёт лимитер ингресса + /// + /// Фабрика scope: store-backed лимитер резолвит хранилище на каждое приобретение. + /// Разрешено вызовов на тенанта в минуту (RateLimit:GrpcIngressPerMinute). + /// Общий PartitionedRateLimiter по строковому tenant-id. + public static PartitionedRateLimiter CreateLimiter(IServiceScopeFactory scopeFactory, int permitsPerMinute) + { + ArgumentNullException.ThrowIfNull(scopeFactory); + ArgumentOutOfRangeException.ThrowIfLessThan(permitsPerMinute, 1); + return PartitionedRateLimiter.Create( + tenantId => + { + string key = StoreKeyPrefix + tenantId; + return RateLimitPartition.Get(key, storeKey => new StoreBackedFixedWindowRateLimiter( + scopeFactory, + storeKey, + permitsPerMinute, + Window, + () => DateTimeOffset.UtcNow)); + }); + } + + /// + /// Проверка лимита для unary-RPC + /// + /// Тип запроса gRPC. + /// Тип ответа gRPC. + /// Тело запроса. + /// Контекст вызова (metadata tenant-id/service-token). + /// Следующий обработчик в цепочке. + /// Ответ нижестоящего обработчика. + public override async Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod continuation) + { + if (context.Method.StartsWith(IngressServiceTokenInterceptor.HealthMethodPrefix, StringComparison.Ordinal)) + { + return await continuation(request, context).ConfigureAwait(false); + } + + string? tenantId = context.RequestHeaders.GetValue(TelegramIngressService.TenantIdMetadataKey); + // permitCount указывается явно: AcquireAsync(TResource, int permitCount, CancellationToken). + using RateLimitLease lease = await _limiter + .AcquireAsync(tenantId ?? MissingTenantIdKey, permitCount: 1, context.CancellationToken) + .ConfigureAwait(false); + if (!lease.IsAcquired) + { + throw Rejection(); + } + + return await continuation(request, context).ConfigureAwait(false); + } + + // Создаёт отказ RESOURCE_EXHAUSTED с текстом 429-детали политик (единый detail rate limit). + private static RpcException Rejection() + => new(new Status(StatusCode.ResourceExhausted, RateLimitPolicies.RejectedDetail)); +} diff --git a/src/core/Deal.Api/Telegram/IngressServiceTokenInterceptor.cs b/src/core/Deal.Api/Telegram/IngressServiceTokenInterceptor.cs index 3ff0050..75193ce 100644 --- a/src/core/Deal.Api/Telegram/IngressServiceTokenInterceptor.cs +++ b/src/core/Deal.Api/Telegram/IngressServiceTokenInterceptor.cs @@ -1,72 +1,72 @@ -using Grpc.Core; -using Grpc.Core.Interceptors; - -namespace Deal.Api.Telegram; - -/// -/// Серверный интерцептор service-token gRPC-ингресса. -/// -public sealed class IngressServiceTokenInterceptor : Interceptor -{ - public const string ServiceTokenMetadataKey = "service-token"; - - /// - /// Префикс методов стандартного gRPC-health, освобождённых от проверки токена. - /// - public const string HealthMethodPrefix = "/grpc.health.v1.Health/"; - - private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - - private const string RejectionDetail = "service-token отсутствует или неверен"; - - private readonly string _expectedToken; - - /// - /// Создаёт интерцептор. - /// - /// Конфигурация хоста (env-провайдер WebApplicationBuilder). - public IngressServiceTokenInterceptor(IConfiguration configuration) - { - _expectedToken = configuration[ServiceTokenEnvKey] ?? string.Empty; - } - - /// - /// Проверка токена для unary-RPC - /// - /// Тип запроса gRPC. - /// Тип ответа gRPC. - /// Тело запроса. - /// Контекст вызова (metadata из заголовков). - /// Следующий обработчик в цепочке. - /// Ответ нижестоящего обработчика. - public override async Task UnaryServerHandler( - TRequest request, - ServerCallContext context, - UnaryServerMethod continuation) - { - if (context.Method.StartsWith(HealthMethodPrefix, StringComparison.Ordinal)) - { - return await continuation(request, context).ConfigureAwait(false); - } - - // Fail-closed-гард: DEAL_SERVICE_TOKEN не задан (пустой ожидаемый токен) — RPC ингресса всегда - // отклоняется, даже если клиент прислал пустой «service-token» (иначе «» == «» прошло бы - // сравнение ниже; см. fix в task-2-report). - if (_expectedToken.Length == 0) - { - throw Rejection(); - } - - string? actualToken = context.RequestHeaders.GetValue(ServiceTokenMetadataKey); - if (!string.Equals(actualToken, _expectedToken, StringComparison.Ordinal)) - { - throw Rejection(); - } - - return await continuation(request, context).ConfigureAwait(false); - } - - // Создаёт отказ UNAUTHENTICATED с общим текстом детали. - private static RpcException Rejection() - => new(new Status(StatusCode.Unauthenticated, RejectionDetail)); -} +using Grpc.Core; +using Grpc.Core.Interceptors; + +namespace Deal.Api.Telegram; + +/// +/// Серверный интерцептор service-token gRPC-ингресса. +/// +public sealed class IngressServiceTokenInterceptor : Interceptor +{ + public const string ServiceTokenMetadataKey = "service-token"; + + /// + /// Префикс методов стандартного gRPC-health, освобождённых от проверки токена. + /// + public const string HealthMethodPrefix = "/grpc.health.v1.Health/"; + + private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; + + private const string RejectionDetail = "service-token отсутствует или неверен"; + + private readonly string _expectedToken; + + /// + /// Создаёт интерцептор. + /// + /// Конфигурация хоста (env-провайдер WebApplicationBuilder). + public IngressServiceTokenInterceptor(IConfiguration configuration) + { + _expectedToken = configuration[ServiceTokenEnvKey] ?? string.Empty; + } + + /// + /// Проверка токена для unary-RPC + /// + /// Тип запроса gRPC. + /// Тип ответа gRPC. + /// Тело запроса. + /// Контекст вызова (metadata из заголовков). + /// Следующий обработчик в цепочке. + /// Ответ нижестоящего обработчика. + public override async Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod continuation) + { + if (context.Method.StartsWith(HealthMethodPrefix, StringComparison.Ordinal)) + { + return await continuation(request, context).ConfigureAwait(false); + } + + // Fail-closed-гард: DEAL_SERVICE_TOKEN не задан (пустой ожидаемый токен) — RPC ингресса всегда + // отклоняется, даже если клиент прислал пустой «service-token» (иначе «» == «» прошло бы + // сравнение ниже; см. fix в task-2-report). + if (_expectedToken.Length == 0) + { + throw Rejection(); + } + + string? actualToken = context.RequestHeaders.GetValue(ServiceTokenMetadataKey); + if (!string.Equals(actualToken, _expectedToken, StringComparison.Ordinal)) + { + throw Rejection(); + } + + return await continuation(request, context).ConfigureAwait(false); + } + + // Создаёт отказ UNAUTHENTICATED с общим текстом детали. + private static RpcException Rejection() + => new(new Status(StatusCode.Unauthenticated, RejectionDetail)); +} diff --git a/src/core/Deal.Api/Telegram/RpcCallLoggingInterceptor.cs b/src/core/Deal.Api/Telegram/RpcCallLoggingInterceptor.cs index 94cc949..60f1e94 100644 --- a/src/core/Deal.Api/Telegram/RpcCallLoggingInterceptor.cs +++ b/src/core/Deal.Api/Telegram/RpcCallLoggingInterceptor.cs @@ -1,82 +1,82 @@ -using System.Diagnostics; -using Grpc.Core; -using Grpc.Core.Interceptors; - -namespace Deal.Api.Telegram; - -/// -/// Access-лог RPC gRPC-ингресса -/// -public sealed class RpcCallLoggingInterceptor : Interceptor -{ - // Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness). - private const string HealthMethodPrefix = "/grpc.health.v1.Health/"; - - private readonly ILogger _logger; - - /// - /// Создаёт интерцептор access-лога gRPC-вызовов. - /// - /// Логгер. - public RpcCallLoggingInterceptor(ILogger logger) - { - ArgumentNullException.ThrowIfNull(logger); - _logger = logger; - } - - /// - /// Логирует unary-RPC - /// - /// Тип запроса gRPC. - /// Тип ответа gRPC. - /// Тело запроса. - /// Контекст вызова (метод — context.Method). - /// Следующий обработчик в цепочке. - /// Ответ нижестоящего обработчика. - public override async Task UnaryServerHandler( - TRequest request, - ServerCallContext context, - UnaryServerMethod continuation) - { - if (context.Method.StartsWith(HealthMethodPrefix, StringComparison.Ordinal)) - { - return await continuation(request, context).ConfigureAwait(false); - } - - long startedAt = Stopwatch.GetTimestamp(); - try - { - TResponse response = await continuation(request, context).ConfigureAwait(false); - LogCall(context, startedAt, null); - return response; - } - catch (OperationCanceledException) - { - // Клиент отменил вызов (дисконнект/дедлайн) — статус Cancelled, повторно не бросаем смысла нет. - LogCall(context, startedAt, StatusCode.Cancelled); - throw; - } - catch (RpcException rpcException) - { - LogCall(context, startedAt, rpcException.Status.StatusCode); - throw; - } - } - - // Пишет одну строку access-лога: полное имя RPC-метода, статус, длительность. - // context: Контекст вызова (метод). - // startedAt: Метка времени старта вызова (Stopwatch.GetTimestamp). - // statusCode: Итоговый gRPC-статус; null — успех (OK). - private void LogCall( - ServerCallContext context, - long startedAt, - StatusCode? statusCode) - { - long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds; - _logger.LogInformation( - "gRPC {RpcMethod}: {GrpcStatus} за {DurationMs} мс", - context.Method, - statusCode?.ToString() ?? StatusCode.OK.ToString(), - elapsedMs); - } -} +using System.Diagnostics; +using Grpc.Core; +using Grpc.Core.Interceptors; + +namespace Deal.Api.Telegram; + +/// +/// Access-лог RPC gRPC-ингресса +/// +public sealed class RpcCallLoggingInterceptor : Interceptor +{ + // Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness). + private const string HealthMethodPrefix = "/grpc.health.v1.Health/"; + + private readonly ILogger _logger; + + /// + /// Создаёт интерцептор access-лога gRPC-вызовов. + /// + /// Логгер. + public RpcCallLoggingInterceptor(ILogger logger) + { + ArgumentNullException.ThrowIfNull(logger); + _logger = logger; + } + + /// + /// Логирует unary-RPC + /// + /// Тип запроса gRPC. + /// Тип ответа gRPC. + /// Тело запроса. + /// Контекст вызова (метод — context.Method). + /// Следующий обработчик в цепочке. + /// Ответ нижестоящего обработчика. + public override async Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod continuation) + { + if (context.Method.StartsWith(HealthMethodPrefix, StringComparison.Ordinal)) + { + return await continuation(request, context).ConfigureAwait(false); + } + + long startedAt = Stopwatch.GetTimestamp(); + try + { + TResponse response = await continuation(request, context).ConfigureAwait(false); + LogCall(context, startedAt, null); + return response; + } + catch (OperationCanceledException) + { + // Клиент отменил вызов (дисконнект/дедлайн) — статус Cancelled, повторно не бросаем смысла нет. + LogCall(context, startedAt, StatusCode.Cancelled); + throw; + } + catch (RpcException rpcException) + { + LogCall(context, startedAt, rpcException.Status.StatusCode); + throw; + } + } + + // Пишет одну строку access-лога: полное имя RPC-метода, статус, длительность. + // context: Контекст вызова (метод). + // startedAt: Метка времени старта вызова (Stopwatch.GetTimestamp). + // statusCode: Итоговый gRPC-статус; null — успех (OK). + private void LogCall( + ServerCallContext context, + long startedAt, + StatusCode? statusCode) + { + long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds; + _logger.LogInformation( + "gRPC {RpcMethod}: {GrpcStatus} за {DurationMs} мс", + context.Method, + statusCode?.ToString() ?? StatusCode.OK.ToString(), + elapsedMs); + } +} diff --git a/src/core/Deal.Api/Telegram/TelegramIngressService.cs b/src/core/Deal.Api/Telegram/TelegramIngressService.cs index 730a607..88a74af 100644 --- a/src/core/Deal.Api/Telegram/TelegramIngressService.cs +++ b/src/core/Deal.Api/Telegram/TelegramIngressService.cs @@ -1,232 +1,232 @@ -using System.Text.Json; -using Deal.Api.Events; -using Deal.Api.Sources; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Telegram; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Grpc.Core; - -namespace Deal.Api.Telegram; - -/// -/// gRPC-сервер служебного потока telegram-service → ядро: каталог диалогов и статус аккаунта. -/// -public sealed class TelegramIngressService( - IServiceScopeFactory scopeFactory, - SseBroker broker, - IngressTenantResolver tenants, - ILogger logger) : IngressService.IngressServiceBase -{ - /// - /// Ключ metadata с id тенанта. - /// - public const string TenantIdMetadataKey = IngressTenantResolver.TenantIdMetadataKey; - - private const string SystemStatusEventType = "system_status"; - - private const string ToastEventType = "toast"; - - private const string ConnectedToastText = "Telegram подключён, сессия сохранена"; - - private const string DisconnectedToastText = "Telegram отключён"; - - // Иконка тоста подключения (из набора Icon.vue фронта). - private const string ConnectedToastIcon = "send"; - - // Иконка тоста отключения (из набора Icon.vue фронта). - private const string DisconnectedToastIcon = "logout"; - - private static readonly JsonSerializerOptions StatusJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - /// - /// SyncDialogs — синхронизация каталога диалогов аккаунта. - /// - /// Актуальный каталог диалогов (entries). - /// Контекст вызова. - /// monitored_ids — диалоги с включённым мониторингом после применения каталога. - public override async Task SyncDialogs(SyncDialogsRequest request, ServerCallContext context) - { - TenantRecordDto? tenant = await tenants.ResolveAsync(context).ConfigureAwait(false); - if (tenant is null) - { - return new SyncDialogsReply(); - } - - await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope(); - ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); - try - { - tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); - DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService(); - - List entries = new(request.Entries.Count); - foreach (DialogEntry entry in request.Entries) - { - entries.Add(new TelegramDialogEntryDto(entry.Id, entry.Name, entry.Username, entry.Kind, entry.Hue)); - } - - int synced = await dialogs.SyncFromTelegramAsync(entries, context.CancellationToken).ConfigureAwait(false); - IReadOnlyCollection monitoredIds = - await dialogs.ListMonitoredIdsAsync(context.CancellationToken).ConfigureAwait(false); - - logger.LogInformation( - "Аудит: SyncDialogs {TenantId}: каталог {Count} → применено {Synced}, monitored {Monitored}", - tenant.Id, - request.Entries.Count, - synced, - monitoredIds.Count); - - var reply = new SyncDialogsReply(); - reply.MonitoredIds.AddRange(monitoredIds); - return reply; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - logger.LogWarning(exception, "Аудит: SyncDialogs {TenantId} → каталог не применён (сбой схемы/БД)", tenant.Id); - return new SyncDialogsReply(); - } - finally - { - tenantContext.Reset(); - } - } - - /// - /// ReportStatus — статус аккаунта в KV + SSE system_status/тосты на переходах фаз. - /// - /// Статус аккаунта из. - /// Контекст вызова. - /// ok — статус принят и сохранён. - public override async Task ReportStatus(ReportStatusRequest request, ServerCallContext context) - { - TenantRecordDto? tenant = await tenants.ResolveAsync(context).ConfigureAwait(false); - if (tenant is null) - { - return new ReportStatusReply(); - } - - await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope(); - ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); - try - { - tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); - ISettingsStore settings = tenantScope.ServiceProvider.GetRequiredService(); - - TgReportedStatus current = ToReportedStatus(request); - TgReportedStatus? previous = await ReadPreviousStatusAsync(settings, context.CancellationToken).ConfigureAwait(false); - - // SSE до записи KV: канал тенанта обновляется и при сбое записи (следующий репорт перепишет KV). - PublishStatusEvents(tenant.Id, previous, current); - - await settings.SetAsync(SettingsKeys.TgStatus, ToJson(current), context.CancellationToken).ConfigureAwait(false); - await settings.SetAsync(SettingsKeys.TgAccount, JsonSerializer.Serialize(request.Account), context.CancellationToken).ConfigureAwait(false); - - logger.LogInformation( - "Аудит: ReportStatus {TenantId} → фаза {Phase}, connected {Connected}", - tenant.Id, - request.Phase, - request.Connected); - return new ReportStatusReply { Ok = true }; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - logger.LogWarning(exception, "Аудит: ReportStatus {TenantId} → не сохранён (сбой схемы/БД)", tenant.Id); - return new ReportStatusReply(); - } - finally - { - tenantContext.Reset(); - } - } - - // Публикует SSE system_status (каждый репорт) и тосты на переходах connected. - // tenantId: Тенант канала (реестровый Guid). - // previous: Предыдущий снимок из KV (null — первый репорт). - // current: Текущий снимок репорта. - private void PublishStatusEvents( - Guid tenantId, - TgReportedStatus? previous, - TgReportedStatus current) - { - broker.Publish(tenantId, SystemStatusEventType, current); - if (previous is null) - { - // Первый репорт после старта сервиса: переходов нет, статус фронт получит по system_status. - return; - } - - if (!previous.Connected && current.Connected) - { - PublishToast(tenantId, ConnectedToastText, ConnectedToastIcon); - } - else if (previous.Connected && !current.Connected) - { - PublishToast(tenantId, DisconnectedToastText, DisconnectedToastIcon); - } - } - - private void PublishToast( - Guid tenantId, - string text, - string icon) - { - broker.Publish(tenantId, ToastEventType, new { text, icon }); - } - - // Снимок предыдущего статуса из KV tgStatus (нет записи/битый JSON — null). - // settings: KV-хранилище настроек схемы тенанта. - // ct: Токен отмены. - // Возвращает: Предыдущий снимок либо null. - private async Task ReadPreviousStatusAsync(ISettingsStore settings, CancellationToken ct) - { - SettingValue? stored = await settings.GetAsync(SettingsKeys.TgStatus, ct).ConfigureAwait(false); - if (stored is null) - { - return null; - } - - try - { - return JsonSerializer.Deserialize(stored.ValueJson, StatusJsonOptions); - } - catch (JsonException exception) - { - logger.LogWarning(exception, "Аудит: KV tgStatus повреждён — переходы фаз не определяются"); - return null; - } - } - - // Маппит запрос ReportStatus в снимок KV (account живёт отдельным ключом tgAccount). - // request: Запрос ReportStatus. - // Возвращает: Снимок статуса. - private static TgReportedStatus ToReportedStatus(ReportStatusRequest request) => new() - { - Phase = request.Phase, - Connected = request.Connected, - Listener = request.Listener, - Error = request.HasError ? request.Error : null, - QrUrl = request.HasQrUrl ? request.QrUrl : null, - }; - - // Сериализует снимок в JSON (camelCase, конвенция value_json). - // status: Снимок статуса. - // Возвращает: JSON-строка. - private static string ToJson(TgReportedStatus status) => JsonSerializer.Serialize(status, StatusJsonOptions); -} +using System.Text.Json; +using Deal.Api.Events; +using Deal.Api.Sources; +using Deal.Contracts.Integrations.Models; +using Deal.Grpc.Telegram; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Telegram.Application; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; +using Grpc.Core; + +namespace Deal.Api.Telegram; + +/// +/// gRPC-сервер служебного потока telegram-service → ядро: каталог диалогов и статус аккаунта. +/// +public sealed class TelegramIngressService( + IServiceScopeFactory scopeFactory, + SseBroker broker, + IngressTenantResolver tenants, + ILogger logger) : IngressService.IngressServiceBase +{ + /// + /// Ключ metadata с id тенанта. + /// + public const string TenantIdMetadataKey = IngressTenantResolver.TenantIdMetadataKey; + + private const string SystemStatusEventType = "system_status"; + + private const string ToastEventType = "toast"; + + private const string ConnectedToastText = "Telegram подключён, сессия сохранена"; + + private const string DisconnectedToastText = "Telegram отключён"; + + // Иконка тоста подключения (из набора Icon.vue фронта). + private const string ConnectedToastIcon = "send"; + + // Иконка тоста отключения (из набора Icon.vue фронта). + private const string DisconnectedToastIcon = "logout"; + + private static readonly JsonSerializerOptions StatusJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + /// + /// SyncDialogs — синхронизация каталога диалогов аккаунта. + /// + /// Актуальный каталог диалогов (entries). + /// Контекст вызова. + /// monitored_ids — диалоги с включённым мониторингом после применения каталога. + public override async Task SyncDialogs(SyncDialogsRequest request, ServerCallContext context) + { + TenantRecordDto? tenant = await tenants.ResolveAsync(context).ConfigureAwait(false); + if (tenant is null) + { + return new SyncDialogsReply(); + } + + await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope(); + ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); + try + { + tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); + DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService(); + + List entries = new(request.Entries.Count); + foreach (DialogEntry entry in request.Entries) + { + entries.Add(new TelegramDialogEntryDto(entry.Id, entry.Name, entry.Username, entry.Kind, entry.Hue)); + } + + int synced = await dialogs.SyncFromTelegramAsync(entries, context.CancellationToken).ConfigureAwait(false); + IReadOnlyCollection monitoredIds = + await dialogs.ListMonitoredIdsAsync(context.CancellationToken).ConfigureAwait(false); + + logger.LogInformation( + "Аудит: SyncDialogs {TenantId}: каталог {Count} → применено {Synced}, monitored {Monitored}", + tenant.Id, + request.Entries.Count, + synced, + monitoredIds.Count); + + var reply = new SyncDialogsReply(); + reply.MonitoredIds.AddRange(monitoredIds); + return reply; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Аудит: SyncDialogs {TenantId} → каталог не применён (сбой схемы/БД)", tenant.Id); + return new SyncDialogsReply(); + } + finally + { + tenantContext.Reset(); + } + } + + /// + /// ReportStatus — статус аккаунта в KV + SSE system_status/тосты на переходах фаз. + /// + /// Статус аккаунта из. + /// Контекст вызова. + /// ok — статус принят и сохранён. + public override async Task ReportStatus(ReportStatusRequest request, ServerCallContext context) + { + TenantRecordDto? tenant = await tenants.ResolveAsync(context).ConfigureAwait(false); + if (tenant is null) + { + return new ReportStatusReply(); + } + + await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope(); + ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); + try + { + tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); + ISettingsStore settings = tenantScope.ServiceProvider.GetRequiredService(); + + TgReportedStatus current = ToReportedStatus(request); + TgReportedStatus? previous = await ReadPreviousStatusAsync(settings, context.CancellationToken).ConfigureAwait(false); + + // SSE до записи KV: канал тенанта обновляется и при сбое записи (следующий репорт перепишет KV). + PublishStatusEvents(tenant.Id, previous, current); + + await settings.SetAsync(SettingsKeys.TgStatus, ToJson(current), context.CancellationToken).ConfigureAwait(false); + await settings.SetAsync(SettingsKeys.TgAccount, JsonSerializer.Serialize(request.Account), context.CancellationToken).ConfigureAwait(false); + + logger.LogInformation( + "Аудит: ReportStatus {TenantId} → фаза {Phase}, connected {Connected}", + tenant.Id, + request.Phase, + request.Connected); + return new ReportStatusReply { Ok = true }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Аудит: ReportStatus {TenantId} → не сохранён (сбой схемы/БД)", tenant.Id); + return new ReportStatusReply(); + } + finally + { + tenantContext.Reset(); + } + } + + // Публикует SSE system_status (каждый репорт) и тосты на переходах connected. + // tenantId: Тенант канала (реестровый Guid). + // previous: Предыдущий снимок из KV (null — первый репорт). + // current: Текущий снимок репорта. + private void PublishStatusEvents( + Guid tenantId, + TgReportedStatus? previous, + TgReportedStatus current) + { + broker.Publish(tenantId, SystemStatusEventType, current); + if (previous is null) + { + // Первый репорт после старта сервиса: переходов нет, статус фронт получит по system_status. + return; + } + + if (!previous.Connected && current.Connected) + { + PublishToast(tenantId, ConnectedToastText, ConnectedToastIcon); + } + else if (previous.Connected && !current.Connected) + { + PublishToast(tenantId, DisconnectedToastText, DisconnectedToastIcon); + } + } + + private void PublishToast( + Guid tenantId, + string text, + string icon) + { + broker.Publish(tenantId, ToastEventType, new { text, icon }); + } + + // Снимок предыдущего статуса из KV tgStatus (нет записи/битый JSON — null). + // settings: KV-хранилище настроек схемы тенанта. + // ct: Токен отмены. + // Возвращает: Предыдущий снимок либо null. + private async Task ReadPreviousStatusAsync(ISettingsStore settings, CancellationToken ct) + { + SettingValue? stored = await settings.GetAsync(SettingsKeys.TgStatus, ct).ConfigureAwait(false); + if (stored is null) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(stored.ValueJson, StatusJsonOptions); + } + catch (JsonException exception) + { + logger.LogWarning(exception, "Аудит: KV tgStatus повреждён — переходы фаз не определяются"); + return null; + } + } + + // Маппит запрос ReportStatus в снимок KV (account живёт отдельным ключом tgAccount). + // request: Запрос ReportStatus. + // Возвращает: Снимок статуса. + private static TgReportedStatus ToReportedStatus(ReportStatusRequest request) => new() + { + Phase = request.Phase, + Connected = request.Connected, + Listener = request.Listener, + Error = request.HasError ? request.Error : null, + QrUrl = request.HasQrUrl ? request.QrUrl : null, + }; + + // Сериализует снимок в JSON (camelCase, конвенция value_json). + // status: Снимок статуса. + // Возвращает: JSON-строка. + private static string ToJson(TgReportedStatus status) => JsonSerializer.Serialize(status, StatusJsonOptions); +} diff --git a/src/core/Deal.Api/Telegram/TelegramKeysMaskedDto.cs b/src/core/Deal.Api/Telegram/TelegramKeysMaskedDto.cs index 698bf91..cc6a012 100644 --- a/src/core/Deal.Api/Telegram/TelegramKeysMaskedDto.cs +++ b/src/core/Deal.Api/Telegram/TelegramKeysMaskedDto.cs @@ -1,9 +1,9 @@ -namespace Deal.Api.Telegram; - -/// -/// Маскированная форма глобальных ключей Telegram для операторской ручки -/// -/// Идентификатор приложения Telegram (открыт; пусто — не задан). -/// Маска api_hash (никогда не открытый секрет). -/// True — оба ключа заданы оператором. -public sealed record TelegramKeysMaskedDto(string ApiId, string ApiHash, bool KeysSet); +namespace Deal.Api.Telegram; + +/// +/// Маскированная форма глобальных ключей Telegram для операторской ручки +/// +/// Идентификатор приложения Telegram (открыт; пусто — не задан). +/// Маска api_hash (никогда не открытый секрет). +/// True — оба ключа заданы оператором. +public sealed record TelegramKeysMaskedDto(string ApiId, string ApiHash, bool KeysSet); diff --git a/src/core/Deal.Api/Telegram/TelegramKeysService.cs b/src/core/Deal.Api/Telegram/TelegramKeysService.cs index 3e61769..6e2725e 100644 --- a/src/core/Deal.Api/Telegram/TelegramKeysService.cs +++ b/src/core/Deal.Api/Telegram/TelegramKeysService.cs @@ -1,150 +1,150 @@ -using System.Text.Json; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Api.Telegram; - -/// -/// Читает, расшифровывает и сохраняет ключи приложения Telegram из глобальной -/// -/// KV-хранилище глобальных настроек оператора (таблица public.global_settings). -/// Шифр секретов (AES-256-GCM, формат enc:). -public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCipher cipher) -{ - /// - /// Минимальная длина api_id приложения Telegram - /// - public const int ApiIdMinDigits = 5; - - /// - /// Максимальная длина api_id приложения Telegram - /// - public const int ApiIdMaxDigits = 9; - - // Символ-заполнитель маски секрета (U+2026, «1234…5678»). - private const string MaskEllipsis = "…"; - - private const string EncryptedPrefix = "enc:"; - - // Опции JSON значения telegramKeys: camelCase (как пишет SaveAsync) + терпимость регистра. - private static readonly JsonSerializerOptions KeysJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - /// - /// Читает текущие ключи приложения - /// - /// Снимок ключей (пустые — настройка не задана/повреждена). - public async Task GetAsync(CancellationToken ct) - { - SettingValue? row = await store.GetAsync(GlobalSettingsKeys.TelegramKeys, ct).ConfigureAwait(false); - if (row is null) - { - return new TgKeysSnapshot(string.Empty, string.Empty); - } - - try - { - using JsonDocument document = JsonDocument.Parse(row.ValueJson); - JsonElement root = document.RootElement; - string apiId = ReadString(root, "apiId"); - string apiHash = ReadString(root, "apiHash"); - return new TgKeysSnapshot(apiId, cipher.Decrypt(apiHash)); - } - catch (JsonException) - { - // Повреждённая строка telegramKeys — пустые ключи (мягкая семантика, как SettingsService). - return new TgKeysSnapshot(string.Empty, string.Empty); - } - } - - /// - /// Маскированная форма ключей для операторской ручки - /// - /// DTO с флагом keysSet (оба ключа заданы) и маской apiHash. - public async Task GetMaskedAsync(CancellationToken ct) - { - TgKeysSnapshot snapshot = await GetAsync(ct).ConfigureAwait(false); - return new TelegramKeysMaskedDto( - ApiId: snapshot.ApiId, - ApiHash: MaskSecret(snapshot.ApiHash), - KeysSet: snapshot.KeysSet); - } - - /// - /// Сохраняет глобальные ключи приложения - /// - /// api_id приложения (5..9 цифр). - /// api_hash приложения (непустой секрет). - /// Значения не прошли валидацию (см. /). - public async Task SaveAsync( - string apiId, - string apiHash, - CancellationToken ct) - { - string trimmedApiId = (apiId ?? string.Empty).Trim(); - string trimmedApiHash = (apiHash ?? string.Empty).Trim(); - if (!IsValidApiId(trimmedApiId)) - { - throw new ArgumentException("api_id должен состоять из 5–9 цифр.", nameof(apiId)); - } - - if (!IsValidApiHash(trimmedApiHash)) - { - throw new ArgumentException("api_hash непустой, без маски и без префикса enc:.", nameof(apiHash)); - } - - var value = new TelegramKeysValue(trimmedApiId, cipher.Encrypt(trimmedApiHash)); - string valueJson = JsonSerializer.Serialize(value, KeysJsonOptions); - await store.SetAsync(GlobalSettingsKeys.TelegramKeys, valueJson, ct).ConfigureAwait(false); - } - - /// - /// Валиден ли api_id - /// - /// Проверяемое значение (уже без пробелов). - /// True — значение допустимо. - public static bool IsValidApiId(string apiId) => - apiId.Length is >= ApiIdMinDigits and <= ApiIdMaxDigits && apiId.All(char.IsAsciiDigit); - - /// - /// Валиден ли api_hash - /// - /// Проверяемое значение (уже без пробелов). - /// True — значение допустимо к шифрованию и сохранению. - public static bool IsValidApiHash(string apiHash) => - apiHash.Length > 0 - && !apiHash.Contains(MaskEllipsis, StringComparison.Ordinal) - && !apiHash.StartsWith(EncryptedPrefix, StringComparison.Ordinal); - - // Читает строковое поле JSON-объекта (пусто при отсутствии/не-строке). - // root: Корень JSON значения telegramKeys. - // propertyName: Имя поля (camelCase). - // Возвращает: Значение поля (trim) или пустая строка. - private static string ReadString(JsonElement root, string propertyName) - { - if (root.ValueKind != JsonValueKind.Object - || !root.TryGetProperty(propertyName, out JsonElement element) - || element.ValueKind != JsonValueKind.String) - { - return string.Empty; - } - - return (element.GetString() ?? string.Empty).Trim(); - } - - // Маска СЕКРЕТА (apiHash): всегда скрывает, кроме пустого. Длина ≤ 8 → «x…», иначе «1234…5678». - // value: Открытый секрет (не null). - // Возвращает: Маскированная строка. - private static string MaskSecret(string value) - { - return value.Length switch - { - 0 => string.Empty, - <= 8 => string.Concat(value.AsSpan(0, 1), MaskEllipsis), - _ => string.Concat(value.AsSpan(0, 4), MaskEllipsis, value.AsSpan(value.Length - 4)), - }; - } -} +using System.Text.Json; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Api.Telegram; + +/// +/// Читает, расшифровывает и сохраняет ключи приложения Telegram из глобальной +/// +/// KV-хранилище глобальных настроек оператора (таблица public.global_settings). +/// Шифр секретов (AES-256-GCM, формат enc:). +public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCipher cipher) +{ + /// + /// Минимальная длина api_id приложения Telegram + /// + public const int ApiIdMinDigits = 5; + + /// + /// Максимальная длина api_id приложения Telegram + /// + public const int ApiIdMaxDigits = 9; + + // Символ-заполнитель маски секрета (U+2026, «1234…5678»). + private const string MaskEllipsis = "…"; + + private const string EncryptedPrefix = "enc:"; + + // Опции JSON значения telegramKeys: camelCase (как пишет SaveAsync) + терпимость регистра. + private static readonly JsonSerializerOptions KeysJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + /// + /// Читает текущие ключи приложения + /// + /// Снимок ключей (пустые — настройка не задана/повреждена). + public async Task GetAsync(CancellationToken ct) + { + SettingValue? row = await store.GetAsync(GlobalSettingsKeys.TelegramKeys, ct).ConfigureAwait(false); + if (row is null) + { + return new TgKeysSnapshot(string.Empty, string.Empty); + } + + try + { + using JsonDocument document = JsonDocument.Parse(row.ValueJson); + JsonElement root = document.RootElement; + string apiId = ReadString(root, "apiId"); + string apiHash = ReadString(root, "apiHash"); + return new TgKeysSnapshot(apiId, cipher.Decrypt(apiHash)); + } + catch (JsonException) + { + // Повреждённая строка telegramKeys — пустые ключи (мягкая семантика, как SettingsService). + return new TgKeysSnapshot(string.Empty, string.Empty); + } + } + + /// + /// Маскированная форма ключей для операторской ручки + /// + /// DTO с флагом keysSet (оба ключа заданы) и маской apiHash. + public async Task GetMaskedAsync(CancellationToken ct) + { + TgKeysSnapshot snapshot = await GetAsync(ct).ConfigureAwait(false); + return new TelegramKeysMaskedDto( + ApiId: snapshot.ApiId, + ApiHash: MaskSecret(snapshot.ApiHash), + KeysSet: snapshot.KeysSet); + } + + /// + /// Сохраняет глобальные ключи приложения + /// + /// api_id приложения (5..9 цифр). + /// api_hash приложения (непустой секрет). + /// Значения не прошли валидацию (см. /). + public async Task SaveAsync( + string apiId, + string apiHash, + CancellationToken ct) + { + string trimmedApiId = (apiId ?? string.Empty).Trim(); + string trimmedApiHash = (apiHash ?? string.Empty).Trim(); + if (!IsValidApiId(trimmedApiId)) + { + throw new ArgumentException("api_id должен состоять из 5–9 цифр.", nameof(apiId)); + } + + if (!IsValidApiHash(trimmedApiHash)) + { + throw new ArgumentException("api_hash непустой, без маски и без префикса enc:.", nameof(apiHash)); + } + + var value = new TelegramKeysValue(trimmedApiId, cipher.Encrypt(trimmedApiHash)); + string valueJson = JsonSerializer.Serialize(value, KeysJsonOptions); + await store.SetAsync(GlobalSettingsKeys.TelegramKeys, valueJson, ct).ConfigureAwait(false); + } + + /// + /// Валиден ли api_id + /// + /// Проверяемое значение (уже без пробелов). + /// True — значение допустимо. + public static bool IsValidApiId(string apiId) => + apiId.Length is >= ApiIdMinDigits and <= ApiIdMaxDigits && apiId.All(char.IsAsciiDigit); + + /// + /// Валиден ли api_hash + /// + /// Проверяемое значение (уже без пробелов). + /// True — значение допустимо к шифрованию и сохранению. + public static bool IsValidApiHash(string apiHash) => + apiHash.Length > 0 + && !apiHash.Contains(MaskEllipsis, StringComparison.Ordinal) + && !apiHash.StartsWith(EncryptedPrefix, StringComparison.Ordinal); + + // Читает строковое поле JSON-объекта (пусто при отсутствии/не-строке). + // root: Корень JSON значения telegramKeys. + // propertyName: Имя поля (camelCase). + // Возвращает: Значение поля (trim) или пустая строка. + private static string ReadString(JsonElement root, string propertyName) + { + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty(propertyName, out JsonElement element) + || element.ValueKind != JsonValueKind.String) + { + return string.Empty; + } + + return (element.GetString() ?? string.Empty).Trim(); + } + + // Маска СЕКРЕТА (apiHash): всегда скрывает, кроме пустого. Длина ≤ 8 → «x…», иначе «1234…5678». + // value: Открытый секрет (не null). + // Возвращает: Маскированная строка. + private static string MaskSecret(string value) + { + return value.Length switch + { + 0 => string.Empty, + <= 8 => string.Concat(value.AsSpan(0, 1), MaskEllipsis), + _ => string.Concat(value.AsSpan(0, 4), MaskEllipsis, value.AsSpan(value.Length - 4)), + }; + } +} diff --git a/src/core/Deal.Api/Telegram/TelegramKeysValue.cs b/src/core/Deal.Api/Telegram/TelegramKeysValue.cs index 19f39ff..aa1f009 100644 --- a/src/core/Deal.Api/Telegram/TelegramKeysValue.cs +++ b/src/core/Deal.Api/Telegram/TelegramKeysValue.cs @@ -1,8 +1,8 @@ -namespace Deal.Api.Telegram; - -/// -/// Сохранённые глобальные ключи приложения Telegram в настройке telegramKeys -/// -/// Идентификатор приложения Telegram (только цифры, длина 5..9). -/// Секрет приложения: enc:-значение. -public sealed record TelegramKeysValue(string ApiId, string ApiHash); +namespace Deal.Api.Telegram; + +/// +/// Сохранённые глобальные ключи приложения Telegram в настройке telegramKeys +/// +/// Идентификатор приложения Telegram (только цифры, длина 5..9). +/// Секрет приложения: enc:-значение. +public sealed record TelegramKeysValue(string ApiId, string ApiHash); diff --git a/src/core/Deal.Api/Telegram/TgKeysSnapshot.cs b/src/core/Deal.Api/Telegram/TgKeysSnapshot.cs index dfdcdc2..166c26b 100644 --- a/src/core/Deal.Api/Telegram/TgKeysSnapshot.cs +++ b/src/core/Deal.Api/Telegram/TgKeysSnapshot.cs @@ -1,14 +1,14 @@ -namespace Deal.Api.Telegram; - -/// -/// Снимок глобальных ключей приложения Telegram для команд входа. -/// -/// Идентификатор приложения Telegram (только цифры, длина 5..9; задаёт оператор). -/// Расшифрованный api_hash приложения Telegram (пуст — не задан). -public sealed record TgKeysSnapshot(string ApiId, string ApiHash) -{ - /// - /// Заданы ли оба ключа приложения - /// - public bool KeysSet => ApiId.Length > 0 && ApiHash.Length > 0; -} +namespace Deal.Api.Telegram; + +/// +/// Снимок глобальных ключей приложения Telegram для команд входа. +/// +/// Идентификатор приложения Telegram (только цифры, длина 5..9; задаёт оператор). +/// Расшифрованный api_hash приложения Telegram (пуст — не задан). +public sealed record TgKeysSnapshot(string ApiId, string ApiHash) +{ + /// + /// Заданы ли оба ключа приложения + /// + public bool KeysSet => ApiId.Length > 0 && ApiHash.Length > 0; +} diff --git a/src/core/Deal.Api/Telegram/TgReportedStatus.cs b/src/core/Deal.Api/Telegram/TgReportedStatus.cs index b7871bb..5e454be 100644 --- a/src/core/Deal.Api/Telegram/TgReportedStatus.cs +++ b/src/core/Deal.Api/Telegram/TgReportedStatus.cs @@ -1,32 +1,32 @@ -namespace Deal.Api.Telegram; - -/// -/// Снимок статуса Telegram-аккаунта из RPC ReportStatus. -/// -public sealed record TgReportedStatus -{ - /// - /// Фаза входа: idle|phone|code|password|qr|ready - /// - public string Phase { get; init; } = string.Empty; - - /// - /// Клиент подключён и авторизован. - /// - public bool Connected { get; init; } - - /// - /// Realtime-listener жив. - /// - public bool Listener { get; init; } - - /// - /// Текст ошибки - /// - public string? Error { get; init; } - - /// - /// URL QR-входа при phase == "qr" - /// - public string? QrUrl { get; init; } -} +namespace Deal.Api.Telegram; + +/// +/// Снимок статуса Telegram-аккаунта из RPC ReportStatus. +/// +public sealed record TgReportedStatus +{ + /// + /// Фаза входа: idle|phone|code|password|qr|ready + /// + public string Phase { get; init; } = string.Empty; + + /// + /// Клиент подключён и авторизован. + /// + public bool Connected { get; init; } + + /// + /// Realtime-listener жив. + /// + public bool Listener { get; init; } + + /// + /// Текст ошибки + /// + public string? Error { get; init; } + + /// + /// URL QR-входа при phase == "qr" + /// + public string? QrUrl { get; init; } +} diff --git a/src/core/Deal.Api/Telegram/TgStatusService.cs b/src/core/Deal.Api/Telegram/TgStatusService.cs index 28ab356..9a6a4e7 100644 --- a/src/core/Deal.Api/Telegram/TgStatusService.cs +++ b/src/core/Deal.Api/Telegram/TgStatusService.cs @@ -1,87 +1,87 @@ -using System.Text.Json; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Telegram.Application.Models; - -namespace Deal.Api.Telegram; - -/// -/// Сборка статуса вкладки Telegram — GET /api/tg/status. -/// -/// Порт-гейт telegram-service (живой статус аккаунта). -/// Сервис каталога диалогов ядра (счётчик мониторящихся). -/// KV-хранилище настроек тенанта (tgAccount). -/// Глобальные ключи приложения Telegram (keysSet). -public sealed class TgStatusService( - ITelegramGateway gateway, - DialogsService dialogs, - ISettingsStore settings, - TelegramKeysService keys) -{ - private const string IdlePhase = "idle"; - - // Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра. - private static readonly JsonSerializerOptions KvJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - /// - /// Форма GET /api/tg/status текущего тенанта. - /// - /// Полный статус вкладки Telegram. - public async Task GetAsync(CancellationToken ct) - { - TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false); - string account = await ReadAccountAsync(ct).ConfigureAwait(false); - int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count; - TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false); - - return new TgStatusDto( - Phase: live.Phase, - Connected: live.Connected, - Listener: live.Listener, - Account: account, - Monitored: monitored, - KeysSet: snapshot.KeysSet, - Error: live.Error, - QrUrl: live.QrUrl); - } - - private async Task ReadLiveAsync(CancellationToken ct) - { - try - { - return await gateway.StatusAsync(ct).ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException || !ct.IsCancellationRequested) - { - return new TelegramAccountStatusDto(IdlePhase, Connected: false, Listener: false, string.Empty, null, null); - } - } - - private async Task ReadAccountAsync(CancellationToken ct) - { - SettingValue? row = await settings.GetAsync(SettingsKeys.TgAccount, ct).ConfigureAwait(false); - if (row is null) - { - return string.Empty; - } - - try - { - using JsonDocument document = JsonDocument.Parse(row.ValueJson); - return document.RootElement.ValueKind == JsonValueKind.String - ? document.RootElement.GetString() ?? string.Empty - : string.Empty; - } - catch (JsonException) - { - return string.Empty; - } - } -} +using System.Text.Json; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Telegram.Application; +using Deal.Modules.Telegram.Application.Models; + +namespace Deal.Api.Telegram; + +/// +/// Сборка статуса вкладки Telegram — GET /api/tg/status. +/// +/// Порт-гейт telegram-service (живой статус аккаунта). +/// Сервис каталога диалогов ядра (счётчик мониторящихся). +/// KV-хранилище настроек тенанта (tgAccount). +/// Глобальные ключи приложения Telegram (keysSet). +public sealed class TgStatusService( + ITelegramGateway gateway, + DialogsService dialogs, + ISettingsStore settings, + TelegramKeysService keys) +{ + private const string IdlePhase = "idle"; + + // Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра. + private static readonly JsonSerializerOptions KvJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + /// + /// Форма GET /api/tg/status текущего тенанта. + /// + /// Полный статус вкладки Telegram. + public async Task GetAsync(CancellationToken ct) + { + TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false); + string account = await ReadAccountAsync(ct).ConfigureAwait(false); + int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count; + TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false); + + return new TgStatusDto( + Phase: live.Phase, + Connected: live.Connected, + Listener: live.Listener, + Account: account, + Monitored: monitored, + KeysSet: snapshot.KeysSet, + Error: live.Error, + QrUrl: live.QrUrl); + } + + private async Task ReadLiveAsync(CancellationToken ct) + { + try + { + return await gateway.StatusAsync(ct).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException || !ct.IsCancellationRequested) + { + return new TelegramAccountStatusDto(IdlePhase, Connected: false, Listener: false, string.Empty, null, null); + } + } + + private async Task ReadAccountAsync(CancellationToken ct) + { + SettingValue? row = await settings.GetAsync(SettingsKeys.TgAccount, ct).ConfigureAwait(false); + if (row is null) + { + return string.Empty; + } + + try + { + using JsonDocument document = JsonDocument.Parse(row.ValueJson); + return document.RootElement.ValueKind == JsonValueKind.String + ? document.RootElement.GetString() ?? string.Empty + : string.Empty; + } + catch (JsonException) + { + return string.Empty; + } + } +} diff --git a/src/core/Deal.Api/appsettings.Development.json b/src/core/Deal.Api/appsettings.Development.json index 5b44c75..1a05ec7 100644 --- a/src/core/Deal.Api/appsettings.Development.json +++ b/src/core/Deal.Api/appsettings.Development.json @@ -1,22 +1,22 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "ConnectionStrings": { - "DealPostgres": "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password" - }, - "Cookies": { - "Name": "deal_session", - "Secure": false - }, - "OperatorCookies": { - "Name": "deal_operator_session", - "Secure": false - }, - "Security": { - "AllowedOrigins": [ "http://localhost:5173" ] - } -} +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "DealPostgres": "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password" + }, + "Cookies": { + "Name": "deal_session", + "Secure": false + }, + "OperatorCookies": { + "Name": "deal_operator_session", + "Secure": false + }, + "Security": { + "AllowedOrigins": [ "http://localhost:5173" ] + } +} diff --git a/src/core/Deal.Api/appsettings.json b/src/core/Deal.Api/appsettings.json index 52ec4a1..7e66f1f 100644 --- a/src/core/Deal.Api/appsettings.json +++ b/src/core/Deal.Api/appsettings.json @@ -1,51 +1,51 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "Cookies": { - "Name": "deal_session", - "Secure": false - }, - "OperatorCookies": { - "Name": "deal_operator_session", - "Secure": false - }, - "RateLimit": { - "Enabled": false, - "AuthPerMinute": 10, - "ApiPerMinute": 600, - "GrpcIngressPerMinute": 600, - "LoginAttemptsMax": 5, - "LoginAttemptWindowMin": 15 - }, - "DataRetention": { - "Enabled": true, - "AuditRetentionDays": 180 - }, - "Security": { - "AllowedOrigins": [] - }, - "ForwardedHeaders": { - "Enabled": false, - "KnownProxies": [ "127.0.0.1", "::1" ], - "KnownNetworks": [] - }, - "Services": { - "Ml": { - "UseLocal": true, - "Endpoint": "http://localhost:5103" - }, - "Ai": { - "UseLocal": true, - "Endpoint": "http://localhost:5102" - }, - "Telegram": { - "UseLocal": true, - "Endpoint": "http://localhost:5101" - } - } -} +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Cookies": { + "Name": "deal_session", + "Secure": false + }, + "OperatorCookies": { + "Name": "deal_operator_session", + "Secure": false + }, + "RateLimit": { + "Enabled": false, + "AuthPerMinute": 10, + "ApiPerMinute": 600, + "GrpcIngressPerMinute": 600, + "LoginAttemptsMax": 5, + "LoginAttemptWindowMin": 15 + }, + "DataRetention": { + "Enabled": true, + "AuditRetentionDays": 180 + }, + "Security": { + "AllowedOrigins": [] + }, + "ForwardedHeaders": { + "Enabled": false, + "KnownProxies": [ "127.0.0.1", "::1" ], + "KnownNetworks": [] + }, + "Services": { + "Ml": { + "UseLocal": true, + "Endpoint": "http://localhost:5103" + }, + "Ai": { + "UseLocal": true, + "Endpoint": "http://localhost:5102" + }, + "Telegram": { + "UseLocal": true, + "Endpoint": "http://localhost:5101" + } + } +} diff --git a/src/core/Deal.Contracts/Deal.Contracts.csproj b/src/core/Deal.Contracts/Deal.Contracts.csproj index 6d36c6d..b760144 100644 --- a/src/core/Deal.Contracts/Deal.Contracts.csproj +++ b/src/core/Deal.Contracts/Deal.Contracts.csproj @@ -1,9 +1,9 @@ - - - - net10.0 - enable - enable - - - + + + + net10.0 + enable + enable + + + diff --git a/src/core/Deal.Contracts/Integrations/Models/TelegramSourceContentDto.cs b/src/core/Deal.Contracts/Integrations/Models/TelegramSourceContentDto.cs index 6520bcd..d600178 100644 --- a/src/core/Deal.Contracts/Integrations/Models/TelegramSourceContentDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/TelegramSourceContentDto.cs @@ -1,9 +1,9 @@ -namespace Deal.Contracts.Integrations.Models; - -/// -/// Исходное сообщение источника — ответ ReadSourceAsync гейта. -/// -/// True — сообщение найдено у источника; false — недоступно (поля пусты). -/// Текст исходного сообщения, если найдено. -/// Время сообщения, epoch-ms; null — источник не отдал время. -public sealed record TelegramSourceContentDto(bool Found, string? Text, long? TimeMs); +namespace Deal.Contracts.Integrations.Models; + +/// +/// Исходное сообщение источника — ответ ReadSourceAsync гейта. +/// +/// True — сообщение найдено у источника; false — недоступно (поля пусты). +/// Текст исходного сообщения, если найдено. +/// Время сообщения, epoch-ms; null — источник не отдал время. +public sealed record TelegramSourceContentDto(bool Found, string? Text, long? TimeMs); diff --git a/src/core/Deal.Infrastructure/Deal.Infrastructure.csproj b/src/core/Deal.Infrastructure/Deal.Infrastructure.csproj index 4d31642..4caa9a9 100644 --- a/src/core/Deal.Infrastructure/Deal.Infrastructure.csproj +++ b/src/core/Deal.Infrastructure/Deal.Infrastructure.csproj @@ -1,42 +1,42 @@ - - - - - - - - - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - - net10.0 - enable - enable - - - + + + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/core/Deal.Infrastructure/Integrations/Abstractions/IMlTrainClient.cs b/src/core/Deal.Infrastructure/Integrations/Abstractions/IMlTrainClient.cs index c10ed17..acde0d7 100644 --- a/src/core/Deal.Infrastructure/Integrations/Abstractions/IMlTrainClient.cs +++ b/src/core/Deal.Infrastructure/Integrations/Abstractions/IMlTrainClient.cs @@ -1,18 +1,18 @@ -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Infrastructure.Integrations.Abstractions; - -/// -/// Порт отправки батча обучения в ml-service — для MlOutboxFlushScheduler. -/// -public interface IMlTrainClient -{ - /// - /// Отправляет порцию очереди обучения в ml-service. - /// - /// Строки outbox (text/label/delta; id в запрос не уходит — нужен вызывающему для удаления). - /// Число применённых примеров (= len(items) при успехе; no-op-пропуски сервис не считает). - /// Сервис недоступен/отклонил батч — строки НЕ удаляются. - public Task TrainBatchAsync(IReadOnlyList items, CancellationToken ct); -} +using Deal.Infrastructure.Integrations.Services; +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Infrastructure.Integrations.Abstractions; + +/// +/// Порт отправки батча обучения в ml-service — для MlOutboxFlushScheduler. +/// +public interface IMlTrainClient +{ + /// + /// Отправляет порцию очереди обучения в ml-service. + /// + /// Строки outbox (text/label/delta; id в запрос не уходит — нужен вызывающему для удаления). + /// Число применённых примеров (= len(items) при успехе; no-op-пропуски сервис не считает). + /// Сервис недоступен/отклонил батч — строки НЕ удаляются. + public Task TrainBatchAsync(IReadOnlyList items, CancellationToken ct); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Models/AiGrpcConnection.cs b/src/core/Deal.Infrastructure/Integrations/Models/AiGrpcConnection.cs index 2622398..f12ed1f 100644 --- a/src/core/Deal.Infrastructure/Integrations/Models/AiGrpcConnection.cs +++ b/src/core/Deal.Infrastructure/Integrations/Models/AiGrpcConnection.cs @@ -1,79 +1,79 @@ -using Deal.Grpc.Ai; -using Deal.Infrastructure.Integrations.Options; -using Grpc.Core; -using Grpc.Net.Client; - -namespace Deal.Infrastructure.Integrations.Models; - -/// -/// Транспорт gRPC-клиентов ai-service -/// -public sealed class AiGrpcConnection : IDisposable -{ - /// - /// Env-ключ ожидаемого service-token. - /// - public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - - public const string TenantIdMetadataKey = "tenant-id"; - - public const string ServiceTokenMetadataKey = "service-token"; - - private readonly GrpcChannel _channel; - private readonly string _serviceToken; - - /// - /// Создаёт транспорт ai-service по конфигурации и env-токену - /// - /// Конфигурация секции Services:Ai (endpoint). - /// Сертификаты mTLS: null — plaintext-канал (dev, флаг выключен). - /// Пустой endpoint или пустой DEAL_SERVICE_TOKEN. - public AiGrpcConnection(AiServiceOptions options, MtlsCertificates? mtlsCertificates = null) - { - ArgumentNullException.ThrowIfNull(options); - if (string.IsNullOrWhiteSpace(options.Endpoint)) - { - throw new InvalidOperationException( - $"AiGrpcConnection: не задан endpoint ai-service (секция \"{AiServiceOptions.SectionName}:Endpoint\")."); - } - - _serviceToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey) ?? string.Empty; - if (_serviceToken.Length == 0) - { - throw new InvalidOperationException( - $"AiGrpcConnection: не задан env {ServiceTokenEnvKey} — ai-service отвергнет вызовы (fail-closed, Ruling 1)."); - } - - GrpcChannelOptions channelOptions = new() { MaxRetryAttempts = 0 }; - if (mtlsCertificates is not null) - { - channelOptions.HttpHandler = mtlsCertificates.CreateClientHttpHandler(); - } - - _channel = GrpcChannel.ForAddress(options.Endpoint, channelOptions); - } - - /// - /// Создаёт клиент RPC AiService поверх общего канала - /// - /// Клиент сервиса AI (Filter/Classify/GenerateKeywords/EvaluateFit). - public AiService.AiServiceClient CreateClient() => new(_channel); - - /// - /// Собирает обязательные metadata вызова - /// - /// Id тенанта (строка, формат N — как в ai-service). - /// Metadata для CallOptions вызова. - public Metadata CreateMetadata(string tenantId) - { - var metadata = new Metadata - { - { TenantIdMetadataKey, tenantId }, - { ServiceTokenMetadataKey, _serviceToken }, - }; - return metadata; - } - - /// - public void Dispose() => _channel.Dispose(); -} +using Deal.Grpc.Ai; +using Deal.Infrastructure.Integrations.Options; +using Grpc.Core; +using Grpc.Net.Client; + +namespace Deal.Infrastructure.Integrations.Models; + +/// +/// Транспорт gRPC-клиентов ai-service +/// +public sealed class AiGrpcConnection : IDisposable +{ + /// + /// Env-ключ ожидаемого service-token. + /// + public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; + + public const string TenantIdMetadataKey = "tenant-id"; + + public const string ServiceTokenMetadataKey = "service-token"; + + private readonly GrpcChannel _channel; + private readonly string _serviceToken; + + /// + /// Создаёт транспорт ai-service по конфигурации и env-токену + /// + /// Конфигурация секции Services:Ai (endpoint). + /// Сертификаты mTLS: null — plaintext-канал (dev, флаг выключен). + /// Пустой endpoint или пустой DEAL_SERVICE_TOKEN. + public AiGrpcConnection(AiServiceOptions options, MtlsCertificates? mtlsCertificates = null) + { + ArgumentNullException.ThrowIfNull(options); + if (string.IsNullOrWhiteSpace(options.Endpoint)) + { + throw new InvalidOperationException( + $"AiGrpcConnection: не задан endpoint ai-service (секция \"{AiServiceOptions.SectionName}:Endpoint\")."); + } + + _serviceToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey) ?? string.Empty; + if (_serviceToken.Length == 0) + { + throw new InvalidOperationException( + $"AiGrpcConnection: не задан env {ServiceTokenEnvKey} — ai-service отвергнет вызовы (fail-closed, Ruling 1)."); + } + + GrpcChannelOptions channelOptions = new() { MaxRetryAttempts = 0 }; + if (mtlsCertificates is not null) + { + channelOptions.HttpHandler = mtlsCertificates.CreateClientHttpHandler(); + } + + _channel = GrpcChannel.ForAddress(options.Endpoint, channelOptions); + } + + /// + /// Создаёт клиент RPC AiService поверх общего канала + /// + /// Клиент сервиса AI (Filter/Classify/GenerateKeywords/EvaluateFit). + public AiService.AiServiceClient CreateClient() => new(_channel); + + /// + /// Собирает обязательные metadata вызова + /// + /// Id тенанта (строка, формат N — как в ai-service). + /// Metadata для CallOptions вызова. + public Metadata CreateMetadata(string tenantId) + { + var metadata = new Metadata + { + { TenantIdMetadataKey, tenantId }, + { ServiceTokenMetadataKey, _serviceToken }, + }; + return metadata; + } + + /// + public void Dispose() => _channel.Dispose(); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Models/MlGrpcConnection.cs b/src/core/Deal.Infrastructure/Integrations/Models/MlGrpcConnection.cs index f3eb8e6..972aa2f 100644 --- a/src/core/Deal.Infrastructure/Integrations/Models/MlGrpcConnection.cs +++ b/src/core/Deal.Infrastructure/Integrations/Models/MlGrpcConnection.cs @@ -1,79 +1,79 @@ -using Deal.Grpc.Ml; -using Deal.Infrastructure.Integrations.Options; -using Grpc.Core; -using Grpc.Net.Client; - -namespace Deal.Infrastructure.Integrations.Models; - -/// -/// Транспорт gRPC-клиента ml-service -/// -public sealed class MlGrpcConnection : IDisposable -{ - /// - /// Env-ключ ожидаемого service-token. - /// - public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - - public const string TenantIdMetadataKey = "tenant-id"; - - public const string ServiceTokenMetadataKey = "service-token"; - - private readonly GrpcChannel _channel; - private readonly string _serviceToken; - - /// - /// Создаёт транспорт ml-service по конфигурации и env-токену - /// - /// Конфигурация секции Services:Ml (endpoint). - /// Сертификаты mTLS: null — plaintext-канал (dev, флаг выключен). - /// Пустой endpoint или пустой DEAL_SERVICE_TOKEN. - public MlGrpcConnection(MlServiceOptions options, MtlsCertificates? mtlsCertificates = null) - { - ArgumentNullException.ThrowIfNull(options); - if (string.IsNullOrWhiteSpace(options.Endpoint)) - { - throw new InvalidOperationException( - $"MlGrpcConnection: не задан endpoint ml-service (секция \"{MlServiceOptions.SectionName}:Endpoint\")."); - } - - _serviceToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey) ?? string.Empty; - if (_serviceToken.Length == 0) - { - throw new InvalidOperationException( - $"MlGrpcConnection: не задан env {ServiceTokenEnvKey} — ml-service отвергнет вызовы (fail-closed, Ruling 1)."); - } - - GrpcChannelOptions channelOptions = new() { MaxRetryAttempts = 0 }; - if (mtlsCertificates is not null) - { - channelOptions.HttpHandler = mtlsCertificates.CreateClientHttpHandler(); - } - - _channel = GrpcChannel.ForAddress(options.Endpoint, channelOptions); - } - - /// - /// Создаёт клиент RPC MlService поверх общего канала - /// - /// Клиент сервиса ML (Predict/Status/Reset/TrainBatch). - public MlService.MlServiceClient CreateClient() => new(_channel); - - /// - /// Собирает обязательные metadata вызова - /// - /// Id тенанта (строка, формат N — как в пуле модели ml-service). - /// Metadata для CallOptions вызова. - public Metadata CreateMetadata(string tenantId) - { - var metadata = new Metadata - { - { TenantIdMetadataKey, tenantId }, - { ServiceTokenMetadataKey, _serviceToken }, - }; - return metadata; - } - - /// - public void Dispose() => _channel.Dispose(); -} +using Deal.Grpc.Ml; +using Deal.Infrastructure.Integrations.Options; +using Grpc.Core; +using Grpc.Net.Client; + +namespace Deal.Infrastructure.Integrations.Models; + +/// +/// Транспорт gRPC-клиента ml-service +/// +public sealed class MlGrpcConnection : IDisposable +{ + /// + /// Env-ключ ожидаемого service-token. + /// + public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; + + public const string TenantIdMetadataKey = "tenant-id"; + + public const string ServiceTokenMetadataKey = "service-token"; + + private readonly GrpcChannel _channel; + private readonly string _serviceToken; + + /// + /// Создаёт транспорт ml-service по конфигурации и env-токену + /// + /// Конфигурация секции Services:Ml (endpoint). + /// Сертификаты mTLS: null — plaintext-канал (dev, флаг выключен). + /// Пустой endpoint или пустой DEAL_SERVICE_TOKEN. + public MlGrpcConnection(MlServiceOptions options, MtlsCertificates? mtlsCertificates = null) + { + ArgumentNullException.ThrowIfNull(options); + if (string.IsNullOrWhiteSpace(options.Endpoint)) + { + throw new InvalidOperationException( + $"MlGrpcConnection: не задан endpoint ml-service (секция \"{MlServiceOptions.SectionName}:Endpoint\")."); + } + + _serviceToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey) ?? string.Empty; + if (_serviceToken.Length == 0) + { + throw new InvalidOperationException( + $"MlGrpcConnection: не задан env {ServiceTokenEnvKey} — ml-service отвергнет вызовы (fail-closed, Ruling 1)."); + } + + GrpcChannelOptions channelOptions = new() { MaxRetryAttempts = 0 }; + if (mtlsCertificates is not null) + { + channelOptions.HttpHandler = mtlsCertificates.CreateClientHttpHandler(); + } + + _channel = GrpcChannel.ForAddress(options.Endpoint, channelOptions); + } + + /// + /// Создаёт клиент RPC MlService поверх общего канала + /// + /// Клиент сервиса ML (Predict/Status/Reset/TrainBatch). + public MlService.MlServiceClient CreateClient() => new(_channel); + + /// + /// Собирает обязательные metadata вызова + /// + /// Id тенанта (строка, формат N — как в пуле модели ml-service). + /// Metadata для CallOptions вызова. + public Metadata CreateMetadata(string tenantId) + { + var metadata = new Metadata + { + { TenantIdMetadataKey, tenantId }, + { ServiceTokenMetadataKey, _serviceToken }, + }; + return metadata; + } + + /// + public void Dispose() => _channel.Dispose(); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Models/TelegramGrpcConnection.cs b/src/core/Deal.Infrastructure/Integrations/Models/TelegramGrpcConnection.cs index 42c9f88..bf7d5e6 100644 --- a/src/core/Deal.Infrastructure/Integrations/Models/TelegramGrpcConnection.cs +++ b/src/core/Deal.Infrastructure/Integrations/Models/TelegramGrpcConnection.cs @@ -1,79 +1,79 @@ -using Deal.Grpc.Telegram; -using Deal.Infrastructure.Integrations.Options; -using Grpc.Core; -using Grpc.Net.Client; - -namespace Deal.Infrastructure.Integrations.Models; - -/// -/// Транспорт gRPC-клиента telegram-service -/// -public sealed class TelegramGrpcConnection : IDisposable -{ - /// - /// Env-ключ ожидаемого service-token. - /// - public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - - public const string TenantIdMetadataKey = "tenant-id"; - - public const string ServiceTokenMetadataKey = "service-token"; - - private readonly GrpcChannel _channel; - private readonly string _serviceToken; - - /// - /// Создаёт транспорт telegram-service по конфигурации и env-токену - /// - /// Конфигурация секции Services:Telegram (endpoint). - /// Сертификаты mTLS: null — plaintext-канал (dev, флаг выключен). - /// Пустой endpoint или пустой DEAL_SERVICE_TOKEN. - public TelegramGrpcConnection(TelegramServiceOptions options, MtlsCertificates? mtlsCertificates = null) - { - ArgumentNullException.ThrowIfNull(options); - if (string.IsNullOrWhiteSpace(options.Endpoint)) - { - throw new InvalidOperationException( - $"TelegramGrpcConnection: не задан endpoint telegram-service (секция \"{TelegramServiceOptions.SectionName}:Endpoint\")."); - } - - _serviceToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey) ?? string.Empty; - if (_serviceToken.Length == 0) - { - throw new InvalidOperationException( - $"TelegramGrpcConnection: не задан env {ServiceTokenEnvKey} — telegram-service отвергнет вызовы (fail-closed, Ruling 1)."); - } - - GrpcChannelOptions channelOptions = new() { MaxRetryAttempts = 0 }; - if (mtlsCertificates is not null) - { - channelOptions.HttpHandler = mtlsCertificates.CreateClientHttpHandler(); - } - - _channel = GrpcChannel.ForAddress(options.Endpoint, channelOptions); - } - - /// - /// Создаёт клиент RPC TelegramService поверх общего канала - /// - /// Клиент сервиса Telegram (команды ядра наружу). - public TelegramService.TelegramServiceClient CreateClient() => new(_channel); - - /// - /// Собирает обязательные metadata вызова - /// - /// Id тенанта (строка, формат N — как в сессиях telegram-service). - /// Metadata для CallOptions вызова. - public Metadata CreateMetadata(string tenantId) - { - var metadata = new Metadata - { - { TenantIdMetadataKey, tenantId }, - { ServiceTokenMetadataKey, _serviceToken }, - }; - return metadata; - } - - /// - public void Dispose() => _channel.Dispose(); -} +using Deal.Grpc.Telegram; +using Deal.Infrastructure.Integrations.Options; +using Grpc.Core; +using Grpc.Net.Client; + +namespace Deal.Infrastructure.Integrations.Models; + +/// +/// Транспорт gRPC-клиента telegram-service +/// +public sealed class TelegramGrpcConnection : IDisposable +{ + /// + /// Env-ключ ожидаемого service-token. + /// + public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; + + public const string TenantIdMetadataKey = "tenant-id"; + + public const string ServiceTokenMetadataKey = "service-token"; + + private readonly GrpcChannel _channel; + private readonly string _serviceToken; + + /// + /// Создаёт транспорт telegram-service по конфигурации и env-токену + /// + /// Конфигурация секции Services:Telegram (endpoint). + /// Сертификаты mTLS: null — plaintext-канал (dev, флаг выключен). + /// Пустой endpoint или пустой DEAL_SERVICE_TOKEN. + public TelegramGrpcConnection(TelegramServiceOptions options, MtlsCertificates? mtlsCertificates = null) + { + ArgumentNullException.ThrowIfNull(options); + if (string.IsNullOrWhiteSpace(options.Endpoint)) + { + throw new InvalidOperationException( + $"TelegramGrpcConnection: не задан endpoint telegram-service (секция \"{TelegramServiceOptions.SectionName}:Endpoint\")."); + } + + _serviceToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey) ?? string.Empty; + if (_serviceToken.Length == 0) + { + throw new InvalidOperationException( + $"TelegramGrpcConnection: не задан env {ServiceTokenEnvKey} — telegram-service отвергнет вызовы (fail-closed, Ruling 1)."); + } + + GrpcChannelOptions channelOptions = new() { MaxRetryAttempts = 0 }; + if (mtlsCertificates is not null) + { + channelOptions.HttpHandler = mtlsCertificates.CreateClientHttpHandler(); + } + + _channel = GrpcChannel.ForAddress(options.Endpoint, channelOptions); + } + + /// + /// Создаёт клиент RPC TelegramService поверх общего канала + /// + /// Клиент сервиса Telegram (команды ядра наружу). + public TelegramService.TelegramServiceClient CreateClient() => new(_channel); + + /// + /// Собирает обязательные metadata вызова + /// + /// Id тенанта (строка, формат N — как в сессиях telegram-service). + /// Metadata для CallOptions вызова. + public Metadata CreateMetadata(string tenantId) + { + var metadata = new Metadata + { + { TenantIdMetadataKey, tenantId }, + { ServiceTokenMetadataKey, _serviceToken }, + }; + return metadata; + } + + /// + public void Dispose() => _channel.Dispose(); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Options/MtlsOptions.cs b/src/core/Deal.Infrastructure/Integrations/Options/MtlsOptions.cs index c790d88..b84afec 100644 --- a/src/core/Deal.Infrastructure/Integrations/Options/MtlsOptions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Options/MtlsOptions.cs @@ -1,101 +1,101 @@ -using Microsoft.Extensions.Configuration; - -namespace Deal.Infrastructure.Integrations.Options; - -/// -/// Конфигурация mTLS-транспорта внутреннего gRPC. -/// -public sealed class MtlsOptions -{ - /// - /// Env-ключ флага: 1/true включает mTLS - /// - public const string EnabledEnvKey = "DEAL_MTLS_ENABLED"; - - /// - /// Env-ключ пути к PFX серверного сертификата процесса - /// - public const string ServerCertPfxEnvKey = "DEAL_MTLS_SERVER_CERT_PFX"; - - /// - /// Env-ключ пароля серверного PFX. - /// - public const string ServerCertPasswordEnvKey = "DEAL_MTLS_SERVER_CERT_PASSWORD"; - - /// - /// Env-ключ пути к PFX клиентского сертификата - /// - public const string ClientCertPfxEnvKey = "DEAL_MTLS_CLIENT_CERT_PFX"; - - /// - /// Env-ключ пароля клиентского PFX. - /// - public const string ClientCertPasswordEnvKey = "DEAL_MTLS_CLIENT_CERT_PASSWORD"; - - /// - /// Env-ключ пути к PEM dev-CA - /// - public const string CaPemEnvKey = "DEAL_MTLS_CA_PEM"; - - /// - /// True — транспорт внутренних gRPC-эндпоинтов и исходящих каналов под mTLS. - /// - public bool Enabled { get; init; } - - /// - /// Путь к PFX серверного сертификата процесса - /// - public string ServerCertPfx { get; init; } = string.Empty; - - /// - /// Пароль серверного PFX - /// - public string ServerCertPassword { get; init; } = string.Empty; - - /// - /// Путь к PFX клиентского сертификата - /// - public string ClientCertPfx { get; init; } = string.Empty; - - /// - /// Пароль клиентского PFX - /// - public string ClientCertPassword { get; init; } = string.Empty; - - /// - /// Путь к PEM-файлу dev-CA - /// - public string CaPem { get; init; } = string.Empty; - - /// - /// Читает опции из конфигурации хоста. - /// - /// Конфигурация хоста (env-провайдер WebApplicationBuilder). - /// Опции mTLS (флаг выключен — остальные поля пустые). - public static MtlsOptions FromConfiguration(IConfiguration configuration) - { - ArgumentNullException.ThrowIfNull(configuration); - return new MtlsOptions - { - Enabled = IsEnabled(configuration[EnabledEnvKey]), - ServerCertPfx = Trimmed(configuration[ServerCertPfxEnvKey]), - ServerCertPassword = configuration[ServerCertPasswordEnvKey] ?? string.Empty, - ClientCertPfx = Trimmed(configuration[ClientCertPfxEnvKey]), - ClientCertPassword = configuration[ClientCertPasswordEnvKey] ?? string.Empty, - CaPem = Trimmed(configuration[CaPemEnvKey]), - }; - } - - /// - /// Разбирает значение флага DEAL_MTLS_ENABLED - /// - /// Сырое значение env (null/пусто — выключено). - public static bool IsEnabled(string? rawValue) - => string.Equals(rawValue, "1", StringComparison.Ordinal) - || string.Equals(rawValue, "true", StringComparison.OrdinalIgnoreCase); - - // Обрезает путь конфигурации (env-значения с пробелами/кавычками не передаются в файловые API). - // rawValue: Сырое значение env. - private static string Trimmed(string? rawValue) - => rawValue is null ? string.Empty : rawValue.Trim(); -} +using Microsoft.Extensions.Configuration; + +namespace Deal.Infrastructure.Integrations.Options; + +/// +/// Конфигурация mTLS-транспорта внутреннего gRPC. +/// +public sealed class MtlsOptions +{ + /// + /// Env-ключ флага: 1/true включает mTLS + /// + public const string EnabledEnvKey = "DEAL_MTLS_ENABLED"; + + /// + /// Env-ключ пути к PFX серверного сертификата процесса + /// + public const string ServerCertPfxEnvKey = "DEAL_MTLS_SERVER_CERT_PFX"; + + /// + /// Env-ключ пароля серверного PFX. + /// + public const string ServerCertPasswordEnvKey = "DEAL_MTLS_SERVER_CERT_PASSWORD"; + + /// + /// Env-ключ пути к PFX клиентского сертификата + /// + public const string ClientCertPfxEnvKey = "DEAL_MTLS_CLIENT_CERT_PFX"; + + /// + /// Env-ключ пароля клиентского PFX. + /// + public const string ClientCertPasswordEnvKey = "DEAL_MTLS_CLIENT_CERT_PASSWORD"; + + /// + /// Env-ключ пути к PEM dev-CA + /// + public const string CaPemEnvKey = "DEAL_MTLS_CA_PEM"; + + /// + /// True — транспорт внутренних gRPC-эндпоинтов и исходящих каналов под mTLS. + /// + public bool Enabled { get; init; } + + /// + /// Путь к PFX серверного сертификата процесса + /// + public string ServerCertPfx { get; init; } = string.Empty; + + /// + /// Пароль серверного PFX + /// + public string ServerCertPassword { get; init; } = string.Empty; + + /// + /// Путь к PFX клиентского сертификата + /// + public string ClientCertPfx { get; init; } = string.Empty; + + /// + /// Пароль клиентского PFX + /// + public string ClientCertPassword { get; init; } = string.Empty; + + /// + /// Путь к PEM-файлу dev-CA + /// + public string CaPem { get; init; } = string.Empty; + + /// + /// Читает опции из конфигурации хоста. + /// + /// Конфигурация хоста (env-провайдер WebApplicationBuilder). + /// Опции mTLS (флаг выключен — остальные поля пустые). + public static MtlsOptions FromConfiguration(IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + return new MtlsOptions + { + Enabled = IsEnabled(configuration[EnabledEnvKey]), + ServerCertPfx = Trimmed(configuration[ServerCertPfxEnvKey]), + ServerCertPassword = configuration[ServerCertPasswordEnvKey] ?? string.Empty, + ClientCertPfx = Trimmed(configuration[ClientCertPfxEnvKey]), + ClientCertPassword = configuration[ClientCertPasswordEnvKey] ?? string.Empty, + CaPem = Trimmed(configuration[CaPemEnvKey]), + }; + } + + /// + /// Разбирает значение флага DEAL_MTLS_ENABLED + /// + /// Сырое значение env (null/пусто — выключено). + public static bool IsEnabled(string? rawValue) + => string.Equals(rawValue, "1", StringComparison.Ordinal) + || string.Equals(rawValue, "true", StringComparison.OrdinalIgnoreCase); + + // Обрезает путь конфигурации (env-значения с пробелами/кавычками не передаются в файловые API). + // rawValue: Сырое значение env. + private static string Trimmed(string? rawValue) + => rawValue is null ? string.Empty : rawValue.Trim(); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs b/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs index e48e29c..5d4021a 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs @@ -1,240 +1,240 @@ -using System.Diagnostics.CodeAnalysis; -using System.Net.Http.Headers; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// HTTP-реализация проверки подключения к AI-провайдеру. -/// -public sealed class AiConnectionChecker : IAiConnectionChecker -{ - /// - /// Таймаут HTTP-запроса проверки в секундах; применяется DI-регистрацией клиента. - /// - public const int RequestTimeoutSeconds = 12; - - - private const string LocalServerMessageTemplate = "Локальный сервер «{0}» (ping в проде)"; - - // Сообщение ветки «API-ключ не задан». - private const string NoApiKeyMessage = "Не задан API-ключ"; - - // Сообщение успешной проверки (HTTP < 400). - private const string SuccessMessage = "Подключение успешно"; - - // Сообщение ветки «ключ не принят» (HTTP 401/403). - private const string KeyRejectedMessageTemplate = "Ключ не принят (HTTP {0}) — проверьте ключ и доступ к модели"; - - // Сообщение ветки «иной HTTP-код» (≥ 400, кроме 401/403). - private const string HttpErrorMessageTemplate = "HTTP {0} — проверьте Base URL и модель"; - - // Сообщение ветки сетевого сбоя (деталь — текст исключения). - private const string ConnectionErrorMessageTemplate = "Ошибка соединения: {0}"; - - // Сообщение ветки таймаута HttpClient. - private const string TimeoutMessage = "Ошибка соединения: превышен таймаут ожидания"; - - // Сообщение SSRF-гейта: провайдер вне фиксированного каталога (allowlist). - private const string ProviderNotAllowedMessage = "Провайдер не из списка разрешённых"; - - // Сообщение SSRF-гейта: base URL не абсолютный http(s). - private const string InvalidBaseUrlMessage = "Недопустимый Base URL (ожидается http/https)"; - - - // Значение api_style провайдера Anthropic (AiProviderDefinition.ApiStyle). - private const string AnthropicApiStyle = "anthropic"; - - // Путь списка моделей Anthropic: {base}/v1/models. - private const string AnthropicModelsPath = "/v1/models"; - - // Путь списка моделей OpenAI-совместимых: {base}/models. - private const string OpenAiModelsPath = "/models"; - - // Сообщение SSRF-гейта: base URL указывает на приватный/локальный адрес. - private const string PrivateEndpointNotAllowedMessage = - "Недопустимый Base URL (приватный/локальный адрес недоступен для проверки)"; - - // Заголовок ключа Anthropic. - private const string ApiKeyHeaderName = "x-api-key"; - - // Заголовок версии протокола Anthropic. - private const string AnthropicVersionHeaderName = "anthropic-version"; - - // Значение версии протокола Anthropic. - private const string AnthropicVersionHeaderValue = "2023-06-01"; - - private readonly HttpClient _httpClient; - - /// - /// Создаёт проверку поверх HttpClient. - /// - /// Клиент с таймаутом 12 с (DI: AddHttpClient в Deal.Api). - public AiConnectionChecker(HttpClient httpClient) - { - ArgumentNullException.ThrowIfNull(httpClient); - _httpClient = httpClient; - } - - /// - public async Task CheckAsync(AiCheckRequest request, CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(request); - - AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == request.ProviderId); - string name = meta?.Name ?? request.ProviderId; - - // SSRF-гейт (allowlist, preflight): проверка возможна только для провайдера фиксированного - // каталога AiProviders. В штатном потоке недостижимо (PATCH-гейт aiProvider/aiConfigs в - // SettingsService) — защита от ручного изменения БД/повреждённого хранилища. - if (meta is null) - { - return BuildResult(request, name, ok: false, message: ProviderNotAllowedMessage); - } - - if (request.IsLocal) - { - return BuildResult(request, name, ok: true, message: string.Format(LocalServerMessageTemplate, name)); - } - - if (string.IsNullOrEmpty(request.ApiKey)) - { - return BuildResult(request, name, ok: false, message: NoApiKeyMessage); - } - - if (!TryBuildModelsUri(request.BaseUrl, request.ApiStyle, out Uri? modelsUri)) - { - return BuildResult(request, name, ok: false, message: InvalidBaseUrlMessage); - } - - // SSRF-гейт (Security review): проверка подключения выполняется только к публичным адресам. - // Private/loopback/link-local литералы и localhost запрещены для не-local провайдеров (локальные - // провайдеры — ветка IsLocal выше, HTTP для них не выполняется вовсе). DNS-имена не резолвятся - // здесь (полный egress-контроль с резолвом — на уровне сетевого периметра/прокси). - if (modelsUri.IsPrivateEndpoint()) - { - return BuildResult(request, name, ok: false, message: PrivateEndpointNotAllowedMessage); - } - - try - { - using HttpRequestMessage httpRequest = new(HttpMethod.Get, modelsUri); - AddAuthHeaders(httpRequest, request.ApiKey, request.ApiStyle); - - using HttpResponseMessage response = await _httpClient.SendAsync(httpRequest, ct); - int statusCode = (int)response.StatusCode; - - if (statusCode < 400) - { - return BuildResult(request, name, ok: true, message: SuccessMessage); - } - - if (statusCode is 401 or 403) - { - return BuildResult(request, name, ok: false, - message: string.Format(KeyRejectedMessageTemplate, statusCode)); - } - - return BuildResult(request, name, ok: false, - message: string.Format(HttpErrorMessageTemplate, statusCode)); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - return BuildResult(request, name, ok: false, message: TimeoutMessage); - } - catch (HttpRequestException exception) - { - return BuildResult(request, name, ok: false, - message: string.Format(ConnectionErrorMessageTemplate, ConnectionErrorDetail(exception))); - } - } - - private static AiCheckResultDto BuildResult( - AiCheckRequest request, - string name, - bool ok, - string message) - { - return new AiCheckResultDto( - Ok: ok, - Message: message, - Provider: request.ProviderId, - Name: name, - Base: request.BaseUrl, - Model: request.Model, - Local: request.IsLocal, - KeySet: !string.IsNullOrEmpty(request.ApiKey), - KeyMasked: MaskKey(request.ApiKey)); - } - - private static string MaskKey(string key) - { - if (string.IsNullOrEmpty(key)) - { - return string.Empty; - } - - if (key.Length <= 8) - { - return string.Concat(key.AsSpan(0, 1), "…"); - } - - return string.Concat(key.AsSpan(0, 4), "…", key.AsSpan(key.Length - 4)); - } - - private static bool TryBuildModelsUri( - string baseUrl, - string? apiStyle, - [NotNullWhen(true)] out Uri? modelsUri) - { - modelsUri = null; - if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri? parsed) - || (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps)) - { - return false; - } - - string root = baseUrl.TrimEnd('/'); - string relativePath = apiStyle == AnthropicApiStyle ? AnthropicModelsPath : OpenAiModelsPath; - if (!Uri.TryCreate(root + relativePath, UriKind.Absolute, out Uri? endpoint)) - { - return false; - } - - modelsUri = endpoint; - return true; - } - - // Заголовки аутентификации: Bearer (OpenAI-совместимые) или x-api-key + версия (Anthropic). - // httpRequest: Запрос списка моделей. - // apiKey: Ключ открытым текстом (непустой — ветка ключа пройдена). - // apiStyle: Стиль API провайдера. - private static void AddAuthHeaders( - HttpRequestMessage httpRequest, - string apiKey, - string? apiStyle) - { - if (apiStyle == AnthropicApiStyle) - { - httpRequest.Headers.TryAddWithoutValidation(ApiKeyHeaderName, apiKey); - httpRequest.Headers.TryAddWithoutValidation(AnthropicVersionHeaderName, AnthropicVersionHeaderValue); - return; - } - - httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); - } - - // Исключение HTTP-слоя. - // Возвращает: Человекочитаемый текст причины. - private static string ConnectionErrorDetail(HttpRequestException exception) - { - if (!string.IsNullOrWhiteSpace(exception.Message)) - { - return exception.Message; - } - - return exception.InnerException?.Message ?? exception.GetType().Name; - } -} +using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Headers; +using Deal.Infrastructure.Integrations.Extensions; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Infrastructure.Integrations.Services; + +/// +/// HTTP-реализация проверки подключения к AI-провайдеру. +/// +public sealed class AiConnectionChecker : IAiConnectionChecker +{ + /// + /// Таймаут HTTP-запроса проверки в секундах; применяется DI-регистрацией клиента. + /// + public const int RequestTimeoutSeconds = 12; + + + private const string LocalServerMessageTemplate = "Локальный сервер «{0}» (ping в проде)"; + + // Сообщение ветки «API-ключ не задан». + private const string NoApiKeyMessage = "Не задан API-ключ"; + + // Сообщение успешной проверки (HTTP < 400). + private const string SuccessMessage = "Подключение успешно"; + + // Сообщение ветки «ключ не принят» (HTTP 401/403). + private const string KeyRejectedMessageTemplate = "Ключ не принят (HTTP {0}) — проверьте ключ и доступ к модели"; + + // Сообщение ветки «иной HTTP-код» (≥ 400, кроме 401/403). + private const string HttpErrorMessageTemplate = "HTTP {0} — проверьте Base URL и модель"; + + // Сообщение ветки сетевого сбоя (деталь — текст исключения). + private const string ConnectionErrorMessageTemplate = "Ошибка соединения: {0}"; + + // Сообщение ветки таймаута HttpClient. + private const string TimeoutMessage = "Ошибка соединения: превышен таймаут ожидания"; + + // Сообщение SSRF-гейта: провайдер вне фиксированного каталога (allowlist). + private const string ProviderNotAllowedMessage = "Провайдер не из списка разрешённых"; + + // Сообщение SSRF-гейта: base URL не абсолютный http(s). + private const string InvalidBaseUrlMessage = "Недопустимый Base URL (ожидается http/https)"; + + + // Значение api_style провайдера Anthropic (AiProviderDefinition.ApiStyle). + private const string AnthropicApiStyle = "anthropic"; + + // Путь списка моделей Anthropic: {base}/v1/models. + private const string AnthropicModelsPath = "/v1/models"; + + // Путь списка моделей OpenAI-совместимых: {base}/models. + private const string OpenAiModelsPath = "/models"; + + // Сообщение SSRF-гейта: base URL указывает на приватный/локальный адрес. + private const string PrivateEndpointNotAllowedMessage = + "Недопустимый Base URL (приватный/локальный адрес недоступен для проверки)"; + + // Заголовок ключа Anthropic. + private const string ApiKeyHeaderName = "x-api-key"; + + // Заголовок версии протокола Anthropic. + private const string AnthropicVersionHeaderName = "anthropic-version"; + + // Значение версии протокола Anthropic. + private const string AnthropicVersionHeaderValue = "2023-06-01"; + + private readonly HttpClient _httpClient; + + /// + /// Создаёт проверку поверх HttpClient. + /// + /// Клиент с таймаутом 12 с (DI: AddHttpClient в Deal.Api). + public AiConnectionChecker(HttpClient httpClient) + { + ArgumentNullException.ThrowIfNull(httpClient); + _httpClient = httpClient; + } + + /// + public async Task CheckAsync(AiCheckRequest request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + + AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == request.ProviderId); + string name = meta?.Name ?? request.ProviderId; + + // SSRF-гейт (allowlist, preflight): проверка возможна только для провайдера фиксированного + // каталога AiProviders. В штатном потоке недостижимо (PATCH-гейт aiProvider/aiConfigs в + // SettingsService) — защита от ручного изменения БД/повреждённого хранилища. + if (meta is null) + { + return BuildResult(request, name, ok: false, message: ProviderNotAllowedMessage); + } + + if (request.IsLocal) + { + return BuildResult(request, name, ok: true, message: string.Format(LocalServerMessageTemplate, name)); + } + + if (string.IsNullOrEmpty(request.ApiKey)) + { + return BuildResult(request, name, ok: false, message: NoApiKeyMessage); + } + + if (!TryBuildModelsUri(request.BaseUrl, request.ApiStyle, out Uri? modelsUri)) + { + return BuildResult(request, name, ok: false, message: InvalidBaseUrlMessage); + } + + // SSRF-гейт (Security review): проверка подключения выполняется только к публичным адресам. + // Private/loopback/link-local литералы и localhost запрещены для не-local провайдеров (локальные + // провайдеры — ветка IsLocal выше, HTTP для них не выполняется вовсе). DNS-имена не резолвятся + // здесь (полный egress-контроль с резолвом — на уровне сетевого периметра/прокси). + if (modelsUri.IsPrivateEndpoint()) + { + return BuildResult(request, name, ok: false, message: PrivateEndpointNotAllowedMessage); + } + + try + { + using HttpRequestMessage httpRequest = new(HttpMethod.Get, modelsUri); + AddAuthHeaders(httpRequest, request.ApiKey, request.ApiStyle); + + using HttpResponseMessage response = await _httpClient.SendAsync(httpRequest, ct); + int statusCode = (int)response.StatusCode; + + if (statusCode < 400) + { + return BuildResult(request, name, ok: true, message: SuccessMessage); + } + + if (statusCode is 401 or 403) + { + return BuildResult(request, name, ok: false, + message: string.Format(KeyRejectedMessageTemplate, statusCode)); + } + + return BuildResult(request, name, ok: false, + message: string.Format(HttpErrorMessageTemplate, statusCode)); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return BuildResult(request, name, ok: false, message: TimeoutMessage); + } + catch (HttpRequestException exception) + { + return BuildResult(request, name, ok: false, + message: string.Format(ConnectionErrorMessageTemplate, ConnectionErrorDetail(exception))); + } + } + + private static AiCheckResultDto BuildResult( + AiCheckRequest request, + string name, + bool ok, + string message) + { + return new AiCheckResultDto( + Ok: ok, + Message: message, + Provider: request.ProviderId, + Name: name, + Base: request.BaseUrl, + Model: request.Model, + Local: request.IsLocal, + KeySet: !string.IsNullOrEmpty(request.ApiKey), + KeyMasked: MaskKey(request.ApiKey)); + } + + private static string MaskKey(string key) + { + if (string.IsNullOrEmpty(key)) + { + return string.Empty; + } + + if (key.Length <= 8) + { + return string.Concat(key.AsSpan(0, 1), "…"); + } + + return string.Concat(key.AsSpan(0, 4), "…", key.AsSpan(key.Length - 4)); + } + + private static bool TryBuildModelsUri( + string baseUrl, + string? apiStyle, + [NotNullWhen(true)] out Uri? modelsUri) + { + modelsUri = null; + if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri? parsed) + || (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps)) + { + return false; + } + + string root = baseUrl.TrimEnd('/'); + string relativePath = apiStyle == AnthropicApiStyle ? AnthropicModelsPath : OpenAiModelsPath; + if (!Uri.TryCreate(root + relativePath, UriKind.Absolute, out Uri? endpoint)) + { + return false; + } + + modelsUri = endpoint; + return true; + } + + // Заголовки аутентификации: Bearer (OpenAI-совместимые) или x-api-key + версия (Anthropic). + // httpRequest: Запрос списка моделей. + // apiKey: Ключ открытым текстом (непустой — ветка ключа пройдена). + // apiStyle: Стиль API провайдера. + private static void AddAuthHeaders( + HttpRequestMessage httpRequest, + string apiKey, + string? apiStyle) + { + if (apiStyle == AnthropicApiStyle) + { + httpRequest.Headers.TryAddWithoutValidation(ApiKeyHeaderName, apiKey); + httpRequest.Headers.TryAddWithoutValidation(AnthropicVersionHeaderName, AnthropicVersionHeaderValue); + return; + } + + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + } + + // Исключение HTTP-слоя. + // Возвращает: Человекочитаемый текст причины. + private static string ConnectionErrorDetail(HttpRequestException exception) + { + if (!string.IsNullOrWhiteSpace(exception.Message)) + { + return exception.Message; + } + + return exception.InnerException?.Message ?? exception.GetType().Name; + } +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/AiProviderConfigBuilder.cs b/src/core/Deal.Infrastructure/Integrations/Services/AiProviderConfigBuilder.cs index a85a7de..0c450e6 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/AiProviderConfigBuilder.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/AiProviderConfigBuilder.cs @@ -1,150 +1,150 @@ -using System.Text.Json.Nodes; -using Deal.Grpc.Ai; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// Собирает конфиг активного ИИ-провайдера для запросов ai-service. -/// -public sealed class AiProviderConfigBuilder -{ - // Ключ aiConfigs: поле apiKey переопределения провайдера. - private const string ApiKeyField = "apiKey"; - - // Ключ aiConfigs: поле baseUrl переопределения провайдера. - private const string BaseUrlField = "baseUrl"; - - // Ключ aiConfigs: поле model переопределения провайдера. - private const string ModelField = "model"; - - private const string EncryptedPrefix = "enc:"; - - private readonly ISettingsStore _store; - private readonly ISecretCipher _secretCipher; - - /// - /// Создаёт сборщик конфига провайдера. - /// - /// KV-хранилище настроек тенанта (aiProvider/aiConfigs). - /// Расшифровка секрета aiConfigs.apiKey. - public AiProviderConfigBuilder(ISettingsStore store, ISecretCipher secretCipher) - { - ArgumentNullException.ThrowIfNull(store); - ArgumentNullException.ThrowIfNull(secretCipher); - _store = store; - _secretCipher = secretCipher; - } - - /// - /// Собирает ProviderConfig активного провайдера для тела запроса ai-service. - /// - /// Конфиг: provider_id/base/model/api_key (расшифрованный)/api_style (см. ai.proto). - public async Task BuildAsync(CancellationToken ct) - { - string providerId = await ReadProviderIdAsync(ct); - AiProviderDefinition meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId) - ?? AiProviders.All[0]; // неизвестный id — дефолтный провайдер (python L28) - - JsonObject? overrides = await ReadAiConfigsOverrideAsync(ct); - JsonObject? raw = overrides?[meta.Id] as JsonObject; - - string apiKey = ReadField(raw, ApiKeyField); - if (apiKey.StartsWith(EncryptedPrefix, StringComparison.Ordinal)) - { - apiKey = _secretCipher.Decrypt(apiKey); - } - - string baseUrl = ReadField(raw, BaseUrlField); - if (baseUrl.Length == 0) - { - baseUrl = meta.Base; // python L90: cfg.baseUrl or meta.base - } - - string model = ReadField(raw, ModelField); - if (model.Length == 0) - { - model = meta.Models.FirstOrDefault() ?? string.Empty; // python L91: cfg.model or models[0] - } - - var config = new ProviderConfig - { - ProviderId = meta.Id, - BaseUrl = baseUrl, - Model = model, - }; - if (apiKey.Length > 0) - { - config.ApiKey = apiKey; - } - - if (meta.ApiStyle is { Length: > 0 } apiStyle) - { - config.ApiStyle = apiStyle; - } - - return config; - } - - private async Task ReadProviderIdAsync(CancellationToken ct) - { - SettingValue? row = await _store.GetAsync(SettingsKeys.AiProvider, ct); - if (row is null) - { - return SettingsDefaults.AiProvider; - } - - try - { - JsonNode? value = JsonNode.Parse(row.ValueJson); - if (value is JsonValue scalar && scalar.TryGetValue(out string? providerId) - && !string.IsNullOrWhiteSpace(providerId)) - { - return providerId; - } - } - catch (System.Text.Json.JsonException) - { - // Повреждённая строка — дефолт (мягкая семантика, как в SettingsService). - } - - return SettingsDefaults.AiProvider; - } - - // Переопределение aiConfigs тенанта (JSON-объект «id провайдера → конфиг»); null — дефолты. - // ct: Токен отмены. - // Возвращает: Объект переопределения или null. - private async Task ReadAiConfigsOverrideAsync(CancellationToken ct) - { - SettingValue? row = await _store.GetAsync(SettingsKeys.AiConfigs, ct); - if (row is null) - { - return null; - } - - try - { - return JsonNode.Parse(row.ValueJson) as JsonObject; - } - catch (System.Text.Json.JsonException) - { - // Повреждённая строка — дефолты (мягкая семантика, как в SettingsService). - return null; - } - } - - // Строковое поле конфига провайдера (отсутствие/null/не-строка → пустая строка). - // config: Объект конфига провайдера (может быть null — дефолты). - // field: Имя поля (apiKey/baseUrl/model). - // Возвращает: Значение строкой или пустая строка. - private static string ReadField(JsonObject? config, string field) - { - if (config is null || !config.TryGetPropertyValue(field, out JsonNode? node) || node is not JsonValue value) - { - return string.Empty; - } - - return value.TryGetValue(out string? text) ? text ?? string.Empty : string.Empty; - } -} +using System.Text.Json.Nodes; +using Deal.Grpc.Ai; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Infrastructure.Integrations.Services; + +/// +/// Собирает конфиг активного ИИ-провайдера для запросов ai-service. +/// +public sealed class AiProviderConfigBuilder +{ + // Ключ aiConfigs: поле apiKey переопределения провайдера. + private const string ApiKeyField = "apiKey"; + + // Ключ aiConfigs: поле baseUrl переопределения провайдера. + private const string BaseUrlField = "baseUrl"; + + // Ключ aiConfigs: поле model переопределения провайдера. + private const string ModelField = "model"; + + private const string EncryptedPrefix = "enc:"; + + private readonly ISettingsStore _store; + private readonly ISecretCipher _secretCipher; + + /// + /// Создаёт сборщик конфига провайдера. + /// + /// KV-хранилище настроек тенанта (aiProvider/aiConfigs). + /// Расшифровка секрета aiConfigs.apiKey. + public AiProviderConfigBuilder(ISettingsStore store, ISecretCipher secretCipher) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(secretCipher); + _store = store; + _secretCipher = secretCipher; + } + + /// + /// Собирает ProviderConfig активного провайдера для тела запроса ai-service. + /// + /// Конфиг: provider_id/base/model/api_key (расшифрованный)/api_style (см. ai.proto). + public async Task BuildAsync(CancellationToken ct) + { + string providerId = await ReadProviderIdAsync(ct); + AiProviderDefinition meta = AiProviders.All.FirstOrDefault(provider => provider.Id == providerId) + ?? AiProviders.All[0]; // неизвестный id — дефолтный провайдер (python L28) + + JsonObject? overrides = await ReadAiConfigsOverrideAsync(ct); + JsonObject? raw = overrides?[meta.Id] as JsonObject; + + string apiKey = ReadField(raw, ApiKeyField); + if (apiKey.StartsWith(EncryptedPrefix, StringComparison.Ordinal)) + { + apiKey = _secretCipher.Decrypt(apiKey); + } + + string baseUrl = ReadField(raw, BaseUrlField); + if (baseUrl.Length == 0) + { + baseUrl = meta.Base; // python L90: cfg.baseUrl or meta.base + } + + string model = ReadField(raw, ModelField); + if (model.Length == 0) + { + model = meta.Models.FirstOrDefault() ?? string.Empty; // python L91: cfg.model or models[0] + } + + var config = new ProviderConfig + { + ProviderId = meta.Id, + BaseUrl = baseUrl, + Model = model, + }; + if (apiKey.Length > 0) + { + config.ApiKey = apiKey; + } + + if (meta.ApiStyle is { Length: > 0 } apiStyle) + { + config.ApiStyle = apiStyle; + } + + return config; + } + + private async Task ReadProviderIdAsync(CancellationToken ct) + { + SettingValue? row = await _store.GetAsync(SettingsKeys.AiProvider, ct); + if (row is null) + { + return SettingsDefaults.AiProvider; + } + + try + { + JsonNode? value = JsonNode.Parse(row.ValueJson); + if (value is JsonValue scalar && scalar.TryGetValue(out string? providerId) + && !string.IsNullOrWhiteSpace(providerId)) + { + return providerId; + } + } + catch (System.Text.Json.JsonException) + { + // Повреждённая строка — дефолт (мягкая семантика, как в SettingsService). + } + + return SettingsDefaults.AiProvider; + } + + // Переопределение aiConfigs тенанта (JSON-объект «id провайдера → конфиг»); null — дефолты. + // ct: Токен отмены. + // Возвращает: Объект переопределения или null. + private async Task ReadAiConfigsOverrideAsync(CancellationToken ct) + { + SettingValue? row = await _store.GetAsync(SettingsKeys.AiConfigs, ct); + if (row is null) + { + return null; + } + + try + { + return JsonNode.Parse(row.ValueJson) as JsonObject; + } + catch (System.Text.Json.JsonException) + { + // Повреждённая строка — дефолты (мягкая семантика, как в SettingsService). + return null; + } + } + + // Строковое поле конфига провайдера (отсутствие/null/не-строка → пустая строка). + // config: Объект конфига провайдера (может быть null — дефолты). + // field: Имя поля (apiKey/baseUrl/model). + // Возвращает: Значение строкой или пустая строка. + private static string ReadField(JsonObject? config, string field) + { + if (config is null || !config.TryGetPropertyValue(field, out JsonNode? node) || node is not JsonValue value) + { + return string.Empty; + } + + return value.TryGetValue(out string? text) ? text ?? string.Empty : string.Empty; + } +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiClassifier.cs b/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiClassifier.cs index 9a3c746..e42a5ab 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiClassifier.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiClassifier.cs @@ -1,106 +1,106 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.Extensions.Logging; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// Декоратор бюджетного гейта порта -/// -public sealed class BudgetedAiClassifier : IAiClassifier -{ - // Текст ошибки вызова вне tenant-контекста (гейт читает лимиты по тенанту). - private const string NoTenantContextText = - "BudgetedAiClassifier запрошен вне tenant-контекста (ITenantContext.TenantId == null/не-Guid)."; - - // Причина запрета в логе (общий текст для исчерпания и приостановки). - private const string GateDeniedLogText = "бюджет исчерпан или тенант приостановлен"; - - private readonly IAiClassifier _paidClassifier; - private readonly LocalAiClassifier _localClassifier; - private readonly ITenantLimitStore _tenantLimits; - private readonly ITenantContext _tenantContext; - private readonly ILogger _logger; - - /// - /// Создаёт декоратор бюджетного гейта классификатора. - /// - /// Платный исполнитель (gRPC-адаптер ai-service; вызывается только при Allowed). - /// Бесплатный локальный разбор/фильтр (fallback при запрете гейта). - /// Хранилище лимитов бюджета (public.tenant_limits; источник гейта). - /// Контекст текущего тенанта (AsyncLocal; tenantId запроса). - /// Логгер переходов на локальный путь. - public BudgetedAiClassifier( - IAiClassifier paidClassifier, - LocalAiClassifier localClassifier, - ITenantLimitStore tenantLimits, - ITenantContext tenantContext, - ILogger logger) - { - ArgumentNullException.ThrowIfNull(paidClassifier); - ArgumentNullException.ThrowIfNull(localClassifier); - ArgumentNullException.ThrowIfNull(tenantLimits); - ArgumentNullException.ThrowIfNull(tenantContext); - ArgumentNullException.ThrowIfNull(logger); - _paidClassifier = paidClassifier; - _localClassifier = localClassifier; - _tenantLimits = tenantLimits; - _tenantContext = tenantContext; - _logger = logger; - } - - /// - public async Task FilterAsync(string text, CancellationToken ct) - { - if (await IsPaidAllowedAsync(ct)) - { - return await _paidClassifier.FilterAsync(text, ct); - } - - _logger.LogDebug( - "ИИ-фильтр: {Reason} — Local-пропуск (тенант {TenantId})", GateDeniedLogText, TenantIdForLog()); - return await _localClassifier.FilterAsync(text, ct); - } - - /// - public async Task ClassifyAsync(string text, CancellationToken ct) - { - if (await IsPaidAllowedAsync(ct)) - { - return await _paidClassifier.ClassifyAsync(text, ct); - } - - _logger.LogDebug( - "ИИ-классификация: {Reason} — Local-разбор (тенант {TenantId})", GateDeniedLogText, TenantIdForLog()); - return await _localClassifier.ClassifyAsync(text, ct); - } - - private async Task IsPaidAllowedAsync(CancellationToken ct) - { - BudgetStateDto state = await _tenantLimits.GetStateAsync(RequireTenantId(), ct); - return state.Allowed; - } - - // Текущий тенант scope как Guid строки public.tenants (гейт читает лимиты по тенанту). - // Возвращает: Идентификатор тенанта (Guid). - // Исключение InvalidOperationException: Вызов вне tenant-контекста или не-Guid формат id. - private Guid RequireTenantId() - { - TenantId? tenantId = _tenantContext.TenantId; - if (tenantId is null || !Guid.TryParse(tenantId.Value.Value, out Guid id)) - { - throw new InvalidOperationException(NoTenantContextText); - } - - return id; - } - - // Id тенанта для лога («-» вне контекста — недостижимо после RequireTenantId). - // Возвращает: Строка id тенанта. - private string TenantIdForLog() => _tenantContext.TenantId?.Value ?? "-"; -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Infrastructure.Integrations.Exceptions; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; +using Microsoft.Extensions.Logging; + +namespace Deal.Infrastructure.Integrations.Services; + +/// +/// Декоратор бюджетного гейта порта +/// +public sealed class BudgetedAiClassifier : IAiClassifier +{ + // Текст ошибки вызова вне tenant-контекста (гейт читает лимиты по тенанту). + private const string NoTenantContextText = + "BudgetedAiClassifier запрошен вне tenant-контекста (ITenantContext.TenantId == null/не-Guid)."; + + // Причина запрета в логе (общий текст для исчерпания и приостановки). + private const string GateDeniedLogText = "бюджет исчерпан или тенант приостановлен"; + + private readonly IAiClassifier _paidClassifier; + private readonly LocalAiClassifier _localClassifier; + private readonly ITenantLimitStore _tenantLimits; + private readonly ITenantContext _tenantContext; + private readonly ILogger _logger; + + /// + /// Создаёт декоратор бюджетного гейта классификатора. + /// + /// Платный исполнитель (gRPC-адаптер ai-service; вызывается только при Allowed). + /// Бесплатный локальный разбор/фильтр (fallback при запрете гейта). + /// Хранилище лимитов бюджета (public.tenant_limits; источник гейта). + /// Контекст текущего тенанта (AsyncLocal; tenantId запроса). + /// Логгер переходов на локальный путь. + public BudgetedAiClassifier( + IAiClassifier paidClassifier, + LocalAiClassifier localClassifier, + ITenantLimitStore tenantLimits, + ITenantContext tenantContext, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(paidClassifier); + ArgumentNullException.ThrowIfNull(localClassifier); + ArgumentNullException.ThrowIfNull(tenantLimits); + ArgumentNullException.ThrowIfNull(tenantContext); + ArgumentNullException.ThrowIfNull(logger); + _paidClassifier = paidClassifier; + _localClassifier = localClassifier; + _tenantLimits = tenantLimits; + _tenantContext = tenantContext; + _logger = logger; + } + + /// + public async Task FilterAsync(string text, CancellationToken ct) + { + if (await IsPaidAllowedAsync(ct)) + { + return await _paidClassifier.FilterAsync(text, ct); + } + + _logger.LogDebug( + "ИИ-фильтр: {Reason} — Local-пропуск (тенант {TenantId})", GateDeniedLogText, TenantIdForLog()); + return await _localClassifier.FilterAsync(text, ct); + } + + /// + public async Task ClassifyAsync(string text, CancellationToken ct) + { + if (await IsPaidAllowedAsync(ct)) + { + return await _paidClassifier.ClassifyAsync(text, ct); + } + + _logger.LogDebug( + "ИИ-классификация: {Reason} — Local-разбор (тенант {TenantId})", GateDeniedLogText, TenantIdForLog()); + return await _localClassifier.ClassifyAsync(text, ct); + } + + private async Task IsPaidAllowedAsync(CancellationToken ct) + { + BudgetStateDto state = await _tenantLimits.GetStateAsync(RequireTenantId(), ct); + return state.Allowed; + } + + // Текущий тенант scope как Guid строки public.tenants (гейт читает лимиты по тенанту). + // Возвращает: Идентификатор тенанта (Guid). + // Исключение InvalidOperationException: Вызов вне tenant-контекста или не-Guid формат id. + private Guid RequireTenantId() + { + TenantId? tenantId = _tenantContext.TenantId; + if (tenantId is null || !Guid.TryParse(tenantId.Value.Value, out Guid id)) + { + throw new InvalidOperationException(NoTenantContextText); + } + + return id; + } + + // Id тенанта для лога («-» вне контекста — недостижимо после RequireTenantId). + // Возвращает: Строка id тенанта. + private string TenantIdForLog() => _tenantContext.TenantId?.Value ?? "-"; +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiTools.cs b/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiTools.cs index df30c05..16a867a 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiTools.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiTools.cs @@ -1,120 +1,120 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.Extensions.Logging; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// Декоратор бюджетного гейта порта -/// -public sealed class BudgetedAiTools : IAiTools -{ - private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна"; - - private const string SuspendedKeywordsError = "Тенант приостановлен — генерация ключевых слов недоступна"; - - private const string ExhaustedFitError = "ИИ-бюджет исчерпан — обработка в локальном режиме"; - - private const string SuspendedFitError = "Тенант приостановлен — ИИ-оценка заморожена"; - - private readonly IAiTools _paidTools; - private readonly ITenantLimitStore _tenantLimits; - private readonly ITenantContext _tenantContext; - private readonly ILogger _logger; - - /// - /// Создаёт декоратор бюджетного гейта ИИ-инструментов. - /// - /// Платный исполнитель (gRPC-адаптер ai-service; вызывается только при Allowed). - /// Хранилище лимитов бюджета (public.tenant_limits; источник гейта). - /// Контекст текущего тенанта (AsyncLocal; tenantId запроса). - /// Логгер переходов на локальный путь. - public BudgetedAiTools( - IAiTools paidTools, - ITenantLimitStore tenantLimits, - ITenantContext tenantContext, - ILogger logger) - { - ArgumentNullException.ThrowIfNull(paidTools); - ArgumentNullException.ThrowIfNull(tenantLimits); - ArgumentNullException.ThrowIfNull(tenantContext); - ArgumentNullException.ThrowIfNull(logger); - _paidTools = paidTools; - _tenantLimits = tenantLimits; - _tenantContext = tenantContext; - _logger = logger; - } - - /// - public async Task GenerateKeywordsAsync(string description, CancellationToken ct) - { - BudgetStateDto state = await GateStateAsync(ct); - if (state.Allowed) - { - return await _paidTools.GenerateKeywordsAsync(description, ct); - } - - _logger.LogDebug( - "generate-keywords: {Reason} — мягкая ошибка (тенант {TenantId})", - DenyLogText(state), - TenantIdForLog()); - return new AiGenerateKeywordsResultDto( - Ok: false, - Keywords: Array.Empty(), - Error: state.Status == TenantStatuses.Suspended ? SuspendedKeywordsError : ExhaustedKeywordsError); - } - - /// - public async Task EvaluateFitAsync( - string text, - string description, - IReadOnlyCollection keywords, - CancellationToken ct) - { - BudgetStateDto state = await GateStateAsync(ct); - if (state.Allowed) - { - return await _paidTools.EvaluateFitAsync(text, description, keywords, ct); - } - - _logger.LogDebug( - "evaluate-fit: {Reason} — эвристика (тенант {TenantId})", - DenyLogText(state), - TenantIdForLog()); - throw new AiUnavailableException( - state.Status == TenantStatuses.Suspended ? SuspendedFitError : ExhaustedFitError); - } - - private async Task GateStateAsync(CancellationToken ct) - => await _tenantLimits.GetStateAsync(RequireTenantId(), ct); - - // Причина запрета в логе: приостановка и исчерпание различаются (короткая строка без секретов). - // state: Состояние бюджета тенанта. - // Возвращает: Текст причины. - private static string DenyLogText(BudgetStateDto state) - => state.Status == TenantStatuses.Suspended ? "тенант приостановлен" : "ИИ-бюджет исчерпан"; - - // Текущий тенант scope как Guid строки public.tenants (гейт читает лимиты по тенанту). - // Возвращает: Идентификатор тенанта (Guid). - // Исключение InvalidOperationException: Вызов вне tenant-контекста или не-Guid формат id. - private Guid RequireTenantId() - { - TenantId? tenantId = _tenantContext.TenantId; - if (tenantId is null || !Guid.TryParse(tenantId.Value.Value, out Guid id)) - { - throw new InvalidOperationException( - "BudgetedAiTools запрошен вне tenant-контекста (ITenantContext.TenantId == null/не-Guid)."); - } - - return id; - } - - // Id тенанта для лога («-» вне контекста — недостижимо после RequireTenantId). - // Возвращает: Строка id тенанта. - private string TenantIdForLog() => _tenantContext.TenantId?.Value ?? "-"; -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Infrastructure.Integrations.Exceptions; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; +using Microsoft.Extensions.Logging; + +namespace Deal.Infrastructure.Integrations.Services; + +/// +/// Декоратор бюджетного гейта порта +/// +public sealed class BudgetedAiTools : IAiTools +{ + private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна"; + + private const string SuspendedKeywordsError = "Тенант приостановлен — генерация ключевых слов недоступна"; + + private const string ExhaustedFitError = "ИИ-бюджет исчерпан — обработка в локальном режиме"; + + private const string SuspendedFitError = "Тенант приостановлен — ИИ-оценка заморожена"; + + private readonly IAiTools _paidTools; + private readonly ITenantLimitStore _tenantLimits; + private readonly ITenantContext _tenantContext; + private readonly ILogger _logger; + + /// + /// Создаёт декоратор бюджетного гейта ИИ-инструментов. + /// + /// Платный исполнитель (gRPC-адаптер ai-service; вызывается только при Allowed). + /// Хранилище лимитов бюджета (public.tenant_limits; источник гейта). + /// Контекст текущего тенанта (AsyncLocal; tenantId запроса). + /// Логгер переходов на локальный путь. + public BudgetedAiTools( + IAiTools paidTools, + ITenantLimitStore tenantLimits, + ITenantContext tenantContext, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(paidTools); + ArgumentNullException.ThrowIfNull(tenantLimits); + ArgumentNullException.ThrowIfNull(tenantContext); + ArgumentNullException.ThrowIfNull(logger); + _paidTools = paidTools; + _tenantLimits = tenantLimits; + _tenantContext = tenantContext; + _logger = logger; + } + + /// + public async Task GenerateKeywordsAsync(string description, CancellationToken ct) + { + BudgetStateDto state = await GateStateAsync(ct); + if (state.Allowed) + { + return await _paidTools.GenerateKeywordsAsync(description, ct); + } + + _logger.LogDebug( + "generate-keywords: {Reason} — мягкая ошибка (тенант {TenantId})", + DenyLogText(state), + TenantIdForLog()); + return new AiGenerateKeywordsResultDto( + Ok: false, + Keywords: Array.Empty(), + Error: state.Status == TenantStatuses.Suspended ? SuspendedKeywordsError : ExhaustedKeywordsError); + } + + /// + public async Task EvaluateFitAsync( + string text, + string description, + IReadOnlyCollection keywords, + CancellationToken ct) + { + BudgetStateDto state = await GateStateAsync(ct); + if (state.Allowed) + { + return await _paidTools.EvaluateFitAsync(text, description, keywords, ct); + } + + _logger.LogDebug( + "evaluate-fit: {Reason} — эвристика (тенант {TenantId})", + DenyLogText(state), + TenantIdForLog()); + throw new AiUnavailableException( + state.Status == TenantStatuses.Suspended ? SuspendedFitError : ExhaustedFitError); + } + + private async Task GateStateAsync(CancellationToken ct) + => await _tenantLimits.GetStateAsync(RequireTenantId(), ct); + + // Причина запрета в логе: приостановка и исчерпание различаются (короткая строка без секретов). + // state: Состояние бюджета тенанта. + // Возвращает: Текст причины. + private static string DenyLogText(BudgetStateDto state) + => state.Status == TenantStatuses.Suspended ? "тенант приостановлен" : "ИИ-бюджет исчерпан"; + + // Текущий тенант scope как Guid строки public.tenants (гейт читает лимиты по тенанту). + // Возвращает: Идентификатор тенанта (Guid). + // Исключение InvalidOperationException: Вызов вне tenant-контекста или не-Guid формат id. + private Guid RequireTenantId() + { + TenantId? tenantId = _tenantContext.TenantId; + if (tenantId is null || !Guid.TryParse(tenantId.Value.Value, out Guid id)) + { + throw new InvalidOperationException( + "BudgetedAiTools запрошен вне tenant-контекста (ITenantContext.TenantId == null/не-Guid)."); + } + + return id; + } + + // Id тенанта для лога («-» вне контекста — недостижимо после RequireTenantId). + // Возвращает: Строка id тенанта. + private string TenantIdForLog() => _tenantContext.TenantId?.Value ?? "-"; +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/CbrRateSource.cs b/src/core/Deal.Infrastructure/Integrations/Services/CbrRateSource.cs index 4b3bccd..93ef35d 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/CbrRateSource.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/CbrRateSource.cs @@ -1,160 +1,160 @@ -using System.Globalization; -using System.Text.Json; -using Deal.Modules.Settings.Application.Abstractions; -using Microsoft.Extensions.Logging; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// HTTP-источник курсов ЦБ РФ -/// -public sealed class CbrRateSource : IRatesSource -{ - /// - /// Таймаут HTTP-запроса в секундах. - /// - public const int RequestTimeoutSeconds = 15; - - private const string CbrUrl = "https://www.cbr-xml-daily.ru/daily_json.js"; - - // Корневой объект ответа: валюта → {Value, Nominal, …}. - private const string ValutePropertyName = "Valute"; - - // Курс единицы валюты в рублях (число). - private const string ValuePropertyName = "Value"; - - // Номинал (сколько единиц за курс Value; может быть > 1). - private const string NominalPropertyName = "Nominal"; - - // Базовая валюта ответа: курсы даются к рублю. - private const string BaseCurrency = "RUB"; - - private const double RubToRubRate = 1.0; - - private readonly HttpClient _httpClient; - private readonly ILogger _logger; - - /// - /// Создаёт источник поверх HttpClient. - /// - /// Клиент с таймаутом 15 с (DI: AddHttpClient в Deal.Api). - /// Логгер предупреждений о сбоях. - public CbrRateSource(HttpClient httpClient, ILogger logger) - { - ArgumentNullException.ThrowIfNull(httpClient); - ArgumentNullException.ThrowIfNull(logger); - _httpClient = httpClient; - _logger = logger; - } - - /// - public async Task?> FetchAsync(CancellationToken ct) - { - try - { - using HttpResponseMessage response = await _httpClient.GetAsync(CbrUrl, ct); - response.EnsureSuccessStatusCode(); - return await ParseRatesAsync(response, ct); - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - // Отменил вызывающий (обрыв запроса/фоновой задачи) — пробрасываем, это не «сбой источника». - throw; - } - catch (Exception exception) - { - _logger.LogWarning("CBR fetch failed: {Reason}", exception.Message); - return null; - } - } - - // Разбирает тело daily_json.js в курсы к рублю; нераспознанное тело/запись — null. - // response: Успешный HTTP-ответ (статус 2xx). - // ct: Токен отмены. - // Возвращает: Словарь «код валюты → курс к RUB» (RUB:1 в начале) или null. - private static async Task?> ParseRatesAsync(HttpResponseMessage response, CancellationToken ct) - { - using Stream content = await response.Content.ReadAsStreamAsync(ct); - using JsonDocument document = await JsonDocument.ParseAsync(content, cancellationToken: ct); - - JsonElement root = document.RootElement; - if (root.ValueKind != JsonValueKind.Object - || !root.TryGetProperty(ValutePropertyName, out JsonElement valute) - || valute.ValueKind != JsonValueKind.Object) - { - // Тело не похоже на daily_json.js (нет объекта Valute) — это не курсы ЦБ. - return null; - } - - var rates = new Dictionary { [BaseCurrency] = RubToRubRate }; - foreach (JsonProperty currency in valute.EnumerateObject()) - { - if (!TryParseCurrency(currency, out double rate)) - { - return null; - } - - rates[currency.Name] = rate; - } - - return rates; - } - - private static bool TryParseCurrency(JsonProperty currency, out double rate) - { - rate = 0; - if (currency.Value.ValueKind != JsonValueKind.Object) - { - return false; - } - - JsonElement item = currency.Value; - - double value = 0; - if (item.TryGetProperty(ValuePropertyName, out JsonElement valueElement)) - { - if (!TryReadDouble(valueElement, out value)) - { - return false; - } - } - - double nominal = 1; - if (item.TryGetProperty(NominalPropertyName, out JsonElement nominalElement)) - { - if (!TryReadDouble(nominalElement, out nominal)) - { - return false; - } - } - - if (nominal == 0) - { - nominal = 1; // python: int(...) or 1 — нулевой номинал трактуем как 1 - } - - rate = Math.Round(value / nominal, 6); - return true; - } - - // Читает число из JSON-элемента (число или строка, как их отдаёт зеркало). - // element: JSON-элемент записи валюты. - // value: Прочитанное число (инвариантная культура). - // Возвращает: True — элемент распознан как число. - private static bool TryReadDouble(JsonElement element, out double value) - { - switch (element.ValueKind) - { - case JsonValueKind.Number: - value = element.GetDouble(); - return true; - - case JsonValueKind.String: - return double.TryParse(element.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out value); - - default: - value = 0; - return false; - } - } -} +using System.Globalization; +using System.Text.Json; +using Deal.Modules.Settings.Application.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Deal.Infrastructure.Integrations.Services; + +/// +/// HTTP-источник курсов ЦБ РФ +/// +public sealed class CbrRateSource : IRatesSource +{ + /// + /// Таймаут HTTP-запроса в секундах. + /// + public const int RequestTimeoutSeconds = 15; + + private const string CbrUrl = "https://www.cbr-xml-daily.ru/daily_json.js"; + + // Корневой объект ответа: валюта → {Value, Nominal, …}. + private const string ValutePropertyName = "Valute"; + + // Курс единицы валюты в рублях (число). + private const string ValuePropertyName = "Value"; + + // Номинал (сколько единиц за курс Value; может быть > 1). + private const string NominalPropertyName = "Nominal"; + + // Базовая валюта ответа: курсы даются к рублю. + private const string BaseCurrency = "RUB"; + + private const double RubToRubRate = 1.0; + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + /// + /// Создаёт источник поверх HttpClient. + /// + /// Клиент с таймаутом 15 с (DI: AddHttpClient в Deal.Api). + /// Логгер предупреждений о сбоях. + public CbrRateSource(HttpClient httpClient, ILogger logger) + { + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(logger); + _httpClient = httpClient; + _logger = logger; + } + + /// + public async Task?> FetchAsync(CancellationToken ct) + { + try + { + using HttpResponseMessage response = await _httpClient.GetAsync(CbrUrl, ct); + response.EnsureSuccessStatusCode(); + return await ParseRatesAsync(response, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Отменил вызывающий (обрыв запроса/фоновой задачи) — пробрасываем, это не «сбой источника». + throw; + } + catch (Exception exception) + { + _logger.LogWarning("CBR fetch failed: {Reason}", exception.Message); + return null; + } + } + + // Разбирает тело daily_json.js в курсы к рублю; нераспознанное тело/запись — null. + // response: Успешный HTTP-ответ (статус 2xx). + // ct: Токен отмены. + // Возвращает: Словарь «код валюты → курс к RUB» (RUB:1 в начале) или null. + private static async Task?> ParseRatesAsync(HttpResponseMessage response, CancellationToken ct) + { + using Stream content = await response.Content.ReadAsStreamAsync(ct); + using JsonDocument document = await JsonDocument.ParseAsync(content, cancellationToken: ct); + + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty(ValutePropertyName, out JsonElement valute) + || valute.ValueKind != JsonValueKind.Object) + { + // Тело не похоже на daily_json.js (нет объекта Valute) — это не курсы ЦБ. + return null; + } + + var rates = new Dictionary { [BaseCurrency] = RubToRubRate }; + foreach (JsonProperty currency in valute.EnumerateObject()) + { + if (!TryParseCurrency(currency, out double rate)) + { + return null; + } + + rates[currency.Name] = rate; + } + + return rates; + } + + private static bool TryParseCurrency(JsonProperty currency, out double rate) + { + rate = 0; + if (currency.Value.ValueKind != JsonValueKind.Object) + { + return false; + } + + JsonElement item = currency.Value; + + double value = 0; + if (item.TryGetProperty(ValuePropertyName, out JsonElement valueElement)) + { + if (!TryReadDouble(valueElement, out value)) + { + return false; + } + } + + double nominal = 1; + if (item.TryGetProperty(NominalPropertyName, out JsonElement nominalElement)) + { + if (!TryReadDouble(nominalElement, out nominal)) + { + return false; + } + } + + if (nominal == 0) + { + nominal = 1; // python: int(...) or 1 — нулевой номинал трактуем как 1 + } + + rate = Math.Round(value / nominal, 6); + return true; + } + + // Читает число из JSON-элемента (число или строка, как их отдаёт зеркало). + // element: JSON-элемент записи валюты. + // value: Прочитанное число (инвариантная культура). + // Возвращает: True — элемент распознан как число. + private static bool TryReadDouble(JsonElement element, out double value) + { + switch (element.ValueKind) + { + case JsonValueKind.Number: + value = element.GetDouble(); + return true; + + case JsonValueKind.String: + return double.TryParse(element.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out value); + + default: + value = 0; + return false; + } + } +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiClassifier.cs b/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiClassifier.cs index e71e6fd..c21cd9a 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiClassifier.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiClassifier.cs @@ -1,210 +1,210 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Ai; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Modules.Pipeline.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Grpc.Core; -using Microsoft.Extensions.Logging; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// gRPC-адаптер порта к автономному ai-service. -/// -public sealed class GrpcAiClassifier : IAiClassifier -{ - /// - /// Deadline RPC ai-service — 120 с - /// - public const int RpcDeadlineSeconds = 120; - - /// - /// Лимит текста сообщения фильтра. - /// - public const int MaxFilterTextCodePoints = 4000; - - // Текст фолбэк-ошибки, когда RPC-ошибка не несёт detail (сервис недоступен). - private const string ServiceUnavailableText = "ai-service недоступен — повторите попытку через несколько секунд"; - - // Текст ошибки ветки «модель не вернула разбор» (ClassifyReply.ok=false, README ai.proto). - private const string NoJsonAnswerText = "ИИ не дал разбора — ответ модели без JSON (повторите попытку через несколько секунд)"; - - private readonly ITenantContext _tenantContext; - private readonly AiGrpcConnection _connection; - private readonly AiProviderConfigBuilder _providerConfigBuilder; - private readonly AiClassifyContextBuilder _contextBuilder; - private readonly TokenUsageRecorder _usageRecorder; - private readonly ILogger _logger; - - /// - /// Создаёт gRPC-адаптер классификатора ai-service. - /// - /// Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами). - /// Транспорт ai-service (singleton-канал + service-token). - /// Конфиг активного провайдера из настроек тенанта. - /// Промпты и user-контекст классификации (модуль Pipeline). - /// Списание usage ответов: бюджет периода tenant_limits + lifetime-KV aiTokenUsage. - /// Логгер сбоев. - public GrpcAiClassifier( - ITenantContext tenantContext, - AiGrpcConnection connection, - AiProviderConfigBuilder providerConfigBuilder, - AiClassifyContextBuilder contextBuilder, - TokenUsageRecorder usageRecorder, - ILogger logger) - { - ArgumentNullException.ThrowIfNull(tenantContext); - ArgumentNullException.ThrowIfNull(connection); - ArgumentNullException.ThrowIfNull(providerConfigBuilder); - ArgumentNullException.ThrowIfNull(contextBuilder); - ArgumentNullException.ThrowIfNull(usageRecorder); - ArgumentNullException.ThrowIfNull(logger); - _tenantContext = tenantContext; - _connection = connection; - _providerConfigBuilder = providerConfigBuilder; - _contextBuilder = contextBuilder; - _usageRecorder = usageRecorder; - _logger = logger; - } - - /// - public async Task FilterAsync(string text, CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - string prompt = await _contextBuilder.BuildFilterPromptAsync(ct); - ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct); - AiService.AiServiceClient client = _connection.CreateClient(); - FilterReply reply = await client.FilterAsync( - new FilterRequest - { - Prompt = prompt, - Text = SliceCodePoints(text, MaxFilterTextCodePoints), // python L193: text[:4000] - ProviderConfig = providerConfig, - }, - CallOptions(tenantId.Value, ct)); - await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct); - - return new AiFilterResultDto( - Pass: reply.Pass, - Reason: reply.HasReason ? reply.Reason : null, - Skipped: false); - } - catch (RpcException exception) - { - _logger.LogDebug(exception, "ИИ-фильтр недоступен (тенант {TenantId})", tenantId.Value); - throw new AiUnavailableException(ErrorText(exception)); - } - catch (HttpRequestException exception) - { - _logger.LogDebug(exception, "ИИ-фильтр недоступен (тенант {TenantId})", tenantId.Value); - throw new AiUnavailableException(ServiceUnavailableText); - } - } - - /// - public async Task ClassifyAsync(string text, CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - string systemPrompt = await _contextBuilder.BuildClassifySystemPromptAsync(ct); - string userContext = await _contextBuilder.BuildClassifyUserContextAsync(text, ct); - ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct); - - AiService.AiServiceClient client = _connection.CreateClient(); - ClassifyReply reply; - try - { - reply = await client.ClassifyAsync( - new ClassifyRequest - { - SystemPrompt = systemPrompt, - UserContext = userContext, - ProviderConfig = providerConfig, - }, - CallOptions(tenantId.Value, ct)); - } - catch (RpcException exception) - { - _logger.LogDebug(exception, "ИИ-классификация недоступна (тенант {TenantId})", tenantId.Value); - throw new AiUnavailableException(ErrorText(exception)); - } - catch (HttpRequestException exception) - { - _logger.LogDebug(exception, "ИИ-классификация недоступна (тенант {TenantId})", tenantId.Value); - throw new AiUnavailableException(ServiceUnavailableText); - } - - await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct); - if (!reply.Ok) - { - _logger.LogWarning("ИИ-классификация: ok=false (тенант {TenantId})", tenantId.Value); - throw new AiUnavailableException(NoJsonAnswerText); - } - - try - { - return AiRawCardMapper.Map(reply.HasJson ? reply.Json : string.Empty, text); - } - catch (System.Text.Json.JsonException exception) - { - // ok=true, но тело не объект/не разбирается — защита от нарушения контракта сервисом: как ok=false. - _logger.LogWarning(exception, "ИИ-классификация: ответ ok=true без разбора (тенант {TenantId})", tenantId.Value); - throw new AiUnavailableException(NoJsonAnswerText); - } - } - - // Текущий тенант scope (без него scoped-хранилища/metadata не имеют смысла). - // Возвращает: Идентификатор тенанта. - // Исключение InvalidOperationException: Вызов вне tenant-контекста (конфигурация). - private TenantId RequireTenant() - => _tenantContext.TenantId - ?? throw new InvalidOperationException( - "GrpcAiClassifier запрошен вне tenant-контекста (ITenantContext.TenantId == null)."); - - private CallOptions CallOptions(string tenantId, CancellationToken ct) - => new( - headers: _connection.CreateMetadata(tenantId), - deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)), - cancellationToken: ct); - - private static string ErrorText(RpcException exception) - { - string detail = exception.Status.Detail?.Trim() ?? string.Empty; - return detail.Length > 0 ? detail : ServiceUnavailableText; - } - - private static string SliceCodePoints(string text, int max) - { - return text.Length <= max ? text : SliceByCodePoints(text, max); - } - - // Ручной срез по кодовым точкам (суррогатная пара не разрывается). - // text: Строка длиннее лимита. - // max: Максимум кодовых точек. - // Возвращает: Усечённая строка. - private static string SliceByCodePoints(string text, int max) - { - var builder = new System.Text.StringBuilder(max); - int taken = 0; - for (int index = 0; index < text.Length && taken < max; index++) - { - bool pair = char.IsHighSurrogate(text[index]) - && index + 1 < text.Length - && char.IsLowSurrogate(text[index + 1]); - builder.Append(text[index]); - if (pair) - { - index++; - builder.Append(text[index]); - } - - taken++; - } - - return builder.ToString(); - } -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Grpc.Ai; +using Deal.Infrastructure.Integrations.Exceptions; +using Deal.Infrastructure.Integrations.Models; +using Deal.Modules.Pipeline.Application.Services; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace Deal.Infrastructure.Integrations.Services; + +/// +/// gRPC-адаптер порта к автономному ai-service. +/// +public sealed class GrpcAiClassifier : IAiClassifier +{ + /// + /// Deadline RPC ai-service — 120 с + /// + public const int RpcDeadlineSeconds = 120; + + /// + /// Лимит текста сообщения фильтра. + /// + public const int MaxFilterTextCodePoints = 4000; + + // Текст фолбэк-ошибки, когда RPC-ошибка не несёт detail (сервис недоступен). + private const string ServiceUnavailableText = "ai-service недоступен — повторите попытку через несколько секунд"; + + // Текст ошибки ветки «модель не вернула разбор» (ClassifyReply.ok=false, README ai.proto). + private const string NoJsonAnswerText = "ИИ не дал разбора — ответ модели без JSON (повторите попытку через несколько секунд)"; + + private readonly ITenantContext _tenantContext; + private readonly AiGrpcConnection _connection; + private readonly AiProviderConfigBuilder _providerConfigBuilder; + private readonly AiClassifyContextBuilder _contextBuilder; + private readonly TokenUsageRecorder _usageRecorder; + private readonly ILogger _logger; + + /// + /// Создаёт gRPC-адаптер классификатора ai-service. + /// + /// Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами). + /// Транспорт ai-service (singleton-канал + service-token). + /// Конфиг активного провайдера из настроек тенанта. + /// Промпты и user-контекст классификации (модуль Pipeline). + /// Списание usage ответов: бюджет периода tenant_limits + lifetime-KV aiTokenUsage. + /// Логгер сбоев. + public GrpcAiClassifier( + ITenantContext tenantContext, + AiGrpcConnection connection, + AiProviderConfigBuilder providerConfigBuilder, + AiClassifyContextBuilder contextBuilder, + TokenUsageRecorder usageRecorder, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(tenantContext); + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(providerConfigBuilder); + ArgumentNullException.ThrowIfNull(contextBuilder); + ArgumentNullException.ThrowIfNull(usageRecorder); + ArgumentNullException.ThrowIfNull(logger); + _tenantContext = tenantContext; + _connection = connection; + _providerConfigBuilder = providerConfigBuilder; + _contextBuilder = contextBuilder; + _usageRecorder = usageRecorder; + _logger = logger; + } + + /// + public async Task FilterAsync(string text, CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + string prompt = await _contextBuilder.BuildFilterPromptAsync(ct); + ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct); + AiService.AiServiceClient client = _connection.CreateClient(); + FilterReply reply = await client.FilterAsync( + new FilterRequest + { + Prompt = prompt, + Text = SliceCodePoints(text, MaxFilterTextCodePoints), // python L193: text[:4000] + ProviderConfig = providerConfig, + }, + CallOptions(tenantId.Value, ct)); + await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct); + + return new AiFilterResultDto( + Pass: reply.Pass, + Reason: reply.HasReason ? reply.Reason : null, + Skipped: false); + } + catch (RpcException exception) + { + _logger.LogDebug(exception, "ИИ-фильтр недоступен (тенант {TenantId})", tenantId.Value); + throw new AiUnavailableException(ErrorText(exception)); + } + catch (HttpRequestException exception) + { + _logger.LogDebug(exception, "ИИ-фильтр недоступен (тенант {TenantId})", tenantId.Value); + throw new AiUnavailableException(ServiceUnavailableText); + } + } + + /// + public async Task ClassifyAsync(string text, CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + string systemPrompt = await _contextBuilder.BuildClassifySystemPromptAsync(ct); + string userContext = await _contextBuilder.BuildClassifyUserContextAsync(text, ct); + ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct); + + AiService.AiServiceClient client = _connection.CreateClient(); + ClassifyReply reply; + try + { + reply = await client.ClassifyAsync( + new ClassifyRequest + { + SystemPrompt = systemPrompt, + UserContext = userContext, + ProviderConfig = providerConfig, + }, + CallOptions(tenantId.Value, ct)); + } + catch (RpcException exception) + { + _logger.LogDebug(exception, "ИИ-классификация недоступна (тенант {TenantId})", tenantId.Value); + throw new AiUnavailableException(ErrorText(exception)); + } + catch (HttpRequestException exception) + { + _logger.LogDebug(exception, "ИИ-классификация недоступна (тенант {TenantId})", tenantId.Value); + throw new AiUnavailableException(ServiceUnavailableText); + } + + await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct); + if (!reply.Ok) + { + _logger.LogWarning("ИИ-классификация: ok=false (тенант {TenantId})", tenantId.Value); + throw new AiUnavailableException(NoJsonAnswerText); + } + + try + { + return AiRawCardMapper.Map(reply.HasJson ? reply.Json : string.Empty, text); + } + catch (System.Text.Json.JsonException exception) + { + // ok=true, но тело не объект/не разбирается — защита от нарушения контракта сервисом: как ok=false. + _logger.LogWarning(exception, "ИИ-классификация: ответ ok=true без разбора (тенант {TenantId})", tenantId.Value); + throw new AiUnavailableException(NoJsonAnswerText); + } + } + + // Текущий тенант scope (без него scoped-хранилища/metadata не имеют смысла). + // Возвращает: Идентификатор тенанта. + // Исключение InvalidOperationException: Вызов вне tenant-контекста (конфигурация). + private TenantId RequireTenant() + => _tenantContext.TenantId + ?? throw new InvalidOperationException( + "GrpcAiClassifier запрошен вне tenant-контекста (ITenantContext.TenantId == null)."); + + private CallOptions CallOptions(string tenantId, CancellationToken ct) + => new( + headers: _connection.CreateMetadata(tenantId), + deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)), + cancellationToken: ct); + + private static string ErrorText(RpcException exception) + { + string detail = exception.Status.Detail?.Trim() ?? string.Empty; + return detail.Length > 0 ? detail : ServiceUnavailableText; + } + + private static string SliceCodePoints(string text, int max) + { + return text.Length <= max ? text : SliceByCodePoints(text, max); + } + + // Ручной срез по кодовым точкам (суррогатная пара не разрывается). + // text: Строка длиннее лимита. + // max: Максимум кодовых точек. + // Возвращает: Усечённая строка. + private static string SliceByCodePoints(string text, int max) + { + var builder = new System.Text.StringBuilder(max); + int taken = 0; + for (int index = 0; index < text.Length && taken < max; index++) + { + bool pair = char.IsHighSurrogate(text[index]) + && index + 1 < text.Length + && char.IsLowSurrogate(text[index + 1]); + builder.Append(text[index]); + if (pair) + { + index++; + builder.Append(text[index]); + } + + taken++; + } + + return builder.ToString(); + } +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiTools.cs b/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiTools.cs index 6202dbe..a82cd19 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiTools.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiTools.cs @@ -1,199 +1,199 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Ai; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Models; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Grpc.Core; -using Microsoft.Extensions.Logging; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// gRPC-адаптер порта к автономному ai-service. -/// -public sealed class GrpcAiTools : IAiTools -{ - /// - /// Deadline RPC ai-service — 120 с - /// - public const int RpcDeadlineSeconds = 120; - - /// - /// Лимит описания ниши generate-keywords. - /// - public const int MaxDescriptionCodePoints = 4000; - - /// - /// Лимит текста сообщения evaluate-fit. - /// - public const int MaxEvalTextCodePoints = 4000; - - // Текст фолбэк-ошибки, когда RPC-ошибка не несёт detail (сервис недоступен). - private const string ServiceUnavailableText = "ai-service недоступен — повторите попытку через несколько секунд"; - - private const string FitReasonDefault = "подходит"; - - private const string NotFitReasonDefault = "не подходит"; - - private readonly ITenantContext _tenantContext; - private readonly AiGrpcConnection _connection; - private readonly AiProviderConfigBuilder _providerConfigBuilder; - private readonly TokenUsageRecorder _usageRecorder; - private readonly ILogger _logger; - - /// - /// Создаёт gRPC-адаптер ИИ-инструментов ai-service. - /// - /// Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами). - /// Транспорт ai-service (singleton-канал + service-token). - /// Конфиг активного провайдера из настроек тенанта. - /// Списание usage ответов: бюджет периода tenant_limits + lifetime-KV aiTokenUsage. - /// Логгер сбоев. - public GrpcAiTools( - ITenantContext tenantContext, - AiGrpcConnection connection, - AiProviderConfigBuilder providerConfigBuilder, - TokenUsageRecorder usageRecorder, - ILogger logger) - { - ArgumentNullException.ThrowIfNull(tenantContext); - ArgumentNullException.ThrowIfNull(connection); - ArgumentNullException.ThrowIfNull(providerConfigBuilder); - ArgumentNullException.ThrowIfNull(usageRecorder); - ArgumentNullException.ThrowIfNull(logger); - _tenantContext = tenantContext; - _connection = connection; - _providerConfigBuilder = providerConfigBuilder; - _usageRecorder = usageRecorder; - _logger = logger; - } - - /// - public async Task GenerateKeywordsAsync(string description, CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - AiService.AiServiceClient client = _connection.CreateClient(); - ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct); - GenerateKeywordsReply reply = await client.GenerateKeywordsAsync( - new GenerateKeywordsRequest - { - Description = SliceCodePoints(description ?? string.Empty, MaxDescriptionCodePoints), - ProviderConfig = providerConfig, - }, - CallOptions(tenantId.Value, ct)); - await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct); - return new AiGenerateKeywordsResultDto( - Ok: true, - Keywords: reply.Keywords.ToList(), - Error: null); - } - catch (RpcException exception) - { - _logger.LogDebug(exception, "generate-keywords недоступен (тенант {TenantId})", tenantId.Value); - return new AiGenerateKeywordsResultDto(Ok: false, Keywords: Array.Empty(), Error: ErrorText(exception)); - } - catch (HttpRequestException exception) - { - _logger.LogDebug(exception, "generate-keywords недоступен (тенант {TenantId})", tenantId.Value); - return new AiGenerateKeywordsResultDto( - Ok: false, Keywords: Array.Empty(), Error: ServiceUnavailableText); - } - } - - /// - public async Task EvaluateFitAsync( - string text, - string description, - IReadOnlyCollection keywords, - CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - AiService.AiServiceClient client = _connection.CreateClient(); - ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct); - var request = new EvaluateFitRequest - { - Text = SliceCodePoints(text ?? string.Empty, MaxEvalTextCodePoints), - Description = description ?? string.Empty, - ProviderConfig = providerConfig, - }; - foreach (string keyword in keywords ?? Array.Empty()) - { - if (!string.IsNullOrEmpty(keyword)) - { - request.Keywords.Add(keyword); - } - } - - EvaluateFitReply reply = await client.EvaluateFitAsync(request, CallOptions(tenantId.Value, ct)); - await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct); - return new AiEvaluateFitResultDto( - Fit: reply.Fit, - Reason: reply.HasReason && reply.Reason.Length > 0 - ? reply.Reason - : (reply.Fit ? FitReasonDefault : NotFitReasonDefault)); - } - catch (RpcException exception) - { - _logger.LogDebug(exception, "evaluate-fit недоступен (тенант {TenantId})", tenantId.Value); - throw new AiUnavailableException(ErrorText(exception)); - } - catch (HttpRequestException exception) - { - _logger.LogDebug(exception, "evaluate-fit недоступен (тенант {TenantId})", tenantId.Value); - throw new AiUnavailableException(ServiceUnavailableText); - } - } - - // Текущий тенант scope (без него scoped-хранилища/metadata не имеют смысла). - // Возвращает: Идентификатор тенанта. - // Исключение InvalidOperationException: Вызов вне tenant-контекста (конфигурация). - private TenantId RequireTenant() - => _tenantContext.TenantId - ?? throw new InvalidOperationException( - "GrpcAiTools запрошен вне tenant-контекста (ITenantContext.TenantId == null)."); - - private CallOptions CallOptions(string tenantId, CancellationToken ct) - => new( - headers: _connection.CreateMetadata(tenantId), - deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)), - cancellationToken: ct); - - private static string ErrorText(RpcException exception) - { - string detail = exception.Status.Detail?.Trim() ?? string.Empty; - return detail.Length > 0 ? detail : ServiceUnavailableText; - } - - private static string SliceCodePoints(string text, int max) - { - if (text.Length <= max) - { - return text; - } - - var builder = new System.Text.StringBuilder(max); - int taken = 0; - for (int index = 0; index < text.Length && taken < max; index++) - { - bool pair = char.IsHighSurrogate(text[index]) - && index + 1 < text.Length - && char.IsLowSurrogate(text[index + 1]); - builder.Append(text[index]); - if (pair) - { - index++; - builder.Append(text[index]); - } - - taken++; - } - - return builder.ToString(); - } -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Grpc.Ai; +using Deal.Infrastructure.Integrations.Exceptions; +using Deal.Infrastructure.Integrations.Models; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace Deal.Infrastructure.Integrations.Services; + +/// +/// gRPC-адаптер порта к автономному ai-service. +/// +public sealed class GrpcAiTools : IAiTools +{ + /// + /// Deadline RPC ai-service — 120 с + /// + public const int RpcDeadlineSeconds = 120; + + /// + /// Лимит описания ниши generate-keywords. + /// + public const int MaxDescriptionCodePoints = 4000; + + /// + /// Лимит текста сообщения evaluate-fit. + /// + public const int MaxEvalTextCodePoints = 4000; + + // Текст фолбэк-ошибки, когда RPC-ошибка не несёт detail (сервис недоступен). + private const string ServiceUnavailableText = "ai-service недоступен — повторите попытку через несколько секунд"; + + private const string FitReasonDefault = "подходит"; + + private const string NotFitReasonDefault = "не подходит"; + + private readonly ITenantContext _tenantContext; + private readonly AiGrpcConnection _connection; + private readonly AiProviderConfigBuilder _providerConfigBuilder; + private readonly TokenUsageRecorder _usageRecorder; + private readonly ILogger _logger; + + /// + /// Создаёт gRPC-адаптер ИИ-инструментов ai-service. + /// + /// Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами). + /// Транспорт ai-service (singleton-канал + service-token). + /// Конфиг активного провайдера из настроек тенанта. + /// Списание usage ответов: бюджет периода tenant_limits + lifetime-KV aiTokenUsage. + /// Логгер сбоев. + public GrpcAiTools( + ITenantContext tenantContext, + AiGrpcConnection connection, + AiProviderConfigBuilder providerConfigBuilder, + TokenUsageRecorder usageRecorder, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(tenantContext); + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(providerConfigBuilder); + ArgumentNullException.ThrowIfNull(usageRecorder); + ArgumentNullException.ThrowIfNull(logger); + _tenantContext = tenantContext; + _connection = connection; + _providerConfigBuilder = providerConfigBuilder; + _usageRecorder = usageRecorder; + _logger = logger; + } + + /// + public async Task GenerateKeywordsAsync(string description, CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + AiService.AiServiceClient client = _connection.CreateClient(); + ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct); + GenerateKeywordsReply reply = await client.GenerateKeywordsAsync( + new GenerateKeywordsRequest + { + Description = SliceCodePoints(description ?? string.Empty, MaxDescriptionCodePoints), + ProviderConfig = providerConfig, + }, + CallOptions(tenantId.Value, ct)); + await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct); + return new AiGenerateKeywordsResultDto( + Ok: true, + Keywords: reply.Keywords.ToList(), + Error: null); + } + catch (RpcException exception) + { + _logger.LogDebug(exception, "generate-keywords недоступен (тенант {TenantId})", tenantId.Value); + return new AiGenerateKeywordsResultDto(Ok: false, Keywords: Array.Empty(), Error: ErrorText(exception)); + } + catch (HttpRequestException exception) + { + _logger.LogDebug(exception, "generate-keywords недоступен (тенант {TenantId})", tenantId.Value); + return new AiGenerateKeywordsResultDto( + Ok: false, Keywords: Array.Empty(), Error: ServiceUnavailableText); + } + } + + /// + public async Task EvaluateFitAsync( + string text, + string description, + IReadOnlyCollection keywords, + CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + AiService.AiServiceClient client = _connection.CreateClient(); + ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct); + var request = new EvaluateFitRequest + { + Text = SliceCodePoints(text ?? string.Empty, MaxEvalTextCodePoints), + Description = description ?? string.Empty, + ProviderConfig = providerConfig, + }; + foreach (string keyword in keywords ?? Array.Empty()) + { + if (!string.IsNullOrEmpty(keyword)) + { + request.Keywords.Add(keyword); + } + } + + EvaluateFitReply reply = await client.EvaluateFitAsync(request, CallOptions(tenantId.Value, ct)); + await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct); + return new AiEvaluateFitResultDto( + Fit: reply.Fit, + Reason: reply.HasReason && reply.Reason.Length > 0 + ? reply.Reason + : (reply.Fit ? FitReasonDefault : NotFitReasonDefault)); + } + catch (RpcException exception) + { + _logger.LogDebug(exception, "evaluate-fit недоступен (тенант {TenantId})", tenantId.Value); + throw new AiUnavailableException(ErrorText(exception)); + } + catch (HttpRequestException exception) + { + _logger.LogDebug(exception, "evaluate-fit недоступен (тенант {TenantId})", tenantId.Value); + throw new AiUnavailableException(ServiceUnavailableText); + } + } + + // Текущий тенант scope (без него scoped-хранилища/metadata не имеют смысла). + // Возвращает: Идентификатор тенанта. + // Исключение InvalidOperationException: Вызов вне tenant-контекста (конфигурация). + private TenantId RequireTenant() + => _tenantContext.TenantId + ?? throw new InvalidOperationException( + "GrpcAiTools запрошен вне tenant-контекста (ITenantContext.TenantId == null)."); + + private CallOptions CallOptions(string tenantId, CancellationToken ct) + => new( + headers: _connection.CreateMetadata(tenantId), + deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)), + cancellationToken: ct); + + private static string ErrorText(RpcException exception) + { + string detail = exception.Status.Detail?.Trim() ?? string.Empty; + return detail.Length > 0 ? detail : ServiceUnavailableText; + } + + private static string SliceCodePoints(string text, int max) + { + if (text.Length <= max) + { + return text; + } + + var builder = new System.Text.StringBuilder(max); + int taken = 0; + for (int index = 0; index < text.Length && taken < max; index++) + { + bool pair = char.IsHighSurrogate(text[index]) + && index + 1 < text.Length + && char.IsLowSurrogate(text[index + 1]); + builder.Append(text[index]); + if (pair) + { + index++; + builder.Append(text[index]); + } + + taken++; + } + + return builder.ToString(); + } +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/GrpcMlClient.cs b/src/core/Deal.Infrastructure/Integrations/Services/GrpcMlClient.cs index 020725d..11ec1f4 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/GrpcMlClient.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/GrpcMlClient.cs @@ -1,373 +1,373 @@ -using System.Text.Json; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Ml; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Grpc.Core; -using Microsoft.Extensions.Logging; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// gRPC-адаптер порта IMlClient к автономному ml-service. -/// -public sealed class GrpcMlClient : IMlClient, IMlTrainClient -{ - /// - /// Deadline Predict — 5 с - /// - public const int PredictDeadlineSeconds = 5; - - /// - /// Deadline Status/Reset — 10 с - /// - public const int StatusDeadlineSeconds = 10; - - /// - /// Deadline TrainBatch — 30 с - /// - public const int TrainBatchDeadlineSeconds = 30; - - // Текст мягкой ошибки, когда сервис вернул ResetReply.ok=false без error (резерв). - private const string DefaultResetError = "ML-сервис не смог сбросить модель"; - - // Пустой словарь весов предсказания/классов неготовой модели. - private static readonly IReadOnlyDictionary EmptyScores = new Dictionary(); - - // Контекст текущего тенанта (id — в metadata вызовов; scoped-хранилища строятся от него же). - private readonly ITenantContext _tenantContext; - - // KV-хранилище настроек тенанта (выключатель mlEnabled, счётчики ml/ai). - private readonly ISettingsStore _store; - - // Хранилище обучения ML: очередь MlOutbox + счётчик журнала CardMoves. - private readonly IMlLearningStore _learningStore; - - // Транспорт gRPC ml-service (канал + metadata). - private readonly MlGrpcConnection _connection; - - // Кэш статуса сервиса на тенанта (15 с). - private readonly MlStatusCache _statusCache; - - private readonly TokenUsageRecorder _usageRecorder; - - // Логгер сбоев вызовов ml-service. - private readonly ILogger _logger; - - /// - /// Создаёт gRPC-адаптер клиента ML-сервиса. - /// - /// Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами). - /// KV-хранилище настроек тенанта. - /// Хранилище обучения ML (очередь MlOutbox + журнал). - /// Транспорт ml-service (singleton-канал + service-token). - /// Кэш статуса сервиса на тенанта (singleton). - /// Recorder истории расхода. - /// Логгер сбоев. - public GrpcMlClient( - ITenantContext tenantContext, - ISettingsStore store, - IMlLearningStore learningStore, - MlGrpcConnection connection, - MlStatusCache statusCache, - TokenUsageRecorder usageRecorder, - ILogger logger) - { - ArgumentNullException.ThrowIfNull(tenantContext); - ArgumentNullException.ThrowIfNull(store); - ArgumentNullException.ThrowIfNull(learningStore); - ArgumentNullException.ThrowIfNull(connection); - ArgumentNullException.ThrowIfNull(statusCache); - ArgumentNullException.ThrowIfNull(usageRecorder); - ArgumentNullException.ThrowIfNull(logger); - _tenantContext = tenantContext; - _store = store; - _learningStore = learningStore; - _connection = connection; - _statusCache = statusCache; - _usageRecorder = usageRecorder; - _logger = logger; - } - - /// - public async Task StatusAsync(CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - MlStatusCache.Snapshot snapshot = await GetServiceSnapshotAsync(tenantId, ct); - - bool enabled = await ReadMlEnabledAsync(ct); - int mlDecisions = await ReadCounterAsync(SettingsKeys.MlDecisions, ct); - int aiDecisions = await ReadCounterAsync(SettingsKeys.AiDecisions, ct); - int learning = await _learningStore.CountLearningAsync(ct); - int outbox = await _learningStore.CountOutboxAsync(ct); - - var stats = new MlStatsDto( - Ml: mlDecisions, - Ai: aiDecisions, - Learning: learning, - Ready: snapshot.Service.Ready, - Classes: snapshot.Service.Classes, - Learned: snapshot.Service.Learned, - Reachable: snapshot.Reachable, - Outbox: outbox); - - return new MlStatusResponseDto(Enabled: enabled, Service: snapshot.Service, Reachable: snapshot.Reachable, Stats: stats); - } - - /// - public async Task PredictAsync(string text, CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - MlService.MlServiceClient client = _connection.CreateClient(); - PredictReply reply = await client.PredictAsync( - new PredictRequest { Text = text ?? string.Empty }, - CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), ct)); - - await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct); - return MapPredict(reply); - } - catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException) - { - _logger.LogDebug(exception, "ML predict недоступен (тенант {TenantId})", tenantId.Value); - return NotReadyPrediction; - } - } - - /// - public async Task ResetAsync(CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - ResetReply reply; - try - { - MlService.MlServiceClient client = _connection.CreateClient(); - reply = await client.ResetAsync( - new ResetRequest(), - CallOptions(tenantId.Value, TimeSpan.FromSeconds(StatusDeadlineSeconds), ct)); - } - catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException) - { - _logger.LogWarning(exception, "ML reset не удался (тенант {TenantId})", tenantId.Value); - return new MlResetResultDto(Ok: false, Error: ErrorText(exception)); - } - - if (!reply.Ok) - { - return new MlResetResultDto(Ok: false, Error: reply.HasError ? reply.Error : DefaultResetError); - } - - await _learningStore.ClearOutboxAsync(ct); - _statusCache.Invalidate(tenantId.Value); - return new MlResetResultDto(Ok: true, Error: null); - } - - /// - public async Task PushAsync( - string text, - string label, - double delta, - CancellationToken ct) - { - await MlOutboxQueue.PushAsync(_learningStore, text, label, delta, ct); - } - - /// - public async Task TrainBatchAsync(IReadOnlyList items, CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - var request = new TrainBatchRequest(); - foreach (MlOutboxEntryDto item in items) - { - request.Items.Add(new TrainExample - { - Text = item.Text, - Label = item.Label, - Delta = item.Delta, - }); - } - - MlService.MlServiceClient client = _connection.CreateClient(); - TrainBatchReply reply = await client.TrainBatchAsync( - request, - CallOptions(tenantId.Value, TimeSpan.FromSeconds(TrainBatchDeadlineSeconds), ct)); - return reply.Learned; - } - - // Текущий тенант scope (без него scoped-хранилища/metadata не имеют смысла). - // Возвращает: Идентификатор тенанта. - // Исключение InvalidOperationException: Вызов вне tenant-контекста (конфигурация). - private TenantId RequireTenant() - => _tenantContext.TenantId - ?? throw new InvalidOperationException( - "GrpcMlClient запрошен вне tenant-контекста (ITenantContext.TenantId == null)."); - - // Возвращает статус модели из кэша либо обновляет его вызовом ml-service (кэш 15 с). - // tenantId: Id тенанта (формат N). - // ct: Токен отмены. - // Возвращает: Свежая запись кэша (при сбое сервиса — старые данные + reachable=false). - private async Task GetServiceSnapshotAsync(TenantId tenantId, CancellationToken ct) - { - if (_statusCache.TryGetFresh(tenantId.Value, out MlStatusCache.Snapshot fresh)) - { - return fresh; - } - - _statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot stale); - MlServiceStatusDto previous = stale?.Service ?? NotReadyServiceStatus; - - try - { - MlService.MlServiceClient client = _connection.CreateClient(); - StatusReply reply = await client.StatusAsync( - new StatusRequest(), - CallOptions(tenantId.Value, TimeSpan.FromSeconds(StatusDeadlineSeconds), ct)); - MlServiceStatusDto service = MapStatus(reply); - _statusCache.Set(tenantId.Value, service, reachable: true); - return _statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot updated) - ? updated - : new MlStatusCache.Snapshot(service, Reachable: true, UpdatedAtMs: 0); - } - catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException) - { - _logger.LogDebug(exception, "ML status недоступен (тенант {TenantId})", tenantId.Value); - _statusCache.Set(tenantId.Value, previous, reachable: false); - return new MlStatusCache.Snapshot(previous, false, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); - } - } - - private static MlServiceStatusDto MapStatus(StatusReply reply) - { - return new MlServiceStatusDto( - Ready: reply.Ready, - Classes: new Dictionary(reply.Classes), - Learned: reply.Learned, - Eval: new MlEvalDto( - Count: reply.Eval?.Count ?? 0, - Correct: reply.Eval?.Correct ?? 0, - Accuracy: reply.Eval?.Accuracy ?? 0.0)); - } - - private static MlPredictResultDto MapPredict(PredictReply reply) - { - return new MlPredictResultDto( - Take: reply.Take, - Label: reply.HasLabel ? reply.Label : null, - Scores: new Dictionary(reply.Scores), - Hits: reply.Hits, - Ready: reply.Ready, - Margin: reply.HasMargin ? reply.Margin : null, - Terms: reply.Terms.ToList(), - Type: MapTypeDecision(reply.Type)); - } - - // Маппит решение о типе заявки (null — модель тип не определила). - // decision: Ответ ml-service (TypeDecision) или null. - // Возвращает: DTO типа заявки или null. - private static MlTypeDecisionDto? MapTypeDecision(TypeDecision? decision) - { - return decision is null - ? null - : new MlTypeDecisionDto( - Take: decision.Take, - Label: decision.Label, - Value: decision.Value, - Margin: decision.Margin); - } - - private CallOptions CallOptions( - string tenantId, - TimeSpan deadline, - CancellationToken ct) - => new( - headers: _connection.CreateMetadata(tenantId), - deadline: DateTime.UtcNow.Add(deadline), - cancellationToken: ct); - - // Краткий текст ошибки для мягкого {ok:false,error} (секреты/тела ответов не логируются). - // exception: Исключение вызова. - // Возвращает: Текст ошибки. - private static string ErrorText(Exception exception) - => exception is RpcException rpc && rpc.StatusCode == StatusCode.Unavailable - ? "ML-сервис недоступен" - : "ML-сервис не ответил — повторите попытку через несколько секунд"; - - private static MlPredictResultDto NotReadyPrediction => new( - Take: false, - Label: null, - Scores: EmptyScores, - Hits: 0, - Ready: false, - Margin: null, - Terms: Array.Empty(), - Type: null); - - // Статус модели по умолчанию (нет данных кэша и сервис недоступен): «не готова». - private static MlServiceStatusDto NotReadyServiceStatus => new( - Ready: false, - Classes: EmptyScores, - Learned: 0, - Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0)); - - private async Task ReadMlEnabledAsync(CancellationToken ct) - { - SettingValue? row = await _store.GetAsync(SettingsKeys.MlEnabled, ct); - if (row is null) - { - return SettingsDefaults.MlEnabled; - } - - try - { - using JsonDocument document = JsonDocument.Parse(row.ValueJson); - if (document.RootElement.ValueKind is JsonValueKind.True or JsonValueKind.False) - { - return document.RootElement.GetBoolean(); - } - } - catch (JsonException) - { - // Повреждённая строка — дефолт (мягкая семантика, как в SettingsService). - } - - return SettingsDefaults.MlEnabled; - } - - // Читает целочисленный счётчик (mlDecisions/aiDecisions); отсутствие/повреждение → 0. - // key: Внутренний KV-ключ счётчика. - // ct: Токен отмены. - // Возвращает: Значение счётчика из хранилища или 0. - private async Task ReadCounterAsync(string key, CancellationToken ct) - { - SettingValue? row = await _store.GetAsync(key, ct); - if (row is null) - { - return 0; - } - - try - { - using JsonDocument document = JsonDocument.Parse(row.ValueJson); - if (document.RootElement.ValueKind == JsonValueKind.Number - && document.RootElement.TryGetInt64(out long wide)) - { - return (int)Math.Clamp(wide, int.MinValue, int.MaxValue); - } - } - catch (JsonException) - { - // Повреждённая строка — 0 (мягкая семантика, как в SettingsService). - } - - return 0; - } -} +using System.Text.Json; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Grpc.Ml; +using Deal.Infrastructure.Integrations.Abstractions; +using Deal.Infrastructure.Integrations.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace Deal.Infrastructure.Integrations.Services; + +/// +/// gRPC-адаптер порта IMlClient к автономному ml-service. +/// +public sealed class GrpcMlClient : IMlClient, IMlTrainClient +{ + /// + /// Deadline Predict — 5 с + /// + public const int PredictDeadlineSeconds = 5; + + /// + /// Deadline Status/Reset — 10 с + /// + public const int StatusDeadlineSeconds = 10; + + /// + /// Deadline TrainBatch — 30 с + /// + public const int TrainBatchDeadlineSeconds = 30; + + // Текст мягкой ошибки, когда сервис вернул ResetReply.ok=false без error (резерв). + private const string DefaultResetError = "ML-сервис не смог сбросить модель"; + + // Пустой словарь весов предсказания/классов неготовой модели. + private static readonly IReadOnlyDictionary EmptyScores = new Dictionary(); + + // Контекст текущего тенанта (id — в metadata вызовов; scoped-хранилища строятся от него же). + private readonly ITenantContext _tenantContext; + + // KV-хранилище настроек тенанта (выключатель mlEnabled, счётчики ml/ai). + private readonly ISettingsStore _store; + + // Хранилище обучения ML: очередь MlOutbox + счётчик журнала CardMoves. + private readonly IMlLearningStore _learningStore; + + // Транспорт gRPC ml-service (канал + metadata). + private readonly MlGrpcConnection _connection; + + // Кэш статуса сервиса на тенанта (15 с). + private readonly MlStatusCache _statusCache; + + private readonly TokenUsageRecorder _usageRecorder; + + // Логгер сбоев вызовов ml-service. + private readonly ILogger _logger; + + /// + /// Создаёт gRPC-адаптер клиента ML-сервиса. + /// + /// Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами). + /// KV-хранилище настроек тенанта. + /// Хранилище обучения ML (очередь MlOutbox + журнал). + /// Транспорт ml-service (singleton-канал + service-token). + /// Кэш статуса сервиса на тенанта (singleton). + /// Recorder истории расхода. + /// Логгер сбоев. + public GrpcMlClient( + ITenantContext tenantContext, + ISettingsStore store, + IMlLearningStore learningStore, + MlGrpcConnection connection, + MlStatusCache statusCache, + TokenUsageRecorder usageRecorder, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(tenantContext); + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(learningStore); + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(statusCache); + ArgumentNullException.ThrowIfNull(usageRecorder); + ArgumentNullException.ThrowIfNull(logger); + _tenantContext = tenantContext; + _store = store; + _learningStore = learningStore; + _connection = connection; + _statusCache = statusCache; + _usageRecorder = usageRecorder; + _logger = logger; + } + + /// + public async Task StatusAsync(CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + MlStatusCache.Snapshot snapshot = await GetServiceSnapshotAsync(tenantId, ct); + + bool enabled = await ReadMlEnabledAsync(ct); + int mlDecisions = await ReadCounterAsync(SettingsKeys.MlDecisions, ct); + int aiDecisions = await ReadCounterAsync(SettingsKeys.AiDecisions, ct); + int learning = await _learningStore.CountLearningAsync(ct); + int outbox = await _learningStore.CountOutboxAsync(ct); + + var stats = new MlStatsDto( + Ml: mlDecisions, + Ai: aiDecisions, + Learning: learning, + Ready: snapshot.Service.Ready, + Classes: snapshot.Service.Classes, + Learned: snapshot.Service.Learned, + Reachable: snapshot.Reachable, + Outbox: outbox); + + return new MlStatusResponseDto(Enabled: enabled, Service: snapshot.Service, Reachable: snapshot.Reachable, Stats: stats); + } + + /// + public async Task PredictAsync(string text, CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + MlService.MlServiceClient client = _connection.CreateClient(); + PredictReply reply = await client.PredictAsync( + new PredictRequest { Text = text ?? string.Empty }, + CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), ct)); + + await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct); + return MapPredict(reply); + } + catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException) + { + _logger.LogDebug(exception, "ML predict недоступен (тенант {TenantId})", tenantId.Value); + return NotReadyPrediction; + } + } + + /// + public async Task ResetAsync(CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + ResetReply reply; + try + { + MlService.MlServiceClient client = _connection.CreateClient(); + reply = await client.ResetAsync( + new ResetRequest(), + CallOptions(tenantId.Value, TimeSpan.FromSeconds(StatusDeadlineSeconds), ct)); + } + catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException) + { + _logger.LogWarning(exception, "ML reset не удался (тенант {TenantId})", tenantId.Value); + return new MlResetResultDto(Ok: false, Error: ErrorText(exception)); + } + + if (!reply.Ok) + { + return new MlResetResultDto(Ok: false, Error: reply.HasError ? reply.Error : DefaultResetError); + } + + await _learningStore.ClearOutboxAsync(ct); + _statusCache.Invalidate(tenantId.Value); + return new MlResetResultDto(Ok: true, Error: null); + } + + /// + public async Task PushAsync( + string text, + string label, + double delta, + CancellationToken ct) + { + await MlOutboxQueue.PushAsync(_learningStore, text, label, delta, ct); + } + + /// + public async Task TrainBatchAsync(IReadOnlyList items, CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + var request = new TrainBatchRequest(); + foreach (MlOutboxEntryDto item in items) + { + request.Items.Add(new TrainExample + { + Text = item.Text, + Label = item.Label, + Delta = item.Delta, + }); + } + + MlService.MlServiceClient client = _connection.CreateClient(); + TrainBatchReply reply = await client.TrainBatchAsync( + request, + CallOptions(tenantId.Value, TimeSpan.FromSeconds(TrainBatchDeadlineSeconds), ct)); + return reply.Learned; + } + + // Текущий тенант scope (без него scoped-хранилища/metadata не имеют смысла). + // Возвращает: Идентификатор тенанта. + // Исключение InvalidOperationException: Вызов вне tenant-контекста (конфигурация). + private TenantId RequireTenant() + => _tenantContext.TenantId + ?? throw new InvalidOperationException( + "GrpcMlClient запрошен вне tenant-контекста (ITenantContext.TenantId == null)."); + + // Возвращает статус модели из кэша либо обновляет его вызовом ml-service (кэш 15 с). + // tenantId: Id тенанта (формат N). + // ct: Токен отмены. + // Возвращает: Свежая запись кэша (при сбое сервиса — старые данные + reachable=false). + private async Task GetServiceSnapshotAsync(TenantId tenantId, CancellationToken ct) + { + if (_statusCache.TryGetFresh(tenantId.Value, out MlStatusCache.Snapshot fresh)) + { + return fresh; + } + + _statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot stale); + MlServiceStatusDto previous = stale?.Service ?? NotReadyServiceStatus; + + try + { + MlService.MlServiceClient client = _connection.CreateClient(); + StatusReply reply = await client.StatusAsync( + new StatusRequest(), + CallOptions(tenantId.Value, TimeSpan.FromSeconds(StatusDeadlineSeconds), ct)); + MlServiceStatusDto service = MapStatus(reply); + _statusCache.Set(tenantId.Value, service, reachable: true); + return _statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot updated) + ? updated + : new MlStatusCache.Snapshot(service, Reachable: true, UpdatedAtMs: 0); + } + catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException) + { + _logger.LogDebug(exception, "ML status недоступен (тенант {TenantId})", tenantId.Value); + _statusCache.Set(tenantId.Value, previous, reachable: false); + return new MlStatusCache.Snapshot(previous, false, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + } + } + + private static MlServiceStatusDto MapStatus(StatusReply reply) + { + return new MlServiceStatusDto( + Ready: reply.Ready, + Classes: new Dictionary(reply.Classes), + Learned: reply.Learned, + Eval: new MlEvalDto( + Count: reply.Eval?.Count ?? 0, + Correct: reply.Eval?.Correct ?? 0, + Accuracy: reply.Eval?.Accuracy ?? 0.0)); + } + + private static MlPredictResultDto MapPredict(PredictReply reply) + { + return new MlPredictResultDto( + Take: reply.Take, + Label: reply.HasLabel ? reply.Label : null, + Scores: new Dictionary(reply.Scores), + Hits: reply.Hits, + Ready: reply.Ready, + Margin: reply.HasMargin ? reply.Margin : null, + Terms: reply.Terms.ToList(), + Type: MapTypeDecision(reply.Type)); + } + + // Маппит решение о типе заявки (null — модель тип не определила). + // decision: Ответ ml-service (TypeDecision) или null. + // Возвращает: DTO типа заявки или null. + private static MlTypeDecisionDto? MapTypeDecision(TypeDecision? decision) + { + return decision is null + ? null + : new MlTypeDecisionDto( + Take: decision.Take, + Label: decision.Label, + Value: decision.Value, + Margin: decision.Margin); + } + + private CallOptions CallOptions( + string tenantId, + TimeSpan deadline, + CancellationToken ct) + => new( + headers: _connection.CreateMetadata(tenantId), + deadline: DateTime.UtcNow.Add(deadline), + cancellationToken: ct); + + // Краткий текст ошибки для мягкого {ok:false,error} (секреты/тела ответов не логируются). + // exception: Исключение вызова. + // Возвращает: Текст ошибки. + private static string ErrorText(Exception exception) + => exception is RpcException rpc && rpc.StatusCode == StatusCode.Unavailable + ? "ML-сервис недоступен" + : "ML-сервис не ответил — повторите попытку через несколько секунд"; + + private static MlPredictResultDto NotReadyPrediction => new( + Take: false, + Label: null, + Scores: EmptyScores, + Hits: 0, + Ready: false, + Margin: null, + Terms: Array.Empty(), + Type: null); + + // Статус модели по умолчанию (нет данных кэша и сервис недоступен): «не готова». + private static MlServiceStatusDto NotReadyServiceStatus => new( + Ready: false, + Classes: EmptyScores, + Learned: 0, + Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0)); + + private async Task ReadMlEnabledAsync(CancellationToken ct) + { + SettingValue? row = await _store.GetAsync(SettingsKeys.MlEnabled, ct); + if (row is null) + { + return SettingsDefaults.MlEnabled; + } + + try + { + using JsonDocument document = JsonDocument.Parse(row.ValueJson); + if (document.RootElement.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + return document.RootElement.GetBoolean(); + } + } + catch (JsonException) + { + // Повреждённая строка — дефолт (мягкая семантика, как в SettingsService). + } + + return SettingsDefaults.MlEnabled; + } + + // Читает целочисленный счётчик (mlDecisions/aiDecisions); отсутствие/повреждение → 0. + // key: Внутренний KV-ключ счётчика. + // ct: Токен отмены. + // Возвращает: Значение счётчика из хранилища или 0. + private async Task ReadCounterAsync(string key, CancellationToken ct) + { + SettingValue? row = await _store.GetAsync(key, ct); + if (row is null) + { + return 0; + } + + try + { + using JsonDocument document = JsonDocument.Parse(row.ValueJson); + if (document.RootElement.ValueKind == JsonValueKind.Number + && document.RootElement.TryGetInt64(out long wide)) + { + return (int)Math.Clamp(wide, int.MinValue, int.MaxValue); + } + } + catch (JsonException) + { + // Повреждённая строка — 0 (мягкая семантика, как в SettingsService). + } + + return 0; + } +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/GrpcTelegramClient.cs b/src/core/Deal.Infrastructure/Integrations/Services/GrpcTelegramClient.cs index c3590ba..b2ce470 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/GrpcTelegramClient.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/GrpcTelegramClient.cs @@ -1,472 +1,472 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Telegram; -using Deal.Infrastructure.Integrations.Models; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Grpc.Core; -using Microsoft.Extensions.Logging; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// gRPC-адаптер порта к автономному telegram-service. -/// -public sealed class GrpcTelegramClient : ITelegramGateway -{ - /// - /// Deadline локальных команд статуса/зеркала — 10 с. - /// - public const int ShortDeadlineSeconds = 10; - - /// - /// Deadline сетевых команд Telegram — 60 с. - /// - public const int CommandDeadlineSeconds = 60; - - /// - /// Deadline тяжёлых команд каталога/backfill — 120 с. - /// - public const int LongDeadlineSeconds = 120; - - private const string NotConnectedDetail = "Telegram не подключён"; - - private readonly ITenantContext _tenantContext; - - // Транспорт gRPC telegram-service (канал + metadata). - private readonly TelegramGrpcConnection _connection; - - // Логгер сбоев вызовов telegram-service. - private readonly ILogger _logger; - - /// - /// Создаёт gRPC-адаптер гейта telegram-service. - /// - /// Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами). - /// Транспорт telegram-service (singleton-канал + service-token). - /// Логгер сбоев. - public GrpcTelegramClient( - ITenantContext tenantContext, - TelegramGrpcConnection connection, - ILogger logger) - { - ArgumentNullException.ThrowIfNull(tenantContext); - ArgumentNullException.ThrowIfNull(connection); - ArgumentNullException.ThrowIfNull(logger); - _tenantContext = tenantContext; - _connection = connection; - _logger = logger; - } - - /// - public async Task StatusAsync(CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - GetStatusReply reply = await CallAsync( - tenantId, TimeSpan.FromSeconds(ShortDeadlineSeconds), ct, - (client, options) => client.GetStatusAsync(new GetStatusRequest(), options)); - return new TelegramAccountStatusDto( - Phase: reply.Phase, - Connected: reply.Connected, - Listener: reply.Listener, - Account: reply.Account, - Error: reply.HasError ? reply.Error : null, - QrUrl: reply.HasQrUrl ? reply.QrUrl : null); - } - catch (Exception exception) - { - throw TranslateTransportFailure(exception, tenantId, "status"); - } - } - - /// - public async Task StartPhoneAsync( - string phone, - int apiId, - string apiHash, - CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - StartPhoneReply reply = await CallAsync( - tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, - (client, options) => client.StartPhoneAsync( - new StartPhoneRequest { Phone = phone ?? string.Empty, ApiId = apiId, ApiHash = apiHash ?? string.Empty }, - options)); - return new TelegramAuthResultDto(Phase: reply.Phase, QrUrl: null); - } - catch (Exception exception) - { - throw TranslateTransportFailure(exception, tenantId, "start_phone"); - } - } - - /// - public async Task StartQrAsync( - int apiId, - string apiHash, - CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - StartQrReply reply = await CallAsync( - tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, - (client, options) => client.StartQrAsync( - new StartQrRequest { ApiId = apiId, ApiHash = apiHash ?? string.Empty }, - options)); - return new TelegramAuthResultDto( - Phase: reply.Phase, - QrUrl: string.IsNullOrEmpty(reply.QrUrl) ? null : reply.QrUrl); - } - catch (Exception exception) - { - throw TranslateTransportFailure(exception, tenantId, "start_qr"); - } - } - - /// - public async Task SendCodeAsync(string code, CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - SendCodeReply reply = await CallAsync( - tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, - (client, options) => client.SendCodeAsync( - new SendCodeRequest { Code = code ?? string.Empty }, options)); - return reply.Phase; - } - catch (Exception exception) - { - throw TranslateTransportFailure(exception, tenantId, "send_code"); - } - } - - /// - public async Task SendPasswordAsync(string password, CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - SendPasswordReply reply = await CallAsync( - tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, - (client, options) => client.SendPasswordAsync( - new SendPasswordRequest { Password = password ?? string.Empty }, options)); - return reply.Phase; - } - catch (Exception exception) - { - throw TranslateTransportFailure(exception, tenantId, "send_password"); - } - } - - /// - public async Task LogoutAsync(CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - await CallAsync( - tenantId, TimeSpan.FromSeconds(ShortDeadlineSeconds), ct, - (client, options) => client.LogoutAsync(new LogoutRequest(), options)); - } - catch (Exception exception) - { - throw TranslateTransportFailure(exception, tenantId, "logout"); - } - } - - /// - public async Task> RefreshDialogsAsync(CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - RefreshDialogsReply reply = await CallAsync( - tenantId, TimeSpan.FromSeconds(LongDeadlineSeconds), ct, - (client, options) => client.RefreshDialogsAsync(new RefreshDialogsRequest(), options)); - return MapEntries(reply.Entries); - } - catch (Exception exception) - { - throw TranslateTransportFailure(exception, tenantId, "refresh_dialogs"); - } - } - - /// - public async Task SetMonitorAsync( - string dialogId, - bool enabled, - CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - await CallAsync( - tenantId, TimeSpan.FromSeconds(ShortDeadlineSeconds), ct, - (client, options) => client.SetMonitorAsync( - new SetMonitorRequest { DialogId = dialogId ?? string.Empty, Enabled = enabled }, options)); - } - catch (Exception exception) - { - throw TranslateTransportFailure(exception, tenantId, "set_monitor"); - } - } - - /// - public async Task SetMonitorAllAsync(bool enabled, CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - await CallAsync( - tenantId, TimeSpan.FromSeconds(ShortDeadlineSeconds), ct, - (client, options) => client.SetMonitorAllAsync( - new SetMonitorAllRequest { Enabled = enabled }, options)); - } - catch (Exception exception) - { - throw TranslateTransportFailure(exception, tenantId, "set_monitor_all"); - } - } - - /// - public async Task BackfillAsync( - string dialogId, - bool force, - CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - BackfillReply reply = await CallAsync( - tenantId, TimeSpan.FromSeconds(LongDeadlineSeconds), ct, - (client, options) => client.BackfillAsync( - new BackfillRequest { DialogId = dialogId ?? string.Empty, Force = force }, options)); - return reply.Processed; - } - catch (Exception exception) - { - throw TranslateTransportFailure(exception, tenantId, "backfill"); - } - } - - /// - public async Task> ReadRecentAsync( - string dialogId, - int limit, - CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - ReadRecentReply reply = await CallAsync( - tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, - (client, options) => client.ReadRecentAsync( - new ReadRecentRequest { DialogId = dialogId ?? string.Empty, Limit = limit }, options)); - return reply.Messages - .Select(message => new TelegramRecentMessageDto(message.Id, message.Text, message.Time)) - .ToList(); - } - catch (Exception exception) - { - throw TranslateTransportFailure(exception, tenantId, "read_recent"); - } - } - - /// - public async Task ReadSourceAsync( - string dialogId, - long msgId, - CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - ReadSourceReply reply = await CallAsync( - tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, - (client, options) => client.ReadSourceAsync( - new ReadSourceRequest { DialogId = dialogId ?? string.Empty, MsgId = msgId }, options)); - return new TelegramSourceContentDto( - reply.Found, - reply.HasText ? reply.Text : null, - reply.HasTime ? reply.Time : null); - } - catch (Exception exception) - { - throw TranslateTransportFailure(exception, tenantId, "read_source"); - } - } - - /// - public async Task> SearchAsync( - string query, - int limit, - CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - SearchReply reply = await CallAsync( - tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, - (client, options) => client.SearchAsync( - new SearchRequest { Query = query ?? string.Empty, Limit = limit }, options)); - return MapEntries(reply.Results); - } - catch (Exception exception) - { - throw TranslateTransportFailure(exception, tenantId, "search"); - } - } - - /// - public async Task InfoAsync(string dialogId, CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - GetInfoReply reply = await CallAsync( - tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, - (client, options) => client.GetInfoAsync( - new GetInfoRequest { DialogId = dialogId ?? string.Empty }, options)); - ChannelInfo info = reply.Info; - return new TelegramChannelInfoDto( - Id: info.Id, - Name: info.Name, - Username: info.Username, - Kind: info.Kind, - Hue: info.Hue, - Participants: info.HasParticipants ? info.Participants : null, - IsForum: info.IsForum); - } - catch (Exception exception) - { - throw TranslateTransportFailure(exception, tenantId, "get_info"); - } - } - - /// - public async Task ReadForEvalAsync( - string dialogId, - int limit, - CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - ReadForEvalReply reply = await CallAsync( - tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, - (client, options) => client.ReadForEvalAsync( - new ReadForEvalRequest { DialogId = dialogId ?? string.Empty, Limit = limit }, options)); - return new TelegramEvalReadDto( - Ok: reply.Ok, - Error: reply.HasError ? reply.Error : null, - Messages: reply.Messages - .Select(message => new TelegramEvalMessageDto( - Id: message.Id, - Text: message.Text, - DateMs: message.DateMs, - TopicId: message.HasTopicId ? message.TopicId : null, - TopicTitle: message.HasTopicTitle ? message.TopicTitle : null)) - .ToList()); - } - catch (Exception exception) - { - throw TranslateTransportFailure(exception, tenantId, "read_for_eval"); - } - } - - /// - public async Task JoinAsync(string username, CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - await CallAsync( - tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, - (client, options) => client.JoinAsync( - new JoinRequest { Username = username ?? string.Empty }, options)); - } - catch (Exception exception) - { - throw TranslateTransportFailure(exception, tenantId, "join"); - } - } - - /// - public async Task LeaveAsync(string dialogId, CancellationToken ct) - { - TenantId tenantId = RequireTenant(); - try - { - await CallAsync( - tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, - (client, options) => client.LeaveAsync( - new LeaveRequest { DialogId = dialogId ?? string.Empty }, options)); - } - catch (Exception exception) - { - throw TranslateTransportFailure(exception, tenantId, "leave"); - } - } - - // Текущий тенант scope (без него metadata вызовов не имеет смысла). - // Возвращает: Идентификатор тенанта. - // Исключение InvalidOperationException: Вызов вне tenant-контекста (конфигурация). - private TenantId RequireTenant() - => _tenantContext.TenantId - ?? throw new InvalidOperationException( - "GrpcTelegramClient запрошен вне tenant-контекста (ITenantContext.TenantId == null)."); - - private async Task CallAsync( - TenantId tenantId, - TimeSpan deadline, - CancellationToken ct, - Func> call) - where TReply : class - { - TelegramService.TelegramServiceClient client = _connection.CreateClient(); - var options = new CallOptions( - headers: _connection.CreateMetadata(tenantId.Value), - deadline: DateTime.UtcNow.Add(deadline), - cancellationToken: ct); - return await call(client, options); - } - - private Exception TranslateTransportFailure( - Exception exception, - TenantId tenantId, - string operation) - { - // Отмена по токену вызывающего — не ошибка сервиса (пробрасываем как обычно). - if (exception is OperationCanceledException) - { - return exception; - } - - if (exception is RpcException rpc && - (rpc.StatusCode != StatusCode.Unavailable || !string.IsNullOrEmpty(rpc.Status.Detail))) - { - return rpc; - } - - _logger.LogWarning(exception, "Telegram {Operation} недоступен (тенант {TenantId})", operation, tenantId.Value); - return new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail)); - } - - private static IReadOnlyList MapEntries(Google.Protobuf.Collections.RepeatedField entries) - { - return entries - .Select(entry => new TelegramDialogEntryDto( - Id: entry.Id, - Name: entry.Name, - Handle: entry.Username, - Kind: entry.Kind, - Hue: entry.Hue)) - .ToList(); - } -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Grpc.Telegram; +using Deal.Infrastructure.Integrations.Models; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace Deal.Infrastructure.Integrations.Services; + +/// +/// gRPC-адаптер порта к автономному telegram-service. +/// +public sealed class GrpcTelegramClient : ITelegramGateway +{ + /// + /// Deadline локальных команд статуса/зеркала — 10 с. + /// + public const int ShortDeadlineSeconds = 10; + + /// + /// Deadline сетевых команд Telegram — 60 с. + /// + public const int CommandDeadlineSeconds = 60; + + /// + /// Deadline тяжёлых команд каталога/backfill — 120 с. + /// + public const int LongDeadlineSeconds = 120; + + private const string NotConnectedDetail = "Telegram не подключён"; + + private readonly ITenantContext _tenantContext; + + // Транспорт gRPC telegram-service (канал + metadata). + private readonly TelegramGrpcConnection _connection; + + // Логгер сбоев вызовов telegram-service. + private readonly ILogger _logger; + + /// + /// Создаёт gRPC-адаптер гейта telegram-service. + /// + /// Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами). + /// Транспорт telegram-service (singleton-канал + service-token). + /// Логгер сбоев. + public GrpcTelegramClient( + ITenantContext tenantContext, + TelegramGrpcConnection connection, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(tenantContext); + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(logger); + _tenantContext = tenantContext; + _connection = connection; + _logger = logger; + } + + /// + public async Task StatusAsync(CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + GetStatusReply reply = await CallAsync( + tenantId, TimeSpan.FromSeconds(ShortDeadlineSeconds), ct, + (client, options) => client.GetStatusAsync(new GetStatusRequest(), options)); + return new TelegramAccountStatusDto( + Phase: reply.Phase, + Connected: reply.Connected, + Listener: reply.Listener, + Account: reply.Account, + Error: reply.HasError ? reply.Error : null, + QrUrl: reply.HasQrUrl ? reply.QrUrl : null); + } + catch (Exception exception) + { + throw TranslateTransportFailure(exception, tenantId, "status"); + } + } + + /// + public async Task StartPhoneAsync( + string phone, + int apiId, + string apiHash, + CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + StartPhoneReply reply = await CallAsync( + tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, + (client, options) => client.StartPhoneAsync( + new StartPhoneRequest { Phone = phone ?? string.Empty, ApiId = apiId, ApiHash = apiHash ?? string.Empty }, + options)); + return new TelegramAuthResultDto(Phase: reply.Phase, QrUrl: null); + } + catch (Exception exception) + { + throw TranslateTransportFailure(exception, tenantId, "start_phone"); + } + } + + /// + public async Task StartQrAsync( + int apiId, + string apiHash, + CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + StartQrReply reply = await CallAsync( + tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, + (client, options) => client.StartQrAsync( + new StartQrRequest { ApiId = apiId, ApiHash = apiHash ?? string.Empty }, + options)); + return new TelegramAuthResultDto( + Phase: reply.Phase, + QrUrl: string.IsNullOrEmpty(reply.QrUrl) ? null : reply.QrUrl); + } + catch (Exception exception) + { + throw TranslateTransportFailure(exception, tenantId, "start_qr"); + } + } + + /// + public async Task SendCodeAsync(string code, CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + SendCodeReply reply = await CallAsync( + tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, + (client, options) => client.SendCodeAsync( + new SendCodeRequest { Code = code ?? string.Empty }, options)); + return reply.Phase; + } + catch (Exception exception) + { + throw TranslateTransportFailure(exception, tenantId, "send_code"); + } + } + + /// + public async Task SendPasswordAsync(string password, CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + SendPasswordReply reply = await CallAsync( + tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, + (client, options) => client.SendPasswordAsync( + new SendPasswordRequest { Password = password ?? string.Empty }, options)); + return reply.Phase; + } + catch (Exception exception) + { + throw TranslateTransportFailure(exception, tenantId, "send_password"); + } + } + + /// + public async Task LogoutAsync(CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + await CallAsync( + tenantId, TimeSpan.FromSeconds(ShortDeadlineSeconds), ct, + (client, options) => client.LogoutAsync(new LogoutRequest(), options)); + } + catch (Exception exception) + { + throw TranslateTransportFailure(exception, tenantId, "logout"); + } + } + + /// + public async Task> RefreshDialogsAsync(CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + RefreshDialogsReply reply = await CallAsync( + tenantId, TimeSpan.FromSeconds(LongDeadlineSeconds), ct, + (client, options) => client.RefreshDialogsAsync(new RefreshDialogsRequest(), options)); + return MapEntries(reply.Entries); + } + catch (Exception exception) + { + throw TranslateTransportFailure(exception, tenantId, "refresh_dialogs"); + } + } + + /// + public async Task SetMonitorAsync( + string dialogId, + bool enabled, + CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + await CallAsync( + tenantId, TimeSpan.FromSeconds(ShortDeadlineSeconds), ct, + (client, options) => client.SetMonitorAsync( + new SetMonitorRequest { DialogId = dialogId ?? string.Empty, Enabled = enabled }, options)); + } + catch (Exception exception) + { + throw TranslateTransportFailure(exception, tenantId, "set_monitor"); + } + } + + /// + public async Task SetMonitorAllAsync(bool enabled, CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + await CallAsync( + tenantId, TimeSpan.FromSeconds(ShortDeadlineSeconds), ct, + (client, options) => client.SetMonitorAllAsync( + new SetMonitorAllRequest { Enabled = enabled }, options)); + } + catch (Exception exception) + { + throw TranslateTransportFailure(exception, tenantId, "set_monitor_all"); + } + } + + /// + public async Task BackfillAsync( + string dialogId, + bool force, + CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + BackfillReply reply = await CallAsync( + tenantId, TimeSpan.FromSeconds(LongDeadlineSeconds), ct, + (client, options) => client.BackfillAsync( + new BackfillRequest { DialogId = dialogId ?? string.Empty, Force = force }, options)); + return reply.Processed; + } + catch (Exception exception) + { + throw TranslateTransportFailure(exception, tenantId, "backfill"); + } + } + + /// + public async Task> ReadRecentAsync( + string dialogId, + int limit, + CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + ReadRecentReply reply = await CallAsync( + tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, + (client, options) => client.ReadRecentAsync( + new ReadRecentRequest { DialogId = dialogId ?? string.Empty, Limit = limit }, options)); + return reply.Messages + .Select(message => new TelegramRecentMessageDto(message.Id, message.Text, message.Time)) + .ToList(); + } + catch (Exception exception) + { + throw TranslateTransportFailure(exception, tenantId, "read_recent"); + } + } + + /// + public async Task ReadSourceAsync( + string dialogId, + long msgId, + CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + ReadSourceReply reply = await CallAsync( + tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, + (client, options) => client.ReadSourceAsync( + new ReadSourceRequest { DialogId = dialogId ?? string.Empty, MsgId = msgId }, options)); + return new TelegramSourceContentDto( + reply.Found, + reply.HasText ? reply.Text : null, + reply.HasTime ? reply.Time : null); + } + catch (Exception exception) + { + throw TranslateTransportFailure(exception, tenantId, "read_source"); + } + } + + /// + public async Task> SearchAsync( + string query, + int limit, + CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + SearchReply reply = await CallAsync( + tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, + (client, options) => client.SearchAsync( + new SearchRequest { Query = query ?? string.Empty, Limit = limit }, options)); + return MapEntries(reply.Results); + } + catch (Exception exception) + { + throw TranslateTransportFailure(exception, tenantId, "search"); + } + } + + /// + public async Task InfoAsync(string dialogId, CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + GetInfoReply reply = await CallAsync( + tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, + (client, options) => client.GetInfoAsync( + new GetInfoRequest { DialogId = dialogId ?? string.Empty }, options)); + ChannelInfo info = reply.Info; + return new TelegramChannelInfoDto( + Id: info.Id, + Name: info.Name, + Username: info.Username, + Kind: info.Kind, + Hue: info.Hue, + Participants: info.HasParticipants ? info.Participants : null, + IsForum: info.IsForum); + } + catch (Exception exception) + { + throw TranslateTransportFailure(exception, tenantId, "get_info"); + } + } + + /// + public async Task ReadForEvalAsync( + string dialogId, + int limit, + CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + ReadForEvalReply reply = await CallAsync( + tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, + (client, options) => client.ReadForEvalAsync( + new ReadForEvalRequest { DialogId = dialogId ?? string.Empty, Limit = limit }, options)); + return new TelegramEvalReadDto( + Ok: reply.Ok, + Error: reply.HasError ? reply.Error : null, + Messages: reply.Messages + .Select(message => new TelegramEvalMessageDto( + Id: message.Id, + Text: message.Text, + DateMs: message.DateMs, + TopicId: message.HasTopicId ? message.TopicId : null, + TopicTitle: message.HasTopicTitle ? message.TopicTitle : null)) + .ToList()); + } + catch (Exception exception) + { + throw TranslateTransportFailure(exception, tenantId, "read_for_eval"); + } + } + + /// + public async Task JoinAsync(string username, CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + await CallAsync( + tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, + (client, options) => client.JoinAsync( + new JoinRequest { Username = username ?? string.Empty }, options)); + } + catch (Exception exception) + { + throw TranslateTransportFailure(exception, tenantId, "join"); + } + } + + /// + public async Task LeaveAsync(string dialogId, CancellationToken ct) + { + TenantId tenantId = RequireTenant(); + try + { + await CallAsync( + tenantId, TimeSpan.FromSeconds(CommandDeadlineSeconds), ct, + (client, options) => client.LeaveAsync( + new LeaveRequest { DialogId = dialogId ?? string.Empty }, options)); + } + catch (Exception exception) + { + throw TranslateTransportFailure(exception, tenantId, "leave"); + } + } + + // Текущий тенант scope (без него metadata вызовов не имеет смысла). + // Возвращает: Идентификатор тенанта. + // Исключение InvalidOperationException: Вызов вне tenant-контекста (конфигурация). + private TenantId RequireTenant() + => _tenantContext.TenantId + ?? throw new InvalidOperationException( + "GrpcTelegramClient запрошен вне tenant-контекста (ITenantContext.TenantId == null)."); + + private async Task CallAsync( + TenantId tenantId, + TimeSpan deadline, + CancellationToken ct, + Func> call) + where TReply : class + { + TelegramService.TelegramServiceClient client = _connection.CreateClient(); + var options = new CallOptions( + headers: _connection.CreateMetadata(tenantId.Value), + deadline: DateTime.UtcNow.Add(deadline), + cancellationToken: ct); + return await call(client, options); + } + + private Exception TranslateTransportFailure( + Exception exception, + TenantId tenantId, + string operation) + { + // Отмена по токену вызывающего — не ошибка сервиса (пробрасываем как обычно). + if (exception is OperationCanceledException) + { + return exception; + } + + if (exception is RpcException rpc && + (rpc.StatusCode != StatusCode.Unavailable || !string.IsNullOrEmpty(rpc.Status.Detail))) + { + return rpc; + } + + _logger.LogWarning(exception, "Telegram {Operation} недоступен (тенант {TenantId})", operation, tenantId.Value); + return new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail)); + } + + private static IReadOnlyList MapEntries(Google.Protobuf.Collections.RepeatedField entries) + { + return entries + .Select(entry => new TelegramDialogEntryDto( + Id: entry.Id, + Name: entry.Name, + Handle: entry.Username, + Kind: entry.Kind, + Hue: entry.Hue)) + .ToList(); + } +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/LocalAiClassifier.cs b/src/core/Deal.Infrastructure/Integrations/Services/LocalAiClassifier.cs index 09bf317..fb9807f 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/LocalAiClassifier.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/LocalAiClassifier.cs @@ -1,27 +1,27 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Pipeline.Application.Services; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// Локальная реализация без внешнего ИИ-сервиса. -/// -/// Локальный структуратор модуля Pipeline (маркеры hireMarkers/levelTerms — из настроек). -public sealed class LocalAiClassifier(LocalFieldsParser fieldsParser) : IAiClassifier -{ - /// - public Task FilterAsync(string text, CancellationToken ct) - { - return Task.FromResult(new AiFilterResultDto(Pass: true, Reason: null, Skipped: true)); - } - - /// - public async Task ClassifyAsync(string text, CancellationToken ct) - { - LocalParsedFields fields = await fieldsParser.ParseAsync(text, ct); - return AiCardMapper.FromLocal(fields, text); - } -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Pipeline.Application.Services; + +namespace Deal.Infrastructure.Integrations.Services; + +/// +/// Локальная реализация без внешнего ИИ-сервиса. +/// +/// Локальный структуратор модуля Pipeline (маркеры hireMarkers/levelTerms — из настроек). +public sealed class LocalAiClassifier(LocalFieldsParser fieldsParser) : IAiClassifier +{ + /// + public Task FilterAsync(string text, CancellationToken ct) + { + return Task.FromResult(new AiFilterResultDto(Pass: true, Reason: null, Skipped: true)); + } + + /// + public async Task ClassifyAsync(string text, CancellationToken ct) + { + LocalParsedFields fields = await fieldsParser.ParseAsync(text, ct); + return AiCardMapper.FromLocal(fields, text); + } +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/LocalColumnSuggester.cs b/src/core/Deal.Infrastructure/Integrations/Services/LocalColumnSuggester.cs index b7c2d8c..d1ad249 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/LocalColumnSuggester.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/LocalColumnSuggester.cs @@ -1,202 +1,202 @@ -using System.Globalization; -using System.Text.Json; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён (см. CardsService) — -// внутри Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство. -using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика. -/// -/// Порт хранилища (карточки «Неразобранного», переносы в колонки-доски). -/// KV-хранилище настроек тенанта. -/// Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами. -public sealed class LocalColumnSuggester( -ICardStore store, -ISettingsStore settings, -ContainersService containersService) : IColumnSuggester -{ - - private const long CooldownSeconds = 20 * 60; - - - private const string CooldownReason = "недавно предлагали — подождите"; - - private const string TooFewCardsReasonFormat = "мало карточек в «Неразобранном» (нужно от {0})"; - - private const string NothingGroupedReason = "похожие колонки уже есть или нечего сгруппировать"; - - private const string KeywordsTooFewReason = "мало карточек — сначала накопите заявки (нужно хотя бы 3)"; - - private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз"; - - private const string RulesModeAny = "any"; - - /// - public async Task SuggestColumnsAsync(CancellationToken ct) - { - if (await WithinCooldownAsync(ct)) - { - return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: CooldownReason, Cooldown: true); - } - - IReadOnlyList inbox = await store.ListInboxWithSourceAsync(ct); - if (inbox.Count < SuggestHeuristics.MinInbox) - { - return new SuggestColumnsResultDto( - Ok: false, - Created: 0, - Reason: string.Format(TooFewCardsReasonFormat, SuggestHeuristics.MinInbox), - Cooldown: false); - } - - IReadOnlyList containers = await containersService.ListAsync(ContainerSpaces.Dashboard, ct); - IReadOnlyList existingNames = containers - .Where(container => !container.Suggested) - .Select(container => container.Name) - .ToList(); - - IReadOnlyList plans = SuggestHeuristics.PlanColumns(inbox, existingNames); - if (plans.Count == 0) - { - return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false); - } - - int created = await StoreSuggestedColumnsAsync(inbox, plans, ct); - if (created == 0) - { - return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false); - } - - await WriteLastSuggestAtAsync(ct); - return new SuggestColumnsResultDto(Ok: true, Created: created, Reason: null, Cooldown: false); - } - - /// - public async Task SuggestKeywordsAsync(CancellationToken ct) - { - IReadOnlyList cards = await store.ListCardsAsync(new CardsQuery(null), ct); - List texts = cards - .Where(card => card.Col != CardIds.Trash - && card.Col != CardIds.Archive - && (card.Content.Text ?? string.Empty).Trim().Length > 0) - .Take(SuggestHeuristics.KeywordsSampleLimit) - .Select(card => (card.Content.Text ?? string.Empty).Trim()) - .ToList(); - if (texts.Count < SuggestHeuristics.MinKeywordsSample) - { - return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsTooFewReason); - } - - IReadOnlyList keywords = SuggestHeuristics.SuggestDomainKeywords(texts); - if (keywords.Count == 0) - { - return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsEmptyReason); - } - - return new SuggestKeywordsResultDto(Ok: true, Keywords: keywords, Reason: null); - } - - private async Task StoreSuggestedColumnsAsync( - IReadOnlyList inbox, - IReadOnlyList plans, - CancellationToken ct) - { - HashSet inboxIds = (await store.ListInboxWithSourceAsync(ct)) - .Select(card => card.Id) - .ToHashSet(StringComparer.Ordinal); - Dictionary textByCardId = inbox - .ToDictionary(card => card.Id, card => card.Content.Text ?? string.Empty, StringComparer.Ordinal); - - int created = 0; - foreach (SuggestedColumnPlan plan in plans) - { - var rules = new ContainerRulesDto( - Mode: RulesModeAny, - Direction: Array.Empty(), - Keywords: [plan.Word], - Stack: Array.Empty(), - Grade: Array.Empty(), - Exclude: Array.Empty(), - Budget: null); - ContainerDto container = await containersService.CreateAsync(new ContainerCreateDto( - Name: plan.Name, - Description: string.Empty, - Color: null, - Space: ContainerSpaces.Dashboard, - Kind: ContainerKinds.Board, - Suggested: true, - Rules: rules, - Note: plan.Note), ct); - - int placed = 0; - foreach (string cardId in plan.CardIds) - { - if (!inboxIds.Contains(cardId)) - { - continue; // карточка уже разобрана другим предложением/пользователем (L231–233) - } - - IReadOnlyList hits = KanbanColumnRules.ComputeHits(rules, textByCardId[cardId]); - await store.UpdateColumnAsync(new CardColumnUpdateDto( - CardId: cardId, - Col: container.Id, - IsNew: true, - PrevCol: CardIds.Inbox, - ArchivedAt: null, - MatchHits: hits), ct); - placed++; - } - - if (placed == 0) - { - await containersService.DeleteAsync(container.Id, ct); - continue; - } - - created++; - } - - return created; - } - - private async Task WithinCooldownAsync(CancellationToken ct) - { - SettingValue? row = await settings.GetAsync(SettingsKeys.LastSuggestAt, ct); - if (row is null) - { - return false; - } - - try - { - using JsonDocument document = JsonDocument.Parse(row.ValueJson); - if (document.RootElement.ValueKind != JsonValueKind.Number) - { - return false; - } - - long lastSuggestAt = document.RootElement.GetInt64(); - return DateTimeOffset.UtcNow.ToUnixTimeSeconds() - lastSuggestAt < CooldownSeconds; - } - catch (JsonException) - { - return false; // повреждённое значение не должно блокировать предложения - } - } - - private Task WriteLastSuggestAtAsync(CancellationToken ct) => - settings.SetAsync( - SettingsKeys.LastSuggestAt, - DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), - ct); -} +using System.Globalization; +using System.Text.Json; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён (см. CardsService) — +// внутри Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство. +using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules; + +namespace Deal.Infrastructure.Integrations.Services; + +/// +/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика. +/// +/// Порт хранилища (карточки «Неразобранного», переносы в колонки-доски). +/// KV-хранилище настроек тенанта. +/// Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами. +public sealed class LocalColumnSuggester( +ICardStore store, +ISettingsStore settings, +ContainersService containersService) : IColumnSuggester +{ + + private const long CooldownSeconds = 20 * 60; + + + private const string CooldownReason = "недавно предлагали — подождите"; + + private const string TooFewCardsReasonFormat = "мало карточек в «Неразобранном» (нужно от {0})"; + + private const string NothingGroupedReason = "похожие колонки уже есть или нечего сгруппировать"; + + private const string KeywordsTooFewReason = "мало карточек — сначала накопите заявки (нужно хотя бы 3)"; + + private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз"; + + private const string RulesModeAny = "any"; + + /// + public async Task SuggestColumnsAsync(CancellationToken ct) + { + if (await WithinCooldownAsync(ct)) + { + return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: CooldownReason, Cooldown: true); + } + + IReadOnlyList inbox = await store.ListInboxWithSourceAsync(ct); + if (inbox.Count < SuggestHeuristics.MinInbox) + { + return new SuggestColumnsResultDto( + Ok: false, + Created: 0, + Reason: string.Format(TooFewCardsReasonFormat, SuggestHeuristics.MinInbox), + Cooldown: false); + } + + IReadOnlyList containers = await containersService.ListAsync(ContainerSpaces.Dashboard, ct); + IReadOnlyList existingNames = containers + .Where(container => !container.Suggested) + .Select(container => container.Name) + .ToList(); + + IReadOnlyList plans = SuggestHeuristics.PlanColumns(inbox, existingNames); + if (plans.Count == 0) + { + return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false); + } + + int created = await StoreSuggestedColumnsAsync(inbox, plans, ct); + if (created == 0) + { + return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false); + } + + await WriteLastSuggestAtAsync(ct); + return new SuggestColumnsResultDto(Ok: true, Created: created, Reason: null, Cooldown: false); + } + + /// + public async Task SuggestKeywordsAsync(CancellationToken ct) + { + IReadOnlyList cards = await store.ListCardsAsync(new CardsQuery(null), ct); + List texts = cards + .Where(card => card.Col != CardIds.Trash + && card.Col != CardIds.Archive + && (card.Content.Text ?? string.Empty).Trim().Length > 0) + .Take(SuggestHeuristics.KeywordsSampleLimit) + .Select(card => (card.Content.Text ?? string.Empty).Trim()) + .ToList(); + if (texts.Count < SuggestHeuristics.MinKeywordsSample) + { + return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsTooFewReason); + } + + IReadOnlyList keywords = SuggestHeuristics.SuggestDomainKeywords(texts); + if (keywords.Count == 0) + { + return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsEmptyReason); + } + + return new SuggestKeywordsResultDto(Ok: true, Keywords: keywords, Reason: null); + } + + private async Task StoreSuggestedColumnsAsync( + IReadOnlyList inbox, + IReadOnlyList plans, + CancellationToken ct) + { + HashSet inboxIds = (await store.ListInboxWithSourceAsync(ct)) + .Select(card => card.Id) + .ToHashSet(StringComparer.Ordinal); + Dictionary textByCardId = inbox + .ToDictionary(card => card.Id, card => card.Content.Text ?? string.Empty, StringComparer.Ordinal); + + int created = 0; + foreach (SuggestedColumnPlan plan in plans) + { + var rules = new ContainerRulesDto( + Mode: RulesModeAny, + Direction: Array.Empty(), + Keywords: [plan.Word], + Stack: Array.Empty(), + Grade: Array.Empty(), + Exclude: Array.Empty(), + Budget: null); + ContainerDto container = await containersService.CreateAsync(new ContainerCreateDto( + Name: plan.Name, + Description: string.Empty, + Color: null, + Space: ContainerSpaces.Dashboard, + Kind: ContainerKinds.Board, + Suggested: true, + Rules: rules, + Note: plan.Note), ct); + + int placed = 0; + foreach (string cardId in plan.CardIds) + { + if (!inboxIds.Contains(cardId)) + { + continue; // карточка уже разобрана другим предложением/пользователем (L231–233) + } + + IReadOnlyList hits = KanbanColumnRules.ComputeHits(rules, textByCardId[cardId]); + await store.UpdateColumnAsync(new CardColumnUpdateDto( + CardId: cardId, + Col: container.Id, + IsNew: true, + PrevCol: CardIds.Inbox, + ArchivedAt: null, + MatchHits: hits), ct); + placed++; + } + + if (placed == 0) + { + await containersService.DeleteAsync(container.Id, ct); + continue; + } + + created++; + } + + return created; + } + + private async Task WithinCooldownAsync(CancellationToken ct) + { + SettingValue? row = await settings.GetAsync(SettingsKeys.LastSuggestAt, ct); + if (row is null) + { + return false; + } + + try + { + using JsonDocument document = JsonDocument.Parse(row.ValueJson); + if (document.RootElement.ValueKind != JsonValueKind.Number) + { + return false; + } + + long lastSuggestAt = document.RootElement.GetInt64(); + return DateTimeOffset.UtcNow.ToUnixTimeSeconds() - lastSuggestAt < CooldownSeconds; + } + catch (JsonException) + { + return false; // повреждённое значение не должно блокировать предложения + } + } + + private Task WriteLastSuggestAtAsync(CancellationToken ct) => + settings.SetAsync( + SettingsKeys.LastSuggestAt, + DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), + ct); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/LocalMlClient.cs b/src/core/Deal.Infrastructure/Integrations/Services/LocalMlClient.cs index cd0ba7d..4f92346 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/LocalMlClient.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/LocalMlClient.cs @@ -1,133 +1,133 @@ -using System.Text.Json; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// Локальная реализация без внешнего ML-сервиса. -/// -/// KV-хранилище настроек тенанта (таблица settings). -/// Хранилище обучения ML: очередь MlOutbox + счётчик журнала CardMoves. -public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learningStore) : IMlClient -{ - private static readonly IReadOnlyDictionary EmptyClasses = new Dictionary(); - - private static readonly IReadOnlyDictionary EmptyScores = new Dictionary(); - - /// - public async Task StatusAsync(CancellationToken ct) - { - var service = new MlServiceStatusDto( - Ready: false, - Classes: EmptyClasses, - Learned: 0, - Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0)); - - bool enabled = await ReadMlEnabledAsync(ct); - int mlDecisions = await ReadCounterAsync(SettingsKeys.MlDecisions, ct); - int aiDecisions = await ReadCounterAsync(SettingsKeys.AiDecisions, ct); - - int learning = await learningStore.CountLearningAsync(ct); - int outbox = await learningStore.CountOutboxAsync(ct); - - var stats = new MlStatsDto( - Ml: mlDecisions, - Ai: aiDecisions, - Learning: learning, - Ready: service.Ready, - Classes: service.Classes, - Learned: service.Learned, - Reachable: true, - Outbox: outbox); - - return new MlStatusResponseDto(Enabled: enabled, Service: service, Reachable: true, Stats: stats); - } - - /// - public Task PredictAsync(string text, CancellationToken ct) - { - return Task.FromResult(new MlPredictResultDto( - Take: false, - Label: null, - Scores: EmptyScores, - Hits: 0, - Ready: false, - Margin: null, - Terms: Array.Empty(), - Type: null)); - } - - /// - public async Task ResetAsync(CancellationToken ct) - { - await learningStore.ClearOutboxAsync(ct); - return new MlResetResultDto(Ok: true, Error: null); - } - - /// - public async Task PushAsync( - string text, - string label, - double delta, - CancellationToken ct) - { - await MlOutboxQueue.PushAsync(learningStore, text, label, delta, ct); - } - - private async Task ReadMlEnabledAsync(CancellationToken ct) - { - SettingValue? row = await store.GetAsync(SettingsKeys.MlEnabled, ct); - if (row is null) - { - return SettingsDefaults.MlEnabled; - } - - try - { - using JsonDocument document = JsonDocument.Parse(row.ValueJson); - if (document.RootElement.ValueKind is JsonValueKind.True or JsonValueKind.False) - { - return document.RootElement.GetBoolean(); - } - } - catch (JsonException) - { - // Повреждённая строка — дефолт (мягкая семантика, как в SettingsService). - } - - return SettingsDefaults.MlEnabled; - } - - // Читает целочисленный счётчик (mlDecisions/aiDecisions); отсутствие/повреждение → 0. - // key: Внутренний KV-ключ счётчика. - // ct: Токен отмены. - // Возвращает: Значение счётчика из хранилища или 0. - private async Task ReadCounterAsync(string key, CancellationToken ct) - { - SettingValue? row = await store.GetAsync(key, ct); - if (row is null) - { - return 0; - } - - try - { - using JsonDocument document = JsonDocument.Parse(row.ValueJson); - if (document.RootElement.ValueKind == JsonValueKind.Number - && document.RootElement.TryGetInt64(out long wide)) - { - return (int)Math.Clamp(wide, int.MinValue, int.MaxValue); - } - } - catch (JsonException) - { - // Повреждённая строка — 0 (мягкая семантика, как в SettingsService). - } - - return 0; - } -} +using System.Text.Json; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Infrastructure.Integrations.Services; + +/// +/// Локальная реализация без внешнего ML-сервиса. +/// +/// KV-хранилище настроек тенанта (таблица settings). +/// Хранилище обучения ML: очередь MlOutbox + счётчик журнала CardMoves. +public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learningStore) : IMlClient +{ + private static readonly IReadOnlyDictionary EmptyClasses = new Dictionary(); + + private static readonly IReadOnlyDictionary EmptyScores = new Dictionary(); + + /// + public async Task StatusAsync(CancellationToken ct) + { + var service = new MlServiceStatusDto( + Ready: false, + Classes: EmptyClasses, + Learned: 0, + Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0)); + + bool enabled = await ReadMlEnabledAsync(ct); + int mlDecisions = await ReadCounterAsync(SettingsKeys.MlDecisions, ct); + int aiDecisions = await ReadCounterAsync(SettingsKeys.AiDecisions, ct); + + int learning = await learningStore.CountLearningAsync(ct); + int outbox = await learningStore.CountOutboxAsync(ct); + + var stats = new MlStatsDto( + Ml: mlDecisions, + Ai: aiDecisions, + Learning: learning, + Ready: service.Ready, + Classes: service.Classes, + Learned: service.Learned, + Reachable: true, + Outbox: outbox); + + return new MlStatusResponseDto(Enabled: enabled, Service: service, Reachable: true, Stats: stats); + } + + /// + public Task PredictAsync(string text, CancellationToken ct) + { + return Task.FromResult(new MlPredictResultDto( + Take: false, + Label: null, + Scores: EmptyScores, + Hits: 0, + Ready: false, + Margin: null, + Terms: Array.Empty(), + Type: null)); + } + + /// + public async Task ResetAsync(CancellationToken ct) + { + await learningStore.ClearOutboxAsync(ct); + return new MlResetResultDto(Ok: true, Error: null); + } + + /// + public async Task PushAsync( + string text, + string label, + double delta, + CancellationToken ct) + { + await MlOutboxQueue.PushAsync(learningStore, text, label, delta, ct); + } + + private async Task ReadMlEnabledAsync(CancellationToken ct) + { + SettingValue? row = await store.GetAsync(SettingsKeys.MlEnabled, ct); + if (row is null) + { + return SettingsDefaults.MlEnabled; + } + + try + { + using JsonDocument document = JsonDocument.Parse(row.ValueJson); + if (document.RootElement.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + return document.RootElement.GetBoolean(); + } + } + catch (JsonException) + { + // Повреждённая строка — дефолт (мягкая семантика, как в SettingsService). + } + + return SettingsDefaults.MlEnabled; + } + + // Читает целочисленный счётчик (mlDecisions/aiDecisions); отсутствие/повреждение → 0. + // key: Внутренний KV-ключ счётчика. + // ct: Токен отмены. + // Возвращает: Значение счётчика из хранилища или 0. + private async Task ReadCounterAsync(string key, CancellationToken ct) + { + SettingValue? row = await store.GetAsync(key, ct); + if (row is null) + { + return 0; + } + + try + { + using JsonDocument document = JsonDocument.Parse(row.ValueJson); + if (document.RootElement.ValueKind == JsonValueKind.Number + && document.RootElement.TryGetInt64(out long wide)) + { + return (int)Math.Clamp(wide, int.MinValue, int.MaxValue); + } + } + catch (JsonException) + { + // Повреждённая строка — 0 (мягкая семантика, как в SettingsService). + } + + return 0; + } +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/MlOutboxQueue.cs b/src/core/Deal.Infrastructure/Integrations/Services/MlOutboxQueue.cs index 0369c00..19f1167 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/MlOutboxQueue.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/MlOutboxQueue.cs @@ -1,56 +1,56 @@ -using System.Security.Cryptography; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Infrastructure.Integrations.Services; - -internal static class MlOutboxQueue -{ - internal const int MaxLearningTextLength = 6000; - - private const int OutboxIdRandomBytes = 6; - - /// - /// Пишет строку очереди обучения - /// - /// Хранилище обучения (таблица MlOutbox схемы тенанта). - /// Текст обучающего примера (source_msg карточки или title). - /// Метка: id доски (b_...), spam либо t:hire|t:order. - /// Вес сигнала (1.0 — учить, −1.0 — снять метку). - /// Задача завершается после записи строки (отправку делает фоновый флашер). - public static async Task PushAsync( - IMlLearningStore learningStore, - string text, - string label, - double delta, - CancellationToken ct) - { - string trimmedText = (text ?? string.Empty).Trim(); - string trimmedLabel = (label ?? string.Empty).Trim(); - if (trimmedText.Length == 0 || trimmedLabel.Length == 0) - { - return; - } - - await learningStore.AddOutboxAsync( - NewOutboxId(), - TruncateText(trimmedText), - trimmedLabel, - delta, - ct); - } - - private static string NewOutboxId() - => KanbanIdPrefixes.MlOutbox + Convert.ToHexString(RandomNumberGenerator.GetBytes(OutboxIdRandomBytes)).ToLowerInvariant(); - - private static string TruncateText(string text) - { - if (text.Length <= MaxLearningTextLength) - { - return text; - } - - string cut = text[..MaxLearningTextLength]; - return char.IsHighSurrogate(cut[^1]) ? cut[..^1] : cut; - } -} +using System.Security.Cryptography; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Infrastructure.Integrations.Services; + +internal static class MlOutboxQueue +{ + internal const int MaxLearningTextLength = 6000; + + private const int OutboxIdRandomBytes = 6; + + /// + /// Пишет строку очереди обучения + /// + /// Хранилище обучения (таблица MlOutbox схемы тенанта). + /// Текст обучающего примера (source_msg карточки или title). + /// Метка: id доски (b_...), spam либо t:hire|t:order. + /// Вес сигнала (1.0 — учить, −1.0 — снять метку). + /// Задача завершается после записи строки (отправку делает фоновый флашер). + public static async Task PushAsync( + IMlLearningStore learningStore, + string text, + string label, + double delta, + CancellationToken ct) + { + string trimmedText = (text ?? string.Empty).Trim(); + string trimmedLabel = (label ?? string.Empty).Trim(); + if (trimmedText.Length == 0 || trimmedLabel.Length == 0) + { + return; + } + + await learningStore.AddOutboxAsync( + NewOutboxId(), + TruncateText(trimmedText), + trimmedLabel, + delta, + ct); + } + + private static string NewOutboxId() + => KanbanIdPrefixes.MlOutbox + Convert.ToHexString(RandomNumberGenerator.GetBytes(OutboxIdRandomBytes)).ToLowerInvariant(); + + private static string TruncateText(string text) + { + if (text.Length <= MaxLearningTextLength) + { + return text; + } + + string cut = text[..MaxLearningTextLength]; + return char.IsHighSurrogate(cut[^1]) ? cut[..^1] : cut; + } +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/ServiceHealthProbe.cs b/src/core/Deal.Infrastructure/Integrations/Services/ServiceHealthProbe.cs index 4b2eb94..af277a3 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/ServiceHealthProbe.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/ServiceHealthProbe.cs @@ -1,67 +1,67 @@ -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Grpc.Core; -using Grpc.Health.V1; -using Grpc.Net.Client; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// Health-проба grpc.health.v1 автономных сервисов -/// -public sealed class ServiceHealthProbe -{ - /// - /// Дедлайн health-RPC, секунд. - /// - public const int HealthTimeoutSeconds = 3; - - private readonly MtlsCertificates? _mtlsCertificates; - - /// - /// Создаёт пробу; mTLS-каналы — при переданных сертификатах - /// - /// Сертификаты mTLS: null — plaintext-канал. - public ServiceHealthProbe(MtlsCertificates? mtlsCertificates = null) - { - _mtlsCertificates = mtlsCertificates; - } - - /// - /// Проверяет health-контракт gRPC-сервиса по базовому адресу - /// - /// Базовый адрес сервиса (http://host:port; пустой/пробельный — ошибка аргумента). - /// Результат пробы (см. ). - public async Task ProbeAsync(string endpoint, CancellationToken ct) - { - ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); - using SocketsHttpHandler? handler = _mtlsCertificates?.CreateClientHttpHandler(); - GrpcChannelOptions channelOptions = new() { MaxRetryAttempts = 0, HttpHandler = handler }; - using var channel = GrpcChannel.ForAddress(endpoint, channelOptions); - var client = new Health.HealthClient(channel); - try - { - HealthCheckResponse response = await client.CheckAsync( - new HealthCheckRequest { Service = string.Empty }, - deadline: DateTime.UtcNow.AddSeconds(HealthTimeoutSeconds), - cancellationToken: ct); - return new ServiceHealthResult( - Reachable: true, - Serving: response.Status == HealthCheckResponse.Types.ServingStatus.Serving); - } - catch (RpcException exception) when (exception.IsCommunicationFailure()) - { - return ServiceHealthResult.Unreachable; - } - catch (HttpRequestException) - { - // Ошибка транспорта HTTP/2 (DNS/соединение) — до gRPC-статуса не дошло. - return ServiceHealthResult.Unreachable; - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - // Сработал дедлайн пробы (отмена вызывающего выше пробросилась бы дальше) — сервис не ответил за 3 с. - return ServiceHealthResult.Unreachable; - } - } -} +using Deal.Infrastructure.Integrations.Extensions; +using Deal.Infrastructure.Integrations.Models; +using Grpc.Core; +using Grpc.Health.V1; +using Grpc.Net.Client; + +namespace Deal.Infrastructure.Integrations.Services; + +/// +/// Health-проба grpc.health.v1 автономных сервисов +/// +public sealed class ServiceHealthProbe +{ + /// + /// Дедлайн health-RPC, секунд. + /// + public const int HealthTimeoutSeconds = 3; + + private readonly MtlsCertificates? _mtlsCertificates; + + /// + /// Создаёт пробу; mTLS-каналы — при переданных сертификатах + /// + /// Сертификаты mTLS: null — plaintext-канал. + public ServiceHealthProbe(MtlsCertificates? mtlsCertificates = null) + { + _mtlsCertificates = mtlsCertificates; + } + + /// + /// Проверяет health-контракт gRPC-сервиса по базовому адресу + /// + /// Базовый адрес сервиса (http://host:port; пустой/пробельный — ошибка аргумента). + /// Результат пробы (см. ). + public async Task ProbeAsync(string endpoint, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + using SocketsHttpHandler? handler = _mtlsCertificates?.CreateClientHttpHandler(); + GrpcChannelOptions channelOptions = new() { MaxRetryAttempts = 0, HttpHandler = handler }; + using var channel = GrpcChannel.ForAddress(endpoint, channelOptions); + var client = new Health.HealthClient(channel); + try + { + HealthCheckResponse response = await client.CheckAsync( + new HealthCheckRequest { Service = string.Empty }, + deadline: DateTime.UtcNow.AddSeconds(HealthTimeoutSeconds), + cancellationToken: ct); + return new ServiceHealthResult( + Reachable: true, + Serving: response.Status == HealthCheckResponse.Types.ServingStatus.Serving); + } + catch (RpcException exception) when (exception.IsCommunicationFailure()) + { + return ServiceHealthResult.Unreachable; + } + catch (HttpRequestException) + { + // Ошибка транспорта HTTP/2 (DNS/соединение) — до gRPC-статуса не дошло. + return ServiceHealthResult.Unreachable; + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + // Сработал дедлайн пробы (отмена вызывающего выше пробросилась бы дальше) — сервис не ответил за 3 с. + return ServiceHealthResult.Unreachable; + } + } +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/TokenUsageRecorder.cs b/src/core/Deal.Infrastructure/Integrations/Services/TokenUsageRecorder.cs index 9defcba..8d12123 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/TokenUsageRecorder.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/TokenUsageRecorder.cs @@ -1,228 +1,228 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using Deal.Grpc.Ai; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Observability; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// Recorder расхода токенов -/// -public sealed class TokenUsageRecorder -{ - private const string PromptField = "prompt"; - - private const string CompletionField = "completion"; - - private const string TotalField = "total"; - - private const int CharsPerToken = 4; - - private readonly ISettingsStore _store; - private readonly ITenantLimitStore _tenantLimits; - private readonly ITenantContext _tenantContext; - private readonly TokenUsageEventService _events; - - /// - /// Создаёт recorder расхода токенов. - /// - /// KV-хранилище настроек тенанта (ключ aiTokenUsage, lifetime-счётчик). - /// Хранилище лимитов бюджета (public.tenant_limits, списание периода). - /// Контекст текущего тенанта (AsyncLocal; tenantId списания/события). - /// Сервис истории расхода. - public TokenUsageRecorder( - ISettingsStore store, - ITenantLimitStore tenantLimits, - ITenantContext tenantContext, - TokenUsageEventService events) - { - ArgumentNullException.ThrowIfNull(store); - ArgumentNullException.ThrowIfNull(tenantLimits); - ArgumentNullException.ThrowIfNull(tenantContext); - ArgumentNullException.ThrowIfNull(events); - _store = store; - _tenantLimits = tenantLimits; - _tenantContext = tenantContext; - _events = events; - } - - /// - /// Списывает usage ответа ai-service - /// - /// Оценка токенов ответа (Usage ai.proto; reply без usage — нули; null — no-op). - /// Id активного провайдера (deepseek/openai/anthropic/…; событие истории). - /// Модель провайдера (событие истории). - public async Task AddAsync( - Usage? usage, - string provider, - string model, - CancellationToken ct) - { - if (usage is null) - { - return; - } - - if (usage.Total > 0) - { - await _tenantLimits.AddUsageAsync(RequireTenantId(), usage.Total, ct); - } - - await AddToLifetimeAsync(usage, ct); - DealMetrics.RecordAiUsage(usage.Prompt, usage.Completion); - await RecordEventAsync( - provider, - model, - TokenUsageEventKinds.Ai, - promptTokens: usage.Prompt, - completionTokens: usage.Completion, - totalTokens: usage.Total, - ct); - } - - /// - /// Записывает событие локального ML-вызова - /// - /// Входной текст предсказания (оценка токенов запроса; null — 0). - /// Провайдер/источник события (для локальной ML-модели — "local"). - /// Модель/вид локального ML-вызова (событие истории). - /// Оценка токенов (для тестов/наблюдаемости). - public async Task AddEstimatedAsync( - string? text, - string provider, - string model, - CancellationToken ct) - { - long promptTokens = EstimateTokens(text); - DealMetrics.RecordMlUsage(promptTokens); - await RecordEventAsync( - provider, - model, - TokenUsageEventKinds.Ml, - promptTokens: promptTokens, - completionTokens: 0, - totalTokens: promptTokens, - ct); - return promptTokens; - } - - /// - /// Оценка токенов по символам. - /// - /// Текст (null/пустой — 0). - /// Оценка токенов (неотрицательная). - public static long EstimateTokens(string? text) => - string.IsNullOrEmpty(text) ? 0 : text.Length / CharsPerToken; - - // Пишет событие истории расхода токенов (public.token_usage_events, tenant-id текущего scope). - // provider: Провайдер/источник. - // model: Модель. - // kind: Вид вызова ai|ml. - // promptTokens: Токены запроса. - // completionTokens: Токены ответа. - // totalTokens: Всего токенов. - // ct: Токен отмены. - private async Task RecordEventAsync( - string provider, - string model, - string kind, - long promptTokens, - long completionTokens, - long totalTokens, - CancellationToken ct) - { - // Секретов в DetailJson нет: событие хранит только провайдера/модель/вид/токены. - await _events.AppendAsync( - new TokenUsageEventDto( - TenantId: RequireTenantId(), - At: default, - Provider: provider, - Model: model, - Kind: kind, - PromptTokens: promptTokens, - CompletionTokens: completionTokens, - TotalTokens: totalTokens, - DetailJson: null), - ct); - } - - // Текущий тенант scope как Guid строки public.tenants (без него списание не имеет смысла). - // Возвращает: Идентификатор тенанта (Guid). - // Исключение InvalidOperationException: Вызов вне tenant-контекста или не-Guid формат id. - private Guid RequireTenantId() - { - TenantId? tenantId = _tenantContext.TenantId; - if (tenantId is null || !Guid.TryParse(tenantId.Value.Value, out Guid id)) - { - throw new InvalidOperationException( - "TokenUsageRecorder запрошен вне tenant-контекста (ITenantContext.TenantId == null/не-Guid)."); - } - - return id; - } - - private async Task AddToLifetimeAsync(Usage usage, CancellationToken ct) - { - JsonObject? current = await ReadAsync(ct); - long prompt = ReadBound(current, PromptField) + usage.Prompt; - long completion = ReadBound(current, CompletionField) + usage.Completion; - long total = ReadBound(current, TotalField) + usage.Total; - - var updated = new JsonObject - { - [PromptField] = ClampToUint(prompt), - [CompletionField] = ClampToUint(completion), - [TotalField] = ClampToUint(total), - }; - await _store.SetAsync(SettingsKeys.AiTokenUsage, updated.ToJsonString(), ct); - } - - // Текущее значение aiTokenUsage (JSON-объект) или null — строки нет. - // ct: Токен отмены. - // Возвращает: Объект значения или null. - private async Task ReadAsync(CancellationToken ct) - { - SettingValue? row = await _store.GetAsync(SettingsKeys.AiTokenUsage, ct); - if (row is null) - { - return null; - } - - try - { - return JsonNode.Parse(row.ValueJson) as JsonObject; - } - catch (JsonException) - { - // Повреждённая строка — нули (мягкая семантика, как в SettingsService). - return null; - } - } - - // Число поля значения: отсутствие/не-число → 0 (без clamp: сумма ограничивается при записи). - // value: Объект значения aiTokenUsage (может быть null). - // field: Имя поля (prompt/completion/total). - // Возвращает: Значение поля или 0. - private static long ReadBound(JsonObject? value, string field) - { - if (value is null || !value.TryGetPropertyValue(field, out JsonNode? node) || node is not JsonValue scalar) - { - return 0; - } - - return scalar.TryGetValue(out long number) && number > 0 ? number : 0; - } - - // Ограничивает сумму диапазоном uint32 (proto Usage — uint; переполнение не ожидается). - // value: Накопленная сумма. - // Возвращает: Значение в диапазоне uint32. - private static JsonNode ClampToUint(long value) - => JsonValue.Create(Math.Clamp(value, 0, uint.MaxValue))!; -} +using System.Text.Json; +using System.Text.Json.Nodes; +using Deal.Grpc.Ai; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; +using Deal.SharedKernel.Observability; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; + +namespace Deal.Infrastructure.Integrations.Services; + +/// +/// Recorder расхода токенов +/// +public sealed class TokenUsageRecorder +{ + private const string PromptField = "prompt"; + + private const string CompletionField = "completion"; + + private const string TotalField = "total"; + + private const int CharsPerToken = 4; + + private readonly ISettingsStore _store; + private readonly ITenantLimitStore _tenantLimits; + private readonly ITenantContext _tenantContext; + private readonly TokenUsageEventService _events; + + /// + /// Создаёт recorder расхода токенов. + /// + /// KV-хранилище настроек тенанта (ключ aiTokenUsage, lifetime-счётчик). + /// Хранилище лимитов бюджета (public.tenant_limits, списание периода). + /// Контекст текущего тенанта (AsyncLocal; tenantId списания/события). + /// Сервис истории расхода. + public TokenUsageRecorder( + ISettingsStore store, + ITenantLimitStore tenantLimits, + ITenantContext tenantContext, + TokenUsageEventService events) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(tenantLimits); + ArgumentNullException.ThrowIfNull(tenantContext); + ArgumentNullException.ThrowIfNull(events); + _store = store; + _tenantLimits = tenantLimits; + _tenantContext = tenantContext; + _events = events; + } + + /// + /// Списывает usage ответа ai-service + /// + /// Оценка токенов ответа (Usage ai.proto; reply без usage — нули; null — no-op). + /// Id активного провайдера (deepseek/openai/anthropic/…; событие истории). + /// Модель провайдера (событие истории). + public async Task AddAsync( + Usage? usage, + string provider, + string model, + CancellationToken ct) + { + if (usage is null) + { + return; + } + + if (usage.Total > 0) + { + await _tenantLimits.AddUsageAsync(RequireTenantId(), usage.Total, ct); + } + + await AddToLifetimeAsync(usage, ct); + DealMetrics.RecordAiUsage(usage.Prompt, usage.Completion); + await RecordEventAsync( + provider, + model, + TokenUsageEventKinds.Ai, + promptTokens: usage.Prompt, + completionTokens: usage.Completion, + totalTokens: usage.Total, + ct); + } + + /// + /// Записывает событие локального ML-вызова + /// + /// Входной текст предсказания (оценка токенов запроса; null — 0). + /// Провайдер/источник события (для локальной ML-модели — "local"). + /// Модель/вид локального ML-вызова (событие истории). + /// Оценка токенов (для тестов/наблюдаемости). + public async Task AddEstimatedAsync( + string? text, + string provider, + string model, + CancellationToken ct) + { + long promptTokens = EstimateTokens(text); + DealMetrics.RecordMlUsage(promptTokens); + await RecordEventAsync( + provider, + model, + TokenUsageEventKinds.Ml, + promptTokens: promptTokens, + completionTokens: 0, + totalTokens: promptTokens, + ct); + return promptTokens; + } + + /// + /// Оценка токенов по символам. + /// + /// Текст (null/пустой — 0). + /// Оценка токенов (неотрицательная). + public static long EstimateTokens(string? text) => + string.IsNullOrEmpty(text) ? 0 : text.Length / CharsPerToken; + + // Пишет событие истории расхода токенов (public.token_usage_events, tenant-id текущего scope). + // provider: Провайдер/источник. + // model: Модель. + // kind: Вид вызова ai|ml. + // promptTokens: Токены запроса. + // completionTokens: Токены ответа. + // totalTokens: Всего токенов. + // ct: Токен отмены. + private async Task RecordEventAsync( + string provider, + string model, + string kind, + long promptTokens, + long completionTokens, + long totalTokens, + CancellationToken ct) + { + // Секретов в DetailJson нет: событие хранит только провайдера/модель/вид/токены. + await _events.AppendAsync( + new TokenUsageEventDto( + TenantId: RequireTenantId(), + At: default, + Provider: provider, + Model: model, + Kind: kind, + PromptTokens: promptTokens, + CompletionTokens: completionTokens, + TotalTokens: totalTokens, + DetailJson: null), + ct); + } + + // Текущий тенант scope как Guid строки public.tenants (без него списание не имеет смысла). + // Возвращает: Идентификатор тенанта (Guid). + // Исключение InvalidOperationException: Вызов вне tenant-контекста или не-Guid формат id. + private Guid RequireTenantId() + { + TenantId? tenantId = _tenantContext.TenantId; + if (tenantId is null || !Guid.TryParse(tenantId.Value.Value, out Guid id)) + { + throw new InvalidOperationException( + "TokenUsageRecorder запрошен вне tenant-контекста (ITenantContext.TenantId == null/не-Guid)."); + } + + return id; + } + + private async Task AddToLifetimeAsync(Usage usage, CancellationToken ct) + { + JsonObject? current = await ReadAsync(ct); + long prompt = ReadBound(current, PromptField) + usage.Prompt; + long completion = ReadBound(current, CompletionField) + usage.Completion; + long total = ReadBound(current, TotalField) + usage.Total; + + var updated = new JsonObject + { + [PromptField] = ClampToUint(prompt), + [CompletionField] = ClampToUint(completion), + [TotalField] = ClampToUint(total), + }; + await _store.SetAsync(SettingsKeys.AiTokenUsage, updated.ToJsonString(), ct); + } + + // Текущее значение aiTokenUsage (JSON-объект) или null — строки нет. + // ct: Токен отмены. + // Возвращает: Объект значения или null. + private async Task ReadAsync(CancellationToken ct) + { + SettingValue? row = await _store.GetAsync(SettingsKeys.AiTokenUsage, ct); + if (row is null) + { + return null; + } + + try + { + return JsonNode.Parse(row.ValueJson) as JsonObject; + } + catch (JsonException) + { + // Повреждённая строка — нули (мягкая семантика, как в SettingsService). + return null; + } + } + + // Число поля значения: отсутствие/не-число → 0 (без clamp: сумма ограничивается при записи). + // value: Объект значения aiTokenUsage (может быть null). + // field: Имя поля (prompt/completion/total). + // Возвращает: Значение поля или 0. + private static long ReadBound(JsonObject? value, string field) + { + if (value is null || !value.TryGetPropertyValue(field, out JsonNode? node) || node is not JsonValue scalar) + { + return 0; + } + + return scalar.TryGetValue(out long number) && number > 0 ? number : 0; + } + + // Ограничивает сумму диапазоном uint32 (proto Usage — uint; переполнение не ожидается). + // value: Накопленная сумма. + // Возвращает: Значение в диапазоне uint32. + private static JsonNode ClampToUint(long value) + => JsonValue.Create(Math.Clamp(value, 0, uint.MaxValue))!; +} diff --git a/src/core/Deal.Infrastructure/Integrations/Sources/TelegramSourceContentProvider.cs b/src/core/Deal.Infrastructure/Integrations/Sources/TelegramSourceContentProvider.cs index 777e35e..643bff2 100644 --- a/src/core/Deal.Infrastructure/Integrations/Sources/TelegramSourceContentProvider.cs +++ b/src/core/Deal.Infrastructure/Integrations/Sources/TelegramSourceContentProvider.cs @@ -1,39 +1,39 @@ -using System.Globalization; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Sources; - -namespace Deal.Infrastructure.Integrations.Sources; - -/// -/// Провайдер содержимого исходника источника telegram: запрос к telegram-service по id сообщения. -/// -/// Порт-гейт к telegram-service. -public sealed class TelegramSourceContentProvider(ITelegramGateway gateway) : ISourceContentProvider -{ - private const string TelegramKind = "telegram"; - - /// - public string Kind => TelegramKind; - - /// - public async Task LoadAsync(SourceRef source, CancellationToken ct) - { - if (!string.Equals(source.Kind, TelegramKind, StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - string dialogId = source.OriginRef ?? string.Empty; - if (dialogId.Length == 0 - || !long.TryParse(source.ExternalId, NumberStyles.Integer, CultureInfo.InvariantCulture, out long msgId)) - { - return null; - } - - TelegramSourceContentDto content = await gateway.ReadSourceAsync(dialogId, msgId, ct).ConfigureAwait(false); - return content.Found - ? new SourceContent { Text = content.Text } - : null; - } -} +using System.Globalization; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Sources; + +namespace Deal.Infrastructure.Integrations.Sources; + +/// +/// Провайдер содержимого исходника источника telegram: запрос к telegram-service по id сообщения. +/// +/// Порт-гейт к telegram-service. +public sealed class TelegramSourceContentProvider(ITelegramGateway gateway) : ISourceContentProvider +{ + private const string TelegramKind = "telegram"; + + /// + public string Kind => TelegramKind; + + /// + public async Task LoadAsync(SourceRef source, CancellationToken ct) + { + if (!string.Equals(source.Kind, TelegramKind, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + string dialogId = source.OriginRef ?? string.Empty; + if (dialogId.Length == 0 + || !long.TryParse(source.ExternalId, NumberStyles.Integer, CultureInfo.InvariantCulture, out long msgId)) + { + return null; + } + + TelegramSourceContentDto content = await gateway.ReadSourceAsync(dialogId, msgId, ct).ConfigureAwait(false); + return content.Found + ? new SourceContent { Text = content.Text } + : null; + } +} diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs index 8c318bc..007b84c 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs @@ -1,151 +1,151 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Storage.Extensions; -using Deal.Infrastructure.Integrations.Storage.Options; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Deal.Infrastructure.Integrations.Storage.Services; - -/// -/// DI-регистрация файлового хранилища -/// -public static class FileStorageRegistrar -{ - /// - /// Имя секции конфигурации файлового хранилища - /// - public const string ConfigurationSectionName = "Storage"; - - /// - /// Дефолтный каталог вложений локального режима относительно ContentRoot. - /// - public const string DefaultAttachmentsRelativePath = "data/attachments"; - - private const string SectionKeyLocalRoot = "Storage:Local:Root"; - private const string SectionKeyMinioEndpoint = "Storage:Minio:Endpoint"; - private const string SectionKeyMinioAccessKey = "Storage:Minio:AccessKey"; - private const string SectionKeyMinioSecretKey = "Storage:Minio:SecretKey"; - private const string SectionKeyMinioBucket = "Storage:Minio:Bucket"; - private const string SectionKeyMinioSecure = "Storage:Minio:Secure"; - - private const string MinioEnvironmentEndpointVariableName = "DEAL_MINIO_ENDPOINT"; - private const string MinioEnvironmentAccessKeyVariableName = "DEAL_MINIO_ACCESS_KEY"; - private const string MinioEnvironmentSecretKeyVariableName = "DEAL_MINIO_SECRET_KEY"; - private const string MinioEnvironmentBucketVariableName = "DEAL_MINIO_BUCKET"; - private const string MinioEnvironmentSecureVariableName = "DEAL_MINIO_SECURE"; - - /// - /// Регистрирует IFileStorage — LocalFileStorage или MinioFileStorage по конфигурации. - /// - /// Конфигурация приложения (секция Storage + env-алиасы DEAL_MINIO_*). - /// ContentRoot приложения — база для дефолтного корня data/attachments. - public static IServiceCollection AddDealFileStorage( - this IServiceCollection services, - IConfiguration configuration, - string contentRootPath) - { - ArgumentNullException.ThrowIfNull(configuration); - - StorageOptions options = ReadOptions(configuration); - - if (options.Minio.IsConfigured()) - { - services.AddSingleton(serviceProvider => - new MinioFileStorage(options.Minio, serviceProvider.GetRequiredService>())); - return services; - } - - string rootPath = ResolveLocalRoot(options.Local, contentRootPath); - services.AddSingleton(new LocalFileStorage(rootPath)); - return services; - } - - // Читает секцию Storage и заполняет незаданные поля Minio из env-алиасов DEAL_MINIO_*. - // configuration: Конфигурация приложения. - // Возвращает: Настройки хранилища (дефолты Local.Root=null и Minio.Bucket=deal-files сохранены). - private static StorageOptions ReadOptions(IConfiguration configuration) - { - StorageOptions options = new(); - - options.Local.Root = ReadSetting(configuration, SectionKeyLocalRoot); - - // Бакет/дефолты не затираем: присваиваем только найденные значения. - string? endpoint = ReadSetting(configuration, SectionKeyMinioEndpoint); - string? accessKey = ReadSetting(configuration, SectionKeyMinioAccessKey); - string? secretKey = ReadSetting(configuration, SectionKeyMinioSecretKey); - string? bucket = ReadSetting(configuration, SectionKeyMinioBucket); - string? secureRaw = ReadSetting(configuration, SectionKeyMinioSecure); - if (endpoint is not null) - { - options.Minio.Endpoint = endpoint; - } - - if (accessKey is not null) - { - options.Minio.AccessKey = accessKey; - } - - if (secretKey is not null) - { - options.Minio.SecretKey = secretKey; - } - - if (bucket is not null) - { - options.Minio.Bucket = bucket; - } - - if (secureRaw is not null) - { - options.Minio.Secure = secureRaw.IsTrue(); - } - - return options; - } - - // Резолвит корень локального хранилища: дефолт data/attachments под ContentRoot; относительный Root — под ContentRoot; абсолютный — как есть. - private static string ResolveLocalRoot(LocalStorageOptions local, string contentRootPath) - { - string? root = local.Root; - if (string.IsNullOrWhiteSpace(root)) - { - return Path.Combine(contentRootPath, DefaultAttachmentsRelativePath); - } - - return Path.IsPathRooted(root) ? root : Path.Combine(contentRootPath, root); - } - - // Значение настройки: секция конфигурации (Storage:… / env Storage__…__…) приоритетнее, иначе env-алиас DEAL_MINIO_*; null — не задано. - private static string? ReadSetting(IConfiguration configuration, string sectionKey) - { - string? fromSection = configuration[sectionKey]; - if (!string.IsNullOrWhiteSpace(fromSection)) - { - return fromSection; - } - - string? alias = EnvironmentAliasFor(sectionKey); - if (alias is null) - { - return null; - } - - string? fromEnvironment = Environment.GetEnvironmentVariable(alias); - return string.IsNullOrWhiteSpace(fromEnvironment) ? null : fromEnvironment; - } - - private static string? EnvironmentAliasFor(string sectionKey) - { - return sectionKey switch - { - SectionKeyMinioEndpoint => MinioEnvironmentEndpointVariableName, - SectionKeyMinioAccessKey => MinioEnvironmentAccessKeyVariableName, - SectionKeyMinioSecretKey => MinioEnvironmentSecretKeyVariableName, - SectionKeyMinioBucket => MinioEnvironmentBucketVariableName, - SectionKeyMinioSecure => MinioEnvironmentSecureVariableName, - _ => null, - }; - } - -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Infrastructure.Integrations.Storage.Extensions; +using Deal.Infrastructure.Integrations.Storage.Options; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Deal.Infrastructure.Integrations.Storage.Services; + +/// +/// DI-регистрация файлового хранилища +/// +public static class FileStorageRegistrar +{ + /// + /// Имя секции конфигурации файлового хранилища + /// + public const string ConfigurationSectionName = "Storage"; + + /// + /// Дефолтный каталог вложений локального режима относительно ContentRoot. + /// + public const string DefaultAttachmentsRelativePath = "data/attachments"; + + private const string SectionKeyLocalRoot = "Storage:Local:Root"; + private const string SectionKeyMinioEndpoint = "Storage:Minio:Endpoint"; + private const string SectionKeyMinioAccessKey = "Storage:Minio:AccessKey"; + private const string SectionKeyMinioSecretKey = "Storage:Minio:SecretKey"; + private const string SectionKeyMinioBucket = "Storage:Minio:Bucket"; + private const string SectionKeyMinioSecure = "Storage:Minio:Secure"; + + private const string MinioEnvironmentEndpointVariableName = "DEAL_MINIO_ENDPOINT"; + private const string MinioEnvironmentAccessKeyVariableName = "DEAL_MINIO_ACCESS_KEY"; + private const string MinioEnvironmentSecretKeyVariableName = "DEAL_MINIO_SECRET_KEY"; + private const string MinioEnvironmentBucketVariableName = "DEAL_MINIO_BUCKET"; + private const string MinioEnvironmentSecureVariableName = "DEAL_MINIO_SECURE"; + + /// + /// Регистрирует IFileStorage — LocalFileStorage или MinioFileStorage по конфигурации. + /// + /// Конфигурация приложения (секция Storage + env-алиасы DEAL_MINIO_*). + /// ContentRoot приложения — база для дефолтного корня data/attachments. + public static IServiceCollection AddDealFileStorage( + this IServiceCollection services, + IConfiguration configuration, + string contentRootPath) + { + ArgumentNullException.ThrowIfNull(configuration); + + StorageOptions options = ReadOptions(configuration); + + if (options.Minio.IsConfigured()) + { + services.AddSingleton(serviceProvider => + new MinioFileStorage(options.Minio, serviceProvider.GetRequiredService>())); + return services; + } + + string rootPath = ResolveLocalRoot(options.Local, contentRootPath); + services.AddSingleton(new LocalFileStorage(rootPath)); + return services; + } + + // Читает секцию Storage и заполняет незаданные поля Minio из env-алиасов DEAL_MINIO_*. + // configuration: Конфигурация приложения. + // Возвращает: Настройки хранилища (дефолты Local.Root=null и Minio.Bucket=deal-files сохранены). + private static StorageOptions ReadOptions(IConfiguration configuration) + { + StorageOptions options = new(); + + options.Local.Root = ReadSetting(configuration, SectionKeyLocalRoot); + + // Бакет/дефолты не затираем: присваиваем только найденные значения. + string? endpoint = ReadSetting(configuration, SectionKeyMinioEndpoint); + string? accessKey = ReadSetting(configuration, SectionKeyMinioAccessKey); + string? secretKey = ReadSetting(configuration, SectionKeyMinioSecretKey); + string? bucket = ReadSetting(configuration, SectionKeyMinioBucket); + string? secureRaw = ReadSetting(configuration, SectionKeyMinioSecure); + if (endpoint is not null) + { + options.Minio.Endpoint = endpoint; + } + + if (accessKey is not null) + { + options.Minio.AccessKey = accessKey; + } + + if (secretKey is not null) + { + options.Minio.SecretKey = secretKey; + } + + if (bucket is not null) + { + options.Minio.Bucket = bucket; + } + + if (secureRaw is not null) + { + options.Minio.Secure = secureRaw.IsTrue(); + } + + return options; + } + + // Резолвит корень локального хранилища: дефолт data/attachments под ContentRoot; относительный Root — под ContentRoot; абсолютный — как есть. + private static string ResolveLocalRoot(LocalStorageOptions local, string contentRootPath) + { + string? root = local.Root; + if (string.IsNullOrWhiteSpace(root)) + { + return Path.Combine(contentRootPath, DefaultAttachmentsRelativePath); + } + + return Path.IsPathRooted(root) ? root : Path.Combine(contentRootPath, root); + } + + // Значение настройки: секция конфигурации (Storage:… / env Storage__…__…) приоритетнее, иначе env-алиас DEAL_MINIO_*; null — не задано. + private static string? ReadSetting(IConfiguration configuration, string sectionKey) + { + string? fromSection = configuration[sectionKey]; + if (!string.IsNullOrWhiteSpace(fromSection)) + { + return fromSection; + } + + string? alias = EnvironmentAliasFor(sectionKey); + if (alias is null) + { + return null; + } + + string? fromEnvironment = Environment.GetEnvironmentVariable(alias); + return string.IsNullOrWhiteSpace(fromEnvironment) ? null : fromEnvironment; + } + + private static string? EnvironmentAliasFor(string sectionKey) + { + return sectionKey switch + { + SectionKeyMinioEndpoint => MinioEnvironmentEndpointVariableName, + SectionKeyMinioAccessKey => MinioEnvironmentAccessKeyVariableName, + SectionKeyMinioSecretKey => MinioEnvironmentSecretKeyVariableName, + SectionKeyMinioBucket => MinioEnvironmentBucketVariableName, + SectionKeyMinioSecure => MinioEnvironmentSecureVariableName, + _ => null, + }; + } + +} diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Services/MinioFileStorage.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Services/MinioFileStorage.cs index 54a3254..1be9229 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Services/MinioFileStorage.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Services/MinioFileStorage.cs @@ -1,207 +1,207 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Integrations.Storage.Options; -using Microsoft.Extensions.Logging; -using Minio; -using Minio.DataModel; -using Minio.DataModel.Args; -using Minio.Exceptions; - -namespace Deal.Infrastructure.Integrations.Storage.Services; - -/// -/// Хранилище вложений на MinIO -/// -public sealed class MinioFileStorage : IFileStorage -{ - private const string DefaultContentType = "application/octet-stream"; - - private readonly IMinioClient _client; - private readonly string _endpoint; - private readonly string _bucket; - private readonly ILogger _logger; - - private readonly SemaphoreSlim _bucketCheckGate = new(1, 1); - - private bool _bucketChecked; - - /// - /// Создаёт адаптер поверх настроек MinIO. - /// - /// Настройки секции Storage:Minio (Endpoint/AccessKey/SecretKey обязательны). - /// Логгер предупреждений о сбоях MinIO. - /// Секция MinIO не заполнена (конфигурационная ошибка регистрации). - public MinioFileStorage(MinioStorageOptions options, ILogger logger) - { - ArgumentNullException.ThrowIfNull(options); - ArgumentNullException.ThrowIfNull(logger); - - if (string.IsNullOrWhiteSpace(options.Endpoint) - || string.IsNullOrWhiteSpace(options.AccessKey) - || string.IsNullOrWhiteSpace(options.SecretKey)) - { - throw new InvalidOperationException( - "MinioFileStorage требует заполненные Storage:Minio Endpoint и AccessKey/SecretKey " - + "(регистратор выбирает Minio-адаптер только при заполненной секции, Ruling 4)."); - } - - _endpoint = options.Endpoint; - _bucket = string.IsNullOrWhiteSpace(options.Bucket) - ? MinioStorageOptions.DefaultBucketName - : options.Bucket; - _logger = logger; - - // Клиент без сетевых вызовов: endpoint — «host:port» без схемы; WithSSL — по настройке Secure - // (dev-compose deal-minio — http, Secure=false). - _client = new MinioClient() - .WithEndpoint(options.Endpoint) - .WithCredentials(options.AccessKey, options.SecretKey) - .WithSSL(options.Secure) - .Build(); - } - - /// - /// Описание режима для стартового лога Api - /// - /// Строка вида MinioFileStorage (endpoint: …; bucket: …). - public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})"; - - /// - public async Task PutAsync( - string objectKey, - Stream content, - string contentType, - CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(content); - - await EnsureBucketAsync(ct); - - if (content.CanSeek && content.Position != 0) - { - content.Position = 0; - } - - using MemoryStream buffer = new(); - await content.CopyToAsync(buffer, ct); - buffer.Position = 0; - - await _client.PutObjectAsync( - new PutObjectArgs() - .WithBucket(_bucket) - .WithObject(objectKey) - .WithStreamData(buffer) - .WithObjectSize(buffer.Length) - .WithContentType(string.IsNullOrWhiteSpace(contentType) ? DefaultContentType : contentType), - ct); - - return objectKey; - } - - /// - public async Task GetAsync(string objectKey, CancellationToken ct) - { - MemoryStream buffer = new(); - try - { - // В 7.0 SDK содержимое приходит в callback-поток, GetObjectAsync возвращает ObjectStat (стат не нужен); - // объекта нет → SDK бросает ObjectNotFoundException → null-семантика порта. - await _client.GetObjectAsync( - new GetObjectArgs() - .WithBucket(_bucket) - .WithObject(objectKey) - .WithCallbackStream(async (stream, token) => await stream.CopyToAsync(buffer, token)), - ct); - } - catch (ObjectNotFoundException) - { - buffer.Dispose(); - return null; - } - catch (Exception) - { - buffer.Dispose(); - throw; - } - - buffer.Position = 0; - return buffer; - } - - /// - public async Task StatAsync(string objectKey, CancellationToken ct) - { - try - { - ObjectStat stat = await _client.StatObjectAsync( - new StatObjectArgs().WithBucket(_bucket).WithObject(objectKey), - ct); - return new FileMeta(objectKey, stat.Size, stat.ContentType ?? string.Empty); - } - catch (ObjectNotFoundException) - { - return null; - } - } - - /// - public async Task DeleteAsync(string objectKey, CancellationToken ct) - { - try - { - // Удаление отсутствующего объекта — успех (MinIO отвечает 204): исключений не будет. - await _client.RemoveObjectAsync( - new RemoveObjectArgs().WithBucket(_bucket).WithObject(objectKey), - ct); - } - catch (MinioException exception) - { - _logger.LogWarning( - exception, - "Не удалось удалить объект MinIO «{ObjectKey}» из бакета «{Bucket}»: {Message}", - objectKey, - _bucket, - exception.Message); - } - } - - private async Task EnsureBucketAsync(CancellationToken ct) - { - if (_bucketChecked) - { - return; - } - - await _bucketCheckGate.WaitAsync(ct); - try - { - if (_bucketChecked) - { - return; - } - - try - { - bool exists = await _client.BucketExistsAsync(new BucketExistsArgs().WithBucket(_bucket), ct); - if (!exists) - { - await _client.MakeBucketAsync(new MakeBucketArgs().WithBucket(_bucket), ct); - } - } - catch (MinioException exception) - { - _logger.LogWarning( - exception, - "Не удалось проверить/создать бакет MinIO «{Bucket}»: {Message}", - _bucket, - exception.Message); - } - - _bucketChecked = true; - } - finally - { - _bucketCheckGate.Release(); - } - } -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Infrastructure.Integrations.Storage.Options; +using Microsoft.Extensions.Logging; +using Minio; +using Minio.DataModel; +using Minio.DataModel.Args; +using Minio.Exceptions; + +namespace Deal.Infrastructure.Integrations.Storage.Services; + +/// +/// Хранилище вложений на MinIO +/// +public sealed class MinioFileStorage : IFileStorage +{ + private const string DefaultContentType = "application/octet-stream"; + + private readonly IMinioClient _client; + private readonly string _endpoint; + private readonly string _bucket; + private readonly ILogger _logger; + + private readonly SemaphoreSlim _bucketCheckGate = new(1, 1); + + private bool _bucketChecked; + + /// + /// Создаёт адаптер поверх настроек MinIO. + /// + /// Настройки секции Storage:Minio (Endpoint/AccessKey/SecretKey обязательны). + /// Логгер предупреждений о сбоях MinIO. + /// Секция MinIO не заполнена (конфигурационная ошибка регистрации). + public MinioFileStorage(MinioStorageOptions options, ILogger logger) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(logger); + + if (string.IsNullOrWhiteSpace(options.Endpoint) + || string.IsNullOrWhiteSpace(options.AccessKey) + || string.IsNullOrWhiteSpace(options.SecretKey)) + { + throw new InvalidOperationException( + "MinioFileStorage требует заполненные Storage:Minio Endpoint и AccessKey/SecretKey " + + "(регистратор выбирает Minio-адаптер только при заполненной секции, Ruling 4)."); + } + + _endpoint = options.Endpoint; + _bucket = string.IsNullOrWhiteSpace(options.Bucket) + ? MinioStorageOptions.DefaultBucketName + : options.Bucket; + _logger = logger; + + // Клиент без сетевых вызовов: endpoint — «host:port» без схемы; WithSSL — по настройке Secure + // (dev-compose deal-minio — http, Secure=false). + _client = new MinioClient() + .WithEndpoint(options.Endpoint) + .WithCredentials(options.AccessKey, options.SecretKey) + .WithSSL(options.Secure) + .Build(); + } + + /// + /// Описание режима для стартового лога Api + /// + /// Строка вида MinioFileStorage (endpoint: …; bucket: …). + public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})"; + + /// + public async Task PutAsync( + string objectKey, + Stream content, + string contentType, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(content); + + await EnsureBucketAsync(ct); + + if (content.CanSeek && content.Position != 0) + { + content.Position = 0; + } + + using MemoryStream buffer = new(); + await content.CopyToAsync(buffer, ct); + buffer.Position = 0; + + await _client.PutObjectAsync( + new PutObjectArgs() + .WithBucket(_bucket) + .WithObject(objectKey) + .WithStreamData(buffer) + .WithObjectSize(buffer.Length) + .WithContentType(string.IsNullOrWhiteSpace(contentType) ? DefaultContentType : contentType), + ct); + + return objectKey; + } + + /// + public async Task GetAsync(string objectKey, CancellationToken ct) + { + MemoryStream buffer = new(); + try + { + // В 7.0 SDK содержимое приходит в callback-поток, GetObjectAsync возвращает ObjectStat (стат не нужен); + // объекта нет → SDK бросает ObjectNotFoundException → null-семантика порта. + await _client.GetObjectAsync( + new GetObjectArgs() + .WithBucket(_bucket) + .WithObject(objectKey) + .WithCallbackStream(async (stream, token) => await stream.CopyToAsync(buffer, token)), + ct); + } + catch (ObjectNotFoundException) + { + buffer.Dispose(); + return null; + } + catch (Exception) + { + buffer.Dispose(); + throw; + } + + buffer.Position = 0; + return buffer; + } + + /// + public async Task StatAsync(string objectKey, CancellationToken ct) + { + try + { + ObjectStat stat = await _client.StatObjectAsync( + new StatObjectArgs().WithBucket(_bucket).WithObject(objectKey), + ct); + return new FileMeta(objectKey, stat.Size, stat.ContentType ?? string.Empty); + } + catch (ObjectNotFoundException) + { + return null; + } + } + + /// + public async Task DeleteAsync(string objectKey, CancellationToken ct) + { + try + { + // Удаление отсутствующего объекта — успех (MinIO отвечает 204): исключений не будет. + await _client.RemoveObjectAsync( + new RemoveObjectArgs().WithBucket(_bucket).WithObject(objectKey), + ct); + } + catch (MinioException exception) + { + _logger.LogWarning( + exception, + "Не удалось удалить объект MinIO «{ObjectKey}» из бакета «{Bucket}»: {Message}", + objectKey, + _bucket, + exception.Message); + } + } + + private async Task EnsureBucketAsync(CancellationToken ct) + { + if (_bucketChecked) + { + return; + } + + await _bucketCheckGate.WaitAsync(ct); + try + { + if (_bucketChecked) + { + return; + } + + try + { + bool exists = await _client.BucketExistsAsync(new BucketExistsArgs().WithBucket(_bucket), ct); + if (!exists) + { + await _client.MakeBucketAsync(new MakeBucketArgs().WithBucket(_bucket), ct); + } + } + catch (MinioException exception) + { + _logger.LogWarning( + exception, + "Не удалось проверить/создать бакет MinIO «{Bucket}»: {Message}", + _bucket, + exception.Message); + } + + _bucketChecked = true; + } + finally + { + _bucketCheckGate.Release(); + } + } +} diff --git a/src/core/Deal.Infrastructure/Migrations/20260910152246_AddTokenUsageEvents.Designer.cs b/src/core/Deal.Infrastructure/Migrations/20260910152246_AddTokenUsageEvents.Designer.cs index 23564a5..7db3ca8 100644 --- a/src/core/Deal.Infrastructure/Migrations/20260910152246_AddTokenUsageEvents.Designer.cs +++ b/src/core/Deal.Infrastructure/Migrations/20260910152246_AddTokenUsageEvents.Designer.cs @@ -1,403 +1,403 @@ -// -using System; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Deal.Infrastructure.Migrations -{ - [DbContext(typeof(DealDbContext))] - [Migration("20260910152246_AddTokenUsageEvents")] - partial class AddTokenUsageEvents - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.11") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.AuditLogEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("ActorId") - .HasColumnType("uuid"); - - b.Property("ActorType") - .IsRequired() - .HasColumnType("text"); - - b.Property("At") - .HasColumnType("timestamp with time zone"); - - b.Property("DetailJson") - .HasColumnType("text"); - - b.Property("EventType") - .IsRequired() - .HasColumnType("text"); - - b.Property("Ip") - .HasColumnType("text"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("At"); - - b.HasIndex("TenantId", "EventType"); - - b.ToTable("audit_log", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b => - { - b.Property("Code") - .HasColumnType("text"); - - b.Property("ActivatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedById") - .HasColumnType("uuid"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("pending"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Code"); - - b.HasIndex("CreatedById"); - - b.HasIndex("Email") - .IsUnique() - .HasFilter("\"Status\" = 'pending'"); - - b.ToTable("invites", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Login") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("text"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("active"); - - b.HasKey("Id"); - - b.HasIndex("Login") - .IsUnique(); - - b.ToTable("operators", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b => - { - b.Property("TokenHash") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Login") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("OperatorId") - .HasColumnType("uuid"); - - b.HasKey("TokenHash"); - - b.HasIndex("ExpiresAt"); - - b.HasIndex("OperatorId"); - - b.ToTable("operator_sessions", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b => - { - b.Property("TokenHash") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ImpersonatedByOperatorId") - .HasColumnType("uuid"); - - b.Property("Login") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("TokenHash"); - - b.HasIndex("ExpiresAt"); - - b.HasIndex("UserId"); - - b.ToTable("sessions", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("tenants", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b => - { - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("BudgetTokens") - .HasColumnType("bigint"); - - b.Property("NotifiedExhausted") - .HasColumnType("boolean"); - - b.Property("Period") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("month"); - - b.Property("PeriodStart") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UsedTokens") - .HasColumnType("bigint"); - - b.Property("Warned80") - .HasColumnType("boolean"); - - b.HasKey("TenantId"); - - b.ToTable("tenant_limits", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("At") - .HasColumnType("timestamp with time zone"); - - b.Property("CompletionTokens") - .HasColumnType("bigint"); - - b.Property("DetailJson") - .HasColumnType("text"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("Model") - .IsRequired() - .HasColumnType("text"); - - b.Property("PromptTokens") - .HasColumnType("bigint"); - - b.Property("Provider") - .IsRequired() - .HasColumnType("text"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("TotalTokens") - .HasColumnType("bigint"); - - b.HasKey("Id"); - - b.HasIndex("At"); - - b.HasIndex("TenantId", "At"); - - b.ToTable("token_usage_events", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - b.Property("Login") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("text"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("active"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("Login") - .IsUnique(); - - b.HasIndex("TenantId"); - - b.ToTable("users", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null) - .WithMany() - .HasForeignKey("CreatedById") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null) - .WithMany() - .HasForeignKey("OperatorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.UserEntity", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} +// +using System; +using Deal.Infrastructure.Persistence; +using Deal.Infrastructure.Persistence.Configurations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Deal.Infrastructure.Migrations +{ + [DbContext(typeof(DealDbContext))] + [Migration("20260910152246_AddTokenUsageEvents")] + partial class AddTokenUsageEvents + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.AuditLogEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("ActorType") + .IsRequired() + .HasColumnType("text"); + + b.Property("At") + .HasColumnType("timestamp with time zone"); + + b.Property("DetailJson") + .HasColumnType("text"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Ip") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("At"); + + b.HasIndex("TenantId", "EventType"); + + b.ToTable("audit_log", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b => + { + b.Property("Code") + .HasColumnType("text"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedById") + .HasColumnType("uuid"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("pending"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Code"); + + b.HasIndex("CreatedById"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("\"Status\" = 'pending'"); + + b.ToTable("invites", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.HasKey("Id"); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("operators", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b => + { + b.Property("TokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OperatorId") + .HasColumnType("uuid"); + + b.HasKey("TokenHash"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("OperatorId"); + + b.ToTable("operator_sessions", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b => + { + b.Property("TokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ImpersonatedByOperatorId") + .HasColumnType("uuid"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("TokenHash"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("UserId"); + + b.ToTable("sessions", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("tenants", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b => + { + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("BudgetTokens") + .HasColumnType("bigint"); + + b.Property("NotifiedExhausted") + .HasColumnType("boolean"); + + b.Property("Period") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("month"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedTokens") + .HasColumnType("bigint"); + + b.Property("Warned80") + .HasColumnType("boolean"); + + b.HasKey("TenantId"); + + b.ToTable("tenant_limits", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("At") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletionTokens") + .HasColumnType("bigint"); + + b.Property("DetailJson") + .HasColumnType("text"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Model") + .IsRequired() + .HasColumnType("text"); + + b.Property("PromptTokens") + .HasColumnType("bigint"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TotalTokens") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("At"); + + b.HasIndex("TenantId", "At"); + + b.ToTable("token_usage_events", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Login") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("users", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null) + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null) + .WithMany() + .HasForeignKey("OperatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/core/Deal.Infrastructure/Migrations/20260910152246_AddTokenUsageEvents.cs b/src/core/Deal.Infrastructure/Migrations/20260910152246_AddTokenUsageEvents.cs index 6e8f53c..ca785d5 100644 --- a/src/core/Deal.Infrastructure/Migrations/20260910152246_AddTokenUsageEvents.cs +++ b/src/core/Deal.Infrastructure/Migrations/20260910152246_AddTokenUsageEvents.cs @@ -1,64 +1,64 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Deal.Infrastructure.Migrations -{ - /// - public partial class AddTokenUsageEvents : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "token_usage_events", - schema: "public", - columns: table => new - { - Id = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - TenantId = table.Column(type: "uuid", nullable: false), - At = table.Column(type: "timestamp with time zone", nullable: false), - Provider = table.Column(type: "text", nullable: false), - Model = table.Column(type: "text", nullable: false), - Kind = table.Column(type: "text", nullable: false), - PromptTokens = table.Column(type: "bigint", nullable: false), - CompletionTokens = table.Column(type: "bigint", nullable: false), - TotalTokens = table.Column(type: "bigint", nullable: false), - DetailJson = table.Column(type: "text", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_token_usage_events", x => x.Id); - table.ForeignKey( - name: "FK_token_usage_events_tenants_TenantId", - column: x => x.TenantId, - principalSchema: "public", - principalTable: "tenants", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateIndex( - name: "IX_token_usage_events_At", - schema: "public", - table: "token_usage_events", - column: "At"); - - migrationBuilder.CreateIndex( - name: "IX_token_usage_events_TenantId_At", - schema: "public", - table: "token_usage_events", - columns: new[] { "TenantId", "At" }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "token_usage_events", - schema: "public"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Deal.Infrastructure.Migrations +{ + /// + public partial class AddTokenUsageEvents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "token_usage_events", + schema: "public", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + TenantId = table.Column(type: "uuid", nullable: false), + At = table.Column(type: "timestamp with time zone", nullable: false), + Provider = table.Column(type: "text", nullable: false), + Model = table.Column(type: "text", nullable: false), + Kind = table.Column(type: "text", nullable: false), + PromptTokens = table.Column(type: "bigint", nullable: false), + CompletionTokens = table.Column(type: "bigint", nullable: false), + TotalTokens = table.Column(type: "bigint", nullable: false), + DetailJson = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_token_usage_events", x => x.Id); + table.ForeignKey( + name: "FK_token_usage_events_tenants_TenantId", + column: x => x.TenantId, + principalSchema: "public", + principalTable: "tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_token_usage_events_At", + schema: "public", + table: "token_usage_events", + column: "At"); + + migrationBuilder.CreateIndex( + name: "IX_token_usage_events_TenantId_At", + schema: "public", + table: "token_usage_events", + columns: new[] { "TenantId", "At" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "token_usage_events", + schema: "public"); + } + } +} diff --git a/src/core/Deal.Infrastructure/Migrations/20260910172545_RateLimitCounters.Designer.cs b/src/core/Deal.Infrastructure/Migrations/20260910172545_RateLimitCounters.Designer.cs index eb77b95..60e03f1 100644 --- a/src/core/Deal.Infrastructure/Migrations/20260910172545_RateLimitCounters.Designer.cs +++ b/src/core/Deal.Infrastructure/Migrations/20260910172545_RateLimitCounters.Designer.cs @@ -1,424 +1,424 @@ -// -using System; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Deal.Infrastructure.Migrations -{ - [DbContext(typeof(DealDbContext))] - [Migration("20260910172545_RateLimitCounters")] - partial class RateLimitCounters - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.11") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.AuditLogEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("ActorId") - .HasColumnType("uuid"); - - b.Property("ActorType") - .IsRequired() - .HasColumnType("text"); - - b.Property("At") - .HasColumnType("timestamp with time zone"); - - b.Property("DetailJson") - .HasColumnType("text"); - - b.Property("EventType") - .IsRequired() - .HasColumnType("text"); - - b.Property("Ip") - .HasColumnType("text"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("At"); - - b.HasIndex("TenantId", "EventType"); - - b.ToTable("audit_log", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b => - { - b.Property("Code") - .HasColumnType("text"); - - b.Property("ActivatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedById") - .HasColumnType("uuid"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("pending"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Code"); - - b.HasIndex("CreatedById"); - - b.HasIndex("Email") - .IsUnique() - .HasFilter("\"Status\" = 'pending'"); - - b.ToTable("invites", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Login") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("text"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("active"); - - b.HasKey("Id"); - - b.HasIndex("Login") - .IsUnique(); - - b.ToTable("operators", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b => - { - b.Property("TokenHash") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Login") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("OperatorId") - .HasColumnType("uuid"); - - b.HasKey("TokenHash"); - - b.HasIndex("ExpiresAt"); - - b.HasIndex("OperatorId"); - - b.ToTable("operator_sessions", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RateLimitCounterEntity", b => - { - b.Property("Key") - .HasColumnType("text"); - - b.Property("Count") - .HasColumnType("integer"); - - b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone"); - - b.Property("WindowStart") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Key"); - - b.HasIndex("ExpiresAt"); - - b.ToTable("rate_limit_counters", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b => - { - b.Property("TokenHash") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ImpersonatedByOperatorId") - .HasColumnType("uuid"); - - b.Property("Login") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("TokenHash"); - - b.HasIndex("ExpiresAt"); - - b.HasIndex("UserId"); - - b.ToTable("sessions", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("tenants", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b => - { - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("BudgetTokens") - .HasColumnType("bigint"); - - b.Property("NotifiedExhausted") - .HasColumnType("boolean"); - - b.Property("Period") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("month"); - - b.Property("PeriodStart") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UsedTokens") - .HasColumnType("bigint"); - - b.Property("Warned80") - .HasColumnType("boolean"); - - b.HasKey("TenantId"); - - b.ToTable("tenant_limits", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("At") - .HasColumnType("timestamp with time zone"); - - b.Property("CompletionTokens") - .HasColumnType("bigint"); - - b.Property("DetailJson") - .HasColumnType("text"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("Model") - .IsRequired() - .HasColumnType("text"); - - b.Property("PromptTokens") - .HasColumnType("bigint"); - - b.Property("Provider") - .IsRequired() - .HasColumnType("text"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("TotalTokens") - .HasColumnType("bigint"); - - b.HasKey("Id"); - - b.HasIndex("At"); - - b.HasIndex("TenantId", "At"); - - b.ToTable("token_usage_events", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - b.Property("Login") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("text"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("active"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("Login") - .IsUnique(); - - b.HasIndex("TenantId"); - - b.ToTable("users", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null) - .WithMany() - .HasForeignKey("CreatedById") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null) - .WithMany() - .HasForeignKey("OperatorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.UserEntity", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} +// +using System; +using Deal.Infrastructure.Persistence; +using Deal.Infrastructure.Persistence.Configurations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Deal.Infrastructure.Migrations +{ + [DbContext(typeof(DealDbContext))] + [Migration("20260910172545_RateLimitCounters")] + partial class RateLimitCounters + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.AuditLogEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("ActorType") + .IsRequired() + .HasColumnType("text"); + + b.Property("At") + .HasColumnType("timestamp with time zone"); + + b.Property("DetailJson") + .HasColumnType("text"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Ip") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("At"); + + b.HasIndex("TenantId", "EventType"); + + b.ToTable("audit_log", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b => + { + b.Property("Code") + .HasColumnType("text"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedById") + .HasColumnType("uuid"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("pending"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Code"); + + b.HasIndex("CreatedById"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("\"Status\" = 'pending'"); + + b.ToTable("invites", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.HasKey("Id"); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("operators", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b => + { + b.Property("TokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OperatorId") + .HasColumnType("uuid"); + + b.HasKey("TokenHash"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("OperatorId"); + + b.ToTable("operator_sessions", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RateLimitCounterEntity", b => + { + b.Property("Key") + .HasColumnType("text"); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowStart") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("rate_limit_counters", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b => + { + b.Property("TokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ImpersonatedByOperatorId") + .HasColumnType("uuid"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("TokenHash"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("UserId"); + + b.ToTable("sessions", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("tenants", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b => + { + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("BudgetTokens") + .HasColumnType("bigint"); + + b.Property("NotifiedExhausted") + .HasColumnType("boolean"); + + b.Property("Period") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("month"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedTokens") + .HasColumnType("bigint"); + + b.Property("Warned80") + .HasColumnType("boolean"); + + b.HasKey("TenantId"); + + b.ToTable("tenant_limits", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("At") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletionTokens") + .HasColumnType("bigint"); + + b.Property("DetailJson") + .HasColumnType("text"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Model") + .IsRequired() + .HasColumnType("text"); + + b.Property("PromptTokens") + .HasColumnType("bigint"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TotalTokens") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("At"); + + b.HasIndex("TenantId", "At"); + + b.ToTable("token_usage_events", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Login") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("users", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null) + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null) + .WithMany() + .HasForeignKey("OperatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/core/Deal.Infrastructure/Migrations/20260910172545_RateLimitCounters.cs b/src/core/Deal.Infrastructure/Migrations/20260910172545_RateLimitCounters.cs index 334caa7..3922053 100644 --- a/src/core/Deal.Infrastructure/Migrations/20260910172545_RateLimitCounters.cs +++ b/src/core/Deal.Infrastructure/Migrations/20260910172545_RateLimitCounters.cs @@ -1,43 +1,43 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Deal.Infrastructure.Migrations -{ - /// - public partial class RateLimitCounters : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "rate_limit_counters", - schema: "public", - columns: table => new - { - Key = table.Column(type: "text", nullable: false), - WindowStart = table.Column(type: "timestamp with time zone", nullable: false), - ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), - Count = table.Column(type: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_rate_limit_counters", x => x.Key); - }); - - migrationBuilder.CreateIndex( - name: "IX_rate_limit_counters_ExpiresAt", - schema: "public", - table: "rate_limit_counters", - column: "ExpiresAt"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "rate_limit_counters", - schema: "public"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Deal.Infrastructure.Migrations +{ + /// + public partial class RateLimitCounters : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "rate_limit_counters", + schema: "public", + columns: table => new + { + Key = table.Column(type: "text", nullable: false), + WindowStart = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + Count = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_rate_limit_counters", x => x.Key); + }); + + migrationBuilder.CreateIndex( + name: "IX_rate_limit_counters_ExpiresAt", + schema: "public", + table: "rate_limit_counters", + column: "ExpiresAt"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "rate_limit_counters", + schema: "public"); + } + } +} diff --git a/src/core/Deal.Infrastructure/Migrations/20260910194443_GlobalSettings.Designer.cs b/src/core/Deal.Infrastructure/Migrations/20260910194443_GlobalSettings.Designer.cs index 80c4dda..c8205f5 100644 --- a/src/core/Deal.Infrastructure/Migrations/20260910194443_GlobalSettings.Designer.cs +++ b/src/core/Deal.Infrastructure/Migrations/20260910194443_GlobalSettings.Designer.cs @@ -1,442 +1,442 @@ -// -using System; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Deal.Infrastructure.Migrations -{ - [DbContext(typeof(DealDbContext))] - [Migration("20260910194443_GlobalSettings")] - partial class GlobalSettings - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.11") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.AuditLogEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("ActorId") - .HasColumnType("uuid"); - - b.Property("ActorType") - .IsRequired() - .HasColumnType("text"); - - b.Property("At") - .HasColumnType("timestamp with time zone"); - - b.Property("DetailJson") - .HasColumnType("text"); - - b.Property("EventType") - .IsRequired() - .HasColumnType("text"); - - b.Property("Ip") - .HasColumnType("text"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("At"); - - b.HasIndex("TenantId", "EventType"); - - b.ToTable("audit_log", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.GlobalSettingEntity", b => - { - b.Property("Key") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Value") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Key"); - - b.ToTable("global_settings", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b => - { - b.Property("Code") - .HasColumnType("text"); - - b.Property("ActivatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedById") - .HasColumnType("uuid"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("pending"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Code"); - - b.HasIndex("CreatedById"); - - b.HasIndex("Email") - .IsUnique() - .HasFilter("\"Status\" = 'pending'"); - - b.ToTable("invites", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Login") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("text"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("active"); - - b.HasKey("Id"); - - b.HasIndex("Login") - .IsUnique(); - - b.ToTable("operators", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b => - { - b.Property("TokenHash") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Login") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("OperatorId") - .HasColumnType("uuid"); - - b.HasKey("TokenHash"); - - b.HasIndex("ExpiresAt"); - - b.HasIndex("OperatorId"); - - b.ToTable("operator_sessions", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RateLimitCounterEntity", b => - { - b.Property("Key") - .HasColumnType("text"); - - b.Property("Count") - .HasColumnType("integer"); - - b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone"); - - b.Property("WindowStart") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Key"); - - b.HasIndex("ExpiresAt"); - - b.ToTable("rate_limit_counters", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b => - { - b.Property("TokenHash") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ImpersonatedByOperatorId") - .HasColumnType("uuid"); - - b.Property("Login") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("TokenHash"); - - b.HasIndex("ExpiresAt"); - - b.HasIndex("UserId"); - - b.ToTable("sessions", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("tenants", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b => - { - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("BudgetTokens") - .HasColumnType("bigint"); - - b.Property("NotifiedExhausted") - .HasColumnType("boolean"); - - b.Property("Period") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("month"); - - b.Property("PeriodStart") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UsedTokens") - .HasColumnType("bigint"); - - b.Property("Warned80") - .HasColumnType("boolean"); - - b.HasKey("TenantId"); - - b.ToTable("tenant_limits", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("At") - .HasColumnType("timestamp with time zone"); - - b.Property("CompletionTokens") - .HasColumnType("bigint"); - - b.Property("DetailJson") - .HasColumnType("text"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("Model") - .IsRequired() - .HasColumnType("text"); - - b.Property("PromptTokens") - .HasColumnType("bigint"); - - b.Property("Provider") - .IsRequired() - .HasColumnType("text"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("TotalTokens") - .HasColumnType("bigint"); - - b.HasKey("Id"); - - b.HasIndex("At"); - - b.HasIndex("TenantId", "At"); - - b.ToTable("token_usage_events", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - b.Property("Login") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("text"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("active"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("Login") - .IsUnique(); - - b.HasIndex("TenantId"); - - b.ToTable("users", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null) - .WithMany() - .HasForeignKey("CreatedById") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null) - .WithMany() - .HasForeignKey("OperatorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.UserEntity", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} +// +using System; +using Deal.Infrastructure.Persistence; +using Deal.Infrastructure.Persistence.Configurations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Deal.Infrastructure.Migrations +{ + [DbContext(typeof(DealDbContext))] + [Migration("20260910194443_GlobalSettings")] + partial class GlobalSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.AuditLogEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("ActorType") + .IsRequired() + .HasColumnType("text"); + + b.Property("At") + .HasColumnType("timestamp with time zone"); + + b.Property("DetailJson") + .HasColumnType("text"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Ip") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("At"); + + b.HasIndex("TenantId", "EventType"); + + b.ToTable("audit_log", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.GlobalSettingEntity", b => + { + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("global_settings", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b => + { + b.Property("Code") + .HasColumnType("text"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedById") + .HasColumnType("uuid"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("pending"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Code"); + + b.HasIndex("CreatedById"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("\"Status\" = 'pending'"); + + b.ToTable("invites", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.HasKey("Id"); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("operators", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b => + { + b.Property("TokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OperatorId") + .HasColumnType("uuid"); + + b.HasKey("TokenHash"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("OperatorId"); + + b.ToTable("operator_sessions", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RateLimitCounterEntity", b => + { + b.Property("Key") + .HasColumnType("text"); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowStart") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("rate_limit_counters", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b => + { + b.Property("TokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ImpersonatedByOperatorId") + .HasColumnType("uuid"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("TokenHash"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("UserId"); + + b.ToTable("sessions", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("tenants", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b => + { + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("BudgetTokens") + .HasColumnType("bigint"); + + b.Property("NotifiedExhausted") + .HasColumnType("boolean"); + + b.Property("Period") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("month"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedTokens") + .HasColumnType("bigint"); + + b.Property("Warned80") + .HasColumnType("boolean"); + + b.HasKey("TenantId"); + + b.ToTable("tenant_limits", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("At") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletionTokens") + .HasColumnType("bigint"); + + b.Property("DetailJson") + .HasColumnType("text"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Model") + .IsRequired() + .HasColumnType("text"); + + b.Property("PromptTokens") + .HasColumnType("bigint"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TotalTokens") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("At"); + + b.HasIndex("TenantId", "At"); + + b.ToTable("token_usage_events", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Login") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("users", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null) + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null) + .WithMany() + .HasForeignKey("OperatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/core/Deal.Infrastructure/Migrations/20260910194443_GlobalSettings.cs b/src/core/Deal.Infrastructure/Migrations/20260910194443_GlobalSettings.cs index e38288c..3029d1f 100644 --- a/src/core/Deal.Infrastructure/Migrations/20260910194443_GlobalSettings.cs +++ b/src/core/Deal.Infrastructure/Migrations/20260910194443_GlobalSettings.cs @@ -1,36 +1,36 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Deal.Infrastructure.Migrations -{ - /// - public partial class GlobalSettings : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "global_settings", - schema: "public", - columns: table => new - { - Key = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - Value = table.Column(type: "text", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_global_settings", x => x.Key); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "global_settings", - schema: "public"); - } - } -} +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Deal.Infrastructure.Migrations +{ + /// + public partial class GlobalSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "global_settings", + schema: "public", + columns: table => new + { + Key = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Value = table.Column(type: "text", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_global_settings", x => x.Key); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "global_settings", + schema: "public"); + } + } +} diff --git a/src/core/Deal.Infrastructure/Migrations/DealDbContextModelSnapshot.cs b/src/core/Deal.Infrastructure/Migrations/DealDbContextModelSnapshot.cs index cf7d7fc..479421b 100644 --- a/src/core/Deal.Infrastructure/Migrations/DealDbContextModelSnapshot.cs +++ b/src/core/Deal.Infrastructure/Migrations/DealDbContextModelSnapshot.cs @@ -1,439 +1,439 @@ -// -using System; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Deal.Infrastructure.Migrations -{ - [DbContext(typeof(DealDbContext))] - partial class DealDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.11") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.AuditLogEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("ActorId") - .HasColumnType("uuid"); - - b.Property("ActorType") - .IsRequired() - .HasColumnType("text"); - - b.Property("At") - .HasColumnType("timestamp with time zone"); - - b.Property("DetailJson") - .HasColumnType("text"); - - b.Property("EventType") - .IsRequired() - .HasColumnType("text"); - - b.Property("Ip") - .HasColumnType("text"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("At"); - - b.HasIndex("TenantId", "EventType"); - - b.ToTable("audit_log", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.GlobalSettingEntity", b => - { - b.Property("Key") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Value") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Key"); - - b.ToTable("global_settings", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b => - { - b.Property("Code") - .HasColumnType("text"); - - b.Property("ActivatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedById") - .HasColumnType("uuid"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("pending"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Code"); - - b.HasIndex("CreatedById"); - - b.HasIndex("Email") - .IsUnique() - .HasFilter("\"Status\" = 'pending'"); - - b.ToTable("invites", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Login") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("text"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("active"); - - b.HasKey("Id"); - - b.HasIndex("Login") - .IsUnique(); - - b.ToTable("operators", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b => - { - b.Property("TokenHash") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Login") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("OperatorId") - .HasColumnType("uuid"); - - b.HasKey("TokenHash"); - - b.HasIndex("ExpiresAt"); - - b.HasIndex("OperatorId"); - - b.ToTable("operator_sessions", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RateLimitCounterEntity", b => - { - b.Property("Key") - .HasColumnType("text"); - - b.Property("Count") - .HasColumnType("integer"); - - b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone"); - - b.Property("WindowStart") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Key"); - - b.HasIndex("ExpiresAt"); - - b.ToTable("rate_limit_counters", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b => - { - b.Property("TokenHash") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ImpersonatedByOperatorId") - .HasColumnType("uuid"); - - b.Property("Login") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("TokenHash"); - - b.HasIndex("ExpiresAt"); - - b.HasIndex("UserId"); - - b.ToTable("sessions", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("tenants", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b => - { - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("BudgetTokens") - .HasColumnType("bigint"); - - b.Property("NotifiedExhausted") - .HasColumnType("boolean"); - - b.Property("Period") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("month"); - - b.Property("PeriodStart") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("UsedTokens") - .HasColumnType("bigint"); - - b.Property("Warned80") - .HasColumnType("boolean"); - - b.HasKey("TenantId"); - - b.ToTable("tenant_limits", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("At") - .HasColumnType("timestamp with time zone"); - - b.Property("CompletionTokens") - .HasColumnType("bigint"); - - b.Property("DetailJson") - .HasColumnType("text"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("Model") - .IsRequired() - .HasColumnType("text"); - - b.Property("PromptTokens") - .HasColumnType("bigint"); - - b.Property("Provider") - .IsRequired() - .HasColumnType("text"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("TotalTokens") - .HasColumnType("bigint"); - - b.HasKey("Id"); - - b.HasIndex("At"); - - b.HasIndex("TenantId", "At"); - - b.ToTable("token_usage_events", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - b.Property("Login") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("text"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("active"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("Login") - .IsUnique(); - - b.HasIndex("TenantId"); - - b.ToTable("users", "public"); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null) - .WithMany() - .HasForeignKey("CreatedById") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null) - .WithMany() - .HasForeignKey("OperatorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.UserEntity", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} +// +using System; +using Deal.Infrastructure.Persistence; +using Deal.Infrastructure.Persistence.Configurations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Deal.Infrastructure.Migrations +{ + [DbContext(typeof(DealDbContext))] + partial class DealDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.AuditLogEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("ActorType") + .IsRequired() + .HasColumnType("text"); + + b.Property("At") + .HasColumnType("timestamp with time zone"); + + b.Property("DetailJson") + .HasColumnType("text"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Ip") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("At"); + + b.HasIndex("TenantId", "EventType"); + + b.ToTable("audit_log", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.GlobalSettingEntity", b => + { + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("global_settings", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b => + { + b.Property("Code") + .HasColumnType("text"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedById") + .HasColumnType("uuid"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("pending"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Code"); + + b.HasIndex("CreatedById"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("\"Status\" = 'pending'"); + + b.ToTable("invites", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.HasKey("Id"); + + b.HasIndex("Login") + .IsUnique(); + + b.ToTable("operators", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b => + { + b.Property("TokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OperatorId") + .HasColumnType("uuid"); + + b.HasKey("TokenHash"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("OperatorId"); + + b.ToTable("operator_sessions", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RateLimitCounterEntity", b => + { + b.Property("Key") + .HasColumnType("text"); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowStart") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("rate_limit_counters", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b => + { + b.Property("TokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ImpersonatedByOperatorId") + .HasColumnType("uuid"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("TokenHash"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("UserId"); + + b.ToTable("sessions", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("tenants", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b => + { + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("BudgetTokens") + .HasColumnType("bigint"); + + b.Property("NotifiedExhausted") + .HasColumnType("boolean"); + + b.Property("Period") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("month"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedTokens") + .HasColumnType("bigint"); + + b.Property("Warned80") + .HasColumnType("boolean"); + + b.HasKey("TenantId"); + + b.ToTable("tenant_limits", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("At") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletionTokens") + .HasColumnType("bigint"); + + b.Property("DetailJson") + .HasColumnType("text"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Model") + .IsRequired() + .HasColumnType("text"); + + b.Property("PromptTokens") + .HasColumnType("bigint"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TotalTokens") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("At"); + + b.HasIndex("TenantId", "At"); + + b.ToTable("token_usage_events", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Login") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Login") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("users", "public"); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.InviteEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null) + .WithMany() + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.OperatorSessionEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.OperatorEntity", null) + .WithMany() + .HasForeignKey("OperatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.SessionEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.UserEntity", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantLimitEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TokenUsageEventEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.UserEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.TenantEntity", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs index 74f5dba..1f09be3 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs @@ -1,26 +1,26 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация записи аудита -/// -public sealed class AuditLogConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("audit_log", "public"); - - builder.HasKey(x => x.Id); - - builder.Property(x => x.At).IsRequired(); - builder.Property(x => x.ActorType).IsRequired(); - builder.Property(x => x.EventType).IsRequired(); - builder.Property(x => x.DetailJson).HasColumnType("text"); - - builder.HasIndex(x => x.At); - builder.HasIndex(x => new { x.TenantId, x.EventType }); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация записи аудита +/// +public sealed class AuditLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("audit_log", "public"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.At).IsRequired(); + builder.Property(x => x.ActorType).IsRequired(); + builder.Property(x => x.EventType).IsRequired(); + builder.Property(x => x.DetailJson).HasColumnType("text"); + + builder.HasIndex(x => x.At); + builder.HasIndex(x => new { x.TenantId, x.EventType }); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/CardConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/CardConfiguration.cs index 177c34d..7d80aec 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/CardConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/CardConfiguration.cs @@ -1,53 +1,53 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация карточки канбана -/// -public sealed class CardConfiguration : IEntityTypeConfiguration -{ - // Максимальная длина Col: служебные значения + id доски (короткие строки b_...). - private const int ColMaxLength = 200; - - // Максимальная длина вида источника. - private const int SourceKindMaxLength = 64; - - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("Cards"); - - builder.HasKey(x => x.Id); - - builder.Property(x => x.Col).HasMaxLength(ColMaxLength); - - // JSON-поля храним текстом с сериализованным JSON (как value_json настроек). - builder.Property(x => x.StackJson).HasColumnType("text"); - builder.Property(x => x.ContactsJson).HasColumnType("text"); - builder.Property(x => x.MatchHitsJson).HasColumnType("text"); - builder.Property(x => x.LinksJson).HasColumnType("text"); - builder.Property(x => x.FilesJson).HasColumnType("text"); - builder.Property(x => x.HistoryJson).HasColumnType("text"); - builder.Property(x => x.SourceKind).HasMaxLength(SourceKindMaxLength); - builder.Property(x => x.SourceExternalId).HasColumnType("text"); - builder.Property(x => x.SourceOriginRef).HasColumnType("text"); - builder.Property(x => x.SourceJson).HasColumnType("text"); - builder.Property(x => x.ContentJson).HasColumnType("text"); - builder.Property(x => x.SourceText).HasColumnType("text"); - - // Выборка колонки сортируется по времени получения (received_at DESC); счётчик новых — по (col, is_new). - builder.HasIndex(x => new { x.Col, x.ReceivedAt }).IsDescending(false, true); - builder.HasIndex(x => new { x.Col, x.IsNew }); - - // Сортировка пространства «Выбранные» — updated_at DESC. - builder.HasIndex(x => x.UpdatedAt).IsDescending(); - - builder.Property(x => x.SearchTsv) - .HasComputedColumnSql( - "to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))", - stored: true); - builder.HasIndex(x => x.SearchTsv).HasMethod("gin"); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация карточки канбана +/// +public sealed class CardConfiguration : IEntityTypeConfiguration +{ + // Максимальная длина Col: служебные значения + id доски (короткие строки b_...). + private const int ColMaxLength = 200; + + // Максимальная длина вида источника. + private const int SourceKindMaxLength = 64; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Cards"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Col).HasMaxLength(ColMaxLength); + + // JSON-поля храним текстом с сериализованным JSON (как value_json настроек). + builder.Property(x => x.StackJson).HasColumnType("text"); + builder.Property(x => x.ContactsJson).HasColumnType("text"); + builder.Property(x => x.MatchHitsJson).HasColumnType("text"); + builder.Property(x => x.LinksJson).HasColumnType("text"); + builder.Property(x => x.FilesJson).HasColumnType("text"); + builder.Property(x => x.HistoryJson).HasColumnType("text"); + builder.Property(x => x.SourceKind).HasMaxLength(SourceKindMaxLength); + builder.Property(x => x.SourceExternalId).HasColumnType("text"); + builder.Property(x => x.SourceOriginRef).HasColumnType("text"); + builder.Property(x => x.SourceJson).HasColumnType("text"); + builder.Property(x => x.ContentJson).HasColumnType("text"); + builder.Property(x => x.SourceText).HasColumnType("text"); + + // Выборка колонки сортируется по времени получения (received_at DESC); счётчик новых — по (col, is_new). + builder.HasIndex(x => new { x.Col, x.ReceivedAt }).IsDescending(false, true); + builder.HasIndex(x => new { x.Col, x.IsNew }); + + // Сортировка пространства «Выбранные» — updated_at DESC. + builder.HasIndex(x => x.UpdatedAt).IsDescending(); + + builder.Property(x => x.SearchTsv) + .HasComputedColumnSql( + "to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))", + stored: true); + builder.HasIndex(x => x.SearchTsv).HasMethod("gin"); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/CardMoveConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/CardMoveConfiguration.cs index 301794c..ba88199 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/CardMoveConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/CardMoveConfiguration.cs @@ -1,18 +1,18 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация журнала действий над карточками -/// -public sealed class CardMoveConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("CardMoves"); - - builder.HasKey(x => x.Id); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация журнала действий над карточками +/// +public sealed class CardMoveConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("CardMoves"); + + builder.HasKey(x => x.Id); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/ContainerConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/ContainerConfiguration.cs index b8efa15..00b9b30 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/ContainerConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/ContainerConfiguration.cs @@ -1,37 +1,37 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация единого контейнера -/// -public sealed class ContainerConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("Containers"); - - builder.HasKey(x => x.Id); - - builder.Property(x => x.Kind).HasMaxLength(20); - builder.Property(x => x.Space).HasMaxLength(20); - - // JSON-поля храним текстом с сериализованным JSON (конвенция value_json). - builder.Property(x => x.RulesJson).HasColumnType("text"); - builder.Property(x => x.PolicyJson).HasColumnType("text"); - builder.Property(x => x.Note).HasColumnType("text"); - - // Выборка пространства сортируется по позиции; ИИ-предложения — отдельно (как Boards). - builder.HasIndex(x => new { x.Space, x.Position }); - builder.HasIndex(x => new { x.Suggested, x.Position }); - - // Полнотекстовый поиск по контейнерам (title/description) — STORED-колонка, как Cards.SearchTsv. - builder.Property(x => x.SearchTsv) - .HasComputedColumnSql( - "to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", - stored: true); - builder.HasIndex(x => x.SearchTsv).HasMethod("gin"); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация единого контейнера +/// +public sealed class ContainerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Containers"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Kind).HasMaxLength(20); + builder.Property(x => x.Space).HasMaxLength(20); + + // JSON-поля храним текстом с сериализованным JSON (конвенция value_json). + builder.Property(x => x.RulesJson).HasColumnType("text"); + builder.Property(x => x.PolicyJson).HasColumnType("text"); + builder.Property(x => x.Note).HasColumnType("text"); + + // Выборка пространства сортируется по позиции; ИИ-предложения — отдельно (как Boards). + builder.HasIndex(x => new { x.Space, x.Position }); + builder.HasIndex(x => new { x.Suggested, x.Position }); + + // Полнотекстовый поиск по контейнерам (title/description) — STORED-колонка, как Cards.SearchTsv. + builder.Property(x => x.SearchTsv) + .HasComputedColumnSql( + "to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", + stored: true); + builder.HasIndex(x => x.SearchTsv).HasMethod("gin"); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/DedupEntryConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/DedupEntryConfiguration.cs index 79a8c26..fa433c4 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/DedupEntryConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/DedupEntryConfiguration.cs @@ -1,18 +1,18 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация дедуп-хэшей пайплайна -/// -public sealed class DedupEntryConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("DedupEntries"); - - builder.HasKey(x => x.Hash); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация дедуп-хэшей пайплайна +/// +public sealed class DedupEntryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("DedupEntries"); + + builder.HasKey(x => x.Hash); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/DialogConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/DialogConfiguration.cs index fb81da2..63b09d7 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/DialogConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/DialogConfiguration.cs @@ -1,20 +1,20 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация каталога диалогов -/// -public sealed class DialogConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("Dialogs"); - - builder.HasKey(x => x.Id); - - builder.Property(x => x.Hue).HasDefaultValue("#666"); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация каталога диалогов +/// +public sealed class DialogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Dialogs"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Hue).HasDefaultValue("#666"); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscBlacklistConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscBlacklistConfiguration.cs index e4087db..d1f1e78 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscBlacklistConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscBlacklistConfiguration.cs @@ -1,18 +1,18 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация чёрного списка Discovery -/// -public sealed class DiscBlacklistConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("DiscBlacklist"); - - builder.HasKey(x => x.DialogId); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация чёрного списка Discovery +/// +public sealed class DiscBlacklistConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("DiscBlacklist"); + + builder.HasKey(x => x.DialogId); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscCandidateConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscCandidateConfiguration.cs index 5a7f66a..dbb3345 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscCandidateConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscCandidateConfiguration.cs @@ -1,23 +1,23 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация кандидата Discovery -/// -public sealed class DiscCandidateConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("DiscCandidates"); - - builder.HasKey(x => x.DialogId); - - builder.Property(x => x.MarksJson).HasColumnType("text"); - builder.Property(x => x.TopicsJson).HasColumnType("text"); - - builder.HasIndex(x => new { x.TaskId, x.Status }); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация кандидата Discovery +/// +public sealed class DiscCandidateConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("DiscCandidates"); + + builder.HasKey(x => x.DialogId); + + builder.Property(x => x.MarksJson).HasColumnType("text"); + builder.Property(x => x.TopicsJson).HasColumnType("text"); + + builder.HasIndex(x => new { x.TaskId, x.Status }); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscLogConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscLogConfiguration.cs index 2979532..65a59f1 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscLogConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscLogConfiguration.cs @@ -1,20 +1,20 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация лога Discovery -/// -public sealed class DiscLogConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("DiscLog"); - - builder.HasKey(x => x.Id); - - builder.HasIndex(x => new { x.TaskId, x.CreatedAt }); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация лога Discovery +/// +public sealed class DiscLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("DiscLog"); + + builder.HasKey(x => x.Id); + + builder.HasIndex(x => new { x.TaskId, x.CreatedAt }); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscTaskConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscTaskConfiguration.cs index 342d52b..a63d8bd 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscTaskConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscTaskConfiguration.cs @@ -1,20 +1,20 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация задачи поиска Discovery -/// -public sealed class DiscTaskConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("DiscTasks"); - - builder.HasKey(x => x.Id); - - builder.Property(x => x.KeywordsJson).HasColumnType("text"); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация задачи поиска Discovery +/// +public sealed class DiscTaskConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("DiscTasks"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.KeywordsJson).HasColumnType("text"); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/GlobalSettingConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/GlobalSettingConfiguration.cs index 678b8b2..c86edbe 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/GlobalSettingConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/GlobalSettingConfiguration.cs @@ -1,24 +1,24 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация глобальной -/// -public sealed class GlobalSettingConfiguration : IEntityTypeConfiguration -{ - private const int KeyMaxLength = 200; - - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("global_settings", "public"); - - builder.HasKey(x => x.Key); - - builder.Property(x => x.Key).HasMaxLength(KeyMaxLength); - builder.Property(x => x.Value).HasColumnType("text").IsRequired(); - builder.Property(x => x.UpdatedAt).IsRequired(); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация глобальной +/// +public sealed class GlobalSettingConfiguration : IEntityTypeConfiguration +{ + private const int KeyMaxLength = 200; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("global_settings", "public"); + + builder.HasKey(x => x.Key); + + builder.Property(x => x.Key).HasMaxLength(KeyMaxLength); + builder.Property(x => x.Value).HasColumnType("text").IsRequired(); + builder.Property(x => x.UpdatedAt).IsRequired(); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/InviteConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/InviteConfiguration.cs index fe410a0..4d6adea 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/InviteConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/InviteConfiguration.cs @@ -1,36 +1,36 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация приглашения -/// -public sealed class InviteConfiguration : IEntityTypeConfiguration -{ - private const int EmailMaxLength = 200; - - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("invites", "public"); - - builder.HasKey(x => x.Code); - - builder.Property(x => x.Email).HasMaxLength(EmailMaxLength).IsRequired(); - builder.Property(x => x.TenantId); - builder.Property(x => x.Status).IsRequired().HasDefaultValue("pending"); - builder.Property(x => x.ExpiresAt).IsRequired(); - builder.Property(x => x.ActivatedAt); - builder.Property(x => x.CreatedAt).IsRequired(); - - // Email уникален среди активных (pending) инвайтов: после активации/отзыва/истечения он - // освобождается — глобальную уникальность регистрации держит unique-индекс users.Login. - builder.HasIndex(x => x.Email).IsUnique().HasFilter("\"Status\" = 'pending'"); - - builder.HasOne() - .WithMany() - .HasForeignKey(x => x.CreatedById) - .OnDelete(DeleteBehavior.Restrict); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация приглашения +/// +public sealed class InviteConfiguration : IEntityTypeConfiguration +{ + private const int EmailMaxLength = 200; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("invites", "public"); + + builder.HasKey(x => x.Code); + + builder.Property(x => x.Email).HasMaxLength(EmailMaxLength).IsRequired(); + builder.Property(x => x.TenantId); + builder.Property(x => x.Status).IsRequired().HasDefaultValue("pending"); + builder.Property(x => x.ExpiresAt).IsRequired(); + builder.Property(x => x.ActivatedAt); + builder.Property(x => x.CreatedAt).IsRequired(); + + // Email уникален среди активных (pending) инвайтов: после активации/отзыва/истечения он + // освобождается — глобальную уникальность регистрации держит unique-индекс users.Login. + builder.HasIndex(x => x.Email).IsUnique().HasFilter("\"Status\" = 'pending'"); + + builder.HasOne() + .WithMany() + .HasForeignKey(x => x.CreatedById) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/LeadCommentConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/LeadCommentConfiguration.cs index 10c8da2..2dd0450 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/LeadCommentConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/LeadCommentConfiguration.cs @@ -1,25 +1,25 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация комментария карточки -/// -public sealed class LeadCommentConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("LeadComments"); - - builder.HasKey(x => x.Id); - - builder.HasOne() - .WithMany() - .HasForeignKey(x => x.CardId) - .OnDelete(DeleteBehavior.Cascade); - - builder.HasIndex(x => x.CardId); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация комментария карточки +/// +public sealed class LeadCommentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("LeadComments"); + + builder.HasKey(x => x.Id); + + builder.HasOne() + .WithMany() + .HasForeignKey(x => x.CardId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(x => x.CardId); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/MlOutboxConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/MlOutboxConfiguration.cs index f627805..9870aaa 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/MlOutboxConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/MlOutboxConfiguration.cs @@ -1,21 +1,21 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация очереди обучающих сигналов ML -/// -public sealed class MlOutboxConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("MlOutbox"); - - builder.HasKey(x => x.Id); - - // Очистка/выборка outbox идёт по времени создания. - builder.HasIndex(x => x.CreatedAt); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация очереди обучающих сигналов ML +/// +public sealed class MlOutboxConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("MlOutbox"); + + builder.HasKey(x => x.Id); + + // Очистка/выборка outbox идёт по времени создания. + builder.HasIndex(x => x.CreatedAt); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorConfiguration.cs index e403fde..da53dba 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorConfiguration.cs @@ -1,27 +1,27 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация оператора -/// -public sealed class OperatorConfiguration : IEntityTypeConfiguration -{ - private const int LoginMaxLength = 200; - - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("operators", "public"); - - builder.HasKey(x => x.Id); - - builder.Property(x => x.Login).HasMaxLength(LoginMaxLength).IsRequired(); - builder.Property(x => x.PasswordHash).IsRequired(); - builder.Property(x => x.Status).IsRequired().HasDefaultValue("active"); - builder.Property(x => x.CreatedAt).IsRequired(); - - builder.HasIndex(x => x.Login).IsUnique(); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация оператора +/// +public sealed class OperatorConfiguration : IEntityTypeConfiguration +{ + private const int LoginMaxLength = 200; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("operators", "public"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Login).HasMaxLength(LoginMaxLength).IsRequired(); + builder.Property(x => x.PasswordHash).IsRequired(); + builder.Property(x => x.Status).IsRequired().HasDefaultValue("active"); + builder.Property(x => x.CreatedAt).IsRequired(); + + builder.HasIndex(x => x.Login).IsUnique(); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorSessionConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorSessionConfiguration.cs index 9596122..81edd07 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorSessionConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorSessionConfiguration.cs @@ -1,34 +1,34 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация сессии оператора -/// -public sealed class OperatorSessionConfiguration : IEntityTypeConfiguration -{ - private const int TokenHashMaxLength = 64; - private const int LoginMaxLength = 200; - - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("operator_sessions", "public"); - - builder.HasKey(x => x.TokenHash); - - builder.Property(x => x.TokenHash).HasMaxLength(TokenHashMaxLength); - builder.Property(x => x.Login).HasMaxLength(LoginMaxLength).IsRequired(); - builder.Property(x => x.ExpiresAt).IsRequired(); - builder.Property(x => x.CreatedAt).IsRequired(); - - builder.HasIndex(x => x.OperatorId); - builder.HasIndex(x => x.ExpiresAt); - - builder.HasOne() - .WithMany() - .HasForeignKey(x => x.OperatorId) - .OnDelete(DeleteBehavior.Cascade); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация сессии оператора +/// +public sealed class OperatorSessionConfiguration : IEntityTypeConfiguration +{ + private const int TokenHashMaxLength = 64; + private const int LoginMaxLength = 200; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("operator_sessions", "public"); + + builder.HasKey(x => x.TokenHash); + + builder.Property(x => x.TokenHash).HasMaxLength(TokenHashMaxLength); + builder.Property(x => x.Login).HasMaxLength(LoginMaxLength).IsRequired(); + builder.Property(x => x.ExpiresAt).IsRequired(); + builder.Property(x => x.CreatedAt).IsRequired(); + + builder.HasIndex(x => x.OperatorId); + builder.HasIndex(x => x.ExpiresAt); + + builder.HasOne() + .WithMany() + .HasForeignKey(x => x.OperatorId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/QueueItemConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/QueueItemConfiguration.cs index b8dd702..f45fad6 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/QueueItemConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/QueueItemConfiguration.cs @@ -1,29 +1,29 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация очереди входящих пайплайна -/// -public sealed class QueueItemConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("QueueItems"); - - builder.HasKey(x => x.Id); - - builder.Property(x => x.Text).HasColumnType("text"); - builder.Property(x => x.SourceKey).HasColumnType("text"); - builder.Property(x => x.SourceJson).HasColumnType("text"); - builder.Property(x => x.ContentJson).HasColumnType("text"); - - // Выборка pump'а идёт по статусу и времени постановки (status='new', лимит 12). - builder.HasIndex(x => new { x.Status, x.CreatedAt }); - - // Дубль-гвард приёма ищет строку по ключу записи источника. - builder.HasIndex(x => x.SourceKey); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация очереди входящих пайплайна +/// +public sealed class QueueItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("QueueItems"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Text).HasColumnType("text"); + builder.Property(x => x.SourceKey).HasColumnType("text"); + builder.Property(x => x.SourceJson).HasColumnType("text"); + builder.Property(x => x.ContentJson).HasColumnType("text"); + + // Выборка pump'а идёт по статусу и времени постановки (status='new', лимит 12). + builder.HasIndex(x => new { x.Status, x.CreatedAt }); + + // Дубль-гвард приёма ищет строку по ключу записи источника. + builder.HasIndex(x => x.SourceKey); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/RateLimitCounterConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/RateLimitCounterConfiguration.cs index bfd302e..1a49f55 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/RateLimitCounterConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/RateLimitCounterConfiguration.cs @@ -1,25 +1,25 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация счётчика фиксированного окна -/// -public sealed class RateLimitCounterConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("rate_limit_counters", "public"); - - builder.HasKey(x => x.Key); - builder.Property(x => x.Key).HasColumnType("text"); - builder.Property(x => x.WindowStart).IsRequired(); - builder.Property(x => x.ExpiresAt).IsRequired(); - builder.Property(x => x.Count).IsRequired(); - - // Индекс для фоновой уборки устаревших окон (DeleteExpiredAsync по ExpiresAt). - builder.HasIndex(x => x.ExpiresAt); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация счётчика фиксированного окна +/// +public sealed class RateLimitCounterConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("rate_limit_counters", "public"); + + builder.HasKey(x => x.Key); + builder.Property(x => x.Key).HasColumnType("text"); + builder.Property(x => x.WindowStart).IsRequired(); + builder.Property(x => x.ExpiresAt).IsRequired(); + builder.Property(x => x.Count).IsRequired(); + + // Индекс для фоновой уборки устаревших окон (DeleteExpiredAsync по ExpiresAt). + builder.HasIndex(x => x.ExpiresAt); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/RejectedItemConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/RejectedItemConfiguration.cs index 9fa1a05..ba22460 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/RejectedItemConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/RejectedItemConfiguration.cs @@ -1,33 +1,33 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация отсева пайплайна -/// -public sealed class RejectedItemConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("RejectedItems"); - - builder.HasKey(x => x.Id); - - builder.Property(x => x.Text).HasColumnType("text"); - builder.Property(x => x.Reason).HasColumnType("text"); - builder.Property(x => x.Kw).HasColumnType("text"); - builder.Property(x => x.SourceKey).HasColumnType("text"); - builder.Property(x => x.SourceJson).HasColumnType("text"); - builder.Property(x => x.ContentJson).HasColumnType("text"); - - builder.Property(x => x.SearchTsv) - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", stored: true); - - // Автоочистка старше 3 суток и сортировка списка идут по времени отсева. - builder.HasIndex(x => x.RejectedAt); - - builder.HasIndex(x => x.SearchTsv).HasMethod("gin"); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация отсева пайплайна +/// +public sealed class RejectedItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("RejectedItems"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Text).HasColumnType("text"); + builder.Property(x => x.Reason).HasColumnType("text"); + builder.Property(x => x.Kw).HasColumnType("text"); + builder.Property(x => x.SourceKey).HasColumnType("text"); + builder.Property(x => x.SourceJson).HasColumnType("text"); + builder.Property(x => x.ContentJson).HasColumnType("text"); + + builder.Property(x => x.SearchTsv) + .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", stored: true); + + // Автоочистка старше 3 суток и сортировка списка идут по времени отсева. + builder.HasIndex(x => x.RejectedAt); + + builder.HasIndex(x => x.SearchTsv).HasMethod("gin"); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/SessionConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/SessionConfiguration.cs index 7ebfd6f..78f2b49 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/SessionConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/SessionConfiguration.cs @@ -1,34 +1,34 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация сессии -/// -public sealed class SessionConfiguration : IEntityTypeConfiguration -{ - private const int TokenHashMaxLength = 64; - private const int LoginMaxLength = 200; - - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("sessions", "public"); - - builder.HasKey(x => x.TokenHash); - - builder.Property(x => x.TokenHash).HasMaxLength(TokenHashMaxLength); - builder.Property(x => x.Login).HasMaxLength(LoginMaxLength).IsRequired(); - builder.Property(x => x.ExpiresAt).IsRequired(); - builder.Property(x => x.CreatedAt).IsRequired(); - - builder.HasIndex(x => x.UserId); - builder.HasIndex(x => x.ExpiresAt); - - builder.HasOne() - .WithMany() - .HasForeignKey(x => x.UserId) - .OnDelete(DeleteBehavior.Cascade); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация сессии +/// +public sealed class SessionConfiguration : IEntityTypeConfiguration +{ + private const int TokenHashMaxLength = 64; + private const int LoginMaxLength = 200; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("sessions", "public"); + + builder.HasKey(x => x.TokenHash); + + builder.Property(x => x.TokenHash).HasMaxLength(TokenHashMaxLength); + builder.Property(x => x.Login).HasMaxLength(LoginMaxLength).IsRequired(); + builder.Property(x => x.ExpiresAt).IsRequired(); + builder.Property(x => x.CreatedAt).IsRequired(); + + builder.HasIndex(x => x.UserId); + builder.HasIndex(x => x.ExpiresAt); + + builder.HasOne() + .WithMany() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/TenantConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/TenantConfiguration.cs index 6d789ac..7f4dcbd 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/TenantConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/TenantConfiguration.cs @@ -1,22 +1,22 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация тенанта -/// -public sealed class TenantConfiguration : IEntityTypeConfiguration -{ - private const int NameMaxLength = 200; - - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("tenants", "public"); - - builder.HasKey(x => x.Id); - - builder.Property(x => x.Name).HasMaxLength(NameMaxLength).IsRequired(); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация тенанта +/// +public sealed class TenantConfiguration : IEntityTypeConfiguration +{ + private const int NameMaxLength = 200; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tenants", "public"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Name).HasMaxLength(NameMaxLength).IsRequired(); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/TenantLimitConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/TenantLimitConfiguration.cs index f089cf0..2a1cd2e 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/TenantLimitConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/TenantLimitConfiguration.cs @@ -1,31 +1,31 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация лимита тенанта -/// -public sealed class TenantLimitConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("tenant_limits", "public"); - - builder.HasKey(x => x.TenantId); - - builder.Property(x => x.BudgetTokens).IsRequired(); - builder.Property(x => x.Period).IsRequired().HasDefaultValue("month"); - builder.Property(x => x.PeriodStart).IsRequired(); - builder.Property(x => x.UsedTokens).IsRequired(); - builder.Property(x => x.Warned80).IsRequired(); - builder.Property(x => x.NotifiedExhausted).IsRequired(); - builder.Property(x => x.UpdatedAt).IsRequired(); - - builder.HasOne() - .WithMany() - .HasForeignKey(x => x.TenantId) - .OnDelete(DeleteBehavior.Restrict); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация лимита тенанта +/// +public sealed class TenantLimitConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tenant_limits", "public"); + + builder.HasKey(x => x.TenantId); + + builder.Property(x => x.BudgetTokens).IsRequired(); + builder.Property(x => x.Period).IsRequired().HasDefaultValue("month"); + builder.Property(x => x.PeriodStart).IsRequired(); + builder.Property(x => x.UsedTokens).IsRequired(); + builder.Property(x => x.Warned80).IsRequired(); + builder.Property(x => x.NotifiedExhausted).IsRequired(); + builder.Property(x => x.UpdatedAt).IsRequired(); + + builder.HasOne() + .WithMany() + .HasForeignKey(x => x.TenantId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/TenantSettingConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/TenantSettingConfiguration.cs index c1df3a6..68eeb16 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/TenantSettingConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/TenantSettingConfiguration.cs @@ -1,24 +1,24 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация настройки тенанта -/// -public sealed class TenantSettingConfiguration : IEntityTypeConfiguration -{ - private const int KeyMaxLength = 200; - - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("settings"); - - builder.HasKey(x => x.Key); - - builder.Property(x => x.Key).HasMaxLength(KeyMaxLength); - builder.Property(x => x.ValueJson).HasColumnType("text").IsRequired(); - builder.Property(x => x.UpdatedAt).IsRequired(); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация настройки тенанта +/// +public sealed class TenantSettingConfiguration : IEntityTypeConfiguration +{ + private const int KeyMaxLength = 200; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("settings"); + + builder.HasKey(x => x.Key); + + builder.Property(x => x.Key).HasMaxLength(KeyMaxLength); + builder.Property(x => x.ValueJson).HasColumnType("text").IsRequired(); + builder.Property(x => x.UpdatedAt).IsRequired(); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/TgMessageConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/TgMessageConfiguration.cs index 5e8ceb1..631b55e 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/TgMessageConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/TgMessageConfiguration.cs @@ -1,20 +1,20 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация превью-сообщений -/// -public sealed class TgMessageConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("TgMessages"); - - builder.HasKey(x => x.Id); - - builder.HasIndex(x => new { x.DialogId, x.MsgAt }); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация превью-сообщений +/// +public sealed class TgMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("TgMessages"); + + builder.HasKey(x => x.Id); + + builder.HasIndex(x => new { x.DialogId, x.MsgAt }); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/TokenUsageEventConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/TokenUsageEventConfiguration.cs index cf07742..551c130 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/TokenUsageEventConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/TokenUsageEventConfiguration.cs @@ -1,36 +1,36 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация события расхода токенов -/// -public sealed class TokenUsageEventConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("token_usage_events", "public"); - - builder.HasKey(x => x.Id); - - builder.Property(x => x.TenantId).IsRequired(); - builder.Property(x => x.At).IsRequired(); - builder.Property(x => x.Provider).IsRequired(); - builder.Property(x => x.Model).IsRequired(); - builder.Property(x => x.Kind).IsRequired(); - builder.Property(x => x.PromptTokens).IsRequired(); - builder.Property(x => x.CompletionTokens).IsRequired(); - builder.Property(x => x.TotalTokens).IsRequired(); - builder.Property(x => x.DetailJson).HasColumnType("text"); - - builder.HasIndex(x => new { x.TenantId, x.At }); - builder.HasIndex(x => x.At); - - builder.HasOne() - .WithMany() - .HasForeignKey(x => x.TenantId) - .OnDelete(DeleteBehavior.Restrict); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация события расхода токенов +/// +public sealed class TokenUsageEventConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("token_usage_events", "public"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.At).IsRequired(); + builder.Property(x => x.Provider).IsRequired(); + builder.Property(x => x.Model).IsRequired(); + builder.Property(x => x.Kind).IsRequired(); + builder.Property(x => x.PromptTokens).IsRequired(); + builder.Property(x => x.CompletionTokens).IsRequired(); + builder.Property(x => x.TotalTokens).IsRequired(); + builder.Property(x => x.DetailJson).HasColumnType("text"); + + builder.HasIndex(x => new { x.TenantId, x.At }); + builder.HasIndex(x => x.At); + + builder.HasOne() + .WithMany() + .HasForeignKey(x => x.TenantId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/UserConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/UserConfiguration.cs index a58d63b..c56c7f8 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/UserConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/UserConfiguration.cs @@ -1,33 +1,33 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация пользователя -/// -public sealed class UserConfiguration : IEntityTypeConfiguration -{ - private const int LoginMaxLength = 200; - - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("users", "public"); - - builder.HasKey(x => x.Id); - - builder.Property(x => x.Login).HasMaxLength(LoginMaxLength).IsRequired(); - builder.Property(x => x.PasswordHash).IsRequired(); - builder.Property(x => x.Status).IsRequired().HasDefaultValue("active"); - builder.Property(x => x.CreatedAt).IsRequired().HasDefaultValueSql("now()"); - - builder.HasIndex(x => x.Login).IsUnique(); - builder.HasIndex(x => x.TenantId); - - builder.HasOne() - .WithMany() - .HasForeignKey(x => x.TenantId) - .OnDelete(DeleteBehavior.Restrict); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация пользователя +/// +public sealed class UserConfiguration : IEntityTypeConfiguration +{ + private const int LoginMaxLength = 200; + + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("users", "public"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Login).HasMaxLength(LoginMaxLength).IsRequired(); + builder.Property(x => x.PasswordHash).IsRequired(); + builder.Property(x => x.Status).IsRequired().HasDefaultValue("active"); + builder.Property(x => x.CreatedAt).IsRequired().HasDefaultValueSql("now()"); + + builder.HasIndex(x => x.Login).IsUnique(); + builder.HasIndex(x => x.TenantId); + + builder.HasOne() + .WithMany() + .HasForeignKey(x => x.TenantId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/DealDbContext.cs b/src/core/Deal.Infrastructure/Persistence/DealDbContext.cs index a45692d..314dcad 100644 --- a/src/core/Deal.Infrastructure/Persistence/DealDbContext.cs +++ b/src/core/Deal.Infrastructure/Persistence/DealDbContext.cs @@ -1,57 +1,57 @@ -using Deal.Infrastructure.Persistence.Configurations; -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence; - -/// -/// Системный DbContext -/// -public sealed class DealDbContext(DbContextOptions options) : DbContext(options) -{ - public DbSet Tenants => Set(); - - public DbSet Users => Set(); - - public DbSet Sessions => Set(); - - public DbSet Operators => Set(); - - public DbSet OperatorSessions => Set(); - - public DbSet Invites => Set(); - - public DbSet TenantLimits => Set(); - - public DbSet AuditLog => Set(); - - /// - /// История расхода токенов. - /// - public DbSet TokenUsageEvents => Set(); - - /// - /// Счётчики фиксированного окна - /// - public DbSet RateLimitCounters => Set(); - - /// - /// Глобальные (системные) настройки оператора - /// - public DbSet GlobalSettings => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.ApplyConfiguration(new TenantConfiguration()); - modelBuilder.ApplyConfiguration(new UserConfiguration()); - modelBuilder.ApplyConfiguration(new SessionConfiguration()); - modelBuilder.ApplyConfiguration(new OperatorConfiguration()); - modelBuilder.ApplyConfiguration(new OperatorSessionConfiguration()); - modelBuilder.ApplyConfiguration(new InviteConfiguration()); - modelBuilder.ApplyConfiguration(new TenantLimitConfiguration()); - modelBuilder.ApplyConfiguration(new AuditLogConfiguration()); - modelBuilder.ApplyConfiguration(new TokenUsageEventConfiguration()); - modelBuilder.ApplyConfiguration(new RateLimitCounterConfiguration()); - modelBuilder.ApplyConfiguration(new GlobalSettingConfiguration()); - } -} +using Deal.Infrastructure.Persistence.Configurations; +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence; + +/// +/// Системный DbContext +/// +public sealed class DealDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Tenants => Set(); + + public DbSet Users => Set(); + + public DbSet Sessions => Set(); + + public DbSet Operators => Set(); + + public DbSet OperatorSessions => Set(); + + public DbSet Invites => Set(); + + public DbSet TenantLimits => Set(); + + public DbSet AuditLog => Set(); + + /// + /// История расхода токенов. + /// + public DbSet TokenUsageEvents => Set(); + + /// + /// Счётчики фиксированного окна + /// + public DbSet RateLimitCounters => Set(); + + /// + /// Глобальные (системные) настройки оператора + /// + public DbSet GlobalSettings => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new TenantConfiguration()); + modelBuilder.ApplyConfiguration(new UserConfiguration()); + modelBuilder.ApplyConfiguration(new SessionConfiguration()); + modelBuilder.ApplyConfiguration(new OperatorConfiguration()); + modelBuilder.ApplyConfiguration(new OperatorSessionConfiguration()); + modelBuilder.ApplyConfiguration(new InviteConfiguration()); + modelBuilder.ApplyConfiguration(new TenantLimitConfiguration()); + modelBuilder.ApplyConfiguration(new AuditLogConfiguration()); + modelBuilder.ApplyConfiguration(new TokenUsageEventConfiguration()); + modelBuilder.ApplyConfiguration(new RateLimitCounterConfiguration()); + modelBuilder.ApplyConfiguration(new GlobalSettingConfiguration()); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/CardEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/CardEntity.cs index 38701b6..a389a7f 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/CardEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/CardEntity.cs @@ -1,189 +1,189 @@ -using NpgsqlTypes; - -namespace Deal.Infrastructure.Persistence.Entities; - -/// -/// Карточка канбана -/// -public sealed class CardEntity -{ - /// - /// Короткий id карточки - /// - public string Id { get; set; } = string.Empty; - - /// - /// Колонка карточки - /// - public string Col { get; set; } = string.Empty; - - /// - /// Признак новой карточки - /// - public bool IsNew { get; set; } = true; - - /// - /// Признак «создано локально вручную» - /// - public bool Local { get; set; } - - /// - /// Признак «найм/разовое», проставленный эвристикой - /// - public bool IsVacancy { get; set; } - - /// - /// True, когда тип «найм/разовое» подтверждён ИИ по контексту сообщения. - /// - public bool IsVacancyKnown { get; set; } - - /// - /// Заголовок карточки - /// - public string Title { get; set; } = string.Empty; - - /// - /// Краткое содержание карточки - /// - public string Summary { get; set; } = string.Empty; - - /// - /// Стек/направления, сериализованные в JSON - /// - public string StackJson { get; set; } = "[]"; - - /// - /// Нижняя граница бюджета - /// - public double? BudgetFrom { get; set; } - - /// - /// Верхняя граница бюджета - /// - public double? BudgetTo { get; set; } - - /// - /// Валюта бюджета - /// - public string BudgetCur { get; set; } = string.Empty; - - /// - /// Сконвертированная нижняя граница бюджета в целевую валюту, либо null. - /// - public double? ConvFrom { get; set; } - - /// - /// Сконвертированная верхняя граница бюджета в целевую валюту, либо null. - /// - public double? ConvTo { get; set; } - - /// - /// Валюта сконвертированного бюджета - /// - public string ConvCur { get; set; } = string.Empty; - - /// - /// Контактная строка «как в сообщении» - /// - public string Contact { get; set; } = string.Empty; - - /// - /// Квалифицированные контакты, сериализованные в JSON - /// - public string ContactsJson { get; set; } = "[]"; - - /// - /// Время получения исходного сообщения - /// - public DateTimeOffset ReceivedAt { get; set; } - - /// - /// Вид источника - /// - public string SourceKind { get; set; } = string.Empty; - - /// - /// Идентификатор записи в источнике - /// - public string SourceExternalId { get; set; } = string.Empty; - - /// - /// Ссылка на оригинал в источнике - /// - public string SourceOriginRef { get; set; } = string.Empty; - - /// - /// Ссылка на источник, сериализованная в JSON - /// - public string SourceJson { get; set; } = string.Empty; - - /// - /// Содержимое источника, сериализованное в JSON - /// - public string ContentJson { get; set; } = string.Empty; - - /// - /// Текст источника для полнотекстового поиска - /// - public string SourceText { get; set; } = string.Empty; - - /// - /// Предыдущая колонка - /// - public string PrevCol { get; set; } = "inbox"; - - /// - /// Время помещения в архив - /// - public DateTimeOffset? ArchivedAt { get; set; } - - /// - /// Совпавшие критерии правил при попадании в колонку, сериализованные в JSON - /// - public string MatchHitsJson { get; set; } = "[]"; - - /// - /// Ссылки карточки, сериализованные в JSON - /// - public string LinksJson { get; set; } = "[]"; - - /// - /// Файлы карточки, сериализованные в JSON - /// - public string FilesJson { get; set; } = "[]"; - - /// - /// История движения карточки, сериализованная в JSON - /// - public string HistoryJson { get; set; } = "[]"; - - /// - /// Текст технического задания по карточке - /// - public string TzText { get; set; } = string.Empty; - - /// - /// Время напоминания об отложенной карточке, либо null - /// - public DateTimeOffset? ReminderAt { get; set; } - - /// - /// Признак «напоминание уже выстрелило» - /// - public bool ReminderFired { get; set; } - - /// - /// Полнотекстовый вектор - /// - public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty; - - /// - /// Время создания карточки. - /// - public DateTimeOffset CreatedAt { get; set; } - - /// - /// Время последнего изменения карточки - /// - public DateTimeOffset UpdatedAt { get; set; } -} +using NpgsqlTypes; + +namespace Deal.Infrastructure.Persistence.Entities; + +/// +/// Карточка канбана +/// +public sealed class CardEntity +{ + /// + /// Короткий id карточки + /// + public string Id { get; set; } = string.Empty; + + /// + /// Колонка карточки + /// + public string Col { get; set; } = string.Empty; + + /// + /// Признак новой карточки + /// + public bool IsNew { get; set; } = true; + + /// + /// Признак «создано локально вручную» + /// + public bool Local { get; set; } + + /// + /// Признак «найм/разовое», проставленный эвристикой + /// + public bool IsVacancy { get; set; } + + /// + /// True, когда тип «найм/разовое» подтверждён ИИ по контексту сообщения. + /// + public bool IsVacancyKnown { get; set; } + + /// + /// Заголовок карточки + /// + public string Title { get; set; } = string.Empty; + + /// + /// Краткое содержание карточки + /// + public string Summary { get; set; } = string.Empty; + + /// + /// Стек/направления, сериализованные в JSON + /// + public string StackJson { get; set; } = "[]"; + + /// + /// Нижняя граница бюджета + /// + public double? BudgetFrom { get; set; } + + /// + /// Верхняя граница бюджета + /// + public double? BudgetTo { get; set; } + + /// + /// Валюта бюджета + /// + public string BudgetCur { get; set; } = string.Empty; + + /// + /// Сконвертированная нижняя граница бюджета в целевую валюту, либо null. + /// + public double? ConvFrom { get; set; } + + /// + /// Сконвертированная верхняя граница бюджета в целевую валюту, либо null. + /// + public double? ConvTo { get; set; } + + /// + /// Валюта сконвертированного бюджета + /// + public string ConvCur { get; set; } = string.Empty; + + /// + /// Контактная строка «как в сообщении» + /// + public string Contact { get; set; } = string.Empty; + + /// + /// Квалифицированные контакты, сериализованные в JSON + /// + public string ContactsJson { get; set; } = "[]"; + + /// + /// Время получения исходного сообщения + /// + public DateTimeOffset ReceivedAt { get; set; } + + /// + /// Вид источника + /// + public string SourceKind { get; set; } = string.Empty; + + /// + /// Идентификатор записи в источнике + /// + public string SourceExternalId { get; set; } = string.Empty; + + /// + /// Ссылка на оригинал в источнике + /// + public string SourceOriginRef { get; set; } = string.Empty; + + /// + /// Ссылка на источник, сериализованная в JSON + /// + public string SourceJson { get; set; } = string.Empty; + + /// + /// Содержимое источника, сериализованное в JSON + /// + public string ContentJson { get; set; } = string.Empty; + + /// + /// Текст источника для полнотекстового поиска + /// + public string SourceText { get; set; } = string.Empty; + + /// + /// Предыдущая колонка + /// + public string PrevCol { get; set; } = "inbox"; + + /// + /// Время помещения в архив + /// + public DateTimeOffset? ArchivedAt { get; set; } + + /// + /// Совпавшие критерии правил при попадании в колонку, сериализованные в JSON + /// + public string MatchHitsJson { get; set; } = "[]"; + + /// + /// Ссылки карточки, сериализованные в JSON + /// + public string LinksJson { get; set; } = "[]"; + + /// + /// Файлы карточки, сериализованные в JSON + /// + public string FilesJson { get; set; } = "[]"; + + /// + /// История движения карточки, сериализованная в JSON + /// + public string HistoryJson { get; set; } = "[]"; + + /// + /// Текст технического задания по карточке + /// + public string TzText { get; set; } = string.Empty; + + /// + /// Время напоминания об отложенной карточке, либо null + /// + public DateTimeOffset? ReminderAt { get; set; } + + /// + /// Признак «напоминание уже выстрелило» + /// + public bool ReminderFired { get; set; } + + /// + /// Полнотекстовый вектор + /// + public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty; + + /// + /// Время создания карточки. + /// + public DateTimeOffset CreatedAt { get; set; } + + /// + /// Время последнего изменения карточки + /// + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/ContainerEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/ContainerEntity.cs index b4428b9..95297d4 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/ContainerEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/ContainerEntity.cs @@ -1,76 +1,76 @@ -using NpgsqlTypes; - -namespace Deal.Infrastructure.Persistence.Entities; - -/// -/// Единый контейнер карточек -/// -public sealed class ContainerEntity -{ - /// - /// Короткий id контейнера - /// - public string Id { get; set; } = string.Empty; - - /// - /// Имя для отображения - /// - public string Name { get; set; } = string.Empty; - - /// - /// Описание контейнера - /// - public string Description { get; set; } = string.Empty; - - /// - /// Цвет (hex). - /// - public string Color { get; set; } = "#818cf8"; - - /// - /// Позиция в пространстве - /// - public int Position { get; set; } - - /// - /// Вид контейнера: board - /// - public string Kind { get; set; } = "board"; - - /// - /// Пространство: dashboard | selected - /// - public string Space { get; set; } = "dashboard"; - - /// - /// Свёрнутость колонки на дашборде - /// - public bool Collapsed { get; set; } - - /// - /// Признак ИИ-предложения - /// - public bool Suggested { get; set; } - - /// - /// Правила маршрутизации - /// - public string RulesJson { get; set; } = "{}"; - - /// - /// Заметка контейнера - /// - public string Note { get; set; } = string.Empty; - - /// - /// Политика контейнера - /// - public string PolicyJson { get; set; } = "{}"; - - /// - /// Полнотекстовый вектор поиска по контейнерам - /// - public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty; - - public DateTimeOffset CreatedAt { get; set; } -} +using NpgsqlTypes; + +namespace Deal.Infrastructure.Persistence.Entities; + +/// +/// Единый контейнер карточек +/// +public sealed class ContainerEntity +{ + /// + /// Короткий id контейнера + /// + public string Id { get; set; } = string.Empty; + + /// + /// Имя для отображения + /// + public string Name { get; set; } = string.Empty; + + /// + /// Описание контейнера + /// + public string Description { get; set; } = string.Empty; + + /// + /// Цвет (hex). + /// + public string Color { get; set; } = "#818cf8"; + + /// + /// Позиция в пространстве + /// + public int Position { get; set; } + + /// + /// Вид контейнера: board + /// + public string Kind { get; set; } = "board"; + + /// + /// Пространство: dashboard | selected + /// + public string Space { get; set; } = "dashboard"; + + /// + /// Свёрнутость колонки на дашборде + /// + public bool Collapsed { get; set; } + + /// + /// Признак ИИ-предложения + /// + public bool Suggested { get; set; } + + /// + /// Правила маршрутизации + /// + public string RulesJson { get; set; } = "{}"; + + /// + /// Заметка контейнера + /// + public string Note { get; set; } = string.Empty; + + /// + /// Политика контейнера + /// + public string PolicyJson { get; set; } = "{}"; + + /// + /// Полнотекстовый вектор поиска по контейнерам + /// + public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty; + + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs index 843cc92..8a4c29b 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs @@ -1,19 +1,19 @@ -namespace Deal.Infrastructure.Persistence.Entities; - -/// -/// Глобальная (системная) настройка оператора -/// -public sealed class GlobalSettingEntity -{ - public string Key { get; set; } = string.Empty; - - /// - /// Значение настройки, сериализованное в JSON. - /// - public string Value { get; set; } = string.Empty; - - /// - /// Время последнего изменения - /// - public DateTimeOffset UpdatedAt { get; set; } -} +namespace Deal.Infrastructure.Persistence.Entities; + +/// +/// Глобальная (системная) настройка оператора +/// +public sealed class GlobalSettingEntity +{ + public string Key { get; set; } = string.Empty; + + /// + /// Значение настройки, сериализованное в JSON. + /// + public string Value { get; set; } = string.Empty; + + /// + /// Время последнего изменения + /// + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/RateLimitCounterEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/RateLimitCounterEntity.cs index 22f2648..2d84f61 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/RateLimitCounterEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/RateLimitCounterEntity.cs @@ -1,27 +1,27 @@ -namespace Deal.Infrastructure.Persistence.Entities; - -/// -/// Счётчик фиксированного окна в системной схеме public. -/// -public sealed class RateLimitCounterEntity -{ - /// - /// Уникальный ключ счётчика - /// - public string Key { get; set; } = string.Empty; - - /// - /// Начало текущего фиксированного окна - /// - public DateTimeOffset WindowStart { get; set; } - - /// - /// Момент, после которого строка считается устаревшей - /// - public DateTimeOffset ExpiresAt { get; set; } - - /// - /// Число потреблённых единиц в текущем окне. - /// - public int Count { get; set; } -} +namespace Deal.Infrastructure.Persistence.Entities; + +/// +/// Счётчик фиксированного окна в системной схеме public. +/// +public sealed class RateLimitCounterEntity +{ + /// + /// Уникальный ключ счётчика + /// + public string Key { get; set; } = string.Empty; + + /// + /// Начало текущего фиксированного окна + /// + public DateTimeOffset WindowStart { get; set; } + + /// + /// Момент, после которого строка считается устаревшей + /// + public DateTimeOffset ExpiresAt { get; set; } + + /// + /// Число потреблённых единиц в текущем окне. + /// + public int Count { get; set; } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/RejectedItemEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/RejectedItemEntity.cs index 4dd2ae5..f8b3846 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/RejectedItemEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/RejectedItemEntity.cs @@ -1,84 +1,84 @@ -using NpgsqlTypes; - -namespace Deal.Infrastructure.Persistence.Entities; - -/// -/// Запись отсева пайплайна -/// -public sealed class RejectedItemEntity -{ - /// - /// Короткий id записи - /// - public string Id { get; set; } = string.Empty; - - /// - /// Ключ дедупликации записи источника - /// - public string SourceKey { get; set; } = string.Empty; - - /// - /// Ссылка на источник, сериализованная в JSON - /// - public string SourceJson { get; set; } = string.Empty; - - /// - /// Содержимое источника, сериализованное в JSON - /// - public string ContentJson { get; set; } = string.Empty; - - /// - /// Текст сообщения - /// - public string Text { get; set; } = string.Empty; - - /// - /// Этап отсева (length|stop|resume|type|budget|stale|dup|spam_ml|spam_ai|filter_ai и т.п.). - /// - public string Stage { get; set; } = string.Empty; - - /// - /// Человекочитаемая причина отсева - /// - public string Reason { get; set; } = string.Empty; - - /// - /// Совпавшее ключевое слово/фраза правила - /// - public string Kw { get; set; } = string.Empty; - - /// - /// Кто вынес решение - /// - public string Source { get; set; } = "stop"; - - /// - /// Время получения исходного сообщения - /// - public DateTimeOffset MsgAt { get; set; } - - /// - /// Время записи в отсев - /// - public DateTimeOffset RejectedAt { get; set; } - - /// - /// Признак возврата записи в обработку пользователем. - /// - public bool Returned { get; set; } - - /// - /// Время возврата в обработку, либо null. - /// - public DateTimeOffset? ReturnedAt { get; set; } - - /// - /// Причина возврата пользователем - /// - public string ReturnReason { get; set; } = string.Empty; - - /// - /// Полнотекстовый вектор - /// - public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty; -} +using NpgsqlTypes; + +namespace Deal.Infrastructure.Persistence.Entities; + +/// +/// Запись отсева пайплайна +/// +public sealed class RejectedItemEntity +{ + /// + /// Короткий id записи + /// + public string Id { get; set; } = string.Empty; + + /// + /// Ключ дедупликации записи источника + /// + public string SourceKey { get; set; } = string.Empty; + + /// + /// Ссылка на источник, сериализованная в JSON + /// + public string SourceJson { get; set; } = string.Empty; + + /// + /// Содержимое источника, сериализованное в JSON + /// + public string ContentJson { get; set; } = string.Empty; + + /// + /// Текст сообщения + /// + public string Text { get; set; } = string.Empty; + + /// + /// Этап отсева (length|stop|resume|type|budget|stale|dup|spam_ml|spam_ai|filter_ai и т.п.). + /// + public string Stage { get; set; } = string.Empty; + + /// + /// Человекочитаемая причина отсева + /// + public string Reason { get; set; } = string.Empty; + + /// + /// Совпавшее ключевое слово/фраза правила + /// + public string Kw { get; set; } = string.Empty; + + /// + /// Кто вынес решение + /// + public string Source { get; set; } = "stop"; + + /// + /// Время получения исходного сообщения + /// + public DateTimeOffset MsgAt { get; set; } + + /// + /// Время записи в отсев + /// + public DateTimeOffset RejectedAt { get; set; } + + /// + /// Признак возврата записи в обработку пользователем. + /// + public bool Returned { get; set; } + + /// + /// Время возврата в обработку, либо null. + /// + public DateTimeOffset? ReturnedAt { get; set; } + + /// + /// Причина возврата пользователем + /// + public string ReturnReason { get; set; } = string.Empty; + + /// + /// Полнотекстовый вектор + /// + public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty; +} diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/TokenUsageEventEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/TokenUsageEventEntity.cs index 13e64ad..bfe3d77 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/TokenUsageEventEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/TokenUsageEventEntity.cs @@ -1,42 +1,42 @@ -namespace Deal.Infrastructure.Persistence.Entities; - -/// -/// Событие расхода токенов -/// -public sealed class TokenUsageEventEntity -{ - public long Id { get; set; } - - /// - /// Тенант события - /// - public Guid TenantId { get; set; } - - public DateTimeOffset At { get; set; } - - /// - /// Провайдер/источник - /// - public string Provider { get; set; } = string.Empty; - - /// - /// Модель провайдера. - /// - public string Model { get; set; } = string.Empty; - - /// - /// Вид вызова: ai|ml - /// - public string Kind { get; set; } = string.Empty; - - public long PromptTokens { get; set; } - - public long CompletionTokens { get; set; } - - public long TotalTokens { get; set; } - - /// - /// Детали события в JSON - /// - public string? DetailJson { get; set; } -} +namespace Deal.Infrastructure.Persistence.Entities; + +/// +/// Событие расхода токенов +/// +public sealed class TokenUsageEventEntity +{ + public long Id { get; set; } + + /// + /// Тенант события + /// + public Guid TenantId { get; set; } + + public DateTimeOffset At { get; set; } + + /// + /// Провайдер/источник + /// + public string Provider { get; set; } = string.Empty; + + /// + /// Модель провайдера. + /// + public string Model { get; set; } = string.Empty; + + /// + /// Вид вызова: ai|ml + /// + public string Kind { get; set; } = string.Empty; + + public long PromptTokens { get; set; } + + public long CompletionTokens { get; set; } + + public long TotalTokens { get; set; } + + /// + /// Детали события в JSON + /// + public string? DetailJson { get; set; } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/20260911114400_InitialTenant.Designer.cs b/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/20260911114400_InitialTenant.Designer.cs index 8b753e6..10971b3 100644 --- a/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/20260911114400_InitialTenant.Designer.cs +++ b/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/20260911114400_InitialTenant.Designer.cs @@ -1,744 +1,744 @@ -// -using System; -using Deal.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using NpgsqlTypes; - -#nullable disable - -namespace Deal.Infrastructure.Persistence.Migrations.TenantDb -{ - [DbContext(typeof(TenantDbContext))] - [Migration("20260911114400_InitialTenant")] - partial class InitialTenant - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.11") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("ArchivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("BudgetCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("BudgetFrom") - .HasColumnType("double precision"); - - b.Property("BudgetTo") - .HasColumnType("double precision"); - - b.Property("Col") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Contact") - .IsRequired() - .HasColumnType("text"); - - b.Property("ContactsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("ContentJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvFrom") - .HasColumnType("double precision"); - - b.Property("ConvTo") - .HasColumnType("double precision"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FilesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("HistoryJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("IsNew") - .HasColumnType("boolean"); - - b.Property("IsVacancy") - .HasColumnType("boolean"); - - b.Property("IsVacancyKnown") - .HasColumnType("boolean"); - - b.Property("LinksJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Local") - .HasColumnType("boolean"); - - b.Property("MatchHitsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("PrevCol") - .IsRequired() - .HasColumnType("text"); - - b.Property("ReceivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReminderAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReminderFired") - .HasColumnType("boolean"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))", true); - - b.Property("SourceExternalId") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceKind") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("SourceOriginRef") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceText") - .IsRequired() - .HasColumnType("text"); - - b.Property("StackJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Summary") - .IsRequired() - .HasColumnType("text"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property("TzText") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("SearchTsv"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin"); - - b.HasIndex("UpdatedAt") - .IsDescending(); - - b.HasIndex("Col", "IsNew"); - - b.HasIndex("Col", "ReceivedAt") - .IsDescending(false, true); - - b.ToTable("Cards", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardMoveEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("Action") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FromCol") - .HasColumnType("text"); - - b.Property("LeadId") - .IsRequired() - .HasColumnType("text"); - - b.Property("ToCol") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("CardMoves", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ContainerEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("Collapsed") - .HasColumnType("boolean"); - - b.Property("Color") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("Kind") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Note") - .IsRequired() - .HasColumnType("text"); - - b.Property("PolicyJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Position") - .HasColumnType("integer"); - - b.Property("RulesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", true); - - b.Property("Space") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Suggested") - .HasColumnType("boolean"); - - b.HasKey("Id"); - - b.HasIndex("SearchTsv"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin"); - - b.HasIndex("Space", "Position"); - - b.HasIndex("Suggested", "Position"); - - b.ToTable("Containers", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b => - { - b.Property("Hash") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.HasKey("Hash"); - - b.ToTable("DedupEntries", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("Backfilled") - .HasColumnType("boolean"); - - b.Property("Handle") - .IsRequired() - .HasColumnType("text"); - - b.Property("Hue") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("#666"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("LastAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LastText") - .IsRequired() - .HasColumnType("text"); - - b.Property("Monitor") - .HasColumnType("boolean"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("Dialogs", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b => - { - b.Property("DialogId") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("DialogId"); - - b.ToTable("DiscBlacklist", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b => - { - b.Property("DialogId") - .HasColumnType("text"); - - b.Property("AutoJoined") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FitRatio") - .HasColumnType("double precision"); - - b.Property("Hue") - .IsRequired() - .HasColumnType("text"); - - b.Property("JoinFailures") - .HasColumnType("integer"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("LangRu") - .HasColumnType("boolean"); - - b.Property("MarksJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Participants") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("TaskId") - .IsRequired() - .HasColumnType("text"); - - b.Property("TopicsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Username") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("DialogId"); - - b.HasIndex("TaskId", "Status"); - - b.ToTable("DiscCandidates", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscLogEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Event") - .IsRequired() - .HasColumnType("text"); - - b.Property("TaskId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("TaskId", "CreatedAt"); - - b.ToTable("DiscLog", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscTaskEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("AutoJoin") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("Evaluated") - .HasColumnType("integer"); - - b.Property("Found") - .HasColumnType("integer"); - - b.Property("Joined") - .HasColumnType("integer"); - - b.Property("KeywordsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Lang") - .IsRequired() - .HasColumnType("text"); - - b.Property("MinSubscribers") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("PlanJoins") - .HasColumnType("integer"); - - b.Property("Rejected") - .HasColumnType("integer"); - - b.Property("SampleSize") - .HasColumnType("integer"); - - b.Property("SearchDone") - .HasColumnType("boolean"); - - b.Property("SearchIdx") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Threshold") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("DiscTasks", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("By") - .IsRequired() - .HasColumnType("text"); - - b.Property("CardId") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("CardId"); - - b.ToTable("LeadComments", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.MlOutboxEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Delta") - .HasColumnType("double precision"); - - b.Property("Label") - .IsRequired() - .HasColumnType("text"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("CreatedAt"); - - b.ToTable("MlOutbox", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.QueueItemEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("ContentJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Force") - .HasColumnType("boolean"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SourceJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceKey") - .IsRequired() - .HasColumnType("text"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("SourceKey"); - - b.HasIndex("Status", "CreatedAt"); - - b.ToTable("QueueItems", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RejectedItemEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("ContentJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Kw") - .IsRequired() - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.Property("RejectedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReturnReason") - .IsRequired() - .HasColumnType("text"); - - b.Property("Returned") - .HasColumnType("boolean"); - - b.Property("ReturnedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true); - - b.Property("Source") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceKey") - .IsRequired() - .HasColumnType("text"); - - b.Property("Stage") - .IsRequired() - .HasColumnType("text"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("RejectedAt"); - - b.HasIndex("SearchTsv"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin"); - - b.ToTable("RejectedItems", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantSettingEntity", b => - { - b.Property("Key") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ValueJson") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Key"); - - b.ToTable("settings", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("DialogId", "MsgAt"); - - b.ToTable("TgMessages", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.CardEntity", null) - .WithMany() - .HasForeignKey("CardId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} +// +using System; +using Deal.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; + +#nullable disable + +namespace Deal.Infrastructure.Persistence.Migrations.TenantDb +{ + [DbContext(typeof(TenantDbContext))] + [Migration("20260911114400_InitialTenant")] + partial class InitialTenant + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("BudgetCur") + .IsRequired() + .HasColumnType("text"); + + b.Property("BudgetFrom") + .HasColumnType("double precision"); + + b.Property("BudgetTo") + .HasColumnType("double precision"); + + b.Property("Col") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Contact") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConvCur") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConvFrom") + .HasColumnType("double precision"); + + b.Property("ConvTo") + .HasColumnType("double precision"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FilesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("HistoryJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsNew") + .HasColumnType("boolean"); + + b.Property("IsVacancy") + .HasColumnType("boolean"); + + b.Property("IsVacancyKnown") + .HasColumnType("boolean"); + + b.Property("LinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Local") + .HasColumnType("boolean"); + + b.Property("MatchHitsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrevCol") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReminderAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReminderFired") + .HasColumnType("boolean"); + + b.Property("SearchTsv") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))", true); + + b.Property("SourceExternalId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceKind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SourceOriginRef") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceText") + .IsRequired() + .HasColumnType("text"); + + b.Property("StackJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("TzText") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SearchTsv"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin"); + + b.HasIndex("UpdatedAt") + .IsDescending(); + + b.HasIndex("Col", "IsNew"); + + b.HasIndex("Col", "ReceivedAt") + .IsDescending(false, true); + + b.ToTable("Cards", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardMoveEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromCol") + .HasColumnType("text"); + + b.Property("LeadId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToCol") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("CardMoves", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ContainerEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Collapsed") + .HasColumnType("boolean"); + + b.Property("Color") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("PolicyJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("RulesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SearchTsv") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", true); + + b.Property("Space") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Suggested") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("SearchTsv"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin"); + + b.HasIndex("Space", "Position"); + + b.HasIndex("Suggested", "Position"); + + b.ToTable("Containers", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b => + { + b.Property("Hash") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LeadId") + .HasColumnType("text"); + + b.HasKey("Hash"); + + b.ToTable("DedupEntries", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Backfilled") + .HasColumnType("boolean"); + + b.Property("Handle") + .IsRequired() + .HasColumnType("text"); + + b.Property("Hue") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("#666"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastText") + .IsRequired() + .HasColumnType("text"); + + b.Property("Monitor") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Dialogs", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b => + { + b.Property("DialogId") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("DialogId"); + + b.ToTable("DiscBlacklist", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b => + { + b.Property("DialogId") + .HasColumnType("text"); + + b.Property("AutoJoined") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FitRatio") + .HasColumnType("double precision"); + + b.Property("Hue") + .IsRequired() + .HasColumnType("text"); + + b.Property("JoinFailures") + .HasColumnType("integer"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("LangRu") + .HasColumnType("boolean"); + + b.Property("MarksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Participants") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TopicsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("DialogId"); + + b.HasIndex("TaskId", "Status"); + + b.ToTable("DiscCandidates", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscLogEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Event") + .IsRequired() + .HasColumnType("text"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TaskId", "CreatedAt"); + + b.ToTable("DiscLog", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscTaskEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AutoJoin") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Evaluated") + .HasColumnType("integer"); + + b.Property("Found") + .HasColumnType("integer"); + + b.Property("Joined") + .HasColumnType("integer"); + + b.Property("KeywordsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Lang") + .IsRequired() + .HasColumnType("text"); + + b.Property("MinSubscribers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlanJoins") + .HasColumnType("integer"); + + b.Property("Rejected") + .HasColumnType("integer"); + + b.Property("SampleSize") + .HasColumnType("integer"); + + b.Property("SearchDone") + .HasColumnType("boolean"); + + b.Property("SearchIdx") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Threshold") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("DiscTasks", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("By") + .IsRequired() + .HasColumnType("text"); + + b.Property("CardId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CardId"); + + b.ToTable("LeadComments", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.MlOutboxEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Delta") + .HasColumnType("double precision"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("MlOutbox", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.QueueItemEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ContentJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Force") + .HasColumnType("boolean"); + + b.Property("MsgAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SourceKey"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("QueueItems", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RejectedItemEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ContentJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kw") + .IsRequired() + .HasColumnType("text"); + + b.Property("MsgAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("RejectedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReturnReason") + .IsRequired() + .HasColumnType("text"); + + b.Property("Returned") + .HasColumnType("boolean"); + + b.Property("ReturnedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SearchTsv") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Stage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RejectedAt"); + + b.HasIndex("SearchTsv"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin"); + + b.ToTable("RejectedItems", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantSettingEntity", b => + { + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ValueJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("settings", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DialogId") + .IsRequired() + .HasColumnType("text"); + + b.Property("LeadId") + .HasColumnType("text"); + + b.Property("MsgAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DialogId", "MsgAt"); + + b.ToTable("TgMessages", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.CardEntity", null) + .WithMany() + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/20260911114400_InitialTenant.cs b/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/20260911114400_InitialTenant.cs index 042882a..5e4c5b2 100644 --- a/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/20260911114400_InitialTenant.cs +++ b/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/20260911114400_InitialTenant.cs @@ -1,462 +1,462 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using NpgsqlTypes; - -#nullable disable - -namespace Deal.Infrastructure.Persistence.Migrations.TenantDb -{ - /// - public partial class InitialTenant : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "CardMoves", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - LeadId = table.Column(type: "text", nullable: false), - Action = table.Column(type: "text", nullable: false), - FromCol = table.Column(type: "text", nullable: true), - ToCol = table.Column(type: "text", nullable: true), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_CardMoves", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Cards", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - Col = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - IsNew = table.Column(type: "boolean", nullable: false), - Local = table.Column(type: "boolean", nullable: false), - IsVacancy = table.Column(type: "boolean", nullable: false), - IsVacancyKnown = table.Column(type: "boolean", nullable: false), - Title = table.Column(type: "text", nullable: false), - Summary = table.Column(type: "text", nullable: false), - StackJson = table.Column(type: "text", nullable: false), - BudgetFrom = table.Column(type: "double precision", nullable: true), - BudgetTo = table.Column(type: "double precision", nullable: true), - BudgetCur = table.Column(type: "text", nullable: false), - ConvFrom = table.Column(type: "double precision", nullable: true), - ConvTo = table.Column(type: "double precision", nullable: true), - ConvCur = table.Column(type: "text", nullable: false), - Contact = table.Column(type: "text", nullable: false), - ContactsJson = table.Column(type: "text", nullable: false), - ReceivedAt = table.Column(type: "timestamp with time zone", nullable: false), - SourceKind = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), - SourceExternalId = table.Column(type: "text", nullable: false), - SourceOriginRef = table.Column(type: "text", nullable: false), - SourceJson = table.Column(type: "text", nullable: false), - ContentJson = table.Column(type: "text", nullable: false), - SourceText = table.Column(type: "text", nullable: false), - PrevCol = table.Column(type: "text", nullable: false), - ArchivedAt = table.Column(type: "timestamp with time zone", nullable: true), - MatchHitsJson = table.Column(type: "text", nullable: false), - LinksJson = table.Column(type: "text", nullable: false), - FilesJson = table.Column(type: "text", nullable: false), - HistoryJson = table.Column(type: "text", nullable: false), - TzText = table.Column(type: "text", nullable: false), - ReminderAt = table.Column(type: "timestamp with time zone", nullable: true), - ReminderFired = table.Column(type: "boolean", nullable: false), - SearchTsv = table.Column(type: "tsvector", nullable: false, computedColumnSql: "to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))", stored: true), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Cards", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Containers", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - Name = table.Column(type: "text", nullable: false), - Description = table.Column(type: "text", nullable: false), - Color = table.Column(type: "text", nullable: false), - Position = table.Column(type: "integer", nullable: false), - Kind = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - Space = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - Collapsed = table.Column(type: "boolean", nullable: false), - Suggested = table.Column(type: "boolean", nullable: false), - RulesJson = table.Column(type: "text", nullable: false), - Note = table.Column(type: "text", nullable: false), - PolicyJson = table.Column(type: "text", nullable: false), - SearchTsv = table.Column(type: "tsvector", nullable: false, computedColumnSql: "to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", stored: true), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Containers", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "DedupEntries", - columns: table => new - { - Hash = table.Column(type: "text", nullable: false), - LeadId = table.Column(type: "text", nullable: true), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_DedupEntries", x => x.Hash); - }); - - migrationBuilder.CreateTable( - name: "Dialogs", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - Name = table.Column(type: "text", nullable: false), - Handle = table.Column(type: "text", nullable: false), - Kind = table.Column(type: "text", nullable: false), - Hue = table.Column(type: "text", nullable: false, defaultValue: "#666"), - Monitor = table.Column(type: "boolean", nullable: false), - LastText = table.Column(type: "text", nullable: false), - LastAt = table.Column(type: "timestamp with time zone", nullable: true), - Backfilled = table.Column(type: "boolean", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Dialogs", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "DiscBlacklist", - columns: table => new - { - DialogId = table.Column(type: "text", nullable: false), - Name = table.Column(type: "text", nullable: false), - Reason = table.Column(type: "text", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_DiscBlacklist", x => x.DialogId); - }); - - migrationBuilder.CreateTable( - name: "DiscCandidates", - columns: table => new - { - DialogId = table.Column(type: "text", nullable: false), - TaskId = table.Column(type: "text", nullable: false), - Name = table.Column(type: "text", nullable: false), - Username = table.Column(type: "text", nullable: false), - Kind = table.Column(type: "text", nullable: false), - Hue = table.Column(type: "text", nullable: false), - Participants = table.Column(type: "integer", nullable: true), - LangRu = table.Column(type: "boolean", nullable: true), - MarksJson = table.Column(type: "text", nullable: false), - TopicsJson = table.Column(type: "text", nullable: false), - FitRatio = table.Column(type: "double precision", nullable: true), - Status = table.Column(type: "text", nullable: false), - AutoJoined = table.Column(type: "boolean", nullable: false), - JoinFailures = table.Column(type: "integer", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_DiscCandidates", x => x.DialogId); - }); - - migrationBuilder.CreateTable( - name: "DiscLog", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - TaskId = table.Column(type: "text", nullable: false), - Event = table.Column(type: "text", nullable: false), - Text = table.Column(type: "text", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_DiscLog", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "DiscTasks", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - Name = table.Column(type: "text", nullable: false), - Description = table.Column(type: "text", nullable: false), - KeywordsJson = table.Column(type: "text", nullable: false), - MinSubscribers = table.Column(type: "integer", nullable: false), - Lang = table.Column(type: "text", nullable: false), - Threshold = table.Column(type: "integer", nullable: false), - SampleSize = table.Column(type: "integer", nullable: false), - PlanJoins = table.Column(type: "integer", nullable: false), - AutoJoin = table.Column(type: "boolean", nullable: false), - Status = table.Column(type: "text", nullable: false), - SearchIdx = table.Column(type: "integer", nullable: false), - SearchDone = table.Column(type: "boolean", nullable: false), - Found = table.Column(type: "integer", nullable: false), - Evaluated = table.Column(type: "integer", nullable: false), - Joined = table.Column(type: "integer", nullable: false), - Rejected = table.Column(type: "integer", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_DiscTasks", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "MlOutbox", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - Text = table.Column(type: "text", nullable: false), - Label = table.Column(type: "text", nullable: false), - Delta = table.Column(type: "double precision", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_MlOutbox", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "QueueItems", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - SourceKey = table.Column(type: "text", nullable: false), - SourceJson = table.Column(type: "text", nullable: false), - ContentJson = table.Column(type: "text", nullable: false), - Text = table.Column(type: "text", nullable: false), - MsgAt = table.Column(type: "timestamp with time zone", nullable: false), - Status = table.Column(type: "text", nullable: false), - Force = table.Column(type: "boolean", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_QueueItems", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "RejectedItems", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - SourceKey = table.Column(type: "text", nullable: false), - SourceJson = table.Column(type: "text", nullable: false), - ContentJson = table.Column(type: "text", nullable: false), - Text = table.Column(type: "text", nullable: false), - Stage = table.Column(type: "text", nullable: false), - Reason = table.Column(type: "text", nullable: false), - Kw = table.Column(type: "text", nullable: false), - Source = table.Column(type: "text", nullable: false), - MsgAt = table.Column(type: "timestamp with time zone", nullable: false), - RejectedAt = table.Column(type: "timestamp with time zone", nullable: false), - Returned = table.Column(type: "boolean", nullable: false), - ReturnedAt = table.Column(type: "timestamp with time zone", nullable: true), - ReturnReason = table.Column(type: "text", nullable: false), - SearchTsv = table.Column(type: "tsvector", nullable: false, computedColumnSql: "to_tsvector('russian', coalesce(\"Text\",''))", stored: true) - }, - constraints: table => - { - table.PrimaryKey("PK_RejectedItems", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "settings", - columns: table => new - { - Key = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - ValueJson = table.Column(type: "text", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_settings", x => x.Key); - }); - - migrationBuilder.CreateTable( - name: "TgMessages", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - DialogId = table.Column(type: "text", nullable: false), - Text = table.Column(type: "text", nullable: false), - MsgAt = table.Column(type: "timestamp with time zone", nullable: false), - LeadId = table.Column(type: "text", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_TgMessages", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "LeadComments", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - CardId = table.Column(type: "text", nullable: false), - By = table.Column(type: "text", nullable: false), - Text = table.Column(type: "text", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_LeadComments", x => x.Id); - table.ForeignKey( - name: "FK_LeadComments_Cards_CardId", - column: x => x.CardId, - principalTable: "Cards", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_Cards_Col_IsNew", - table: "Cards", - columns: new[] { "Col", "IsNew" }); - - migrationBuilder.CreateIndex( - name: "IX_Cards_Col_ReceivedAt", - table: "Cards", - columns: new[] { "Col", "ReceivedAt" }, - descending: new[] { false, true }); - - migrationBuilder.CreateIndex( - name: "IX_Cards_SearchTsv", - table: "Cards", - column: "SearchTsv") - .Annotation("Npgsql:IndexMethod", "gin"); - - migrationBuilder.CreateIndex( - name: "IX_Cards_UpdatedAt", - table: "Cards", - column: "UpdatedAt", - descending: new bool[0]); - - migrationBuilder.CreateIndex( - name: "IX_Containers_SearchTsv", - table: "Containers", - column: "SearchTsv") - .Annotation("Npgsql:IndexMethod", "gin"); - - migrationBuilder.CreateIndex( - name: "IX_Containers_Space_Position", - table: "Containers", - columns: new[] { "Space", "Position" }); - - migrationBuilder.CreateIndex( - name: "IX_Containers_Suggested_Position", - table: "Containers", - columns: new[] { "Suggested", "Position" }); - - migrationBuilder.CreateIndex( - name: "IX_DiscCandidates_TaskId_Status", - table: "DiscCandidates", - columns: new[] { "TaskId", "Status" }); - - migrationBuilder.CreateIndex( - name: "IX_DiscLog_TaskId_CreatedAt", - table: "DiscLog", - columns: new[] { "TaskId", "CreatedAt" }); - - migrationBuilder.CreateIndex( - name: "IX_LeadComments_CardId", - table: "LeadComments", - column: "CardId"); - - migrationBuilder.CreateIndex( - name: "IX_MlOutbox_CreatedAt", - table: "MlOutbox", - column: "CreatedAt"); - - migrationBuilder.CreateIndex( - name: "IX_QueueItems_SourceKey", - table: "QueueItems", - column: "SourceKey"); - - migrationBuilder.CreateIndex( - name: "IX_QueueItems_Status_CreatedAt", - table: "QueueItems", - columns: new[] { "Status", "CreatedAt" }); - - migrationBuilder.CreateIndex( - name: "IX_RejectedItems_RejectedAt", - table: "RejectedItems", - column: "RejectedAt"); - - migrationBuilder.CreateIndex( - name: "IX_RejectedItems_SearchTsv", - table: "RejectedItems", - column: "SearchTsv") - .Annotation("Npgsql:IndexMethod", "gin"); - - migrationBuilder.CreateIndex( - name: "IX_TgMessages_DialogId_MsgAt", - table: "TgMessages", - columns: new[] { "DialogId", "MsgAt" }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "CardMoves"); - - migrationBuilder.DropTable( - name: "Containers"); - - migrationBuilder.DropTable( - name: "DedupEntries"); - - migrationBuilder.DropTable( - name: "Dialogs"); - - migrationBuilder.DropTable( - name: "DiscBlacklist"); - - migrationBuilder.DropTable( - name: "DiscCandidates"); - - migrationBuilder.DropTable( - name: "DiscLog"); - - migrationBuilder.DropTable( - name: "DiscTasks"); - - migrationBuilder.DropTable( - name: "LeadComments"); - - migrationBuilder.DropTable( - name: "MlOutbox"); - - migrationBuilder.DropTable( - name: "QueueItems"); - - migrationBuilder.DropTable( - name: "RejectedItems"); - - migrationBuilder.DropTable( - name: "settings"); - - migrationBuilder.DropTable( - name: "TgMessages"); - - migrationBuilder.DropTable( - name: "Cards"); - } - } -} +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using NpgsqlTypes; + +#nullable disable + +namespace Deal.Infrastructure.Persistence.Migrations.TenantDb +{ + /// + public partial class InitialTenant : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CardMoves", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + LeadId = table.Column(type: "text", nullable: false), + Action = table.Column(type: "text", nullable: false), + FromCol = table.Column(type: "text", nullable: true), + ToCol = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CardMoves", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Cards", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Col = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + IsNew = table.Column(type: "boolean", nullable: false), + Local = table.Column(type: "boolean", nullable: false), + IsVacancy = table.Column(type: "boolean", nullable: false), + IsVacancyKnown = table.Column(type: "boolean", nullable: false), + Title = table.Column(type: "text", nullable: false), + Summary = table.Column(type: "text", nullable: false), + StackJson = table.Column(type: "text", nullable: false), + BudgetFrom = table.Column(type: "double precision", nullable: true), + BudgetTo = table.Column(type: "double precision", nullable: true), + BudgetCur = table.Column(type: "text", nullable: false), + ConvFrom = table.Column(type: "double precision", nullable: true), + ConvTo = table.Column(type: "double precision", nullable: true), + ConvCur = table.Column(type: "text", nullable: false), + Contact = table.Column(type: "text", nullable: false), + ContactsJson = table.Column(type: "text", nullable: false), + ReceivedAt = table.Column(type: "timestamp with time zone", nullable: false), + SourceKind = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + SourceExternalId = table.Column(type: "text", nullable: false), + SourceOriginRef = table.Column(type: "text", nullable: false), + SourceJson = table.Column(type: "text", nullable: false), + ContentJson = table.Column(type: "text", nullable: false), + SourceText = table.Column(type: "text", nullable: false), + PrevCol = table.Column(type: "text", nullable: false), + ArchivedAt = table.Column(type: "timestamp with time zone", nullable: true), + MatchHitsJson = table.Column(type: "text", nullable: false), + LinksJson = table.Column(type: "text", nullable: false), + FilesJson = table.Column(type: "text", nullable: false), + HistoryJson = table.Column(type: "text", nullable: false), + TzText = table.Column(type: "text", nullable: false), + ReminderAt = table.Column(type: "timestamp with time zone", nullable: true), + ReminderFired = table.Column(type: "boolean", nullable: false), + SearchTsv = table.Column(type: "tsvector", nullable: false, computedColumnSql: "to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))", stored: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Cards", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Containers", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: false), + Color = table.Column(type: "text", nullable: false), + Position = table.Column(type: "integer", nullable: false), + Kind = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Space = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Collapsed = table.Column(type: "boolean", nullable: false), + Suggested = table.Column(type: "boolean", nullable: false), + RulesJson = table.Column(type: "text", nullable: false), + Note = table.Column(type: "text", nullable: false), + PolicyJson = table.Column(type: "text", nullable: false), + SearchTsv = table.Column(type: "tsvector", nullable: false, computedColumnSql: "to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", stored: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Containers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "DedupEntries", + columns: table => new + { + Hash = table.Column(type: "text", nullable: false), + LeadId = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DedupEntries", x => x.Hash); + }); + + migrationBuilder.CreateTable( + name: "Dialogs", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Handle = table.Column(type: "text", nullable: false), + Kind = table.Column(type: "text", nullable: false), + Hue = table.Column(type: "text", nullable: false, defaultValue: "#666"), + Monitor = table.Column(type: "boolean", nullable: false), + LastText = table.Column(type: "text", nullable: false), + LastAt = table.Column(type: "timestamp with time zone", nullable: true), + Backfilled = table.Column(type: "boolean", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Dialogs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "DiscBlacklist", + columns: table => new + { + DialogId = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Reason = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DiscBlacklist", x => x.DialogId); + }); + + migrationBuilder.CreateTable( + name: "DiscCandidates", + columns: table => new + { + DialogId = table.Column(type: "text", nullable: false), + TaskId = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Username = table.Column(type: "text", nullable: false), + Kind = table.Column(type: "text", nullable: false), + Hue = table.Column(type: "text", nullable: false), + Participants = table.Column(type: "integer", nullable: true), + LangRu = table.Column(type: "boolean", nullable: true), + MarksJson = table.Column(type: "text", nullable: false), + TopicsJson = table.Column(type: "text", nullable: false), + FitRatio = table.Column(type: "double precision", nullable: true), + Status = table.Column(type: "text", nullable: false), + AutoJoined = table.Column(type: "boolean", nullable: false), + JoinFailures = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DiscCandidates", x => x.DialogId); + }); + + migrationBuilder.CreateTable( + name: "DiscLog", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + TaskId = table.Column(type: "text", nullable: false), + Event = table.Column(type: "text", nullable: false), + Text = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DiscLog", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "DiscTasks", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: false), + KeywordsJson = table.Column(type: "text", nullable: false), + MinSubscribers = table.Column(type: "integer", nullable: false), + Lang = table.Column(type: "text", nullable: false), + Threshold = table.Column(type: "integer", nullable: false), + SampleSize = table.Column(type: "integer", nullable: false), + PlanJoins = table.Column(type: "integer", nullable: false), + AutoJoin = table.Column(type: "boolean", nullable: false), + Status = table.Column(type: "text", nullable: false), + SearchIdx = table.Column(type: "integer", nullable: false), + SearchDone = table.Column(type: "boolean", nullable: false), + Found = table.Column(type: "integer", nullable: false), + Evaluated = table.Column(type: "integer", nullable: false), + Joined = table.Column(type: "integer", nullable: false), + Rejected = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DiscTasks", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "MlOutbox", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Text = table.Column(type: "text", nullable: false), + Label = table.Column(type: "text", nullable: false), + Delta = table.Column(type: "double precision", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MlOutbox", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "QueueItems", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + SourceKey = table.Column(type: "text", nullable: false), + SourceJson = table.Column(type: "text", nullable: false), + ContentJson = table.Column(type: "text", nullable: false), + Text = table.Column(type: "text", nullable: false), + MsgAt = table.Column(type: "timestamp with time zone", nullable: false), + Status = table.Column(type: "text", nullable: false), + Force = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_QueueItems", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RejectedItems", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + SourceKey = table.Column(type: "text", nullable: false), + SourceJson = table.Column(type: "text", nullable: false), + ContentJson = table.Column(type: "text", nullable: false), + Text = table.Column(type: "text", nullable: false), + Stage = table.Column(type: "text", nullable: false), + Reason = table.Column(type: "text", nullable: false), + Kw = table.Column(type: "text", nullable: false), + Source = table.Column(type: "text", nullable: false), + MsgAt = table.Column(type: "timestamp with time zone", nullable: false), + RejectedAt = table.Column(type: "timestamp with time zone", nullable: false), + Returned = table.Column(type: "boolean", nullable: false), + ReturnedAt = table.Column(type: "timestamp with time zone", nullable: true), + ReturnReason = table.Column(type: "text", nullable: false), + SearchTsv = table.Column(type: "tsvector", nullable: false, computedColumnSql: "to_tsvector('russian', coalesce(\"Text\",''))", stored: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RejectedItems", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "settings", + columns: table => new + { + Key = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + ValueJson = table.Column(type: "text", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_settings", x => x.Key); + }); + + migrationBuilder.CreateTable( + name: "TgMessages", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + DialogId = table.Column(type: "text", nullable: false), + Text = table.Column(type: "text", nullable: false), + MsgAt = table.Column(type: "timestamp with time zone", nullable: false), + LeadId = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TgMessages", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "LeadComments", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + CardId = table.Column(type: "text", nullable: false), + By = table.Column(type: "text", nullable: false), + Text = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LeadComments", x => x.Id); + table.ForeignKey( + name: "FK_LeadComments_Cards_CardId", + column: x => x.CardId, + principalTable: "Cards", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Cards_Col_IsNew", + table: "Cards", + columns: new[] { "Col", "IsNew" }); + + migrationBuilder.CreateIndex( + name: "IX_Cards_Col_ReceivedAt", + table: "Cards", + columns: new[] { "Col", "ReceivedAt" }, + descending: new[] { false, true }); + + migrationBuilder.CreateIndex( + name: "IX_Cards_SearchTsv", + table: "Cards", + column: "SearchTsv") + .Annotation("Npgsql:IndexMethod", "gin"); + + migrationBuilder.CreateIndex( + name: "IX_Cards_UpdatedAt", + table: "Cards", + column: "UpdatedAt", + descending: new bool[0]); + + migrationBuilder.CreateIndex( + name: "IX_Containers_SearchTsv", + table: "Containers", + column: "SearchTsv") + .Annotation("Npgsql:IndexMethod", "gin"); + + migrationBuilder.CreateIndex( + name: "IX_Containers_Space_Position", + table: "Containers", + columns: new[] { "Space", "Position" }); + + migrationBuilder.CreateIndex( + name: "IX_Containers_Suggested_Position", + table: "Containers", + columns: new[] { "Suggested", "Position" }); + + migrationBuilder.CreateIndex( + name: "IX_DiscCandidates_TaskId_Status", + table: "DiscCandidates", + columns: new[] { "TaskId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_DiscLog_TaskId_CreatedAt", + table: "DiscLog", + columns: new[] { "TaskId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_LeadComments_CardId", + table: "LeadComments", + column: "CardId"); + + migrationBuilder.CreateIndex( + name: "IX_MlOutbox_CreatedAt", + table: "MlOutbox", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_QueueItems_SourceKey", + table: "QueueItems", + column: "SourceKey"); + + migrationBuilder.CreateIndex( + name: "IX_QueueItems_Status_CreatedAt", + table: "QueueItems", + columns: new[] { "Status", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_RejectedItems_RejectedAt", + table: "RejectedItems", + column: "RejectedAt"); + + migrationBuilder.CreateIndex( + name: "IX_RejectedItems_SearchTsv", + table: "RejectedItems", + column: "SearchTsv") + .Annotation("Npgsql:IndexMethod", "gin"); + + migrationBuilder.CreateIndex( + name: "IX_TgMessages_DialogId_MsgAt", + table: "TgMessages", + columns: new[] { "DialogId", "MsgAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CardMoves"); + + migrationBuilder.DropTable( + name: "Containers"); + + migrationBuilder.DropTable( + name: "DedupEntries"); + + migrationBuilder.DropTable( + name: "Dialogs"); + + migrationBuilder.DropTable( + name: "DiscBlacklist"); + + migrationBuilder.DropTable( + name: "DiscCandidates"); + + migrationBuilder.DropTable( + name: "DiscLog"); + + migrationBuilder.DropTable( + name: "DiscTasks"); + + migrationBuilder.DropTable( + name: "LeadComments"); + + migrationBuilder.DropTable( + name: "MlOutbox"); + + migrationBuilder.DropTable( + name: "QueueItems"); + + migrationBuilder.DropTable( + name: "RejectedItems"); + + migrationBuilder.DropTable( + name: "settings"); + + migrationBuilder.DropTable( + name: "TgMessages"); + + migrationBuilder.DropTable( + name: "Cards"); + } + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/TenantDbContextModelSnapshot.cs b/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/TenantDbContextModelSnapshot.cs index 712008e..822399e 100644 --- a/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/TenantDbContextModelSnapshot.cs +++ b/src/core/Deal.Infrastructure/Persistence/Migrations/TenantDb/TenantDbContextModelSnapshot.cs @@ -1,741 +1,741 @@ -// -using System; -using Deal.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using NpgsqlTypes; - -#nullable disable - -namespace Deal.Infrastructure.Persistence.Migrations.TenantDb -{ - [DbContext(typeof(TenantDbContext))] - partial class TenantDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.11") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("ArchivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("BudgetCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("BudgetFrom") - .HasColumnType("double precision"); - - b.Property("BudgetTo") - .HasColumnType("double precision"); - - b.Property("Col") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Contact") - .IsRequired() - .HasColumnType("text"); - - b.Property("ContactsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("ContentJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvCur") - .IsRequired() - .HasColumnType("text"); - - b.Property("ConvFrom") - .HasColumnType("double precision"); - - b.Property("ConvTo") - .HasColumnType("double precision"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FilesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("HistoryJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("IsNew") - .HasColumnType("boolean"); - - b.Property("IsVacancy") - .HasColumnType("boolean"); - - b.Property("IsVacancyKnown") - .HasColumnType("boolean"); - - b.Property("LinksJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Local") - .HasColumnType("boolean"); - - b.Property("MatchHitsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("PrevCol") - .IsRequired() - .HasColumnType("text"); - - b.Property("ReceivedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReminderAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReminderFired") - .HasColumnType("boolean"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))", true); - - b.Property("SourceExternalId") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceKind") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - - b.Property("SourceOriginRef") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceText") - .IsRequired() - .HasColumnType("text"); - - b.Property("StackJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Summary") - .IsRequired() - .HasColumnType("text"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property("TzText") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("SearchTsv"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin"); - - b.HasIndex("UpdatedAt") - .IsDescending(); - - b.HasIndex("Col", "IsNew"); - - b.HasIndex("Col", "ReceivedAt") - .IsDescending(false, true); - - b.ToTable("Cards", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardMoveEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("Action") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FromCol") - .HasColumnType("text"); - - b.Property("LeadId") - .IsRequired() - .HasColumnType("text"); - - b.Property("ToCol") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("CardMoves", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ContainerEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("Collapsed") - .HasColumnType("boolean"); - - b.Property("Color") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("Kind") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Note") - .IsRequired() - .HasColumnType("text"); - - b.Property("PolicyJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Position") - .HasColumnType("integer"); - - b.Property("RulesJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", true); - - b.Property("Space") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Suggested") - .HasColumnType("boolean"); - - b.HasKey("Id"); - - b.HasIndex("SearchTsv"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin"); - - b.HasIndex("Space", "Position"); - - b.HasIndex("Suggested", "Position"); - - b.ToTable("Containers", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b => - { - b.Property("Hash") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.HasKey("Hash"); - - b.ToTable("DedupEntries", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("Backfilled") - .HasColumnType("boolean"); - - b.Property("Handle") - .IsRequired() - .HasColumnType("text"); - - b.Property("Hue") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("text") - .HasDefaultValue("#666"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("LastAt") - .HasColumnType("timestamp with time zone"); - - b.Property("LastText") - .IsRequired() - .HasColumnType("text"); - - b.Property("Monitor") - .HasColumnType("boolean"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("Dialogs", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b => - { - b.Property("DialogId") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("DialogId"); - - b.ToTable("DiscBlacklist", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b => - { - b.Property("DialogId") - .HasColumnType("text"); - - b.Property("AutoJoined") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("FitRatio") - .HasColumnType("double precision"); - - b.Property("Hue") - .IsRequired() - .HasColumnType("text"); - - b.Property("JoinFailures") - .HasColumnType("integer"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("text"); - - b.Property("LangRu") - .HasColumnType("boolean"); - - b.Property("MarksJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Participants") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("TaskId") - .IsRequired() - .HasColumnType("text"); - - b.Property("TopicsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Username") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("DialogId"); - - b.HasIndex("TaskId", "Status"); - - b.ToTable("DiscCandidates", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscLogEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Event") - .IsRequired() - .HasColumnType("text"); - - b.Property("TaskId") - .IsRequired() - .HasColumnType("text"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("TaskId", "CreatedAt"); - - b.ToTable("DiscLog", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscTaskEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("AutoJoin") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("Evaluated") - .HasColumnType("integer"); - - b.Property("Found") - .HasColumnType("integer"); - - b.Property("Joined") - .HasColumnType("integer"); - - b.Property("KeywordsJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Lang") - .IsRequired() - .HasColumnType("text"); - - b.Property("MinSubscribers") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("PlanJoins") - .HasColumnType("integer"); - - b.Property("Rejected") - .HasColumnType("integer"); - - b.Property("SampleSize") - .HasColumnType("integer"); - - b.Property("SearchDone") - .HasColumnType("boolean"); - - b.Property("SearchIdx") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Threshold") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("DiscTasks", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("By") - .IsRequired() - .HasColumnType("text"); - - b.Property("CardId") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("CardId"); - - b.ToTable("LeadComments", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.MlOutboxEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Delta") - .HasColumnType("double precision"); - - b.Property("Label") - .IsRequired() - .HasColumnType("text"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("CreatedAt"); - - b.ToTable("MlOutbox", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.QueueItemEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("ContentJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Force") - .HasColumnType("boolean"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SourceJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceKey") - .IsRequired() - .HasColumnType("text"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("SourceKey"); - - b.HasIndex("Status", "CreatedAt"); - - b.ToTable("QueueItems", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RejectedItemEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("ContentJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("Kw") - .IsRequired() - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("text"); - - b.Property("RejectedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ReturnReason") - .IsRequired() - .HasColumnType("text"); - - b.Property("Returned") - .HasColumnType("boolean"); - - b.Property("ReturnedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SearchTsv") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true); - - b.Property("Source") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceJson") - .IsRequired() - .HasColumnType("text"); - - b.Property("SourceKey") - .IsRequired() - .HasColumnType("text"); - - b.Property("Stage") - .IsRequired() - .HasColumnType("text"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("RejectedAt"); - - b.HasIndex("SearchTsv"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin"); - - b.ToTable("RejectedItems", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantSettingEntity", b => - { - b.Property("Key") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ValueJson") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Key"); - - b.ToTable("settings", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("DialogId") - .IsRequired() - .HasColumnType("text"); - - b.Property("LeadId") - .HasColumnType("text"); - - b.Property("MsgAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Text") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("DialogId", "MsgAt"); - - b.ToTable("TgMessages", (string)null); - }); - - modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => - { - b.HasOne("Deal.Infrastructure.Persistence.Entities.CardEntity", null) - .WithMany() - .HasForeignKey("CardId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} +// +using System; +using Deal.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; + +#nullable disable + +namespace Deal.Infrastructure.Persistence.Migrations.TenantDb +{ + [DbContext(typeof(TenantDbContext))] + partial class TenantDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("BudgetCur") + .IsRequired() + .HasColumnType("text"); + + b.Property("BudgetFrom") + .HasColumnType("double precision"); + + b.Property("BudgetTo") + .HasColumnType("double precision"); + + b.Property("Col") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Contact") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConvCur") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConvFrom") + .HasColumnType("double precision"); + + b.Property("ConvTo") + .HasColumnType("double precision"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FilesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("HistoryJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsNew") + .HasColumnType("boolean"); + + b.Property("IsVacancy") + .HasColumnType("boolean"); + + b.Property("IsVacancyKnown") + .HasColumnType("boolean"); + + b.Property("LinksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Local") + .HasColumnType("boolean"); + + b.Property("MatchHitsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrevCol") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReminderAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReminderFired") + .HasColumnType("boolean"); + + b.Property("SearchTsv") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))", true); + + b.Property("SourceExternalId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceKind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SourceOriginRef") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceText") + .IsRequired() + .HasColumnType("text"); + + b.Property("StackJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("TzText") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SearchTsv"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin"); + + b.HasIndex("UpdatedAt") + .IsDescending(); + + b.HasIndex("Col", "IsNew"); + + b.HasIndex("Col", "ReceivedAt") + .IsDescending(false, true); + + b.ToTable("Cards", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardMoveEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromCol") + .HasColumnType("text"); + + b.Property("LeadId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToCol") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("CardMoves", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ContainerEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Collapsed") + .HasColumnType("boolean"); + + b.Property("Color") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("PolicyJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("RulesJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SearchTsv") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", true); + + b.Property("Space") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Suggested") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("SearchTsv"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin"); + + b.HasIndex("Space", "Position"); + + b.HasIndex("Suggested", "Position"); + + b.ToTable("Containers", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b => + { + b.Property("Hash") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LeadId") + .HasColumnType("text"); + + b.HasKey("Hash"); + + b.ToTable("DedupEntries", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Backfilled") + .HasColumnType("boolean"); + + b.Property("Handle") + .IsRequired() + .HasColumnType("text"); + + b.Property("Hue") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("#666"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastText") + .IsRequired() + .HasColumnType("text"); + + b.Property("Monitor") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Dialogs", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b => + { + b.Property("DialogId") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("DialogId"); + + b.ToTable("DiscBlacklist", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b => + { + b.Property("DialogId") + .HasColumnType("text"); + + b.Property("AutoJoined") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FitRatio") + .HasColumnType("double precision"); + + b.Property("Hue") + .IsRequired() + .HasColumnType("text"); + + b.Property("JoinFailures") + .HasColumnType("integer"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("LangRu") + .HasColumnType("boolean"); + + b.Property("MarksJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Participants") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TopicsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("DialogId"); + + b.HasIndex("TaskId", "Status"); + + b.ToTable("DiscCandidates", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscLogEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Event") + .IsRequired() + .HasColumnType("text"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TaskId", "CreatedAt"); + + b.ToTable("DiscLog", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscTaskEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AutoJoin") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Evaluated") + .HasColumnType("integer"); + + b.Property("Found") + .HasColumnType("integer"); + + b.Property("Joined") + .HasColumnType("integer"); + + b.Property("KeywordsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Lang") + .IsRequired() + .HasColumnType("text"); + + b.Property("MinSubscribers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlanJoins") + .HasColumnType("integer"); + + b.Property("Rejected") + .HasColumnType("integer"); + + b.Property("SampleSize") + .HasColumnType("integer"); + + b.Property("SearchDone") + .HasColumnType("boolean"); + + b.Property("SearchIdx") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Threshold") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("DiscTasks", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("By") + .IsRequired() + .HasColumnType("text"); + + b.Property("CardId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CardId"); + + b.ToTable("LeadComments", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.MlOutboxEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Delta") + .HasColumnType("double precision"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("MlOutbox", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.QueueItemEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ContentJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Force") + .HasColumnType("boolean"); + + b.Property("MsgAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SourceKey"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("QueueItems", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RejectedItemEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ContentJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kw") + .IsRequired() + .HasColumnType("text"); + + b.Property("MsgAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("RejectedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReturnReason") + .IsRequired() + .HasColumnType("text"); + + b.Property("Returned") + .HasColumnType("boolean"); + + b.Property("ReturnedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SearchTsv") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Stage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RejectedAt"); + + b.HasIndex("SearchTsv"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin"); + + b.ToTable("RejectedItems", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantSettingEntity", b => + { + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ValueJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("settings", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DialogId") + .IsRequired() + .HasColumnType("text"); + + b.Property("LeadId") + .HasColumnType("text"); + + b.Property("MsgAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DialogId", "MsgAt"); + + b.ToTable("TgMessages", (string)null); + }); + + modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b => + { + b.HasOne("Deal.Infrastructure.Persistence.Entities.CardEntity", null) + .WithMany() + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs index 00df77d..a67f7bb 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs @@ -1,122 +1,122 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища аудита -/// -public sealed class AuditLogStore(DealDbContext dbContext) : IAuditLogStore -{ - /// - public async Task AppendAsync(AuditRecordDto record, CancellationToken ct) - { - dbContext.AuditLog.Add(new AuditLogEntity - { - // Id генерирует БД (identity) — из DTO не копируется. - At = record.At, - ActorType = record.ActorType, - ActorId = record.ActorId, - TenantId = record.TenantId, - EventType = record.EventType, - Ip = record.Ip, - DetailJson = record.DetailJson, - }); - await dbContext.SaveChangesAsync(ct); - } - - /// - public async Task> QueryAsync(AuditQueryDto filter, CancellationToken ct) - { - var entities = await ApplyFilters(dbContext.AuditLog.AsNoTracking(), filter) - .OrderByDescending(a => a.At) - .Skip(Math.Max(0, filter.Offset)) - .Take(ClampLimit(filter.Limit)) - .ToListAsync(ct); - return entities.Select(ToAuditRecordDto).ToList(); - } - - /// - public async Task CountAsync(AuditQueryDto filter, CancellationToken ct) - { - return await ApplyFilters(dbContext.AuditLog.AsNoTracking(), filter).CountAsync(ct); - } - - /// - public async Task PurgeOlderThanAsync(DateTimeOffset cutoff, CancellationToken ct) - { - if (dbContext.Database.IsNpgsql()) - { - // Операционная авто-очистка: удаляем только устаревшие строки одним DELETE. - return await dbContext.AuditLog.Where(a => a.At < cutoff).ExecuteDeleteAsync(ct); - } - - // InMemory-провайдер (тесты) ExecuteDelete не исполняет — выгрузка и RemoveRange. - List expired = await dbContext.AuditLog - .Where(a => a.At < cutoff) - .ToListAsync(ct); - if (expired.Count == 0) - { - return 0; - } - - dbContext.AuditLog.RemoveRange(expired); - await dbContext.SaveChangesAsync(ct); - return expired.Count; - } - - // Применяет фильтры выборки (EventType/ActorType/TenantId/ActorId/At-range); без Limit/Offset/сортировки. - // query: Базовый запрос. - // filter: Фильтр выборки. - // Возвращает: Запрос с фильтрами. - private static IQueryable ApplyFilters(IQueryable query, AuditQueryDto filter) - { - if (!string.IsNullOrWhiteSpace(filter.EventType)) - { - query = query.Where(a => a.EventType == filter.EventType); - } - - if (!string.IsNullOrWhiteSpace(filter.ActorType)) - { - query = query.Where(a => a.ActorType == filter.ActorType); - } - - if (filter.TenantId is not null) - { - query = query.Where(a => a.TenantId == filter.TenantId); - } - - if (filter.ActorId is not null) - { - query = query.Where(a => a.ActorId == filter.ActorId); - } - - if (filter.From is not null) - { - query = query.Where(a => a.At >= filter.From.Value); - } - - if (filter.To is not null) - { - query = query.Where(a => a.At <= filter.To.Value); - } - - return query; - } - - private static int ClampLimit(int limit) => Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit)); - - private static AuditRecordDto ToAuditRecordDto(AuditLogEntity entity) => - new( - entity.EventType, - entity.ActorType, - entity.ActorId, - entity.TenantId, - entity.Ip, - entity.DetailJson, - entity.At, - entity.Id); -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер хранилища аудита +/// +public sealed class AuditLogStore(DealDbContext dbContext) : IAuditLogStore +{ + /// + public async Task AppendAsync(AuditRecordDto record, CancellationToken ct) + { + dbContext.AuditLog.Add(new AuditLogEntity + { + // Id генерирует БД (identity) — из DTO не копируется. + At = record.At, + ActorType = record.ActorType, + ActorId = record.ActorId, + TenantId = record.TenantId, + EventType = record.EventType, + Ip = record.Ip, + DetailJson = record.DetailJson, + }); + await dbContext.SaveChangesAsync(ct); + } + + /// + public async Task> QueryAsync(AuditQueryDto filter, CancellationToken ct) + { + var entities = await ApplyFilters(dbContext.AuditLog.AsNoTracking(), filter) + .OrderByDescending(a => a.At) + .Skip(Math.Max(0, filter.Offset)) + .Take(ClampLimit(filter.Limit)) + .ToListAsync(ct); + return entities.Select(ToAuditRecordDto).ToList(); + } + + /// + public async Task CountAsync(AuditQueryDto filter, CancellationToken ct) + { + return await ApplyFilters(dbContext.AuditLog.AsNoTracking(), filter).CountAsync(ct); + } + + /// + public async Task PurgeOlderThanAsync(DateTimeOffset cutoff, CancellationToken ct) + { + if (dbContext.Database.IsNpgsql()) + { + // Операционная авто-очистка: удаляем только устаревшие строки одним DELETE. + return await dbContext.AuditLog.Where(a => a.At < cutoff).ExecuteDeleteAsync(ct); + } + + // InMemory-провайдер (тесты) ExecuteDelete не исполняет — выгрузка и RemoveRange. + List expired = await dbContext.AuditLog + .Where(a => a.At < cutoff) + .ToListAsync(ct); + if (expired.Count == 0) + { + return 0; + } + + dbContext.AuditLog.RemoveRange(expired); + await dbContext.SaveChangesAsync(ct); + return expired.Count; + } + + // Применяет фильтры выборки (EventType/ActorType/TenantId/ActorId/At-range); без Limit/Offset/сортировки. + // query: Базовый запрос. + // filter: Фильтр выборки. + // Возвращает: Запрос с фильтрами. + private static IQueryable ApplyFilters(IQueryable query, AuditQueryDto filter) + { + if (!string.IsNullOrWhiteSpace(filter.EventType)) + { + query = query.Where(a => a.EventType == filter.EventType); + } + + if (!string.IsNullOrWhiteSpace(filter.ActorType)) + { + query = query.Where(a => a.ActorType == filter.ActorType); + } + + if (filter.TenantId is not null) + { + query = query.Where(a => a.TenantId == filter.TenantId); + } + + if (filter.ActorId is not null) + { + query = query.Where(a => a.ActorId == filter.ActorId); + } + + if (filter.From is not null) + { + query = query.Where(a => a.At >= filter.From.Value); + } + + if (filter.To is not null) + { + query = query.Where(a => a.At <= filter.To.Value); + } + + return query; + } + + private static int ClampLimit(int limit) => Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit)); + + private static AuditRecordDto ToAuditRecordDto(AuditLogEntity entity) => + new( + entity.EventType, + entity.ActorType, + entity.ActorId, + entity.TenantId, + entity.Ip, + entity.DetailJson, + entity.At, + entity.Id); +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs index 75be645..0ca3df1 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs @@ -1,128 +1,128 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища аутентификации -/// -public sealed class AuthStore(DealDbContext dbContext) : IAuthStore -{ - /// - public async Task FindUserByLoginAsync(string login, CancellationToken ct) - { - var entity = await dbContext.Users - .AsNoTracking() - .SingleOrDefaultAsync(u => u.Login == login, ct); - return entity is null ? null : ToStoredUserDto(entity); - } - - /// - public async Task CreateUserAsync(StoredUserDto user, CancellationToken ct) - { - dbContext.Users.Add(new UserEntity - { - Id = user.Id, - Login = user.Login, - TenantId = user.TenantId, - Status = user.Status, - PasswordHash = user.PasswordHash, - CreatedAt = DateTimeOffset.UtcNow, - }); - await dbContext.SaveChangesAsync(ct); - } - - /// - public async Task FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct) - { - // Протухшие сессии не возвращаем: с момента истечения они «не существуют» - // (физическую очистку выполняет DeleteExpiredSessionsAsync). - var entity = await dbContext.Sessions - .AsNoTracking() - .SingleOrDefaultAsync(s => s.TokenHash == tokenHash && s.ExpiresAt > DateTimeOffset.UtcNow, ct); - return entity is null ? null : ToSessionDto(entity); - } - - /// - public async Task FindUserByIdAsync(Guid userId, CancellationToken ct) - { - var entity = await dbContext.Users - .AsNoTracking() - .SingleOrDefaultAsync(u => u.Id == userId, ct); - return entity is null ? null : ToUserIdentityDto(entity); - } - - /// - public async Task> ListUsersByTenantIdAsync(Guid tenantId, CancellationToken ct) - { - var entities = await dbContext.Users - .AsNoTracking() - .Where(u => u.TenantId == tenantId) - .OrderBy(u => u.CreatedAt) - .ThenBy(u => u.Login) - .Select(u => ToUserIdentityDto(u)) - .ToListAsync(ct); - return entities; - } - - /// - public async Task CreateSessionAsync(SessionDto session, CancellationToken ct) - { - dbContext.Sessions.Add(new SessionEntity - { - TokenHash = session.TokenHash, - UserId = session.UserId, - Login = session.Login, - ExpiresAt = session.ExpiresAt, - CreatedAt = DateTimeOffset.UtcNow, - ImpersonatedByOperatorId = session.ImpersonatedByOperatorId, - }); - await dbContext.SaveChangesAsync(ct); - } - - /// - public async Task DeleteSessionAsync(string tokenHash, CancellationToken ct) - { - await dbContext.Sessions - .Where(s => s.TokenHash == tokenHash) - .ExecuteDeleteAsync(ct); - } - - /// - public async Task DeleteSessionsByUserIdAsync(Guid userId, CancellationToken ct) - { - await dbContext.Sessions - .Where(s => s.UserId == userId) - .ExecuteDeleteAsync(ct); - } - - /// - public async Task UpdatePasswordHashAsync( - Guid userId, - string passwordHash, - CancellationToken ct) - { - await dbContext.Users - .Where(u => u.Id == userId) - .ExecuteUpdateAsync(s => s.SetProperty(u => u.PasswordHash, passwordHash), ct); - } - - /// - public async Task DeleteExpiredSessionsAsync(CancellationToken ct) - { - await dbContext.Sessions - .Where(s => s.ExpiresAt <= DateTimeOffset.UtcNow) - .ExecuteDeleteAsync(ct); - } - - private static StoredUserDto ToStoredUserDto(UserEntity entity) => - new(entity.Id, entity.Login, entity.TenantId, entity.Status, entity.PasswordHash); - - private static UserIdentityDto ToUserIdentityDto(UserEntity entity) => - new(entity.Id, entity.Login, entity.TenantId, entity.Status); - - private static SessionDto ToSessionDto(SessionEntity entity) => - new(entity.TokenHash, entity.UserId, entity.Login, entity.ExpiresAt, entity.ImpersonatedByOperatorId); -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер хранилища аутентификации +/// +public sealed class AuthStore(DealDbContext dbContext) : IAuthStore +{ + /// + public async Task FindUserByLoginAsync(string login, CancellationToken ct) + { + var entity = await dbContext.Users + .AsNoTracking() + .SingleOrDefaultAsync(u => u.Login == login, ct); + return entity is null ? null : ToStoredUserDto(entity); + } + + /// + public async Task CreateUserAsync(StoredUserDto user, CancellationToken ct) + { + dbContext.Users.Add(new UserEntity + { + Id = user.Id, + Login = user.Login, + TenantId = user.TenantId, + Status = user.Status, + PasswordHash = user.PasswordHash, + CreatedAt = DateTimeOffset.UtcNow, + }); + await dbContext.SaveChangesAsync(ct); + } + + /// + public async Task FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct) + { + // Протухшие сессии не возвращаем: с момента истечения они «не существуют» + // (физическую очистку выполняет DeleteExpiredSessionsAsync). + var entity = await dbContext.Sessions + .AsNoTracking() + .SingleOrDefaultAsync(s => s.TokenHash == tokenHash && s.ExpiresAt > DateTimeOffset.UtcNow, ct); + return entity is null ? null : ToSessionDto(entity); + } + + /// + public async Task FindUserByIdAsync(Guid userId, CancellationToken ct) + { + var entity = await dbContext.Users + .AsNoTracking() + .SingleOrDefaultAsync(u => u.Id == userId, ct); + return entity is null ? null : ToUserIdentityDto(entity); + } + + /// + public async Task> ListUsersByTenantIdAsync(Guid tenantId, CancellationToken ct) + { + var entities = await dbContext.Users + .AsNoTracking() + .Where(u => u.TenantId == tenantId) + .OrderBy(u => u.CreatedAt) + .ThenBy(u => u.Login) + .Select(u => ToUserIdentityDto(u)) + .ToListAsync(ct); + return entities; + } + + /// + public async Task CreateSessionAsync(SessionDto session, CancellationToken ct) + { + dbContext.Sessions.Add(new SessionEntity + { + TokenHash = session.TokenHash, + UserId = session.UserId, + Login = session.Login, + ExpiresAt = session.ExpiresAt, + CreatedAt = DateTimeOffset.UtcNow, + ImpersonatedByOperatorId = session.ImpersonatedByOperatorId, + }); + await dbContext.SaveChangesAsync(ct); + } + + /// + public async Task DeleteSessionAsync(string tokenHash, CancellationToken ct) + { + await dbContext.Sessions + .Where(s => s.TokenHash == tokenHash) + .ExecuteDeleteAsync(ct); + } + + /// + public async Task DeleteSessionsByUserIdAsync(Guid userId, CancellationToken ct) + { + await dbContext.Sessions + .Where(s => s.UserId == userId) + .ExecuteDeleteAsync(ct); + } + + /// + public async Task UpdatePasswordHashAsync( + Guid userId, + string passwordHash, + CancellationToken ct) + { + await dbContext.Users + .Where(u => u.Id == userId) + .ExecuteUpdateAsync(s => s.SetProperty(u => u.PasswordHash, passwordHash), ct); + } + + /// + public async Task DeleteExpiredSessionsAsync(CancellationToken ct) + { + await dbContext.Sessions + .Where(s => s.ExpiresAt <= DateTimeOffset.UtcNow) + .ExecuteDeleteAsync(ct); + } + + private static StoredUserDto ToStoredUserDto(UserEntity entity) => + new(entity.Id, entity.Login, entity.TenantId, entity.Status, entity.PasswordHash); + + private static UserIdentityDto ToUserIdentityDto(UserEntity entity) => + new(entity.Id, entity.Login, entity.TenantId, entity.Status); + + private static SessionDto ToSessionDto(SessionEntity entity) => + new(entity.TokenHash, entity.UserId, entity.Login, entity.ExpiresAt, entity.ImpersonatedByOperatorId); +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Blacklist.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Blacklist.cs index bd9becc..2a1d0c8 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Blacklist.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Blacklist.cs @@ -1,65 +1,65 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Discovery.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Чёрный список Discovery — partial-часть -/// -public sealed partial class DiscoveryStore -{ - /// - public async Task UpsertBlacklistAsync( - string dialogId, - string name, - string reason, - CancellationToken ct) - { - DiscBlacklistEntity? row = await _dbContext.DiscBlacklist - .FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct); - if (row is null) - { - _dbContext.DiscBlacklist.Add(new DiscBlacklistEntity - { - DialogId = dialogId, - Name = name, - Reason = reason, - CreatedAt = DateTimeOffset.UtcNow, - }); - } - else - { - row.Name = name; - row.Reason = reason; - } - - await _dbContext.SaveChangesAsync(ct); - } - - /// - public async Task RemoveBlacklistAsync(string dialogId, CancellationToken ct) - { - await _dbContext.DiscBlacklist.Where(entry => entry.DialogId == dialogId).ExecuteDeleteAsync(ct); - } - - /// - public async Task GetBlacklistAsync(string dialogId, CancellationToken ct) - { - DiscBlacklistEntity? row = await _dbContext.DiscBlacklist - .AsNoTracking() - .FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct); - return row is null ? null : ToBlacklistDto(row); - } - - /// - public async Task> ListBlacklistAsync(CancellationToken ct) - { - List rows = await _dbContext.DiscBlacklist - .AsNoTracking() - .OrderByDescending(entry => entry.CreatedAt) - .ToListAsync(ct); - return rows.Select(ToBlacklistDto).ToList(); - } - -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Discovery.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// Чёрный список Discovery — partial-часть +/// +public sealed partial class DiscoveryStore +{ + /// + public async Task UpsertBlacklistAsync( + string dialogId, + string name, + string reason, + CancellationToken ct) + { + DiscBlacklistEntity? row = await _dbContext.DiscBlacklist + .FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct); + if (row is null) + { + _dbContext.DiscBlacklist.Add(new DiscBlacklistEntity + { + DialogId = dialogId, + Name = name, + Reason = reason, + CreatedAt = DateTimeOffset.UtcNow, + }); + } + else + { + row.Name = name; + row.Reason = reason; + } + + await _dbContext.SaveChangesAsync(ct); + } + + /// + public async Task RemoveBlacklistAsync(string dialogId, CancellationToken ct) + { + await _dbContext.DiscBlacklist.Where(entry => entry.DialogId == dialogId).ExecuteDeleteAsync(ct); + } + + /// + public async Task GetBlacklistAsync(string dialogId, CancellationToken ct) + { + DiscBlacklistEntity? row = await _dbContext.DiscBlacklist + .AsNoTracking() + .FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct); + return row is null ? null : ToBlacklistDto(row); + } + + /// + public async Task> ListBlacklistAsync(CancellationToken ct) + { + List rows = await _dbContext.DiscBlacklist + .AsNoTracking() + .OrderByDescending(entry => entry.CreatedAt) + .ToListAsync(ct); + return rows.Select(ToBlacklistDto).ToList(); + } + +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Candidates.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Candidates.cs index afc2777..6573fd7 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Candidates.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Candidates.cs @@ -1,164 +1,164 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Discovery.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Кандидаты Discovery — partial-часть -/// -public sealed partial class DiscoveryStore -{ - /// - public async Task> ListCandidatesAsync( - string taskId, - string? status, - CancellationToken ct) - { - IQueryable query = _dbContext.DiscCandidates.AsNoTracking().Where(candidate => candidate.TaskId == taskId); - if (status is not null) - { - query = query.Where(candidate => candidate.Status == status); - } - - List rows = await query.OrderBy(candidate => candidate.CreatedAt).ToListAsync(ct); - return rows.Select(ToCandidateDto).ToList(); - } - - /// - public async Task GetCandidateAsync(string dialogId, CancellationToken ct) - { - DiscCandidateEntity? row = await _dbContext.DiscCandidates - .AsNoTracking() - .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); - return row is null ? null : ToCandidateDto(row); - } - - /// - public async Task IsDialogMonitoredAsync(string dialogId, CancellationToken ct) - { - return await _dbContext.Dialogs.AnyAsync(dialog => dialog.Id == dialogId, ct); - } - - /// - public Task IsBlacklistedAsync(string dialogId, CancellationToken ct) - { - return _dbContext.DiscBlacklist.AnyAsync(row => row.DialogId == dialogId, ct); - } - - /// - public async Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct) - { - DateTimeOffset now = DateTimeOffset.UtcNow; - _dbContext.DiscCandidates.Add(new DiscCandidateEntity - { - DialogId = row.DialogId, - TaskId = row.TaskId, - Name = row.Name, - Username = row.Username, - Kind = row.Kind, - Hue = row.Hue, - Status = "new", - CreatedAt = now, - UpdatedAt = now, - }); - await _dbContext.SaveChangesAsync(ct); - } - - /// - public async Task DeleteCandidateAsync(string dialogId, CancellationToken ct) - { - await _dbContext.DiscCandidates.Where(candidate => candidate.DialogId == dialogId).ExecuteDeleteAsync(ct); - } - - /// - public async Task PatchCandidateAsync( - string dialogId, - DiscoveryCandidatePatch patch, - CancellationToken ct) - { - DiscCandidateEntity? row = await _dbContext.DiscCandidates - .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); - if (row is null) - { - return false; - } - - ApplyCandidatePatch(row, patch); - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task SetCandidateStatusAsync( - string dialogId, - string status, - CancellationToken ct) - { - DiscCandidateEntity? row = await _dbContext.DiscCandidates - .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); - if (row is null) - { - return false; - } - - row.Status = status; - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task SetCandidateJoinedAsync( - string dialogId, - bool autoJoined, - CancellationToken ct) - { - DiscCandidateEntity? row = await _dbContext.DiscCandidates - .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); - if (row is null) - { - return false; - } - - row.Status = "joined"; - row.AutoJoined = autoJoined; - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task IncrementJoinFailuresAsync(string dialogId, CancellationToken ct) - { - DiscCandidateEntity? row = await _dbContext.DiscCandidates - .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); - if (row is null || row.Status != "review") - { - return null; - } - - row.JoinFailures += 1; - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return row.JoinFailures; - } - - /// - public async Task SetCandidateRejectedAsync(string dialogId, CancellationToken ct) - { - DiscCandidateEntity? row = await _dbContext.DiscCandidates - .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); - if (row is null) - { - return false; - } - - row.Status = "rejected"; - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Discovery.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// Кандидаты Discovery — partial-часть +/// +public sealed partial class DiscoveryStore +{ + /// + public async Task> ListCandidatesAsync( + string taskId, + string? status, + CancellationToken ct) + { + IQueryable query = _dbContext.DiscCandidates.AsNoTracking().Where(candidate => candidate.TaskId == taskId); + if (status is not null) + { + query = query.Where(candidate => candidate.Status == status); + } + + List rows = await query.OrderBy(candidate => candidate.CreatedAt).ToListAsync(ct); + return rows.Select(ToCandidateDto).ToList(); + } + + /// + public async Task GetCandidateAsync(string dialogId, CancellationToken ct) + { + DiscCandidateEntity? row = await _dbContext.DiscCandidates + .AsNoTracking() + .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); + return row is null ? null : ToCandidateDto(row); + } + + /// + public async Task IsDialogMonitoredAsync(string dialogId, CancellationToken ct) + { + return await _dbContext.Dialogs.AnyAsync(dialog => dialog.Id == dialogId, ct); + } + + /// + public Task IsBlacklistedAsync(string dialogId, CancellationToken ct) + { + return _dbContext.DiscBlacklist.AnyAsync(row => row.DialogId == dialogId, ct); + } + + /// + public async Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + _dbContext.DiscCandidates.Add(new DiscCandidateEntity + { + DialogId = row.DialogId, + TaskId = row.TaskId, + Name = row.Name, + Username = row.Username, + Kind = row.Kind, + Hue = row.Hue, + Status = "new", + CreatedAt = now, + UpdatedAt = now, + }); + await _dbContext.SaveChangesAsync(ct); + } + + /// + public async Task DeleteCandidateAsync(string dialogId, CancellationToken ct) + { + await _dbContext.DiscCandidates.Where(candidate => candidate.DialogId == dialogId).ExecuteDeleteAsync(ct); + } + + /// + public async Task PatchCandidateAsync( + string dialogId, + DiscoveryCandidatePatch patch, + CancellationToken ct) + { + DiscCandidateEntity? row = await _dbContext.DiscCandidates + .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); + if (row is null) + { + return false; + } + + ApplyCandidatePatch(row, patch); + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task SetCandidateStatusAsync( + string dialogId, + string status, + CancellationToken ct) + { + DiscCandidateEntity? row = await _dbContext.DiscCandidates + .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); + if (row is null) + { + return false; + } + + row.Status = status; + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task SetCandidateJoinedAsync( + string dialogId, + bool autoJoined, + CancellationToken ct) + { + DiscCandidateEntity? row = await _dbContext.DiscCandidates + .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); + if (row is null) + { + return false; + } + + row.Status = "joined"; + row.AutoJoined = autoJoined; + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task IncrementJoinFailuresAsync(string dialogId, CancellationToken ct) + { + DiscCandidateEntity? row = await _dbContext.DiscCandidates + .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); + if (row is null || row.Status != "review") + { + return null; + } + + row.JoinFailures += 1; + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return row.JoinFailures; + } + + /// + public async Task SetCandidateRejectedAsync(string dialogId, CancellationToken ct) + { + DiscCandidateEntity? row = await _dbContext.DiscCandidates + .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); + if (row is null) + { + return false; + } + + row.Status = "rejected"; + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Logs.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Logs.cs index 288b1ee..a688408 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Logs.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Logs.cs @@ -1,54 +1,54 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Discovery.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Лог Discovery — partial-часть -/// -public sealed partial class DiscoveryStore -{ - /// - public async Task AddLogAsync( - string logId, - string taskId, - string logEvent, - string text, - CancellationToken ct) - { - _dbContext.DiscLog.Add(new DiscLogEntity - { - Id = logId, - TaskId = taskId, - Event = logEvent, - Text = text, - CreatedAt = DateTimeOffset.UtcNow, - }); - await _dbContext.SaveChangesAsync(ct); - } - - /// - public async Task CountLogEventAsync( - string logEvent, - DateTimeOffset sinceUtc, - CancellationToken ct) - { - return await _dbContext.DiscLog.CountAsync(row => row.Event == logEvent && row.CreatedAt >= sinceUtc, ct); - } - - /// - public async Task> ListTaskLogAsync( - string taskId, - int limit, - CancellationToken ct) - { - List rows = await _dbContext.DiscLog - .AsNoTracking() - .Where(log => log.TaskId == taskId) - .OrderByDescending(log => log.CreatedAt) - .Take(limit) - .ToListAsync(ct); - return rows.Select(ToLogDto).ToList(); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Discovery.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// Лог Discovery — partial-часть +/// +public sealed partial class DiscoveryStore +{ + /// + public async Task AddLogAsync( + string logId, + string taskId, + string logEvent, + string text, + CancellationToken ct) + { + _dbContext.DiscLog.Add(new DiscLogEntity + { + Id = logId, + TaskId = taskId, + Event = logEvent, + Text = text, + CreatedAt = DateTimeOffset.UtcNow, + }); + await _dbContext.SaveChangesAsync(ct); + } + + /// + public async Task CountLogEventAsync( + string logEvent, + DateTimeOffset sinceUtc, + CancellationToken ct) + { + return await _dbContext.DiscLog.CountAsync(row => row.Event == logEvent && row.CreatedAt >= sinceUtc, ct); + } + + /// + public async Task> ListTaskLogAsync( + string taskId, + int limit, + CancellationToken ct) + { + List rows = await _dbContext.DiscLog + .AsNoTracking() + .Where(log => log.TaskId == taskId) + .OrderByDescending(log => log.CreatedAt) + .Take(limit) + .ToListAsync(ct); + return rows.Select(ToLogDto).ToList(); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Tasks.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Tasks.cs index f72473c..5cbe731 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Tasks.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Tasks.cs @@ -1,222 +1,222 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Discovery.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Задачи Discovery — partial-часть -/// -public sealed partial class DiscoveryStore -{ - /// - public async Task> ListTasksAsync(CancellationToken ct) - { - List rows = await _dbContext.DiscTasks - .AsNoTracking() - .OrderBy(task => task.CreatedAt) - .ToListAsync(ct); - return rows.Select(ToTaskDto).ToList(); - } - - /// - public async Task GetTaskAsync(string taskId, CancellationToken ct) - { - DiscTaskEntity? row = await _dbContext.DiscTasks - .AsNoTracking() - .FirstOrDefaultAsync(task => task.Id == taskId, ct); - return row is null ? null : ToTaskDto(row); - } - - /// - public async Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct) - { - DateTimeOffset now = DateTimeOffset.UtcNow; - _dbContext.DiscTasks.Add(new DiscTaskEntity - { - Id = row.Id, - Name = row.Name, - Description = row.Description, - KeywordsJson = ToJson(row.Keywords), - MinSubscribers = row.MinSubscribers, - Lang = row.Lang, - Threshold = row.Threshold, - SampleSize = row.SampleSize, - PlanJoins = row.PlanJoins, - AutoJoin = row.AutoJoin, - Status = "draft", - CreatedAt = now, - UpdatedAt = now, - }); - await _dbContext.SaveChangesAsync(ct); - } - - /// - public async Task PatchTaskAsync( - string taskId, - DiscoveryTaskPatch patch, - CancellationToken ct) - { - DiscTaskEntity? row = await _dbContext.DiscTasks - .FirstOrDefaultAsync(task => task.Id == taskId, ct); - if (row is null) - { - return false; - } - - ApplyTaskPatch(row, patch); - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task DeleteTaskAsync(string taskId, CancellationToken ct) - { - DiscTaskEntity? row = await _dbContext.DiscTasks - .FirstOrDefaultAsync(task => task.Id == taskId, ct); - if (row is null) - { - return false; - } - - await _dbContext.DiscCandidates.Where(candidate => candidate.TaskId == taskId).ExecuteDeleteAsync(ct); - await _dbContext.DiscLog.Where(log => log.TaskId == taskId).ExecuteDeleteAsync(ct); - _dbContext.DiscTasks.Remove(row); - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task SetTaskRunningAsync( - string taskId, - bool resetProgress, - CancellationToken ct) - { - DiscTaskEntity? row = await _dbContext.DiscTasks - .FirstOrDefaultAsync(task => task.Id == taskId, ct); - if (row is null) - { - return false; - } - - row.Status = "running"; - if (resetProgress) - { - row.SearchIdx = 0; - row.SearchDone = false; - row.Found = 0; - row.Evaluated = 0; - row.Joined = 0; - row.Rejected = 0; - } - - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task SetTaskPausedAsync(string taskId, CancellationToken ct) - { - DiscTaskEntity? row = await _dbContext.DiscTasks - .FirstOrDefaultAsync(task => task.Id == taskId, ct); - if (row is null) - { - return false; - } - - row.Status = "paused"; - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task SetTaskDoneAsync(string taskId, CancellationToken ct) - { - DiscTaskEntity? row = await _dbContext.DiscTasks - .FirstOrDefaultAsync(task => task.Id == taskId, ct); - if (row is null) - { - return false; - } - - row.Status = "done"; - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task BumpTaskCounterAsync( - string taskId, - DiscoveryCounterField field, - int n, - CancellationToken ct) - { - DiscTaskEntity? row = await _dbContext.DiscTasks - .FirstOrDefaultAsync(task => task.Id == taskId, ct); - if (row is null || n <= 0) - { - return row is not null; - } - - switch (field) - { - case DiscoveryCounterField.Found: - row.Found += n; - break; - case DiscoveryCounterField.Evaluated: - row.Evaluated += n; - break; - case DiscoveryCounterField.Joined: - row.Joined += n; - break; - case DiscoveryCounterField.Rejected: - row.Rejected += n; - break; - default: - throw new ArgumentOutOfRangeException(nameof(field), field, "Неизвестный счётчик задачи"); - } - - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task AdvanceSearchAsync( - string taskId, - int nextIndex, - bool searchDone, - CancellationToken ct) - { - DiscTaskEntity? row = await _dbContext.DiscTasks - .FirstOrDefaultAsync(task => task.Id == taskId, ct); - if (row is null) - { - return false; - } - - row.SearchIdx = nextIndex; - row.SearchDone = searchDone; - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task SumActivePlanAsync(string? excludeTaskId, CancellationToken ct) - { - IQueryable query = _dbContext.DiscTasks - .Where(task => task.Status != "done" && task.Status != "failed"); - if (excludeTaskId is not null) - { - query = query.Where(task => task.Id != excludeTaskId); - } - - return await query.SumAsync(task => task.PlanJoins, ct); - } - -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Discovery.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// Задачи Discovery — partial-часть +/// +public sealed partial class DiscoveryStore +{ + /// + public async Task> ListTasksAsync(CancellationToken ct) + { + List rows = await _dbContext.DiscTasks + .AsNoTracking() + .OrderBy(task => task.CreatedAt) + .ToListAsync(ct); + return rows.Select(ToTaskDto).ToList(); + } + + /// + public async Task GetTaskAsync(string taskId, CancellationToken ct) + { + DiscTaskEntity? row = await _dbContext.DiscTasks + .AsNoTracking() + .FirstOrDefaultAsync(task => task.Id == taskId, ct); + return row is null ? null : ToTaskDto(row); + } + + /// + public async Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + _dbContext.DiscTasks.Add(new DiscTaskEntity + { + Id = row.Id, + Name = row.Name, + Description = row.Description, + KeywordsJson = ToJson(row.Keywords), + MinSubscribers = row.MinSubscribers, + Lang = row.Lang, + Threshold = row.Threshold, + SampleSize = row.SampleSize, + PlanJoins = row.PlanJoins, + AutoJoin = row.AutoJoin, + Status = "draft", + CreatedAt = now, + UpdatedAt = now, + }); + await _dbContext.SaveChangesAsync(ct); + } + + /// + public async Task PatchTaskAsync( + string taskId, + DiscoveryTaskPatch patch, + CancellationToken ct) + { + DiscTaskEntity? row = await _dbContext.DiscTasks + .FirstOrDefaultAsync(task => task.Id == taskId, ct); + if (row is null) + { + return false; + } + + ApplyTaskPatch(row, patch); + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task DeleteTaskAsync(string taskId, CancellationToken ct) + { + DiscTaskEntity? row = await _dbContext.DiscTasks + .FirstOrDefaultAsync(task => task.Id == taskId, ct); + if (row is null) + { + return false; + } + + await _dbContext.DiscCandidates.Where(candidate => candidate.TaskId == taskId).ExecuteDeleteAsync(ct); + await _dbContext.DiscLog.Where(log => log.TaskId == taskId).ExecuteDeleteAsync(ct); + _dbContext.DiscTasks.Remove(row); + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task SetTaskRunningAsync( + string taskId, + bool resetProgress, + CancellationToken ct) + { + DiscTaskEntity? row = await _dbContext.DiscTasks + .FirstOrDefaultAsync(task => task.Id == taskId, ct); + if (row is null) + { + return false; + } + + row.Status = "running"; + if (resetProgress) + { + row.SearchIdx = 0; + row.SearchDone = false; + row.Found = 0; + row.Evaluated = 0; + row.Joined = 0; + row.Rejected = 0; + } + + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task SetTaskPausedAsync(string taskId, CancellationToken ct) + { + DiscTaskEntity? row = await _dbContext.DiscTasks + .FirstOrDefaultAsync(task => task.Id == taskId, ct); + if (row is null) + { + return false; + } + + row.Status = "paused"; + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task SetTaskDoneAsync(string taskId, CancellationToken ct) + { + DiscTaskEntity? row = await _dbContext.DiscTasks + .FirstOrDefaultAsync(task => task.Id == taskId, ct); + if (row is null) + { + return false; + } + + row.Status = "done"; + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task BumpTaskCounterAsync( + string taskId, + DiscoveryCounterField field, + int n, + CancellationToken ct) + { + DiscTaskEntity? row = await _dbContext.DiscTasks + .FirstOrDefaultAsync(task => task.Id == taskId, ct); + if (row is null || n <= 0) + { + return row is not null; + } + + switch (field) + { + case DiscoveryCounterField.Found: + row.Found += n; + break; + case DiscoveryCounterField.Evaluated: + row.Evaluated += n; + break; + case DiscoveryCounterField.Joined: + row.Joined += n; + break; + case DiscoveryCounterField.Rejected: + row.Rejected += n; + break; + default: + throw new ArgumentOutOfRangeException(nameof(field), field, "Неизвестный счётчик задачи"); + } + + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task AdvanceSearchAsync( + string taskId, + int nextIndex, + bool searchDone, + CancellationToken ct) + { + DiscTaskEntity? row = await _dbContext.DiscTasks + .FirstOrDefaultAsync(task => task.Id == taskId, ct); + if (row is null) + { + return false; + } + + row.SearchIdx = nextIndex; + row.SearchDone = searchDone; + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task SumActivePlanAsync(string? excludeTaskId, CancellationToken ct) + { + IQueryable query = _dbContext.DiscTasks + .Where(task => task.Status != "done" && task.Status != "failed"); + if (excludeTaskId is not null) + { + query = query.Where(task => task.Id != excludeTaskId); + } + + return await query.SumAsync(task => task.PlanJoins, ct); + } + +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs index 464dc7c..d1e3e13 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs @@ -1,199 +1,199 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Models; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища Discovery -/// -public sealed partial class DiscoveryStore : IDiscoveryStore -{ - private readonly TenantDbContext _dbContext; - - /// - /// Создаёт EF-адаптер хранилища Discovery - /// - /// Scoped-контекст тенанта запроса (search_path). - public DiscoveryStore(TenantDbContext dbContext) - { - _dbContext = dbContext; - } - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - private static DiscoveryTaskDto ToTaskDto(DiscTaskEntity row) - { - return new DiscoveryTaskDto( - row.Id, - row.Name, - row.Description, - FromJson(row.KeywordsJson), - row.MinSubscribers, - row.Lang, - row.Threshold, - row.SampleSize, - row.PlanJoins, - row.AutoJoin, - row.Status, - row.SearchIdx, - row.SearchDone, - row.Found, - row.Evaluated, - row.Joined, - row.Rejected, - row.CreatedAt.ToUnixTimeMilliseconds(), - row.UpdatedAt.ToUnixTimeMilliseconds()); - } - - private static DiscoveryCandidateDto ToCandidateDto(DiscCandidateEntity row) - { - return new DiscoveryCandidateDto( - row.DialogId, - row.TaskId, - row.Name, - row.Username, - row.Kind, - row.Hue, - row.Participants, - row.LangRu, - FromJson(row.MarksJson), - FromJson(row.TopicsJson), - row.FitRatio, - row.Status, - row.AutoJoined, - row.JoinFailures, - row.CreatedAt.ToUnixTimeMilliseconds(), - row.UpdatedAt.ToUnixTimeMilliseconds()); - } - - private static DiscoveryBlacklistDto ToBlacklistDto(DiscBlacklistEntity row) - { - return new DiscoveryBlacklistDto(row.DialogId, row.Name, row.Reason, row.CreatedAt.ToUnixTimeMilliseconds()); - } - - private static DiscoveryLogDto ToLogDto(DiscLogEntity row) - { - return new DiscoveryLogDto(row.Id, row.TaskId, row.Event, row.Text, row.CreatedAt.ToUnixTimeMilliseconds()); - } - - private static void ApplyTaskPatch(DiscTaskEntity row, DiscoveryTaskPatch patch) - { - if (patch.Name is not null) - { - row.Name = patch.Name; - } - - if (patch.Description is not null) - { - row.Description = patch.Description; - } - - if (patch.Keywords is not null) - { - row.KeywordsJson = ToJson(patch.Keywords); - } - - if (patch.MinSubscribers is int minSubscribers) - { - row.MinSubscribers = minSubscribers; - } - - if (patch.Lang is not null) - { - row.Lang = patch.Lang; - } - - if (patch.Threshold is int threshold) - { - row.Threshold = threshold; - } - - if (patch.SampleSize is int sampleSize) - { - row.SampleSize = sampleSize; - } - - if (patch.PlanJoins is int planJoins) - { - row.PlanJoins = planJoins; - } - - if (patch.AutoJoin is bool autoJoin) - { - row.AutoJoin = autoJoin; - } - } - - private static void ApplyCandidatePatch(DiscCandidateEntity row, DiscoveryCandidatePatch patch) - { - if (patch.Name is not null) - { - row.Name = patch.Name; - } - - if (patch.Username is not null) - { - row.Username = patch.Username; - } - - if (patch.Kind is not null) - { - row.Kind = patch.Kind; - } - - if (patch.Hue is not null) - { - row.Hue = patch.Hue; - } - - if (patch.Participants is int participants) - { - row.Participants = participants; - } - - if (patch.LangRu is bool langRu) - { - row.LangRu = langRu; - } - - if (patch.Marks is not null) - { - row.MarksJson = ToJson(patch.Marks); - } - - if (patch.Topics is not null) - { - row.TopicsJson = ToJson(patch.Topics); - } - - if (patch.FitRatio is double fitRatio) - { - row.FitRatio = fitRatio; - } - - if (patch.AutoJoined is bool autoJoined) - { - row.AutoJoined = autoJoined; - } - } - - private static IReadOnlyList FromJson(string json) - { - try - { - return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; - } - catch (JsonException) - { - return []; - } - } - - // Сериализует значение в JSON (camelCase, конвенция value_json). - private static string ToJson(T value) => JsonSerializer.Serialize(value, JsonOptions); -} +using System.Text.Json; +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Models; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер хранилища Discovery +/// +public sealed partial class DiscoveryStore : IDiscoveryStore +{ + private readonly TenantDbContext _dbContext; + + /// + /// Создаёт EF-адаптер хранилища Discovery + /// + /// Scoped-контекст тенанта запроса (search_path). + public DiscoveryStore(TenantDbContext dbContext) + { + _dbContext = dbContext; + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + private static DiscoveryTaskDto ToTaskDto(DiscTaskEntity row) + { + return new DiscoveryTaskDto( + row.Id, + row.Name, + row.Description, + FromJson(row.KeywordsJson), + row.MinSubscribers, + row.Lang, + row.Threshold, + row.SampleSize, + row.PlanJoins, + row.AutoJoin, + row.Status, + row.SearchIdx, + row.SearchDone, + row.Found, + row.Evaluated, + row.Joined, + row.Rejected, + row.CreatedAt.ToUnixTimeMilliseconds(), + row.UpdatedAt.ToUnixTimeMilliseconds()); + } + + private static DiscoveryCandidateDto ToCandidateDto(DiscCandidateEntity row) + { + return new DiscoveryCandidateDto( + row.DialogId, + row.TaskId, + row.Name, + row.Username, + row.Kind, + row.Hue, + row.Participants, + row.LangRu, + FromJson(row.MarksJson), + FromJson(row.TopicsJson), + row.FitRatio, + row.Status, + row.AutoJoined, + row.JoinFailures, + row.CreatedAt.ToUnixTimeMilliseconds(), + row.UpdatedAt.ToUnixTimeMilliseconds()); + } + + private static DiscoveryBlacklistDto ToBlacklistDto(DiscBlacklistEntity row) + { + return new DiscoveryBlacklistDto(row.DialogId, row.Name, row.Reason, row.CreatedAt.ToUnixTimeMilliseconds()); + } + + private static DiscoveryLogDto ToLogDto(DiscLogEntity row) + { + return new DiscoveryLogDto(row.Id, row.TaskId, row.Event, row.Text, row.CreatedAt.ToUnixTimeMilliseconds()); + } + + private static void ApplyTaskPatch(DiscTaskEntity row, DiscoveryTaskPatch patch) + { + if (patch.Name is not null) + { + row.Name = patch.Name; + } + + if (patch.Description is not null) + { + row.Description = patch.Description; + } + + if (patch.Keywords is not null) + { + row.KeywordsJson = ToJson(patch.Keywords); + } + + if (patch.MinSubscribers is int minSubscribers) + { + row.MinSubscribers = minSubscribers; + } + + if (patch.Lang is not null) + { + row.Lang = patch.Lang; + } + + if (patch.Threshold is int threshold) + { + row.Threshold = threshold; + } + + if (patch.SampleSize is int sampleSize) + { + row.SampleSize = sampleSize; + } + + if (patch.PlanJoins is int planJoins) + { + row.PlanJoins = planJoins; + } + + if (patch.AutoJoin is bool autoJoin) + { + row.AutoJoin = autoJoin; + } + } + + private static void ApplyCandidatePatch(DiscCandidateEntity row, DiscoveryCandidatePatch patch) + { + if (patch.Name is not null) + { + row.Name = patch.Name; + } + + if (patch.Username is not null) + { + row.Username = patch.Username; + } + + if (patch.Kind is not null) + { + row.Kind = patch.Kind; + } + + if (patch.Hue is not null) + { + row.Hue = patch.Hue; + } + + if (patch.Participants is int participants) + { + row.Participants = participants; + } + + if (patch.LangRu is bool langRu) + { + row.LangRu = langRu; + } + + if (patch.Marks is not null) + { + row.MarksJson = ToJson(patch.Marks); + } + + if (patch.Topics is not null) + { + row.TopicsJson = ToJson(patch.Topics); + } + + if (patch.FitRatio is double fitRatio) + { + row.FitRatio = fitRatio; + } + + if (patch.AutoJoined is bool autoJoined) + { + row.AutoJoined = autoJoined; + } + } + + private static IReadOnlyList FromJson(string json) + { + try + { + return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + } + catch (JsonException) + { + return []; + } + } + + // Сериализует значение в JSON (camelCase, конвенция value_json). + private static string ToJson(T value) => JsonSerializer.Serialize(value, JsonOptions); +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/GlobalSettingsStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/GlobalSettingsStore.cs index 022fea3..6bf5214 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/GlobalSettingsStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/GlobalSettingsStore.cs @@ -1,48 +1,48 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер KV-хранилища глобальных -/// -public sealed class GlobalSettingsStore(DealDbContext dbContext) : IGlobalSettingsStore -{ - /// - public async Task GetAsync(string key, CancellationToken ct) - { - var entity = await dbContext.GlobalSettings - .AsNoTracking() - .SingleOrDefaultAsync(s => s.Key == key, ct); - return entity is null ? null : new SettingValue(entity.Key, entity.Value, entity.UpdatedAt); - } - - /// - public async Task SetAsync( - string key, - string valueJson, - CancellationToken ct) - { - // Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется. - GlobalSettingEntity? entity = await dbContext.GlobalSettings - .SingleOrDefaultAsync(s => s.Key == key, ct); - if (entity is null) - { - dbContext.GlobalSettings.Add(new GlobalSettingEntity - { - Key = key, - Value = valueJson, - UpdatedAt = DateTimeOffset.UtcNow, - }); - } - else - { - entity.Value = valueJson; - entity.UpdatedAt = DateTimeOffset.UtcNow; - } - - await dbContext.SaveChangesAsync(ct); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер KV-хранилища глобальных +/// +public sealed class GlobalSettingsStore(DealDbContext dbContext) : IGlobalSettingsStore +{ + /// + public async Task GetAsync(string key, CancellationToken ct) + { + var entity = await dbContext.GlobalSettings + .AsNoTracking() + .SingleOrDefaultAsync(s => s.Key == key, ct); + return entity is null ? null : new SettingValue(entity.Key, entity.Value, entity.UpdatedAt); + } + + /// + public async Task SetAsync( + string key, + string valueJson, + CancellationToken ct) + { + // Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется. + GlobalSettingEntity? entity = await dbContext.GlobalSettings + .SingleOrDefaultAsync(s => s.Key == key, ct); + if (entity is null) + { + dbContext.GlobalSettings.Add(new GlobalSettingEntity + { + Key = key, + Value = valueJson, + UpdatedAt = DateTimeOffset.UtcNow, + }); + } + else + { + entity.Value = valueJson; + entity.UpdatedAt = DateTimeOffset.UtcNow; + } + + await dbContext.SaveChangesAsync(ct); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/InviteStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/InviteStore.cs index d3857a3..24b77b4 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/InviteStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/InviteStore.cs @@ -1,105 +1,105 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища приглашений -/// -public sealed class InviteStore(DealDbContext dbContext) : IInviteStore -{ - /// - public async Task CreateAsync(InviteDto invite, CancellationToken ct) - { - dbContext.Invites.Add(new InviteEntity - { - Code = invite.Code, - Email = invite.Email, - TenantId = invite.TenantId, - Status = invite.Status, - ExpiresAt = invite.ExpiresAt, - ActivatedAt = invite.ActivatedAt, - CreatedById = invite.CreatedById, - CreatedAt = invite.CreatedAt, - }); - await dbContext.SaveChangesAsync(ct); - } - - /// - public async Task GetByCodeAsync(string code, CancellationToken ct) - { - var entity = await dbContext.Invites - .AsNoTracking() - .SingleOrDefaultAsync(i => i.Code == code, ct); - return entity is null ? null : ToInviteDto(entity); - } - - /// - public async Task> ListAsync(CancellationToken ct) - { - var entities = await dbContext.Invites - .AsNoTracking() - .OrderByDescending(i => i.CreatedAt) - .ToListAsync(ct); - return entities.Select(ToInviteDto).ToList(); - } - - /// - public async Task UpdateStatusAsync( - string code, - string status, - DateTimeOffset? activatedAt, - CancellationToken ct) - { - var entity = await dbContext.Invites.SingleOrDefaultAsync(i => i.Code == code, ct); - if (entity is null) - { - return false; - } - - entity.Status = status; - entity.ActivatedAt = activatedAt; - await dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task TryActivateAsync( - string code, - DateTimeOffset activatedAt, - CancellationToken ct) - { - int affected = await dbContext.Invites - .Where(i => i.Code == code && i.Status == InviteStatuses.Pending) - .ExecuteUpdateAsync(s => s - .SetProperty(i => i.Status, InviteStatuses.Activated) - .SetProperty(i => i.ActivatedAt, activatedAt), ct); - return affected > 0; - } - - /// - public async Task FindActiveByEmailAsync(string email, CancellationToken ct) - { - // «Активное» = статус pending (зеркало частичного unique-индекса InviteConfiguration: WHERE Status='pending'). - // Протухшее, но ещё не помеченное pending-приглашение тоже вернётся — переводом в expired занимается сервис. - var entity = await dbContext.Invites - .AsNoTracking() - .Where(i => i.Status == InviteStatuses.Pending && i.Email == email) - .FirstOrDefaultAsync(ct); - return entity is null ? null : ToInviteDto(entity); - } - - private static InviteDto ToInviteDto(InviteEntity entity) => - new( - entity.Code, - entity.Email, - entity.TenantId, - entity.Status, - entity.ExpiresAt, - entity.ActivatedAt, - entity.CreatedById, - entity.CreatedAt); -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер хранилища приглашений +/// +public sealed class InviteStore(DealDbContext dbContext) : IInviteStore +{ + /// + public async Task CreateAsync(InviteDto invite, CancellationToken ct) + { + dbContext.Invites.Add(new InviteEntity + { + Code = invite.Code, + Email = invite.Email, + TenantId = invite.TenantId, + Status = invite.Status, + ExpiresAt = invite.ExpiresAt, + ActivatedAt = invite.ActivatedAt, + CreatedById = invite.CreatedById, + CreatedAt = invite.CreatedAt, + }); + await dbContext.SaveChangesAsync(ct); + } + + /// + public async Task GetByCodeAsync(string code, CancellationToken ct) + { + var entity = await dbContext.Invites + .AsNoTracking() + .SingleOrDefaultAsync(i => i.Code == code, ct); + return entity is null ? null : ToInviteDto(entity); + } + + /// + public async Task> ListAsync(CancellationToken ct) + { + var entities = await dbContext.Invites + .AsNoTracking() + .OrderByDescending(i => i.CreatedAt) + .ToListAsync(ct); + return entities.Select(ToInviteDto).ToList(); + } + + /// + public async Task UpdateStatusAsync( + string code, + string status, + DateTimeOffset? activatedAt, + CancellationToken ct) + { + var entity = await dbContext.Invites.SingleOrDefaultAsync(i => i.Code == code, ct); + if (entity is null) + { + return false; + } + + entity.Status = status; + entity.ActivatedAt = activatedAt; + await dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task TryActivateAsync( + string code, + DateTimeOffset activatedAt, + CancellationToken ct) + { + int affected = await dbContext.Invites + .Where(i => i.Code == code && i.Status == InviteStatuses.Pending) + .ExecuteUpdateAsync(s => s + .SetProperty(i => i.Status, InviteStatuses.Activated) + .SetProperty(i => i.ActivatedAt, activatedAt), ct); + return affected > 0; + } + + /// + public async Task FindActiveByEmailAsync(string email, CancellationToken ct) + { + // «Активное» = статус pending (зеркало частичного unique-индекса InviteConfiguration: WHERE Status='pending'). + // Протухшее, но ещё не помеченное pending-приглашение тоже вернётся — переводом в expired занимается сервис. + var entity = await dbContext.Invites + .AsNoTracking() + .Where(i => i.Status == InviteStatuses.Pending && i.Email == email) + .FirstOrDefaultAsync(ct); + return entity is null ? null : ToInviteDto(entity); + } + + private static InviteDto ToInviteDto(InviteEntity entity) => + new( + entity.Code, + entity.Email, + entity.TenantId, + entity.Status, + entity.ExpiresAt, + entity.ActivatedAt, + entity.CreatedById, + entity.CreatedAt); +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs index 8bf15b1..23d7ae5 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs @@ -1,250 +1,250 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Kanban.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Карточки канбана — partial-часть -/// -public sealed partial class KanbanStore -{ - /// - public async Task> ListCardsAsync(CardsQuery query, CancellationToken ct) - { - IQueryable queryable = _dbContext.Cards.AsNoTracking(); - if (query.Col is null) - { - queryable = queryable.Where(card => !SelectedStageIds.Contains(card.Col)); - } - else - { - queryable = queryable.Where(card => card.Col == query.Col); - } - - List entities = await queryable - .OrderByDescending(card => card.ReceivedAt) - .ToListAsync(ct); - return await ToCardDtosAsync(entities, ct); - } - - /// - public async Task> SearchCardsAsync( - string q, - int limit, - CancellationToken ct) - { - string query = q.Trim(); - if (query.Length == 0) - { - return Array.Empty(); - } - - string pattern = $"%{query}%"; - List entities = await _dbContext.Cards - .FromSqlInterpolated( - $""" - SELECT * FROM "Cards" - WHERE "Col" <> ALL({SelectedStageIds}) - AND ("SearchTsv" @@ plainto_tsquery('russian', {query}) - OR lower("Title") LIKE {pattern} - OR lower("Summary") LIKE {pattern} - OR lower("SourceText") LIKE {pattern} - OR lower("Contact") LIKE {pattern}) - ORDER BY ts_rank("SearchTsv", plainto_tsquery('russian', {query})) DESC, "ReceivedAt" DESC - LIMIT {limit} - """) - .AsNoTracking() - .ToListAsync(ct); - return await ToCardDtosAsync(entities, ct); - } - - /// - public async Task GetCardAsync(string cardId, CancellationToken ct) - { - CardEntity? entity = await _dbContext.Cards - .AsNoTracking() - .SingleOrDefaultAsync(card => card.Id == cardId, ct); - if (entity is null) - { - return null; - } - - IReadOnlyList cards = await ToCardDtosAsync([entity], ct); - return cards[0]; - } - - /// - public async Task GetCardBySourceAsync( - SourceRef source, - CancellationToken ct) - { - if (string.IsNullOrEmpty(source.Kind)) - { - return null; - } - - string externalId = source.ExternalId ?? string.Empty; - string originRef = source.OriginRef ?? string.Empty; - CardEntity? entity = await _dbContext.Cards - .AsNoTracking() - .Where(card => card.SourceKind == source.Kind - && card.SourceExternalId == externalId - && card.SourceOriginRef == originRef) - .OrderByDescending(card => card.ReceivedAt) - .FirstOrDefaultAsync(ct); - if (entity is null) - { - return null; - } - - IReadOnlyList cards = await ToCardDtosAsync([entity], ct); - return cards[0]; - } - - /// - public async Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct) - { - // CreatedAt проставляет хранилище (UTC-now) — в snapshot поля нет (см. CardSnapshot). - _dbContext.Cards.Add(ToCardEntity(snapshot)); - await _dbContext.SaveChangesAsync(ct); - - // Стартовые комментарии (ручное создание): таблица LeadComments, CreatedAt — один «now» вставки. - if (snapshot.Comments.Count > 0) - { - DateTimeOffset commentAt = DateTimeOffset.UtcNow; - foreach (CardCommentDto comment in snapshot.Comments) - { - _dbContext.LeadComments.Add(new LeadCommentEntity - { - Id = comment.Id, - CardId = snapshot.Id, - By = comment.By, - Text = comment.Text, - CreatedAt = commentAt, - }); - } - - await _dbContext.SaveChangesAsync(ct); - } - } - - /// - public async Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct) - { - CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct); - if (entity is null) - { - return; - } - - entity.Col = update.Col; - entity.IsNew = update.IsNew; - if (update.PrevCol is not null) - { - entity.PrevCol = update.PrevCol; - } - - entity.ArchivedAt = update.ArchivedAt; - entity.MatchHitsJson = ToJson(update.MatchHits); - await _dbContext.SaveChangesAsync(ct); - } - - /// - public async Task ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct) - { - CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct); - if (entity is null) - { - return false; - } - - entity.Col = update.Col; - entity.IsNew = update.IsNew; - entity.IsVacancy = update.IsVacancy; - entity.IsVacancyKnown = update.IsVacancyKnown; - entity.Title = update.Title; - entity.Summary = update.Summary; - entity.StackJson = ToJson(update.Stack); - entity.BudgetFrom = update.Budget?.From; - entity.BudgetTo = update.Budget?.To; - entity.BudgetCur = update.Budget?.Cur ?? string.Empty; - entity.ConvFrom = update.Converted?.From; - entity.ConvTo = update.Converted?.To; - entity.ConvCur = update.Converted?.Cur ?? string.Empty; - entity.Contact = update.Contact; - entity.ContactsJson = ToJson(update.Contacts); - entity.MatchHitsJson = ToJson(update.MatchHits); - entity.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task UpdateSeenAsync( - string? cardId, - string? col, - CancellationToken ct) - { - IQueryable queryable = _dbContext.Cards; - if (cardId is not null) - { - queryable = queryable.Where(card => card.Id == cardId); - } - else if (col is not null) - { - queryable = queryable.Where(card => card.Col == col); - } - - await queryable.ExecuteUpdateAsync(setters => setters.SetProperty(card => card.IsNew, false), ct); - } - - /// - public async Task DeleteForeverAsync(string cardId, CancellationToken ct) - { - await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct); - await _dbContext.DedupEntries - .Where(entry => entry.LeadId == cardId) - .ExecuteDeleteAsync(ct); - await _dbContext.Cards - .Where(card => card.Id == cardId) - .ExecuteDeleteAsync(ct); - await transaction.CommitAsync(ct); - } - - /// - public async Task ClearColAsync(string col, CancellationToken ct) - { - await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct); - await _dbContext.DedupEntries - .Where(entry => _dbContext.Cards.Any(card => card.Col == col && card.Id == entry.LeadId)) - .ExecuteDeleteAsync(ct); - int cleared = await _dbContext.Cards - .Where(card => card.Col == col) - .ExecuteDeleteAsync(ct); - await transaction.CommitAsync(ct); - return cleared; - } - - /// - public async Task> CountCardsByColAsync(CancellationToken ct) - { - var rows = await _dbContext.Cards - .AsNoTracking() - .Where(card => !SelectedStageIds.Contains(card.Col)) - .GroupBy(card => card.Col) - .Select(group => new { Col = group.Key, Count = group.Count(), NewCount = group.Count(card => card.IsNew) }) - .ToListAsync(ct); - - var result = new Dictionary(); - foreach (var row in rows) - { - result.Add(row.Col, new CardColumnCountDto(row.Count, row.NewCount)); - } - - return result; - } - - /// -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// Карточки канбана — partial-часть +/// +public sealed partial class KanbanStore +{ + /// + public async Task> ListCardsAsync(CardsQuery query, CancellationToken ct) + { + IQueryable queryable = _dbContext.Cards.AsNoTracking(); + if (query.Col is null) + { + queryable = queryable.Where(card => !SelectedStageIds.Contains(card.Col)); + } + else + { + queryable = queryable.Where(card => card.Col == query.Col); + } + + List entities = await queryable + .OrderByDescending(card => card.ReceivedAt) + .ToListAsync(ct); + return await ToCardDtosAsync(entities, ct); + } + + /// + public async Task> SearchCardsAsync( + string q, + int limit, + CancellationToken ct) + { + string query = q.Trim(); + if (query.Length == 0) + { + return Array.Empty(); + } + + string pattern = $"%{query}%"; + List entities = await _dbContext.Cards + .FromSqlInterpolated( + $""" + SELECT * FROM "Cards" + WHERE "Col" <> ALL({SelectedStageIds}) + AND ("SearchTsv" @@ plainto_tsquery('russian', {query}) + OR lower("Title") LIKE {pattern} + OR lower("Summary") LIKE {pattern} + OR lower("SourceText") LIKE {pattern} + OR lower("Contact") LIKE {pattern}) + ORDER BY ts_rank("SearchTsv", plainto_tsquery('russian', {query})) DESC, "ReceivedAt" DESC + LIMIT {limit} + """) + .AsNoTracking() + .ToListAsync(ct); + return await ToCardDtosAsync(entities, ct); + } + + /// + public async Task GetCardAsync(string cardId, CancellationToken ct) + { + CardEntity? entity = await _dbContext.Cards + .AsNoTracking() + .SingleOrDefaultAsync(card => card.Id == cardId, ct); + if (entity is null) + { + return null; + } + + IReadOnlyList cards = await ToCardDtosAsync([entity], ct); + return cards[0]; + } + + /// + public async Task GetCardBySourceAsync( + SourceRef source, + CancellationToken ct) + { + if (string.IsNullOrEmpty(source.Kind)) + { + return null; + } + + string externalId = source.ExternalId ?? string.Empty; + string originRef = source.OriginRef ?? string.Empty; + CardEntity? entity = await _dbContext.Cards + .AsNoTracking() + .Where(card => card.SourceKind == source.Kind + && card.SourceExternalId == externalId + && card.SourceOriginRef == originRef) + .OrderByDescending(card => card.ReceivedAt) + .FirstOrDefaultAsync(ct); + if (entity is null) + { + return null; + } + + IReadOnlyList cards = await ToCardDtosAsync([entity], ct); + return cards[0]; + } + + /// + public async Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct) + { + // CreatedAt проставляет хранилище (UTC-now) — в snapshot поля нет (см. CardSnapshot). + _dbContext.Cards.Add(ToCardEntity(snapshot)); + await _dbContext.SaveChangesAsync(ct); + + // Стартовые комментарии (ручное создание): таблица LeadComments, CreatedAt — один «now» вставки. + if (snapshot.Comments.Count > 0) + { + DateTimeOffset commentAt = DateTimeOffset.UtcNow; + foreach (CardCommentDto comment in snapshot.Comments) + { + _dbContext.LeadComments.Add(new LeadCommentEntity + { + Id = comment.Id, + CardId = snapshot.Id, + By = comment.By, + Text = comment.Text, + CreatedAt = commentAt, + }); + } + + await _dbContext.SaveChangesAsync(ct); + } + } + + /// + public async Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct) + { + CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct); + if (entity is null) + { + return; + } + + entity.Col = update.Col; + entity.IsNew = update.IsNew; + if (update.PrevCol is not null) + { + entity.PrevCol = update.PrevCol; + } + + entity.ArchivedAt = update.ArchivedAt; + entity.MatchHitsJson = ToJson(update.MatchHits); + await _dbContext.SaveChangesAsync(ct); + } + + /// + public async Task ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct) + { + CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct); + if (entity is null) + { + return false; + } + + entity.Col = update.Col; + entity.IsNew = update.IsNew; + entity.IsVacancy = update.IsVacancy; + entity.IsVacancyKnown = update.IsVacancyKnown; + entity.Title = update.Title; + entity.Summary = update.Summary; + entity.StackJson = ToJson(update.Stack); + entity.BudgetFrom = update.Budget?.From; + entity.BudgetTo = update.Budget?.To; + entity.BudgetCur = update.Budget?.Cur ?? string.Empty; + entity.ConvFrom = update.Converted?.From; + entity.ConvTo = update.Converted?.To; + entity.ConvCur = update.Converted?.Cur ?? string.Empty; + entity.Contact = update.Contact; + entity.ContactsJson = ToJson(update.Contacts); + entity.MatchHitsJson = ToJson(update.MatchHits); + entity.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task UpdateSeenAsync( + string? cardId, + string? col, + CancellationToken ct) + { + IQueryable queryable = _dbContext.Cards; + if (cardId is not null) + { + queryable = queryable.Where(card => card.Id == cardId); + } + else if (col is not null) + { + queryable = queryable.Where(card => card.Col == col); + } + + await queryable.ExecuteUpdateAsync(setters => setters.SetProperty(card => card.IsNew, false), ct); + } + + /// + public async Task DeleteForeverAsync(string cardId, CancellationToken ct) + { + await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct); + await _dbContext.DedupEntries + .Where(entry => entry.LeadId == cardId) + .ExecuteDeleteAsync(ct); + await _dbContext.Cards + .Where(card => card.Id == cardId) + .ExecuteDeleteAsync(ct); + await transaction.CommitAsync(ct); + } + + /// + public async Task ClearColAsync(string col, CancellationToken ct) + { + await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct); + await _dbContext.DedupEntries + .Where(entry => _dbContext.Cards.Any(card => card.Col == col && card.Id == entry.LeadId)) + .ExecuteDeleteAsync(ct); + int cleared = await _dbContext.Cards + .Where(card => card.Col == col) + .ExecuteDeleteAsync(ct); + await transaction.CommitAsync(ct); + return cleared; + } + + /// + public async Task> CountCardsByColAsync(CancellationToken ct) + { + var rows = await _dbContext.Cards + .AsNoTracking() + .Where(card => !SelectedStageIds.Contains(card.Col)) + .GroupBy(card => card.Col) + .Select(group => new { Col = group.Key, Count = group.Count(), NewCount = group.Count(card => card.IsNew) }) + .ToListAsync(ct); + + var result = new Dictionary(); + foreach (var row in rows) + { + result.Add(row.Col, new CardColumnCountDto(row.Count, row.NewCount)); + } + + return result; + } + + /// +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Comments.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Comments.cs index ae047c7..dfaf599 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Comments.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Comments.cs @@ -1,82 +1,82 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Комментарии и журнал действий — partial-часть -/// -public sealed partial class KanbanStore -{ - /// - public async Task> ListCommentsAsync(string cardId, CancellationToken ct) - { - List entities = await _dbContext.LeadComments - .AsNoTracking() - .Where(comment => comment.CardId == cardId) - .OrderBy(comment => comment.CreatedAt) - .ThenBy(comment => comment.Id) - .ToListAsync(ct); - return entities.Select(ToCommentDto).ToList(); - } - - /// - public async Task AddCommentAsync( - string commentId, - string cardId, - string by, - string text, - CancellationToken ct) - { - _dbContext.LeadComments.Add(new LeadCommentEntity - { - Id = commentId, - CardId = cardId, - By = by, - Text = text, - CreatedAt = DateTimeOffset.UtcNow, - }); - await _dbContext.SaveChangesAsync(ct); - } - - /// - public async Task AddMoveAsync(CardMoveDto move, CancellationToken ct) - { - _dbContext.CardMoves.Add(new CardMoveEntity - { - Id = move.Id, - LeadId = move.LeadId, - Action = move.Action, - FromCol = move.FromCol, - ToCol = move.ToCol, - CreatedAt = DateTimeOffset.UtcNow, - }); - await _dbContext.SaveChangesAsync(ct); - } - - /// - public Task CountMovesAsync(CancellationToken ct) => _dbContext.CardMoves.CountAsync(ct); - - /// - public async Task> GetAiMarkupExamplesAsync(int limit, CancellationToken ct) - { - return await _dbContext.CardMoves - .AsNoTracking() - .Where(move => move.ToCol != null && move.ToCol != CardIds.Trash && move.ToCol != CardIds.Archive) - .Join( - _dbContext.Cards.AsNoTracking(), - move => move.LeadId, - card => card.Id, - (move, card) => new { move, card }) - .Where(joined => (joined.move.Action == MoveAction || joined.move.Action == RestoreAction) - && joined.card.SourceText.Length > 0) - .OrderByDescending(joined => joined.move.CreatedAt) - .Take(limit) - .Select(joined => new AiMarkupExampleDto(joined.card.SourceText, joined.move.ToCol!)) - .ToListAsync(ct); - } - - /// -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// Комментарии и журнал действий — partial-часть +/// +public sealed partial class KanbanStore +{ + /// + public async Task> ListCommentsAsync(string cardId, CancellationToken ct) + { + List entities = await _dbContext.LeadComments + .AsNoTracking() + .Where(comment => comment.CardId == cardId) + .OrderBy(comment => comment.CreatedAt) + .ThenBy(comment => comment.Id) + .ToListAsync(ct); + return entities.Select(ToCommentDto).ToList(); + } + + /// + public async Task AddCommentAsync( + string commentId, + string cardId, + string by, + string text, + CancellationToken ct) + { + _dbContext.LeadComments.Add(new LeadCommentEntity + { + Id = commentId, + CardId = cardId, + By = by, + Text = text, + CreatedAt = DateTimeOffset.UtcNow, + }); + await _dbContext.SaveChangesAsync(ct); + } + + /// + public async Task AddMoveAsync(CardMoveDto move, CancellationToken ct) + { + _dbContext.CardMoves.Add(new CardMoveEntity + { + Id = move.Id, + LeadId = move.LeadId, + Action = move.Action, + FromCol = move.FromCol, + ToCol = move.ToCol, + CreatedAt = DateTimeOffset.UtcNow, + }); + await _dbContext.SaveChangesAsync(ct); + } + + /// + public Task CountMovesAsync(CancellationToken ct) => _dbContext.CardMoves.CountAsync(ct); + + /// + public async Task> GetAiMarkupExamplesAsync(int limit, CancellationToken ct) + { + return await _dbContext.CardMoves + .AsNoTracking() + .Where(move => move.ToCol != null && move.ToCol != CardIds.Trash && move.ToCol != CardIds.Archive) + .Join( + _dbContext.Cards.AsNoTracking(), + move => move.LeadId, + card => card.Id, + (move, card) => new { move, card }) + .Where(joined => (joined.move.Action == MoveAction || joined.move.Action == RestoreAction) + && joined.card.SourceText.Length > 0) + .OrderByDescending(joined => joined.move.CreatedAt) + .Take(limit) + .Select(joined => new AiMarkupExampleDto(joined.card.SourceText, joined.move.ToCol!)) + .ToListAsync(ct); + } + + /// +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Containers.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Containers.cs index d5f9e3d..bf35d06 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Containers.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Containers.cs @@ -1,105 +1,105 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Контейнеры карточек — partial-часть -/// -public sealed partial class KanbanStore -{ - /// - public async Task> ListContainersAsync(string? space, CancellationToken ct) - { - IQueryable queryable = _dbContext.Containers.AsNoTracking(); - if (space is not null) - { - queryable = queryable.Where(container => container.Space == space); - } - - // Принятые колонки идут первыми, ИИ-предложения — в конец (как ORDER BY suggested, position). - List entities = await queryable - .OrderBy(container => container.Suggested) - .ThenBy(container => container.Position) - .ToListAsync(ct); - return entities.Select(ToContainerDto).ToList(); - } - - /// - public async Task GetContainerAsync(string containerId, CancellationToken ct) - { - ContainerEntity? entity = await _dbContext.Containers - .AsNoTracking() - .SingleOrDefaultAsync(container => container.Id == containerId, ct); - return entity is null ? null : ToContainerDto(entity); - } - - /// - public async Task CreateContainerAsync(ContainerDto container, CancellationToken ct) - { - _dbContext.Containers.Add(ToContainerEntity(container)); - await _dbContext.SaveChangesAsync(ct); - } - - /// - public async Task UpdateContainerAsync(ContainerDto container, CancellationToken ct) - { - // Полное обновление строки (сервис читает Get + применяет ContainerPatchDto): JSON-поля пишутся - // целиком, CreatedAt не трогаем — одним UPDATE. - await _dbContext.Containers - .Where(row => row.Id == container.Id) - .ExecuteUpdateAsync(setters => setters - .SetProperty(row => row.Name, container.Name) - .SetProperty(row => row.Description, container.Description) - .SetProperty(row => row.Color, container.Color) - .SetProperty(row => row.Position, container.Order) - .SetProperty(row => row.Collapsed, container.Collapsed) - .SetProperty(row => row.Suggested, container.Suggested) - .SetProperty(row => row.RulesJson, ToRulesJson(container.Rules)) - .SetProperty(row => row.Note, container.Note) - .SetProperty(row => row.PolicyJson, ToPolicyJson(container.Policy)), - ct); - } - - /// - public async Task DeleteContainerAsync(string containerId, CancellationToken ct) - { - // Два изменения разных таблиц — в одной транзакции: либо карточки ушли в «Неразобранное» и контейнер - // удалён, либо ничего не изменилось. - await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct); - int moved = await _dbContext.Cards - .Where(card => card.Col == containerId) - .ExecuteUpdateAsync(setters => setters - .SetProperty(card => card.Col, CardIds.Inbox) - .SetProperty(card => card.IsNew, true) - .SetProperty(card => card.PrevCol, CardIds.Inbox), - ct); - await _dbContext.Containers - .Where(container => container.Id == containerId) - .ExecuteDeleteAsync(ct); - await transaction.CommitAsync(ct); - return moved; - } - - /// - public async Task ReorderContainersAsync( - string space, - IReadOnlyList containerIds, - CancellationToken ct) - { - // Позиции 0..N-1 — одна транзакция: при сбое середины порядок не остаётся частично проставленным. - await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct); - for (int i = 0; i < containerIds.Count; i++) - { - int position = i; - string containerId = containerIds[i]; - await _dbContext.Containers - .Where(container => container.Id == containerId && container.Space == space) - .ExecuteUpdateAsync(setters => setters.SetProperty(container => container.Position, position), ct); - } - - await transaction.CommitAsync(ct); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// Контейнеры карточек — partial-часть +/// +public sealed partial class KanbanStore +{ + /// + public async Task> ListContainersAsync(string? space, CancellationToken ct) + { + IQueryable queryable = _dbContext.Containers.AsNoTracking(); + if (space is not null) + { + queryable = queryable.Where(container => container.Space == space); + } + + // Принятые колонки идут первыми, ИИ-предложения — в конец (как ORDER BY suggested, position). + List entities = await queryable + .OrderBy(container => container.Suggested) + .ThenBy(container => container.Position) + .ToListAsync(ct); + return entities.Select(ToContainerDto).ToList(); + } + + /// + public async Task GetContainerAsync(string containerId, CancellationToken ct) + { + ContainerEntity? entity = await _dbContext.Containers + .AsNoTracking() + .SingleOrDefaultAsync(container => container.Id == containerId, ct); + return entity is null ? null : ToContainerDto(entity); + } + + /// + public async Task CreateContainerAsync(ContainerDto container, CancellationToken ct) + { + _dbContext.Containers.Add(ToContainerEntity(container)); + await _dbContext.SaveChangesAsync(ct); + } + + /// + public async Task UpdateContainerAsync(ContainerDto container, CancellationToken ct) + { + // Полное обновление строки (сервис читает Get + применяет ContainerPatchDto): JSON-поля пишутся + // целиком, CreatedAt не трогаем — одним UPDATE. + await _dbContext.Containers + .Where(row => row.Id == container.Id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(row => row.Name, container.Name) + .SetProperty(row => row.Description, container.Description) + .SetProperty(row => row.Color, container.Color) + .SetProperty(row => row.Position, container.Order) + .SetProperty(row => row.Collapsed, container.Collapsed) + .SetProperty(row => row.Suggested, container.Suggested) + .SetProperty(row => row.RulesJson, ToRulesJson(container.Rules)) + .SetProperty(row => row.Note, container.Note) + .SetProperty(row => row.PolicyJson, ToPolicyJson(container.Policy)), + ct); + } + + /// + public async Task DeleteContainerAsync(string containerId, CancellationToken ct) + { + // Два изменения разных таблиц — в одной транзакции: либо карточки ушли в «Неразобранное» и контейнер + // удалён, либо ничего не изменилось. + await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct); + int moved = await _dbContext.Cards + .Where(card => card.Col == containerId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(card => card.Col, CardIds.Inbox) + .SetProperty(card => card.IsNew, true) + .SetProperty(card => card.PrevCol, CardIds.Inbox), + ct); + await _dbContext.Containers + .Where(container => container.Id == containerId) + .ExecuteDeleteAsync(ct); + await transaction.CommitAsync(ct); + return moved; + } + + /// + public async Task ReorderContainersAsync( + string space, + IReadOnlyList containerIds, + CancellationToken ct) + { + // Позиции 0..N-1 — одна транзакция: при сбое середины порядок не остаётся частично проставленным. + await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct); + for (int i = 0; i < containerIds.Count; i++) + { + int position = i; + string containerId = containerIds[i]; + await _dbContext.Containers + .Where(container => container.Id == containerId && container.Space == space) + .ExecuteUpdateAsync(setters => setters.SetProperty(container => container.Position, position), ct); + } + + await transaction.CommitAsync(ct); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Selected.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Selected.cs index cc74042..688bf36 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Selected.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Selected.cs @@ -1,301 +1,301 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Kanban.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Операции карточек пространства «Выбранные» — partial-часть -/// -public sealed partial class KanbanStore -{ - /// - public async Task> ListSelectedCardsAsync(string? containerId, CancellationToken ct) - { - IQueryable queryable = _dbContext.Cards.AsNoTracking(); - if (containerId is not null) - { - queryable = queryable.Where(card => card.Col == containerId); - } - else - { - queryable = queryable.Where(card => SelectedStageIds.Contains(card.Col)); - } - - List entities = await queryable - .OrderByDescending(card => card.UpdatedAt) - .ToListAsync(ct); - return await ToCardDtosAsync(entities, ct); - } - - /// - public async Task PatchCardAsync( - string cardId, - CardPatch patch, - CancellationToken ct) - { - CardEntity? entity = await _dbContext.Cards - .SingleOrDefaultAsync(card => card.Id == cardId, ct); - if (entity is null) - { - return false; - } - - ApplyPatch(entity, patch); - entity.UpdatedAt = DateTimeOffset.UtcNow; - - if (patch.Comments is not null) - { - await _dbContext.LeadComments - .Where(comment => comment.CardId == cardId) - .ExecuteDeleteAsync(ct); - DateTimeOffset commentAt = DateTimeOffset.UtcNow; - foreach (CardCommentDto comment in patch.Comments) - { - _dbContext.LeadComments.Add(new LeadCommentEntity - { - Id = comment.Id, - CardId = cardId, - By = comment.By, - Text = comment.Text, - CreatedAt = commentAt, - }); - } - } - - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task AddLinkAsync( - string cardId, - CardLinkDto link, - CancellationToken ct) - { - return await ExecuteJsonMutationAsync( - $""" - UPDATE "Cards" - SET "LinksJson" = (COALESCE(NULLIF("LinksJson", '')::jsonb, '[]'::jsonb) - || jsonb_build_array({ToJson(link)}::jsonb))::text, - "UpdatedAt" = {DateTimeOffset.UtcNow} - WHERE "Id" = {cardId} - """, - ct); - } - - /// - public async Task RemoveLinkAsync( - string cardId, - string linkId, - CancellationToken ct) - { - return await ExecuteJsonMutationAsync( - $""" - UPDATE "Cards" - SET "LinksJson" = COALESCE(( - SELECT jsonb_agg(item ORDER BY ord) - FROM jsonb_array_elements(COALESCE(NULLIF("LinksJson", '')::jsonb, '[]'::jsonb)) - WITH ORDINALITY AS t(item, ord) - WHERE item ->> 'id' <> {linkId} - ), '[]'::jsonb)::text, - "UpdatedAt" = {DateTimeOffset.UtcNow} - WHERE "Id" = {cardId} - """, - ct); - } - - /// - public async Task AddFileAsync( - string cardId, - CardFileDto file, - CancellationToken ct) - { - return await ExecuteJsonMutationAsync( - $""" - UPDATE "Cards" - SET "FilesJson" = (COALESCE(NULLIF("FilesJson", '')::jsonb, '[]'::jsonb) - || jsonb_build_array({ToJson(file)}::jsonb))::text, - "UpdatedAt" = {DateTimeOffset.UtcNow} - WHERE "Id" = {cardId} - """, - ct); - } - - /// - public async Task RemoveFileAsync( - string cardId, - string fileId, - CancellationToken ct) - { - return await ExecuteJsonMutationAsync( - $""" - UPDATE "Cards" - SET "FilesJson" = COALESCE(( - SELECT jsonb_agg(item ORDER BY ord) - FROM jsonb_array_elements(COALESCE(NULLIF("FilesJson", '')::jsonb, '[]'::jsonb)) - WITH ORDINALITY AS t(item, ord) - WHERE item ->> 'id' <> {fileId} - ), '[]'::jsonb)::text, - "UpdatedAt" = {DateTimeOffset.UtcNow} - WHERE "Id" = {cardId} - """, - ct); - } - - /// - public async Task MoveCardStageAsync( - string cardId, - string containerId, - CardHistoryDto historyEntry, - long atMs, - CancellationToken ct) - { - CardEntity? entity = await _dbContext.Cards - .AsNoTracking() - .SingleOrDefaultAsync(card => card.Id == cardId, ct); - if (entity is null) - { - return false; - } - - List history = ToJsonList(entity.HistoryJson).ToList(); - history.Add(historyEntry); - DateTimeOffset at = DateTimeOffset.FromUnixTimeMilliseconds(atMs); - - await _dbContext.Cards - .Where(card => card.Id == cardId) - .ExecuteUpdateAsync(setters => setters - .SetProperty(card => card.Col, containerId) - .SetProperty(card => card.ReminderAt, (DateTimeOffset?)null) - .SetProperty(card => card.ReminderFired, false) - .SetProperty(card => card.UpdatedAt, at) - .SetProperty(card => card.HistoryJson, ToJson(history)), - ct); - return true; - } - - /// - public async Task SetReminderAsync( - string cardId, - long atMs, - CancellationToken ct) - { - await _dbContext.Cards - .Where(card => card.Id == cardId) - .ExecuteUpdateAsync(setters => setters - .SetProperty(card => card.ReminderAt, DateTimeOffset.FromUnixTimeMilliseconds(atMs)) - .SetProperty(card => card.ReminderFired, false) - .SetProperty(card => card.UpdatedAt, DateTimeOffset.UtcNow), - ct); - } - - /// - public async Task ClearReminderAsync(string cardId, CancellationToken ct) - { - await _dbContext.Cards - .Where(card => card.Id == cardId) - .ExecuteUpdateAsync(setters => setters - .SetProperty(card => card.ReminderAt, (DateTimeOffset?)null) - .SetProperty(card => card.ReminderFired, false), - ct); - } - - /// - public async Task ClearStageAsync(string containerId, CancellationToken ct) - { - return await _dbContext.Cards - .Where(card => card.Col == containerId) - .ExecuteDeleteAsync(ct); - } - - /// - public async Task> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct) - { - return await _dbContext.Cards - .AsNoTracking() - .Where(card => card.Col == HoldStage - && card.ReminderAt != null - && !card.ReminderFired - && card.ReminderAt <= now) - .OrderBy(card => card.ReminderAt) - .Select(card => new CardReminderDueDto(card.Id, card.Title, card.Col)) - .ToListAsync(ct); - } - - /// - public async Task MarkRemindersFiredAsync(IReadOnlyList cardIds, CancellationToken ct) - { - if (cardIds.Count == 0) - { - return; - } - - await _dbContext.Cards - .Where(card => cardIds.Contains(card.Id)) - .ExecuteUpdateAsync(setters => setters.SetProperty(card => card.ReminderFired, true), ct); - } - - /// - public async Task ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct) - { - return await _dbContext.Cards - .Where(card => card.ReminderAt != null && card.ReminderAt <= now) - .ExecuteUpdateAsync(setters => setters - .SetProperty(card => card.ReminderAt, (DateTimeOffset?)null) - .SetProperty(card => card.ReminderFired, false), - ct); - } - - // Выполняет атомарную JSON-мутацию колонки карточки: один UPDATE, 0 затронутых строк = карточки нет. - private async Task ExecuteJsonMutationAsync(FormattableString sql, CancellationToken ct) - { - int affected = await _dbContext.Database.ExecuteSqlInterpolatedAsync(sql, ct); - return affected == 1; - } - - private static void ApplyPatch(CardEntity entity, CardPatch patch) - { - if (patch.Title is not null) - { - entity.Title = patch.Title; - } - - if (patch.Summary is not null) - { - entity.Summary = patch.Summary; - } - - if (patch.Contact is not null) - { - entity.Contact = patch.Contact; - } - - if (patch.TzText is not null) - { - entity.TzText = patch.TzText; - } - - if (patch.Stack is not null) - { - entity.StackJson = ToJson(patch.Stack); - } - - if (patch.Budget is not null) - { - entity.BudgetFrom = patch.Budget.From; - entity.BudgetTo = patch.Budget.To; - entity.BudgetCur = patch.Budget.Cur; - } - - if (patch.Links is not null) - { - entity.LinksJson = ToJson(patch.Links); - } - - if (patch.Files is not null) - { - entity.FilesJson = ToJson(patch.Files); - } - } -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Kanban.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// Операции карточек пространства «Выбранные» — partial-часть +/// +public sealed partial class KanbanStore +{ + /// + public async Task> ListSelectedCardsAsync(string? containerId, CancellationToken ct) + { + IQueryable queryable = _dbContext.Cards.AsNoTracking(); + if (containerId is not null) + { + queryable = queryable.Where(card => card.Col == containerId); + } + else + { + queryable = queryable.Where(card => SelectedStageIds.Contains(card.Col)); + } + + List entities = await queryable + .OrderByDescending(card => card.UpdatedAt) + .ToListAsync(ct); + return await ToCardDtosAsync(entities, ct); + } + + /// + public async Task PatchCardAsync( + string cardId, + CardPatch patch, + CancellationToken ct) + { + CardEntity? entity = await _dbContext.Cards + .SingleOrDefaultAsync(card => card.Id == cardId, ct); + if (entity is null) + { + return false; + } + + ApplyPatch(entity, patch); + entity.UpdatedAt = DateTimeOffset.UtcNow; + + if (patch.Comments is not null) + { + await _dbContext.LeadComments + .Where(comment => comment.CardId == cardId) + .ExecuteDeleteAsync(ct); + DateTimeOffset commentAt = DateTimeOffset.UtcNow; + foreach (CardCommentDto comment in patch.Comments) + { + _dbContext.LeadComments.Add(new LeadCommentEntity + { + Id = comment.Id, + CardId = cardId, + By = comment.By, + Text = comment.Text, + CreatedAt = commentAt, + }); + } + } + + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task AddLinkAsync( + string cardId, + CardLinkDto link, + CancellationToken ct) + { + return await ExecuteJsonMutationAsync( + $""" + UPDATE "Cards" + SET "LinksJson" = (COALESCE(NULLIF("LinksJson", '')::jsonb, '[]'::jsonb) + || jsonb_build_array({ToJson(link)}::jsonb))::text, + "UpdatedAt" = {DateTimeOffset.UtcNow} + WHERE "Id" = {cardId} + """, + ct); + } + + /// + public async Task RemoveLinkAsync( + string cardId, + string linkId, + CancellationToken ct) + { + return await ExecuteJsonMutationAsync( + $""" + UPDATE "Cards" + SET "LinksJson" = COALESCE(( + SELECT jsonb_agg(item ORDER BY ord) + FROM jsonb_array_elements(COALESCE(NULLIF("LinksJson", '')::jsonb, '[]'::jsonb)) + WITH ORDINALITY AS t(item, ord) + WHERE item ->> 'id' <> {linkId} + ), '[]'::jsonb)::text, + "UpdatedAt" = {DateTimeOffset.UtcNow} + WHERE "Id" = {cardId} + """, + ct); + } + + /// + public async Task AddFileAsync( + string cardId, + CardFileDto file, + CancellationToken ct) + { + return await ExecuteJsonMutationAsync( + $""" + UPDATE "Cards" + SET "FilesJson" = (COALESCE(NULLIF("FilesJson", '')::jsonb, '[]'::jsonb) + || jsonb_build_array({ToJson(file)}::jsonb))::text, + "UpdatedAt" = {DateTimeOffset.UtcNow} + WHERE "Id" = {cardId} + """, + ct); + } + + /// + public async Task RemoveFileAsync( + string cardId, + string fileId, + CancellationToken ct) + { + return await ExecuteJsonMutationAsync( + $""" + UPDATE "Cards" + SET "FilesJson" = COALESCE(( + SELECT jsonb_agg(item ORDER BY ord) + FROM jsonb_array_elements(COALESCE(NULLIF("FilesJson", '')::jsonb, '[]'::jsonb)) + WITH ORDINALITY AS t(item, ord) + WHERE item ->> 'id' <> {fileId} + ), '[]'::jsonb)::text, + "UpdatedAt" = {DateTimeOffset.UtcNow} + WHERE "Id" = {cardId} + """, + ct); + } + + /// + public async Task MoveCardStageAsync( + string cardId, + string containerId, + CardHistoryDto historyEntry, + long atMs, + CancellationToken ct) + { + CardEntity? entity = await _dbContext.Cards + .AsNoTracking() + .SingleOrDefaultAsync(card => card.Id == cardId, ct); + if (entity is null) + { + return false; + } + + List history = ToJsonList(entity.HistoryJson).ToList(); + history.Add(historyEntry); + DateTimeOffset at = DateTimeOffset.FromUnixTimeMilliseconds(atMs); + + await _dbContext.Cards + .Where(card => card.Id == cardId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(card => card.Col, containerId) + .SetProperty(card => card.ReminderAt, (DateTimeOffset?)null) + .SetProperty(card => card.ReminderFired, false) + .SetProperty(card => card.UpdatedAt, at) + .SetProperty(card => card.HistoryJson, ToJson(history)), + ct); + return true; + } + + /// + public async Task SetReminderAsync( + string cardId, + long atMs, + CancellationToken ct) + { + await _dbContext.Cards + .Where(card => card.Id == cardId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(card => card.ReminderAt, DateTimeOffset.FromUnixTimeMilliseconds(atMs)) + .SetProperty(card => card.ReminderFired, false) + .SetProperty(card => card.UpdatedAt, DateTimeOffset.UtcNow), + ct); + } + + /// + public async Task ClearReminderAsync(string cardId, CancellationToken ct) + { + await _dbContext.Cards + .Where(card => card.Id == cardId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(card => card.ReminderAt, (DateTimeOffset?)null) + .SetProperty(card => card.ReminderFired, false), + ct); + } + + /// + public async Task ClearStageAsync(string containerId, CancellationToken ct) + { + return await _dbContext.Cards + .Where(card => card.Col == containerId) + .ExecuteDeleteAsync(ct); + } + + /// + public async Task> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct) + { + return await _dbContext.Cards + .AsNoTracking() + .Where(card => card.Col == HoldStage + && card.ReminderAt != null + && !card.ReminderFired + && card.ReminderAt <= now) + .OrderBy(card => card.ReminderAt) + .Select(card => new CardReminderDueDto(card.Id, card.Title, card.Col)) + .ToListAsync(ct); + } + + /// + public async Task MarkRemindersFiredAsync(IReadOnlyList cardIds, CancellationToken ct) + { + if (cardIds.Count == 0) + { + return; + } + + await _dbContext.Cards + .Where(card => cardIds.Contains(card.Id)) + .ExecuteUpdateAsync(setters => setters.SetProperty(card => card.ReminderFired, true), ct); + } + + /// + public async Task ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct) + { + return await _dbContext.Cards + .Where(card => card.ReminderAt != null && card.ReminderAt <= now) + .ExecuteUpdateAsync(setters => setters + .SetProperty(card => card.ReminderAt, (DateTimeOffset?)null) + .SetProperty(card => card.ReminderFired, false), + ct); + } + + // Выполняет атомарную JSON-мутацию колонки карточки: один UPDATE, 0 затронутых строк = карточки нет. + private async Task ExecuteJsonMutationAsync(FormattableString sql, CancellationToken ct) + { + int affected = await _dbContext.Database.ExecuteSqlInterpolatedAsync(sql, ct); + return affected == 1; + } + + private static void ApplyPatch(CardEntity entity, CardPatch patch) + { + if (patch.Title is not null) + { + entity.Title = patch.Title; + } + + if (patch.Summary is not null) + { + entity.Summary = patch.Summary; + } + + if (patch.Contact is not null) + { + entity.Contact = patch.Contact; + } + + if (patch.TzText is not null) + { + entity.TzText = patch.TzText; + } + + if (patch.Stack is not null) + { + entity.StackJson = ToJson(patch.Stack); + } + + if (patch.Budget is not null) + { + entity.BudgetFrom = patch.Budget.From; + entity.BudgetTo = patch.Budget.To; + entity.BudgetCur = patch.Budget.Cur; + } + + if (patch.Links is not null) + { + entity.LinksJson = ToJson(patch.Links); + } + + if (patch.Files is not null) + { + entity.FilesJson = ToJson(patch.Files); + } + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs index 0a3c379..616b47a 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs @@ -1,132 +1,132 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Правила хранения, конверсии и вход эвристики suggest — partial-часть -/// -public sealed partial class KanbanStore -{ - /// - public async Task> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) - { - List boardIds = await _dbContext.Containers - .AsNoTracking() - .Where(container => container.Kind == ContainerKinds.Board) - .Select(container => container.Id) - .ToListAsync(ct); - - return await _dbContext.Cards - .AsNoTracking() - .Where(card => card.ReceivedAt < receivedBeforeUtc - && (card.Col == CardIds.Inbox || boardIds.Contains(card.Col))) - .Select(card => card.Id) - .ToListAsync(ct); - } - - /// - public async Task ArchiveAsync( - IReadOnlyList cardIds, - DateTimeOffset archivedAt, - CancellationToken ct) - { - if (cardIds.Count == 0) - { - return 0; - } - - return await _dbContext.Cards - .Where(card => cardIds.Contains(card.Id)) - .ExecuteUpdateAsync(setters => setters - .SetProperty(card => card.Col, CardIds.Archive) - .SetProperty(card => card.IsNew, false) - .SetProperty(card => card.ArchivedAt, archivedAt) - .SetProperty(card => card.MatchHitsJson, ToJson(Array.Empty())), - ct); - } - - /// - public async Task> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct) - { - return await _dbContext.Cards - .AsNoTracking() - .Where(card => card.Col == CardIds.Archive - && card.ArchivedAt != null - && card.ArchivedAt < archivedBeforeUtc) - .Select(card => card.Id) - .ToListAsync(ct); - } - - /// - public async Task> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) - { - return await _dbContext.Cards - .AsNoTracking() - .Where(card => card.Col == CardIds.Trash && card.ReceivedAt < receivedBeforeUtc) - .Select(card => card.Id) - .ToListAsync(ct); - } - - /// - public async Task PurgeAsync(IReadOnlyList cardIds, CancellationToken ct) - { - if (cardIds.Count == 0) - { - return 0; - } - - await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct); - await _dbContext.DedupEntries - .Where(entry => entry.LeadId != null && cardIds.Contains(entry.LeadId!)) - .ExecuteDeleteAsync(ct); - int deleted = await _dbContext.Cards - .Where(card => cardIds.Contains(card.Id)) - .ExecuteDeleteAsync(ct); - await transaction.CommitAsync(ct); - return deleted; - } - - /// - public async Task> ListCardsForConversionAsync(CancellationToken ct) - { - List entities = await _dbContext.Cards - .AsNoTracking() - .Where(card => card.BudgetCur != string.Empty - && !ConversionExcludedCols.Contains(card.Col)) - .OrderByDescending(card => card.ReceivedAt) - .ToListAsync(ct); - return entities.Select(card => ToCardDto(card, Array.Empty())).ToList(); - } - - /// - public async Task UpdateConversionAsync( - string cardId, - double? convFrom, - double? convTo, - string convCur, - CancellationToken ct) - { - await _dbContext.Cards - .Where(card => card.Id == cardId) - .ExecuteUpdateAsync(setters => setters - .SetProperty(card => card.ConvFrom, convFrom) - .SetProperty(card => card.ConvTo, convTo) - .SetProperty(card => card.ConvCur, convCur), - ct); - } - - /// - public async Task> ListInboxWithSourceAsync(CancellationToken ct) - { - List entities = await _dbContext.Cards - .AsNoTracking() - .Where(card => card.Col == CardIds.Inbox && card.SourceText != string.Empty) - .OrderByDescending(card => card.ReceivedAt) - .ToListAsync(ct); - return entities.Select(card => ToCardDto(card, Array.Empty())).ToList(); - } - -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// Правила хранения, конверсии и вход эвристики suggest — partial-часть +/// +public sealed partial class KanbanStore +{ + /// + public async Task> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) + { + List boardIds = await _dbContext.Containers + .AsNoTracking() + .Where(container => container.Kind == ContainerKinds.Board) + .Select(container => container.Id) + .ToListAsync(ct); + + return await _dbContext.Cards + .AsNoTracking() + .Where(card => card.ReceivedAt < receivedBeforeUtc + && (card.Col == CardIds.Inbox || boardIds.Contains(card.Col))) + .Select(card => card.Id) + .ToListAsync(ct); + } + + /// + public async Task ArchiveAsync( + IReadOnlyList cardIds, + DateTimeOffset archivedAt, + CancellationToken ct) + { + if (cardIds.Count == 0) + { + return 0; + } + + return await _dbContext.Cards + .Where(card => cardIds.Contains(card.Id)) + .ExecuteUpdateAsync(setters => setters + .SetProperty(card => card.Col, CardIds.Archive) + .SetProperty(card => card.IsNew, false) + .SetProperty(card => card.ArchivedAt, archivedAt) + .SetProperty(card => card.MatchHitsJson, ToJson(Array.Empty())), + ct); + } + + /// + public async Task> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct) + { + return await _dbContext.Cards + .AsNoTracking() + .Where(card => card.Col == CardIds.Archive + && card.ArchivedAt != null + && card.ArchivedAt < archivedBeforeUtc) + .Select(card => card.Id) + .ToListAsync(ct); + } + + /// + public async Task> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) + { + return await _dbContext.Cards + .AsNoTracking() + .Where(card => card.Col == CardIds.Trash && card.ReceivedAt < receivedBeforeUtc) + .Select(card => card.Id) + .ToListAsync(ct); + } + + /// + public async Task PurgeAsync(IReadOnlyList cardIds, CancellationToken ct) + { + if (cardIds.Count == 0) + { + return 0; + } + + await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct); + await _dbContext.DedupEntries + .Where(entry => entry.LeadId != null && cardIds.Contains(entry.LeadId!)) + .ExecuteDeleteAsync(ct); + int deleted = await _dbContext.Cards + .Where(card => cardIds.Contains(card.Id)) + .ExecuteDeleteAsync(ct); + await transaction.CommitAsync(ct); + return deleted; + } + + /// + public async Task> ListCardsForConversionAsync(CancellationToken ct) + { + List entities = await _dbContext.Cards + .AsNoTracking() + .Where(card => card.BudgetCur != string.Empty + && !ConversionExcludedCols.Contains(card.Col)) + .OrderByDescending(card => card.ReceivedAt) + .ToListAsync(ct); + return entities.Select(card => ToCardDto(card, Array.Empty())).ToList(); + } + + /// + public async Task UpdateConversionAsync( + string cardId, + double? convFrom, + double? convTo, + string convCur, + CancellationToken ct) + { + await _dbContext.Cards + .Where(card => card.Id == cardId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(card => card.ConvFrom, convFrom) + .SetProperty(card => card.ConvTo, convTo) + .SetProperty(card => card.ConvCur, convCur), + ct); + } + + /// + public async Task> ListInboxWithSourceAsync(CancellationToken ct) + { + List entities = await _dbContext.Cards + .AsNoTracking() + .Where(card => card.Col == CardIds.Inbox && card.SourceText != string.Empty) + .OrderByDescending(card => card.ReceivedAt) + .ToListAsync(ct); + return entities.Select(card => ToCardDto(card, Array.Empty())).ToList(); + } + +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs index b2f7afc..5bce02b 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs @@ -1,288 +1,288 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища карточек и контейнеров -/// -public sealed partial class KanbanStore : ICardStore -{ - private readonly TenantDbContext _dbContext; - - /// - /// Создаёт EF-адаптер хранилища канбана - /// - /// Контекст схемы тенанта (Containers/Cards/LeadComments/CardMoves/DedupEntries). - public KanbanStore(TenantDbContext dbContext) - { - _dbContext = dbContext; - } - - private const string NoRulesJson = "{}"; - - private const string MoveAction = "move"; - - private const string RestoreAction = "restore"; - - private const long MillisPerMinute = 60_000L; - private const long MinutesPerHour = 60L; - private const long HoursPerDay = 24L; - - private static readonly string[] ConversionExcludedCols = - [CardIds.Archive, CardIds.Trash]; - - private static readonly string[] SelectedStageIds = CardsDefaultContainers.Ids.ToArray(); - - private const string HoldStage = CardsDefaultContainers.Hold; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - // Читает карточки пачкой с приложенными комментариями (один запрос на все id). - // cards: Карточки (уже отсортированы запросом чтения). - // ct: Токен отмены. - // Возвращает: DTO карточек в порядке входа: JSON-поля разобраны, комментарии приложены, time посчитан. - private async Task> ToCardDtosAsync(List cards, CancellationToken ct) - { - if (cards.Count == 0) - { - return Array.Empty(); - } - - List cardIds = cards.Select(card => card.Id).ToList(); - List comments = await _dbContext.LeadComments - .AsNoTracking() - .Where(comment => cardIds.Contains(comment.CardId)) - .OrderBy(comment => comment.CreatedAt) - .ThenBy(comment => comment.Id) - .ToListAsync(ct); - ILookup commentsByCardId = comments.ToLookup(comment => comment.CardId); - - return cards - .Select(card => ToCardDto(card, commentsByCardId[card.Id].Select(ToCommentDto).ToList())) - .ToList(); - } - - // Маппинг строки Containers в DTO (JSON-поля разбираются). - private static ContainerDto ToContainerDto(ContainerEntity entity) => new() - { - Id = entity.Id, - Name = entity.Name, - Description = entity.Description, - Color = entity.Color, - Order = entity.Position, - Space = entity.Space, - Kind = entity.Kind, - Collapsed = entity.Collapsed, - Suggested = entity.Suggested, - Rules = ToRulesOrNull(entity.RulesJson), - Policy = ToPolicyOrEmpty(entity.PolicyJson), - Note = entity.Note, - }; - - // Маппинг DTO в строку Containers (JSON-поля сериализуются; CreatedAt — UTC-now). - private static ContainerEntity ToContainerEntity(ContainerDto container) => new() - { - Id = container.Id, - Name = container.Name, - Description = container.Description, - Color = container.Color, - Position = container.Order, - Space = container.Space, - Kind = container.Kind, - Collapsed = container.Collapsed, - Suggested = container.Suggested, - RulesJson = ToRulesJson(container.Rules), - PolicyJson = ToPolicyJson(container.Policy), - Note = container.Note, - CreatedAt = DateTimeOffset.UtcNow, - }; - - // Маппинг строки Cards в DTO: JSON-поля разобраны, budget/converted собраны из пар (from,to,cur). - // entity: Строка карточки. - // comments: Комментарии карточки (уже в DTO с human-меткой time). - private static CardDto ToCardDto(CardEntity entity, IReadOnlyList comments) => new() - { - Id = entity.Id, - Col = entity.Col, - IsNew = entity.IsNew, - Local = entity.Local, - IsVacancy = entity.IsVacancy, - IsVacancyKnown = entity.IsVacancyKnown, - Title = entity.Title, - Summary = entity.Summary, - Source = FromJsonOr(entity.SourceJson, SourceRefs.Empty), - Content = FromJsonOr(entity.ContentJson, new SourceContent { Text = entity.SourceText }), - Stack = ToJsonList(entity.StackJson), - Budget = entity.BudgetCur.Length == 0 - ? null - : new CardBudgetDto(entity.BudgetFrom, entity.BudgetTo, entity.BudgetCur), - Converted = entity.ConvCur.Length == 0 - ? null - : new CardBudgetDto(entity.ConvFrom, entity.ConvTo, entity.ConvCur), - Contact = entity.Contact, - Contacts = ToJsonList(entity.ContactsJson), - Time = HumanAge(entity.ReceivedAt), - ReceivedAtMs = entity.ReceivedAt.ToUnixTimeMilliseconds(), - PrevCol = entity.PrevCol, - MatchHits = ToJsonList(entity.MatchHitsJson), - Comments = comments, - Links = ToJsonList(entity.LinksJson), - Files = ToJsonList(entity.FilesJson), - History = ToJsonList(entity.HistoryJson), - TzText = entity.TzText, - Reminder = entity.ReminderAt is { } reminderAt ? new CardReminderDto(reminderAt.ToUnixTimeMilliseconds()) : null, - CreatedAtMs = entity.CreatedAt.ToUnixTimeMilliseconds(), - UpdatedAtMs = entity.UpdatedAt.ToUnixTimeMilliseconds(), - }; - - // Разбирает одиночный JSON-объект; пустая/битая строка — запасное значение. - private static T FromJsonOr(string json, T fallback) - { - if (string.IsNullOrWhiteSpace(json)) - { - return fallback; - } - - try - { - return JsonSerializer.Deserialize(json, JsonOptions) ?? fallback; - } - catch (JsonException) - { - return fallback; - } - } - - // Маппинг снимка (write-модель создания карточки) в строку Cards; CreatedAt — UTC-now. - private static CardEntity ToCardEntity(CardSnapshot snapshot) => new() - { - Id = snapshot.Id, - Col = snapshot.Col, - IsNew = snapshot.IsNew, - Local = snapshot.Local, - IsVacancy = snapshot.IsVacancy, - IsVacancyKnown = snapshot.IsVacancyKnown, - Title = snapshot.Title, - Summary = snapshot.Summary, - StackJson = ToJson(snapshot.Stack), - BudgetFrom = snapshot.BudgetFrom, - BudgetTo = snapshot.BudgetTo, - BudgetCur = snapshot.BudgetCur, - ConvFrom = snapshot.ConvFrom, - ConvTo = snapshot.ConvTo, - ConvCur = snapshot.ConvCur, - Contact = snapshot.Contact, - ContactsJson = ToJson(snapshot.Contacts), - ReceivedAt = snapshot.ReceivedAt, - SourceKind = snapshot.Source.Kind, - SourceExternalId = snapshot.Source.ExternalId ?? string.Empty, - SourceOriginRef = snapshot.Source.OriginRef ?? string.Empty, - SourceJson = ToJson(snapshot.Source), - ContentJson = ToJson(snapshot.Content), - SourceText = snapshot.Content.Text ?? string.Empty, - PrevCol = snapshot.PrevCol, - ArchivedAt = snapshot.ArchivedAt, - MatchHitsJson = ToJson(snapshot.MatchHits), - TzText = snapshot.TzText, - HistoryJson = ToJson(snapshot.History), - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow, - }; - - private static CardCommentDto ToCommentDto(LeadCommentEntity entity) => - new(entity.Id, entity.By, entity.Text, HumanAge(entity.CreatedAt)); - - private static string HumanAge(DateTimeOffset timestamp) - { - long deltaMs = Math.Max(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - timestamp.ToUnixTimeMilliseconds()); - long minutes = deltaMs / MillisPerMinute; - if (minutes < MinutesPerHour) - { - return minutes < 1 ? CardsService.JustNowLabel : $"{minutes} мин"; - } - - long hours = minutes / MinutesPerHour; - if (hours < HoursPerDay) - { - return $"{hours} ч"; - } - - long days = hours / HoursPerDay; - return $"{days} дн"; - } - - private static IReadOnlyList ToJsonList(string json) - { - if (string.IsNullOrWhiteSpace(json)) - { - return Array.Empty(); - } - - try - { - return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; - } - catch (JsonException) - { - return Array.Empty(); - } - } - - // Разбирает rules-объект: {}/пустая/битая строка — null («правил нет»). - private static ContainerRulesDto? ToRulesOrNull(string json) - { - if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson) - { - return null; - } - - try - { - return JsonSerializer.Deserialize(json, JsonOptions); - } - catch (JsonException) - { - return null; - } - } - - // Сериализует rules-объект: null («правил нет») хранится как {}. - private static string ToRulesJson(ContainerRulesDto? rules) => - rules is null ? NoRulesJson : JsonSerializer.Serialize(rules, JsonOptions); - - // Разбирает policy-объект: пустая/битая строка — политика по умолчанию (обычная колонка). - private static ContainerPolicyDto ToPolicyOrEmpty(string json) - { - if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson) - { - return new ContainerPolicyDto(); - } - - try - { - return JsonSerializer.Deserialize(json, JsonOptions) ?? new ContainerPolicyDto(); - } - catch (JsonException) - { - return new ContainerPolicyDto(); - } - } - - // Сериализует policy-объект в JSON (camelCase, конвенция value_json). - private static string ToPolicyJson(ContainerPolicyDto policy) => JsonSerializer.Serialize(policy, JsonOptions); - - // Сериализует значение в JSON (camelCase, конвенция value_json). - private static string ToJson(T value) => JsonSerializer.Serialize(value, JsonOptions); -} - +using System.Text.Json; +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер хранилища карточек и контейнеров +/// +public sealed partial class KanbanStore : ICardStore +{ + private readonly TenantDbContext _dbContext; + + /// + /// Создаёт EF-адаптер хранилища канбана + /// + /// Контекст схемы тенанта (Containers/Cards/LeadComments/CardMoves/DedupEntries). + public KanbanStore(TenantDbContext dbContext) + { + _dbContext = dbContext; + } + + private const string NoRulesJson = "{}"; + + private const string MoveAction = "move"; + + private const string RestoreAction = "restore"; + + private const long MillisPerMinute = 60_000L; + private const long MinutesPerHour = 60L; + private const long HoursPerDay = 24L; + + private static readonly string[] ConversionExcludedCols = + [CardIds.Archive, CardIds.Trash]; + + private static readonly string[] SelectedStageIds = CardsDefaultContainers.Ids.ToArray(); + + private const string HoldStage = CardsDefaultContainers.Hold; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + // Читает карточки пачкой с приложенными комментариями (один запрос на все id). + // cards: Карточки (уже отсортированы запросом чтения). + // ct: Токен отмены. + // Возвращает: DTO карточек в порядке входа: JSON-поля разобраны, комментарии приложены, time посчитан. + private async Task> ToCardDtosAsync(List cards, CancellationToken ct) + { + if (cards.Count == 0) + { + return Array.Empty(); + } + + List cardIds = cards.Select(card => card.Id).ToList(); + List comments = await _dbContext.LeadComments + .AsNoTracking() + .Where(comment => cardIds.Contains(comment.CardId)) + .OrderBy(comment => comment.CreatedAt) + .ThenBy(comment => comment.Id) + .ToListAsync(ct); + ILookup commentsByCardId = comments.ToLookup(comment => comment.CardId); + + return cards + .Select(card => ToCardDto(card, commentsByCardId[card.Id].Select(ToCommentDto).ToList())) + .ToList(); + } + + // Маппинг строки Containers в DTO (JSON-поля разбираются). + private static ContainerDto ToContainerDto(ContainerEntity entity) => new() + { + Id = entity.Id, + Name = entity.Name, + Description = entity.Description, + Color = entity.Color, + Order = entity.Position, + Space = entity.Space, + Kind = entity.Kind, + Collapsed = entity.Collapsed, + Suggested = entity.Suggested, + Rules = ToRulesOrNull(entity.RulesJson), + Policy = ToPolicyOrEmpty(entity.PolicyJson), + Note = entity.Note, + }; + + // Маппинг DTO в строку Containers (JSON-поля сериализуются; CreatedAt — UTC-now). + private static ContainerEntity ToContainerEntity(ContainerDto container) => new() + { + Id = container.Id, + Name = container.Name, + Description = container.Description, + Color = container.Color, + Position = container.Order, + Space = container.Space, + Kind = container.Kind, + Collapsed = container.Collapsed, + Suggested = container.Suggested, + RulesJson = ToRulesJson(container.Rules), + PolicyJson = ToPolicyJson(container.Policy), + Note = container.Note, + CreatedAt = DateTimeOffset.UtcNow, + }; + + // Маппинг строки Cards в DTO: JSON-поля разобраны, budget/converted собраны из пар (from,to,cur). + // entity: Строка карточки. + // comments: Комментарии карточки (уже в DTO с human-меткой time). + private static CardDto ToCardDto(CardEntity entity, IReadOnlyList comments) => new() + { + Id = entity.Id, + Col = entity.Col, + IsNew = entity.IsNew, + Local = entity.Local, + IsVacancy = entity.IsVacancy, + IsVacancyKnown = entity.IsVacancyKnown, + Title = entity.Title, + Summary = entity.Summary, + Source = FromJsonOr(entity.SourceJson, SourceRefs.Empty), + Content = FromJsonOr(entity.ContentJson, new SourceContent { Text = entity.SourceText }), + Stack = ToJsonList(entity.StackJson), + Budget = entity.BudgetCur.Length == 0 + ? null + : new CardBudgetDto(entity.BudgetFrom, entity.BudgetTo, entity.BudgetCur), + Converted = entity.ConvCur.Length == 0 + ? null + : new CardBudgetDto(entity.ConvFrom, entity.ConvTo, entity.ConvCur), + Contact = entity.Contact, + Contacts = ToJsonList(entity.ContactsJson), + Time = HumanAge(entity.ReceivedAt), + ReceivedAtMs = entity.ReceivedAt.ToUnixTimeMilliseconds(), + PrevCol = entity.PrevCol, + MatchHits = ToJsonList(entity.MatchHitsJson), + Comments = comments, + Links = ToJsonList(entity.LinksJson), + Files = ToJsonList(entity.FilesJson), + History = ToJsonList(entity.HistoryJson), + TzText = entity.TzText, + Reminder = entity.ReminderAt is { } reminderAt ? new CardReminderDto(reminderAt.ToUnixTimeMilliseconds()) : null, + CreatedAtMs = entity.CreatedAt.ToUnixTimeMilliseconds(), + UpdatedAtMs = entity.UpdatedAt.ToUnixTimeMilliseconds(), + }; + + // Разбирает одиночный JSON-объект; пустая/битая строка — запасное значение. + private static T FromJsonOr(string json, T fallback) + { + if (string.IsNullOrWhiteSpace(json)) + { + return fallback; + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions) ?? fallback; + } + catch (JsonException) + { + return fallback; + } + } + + // Маппинг снимка (write-модель создания карточки) в строку Cards; CreatedAt — UTC-now. + private static CardEntity ToCardEntity(CardSnapshot snapshot) => new() + { + Id = snapshot.Id, + Col = snapshot.Col, + IsNew = snapshot.IsNew, + Local = snapshot.Local, + IsVacancy = snapshot.IsVacancy, + IsVacancyKnown = snapshot.IsVacancyKnown, + Title = snapshot.Title, + Summary = snapshot.Summary, + StackJson = ToJson(snapshot.Stack), + BudgetFrom = snapshot.BudgetFrom, + BudgetTo = snapshot.BudgetTo, + BudgetCur = snapshot.BudgetCur, + ConvFrom = snapshot.ConvFrom, + ConvTo = snapshot.ConvTo, + ConvCur = snapshot.ConvCur, + Contact = snapshot.Contact, + ContactsJson = ToJson(snapshot.Contacts), + ReceivedAt = snapshot.ReceivedAt, + SourceKind = snapshot.Source.Kind, + SourceExternalId = snapshot.Source.ExternalId ?? string.Empty, + SourceOriginRef = snapshot.Source.OriginRef ?? string.Empty, + SourceJson = ToJson(snapshot.Source), + ContentJson = ToJson(snapshot.Content), + SourceText = snapshot.Content.Text ?? string.Empty, + PrevCol = snapshot.PrevCol, + ArchivedAt = snapshot.ArchivedAt, + MatchHitsJson = ToJson(snapshot.MatchHits), + TzText = snapshot.TzText, + HistoryJson = ToJson(snapshot.History), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + }; + + private static CardCommentDto ToCommentDto(LeadCommentEntity entity) => + new(entity.Id, entity.By, entity.Text, HumanAge(entity.CreatedAt)); + + private static string HumanAge(DateTimeOffset timestamp) + { + long deltaMs = Math.Max(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - timestamp.ToUnixTimeMilliseconds()); + long minutes = deltaMs / MillisPerMinute; + if (minutes < MinutesPerHour) + { + return minutes < 1 ? CardsService.JustNowLabel : $"{minutes} мин"; + } + + long hours = minutes / MinutesPerHour; + if (hours < HoursPerDay) + { + return $"{hours} ч"; + } + + long days = hours / HoursPerDay; + return $"{days} дн"; + } + + private static IReadOnlyList ToJsonList(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return Array.Empty(); + } + + try + { + return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + } + catch (JsonException) + { + return Array.Empty(); + } + } + + // Разбирает rules-объект: {}/пустая/битая строка — null («правил нет»). + private static ContainerRulesDto? ToRulesOrNull(string json) + { + if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (JsonException) + { + return null; + } + } + + // Сериализует rules-объект: null («правил нет») хранится как {}. + private static string ToRulesJson(ContainerRulesDto? rules) => + rules is null ? NoRulesJson : JsonSerializer.Serialize(rules, JsonOptions); + + // Разбирает policy-объект: пустая/битая строка — политика по умолчанию (обычная колонка). + private static ContainerPolicyDto ToPolicyOrEmpty(string json) + { + if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson) + { + return new ContainerPolicyDto(); + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions) ?? new ContainerPolicyDto(); + } + catch (JsonException) + { + return new ContainerPolicyDto(); + } + } + + // Сериализует policy-объект в JSON (camelCase, конвенция value_json). + private static string ToPolicyJson(ContainerPolicyDto policy) => JsonSerializer.Serialize(policy, JsonOptions); + + // Сериализует значение в JSON (camelCase, конвенция value_json). + private static string ToJson(T value) => JsonSerializer.Serialize(value, JsonOptions); +} + diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/MlLearningStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/MlLearningStore.cs index 41b559e..7d5aeb9 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/MlLearningStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/MlLearningStore.cs @@ -1,64 +1,64 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища обучения ML -/// -/// Scoped-контекст тенанта запроса (search_path). -public sealed class MlLearningStore(TenantDbContext dbContext) : IMlLearningStore -{ - /// - public Task CountLearningAsync(CancellationToken ct) => dbContext.CardMoves.CountAsync(ct); - - /// - public Task CountOutboxAsync(CancellationToken ct) => dbContext.MlOutbox.CountAsync(ct); - - /// - public async Task AddOutboxAsync( - string id, - string text, - string label, - double delta, - CancellationToken ct) - { - dbContext.MlOutbox.Add(new MlOutboxEntity - { - Id = id, - Text = text, - Label = label, - Delta = delta, - CreatedAt = DateTimeOffset.UtcNow, - }); - await dbContext.SaveChangesAsync(ct); - } - - /// - public Task ClearOutboxAsync(CancellationToken ct) => dbContext.MlOutbox.ExecuteDeleteAsync(ct); - - /// - public async Task> TakeOutboxBatchAsync(int limit, CancellationToken ct) - { - return await dbContext.MlOutbox - .OrderBy(row => row.CreatedAt) - .Take(limit) - .Select(row => new MlOutboxEntryDto(row.Id, row.Text, row.Label, row.Delta)) - .ToListAsync(ct); - } - - /// - public async Task DeleteOutboxAsync(IReadOnlyCollection ids, CancellationToken ct) - { - if (ids.Count == 0) - { - return; - } - - await dbContext.MlOutbox - .Where(row => ids.Contains(row.Id)) - .ExecuteDeleteAsync(ct); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер хранилища обучения ML +/// +/// Scoped-контекст тенанта запроса (search_path). +public sealed class MlLearningStore(TenantDbContext dbContext) : IMlLearningStore +{ + /// + public Task CountLearningAsync(CancellationToken ct) => dbContext.CardMoves.CountAsync(ct); + + /// + public Task CountOutboxAsync(CancellationToken ct) => dbContext.MlOutbox.CountAsync(ct); + + /// + public async Task AddOutboxAsync( + string id, + string text, + string label, + double delta, + CancellationToken ct) + { + dbContext.MlOutbox.Add(new MlOutboxEntity + { + Id = id, + Text = text, + Label = label, + Delta = delta, + CreatedAt = DateTimeOffset.UtcNow, + }); + await dbContext.SaveChangesAsync(ct); + } + + /// + public Task ClearOutboxAsync(CancellationToken ct) => dbContext.MlOutbox.ExecuteDeleteAsync(ct); + + /// + public async Task> TakeOutboxBatchAsync(int limit, CancellationToken ct) + { + return await dbContext.MlOutbox + .OrderBy(row => row.CreatedAt) + .Take(limit) + .Select(row => new MlOutboxEntryDto(row.Id, row.Text, row.Label, row.Delta)) + .ToListAsync(ct); + } + + /// + public async Task DeleteOutboxAsync(IReadOnlyCollection ids, CancellationToken ct) + { + if (ids.Count == 0) + { + return; + } + + await dbContext.MlOutbox + .Where(row => ids.Contains(row.Id)) + .ExecuteDeleteAsync(ct); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/OperatorAuthStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/OperatorAuthStore.cs index 55caf51..00f0013 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/OperatorAuthStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/OperatorAuthStore.cs @@ -1,82 +1,82 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища аутентификации оператора -/// -public sealed class OperatorAuthStore(DealDbContext dbContext) : IOperatorAuthStore -{ - /// - public async Task FindByLoginAsync(string login, CancellationToken ct) - { - var entity = await dbContext.Operators - .AsNoTracking() - .SingleOrDefaultAsync(o => o.Login == login, ct); - return entity is null ? null : ToStoredOperatorDto(entity); - } - - /// - public async Task CreateAsync(StoredOperatorDto operatorRecord, CancellationToken ct) - { - dbContext.Operators.Add(new OperatorEntity - { - Id = operatorRecord.Id, - Login = operatorRecord.Login, - Status = operatorRecord.Status, - PasswordHash = operatorRecord.PasswordHash, - CreatedAt = DateTimeOffset.UtcNow, - }); - await dbContext.SaveChangesAsync(ct); - } - - /// - public async Task FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct) - { - // Протухшие сессии не возвращаем: с момента истечения они «не существуют» - // (физическую очистку выполняет DeleteExpiredSessionsAsync). - var entity = await dbContext.OperatorSessions - .AsNoTracking() - .SingleOrDefaultAsync(s => s.TokenHash == tokenHash && s.ExpiresAt > DateTimeOffset.UtcNow, ct); - return entity is null ? null : ToOperatorSessionDto(entity); - } - - /// - public async Task CreateSessionAsync(OperatorSessionDto session, CancellationToken ct) - { - dbContext.OperatorSessions.Add(new OperatorSessionEntity - { - TokenHash = session.TokenHash, - OperatorId = session.OperatorId, - Login = session.Login, - ExpiresAt = session.ExpiresAt, - CreatedAt = DateTimeOffset.UtcNow, - }); - await dbContext.SaveChangesAsync(ct); - } - - /// - public async Task DeleteSessionAsync(string tokenHash, CancellationToken ct) - { - await dbContext.OperatorSessions - .Where(s => s.TokenHash == tokenHash) - .ExecuteDeleteAsync(ct); - } - - /// - public async Task DeleteExpiredSessionsAsync(CancellationToken ct) - { - await dbContext.OperatorSessions - .Where(s => s.ExpiresAt <= DateTimeOffset.UtcNow) - .ExecuteDeleteAsync(ct); - } - - private static StoredOperatorDto ToStoredOperatorDto(OperatorEntity entity) => - new(entity.Id, entity.Login, entity.Status, entity.PasswordHash); - - private static OperatorSessionDto ToOperatorSessionDto(OperatorSessionEntity entity) => - new(entity.TokenHash, entity.OperatorId, entity.Login, entity.ExpiresAt); -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер хранилища аутентификации оператора +/// +public sealed class OperatorAuthStore(DealDbContext dbContext) : IOperatorAuthStore +{ + /// + public async Task FindByLoginAsync(string login, CancellationToken ct) + { + var entity = await dbContext.Operators + .AsNoTracking() + .SingleOrDefaultAsync(o => o.Login == login, ct); + return entity is null ? null : ToStoredOperatorDto(entity); + } + + /// + public async Task CreateAsync(StoredOperatorDto operatorRecord, CancellationToken ct) + { + dbContext.Operators.Add(new OperatorEntity + { + Id = operatorRecord.Id, + Login = operatorRecord.Login, + Status = operatorRecord.Status, + PasswordHash = operatorRecord.PasswordHash, + CreatedAt = DateTimeOffset.UtcNow, + }); + await dbContext.SaveChangesAsync(ct); + } + + /// + public async Task FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct) + { + // Протухшие сессии не возвращаем: с момента истечения они «не существуют» + // (физическую очистку выполняет DeleteExpiredSessionsAsync). + var entity = await dbContext.OperatorSessions + .AsNoTracking() + .SingleOrDefaultAsync(s => s.TokenHash == tokenHash && s.ExpiresAt > DateTimeOffset.UtcNow, ct); + return entity is null ? null : ToOperatorSessionDto(entity); + } + + /// + public async Task CreateSessionAsync(OperatorSessionDto session, CancellationToken ct) + { + dbContext.OperatorSessions.Add(new OperatorSessionEntity + { + TokenHash = session.TokenHash, + OperatorId = session.OperatorId, + Login = session.Login, + ExpiresAt = session.ExpiresAt, + CreatedAt = DateTimeOffset.UtcNow, + }); + await dbContext.SaveChangesAsync(ct); + } + + /// + public async Task DeleteSessionAsync(string tokenHash, CancellationToken ct) + { + await dbContext.OperatorSessions + .Where(s => s.TokenHash == tokenHash) + .ExecuteDeleteAsync(ct); + } + + /// + public async Task DeleteExpiredSessionsAsync(CancellationToken ct) + { + await dbContext.OperatorSessions + .Where(s => s.ExpiresAt <= DateTimeOffset.UtcNow) + .ExecuteDeleteAsync(ct); + } + + private static StoredOperatorDto ToStoredOperatorDto(OperatorEntity entity) => + new(entity.Id, entity.Login, entity.Status, entity.PasswordHash); + + private static OperatorSessionDto ToOperatorSessionDto(OperatorSessionEntity entity) => + new(entity.TokenHash, entity.OperatorId, entity.Login, entity.ExpiresAt); +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs index 82bc5f7..07e63a9 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs @@ -1,355 +1,355 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища пайплайна -/// -public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - // ── Очередь (QueueItems) ─────────────────────────────────────── - - /// - public Task ExistsDuplicateAsync( - SourceRef source, - CancellationToken ct) - { - string key = source.DedupeKey(); - return dbContext.QueueItems.AnyAsync(item => item.SourceKey == key, ct); - } - - /// - public async Task AddAsync(QueueItemDto item, CancellationToken ct) - { - dbContext.QueueItems.Add(ToQueueItemEntity(item)); - await dbContext.SaveChangesAsync(ct); - } - - /// - public async Task> ListAsync( - string? status, - int limit, - CancellationToken ct) - { - IQueryable queryable = dbContext.QueueItems.AsNoTracking(); - if (status is not null) - { - queryable = queryable.Where(item => item.Status == status); - } - - List entities = await queryable - .OrderBy(item => item.CreatedAt) - .Take(limit) - .ToListAsync(ct); - return entities.Select(ToQueueItemDto).ToList(); - } - - /// - public Task CountByStatusAsync(string status, CancellationToken ct) - => dbContext.QueueItems.CountAsync(item => item.Status == status, ct); - - /// - public async Task SetStatusAsync( - string id, - string status, - CancellationToken ct) - { - // Смена статуса строки очереди (new → filtered в воркере): UpdatedAt = UTC-now (порт). - await dbContext.QueueItems - .Where(item => item.Id == id) - .ExecuteUpdateAsync(setters => setters - .SetProperty(item => item.Status, status) - .SetProperty(item => item.UpdatedAt, DateTimeOffset.UtcNow), - ct); - } - - /// - public async Task RemoveAsync(string id, CancellationToken ct) - { - await dbContext.QueueItems - .Where(item => item.Id == id) - .ExecuteDeleteAsync(ct); - } - - // ── Отсев (RejectedItems) ────────────────────────────────────────────── - - /// - public async Task UpsertAsync(RejectRecord record, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(record.Text)) - { - return; - } - - string id = record.DeterministicId ?? PrefixId.New(PipelineIdPrefixes.Rejected); - DateTimeOffset rejectedAt = DateTimeOffset.UtcNow; - string sourceKey = record.Source.DedupeKey(); - string sourceJson = ToJson(record.Source); - string contentJson = ToJson(record.Content); - - await dbContext.Database.ExecuteSqlInterpolatedAsync( - $""" - INSERT INTO "RejectedItems" ("Id", "SourceKey", "SourceJson", "ContentJson", "Text", "Stage", "Reason", "Kw", "Source", "MsgAt", "RejectedAt", "Returned", "ReturnReason") - VALUES ({id}, {sourceKey}, {sourceJson}, {contentJson}, {record.Text}, {record.Stage}, {record.Reason}, {record.Kw}, {record.DecidedBy}, {FromEpochMs(record.MsgAtMs)}, {rejectedAt}, false, {string.Empty}) - ON CONFLICT ("Id") DO UPDATE SET - "SourceKey" = excluded."SourceKey", - "SourceJson" = excluded."SourceJson", - "ContentJson" = excluded."ContentJson", - "Text" = excluded."Text", - "Stage" = excluded."Stage", - "Reason" = excluded."Reason", - "Kw" = excluded."Kw", - "Source" = excluded."Source", - "MsgAt" = excluded."MsgAt", - "RejectedAt" = excluded."RejectedAt" - """, - ct); - } - - /// - public async Task> ListPageAsync( - int offset, - int limit, - CancellationToken ct) - { - List entities = await dbContext.RejectedItems - .AsNoTracking() - .OrderByDescending(item => item.RejectedAt) - .Skip(offset) - .Take(limit) - .ToListAsync(ct); - return entities.Select(ToRejectedItemDto).ToList(); - } - - /// - public async Task> SearchAsync( - string q, - int limitFts, - int limitLike, - CancellationToken ct) - { - string query = q.Trim(); - if (query.Length == 0) - { - return Array.Empty(); - } - - List fts = await dbContext.RejectedItems - .FromSqlInterpolated( - $""" - SELECT * FROM "RejectedItems" - WHERE "SearchTsv" @@ plainto_tsquery('russian', {query}) - ORDER BY ts_rank("SearchTsv", plainto_tsquery('russian', {query})) DESC, "RejectedAt" DESC - LIMIT {limitFts} - """) - .AsNoTracking() - .ToListAsync(ct); - - var rows = fts.Select(ToRejectedItemDto).ToList(); - var seen = new HashSet(fts.Select(item => item.Id), StringComparer.Ordinal); - - string pattern = $"%{query}%"; - List like = await dbContext.RejectedItems - .AsNoTracking() - .Where(item => EF.Functions.Like(item.Text.ToLower(), pattern) - || EF.Functions.Like(item.Reason.ToLower(), pattern) - || EF.Functions.Like(item.Kw.ToLower(), pattern)) - .OrderByDescending(item => item.RejectedAt) - .Take(limitLike) - .ToListAsync(ct); - - foreach (RejectedItemEntity entity in like) - { - if (seen.Add(entity.Id)) - { - rows.Add(ToRejectedItemDto(entity)); - } - } - - return rows; - } - - /// - public Task CountAsync(CancellationToken ct) => dbContext.RejectedItems.CountAsync(ct); - - /// - public async Task GetAsync(string id, CancellationToken ct) - { - RejectedItemEntity? entity = await dbContext.RejectedItems - .AsNoTracking() - .SingleOrDefaultAsync(item => item.Id == id, ct); - return entity is null ? null : ToRejectedItemDto(entity); - } - - /// - public async Task DeleteAsync(string id, CancellationToken ct) - { - await dbContext.RejectedItems - .Where(item => item.Id == id) - .ExecuteDeleteAsync(ct); - } - - /// - public async Task ClearAsync(CancellationToken ct) - { - return await dbContext.RejectedItems.ExecuteDeleteAsync(ct); - } - - /// - public async Task PurgeExpiredAsync(DateTimeOffset olderThan, CancellationToken ct) - { - return await dbContext.RejectedItems - .Where(item => item.RejectedAt < olderThan) - .ExecuteDeleteAsync(ct); - } - - /// - public async Task MarkReturnedAsync( - string id, - string reason, - DateTimeOffset returnedAt, - CancellationToken ct) - { - await dbContext.RejectedItems - .Where(item => item.Id == id) - .ExecuteUpdateAsync(setters => setters - .SetProperty(item => item.Returned, true) - .SetProperty(item => item.ReturnedAt, returnedAt) - .SetProperty(item => item.ReturnReason, reason), - ct); - } - - // ── Дедуп (DedupEntries) ────────────────────────────────────────────── - - /// - public Task ExistsAsync(string hash, CancellationToken ct) - => dbContext.DedupEntries.AnyAsync(entry => entry.Hash == hash, ct); - - /// - public async Task ClaimAsync(string hash, CancellationToken ct) - { - int inserted = await dbContext.Database.ExecuteSqlInterpolatedAsync( - $""" - INSERT INTO "DedupEntries" ("Hash", "LeadId", "CreatedAt") - VALUES ({hash}, NULL, {DateTimeOffset.UtcNow}) - ON CONFLICT ("Hash") DO NOTHING - """, - ct); - return inserted == 1; - } - - /// - public async Task DeleteClaimAsync(string hash, CancellationToken ct) - { - await dbContext.DedupEntries - .Where(entry => entry.Hash == hash && entry.LeadId == null) - .ExecuteDeleteAsync(ct); - } - - /// - public async Task LinkAsync( - string hash, - string cardId, - CancellationToken ct) - { - await dbContext.DedupEntries - .Where(entry => entry.Hash == hash) - .ExecuteUpdateAsync(setters => setters.SetProperty(entry => entry.LeadId, cardId), ct); - } - - /// - public async Task DeleteByCardAsync(string cardId, CancellationToken ct) - { - await dbContext.DedupEntries - .Where(entry => entry.LeadId == cardId) - .ExecuteDeleteAsync(ct); - } - - // ── Маппинг (вручную: порт не видит EF-сущности) ─────────────────────── - - private static QueueItemDto ToQueueItemDto(QueueItemEntity entity) => new() - { - Id = entity.Id, - Source = FromJsonOr(entity.SourceJson, SourceRefs.Empty), - Content = FromJsonOr(entity.ContentJson, new SourceContent()), - Text = entity.Text, - Status = entity.Status, - MsgAtMs = entity.MsgAt.ToUnixTimeMilliseconds(), - QueuedAtMs = entity.CreatedAt.ToUnixTimeMilliseconds(), - Force = entity.Force, - }; - - // DTO очереди → строка QueueItems (CreatedAt = UpdatedAt = QueuedAtMs, задаёт модуль). - private static QueueItemEntity ToQueueItemEntity(QueueItemDto item) - { - DateTimeOffset queuedAt = FromEpochMs(item.QueuedAtMs); - return new QueueItemEntity - { - Id = item.Id, - SourceKey = item.Source.DedupeKey(), - SourceJson = ToJson(item.Source), - ContentJson = ToJson(item.Content), - Text = item.Text, - Status = item.Status, - MsgAt = FromEpochMs(item.MsgAtMs), - CreatedAt = queuedAt, - UpdatedAt = queuedAt, - Force = item.Force, - }; - } - - private static RejectedItemDto ToRejectedItemDto(RejectedItemEntity entity) => new() - { - Id = entity.Id, - Source = FromJsonOr(entity.SourceJson, SourceRefs.Empty), - Content = FromJsonOr(entity.ContentJson, new SourceContent()), - Text = entity.Text, - Stage = entity.Stage, - StageLabel = PipelineRejectConstants.StageLabel(entity.Stage), - Reason = entity.Reason, - Kw = entity.Kw, - DecidedBy = entity.Source, - DecidedByLabel = PipelineRejectConstants.SourceLabel(entity.Source), - MsgAtMs = entity.MsgAt.ToUnixTimeMilliseconds(), - RejectedAtMs = entity.RejectedAt.ToUnixTimeMilliseconds(), - Returned = entity.Returned, - ReturnedAtMs = entity.ReturnedAt?.ToUnixTimeMilliseconds(), - ReturnReason = entity.ReturnReason, - }; - - // epoch-ms → DateTimeOffset (UTC-момент для timestamptz-колонки). - private static DateTimeOffset FromEpochMs(long epochMs) => DateTimeOffset.FromUnixTimeMilliseconds(epochMs); - - // Сериализует значение в JSON (camelCase, конвенция value_json). - private static string ToJson(T value) => JsonSerializer.Serialize(value, JsonOptions); - - // Разбирает одиночный JSON-объект; пустая/битая строка — запасное значение. - private static T FromJsonOr(string json, T fallback) - { - if (string.IsNullOrWhiteSpace(json)) - { - return fallback; - } - - try - { - return JsonSerializer.Deserialize(json, JsonOptions) ?? fallback; - } - catch (JsonException) - { - return fallback; - } - } -} +using System.Text.Json; +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер хранилища пайплайна +/// +public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + // ── Очередь (QueueItems) ─────────────────────────────────────── + + /// + public Task ExistsDuplicateAsync( + SourceRef source, + CancellationToken ct) + { + string key = source.DedupeKey(); + return dbContext.QueueItems.AnyAsync(item => item.SourceKey == key, ct); + } + + /// + public async Task AddAsync(QueueItemDto item, CancellationToken ct) + { + dbContext.QueueItems.Add(ToQueueItemEntity(item)); + await dbContext.SaveChangesAsync(ct); + } + + /// + public async Task> ListAsync( + string? status, + int limit, + CancellationToken ct) + { + IQueryable queryable = dbContext.QueueItems.AsNoTracking(); + if (status is not null) + { + queryable = queryable.Where(item => item.Status == status); + } + + List entities = await queryable + .OrderBy(item => item.CreatedAt) + .Take(limit) + .ToListAsync(ct); + return entities.Select(ToQueueItemDto).ToList(); + } + + /// + public Task CountByStatusAsync(string status, CancellationToken ct) + => dbContext.QueueItems.CountAsync(item => item.Status == status, ct); + + /// + public async Task SetStatusAsync( + string id, + string status, + CancellationToken ct) + { + // Смена статуса строки очереди (new → filtered в воркере): UpdatedAt = UTC-now (порт). + await dbContext.QueueItems + .Where(item => item.Id == id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(item => item.Status, status) + .SetProperty(item => item.UpdatedAt, DateTimeOffset.UtcNow), + ct); + } + + /// + public async Task RemoveAsync(string id, CancellationToken ct) + { + await dbContext.QueueItems + .Where(item => item.Id == id) + .ExecuteDeleteAsync(ct); + } + + // ── Отсев (RejectedItems) ────────────────────────────────────────────── + + /// + public async Task UpsertAsync(RejectRecord record, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(record.Text)) + { + return; + } + + string id = record.DeterministicId ?? PrefixId.New(PipelineIdPrefixes.Rejected); + DateTimeOffset rejectedAt = DateTimeOffset.UtcNow; + string sourceKey = record.Source.DedupeKey(); + string sourceJson = ToJson(record.Source); + string contentJson = ToJson(record.Content); + + await dbContext.Database.ExecuteSqlInterpolatedAsync( + $""" + INSERT INTO "RejectedItems" ("Id", "SourceKey", "SourceJson", "ContentJson", "Text", "Stage", "Reason", "Kw", "Source", "MsgAt", "RejectedAt", "Returned", "ReturnReason") + VALUES ({id}, {sourceKey}, {sourceJson}, {contentJson}, {record.Text}, {record.Stage}, {record.Reason}, {record.Kw}, {record.DecidedBy}, {FromEpochMs(record.MsgAtMs)}, {rejectedAt}, false, {string.Empty}) + ON CONFLICT ("Id") DO UPDATE SET + "SourceKey" = excluded."SourceKey", + "SourceJson" = excluded."SourceJson", + "ContentJson" = excluded."ContentJson", + "Text" = excluded."Text", + "Stage" = excluded."Stage", + "Reason" = excluded."Reason", + "Kw" = excluded."Kw", + "Source" = excluded."Source", + "MsgAt" = excluded."MsgAt", + "RejectedAt" = excluded."RejectedAt" + """, + ct); + } + + /// + public async Task> ListPageAsync( + int offset, + int limit, + CancellationToken ct) + { + List entities = await dbContext.RejectedItems + .AsNoTracking() + .OrderByDescending(item => item.RejectedAt) + .Skip(offset) + .Take(limit) + .ToListAsync(ct); + return entities.Select(ToRejectedItemDto).ToList(); + } + + /// + public async Task> SearchAsync( + string q, + int limitFts, + int limitLike, + CancellationToken ct) + { + string query = q.Trim(); + if (query.Length == 0) + { + return Array.Empty(); + } + + List fts = await dbContext.RejectedItems + .FromSqlInterpolated( + $""" + SELECT * FROM "RejectedItems" + WHERE "SearchTsv" @@ plainto_tsquery('russian', {query}) + ORDER BY ts_rank("SearchTsv", plainto_tsquery('russian', {query})) DESC, "RejectedAt" DESC + LIMIT {limitFts} + """) + .AsNoTracking() + .ToListAsync(ct); + + var rows = fts.Select(ToRejectedItemDto).ToList(); + var seen = new HashSet(fts.Select(item => item.Id), StringComparer.Ordinal); + + string pattern = $"%{query}%"; + List like = await dbContext.RejectedItems + .AsNoTracking() + .Where(item => EF.Functions.Like(item.Text.ToLower(), pattern) + || EF.Functions.Like(item.Reason.ToLower(), pattern) + || EF.Functions.Like(item.Kw.ToLower(), pattern)) + .OrderByDescending(item => item.RejectedAt) + .Take(limitLike) + .ToListAsync(ct); + + foreach (RejectedItemEntity entity in like) + { + if (seen.Add(entity.Id)) + { + rows.Add(ToRejectedItemDto(entity)); + } + } + + return rows; + } + + /// + public Task CountAsync(CancellationToken ct) => dbContext.RejectedItems.CountAsync(ct); + + /// + public async Task GetAsync(string id, CancellationToken ct) + { + RejectedItemEntity? entity = await dbContext.RejectedItems + .AsNoTracking() + .SingleOrDefaultAsync(item => item.Id == id, ct); + return entity is null ? null : ToRejectedItemDto(entity); + } + + /// + public async Task DeleteAsync(string id, CancellationToken ct) + { + await dbContext.RejectedItems + .Where(item => item.Id == id) + .ExecuteDeleteAsync(ct); + } + + /// + public async Task ClearAsync(CancellationToken ct) + { + return await dbContext.RejectedItems.ExecuteDeleteAsync(ct); + } + + /// + public async Task PurgeExpiredAsync(DateTimeOffset olderThan, CancellationToken ct) + { + return await dbContext.RejectedItems + .Where(item => item.RejectedAt < olderThan) + .ExecuteDeleteAsync(ct); + } + + /// + public async Task MarkReturnedAsync( + string id, + string reason, + DateTimeOffset returnedAt, + CancellationToken ct) + { + await dbContext.RejectedItems + .Where(item => item.Id == id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(item => item.Returned, true) + .SetProperty(item => item.ReturnedAt, returnedAt) + .SetProperty(item => item.ReturnReason, reason), + ct); + } + + // ── Дедуп (DedupEntries) ────────────────────────────────────────────── + + /// + public Task ExistsAsync(string hash, CancellationToken ct) + => dbContext.DedupEntries.AnyAsync(entry => entry.Hash == hash, ct); + + /// + public async Task ClaimAsync(string hash, CancellationToken ct) + { + int inserted = await dbContext.Database.ExecuteSqlInterpolatedAsync( + $""" + INSERT INTO "DedupEntries" ("Hash", "LeadId", "CreatedAt") + VALUES ({hash}, NULL, {DateTimeOffset.UtcNow}) + ON CONFLICT ("Hash") DO NOTHING + """, + ct); + return inserted == 1; + } + + /// + public async Task DeleteClaimAsync(string hash, CancellationToken ct) + { + await dbContext.DedupEntries + .Where(entry => entry.Hash == hash && entry.LeadId == null) + .ExecuteDeleteAsync(ct); + } + + /// + public async Task LinkAsync( + string hash, + string cardId, + CancellationToken ct) + { + await dbContext.DedupEntries + .Where(entry => entry.Hash == hash) + .ExecuteUpdateAsync(setters => setters.SetProperty(entry => entry.LeadId, cardId), ct); + } + + /// + public async Task DeleteByCardAsync(string cardId, CancellationToken ct) + { + await dbContext.DedupEntries + .Where(entry => entry.LeadId == cardId) + .ExecuteDeleteAsync(ct); + } + + // ── Маппинг (вручную: порт не видит EF-сущности) ─────────────────────── + + private static QueueItemDto ToQueueItemDto(QueueItemEntity entity) => new() + { + Id = entity.Id, + Source = FromJsonOr(entity.SourceJson, SourceRefs.Empty), + Content = FromJsonOr(entity.ContentJson, new SourceContent()), + Text = entity.Text, + Status = entity.Status, + MsgAtMs = entity.MsgAt.ToUnixTimeMilliseconds(), + QueuedAtMs = entity.CreatedAt.ToUnixTimeMilliseconds(), + Force = entity.Force, + }; + + // DTO очереди → строка QueueItems (CreatedAt = UpdatedAt = QueuedAtMs, задаёт модуль). + private static QueueItemEntity ToQueueItemEntity(QueueItemDto item) + { + DateTimeOffset queuedAt = FromEpochMs(item.QueuedAtMs); + return new QueueItemEntity + { + Id = item.Id, + SourceKey = item.Source.DedupeKey(), + SourceJson = ToJson(item.Source), + ContentJson = ToJson(item.Content), + Text = item.Text, + Status = item.Status, + MsgAt = FromEpochMs(item.MsgAtMs), + CreatedAt = queuedAt, + UpdatedAt = queuedAt, + Force = item.Force, + }; + } + + private static RejectedItemDto ToRejectedItemDto(RejectedItemEntity entity) => new() + { + Id = entity.Id, + Source = FromJsonOr(entity.SourceJson, SourceRefs.Empty), + Content = FromJsonOr(entity.ContentJson, new SourceContent()), + Text = entity.Text, + Stage = entity.Stage, + StageLabel = PipelineRejectConstants.StageLabel(entity.Stage), + Reason = entity.Reason, + Kw = entity.Kw, + DecidedBy = entity.Source, + DecidedByLabel = PipelineRejectConstants.SourceLabel(entity.Source), + MsgAtMs = entity.MsgAt.ToUnixTimeMilliseconds(), + RejectedAtMs = entity.RejectedAt.ToUnixTimeMilliseconds(), + Returned = entity.Returned, + ReturnedAtMs = entity.ReturnedAt?.ToUnixTimeMilliseconds(), + ReturnReason = entity.ReturnReason, + }; + + // epoch-ms → DateTimeOffset (UTC-момент для timestamptz-колонки). + private static DateTimeOffset FromEpochMs(long epochMs) => DateTimeOffset.FromUnixTimeMilliseconds(epochMs); + + // Сериализует значение в JSON (camelCase, конвенция value_json). + private static string ToJson(T value) => JsonSerializer.Serialize(value, JsonOptions); + + // Разбирает одиночный JSON-объект; пустая/битая строка — запасное значение. + private static T FromJsonOr(string json, T fallback) + { + if (string.IsNullOrWhiteSpace(json)) + { + return fallback; + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions) ?? fallback; + } + catch (JsonException) + { + return fallback; + } + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs index aee05ca..3eacab5 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs @@ -1,199 +1,199 @@ -using System.Data; -using System.Data.Common; -using System.Globalization; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application.Abstractions; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер распределённого счётчика фиксированного окна -/// -public sealed class RateLimitCounterStore(DealDbContext dbContext) : IRateLimitCounterStore -{ - // Атомарный upsert счётчика окна на Postgres (INSERT … ON CONFLICT … RETURNING). - // Выполняется raw-командой (а не SqlQuery): EF запрещает композицию по не-SELECT SQL, а RETURNING - // из INSERT не является SELECT-запросом. Одиночный INSERT … ON CONFLICT атомарен на уровне БД — - // параллельные инкременты одного ключа не теряются. - private const string NpgsqlIncrementSql = """ - INSERT INTO public.rate_limit_counters ("Key", "WindowStart", "ExpiresAt", "Count") - VALUES (@key, @windowStart, @windowEnd, @amount) - ON CONFLICT ("Key") DO UPDATE - SET "Count" = CASE - WHEN public.rate_limit_counters."WindowStart" = EXCLUDED."WindowStart" - THEN public.rate_limit_counters."Count" + EXCLUDED."Count" - ELSE EXCLUDED."Count" - END, - "WindowStart" = EXCLUDED."WindowStart", - "ExpiresAt" = EXCLUDED."ExpiresAt" - RETURNING "Count"; - """; - - /// - public async Task IncrementAsync( - string key, - DateTimeOffset windowStart, - DateTimeOffset windowEnd, - int amount, - CancellationToken ct) - { - ArgumentException.ThrowIfNullOrEmpty(key); - ArgumentOutOfRangeException.ThrowIfNegative(amount); - - if (dbContext.Database.IsNpgsql()) - { - return await IncrementNpgsqlAsync(key, windowStart, windowEnd, amount, ct); - } - - // InMemory-провайдер (тесты): тот же результат read-modify-write отслеживаемой строки. - RateLimitCounterEntity? entry = await dbContext.RateLimitCounters - .SingleOrDefaultAsync(x => x.Key == key, ct); - if (entry is null) - { - entry = new RateLimitCounterEntity - { - Key = key, - WindowStart = windowStart, - ExpiresAt = windowEnd, - Count = amount, - }; - dbContext.RateLimitCounters.Add(entry); - } - else if (entry.WindowStart == windowStart) - { - entry.Count += amount; - entry.ExpiresAt = windowEnd; - } - else - { - entry.WindowStart = windowStart; - entry.ExpiresAt = windowEnd; - entry.Count = amount; - } - - await dbContext.SaveChangesAsync(ct); - return entry.Count; - } - - // Атомарный инкремент окна raw-командой на Postgres (INSERT … ON CONFLICT … RETURNING). - // key: Ключ счётчика. - // windowStart: Начало окна (UTC). - // windowEnd: Конец окна (UTC). - // amount: Величина приращения. - // ct: Токен отмены. - // Возвращает: Значение счётчика после операции. - private async Task IncrementNpgsqlAsync( - string key, - DateTimeOffset windowStart, - DateTimeOffset windowEnd, - int amount, - CancellationToken ct) - { - DbConnection connection = dbContext.Database.GetDbConnection(); - bool openedHere = connection.State != ConnectionState.Open; - if (openedHere) - { - await connection.OpenAsync(ct); - } - - try - { - await using DbCommand command = connection.CreateCommand(); - command.CommandText = NpgsqlIncrementSql; - AddParameter(command, "@key", key); - AddParameter(command, "@windowStart", windowStart); - AddParameter(command, "@windowEnd", windowEnd); - AddParameter(command, "@amount", amount); - object? result = await command.ExecuteScalarAsync(ct); - return Convert.ToInt32(result, CultureInfo.InvariantCulture); - } - finally - { - if (openedHere) - { - await connection.CloseAsync(); - } - } - } - - // Добавляет параметр команды (провайдер-независимо, через DbParameter). - // command: Команда upsert. - // name: Имя параметра (с префиксом @). - // value: Значение. - private static void AddParameter( - DbCommand command, - string name, - object value) - { - DbParameter parameter = command.CreateParameter(); - parameter.ParameterName = name; - parameter.Value = value; - command.Parameters.Add(parameter); - } - - /// - public async Task GetCountAsync( - string key, - DateTimeOffset windowStart, - CancellationToken ct) - { - ArgumentException.ThrowIfNullOrEmpty(key); - - if (dbContext.Database.IsNpgsql()) - { - return await dbContext.Database - .SqlQuery($""" - SELECT "Count" AS "Value" FROM public.rate_limit_counters - WHERE "Key" = {key} AND "WindowStart" = {windowStart} - """) - .SingleOrDefaultAsync(ct); - } - - RateLimitCounterEntity? entry = await dbContext.RateLimitCounters - .AsNoTracking() - .SingleOrDefaultAsync(x => x.Key == key, ct); - return entry is not null && entry.WindowStart == windowStart ? entry.Count : 0; - } - - /// - public async Task ResetAsync(string key, CancellationToken ct) - { - ArgumentException.ThrowIfNullOrEmpty(key); - - if (dbContext.Database.IsNpgsql()) - { - await dbContext.RateLimitCounters.Where(x => x.Key == key).ExecuteDeleteAsync(ct); - return; - } - - RateLimitCounterEntity? entry = await dbContext.RateLimitCounters - .SingleOrDefaultAsync(x => x.Key == key, ct); - if (entry is not null) - { - dbContext.RateLimitCounters.Remove(entry); - await dbContext.SaveChangesAsync(ct); - } - } - - /// - public async Task DeleteExpiredAsync(DateTimeOffset now, CancellationToken ct) - { - if (dbContext.Database.IsNpgsql()) - { - return await dbContext.RateLimitCounters.Where(x => x.ExpiresAt < now).ExecuteDeleteAsync(ct); - } - - List expired = await dbContext.RateLimitCounters - .Where(x => x.ExpiresAt < now) - .ToListAsync(ct); - if (expired.Count == 0) - { - return 0; - } - - dbContext.RateLimitCounters.RemoveRange(expired); - await dbContext.SaveChangesAsync(ct); - return expired.Count; - } -} +using System.Data; +using System.Data.Common; +using System.Globalization; +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Tenants.Application.Abstractions; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер распределённого счётчика фиксированного окна +/// +public sealed class RateLimitCounterStore(DealDbContext dbContext) : IRateLimitCounterStore +{ + // Атомарный upsert счётчика окна на Postgres (INSERT … ON CONFLICT … RETURNING). + // Выполняется raw-командой (а не SqlQuery): EF запрещает композицию по не-SELECT SQL, а RETURNING + // из INSERT не является SELECT-запросом. Одиночный INSERT … ON CONFLICT атомарен на уровне БД — + // параллельные инкременты одного ключа не теряются. + private const string NpgsqlIncrementSql = """ + INSERT INTO public.rate_limit_counters ("Key", "WindowStart", "ExpiresAt", "Count") + VALUES (@key, @windowStart, @windowEnd, @amount) + ON CONFLICT ("Key") DO UPDATE + SET "Count" = CASE + WHEN public.rate_limit_counters."WindowStart" = EXCLUDED."WindowStart" + THEN public.rate_limit_counters."Count" + EXCLUDED."Count" + ELSE EXCLUDED."Count" + END, + "WindowStart" = EXCLUDED."WindowStart", + "ExpiresAt" = EXCLUDED."ExpiresAt" + RETURNING "Count"; + """; + + /// + public async Task IncrementAsync( + string key, + DateTimeOffset windowStart, + DateTimeOffset windowEnd, + int amount, + CancellationToken ct) + { + ArgumentException.ThrowIfNullOrEmpty(key); + ArgumentOutOfRangeException.ThrowIfNegative(amount); + + if (dbContext.Database.IsNpgsql()) + { + return await IncrementNpgsqlAsync(key, windowStart, windowEnd, amount, ct); + } + + // InMemory-провайдер (тесты): тот же результат read-modify-write отслеживаемой строки. + RateLimitCounterEntity? entry = await dbContext.RateLimitCounters + .SingleOrDefaultAsync(x => x.Key == key, ct); + if (entry is null) + { + entry = new RateLimitCounterEntity + { + Key = key, + WindowStart = windowStart, + ExpiresAt = windowEnd, + Count = amount, + }; + dbContext.RateLimitCounters.Add(entry); + } + else if (entry.WindowStart == windowStart) + { + entry.Count += amount; + entry.ExpiresAt = windowEnd; + } + else + { + entry.WindowStart = windowStart; + entry.ExpiresAt = windowEnd; + entry.Count = amount; + } + + await dbContext.SaveChangesAsync(ct); + return entry.Count; + } + + // Атомарный инкремент окна raw-командой на Postgres (INSERT … ON CONFLICT … RETURNING). + // key: Ключ счётчика. + // windowStart: Начало окна (UTC). + // windowEnd: Конец окна (UTC). + // amount: Величина приращения. + // ct: Токен отмены. + // Возвращает: Значение счётчика после операции. + private async Task IncrementNpgsqlAsync( + string key, + DateTimeOffset windowStart, + DateTimeOffset windowEnd, + int amount, + CancellationToken ct) + { + DbConnection connection = dbContext.Database.GetDbConnection(); + bool openedHere = connection.State != ConnectionState.Open; + if (openedHere) + { + await connection.OpenAsync(ct); + } + + try + { + await using DbCommand command = connection.CreateCommand(); + command.CommandText = NpgsqlIncrementSql; + AddParameter(command, "@key", key); + AddParameter(command, "@windowStart", windowStart); + AddParameter(command, "@windowEnd", windowEnd); + AddParameter(command, "@amount", amount); + object? result = await command.ExecuteScalarAsync(ct); + return Convert.ToInt32(result, CultureInfo.InvariantCulture); + } + finally + { + if (openedHere) + { + await connection.CloseAsync(); + } + } + } + + // Добавляет параметр команды (провайдер-независимо, через DbParameter). + // command: Команда upsert. + // name: Имя параметра (с префиксом @). + // value: Значение. + private static void AddParameter( + DbCommand command, + string name, + object value) + { + DbParameter parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value; + command.Parameters.Add(parameter); + } + + /// + public async Task GetCountAsync( + string key, + DateTimeOffset windowStart, + CancellationToken ct) + { + ArgumentException.ThrowIfNullOrEmpty(key); + + if (dbContext.Database.IsNpgsql()) + { + return await dbContext.Database + .SqlQuery($""" + SELECT "Count" AS "Value" FROM public.rate_limit_counters + WHERE "Key" = {key} AND "WindowStart" = {windowStart} + """) + .SingleOrDefaultAsync(ct); + } + + RateLimitCounterEntity? entry = await dbContext.RateLimitCounters + .AsNoTracking() + .SingleOrDefaultAsync(x => x.Key == key, ct); + return entry is not null && entry.WindowStart == windowStart ? entry.Count : 0; + } + + /// + public async Task ResetAsync(string key, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrEmpty(key); + + if (dbContext.Database.IsNpgsql()) + { + await dbContext.RateLimitCounters.Where(x => x.Key == key).ExecuteDeleteAsync(ct); + return; + } + + RateLimitCounterEntity? entry = await dbContext.RateLimitCounters + .SingleOrDefaultAsync(x => x.Key == key, ct); + if (entry is not null) + { + dbContext.RateLimitCounters.Remove(entry); + await dbContext.SaveChangesAsync(ct); + } + } + + /// + public async Task DeleteExpiredAsync(DateTimeOffset now, CancellationToken ct) + { + if (dbContext.Database.IsNpgsql()) + { + return await dbContext.RateLimitCounters.Where(x => x.ExpiresAt < now).ExecuteDeleteAsync(ct); + } + + List expired = await dbContext.RateLimitCounters + .Where(x => x.ExpiresAt < now) + .ToListAsync(ct); + if (expired.Count == 0) + { + return 0; + } + + dbContext.RateLimitCounters.RemoveRange(expired); + await dbContext.SaveChangesAsync(ct); + return expired.Count; + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs index aab51a1..5420ab8 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs @@ -1,71 +1,71 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер KV-хранилища настроек тенанта -/// -public sealed class SettingsStore(TenantDbContext dbContext) : ISettingsStore -{ - /// - public async Task GetAsync(string key, CancellationToken ct) - { - var entity = await dbContext.Settings - .AsNoTracking() - .SingleOrDefaultAsync(s => s.Key == key, ct); - return entity is null ? null : ToSettingValue(entity); - } - - /// - public async Task> GetAllAsync(CancellationToken ct) - { - var entities = await dbContext.Settings - .AsNoTracking() - .OrderBy(s => s.Key) - .Select(s => ToSettingValue(s)) - .ToListAsync(ct); - return entities; - } - - /// - public async Task SetAsync( - string key, - string valueJson, - CancellationToken ct) - { - // Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется. - TenantSettingEntity? entity = await dbContext.Settings - .SingleOrDefaultAsync(s => s.Key == key, ct); - if (entity is null) - { - dbContext.Settings.Add(new TenantSettingEntity - { - Key = key, - ValueJson = valueJson, - UpdatedAt = DateTimeOffset.UtcNow, - }); - } - else - { - entity.ValueJson = valueJson; - entity.UpdatedAt = DateTimeOffset.UtcNow; - } - - await dbContext.SaveChangesAsync(ct); - } - - /// - public async Task RemoveAsync(string key, CancellationToken ct) - { - // ExecuteDelete удаляет существующую строку; при отсутствии ключа — no-op. - await dbContext.Settings - .Where(s => s.Key == key) - .ExecuteDeleteAsync(ct); - } - - private static SettingValue ToSettingValue(TenantSettingEntity entity) => - new(entity.Key, entity.ValueJson, entity.UpdatedAt); -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер KV-хранилища настроек тенанта +/// +public sealed class SettingsStore(TenantDbContext dbContext) : ISettingsStore +{ + /// + public async Task GetAsync(string key, CancellationToken ct) + { + var entity = await dbContext.Settings + .AsNoTracking() + .SingleOrDefaultAsync(s => s.Key == key, ct); + return entity is null ? null : ToSettingValue(entity); + } + + /// + public async Task> GetAllAsync(CancellationToken ct) + { + var entities = await dbContext.Settings + .AsNoTracking() + .OrderBy(s => s.Key) + .Select(s => ToSettingValue(s)) + .ToListAsync(ct); + return entities; + } + + /// + public async Task SetAsync( + string key, + string valueJson, + CancellationToken ct) + { + // Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется. + TenantSettingEntity? entity = await dbContext.Settings + .SingleOrDefaultAsync(s => s.Key == key, ct); + if (entity is null) + { + dbContext.Settings.Add(new TenantSettingEntity + { + Key = key, + ValueJson = valueJson, + UpdatedAt = DateTimeOffset.UtcNow, + }); + } + else + { + entity.ValueJson = valueJson; + entity.UpdatedAt = DateTimeOffset.UtcNow; + } + + await dbContext.SaveChangesAsync(ct); + } + + /// + public async Task RemoveAsync(string key, CancellationToken ct) + { + // ExecuteDelete удаляет существующую строку; при отсутствии ключа — no-op. + await dbContext.Settings + .Where(s => s.Key == key) + .ExecuteDeleteAsync(ct); + } + + private static SettingValue ToSettingValue(TenantSettingEntity entity) => + new(entity.Key, entity.ValueJson, entity.UpdatedAt); +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/TelegramStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/TelegramStore.cs index 617088f..c6f286e 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/TelegramStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/TelegramStore.cs @@ -1,241 +1,241 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Telegram.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища каталога диалогов -/// -/// Scoped-контекст тенанта запроса (search_path). -public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore -{ - /// - public async Task SyncFromTelegramAsync( - IReadOnlyCollection entries, - bool autoMonitorNew, - CancellationToken ct) - { - if (entries.Count == 0) - { - return 0; - } - - string[] ids = entries.Select(entry => entry.Id).ToArray(); - Dictionary existing = await dbContext.Dialogs - .Where(dialog => ids.Contains(dialog.Id)) - .ToDictionaryAsync(dialog => dialog.Id, ct); - - DateTimeOffset now = DateTimeOffset.UtcNow; - foreach (TelegramDialogEntryDto entry in entries) - { - if (existing.TryGetValue(entry.Id, out DialogEntity? row)) - { - row.Name = entry.Name; - row.Handle = entry.Handle; - row.Kind = entry.Kind; - row.Hue = entry.Hue; - row.UpdatedAt = now; - } - else - { - dbContext.Dialogs.Add(new DialogEntity - { - Id = entry.Id, - Name = entry.Name, - Handle = entry.Handle, - Kind = entry.Kind, - Hue = entry.Hue, - Monitor = autoMonitorNew, - UpdatedAt = now, - }); - } - } - - List stale = await dbContext.Dialogs - .Where(dialog => !ids.Contains(dialog.Id)) - .ToListAsync(ct); - dbContext.Dialogs.RemoveRange(stale); - - await dbContext.SaveChangesAsync(ct); - return entries.Count; - } - - /// - public async Task> ListAsync(CancellationToken ct) - { - List rows = await dbContext.Dialogs - .OrderByDescending(dialog => dialog.Monitor) - .ThenBy(dialog => dialog.Name) - .ToListAsync(ct); - return rows.Select(ToDialogDto).ToList(); - } - - /// - public async Task> ListMonitoredIdsAsync(CancellationToken ct) - { - return await dbContext.Dialogs - .Where(dialog => dialog.Monitor) - .OrderBy(dialog => dialog.Id) - .Select(dialog => dialog.Id) - .ToListAsync(ct); - } - - /// - public Task SetMonitorAsync( - string dialogId, - bool enabled, - CancellationToken ct) - { - return dbContext.Dialogs - .Where(dialog => dialog.Id == dialogId) - .ExecuteUpdateAsync( - setters => setters - .SetProperty(dialog => dialog.Monitor, enabled) - .SetProperty(dialog => dialog.UpdatedAt, DateTimeOffset.UtcNow), - ct); - } - - /// - public async Task SetMonitorAllAsync(bool enabled, CancellationToken ct) - { - int count = await dbContext.Dialogs.CountAsync(ct); - await dbContext.Dialogs.ExecuteUpdateAsync( - setters => setters - .SetProperty(dialog => dialog.Monitor, enabled) - .SetProperty(dialog => dialog.UpdatedAt, DateTimeOffset.UtcNow), - ct); - return count; - } - - /// - public async Task GetBackfilledAsync(string dialogId, CancellationToken ct) - { - return await dbContext.Dialogs - .Where(dialog => dialog.Id == dialogId) - .Select(dialog => (bool?)dialog.Backfilled) - .FirstOrDefaultAsync(ct); - } - - /// - public async Task> ListNotBackfilledIdsAsync(CancellationToken ct) - { - return await dbContext.Dialogs - .Where(dialog => !dialog.Backfilled) - .OrderBy(dialog => dialog.Id) - .Select(dialog => dialog.Id) - .ToListAsync(ct); - } - - /// - public Task SetBackfilledAsync(string dialogId, CancellationToken ct) - { - return dbContext.Dialogs - .Where(dialog => dialog.Id == dialogId) - .ExecuteUpdateAsync(setters => setters.SetProperty(dialog => dialog.Backfilled, true), ct); - } - - /// - public async Task UpsertDiscoveredMonitoredAsync( - string dialogId, - string name, - string handle, - string kind, - string hue, - CancellationToken ct) - { - DialogEntity? existing = await dbContext.Dialogs.FirstOrDefaultAsync(dialog => dialog.Id == dialogId, ct); - DateTimeOffset now = DateTimeOffset.UtcNow; - if (existing is null) - { - dbContext.Dialogs.Add(new DialogEntity - { - Id = dialogId, - Name = name, - Handle = handle, - Kind = kind, - Hue = hue, - Monitor = true, - Backfilled = false, - UpdatedAt = now, - }); - } - else - { - existing.Name = name; - existing.Handle = handle; - existing.Kind = kind; - existing.Hue = hue; - existing.Monitor = true; - existing.Backfilled = false; - existing.UpdatedAt = now; - } - - await dbContext.SaveChangesAsync(ct); - } - - /// - public async Task SavePreviewAsync( - string messageId, - string dialogId, - string text, - DateTimeOffset msgAt, - CancellationToken ct) - { - bool exists = await dbContext.TgMessages.AnyAsync(message => message.Id == messageId, ct); - if (exists) - { - return; - } - - dbContext.TgMessages.Add(new TgMessageEntity - { - Id = messageId, - DialogId = dialogId, - Text = text, - MsgAt = msgAt, - }); - await dbContext.SaveChangesAsync(ct); - } - - /// - public Task TouchDialogLastAsync( - string dialogId, - string text, - DateTimeOffset at, - CancellationToken ct) - { - return dbContext.Dialogs - .Where(dialog => dialog.Id == dialogId) - .ExecuteUpdateAsync( - setters => setters - .SetProperty(dialog => dialog.LastText, text) - .SetProperty(dialog => dialog.LastAt, at) - .SetProperty(dialog => dialog.UpdatedAt, at), - ct); - } - - /// - public async Task> ListMessagesAsync( - string dialogId, - int limit, - CancellationToken ct) - { - List rows = await dbContext.TgMessages - .Where(message => message.DialogId == dialogId) - .OrderByDescending(message => message.MsgAt) - .Take(limit) - .ToListAsync(ct); - return rows.Select(row => new TelegramMessageDto( - row.Id, row.Text, row.MsgAt.ToUnixTimeMilliseconds(), row.LeadId is not null)).ToList(); - } - - private static TelegramDialogDto ToDialogDto(DialogEntity row) - { - long? lastAtMs = row.LastAt?.ToUnixTimeMilliseconds(); - TelegramDialogLastDto? last = lastAtMs is null ? null : new TelegramDialogLastDto(row.LastText, lastAtMs); - return new TelegramDialogDto(row.Id, row.Name, row.Handle, row.Kind, row.Hue, row.Monitor, last); - } -} +using Deal.Contracts.Integrations.Models; +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Telegram.Application; +using Deal.Modules.Telegram.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер хранилища каталога диалогов +/// +/// Scoped-контекст тенанта запроса (search_path). +public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore +{ + /// + public async Task SyncFromTelegramAsync( + IReadOnlyCollection entries, + bool autoMonitorNew, + CancellationToken ct) + { + if (entries.Count == 0) + { + return 0; + } + + string[] ids = entries.Select(entry => entry.Id).ToArray(); + Dictionary existing = await dbContext.Dialogs + .Where(dialog => ids.Contains(dialog.Id)) + .ToDictionaryAsync(dialog => dialog.Id, ct); + + DateTimeOffset now = DateTimeOffset.UtcNow; + foreach (TelegramDialogEntryDto entry in entries) + { + if (existing.TryGetValue(entry.Id, out DialogEntity? row)) + { + row.Name = entry.Name; + row.Handle = entry.Handle; + row.Kind = entry.Kind; + row.Hue = entry.Hue; + row.UpdatedAt = now; + } + else + { + dbContext.Dialogs.Add(new DialogEntity + { + Id = entry.Id, + Name = entry.Name, + Handle = entry.Handle, + Kind = entry.Kind, + Hue = entry.Hue, + Monitor = autoMonitorNew, + UpdatedAt = now, + }); + } + } + + List stale = await dbContext.Dialogs + .Where(dialog => !ids.Contains(dialog.Id)) + .ToListAsync(ct); + dbContext.Dialogs.RemoveRange(stale); + + await dbContext.SaveChangesAsync(ct); + return entries.Count; + } + + /// + public async Task> ListAsync(CancellationToken ct) + { + List rows = await dbContext.Dialogs + .OrderByDescending(dialog => dialog.Monitor) + .ThenBy(dialog => dialog.Name) + .ToListAsync(ct); + return rows.Select(ToDialogDto).ToList(); + } + + /// + public async Task> ListMonitoredIdsAsync(CancellationToken ct) + { + return await dbContext.Dialogs + .Where(dialog => dialog.Monitor) + .OrderBy(dialog => dialog.Id) + .Select(dialog => dialog.Id) + .ToListAsync(ct); + } + + /// + public Task SetMonitorAsync( + string dialogId, + bool enabled, + CancellationToken ct) + { + return dbContext.Dialogs + .Where(dialog => dialog.Id == dialogId) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(dialog => dialog.Monitor, enabled) + .SetProperty(dialog => dialog.UpdatedAt, DateTimeOffset.UtcNow), + ct); + } + + /// + public async Task SetMonitorAllAsync(bool enabled, CancellationToken ct) + { + int count = await dbContext.Dialogs.CountAsync(ct); + await dbContext.Dialogs.ExecuteUpdateAsync( + setters => setters + .SetProperty(dialog => dialog.Monitor, enabled) + .SetProperty(dialog => dialog.UpdatedAt, DateTimeOffset.UtcNow), + ct); + return count; + } + + /// + public async Task GetBackfilledAsync(string dialogId, CancellationToken ct) + { + return await dbContext.Dialogs + .Where(dialog => dialog.Id == dialogId) + .Select(dialog => (bool?)dialog.Backfilled) + .FirstOrDefaultAsync(ct); + } + + /// + public async Task> ListNotBackfilledIdsAsync(CancellationToken ct) + { + return await dbContext.Dialogs + .Where(dialog => !dialog.Backfilled) + .OrderBy(dialog => dialog.Id) + .Select(dialog => dialog.Id) + .ToListAsync(ct); + } + + /// + public Task SetBackfilledAsync(string dialogId, CancellationToken ct) + { + return dbContext.Dialogs + .Where(dialog => dialog.Id == dialogId) + .ExecuteUpdateAsync(setters => setters.SetProperty(dialog => dialog.Backfilled, true), ct); + } + + /// + public async Task UpsertDiscoveredMonitoredAsync( + string dialogId, + string name, + string handle, + string kind, + string hue, + CancellationToken ct) + { + DialogEntity? existing = await dbContext.Dialogs.FirstOrDefaultAsync(dialog => dialog.Id == dialogId, ct); + DateTimeOffset now = DateTimeOffset.UtcNow; + if (existing is null) + { + dbContext.Dialogs.Add(new DialogEntity + { + Id = dialogId, + Name = name, + Handle = handle, + Kind = kind, + Hue = hue, + Monitor = true, + Backfilled = false, + UpdatedAt = now, + }); + } + else + { + existing.Name = name; + existing.Handle = handle; + existing.Kind = kind; + existing.Hue = hue; + existing.Monitor = true; + existing.Backfilled = false; + existing.UpdatedAt = now; + } + + await dbContext.SaveChangesAsync(ct); + } + + /// + public async Task SavePreviewAsync( + string messageId, + string dialogId, + string text, + DateTimeOffset msgAt, + CancellationToken ct) + { + bool exists = await dbContext.TgMessages.AnyAsync(message => message.Id == messageId, ct); + if (exists) + { + return; + } + + dbContext.TgMessages.Add(new TgMessageEntity + { + Id = messageId, + DialogId = dialogId, + Text = text, + MsgAt = msgAt, + }); + await dbContext.SaveChangesAsync(ct); + } + + /// + public Task TouchDialogLastAsync( + string dialogId, + string text, + DateTimeOffset at, + CancellationToken ct) + { + return dbContext.Dialogs + .Where(dialog => dialog.Id == dialogId) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(dialog => dialog.LastText, text) + .SetProperty(dialog => dialog.LastAt, at) + .SetProperty(dialog => dialog.UpdatedAt, at), + ct); + } + + /// + public async Task> ListMessagesAsync( + string dialogId, + int limit, + CancellationToken ct) + { + List rows = await dbContext.TgMessages + .Where(message => message.DialogId == dialogId) + .OrderByDescending(message => message.MsgAt) + .Take(limit) + .ToListAsync(ct); + return rows.Select(row => new TelegramMessageDto( + row.Id, row.Text, row.MsgAt.ToUnixTimeMilliseconds(), row.LeadId is not null)).ToList(); + } + + private static TelegramDialogDto ToDialogDto(DialogEntity row) + { + long? lastAtMs = row.LastAt?.ToUnixTimeMilliseconds(); + TelegramDialogLastDto? last = lastAtMs is null ? null : new TelegramDialogLastDto(row.LastText, lastAtMs); + return new TelegramDialogDto(row.Id, row.Name, row.Handle, row.Kind, row.Hue, row.Monitor, last); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/TenantLimitStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/TenantLimitStore.cs index 7da6eeb..ec32257 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/TenantLimitStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/TenantLimitStore.cs @@ -1,277 +1,277 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища лимитов ИИ-бюджета -/// -public sealed class TenantLimitStore : ITenantLimitStore -{ - private readonly DealDbContext _dbContext; - private readonly TokenLimitDefaults _defaults; - private readonly TokenBudgetService _budgetService; - private readonly Func _utcNow; - - /// - /// Создаёт адаптер с дефолт-бюджетом модуля - /// - /// Системный контекст (public-схема). - public TenantLimitStore(DealDbContext dbContext) - : this(dbContext, TokenBudgetDefaults.Default, new TokenBudgetService(), () => DateTimeOffset.UtcNow) - { - } - - /// - /// Создаёт адаптер с дефолт-бюджетом из конфигурации - /// - /// Системный контекст (public-схема). - /// Дефолт-параметры лениво создаваемой строки. - public TenantLimitStore(DealDbContext dbContext, TokenLimitDefaults defaults) - : this(dbContext, defaults, new TokenBudgetService(), () => DateTimeOffset.UtcNow) - { - } - - /// - /// Создаёт адаптер с явными зависимостями - /// - /// Системный контекст (public-схема). - /// Дефолт-параметры лениво создаваемой строки. - /// Период-математика (reset/пороги). - /// Источник текущего времени (UTC). - public TenantLimitStore( - DealDbContext dbContext, - TokenLimitDefaults defaults, - TokenBudgetService budgetService, - Func utcNow) - { - ArgumentNullException.ThrowIfNull(dbContext); - ArgumentNullException.ThrowIfNull(defaults); - ArgumentNullException.ThrowIfNull(budgetService); - ArgumentNullException.ThrowIfNull(utcNow); - _dbContext = dbContext; - _defaults = defaults; - _budgetService = budgetService; - _utcNow = utcNow; - } - - /// - public async Task GetOrCreateAsync( - Guid tenantId, - CancellationToken ct, - TokenLimitDefaults? defaults = null) - { - TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, defaults ?? _defaults, ct); - return ToLimitDto(entity); - } - - /// - public async Task GetStateAsync(Guid tenantId, CancellationToken ct) - { - TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct); - await ResetIfPeriodExpiredAsync(entity, ct); - return await ToStateDtoAsync(entity, ct); - } - - /// - public async Task AddUsageAsync( - Guid tenantId, - long tokens, - CancellationToken ct) - { - TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct); - await ResetIfPeriodExpiredAsync(entity, ct); - if (tokens > 0) - { - if (_dbContext.Database.IsNpgsql()) - { - // Атомарный инкремент одним UPDATE (Security review A12): параллельные списания одного тенанта - // (пользовательские ИИ-вызовы) не теряют токены — read-modify-write ниже неатомарен под гонкой. - var now = _utcNow(); - await _dbContext.Database.ExecuteSqlInterpolatedAsync( - $"UPDATE public.tenant_limits SET \"UsedTokens\" = \"UsedTokens\" + {tokens}, \"UpdatedAt\" = {now} WHERE \"TenantId\" = {tenantId}", - ct); - // Синхронизировать отслеживаемую сущность с БД (ToStateDtoAsync читает её поля). - await _dbContext.Entry(entity).ReloadAsync(ct); - } - else - { - // InMemory-провайдер (юнит-тесты) не исполняет raw SQL — EF read-modify-write (семантика та же). - entity.UsedTokens += tokens; - entity.UpdatedAt = _utcNow(); - await _dbContext.SaveChangesAsync(ct); - } - } - - return await ToStateDtoAsync(entity, ct); - } - - /// - public async Task UpdateBudgetAsync( - Guid tenantId, - long budgetTokens, - string period, - CancellationToken ct) - { - ArgumentOutOfRangeException.ThrowIfNegative(budgetTokens); - if (period != TenantLimitPeriods.Month && period != TenantLimitPeriods.Day) - { - throw new ArgumentOutOfRangeException(nameof(period), period, "Период лимита: month или day."); - } - - TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct); - entity.BudgetTokens = budgetTokens; - entity.Period = period; - entity.Warned80 = false; - entity.NotifiedExhausted = false; - entity.UpdatedAt = _utcNow(); - await _dbContext.SaveChangesAsync(ct); - return await ToStateDtoAsync(entity, ct); - } - - /// - public async Task TryMarkWarnedAsync(Guid tenantId, CancellationToken ct) - { - TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct); - await ResetIfPeriodExpiredAsync(entity, ct); - if (entity.Warned80 || !_budgetService.IsWarned80(entity.UsedTokens, entity.BudgetTokens)) - { - return false; - } - - entity.Warned80 = true; - entity.UpdatedAt = _utcNow(); - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct) - { - TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct); - await ResetIfPeriodExpiredAsync(entity, ct); - if (entity.NotifiedExhausted || !_budgetService.IsExhausted(entity.UsedTokens, entity.BudgetTokens)) - { - return false; - } - - entity.NotifiedExhausted = true; - entity.UpdatedAt = _utcNow(); - await _dbContext.SaveChangesAsync(ct); - return true; - } - - private async Task LoadOrCreateAsync( - Guid tenantId, - TokenLimitDefaults defaults, - CancellationToken ct) - { - TenantLimitEntity? entity = await _dbContext.TenantLimits - .SingleOrDefaultAsync(x => x.TenantId == tenantId, ct); - if (entity is not null) - { - return entity; - } - - var created = new TenantLimitEntity - { - TenantId = tenantId, - BudgetTokens = defaults.BudgetTokens, - Period = defaults.Period, - PeriodStart = _utcNow(), - UsedTokens = 0, - Warned80 = false, - NotifiedExhausted = false, - UpdatedAt = _utcNow(), - }; - _dbContext.TenantLimits.Add(created); - await _dbContext.SaveChangesAsync(ct); - return created; - } - - private async Task ResetIfPeriodExpiredAsync(TenantLimitEntity entity, CancellationToken ct) - { - if (ResetIfExpired(entity, _utcNow())) - { - await _dbContext.SaveChangesAsync(ct); - } - } - - // Обнуляет накопительные поля строки, если её период завершился к now. - // entity: Отслеживаемая строка лимита. - // now: Текущий момент (UTC). - // Возвращает: True — период был сброшен (строка изменена). - private bool ResetIfExpired(TenantLimitEntity entity, DateTimeOffset now) - { - if (!_budgetService.IsPeriodExpired(entity.PeriodStart, entity.Period, now)) - { - return false; - } - - entity.UsedTokens = 0; - entity.Warned80 = false; - entity.NotifiedExhausted = false; - entity.PeriodStart = now; - entity.UpdatedAt = now; - return true; - } - - /// - public async Task ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct) - { - // Трогаем только строки с накоплениями (расход/флаги): строки без накоплений чистить нечего. - List candidates = await _dbContext.TenantLimits - .Where(x => x.UsedTokens > 0 || x.Warned80 || x.NotifiedExhausted) - .ToListAsync(ct); - int reset = 0; - foreach (TenantLimitEntity entity in candidates) - { - if (ResetIfExpired(entity, now)) - { - reset++; - } - } - - if (reset > 0) - { - await _dbContext.SaveChangesAsync(ct); - } - - return reset; - } - - private async Task ToStateDtoAsync(TenantLimitEntity entity, CancellationToken ct) - { - string? tenantStatus = await _dbContext.Tenants - .AsNoTracking() - .Where(t => t.Id == entity.TenantId) - .Select(t => t.Status) - .SingleOrDefaultAsync(ct); - string status = tenantStatus ?? TenantStatuses.Suspended; - bool allowed = status == TenantStatuses.Active - && !_budgetService.IsExhausted(entity.UsedTokens, entity.BudgetTokens); - return new BudgetStateDto( - entity.TenantId, - entity.BudgetTokens, - entity.Period, - entity.PeriodStart, - entity.UsedTokens, - status, - allowed, - entity.Warned80, - entity.NotifiedExhausted); - } - - private static TenantLimitDto ToLimitDto(TenantLimitEntity entity) => - new( - entity.TenantId, - entity.BudgetTokens, - entity.Period, - entity.PeriodStart, - entity.UsedTokens, - entity.Warned80, - entity.NotifiedExhausted); -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер хранилища лимитов ИИ-бюджета +/// +public sealed class TenantLimitStore : ITenantLimitStore +{ + private readonly DealDbContext _dbContext; + private readonly TokenLimitDefaults _defaults; + private readonly TokenBudgetService _budgetService; + private readonly Func _utcNow; + + /// + /// Создаёт адаптер с дефолт-бюджетом модуля + /// + /// Системный контекст (public-схема). + public TenantLimitStore(DealDbContext dbContext) + : this(dbContext, TokenBudgetDefaults.Default, new TokenBudgetService(), () => DateTimeOffset.UtcNow) + { + } + + /// + /// Создаёт адаптер с дефолт-бюджетом из конфигурации + /// + /// Системный контекст (public-схема). + /// Дефолт-параметры лениво создаваемой строки. + public TenantLimitStore(DealDbContext dbContext, TokenLimitDefaults defaults) + : this(dbContext, defaults, new TokenBudgetService(), () => DateTimeOffset.UtcNow) + { + } + + /// + /// Создаёт адаптер с явными зависимостями + /// + /// Системный контекст (public-схема). + /// Дефолт-параметры лениво создаваемой строки. + /// Период-математика (reset/пороги). + /// Источник текущего времени (UTC). + public TenantLimitStore( + DealDbContext dbContext, + TokenLimitDefaults defaults, + TokenBudgetService budgetService, + Func utcNow) + { + ArgumentNullException.ThrowIfNull(dbContext); + ArgumentNullException.ThrowIfNull(defaults); + ArgumentNullException.ThrowIfNull(budgetService); + ArgumentNullException.ThrowIfNull(utcNow); + _dbContext = dbContext; + _defaults = defaults; + _budgetService = budgetService; + _utcNow = utcNow; + } + + /// + public async Task GetOrCreateAsync( + Guid tenantId, + CancellationToken ct, + TokenLimitDefaults? defaults = null) + { + TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, defaults ?? _defaults, ct); + return ToLimitDto(entity); + } + + /// + public async Task GetStateAsync(Guid tenantId, CancellationToken ct) + { + TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct); + await ResetIfPeriodExpiredAsync(entity, ct); + return await ToStateDtoAsync(entity, ct); + } + + /// + public async Task AddUsageAsync( + Guid tenantId, + long tokens, + CancellationToken ct) + { + TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct); + await ResetIfPeriodExpiredAsync(entity, ct); + if (tokens > 0) + { + if (_dbContext.Database.IsNpgsql()) + { + // Атомарный инкремент одним UPDATE (Security review A12): параллельные списания одного тенанта + // (пользовательские ИИ-вызовы) не теряют токены — read-modify-write ниже неатомарен под гонкой. + var now = _utcNow(); + await _dbContext.Database.ExecuteSqlInterpolatedAsync( + $"UPDATE public.tenant_limits SET \"UsedTokens\" = \"UsedTokens\" + {tokens}, \"UpdatedAt\" = {now} WHERE \"TenantId\" = {tenantId}", + ct); + // Синхронизировать отслеживаемую сущность с БД (ToStateDtoAsync читает её поля). + await _dbContext.Entry(entity).ReloadAsync(ct); + } + else + { + // InMemory-провайдер (юнит-тесты) не исполняет raw SQL — EF read-modify-write (семантика та же). + entity.UsedTokens += tokens; + entity.UpdatedAt = _utcNow(); + await _dbContext.SaveChangesAsync(ct); + } + } + + return await ToStateDtoAsync(entity, ct); + } + + /// + public async Task UpdateBudgetAsync( + Guid tenantId, + long budgetTokens, + string period, + CancellationToken ct) + { + ArgumentOutOfRangeException.ThrowIfNegative(budgetTokens); + if (period != TenantLimitPeriods.Month && period != TenantLimitPeriods.Day) + { + throw new ArgumentOutOfRangeException(nameof(period), period, "Период лимита: month или day."); + } + + TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct); + entity.BudgetTokens = budgetTokens; + entity.Period = period; + entity.Warned80 = false; + entity.NotifiedExhausted = false; + entity.UpdatedAt = _utcNow(); + await _dbContext.SaveChangesAsync(ct); + return await ToStateDtoAsync(entity, ct); + } + + /// + public async Task TryMarkWarnedAsync(Guid tenantId, CancellationToken ct) + { + TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct); + await ResetIfPeriodExpiredAsync(entity, ct); + if (entity.Warned80 || !_budgetService.IsWarned80(entity.UsedTokens, entity.BudgetTokens)) + { + return false; + } + + entity.Warned80 = true; + entity.UpdatedAt = _utcNow(); + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct) + { + TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct); + await ResetIfPeriodExpiredAsync(entity, ct); + if (entity.NotifiedExhausted || !_budgetService.IsExhausted(entity.UsedTokens, entity.BudgetTokens)) + { + return false; + } + + entity.NotifiedExhausted = true; + entity.UpdatedAt = _utcNow(); + await _dbContext.SaveChangesAsync(ct); + return true; + } + + private async Task LoadOrCreateAsync( + Guid tenantId, + TokenLimitDefaults defaults, + CancellationToken ct) + { + TenantLimitEntity? entity = await _dbContext.TenantLimits + .SingleOrDefaultAsync(x => x.TenantId == tenantId, ct); + if (entity is not null) + { + return entity; + } + + var created = new TenantLimitEntity + { + TenantId = tenantId, + BudgetTokens = defaults.BudgetTokens, + Period = defaults.Period, + PeriodStart = _utcNow(), + UsedTokens = 0, + Warned80 = false, + NotifiedExhausted = false, + UpdatedAt = _utcNow(), + }; + _dbContext.TenantLimits.Add(created); + await _dbContext.SaveChangesAsync(ct); + return created; + } + + private async Task ResetIfPeriodExpiredAsync(TenantLimitEntity entity, CancellationToken ct) + { + if (ResetIfExpired(entity, _utcNow())) + { + await _dbContext.SaveChangesAsync(ct); + } + } + + // Обнуляет накопительные поля строки, если её период завершился к now. + // entity: Отслеживаемая строка лимита. + // now: Текущий момент (UTC). + // Возвращает: True — период был сброшен (строка изменена). + private bool ResetIfExpired(TenantLimitEntity entity, DateTimeOffset now) + { + if (!_budgetService.IsPeriodExpired(entity.PeriodStart, entity.Period, now)) + { + return false; + } + + entity.UsedTokens = 0; + entity.Warned80 = false; + entity.NotifiedExhausted = false; + entity.PeriodStart = now; + entity.UpdatedAt = now; + return true; + } + + /// + public async Task ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct) + { + // Трогаем только строки с накоплениями (расход/флаги): строки без накоплений чистить нечего. + List candidates = await _dbContext.TenantLimits + .Where(x => x.UsedTokens > 0 || x.Warned80 || x.NotifiedExhausted) + .ToListAsync(ct); + int reset = 0; + foreach (TenantLimitEntity entity in candidates) + { + if (ResetIfExpired(entity, now)) + { + reset++; + } + } + + if (reset > 0) + { + await _dbContext.SaveChangesAsync(ct); + } + + return reset; + } + + private async Task ToStateDtoAsync(TenantLimitEntity entity, CancellationToken ct) + { + string? tenantStatus = await _dbContext.Tenants + .AsNoTracking() + .Where(t => t.Id == entity.TenantId) + .Select(t => t.Status) + .SingleOrDefaultAsync(ct); + string status = tenantStatus ?? TenantStatuses.Suspended; + bool allowed = status == TenantStatuses.Active + && !_budgetService.IsExhausted(entity.UsedTokens, entity.BudgetTokens); + return new BudgetStateDto( + entity.TenantId, + entity.BudgetTokens, + entity.Period, + entity.PeriodStart, + entity.UsedTokens, + status, + allowed, + entity.Warned80, + entity.NotifiedExhausted); + } + + private static TenantLimitDto ToLimitDto(TenantLimitEntity entity) => + new( + entity.TenantId, + entity.BudgetTokens, + entity.Period, + entity.PeriodStart, + entity.UsedTokens, + entity.Warned80, + entity.NotifiedExhausted); +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/TenantRepository.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/TenantRepository.cs index d8ad946..f9272df 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/TenantRepository.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/TenantRepository.cs @@ -1,85 +1,85 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер реестра тенантов -/// -public sealed class TenantRepository(DealDbContext dbContext) : ITenantRepository -{ - /// - public async Task FindByIdAsync(Guid id, CancellationToken ct) - { - var entity = await dbContext.Tenants - .AsNoTracking() - .SingleOrDefaultAsync(t => t.Id == id, ct); - return entity is null ? null : ToTenantRecordDto(entity); - } - - /// - public async Task CreateAsync(TenantRecordDto tenant, CancellationToken ct) - { - dbContext.Tenants.Add(new TenantEntity - { - Id = tenant.Id, - Name = tenant.Name, - Status = tenant.Status, - CreatedAt = tenant.CreatedAt, - }); - await dbContext.SaveChangesAsync(ct); - } - - /// - public async Task> ListAsync(CancellationToken ct) - { - var entities = await dbContext.Tenants - .AsNoTracking() - .OrderBy(t => t.CreatedAt) - .ThenBy(t => t.Id) - .Select(t => ToTenantRecordDto(t)) - .ToListAsync(ct); - return entities; - } - - /// - public async Task> ListPageAsync( - int offset, - int limit, - CancellationToken ct) - { - var entities = await dbContext.Tenants - .AsNoTracking() - .OrderBy(t => t.CreatedAt) - .ThenBy(t => t.Id) - .Skip(offset) - .Take(limit) - .Select(t => ToTenantRecordDto(t)) - .ToListAsync(ct); - return entities; - } - - /// - public async Task UpdateStatusAsync( - Guid id, - string status, - CancellationToken ct) - { - // Отслеживаемая запись + SaveChanges (не ExecuteUpdateAsync): операция редкая (операторская админка), - // зато семантика проверяема на InMemory-провайдере в unit-тестах. - var entity = await dbContext.Tenants.SingleOrDefaultAsync(t => t.Id == id, ct); - if (entity is null) - { - return false; - } - - entity.Status = status; - await dbContext.SaveChangesAsync(ct); - return true; - } - - private static TenantRecordDto ToTenantRecordDto(TenantEntity entity) => - new(entity.Id, entity.Name, entity.Status, entity.CreatedAt); -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер реестра тенантов +/// +public sealed class TenantRepository(DealDbContext dbContext) : ITenantRepository +{ + /// + public async Task FindByIdAsync(Guid id, CancellationToken ct) + { + var entity = await dbContext.Tenants + .AsNoTracking() + .SingleOrDefaultAsync(t => t.Id == id, ct); + return entity is null ? null : ToTenantRecordDto(entity); + } + + /// + public async Task CreateAsync(TenantRecordDto tenant, CancellationToken ct) + { + dbContext.Tenants.Add(new TenantEntity + { + Id = tenant.Id, + Name = tenant.Name, + Status = tenant.Status, + CreatedAt = tenant.CreatedAt, + }); + await dbContext.SaveChangesAsync(ct); + } + + /// + public async Task> ListAsync(CancellationToken ct) + { + var entities = await dbContext.Tenants + .AsNoTracking() + .OrderBy(t => t.CreatedAt) + .ThenBy(t => t.Id) + .Select(t => ToTenantRecordDto(t)) + .ToListAsync(ct); + return entities; + } + + /// + public async Task> ListPageAsync( + int offset, + int limit, + CancellationToken ct) + { + var entities = await dbContext.Tenants + .AsNoTracking() + .OrderBy(t => t.CreatedAt) + .ThenBy(t => t.Id) + .Skip(offset) + .Take(limit) + .Select(t => ToTenantRecordDto(t)) + .ToListAsync(ct); + return entities; + } + + /// + public async Task UpdateStatusAsync( + Guid id, + string status, + CancellationToken ct) + { + // Отслеживаемая запись + SaveChanges (не ExecuteUpdateAsync): операция редкая (операторская админка), + // зато семантика проверяема на InMemory-провайдере в unit-тестах. + var entity = await dbContext.Tenants.SingleOrDefaultAsync(t => t.Id == id, ct); + if (entity is null) + { + return false; + } + + entity.Status = status; + await dbContext.SaveChangesAsync(ct); + return true; + } + + private static TenantRecordDto ToTenantRecordDto(TenantEntity entity) => + new(entity.Id, entity.Name, entity.Status, entity.CreatedAt); +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/TokenUsageEventStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/TokenUsageEventStore.cs index 1b0ba0c..3bbd142 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/TokenUsageEventStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/TokenUsageEventStore.cs @@ -1,175 +1,175 @@ -using System.Linq.Expressions; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер истории расхода токенов -/// -public sealed class TokenUsageEventStore(DealDbContext dbContext) : ITokenUsageEventStore -{ - /// - public async Task AppendAsync(TokenUsageEventDto record, CancellationToken ct) - { - dbContext.TokenUsageEvents.Add(new TokenUsageEventEntity - { - // Id генерирует БД (identity) — из DTO не копируется. - TenantId = record.TenantId, - At = record.At, - Provider = record.Provider, - Model = record.Model, - Kind = record.Kind, - PromptTokens = record.PromptTokens, - CompletionTokens = record.CompletionTokens, - TotalTokens = record.TotalTokens, - DetailJson = record.DetailJson, - }); - await dbContext.SaveChangesAsync(ct); - } - - /// - public async Task> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct) - { - IQueryable source = ApplyFilters(dbContext.TokenUsageEvents.AsNoTracking(), query); - return query.GroupBy switch - { - TokenUsageGroupBys.Day => await AggregateByDayAsync(source, ct), - TokenUsageGroupBys.Tenant => await AggregateByTenantAsync(source, ct), - TokenUsageGroupBys.Provider => await AggregateByStringAsync(source, entity => entity.Provider, ct), - TokenUsageGroupBys.Model => await AggregateByStringAsync(source, entity => entity.Model, ct), - _ => throw new ArgumentException($"Неизвестная группировка расхода токенов: '{query.GroupBy}'.", nameof(query)), - }; - } - - // Применяет фильтры агрегации (TenantId/Provider/Model/Kind/At-range). - // source: Базовый запрос. - // query: Фильтр/группировка. - // Возвращает: Запрос с фильтрами. - private static IQueryable ApplyFilters(IQueryable source, TokenUsageEventQueryDto query) - { - if (query.TenantId is not null) - { - source = source.Where(e => e.TenantId == query.TenantId); - } - - if (!string.IsNullOrWhiteSpace(query.Provider)) - { - source = source.Where(e => e.Provider == query.Provider); - } - - if (!string.IsNullOrWhiteSpace(query.Model)) - { - source = source.Where(e => e.Model == query.Model); - } - - if (!string.IsNullOrWhiteSpace(query.Kind)) - { - source = source.Where(e => e.Kind == query.Kind); - } - - if (query.From is not null) - { - source = source.Where(e => e.At >= query.From.Value); - } - - if (query.To is not null) - { - source = source.Where(e => e.At <= query.To.Value); - } - - return source; - } - - // Агрегат по суткам UTC (ключ ГГГГ-ММ-ДД), порядок — по возрастанию даты. - // source: Отфильтрованный запрос. - // ct: Токен отмены. - // Возвращает: Строки агрегатов по дням. - private static async Task> AggregateByDayAsync(IQueryable source, CancellationToken ct) - { - var rows = await source - .GroupBy(e => new { e.At.Year, e.At.Month, e.At.Day }) - .Select(group => new - { - group.Key.Year, - group.Key.Month, - group.Key.Day, - Prompt = group.Sum(x => x.PromptTokens), - Completion = group.Sum(x => x.CompletionTokens), - Total = group.Sum(x => x.TotalTokens), - Count = group.LongCount(), - }) - .ToListAsync(ct); - - return rows - .OrderBy(row => row.Year) - .ThenBy(row => row.Month) - .ThenBy(row => row.Day) - .Select(row => new TokenUsageAggregateDto( - $"{row.Year:D4}-{row.Month:D2}-{row.Day:D2}", - row.Prompt, - row.Completion, - row.Total, - row.Count)) - .ToList(); - } - - // Агрегат по тенантам (ключ — Guid "D"), порядок — по убыванию total. - // source: Отфильтрованный запрос. - // ct: Токен отмены. - // Возвращает: Строки агрегатов по тенантам. - private static async Task> AggregateByTenantAsync(IQueryable source, CancellationToken ct) - { - var rows = await source - .GroupBy(e => e.TenantId) - .Select(group => new - { - group.Key, - Prompt = group.Sum(x => x.PromptTokens), - Completion = group.Sum(x => x.CompletionTokens), - Total = group.Sum(x => x.TotalTokens), - Count = group.LongCount(), - }) - .ToListAsync(ct); - - return rows - .OrderByDescending(row => row.Total) - .Select(row => new TokenUsageAggregateDto( - row.Key.ToString("D"), - row.Prompt, - row.Completion, - row.Total, - row.Count)) - .ToList(); - } - - // Агрегат по строковому ключу (провайдер/модель), порядок — по убыванию total. - // source: Отфильтрованный запрос. - // keySelector: Селектор ключа группы (провайдер/модель). - // ct: Токен отмены. - // Возвращает: Строки агрегатов по ключу. - private static async Task> AggregateByStringAsync( - IQueryable source, - Expression> keySelector, - CancellationToken ct) - { - var rows = await source - .GroupBy(keySelector) - .Select(group => new - { - Key = group.Key, - Prompt = group.Sum(x => x.PromptTokens), - Completion = group.Sum(x => x.CompletionTokens), - Total = group.Sum(x => x.TotalTokens), - Count = group.LongCount(), - }) - .ToListAsync(ct); - - return rows - .OrderByDescending(row => row.Total) - .Select(row => new TokenUsageAggregateDto(row.Key, row.Prompt, row.Completion, row.Total, row.Count)) - .ToList(); - } -} +using System.Linq.Expressions; +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер истории расхода токенов +/// +public sealed class TokenUsageEventStore(DealDbContext dbContext) : ITokenUsageEventStore +{ + /// + public async Task AppendAsync(TokenUsageEventDto record, CancellationToken ct) + { + dbContext.TokenUsageEvents.Add(new TokenUsageEventEntity + { + // Id генерирует БД (identity) — из DTO не копируется. + TenantId = record.TenantId, + At = record.At, + Provider = record.Provider, + Model = record.Model, + Kind = record.Kind, + PromptTokens = record.PromptTokens, + CompletionTokens = record.CompletionTokens, + TotalTokens = record.TotalTokens, + DetailJson = record.DetailJson, + }); + await dbContext.SaveChangesAsync(ct); + } + + /// + public async Task> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct) + { + IQueryable source = ApplyFilters(dbContext.TokenUsageEvents.AsNoTracking(), query); + return query.GroupBy switch + { + TokenUsageGroupBys.Day => await AggregateByDayAsync(source, ct), + TokenUsageGroupBys.Tenant => await AggregateByTenantAsync(source, ct), + TokenUsageGroupBys.Provider => await AggregateByStringAsync(source, entity => entity.Provider, ct), + TokenUsageGroupBys.Model => await AggregateByStringAsync(source, entity => entity.Model, ct), + _ => throw new ArgumentException($"Неизвестная группировка расхода токенов: '{query.GroupBy}'.", nameof(query)), + }; + } + + // Применяет фильтры агрегации (TenantId/Provider/Model/Kind/At-range). + // source: Базовый запрос. + // query: Фильтр/группировка. + // Возвращает: Запрос с фильтрами. + private static IQueryable ApplyFilters(IQueryable source, TokenUsageEventQueryDto query) + { + if (query.TenantId is not null) + { + source = source.Where(e => e.TenantId == query.TenantId); + } + + if (!string.IsNullOrWhiteSpace(query.Provider)) + { + source = source.Where(e => e.Provider == query.Provider); + } + + if (!string.IsNullOrWhiteSpace(query.Model)) + { + source = source.Where(e => e.Model == query.Model); + } + + if (!string.IsNullOrWhiteSpace(query.Kind)) + { + source = source.Where(e => e.Kind == query.Kind); + } + + if (query.From is not null) + { + source = source.Where(e => e.At >= query.From.Value); + } + + if (query.To is not null) + { + source = source.Where(e => e.At <= query.To.Value); + } + + return source; + } + + // Агрегат по суткам UTC (ключ ГГГГ-ММ-ДД), порядок — по возрастанию даты. + // source: Отфильтрованный запрос. + // ct: Токен отмены. + // Возвращает: Строки агрегатов по дням. + private static async Task> AggregateByDayAsync(IQueryable source, CancellationToken ct) + { + var rows = await source + .GroupBy(e => new { e.At.Year, e.At.Month, e.At.Day }) + .Select(group => new + { + group.Key.Year, + group.Key.Month, + group.Key.Day, + Prompt = group.Sum(x => x.PromptTokens), + Completion = group.Sum(x => x.CompletionTokens), + Total = group.Sum(x => x.TotalTokens), + Count = group.LongCount(), + }) + .ToListAsync(ct); + + return rows + .OrderBy(row => row.Year) + .ThenBy(row => row.Month) + .ThenBy(row => row.Day) + .Select(row => new TokenUsageAggregateDto( + $"{row.Year:D4}-{row.Month:D2}-{row.Day:D2}", + row.Prompt, + row.Completion, + row.Total, + row.Count)) + .ToList(); + } + + // Агрегат по тенантам (ключ — Guid "D"), порядок — по убыванию total. + // source: Отфильтрованный запрос. + // ct: Токен отмены. + // Возвращает: Строки агрегатов по тенантам. + private static async Task> AggregateByTenantAsync(IQueryable source, CancellationToken ct) + { + var rows = await source + .GroupBy(e => e.TenantId) + .Select(group => new + { + group.Key, + Prompt = group.Sum(x => x.PromptTokens), + Completion = group.Sum(x => x.CompletionTokens), + Total = group.Sum(x => x.TotalTokens), + Count = group.LongCount(), + }) + .ToListAsync(ct); + + return rows + .OrderByDescending(row => row.Total) + .Select(row => new TokenUsageAggregateDto( + row.Key.ToString("D"), + row.Prompt, + row.Completion, + row.Total, + row.Count)) + .ToList(); + } + + // Агрегат по строковому ключу (провайдер/модель), порядок — по убыванию total. + // source: Отфильтрованный запрос. + // keySelector: Селектор ключа группы (провайдер/модель). + // ct: Токен отмены. + // Возвращает: Строки агрегатов по ключу. + private static async Task> AggregateByStringAsync( + IQueryable source, + Expression> keySelector, + CancellationToken ct) + { + var rows = await source + .GroupBy(keySelector) + .Select(group => new + { + Key = group.Key, + Prompt = group.Sum(x => x.PromptTokens), + Completion = group.Sum(x => x.CompletionTokens), + Total = group.Sum(x => x.TotalTokens), + Count = group.LongCount(), + }) + .ToListAsync(ct); + + return rows + .OrderByDescending(row => row.Total) + .Select(row => new TokenUsageAggregateDto(row.Key, row.Prompt, row.Completion, row.Total, row.Count)) + .ToList(); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/TenantDbContext.cs b/src/core/Deal.Infrastructure/Persistence/TenantDbContext.cs index 47e3506..8242dbe 100644 --- a/src/core/Deal.Infrastructure/Persistence/TenantDbContext.cs +++ b/src/core/Deal.Infrastructure/Persistence/TenantDbContext.cs @@ -1,105 +1,105 @@ -using Deal.Infrastructure.Persistence.Configurations; -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence; - -/// -/// Бессхемный DbContext тенанта -/// -public sealed class TenantDbContext(DbContextOptions options) : DbContext(options) -{ - /// - /// Настройки тенанта - /// - public DbSet Settings => Set(); - - /// - /// Карточки дашборда - /// - public DbSet Cards => Set(); - - /// - /// Комментарии карточек - /// - public DbSet LeadComments => Set(); - - /// - /// Журнал действий над карточками для обучения ML - /// - public DbSet CardMoves => Set(); - - /// - /// Очередь обучающих сигналов ML - /// - public DbSet MlOutbox => Set(); - - /// - /// Очередь входящих сообщений пайплайна - /// - public DbSet QueueItems => Set(); - - /// - /// Отсев пайплайна - /// - public DbSet RejectedItems => Set(); - - /// - /// Дедуп-хэши текстов - /// - public DbSet DedupEntries => Set(); - - /// - /// Единые контейнеры карточек - /// - public DbSet Containers => Set(); - - /// - /// Каталог диалогов/каналов Telegram. - /// - public DbSet Dialogs => Set(); - - /// - /// Превью-сообщения диалогов. - /// - public DbSet TgMessages => Set(); - - /// - /// Задачи поиска Discovery. - /// - public DbSet DiscTasks => Set(); - - /// - /// Кандидаты задач Discovery. - /// - public DbSet DiscCandidates => Set(); - - /// - /// Чёрный список Discovery. - /// - public DbSet DiscBlacklist => Set(); - - /// - /// Лог событий задач Discovery. - /// - public DbSet DiscLog => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.ApplyConfiguration(new TenantSettingConfiguration()); - modelBuilder.ApplyConfiguration(new CardConfiguration()); - modelBuilder.ApplyConfiguration(new LeadCommentConfiguration()); - modelBuilder.ApplyConfiguration(new CardMoveConfiguration()); - modelBuilder.ApplyConfiguration(new MlOutboxConfiguration()); - modelBuilder.ApplyConfiguration(new QueueItemConfiguration()); - modelBuilder.ApplyConfiguration(new RejectedItemConfiguration()); - modelBuilder.ApplyConfiguration(new DedupEntryConfiguration()); - modelBuilder.ApplyConfiguration(new ContainerConfiguration()); - modelBuilder.ApplyConfiguration(new DialogConfiguration()); - modelBuilder.ApplyConfiguration(new TgMessageConfiguration()); - modelBuilder.ApplyConfiguration(new DiscTaskConfiguration()); - modelBuilder.ApplyConfiguration(new DiscCandidateConfiguration()); - modelBuilder.ApplyConfiguration(new DiscBlacklistConfiguration()); - modelBuilder.ApplyConfiguration(new DiscLogConfiguration()); - } -} +using Deal.Infrastructure.Persistence.Configurations; +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence; + +/// +/// Бессхемный DbContext тенанта +/// +public sealed class TenantDbContext(DbContextOptions options) : DbContext(options) +{ + /// + /// Настройки тенанта + /// + public DbSet Settings => Set(); + + /// + /// Карточки дашборда + /// + public DbSet Cards => Set(); + + /// + /// Комментарии карточек + /// + public DbSet LeadComments => Set(); + + /// + /// Журнал действий над карточками для обучения ML + /// + public DbSet CardMoves => Set(); + + /// + /// Очередь обучающих сигналов ML + /// + public DbSet MlOutbox => Set(); + + /// + /// Очередь входящих сообщений пайплайна + /// + public DbSet QueueItems => Set(); + + /// + /// Отсев пайплайна + /// + public DbSet RejectedItems => Set(); + + /// + /// Дедуп-хэши текстов + /// + public DbSet DedupEntries => Set(); + + /// + /// Единые контейнеры карточек + /// + public DbSet Containers => Set(); + + /// + /// Каталог диалогов/каналов Telegram. + /// + public DbSet Dialogs => Set(); + + /// + /// Превью-сообщения диалогов. + /// + public DbSet TgMessages => Set(); + + /// + /// Задачи поиска Discovery. + /// + public DbSet DiscTasks => Set(); + + /// + /// Кандидаты задач Discovery. + /// + public DbSet DiscCandidates => Set(); + + /// + /// Чёрный список Discovery. + /// + public DbSet DiscBlacklist => Set(); + + /// + /// Лог событий задач Discovery. + /// + public DbSet DiscLog => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new TenantSettingConfiguration()); + modelBuilder.ApplyConfiguration(new CardConfiguration()); + modelBuilder.ApplyConfiguration(new LeadCommentConfiguration()); + modelBuilder.ApplyConfiguration(new CardMoveConfiguration()); + modelBuilder.ApplyConfiguration(new MlOutboxConfiguration()); + modelBuilder.ApplyConfiguration(new QueueItemConfiguration()); + modelBuilder.ApplyConfiguration(new RejectedItemConfiguration()); + modelBuilder.ApplyConfiguration(new DedupEntryConfiguration()); + modelBuilder.ApplyConfiguration(new ContainerConfiguration()); + modelBuilder.ApplyConfiguration(new DialogConfiguration()); + modelBuilder.ApplyConfiguration(new TgMessageConfiguration()); + modelBuilder.ApplyConfiguration(new DiscTaskConfiguration()); + modelBuilder.ApplyConfiguration(new DiscCandidateConfiguration()); + modelBuilder.ApplyConfiguration(new DiscBlacklistConfiguration()); + modelBuilder.ApplyConfiguration(new DiscLogConfiguration()); + } +} diff --git a/src/core/Deal.Infrastructure/Tenancy/TenantMigrationSummary.cs b/src/core/Deal.Infrastructure/Tenancy/TenantMigrationSummary.cs index fab00a0..e5df8ae 100644 --- a/src/core/Deal.Infrastructure/Tenancy/TenantMigrationSummary.cs +++ b/src/core/Deal.Infrastructure/Tenancy/TenantMigrationSummary.cs @@ -1,22 +1,22 @@ -namespace Deal.Infrastructure.Tenancy; - -/// -/// Итог пакетной миграции схем всех тенантов. -/// -/// Сколько схем тенантов обработано (число тенантов в реестре). -/// Сколько схем успешно провижинено/мигрировано. -/// Сколько схем не удалось мигрировать (сбой одной не прерывает остальные). -/// Длительность пакетной операции, миллисекунды. -/// Имена схем, которые не удалось мигрировать (для диагностики; пусто при успехе). -public sealed record TenantMigrationSummary( - int Total, - int Migrated, - int Failed, - long DurationMs, - IReadOnlyList FailedSchemas) -{ - /// - /// True — все схемы обработаны без сбоев. - /// - public bool Ok => Failed == 0; -} +namespace Deal.Infrastructure.Tenancy; + +/// +/// Итог пакетной миграции схем всех тенантов. +/// +/// Сколько схем тенантов обработано (число тенантов в реестре). +/// Сколько схем успешно провижинено/мигрировано. +/// Сколько схем не удалось мигрировать (сбой одной не прерывает остальные). +/// Длительность пакетной операции, миллисекунды. +/// Имена схем, которые не удалось мигрировать (для диагностики; пусто при успехе). +public sealed record TenantMigrationSummary( + int Total, + int Migrated, + int Failed, + long DurationMs, + IReadOnlyList FailedSchemas) +{ + /// + /// True — все схемы обработаны без сбоев. + /// + public bool Ok => Failed == 0; +} diff --git a/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs b/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs index a3e907f..166419e 100644 --- a/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs +++ b/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs @@ -1,10 +1,10 @@ -using Deal.Modules.Cards.Application.Abstractions; - -namespace Deal.Modules.Cards.Application.Dtos; - -/// -/// Результат перехода карточки единым механизмом . -/// -/// Текст 400-ошибки либо null. -/// True — карточка найдена и переход выполнен (либо перенос был no-op). -public sealed record CardMoveResultDto(string? Error, bool Exists); +using Deal.Modules.Cards.Application.Abstractions; + +namespace Deal.Modules.Cards.Application.Dtos; + +/// +/// Результат перехода карточки единым механизмом . +/// +/// Текст 400-ошибки либо null. +/// True — карточка найдена и переход выполнен (либо перенос был no-op). +public sealed record CardMoveResultDto(string? Error, bool Exists); diff --git a/src/core/Deal.Modules.Cards/Application/Sources/ContactRef.cs b/src/core/Deal.Modules.Cards/Application/Sources/ContactRef.cs index 174ebd1..c6661af 100644 --- a/src/core/Deal.Modules.Cards/Application/Sources/ContactRef.cs +++ b/src/core/Deal.Modules.Cards/Application/Sources/ContactRef.cs @@ -1,20 +1,20 @@ -namespace Deal.Modules.Cards.Application.Sources; - -/// -/// Контакт, извлечённый из источника или указанный вручную. -/// -public sealed record ContactRef -{ - /// - /// Вид контакта (например, телефон/почта/мессенджер/сайт). - /// - public string? Kind { get; init; } - - public string? Name { get; init; } - - public string? Phone { get; init; } - - public string? Email { get; init; } - - public string? Url { get; init; } -} +namespace Deal.Modules.Cards.Application.Sources; + +/// +/// Контакт, извлечённый из источника или указанный вручную. +/// +public sealed record ContactRef +{ + /// + /// Вид контакта (например, телефон/почта/мессенджер/сайт). + /// + public string? Kind { get; init; } + + public string? Name { get; init; } + + public string? Phone { get; init; } + + public string? Email { get; init; } + + public string? Url { get; init; } +} diff --git a/src/core/Deal.Modules.Cards/Application/Sources/DataRef.cs b/src/core/Deal.Modules.Cards/Application/Sources/DataRef.cs index 08cdc28..524aa1f 100644 --- a/src/core/Deal.Modules.Cards/Application/Sources/DataRef.cs +++ b/src/core/Deal.Modules.Cards/Application/Sources/DataRef.cs @@ -1,48 +1,48 @@ -namespace Deal.Modules.Cards.Application.Sources; - -/// -/// Ссылка на файл в общем сервисе данных. -/// -public sealed record DataRef -{ - /// - /// Идентификатор объекта в сервисе данных. - /// - public required string Id { get; init; } - - /// - /// Ссылка на скачивание/отображение. - /// - public required string Ref { get; init; } - - /// - /// Тип, определённый сервисом данных (image/video/audio/document/archive/other). - /// - public string? Kind { get; init; } - - public string? MimeType { get; init; } - - public string? FileName { get; init; } - - public long? Size { get; init; } - - public int? Width { get; init; } - - public int? Height { get; init; } - - public double? DurationSec { get; init; } - - /// - /// Ссылка на превью (если сервис данных сгенерировал). - /// - public string? PreviewRef { get; init; } - - public string? Caption { get; init; } - - public int? Order { get; init; } - - /// - /// Прочие метаданные от сервиса данных. - /// - public IReadOnlyDictionary? Meta { get; init; } -} +namespace Deal.Modules.Cards.Application.Sources; + +/// +/// Ссылка на файл в общем сервисе данных. +/// +public sealed record DataRef +{ + /// + /// Идентификатор объекта в сервисе данных. + /// + public required string Id { get; init; } + + /// + /// Ссылка на скачивание/отображение. + /// + public required string Ref { get; init; } + + /// + /// Тип, определённый сервисом данных (image/video/audio/document/archive/other). + /// + public string? Kind { get; init; } + + public string? MimeType { get; init; } + + public string? FileName { get; init; } + + public long? Size { get; init; } + + public int? Width { get; init; } + + public int? Height { get; init; } + + public double? DurationSec { get; init; } + + /// + /// Ссылка на превью (если сервис данных сгенерировал). + /// + public string? PreviewRef { get; init; } + + public string? Caption { get; init; } + + public int? Order { get; init; } + + /// + /// Прочие метаданные от сервиса данных. + /// + public IReadOnlyDictionary? Meta { get; init; } +} diff --git a/src/core/Deal.Modules.Cards/Application/Sources/ISourceContentProvider.cs b/src/core/Deal.Modules.Cards/Application/Sources/ISourceContentProvider.cs index 15d55bd..34cf13d 100644 --- a/src/core/Deal.Modules.Cards/Application/Sources/ISourceContentProvider.cs +++ b/src/core/Deal.Modules.Cards/Application/Sources/ISourceContentProvider.cs @@ -1,20 +1,20 @@ -namespace Deal.Modules.Cards.Application.Sources; - -/// -/// Загрузка содержимого записи источника по ссылке. -/// -public interface ISourceContentProvider -{ - /// - /// Вид источника, который обслуживает провайдер. - /// - public string Kind { get; } - - /// - /// Загружает содержимое записи источника. - /// - /// Ссылка на источник. - /// Токен отмены. - /// Содержимое записи либо null, если источник не отдал данные. - public Task LoadAsync(SourceRef source, CancellationToken ct); -} +namespace Deal.Modules.Cards.Application.Sources; + +/// +/// Загрузка содержимого записи источника по ссылке. +/// +public interface ISourceContentProvider +{ + /// + /// Вид источника, который обслуживает провайдер. + /// + public string Kind { get; } + + /// + /// Загружает содержимое записи источника. + /// + /// Ссылка на источник. + /// Токен отмены. + /// Содержимое записи либо null, если источник не отдал данные. + public Task LoadAsync(SourceRef source, CancellationToken ct); +} diff --git a/src/core/Deal.Modules.Cards/Application/Sources/ISourceIngestObserver.cs b/src/core/Deal.Modules.Cards/Application/Sources/ISourceIngestObserver.cs index af31efe..ca98a8b 100644 --- a/src/core/Deal.Modules.Cards/Application/Sources/ISourceIngestObserver.cs +++ b/src/core/Deal.Modules.Cards/Application/Sources/ISourceIngestObserver.cs @@ -1,15 +1,15 @@ -namespace Deal.Modules.Cards.Application.Sources; - -/// -/// Уведомление о принятой записи источника. -/// -public interface ISourceIngestObserver -{ - /// - /// Обрабатывает принятую запись источника. - /// - /// Принятая запись. - /// Токен отмены. - /// Завершается после обработки; сбой наблюдателя не влияет на приём. - public Task OnIngestedAsync(SourceItem item, CancellationToken ct); -} +namespace Deal.Modules.Cards.Application.Sources; + +/// +/// Уведомление о принятой записи источника. +/// +public interface ISourceIngestObserver +{ + /// + /// Обрабатывает принятую запись источника. + /// + /// Принятая запись. + /// Токен отмены. + /// Завершается после обработки; сбой наблюдателя не влияет на приём. + public Task OnIngestedAsync(SourceItem item, CancellationToken ct); +} diff --git a/src/core/Deal.Modules.Cards/Application/Sources/SourceContent.cs b/src/core/Deal.Modules.Cards/Application/Sources/SourceContent.cs index f1a1485..4d950bf 100644 --- a/src/core/Deal.Modules.Cards/Application/Sources/SourceContent.cs +++ b/src/core/Deal.Modules.Cards/Application/Sources/SourceContent.cs @@ -1,35 +1,35 @@ -namespace Deal.Modules.Cards.Application.Sources; - -/// -/// Содержимое первичного сообщения источника. -/// -public sealed record SourceContent -{ - public string? Text { get; init; } - - /// - /// Форматированный текст (если источник его предоставил). - /// - public string? Html { get; init; } - - public string? Author { get; init; } - - public string? Subject { get; init; } - - /// - /// Вложения: ссылки на файлы в общем сервисе данных. - /// - public IReadOnlyList Data { get; init; } = []; - - /// - /// Ссылки, не являющиеся файлами. - /// - public IReadOnlyList? Links { get; init; } - - public IReadOnlyList? Contacts { get; init; } - - /// - /// Прочие поля, не подходящие под текст/файл/ссылку/контакт. - /// - public IReadOnlyDictionary? Extra { get; init; } -} +namespace Deal.Modules.Cards.Application.Sources; + +/// +/// Содержимое первичного сообщения источника. +/// +public sealed record SourceContent +{ + public string? Text { get; init; } + + /// + /// Форматированный текст (если источник его предоставил). + /// + public string? Html { get; init; } + + public string? Author { get; init; } + + public string? Subject { get; init; } + + /// + /// Вложения: ссылки на файлы в общем сервисе данных. + /// + public IReadOnlyList Data { get; init; } = []; + + /// + /// Ссылки, не являющиеся файлами. + /// + public IReadOnlyList? Links { get; init; } + + public IReadOnlyList? Contacts { get; init; } + + /// + /// Прочие поля, не подходящие под текст/файл/ссылку/контакт. + /// + public IReadOnlyDictionary? Extra { get; init; } +} diff --git a/src/core/Deal.Modules.Cards/Application/Sources/SourceContentResolver.cs b/src/core/Deal.Modules.Cards/Application/Sources/SourceContentResolver.cs index ba26810..86f458d 100644 --- a/src/core/Deal.Modules.Cards/Application/Sources/SourceContentResolver.cs +++ b/src/core/Deal.Modules.Cards/Application/Sources/SourceContentResolver.cs @@ -1,42 +1,42 @@ -namespace Deal.Modules.Cards.Application.Sources; - -/// -/// Выбирает провайдера содержимого по виду источника. -/// -public sealed class SourceContentResolver -{ - private readonly Dictionary _providers; - - /// - /// Создаёт реестр провайдеров содержимого. - /// - /// Зарегистрированные провайдеры; последний по виду перекрывает предыдущих. - public SourceContentResolver(IEnumerable providers) - { - _providers = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (ISourceContentProvider provider in providers) - { - _providers[provider.Kind] = provider; - } - } - - /// - /// Есть ли провайдер для вида источника. - /// - /// Ссылка на источник. - /// true — содержимое можно загрузить лениво. - public bool CanResolve(SourceRef source) => _providers.ContainsKey(source.Kind); - - /// - /// Загружает содержимое записи источника. - /// - /// Ссылка на источник. - /// Токен отмены. - /// Содержимое записи либо null — провайдера нет/источник не отдал данные. - public Task ResolveAsync(SourceRef source, CancellationToken ct) - { - return _providers.TryGetValue(source.Kind, out ISourceContentProvider? provider) - ? provider.LoadAsync(source, ct) - : Task.FromResult(null); - } -} +namespace Deal.Modules.Cards.Application.Sources; + +/// +/// Выбирает провайдера содержимого по виду источника. +/// +public sealed class SourceContentResolver +{ + private readonly Dictionary _providers; + + /// + /// Создаёт реестр провайдеров содержимого. + /// + /// Зарегистрированные провайдеры; последний по виду перекрывает предыдущих. + public SourceContentResolver(IEnumerable providers) + { + _providers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (ISourceContentProvider provider in providers) + { + _providers[provider.Kind] = provider; + } + } + + /// + /// Есть ли провайдер для вида источника. + /// + /// Ссылка на источник. + /// true — содержимое можно загрузить лениво. + public bool CanResolve(SourceRef source) => _providers.ContainsKey(source.Kind); + + /// + /// Загружает содержимое записи источника. + /// + /// Ссылка на источник. + /// Токен отмены. + /// Содержимое записи либо null — провайдера нет/источник не отдал данные. + public Task ResolveAsync(SourceRef source, CancellationToken ct) + { + return _providers.TryGetValue(source.Kind, out ISourceContentProvider? provider) + ? provider.LoadAsync(source, ct) + : Task.FromResult(null); + } +} diff --git a/src/core/Deal.Modules.Cards/Application/Sources/SourceItem.cs b/src/core/Deal.Modules.Cards/Application/Sources/SourceItem.cs index ddddd39..5b66bae 100644 --- a/src/core/Deal.Modules.Cards/Application/Sources/SourceItem.cs +++ b/src/core/Deal.Modules.Cards/Application/Sources/SourceItem.cs @@ -1,11 +1,11 @@ -namespace Deal.Modules.Cards.Application.Sources; - -/// -/// Единый контракт входящей записи: источник + содержимое. -/// -public sealed record SourceItem -{ - public required SourceRef Source { get; init; } - - public required SourceContent Content { get; init; } -} +namespace Deal.Modules.Cards.Application.Sources; + +/// +/// Единый контракт входящей записи: источник + содержимое. +/// +public sealed record SourceItem +{ + public required SourceRef Source { get; init; } + + public required SourceContent Content { get; init; } +} diff --git a/src/core/Deal.Modules.Cards/Application/Sources/SourceRef.cs b/src/core/Deal.Modules.Cards/Application/Sources/SourceRef.cs index a8f1c1e..7f03c8f 100644 --- a/src/core/Deal.Modules.Cards/Application/Sources/SourceRef.cs +++ b/src/core/Deal.Modules.Cards/Application/Sources/SourceRef.cs @@ -1,47 +1,47 @@ -namespace Deal.Modules.Cards.Application.Sources; - -/// -/// Источник данных: откуда пришла запись. -/// -public sealed record SourceRef -{ - /// - /// Дискриминатор источника (значение задаёт сервис-владелец). - /// - public required string Kind { get; init; } - - /// - /// Идентификатор записи в источнике (сообщение/строка/файл). - /// - public string? ExternalId { get; init; } - - /// - /// Подпись источника для интерфейса. - /// - public string? DisplayName { get; init; } - - /// - /// Ссылка на оригинал (url, deep-link, путь). - /// - public string? OriginRef { get; init; } - - public string? Author { get; init; } - - public DateTimeOffset ReceivedAt { get; init; } - - /// - /// Прочие метаданные источника. - /// - public IReadOnlyDictionary? Extra { get; init; } -} - -/// -/// Дискриминаторы источников, владельцем которых является модуль. -/// -public static class SourceKinds -{ - /// - /// Запись создана вручную в системе. - /// - public const string Local = "local"; -} +namespace Deal.Modules.Cards.Application.Sources; + +/// +/// Источник данных: откуда пришла запись. +/// +public sealed record SourceRef +{ + /// + /// Дискриминатор источника (значение задаёт сервис-владелец). + /// + public required string Kind { get; init; } + + /// + /// Идентификатор записи в источнике (сообщение/строка/файл). + /// + public string? ExternalId { get; init; } + + /// + /// Подпись источника для интерфейса. + /// + public string? DisplayName { get; init; } + + /// + /// Ссылка на оригинал (url, deep-link, путь). + /// + public string? OriginRef { get; init; } + + public string? Author { get; init; } + + public DateTimeOffset ReceivedAt { get; init; } + + /// + /// Прочие метаданные источника. + /// + public IReadOnlyDictionary? Extra { get; init; } +} + +/// +/// Дискриминаторы источников, владельцем которых является модуль. +/// +public static class SourceKinds +{ + /// + /// Запись создана вручную в системе. + /// + public const string Local = "local"; +} diff --git a/src/core/Deal.Modules.Cards/Application/Sources/SourceRefs.cs b/src/core/Deal.Modules.Cards/Application/Sources/SourceRefs.cs index 1eefc8f..2a12326 100644 --- a/src/core/Deal.Modules.Cards/Application/Sources/SourceRefs.cs +++ b/src/core/Deal.Modules.Cards/Application/Sources/SourceRefs.cs @@ -1,47 +1,47 @@ -namespace Deal.Modules.Cards.Application.Sources; - -/// -/// Общие значения и производные поля ссылки на источник. -/// -public static class SourceRefs -{ - /// - /// Цвет источника по умолчанию. - /// - public const string DefaultHue = "#666"; - - /// - /// Ключ прочих метаданных с цветом источника. - /// - public const string HueKey = "hue"; - - /// - /// Пустая ссылка на источник. - /// - public static SourceRef Empty { get; } = new() { Kind = string.Empty }; - - /// - /// Ключ уникальности записи в источнике: вид + оригинал + внешний id. - /// - /// Ссылка на источник. - /// Стабильный ключ для дедупликации. - public static string DedupeKey(this SourceRef source) => - string.Concat(source.Kind, "|", source.OriginRef ?? string.Empty, "|", source.ExternalId ?? string.Empty); - - /// - /// Цвет источника для интерфейса. - /// - /// Ссылка на источник. - /// Цвет из метаданных либо цвет по умолчанию. - public static string ResolveHue(this SourceRef source) - { - if (source.Extra is not null - && source.Extra.TryGetValue(HueKey, out string? hue) - && !string.IsNullOrWhiteSpace(hue)) - { - return hue; - } - - return DefaultHue; - } -} +namespace Deal.Modules.Cards.Application.Sources; + +/// +/// Общие значения и производные поля ссылки на источник. +/// +public static class SourceRefs +{ + /// + /// Цвет источника по умолчанию. + /// + public const string DefaultHue = "#666"; + + /// + /// Ключ прочих метаданных с цветом источника. + /// + public const string HueKey = "hue"; + + /// + /// Пустая ссылка на источник. + /// + public static SourceRef Empty { get; } = new() { Kind = string.Empty }; + + /// + /// Ключ уникальности записи в источнике: вид + оригинал + внешний id. + /// + /// Ссылка на источник. + /// Стабильный ключ для дедупликации. + public static string DedupeKey(this SourceRef source) => + string.Concat(source.Kind, "|", source.OriginRef ?? string.Empty, "|", source.ExternalId ?? string.Empty); + + /// + /// Цвет источника для интерфейса. + /// + /// Ссылка на источник. + /// Цвет из метаданных либо цвет по умолчанию. + public static string ResolveHue(this SourceRef source) + { + if (source.Extra is not null + && source.Extra.TryGetValue(HueKey, out string? hue) + && !string.IsNullOrWhiteSpace(hue)) + { + return hue; + } + + return DefaultHue; + } +} diff --git a/src/core/Deal.Modules.Cards/Deal.Modules.Cards.csproj b/src/core/Deal.Modules.Cards/Deal.Modules.Cards.csproj index be68ec1..d4b0698 100644 --- a/src/core/Deal.Modules.Cards/Deal.Modules.Cards.csproj +++ b/src/core/Deal.Modules.Cards/Deal.Modules.Cards.csproj @@ -1,18 +1,18 @@ - - - - - - - - - - - - - net10.0 - enable - enable - - - + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryStore.cs b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryStore.cs index 5f7f85d..f5f65c0 100644 --- a/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryStore.cs +++ b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryStore.cs @@ -1,266 +1,266 @@ -using Deal.Modules.Discovery.Application.Models; - -namespace Deal.Modules.Discovery.Application.Abstractions; - -/// -/// Порт хранилища Discovery -/// -public interface IDiscoveryStore -{ - - /// - /// Все задачи, старые первыми. - /// - /// Задачи в порядке создания; пусто — задач нет. - public Task> ListTasksAsync(CancellationToken ct); - - /// - /// Одна задача по id. - /// - /// Id задачи (dt_...). - /// Задача или null, если строки нет. - public Task GetTaskAsync(string taskId, CancellationToken ct); - - /// - /// Создаёт задачу из полной записи. - /// - /// Полное состояние новой задачи (id сгенерирован модулем, см. ). - public Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct); - - /// - /// Точечная правка полей по присутствующим в патче + bump UpdatedAt. - /// - /// Id задачи (dt_...). - /// Изменяемые поля (null — поле не меняется; keywords — полная замена JSON-массива). - /// True — строка обновлена; false — задачи нет (404-семантика сервиса). - public Task PatchTaskAsync( - string taskId, - DiscoveryTaskPatch patch, - CancellationToken ct); - - /// - /// Удаляет задачу вместе с её кандидатами и логом. - /// - /// Id задачи (dt_...). - /// True — задача была и удалена; false — строки нет. - public Task DeleteTaskAsync(string taskId, CancellationToken ct); - - /// - /// Переводит задачу в running. - /// - /// Id задачи (dt_...). - /// True — повторный старт терминальной (done/failed): search_idx=0, search_done=false, счётчики found/evaluated/joined/rejected=0; False — draft/paused: только статус. - /// True — задача обновлена; false — строки нет. - public Task SetTaskRunningAsync( - string taskId, - bool resetProgress, - CancellationToken ct); - - /// - /// Переводит задачу в paused. - /// - /// Id задачи (dt_...). - /// True — задача обновлена; false — строки нет. - public Task SetTaskPausedAsync(string taskId, CancellationToken ct); - - /// - /// Переводит задачу в done. - /// - /// Id задачи (dt_...). - /// True — задача обновлена; false — строки нет. - public Task SetTaskDoneAsync(string taskId, CancellationToken ct); - - /// - /// Увеличивает счётчик прогресса задачи. - /// - /// Id задачи (dt_...). - /// Счётчик (found/evaluated/joined/rejected). - /// Приращение (вызывающий передаёт ≥1; ≤0 — no-op, как max(0, n)). - /// True — задача обновлена; false — строки нет. - public Task BumpTaskCounterAsync( - string taskId, - DiscoveryCounterField field, - int n, - CancellationToken ct); - - /// - /// Продвигает индекс поиска. - /// - /// Id задачи (dt_...). - /// Новый search_idx (текущий + 1; считает сервис). - /// search_done = nextIndex ≥ keywords.Count (считает сервис). - /// True — задача обновлена; false — строки нет. - public Task AdvanceSearchAsync( - string taskId, - int nextIndex, - bool searchDone, - CancellationToken ct); - - /// - /// Сумма plan_joins активных задач. - /// - /// Id задачи, исключаемой из суммы (patch-рост плана); null — все активные. - /// Занятый бюджет авто-вступлений (0 — активных задач нет). - public Task SumActivePlanAsync(string? excludeTaskId, CancellationToken ct); - - - /// - /// Кандидаты задачи, старые первыми; status — фильтр. - /// - /// Id задачи (dt_...). - /// Статус-фильтр (см. ); null — все статусы. - /// Кандидаты задачи (marks/topics — типизированными списками); пусто — кандидатов нет. - public Task> ListCandidatesAsync( - string taskId, - string? status, - CancellationToken ct); - - /// - /// Кандидат по dialog_id. - /// - /// Подписанный id источника. - /// Кандидат или null, если строки нет. - public Task GetCandidateAsync(string dialogId, CancellationToken ct); - - /// - /// Считает события лога по типу с CreatedAt ≥ sinceUtc. - /// - /// Событие (см. ; воркер считает «join_auto»). - /// Нижняя граница CreatedAt (UTC; бан-гард передаёт начало текущих суток). - /// Число событий с начала суток (0 — событий нет). - public Task CountLogEventAsync( - string logEvent, - DateTimeOffset sinceUtc, - CancellationToken ct); - - /// - /// Увеличивает join_failures кандидата, если запись жива и в статусе review. - /// - /// Подписанный id источника. - /// Новое значение счётчика; null — записи нет/не в review (счётчик не трогается). - public Task IncrementJoinFailuresAsync(string dialogId, CancellationToken ct); - - /// - /// Проверка «уже мониторится - /// - /// Подписанный id источника. - /// True — источник есть в Dialogs (добавление пропускается с логом skip). - public Task IsDialogMonitoredAsync(string dialogId, CancellationToken ct); - - /// - /// Проверка «источник в чёрном списке». - /// - /// Подписанный id источника. - /// True — источник есть в DiscBlacklist. - public Task IsBlacklistedAsync(string dialogId, CancellationToken ct); - - /// - /// Добавляет кандидата со служебными дефолтами. - /// - /// Новые значения кандидата (см. ). - public Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct); - - /// - /// Удаляет кандидата по dialog_id. - /// - /// Подписанный id источника. - public Task DeleteCandidateAsync(string dialogId, CancellationToken ct); - - /// - /// Правка полей кандидата. - /// - /// Подписанный id источника. - /// Изменяемые поля (null — поле не меняется; marks/topics — полная замена JSON). - /// True — строка обновлена; false — кандидата нет. - public Task PatchCandidateAsync( - string dialogId, - DiscoveryCandidatePatch patch, - CancellationToken ct); - - /// - /// Переводит кандидата в new/review. - /// - /// Подписанный id источника. - /// Новый статус: new|review (валидирует сервис). - /// True — строка обновлена; false — кандидата нет. - public Task SetCandidateStatusAsync( - string dialogId, - string status, - CancellationToken ct); - - /// - /// Ставит кандидату joined + auto_joined. - /// - /// Подписанный id источника. - /// True — авто-вступление воркера; false — ручное (join из UI). - /// True — строка обновлена; false — кандидата нет. - public Task SetCandidateJoinedAsync( - string dialogId, - bool autoJoined, - CancellationToken ct); - - /// - /// Ставит кандидату rejected. - /// - /// Подписанный id источника. - /// True — строка обновлена; false — кандидата нет. - public Task SetCandidateRejectedAsync(string dialogId, CancellationToken ct); - - - /// - /// Помечает источник в чёрном списке. - /// - /// Подписанный id источника. - /// Имя источника (вызывающий передаёт нормализованное: пусто → DialogId). - /// Причина добавления. - public Task UpsertBlacklistAsync( - string dialogId, - string name, - string reason, - CancellationToken ct); - - /// - /// Снимает источник с чёрного списка. - /// - /// Подписанный id источника. - public Task RemoveBlacklistAsync(string dialogId, CancellationToken ct); - - /// - /// Запись чёрного списка по dialog_id. - /// - /// Подписанный id источника. - /// Запись или null, если строки нет. - public Task GetBlacklistAsync(string dialogId, CancellationToken ct); - - /// - /// Весь чёрный список, новые записи первыми. - /// - /// Записи от новых к старым; пусто — список пуст. - public Task> ListBlacklistAsync(CancellationToken ct); - - - /// - /// Пишет строку лога. - /// - /// Готовый id записи (префикс dl_). - /// Id задачи поиска. - /// Событие (см. ). - /// Текст/детали события. - public Task AddLogAsync( - string logId, - string taskId, - string logEvent, - string text, - CancellationToken ct); - - /// - /// Последние события задачи, новые сверху. - /// - /// Id задачи поиска. - /// Сколько последних записей (кламп 1..500 выполняет сервис; дефолт 100). - /// События задачи от новых к старым; пусто — лога нет. - public Task> ListTaskLogAsync( - string taskId, - int limit, - CancellationToken ct); -} +using Deal.Modules.Discovery.Application.Models; + +namespace Deal.Modules.Discovery.Application.Abstractions; + +/// +/// Порт хранилища Discovery +/// +public interface IDiscoveryStore +{ + + /// + /// Все задачи, старые первыми. + /// + /// Задачи в порядке создания; пусто — задач нет. + public Task> ListTasksAsync(CancellationToken ct); + + /// + /// Одна задача по id. + /// + /// Id задачи (dt_...). + /// Задача или null, если строки нет. + public Task GetTaskAsync(string taskId, CancellationToken ct); + + /// + /// Создаёт задачу из полной записи. + /// + /// Полное состояние новой задачи (id сгенерирован модулем, см. ). + public Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct); + + /// + /// Точечная правка полей по присутствующим в патче + bump UpdatedAt. + /// + /// Id задачи (dt_...). + /// Изменяемые поля (null — поле не меняется; keywords — полная замена JSON-массива). + /// True — строка обновлена; false — задачи нет (404-семантика сервиса). + public Task PatchTaskAsync( + string taskId, + DiscoveryTaskPatch patch, + CancellationToken ct); + + /// + /// Удаляет задачу вместе с её кандидатами и логом. + /// + /// Id задачи (dt_...). + /// True — задача была и удалена; false — строки нет. + public Task DeleteTaskAsync(string taskId, CancellationToken ct); + + /// + /// Переводит задачу в running. + /// + /// Id задачи (dt_...). + /// True — повторный старт терминальной (done/failed): search_idx=0, search_done=false, счётчики found/evaluated/joined/rejected=0; False — draft/paused: только статус. + /// True — задача обновлена; false — строки нет. + public Task SetTaskRunningAsync( + string taskId, + bool resetProgress, + CancellationToken ct); + + /// + /// Переводит задачу в paused. + /// + /// Id задачи (dt_...). + /// True — задача обновлена; false — строки нет. + public Task SetTaskPausedAsync(string taskId, CancellationToken ct); + + /// + /// Переводит задачу в done. + /// + /// Id задачи (dt_...). + /// True — задача обновлена; false — строки нет. + public Task SetTaskDoneAsync(string taskId, CancellationToken ct); + + /// + /// Увеличивает счётчик прогресса задачи. + /// + /// Id задачи (dt_...). + /// Счётчик (found/evaluated/joined/rejected). + /// Приращение (вызывающий передаёт ≥1; ≤0 — no-op, как max(0, n)). + /// True — задача обновлена; false — строки нет. + public Task BumpTaskCounterAsync( + string taskId, + DiscoveryCounterField field, + int n, + CancellationToken ct); + + /// + /// Продвигает индекс поиска. + /// + /// Id задачи (dt_...). + /// Новый search_idx (текущий + 1; считает сервис). + /// search_done = nextIndex ≥ keywords.Count (считает сервис). + /// True — задача обновлена; false — строки нет. + public Task AdvanceSearchAsync( + string taskId, + int nextIndex, + bool searchDone, + CancellationToken ct); + + /// + /// Сумма plan_joins активных задач. + /// + /// Id задачи, исключаемой из суммы (patch-рост плана); null — все активные. + /// Занятый бюджет авто-вступлений (0 — активных задач нет). + public Task SumActivePlanAsync(string? excludeTaskId, CancellationToken ct); + + + /// + /// Кандидаты задачи, старые первыми; status — фильтр. + /// + /// Id задачи (dt_...). + /// Статус-фильтр (см. ); null — все статусы. + /// Кандидаты задачи (marks/topics — типизированными списками); пусто — кандидатов нет. + public Task> ListCandidatesAsync( + string taskId, + string? status, + CancellationToken ct); + + /// + /// Кандидат по dialog_id. + /// + /// Подписанный id источника. + /// Кандидат или null, если строки нет. + public Task GetCandidateAsync(string dialogId, CancellationToken ct); + + /// + /// Считает события лога по типу с CreatedAt ≥ sinceUtc. + /// + /// Событие (см. ; воркер считает «join_auto»). + /// Нижняя граница CreatedAt (UTC; бан-гард передаёт начало текущих суток). + /// Число событий с начала суток (0 — событий нет). + public Task CountLogEventAsync( + string logEvent, + DateTimeOffset sinceUtc, + CancellationToken ct); + + /// + /// Увеличивает join_failures кандидата, если запись жива и в статусе review. + /// + /// Подписанный id источника. + /// Новое значение счётчика; null — записи нет/не в review (счётчик не трогается). + public Task IncrementJoinFailuresAsync(string dialogId, CancellationToken ct); + + /// + /// Проверка «уже мониторится + /// + /// Подписанный id источника. + /// True — источник есть в Dialogs (добавление пропускается с логом skip). + public Task IsDialogMonitoredAsync(string dialogId, CancellationToken ct); + + /// + /// Проверка «источник в чёрном списке». + /// + /// Подписанный id источника. + /// True — источник есть в DiscBlacklist. + public Task IsBlacklistedAsync(string dialogId, CancellationToken ct); + + /// + /// Добавляет кандидата со служебными дефолтами. + /// + /// Новые значения кандидата (см. ). + public Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct); + + /// + /// Удаляет кандидата по dialog_id. + /// + /// Подписанный id источника. + public Task DeleteCandidateAsync(string dialogId, CancellationToken ct); + + /// + /// Правка полей кандидата. + /// + /// Подписанный id источника. + /// Изменяемые поля (null — поле не меняется; marks/topics — полная замена JSON). + /// True — строка обновлена; false — кандидата нет. + public Task PatchCandidateAsync( + string dialogId, + DiscoveryCandidatePatch patch, + CancellationToken ct); + + /// + /// Переводит кандидата в new/review. + /// + /// Подписанный id источника. + /// Новый статус: new|review (валидирует сервис). + /// True — строка обновлена; false — кандидата нет. + public Task SetCandidateStatusAsync( + string dialogId, + string status, + CancellationToken ct); + + /// + /// Ставит кандидату joined + auto_joined. + /// + /// Подписанный id источника. + /// True — авто-вступление воркера; false — ручное (join из UI). + /// True — строка обновлена; false — кандидата нет. + public Task SetCandidateJoinedAsync( + string dialogId, + bool autoJoined, + CancellationToken ct); + + /// + /// Ставит кандидату rejected. + /// + /// Подписанный id источника. + /// True — строка обновлена; false — кандидата нет. + public Task SetCandidateRejectedAsync(string dialogId, CancellationToken ct); + + + /// + /// Помечает источник в чёрном списке. + /// + /// Подписанный id источника. + /// Имя источника (вызывающий передаёт нормализованное: пусто → DialogId). + /// Причина добавления. + public Task UpsertBlacklistAsync( + string dialogId, + string name, + string reason, + CancellationToken ct); + + /// + /// Снимает источник с чёрного списка. + /// + /// Подписанный id источника. + public Task RemoveBlacklistAsync(string dialogId, CancellationToken ct); + + /// + /// Запись чёрного списка по dialog_id. + /// + /// Подписанный id источника. + /// Запись или null, если строки нет. + public Task GetBlacklistAsync(string dialogId, CancellationToken ct); + + /// + /// Весь чёрный список, новые записи первыми. + /// + /// Записи от новых к старым; пусто — список пуст. + public Task> ListBlacklistAsync(CancellationToken ct); + + + /// + /// Пишет строку лога. + /// + /// Готовый id записи (префикс dl_). + /// Id задачи поиска. + /// Событие (см. ). + /// Текст/детали события. + public Task AddLogAsync( + string logId, + string taskId, + string logEvent, + string text, + CancellationToken ct); + + /// + /// Последние события задачи, новые сверху. + /// + /// Id задачи поиска. + /// Сколько последних записей (кламп 1..500 выполняет сервис; дефолт 100). + /// События задачи от новых к старым; пусто — лога нет. + public Task> ListTaskLogAsync( + string taskId, + int limit, + CancellationToken ct); +} diff --git a/src/core/Deal.Modules.Discovery/Application/Extensions/DiscoveryTaskPatchExtensions.cs b/src/core/Deal.Modules.Discovery/Application/Extensions/DiscoveryTaskPatchExtensions.cs index 1893972..d81eb85 100644 --- a/src/core/Deal.Modules.Discovery/Application/Extensions/DiscoveryTaskPatchExtensions.cs +++ b/src/core/Deal.Modules.Discovery/Application/Extensions/DiscoveryTaskPatchExtensions.cs @@ -1,18 +1,18 @@ -using Deal.Modules.Discovery.Application.Models; - -namespace Deal.Modules.Discovery.Application.Extensions; - -internal static class DiscoveryTaskPatchExtensions -{ - /// - /// Содержит ли патч хотя бы одно изменяемое поле. - /// - /// Нормализованный патч. - /// True — есть поле к записи. - public static bool HasChanges(this DiscoveryTaskPatch patch) - { - return patch.Name is not null || patch.Description is not null || patch.Keywords is not null - || patch.MinSubscribers is not null || patch.Lang is not null || patch.Threshold is not null - || patch.SampleSize is not null || patch.PlanJoins is not null || patch.AutoJoin is not null; - } -} +using Deal.Modules.Discovery.Application.Models; + +namespace Deal.Modules.Discovery.Application.Extensions; + +internal static class DiscoveryTaskPatchExtensions +{ + /// + /// Содержит ли патч хотя бы одно изменяемое поле. + /// + /// Нормализованный патч. + /// True — есть поле к записи. + public static bool HasChanges(this DiscoveryTaskPatch patch) + { + return patch.Name is not null || patch.Description is not null || patch.Keywords is not null + || patch.MinSubscribers is not null || patch.Lang is not null || patch.Threshold is not null + || patch.SampleSize is not null || patch.PlanJoins is not null || patch.AutoJoin is not null; + } +} diff --git a/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs b/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs index 1da58be..d87810f 100644 --- a/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs +++ b/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs @@ -1,34 +1,34 @@ -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Modules.Discovery.Application.Registrars; - -/// -/// DI-регистрация модуля Discovery. -/// -public static class DiscoveryModuleRegistrar -{ - /// - /// Регистрирует сервисы модуля Discovery в контейнере. - /// - public static IServiceCollection AddDiscoveryModule(this IServiceCollection services) - { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(sp => new DiscoveryBanGuard( - sp.GetRequiredService(), - sp.GetRequiredService())); - services.AddScoped(); - return services; - } -} +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Modules.Discovery.Application.Registrars; + +/// +/// DI-регистрация модуля Discovery. +/// +public static class DiscoveryModuleRegistrar +{ + /// + /// Регистрирует сервисы модуля Discovery в контейнере. + /// + public static IServiceCollection AddDiscoveryModule(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(sp => new DiscoveryBanGuard( + sp.GetRequiredService(), + sp.GetRequiredService())); + services.AddScoped(); + return services; + } +} diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs index f4e4ae5..ef39851 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs @@ -1,95 +1,95 @@ -using System.Text.Json; -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Discovery.Application.Services; - -/// -/// Бан-гард авто-вступлений Discovery -/// -public sealed class DiscoveryBanGuard -{ - private readonly IDiscoveryStore _store; - private readonly ISettingsStore _settings; - private readonly Func _utcNow; - - /// - /// Создаёт бан-гард. - /// - /// Хранилище Discovery (счётчик DiscLog за сутки). - /// KV-настройки тенанта (discJoinLimit/discFloodDay/discPaused). - /// Источник текущего времени (UTC; тесты передают фиксированные «часы», эталон FakeDiscoveryStore). По умолчанию — . - public DiscoveryBanGuard( - IDiscoveryStore store, - ISettingsStore settings, - Func? utcNow = null) - { - ArgumentNullException.ThrowIfNull(store); - ArgumentNullException.ThrowIfNull(settings); - _store = store; - _settings = settings; - _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow); - } - - /// - /// Ручной стоп-кран авто-вступлений. - /// - /// True — вступления на паузе (воркер не делает сетевых шагов). - public async Task GlobalPausedAsync(CancellationToken ct) - { - TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct); - return settingsSnapshot.GetBool(SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused); - } - - /// - /// Авто-вступления за текущие UTC-сутки. - /// - /// Число авто-вступлений сегодня (0 — вступлений нет). - public Task JoinsTodayAutoAsync(CancellationToken ct) - => _store.CountLogEventAsync(DiscoveryLogEvents.JoinAuto, StartOfDayUtc(), ct); - - /// - /// Была ли flood-блокировка в текущие UTC-сутки. - /// - /// True — фиксировалась сегодня (стоп до конца суток). - public async Task FloodTodayAsync(CancellationToken ct) - { - TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct); - return IsFloodToday(snapshot.GetLong(SettingsKeys.DiscFloodDay, 0), StartOfDayMs()); - } - - /// - /// Разрешено ли авто-вступление - /// - /// True — следующее авто-вступление допустимо. - public async Task CanAutoJoinAsync(CancellationToken ct) - { - // Один типизированный снимок настроек на вердикт (C30): discJoinLimit/discFloodDay/discPaused - // читаются одним GetAllAsync вместо трёх GetAsync. - TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct); - int limit = snapshot.GetInt(SettingsKeys.DiscJoinLimit, SettingsDefaults.DiscJoinLimit); - int joined = await JoinsTodayAutoAsync(ct).ConfigureAwait(false); - bool floodToday = IsFloodToday(snapshot.GetLong(SettingsKeys.DiscFloodDay, 0), StartOfDayMs()); - bool paused = snapshot.GetBool(SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused); - return joined < Math.Max(1, limit) && !floodToday && !paused; - } - - /// - /// Фиксирует FloodWait - /// - public Task NoteFloodAsync(CancellationToken ct) - => _settings.SetAsync(SettingsKeys.DiscFloodDay, JsonSerializer.Serialize(StartOfDayMs()), ct); - - private DateTimeOffset StartOfDayUtc() - { - DateTimeOffset now = _utcNow().ToUniversalTime(); - return new DateTimeOffset(now.Year, now.Month, now.Day, 0, 0, 0, TimeSpan.Zero); - } - - private long StartOfDayMs() => StartOfDayUtc().ToUnixTimeMilliseconds(); - - private static bool IsFloodToday(long floodDay, long startOfDayMs) - => floodDay != 0 && floodDay == startOfDayMs; -} +using System.Text.Json; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Discovery.Application.Services; + +/// +/// Бан-гард авто-вступлений Discovery +/// +public sealed class DiscoveryBanGuard +{ + private readonly IDiscoveryStore _store; + private readonly ISettingsStore _settings; + private readonly Func _utcNow; + + /// + /// Создаёт бан-гард. + /// + /// Хранилище Discovery (счётчик DiscLog за сутки). + /// KV-настройки тенанта (discJoinLimit/discFloodDay/discPaused). + /// Источник текущего времени (UTC; тесты передают фиксированные «часы», эталон FakeDiscoveryStore). По умолчанию — . + public DiscoveryBanGuard( + IDiscoveryStore store, + ISettingsStore settings, + Func? utcNow = null) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(settings); + _store = store; + _settings = settings; + _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow); + } + + /// + /// Ручной стоп-кран авто-вступлений. + /// + /// True — вступления на паузе (воркер не делает сетевых шагов). + public async Task GlobalPausedAsync(CancellationToken ct) + { + TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct); + return settingsSnapshot.GetBool(SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused); + } + + /// + /// Авто-вступления за текущие UTC-сутки. + /// + /// Число авто-вступлений сегодня (0 — вступлений нет). + public Task JoinsTodayAutoAsync(CancellationToken ct) + => _store.CountLogEventAsync(DiscoveryLogEvents.JoinAuto, StartOfDayUtc(), ct); + + /// + /// Была ли flood-блокировка в текущие UTC-сутки. + /// + /// True — фиксировалась сегодня (стоп до конца суток). + public async Task FloodTodayAsync(CancellationToken ct) + { + TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct); + return IsFloodToday(snapshot.GetLong(SettingsKeys.DiscFloodDay, 0), StartOfDayMs()); + } + + /// + /// Разрешено ли авто-вступление + /// + /// True — следующее авто-вступление допустимо. + public async Task CanAutoJoinAsync(CancellationToken ct) + { + // Один типизированный снимок настроек на вердикт (C30): discJoinLimit/discFloodDay/discPaused + // читаются одним GetAllAsync вместо трёх GetAsync. + TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct); + int limit = snapshot.GetInt(SettingsKeys.DiscJoinLimit, SettingsDefaults.DiscJoinLimit); + int joined = await JoinsTodayAutoAsync(ct).ConfigureAwait(false); + bool floodToday = IsFloodToday(snapshot.GetLong(SettingsKeys.DiscFloodDay, 0), StartOfDayMs()); + bool paused = snapshot.GetBool(SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused); + return joined < Math.Max(1, limit) && !floodToday && !paused; + } + + /// + /// Фиксирует FloodWait + /// + public Task NoteFloodAsync(CancellationToken ct) + => _settings.SetAsync(SettingsKeys.DiscFloodDay, JsonSerializer.Serialize(StartOfDayMs()), ct); + + private DateTimeOffset StartOfDayUtc() + { + DateTimeOffset now = _utcNow().ToUniversalTime(); + return new DateTimeOffset(now.Year, now.Month, now.Day, 0, 0, 0, TimeSpan.Zero); + } + + private long StartOfDayMs() => StartOfDayUtc().ToUnixTimeMilliseconds(); + + private static bool IsFloodToday(long floodDay, long startOfDayMs) + => floodDay != 0 && floodDay == startOfDayMs; +} diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBlacklistService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBlacklistService.cs index 9bd875f..c3c30d8 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBlacklistService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBlacklistService.cs @@ -1,53 +1,53 @@ -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Models; - -namespace Deal.Modules.Discovery.Application.Services; - -/// -/// Сервис чёрного списка Discovery — добавление/снятие/список. -/// -public sealed class DiscoveryBlacklistService(IDiscoveryStore store) -{ - /// - /// Помечает источник в чёрном списке. - /// - /// Подписанный id источника. - /// Имя источника. - /// Причина добавления («отклонено вручную», метка воркера). - /// Запись чёрного списка (echo после upsert; CreatedAt — первое добавление). - public async Task AddAsync( - string dialogId, - string name, - string reason, - CancellationToken ct) - { - string normalizedName = (name ?? string.Empty).Trim(); - if (normalizedName.Length == 0) - { - normalizedName = dialogId; - } - - await store.UpsertBlacklistAsync(dialogId, normalizedName, reason ?? string.Empty, ct).ConfigureAwait(false); - return await store.GetBlacklistAsync(dialogId, ct).ConfigureAwait(false) - ?? throw new InvalidOperationException("add_blacklist: строка не прочиталась после upsert"); - } - - /// - /// Снимает источник с чёрного списка. - /// - /// Подписанный id источника. - /// Завершается после удаления строки. - public Task RemoveAsync(string dialogId, CancellationToken ct) - { - return store.RemoveBlacklistAsync(dialogId, ct); - } - - /// - /// Весь чёрный список, новые записи первыми. - /// - /// Записи от новых к старым. - public Task> ListAsync(CancellationToken ct) - { - return store.ListBlacklistAsync(ct); - } -} +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Models; + +namespace Deal.Modules.Discovery.Application.Services; + +/// +/// Сервис чёрного списка Discovery — добавление/снятие/список. +/// +public sealed class DiscoveryBlacklistService(IDiscoveryStore store) +{ + /// + /// Помечает источник в чёрном списке. + /// + /// Подписанный id источника. + /// Имя источника. + /// Причина добавления («отклонено вручную», метка воркера). + /// Запись чёрного списка (echo после upsert; CreatedAt — первое добавление). + public async Task AddAsync( + string dialogId, + string name, + string reason, + CancellationToken ct) + { + string normalizedName = (name ?? string.Empty).Trim(); + if (normalizedName.Length == 0) + { + normalizedName = dialogId; + } + + await store.UpsertBlacklistAsync(dialogId, normalizedName, reason ?? string.Empty, ct).ConfigureAwait(false); + return await store.GetBlacklistAsync(dialogId, ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("add_blacklist: строка не прочиталась после upsert"); + } + + /// + /// Снимает источник с чёрного списка. + /// + /// Подписанный id источника. + /// Завершается после удаления строки. + public Task RemoveAsync(string dialogId, CancellationToken ct) + { + return store.RemoveBlacklistAsync(dialogId, ct); + } + + /// + /// Весь чёрный список, новые записи первыми. + /// + /// Записи от новых к старым. + public Task> ListAsync(CancellationToken ct) + { + return store.ListBlacklistAsync(ct); + } +} diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryCandidatesService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryCandidatesService.cs index 2629914..ecabc16 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryCandidatesService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryCandidatesService.cs @@ -1,309 +1,309 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Models; - -namespace Deal.Modules.Discovery.Application.Services; - -/// -/// Сервис кандидатов Discovery — add с исключениями, set_candidate, review-перевод, mark_joined/rejected, delete. -/// -public sealed class DiscoveryCandidatesService( - IDiscoveryStore store, - DiscoveryLogService log, - DiscoveryBlacklistService blacklist) -{ - /// - /// 400 mark_rejected - /// - public const string RejectJoinedDetail = "Нельзя отклонить источник, в который уже вступили"; - - /// - /// 400 set_candidate_status - /// - public const string TransitionNotAllowedFormat = "Статус {0} выставляется через mark_joined/mark_rejected"; - - private const string SkipMonitoredFormat = "пропущен {0}: источник уже мониторится (мы состоим)"; - - private const string SkipBlacklistedFormat = "пропущен {0}: источник в чёрном списке"; - - private const string SkipActiveFormat = "пропущен {0}: кандидат уже есть (статус {1})"; - - private const string ReviewLogFormat = "кандидат {0} переведён в review"; - - private const string JoinedLogFormat = "вступили в {0}"; - - private const string RejectedLogFormat = "отклонён {0}"; - - /// - /// Кандидаты задачи, старые первыми. - /// - /// Id задачи (dt_...). - /// Статус-фильтр (new|review|joined|rejected); null — все. - /// Кандидаты задачи (marks/topics — списками). - public Task> ListAsync( - string taskId, - string? status, - CancellationToken ct) - { - return store.ListCandidatesAsync(taskId, status, ct); - } - - /// - /// Кандидат по dialog_id. - /// - /// Подписанный id источника. - /// Кандидат или null (404 «Кандидат не найден»). - public Task GetAsync(string dialogId, CancellationToken ct) - { - return store.GetCandidateAsync(dialogId, ct); - } - - /// - /// Добавляет найденный источник как кандидата задачи. - /// - /// Id задачи (dt_...). - /// Подписанный id источника. - /// Имя источника (пустое → DialogId). - /// Username источника (пуст, если нет публичного). - /// Тип источника: channel|group|forum (пусто → channel). - /// Цвет источника (пусто → «#666»). - /// Новый кандидат либо null — источник пропущен (в лог записан skip). - public async Task AddAsync( - string taskId, - string dialogId, - string name, - string username, - string kind, - string hue, - CancellationToken ct) - { - DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); - if (task is null) - { - return null; - } - - if (await store.IsDialogMonitoredAsync(dialogId, ct).ConfigureAwait(false)) - { - await log.AddAsync(taskId, DiscoveryLogEvents.Skip, string.Format(SkipMonitoredFormat, dialogId), ct) - .ConfigureAwait(false); - return null; - } - - if (await store.IsBlacklistedAsync(dialogId, ct).ConfigureAwait(false)) - { - await log.AddAsync(taskId, DiscoveryLogEvents.Skip, string.Format(SkipBlacklistedFormat, dialogId), ct) - .ConfigureAwait(false); - return null; - } - - DiscoveryCandidateDto? existing = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); - if (existing is not null && DiscoveryCandidateStatuses.IsActive(existing.Status)) - { - await log.AddAsync( - taskId, - DiscoveryLogEvents.Skip, - string.Format(SkipActiveFormat, dialogId, existing.Status), - ct).ConfigureAwait(false); - return null; - } - - if (existing is not null) - { - await store.DeleteCandidateAsync(dialogId, ct).ConfigureAwait(false); - } - - var row = new DiscoveryCandidateRow - { - DialogId = dialogId, - TaskId = taskId, - Name = TrimOr(name, dialogId), - Username = TrimOr(username, string.Empty), - Kind = TrimOr(kind, DiscoveryCandidateKinds.Channel), - Hue = TrimOr(hue, SourceDefaults.DefaultHue), - }; - await store.CreateCandidateAsync(row, ct).ConfigureAwait(false); - await store.BumpTaskCounterAsync(taskId, DiscoveryCounterField.Found, 1, ct).ConfigureAwait(false); - return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); - } - - /// - /// Обновляет поля кандидата по результатам оценки. - /// - /// Id задачи. - /// Подписанный id источника. - /// Изменяемые поля (null — не менять). - /// Обновлённый кандидат либо null — задачи/кандидата нет (или кандидат другой задачи). - public async Task SetAsync( - string taskId, - string dialogId, - DiscoveryCandidatePatch patch, - CancellationToken ct) - { - DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); - DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); - if (task is null || current is null || current.TaskId != taskId) - { - return null; - } - - DiscoveryCandidatePatch normalized = NormalizePatch(patch, current); - bool hasChanges = normalized.Name is not null || normalized.Username is not null || normalized.Kind is not null - || normalized.Hue is not null || normalized.Participants is not null || normalized.LangRu is not null - || normalized.Marks is not null || normalized.Topics is not null || normalized.FitRatio is not null - || normalized.AutoJoined is not null; - if (!hasChanges) - { - return current; - } - - await store.PatchCandidateAsync(dialogId, normalized, ct).ConfigureAwait(false); - return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); - } - - /// - /// Переводит кандидата в new/review. - /// - /// Подписанный id источника. - /// Новый статус: new|review. - /// Обновлённый кандидат либо null (кандидата нет). - /// Статус не new/review. - public async Task SetStatusAsync( - string dialogId, - string status, - CancellationToken ct) - { - if (!DiscoveryCandidateStatuses.IsTransitionAllowed(status)) - { - throw new DiscoveryValidationException(string.Format(TransitionNotAllowedFormat, status)); - } - - DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); - if (current is null) - { - return null; - } - - await store.SetCandidateStatusAsync(dialogId, status, ct).ConfigureAwait(false); - if (status == DiscoveryCandidateStatuses.Review) - { - await log.AddAsync( - current.TaskId, - DiscoveryLogEvents.Review, - string.Format(ReviewLogFormat, dialogId), - ct).ConfigureAwait(false); - } - - return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); - } - - /// - /// Вступили в источник - /// - /// Подписанный id источника. - /// True — авто-вступление воркера; false — ручное. - /// Кандидат в joined либо null (кандидата нет). Повторный вызов для joined — идемпотентен. - public async Task MarkJoinedAsync( - string dialogId, - bool auto, - CancellationToken ct) - { - DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); - if (current is null) - { - return null; - } - - if (current.Status == DiscoveryCandidateStatuses.Joined) - { - return current; - } - - await store.SetCandidateJoinedAsync(dialogId, auto, ct).ConfigureAwait(false); - await store.BumpTaskCounterAsync(current.TaskId, DiscoveryCounterField.Joined, 1, ct).ConfigureAwait(false); - string logEvent = auto ? DiscoveryLogEvents.JoinAuto : DiscoveryLogEvents.JoinManual; - await log.AddAsync(current.TaskId, logEvent, string.Format(JoinedLogFormat, dialogId), ct).ConfigureAwait(false); - return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); - } - - /// - /// Отклоняет кандидата - /// - /// Подписанный id источника. - /// Причина отклонения (в лог/чёрный список; «отклонено вручную» — эндпоинт reject). - /// Кандидат в rejected либо null (кандидата нет). Повторный вызов для rejected — идемпотентен. - /// Источник уже joined — отклонить нельзя. - public async Task MarkRejectedAsync( - string dialogId, - string reason, - CancellationToken ct) - { - DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); - if (current is null) - { - return null; - } - - if (current.Status == DiscoveryCandidateStatuses.Joined) - { - throw new DiscoveryValidationException(RejectJoinedDetail); - } - - if (current.Status == DiscoveryCandidateStatuses.Rejected) - { - return current; - } - - await store.SetCandidateRejectedAsync(dialogId, ct).ConfigureAwait(false); - await store.BumpTaskCounterAsync(current.TaskId, DiscoveryCounterField.Rejected, 1, ct).ConfigureAwait(false); - string text = string.IsNullOrEmpty(reason) ? string.Format(RejectedLogFormat, dialogId) : reason; - await log.AddAsync(current.TaskId, DiscoveryLogEvents.Reject, text, ct).ConfigureAwait(false); - await blacklist.AddAsync(dialogId, current.Name, reason ?? string.Empty, ct).ConfigureAwait(false); - return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); - } - - /// - /// Удаляет кандидата. - /// - /// Подписанный id источника. - /// Завершается после удаления строки. - public Task DeleteAsync(string dialogId, CancellationToken ct) - { - return store.DeleteCandidateAsync(dialogId, ct); - } - - private static DiscoveryCandidatePatch NormalizePatch(DiscoveryCandidatePatch patch, DiscoveryCandidateDto current) - { - return new DiscoveryCandidatePatch - { - Name = NormalizeKeptOr(patch.Name, current.Name), - Username = NormalizeKeptOr(patch.Username, current.Username), - Kind = NormalizeKeptOr(patch.Kind, current.Kind), - Hue = NormalizeKeptOr(patch.Hue, current.Hue), - Participants = patch.Participants, - LangRu = patch.LangRu, - Marks = patch.Marks?.Select(mark => (string)mark).ToList(), - Topics = patch.Topics?.ToList(), - FitRatio = patch.FitRatio, - AutoJoined = patch.AutoJoined, - }; - } - - private static string? NormalizeKeptOr(string? value, string current) - { - if (value is null) - { - return null; - } - - string trimmed = value.Trim(); - return trimmed.Length == 0 ? null : trimmed; - } - - private static string TrimOr(string value, string fallback) - { - string trimmed = (value ?? string.Empty).Trim(); - return trimmed.Length == 0 ? fallback : trimmed; - } -} +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Models; + +namespace Deal.Modules.Discovery.Application.Services; + +/// +/// Сервис кандидатов Discovery — add с исключениями, set_candidate, review-перевод, mark_joined/rejected, delete. +/// +public sealed class DiscoveryCandidatesService( + IDiscoveryStore store, + DiscoveryLogService log, + DiscoveryBlacklistService blacklist) +{ + /// + /// 400 mark_rejected + /// + public const string RejectJoinedDetail = "Нельзя отклонить источник, в который уже вступили"; + + /// + /// 400 set_candidate_status + /// + public const string TransitionNotAllowedFormat = "Статус {0} выставляется через mark_joined/mark_rejected"; + + private const string SkipMonitoredFormat = "пропущен {0}: источник уже мониторится (мы состоим)"; + + private const string SkipBlacklistedFormat = "пропущен {0}: источник в чёрном списке"; + + private const string SkipActiveFormat = "пропущен {0}: кандидат уже есть (статус {1})"; + + private const string ReviewLogFormat = "кандидат {0} переведён в review"; + + private const string JoinedLogFormat = "вступили в {0}"; + + private const string RejectedLogFormat = "отклонён {0}"; + + /// + /// Кандидаты задачи, старые первыми. + /// + /// Id задачи (dt_...). + /// Статус-фильтр (new|review|joined|rejected); null — все. + /// Кандидаты задачи (marks/topics — списками). + public Task> ListAsync( + string taskId, + string? status, + CancellationToken ct) + { + return store.ListCandidatesAsync(taskId, status, ct); + } + + /// + /// Кандидат по dialog_id. + /// + /// Подписанный id источника. + /// Кандидат или null (404 «Кандидат не найден»). + public Task GetAsync(string dialogId, CancellationToken ct) + { + return store.GetCandidateAsync(dialogId, ct); + } + + /// + /// Добавляет найденный источник как кандидата задачи. + /// + /// Id задачи (dt_...). + /// Подписанный id источника. + /// Имя источника (пустое → DialogId). + /// Username источника (пуст, если нет публичного). + /// Тип источника: channel|group|forum (пусто → channel). + /// Цвет источника (пусто → «#666»). + /// Новый кандидат либо null — источник пропущен (в лог записан skip). + public async Task AddAsync( + string taskId, + string dialogId, + string name, + string username, + string kind, + string hue, + CancellationToken ct) + { + DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); + if (task is null) + { + return null; + } + + if (await store.IsDialogMonitoredAsync(dialogId, ct).ConfigureAwait(false)) + { + await log.AddAsync(taskId, DiscoveryLogEvents.Skip, string.Format(SkipMonitoredFormat, dialogId), ct) + .ConfigureAwait(false); + return null; + } + + if (await store.IsBlacklistedAsync(dialogId, ct).ConfigureAwait(false)) + { + await log.AddAsync(taskId, DiscoveryLogEvents.Skip, string.Format(SkipBlacklistedFormat, dialogId), ct) + .ConfigureAwait(false); + return null; + } + + DiscoveryCandidateDto? existing = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); + if (existing is not null && DiscoveryCandidateStatuses.IsActive(existing.Status)) + { + await log.AddAsync( + taskId, + DiscoveryLogEvents.Skip, + string.Format(SkipActiveFormat, dialogId, existing.Status), + ct).ConfigureAwait(false); + return null; + } + + if (existing is not null) + { + await store.DeleteCandidateAsync(dialogId, ct).ConfigureAwait(false); + } + + var row = new DiscoveryCandidateRow + { + DialogId = dialogId, + TaskId = taskId, + Name = TrimOr(name, dialogId), + Username = TrimOr(username, string.Empty), + Kind = TrimOr(kind, DiscoveryCandidateKinds.Channel), + Hue = TrimOr(hue, SourceDefaults.DefaultHue), + }; + await store.CreateCandidateAsync(row, ct).ConfigureAwait(false); + await store.BumpTaskCounterAsync(taskId, DiscoveryCounterField.Found, 1, ct).ConfigureAwait(false); + return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); + } + + /// + /// Обновляет поля кандидата по результатам оценки. + /// + /// Id задачи. + /// Подписанный id источника. + /// Изменяемые поля (null — не менять). + /// Обновлённый кандидат либо null — задачи/кандидата нет (или кандидат другой задачи). + public async Task SetAsync( + string taskId, + string dialogId, + DiscoveryCandidatePatch patch, + CancellationToken ct) + { + DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); + DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); + if (task is null || current is null || current.TaskId != taskId) + { + return null; + } + + DiscoveryCandidatePatch normalized = NormalizePatch(patch, current); + bool hasChanges = normalized.Name is not null || normalized.Username is not null || normalized.Kind is not null + || normalized.Hue is not null || normalized.Participants is not null || normalized.LangRu is not null + || normalized.Marks is not null || normalized.Topics is not null || normalized.FitRatio is not null + || normalized.AutoJoined is not null; + if (!hasChanges) + { + return current; + } + + await store.PatchCandidateAsync(dialogId, normalized, ct).ConfigureAwait(false); + return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); + } + + /// + /// Переводит кандидата в new/review. + /// + /// Подписанный id источника. + /// Новый статус: new|review. + /// Обновлённый кандидат либо null (кандидата нет). + /// Статус не new/review. + public async Task SetStatusAsync( + string dialogId, + string status, + CancellationToken ct) + { + if (!DiscoveryCandidateStatuses.IsTransitionAllowed(status)) + { + throw new DiscoveryValidationException(string.Format(TransitionNotAllowedFormat, status)); + } + + DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); + if (current is null) + { + return null; + } + + await store.SetCandidateStatusAsync(dialogId, status, ct).ConfigureAwait(false); + if (status == DiscoveryCandidateStatuses.Review) + { + await log.AddAsync( + current.TaskId, + DiscoveryLogEvents.Review, + string.Format(ReviewLogFormat, dialogId), + ct).ConfigureAwait(false); + } + + return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); + } + + /// + /// Вступили в источник + /// + /// Подписанный id источника. + /// True — авто-вступление воркера; false — ручное. + /// Кандидат в joined либо null (кандидата нет). Повторный вызов для joined — идемпотентен. + public async Task MarkJoinedAsync( + string dialogId, + bool auto, + CancellationToken ct) + { + DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); + if (current is null) + { + return null; + } + + if (current.Status == DiscoveryCandidateStatuses.Joined) + { + return current; + } + + await store.SetCandidateJoinedAsync(dialogId, auto, ct).ConfigureAwait(false); + await store.BumpTaskCounterAsync(current.TaskId, DiscoveryCounterField.Joined, 1, ct).ConfigureAwait(false); + string logEvent = auto ? DiscoveryLogEvents.JoinAuto : DiscoveryLogEvents.JoinManual; + await log.AddAsync(current.TaskId, logEvent, string.Format(JoinedLogFormat, dialogId), ct).ConfigureAwait(false); + return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); + } + + /// + /// Отклоняет кандидата + /// + /// Подписанный id источника. + /// Причина отклонения (в лог/чёрный список; «отклонено вручную» — эндпоинт reject). + /// Кандидат в rejected либо null (кандидата нет). Повторный вызов для rejected — идемпотентен. + /// Источник уже joined — отклонить нельзя. + public async Task MarkRejectedAsync( + string dialogId, + string reason, + CancellationToken ct) + { + DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); + if (current is null) + { + return null; + } + + if (current.Status == DiscoveryCandidateStatuses.Joined) + { + throw new DiscoveryValidationException(RejectJoinedDetail); + } + + if (current.Status == DiscoveryCandidateStatuses.Rejected) + { + return current; + } + + await store.SetCandidateRejectedAsync(dialogId, ct).ConfigureAwait(false); + await store.BumpTaskCounterAsync(current.TaskId, DiscoveryCounterField.Rejected, 1, ct).ConfigureAwait(false); + string text = string.IsNullOrEmpty(reason) ? string.Format(RejectedLogFormat, dialogId) : reason; + await log.AddAsync(current.TaskId, DiscoveryLogEvents.Reject, text, ct).ConfigureAwait(false); + await blacklist.AddAsync(dialogId, current.Name, reason ?? string.Empty, ct).ConfigureAwait(false); + return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); + } + + /// + /// Удаляет кандидата. + /// + /// Подписанный id источника. + /// Завершается после удаления строки. + public Task DeleteAsync(string dialogId, CancellationToken ct) + { + return store.DeleteCandidateAsync(dialogId, ct); + } + + private static DiscoveryCandidatePatch NormalizePatch(DiscoveryCandidatePatch patch, DiscoveryCandidateDto current) + { + return new DiscoveryCandidatePatch + { + Name = NormalizeKeptOr(patch.Name, current.Name), + Username = NormalizeKeptOr(patch.Username, current.Username), + Kind = NormalizeKeptOr(patch.Kind, current.Kind), + Hue = NormalizeKeptOr(patch.Hue, current.Hue), + Participants = patch.Participants, + LangRu = patch.LangRu, + Marks = patch.Marks?.Select(mark => (string)mark).ToList(), + Topics = patch.Topics?.ToList(), + FitRatio = patch.FitRatio, + AutoJoined = patch.AutoJoined, + }; + } + + private static string? NormalizeKeptOr(string? value, string current) + { + if (value is null) + { + return null; + } + + string trimmed = value.Trim(); + return trimmed.Length == 0 ? null : trimmed; + } + + private static string TrimOr(string value, string fallback) + { + string trimmed = (value ?? string.Empty).Trim(); + return trimmed.Length == 0 ? fallback : trimmed; + } +} diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryEvaluator.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryEvaluator.cs index 2e2462b..5fac7a4 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryEvaluator.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryEvaluator.cs @@ -1,292 +1,292 @@ -using System.Text; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Discovery.Application.Services; - -/// -/// Оценка содержания кандидата -/// -public sealed class DiscoveryEvaluator -{ - private const int MinTextLength = 10; - - private const int AiTextLimit = 4000; - - private const int AiReasonLimit = 200; - - private const int MinContentMessages = 3; - - private const string MainTopic = "main"; - - private const int TopicTitleLimit = 60; - - private const string AiFitReasonDefault = "подходит"; - - private const string AiNotFitReasonDefault = "не подходит"; - - private readonly ISettingsStore _settings; - private readonly IMlClient _mlClient; - private readonly IAiTools _aiTools; - - /// - /// Создаёт оценщик содержания кандидатов Discovery. - /// - /// KV-настройки тенанта (mlEnabled/aiEnabled — ветки каскада). - /// ML-порт (спам-отсев при mlEnabled; сбой — «не уверен»). - /// ИИ-порт. - public DiscoveryEvaluator( - ISettingsStore settings, - IMlClient mlClient, - IAiTools aiTools) - { - ArgumentNullException.ThrowIfNull(settings); - ArgumentNullException.ThrowIfNull(mlClient); - ArgumentNullException.ThrowIfNull(aiTools); - _settings = settings; - _mlClient = mlClient; - _aiTools = aiTools; - } - - /// - /// Последовательная оценка всех сообщений выборки. - /// - /// Задача поиска (description/keywords — промпт и эвристика). - /// Тексты сообщений выборки (порядок — входной). - /// Агрегат выборки: fit_count, total, fit_ratio, per-message. - public async Task EvaluateSampleAsync( - DiscoveryTaskDto task, - IReadOnlyList texts, - CancellationToken ct) - { - // Флаги веток (mlEnabled/aiEnabled) не меняются в пределах выборки: один типизированный снимок - // настроек на выборку (C30) вместо двух GetAsync на каждое сообщение. - TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct).ConfigureAwait(false); - var perMessage = new List(texts?.Count ?? 0); - int fitCount = 0; - foreach (string text in texts ?? []) - { - DiscoveryMessageFit fit = - await EvaluateMessageCoreAsync(task, text, settingsSnapshot, ct).ConfigureAwait(false); - perMessage.Add(fit); - if (fit.Fit) - { - fitCount++; - } - } - - int total = perMessage.Count; - return new DiscoveryEvalSample( - fitCount, - total, - total > 0 ? (double)fitCount / total : 0.0, - perMessage); - } - - /// - /// Оценка фита одного сообщения под задачу - /// - /// Задача поиска (description/keywords). - /// Текст сообщения. - /// Вердикт: fit + причина + источник (heuristic|ml|ai). - public async Task EvaluateMessageAsync( - DiscoveryTaskDto task, - string text, - CancellationToken ct) - { - TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct).ConfigureAwait(false); - return await EvaluateMessageCoreAsync(task, text, settingsSnapshot, ct).ConfigureAwait(false); - } - - // Чистое ядро оценки одного сообщения по фиксированному снимку настроек (см. EvaluateMessageAsync). - // task: Задача поиска (description/keywords). - // text: Текст сообщения. - // settingsSnapshot: Снимок настроек тенанта (mlEnabled/aiEnabled). - // ct: Токен отмены. - // Возвращает: Вердикт: fit + причина + источник (heuristic|ml|ai). - private async Task EvaluateMessageCoreAsync( - DiscoveryTaskDto task, - string text, - TenantSettingsSnapshot settingsSnapshot, - CancellationToken ct) - { - string raw = text ?? string.Empty; - if (raw.Trim().Length < MinTextLength) - { - return new DiscoveryMessageFit(false, "слишком короткое", "heuristic"); - } - - if (settingsSnapshot.GetBool(SettingsKeys.MlEnabled, SettingsDefaults.MlEnabled)) - { - try - { - MlPredictResultDto prediction = await _mlClient.PredictAsync(raw, ct).ConfigureAwait(false); - if (prediction.Take && string.Equals(prediction.Label, MlLearningLabels.Spam, StringComparison.OrdinalIgnoreCase)) - { - return new DiscoveryMessageFit(false, "ML: спам", "ml"); - } - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - } - } - - if (settingsSnapshot.GetBool(SettingsKeys.AiEnabled, SettingsDefaults.AiEnabled)) - { - try - { - AiEvaluateFitResultDto result = await _aiTools.EvaluateFitAsync( - SliceCodePoints(raw, AiTextLimit), - task.Description ?? string.Empty, - task.Keywords, - ct).ConfigureAwait(false); - string reason = (result.Reason ?? string.Empty).Trim(); - if (reason.Length == 0) - { - reason = result.Fit ? AiFitReasonDefault : AiNotFitReasonDefault; - } - - return new DiscoveryMessageFit(result.Fit, SliceCodePoints(reason, AiReasonLimit), "ai"); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - } - } - - return Heuristic(task, raw); - } - - private static DiscoveryMessageFit Heuristic(DiscoveryTaskDto task, string text) - { - string haystack = CleanShort(text); - foreach (string keyword in task.Keywords) - { - string kw = (keyword ?? string.Empty).Trim(); - if (kw.Length > 0 && haystack.Contains(kw.ToLowerInvariant(), StringComparison.Ordinal)) - { - return new DiscoveryMessageFit(true, $"совпал ключ \"{kw}\"", "heuristic"); - } - } - - return new DiscoveryMessageFit(false, "нет совпадений с ключами", "heuristic"); - } - - /// - /// Вердикт «источник подходит» - /// - /// Агрегат оценки выборки/темы. - /// Порог задачи (1..100; дефолт discEvalThreshold). - /// True — источник/тема проходят порог (каналы часто разбавляют контент офтопом — доля, не сплошное). - public static bool Passed(DiscoveryEvalSample sample, int thresholdPercent) - { - return sample.Total >= MinContentMessages && sample.FitRatio * 100 >= Math.Max(1, thresholdPercent); - } - - /// - /// Группирует сообщения выборки по topic_id - /// - /// Сообщения выборки (для форумов заполнен TopicId). - /// Группы от большей к меньшей; пусто — выборки нет. - public static IReadOnlyList GroupByTopic(IReadOnlyList messages) - { - var groups = new Dictionary>(StringComparer.Ordinal); - var order = new List(); - foreach (TelegramEvalMessageDto message in messages ?? []) - { - string key = message.TopicId is long topicId ? topicId.ToString() : MainTopic; - if (!groups.TryGetValue(key, out List? bucket)) - { - bucket = []; - groups[key] = bucket; - order.Add(key); - } - - bucket.Add(message); - } - - var outGroups = new List(order.Count); - foreach (string key in order) - { - List bucket = groups[key]; - outGroups.Add(new DiscoveryTopicGroup(key, TopicTitle(bucket), bucket)); - } - - return outGroups - .OrderByDescending(group => group.Messages.Count) - .ToList(); - } - - private static string TopicTitle(IReadOnlyList messages) - { - foreach (TelegramEvalMessageDto message in messages) - { - string text = (message.Text ?? string.Empty).Trim(); - if (text.Length > 0) - { - return SliceCodePoints(CollapseWhitespace(text), TopicTitleLimit); - } - } - - return string.Empty; - } - - private static string CleanShort(string text) - { - string collapsed = CollapseWhitespace(text); - return collapsed.ToLowerInvariant(); - } - - private static string CollapseWhitespace(string text) - { - var builder = new StringBuilder(text.Length); - bool pendingSpace = false; - foreach (char ch in text) - { - if (char.IsWhiteSpace(ch)) - { - pendingSpace = builder.Length > 0; - continue; - } - - if (pendingSpace) - { - builder.Append(' '); - pendingSpace = false; - } - - builder.Append(ch); - } - - return builder.ToString(); - } - - private static string SliceCodePoints(string text, int max) - { - if (text.Length <= max) - { - return text; - } - - var builder = new StringBuilder(max); - int taken = 0; - for (int index = 0; index < text.Length && taken < max; index++) - { - bool pair = char.IsHighSurrogate(text[index]) - && index + 1 < text.Length - && char.IsLowSurrogate(text[index + 1]); - builder.Append(text[index]); - if (pair) - { - index++; - builder.Append(text[index]); - } - - taken++; - } - - return builder.ToString(); - } -} +using System.Text; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Discovery.Application.Services; + +/// +/// Оценка содержания кандидата +/// +public sealed class DiscoveryEvaluator +{ + private const int MinTextLength = 10; + + private const int AiTextLimit = 4000; + + private const int AiReasonLimit = 200; + + private const int MinContentMessages = 3; + + private const string MainTopic = "main"; + + private const int TopicTitleLimit = 60; + + private const string AiFitReasonDefault = "подходит"; + + private const string AiNotFitReasonDefault = "не подходит"; + + private readonly ISettingsStore _settings; + private readonly IMlClient _mlClient; + private readonly IAiTools _aiTools; + + /// + /// Создаёт оценщик содержания кандидатов Discovery. + /// + /// KV-настройки тенанта (mlEnabled/aiEnabled — ветки каскада). + /// ML-порт (спам-отсев при mlEnabled; сбой — «не уверен»). + /// ИИ-порт. + public DiscoveryEvaluator( + ISettingsStore settings, + IMlClient mlClient, + IAiTools aiTools) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(mlClient); + ArgumentNullException.ThrowIfNull(aiTools); + _settings = settings; + _mlClient = mlClient; + _aiTools = aiTools; + } + + /// + /// Последовательная оценка всех сообщений выборки. + /// + /// Задача поиска (description/keywords — промпт и эвристика). + /// Тексты сообщений выборки (порядок — входной). + /// Агрегат выборки: fit_count, total, fit_ratio, per-message. + public async Task EvaluateSampleAsync( + DiscoveryTaskDto task, + IReadOnlyList texts, + CancellationToken ct) + { + // Флаги веток (mlEnabled/aiEnabled) не меняются в пределах выборки: один типизированный снимок + // настроек на выборку (C30) вместо двух GetAsync на каждое сообщение. + TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct).ConfigureAwait(false); + var perMessage = new List(texts?.Count ?? 0); + int fitCount = 0; + foreach (string text in texts ?? []) + { + DiscoveryMessageFit fit = + await EvaluateMessageCoreAsync(task, text, settingsSnapshot, ct).ConfigureAwait(false); + perMessage.Add(fit); + if (fit.Fit) + { + fitCount++; + } + } + + int total = perMessage.Count; + return new DiscoveryEvalSample( + fitCount, + total, + total > 0 ? (double)fitCount / total : 0.0, + perMessage); + } + + /// + /// Оценка фита одного сообщения под задачу + /// + /// Задача поиска (description/keywords). + /// Текст сообщения. + /// Вердикт: fit + причина + источник (heuristic|ml|ai). + public async Task EvaluateMessageAsync( + DiscoveryTaskDto task, + string text, + CancellationToken ct) + { + TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct).ConfigureAwait(false); + return await EvaluateMessageCoreAsync(task, text, settingsSnapshot, ct).ConfigureAwait(false); + } + + // Чистое ядро оценки одного сообщения по фиксированному снимку настроек (см. EvaluateMessageAsync). + // task: Задача поиска (description/keywords). + // text: Текст сообщения. + // settingsSnapshot: Снимок настроек тенанта (mlEnabled/aiEnabled). + // ct: Токен отмены. + // Возвращает: Вердикт: fit + причина + источник (heuristic|ml|ai). + private async Task EvaluateMessageCoreAsync( + DiscoveryTaskDto task, + string text, + TenantSettingsSnapshot settingsSnapshot, + CancellationToken ct) + { + string raw = text ?? string.Empty; + if (raw.Trim().Length < MinTextLength) + { + return new DiscoveryMessageFit(false, "слишком короткое", "heuristic"); + } + + if (settingsSnapshot.GetBool(SettingsKeys.MlEnabled, SettingsDefaults.MlEnabled)) + { + try + { + MlPredictResultDto prediction = await _mlClient.PredictAsync(raw, ct).ConfigureAwait(false); + if (prediction.Take && string.Equals(prediction.Label, MlLearningLabels.Spam, StringComparison.OrdinalIgnoreCase)) + { + return new DiscoveryMessageFit(false, "ML: спам", "ml"); + } + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + } + } + + if (settingsSnapshot.GetBool(SettingsKeys.AiEnabled, SettingsDefaults.AiEnabled)) + { + try + { + AiEvaluateFitResultDto result = await _aiTools.EvaluateFitAsync( + SliceCodePoints(raw, AiTextLimit), + task.Description ?? string.Empty, + task.Keywords, + ct).ConfigureAwait(false); + string reason = (result.Reason ?? string.Empty).Trim(); + if (reason.Length == 0) + { + reason = result.Fit ? AiFitReasonDefault : AiNotFitReasonDefault; + } + + return new DiscoveryMessageFit(result.Fit, SliceCodePoints(reason, AiReasonLimit), "ai"); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + } + } + + return Heuristic(task, raw); + } + + private static DiscoveryMessageFit Heuristic(DiscoveryTaskDto task, string text) + { + string haystack = CleanShort(text); + foreach (string keyword in task.Keywords) + { + string kw = (keyword ?? string.Empty).Trim(); + if (kw.Length > 0 && haystack.Contains(kw.ToLowerInvariant(), StringComparison.Ordinal)) + { + return new DiscoveryMessageFit(true, $"совпал ключ \"{kw}\"", "heuristic"); + } + } + + return new DiscoveryMessageFit(false, "нет совпадений с ключами", "heuristic"); + } + + /// + /// Вердикт «источник подходит» + /// + /// Агрегат оценки выборки/темы. + /// Порог задачи (1..100; дефолт discEvalThreshold). + /// True — источник/тема проходят порог (каналы часто разбавляют контент офтопом — доля, не сплошное). + public static bool Passed(DiscoveryEvalSample sample, int thresholdPercent) + { + return sample.Total >= MinContentMessages && sample.FitRatio * 100 >= Math.Max(1, thresholdPercent); + } + + /// + /// Группирует сообщения выборки по topic_id + /// + /// Сообщения выборки (для форумов заполнен TopicId). + /// Группы от большей к меньшей; пусто — выборки нет. + public static IReadOnlyList GroupByTopic(IReadOnlyList messages) + { + var groups = new Dictionary>(StringComparer.Ordinal); + var order = new List(); + foreach (TelegramEvalMessageDto message in messages ?? []) + { + string key = message.TopicId is long topicId ? topicId.ToString() : MainTopic; + if (!groups.TryGetValue(key, out List? bucket)) + { + bucket = []; + groups[key] = bucket; + order.Add(key); + } + + bucket.Add(message); + } + + var outGroups = new List(order.Count); + foreach (string key in order) + { + List bucket = groups[key]; + outGroups.Add(new DiscoveryTopicGroup(key, TopicTitle(bucket), bucket)); + } + + return outGroups + .OrderByDescending(group => group.Messages.Count) + .ToList(); + } + + private static string TopicTitle(IReadOnlyList messages) + { + foreach (TelegramEvalMessageDto message in messages) + { + string text = (message.Text ?? string.Empty).Trim(); + if (text.Length > 0) + { + return SliceCodePoints(CollapseWhitespace(text), TopicTitleLimit); + } + } + + return string.Empty; + } + + private static string CleanShort(string text) + { + string collapsed = CollapseWhitespace(text); + return collapsed.ToLowerInvariant(); + } + + private static string CollapseWhitespace(string text) + { + var builder = new StringBuilder(text.Length); + bool pendingSpace = false; + foreach (char ch in text) + { + if (char.IsWhiteSpace(ch)) + { + pendingSpace = builder.Length > 0; + continue; + } + + if (pendingSpace) + { + builder.Append(' '); + pendingSpace = false; + } + + builder.Append(ch); + } + + return builder.ToString(); + } + + private static string SliceCodePoints(string text, int max) + { + if (text.Length <= max) + { + return text; + } + + var builder = new StringBuilder(max); + int taken = 0; + for (int index = 0; index < text.Length && taken < max; index++) + { + bool pair = char.IsHighSurrogate(text[index]) + && index + 1 < text.Length + && char.IsLowSurrogate(text[index + 1]); + builder.Append(text[index]); + if (pair) + { + index++; + builder.Append(text[index]); + } + + taken++; + } + + return builder.ToString(); + } +} diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLogService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLogService.cs index 2e69f1a..3e4a1f6 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLogService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLogService.cs @@ -1,57 +1,57 @@ -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Models; - -namespace Deal.Modules.Discovery.Application.Services; - -/// -/// Сервис лога задач Discovery — запись событий и чтение истории. -/// -public sealed class DiscoveryLogService(IDiscoveryStore store) -{ - private const int MaxLogLimit = 500; - - private const int MinLogLimit = 1; - - private const int DefaultLogLimit = 100; - - /// - /// Пишет событие в лог задачи. - /// - /// Id задачи поиска. - /// Событие (см. ). - /// Текст/детали события. - /// Завершается после записи. - public Task AddAsync( - string taskId, - string logEvent, - string text, - CancellationToken ct) - { - return store.AddLogAsync(DiscoveryIdPrefixes.NewLogId(), taskId, logEvent, text ?? string.Empty, ct); - } - - /// - /// Последние события задачи, новые сверху. - /// - /// Id задачи поиска. - /// События задачи от новых к старым (до 100; пусто — лога нет). - public Task> TaskLogAsync(string taskId, CancellationToken ct) - { - return TaskLogAsync(taskId, DefaultLogLimit, ct); - } - - /// - /// Последние события задачи с выборкой. - /// - /// Id задачи поиска. - /// Запрошенный размер выборки. - /// События задачи от новых к старым. - public Task> TaskLogAsync( - string taskId, - int limit, - CancellationToken ct) - { - int clamped = Math.Max(MinLogLimit, Math.Min(MaxLogLimit, limit)); - return store.ListTaskLogAsync(taskId, clamped, ct); - } -} +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Models; + +namespace Deal.Modules.Discovery.Application.Services; + +/// +/// Сервис лога задач Discovery — запись событий и чтение истории. +/// +public sealed class DiscoveryLogService(IDiscoveryStore store) +{ + private const int MaxLogLimit = 500; + + private const int MinLogLimit = 1; + + private const int DefaultLogLimit = 100; + + /// + /// Пишет событие в лог задачи. + /// + /// Id задачи поиска. + /// Событие (см. ). + /// Текст/детали события. + /// Завершается после записи. + public Task AddAsync( + string taskId, + string logEvent, + string text, + CancellationToken ct) + { + return store.AddLogAsync(DiscoveryIdPrefixes.NewLogId(), taskId, logEvent, text ?? string.Empty, ct); + } + + /// + /// Последние события задачи, новые сверху. + /// + /// Id задачи поиска. + /// События задачи от новых к старым (до 100; пусто — лога нет). + public Task> TaskLogAsync(string taskId, CancellationToken ct) + { + return TaskLogAsync(taskId, DefaultLogLimit, ct); + } + + /// + /// Последние события задачи с выборкой. + /// + /// Id задачи поиска. + /// Запрошенный размер выборки. + /// События задачи от новых к старым. + public Task> TaskLogAsync( + string taskId, + int limit, + CancellationToken ct) + { + int clamped = Math.Max(MinLogLimit, Math.Min(MaxLogLimit, limit)); + return store.ListTaskLogAsync(taskId, clamped, ct); + } +} diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPacer.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPacer.cs index d758a3c..b031973 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPacer.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPacer.cs @@ -1,31 +1,31 @@ -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Discovery.Application.Services; - -/// -/// Продовая реализация -/// -public sealed class DiscoveryPacer(ISettingsStore settings) : IDiscoveryPacer -{ - /// - public async Task WaitJoinDelayAsync(CancellationToken ct) - { - TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); - int low = snapshot.GetInt(SettingsKeys.DiscJoinDelayMin, SettingsDefaults.DiscJoinDelayMin); - int high = snapshot.GetInt(SettingsKeys.DiscJoinDelayMax, SettingsDefaults.DiscJoinDelayMax); - if (low <= 0 && high <= 0) - { - return; - } - - if (low > high) - { - (low, high) = (high, low); - } - - double seconds = low + Random.Shared.NextDouble() * (high - low); - await Task.Delay(TimeSpan.FromSeconds(seconds), ct).ConfigureAwait(false); - } -} +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Discovery.Application.Services; + +/// +/// Продовая реализация +/// +public sealed class DiscoveryPacer(ISettingsStore settings) : IDiscoveryPacer +{ + /// + public async Task WaitJoinDelayAsync(CancellationToken ct) + { + TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); + int low = snapshot.GetInt(SettingsKeys.DiscJoinDelayMin, SettingsDefaults.DiscJoinDelayMin); + int high = snapshot.GetInt(SettingsKeys.DiscJoinDelayMax, SettingsDefaults.DiscJoinDelayMax); + if (low <= 0 && high <= 0) + { + return; + } + + if (low > high) + { + (low, high) = (high, low); + } + + double seconds = low + Random.Shared.NextDouble() * (high - low); + await Task.Delay(TimeSpan.FromSeconds(seconds), ct).ConfigureAwait(false); + } +} diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPlanGuard.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPlanGuard.cs index 6a0a114..00c9de0 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPlanGuard.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPlanGuard.cs @@ -1,77 +1,77 @@ -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Discovery.Application.Services; - -/// -/// План-бюджет авто-вступлений Discovery -/// -public sealed class DiscoveryPlanGuard(IDiscoveryStore store, ISettingsStore settings) -{ - /// - /// 400: план меньше 1. - /// - public const string PlanTooSmallDetail = "plan_joins должен быть не меньше 1"; - - /// - /// 400: план больше суточного лимита. - /// - public const string PlanTooBigFormat = "plan_joins {0} больше суточного лимита авто-вступлений ({1})"; - - /// - /// 400: бюджет исчерпан. - /// - public const string BudgetExceededFormat = - "Бюджет авто-вступлений исчерпан: задачи уже занимают {0} из {1} в сутки, ещё {2} не влезает"; - - /// - /// Верхняя граница plan_joins - /// - /// discJoinLimit настройки (дефолт 50) с нижней границей 1. - public async Task PlanLimitAsync(CancellationToken ct) - { - int limit = (await TenantSettingsSnapshot.LoadAsync(settings, ct)) - .GetInt(SettingsKeys.DiscJoinLimit, SettingsDefaults.DiscJoinLimit); - return Math.Max(1, limit); - } - - /// - /// Проверяет план новой задачи - /// - /// Запрашиваемый план авто-вступлений. - /// План меньше 1 или больше суточного лимита. - public async Task AssertPlanAsync(int planJoins, CancellationToken ct) - { - if (planJoins < 1) - { - throw new DiscoveryValidationException(PlanTooSmallDetail); - } - - int limit = await PlanLimitAsync(ct).ConfigureAwait(false); - if (planJoins > limit) - { - throw new DiscoveryValidationException(string.Format(PlanTooBigFormat, planJoins, limit)); - } - } - - /// - /// Проверяет бюджет - /// - /// План создаваемой/итоговый план увеличиваемой задачи. - /// Id задачи, исключаемой из занятого бюджета (patch-рост плана); null — создание. - /// Бюджет исчерпан. - public async Task AssertBudgetAsync( - int planJoins, - string? excludeTaskId, - CancellationToken ct) - { - int limit = await PlanLimitAsync(ct).ConfigureAwait(false); - int used = await store.SumActivePlanAsync(excludeTaskId, ct).ConfigureAwait(false); - if (used + planJoins > limit) - { - throw new DiscoveryValidationException(string.Format(BudgetExceededFormat, used, limit, planJoins)); - } - } -} +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Discovery.Application.Services; + +/// +/// План-бюджет авто-вступлений Discovery +/// +public sealed class DiscoveryPlanGuard(IDiscoveryStore store, ISettingsStore settings) +{ + /// + /// 400: план меньше 1. + /// + public const string PlanTooSmallDetail = "plan_joins должен быть не меньше 1"; + + /// + /// 400: план больше суточного лимита. + /// + public const string PlanTooBigFormat = "plan_joins {0} больше суточного лимита авто-вступлений ({1})"; + + /// + /// 400: бюджет исчерпан. + /// + public const string BudgetExceededFormat = + "Бюджет авто-вступлений исчерпан: задачи уже занимают {0} из {1} в сутки, ещё {2} не влезает"; + + /// + /// Верхняя граница plan_joins + /// + /// discJoinLimit настройки (дефолт 50) с нижней границей 1. + public async Task PlanLimitAsync(CancellationToken ct) + { + int limit = (await TenantSettingsSnapshot.LoadAsync(settings, ct)) + .GetInt(SettingsKeys.DiscJoinLimit, SettingsDefaults.DiscJoinLimit); + return Math.Max(1, limit); + } + + /// + /// Проверяет план новой задачи + /// + /// Запрашиваемый план авто-вступлений. + /// План меньше 1 или больше суточного лимита. + public async Task AssertPlanAsync(int planJoins, CancellationToken ct) + { + if (planJoins < 1) + { + throw new DiscoveryValidationException(PlanTooSmallDetail); + } + + int limit = await PlanLimitAsync(ct).ConfigureAwait(false); + if (planJoins > limit) + { + throw new DiscoveryValidationException(string.Format(PlanTooBigFormat, planJoins, limit)); + } + } + + /// + /// Проверяет бюджет + /// + /// План создаваемой/итоговый план увеличиваемой задачи. + /// Id задачи, исключаемой из занятого бюджета (patch-рост плана); null — создание. + /// Бюджет исчерпан. + public async Task AssertBudgetAsync( + int planJoins, + string? excludeTaskId, + CancellationToken ct) + { + int limit = await PlanLimitAsync(ct).ConfigureAwait(false); + int used = await store.SumActivePlanAsync(excludeTaskId, ct).ConfigureAwait(false); + if (used + planJoins > limit) + { + throw new DiscoveryValidationException(string.Format(BudgetExceededFormat, used, limit, planJoins)); + } + } +} diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs index ca634a6..3a6c515 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs @@ -1,264 +1,264 @@ -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Discovery.Application.Services; - -/// -/// Сервис задач поиска Discovery — create/patch/delete/start/pause/advance/bump. -/// -public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGuard planGuard, ISettingsStore settings) -{ - /// - /// 400 create: пустое название после Trim. - /// - public const string NameRequiredDetail = "Укажите название задачи"; - - /// - /// 400 start: у задачи нет ключевых слов. - /// - public const string NoKeywordsDetail = "Нет ключевых слов для поиска — добавьте их в задачу"; - - /// - /// Все задачи, старые первыми. - /// - /// Задачи в порядке создания. - public Task> ListAsync(CancellationToken ct) - { - return store.ListTasksAsync(ct); - } - - /// - /// Одна задача по id. - /// - /// Id задачи (dt_...). - /// Задача или null (404 «Задача не найдена» у эндпоинта). - public Task GetAsync(string taskId, CancellationToken ct) - { - return store.GetTaskAsync(taskId, ct); - } - - /// - /// Создаёт задачу поиска. - /// - /// Поля новой задачи (см. ). - /// Созданная задача (полный DTO). - /// Пустое имя / план вне 1..discJoinLimit / бюджет исчерпан. - public async Task CreateAsync(DiscoveryTaskDraft draft, CancellationToken ct) - { - string name = (draft.Name ?? string.Empty).Trim(); - if (name.Length == 0) - { - throw new DiscoveryValidationException(NameRequiredDetail); - } - - int planJoins = draft.PlanJoins ?? 1; - await planGuard.AssertPlanAsync(planJoins, ct).ConfigureAwait(false); - await planGuard.AssertBudgetAsync(planJoins, excludeTaskId: null, ct).ConfigureAwait(false); - - string lang = draft.Lang is "ru" or "any" ? draft.Lang : "ru"; - - // Дефолты threshold/sampleSize — из типизированного снимка настроек (C30: один GetAllAsync на создание). - TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); - int threshold = Clamp( - settingsSnapshot.GetInt(SettingsKeys.DiscEvalThreshold, SettingsDefaults.DiscEvalThreshold), - min: 1, max: 100); - int sampleSize = Math.Max(1, - settingsSnapshot.GetInt(SettingsKeys.DiscEvalSample, SettingsDefaults.DiscEvalSample)); - if (draft.Threshold is int requestedThreshold) - { - threshold = Clamp(requestedThreshold, min: 1, max: 100); - } - - if (draft.SampleSize is int requestedSample) - { - sampleSize = Math.Max(1, requestedSample); - } - - var row = new DiscoveryTaskRow - { - Id = DiscoveryIdPrefixes.NewTaskId(), - Name = name, - Description = draft.Description ?? string.Empty, - Keywords = CleanKeywords(draft.Keywords), - MinSubscribers = Math.Max(0, draft.MinSubscribers ?? 0), - Lang = lang, - Threshold = threshold, - SampleSize = sampleSize, - PlanJoins = planJoins, - AutoJoin = draft.AutoJoin ?? false, - }; - await store.CreateTaskAsync(row, ct).ConfigureAwait(false); - DiscoveryTaskDto created = await store.GetTaskAsync(row.Id, ct).ConfigureAwait(false) - ?? throw new InvalidOperationException("create_task: строка не прочиталась после INSERT"); - return created; - } - - /// - /// Обновляет поля задачи. - /// - /// Id задачи (dt_...). - /// Изменяемые поля (null — не меняется). - /// Обновлённая задача либо null (404 «Задача не найдена»). - /// Новый план вне границ / бюджет исчерпан. - public async Task PatchAsync( - string taskId, - DiscoveryTaskPatch patch, - CancellationToken ct) - { - DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); - if (current is null) - { - return null; - } - - DiscoveryTaskPatch normalized = NormalizeTaskPatch(patch); - if (normalized.PlanJoins is int newPlan) - { - await planGuard.AssertPlanAsync(newPlan, ct).ConfigureAwait(false); - if (newPlan > current.PlanJoins) - { - await planGuard.AssertBudgetAsync(newPlan, excludeTaskId: taskId, ct).ConfigureAwait(false); - } - } - - if (!normalized.HasChanges()) - { - return current; - } - - await store.PatchTaskAsync(taskId, normalized, ct).ConfigureAwait(false); - return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); - } - - /// - /// Удаляет задачу вместе с кандидатами и логом. - /// - /// Id задачи (dt_...). - /// True — задача удалена; false — строки нет (404 у эндпоинта). - public async Task DeleteAsync(string taskId, CancellationToken ct) - { - DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); - if (current is null) - { - return false; - } - - return await store.DeleteTaskAsync(taskId, ct).ConfigureAwait(false); - } - - /// - /// Запускает поиск - /// - /// Id задачи (dt_...). - /// Задача в running либо null (404). - /// Ключевых слов нет. - public async Task StartAsync(string taskId, CancellationToken ct) - { - DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); - if (current is null) - { - return null; - } - - if (current.Keywords.Count == 0) - { - throw new DiscoveryValidationException(NoKeywordsDetail); - } - - bool resetProgress = DiscoveryTaskStatuses.IsFinished(current.Status); - await store.SetTaskRunningAsync(taskId, resetProgress, ct).ConfigureAwait(false); - return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); - } - - /// - /// Ставит задачу на паузу. - /// - /// Id задачи (dt_...). - /// Задача в paused либо null (404). - public async Task PauseAsync(string taskId, CancellationToken ct) - { - DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); - if (current is null) - { - return null; - } - - await store.SetTaskPausedAsync(taskId, ct).ConfigureAwait(false); - return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); - } - - /// - /// Увеличивает счётчик прогресса задачи - /// - /// Id задачи (dt_...). - /// Счётчик (found/evaluated/joined/rejected). - /// Приращение (по умолчанию 1). - /// True — счётчик увеличен; false — задачи нет. - public Task BumpCounterAsync( - string taskId, - DiscoveryCounterField field, - int n, - CancellationToken ct) - { - return store.BumpTaskCounterAsync(taskId, field, Math.Max(0, n), ct); - } - - /// - /// Продвигает индекс поиска - /// - /// Id задачи (dt_...). - /// True — задача обновлена; false — строки нет. - public async Task AdvanceSearchAsync(string taskId, CancellationToken ct) - { - DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); - if (current is null) - { - return false; - } - - int nextIndex = current.SearchIdx + 1; - bool searchDone = nextIndex >= current.Keywords.Count; - return await store.AdvanceSearchAsync(taskId, nextIndex, searchDone, ct).ConfigureAwait(false); - } - - private static DiscoveryTaskPatch NormalizeTaskPatch(DiscoveryTaskPatch patch) - { - int? threshold = patch.Threshold is int value ? Clamp(value, min: 1, max: 100) : null; - int? sampleSize = patch.SampleSize is int sample ? Math.Max(1, sample) : null; - int? minSubscribers = patch.MinSubscribers is int min ? Math.Max(0, min) : null; - string? lang = patch.Lang is "ru" or "any" ? patch.Lang : patch.Lang is null ? null : "ru"; - string? name = patch.Name is null ? null : patch.Name.Trim(); - string? description = patch.Description; - return new DiscoveryTaskPatch - { - Name = name, - Description = description, - Keywords = patch.Keywords is null ? null : CleanKeywords(patch.Keywords), - MinSubscribers = minSubscribers, - Lang = lang, - Threshold = threshold, - SampleSize = sampleSize, - PlanJoins = patch.PlanJoins, - AutoJoin = patch.AutoJoin, - }; - } - - private static IReadOnlyList CleanKeywords(IReadOnlyList? keywords) - { - return keywords?.Where(keyword => !string.IsNullOrWhiteSpace(keyword)) - .Select(keyword => keyword.Trim()) - .ToList() ?? new List(); - } - - private static int Clamp( - int value, - int min, - int max) - { - return Math.Max(min, Math.Min(max, value)); - } -} +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Discovery.Application.Services; + +/// +/// Сервис задач поиска Discovery — create/patch/delete/start/pause/advance/bump. +/// +public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGuard planGuard, ISettingsStore settings) +{ + /// + /// 400 create: пустое название после Trim. + /// + public const string NameRequiredDetail = "Укажите название задачи"; + + /// + /// 400 start: у задачи нет ключевых слов. + /// + public const string NoKeywordsDetail = "Нет ключевых слов для поиска — добавьте их в задачу"; + + /// + /// Все задачи, старые первыми. + /// + /// Задачи в порядке создания. + public Task> ListAsync(CancellationToken ct) + { + return store.ListTasksAsync(ct); + } + + /// + /// Одна задача по id. + /// + /// Id задачи (dt_...). + /// Задача или null (404 «Задача не найдена» у эндпоинта). + public Task GetAsync(string taskId, CancellationToken ct) + { + return store.GetTaskAsync(taskId, ct); + } + + /// + /// Создаёт задачу поиска. + /// + /// Поля новой задачи (см. ). + /// Созданная задача (полный DTO). + /// Пустое имя / план вне 1..discJoinLimit / бюджет исчерпан. + public async Task CreateAsync(DiscoveryTaskDraft draft, CancellationToken ct) + { + string name = (draft.Name ?? string.Empty).Trim(); + if (name.Length == 0) + { + throw new DiscoveryValidationException(NameRequiredDetail); + } + + int planJoins = draft.PlanJoins ?? 1; + await planGuard.AssertPlanAsync(planJoins, ct).ConfigureAwait(false); + await planGuard.AssertBudgetAsync(planJoins, excludeTaskId: null, ct).ConfigureAwait(false); + + string lang = draft.Lang is "ru" or "any" ? draft.Lang : "ru"; + + // Дефолты threshold/sampleSize — из типизированного снимка настроек (C30: один GetAllAsync на создание). + TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); + int threshold = Clamp( + settingsSnapshot.GetInt(SettingsKeys.DiscEvalThreshold, SettingsDefaults.DiscEvalThreshold), + min: 1, max: 100); + int sampleSize = Math.Max(1, + settingsSnapshot.GetInt(SettingsKeys.DiscEvalSample, SettingsDefaults.DiscEvalSample)); + if (draft.Threshold is int requestedThreshold) + { + threshold = Clamp(requestedThreshold, min: 1, max: 100); + } + + if (draft.SampleSize is int requestedSample) + { + sampleSize = Math.Max(1, requestedSample); + } + + var row = new DiscoveryTaskRow + { + Id = DiscoveryIdPrefixes.NewTaskId(), + Name = name, + Description = draft.Description ?? string.Empty, + Keywords = CleanKeywords(draft.Keywords), + MinSubscribers = Math.Max(0, draft.MinSubscribers ?? 0), + Lang = lang, + Threshold = threshold, + SampleSize = sampleSize, + PlanJoins = planJoins, + AutoJoin = draft.AutoJoin ?? false, + }; + await store.CreateTaskAsync(row, ct).ConfigureAwait(false); + DiscoveryTaskDto created = await store.GetTaskAsync(row.Id, ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("create_task: строка не прочиталась после INSERT"); + return created; + } + + /// + /// Обновляет поля задачи. + /// + /// Id задачи (dt_...). + /// Изменяемые поля (null — не меняется). + /// Обновлённая задача либо null (404 «Задача не найдена»). + /// Новый план вне границ / бюджет исчерпан. + public async Task PatchAsync( + string taskId, + DiscoveryTaskPatch patch, + CancellationToken ct) + { + DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); + if (current is null) + { + return null; + } + + DiscoveryTaskPatch normalized = NormalizeTaskPatch(patch); + if (normalized.PlanJoins is int newPlan) + { + await planGuard.AssertPlanAsync(newPlan, ct).ConfigureAwait(false); + if (newPlan > current.PlanJoins) + { + await planGuard.AssertBudgetAsync(newPlan, excludeTaskId: taskId, ct).ConfigureAwait(false); + } + } + + if (!normalized.HasChanges()) + { + return current; + } + + await store.PatchTaskAsync(taskId, normalized, ct).ConfigureAwait(false); + return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); + } + + /// + /// Удаляет задачу вместе с кандидатами и логом. + /// + /// Id задачи (dt_...). + /// True — задача удалена; false — строки нет (404 у эндпоинта). + public async Task DeleteAsync(string taskId, CancellationToken ct) + { + DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); + if (current is null) + { + return false; + } + + return await store.DeleteTaskAsync(taskId, ct).ConfigureAwait(false); + } + + /// + /// Запускает поиск + /// + /// Id задачи (dt_...). + /// Задача в running либо null (404). + /// Ключевых слов нет. + public async Task StartAsync(string taskId, CancellationToken ct) + { + DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); + if (current is null) + { + return null; + } + + if (current.Keywords.Count == 0) + { + throw new DiscoveryValidationException(NoKeywordsDetail); + } + + bool resetProgress = DiscoveryTaskStatuses.IsFinished(current.Status); + await store.SetTaskRunningAsync(taskId, resetProgress, ct).ConfigureAwait(false); + return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); + } + + /// + /// Ставит задачу на паузу. + /// + /// Id задачи (dt_...). + /// Задача в paused либо null (404). + public async Task PauseAsync(string taskId, CancellationToken ct) + { + DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); + if (current is null) + { + return null; + } + + await store.SetTaskPausedAsync(taskId, ct).ConfigureAwait(false); + return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); + } + + /// + /// Увеличивает счётчик прогресса задачи + /// + /// Id задачи (dt_...). + /// Счётчик (found/evaluated/joined/rejected). + /// Приращение (по умолчанию 1). + /// True — счётчик увеличен; false — задачи нет. + public Task BumpCounterAsync( + string taskId, + DiscoveryCounterField field, + int n, + CancellationToken ct) + { + return store.BumpTaskCounterAsync(taskId, field, Math.Max(0, n), ct); + } + + /// + /// Продвигает индекс поиска + /// + /// Id задачи (dt_...). + /// True — задача обновлена; false — строки нет. + public async Task AdvanceSearchAsync(string taskId, CancellationToken ct) + { + DiscoveryTaskDto? current = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); + if (current is null) + { + return false; + } + + int nextIndex = current.SearchIdx + 1; + bool searchDone = nextIndex >= current.Keywords.Count; + return await store.AdvanceSearchAsync(taskId, nextIndex, searchDone, ct).ConfigureAwait(false); + } + + private static DiscoveryTaskPatch NormalizeTaskPatch(DiscoveryTaskPatch patch) + { + int? threshold = patch.Threshold is int value ? Clamp(value, min: 1, max: 100) : null; + int? sampleSize = patch.SampleSize is int sample ? Math.Max(1, sample) : null; + int? minSubscribers = patch.MinSubscribers is int min ? Math.Max(0, min) : null; + string? lang = patch.Lang is "ru" or "any" ? patch.Lang : patch.Lang is null ? null : "ru"; + string? name = patch.Name is null ? null : patch.Name.Trim(); + string? description = patch.Description; + return new DiscoveryTaskPatch + { + Name = name, + Description = description, + Keywords = patch.Keywords is null ? null : CleanKeywords(patch.Keywords), + MinSubscribers = minSubscribers, + Lang = lang, + Threshold = threshold, + SampleSize = sampleSize, + PlanJoins = patch.PlanJoins, + AutoJoin = patch.AutoJoin, + }; + } + + private static IReadOnlyList CleanKeywords(IReadOnlyList? keywords) + { + return keywords?.Where(keyword => !string.IsNullOrWhiteSpace(keyword)) + .Select(keyword => keyword.Trim()) + .ToList() ?? new List(); + } + + private static int Clamp( + int value, + int min, + int max) + { + return Math.Max(min, Math.Min(max, value)); + } +} diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Evaluate.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Evaluate.cs index 4a80fe9..76554e4 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Evaluate.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Evaluate.cs @@ -1,211 +1,211 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Discovery.Application.Models; - -namespace Deal.Modules.Discovery.Application.Services; - -public sealed partial class DiscoveryWorkerService -{ - private async Task EvalStepAsync( - DiscoveryTaskDto task, - DiscoveryCandidateDto candidate, - CancellationToken ct) - { - string taskId = task.Id; - string dialogId = candidate.DialogId; - List marks = new(2); - - TelegramChannelInfoDto info; - try - { - info = await _gateway.InfoAsync(dialogId, ct).ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"инфо источника {dialogId}: {exception.Message}", ct) - .ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionError, taskId); - } - - bool isForum = info.IsForum; - string infoKind = (info.Kind ?? string.Empty).Trim(); - bool resolved = infoKind.Length > 0 || isForum; - string kind = resolved - ? KindCode(infoKind, isForum) - : (string.IsNullOrEmpty(candidate.Kind) ? DiscoveryCandidateKinds.Channel : candidate.Kind); - - var patch = new DiscoveryCandidatePatch - { - Kind = kind, - Hue = string.IsNullOrEmpty(info.Hue) ? null : info.Hue, - Participants = info.Participants, - }; - if (resolved) - { - patch = patch with - { - Name = string.IsNullOrWhiteSpace(info.Name) ? null : info.Name.Trim(), - Username = string.IsNullOrWhiteSpace(info.Username) ? null : info.Username.Trim(), - }; - } - - await _candidates.SetAsync(taskId, dialogId, patch, ct).ConfigureAwait(false); - int? participants = info.Participants; - - if (task.MinSubscribers > 0) - { - if (participants is null) - { - marks.Add(MarkParticipantsNotConfirmed); - } - else if (participants.Value < task.MinSubscribers) - { - await _candidates.DeleteAsync(dialogId, ct).ConfigureAwait(false); - await _log.AddAsync( - taskId, - DiscoveryLogEvents.Skip, - $"{dialogId}: мало участников ({participants} < {task.MinSubscribers})", - ct).ConfigureAwait(false); - await _tasks.BumpCounterAsync(taskId, DiscoveryCounterField.Evaluated, 1, ct).ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionSkip, taskId); - } - } - - TelegramEvalReadDto read; - try - { - read = await _gateway.ReadForEvalAsync(dialogId, Math.Max(1, task.SampleSize), ct).ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"чтение выборки {dialogId}: {exception.Message}", ct) - .ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionError, taskId); - } - - IReadOnlyList messages = read.Messages ?? Array.Empty(); - if (!read.Ok) - { - marks.Add(kind == DiscoveryCandidateKinds.Channel ? MarkChannelNoHistory : MarkClosedGroup); - if (task.Lang == "ru") - { - marks.Add(MarkLangNotConfirmed); - } - - await FinishReviewAsync(taskId, dialogId, marks, langRu: null, ct: ct).ConfigureAwait(false); - await _tasks.BumpCounterAsync(taskId, DiscoveryCounterField.Evaluated, 1, ct).ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionReview, taskId); - } - - bool? langRu = null; - if (task.Lang == "ru") - { - langRu = DiscoveryLangDetector.DetectRu(messages.Select(message => message.Text).ToArray()); - if (langRu == false) - { - await _candidates.DeleteAsync(dialogId, ct).ConfigureAwait(false); - await _log.AddAsync(taskId, DiscoveryLogEvents.Skip, $"{dialogId}: язык не русский", ct) - .ConfigureAwait(false); - await _tasks.BumpCounterAsync(taskId, DiscoveryCounterField.Evaluated, 1, ct).ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionSkip, taskId); - } - - if (langRu is null) - { - marks.Add(MarkLangNotConfirmed); - } - } - - if (messages.Count < MinContentMessages) - { - marks.Add(MarkFewMessages); - await FinishReviewAsync(taskId, dialogId, marks, langRu: langRu, ct: ct).ConfigureAwait(false); - await _tasks.BumpCounterAsync(taskId, DiscoveryCounterField.Evaluated, 1, ct).ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionReview, taskId); - } - - int fitCount; - int total; - double fitRatio; - IReadOnlyList? topics; - bool passed; - if (kind == DiscoveryCandidateKinds.Forum) - { - var topicRows = new List(); - int forumFit = 0; - int forumTotal = 0; - bool anyPassed = false; - foreach (DiscoveryTopicGroup group in DiscoveryEvaluator.GroupByTopic(messages)) - { - DiscoveryEvalSample groupEval = - await _evaluator.EvaluateSampleAsync(task, GroupTexts(group.Messages), ct).ConfigureAwait(false); - bool groupPassed = DiscoveryEvaluator.Passed(groupEval, task.Threshold); - forumFit += groupEval.FitCount; - forumTotal += groupEval.Total; - topicRows.Add(new DiscoveryTopicDto( - group.TopicKey, - group.Title, - groupEval.FitCount, - groupEval.Total, - groupEval.FitRatio, - groupPassed)); - anyPassed = anyPassed || groupPassed; - } - - fitCount = forumFit; - total = forumTotal; - fitRatio = forumTotal > 0 ? (double)forumFit / forumTotal : 0.0; - topics = topicRows; - passed = anyPassed; // форум подходит, если есть хотя бы одна проходная тема (python L346–347) - } - else - { - DiscoveryEvalSample eval = - await _evaluator.EvaluateSampleAsync(task, GroupTexts(messages), ct).ConfigureAwait(false); - fitCount = eval.FitCount; - total = eval.Total; - fitRatio = eval.FitRatio; - topics = null; - passed = DiscoveryEvaluator.Passed(eval, task.Threshold); - } - - if (passed) - { - await FinishReviewAsync( - taskId, - dialogId, - marks, - langRu: langRu, - fitRatio: fitRatio, - topics: topics, - ct: ct).ConfigureAwait(false); - await _tasks.BumpCounterAsync(taskId, DiscoveryCounterField.Evaluated, 1, ct).ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionReview, taskId); - } - - await _candidates.DeleteAsync(dialogId, ct).ConfigureAwait(false); - await _log.AddAsync(taskId, DiscoveryLogEvents.Skip, $"{dialogId}: мало подходящих ({fitCount} из {total})", ct) - .ConfigureAwait(false); - await _tasks.BumpCounterAsync(taskId, DiscoveryCounterField.Evaluated, 1, ct).ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionSkip, taskId); - } - - private async Task FinishReviewAsync( - string taskId, - string dialogId, - IReadOnlyList marks, - bool? langRu = null, - double? fitRatio = null, - IReadOnlyList? topics = null, - CancellationToken ct = default) - { - var patch = new DiscoveryCandidatePatch - { - Marks = marks.Where(mark => !string.IsNullOrEmpty(mark)).ToList(), - LangRu = langRu, - FitRatio = fitRatio, - Topics = topics, - }; - await _candidates.SetAsync(taskId, dialogId, patch, ct).ConfigureAwait(false); - await _candidates.SetStatusAsync(dialogId, DiscoveryCandidateStatuses.Review, ct).ConfigureAwait(false); - } -} +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Discovery.Application.Models; + +namespace Deal.Modules.Discovery.Application.Services; + +public sealed partial class DiscoveryWorkerService +{ + private async Task EvalStepAsync( + DiscoveryTaskDto task, + DiscoveryCandidateDto candidate, + CancellationToken ct) + { + string taskId = task.Id; + string dialogId = candidate.DialogId; + List marks = new(2); + + TelegramChannelInfoDto info; + try + { + info = await _gateway.InfoAsync(dialogId, ct).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"инфо источника {dialogId}: {exception.Message}", ct) + .ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionError, taskId); + } + + bool isForum = info.IsForum; + string infoKind = (info.Kind ?? string.Empty).Trim(); + bool resolved = infoKind.Length > 0 || isForum; + string kind = resolved + ? KindCode(infoKind, isForum) + : (string.IsNullOrEmpty(candidate.Kind) ? DiscoveryCandidateKinds.Channel : candidate.Kind); + + var patch = new DiscoveryCandidatePatch + { + Kind = kind, + Hue = string.IsNullOrEmpty(info.Hue) ? null : info.Hue, + Participants = info.Participants, + }; + if (resolved) + { + patch = patch with + { + Name = string.IsNullOrWhiteSpace(info.Name) ? null : info.Name.Trim(), + Username = string.IsNullOrWhiteSpace(info.Username) ? null : info.Username.Trim(), + }; + } + + await _candidates.SetAsync(taskId, dialogId, patch, ct).ConfigureAwait(false); + int? participants = info.Participants; + + if (task.MinSubscribers > 0) + { + if (participants is null) + { + marks.Add(MarkParticipantsNotConfirmed); + } + else if (participants.Value < task.MinSubscribers) + { + await _candidates.DeleteAsync(dialogId, ct).ConfigureAwait(false); + await _log.AddAsync( + taskId, + DiscoveryLogEvents.Skip, + $"{dialogId}: мало участников ({participants} < {task.MinSubscribers})", + ct).ConfigureAwait(false); + await _tasks.BumpCounterAsync(taskId, DiscoveryCounterField.Evaluated, 1, ct).ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionSkip, taskId); + } + } + + TelegramEvalReadDto read; + try + { + read = await _gateway.ReadForEvalAsync(dialogId, Math.Max(1, task.SampleSize), ct).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"чтение выборки {dialogId}: {exception.Message}", ct) + .ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionError, taskId); + } + + IReadOnlyList messages = read.Messages ?? Array.Empty(); + if (!read.Ok) + { + marks.Add(kind == DiscoveryCandidateKinds.Channel ? MarkChannelNoHistory : MarkClosedGroup); + if (task.Lang == "ru") + { + marks.Add(MarkLangNotConfirmed); + } + + await FinishReviewAsync(taskId, dialogId, marks, langRu: null, ct: ct).ConfigureAwait(false); + await _tasks.BumpCounterAsync(taskId, DiscoveryCounterField.Evaluated, 1, ct).ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionReview, taskId); + } + + bool? langRu = null; + if (task.Lang == "ru") + { + langRu = DiscoveryLangDetector.DetectRu(messages.Select(message => message.Text).ToArray()); + if (langRu == false) + { + await _candidates.DeleteAsync(dialogId, ct).ConfigureAwait(false); + await _log.AddAsync(taskId, DiscoveryLogEvents.Skip, $"{dialogId}: язык не русский", ct) + .ConfigureAwait(false); + await _tasks.BumpCounterAsync(taskId, DiscoveryCounterField.Evaluated, 1, ct).ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionSkip, taskId); + } + + if (langRu is null) + { + marks.Add(MarkLangNotConfirmed); + } + } + + if (messages.Count < MinContentMessages) + { + marks.Add(MarkFewMessages); + await FinishReviewAsync(taskId, dialogId, marks, langRu: langRu, ct: ct).ConfigureAwait(false); + await _tasks.BumpCounterAsync(taskId, DiscoveryCounterField.Evaluated, 1, ct).ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionReview, taskId); + } + + int fitCount; + int total; + double fitRatio; + IReadOnlyList? topics; + bool passed; + if (kind == DiscoveryCandidateKinds.Forum) + { + var topicRows = new List(); + int forumFit = 0; + int forumTotal = 0; + bool anyPassed = false; + foreach (DiscoveryTopicGroup group in DiscoveryEvaluator.GroupByTopic(messages)) + { + DiscoveryEvalSample groupEval = + await _evaluator.EvaluateSampleAsync(task, GroupTexts(group.Messages), ct).ConfigureAwait(false); + bool groupPassed = DiscoveryEvaluator.Passed(groupEval, task.Threshold); + forumFit += groupEval.FitCount; + forumTotal += groupEval.Total; + topicRows.Add(new DiscoveryTopicDto( + group.TopicKey, + group.Title, + groupEval.FitCount, + groupEval.Total, + groupEval.FitRatio, + groupPassed)); + anyPassed = anyPassed || groupPassed; + } + + fitCount = forumFit; + total = forumTotal; + fitRatio = forumTotal > 0 ? (double)forumFit / forumTotal : 0.0; + topics = topicRows; + passed = anyPassed; // форум подходит, если есть хотя бы одна проходная тема (python L346–347) + } + else + { + DiscoveryEvalSample eval = + await _evaluator.EvaluateSampleAsync(task, GroupTexts(messages), ct).ConfigureAwait(false); + fitCount = eval.FitCount; + total = eval.Total; + fitRatio = eval.FitRatio; + topics = null; + passed = DiscoveryEvaluator.Passed(eval, task.Threshold); + } + + if (passed) + { + await FinishReviewAsync( + taskId, + dialogId, + marks, + langRu: langRu, + fitRatio: fitRatio, + topics: topics, + ct: ct).ConfigureAwait(false); + await _tasks.BumpCounterAsync(taskId, DiscoveryCounterField.Evaluated, 1, ct).ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionReview, taskId); + } + + await _candidates.DeleteAsync(dialogId, ct).ConfigureAwait(false); + await _log.AddAsync(taskId, DiscoveryLogEvents.Skip, $"{dialogId}: мало подходящих ({fitCount} из {total})", ct) + .ConfigureAwait(false); + await _tasks.BumpCounterAsync(taskId, DiscoveryCounterField.Evaluated, 1, ct).ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionSkip, taskId); + } + + private async Task FinishReviewAsync( + string taskId, + string dialogId, + IReadOnlyList marks, + bool? langRu = null, + double? fitRatio = null, + IReadOnlyList? topics = null, + CancellationToken ct = default) + { + var patch = new DiscoveryCandidatePatch + { + Marks = marks.Where(mark => !string.IsNullOrEmpty(mark)).ToList(), + LangRu = langRu, + FitRatio = fitRatio, + Topics = topics, + }; + await _candidates.SetAsync(taskId, dialogId, patch, ct).ConfigureAwait(false); + await _candidates.SetStatusAsync(dialogId, DiscoveryCandidateStatuses.Review, ct).ConfigureAwait(false); + } +} diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Helpers.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Helpers.cs index 0fbbc0e..ccf21d7 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Helpers.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Helpers.cs @@ -1,37 +1,37 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Discovery.Application.Models; - -namespace Deal.Modules.Discovery.Application.Services; - -// Часть DiscoveryWorkerService: общие хелперы — распознавание FloodWait (IsFlood), нормализация kind -// источника (KindCode) и тексты сообщений для оценки (GroupTexts). -public sealed partial class DiscoveryWorkerService -{ - internal static bool IsFlood(Exception exception) - { - string message = exception.Message ?? string.Empty; - return message.StartsWith("flood:", StringComparison.OrdinalIgnoreCase) - || message.StartsWith("flood ", StringComparison.OrdinalIgnoreCase) - || message.Contains("flood:", StringComparison.OrdinalIgnoreCase); - } - - private static string KindCode(string kind, bool isForum = false) - { - if (isForum) - { - return DiscoveryCandidateKinds.Forum; - } - - return kind.Trim().ToLowerInvariant() switch - { - DiscoveryCandidateKinds.Channel or "канал" => DiscoveryCandidateKinds.Channel, - DiscoveryCandidateKinds.Forum or "форум" => DiscoveryCandidateKinds.Forum, - "chat" or "чат" => DiscoveryCandidateKinds.Group, - _ => DiscoveryCandidateKinds.Group, - }; - } - - // Тексты сообщений группы (входной порядок) для оценки выборки. - private static IReadOnlyList GroupTexts(IReadOnlyList messages) - => messages.Select(message => message.Text ?? string.Empty).ToList(); -} +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Discovery.Application.Models; + +namespace Deal.Modules.Discovery.Application.Services; + +// Часть DiscoveryWorkerService: общие хелперы — распознавание FloodWait (IsFlood), нормализация kind +// источника (KindCode) и тексты сообщений для оценки (GroupTexts). +public sealed partial class DiscoveryWorkerService +{ + internal static bool IsFlood(Exception exception) + { + string message = exception.Message ?? string.Empty; + return message.StartsWith("flood:", StringComparison.OrdinalIgnoreCase) + || message.StartsWith("flood ", StringComparison.OrdinalIgnoreCase) + || message.Contains("flood:", StringComparison.OrdinalIgnoreCase); + } + + private static string KindCode(string kind, bool isForum = false) + { + if (isForum) + { + return DiscoveryCandidateKinds.Forum; + } + + return kind.Trim().ToLowerInvariant() switch + { + DiscoveryCandidateKinds.Channel or "канал" => DiscoveryCandidateKinds.Channel, + DiscoveryCandidateKinds.Forum or "форум" => DiscoveryCandidateKinds.Forum, + "chat" or "чат" => DiscoveryCandidateKinds.Group, + _ => DiscoveryCandidateKinds.Group, + }; + } + + // Тексты сообщений группы (входной порядок) для оценки выборки. + private static IReadOnlyList GroupTexts(IReadOnlyList messages) + => messages.Select(message => message.Text ?? string.Empty).ToList(); +} diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Join.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Join.cs index b52f8b7..6996131 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Join.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Join.cs @@ -1,130 +1,130 @@ -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Models; - -namespace Deal.Modules.Discovery.Application.Services; - -public sealed partial class DiscoveryWorkerService -{ - private async Task JoinStepAsync( - DiscoveryTaskDto task, - DiscoveryCandidateDto candidate, - CancellationToken ct) - { - string taskId = task.Id; - string dialogId = candidate.DialogId; - - bool inDialogs = await _store.IsDialogMonitoredAsync(dialogId, ct).ConfigureAwait(false); - if (inDialogs || await _store.IsBlacklistedAsync(dialogId, ct).ConfigureAwait(false)) - { - string reason = inDialogs - ? "уже вступили между оценкой и авто-вступлением" - : "источник в чёрном списке (повторная проверка перед авто-вступлением)"; - try - { - await _candidates.MarkRejectedAsync(dialogId, reason, ct).ConfigureAwait(false); - } - catch (DiscoveryValidationException) - { - } - - return new DiscoveryWorkerOutcome(ActionReject, taskId); - } - - await _pacer.WaitJoinDelayAsync(ct).ConfigureAwait(false); - - DiscoveryTaskDto? taskNow = await _tasks.GetAsync(taskId, ct).ConfigureAwait(false); - DiscoveryCandidateDto? fresh = await _store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); - if (fresh is null - || fresh.Status != DiscoveryCandidateStatuses.Review - || taskNow is null - || taskNow.Status != DiscoveryTaskStatuses.Running - || !taskNow.AutoJoin - || await _store.IsDialogMonitoredAsync(dialogId, ct).ConfigureAwait(false) - || await _store.IsBlacklistedAsync(dialogId, ct).ConfigureAwait(false) - || !await _banGuard.CanAutoJoinAsync(ct).ConfigureAwait(false)) - { - return DiscoveryWorkerOutcome.None; - } - - string username = (fresh.Username ?? string.Empty).Trim().TrimStart('@'); - try - { - await _gateway.JoinAsync(username, ct).ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - if (IsFlood(exception)) - { - await _banGuard.NoteFloodAsync(ct).ConfigureAwait(false); - await _log.AddAsync( - taskId, - DiscoveryLogEvents.Flood, - $"авто-вступление {dialogId}: flood — стоп до конца суток", - ct).ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionFlood, taskId); - } - - DiscoveryCandidateDto? rowNow = await _store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); - if (rowNow is null || rowNow.Status != DiscoveryCandidateStatuses.Review) - { - return DiscoveryWorkerOutcome.None; - } - - int? failures = await _store.IncrementJoinFailuresAsync(dialogId, ct).ConfigureAwait(false); - if (failures is null) - { - return DiscoveryWorkerOutcome.None; - } - - if (failures.Value >= MaxJoinFailures) - { - await _candidates.DeleteAsync(dialogId, ct).ConfigureAwait(false); - await _log.AddAsync( - taskId, - DiscoveryLogEvents.Skip, - $"{dialogId}: не удалось вступить (3 попытки): {exception.Message}", - ct).ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionSkip, taskId); - } - - await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"авто-вступление {dialogId}: {exception.Message}", ct) - .ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionError, taskId); - } - - await _candidates.MarkJoinedAsync(dialogId, auto: true, ct).ConfigureAwait(false); - - try - { - await _gateway.SetMonitorAsync(dialogId, true, ct).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - // Зеркало сервиса обновится ближайшей SyncDialogs-синхронизацией — не ошибка шага, - // но сбой обязан быть видимым (DiscLog; Security/наблюдаемость review). - await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"включение мониторинга {dialogId}: {exception.Message}", ct) - .ConfigureAwait(false); - } - - try - { - await _gateway.BackfillAsync(dialogId, force: false, ct).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"фоновый разбор {dialogId}: {exception.Message}", ct) - .ConfigureAwait(false); - } - - await _store.RemoveBlacklistAsync(dialogId, ct).ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionJoin, taskId); - } -} +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Models; + +namespace Deal.Modules.Discovery.Application.Services; + +public sealed partial class DiscoveryWorkerService +{ + private async Task JoinStepAsync( + DiscoveryTaskDto task, + DiscoveryCandidateDto candidate, + CancellationToken ct) + { + string taskId = task.Id; + string dialogId = candidate.DialogId; + + bool inDialogs = await _store.IsDialogMonitoredAsync(dialogId, ct).ConfigureAwait(false); + if (inDialogs || await _store.IsBlacklistedAsync(dialogId, ct).ConfigureAwait(false)) + { + string reason = inDialogs + ? "уже вступили между оценкой и авто-вступлением" + : "источник в чёрном списке (повторная проверка перед авто-вступлением)"; + try + { + await _candidates.MarkRejectedAsync(dialogId, reason, ct).ConfigureAwait(false); + } + catch (DiscoveryValidationException) + { + } + + return new DiscoveryWorkerOutcome(ActionReject, taskId); + } + + await _pacer.WaitJoinDelayAsync(ct).ConfigureAwait(false); + + DiscoveryTaskDto? taskNow = await _tasks.GetAsync(taskId, ct).ConfigureAwait(false); + DiscoveryCandidateDto? fresh = await _store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); + if (fresh is null + || fresh.Status != DiscoveryCandidateStatuses.Review + || taskNow is null + || taskNow.Status != DiscoveryTaskStatuses.Running + || !taskNow.AutoJoin + || await _store.IsDialogMonitoredAsync(dialogId, ct).ConfigureAwait(false) + || await _store.IsBlacklistedAsync(dialogId, ct).ConfigureAwait(false) + || !await _banGuard.CanAutoJoinAsync(ct).ConfigureAwait(false)) + { + return DiscoveryWorkerOutcome.None; + } + + string username = (fresh.Username ?? string.Empty).Trim().TrimStart('@'); + try + { + await _gateway.JoinAsync(username, ct).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + if (IsFlood(exception)) + { + await _banGuard.NoteFloodAsync(ct).ConfigureAwait(false); + await _log.AddAsync( + taskId, + DiscoveryLogEvents.Flood, + $"авто-вступление {dialogId}: flood — стоп до конца суток", + ct).ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionFlood, taskId); + } + + DiscoveryCandidateDto? rowNow = await _store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); + if (rowNow is null || rowNow.Status != DiscoveryCandidateStatuses.Review) + { + return DiscoveryWorkerOutcome.None; + } + + int? failures = await _store.IncrementJoinFailuresAsync(dialogId, ct).ConfigureAwait(false); + if (failures is null) + { + return DiscoveryWorkerOutcome.None; + } + + if (failures.Value >= MaxJoinFailures) + { + await _candidates.DeleteAsync(dialogId, ct).ConfigureAwait(false); + await _log.AddAsync( + taskId, + DiscoveryLogEvents.Skip, + $"{dialogId}: не удалось вступить (3 попытки): {exception.Message}", + ct).ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionSkip, taskId); + } + + await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"авто-вступление {dialogId}: {exception.Message}", ct) + .ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionError, taskId); + } + + await _candidates.MarkJoinedAsync(dialogId, auto: true, ct).ConfigureAwait(false); + + try + { + await _gateway.SetMonitorAsync(dialogId, true, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + // Зеркало сервиса обновится ближайшей SyncDialogs-синхронизацией — не ошибка шага, + // но сбой обязан быть видимым (DiscLog; Security/наблюдаемость review). + await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"включение мониторинга {dialogId}: {exception.Message}", ct) + .ConfigureAwait(false); + } + + try + { + await _gateway.BackfillAsync(dialogId, force: false, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"фоновый разбор {dialogId}: {exception.Message}", ct) + .ConfigureAwait(false); + } + + await _store.RemoveBlacklistAsync(dialogId, ct).ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionJoin, taskId); + } +} diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Search.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Search.cs index 39d3572..e94ea57 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Search.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Search.cs @@ -1,90 +1,90 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Discovery.Application.Models; - -namespace Deal.Modules.Discovery.Application.Services; - -public sealed partial class DiscoveryWorkerService -{ - private async Task SearchStepAsync(DiscoveryTaskDto task, CancellationToken ct) - { - string taskId = task.Id; - IReadOnlyList keywords = task.Keywords; - int index = task.SearchIdx; - if (keywords.Count == 0 || index >= keywords.Count) - { - await _tasks.AdvanceSearchAsync(taskId, ct).ConfigureAwait(false); - await LogSearchDoneIfAnyAsync(taskId, ct).ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionSearch, taskId); - } - - string keyword = keywords[index]; - IReadOnlyList results; - try - { - results = await _gateway.SearchAsync(keyword, SearchResultLimit, ct).ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - if (IsFlood(exception)) - { - await _banGuard.NoteFloodAsync(ct).ConfigureAwait(false); - await _log.AddAsync(taskId, DiscoveryLogEvents.Flood, $"поиск «{keyword}»: flood — стоп до конца суток", ct) - .ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionFlood, taskId); - } - - int errors = _searchErrors.Next(taskId); - if (errors >= SearchErrorsToSkip) - { - _searchErrors.Reset(taskId); - await _log.AddAsync( - taskId, - DiscoveryLogEvents.Error, - $"поиск «{keyword}»: {exception.Message} — ключ пропущен ({errors} ошибки подряд)", - ct).ConfigureAwait(false); - await _tasks.AdvanceSearchAsync(taskId, ct).ConfigureAwait(false); - await LogSearchDoneIfAnyAsync(taskId, ct).ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionError, taskId); - } - - await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"поиск «{keyword}»: {exception.Message}", ct) - .ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionError, taskId); - } - - _searchErrors.Reset(taskId); // успешный поиск — сброс счётчика ошибок ключа (python L207). - foreach (TelegramDialogEntryDto item in results) - { - string rawKind = (item.Kind ?? string.Empty).Trim().ToLowerInvariant(); - if (rawKind == "чат" || rawKind == "chat") - { - await _log.AddAsync(taskId, DiscoveryLogEvents.Skip, $"{item.Name}: личный чат/бот", ct) - .ConfigureAwait(false); - continue; - } - - await _candidates.AddAsync( - taskId, - item.Id, - item.Name ?? string.Empty, - item.Handle ?? string.Empty, - KindCode(rawKind), - item.Hue ?? string.Empty, - ct).ConfigureAwait(false); - } - - await _tasks.AdvanceSearchAsync(taskId, ct).ConfigureAwait(false); - await LogSearchDoneIfAnyAsync(taskId, ct).ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionSearch, taskId); - } - - private async Task LogSearchDoneIfAnyAsync(string taskId, CancellationToken ct) - { - DiscoveryTaskDto? task = await _tasks.GetAsync(taskId, ct).ConfigureAwait(false); - if (task is not null && task.SearchDone) - { - await _log.AddAsync(taskId, DiscoveryLogEvents.Search, $"поиск завершён: {task.Found} кандидатов", ct) - .ConfigureAwait(false); - } - } -} +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Discovery.Application.Models; + +namespace Deal.Modules.Discovery.Application.Services; + +public sealed partial class DiscoveryWorkerService +{ + private async Task SearchStepAsync(DiscoveryTaskDto task, CancellationToken ct) + { + string taskId = task.Id; + IReadOnlyList keywords = task.Keywords; + int index = task.SearchIdx; + if (keywords.Count == 0 || index >= keywords.Count) + { + await _tasks.AdvanceSearchAsync(taskId, ct).ConfigureAwait(false); + await LogSearchDoneIfAnyAsync(taskId, ct).ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionSearch, taskId); + } + + string keyword = keywords[index]; + IReadOnlyList results; + try + { + results = await _gateway.SearchAsync(keyword, SearchResultLimit, ct).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + if (IsFlood(exception)) + { + await _banGuard.NoteFloodAsync(ct).ConfigureAwait(false); + await _log.AddAsync(taskId, DiscoveryLogEvents.Flood, $"поиск «{keyword}»: flood — стоп до конца суток", ct) + .ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionFlood, taskId); + } + + int errors = _searchErrors.Next(taskId); + if (errors >= SearchErrorsToSkip) + { + _searchErrors.Reset(taskId); + await _log.AddAsync( + taskId, + DiscoveryLogEvents.Error, + $"поиск «{keyword}»: {exception.Message} — ключ пропущен ({errors} ошибки подряд)", + ct).ConfigureAwait(false); + await _tasks.AdvanceSearchAsync(taskId, ct).ConfigureAwait(false); + await LogSearchDoneIfAnyAsync(taskId, ct).ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionError, taskId); + } + + await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"поиск «{keyword}»: {exception.Message}", ct) + .ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionError, taskId); + } + + _searchErrors.Reset(taskId); // успешный поиск — сброс счётчика ошибок ключа (python L207). + foreach (TelegramDialogEntryDto item in results) + { + string rawKind = (item.Kind ?? string.Empty).Trim().ToLowerInvariant(); + if (rawKind == "чат" || rawKind == "chat") + { + await _log.AddAsync(taskId, DiscoveryLogEvents.Skip, $"{item.Name}: личный чат/бот", ct) + .ConfigureAwait(false); + continue; + } + + await _candidates.AddAsync( + taskId, + item.Id, + item.Name ?? string.Empty, + item.Handle ?? string.Empty, + KindCode(rawKind), + item.Hue ?? string.Empty, + ct).ConfigureAwait(false); + } + + await _tasks.AdvanceSearchAsync(taskId, ct).ConfigureAwait(false); + await LogSearchDoneIfAnyAsync(taskId, ct).ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionSearch, taskId); + } + + private async Task LogSearchDoneIfAnyAsync(string taskId, CancellationToken ct) + { + DiscoveryTaskDto? task = await _tasks.GetAsync(taskId, ct).ConfigureAwait(false); + if (task is not null && task.SearchDone) + { + await _log.AddAsync(taskId, DiscoveryLogEvents.Search, $"поиск завершён: {task.Found} кандидатов", ct) + .ConfigureAwait(false); + } + } +} diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs index 1648bec..a5e66ea 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs @@ -1,142 +1,142 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Models; - -namespace Deal.Modules.Discovery.Application.Services; - -/// -/// Воркер Discovery -/// -public sealed partial class DiscoveryWorkerService -{ - - private readonly IDiscoveryStore _store; - private readonly DiscoveryTasksService _tasks; - private readonly DiscoveryCandidatesService _candidates; - private readonly DiscoveryLogService _log; - private readonly DiscoveryEvaluator _evaluator; - private readonly DiscoveryBanGuard _banGuard; - private readonly IDiscoveryPacer _pacer; - private readonly ITelegramGateway _gateway; - private readonly IDiscoverySearchErrorCounter _searchErrors; - - /// - /// Создаёт воркер Discovery - /// - /// Хранилище Discovery (микро-операции: done, join_failures, проверки «не состоим»). - /// Сервис задач (список running, advance_search, bump счётчиков). - /// Сервис кандидатов (add/set/status/mark_joined/mark_rejected/delete). - /// Лог задач (события search/flood/error/done/skip). - /// Оценка содержания (фит/темы; ядро каскада discovery_eval). - /// Квоты дня/flood/стоп-кран (DiscoveryBanGuard). - /// Паузы между авто-вступлениями (интерфейс — фейк в тестах). - /// Гейт telegram-service (Search/Info/ReadForEval/Join/SetMonitor/Backfill). - /// Singleton-счётчик ошибок ключей поиска; null — локальный (на инстанс воркера). - public DiscoveryWorkerService( - IDiscoveryStore store, - DiscoveryTasksService tasks, - DiscoveryCandidatesService candidates, - DiscoveryLogService log, - DiscoveryEvaluator evaluator, - DiscoveryBanGuard banGuard, - IDiscoveryPacer pacer, - ITelegramGateway gateway, - IDiscoverySearchErrorCounter? searchErrors = null) - { - ArgumentNullException.ThrowIfNull(store); - ArgumentNullException.ThrowIfNull(tasks); - ArgumentNullException.ThrowIfNull(candidates); - ArgumentNullException.ThrowIfNull(log); - ArgumentNullException.ThrowIfNull(evaluator); - ArgumentNullException.ThrowIfNull(banGuard); - ArgumentNullException.ThrowIfNull(pacer); - ArgumentNullException.ThrowIfNull(gateway); - _store = store; - _tasks = tasks; - _candidates = candidates; - _log = log; - _evaluator = evaluator; - _banGuard = banGuard; - _pacer = pacer; - _gateway = gateway; - _searchErrors = searchErrors ?? new DiscoverySearchErrorCounter(); - } - - /// - /// Один шаг discovery-воркера. - /// - /// Результат действия: действие + id задачи (если применимо). - public async Task TickOnceAsync(CancellationToken ct) - { - if (await _banGuard.GlobalPausedAsync(ct).ConfigureAwait(false) - || await _banGuard.FloodTodayAsync(ct).ConfigureAwait(false)) - { - return DiscoveryWorkerOutcome.None; - } - - IReadOnlyList allTasks = await _tasks.ListAsync(ct).ConfigureAwait(false); - List running = allTasks.Where(task => task.Status == DiscoveryTaskStatuses.Running).ToList(); - if (running.Count == 0) - { - return DiscoveryWorkerOutcome.None; - } - - foreach (DiscoveryTaskDto task in running) - { - if (task.Joined >= task.PlanJoins) - { - await FinishDoneAsync(task, ct).ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionDone, task.Id); - } - } - - foreach (DiscoveryTaskDto task in running) - { - if (!task.SearchDone) - { - return await SearchStepAsync(task, ct).ConfigureAwait(false); - } - } - - foreach (DiscoveryTaskDto task in running) - { - IReadOnlyList newCandidates = - await _candidates.ListAsync(task.Id, DiscoveryCandidateStatuses.New, ct).ConfigureAwait(false); - if (newCandidates.Count > 0) - { - return await EvalStepAsync(task, newCandidates[0], ct).ConfigureAwait(false); - } - } - - foreach (DiscoveryTaskDto task in running) - { - if (!task.AutoJoin) - { - continue; - } - - IReadOnlyList reviewCandidates = - await _candidates.ListAsync(task.Id, DiscoveryCandidateStatuses.Review, ct).ConfigureAwait(false); - if (reviewCandidates.Count == 0) - { - continue; - } - - if (!await _banGuard.CanAutoJoinAsync(ct).ConfigureAwait(false)) - { - return DiscoveryWorkerOutcome.None; - } - - return await JoinStepAsync(task, reviewCandidates[0], ct).ConfigureAwait(false); - } - - return DiscoveryWorkerOutcome.None; - } - - private async Task FinishDoneAsync(DiscoveryTaskDto task, CancellationToken ct) - { - await _store.SetTaskDoneAsync(task.Id, ct).ConfigureAwait(false); - await _log.AddAsync(task.Id, DiscoveryLogEvents.Done, $"план выполнен: вступили {task.Joined} из {task.PlanJoins}", ct) - .ConfigureAwait(false); - } -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Models; + +namespace Deal.Modules.Discovery.Application.Services; + +/// +/// Воркер Discovery +/// +public sealed partial class DiscoveryWorkerService +{ + + private readonly IDiscoveryStore _store; + private readonly DiscoveryTasksService _tasks; + private readonly DiscoveryCandidatesService _candidates; + private readonly DiscoveryLogService _log; + private readonly DiscoveryEvaluator _evaluator; + private readonly DiscoveryBanGuard _banGuard; + private readonly IDiscoveryPacer _pacer; + private readonly ITelegramGateway _gateway; + private readonly IDiscoverySearchErrorCounter _searchErrors; + + /// + /// Создаёт воркер Discovery + /// + /// Хранилище Discovery (микро-операции: done, join_failures, проверки «не состоим»). + /// Сервис задач (список running, advance_search, bump счётчиков). + /// Сервис кандидатов (add/set/status/mark_joined/mark_rejected/delete). + /// Лог задач (события search/flood/error/done/skip). + /// Оценка содержания (фит/темы; ядро каскада discovery_eval). + /// Квоты дня/flood/стоп-кран (DiscoveryBanGuard). + /// Паузы между авто-вступлениями (интерфейс — фейк в тестах). + /// Гейт telegram-service (Search/Info/ReadForEval/Join/SetMonitor/Backfill). + /// Singleton-счётчик ошибок ключей поиска; null — локальный (на инстанс воркера). + public DiscoveryWorkerService( + IDiscoveryStore store, + DiscoveryTasksService tasks, + DiscoveryCandidatesService candidates, + DiscoveryLogService log, + DiscoveryEvaluator evaluator, + DiscoveryBanGuard banGuard, + IDiscoveryPacer pacer, + ITelegramGateway gateway, + IDiscoverySearchErrorCounter? searchErrors = null) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(tasks); + ArgumentNullException.ThrowIfNull(candidates); + ArgumentNullException.ThrowIfNull(log); + ArgumentNullException.ThrowIfNull(evaluator); + ArgumentNullException.ThrowIfNull(banGuard); + ArgumentNullException.ThrowIfNull(pacer); + ArgumentNullException.ThrowIfNull(gateway); + _store = store; + _tasks = tasks; + _candidates = candidates; + _log = log; + _evaluator = evaluator; + _banGuard = banGuard; + _pacer = pacer; + _gateway = gateway; + _searchErrors = searchErrors ?? new DiscoverySearchErrorCounter(); + } + + /// + /// Один шаг discovery-воркера. + /// + /// Результат действия: действие + id задачи (если применимо). + public async Task TickOnceAsync(CancellationToken ct) + { + if (await _banGuard.GlobalPausedAsync(ct).ConfigureAwait(false) + || await _banGuard.FloodTodayAsync(ct).ConfigureAwait(false)) + { + return DiscoveryWorkerOutcome.None; + } + + IReadOnlyList allTasks = await _tasks.ListAsync(ct).ConfigureAwait(false); + List running = allTasks.Where(task => task.Status == DiscoveryTaskStatuses.Running).ToList(); + if (running.Count == 0) + { + return DiscoveryWorkerOutcome.None; + } + + foreach (DiscoveryTaskDto task in running) + { + if (task.Joined >= task.PlanJoins) + { + await FinishDoneAsync(task, ct).ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionDone, task.Id); + } + } + + foreach (DiscoveryTaskDto task in running) + { + if (!task.SearchDone) + { + return await SearchStepAsync(task, ct).ConfigureAwait(false); + } + } + + foreach (DiscoveryTaskDto task in running) + { + IReadOnlyList newCandidates = + await _candidates.ListAsync(task.Id, DiscoveryCandidateStatuses.New, ct).ConfigureAwait(false); + if (newCandidates.Count > 0) + { + return await EvalStepAsync(task, newCandidates[0], ct).ConfigureAwait(false); + } + } + + foreach (DiscoveryTaskDto task in running) + { + if (!task.AutoJoin) + { + continue; + } + + IReadOnlyList reviewCandidates = + await _candidates.ListAsync(task.Id, DiscoveryCandidateStatuses.Review, ct).ConfigureAwait(false); + if (reviewCandidates.Count == 0) + { + continue; + } + + if (!await _banGuard.CanAutoJoinAsync(ct).ConfigureAwait(false)) + { + return DiscoveryWorkerOutcome.None; + } + + return await JoinStepAsync(task, reviewCandidates[0], ct).ConfigureAwait(false); + } + + return DiscoveryWorkerOutcome.None; + } + + private async Task FinishDoneAsync(DiscoveryTaskDto task, CancellationToken ct) + { + await _store.SetTaskDoneAsync(task.Id, ct).ConfigureAwait(false); + await _log.AddAsync(task.Id, DiscoveryLogEvents.Done, $"план выполнен: вступили {task.Joined} из {task.PlanJoins}", ct) + .ConfigureAwait(false); + } +} diff --git a/src/core/Deal.Modules.Discovery/Deal.Modules.Discovery.csproj b/src/core/Deal.Modules.Discovery/Deal.Modules.Discovery.csproj index b9d5e25..54ce0fb 100644 --- a/src/core/Deal.Modules.Discovery/Deal.Modules.Discovery.csproj +++ b/src/core/Deal.Modules.Discovery/Deal.Modules.Discovery.csproj @@ -1,19 +1,19 @@ - - - - - - - - - - - - - - net10.0 - enable - enable - - - + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/core/Deal.Modules.Kanban/Application/Abstractions/ICardStore.cs b/src/core/Deal.Modules.Kanban/Application/Abstractions/ICardStore.cs index dde73cd..0a199f4 100644 --- a/src/core/Deal.Modules.Kanban/Application/Abstractions/ICardStore.cs +++ b/src/core/Deal.Modules.Kanban/Application/Abstractions/ICardStore.cs @@ -1,371 +1,371 @@ -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Modules.Kanban.Application.Abstractions; - -/// -/// Единый порт хранилища карточек и контейнеров. -/// -public interface ICardStore -{ - // ── Контейнеры (колонки/стадии/зоны) ───────────────────────────────── - - /// - /// Контейнеры пространства в порядке показа - /// - /// Пространство (dashboard/selected) либо null — все контейнеры. - /// Контейнеры (DTO, счётчики заполняет сервис); пусто — контейнеров нет. - public Task> ListContainersAsync(string? space, CancellationToken ct); - - /// - /// Один контейнер по id - /// - /// Id контейнера (inbox/archive/trash/стадия/b_...). - /// Контейнер или null, если строки нет. - public Task GetContainerAsync(string containerId, CancellationToken ct); - - /// - /// Создаёт контейнер - /// - /// Полный контейнер для вставки, включая . - public Task CreateContainerAsync(ContainerDto container, CancellationToken ct); - - /// - /// Обновляет контейнер целиком - /// - /// Контейнер с изменёнными полями (идентифицируется по Id). - public Task UpdateContainerAsync(ContainerDto container, CancellationToken ct); - - /// - /// Удаляет контейнер, предварительно перенося его карточки в «Неразобранное». - /// - /// Id удаляемого контейнера. - /// Сколько карточек перенесено в inbox (0 — контейнера/карточек не было; ответ {ok, movedToInbox}). - public Task DeleteContainerAsync(string containerId, CancellationToken ct); - - /// - /// Переставляет контейнеры пространства - /// - /// Пространство переставляемых контейнеров. - /// Id контейнеров в новом порядке. - public Task ReorderContainersAsync( - string space, - IReadOnlyList containerIds, - CancellationToken ct); - - // ── Карточки ────────────────────────────────────────────────────────── - - /// - /// Карточки колонки или всех колонок дашборда, ORDER BY received_at DESC. - /// - /// Фильтр: Col — конкретная колонка либо null (все колонки дашборда). - /// Полные карточки (DTO): JSON-поля разобраны, комментарии приложены, time посчитан. - public Task> ListCardsAsync(CardsQuery query, CancellationToken ct); - - /// - /// Карточки пространства «Выбранные», ORDER BY updated_at DESC. - /// - /// Фильтр по контейнеру-стадии (id каталога ); null — все стадии. - /// Полные карточки (DTO): JSON-поля разобраны, комментарии приложены, time посчитан. - public Task> ListSelectedCardsAsync(string? containerId, CancellationToken ct); - - /// - /// Полнотекстовый поиск карточек. - /// - /// Поисковый запрос (уже Trim+lowercase, как в отсев-поиске; короче 2 символов сервис не пропускает). - /// Ограничение результата. - /// Полные карточки (комментарии приложены, time посчитан): FTS-кандидаты по убыванию ts_rank (SearchTsv @@ plainto_tsquery), затем LIKE-дополнение, внутри — ReceivedAt DESC. - public Task> SearchCardsAsync( - string q, - int limit, - CancellationToken ct); - - /// - /// Одна карточка по id - /// - /// Id карточки (c_...). - /// Карточка или null, если строки нет. - public Task GetCardAsync(string cardId, CancellationToken ct); - - /// - /// Карточка по исходному сообщению - /// - /// Ссылка на источник: вид + оригинал + внешний id. - /// Карточка или null, если сообщение не становилось карточкой. - public Task GetCardBySourceAsync( - SourceRef source, - CancellationToken ct); - - /// - /// Создаёт карточку из готового снимка. - /// - /// Полное состояние новой карточки (id сгенерирован модулем, см. CardSnapshot). - public Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct); - - /// - /// Точечная правка полей по присутствующим в патче + bump UpdatedAt. - /// - /// Id карточки (c_...). - /// Изменяемые поля (null — поле не меняется; JSON-поля — полная замена). - /// True — строка обновлена; false — карточки нет (404-семантика сервиса). - public Task PatchCardAsync( - string cardId, - CardPatch patch, - CancellationToken ct); - - /// - /// Атомарно дописывает ссылку в JSON-массив links ОДНИМ UPDATE - /// - /// Id карточки (c_...). - /// Готовая ссылка {id,name,url} (id сгенерирован модулем). - /// True — строка обновлена; false — карточки нет (404-семантика сервиса). - public Task AddLinkAsync( - string cardId, - CardLinkDto link, - CancellationToken ct); - - /// - /// Атомарно убирает из JSON-массива links элемент с указанным id ОДНИМ UPDATE - /// - /// Id карточки (c_...). - /// Id удаляемой ссылки (pl_...). - /// True — строка обновлена; false — карточки нет (404-семантика сервиса). - public Task RemoveLinkAsync( - string cardId, - string linkId, - CancellationToken ct); - - /// - /// Атомарно дописывает метаданные файла в JSON-массив files ОДНИМ UPDATE - /// - /// Id карточки (c_...). - /// Готовые метаданные {id,name,size,kind,label,objectKey} (id сгенерирован модулем). - /// True — строка обновлена; false — карточки нет (404-семантика сервиса). - public Task AddFileAsync( - string cardId, - CardFileDto file, - CancellationToken ct); - - /// - /// Атомарно убирает из JSON-массива files элемент с указанным id ОДНИМ UPDATE - /// - /// Id карточки (c_...). - /// Id удаляемой записи файла (pf_...). - /// True — строка обновлена; false — карточки нет (404-семантика сервиса). - public Task RemoveFileAsync( - string cardId, - string fileId, - CancellationToken ct); - - /// - /// Смена контейнера-стадии - /// - /// Id карточки (c_...). - /// Новый контейнер-стадия (валидирует сервис каталогом ). - /// Готовая запись истории {id,at,stage} (id сгенерирован модулем). - /// Время переноса, epoch-ms (пишется в updated_at и в запись истории). - /// True — строка обновлена; false — карточки нет (404-семантика сервиса). - public Task MoveCardStageAsync( - string cardId, - string containerId, - CardHistoryDto historyEntry, - long atMs, - CancellationToken ct); - - /// - /// Устанавливает напоминание - /// - /// Id карточки (c_...). - /// Время напоминания, epoch-ms. - public Task SetReminderAsync( - string cardId, - long atMs, - CancellationToken ct); - - /// - /// Снимает напоминание - /// - /// Id карточки (c_...). - public Task ClearReminderAsync(string cardId, CancellationToken ct); - - /// - /// Полная ручная очистка контейнера-стадии - /// - /// Очищаемая стадия (допустимость — только rejected — валидирует сервис). - /// Сколько карточек удалено (0 — стадия пуста). - public Task ClearStageAsync(string containerId, CancellationToken ct); - - /// - /// Наступившие напоминания стадии hold - /// - /// Текущий момент (UTC) для сравнения с ReminderAt. - /// Due-строки {id,title,containerId} в порядке наступления; пусто — сработавших нет. - public Task> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct); - - /// - /// Помечает due-карточки сработавшими - /// - /// Id карточек, чьи напоминания «выстрелили». - public Task MarkRemindersFiredAsync(IReadOnlyList cardIds, CancellationToken ct); - - /// - /// Очищает протухшие напоминания при выключенной настройке - /// - /// Текущий момент (UTC). - /// Сколько строк очищено. - public Task ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct); - - /// - /// Меняет колонку/состояние карточки - /// - /// Новое состояние колонки карточки. - public Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct); - - /// - /// Применяет результат ручной переклассификации к существующей карточке ОДНИМ обновлением - /// - /// Поля классификации (полная замена; см. CardReclassificationDto). - /// True — строка обновлена; false — карточки нет (404-семантика сервиса). - public Task ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct); - - /// - /// Снимает флаг «новое» - /// - /// Id карточки либо null. - /// Колонка либо null. - public Task UpdateSeenAsync( - string? cardId, - string? col, - CancellationToken ct); - - /// - /// Удаляет карточку навсегда - /// - /// Id карточки. - public Task DeleteForeverAsync(string cardId, CancellationToken ct); - - /// - /// Полная очистка служебной колонки - /// - /// Очищаемая колонка (trash/archive). - /// Сколько карточек удалено (0 — колонка пуста). - public Task ClearColAsync(string col, CancellationToken ct); - - /// - /// Счётчики карточек по колонкам - /// - /// Словарь col → {count, new}; ключи — только колонки с карточками. - public Task> CountCardsByColAsync(CancellationToken ct); - - // ── Комментарии (LeadComments) ──────────────────────────────────────── - - /// - /// Комментарии карточки - /// - /// Id карточки. - /// Комментарии (time — human-метка от CreatedAt); пусто — комментариев нет. - public Task> ListCommentsAsync(string cardId, CancellationToken ct); - - /// - /// Добавляет комментарий. - /// - /// Готовый id (cm_..., генерирует модуль). - /// Id карточки. - /// Автор («Вы» — свои комментарии). - /// Текст (непустой — валидирует сервис, 400 «Пустой комментарий»). - public Task AddCommentAsync( - string commentId, - string cardId, - string by, - string text, - CancellationToken ct); - - // ── Журнал действий (CardMoves = learning_log) ──────────────────────── - - /// - /// Пишет строку журнала действия пользователя - /// - /// Запись журнала (id lm_... сгенерирован модулем). - public Task AddMoveAsync(CardMoveDto move, CancellationToken ct); - - /// - /// Число записей журнала = счётчик learning. - /// - /// Количество строк CardMoves. - public Task CountMovesAsync(CancellationToken ct); - - /// - /// Свежие примеры разметки пользователя для few-shot ИИ-классификации. - /// - /// Максимум примеров. - /// Примеры «текст → колонка», свежие первыми; пусто — истории разметки нет. - public Task> GetAiMarkupExamplesAsync(int limit, CancellationToken ct); - - - /// - /// Кандидаты на автоархив - /// - /// Граница: received_at < now − archiveAfterDays. - /// Id карточек-кандидатов (колонка при архивации пишется archive через UpdateColumnAsync). - public Task> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct); - - /// - /// Архивирует пачку карточек ОДНИМ UPDATE - /// - /// Id карточек-кандидатов (список из ListArchiveCandidatesAsync). - /// Момент архивации (один «now» тика). - /// Сколько карточек реально архивировано (0 — кандидатов не было/уже не в рабочих колонках). - public Task ArchiveAsync( - IReadOnlyList cardIds, - DateTimeOffset archivedAt, - CancellationToken ct); - - /// - /// Кандидаты на очистку архива - /// - /// Граница: archived_at < now − archiveClearDays. - /// Id карточек для жёсткого удаления. - public Task> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct); - - /// - /// Кандидаты на очистку корзины - /// - /// Граница: received_at < now − trashClearDays. - /// Id карточек для жёсткого удаления. - public Task> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct); - - /// - /// Жёстко удаляет пачку карточек - /// - /// Id карточек на удаление. - /// Сколько карточек удалено. - public Task PurgeAsync(IReadOnlyList cardIds, CancellationToken ct); - - - /// - /// Карточки для пересчёта ConvFrom/ConvTo/ConvCur - /// - /// Карточки с бюджетом (используются Budget/бюджетные поля; служебные колонки исключены). - public Task> ListCardsForConversionAsync(CancellationToken ct); - - /// - /// Записывает пересчитанную конверсию бюджета карточки - /// - /// Id карточки. - /// Сконвертированная нижняя граница, либо null. - /// Сконвертированная верхняя граница, либо null. - /// Валюта конверсии (целевая валюта тенанта); пусто — конверсия снята. - public Task UpdateConversionAsync( - string cardId, - double? convFrom, - double? convTo, - string convCur, - CancellationToken ct); - - - /// - /// Карточки «Неразобранного» с исходным текстом — вход эвристики suggest. - /// - /// Карточки col='inbox' с непустым текстом источника. - public Task> ListInboxWithSourceAsync(CancellationToken ct); -} +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Modules.Kanban.Application.Abstractions; + +/// +/// Единый порт хранилища карточек и контейнеров. +/// +public interface ICardStore +{ + // ── Контейнеры (колонки/стадии/зоны) ───────────────────────────────── + + /// + /// Контейнеры пространства в порядке показа + /// + /// Пространство (dashboard/selected) либо null — все контейнеры. + /// Контейнеры (DTO, счётчики заполняет сервис); пусто — контейнеров нет. + public Task> ListContainersAsync(string? space, CancellationToken ct); + + /// + /// Один контейнер по id + /// + /// Id контейнера (inbox/archive/trash/стадия/b_...). + /// Контейнер или null, если строки нет. + public Task GetContainerAsync(string containerId, CancellationToken ct); + + /// + /// Создаёт контейнер + /// + /// Полный контейнер для вставки, включая . + public Task CreateContainerAsync(ContainerDto container, CancellationToken ct); + + /// + /// Обновляет контейнер целиком + /// + /// Контейнер с изменёнными полями (идентифицируется по Id). + public Task UpdateContainerAsync(ContainerDto container, CancellationToken ct); + + /// + /// Удаляет контейнер, предварительно перенося его карточки в «Неразобранное». + /// + /// Id удаляемого контейнера. + /// Сколько карточек перенесено в inbox (0 — контейнера/карточек не было; ответ {ok, movedToInbox}). + public Task DeleteContainerAsync(string containerId, CancellationToken ct); + + /// + /// Переставляет контейнеры пространства + /// + /// Пространство переставляемых контейнеров. + /// Id контейнеров в новом порядке. + public Task ReorderContainersAsync( + string space, + IReadOnlyList containerIds, + CancellationToken ct); + + // ── Карточки ────────────────────────────────────────────────────────── + + /// + /// Карточки колонки или всех колонок дашборда, ORDER BY received_at DESC. + /// + /// Фильтр: Col — конкретная колонка либо null (все колонки дашборда). + /// Полные карточки (DTO): JSON-поля разобраны, комментарии приложены, time посчитан. + public Task> ListCardsAsync(CardsQuery query, CancellationToken ct); + + /// + /// Карточки пространства «Выбранные», ORDER BY updated_at DESC. + /// + /// Фильтр по контейнеру-стадии (id каталога ); null — все стадии. + /// Полные карточки (DTO): JSON-поля разобраны, комментарии приложены, time посчитан. + public Task> ListSelectedCardsAsync(string? containerId, CancellationToken ct); + + /// + /// Полнотекстовый поиск карточек. + /// + /// Поисковый запрос (уже Trim+lowercase, как в отсев-поиске; короче 2 символов сервис не пропускает). + /// Ограничение результата. + /// Полные карточки (комментарии приложены, time посчитан): FTS-кандидаты по убыванию ts_rank (SearchTsv @@ plainto_tsquery), затем LIKE-дополнение, внутри — ReceivedAt DESC. + public Task> SearchCardsAsync( + string q, + int limit, + CancellationToken ct); + + /// + /// Одна карточка по id + /// + /// Id карточки (c_...). + /// Карточка или null, если строки нет. + public Task GetCardAsync(string cardId, CancellationToken ct); + + /// + /// Карточка по исходному сообщению + /// + /// Ссылка на источник: вид + оригинал + внешний id. + /// Карточка или null, если сообщение не становилось карточкой. + public Task GetCardBySourceAsync( + SourceRef source, + CancellationToken ct); + + /// + /// Создаёт карточку из готового снимка. + /// + /// Полное состояние новой карточки (id сгенерирован модулем, см. CardSnapshot). + public Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct); + + /// + /// Точечная правка полей по присутствующим в патче + bump UpdatedAt. + /// + /// Id карточки (c_...). + /// Изменяемые поля (null — поле не меняется; JSON-поля — полная замена). + /// True — строка обновлена; false — карточки нет (404-семантика сервиса). + public Task PatchCardAsync( + string cardId, + CardPatch patch, + CancellationToken ct); + + /// + /// Атомарно дописывает ссылку в JSON-массив links ОДНИМ UPDATE + /// + /// Id карточки (c_...). + /// Готовая ссылка {id,name,url} (id сгенерирован модулем). + /// True — строка обновлена; false — карточки нет (404-семантика сервиса). + public Task AddLinkAsync( + string cardId, + CardLinkDto link, + CancellationToken ct); + + /// + /// Атомарно убирает из JSON-массива links элемент с указанным id ОДНИМ UPDATE + /// + /// Id карточки (c_...). + /// Id удаляемой ссылки (pl_...). + /// True — строка обновлена; false — карточки нет (404-семантика сервиса). + public Task RemoveLinkAsync( + string cardId, + string linkId, + CancellationToken ct); + + /// + /// Атомарно дописывает метаданные файла в JSON-массив files ОДНИМ UPDATE + /// + /// Id карточки (c_...). + /// Готовые метаданные {id,name,size,kind,label,objectKey} (id сгенерирован модулем). + /// True — строка обновлена; false — карточки нет (404-семантика сервиса). + public Task AddFileAsync( + string cardId, + CardFileDto file, + CancellationToken ct); + + /// + /// Атомарно убирает из JSON-массива files элемент с указанным id ОДНИМ UPDATE + /// + /// Id карточки (c_...). + /// Id удаляемой записи файла (pf_...). + /// True — строка обновлена; false — карточки нет (404-семантика сервиса). + public Task RemoveFileAsync( + string cardId, + string fileId, + CancellationToken ct); + + /// + /// Смена контейнера-стадии + /// + /// Id карточки (c_...). + /// Новый контейнер-стадия (валидирует сервис каталогом ). + /// Готовая запись истории {id,at,stage} (id сгенерирован модулем). + /// Время переноса, epoch-ms (пишется в updated_at и в запись истории). + /// True — строка обновлена; false — карточки нет (404-семантика сервиса). + public Task MoveCardStageAsync( + string cardId, + string containerId, + CardHistoryDto historyEntry, + long atMs, + CancellationToken ct); + + /// + /// Устанавливает напоминание + /// + /// Id карточки (c_...). + /// Время напоминания, epoch-ms. + public Task SetReminderAsync( + string cardId, + long atMs, + CancellationToken ct); + + /// + /// Снимает напоминание + /// + /// Id карточки (c_...). + public Task ClearReminderAsync(string cardId, CancellationToken ct); + + /// + /// Полная ручная очистка контейнера-стадии + /// + /// Очищаемая стадия (допустимость — только rejected — валидирует сервис). + /// Сколько карточек удалено (0 — стадия пуста). + public Task ClearStageAsync(string containerId, CancellationToken ct); + + /// + /// Наступившие напоминания стадии hold + /// + /// Текущий момент (UTC) для сравнения с ReminderAt. + /// Due-строки {id,title,containerId} в порядке наступления; пусто — сработавших нет. + public Task> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct); + + /// + /// Помечает due-карточки сработавшими + /// + /// Id карточек, чьи напоминания «выстрелили». + public Task MarkRemindersFiredAsync(IReadOnlyList cardIds, CancellationToken ct); + + /// + /// Очищает протухшие напоминания при выключенной настройке + /// + /// Текущий момент (UTC). + /// Сколько строк очищено. + public Task ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct); + + /// + /// Меняет колонку/состояние карточки + /// + /// Новое состояние колонки карточки. + public Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct); + + /// + /// Применяет результат ручной переклассификации к существующей карточке ОДНИМ обновлением + /// + /// Поля классификации (полная замена; см. CardReclassificationDto). + /// True — строка обновлена; false — карточки нет (404-семантика сервиса). + public Task ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct); + + /// + /// Снимает флаг «новое» + /// + /// Id карточки либо null. + /// Колонка либо null. + public Task UpdateSeenAsync( + string? cardId, + string? col, + CancellationToken ct); + + /// + /// Удаляет карточку навсегда + /// + /// Id карточки. + public Task DeleteForeverAsync(string cardId, CancellationToken ct); + + /// + /// Полная очистка служебной колонки + /// + /// Очищаемая колонка (trash/archive). + /// Сколько карточек удалено (0 — колонка пуста). + public Task ClearColAsync(string col, CancellationToken ct); + + /// + /// Счётчики карточек по колонкам + /// + /// Словарь col → {count, new}; ключи — только колонки с карточками. + public Task> CountCardsByColAsync(CancellationToken ct); + + // ── Комментарии (LeadComments) ──────────────────────────────────────── + + /// + /// Комментарии карточки + /// + /// Id карточки. + /// Комментарии (time — human-метка от CreatedAt); пусто — комментариев нет. + public Task> ListCommentsAsync(string cardId, CancellationToken ct); + + /// + /// Добавляет комментарий. + /// + /// Готовый id (cm_..., генерирует модуль). + /// Id карточки. + /// Автор («Вы» — свои комментарии). + /// Текст (непустой — валидирует сервис, 400 «Пустой комментарий»). + public Task AddCommentAsync( + string commentId, + string cardId, + string by, + string text, + CancellationToken ct); + + // ── Журнал действий (CardMoves = learning_log) ──────────────────────── + + /// + /// Пишет строку журнала действия пользователя + /// + /// Запись журнала (id lm_... сгенерирован модулем). + public Task AddMoveAsync(CardMoveDto move, CancellationToken ct); + + /// + /// Число записей журнала = счётчик learning. + /// + /// Количество строк CardMoves. + public Task CountMovesAsync(CancellationToken ct); + + /// + /// Свежие примеры разметки пользователя для few-shot ИИ-классификации. + /// + /// Максимум примеров. + /// Примеры «текст → колонка», свежие первыми; пусто — истории разметки нет. + public Task> GetAiMarkupExamplesAsync(int limit, CancellationToken ct); + + + /// + /// Кандидаты на автоархив + /// + /// Граница: received_at < now − archiveAfterDays. + /// Id карточек-кандидатов (колонка при архивации пишется archive через UpdateColumnAsync). + public Task> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct); + + /// + /// Архивирует пачку карточек ОДНИМ UPDATE + /// + /// Id карточек-кандидатов (список из ListArchiveCandidatesAsync). + /// Момент архивации (один «now» тика). + /// Сколько карточек реально архивировано (0 — кандидатов не было/уже не в рабочих колонках). + public Task ArchiveAsync( + IReadOnlyList cardIds, + DateTimeOffset archivedAt, + CancellationToken ct); + + /// + /// Кандидаты на очистку архива + /// + /// Граница: archived_at < now − archiveClearDays. + /// Id карточек для жёсткого удаления. + public Task> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct); + + /// + /// Кандидаты на очистку корзины + /// + /// Граница: received_at < now − trashClearDays. + /// Id карточек для жёсткого удаления. + public Task> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct); + + /// + /// Жёстко удаляет пачку карточек + /// + /// Id карточек на удаление. + /// Сколько карточек удалено. + public Task PurgeAsync(IReadOnlyList cardIds, CancellationToken ct); + + + /// + /// Карточки для пересчёта ConvFrom/ConvTo/ConvCur + /// + /// Карточки с бюджетом (используются Budget/бюджетные поля; служебные колонки исключены). + public Task> ListCardsForConversionAsync(CancellationToken ct); + + /// + /// Записывает пересчитанную конверсию бюджета карточки + /// + /// Id карточки. + /// Сконвертированная нижняя граница, либо null. + /// Сконвертированная верхняя граница, либо null. + /// Валюта конверсии (целевая валюта тенанта); пусто — конверсия снята. + public Task UpdateConversionAsync( + string cardId, + double? convFrom, + double? convTo, + string convCur, + CancellationToken ct); + + + /// + /// Карточки «Неразобранного» с исходным текстом — вход эвристики suggest. + /// + /// Карточки col='inbox' с непустым текстом источника. + public Task> ListInboxWithSourceAsync(CancellationToken ct); +} diff --git a/src/core/Deal.Modules.Kanban/Application/Abstractions/IMlLearningStore.cs b/src/core/Deal.Modules.Kanban/Application/Abstractions/IMlLearningStore.cs index c624595..b61ccda 100644 --- a/src/core/Deal.Modules.Kanban/Application/Abstractions/IMlLearningStore.cs +++ b/src/core/Deal.Modules.Kanban/Application/Abstractions/IMlLearningStore.cs @@ -1,54 +1,54 @@ -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Modules.Kanban.Application.Abstractions; - -/// -/// Порт хранилища обучения ML -/// -public interface IMlLearningStore -{ - /// - /// Число записей журнала обучения = счётчик learning - /// - /// Количество строк журнала CardMoves. - public Task CountLearningAsync(CancellationToken ct); - - /// - /// Число строк очереди обучения = счётчик outbox - /// - /// Количество строк очереди MlOutbox. - public Task CountOutboxAsync(CancellationToken ct); - - /// - /// Пишет строку очереди обучения - /// - /// Готовый id строки (mle_..., генерирует вызывающий). - /// Текст обучающего примера (непустой, ≤6000 символов). - /// Метка обучения: id доски, spam либо t:hire|t:order. - /// Вес сигнала (1.0 — учить, −1.0 — снять метку). - public Task AddOutboxAsync( - string id, - string text, - string label, - double delta, - CancellationToken ct); - - /// - /// Полная очистка очереди обучения - /// - public Task ClearOutboxAsync(CancellationToken ct); - - /// - /// Забирает следующую порцию очереди обучения - /// - /// Размер порции (флашер шлёт по 10 строк за батч). - /// До строк очереди в порядке created_at. - public Task> TakeOutboxBatchAsync(int limit, CancellationToken ct); - - /// - /// Удаляет отправленные строки очереди по id. - /// - /// Id строк, успешно отправленных в ML-сервис. - /// Задача завершается после удаления строк. - public Task DeleteOutboxAsync(IReadOnlyCollection ids, CancellationToken ct); -} +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Modules.Kanban.Application.Abstractions; + +/// +/// Порт хранилища обучения ML +/// +public interface IMlLearningStore +{ + /// + /// Число записей журнала обучения = счётчик learning + /// + /// Количество строк журнала CardMoves. + public Task CountLearningAsync(CancellationToken ct); + + /// + /// Число строк очереди обучения = счётчик outbox + /// + /// Количество строк очереди MlOutbox. + public Task CountOutboxAsync(CancellationToken ct); + + /// + /// Пишет строку очереди обучения + /// + /// Готовый id строки (mle_..., генерирует вызывающий). + /// Текст обучающего примера (непустой, ≤6000 символов). + /// Метка обучения: id доски, spam либо t:hire|t:order. + /// Вес сигнала (1.0 — учить, −1.0 — снять метку). + public Task AddOutboxAsync( + string id, + string text, + string label, + double delta, + CancellationToken ct); + + /// + /// Полная очистка очереди обучения + /// + public Task ClearOutboxAsync(CancellationToken ct); + + /// + /// Забирает следующую порцию очереди обучения + /// + /// Размер порции (флашер шлёт по 10 строк за батч). + /// До строк очереди в порядке created_at. + public Task> TakeOutboxBatchAsync(int limit, CancellationToken ct); + + /// + /// Удаляет отправленные строки очереди по id. + /// + /// Id строк, успешно отправленных в ML-сервис. + /// Задача завершается после удаления строк. + public Task DeleteOutboxAsync(IReadOnlyCollection ids, CancellationToken ct); +} diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/TypeAliases.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/TypeAliases.cs index 72cf90f..ac19e99 100644 --- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/TypeAliases.cs +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/TypeAliases.cs @@ -1,49 +1,49 @@ -namespace Deal.Modules.Kanban.Application.ColumnRules; - -/// -/// Синонимы типов заявки для группы types правил колонки и глобальных исключений. -/// -public static class TypeAliases -{ - // Карта «тег типа → синонимы» в нижнем регистре. - private static readonly IReadOnlyDictionary Aliases = new Dictionary - { - ["vacancy"] = new[] { "вакансия", "вакансию", "вакансии", "вакант", "найм", "нанимаем", "full-time", "занятость", "в штат", "в команду" }, - ["freelance"] = new[] { "фриланс", "заказ", "закажу", "проект", "подработка", "подряд", "разово", "услуга" }, - ["announcement"] = new[] { "объявление", "продам", "куплю", "отдам", "продаётся", "продается" }, - }; - - /// - /// Расширяет теги типов синонимами. - /// - /// Теги группы types (как сохранил пользователь/фронт). - /// Список термов для поиска: для известного тега — его синонимы, для неизвестного — сам тег в нижнем регистре; пустые теги пропускаются. - public static IReadOnlyList ExpandTerms(IEnumerable? tags) - { - var result = new List(); - if (tags is null) - { - return result; - } - - foreach (string? raw in tags) - { - string key = (raw ?? string.Empty).Trim().ToLowerInvariant(); - if (key.Length == 0) - { - continue; - } - - if (Aliases.TryGetValue(key, out string[]? synonyms)) - { - result.AddRange(synonyms); - } - else - { - result.Add(key); - } - } - - return result; - } -} +namespace Deal.Modules.Kanban.Application.ColumnRules; + +/// +/// Синонимы типов заявки для группы types правил колонки и глобальных исключений. +/// +public static class TypeAliases +{ + // Карта «тег типа → синонимы» в нижнем регистре. + private static readonly IReadOnlyDictionary Aliases = new Dictionary + { + ["vacancy"] = new[] { "вакансия", "вакансию", "вакансии", "вакант", "найм", "нанимаем", "full-time", "занятость", "в штат", "в команду" }, + ["freelance"] = new[] { "фриланс", "заказ", "закажу", "проект", "подработка", "подряд", "разово", "услуга" }, + ["announcement"] = new[] { "объявление", "продам", "куплю", "отдам", "продаётся", "продается" }, + }; + + /// + /// Расширяет теги типов синонимами. + /// + /// Теги группы types (как сохранил пользователь/фронт). + /// Список термов для поиска: для известного тега — его синонимы, для неизвестного — сам тег в нижнем регистре; пустые теги пропускаются. + public static IReadOnlyList ExpandTerms(IEnumerable? tags) + { + var result = new List(); + if (tags is null) + { + return result; + } + + foreach (string? raw in tags) + { + string key = (raw ?? string.Empty).Trim().ToLowerInvariant(); + if (key.Length == 0) + { + continue; + } + + if (Aliases.TryGetValue(key, out string[]? synonyms)) + { + result.AddRange(synonyms); + } + else + { + result.Add(key); + } + } + + return result; + } +} diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardFileKind.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardFileKind.cs index 9932b79..54d8e94 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardFileKind.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardFileKind.cs @@ -1,12 +1,12 @@ -using Deal.Modules.Kanban.Application.Services; - -namespace Deal.Modules.Kanban.Application.Models; - -/// -/// Результат — kind/label вложения. -/// -/// Категория файла: image|video|audio|archive|document|other. -/// Человекочитаемая метка («Изображение», «Документ», «Файл» …). -public sealed record CardFileKind( - string Kind, - string Label); +using Deal.Modules.Kanban.Application.Services; + +namespace Deal.Modules.Kanban.Application.Models; + +/// +/// Результат — kind/label вложения. +/// +/// Категория файла: image|video|audio|archive|document|other. +/// Человекочитаемая метка («Изображение», «Документ», «Файл» …). +public sealed record CardFileKind( + string Kind, + string Label); diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardLocalCreateDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardLocalCreateDto.cs index bee98d7..ef64e1c 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardLocalCreateDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardLocalCreateDto.cs @@ -1,20 +1,20 @@ -namespace Deal.Modules.Kanban.Application.Models; - -/// -/// Начальные поля ручного -/// -/// Заголовок карточки (при создании Trim; пустой допустим). -/// Краткое содержание карточки. -/// Стек/направления (null — пусто). -/// Бюджет (from/to/cur); null — бюджета нет. -/// Контактная строка карточки. -/// Текст технического задания. -/// Желаемый контейнер-стадия (id каталога ); null или неизвестная — карточка создаётся в planned. -public sealed record CardLocalCreateDto( - string Title = "", - string Summary = "", - IReadOnlyList? Stack = null, - CardBudgetDto? Budget = null, - string Contact = "", - string TzText = "", - string? ContainerId = null); +namespace Deal.Modules.Kanban.Application.Models; + +/// +/// Начальные поля ручного +/// +/// Заголовок карточки (при создании Trim; пустой допустим). +/// Краткое содержание карточки. +/// Стек/направления (null — пусто). +/// Бюджет (from/to/cur); null — бюджета нет. +/// Контактная строка карточки. +/// Текст технического задания. +/// Желаемый контейнер-стадия (id каталога ); null или неизвестная — карточка создаётся в planned. +public sealed record CardLocalCreateDto( + string Title = "", + string Summary = "", + IReadOnlyList? Stack = null, + CardBudgetDto? Budget = null, + string Contact = "", + string TzText = "", + string? ContainerId = null); diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardPatch.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardPatch.cs index 1207524..cdccae8 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardPatch.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardPatch.cs @@ -1,24 +1,24 @@ -namespace Deal.Modules.Kanban.Application.Models; - -/// -/// Частичная правка карточки. -/// -/// Новый заголовок. -/// Новое краткое содержание. -/// Новая контактная строка. -/// Новый текст технического задания. -/// Новый стек (полная замена массива). -/// Новый бюджет (полная замена from/to/cur). -/// Новый массив комментариев (полная замена). -/// Новый массив ссылок (полная замена). -/// Новый массив файлов (полная замена). -public sealed record CardPatch( - string? Title, - string? Summary, - string? Contact, - string? TzText, - IReadOnlyList? Stack, - CardBudgetDto? Budget, - IReadOnlyList? Comments, - IReadOnlyList? Links, - IReadOnlyList? Files); +namespace Deal.Modules.Kanban.Application.Models; + +/// +/// Частичная правка карточки. +/// +/// Новый заголовок. +/// Новое краткое содержание. +/// Новая контактная строка. +/// Новый текст технического задания. +/// Новый стек (полная замена массива). +/// Новый бюджет (полная замена from/to/cur). +/// Новый массив комментариев (полная замена). +/// Новый массив ссылок (полная замена). +/// Новый массив файлов (полная замена). +public sealed record CardPatch( + string? Title, + string? Summary, + string? Contact, + string? TzText, + IReadOnlyList? Stack, + CardBudgetDto? Budget, + IReadOnlyList? Comments, + IReadOnlyList? Links, + IReadOnlyList? Files); diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardReclassificationDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardReclassificationDto.cs index 8ece0c3..6030f72 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardReclassificationDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardReclassificationDto.cs @@ -1,32 +1,32 @@ -namespace Deal.Modules.Kanban.Application.Models; - -/// -/// Результат ручной переклассификации карточки — параметр ICardStore.ApplyReclassificationAsync. -/// -/// Id карточки (c_...). -/// Новая колонка (доска либо inbox — страховку ContainerAccepts уже применил вызывающий). -/// Флаг «новое». -/// Признак найма/заказа после переклассификации. -/// Тип подтверждён классификатором (иначе — маркерная гипотеза). -/// Новый заголовок (очищенный, ≤140). -/// Новый блок «О заявке» (≤2000). -/// Новый стек (полная замена массива). -/// Бюджет по исходнику; null — суммы нет (поля очищаются). -/// Бюджет в целевой валюте; null — конверсии нет (поля очищаются). -/// «Быстрый» контакт (значение основного контакта). -/// Квалифицированные контакты (полная замена). -/// Совпавшие критерии правил доски (для inbox — пусто). -public sealed record CardReclassificationDto( - string CardId, - string Col, - bool IsNew, - bool IsVacancy, - bool IsVacancyKnown, - string Title, - string Summary, - IReadOnlyList Stack, - CardBudgetDto? Budget, - CardBudgetDto? Converted, - string Contact, - IReadOnlyList Contacts, - IReadOnlyList MatchHits); +namespace Deal.Modules.Kanban.Application.Models; + +/// +/// Результат ручной переклассификации карточки — параметр ICardStore.ApplyReclassificationAsync. +/// +/// Id карточки (c_...). +/// Новая колонка (доска либо inbox — страховку ContainerAccepts уже применил вызывающий). +/// Флаг «новое». +/// Признак найма/заказа после переклассификации. +/// Тип подтверждён классификатором (иначе — маркерная гипотеза). +/// Новый заголовок (очищенный, ≤140). +/// Новый блок «О заявке» (≤2000). +/// Новый стек (полная замена массива). +/// Бюджет по исходнику; null — суммы нет (поля очищаются). +/// Бюджет в целевой валюте; null — конверсии нет (поля очищаются). +/// «Быстрый» контакт (значение основного контакта). +/// Квалифицированные контакты (полная замена). +/// Совпавшие критерии правил доски (для inbox — пусто). +public sealed record CardReclassificationDto( + string CardId, + string Col, + bool IsNew, + bool IsVacancy, + bool IsVacancyKnown, + string Title, + string Summary, + IReadOnlyList Stack, + CardBudgetDto? Budget, + CardBudgetDto? Converted, + string Contact, + IReadOnlyList Contacts, + IReadOnlyList MatchHits); diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardReminderDueDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardReminderDueDto.cs index d061661..531f3b9 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardReminderDueDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardReminderDueDto.cs @@ -1,12 +1,12 @@ -namespace Deal.Modules.Kanban.Application.Models; - -/// -/// Строка «выстрелившего» напоминания — мини-DTO выборки ICardStore.ListDueRemindersAsync. -/// -/// Id карточки (c_...). -/// Заголовок карточки (для уведомления). -/// Контейнер карточки (всегда hold на момент срабатывания). -public sealed record CardReminderDueDto( - string Id, - string Title, - string ContainerId); +namespace Deal.Modules.Kanban.Application.Models; + +/// +/// Строка «выстрелившего» напоминания — мини-DTO выборки ICardStore.ListDueRemindersAsync. +/// +/// Id карточки (c_...). +/// Заголовок карточки (для уведомления). +/// Контейнер карточки (всегда hold на момент срабатывания). +public sealed record CardReminderDueDto( + string Id, + string Title, + string ContainerId); diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardResultDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardResultDto.cs index 136ec0a..8a2f04b 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardResultDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardResultDto.cs @@ -1,8 +1,8 @@ -namespace Deal.Modules.Kanban.Application.Models; - -/// -/// Результат мутации карточки, отвечающей карточкой -/// -/// Текст 400 (фиксированная строка) либо null. -/// Карточка после мутации либо null (400/карточки нет). -public sealed record CardResultDto(string? Error, CardDto? Card); +namespace Deal.Modules.Kanban.Application.Models; + +/// +/// Результат мутации карточки, отвечающей карточкой +/// +/// Текст 400 (фиксированная строка) либо null. +/// Карточка после мутации либо null (400/карточки нет). +public sealed record CardResultDto(string? Error, CardDto? Card); diff --git a/src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs b/src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs index dcd28fb..c2fe25a 100644 --- a/src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs +++ b/src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs @@ -1,26 +1,26 @@ -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Modules.Kanban.Application.Registrars; - -/// -/// DI-регистрация модуля Kanban. -/// -public static class KanbanModuleRegistrar -{ - /// - /// Регистрирует сервисы модуля Kanban в контейнере. - /// - public static IServiceCollection AddKanbanModule(this IServiceCollection services) - { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - services.AddScoped(); - return services; - } -} +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Modules.Kanban.Application.Registrars; + +/// +/// DI-регистрация модуля Kanban. +/// +public static class KanbanModuleRegistrar +{ + /// + /// Регистрирует сервисы модуля Kanban в контейнере. + /// + public static IServiceCollection AddKanbanModule(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + return services; + } +} diff --git a/src/core/Deal.Modules.Kanban/Application/Services/BudgetNormalizer.cs b/src/core/Deal.Modules.Kanban/Application/Services/BudgetNormalizer.cs index 8eb7ef8..558cbb0 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/BudgetNormalizer.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/BudgetNormalizer.cs @@ -1,154 +1,154 @@ -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application.Services; - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Нормализация бюджета «при поступлении» -/// -public static class BudgetNormalizer -{ - private const string DefaultTargetCurrency = "RUB"; - - private static readonly IReadOnlyDictionary CurrencyAliases = new Dictionary - { - ["USD"] = "USD", ["$"] = "USD", ["US$"] = "USD", ["ДОЛЛАР"] = "USD", ["ДОЛЛАРОВ"] = "USD", - ["ДОЛЛ"] = "USD", ["БАКС"] = "USD", ["БАКСОВ"] = "USD", - ["EUR"] = "EUR", ["€"] = "EUR", ["ЕВРО"] = "EUR", - ["RUB"] = "RUB", ["RUR"] = "RUB", ["₽"] = "RUB", ["РУБ"] = "RUB", ["РУБЛЕЙ"] = "RUB", - ["РУБЛИ"] = "RUB", ["РУБЛЬ"] = "RUB", ["РУБЛЯ"] = "RUB", - ["GBP"] = "GBP", ["£"] = "GBP", ["CNY"] = "CNY", ["¥"] = "CNY", ["USDT"] = "USDT", ["₮"] = "USDT", - }; - - /// - /// Нормализует бюджет к форме хранения. - /// - /// Бюджет из ИИ/локального разбора (null — бюджета нет). - /// Нормализованный бюджет CardBudgetDto(from/to/cur) либо null: бюджет отсутствует, валюта не распознана или обе границы отсутствуют/равны нулю. from=0 трактуется как отсутствие нижней границы; to без from у «одной суммы» приравнивается к from. - public static CardBudgetDto? Normalize(BudgetRangeDto? budget) - { - if (budget is null) - { - return null; - } - - string? cur = NormalizeCurrency(budget.Cur); - if (cur is null) - { - return null; - } - - double? from = NormalizeBound(budget.From); - double? to = NormalizeBound(budget.To); - if (from is null && to is null) - { - return null; - } - - if (to is null) - { - to = from; // одна сумма или «от X» без верхней границы (ai.py L337–338) - } - - return new CardBudgetDto(from, to, cur); - } - - /// - /// Пересчёт бюджета в целевую валюту «один раз при поступлении». - /// - /// Нормализованный бюджет (валюта — код, см. ). - /// Настройка conversionOn; false → конверсия снята. - /// Настройка targetCurrency. - /// Курсы к рублю «код → курс» (кэш ratesCache либо мок-курсы). - /// Сконвертированный бюджет CardBudgetDto(convFrom, convTo, targetCurrency), либо null — конверсия выключена/бюджета нет/валюта не задана. При валюте, отсутствующей в курсах, границы null, но целевая валюта сохраняется. - public static CardBudgetDto? ToTarget( - CardBudgetDto? budget, - bool conversionOn, - string? targetCurrency, - IReadOnlyDictionary? rates) - { - if (budget is null || string.IsNullOrWhiteSpace(budget.Cur)) - { - return null; - } - - string target = string.IsNullOrWhiteSpace(targetCurrency) - ? DefaultTargetCurrency - : targetCurrency.ToUpperInvariant(); - if (!conversionOn) - { - return null; - } - - double? convFrom = null; - double? convTo = null; - if (budget.From is not null) - { - convFrom = Convert(budget.From.Value, budget.Cur, target, rates); - } - - if (budget.To is not null) - { - convTo = Convert(budget.To.Value, budget.Cur, target, rates); - } - else if (budget.From is not null) - { - convTo = convFrom; - } - - return new CardBudgetDto(convFrom, convTo, target); - } - - private static double? NormalizeBound(double? value) - { - if (value is null || value.Value == 0) - { - return null; - } - - return value.Value; - } - - private static string? NormalizeCurrency(string? raw) - { - string s = (raw ?? string.Empty).Trim().ToUpperInvariant(); - if (s.Length == 0) - { - return null; - } - - if (CurrencyAliases.TryGetValue(s, out string? direct)) - { - return direct; - } - - string letters = new string(s.Where(c => c.IsCurrencyLetter()).ToArray()); - if (CurrencyAliases.TryGetValue(letters, out string? fromLetters)) - { - return fromLetters; - } - - if (s.Length == 3 && s.All(c => c.IsCurrencyLetter())) - { - return s; - } - - return null; - } - - // Конвертация суммы через курсы к рублю; null rates → null (курсов нет — граница не конвертируется). - // amount: Сумма. - // fromCurrency: Исходная валюта (код). - // toCurrency: Целевая валюта (код). - // rates: Курсы к рублю либо null. - // Возвращает: Сумма в целевой валюте или null. - private static double? Convert( - double amount, - string fromCurrency, - string toCurrency, - IReadOnlyDictionary? rates) - { - return rates is null ? null : RatesService.ConvertAmount(amount, fromCurrency, toCurrency, rates); - } -} +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Modules.Kanban.Application.Services; + +/// +/// Нормализация бюджета «при поступлении» +/// +public static class BudgetNormalizer +{ + private const string DefaultTargetCurrency = "RUB"; + + private static readonly IReadOnlyDictionary CurrencyAliases = new Dictionary + { + ["USD"] = "USD", ["$"] = "USD", ["US$"] = "USD", ["ДОЛЛАР"] = "USD", ["ДОЛЛАРОВ"] = "USD", + ["ДОЛЛ"] = "USD", ["БАКС"] = "USD", ["БАКСОВ"] = "USD", + ["EUR"] = "EUR", ["€"] = "EUR", ["ЕВРО"] = "EUR", + ["RUB"] = "RUB", ["RUR"] = "RUB", ["₽"] = "RUB", ["РУБ"] = "RUB", ["РУБЛЕЙ"] = "RUB", + ["РУБЛИ"] = "RUB", ["РУБЛЬ"] = "RUB", ["РУБЛЯ"] = "RUB", + ["GBP"] = "GBP", ["£"] = "GBP", ["CNY"] = "CNY", ["¥"] = "CNY", ["USDT"] = "USDT", ["₮"] = "USDT", + }; + + /// + /// Нормализует бюджет к форме хранения. + /// + /// Бюджет из ИИ/локального разбора (null — бюджета нет). + /// Нормализованный бюджет CardBudgetDto(from/to/cur) либо null: бюджет отсутствует, валюта не распознана или обе границы отсутствуют/равны нулю. from=0 трактуется как отсутствие нижней границы; to без from у «одной суммы» приравнивается к from. + public static CardBudgetDto? Normalize(BudgetRangeDto? budget) + { + if (budget is null) + { + return null; + } + + string? cur = NormalizeCurrency(budget.Cur); + if (cur is null) + { + return null; + } + + double? from = NormalizeBound(budget.From); + double? to = NormalizeBound(budget.To); + if (from is null && to is null) + { + return null; + } + + if (to is null) + { + to = from; // одна сумма или «от X» без верхней границы (ai.py L337–338) + } + + return new CardBudgetDto(from, to, cur); + } + + /// + /// Пересчёт бюджета в целевую валюту «один раз при поступлении». + /// + /// Нормализованный бюджет (валюта — код, см. ). + /// Настройка conversionOn; false → конверсия снята. + /// Настройка targetCurrency. + /// Курсы к рублю «код → курс» (кэш ratesCache либо мок-курсы). + /// Сконвертированный бюджет CardBudgetDto(convFrom, convTo, targetCurrency), либо null — конверсия выключена/бюджета нет/валюта не задана. При валюте, отсутствующей в курсах, границы null, но целевая валюта сохраняется. + public static CardBudgetDto? ToTarget( + CardBudgetDto? budget, + bool conversionOn, + string? targetCurrency, + IReadOnlyDictionary? rates) + { + if (budget is null || string.IsNullOrWhiteSpace(budget.Cur)) + { + return null; + } + + string target = string.IsNullOrWhiteSpace(targetCurrency) + ? DefaultTargetCurrency + : targetCurrency.ToUpperInvariant(); + if (!conversionOn) + { + return null; + } + + double? convFrom = null; + double? convTo = null; + if (budget.From is not null) + { + convFrom = Convert(budget.From.Value, budget.Cur, target, rates); + } + + if (budget.To is not null) + { + convTo = Convert(budget.To.Value, budget.Cur, target, rates); + } + else if (budget.From is not null) + { + convTo = convFrom; + } + + return new CardBudgetDto(convFrom, convTo, target); + } + + private static double? NormalizeBound(double? value) + { + if (value is null || value.Value == 0) + { + return null; + } + + return value.Value; + } + + private static string? NormalizeCurrency(string? raw) + { + string s = (raw ?? string.Empty).Trim().ToUpperInvariant(); + if (s.Length == 0) + { + return null; + } + + if (CurrencyAliases.TryGetValue(s, out string? direct)) + { + return direct; + } + + string letters = new string(s.Where(c => c.IsCurrencyLetter()).ToArray()); + if (CurrencyAliases.TryGetValue(letters, out string? fromLetters)) + { + return fromLetters; + } + + if (s.Length == 3 && s.All(c => c.IsCurrencyLetter())) + { + return s; + } + + return null; + } + + // Конвертация суммы через курсы к рублю; null rates → null (курсов нет — граница не конвертируется). + // amount: Сумма. + // fromCurrency: Исходная валюта (код). + // toCurrency: Целевая валюта (код). + // rates: Курсы к рублю либо null. + // Возвращает: Сумма в целевой валюте или null. + private static double? Convert( + double amount, + string fromCurrency, + string toCurrency, + IReadOnlyDictionary? rates) + { + return rates is null ? null : RatesService.ConvertAmount(amount, fromCurrency, toCurrency, rates); + } +} diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs index 3bb8261..33e0ee1 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs @@ -1,143 +1,143 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Файлы карточки — partial-часть -/// -public sealed partial class CardsService -{ - private const string ObjectRootSegment = "projects"; - - // Замена недопустимых символов имени в objectKey (path-разделители/кавычки заменяются). - private const char KeyNameReplacement = '_'; - - // Символы имени, заменяемые в objectKey: path-разделители («/», «\») и кавычки «"». - private const string KeyNameUnsafeCharacters = "/\\\""; - - private const string DefaultAttachmentName = "file"; - - /// - /// Добавляет файл карточке - /// - /// Id карточки (c_...). - /// Имя файла как прислано (в objectKey санитизируется); пустое → «file». - /// MIME-тип загрузки (может быть null/пустым — детект по расширению). - /// Поток содержимого файла (читается хранилищем с позиции 0). - /// Длина содержимого в байтах (пишется в метаданные записи). - /// Метаданные добавленного файла или null — карточки нет (404). - public async Task AddFileAsync( - string cardId, - string fileName, - string? contentType, - Stream content, - long size, - CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(content); - - CardDto? card = await _store.GetCardAsync(cardId, ct); - if (card is null) - { - return null; - } - - string name = string.IsNullOrWhiteSpace(fileName) ? DefaultAttachmentName : fileName; - CardFileKind kind = FileKindDetector.Detect(name, contentType); - string fileId = PrefixId.New(KanbanIdPrefixes.File); - string objectKey = BuildObjectKey(cardId, fileId, name); - await _storage.PutAsync(objectKey, content, contentType ?? string.Empty, ct); - - var entry = new CardFileDto( - fileId, - name, - size, - kind.Kind, - kind.Label, - objectKey); - bool updated = await _store.AddFileAsync(cardId, entry, ct); - if (!updated) - { - // Карточка исчезла между чтением и записью (гонка): объект-сирота в хранилище не нужен — - // удаляем и отвечаем 404-семантикой (DeleteAsync сбои не бросает). - await _storage.DeleteAsync(objectKey, ct); - return null; - } - - return entry; - } - - /// - /// Запись файла по id для download-эндпоинта. - /// - /// Id карточки (c_...). - /// Id записи файла (pf_...). - /// Метаданные записи файла либо null (карточка/запись не найдены). - public async Task GetFileEntryAsync( - string cardId, - string fileId, - CancellationToken ct) - { - CardDto? card = await _store.GetCardAsync(cardId, ct); - if (card is null) - { - return null; - } - - return card.Files.FirstOrDefault(file => file.Id == fileId); - } - - /// - /// Удаляет файл карточки - /// - /// Id карточки (c_...). - /// Id удаляемой записи файла (pf_...). - /// Карточка после удаления (без записи) либо null — карточки нет (404-семантика). - public async Task RemoveFileAsync( - string cardId, - string fileId, - CancellationToken ct) - { - CardDto? card = await _store.GetCardAsync(cardId, ct); - if (card is null) - { - return null; - } - - CardFileDto? entry = card.Files.FirstOrDefault(file => file.Id == fileId); - if (entry is not null && !string.IsNullOrWhiteSpace(entry.ObjectKey)) - { - await _storage.DeleteAsync(entry.ObjectKey, ct); - } - - if (!await _store.RemoveFileAsync(cardId, fileId, ct)) - { - return null; - } - - return await _store.GetCardAsync(cardId, ct) - ?? throw new InvalidOperationException("Карточка не прочиталась после удаления файла: " + cardId); - } - - private static string BuildObjectKey( - string cardId, - string fileId, - string name) - { - return $"{ObjectRootSegment}/{cardId}/{fileId}_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{SanitizeKeyName(name)}"; - } - - // Санитизирует имя файла для objectKey: path-разделители и кавычки заменяются «_». - // name: Имя файла как прислано. - // Возвращает: Безопасная часть имени для ключа. - private static string SanitizeKeyName(string name) - { - foreach (char character in KeyNameUnsafeCharacters) - { - name = name.Replace(character, KeyNameReplacement); - } - - return name; - } -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Modules.Kanban.Application.Services; + +/// +/// Файлы карточки — partial-часть +/// +public sealed partial class CardsService +{ + private const string ObjectRootSegment = "projects"; + + // Замена недопустимых символов имени в objectKey (path-разделители/кавычки заменяются). + private const char KeyNameReplacement = '_'; + + // Символы имени, заменяемые в objectKey: path-разделители («/», «\») и кавычки «"». + private const string KeyNameUnsafeCharacters = "/\\\""; + + private const string DefaultAttachmentName = "file"; + + /// + /// Добавляет файл карточке + /// + /// Id карточки (c_...). + /// Имя файла как прислано (в objectKey санитизируется); пустое → «file». + /// MIME-тип загрузки (может быть null/пустым — детект по расширению). + /// Поток содержимого файла (читается хранилищем с позиции 0). + /// Длина содержимого в байтах (пишется в метаданные записи). + /// Метаданные добавленного файла или null — карточки нет (404). + public async Task AddFileAsync( + string cardId, + string fileName, + string? contentType, + Stream content, + long size, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(content); + + CardDto? card = await _store.GetCardAsync(cardId, ct); + if (card is null) + { + return null; + } + + string name = string.IsNullOrWhiteSpace(fileName) ? DefaultAttachmentName : fileName; + CardFileKind kind = FileKindDetector.Detect(name, contentType); + string fileId = PrefixId.New(KanbanIdPrefixes.File); + string objectKey = BuildObjectKey(cardId, fileId, name); + await _storage.PutAsync(objectKey, content, contentType ?? string.Empty, ct); + + var entry = new CardFileDto( + fileId, + name, + size, + kind.Kind, + kind.Label, + objectKey); + bool updated = await _store.AddFileAsync(cardId, entry, ct); + if (!updated) + { + // Карточка исчезла между чтением и записью (гонка): объект-сирота в хранилище не нужен — + // удаляем и отвечаем 404-семантикой (DeleteAsync сбои не бросает). + await _storage.DeleteAsync(objectKey, ct); + return null; + } + + return entry; + } + + /// + /// Запись файла по id для download-эндпоинта. + /// + /// Id карточки (c_...). + /// Id записи файла (pf_...). + /// Метаданные записи файла либо null (карточка/запись не найдены). + public async Task GetFileEntryAsync( + string cardId, + string fileId, + CancellationToken ct) + { + CardDto? card = await _store.GetCardAsync(cardId, ct); + if (card is null) + { + return null; + } + + return card.Files.FirstOrDefault(file => file.Id == fileId); + } + + /// + /// Удаляет файл карточки + /// + /// Id карточки (c_...). + /// Id удаляемой записи файла (pf_...). + /// Карточка после удаления (без записи) либо null — карточки нет (404-семантика). + public async Task RemoveFileAsync( + string cardId, + string fileId, + CancellationToken ct) + { + CardDto? card = await _store.GetCardAsync(cardId, ct); + if (card is null) + { + return null; + } + + CardFileDto? entry = card.Files.FirstOrDefault(file => file.Id == fileId); + if (entry is not null && !string.IsNullOrWhiteSpace(entry.ObjectKey)) + { + await _storage.DeleteAsync(entry.ObjectKey, ct); + } + + if (!await _store.RemoveFileAsync(cardId, fileId, ct)) + { + return null; + } + + return await _store.GetCardAsync(cardId, ct) + ?? throw new InvalidOperationException("Карточка не прочиталась после удаления файла: " + cardId); + } + + private static string BuildObjectKey( + string cardId, + string fileId, + string name) + { + return $"{ObjectRootSegment}/{cardId}/{fileId}_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{SanitizeKeyName(name)}"; + } + + // Санитизирует имя файла для objectKey: path-разделители и кавычки заменяются «_». + // name: Имя файла как прислано. + // Возвращает: Безопасная часть имени для ключа. + private static string SanitizeKeyName(string name) + { + foreach (char character in KeyNameUnsafeCharacters) + { + name = name.Replace(character, KeyNameReplacement); + } + + return name; + } +} diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Helpers.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Helpers.cs index 51e6084..d77956e 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Helpers.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Helpers.cs @@ -1,88 +1,88 @@ -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Services; -// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён -// Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас. -using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules; - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Приватные помощники — partial-часть -/// -public sealed partial class CardsService -{ - // ── Внутреннее ───────────────────────────────────────────────────────── - - private static string LearningText(CardDto card) - { - string source = (card.Content.Text ?? string.Empty).Trim(); - return source.Length > 0 ? source : card.Title; - } - - private async Task MoveToColumnAsync( - CardDto card, - string toCol, - IReadOnlyList hits, - string action, - CancellationToken ct) - { - await _store.UpdateColumnAsync(new CardColumnUpdateDto( - CardId: card.Id, - Col: toCol, - IsNew: false, - PrevCol: card.Col, - ArchivedAt: null, - MatchHits: hits), ct); - await LogMoveAsync(card.Id, action, card.Col, toCol, ct); - } - - private async Task LogMoveAsync( - string cardId, - string action, - string? fromCol, - string? toCol, - CancellationToken ct) - { - await _store.AddMoveAsync(new CardMoveDto( - PrefixId.New(KanbanIdPrefixes.CardMove), - cardId, - action, - fromCol, - toCol), ct); - } - - private async Task> ComputeHitsForBoardAsync( - string boardId, - string text, - CancellationToken ct) - { - ContainerDto? board = await _store.GetContainerAsync(boardId, ct); - return board is null - ? Array.Empty() - : await ComputeHitsAsync(board.Rules, text, ct); - } - - private async Task> ComputeHitsAsync( - ContainerRulesDto? rules, - string text, - CancellationToken ct) - { - // Кэш курсов из типизированного снимка настроек (C30): null → мок-курсы (дефолт RatesService). - IReadOnlyDictionary rates = - (await TenantSettingsSnapshot.LoadAsync(_settings, ct)).TryGetRatesCache()?.Rates ?? MockRates.Values; - return KanbanColumnRules.ComputeHits(rules, text, rates); - } - - private async Task ResolveReturnColAsync(string prevCol, CancellationToken ct) - { - if (prevCol == CardIds.Inbox) - { - return prevCol; - } - - ContainerDto? board = await _store.GetContainerAsync(prevCol, ct); - return board is null ? CardIds.Inbox : prevCol; - } -} +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Services; +// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён +// Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас. +using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules; + +namespace Deal.Modules.Kanban.Application.Services; + +/// +/// Приватные помощники — partial-часть +/// +public sealed partial class CardsService +{ + // ── Внутреннее ───────────────────────────────────────────────────────── + + private static string LearningText(CardDto card) + { + string source = (card.Content.Text ?? string.Empty).Trim(); + return source.Length > 0 ? source : card.Title; + } + + private async Task MoveToColumnAsync( + CardDto card, + string toCol, + IReadOnlyList hits, + string action, + CancellationToken ct) + { + await _store.UpdateColumnAsync(new CardColumnUpdateDto( + CardId: card.Id, + Col: toCol, + IsNew: false, + PrevCol: card.Col, + ArchivedAt: null, + MatchHits: hits), ct); + await LogMoveAsync(card.Id, action, card.Col, toCol, ct); + } + + private async Task LogMoveAsync( + string cardId, + string action, + string? fromCol, + string? toCol, + CancellationToken ct) + { + await _store.AddMoveAsync(new CardMoveDto( + PrefixId.New(KanbanIdPrefixes.CardMove), + cardId, + action, + fromCol, + toCol), ct); + } + + private async Task> ComputeHitsForBoardAsync( + string boardId, + string text, + CancellationToken ct) + { + ContainerDto? board = await _store.GetContainerAsync(boardId, ct); + return board is null + ? Array.Empty() + : await ComputeHitsAsync(board.Rules, text, ct); + } + + private async Task> ComputeHitsAsync( + ContainerRulesDto? rules, + string text, + CancellationToken ct) + { + // Кэш курсов из типизированного снимка настроек (C30): null → мок-курсы (дефолт RatesService). + IReadOnlyDictionary rates = + (await TenantSettingsSnapshot.LoadAsync(_settings, ct)).TryGetRatesCache()?.Rates ?? MockRates.Values; + return KanbanColumnRules.ComputeHits(rules, text, rates); + } + + private async Task ResolveReturnColAsync(string prevCol, CancellationToken ct) + { + if (prevCol == CardIds.Inbox) + { + return prevCol; + } + + ContainerDto? board = await _store.GetContainerAsync(prevCol, ct); + return board is null ? CardIds.Inbox : prevCol; + } +} diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs index a360fb9..df3471a 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs @@ -1,278 +1,278 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Публичные операции карточек — partial-часть -/// -public sealed partial class CardsService -{ - - /// - /// Карточки колонки или всех колонок дашборда, received_at DESC. - /// - /// Колонка-фильтр (inbox/archive/trash/доска); null — все колонки дашборда. - /// Полные карточки (маппинг/комментарии/time — адаптер); пусто — карточек нет. - public Task> ListCardsAsync(string? col, CancellationToken ct) - { - return _store.ListCardsAsync(new CardsQuery(col), ct); - } - - /// - /// Одна карточка по id. - /// - /// Id карточки (c_...). - /// Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»). - public Task GetCardAsync(string cardId, CancellationToken ct) - { - return _store.GetCardAsync(cardId, ct); - } - - - /// - /// Перенос карточки на доску или в «Неразобранное». - /// - /// Id карточки (c_...). - /// Цель: inbox либо id доски (b_...). - /// Результат: Error (400) | Card=null (карточки нет, 404) | Card — карточка после переноса. - public async Task MoveDashboardCardAsync( - string cardId, - string toCol, - CancellationToken ct) - { - ContainerDto? board = null; - if (toCol != CardIds.Inbox) - { - board = await _store.GetContainerAsync(toCol, ct); - if (board is null) - { - return new CardResultDto(MoveTargetInvalidDetail, null); - } - } - - CardDto? card = await _store.GetCardAsync(cardId, ct); - if (card is null) - { - return new CardResultDto(null, null); - } - - if (card.Col == CardIds.Archive || card.Col == CardIds.Trash) - { - return new CardResultDto(MoveSourceRestrictedDetail, null); - } - - if (card.Col == toCol) - { - return new CardResultDto(null, card); - } - - string text = LearningText(card); - IReadOnlyList hits = toCol == CardIds.Inbox - ? Array.Empty() - : await ComputeHitsAsync(board!.Rules, text, ct); - - await MoveToColumnAsync(card, toCol, hits, ActionMove, ct); - if (toCol != CardIds.Inbox && text.Length > 0) - { - await _mlClient.PushAsync(text, toCol, PushWeightUser, ct); - } - - return new CardResultDto(null, await _store.GetCardAsync(cardId, ct)); - } - - /// - /// Перенос карточки в корзину - /// - /// Id карточки (c_...). - /// Карточка после переноса (при no-op — как была) либо null — карточки нет (404). - public Task TrashCardAsync(string cardId, CancellationToken ct) - { - return TrashCardAsync(cardId, teach: true, ct); - } - - /// - /// Перенос карточки в корзину с управлением обучением ML. - /// - /// Id карточки (c_...). - /// True — писать сигнал «спам» (действие пользователя); false — не писать. - /// Карточка после переноса (при no-op — как была) либо null — карточки нет (404). - public async Task TrashCardAsync( - string cardId, - bool teach, - CancellationToken ct) - { - CardDto? card = await _store.GetCardAsync(cardId, ct); - if (card is null) - { - return null; - } - - if (card.Col == CardIds.Trash) - { - return card; - } - - string text = LearningText(card); - await MoveToColumnAsync(card, CardIds.Trash, Array.Empty(), ActionTrash, ct); - if (teach && card.Col != CardIds.Archive && text.Length > 0) - { - await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUser, ct); - } - - return await _store.GetCardAsync(cardId, ct); - } - - /// - /// Возврат карточки из архива/корзины на канбан. - /// - /// Id карточки (c_...). - /// Колонка возврата (inbox/доска) либо null — карточки нет (404). - public async Task RestoreCardAsync(string cardId, CancellationToken ct) - { - CardDto? card = await _store.GetCardAsync(cardId, ct); - if (card is null) - { - return null; - } - - string back = await ResolveReturnColAsync(card.PrevCol, ct); - string text = LearningText(card); - IReadOnlyList hits = back == CardIds.Inbox - ? Array.Empty() - : await ComputeHitsForBoardAsync(back, text, ct); - - await _store.UpdateColumnAsync(new CardColumnUpdateDto( - CardId: cardId, - Col: back, - IsNew: true, - PrevCol: CardIds.Inbox, - ArchivedAt: null, - MatchHits: hits), ct); - await LogMoveAsync(cardId, ActionRestore, card.Col, back, ct); - - if (card.Col == CardIds.Trash && text.Length > 0) - { - await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUnlearn, ct); - } - - return back; - } - - /// - /// Полное удаление карточки - /// - /// Id карточки (c_...). - /// False — карточки нет (404 «Карточка не найдена»); True — удалена. - public async Task DeleteForeverAsync(string cardId, CancellationToken ct) - { - if (await _store.GetCardAsync(cardId, ct) is null) - { - return false; - } - - await _store.DeleteForeverAsync(cardId, ct); - return true; - } - - /// - /// Полная ручная очистка служебной колонки trash/archive. - /// - /// Очищаемая колонка: trash | archive. - /// Результат: Error (400) либо Cleared — сколько карточек удалено навсегда. - public async Task ClearColAsync(string col, CancellationToken ct) - { - if (col != CardIds.Trash && col != CardIds.Archive) - { - return new ClearColResultDto(ClearColInvalidDetail, 0); - } - - int cleared = await _store.ClearColAsync(col, ct); - return new ClearColResultDto(null, cleared); - } - - - /// - /// Добавляет комментарий к карточке - /// - /// Id карточки (c_...). - /// Текст комментария (непустой после Trim). - /// Результат: Error (400) | Comments=null (404) | Comments — список после добавления. - public async Task AddCommentAsync( - string cardId, - string text, - CancellationToken ct) - { - string trimmed = (text ?? string.Empty).Trim(); - if (trimmed.Length == 0) - { - return new AddCommentResultDto(EmptyCommentDetail, null); - } - - if (await _store.GetCardAsync(cardId, ct) is null) - { - return new AddCommentResultDto(null, null); - } - - await _store.AddCommentAsync(PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, trimmed, ct); - await LogMoveAsync(cardId, ActionComment, null, null, ct); - IReadOnlyList comments = await _store.ListCommentsAsync(cardId, ct); - return new AddCommentResultDto(null, comments); - } - - - /// - /// Снимает флаг «новое» - /// - /// Id карточки либо null/пусто. - /// Колонка либо null/пусто (используется, когда cardId не задан). - public Task MarkSeenAsync( - string? cardId, - string? col, - CancellationToken ct) - { - return _store.UpdateSeenAsync( - string.IsNullOrEmpty(cardId) ? null : cardId, - string.IsNullOrEmpty(col) ? null : col, - ct); - } - - - /// - /// Счётчики колонок - /// - /// Счётчики: колонки + learning/ml/ai (поля New/Learning/Ml/Ai и словарь Columns). - public async Task CountsAsync(CancellationToken ct) - { - IReadOnlyDictionary columns = await _store.CountCardsByColAsync(ct); - MlStatusResponseDto mlStatus = await _mlClient.StatusAsync(ct); - return new CardCountsDto - { - New = columns.Values.Sum(column => column.New), - Columns = columns, - Learning = mlStatus.Stats.Learning, - Ml = mlStatus.Stats.Ml, - Ai = mlStatus.Stats.Ai, - }; - } - - - /// - /// Поиск карточек: FTS по Cards.SearchTsv + LIKE-дополнение. - /// - /// Поисковый запрос (trim + lowercase внутри). - /// Найденные карточки (≤12); пусто — запрос короче 2 символов или нет совпадений. - public async Task> SearchCardsAsync(string? query, CancellationToken ct) - { - string lowered = (query ?? string.Empty).Trim().ToLowerInvariant(); - if (lowered.Length < MinSearchQueryLength) - { - return Array.Empty(); - } - - return await _store.SearchCardsAsync(lowered, SearchLimit, ct); - } - -} +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Modules.Kanban.Application.Services; + +/// +/// Публичные операции карточек — partial-часть +/// +public sealed partial class CardsService +{ + + /// + /// Карточки колонки или всех колонок дашборда, received_at DESC. + /// + /// Колонка-фильтр (inbox/archive/trash/доска); null — все колонки дашборда. + /// Полные карточки (маппинг/комментарии/time — адаптер); пусто — карточек нет. + public Task> ListCardsAsync(string? col, CancellationToken ct) + { + return _store.ListCardsAsync(new CardsQuery(col), ct); + } + + /// + /// Одна карточка по id. + /// + /// Id карточки (c_...). + /// Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»). + public Task GetCardAsync(string cardId, CancellationToken ct) + { + return _store.GetCardAsync(cardId, ct); + } + + + /// + /// Перенос карточки на доску или в «Неразобранное». + /// + /// Id карточки (c_...). + /// Цель: inbox либо id доски (b_...). + /// Результат: Error (400) | Card=null (карточки нет, 404) | Card — карточка после переноса. + public async Task MoveDashboardCardAsync( + string cardId, + string toCol, + CancellationToken ct) + { + ContainerDto? board = null; + if (toCol != CardIds.Inbox) + { + board = await _store.GetContainerAsync(toCol, ct); + if (board is null) + { + return new CardResultDto(MoveTargetInvalidDetail, null); + } + } + + CardDto? card = await _store.GetCardAsync(cardId, ct); + if (card is null) + { + return new CardResultDto(null, null); + } + + if (card.Col == CardIds.Archive || card.Col == CardIds.Trash) + { + return new CardResultDto(MoveSourceRestrictedDetail, null); + } + + if (card.Col == toCol) + { + return new CardResultDto(null, card); + } + + string text = LearningText(card); + IReadOnlyList hits = toCol == CardIds.Inbox + ? Array.Empty() + : await ComputeHitsAsync(board!.Rules, text, ct); + + await MoveToColumnAsync(card, toCol, hits, ActionMove, ct); + if (toCol != CardIds.Inbox && text.Length > 0) + { + await _mlClient.PushAsync(text, toCol, PushWeightUser, ct); + } + + return new CardResultDto(null, await _store.GetCardAsync(cardId, ct)); + } + + /// + /// Перенос карточки в корзину + /// + /// Id карточки (c_...). + /// Карточка после переноса (при no-op — как была) либо null — карточки нет (404). + public Task TrashCardAsync(string cardId, CancellationToken ct) + { + return TrashCardAsync(cardId, teach: true, ct); + } + + /// + /// Перенос карточки в корзину с управлением обучением ML. + /// + /// Id карточки (c_...). + /// True — писать сигнал «спам» (действие пользователя); false — не писать. + /// Карточка после переноса (при no-op — как была) либо null — карточки нет (404). + public async Task TrashCardAsync( + string cardId, + bool teach, + CancellationToken ct) + { + CardDto? card = await _store.GetCardAsync(cardId, ct); + if (card is null) + { + return null; + } + + if (card.Col == CardIds.Trash) + { + return card; + } + + string text = LearningText(card); + await MoveToColumnAsync(card, CardIds.Trash, Array.Empty(), ActionTrash, ct); + if (teach && card.Col != CardIds.Archive && text.Length > 0) + { + await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUser, ct); + } + + return await _store.GetCardAsync(cardId, ct); + } + + /// + /// Возврат карточки из архива/корзины на канбан. + /// + /// Id карточки (c_...). + /// Колонка возврата (inbox/доска) либо null — карточки нет (404). + public async Task RestoreCardAsync(string cardId, CancellationToken ct) + { + CardDto? card = await _store.GetCardAsync(cardId, ct); + if (card is null) + { + return null; + } + + string back = await ResolveReturnColAsync(card.PrevCol, ct); + string text = LearningText(card); + IReadOnlyList hits = back == CardIds.Inbox + ? Array.Empty() + : await ComputeHitsForBoardAsync(back, text, ct); + + await _store.UpdateColumnAsync(new CardColumnUpdateDto( + CardId: cardId, + Col: back, + IsNew: true, + PrevCol: CardIds.Inbox, + ArchivedAt: null, + MatchHits: hits), ct); + await LogMoveAsync(cardId, ActionRestore, card.Col, back, ct); + + if (card.Col == CardIds.Trash && text.Length > 0) + { + await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUnlearn, ct); + } + + return back; + } + + /// + /// Полное удаление карточки + /// + /// Id карточки (c_...). + /// False — карточки нет (404 «Карточка не найдена»); True — удалена. + public async Task DeleteForeverAsync(string cardId, CancellationToken ct) + { + if (await _store.GetCardAsync(cardId, ct) is null) + { + return false; + } + + await _store.DeleteForeverAsync(cardId, ct); + return true; + } + + /// + /// Полная ручная очистка служебной колонки trash/archive. + /// + /// Очищаемая колонка: trash | archive. + /// Результат: Error (400) либо Cleared — сколько карточек удалено навсегда. + public async Task ClearColAsync(string col, CancellationToken ct) + { + if (col != CardIds.Trash && col != CardIds.Archive) + { + return new ClearColResultDto(ClearColInvalidDetail, 0); + } + + int cleared = await _store.ClearColAsync(col, ct); + return new ClearColResultDto(null, cleared); + } + + + /// + /// Добавляет комментарий к карточке + /// + /// Id карточки (c_...). + /// Текст комментария (непустой после Trim). + /// Результат: Error (400) | Comments=null (404) | Comments — список после добавления. + public async Task AddCommentAsync( + string cardId, + string text, + CancellationToken ct) + { + string trimmed = (text ?? string.Empty).Trim(); + if (trimmed.Length == 0) + { + return new AddCommentResultDto(EmptyCommentDetail, null); + } + + if (await _store.GetCardAsync(cardId, ct) is null) + { + return new AddCommentResultDto(null, null); + } + + await _store.AddCommentAsync(PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, trimmed, ct); + await LogMoveAsync(cardId, ActionComment, null, null, ct); + IReadOnlyList comments = await _store.ListCommentsAsync(cardId, ct); + return new AddCommentResultDto(null, comments); + } + + + /// + /// Снимает флаг «новое» + /// + /// Id карточки либо null/пусто. + /// Колонка либо null/пусто (используется, когда cardId не задан). + public Task MarkSeenAsync( + string? cardId, + string? col, + CancellationToken ct) + { + return _store.UpdateSeenAsync( + string.IsNullOrEmpty(cardId) ? null : cardId, + string.IsNullOrEmpty(col) ? null : col, + ct); + } + + + /// + /// Счётчики колонок + /// + /// Счётчики: колонки + learning/ml/ai (поля New/Learning/Ml/Ai и словарь Columns). + public async Task CountsAsync(CancellationToken ct) + { + IReadOnlyDictionary columns = await _store.CountCardsByColAsync(ct); + MlStatusResponseDto mlStatus = await _mlClient.StatusAsync(ct); + return new CardCountsDto + { + New = columns.Values.Sum(column => column.New), + Columns = columns, + Learning = mlStatus.Stats.Learning, + Ml = mlStatus.Stats.Ml, + Ai = mlStatus.Stats.Ai, + }; + } + + + /// + /// Поиск карточек: FTS по Cards.SearchTsv + LIKE-дополнение. + /// + /// Поисковый запрос (trim + lowercase внутри). + /// Найденные карточки (≤12); пусто — запрос короче 2 символов или нет совпадений. + public async Task> SearchCardsAsync(string? query, CancellationToken ct) + { + string lowered = (query ?? string.Empty).Trim().ToLowerInvariant(); + if (lowered.Length < MinSearchQueryLength) + { + return Array.Empty(); + } + + return await _store.SearchCardsAsync(lowered, SearchLimit, ct); + } + +} diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Reminders.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Reminders.cs index e0fb89a..c1c6b56 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Reminders.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Reminders.cs @@ -1,110 +1,110 @@ -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Напоминания «Отложено» — partial-часть -/// -public sealed partial class CardsService -{ - /// - /// 400 set: напоминания выключены в настройках. - /// - public const string RemindersDisabledDetail = "Напоминания об отложенных выключены в настройках"; - - private const string RemindersEnabledKey = SettingsKeys.RemindersEnabled; - - private const long ReminderSnoozeMs = 86_400_000; - - /// - /// Устанавливает напоминание карточке. - /// - /// Id карточки (c_...). - /// Время напоминания, epoch-ms. - /// Результат: Error (400) | Card=null без Error (404) | Card — карточка с напоминанием. - public async Task SetReminderAsync( - string cardId, - long atMs, - CancellationToken ct) - { - CardDto? card = await _store.GetCardAsync(cardId, ct); - if (card is null) - { - return new CardResultDto(null, null); - } - - if (!await ReadRemindersEnabledAsync(ct)) - { - return new CardResultDto(RemindersDisabledDetail, null); - } - - await _store.SetReminderAsync(cardId, atMs, ct); - CardDto saved = await _store.GetCardAsync(cardId, ct) - ?? throw new InvalidOperationException("Карточка не прочиталась после установки напоминания: " + cardId); - return new CardResultDto(null, saved); - } - - /// - /// Снимает напоминание карточки. - /// - /// Id карточки (c_...). - /// True — карточка есть и напоминание снято; false — карточки нет (404). - public async Task ClearReminderAsync(string cardId, CancellationToken ct) - { - CardDto? card = await _store.GetCardAsync(cardId, ct); - if (card is null) - { - return false; - } - - await _store.ClearReminderAsync(cardId, ct); - return true; - } - - /// - /// «Напомнить позже» - /// - /// Id карточки (c_...). - /// True — карточка есть и напоминание отложено; false — карточки нет (404). - public async Task SnoozeReminderAsync(string cardId, CancellationToken ct) - { - CardDto? card = await _store.GetCardAsync(cardId, ct); - if (card is null) - { - return false; - } - - long snoozedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + ReminderSnoozeMs; - await _store.SetReminderAsync(cardId, snoozedAtMs, ct); - return true; - } - - /// - /// Проверка наступивших напоминаний «Отложено». - /// - /// «Выстрелившие» напоминания (после пометки fired); пусто — сработавших нет/напоминания выключены. - public async Task> CheckDueRemindersAsync(CancellationToken ct) - { - if (!await ReadRemindersEnabledAsync(ct)) - { - await _store.ClearExpiredRemindersAsync(DateTimeOffset.UtcNow, ct); - return Array.Empty(); - } - - DateTimeOffset now = DateTimeOffset.UtcNow; - IReadOnlyList due = await _store.ListDueRemindersAsync(now, ct); - if (due.Count > 0) - { - await _store.MarkRemindersFiredAsync(due.Select(item => item.Id).ToList(), ct); - } - - return due; - } - - private async Task ReadRemindersEnabledAsync(CancellationToken ct) - { - return (await TenantSettingsSnapshot.LoadAsync(_settings, ct)) - .GetBool(RemindersEnabledKey, SettingsDefaults.RemindersEnabled); - } -} +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Kanban.Application.Services; + +/// +/// Напоминания «Отложено» — partial-часть +/// +public sealed partial class CardsService +{ + /// + /// 400 set: напоминания выключены в настройках. + /// + public const string RemindersDisabledDetail = "Напоминания об отложенных выключены в настройках"; + + private const string RemindersEnabledKey = SettingsKeys.RemindersEnabled; + + private const long ReminderSnoozeMs = 86_400_000; + + /// + /// Устанавливает напоминание карточке. + /// + /// Id карточки (c_...). + /// Время напоминания, epoch-ms. + /// Результат: Error (400) | Card=null без Error (404) | Card — карточка с напоминанием. + public async Task SetReminderAsync( + string cardId, + long atMs, + CancellationToken ct) + { + CardDto? card = await _store.GetCardAsync(cardId, ct); + if (card is null) + { + return new CardResultDto(null, null); + } + + if (!await ReadRemindersEnabledAsync(ct)) + { + return new CardResultDto(RemindersDisabledDetail, null); + } + + await _store.SetReminderAsync(cardId, atMs, ct); + CardDto saved = await _store.GetCardAsync(cardId, ct) + ?? throw new InvalidOperationException("Карточка не прочиталась после установки напоминания: " + cardId); + return new CardResultDto(null, saved); + } + + /// + /// Снимает напоминание карточки. + /// + /// Id карточки (c_...). + /// True — карточка есть и напоминание снято; false — карточки нет (404). + public async Task ClearReminderAsync(string cardId, CancellationToken ct) + { + CardDto? card = await _store.GetCardAsync(cardId, ct); + if (card is null) + { + return false; + } + + await _store.ClearReminderAsync(cardId, ct); + return true; + } + + /// + /// «Напомнить позже» + /// + /// Id карточки (c_...). + /// True — карточка есть и напоминание отложено; false — карточки нет (404). + public async Task SnoozeReminderAsync(string cardId, CancellationToken ct) + { + CardDto? card = await _store.GetCardAsync(cardId, ct); + if (card is null) + { + return false; + } + + long snoozedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + ReminderSnoozeMs; + await _store.SetReminderAsync(cardId, snoozedAtMs, ct); + return true; + } + + /// + /// Проверка наступивших напоминаний «Отложено». + /// + /// «Выстрелившие» напоминания (после пометки fired); пусто — сработавших нет/напоминания выключены. + public async Task> CheckDueRemindersAsync(CancellationToken ct) + { + if (!await ReadRemindersEnabledAsync(ct)) + { + await _store.ClearExpiredRemindersAsync(DateTimeOffset.UtcNow, ct); + return Array.Empty(); + } + + DateTimeOffset now = DateTimeOffset.UtcNow; + IReadOnlyList due = await _store.ListDueRemindersAsync(now, ct); + if (due.Count > 0) + { + await _store.MarkRemindersFiredAsync(due.Select(item => item.Id).ToList(), ct); + } + + return due; + } + + private async Task ReadRemindersEnabledAsync(CancellationToken ct) + { + return (await TenantSettingsSnapshot.LoadAsync(_settings, ct)) + .GetBool(RemindersEnabledKey, SettingsDefaults.RemindersEnabled); + } +} diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs index cfb07bc..bce0780 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs @@ -1,382 +1,382 @@ -using System.Text.Json; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Операции пространства «Выбранные» — partial-часть -/// -public sealed partial class CardsService -{ - /// - /// 400 перенос по стадии - /// - public const string UnknownStageDetail = "Неизвестная стадия"; - - private const string PlannedStage = CardsDefaultContainers.Planned; - - private const string RejectedStage = CardsDefaultContainers.Rejected; - - private const string TakenCommentText = "Взял в работу."; - - /// - /// 400 ссылка: пустой url после Trim. - /// - public const string EmptyLinkDetail = "Пустая ссылка"; - - private const string HttpsUrlScheme = "https://"; - - private const string HttpUrlScheme = "http://"; - - // Ключ тела PATCH: заголовок (JSON-строка). - private const string PatchKeyTitle = "title"; - - // Ключ тела PATCH: краткое содержание (JSON-строка). - private const string PatchKeySummary = "summary"; - - // Ключ тела PATCH: контактная строка (JSON-строка). - private const string PatchKeyContact = "contact"; - - // Ключ тела PATCH: текст технического задания (JSON-строка). - private const string PatchKeyTzText = "tzText"; - - // Ключ тела PATCH: стек (JSON-массив строк либо null — очистка). - private const string PatchKeyStack = "stack"; - - // Ключ тела PATCH: бюджет (JSON-объект {from,to,cur} либо null/не-объект — очистка). - private const string PatchKeyBudget = "budget"; - - private static readonly CardBudgetDto ClearedBudget = new(From: null, To: null, Cur: string.Empty); - - /// - /// Карточки пространства «Выбранные» - /// - /// Фильтр по контейнеру-стадии; null — все стадии «Выбранных». - /// Полные карточки в порядке UpdatedAt DESC; пусто — карточек нет. - public Task> ListSelectedCardsAsync(string? containerId, CancellationToken ct) - { - return _store.ListSelectedCardsAsync(containerId, ct); - } - - /// - /// Ручное создание «локальной» карточки без внешнего источника. - /// - /// Начальные поля карточки (тело POST /api/cards, см. ). - /// Созданная карточка (полное чтение после записи). - public async Task CreateLocalCardAsync(CardLocalCreateDto draft, CancellationToken ct) - { - long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - DateTimeOffset receivedAt = DateTimeOffset.FromUnixTimeMilliseconds(nowMs); - string containerId = draft.ContainerId is not null && CardsDefaultContainers.Contains(draft.ContainerId) - ? draft.ContainerId - : PlannedStage; - var snapshot = new CardSnapshot - { - Id = PrefixId.New(KanbanIdPrefixes.Card), - Col = containerId, - IsNew = false, - Local = true, - Title = draft.Title.Trim(), - Summary = draft.Summary, - Source = new SourceRef { Kind = SourceKinds.Local, ReceivedAt = receivedAt }, - Content = new SourceContent { Text = draft.Summary }, - ReceivedAt = receivedAt, - Stack = draft.Stack ?? Array.Empty(), - BudgetFrom = draft.Budget?.From, - BudgetTo = draft.Budget?.To, - BudgetCur = draft.Budget?.Cur ?? string.Empty, - Contact = draft.Contact, - TzText = draft.TzText, - History = new[] { new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, "createdLocal", null) }, - }; - await _store.AddCardAsync(snapshot, ct); - return await _store.GetCardAsync(snapshot.Id, ct) - ?? throw new InvalidOperationException("Карточка не прочиталась после создания: " + snapshot.Id); - } - - /// - /// «Взять в работу» - /// - /// Id карточки (c_...). - /// Карточка в стадии planned; null — карточки нет (404). - public async Task TakeCardAsync(string cardId, CancellationToken ct) - { - CardDto? card = await _store.GetCardAsync(cardId, ct); - if (card is null) - { - return null; - } - - if (CardsDefaultContainers.Contains(card.Col)) - { - return card; - } - - long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - var entry = new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, null, PlannedStage); - if (!await _store.MoveCardStageAsync(cardId, PlannedStage, entry, nowMs, ct)) - { - // Карточка исчезла между чтением и переносом (гонка с удалением). - return null; - } - - await _store.AddCommentAsync( - PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, TakenCommentText, ct); - - return await _store.GetCardAsync(cardId, ct) - ?? throw new InvalidOperationException("Карточка не прочиталась после take: " + cardId); - } - - /// - /// Точечная правка полей карточки по телу PATCH — presence-aware. - /// - /// Id карточки (c_...). - /// Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется). - /// Обновлённая карточка или null — карточки нет (404). - public async Task PatchCardAsync( - string cardId, - IReadOnlyDictionary body, - CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(body); - - bool updated = await _store.PatchCardAsync(cardId, ResolvePatch(body), ct); - return updated ? await _store.GetCardAsync(cardId, ct) : null; - } - - /// - /// Добавляет ссылку карточке - /// - /// Id карточки (c_...). - /// Название ссылки; пустое после Trim → name = url. - /// URL ссылки (без схемы — добавится https://). - /// Результат: Error (400 «Пустая ссылка») | Card=null без Error (404) | Card — карточка со ссылкой. - public async Task AddLinkAsync( - string cardId, - string name, - string url, - CancellationToken ct) - { - CardDto? card = await _store.GetCardAsync(cardId, ct); - if (card is null) - { - return new CardResultDto(null, null); - } - - string normalizedUrl = (url ?? string.Empty).Trim(); - if (normalizedUrl.Length == 0) - { - return new CardResultDto(EmptyLinkDetail, null); - } - - if (!normalizedUrl.StartsWith(HttpsUrlScheme, StringComparison.Ordinal) - && !normalizedUrl.StartsWith(HttpUrlScheme, StringComparison.Ordinal)) - { - normalizedUrl = HttpsUrlScheme + normalizedUrl; - } - - string trimmedName = (name ?? string.Empty).Trim(); - var link = new CardLinkDto( - PrefixId.New(KanbanIdPrefixes.Link), - trimmedName.Length == 0 ? normalizedUrl : trimmedName, - normalizedUrl); - if (!await _store.AddLinkAsync(cardId, link, ct)) - { - return new CardResultDto(null, null); - } - - CardDto saved = await _store.GetCardAsync(cardId, ct) - ?? throw new InvalidOperationException("Карточка не прочиталась после добавления ссылки: " + cardId); - return new CardResultDto(null, saved); - } - - /// - /// Удаляет ссылку карточки по id - /// - /// Id карточки (c_...). - /// Id удаляемой ссылки (pl_...). - /// Результат: Card=null без Error (404) | Card — карточка без ссылки. - public async Task RemoveLinkAsync( - string cardId, - string linkId, - CancellationToken ct) - { - if (!await _store.RemoveLinkAsync(cardId, linkId, ct)) - { - return new CardResultDto(null, null); - } - - CardDto saved = await _store.GetCardAsync(cardId, ct) - ?? throw new InvalidOperationException("Карточка не прочиталась после удаления ссылки: " + cardId); - return new CardResultDto(null, saved); - } - - /// - /// Перенос карточки по контейнерам-стадиям «Выбранных» - /// - /// Id карточки (c_...). - /// Новый контейнер-стадия — id каталога . - /// Результат: Error «Неизвестная стадия» (400) | Card=null без Error (карточки нет, 404) | Card — карточка после переноса. - public async Task MoveStageCardAsync( - string cardId, - string containerId, - CancellationToken ct) - { - if (!CardsDefaultContainers.Contains(containerId)) - { - return new CardResultDto(UnknownStageDetail, null); - } - - long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - var entry = new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, null, containerId); - bool moved = await _store.MoveCardStageAsync(cardId, containerId, entry, nowMs, ct); - if (!moved) - { - return new CardResultDto(null, null); - } - - CardDto card = await _store.GetCardAsync(cardId, ct) - ?? throw new InvalidOperationException("Карточка не прочиталась после move: " + cardId); - return new CardResultDto(null, card); - } - - /// - /// Полная ручная очистка терминальной стадии «Отклонено» — hard-delete строк - /// - /// Сколько карточек удалено (0 — стадия пуста). - public Task ClearRejectedAsync(CancellationToken ct) - { - return _store.ClearStageAsync(RejectedStage, ct); - } - - private static CardPatch ResolvePatch(IReadOnlyDictionary body) - { - string? title = null; - string? summary = null; - string? contact = null; - string? tzText = null; - IReadOnlyList? stack = null; - CardBudgetDto? budget = null; - foreach ((string key, JsonElement value) in body) - { - switch (key) - { - case PatchKeyTitle: - if (ReadTextValue(value, out string parsedTitle)) - { - title = parsedTitle; - } - - break; - case PatchKeySummary: - if (ReadTextValue(value, out string parsedSummary)) - { - summary = parsedSummary; - } - - break; - case PatchKeyContact: - if (ReadTextValue(value, out string parsedContact)) - { - contact = parsedContact; - } - - break; - case PatchKeyTzText: - if (ReadTextValue(value, out string parsedTzText)) - { - tzText = parsedTzText; - } - - break; - case PatchKeyStack: - stack = ReadStack(value); - break; - case PatchKeyBudget: - budget = ReadBudget(value); - break; - } - } - - return new CardPatch(title, summary, contact, tzText, stack, budget, null, null, null); - } - - // Читает JSON-строку текстового поля; null/не-строка → ключ не применяется. - // element: Значение ключа тела. - // text: Прочитанная строка (при успехе). - // Возвращает: True — значение строковое (в т.ч. пустое — очистка текста). - private static bool ReadTextValue(JsonElement element, out string text) - { - if (element.ValueKind == JsonValueKind.String) - { - text = element.GetString() ?? string.Empty; - return true; - } - - text = string.Empty; - return false; - } - - // Читает стек: массив строк (не-строки отбрасываются); null/не-массив → пустой стек. - // element: Значение ключа stack. - // Возвращает: Новый стек (полная замена массива). - private static IReadOnlyList ReadStack(JsonElement element) - { - if (element.ValueKind != JsonValueKind.Array) - { - return Array.Empty(); - } - - var items = new List(); - foreach (JsonElement item in element.EnumerateArray()) - { - if (item.ValueKind == JsonValueKind.String) - { - items.Add(item.GetString() ?? string.Empty); - } - } - - return items; - } - - // Читает бюджет: объект {from,to,cur} → пара границ + валюта; null/не-объект → очистка. - // element: Значение ключа budget. - // Возвращает: Бюджет патча: валюта пуста — «бюджета нет» (очистка). - private static CardBudgetDto ReadBudget(JsonElement element) - { - if (element.ValueKind != JsonValueKind.Object) - { - return ClearedBudget; - } - - double? from = ReadNumber(element, "from"); - double? to = ReadNumber(element, "to"); - string cur = ReadText(element, "cur"); - return new CardBudgetDto(from, to, cur); - } - - // Читает число поля объекта бюджета: число → значение; иное — пусто. - // obj: Объект бюджета. - // fieldName: Имя поля (from/to). - // Возвращает: Граница либо null. - private static double? ReadNumber(JsonElement obj, string fieldName) - { - return obj.TryGetProperty(fieldName, out JsonElement element) && element.ValueKind == JsonValueKind.Number - ? element.GetDouble() - : null; - } - - // Читает строку поля объекта бюджета: строка → значение; отсутствие/иное → пусто. - // obj: Объект бюджета. - // fieldName: Имя поля (cur). - // Возвращает: Код валюты либо пустая строка. - private static string ReadText(JsonElement obj, string fieldName) - { - return obj.TryGetProperty(fieldName, out JsonElement element) && element.ValueKind == JsonValueKind.String - ? element.GetString() ?? string.Empty - : string.Empty; - } -} +using System.Text.Json; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Modules.Kanban.Application.Services; + +/// +/// Операции пространства «Выбранные» — partial-часть +/// +public sealed partial class CardsService +{ + /// + /// 400 перенос по стадии + /// + public const string UnknownStageDetail = "Неизвестная стадия"; + + private const string PlannedStage = CardsDefaultContainers.Planned; + + private const string RejectedStage = CardsDefaultContainers.Rejected; + + private const string TakenCommentText = "Взял в работу."; + + /// + /// 400 ссылка: пустой url после Trim. + /// + public const string EmptyLinkDetail = "Пустая ссылка"; + + private const string HttpsUrlScheme = "https://"; + + private const string HttpUrlScheme = "http://"; + + // Ключ тела PATCH: заголовок (JSON-строка). + private const string PatchKeyTitle = "title"; + + // Ключ тела PATCH: краткое содержание (JSON-строка). + private const string PatchKeySummary = "summary"; + + // Ключ тела PATCH: контактная строка (JSON-строка). + private const string PatchKeyContact = "contact"; + + // Ключ тела PATCH: текст технического задания (JSON-строка). + private const string PatchKeyTzText = "tzText"; + + // Ключ тела PATCH: стек (JSON-массив строк либо null — очистка). + private const string PatchKeyStack = "stack"; + + // Ключ тела PATCH: бюджет (JSON-объект {from,to,cur} либо null/не-объект — очистка). + private const string PatchKeyBudget = "budget"; + + private static readonly CardBudgetDto ClearedBudget = new(From: null, To: null, Cur: string.Empty); + + /// + /// Карточки пространства «Выбранные» + /// + /// Фильтр по контейнеру-стадии; null — все стадии «Выбранных». + /// Полные карточки в порядке UpdatedAt DESC; пусто — карточек нет. + public Task> ListSelectedCardsAsync(string? containerId, CancellationToken ct) + { + return _store.ListSelectedCardsAsync(containerId, ct); + } + + /// + /// Ручное создание «локальной» карточки без внешнего источника. + /// + /// Начальные поля карточки (тело POST /api/cards, см. ). + /// Созданная карточка (полное чтение после записи). + public async Task CreateLocalCardAsync(CardLocalCreateDto draft, CancellationToken ct) + { + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + DateTimeOffset receivedAt = DateTimeOffset.FromUnixTimeMilliseconds(nowMs); + string containerId = draft.ContainerId is not null && CardsDefaultContainers.Contains(draft.ContainerId) + ? draft.ContainerId + : PlannedStage; + var snapshot = new CardSnapshot + { + Id = PrefixId.New(KanbanIdPrefixes.Card), + Col = containerId, + IsNew = false, + Local = true, + Title = draft.Title.Trim(), + Summary = draft.Summary, + Source = new SourceRef { Kind = SourceKinds.Local, ReceivedAt = receivedAt }, + Content = new SourceContent { Text = draft.Summary }, + ReceivedAt = receivedAt, + Stack = draft.Stack ?? Array.Empty(), + BudgetFrom = draft.Budget?.From, + BudgetTo = draft.Budget?.To, + BudgetCur = draft.Budget?.Cur ?? string.Empty, + Contact = draft.Contact, + TzText = draft.TzText, + History = new[] { new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, "createdLocal", null) }, + }; + await _store.AddCardAsync(snapshot, ct); + return await _store.GetCardAsync(snapshot.Id, ct) + ?? throw new InvalidOperationException("Карточка не прочиталась после создания: " + snapshot.Id); + } + + /// + /// «Взять в работу» + /// + /// Id карточки (c_...). + /// Карточка в стадии planned; null — карточки нет (404). + public async Task TakeCardAsync(string cardId, CancellationToken ct) + { + CardDto? card = await _store.GetCardAsync(cardId, ct); + if (card is null) + { + return null; + } + + if (CardsDefaultContainers.Contains(card.Col)) + { + return card; + } + + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var entry = new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, null, PlannedStage); + if (!await _store.MoveCardStageAsync(cardId, PlannedStage, entry, nowMs, ct)) + { + // Карточка исчезла между чтением и переносом (гонка с удалением). + return null; + } + + await _store.AddCommentAsync( + PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, TakenCommentText, ct); + + return await _store.GetCardAsync(cardId, ct) + ?? throw new InvalidOperationException("Карточка не прочиталась после take: " + cardId); + } + + /// + /// Точечная правка полей карточки по телу PATCH — presence-aware. + /// + /// Id карточки (c_...). + /// Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется). + /// Обновлённая карточка или null — карточки нет (404). + public async Task PatchCardAsync( + string cardId, + IReadOnlyDictionary body, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(body); + + bool updated = await _store.PatchCardAsync(cardId, ResolvePatch(body), ct); + return updated ? await _store.GetCardAsync(cardId, ct) : null; + } + + /// + /// Добавляет ссылку карточке + /// + /// Id карточки (c_...). + /// Название ссылки; пустое после Trim → name = url. + /// URL ссылки (без схемы — добавится https://). + /// Результат: Error (400 «Пустая ссылка») | Card=null без Error (404) | Card — карточка со ссылкой. + public async Task AddLinkAsync( + string cardId, + string name, + string url, + CancellationToken ct) + { + CardDto? card = await _store.GetCardAsync(cardId, ct); + if (card is null) + { + return new CardResultDto(null, null); + } + + string normalizedUrl = (url ?? string.Empty).Trim(); + if (normalizedUrl.Length == 0) + { + return new CardResultDto(EmptyLinkDetail, null); + } + + if (!normalizedUrl.StartsWith(HttpsUrlScheme, StringComparison.Ordinal) + && !normalizedUrl.StartsWith(HttpUrlScheme, StringComparison.Ordinal)) + { + normalizedUrl = HttpsUrlScheme + normalizedUrl; + } + + string trimmedName = (name ?? string.Empty).Trim(); + var link = new CardLinkDto( + PrefixId.New(KanbanIdPrefixes.Link), + trimmedName.Length == 0 ? normalizedUrl : trimmedName, + normalizedUrl); + if (!await _store.AddLinkAsync(cardId, link, ct)) + { + return new CardResultDto(null, null); + } + + CardDto saved = await _store.GetCardAsync(cardId, ct) + ?? throw new InvalidOperationException("Карточка не прочиталась после добавления ссылки: " + cardId); + return new CardResultDto(null, saved); + } + + /// + /// Удаляет ссылку карточки по id + /// + /// Id карточки (c_...). + /// Id удаляемой ссылки (pl_...). + /// Результат: Card=null без Error (404) | Card — карточка без ссылки. + public async Task RemoveLinkAsync( + string cardId, + string linkId, + CancellationToken ct) + { + if (!await _store.RemoveLinkAsync(cardId, linkId, ct)) + { + return new CardResultDto(null, null); + } + + CardDto saved = await _store.GetCardAsync(cardId, ct) + ?? throw new InvalidOperationException("Карточка не прочиталась после удаления ссылки: " + cardId); + return new CardResultDto(null, saved); + } + + /// + /// Перенос карточки по контейнерам-стадиям «Выбранных» + /// + /// Id карточки (c_...). + /// Новый контейнер-стадия — id каталога . + /// Результат: Error «Неизвестная стадия» (400) | Card=null без Error (карточки нет, 404) | Card — карточка после переноса. + public async Task MoveStageCardAsync( + string cardId, + string containerId, + CancellationToken ct) + { + if (!CardsDefaultContainers.Contains(containerId)) + { + return new CardResultDto(UnknownStageDetail, null); + } + + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var entry = new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, null, containerId); + bool moved = await _store.MoveCardStageAsync(cardId, containerId, entry, nowMs, ct); + if (!moved) + { + return new CardResultDto(null, null); + } + + CardDto card = await _store.GetCardAsync(cardId, ct) + ?? throw new InvalidOperationException("Карточка не прочиталась после move: " + cardId); + return new CardResultDto(null, card); + } + + /// + /// Полная ручная очистка терминальной стадии «Отклонено» — hard-delete строк + /// + /// Сколько карточек удалено (0 — стадия пуста). + public Task ClearRejectedAsync(CancellationToken ct) + { + return _store.ClearStageAsync(RejectedStage, ct); + } + + private static CardPatch ResolvePatch(IReadOnlyDictionary body) + { + string? title = null; + string? summary = null; + string? contact = null; + string? tzText = null; + IReadOnlyList? stack = null; + CardBudgetDto? budget = null; + foreach ((string key, JsonElement value) in body) + { + switch (key) + { + case PatchKeyTitle: + if (ReadTextValue(value, out string parsedTitle)) + { + title = parsedTitle; + } + + break; + case PatchKeySummary: + if (ReadTextValue(value, out string parsedSummary)) + { + summary = parsedSummary; + } + + break; + case PatchKeyContact: + if (ReadTextValue(value, out string parsedContact)) + { + contact = parsedContact; + } + + break; + case PatchKeyTzText: + if (ReadTextValue(value, out string parsedTzText)) + { + tzText = parsedTzText; + } + + break; + case PatchKeyStack: + stack = ReadStack(value); + break; + case PatchKeyBudget: + budget = ReadBudget(value); + break; + } + } + + return new CardPatch(title, summary, contact, tzText, stack, budget, null, null, null); + } + + // Читает JSON-строку текстового поля; null/не-строка → ключ не применяется. + // element: Значение ключа тела. + // text: Прочитанная строка (при успехе). + // Возвращает: True — значение строковое (в т.ч. пустое — очистка текста). + private static bool ReadTextValue(JsonElement element, out string text) + { + if (element.ValueKind == JsonValueKind.String) + { + text = element.GetString() ?? string.Empty; + return true; + } + + text = string.Empty; + return false; + } + + // Читает стек: массив строк (не-строки отбрасываются); null/не-массив → пустой стек. + // element: Значение ключа stack. + // Возвращает: Новый стек (полная замена массива). + private static IReadOnlyList ReadStack(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Array) + { + return Array.Empty(); + } + + var items = new List(); + foreach (JsonElement item in element.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + items.Add(item.GetString() ?? string.Empty); + } + } + + return items; + } + + // Читает бюджет: объект {from,to,cur} → пара границ + валюта; null/не-объект → очистка. + // element: Значение ключа budget. + // Возвращает: Бюджет патча: валюта пуста — «бюджета нет» (очистка). + private static CardBudgetDto ReadBudget(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) + { + return ClearedBudget; + } + + double? from = ReadNumber(element, "from"); + double? to = ReadNumber(element, "to"); + string cur = ReadText(element, "cur"); + return new CardBudgetDto(from, to, cur); + } + + // Читает число поля объекта бюджета: число → значение; иное — пусто. + // obj: Объект бюджета. + // fieldName: Имя поля (from/to). + // Возвращает: Граница либо null. + private static double? ReadNumber(JsonElement obj, string fieldName) + { + return obj.TryGetProperty(fieldName, out JsonElement element) && element.ValueKind == JsonValueKind.Number + ? element.GetDouble() + : null; + } + + // Читает строку поля объекта бюджета: строка → значение; отсутствие/иное → пусто. + // obj: Объект бюджета. + // fieldName: Имя поля (cur). + // Возвращает: Код валюты либо пустая строка. + private static string ReadText(JsonElement obj, string fieldName) + { + return obj.TryGetProperty(fieldName, out JsonElement element) && element.ValueKind == JsonValueKind.String + ? element.GetString() ?? string.Empty + : string.Empty; + } +} diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Source.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Source.cs index c02c220..8009839 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Source.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Source.cs @@ -1,30 +1,30 @@ -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Содержимое источника карточки — partial-часть -/// -public sealed partial class CardsService -{ - /// - /// Загружает содержимое источника карточки. - /// - /// Карточка, для которой нужно содержимое. - /// Токен отмены. - /// Содержимое от провайдера источника либо сохранённое в карточке. - public async Task ResolveSourceAsync(CardDto card, CancellationToken ct) - { - if (_sourceResolver is not null && _sourceResolver.CanResolve(card.Source)) - { - SourceContent? loaded = await _sourceResolver.ResolveAsync(card.Source, ct); - if (loaded is not null) - { - return loaded; - } - } - - return card.Content; - } -} +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Modules.Kanban.Application.Services; + +/// +/// Содержимое источника карточки — partial-часть +/// +public sealed partial class CardsService +{ + /// + /// Загружает содержимое источника карточки. + /// + /// Карточка, для которой нужно содержимое. + /// Токен отмены. + /// Содержимое от провайдера источника либо сохранённое в карточке. + public async Task ResolveSourceAsync(CardDto card, CancellationToken ct) + { + if (_sourceResolver is not null && _sourceResolver.CanResolve(card.Source)) + { + SourceContent? loaded = await _sourceResolver.ResolveAsync(card.Source, ct); + if (loaded is not null) + { + return loaded; + } + } + + return card.Content; + } +} diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs index eb49497..c5e137f 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs @@ -1,94 +1,94 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Settings.Application.Abstractions; -// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён -// Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас. - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Сервис карточек — единый домен карточки -/// -/// Единый порт хранилища карточек/контейнеров/журнала тенанта. -/// KV-хранилище настроек тенанта (курсы, напоминания). -/// Клиент ML: PushAsync — обучающий сигнал действия, StatusAsync — счётчики counts. -/// Файловое хранилище вложений карточки (объекты файлов). -public sealed partial class CardsService -{ - private readonly ICardStore _store; - private readonly ISettingsStore _settings; - private readonly IMlClient _mlClient; - private readonly IFileStorage _storage; - private readonly SourceContentResolver? _sourceResolver; - - /// - /// Создаёт сервис карточек над портами модуля - /// - /// Единый порт хранилища карточек/контейнеров/журнала тенанта. - /// KV-хранилище настроек тенанта (курсы, напоминания). - /// Клиент ML: PushAsync — обучающий сигнал действия, StatusAsync — счётчики counts. - /// Файловое хранилище вложений карточки (объекты файлов). - /// Реестр провайдеров содержимого источника; null — только содержимое карточки. - public CardsService( - ICardStore store, - ISettingsStore settings, - IMlClient mlClient, - IFileStorage storage, - SourceContentResolver? sourceResolver = null) - { - _store = store; - _settings = settings; - _mlClient = mlClient; - _storage = storage; - _sourceResolver = sourceResolver; - } - - - /// - /// 400 move: целевой контейнер не существует - /// - public const string MoveTargetInvalidDetail = "Переносить можно только в существующий контейнер или в «Неразобранное»"; - - /// - /// 400 move: исходная колонка archive/trash — из них карточку выводит только restore_lead - /// - public const string MoveSourceRestrictedDetail = "Переносить из корзины, архива или «взятых в работу» нельзя — верните карточку на канбан"; - - /// - /// 400 clear-col: колонка не trash/archive. - /// - public const string ClearColInvalidDetail = "Очищать можно только корзину или архив"; - - /// - /// 400 комментарий - /// - public const string EmptyCommentDetail = "Пустой комментарий"; - - - private const string ActionMove = "move"; - - private const string ActionTrash = "trash"; - - private const string ActionRestore = "restore"; - - private const string ActionComment = "comment"; - - /// - /// Автор комментария — «Вы». - /// - public const string CommentAuthor = "Вы"; - - /// - /// Human-метка времени свежего комментария. - /// - public const string JustNowLabel = "только что"; - - private const int MinSearchQueryLength = 2; - - private const int SearchLimit = 12; - - private const double PushWeightUser = 1.0; - - private const double PushWeightUnlearn = -1.0; -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Settings.Application.Abstractions; +// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён +// Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас. + +namespace Deal.Modules.Kanban.Application.Services; + +/// +/// Сервис карточек — единый домен карточки +/// +/// Единый порт хранилища карточек/контейнеров/журнала тенанта. +/// KV-хранилище настроек тенанта (курсы, напоминания). +/// Клиент ML: PushAsync — обучающий сигнал действия, StatusAsync — счётчики counts. +/// Файловое хранилище вложений карточки (объекты файлов). +public sealed partial class CardsService +{ + private readonly ICardStore _store; + private readonly ISettingsStore _settings; + private readonly IMlClient _mlClient; + private readonly IFileStorage _storage; + private readonly SourceContentResolver? _sourceResolver; + + /// + /// Создаёт сервис карточек над портами модуля + /// + /// Единый порт хранилища карточек/контейнеров/журнала тенанта. + /// KV-хранилище настроек тенанта (курсы, напоминания). + /// Клиент ML: PushAsync — обучающий сигнал действия, StatusAsync — счётчики counts. + /// Файловое хранилище вложений карточки (объекты файлов). + /// Реестр провайдеров содержимого источника; null — только содержимое карточки. + public CardsService( + ICardStore store, + ISettingsStore settings, + IMlClient mlClient, + IFileStorage storage, + SourceContentResolver? sourceResolver = null) + { + _store = store; + _settings = settings; + _mlClient = mlClient; + _storage = storage; + _sourceResolver = sourceResolver; + } + + + /// + /// 400 move: целевой контейнер не существует + /// + public const string MoveTargetInvalidDetail = "Переносить можно только в существующий контейнер или в «Неразобранное»"; + + /// + /// 400 move: исходная колонка archive/trash — из них карточку выводит только restore_lead + /// + public const string MoveSourceRestrictedDetail = "Переносить из корзины, архива или «взятых в работу» нельзя — верните карточку на канбан"; + + /// + /// 400 clear-col: колонка не trash/archive. + /// + public const string ClearColInvalidDetail = "Очищать можно только корзину или архив"; + + /// + /// 400 комментарий + /// + public const string EmptyCommentDetail = "Пустой комментарий"; + + + private const string ActionMove = "move"; + + private const string ActionTrash = "trash"; + + private const string ActionRestore = "restore"; + + private const string ActionComment = "comment"; + + /// + /// Автор комментария — «Вы». + /// + public const string CommentAuthor = "Вы"; + + /// + /// Human-метка времени свежего комментария. + /// + public const string JustNowLabel = "только что"; + + private const int MinSearchQueryLength = 2; + + private const int SearchLimit = 12; + + private const double PushWeightUser = 1.0; + + private const double PushWeightUnlearn = -1.0; +} diff --git a/src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs b/src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs index 8340991..a1168a8 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs @@ -1,279 +1,279 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Сервис контейнеров -/// -/// Порт хранилища (контейнеры/карточки тенанта). -/// KV-хранилище настроек тенанта (ключ colState). -public sealed class ContainersService(ICardStore store, ISettingsStore settings) -{ - /// - /// Имя по умолчанию - /// - public const string DefaultContainerName = "Новая колонка"; - - // Палитра колонок по умолчанию: цвет = Palette[order % 8], если цвет не задан. - private static readonly string[] Palette = - ["#818cf8", "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"]; - - // Опции JSON colState: camelCase при записи, терпимость регистра при чтении; null-поля не пишутся. - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - // ── Контейнеры ─────────────────────────────────────────────────────── - - /// - /// Контейнеры пространства в порядке показа, со счётчиками карточек. - /// - /// Пространство (dashboard/selected) либо null — все контейнеры. - /// Контейнеры с заполненными counts; пусто — контейнеров нет. - public async Task> ListAsync(string? space, CancellationToken ct) - { - IReadOnlyList containers = await store.ListContainersAsync(space, ct); - IReadOnlyDictionary counts = await store.CountCardsByColAsync(ct); - return containers - .Select(container => container with - { - Counts = counts.TryGetValue(container.Id, out CardColumnCountDto? count) - ? new ContainerCountsDto(count.Count, count.New) - : new ContainerCountsDto(0, 0), - }) - .ToList(); - } - - /// - /// Один контейнер со счётчиками; null — контейнера нет. - /// - /// Id контейнера. - /// Контейнер со счётчиками либо null. - public async Task GetAsync(string containerId, CancellationToken ct) - { - ContainerDto? container = await store.GetContainerAsync(containerId, ct); - if (container is null) - { - return null; - } - - IReadOnlyDictionary counts = await store.CountCardsByColAsync(ct); - ContainerCountsDto containerCounts = counts.TryGetValue(container.Id, out CardColumnCountDto? count) - ? new ContainerCountsDto(count.Count, count.New) - : new ContainerCountsDto(0, 0); - return container with { Counts = containerCounts }; - } - - /// - /// Создаёт контейнер с дефолтами - /// - /// Вход создания (имя обязательно; цвет/правила/пространство/вид — опциональны). - /// Созданный контейнер (id b_ + 12 hex). - public async Task CreateAsync(ContainerCreateDto create, CancellationToken ct) - { - string space = string.IsNullOrWhiteSpace(create.Space) ? ContainerSpaces.Dashboard : create.Space; - IReadOnlyList existing = await store.ListContainersAsync(space, ct); - int order = existing.Count == 0 ? 0 : existing.Max(container => container.Order) + 1; - - var container = new ContainerDto - { - Id = PrefixId.New(KanbanIdPrefixes.Board), - Name = BuildName(create.Name), - Description = create.Description.Trim(), - Color = string.IsNullOrEmpty(create.Color) ? Palette[order % Palette.Length] : create.Color, - Order = order, - Space = space, - Kind = string.IsNullOrWhiteSpace(create.Kind) ? ContainerKinds.Board : create.Kind, - Suggested = create.Suggested, - Note = create.Note ?? string.Empty, - Rules = NormalizeRules(create.Rules), - Policy = new ContainerPolicyDto(), - }; - - await store.CreateContainerAsync(container, ct); - return container; - } - - /// - /// Частичное обновление контейнера - /// - /// Id контейнера. - /// Изменения; null-поле означает «не менять». - /// Контейнер после патча; null — контейнера нет (404 «Контейнер не найден»). - public async Task PatchAsync( - string containerId, - ContainerPatchDto patch, - CancellationToken ct) - { - ContainerDto? current = await store.GetContainerAsync(containerId, ct); - if (current is null) - { - return null; - } - - ContainerDto updated = ApplyPatch(current, patch); - await store.UpdateContainerAsync(updated, ct); - return updated; - } - - /// - /// Принимает ИИ-предложение - /// - /// Id контейнера-предложения. - /// Контейнер после принятия; null — контейнера нет (404). - public Task AcceptSuggestedAsync(string containerId, CancellationToken ct) - { - return PatchAsync(containerId, new ContainerPatchDto( - Name: null, - Description: null, - Color: null, - Collapsed: null, - Suggested: false, - Note: null, - Rules: null, - Policy: null), ct); - } - - /// - /// Удаляет контейнер, перенося его карточки в «Неразобранное» новыми. - /// - /// Id удаляемого контейнера. - /// Сколько карточек перенесено в inbox (ответ {ok, movedToInbox}). - public Task DeleteAsync(string containerId, CancellationToken ct) - { - return store.DeleteContainerAsync(containerId, ct); - } - - /// - /// Переставляет контейнеры пространства - /// - /// Пространство переставляемых контейнеров. - /// Id контейнеров в новом порядке. - public Task ReorderAsync( - string space, - IReadOnlyList containerIds, - CancellationToken ct) - { - return store.ReorderContainersAsync(space, containerIds, ct); - } - - // ── Состояние колонок (colState, KV через ISettingsStore) ──────────── - - /// - /// Весь объект colState - /// - /// Состояния всех колонок; пусто — настройка не сохранена/повреждена. - public async Task> GetColStateAsync(CancellationToken ct) - { - return await ReadColStateAsync(ct); - } - - /// - /// PATCH состояния одной колонки - /// - /// Id контейнера. - /// Изменяемые поля состояния. - /// Состояние колонки после merge. - public async Task PatchColStateAsync( - string colId, - ColumnStateDto patch, - CancellationToken ct) - { - Dictionary state = await ReadColStateAsync(ct); - state.TryGetValue(colId, out ColumnStateDto? current); - - var merged = new ColumnStateDto( - patch.Collapsed ?? current?.Collapsed, - patch.Width ?? current?.Width); - state[colId] = merged; - - await settings.SetAsync(SettingsKeys.ColState, JsonSerializer.Serialize(state, JsonOptions), ct); - return merged; - } - - // ── Внутреннее ─────────────────────────────────────────────────────── - - // Имя контейнера: trim; пустое → «Новая колонка». - // name: Имя из запроса. - // Возвращает: Непустое имя контейнера. - private static string BuildName(string name) - { - string trimmed = name.Trim(); - return trimmed.Length == 0 ? DefaultContainerName : trimmed; - } - - // Правила: пустые правила (все группы пусты) нормализуются в null — хранилище пишет каноничное {}. - // rules: Правила из запроса. - // Возвращает: Нормализованные правила либо null. - private static ContainerRulesDto? NormalizeRules(ContainerRulesDto? rules) - { - if (rules is null) - { - return null; - } - - bool isEmpty = rules.Mode.Length == 0 - && rules.Direction.Count == 0 - && rules.Keywords.Count == 0 - && rules.Stack.Count == 0 - && rules.Grade.Count == 0 - && rules.Exclude.Count == 0 - && (rules.Levels?.Count ?? 0) == 0 - && (rules.Locations?.Count ?? 0) == 0 - && (rules.Types?.Count ?? 0) == 0 - && rules.Prices is null - && rules.Budget is null; - return isEmpty ? null : rules; - } - - // Применяет не-null поля патча к текущему контейнеру. - // container: Текущий контейнер. - // patch: Патч. - // Возвращает: Контейнер с применёнными изменениями. - private static ContainerDto ApplyPatch(ContainerDto container, ContainerPatchDto patch) - { - return container with - { - Name = patch.Name ?? container.Name, - Description = patch.Description ?? container.Description, - Color = patch.Color ?? container.Color, - Collapsed = patch.Collapsed ?? container.Collapsed, - Suggested = patch.Suggested ?? container.Suggested, - Note = patch.Note ?? container.Note, - Rules = patch.Rules is null ? container.Rules : NormalizeRules(patch.Rules), - Policy = patch.Policy ?? container.Policy, - }; - } - - // Читает весь объект colState из KV; повреждённый JSON трактуется как пустое состояние. - // ct: Токен отмены. - // Возвращает: Словарь контейнер → состояние (Ordinal-ключи). - private async Task> ReadColStateAsync(CancellationToken ct) - { - SettingValue? row = await settings.GetAsync(SettingsKeys.ColState, ct); - if (row is null) - { - return new Dictionary(StringComparer.Ordinal); - } - - try - { - Dictionary? state = - JsonSerializer.Deserialize>(row.ValueJson, JsonOptions); - return state ?? new Dictionary(StringComparer.Ordinal); - } - catch (JsonException) - { - // Битое значение не должно ронять GET /state и последующие PATCH (перезапишут при записи). - return new Dictionary(StringComparer.Ordinal); - } - } -} +using System.Text.Json; +using System.Text.Json.Serialization; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Kanban.Application.Services; + +/// +/// Сервис контейнеров +/// +/// Порт хранилища (контейнеры/карточки тенанта). +/// KV-хранилище настроек тенанта (ключ colState). +public sealed class ContainersService(ICardStore store, ISettingsStore settings) +{ + /// + /// Имя по умолчанию + /// + public const string DefaultContainerName = "Новая колонка"; + + // Палитра колонок по умолчанию: цвет = Palette[order % 8], если цвет не задан. + private static readonly string[] Palette = + ["#818cf8", "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"]; + + // Опции JSON colState: camelCase при записи, терпимость регистра при чтении; null-поля не пишутся. + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + // ── Контейнеры ─────────────────────────────────────────────────────── + + /// + /// Контейнеры пространства в порядке показа, со счётчиками карточек. + /// + /// Пространство (dashboard/selected) либо null — все контейнеры. + /// Контейнеры с заполненными counts; пусто — контейнеров нет. + public async Task> ListAsync(string? space, CancellationToken ct) + { + IReadOnlyList containers = await store.ListContainersAsync(space, ct); + IReadOnlyDictionary counts = await store.CountCardsByColAsync(ct); + return containers + .Select(container => container with + { + Counts = counts.TryGetValue(container.Id, out CardColumnCountDto? count) + ? new ContainerCountsDto(count.Count, count.New) + : new ContainerCountsDto(0, 0), + }) + .ToList(); + } + + /// + /// Один контейнер со счётчиками; null — контейнера нет. + /// + /// Id контейнера. + /// Контейнер со счётчиками либо null. + public async Task GetAsync(string containerId, CancellationToken ct) + { + ContainerDto? container = await store.GetContainerAsync(containerId, ct); + if (container is null) + { + return null; + } + + IReadOnlyDictionary counts = await store.CountCardsByColAsync(ct); + ContainerCountsDto containerCounts = counts.TryGetValue(container.Id, out CardColumnCountDto? count) + ? new ContainerCountsDto(count.Count, count.New) + : new ContainerCountsDto(0, 0); + return container with { Counts = containerCounts }; + } + + /// + /// Создаёт контейнер с дефолтами + /// + /// Вход создания (имя обязательно; цвет/правила/пространство/вид — опциональны). + /// Созданный контейнер (id b_ + 12 hex). + public async Task CreateAsync(ContainerCreateDto create, CancellationToken ct) + { + string space = string.IsNullOrWhiteSpace(create.Space) ? ContainerSpaces.Dashboard : create.Space; + IReadOnlyList existing = await store.ListContainersAsync(space, ct); + int order = existing.Count == 0 ? 0 : existing.Max(container => container.Order) + 1; + + var container = new ContainerDto + { + Id = PrefixId.New(KanbanIdPrefixes.Board), + Name = BuildName(create.Name), + Description = create.Description.Trim(), + Color = string.IsNullOrEmpty(create.Color) ? Palette[order % Palette.Length] : create.Color, + Order = order, + Space = space, + Kind = string.IsNullOrWhiteSpace(create.Kind) ? ContainerKinds.Board : create.Kind, + Suggested = create.Suggested, + Note = create.Note ?? string.Empty, + Rules = NormalizeRules(create.Rules), + Policy = new ContainerPolicyDto(), + }; + + await store.CreateContainerAsync(container, ct); + return container; + } + + /// + /// Частичное обновление контейнера + /// + /// Id контейнера. + /// Изменения; null-поле означает «не менять». + /// Контейнер после патча; null — контейнера нет (404 «Контейнер не найден»). + public async Task PatchAsync( + string containerId, + ContainerPatchDto patch, + CancellationToken ct) + { + ContainerDto? current = await store.GetContainerAsync(containerId, ct); + if (current is null) + { + return null; + } + + ContainerDto updated = ApplyPatch(current, patch); + await store.UpdateContainerAsync(updated, ct); + return updated; + } + + /// + /// Принимает ИИ-предложение + /// + /// Id контейнера-предложения. + /// Контейнер после принятия; null — контейнера нет (404). + public Task AcceptSuggestedAsync(string containerId, CancellationToken ct) + { + return PatchAsync(containerId, new ContainerPatchDto( + Name: null, + Description: null, + Color: null, + Collapsed: null, + Suggested: false, + Note: null, + Rules: null, + Policy: null), ct); + } + + /// + /// Удаляет контейнер, перенося его карточки в «Неразобранное» новыми. + /// + /// Id удаляемого контейнера. + /// Сколько карточек перенесено в inbox (ответ {ok, movedToInbox}). + public Task DeleteAsync(string containerId, CancellationToken ct) + { + return store.DeleteContainerAsync(containerId, ct); + } + + /// + /// Переставляет контейнеры пространства + /// + /// Пространство переставляемых контейнеров. + /// Id контейнеров в новом порядке. + public Task ReorderAsync( + string space, + IReadOnlyList containerIds, + CancellationToken ct) + { + return store.ReorderContainersAsync(space, containerIds, ct); + } + + // ── Состояние колонок (colState, KV через ISettingsStore) ──────────── + + /// + /// Весь объект colState + /// + /// Состояния всех колонок; пусто — настройка не сохранена/повреждена. + public async Task> GetColStateAsync(CancellationToken ct) + { + return await ReadColStateAsync(ct); + } + + /// + /// PATCH состояния одной колонки + /// + /// Id контейнера. + /// Изменяемые поля состояния. + /// Состояние колонки после merge. + public async Task PatchColStateAsync( + string colId, + ColumnStateDto patch, + CancellationToken ct) + { + Dictionary state = await ReadColStateAsync(ct); + state.TryGetValue(colId, out ColumnStateDto? current); + + var merged = new ColumnStateDto( + patch.Collapsed ?? current?.Collapsed, + patch.Width ?? current?.Width); + state[colId] = merged; + + await settings.SetAsync(SettingsKeys.ColState, JsonSerializer.Serialize(state, JsonOptions), ct); + return merged; + } + + // ── Внутреннее ─────────────────────────────────────────────────────── + + // Имя контейнера: trim; пустое → «Новая колонка». + // name: Имя из запроса. + // Возвращает: Непустое имя контейнера. + private static string BuildName(string name) + { + string trimmed = name.Trim(); + return trimmed.Length == 0 ? DefaultContainerName : trimmed; + } + + // Правила: пустые правила (все группы пусты) нормализуются в null — хранилище пишет каноничное {}. + // rules: Правила из запроса. + // Возвращает: Нормализованные правила либо null. + private static ContainerRulesDto? NormalizeRules(ContainerRulesDto? rules) + { + if (rules is null) + { + return null; + } + + bool isEmpty = rules.Mode.Length == 0 + && rules.Direction.Count == 0 + && rules.Keywords.Count == 0 + && rules.Stack.Count == 0 + && rules.Grade.Count == 0 + && rules.Exclude.Count == 0 + && (rules.Levels?.Count ?? 0) == 0 + && (rules.Locations?.Count ?? 0) == 0 + && (rules.Types?.Count ?? 0) == 0 + && rules.Prices is null + && rules.Budget is null; + return isEmpty ? null : rules; + } + + // Применяет не-null поля патча к текущему контейнеру. + // container: Текущий контейнер. + // patch: Патч. + // Возвращает: Контейнер с применёнными изменениями. + private static ContainerDto ApplyPatch(ContainerDto container, ContainerPatchDto patch) + { + return container with + { + Name = patch.Name ?? container.Name, + Description = patch.Description ?? container.Description, + Color = patch.Color ?? container.Color, + Collapsed = patch.Collapsed ?? container.Collapsed, + Suggested = patch.Suggested ?? container.Suggested, + Note = patch.Note ?? container.Note, + Rules = patch.Rules is null ? container.Rules : NormalizeRules(patch.Rules), + Policy = patch.Policy ?? container.Policy, + }; + } + + // Читает весь объект colState из KV; повреждённый JSON трактуется как пустое состояние. + // ct: Токен отмены. + // Возвращает: Словарь контейнер → состояние (Ordinal-ключи). + private async Task> ReadColStateAsync(CancellationToken ct) + { + SettingValue? row = await settings.GetAsync(SettingsKeys.ColState, ct); + if (row is null) + { + return new Dictionary(StringComparer.Ordinal); + } + + try + { + Dictionary? state = + JsonSerializer.Deserialize>(row.ValueJson, JsonOptions); + return state ?? new Dictionary(StringComparer.Ordinal); + } + catch (JsonException) + { + // Битое значение не должно ронять GET /state и последующие PATCH (перезапишут при записи). + return new Dictionary(StringComparer.Ordinal); + } + } +} diff --git a/src/core/Deal.Modules.Kanban/Application/Services/ConversionRecomputer.cs b/src/core/Deal.Modules.Kanban/Application/Services/ConversionRecomputer.cs index 51e4a19..d3395fe 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/ConversionRecomputer.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/ConversionRecomputer.cs @@ -1,80 +1,80 @@ -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Services; - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Пересчёт конверсий бюджетов карточек при смене курсов/целевой валюты. -/// -/// KV-хранилище настроек тенанта (conversionOn/targetCurrency/ratesCache). -/// Порт хранилища канбана: кандидаты пересчёта и запись conv-полей. -public sealed class ConversionRecomputer(ISettingsStore settings, ICardStore store) : IRatesChangedListener -{ - /// - public Task OnRatesChangedAsync(bool fullRecompute, CancellationToken ct) - { - // Оба текущих триггера (обновление кэша курсов, PATCH targetCurrency/conversionOn) требуют полного - // пересчёта всех кандидатов; параметр fullRecompute зарезервирован под будущие частичные события - // (пересчёт одной карточки) — см. IRatesChangedListener. - return RecomputeAsync(ct); - } - - /// - /// Полный пересчёт конверсий кандидатов по текущим настройкам и кэшу курсов. - /// - /// Сколько карточек обновлено (0 — конверсия выключена / нет кэша курсов / нет кандидатов). - public async Task RecomputeAsync(CancellationToken ct) - { - // Все настройки/кэш — из одного типизированного снимка (C30): один GetAllAsync на пересчёт. - TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); - bool conversionOn = snapshot.GetBool(SettingsKeys.ConversionOn, SettingsDefaults.ConversionOn); - if (!conversionOn) - { - return 0; // конверсия снята — ничего не пересчитываем (rates.py L112–113) - } - - string targetCurrency = NormalizeTargetCurrency( - snapshot.GetString(SettingsKeys.TargetCurrency, SettingsDefaults.TargetCurrency)); - RatesCacheValue? cache = snapshot.TryGetRatesCache(); - if (cache is not { Rates.Count: > 0 }) - { - return 0; // кэша курсов нет — конвертировать нечем; карточки не трогаем - } - - int updated = 0; - IReadOnlyList candidates = await store.ListCardsForConversionAsync(ct); - foreach (CardDto card in candidates) - { - CardBudgetDto? budget = card.Budget; - if (budget is null) - { - continue; // хранилище уже фильтрует budget_cur != '' (null Budget), страховка DTO - } - - double? convFrom = RatesService.ConvertAmount(budget.From, budget.Cur, targetCurrency, cache.Rates); - if (convFrom is null) - { - continue; // нет курса валюты / нет нижней границы — строку не трогаем (rates.py L123–124) - } - - double? convTo = - RatesService.ConvertAmount(budget.To ?? budget.From, budget.Cur, targetCurrency, cache.Rates); - await store.UpdateConversionAsync(card.Id, convFrom, convTo, targetCurrency, ct); - updated++; - } - - return updated; - } - - // Код целевой валюты для пересчёта: trim + верхний регистр; пусто → дефолт RUB (как писал PATCH). - // value: Значение настройки targetCurrency (JSON-строка). - // Возвращает: Код валюты (RUB/USD/…) либо дефолт. - private static string NormalizeTargetCurrency(string value) - { - string currency = value.Trim().ToUpperInvariant(); - return currency.Length > 0 ? currency : SettingsDefaults.TargetCurrency; - } -} +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Modules.Kanban.Application.Services; + +/// +/// Пересчёт конверсий бюджетов карточек при смене курсов/целевой валюты. +/// +/// KV-хранилище настроек тенанта (conversionOn/targetCurrency/ratesCache). +/// Порт хранилища канбана: кандидаты пересчёта и запись conv-полей. +public sealed class ConversionRecomputer(ISettingsStore settings, ICardStore store) : IRatesChangedListener +{ + /// + public Task OnRatesChangedAsync(bool fullRecompute, CancellationToken ct) + { + // Оба текущих триггера (обновление кэша курсов, PATCH targetCurrency/conversionOn) требуют полного + // пересчёта всех кандидатов; параметр fullRecompute зарезервирован под будущие частичные события + // (пересчёт одной карточки) — см. IRatesChangedListener. + return RecomputeAsync(ct); + } + + /// + /// Полный пересчёт конверсий кандидатов по текущим настройкам и кэшу курсов. + /// + /// Сколько карточек обновлено (0 — конверсия выключена / нет кэша курсов / нет кандидатов). + public async Task RecomputeAsync(CancellationToken ct) + { + // Все настройки/кэш — из одного типизированного снимка (C30): один GetAllAsync на пересчёт. + TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); + bool conversionOn = snapshot.GetBool(SettingsKeys.ConversionOn, SettingsDefaults.ConversionOn); + if (!conversionOn) + { + return 0; // конверсия снята — ничего не пересчитываем (rates.py L112–113) + } + + string targetCurrency = NormalizeTargetCurrency( + snapshot.GetString(SettingsKeys.TargetCurrency, SettingsDefaults.TargetCurrency)); + RatesCacheValue? cache = snapshot.TryGetRatesCache(); + if (cache is not { Rates.Count: > 0 }) + { + return 0; // кэша курсов нет — конвертировать нечем; карточки не трогаем + } + + int updated = 0; + IReadOnlyList candidates = await store.ListCardsForConversionAsync(ct); + foreach (CardDto card in candidates) + { + CardBudgetDto? budget = card.Budget; + if (budget is null) + { + continue; // хранилище уже фильтрует budget_cur != '' (null Budget), страховка DTO + } + + double? convFrom = RatesService.ConvertAmount(budget.From, budget.Cur, targetCurrency, cache.Rates); + if (convFrom is null) + { + continue; // нет курса валюты / нет нижней границы — строку не трогаем (rates.py L123–124) + } + + double? convTo = + RatesService.ConvertAmount(budget.To ?? budget.From, budget.Cur, targetCurrency, cache.Rates); + await store.UpdateConversionAsync(card.Id, convFrom, convTo, targetCurrency, ct); + updated++; + } + + return updated; + } + + // Код целевой валюты для пересчёта: trim + верхний регистр; пусто → дефолт RUB (как писал PATCH). + // value: Значение настройки targetCurrency (JSON-строка). + // Возвращает: Код валюты (RUB/USD/…) либо дефолт. + private static string NormalizeTargetCurrency(string value) + { + string currency = value.Trim().ToUpperInvariant(); + return currency.Length > 0 ? currency : SettingsDefaults.TargetCurrency; + } +} diff --git a/src/core/Deal.Modules.Kanban/Application/Services/StorageTickService.cs b/src/core/Deal.Modules.Kanban/Application/Services/StorageTickService.cs index a8c1e42..dd91f18 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/StorageTickService.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/StorageTickService.cs @@ -1,51 +1,51 @@ -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Сервис правил хранения -/// -/// Порт хранилища канбана: кандидаты тика, batch-перенос в архив, жёсткое удаление пачки. -/// KV-хранилище настроек тенанта. -public sealed class StorageTickService(ICardStore store, ISettingsStore settings) -{ - /// - /// Один тик правил хранения тенанта. - /// - /// Статистика тика: сколько карточек архивировано/очищено из архива и корзины (purgedRejected=0). - public async Task TickAsync(CancellationToken ct) - { - // Все правила хранения — из одного типизированного снимка настроек (C30): один GetAllAsync на тик. - TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); - bool autoArchive = settingsSnapshot.GetBool(SettingsKeys.AutoArchive, SettingsDefaults.AutoArchive); - int archiveAfterDays = settingsSnapshot.GetInt(SettingsKeys.ArchiveAfterDays, SettingsDefaults.ArchiveAfterDays); - int archiveClearDays = settingsSnapshot.GetInt(SettingsKeys.ArchiveClearDays, SettingsDefaults.ArchiveClearDays); - int trashClearDays = settingsSnapshot.GetInt(SettingsKeys.TrashClearDays, SettingsDefaults.TrashClearDays); - - DateTimeOffset now = DateTimeOffset.UtcNow; - - int archived = 0; - if (autoArchive) - { - IReadOnlyList candidates = await store.ListArchiveCandidatesAsync( - now.AddDays(-archiveAfterDays), ct); - archived = candidates.Count == 0 ? 0 : await store.ArchiveAsync(candidates, now, ct); - } - - int purgedArchive = await PurgeAsync( - await store.ListExpiredArchiveCandidatesAsync(now.AddDays(-archiveClearDays), ct), ct); - - int purgedTrash = await PurgeAsync( - await store.ListTrashCandidatesAsync(now.AddDays(-trashClearDays), ct), ct); - - return new StorageTickStatsDto(archived, purgedArchive, purgedTrash, PurgedRejected: 0); - } - - private async Task PurgeAsync(IReadOnlyList cardIds, CancellationToken ct) - { - return cardIds.Count == 0 ? 0 : await store.PurgeAsync(cardIds, ct); - } -} +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Kanban.Application.Services; + +/// +/// Сервис правил хранения +/// +/// Порт хранилища канбана: кандидаты тика, batch-перенос в архив, жёсткое удаление пачки. +/// KV-хранилище настроек тенанта. +public sealed class StorageTickService(ICardStore store, ISettingsStore settings) +{ + /// + /// Один тик правил хранения тенанта. + /// + /// Статистика тика: сколько карточек архивировано/очищено из архива и корзины (purgedRejected=0). + public async Task TickAsync(CancellationToken ct) + { + // Все правила хранения — из одного типизированного снимка настроек (C30): один GetAllAsync на тик. + TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); + bool autoArchive = settingsSnapshot.GetBool(SettingsKeys.AutoArchive, SettingsDefaults.AutoArchive); + int archiveAfterDays = settingsSnapshot.GetInt(SettingsKeys.ArchiveAfterDays, SettingsDefaults.ArchiveAfterDays); + int archiveClearDays = settingsSnapshot.GetInt(SettingsKeys.ArchiveClearDays, SettingsDefaults.ArchiveClearDays); + int trashClearDays = settingsSnapshot.GetInt(SettingsKeys.TrashClearDays, SettingsDefaults.TrashClearDays); + + DateTimeOffset now = DateTimeOffset.UtcNow; + + int archived = 0; + if (autoArchive) + { + IReadOnlyList candidates = await store.ListArchiveCandidatesAsync( + now.AddDays(-archiveAfterDays), ct); + archived = candidates.Count == 0 ? 0 : await store.ArchiveAsync(candidates, now, ct); + } + + int purgedArchive = await PurgeAsync( + await store.ListExpiredArchiveCandidatesAsync(now.AddDays(-archiveClearDays), ct), ct); + + int purgedTrash = await PurgeAsync( + await store.ListTrashCandidatesAsync(now.AddDays(-trashClearDays), ct), ct); + + return new StorageTickStatsDto(archived, purgedArchive, purgedTrash, PurgedRejected: 0); + } + + private async Task PurgeAsync(IReadOnlyList cardIds, CancellationToken ct) + { + return cardIds.Count == 0 ? 0 : await store.PurgeAsync(cardIds, ct); + } +} diff --git a/src/core/Deal.Modules.Kanban/Application/Services/SuggestHeuristics.cs b/src/core/Deal.Modules.Kanban/Application/Services/SuggestHeuristics.cs index ed115c0..fe0c531 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/SuggestHeuristics.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/SuggestHeuristics.cs @@ -1,249 +1,249 @@ -using System.Text; -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Чистое ядро ИИ-предложений колонок/ключей — план. -/// -public static class SuggestHeuristics -{ - - /// - /// Минимум карточек в «Неразобранном» для анализа. - /// - public const int MinInbox = 6; - - /// - /// Минимум карточек в одной колонке-предложении. - /// - public const int MinInboxGroup = 2; - - /// - /// Сколько сообщений берём в анализ — окно по received_at DESC. - /// - public const int MaxText = 12; - - /// - /// Колонок-предложений за один прогон максимум. - /// - public const int MaxColumns = 4; - - /// - /// Минимальная длина слова-темы - /// - public const int MinWordLength = 3; - - /// - /// Максимальная длина слова-темы/ключа - /// - public const int MaxKeywordLength = 40; - - /// - /// Максимум ключей-маркеров suggest-keywords. - /// - public const int MaxKeywordsTotal = 60; - - /// - /// Окно карточек для suggest-keywords - /// - public const int KeywordsSampleLimit = 40; - - /// - /// Минимум карточек для suggest-keywords - /// - public const int MinKeywordsSample = 3; - - private const string ColumnNoteFormat = - "Эвристика (этап 3): слово-тема «{0}» встречается у {1} карточек; реальные предложения ИИ — этап 6"; - - // Служебные слова, не несущие темы: предлоги/союзы/частицы и типовые обращения заявок («нужен», «ищу»…). - private static readonly HashSet StopWords = new(StringComparer.Ordinal) - { - // Русские служебные слова (предлоги/союзы/частицы/местоимения). - "а", "без", "более", "будет", "бы", "в", "вам", "вас", "ведь", "весь", "вместе", "вот", "впрочем", - "всё", "все", "всего", "всех", "вы", "где", "да", "даже", "для", "до", "его", "её", "ей", "ему", - "если", "есть", "ещё", "еще", "же", "за", "затем", "здесь", "и", "из", "или", "им", "иногда", "их", - "к", "каждый", "как", "какая", "какие", "какой", "когда", "кого", "кому", "конечно", "которая", - "которого", "которые", "который", "которых", "кто", "куда", "ли", "лишь", "лучше", "между", "меня", - "мне", "много", "может", "можно", "мой", "моя", "мы", "на", "над", "надо", "нас", "не", "него", - "нее", "неё", "ней", "нельзя", "нет", "ни", "но", "ну", "о", "об", "один", "однако", "он", "она", - "они", "опять", "от", "очень", "перед", "по", "под", "после", "потом", "потому", "почти", "при", - "про", "раз", "с", "сам", "сама", "самое", "самый", "свою", "себе", "себя", "сейчас", "сколько", - "со", "совсем", "так", "такая", "такие", "такой", "там", "те", "тебе", "тебя", "тем", "теперь", - "то", "тогда", "того", "тоже", "только", "том", "тот", "ту", "тут", "ты", "у", "уже", "хотя", - "чего", "чем", "через", "что", "чтобы", "чуть", "эта", "эти", "этим", "этих", "это", "этого", - "этой", "этом", "этот", "эту", "я", - // Типовые обращения/глаголы-просьбы, общие для заявок любой сферы (не слова-темы). - "добрый", "здравствуйте", "привет", "пожалуйста", "напишите", "написать", "пишите", "писать", - "звоните", "свяжитесь", "связаться", "подробнее", "интересно", "интересует", "нужен", "нужна", - "нужно", "нужны", "ищу", "ищем", "ищет", "ищут", "искал", "искали", "требуется", "требуются", - "найти", "нанять", "рассмотрите", "рассмотрим", - // Английские служебные слова. - "the", "and", "for", "with", "from", "that", "this", "are", "was", "were", "have", "has", "had", - "not", "but", "you", "your", "our", "can", "will", "would", "should", "about", "into", "over", - "after", "before", "more", "most", "than", "then", "them", "they", "their", "there", "here", "when", - "where", "which", "what", "who", "whom", "its", "been", "being", "all", "any", "some", "such", - "only", "also", "very", "just", "please", - }; - - /// - /// Планы колонок-предложений по карточкам «Неразобранного». - /// - /// Карточки «Неразобранного» с непустым текстом источника (ICardStore.ListInboxWithSourceAsync). - /// Имена существующих (suggested=false) досок — похожие темы не предлагаются. - /// Планы колонок (≤4, каждая ≥2 карточки); пусто — мало карточек/нечего сгруппировать. - public static IReadOnlyList PlanColumns(IReadOnlyList inbox, IReadOnlyList existingBoardNames) - { - List window = inbox - .Where(card => (card.Content.Text ?? string.Empty).Trim().Length > 0) - .OrderByDescending(card => card.ReceivedAtMs) - .ThenBy(card => card.Id, StringComparer.Ordinal) - .Take(MaxText) - .ToList(); - if (window.Count < MinInbox) - { - return Array.Empty(); - } - - // Слово → карточки окна, где оно встречается (у карточки слово учитывается один раз). - Dictionary> termCards = new(StringComparer.Ordinal); - foreach (CardDto card in window) - { - // Distinct: у карточки слово учитывается один раз, сколько бы раз ни встречалось в тексте. - foreach (string term in Tokenize(card.Content.Text ?? string.Empty).Distinct(StringComparer.Ordinal)) - { - if (!termCards.TryGetValue(term, out List? cardIds)) - { - cardIds = []; - termCards[term] = cardIds; - } - - cardIds.Add(card.Id); - } - } - - List existingLowered = existingBoardNames - .Select(name => name.ToLowerInvariant()) - .ToList(); - - // Кандидаты: слово в ≥2 карточках и не похожее на существующую доску; порядок — частота, - // при равенстве более длинное (специфичнее), затем лексикографически (детерминизм). - List candidates = termCards - .Where(pair => pair.Value.Count >= MinInboxGroup && !SimilarToExisting(pair.Key, existingLowered)) - .Select(pair => pair.Key) - .OrderByDescending(term => termCards[term].Count) - .ThenByDescending(term => term.Length) - .ThenBy(term => term, StringComparer.Ordinal) - .ToList(); - - var usedCardIds = new HashSet(StringComparer.Ordinal); - var plans = new List(); - foreach (string term in candidates) - { - if (plans.Count == MaxColumns) - { - break; - } - - List freeCardIds = termCards[term].Where(cardId => !usedCardIds.Contains(cardId)).ToList(); - if (freeCardIds.Count < MinInboxGroup) - { - continue; - } - - usedCardIds.UnionWith(freeCardIds); - plans.Add(new SuggestedColumnPlan( - Word: term, - Name: Capitalize(term), - CardIds: freeCardIds, - Note: string.Format(ColumnNoteFormat, term, freeCardIds.Count))); - } - - return plans; - } - - /// - /// Частотные слова-маркеры по текстам карточек. - /// - /// Тексты источника карточек выборки (непустые, свежие ≤40). - /// Слова-маркеры (≤60); пусто — нет слов, встречающихся в ≥2 текстах. - public static IReadOnlyList SuggestDomainKeywords(IReadOnlyList texts) - { - Dictionary termCounts = new(StringComparer.Ordinal); - foreach (string text in texts) - { - // Маркер — слово, встречающееся в ≥2 ТЕКСТАХ выборки: повтор в одном тексте не увеличивает счёт. - foreach (string term in Tokenize(text).Distinct(StringComparer.Ordinal)) - { - termCounts[term] = termCounts.GetValueOrDefault(term) + 1; - } - } - - return termCounts - .Where(pair => pair.Value >= MinInboxGroup) - .OrderByDescending(pair => pair.Value) - .ThenByDescending(pair => pair.Key.Length) - .ThenBy(pair => pair.Key, StringComparer.Ordinal) - .Select(pair => pair.Key) - .Take(MaxKeywordsTotal) - .ToList(); - } - - // Слова текста: подряд букв (латиница/кириллица), нижний регистр, длина 3..40, минус стоп-слова. - // text: Исходный текст (текст источника карточки). - // Возвращает: Уникальные слова текста в порядке появления (Ordinal). - private static List Tokenize(string text) - { - var terms = new List(); - var buffer = new StringBuilder(); - foreach (char c in text) - { - if (char.IsLetter(c)) - { - buffer.Append(c); - continue; - } - - AddTerm(buffer, terms); - } - - AddTerm(buffer, terms); - return terms; - } - - // Сбрасывает буфер слова в список, если слово проходит пороги (длина, стоп-слова). - // buffer: Накопленные буквы слова. - // terms: Список слов текста. - private static void AddTerm(StringBuilder buffer, List terms) - { - if (buffer.Length == 0) - { - return; - } - - string term = buffer.ToString().ToLowerInvariant(); - buffer.Clear(); - if (term.Length is < MinWordLength or > MaxKeywordLength || StopWords.Contains(term)) - { - return; - } - - terms.Add(term); - } - - private static bool SimilarToExisting(string term, List existingLowered) - { - foreach (string name in existingLowered) - { - if (term == name || name.Contains(term, StringComparison.Ordinal) || term.Contains(name, StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } - - private static string Capitalize(string term) => char.ToUpperInvariant(term[0]) + term[1..]; -} +using System.Text; +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Modules.Kanban.Application.Services; + +/// +/// Чистое ядро ИИ-предложений колонок/ключей — план. +/// +public static class SuggestHeuristics +{ + + /// + /// Минимум карточек в «Неразобранном» для анализа. + /// + public const int MinInbox = 6; + + /// + /// Минимум карточек в одной колонке-предложении. + /// + public const int MinInboxGroup = 2; + + /// + /// Сколько сообщений берём в анализ — окно по received_at DESC. + /// + public const int MaxText = 12; + + /// + /// Колонок-предложений за один прогон максимум. + /// + public const int MaxColumns = 4; + + /// + /// Минимальная длина слова-темы + /// + public const int MinWordLength = 3; + + /// + /// Максимальная длина слова-темы/ключа + /// + public const int MaxKeywordLength = 40; + + /// + /// Максимум ключей-маркеров suggest-keywords. + /// + public const int MaxKeywordsTotal = 60; + + /// + /// Окно карточек для suggest-keywords + /// + public const int KeywordsSampleLimit = 40; + + /// + /// Минимум карточек для suggest-keywords + /// + public const int MinKeywordsSample = 3; + + private const string ColumnNoteFormat = + "Эвристика (этап 3): слово-тема «{0}» встречается у {1} карточек; реальные предложения ИИ — этап 6"; + + // Служебные слова, не несущие темы: предлоги/союзы/частицы и типовые обращения заявок («нужен», «ищу»…). + private static readonly HashSet StopWords = new(StringComparer.Ordinal) + { + // Русские служебные слова (предлоги/союзы/частицы/местоимения). + "а", "без", "более", "будет", "бы", "в", "вам", "вас", "ведь", "весь", "вместе", "вот", "впрочем", + "всё", "все", "всего", "всех", "вы", "где", "да", "даже", "для", "до", "его", "её", "ей", "ему", + "если", "есть", "ещё", "еще", "же", "за", "затем", "здесь", "и", "из", "или", "им", "иногда", "их", + "к", "каждый", "как", "какая", "какие", "какой", "когда", "кого", "кому", "конечно", "которая", + "которого", "которые", "который", "которых", "кто", "куда", "ли", "лишь", "лучше", "между", "меня", + "мне", "много", "может", "можно", "мой", "моя", "мы", "на", "над", "надо", "нас", "не", "него", + "нее", "неё", "ней", "нельзя", "нет", "ни", "но", "ну", "о", "об", "один", "однако", "он", "она", + "они", "опять", "от", "очень", "перед", "по", "под", "после", "потом", "потому", "почти", "при", + "про", "раз", "с", "сам", "сама", "самое", "самый", "свою", "себе", "себя", "сейчас", "сколько", + "со", "совсем", "так", "такая", "такие", "такой", "там", "те", "тебе", "тебя", "тем", "теперь", + "то", "тогда", "того", "тоже", "только", "том", "тот", "ту", "тут", "ты", "у", "уже", "хотя", + "чего", "чем", "через", "что", "чтобы", "чуть", "эта", "эти", "этим", "этих", "это", "этого", + "этой", "этом", "этот", "эту", "я", + // Типовые обращения/глаголы-просьбы, общие для заявок любой сферы (не слова-темы). + "добрый", "здравствуйте", "привет", "пожалуйста", "напишите", "написать", "пишите", "писать", + "звоните", "свяжитесь", "связаться", "подробнее", "интересно", "интересует", "нужен", "нужна", + "нужно", "нужны", "ищу", "ищем", "ищет", "ищут", "искал", "искали", "требуется", "требуются", + "найти", "нанять", "рассмотрите", "рассмотрим", + // Английские служебные слова. + "the", "and", "for", "with", "from", "that", "this", "are", "was", "were", "have", "has", "had", + "not", "but", "you", "your", "our", "can", "will", "would", "should", "about", "into", "over", + "after", "before", "more", "most", "than", "then", "them", "they", "their", "there", "here", "when", + "where", "which", "what", "who", "whom", "its", "been", "being", "all", "any", "some", "such", + "only", "also", "very", "just", "please", + }; + + /// + /// Планы колонок-предложений по карточкам «Неразобранного». + /// + /// Карточки «Неразобранного» с непустым текстом источника (ICardStore.ListInboxWithSourceAsync). + /// Имена существующих (suggested=false) досок — похожие темы не предлагаются. + /// Планы колонок (≤4, каждая ≥2 карточки); пусто — мало карточек/нечего сгруппировать. + public static IReadOnlyList PlanColumns(IReadOnlyList inbox, IReadOnlyList existingBoardNames) + { + List window = inbox + .Where(card => (card.Content.Text ?? string.Empty).Trim().Length > 0) + .OrderByDescending(card => card.ReceivedAtMs) + .ThenBy(card => card.Id, StringComparer.Ordinal) + .Take(MaxText) + .ToList(); + if (window.Count < MinInbox) + { + return Array.Empty(); + } + + // Слово → карточки окна, где оно встречается (у карточки слово учитывается один раз). + Dictionary> termCards = new(StringComparer.Ordinal); + foreach (CardDto card in window) + { + // Distinct: у карточки слово учитывается один раз, сколько бы раз ни встречалось в тексте. + foreach (string term in Tokenize(card.Content.Text ?? string.Empty).Distinct(StringComparer.Ordinal)) + { + if (!termCards.TryGetValue(term, out List? cardIds)) + { + cardIds = []; + termCards[term] = cardIds; + } + + cardIds.Add(card.Id); + } + } + + List existingLowered = existingBoardNames + .Select(name => name.ToLowerInvariant()) + .ToList(); + + // Кандидаты: слово в ≥2 карточках и не похожее на существующую доску; порядок — частота, + // при равенстве более длинное (специфичнее), затем лексикографически (детерминизм). + List candidates = termCards + .Where(pair => pair.Value.Count >= MinInboxGroup && !SimilarToExisting(pair.Key, existingLowered)) + .Select(pair => pair.Key) + .OrderByDescending(term => termCards[term].Count) + .ThenByDescending(term => term.Length) + .ThenBy(term => term, StringComparer.Ordinal) + .ToList(); + + var usedCardIds = new HashSet(StringComparer.Ordinal); + var plans = new List(); + foreach (string term in candidates) + { + if (plans.Count == MaxColumns) + { + break; + } + + List freeCardIds = termCards[term].Where(cardId => !usedCardIds.Contains(cardId)).ToList(); + if (freeCardIds.Count < MinInboxGroup) + { + continue; + } + + usedCardIds.UnionWith(freeCardIds); + plans.Add(new SuggestedColumnPlan( + Word: term, + Name: Capitalize(term), + CardIds: freeCardIds, + Note: string.Format(ColumnNoteFormat, term, freeCardIds.Count))); + } + + return plans; + } + + /// + /// Частотные слова-маркеры по текстам карточек. + /// + /// Тексты источника карточек выборки (непустые, свежие ≤40). + /// Слова-маркеры (≤60); пусто — нет слов, встречающихся в ≥2 текстах. + public static IReadOnlyList SuggestDomainKeywords(IReadOnlyList texts) + { + Dictionary termCounts = new(StringComparer.Ordinal); + foreach (string text in texts) + { + // Маркер — слово, встречающееся в ≥2 ТЕКСТАХ выборки: повтор в одном тексте не увеличивает счёт. + foreach (string term in Tokenize(text).Distinct(StringComparer.Ordinal)) + { + termCounts[term] = termCounts.GetValueOrDefault(term) + 1; + } + } + + return termCounts + .Where(pair => pair.Value >= MinInboxGroup) + .OrderByDescending(pair => pair.Value) + .ThenByDescending(pair => pair.Key.Length) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Key) + .Take(MaxKeywordsTotal) + .ToList(); + } + + // Слова текста: подряд букв (латиница/кириллица), нижний регистр, длина 3..40, минус стоп-слова. + // text: Исходный текст (текст источника карточки). + // Возвращает: Уникальные слова текста в порядке появления (Ordinal). + private static List Tokenize(string text) + { + var terms = new List(); + var buffer = new StringBuilder(); + foreach (char c in text) + { + if (char.IsLetter(c)) + { + buffer.Append(c); + continue; + } + + AddTerm(buffer, terms); + } + + AddTerm(buffer, terms); + return terms; + } + + // Сбрасывает буфер слова в список, если слово проходит пороги (длина, стоп-слова). + // buffer: Накопленные буквы слова. + // terms: Список слов текста. + private static void AddTerm(StringBuilder buffer, List terms) + { + if (buffer.Length == 0) + { + return; + } + + string term = buffer.ToString().ToLowerInvariant(); + buffer.Clear(); + if (term.Length is < MinWordLength or > MaxKeywordLength || StopWords.Contains(term)) + { + return; + } + + terms.Add(term); + } + + private static bool SimilarToExisting(string term, List existingLowered) + { + foreach (string name in existingLowered) + { + if (term == name || name.Contains(term, StringComparison.Ordinal) || term.Contains(name, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static string Capitalize(string term) => char.ToUpperInvariant(term[0]) + term[1..]; +} diff --git a/src/core/Deal.Modules.Kanban/Deal.Modules.Kanban.csproj b/src/core/Deal.Modules.Kanban/Deal.Modules.Kanban.csproj index 2d25f8f..1f167be 100644 --- a/src/core/Deal.Modules.Kanban/Deal.Modules.Kanban.csproj +++ b/src/core/Deal.Modules.Kanban/Deal.Modules.Kanban.csproj @@ -1,20 +1,20 @@ - - - - - - - - - - - - - - - net10.0 - enable - enable - - - + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/GlobalExcludeSettings.cs b/src/core/Deal.Modules.Pipeline/Application/Models/GlobalExcludeSettings.cs index e9865dc..6df9245 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/GlobalExcludeSettings.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/GlobalExcludeSettings.cs @@ -1,16 +1,16 @@ -namespace Deal.Modules.Pipeline.Application.Models; - -/// -/// Снимок глобальных исключений тенанта. -/// -/// Ключевые слова/фразы/технологии (пусто — группа выключена). -/// Локации/языки (пусто — группа выключена). -/// Типы заявки (vacancy|freelance|announcement; пусто — группа выключена). -/// Нижняя граница бюджета (null — не задана). -/// Верхняя граница бюджета (null — не задана). -public sealed record GlobalExcludeSettings( - IReadOnlyList Keywords, - IReadOnlyList Locations, - IReadOnlyList Types, - double? BudgetFrom, - double? BudgetTo); +namespace Deal.Modules.Pipeline.Application.Models; + +/// +/// Снимок глобальных исключений тенанта. +/// +/// Ключевые слова/фразы/технологии (пусто — группа выключена). +/// Локации/языки (пусто — группа выключена). +/// Типы заявки (vacancy|freelance|announcement; пусто — группа выключена). +/// Нижняя граница бюджета (null — не задана). +/// Верхняя граница бюджета (null — не задана). +public sealed record GlobalExcludeSettings( + IReadOnlyList Keywords, + IReadOnlyList Locations, + IReadOnlyList Types, + double? BudgetFrom, + double? BudgetTo); diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/GlobalExclusionResult.cs b/src/core/Deal.Modules.Pipeline/Application/Models/GlobalExclusionResult.cs index 79cd911..ade1393 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/GlobalExclusionResult.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/GlobalExclusionResult.cs @@ -1,9 +1,9 @@ -namespace Deal.Modules.Pipeline.Application.Models; - -/// -/// Сработавшее глобальное исключение -/// -/// Этап/правило исключения (константы GlobalExclusionRules). -/// Причина отсева для UI (какое исключение сработало). -/// Конкретное слово/фраза/тип (пусто — не терм). -public sealed record GlobalExclusionResult(string Kind, string Reason, string Kw); +namespace Deal.Modules.Pipeline.Application.Models; + +/// +/// Сработавшее глобальное исключение +/// +/// Этап/правило исключения (константы GlobalExclusionRules). +/// Причина отсева для UI (какое исключение сработало). +/// Конкретное слово/фраза/тип (пусто — не терм). +public sealed record GlobalExclusionResult(string Kind, string Reason, string Kw); diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/MlApplyResult.cs b/src/core/Deal.Modules.Pipeline/Application/Models/MlApplyResult.cs index a7821f9..d658fe8 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/MlApplyResult.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/MlApplyResult.cs @@ -1,16 +1,16 @@ -namespace Deal.Modules.Pipeline.Application.Models; - -/// -/// Результат ручного решения по сообщению. -/// -/// Текст ошибки 400 (неизвестная доска/действие); null — решение применено. -/// True — решение принято. -/// True — ML обучен (передан обучающий сигнал). -/// Куда переехала карточка: trash | id колонки; null — карточки не было. -/// Id карточки, если она была и переехала. -public sealed record MlApplyResult( - string? Error, - bool Ok, - bool Learned, - string? Moved, - string? LeadId); +namespace Deal.Modules.Pipeline.Application.Models; + +/// +/// Результат ручного решения по сообщению. +/// +/// Текст ошибки 400 (неизвестная доска/действие); null — решение применено. +/// True — решение принято. +/// True — ML обучен (передан обучающий сигнал). +/// Куда переехала карточка: trash | id колонки; null — карточки не было. +/// Id карточки, если она была и переехала. +public sealed record MlApplyResult( + string? Error, + bool Ok, + bool Learned, + string? Moved, + string? LeadId); diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidateDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidateDto.cs index 9406937..7493f6c 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidateDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidateDto.cs @@ -1,59 +1,59 @@ -using Deal.Modules.Cards.Application.Sources; - -namespace Deal.Modules.Pipeline.Application.Models; - -/// -/// Запись-кандидат для ручной проверки ML. -/// -public sealed record MlCandidateDto -{ - /// - /// Источник записи. - /// - public SourceRef Source { get; init; } = SourceRefs.Empty; - - /// - /// Содержимое записи источника. - /// - public SourceContent Content { get; init; } = new(); - - /// - /// Текст записи. - /// - public string Text { get; init; } = string.Empty; - - /// - /// Время исходного сообщения, epoch-ms; null — неизвестно. - /// - public long? Time { get; init; } - - /// - /// True — по сообщению уже есть карточка. - /// - public bool Lead { get; init; } - - /// - /// Текущий вердикт - /// - public string Verdict { get; init; } = string.Empty; - - /// - /// Колонка карточки - /// - public string? Col { get; init; } - - /// - /// Этап отсева (для verdict=rejected) либо статус очереди (для verdict=queued). - /// - public string? Stage { get; init; } - - /// - /// Причина отсева - /// - public string? Reason { get; init; } - - /// - /// Мнение ML по тексту; null — ML не ответил/не готов. - /// - public MlCandidatePredictionDto? Pred { get; init; } -} +using Deal.Modules.Cards.Application.Sources; + +namespace Deal.Modules.Pipeline.Application.Models; + +/// +/// Запись-кандидат для ручной проверки ML. +/// +public sealed record MlCandidateDto +{ + /// + /// Источник записи. + /// + public SourceRef Source { get; init; } = SourceRefs.Empty; + + /// + /// Содержимое записи источника. + /// + public SourceContent Content { get; init; } = new(); + + /// + /// Текст записи. + /// + public string Text { get; init; } = string.Empty; + + /// + /// Время исходного сообщения, epoch-ms; null — неизвестно. + /// + public long? Time { get; init; } + + /// + /// True — по сообщению уже есть карточка. + /// + public bool Lead { get; init; } + + /// + /// Текущий вердикт + /// + public string Verdict { get; init; } = string.Empty; + + /// + /// Колонка карточки + /// + public string? Col { get; init; } + + /// + /// Этап отсева (для verdict=rejected) либо статус очереди (для verdict=queued). + /// + public string? Stage { get; init; } + + /// + /// Причина отсева + /// + public string? Reason { get; init; } + + /// + /// Мнение ML по тексту; null — ML не ответил/не готов. + /// + public MlCandidatePredictionDto? Pred { get; init; } +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidatePredictionDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidatePredictionDto.cs index 9f533fe..d16935c 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidatePredictionDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidatePredictionDto.cs @@ -1,12 +1,12 @@ -namespace Deal.Modules.Pipeline.Application.Models; - -/// -/// Мнение ML по сообщению-кандидату. -/// -/// Модель «взяла бы» сообщение (готова и уверена). -/// Метка решения (id доски либо spam); null — модель не уверена/не готова. -/// Оценки классов (пусто — модель не готова). -public sealed record MlCandidatePredictionDto( - bool Take, - string? Label, - IReadOnlyDictionary Scores); +namespace Deal.Modules.Pipeline.Application.Models; + +/// +/// Мнение ML по сообщению-кандидату. +/// +/// Модель «взяла бы» сообщение (готова и уверена). +/// Метка решения (id доски либо spam); null — модель не уверена/не готова. +/// Оценки классов (пусто — модель не готова). +public sealed record MlCandidatePredictionDto( + bool Take, + string? Label, + IReadOnlyDictionary Scores); diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineDryRunDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineDryRunDto.cs index c0bce4a..a4f478c 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineDryRunDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineDryRunDto.cs @@ -1,21 +1,21 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Modules.Pipeline.Application.Models; - -/// -/// Результат сухого прогона текста по конвейеру (без записи в систему). -/// -/// True — текст прошёл все включённые этапы. -/// True — из текста была бы создана карточка. -/// Контейнер, в который попала бы карточка, если она создаётся. -/// Совпавшие критерии правил целевого контейнера. -/// Разбор текста (поля карточки), если прогон дошёл до сборки. -/// Результаты этапов в порядке прохождения. -public sealed record PipelineDryRunDto( - bool Passed, - bool WouldCreateCard, - string? TargetContainer, - IReadOnlyList MatchHits, - AiParsedCardDto? Parsed, - IReadOnlyList Stages); +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Modules.Pipeline.Application.Models; + +/// +/// Результат сухого прогона текста по конвейеру (без записи в систему). +/// +/// True — текст прошёл все включённые этапы. +/// True — из текста была бы создана карточка. +/// Контейнер, в который попала бы карточка, если она создаётся. +/// Совпавшие критерии правил целевого контейнера. +/// Разбор текста (поля карточки), если прогон дошёл до сборки. +/// Результаты этапов в порядке прохождения. +public sealed record PipelineDryRunDto( + bool Passed, + bool WouldCreateCard, + string? TargetContainer, + IReadOnlyList MatchHits, + AiParsedCardDto? Parsed, + IReadOnlyList Stages); diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineDryRunStageDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineDryRunStageDto.cs index 5ac87d8..6fb39a6 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineDryRunStageDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineDryRunStageDto.cs @@ -1,18 +1,18 @@ -namespace Deal.Modules.Pipeline.Application.Models; - -/// -/// Результат одного этапа сухого прогона текста по конвейеру. -/// -/// Код этапа: length|stop|resume|type|exclude|stale|ml|ai|budget. -/// True — этап пропустил текст дальше. -/// True — этап выключен настройкой и не выполнялся. -/// Причина блокировки (пусто — блокировки нет). -/// Сработавшее слово/фраза правила, если есть. -/// Метка решения ML/ИИ, если применимо. -public sealed record PipelineDryRunStageDto( - string Stage, - bool Pass, - bool Skipped, - string Reason, - string? Kw = null, - string? Label = null); +namespace Deal.Modules.Pipeline.Application.Models; + +/// +/// Результат одного этапа сухого прогона текста по конвейеру. +/// +/// Код этапа: length|stop|resume|type|exclude|stale|ml|ai|budget. +/// True — этап пропустил текст дальше. +/// True — этап выключен настройкой и не выполнялся. +/// Причина блокировки (пусто — блокировки нет). +/// Сработавшее слово/фраза правила, если есть. +/// Метка решения ML/ИИ, если применимо. +public sealed record PipelineDryRunStageDto( + string Stage, + bool Pass, + bool Skipped, + string Reason, + string? Kw = null, + string? Label = null); diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/ReclassifyProgressDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/ReclassifyProgressDto.cs index b62381d..a1d4e10 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/ReclassifyProgressDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/ReclassifyProgressDto.cs @@ -1,18 +1,18 @@ -namespace Deal.Modules.Pipeline.Application.Models; - -/// -/// Промежуточный прогресс пакетной переклассификации «Неразобранного». -/// -/// Сколько карточек уже обработано. -/// Сколько карточек отобрано всего. -/// Сколько ушло в смысловую колонку. -/// Сколько осталось в «Неразобранном». -/// Сколько отправлено в корзину. -/// Сколько пропущено (нет исходного текста). -public sealed record ReclassifyProgressDto( - int Done, - int Total, - int Moved, - int Kept, - int Trashed, - int Skipped); +namespace Deal.Modules.Pipeline.Application.Models; + +/// +/// Промежуточный прогресс пакетной переклассификации «Неразобранного». +/// +/// Сколько карточек уже обработано. +/// Сколько карточек отобрано всего. +/// Сколько ушло в смысловую колонку. +/// Сколько осталось в «Неразобранном». +/// Сколько отправлено в корзину. +/// Сколько пропущено (нет исходного текста). +public sealed record ReclassifyProgressDto( + int Done, + int Total, + int Moved, + int Kept, + int Trashed, + int Skipped); diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/ReclassifyResultDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/ReclassifyResultDto.cs index 64664e3..12a3358 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/ReclassifyResultDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/ReclassifyResultDto.cs @@ -1,26 +1,26 @@ -namespace Deal.Modules.Pipeline.Application.Models; - -/// -/// Итог ручной переклассификации «Неразобранного»/одной карточки -/// -/// Переклассификация выполнена (target непуст). -/// Проход уже выполняется другим запросом. -/// Сколько карточек отобрано. -/// Сколько карточек успешно переклассифицировано. -/// Сколько ушло в смысловую колонку. -/// Сколько осталось в «Неразобранном». -/// Сколько отправлено в корзину (спам/не прошло ИИ-фильтр). -/// Сколько пропущено (нет исходного текста). -/// True — использован порт ИИ; false — локальный детерминированный разбор. -/// Причина невыполнения/пустого target либо null. -public sealed record ReclassifyResultDto( - bool Started, - bool Busy, - int Attempted, - int Reclassified, - int Moved, - int Kept, - int Trashed, - int Skipped, - bool UsedAi, - string? Reason); +namespace Deal.Modules.Pipeline.Application.Models; + +/// +/// Итог ручной переклассификации «Неразобранного»/одной карточки +/// +/// Переклассификация выполнена (target непуст). +/// Проход уже выполняется другим запросом. +/// Сколько карточек отобрано. +/// Сколько карточек успешно переклассифицировано. +/// Сколько ушло в смысловую колонку. +/// Сколько осталось в «Неразобранном». +/// Сколько отправлено в корзину (спам/не прошло ИИ-фильтр). +/// Сколько пропущено (нет исходного текста). +/// True — использован порт ИИ; false — локальный детерминированный разбор. +/// Причина невыполнения/пустого target либо null. +public sealed record ReclassifyResultDto( + bool Started, + bool Busy, + int Attempted, + int Reclassified, + int Moved, + int Kept, + int Trashed, + int Skipped, + bool UsedAi, + string? Reason); diff --git a/src/core/Deal.Modules.Pipeline/Application/Registrars/PipelineModuleRegistrar.cs b/src/core/Deal.Modules.Pipeline/Application/Registrars/PipelineModuleRegistrar.cs index f5c6abe..d0e2662 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Registrars/PipelineModuleRegistrar.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Registrars/PipelineModuleRegistrar.cs @@ -1,40 +1,40 @@ -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Pipeline.Application.Services; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Modules.Pipeline.Application.Registrars; - -/// -/// DI-регистрация модуля Pipeline. -/// -public static class PipelineModuleRegistrar -{ - /// - /// Регистрирует сервисы модуля Pipeline в контейнере. - /// - public static IServiceCollection AddPipelineModule(this IServiceCollection services) - { - // Ядра разбора задачи 4: статические (MessageTextCleaner/…/SummaryComposer) не регистрируются; - // LocalFieldsParser читает маркеры через ISettingsStore — scoped, как IncomingRules (эталон KanbanModuleRegistrar). - services.AddScoped(); - - services.AddScoped(); - services.AddScoped(); - - services.AddScoped(); - services.AddScoped(); - - services.AddScoped(); - - services.AddScoped(); - - // Этап 12, пакет D: ручная переклассификация карточек (POST /api/cards/reclassify|{id}/reclassify). - // Синхронный проход той же механикой, что и воркер (фильтр/классификация/сборка/ML), поэтому scoped — - // как воркер. Замок single-flight живёт в singleton (общий для всех tenant-запросов процесса). - services.AddSingleton(); - services.AddScoped(); - - services.AddScoped(); - return services; - } -} +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Pipeline.Application.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Modules.Pipeline.Application.Registrars; + +/// +/// DI-регистрация модуля Pipeline. +/// +public static class PipelineModuleRegistrar +{ + /// + /// Регистрирует сервисы модуля Pipeline в контейнере. + /// + public static IServiceCollection AddPipelineModule(this IServiceCollection services) + { + // Ядра разбора задачи 4: статические (MessageTextCleaner/…/SummaryComposer) не регистрируются; + // LocalFieldsParser читает маркеры через ISettingsStore — scoped, как IncomingRules (эталон KanbanModuleRegistrar). + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + + services.AddScoped(); + + // Этап 12, пакет D: ручная переклассификация карточек (POST /api/cards/reclassify|{id}/reclassify). + // Синхронный проход той же механикой, что и воркер (фильтр/классификация/сборка/ML), поэтому scoped — + // как воркер. Замок single-flight живёт в singleton (общий для всех tenant-запросов процесса). + services.AddSingleton(); + services.AddScoped(); + + services.AddScoped(); + return services; + } +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/AiCardLearning.cs b/src/core/Deal.Modules.Pipeline/Application/Services/AiCardLearning.cs index 505628b..0a1033e 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/AiCardLearning.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/AiCardLearning.cs @@ -1,53 +1,53 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.ColumnRules; -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Обучающие сигналы ML по карточке ИИ-пути -/// -public static class AiCardLearning -{ - /// - /// Пушит обучающие сигналы ML - /// - /// Порт канбана: чтение правил/признака предложения доски. - /// Клиент ML (PushAsync — обучающий сигнал). - /// Колонка карточки после классификации (реальная, после ContainerAccepts-страховки). - /// Разбор, на котором собрана карточка (тип/спам из классификатора). - /// Текст сообщения (обучающий пример — как source_msg карточки). - /// Вес сигнала (гипотеза ИИ — ). - public static async Task PushSignalsAsync( - ICardStore kanjStore, - IMlClient mlClient, - string col, - AiParsedCardDto parsed, - string text, - double weight, - CancellationToken ct) - { - bool isServiceCol = col == CardIds.Inbox || col == CardIds.Trash || col == CardIds.Archive; - if (!isServiceCol && !parsed.IsSpam) - { - ContainerDto? board = await kanjStore.GetContainerAsync(col, ct); - bool free = board is not null && !board.Suggested && !ColumnRules.HasActiveRules(board.Rules); - if (free) - { - await mlClient.PushAsync(text, col, weight, ct); - } - } - - if (parsed.IsVacancyKnown) - { - await mlClient.PushAsync( - text, - parsed.IsVacancy ? MlLearningLabels.TypeHireValue : MlLearningLabels.TypeOrderValue, - weight, - ct); - } - } -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Обучающие сигналы ML по карточке ИИ-пути +/// +public static class AiCardLearning +{ + /// + /// Пушит обучающие сигналы ML + /// + /// Порт канбана: чтение правил/признака предложения доски. + /// Клиент ML (PushAsync — обучающий сигнал). + /// Колонка карточки после классификации (реальная, после ContainerAccepts-страховки). + /// Разбор, на котором собрана карточка (тип/спам из классификатора). + /// Текст сообщения (обучающий пример — как source_msg карточки). + /// Вес сигнала (гипотеза ИИ — ). + public static async Task PushSignalsAsync( + ICardStore kanjStore, + IMlClient mlClient, + string col, + AiParsedCardDto parsed, + string text, + double weight, + CancellationToken ct) + { + bool isServiceCol = col == CardIds.Inbox || col == CardIds.Trash || col == CardIds.Archive; + if (!isServiceCol && !parsed.IsSpam) + { + ContainerDto? board = await kanjStore.GetContainerAsync(col, ct); + bool free = board is not null && !board.Suggested && !ColumnRules.HasActiveRules(board.Rules); + if (free) + { + await mlClient.PushAsync(text, col, weight, ct); + } + } + + if (parsed.IsVacancyKnown) + { + await mlClient.PushAsync( + text, + parsed.IsVacancy ? MlLearningLabels.TypeHireValue : MlLearningLabels.TypeOrderValue, + weight, + ct); + } + } +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/AiCardMapper.cs b/src/core/Deal.Modules.Pipeline/Application/Services/AiCardMapper.cs index 8585a79..7c73541 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/AiCardMapper.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/AiCardMapper.cs @@ -1,49 +1,49 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Маппинг локального разбора в контрактный . -/// -public static class AiCardMapper -{ - /// - /// Строит разбор карточки из локальных полей и исходного текста. - /// - /// Локальные поля (заголовок/суть/стек/бюджет/контакты/признак найма). - /// Исходный текст сообщения (fallback-источник кандидатов контактов). - /// Контрактный разбор: бюджет нормализован, контакты квалифицированы, board=null, is_vacancy_known=false. - public static AiParsedCardDto FromLocal(LocalParsedFields fields, string text) - { - CardBudgetDto? normalized = BudgetNormalizer.Normalize(fields.Budget); - AiBudgetDto? budget = normalized is null ? null : new AiBudgetDto(normalized.From, normalized.To, normalized.Cur); - - IReadOnlyList qualified = ContactsQualifier.Build(fields.Contacts, text); - var contacts = new List(qualified.Count); - foreach (CardContactDto contact in qualified) - { - contacts.Add(new AiContactDto(contact.Type, contact.Value)); - } - - return new AiParsedCardDto( - Title: fields.Title, - Company: null, - Format: null, - Task: null, - Requirements: null, - Plus: null, - Conditions: null, - Summary: fields.Summary, - Stack: fields.Stack, - Budget: budget, - Contacts: contacts, - IsVacancy: fields.IsVacancy, - IsVacancyKnown: false, // маркерная гипотеза — тип подтверждает только ИИ по контексту (python L796) - IsSpam: false, - Board: null); // смысловые колонки до ИИ не назначаем (python L797) - } -} +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Маппинг локального разбора в контрактный . +/// +public static class AiCardMapper +{ + /// + /// Строит разбор карточки из локальных полей и исходного текста. + /// + /// Локальные поля (заголовок/суть/стек/бюджет/контакты/признак найма). + /// Исходный текст сообщения (fallback-источник кандидатов контактов). + /// Контрактный разбор: бюджет нормализован, контакты квалифицированы, board=null, is_vacancy_known=false. + public static AiParsedCardDto FromLocal(LocalParsedFields fields, string text) + { + CardBudgetDto? normalized = BudgetNormalizer.Normalize(fields.Budget); + AiBudgetDto? budget = normalized is null ? null : new AiBudgetDto(normalized.From, normalized.To, normalized.Cur); + + IReadOnlyList qualified = ContactsQualifier.Build(fields.Contacts, text); + var contacts = new List(qualified.Count); + foreach (CardContactDto contact in qualified) + { + contacts.Add(new AiContactDto(contact.Type, contact.Value)); + } + + return new AiParsedCardDto( + Title: fields.Title, + Company: null, + Format: null, + Task: null, + Requirements: null, + Plus: null, + Conditions: null, + Summary: fields.Summary, + Stack: fields.Stack, + Budget: budget, + Contacts: contacts, + IsVacancy: fields.IsVacancy, + IsVacancyKnown: false, // маркерная гипотеза — тип подтверждает только ИИ по контексту (python L796) + IsSpam: false, + Board: null); // смысловые колонки до ИИ не назначаем (python L797) + } +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/AiClassifyContextBuilder.cs b/src/core/Deal.Modules.Pipeline/Application/Services/AiClassifyContextBuilder.cs index a5b7489..4888aea 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/AiClassifyContextBuilder.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/AiClassifyContextBuilder.cs @@ -1,157 +1,157 @@ -using System.Text; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.ColumnRules; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Services; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Сборка контекста ИИ-классификации из настроек и данных тенанта. -/// -/// KV-настройки тенанта (промпты и «Сфера и ключи»). -/// Порт канбана: доски (non-suggested) и few-shot-примеры журнала CardMoves. -public sealed class AiClassifyContextBuilder(ISettingsStore settings, ICardStore kanjStore) -{ - /// - /// Лимит примеров разметки в контексте. - /// - public const int MaxLearningExamples = 8; - - /// - /// Лимит описания колонки в строке-описании. - /// - public const int MaxBoardDescriptionCodePoints = 160; - - /// - /// Лимит текста примера разметки. - /// - public const int MaxMarkupTextCodePoints = 500; - - /// - /// Лимит текста сообщения в контексте. - /// - public const int MaxMessageTextCodePoints = 5000; - - private const string NoBoardsLine = "- (колонок пока нет — верните board: null)"; - - private const string BoardsHeader = "Доски: "; - - private const string ExamplesHeader = "Примеры разметки пользователя:"; - - private const string MessageHeader = "Новое сообщение:"; - - /// - /// Заполненный промпт ИИ-фильтра - /// - /// Текст system-промпта фильтра для FilterRequest. - public async Task BuildFilterPromptAsync(CancellationToken ct) - { - TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); - return FillPrompt(SettingsKeys.AiFilterPrompt, SettingsDefaults.AiFilterPrompt, settingsSnapshot); - } - - /// - /// Заполненный system-промпт классификации - /// - /// Текст system_prompt ClassifyRequest. - public async Task BuildClassifySystemPromptAsync(CancellationToken ct) - { - // Промпты/«Сфера и ключи» — одним типизированным снимком (C30): один GetAllAsync вместо 6 GetAsync. - TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); - string prompt = FillPrompt(SettingsKeys.AiPrompt, SettingsDefaults.AiPrompt, settingsSnapshot); - string card = FillPrompt(SettingsKeys.CardPrompt, SettingsDefaults.CardPrompt, settingsSnapshot); - if (card.Length > 0) - { - prompt = prompt + "\n\n" + card; - } - - return prompt; - } - - /// - /// user-контекст классификации «Доски + примеры разметки + Сообщение». - /// - /// Текст сообщения (режется до ). - /// Текст user_context ClassifyRequest. - public async Task BuildClassifyUserContextAsync(string text, CancellationToken ct) - { - string boardMap = await BuildBoardMapAsync(ct); - IReadOnlyList examples = await kanjStore.GetAiMarkupExamplesAsync(MaxLearningExamples, ct); - - var context = new StringBuilder(); - context.Append(BoardsHeader).Append(boardMap).Append("\n\n"); - if (examples.Count > 0) - { - context.Append(ExamplesHeader).Append('\n'); - for (int index = 0; index < examples.Count; index++) - { - if (index > 0) - { - context.Append('\n'); - } - - context.Append("текст: ") - .Append(MessageTextCleaner.SliceCodePoints(examples[index].Text, MaxMarkupTextCodePoints)) - .Append("\n→ колонка: ") - .Append(examples[index].Board); - } - - context.Append("\n\n"); - } - - context.Append(MessageHeader).Append('\n') - .Append(MessageTextCleaner.SliceCodePoints(text, MaxMessageTextCodePoints)); - return context.ToString(); - } - - private async Task BuildBoardMapAsync(CancellationToken ct) - { - IReadOnlyList containers = await kanjStore.ListContainersAsync(ContainerSpaces.Dashboard, ct); - var lines = new List(); - foreach (ContainerDto container in containers) - { - if (container.Kind != ContainerKinds.Board || container.Suggested) - { - continue; // в классификации участвуют только принятые пользовательские колонки - } - - lines.Add(BuildBoardLine(container)); - } - - return lines.Count == 0 ? NoBoardsLine : string.Join("\n", lines); - } - - private static string BuildBoardLine(ContainerDto container) - { - string line = "- " + container.Id + ": " + container.Name; - if (ColumnRules.HasActiveRules(container.Rules)) - { - line += " (критерии: " + RulesDescriber.Describe(container.Rules) + ")"; - } - - string description = container.Description.Trim(); - if (description.Length > 0) - { - line += " — " + MessageTextCleaner.SliceCodePoints(description, MaxBoardDescriptionCodePoints); - } - - return line; - } - - private static string FillPrompt( - string key, - string defaultValue, - TenantSettingsSnapshot settingsSnapshot) - { - string prompt = settingsSnapshot.GetString(key, defaultValue); - string domain = settingsSnapshot.GetString( - SettingsKeys.DomainDescription, SettingsDefaults.DomainDescription); - IReadOnlyList keywords = settingsSnapshot.GetStringList( - SettingsKeys.DomainKeywords, SettingsDefaults.DomainKeywords); - return PromptFiller.Fill(prompt, domain, keywords); - } -} +using System.Text; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Сборка контекста ИИ-классификации из настроек и данных тенанта. +/// +/// KV-настройки тенанта (промпты и «Сфера и ключи»). +/// Порт канбана: доски (non-suggested) и few-shot-примеры журнала CardMoves. +public sealed class AiClassifyContextBuilder(ISettingsStore settings, ICardStore kanjStore) +{ + /// + /// Лимит примеров разметки в контексте. + /// + public const int MaxLearningExamples = 8; + + /// + /// Лимит описания колонки в строке-описании. + /// + public const int MaxBoardDescriptionCodePoints = 160; + + /// + /// Лимит текста примера разметки. + /// + public const int MaxMarkupTextCodePoints = 500; + + /// + /// Лимит текста сообщения в контексте. + /// + public const int MaxMessageTextCodePoints = 5000; + + private const string NoBoardsLine = "- (колонок пока нет — верните board: null)"; + + private const string BoardsHeader = "Доски: "; + + private const string ExamplesHeader = "Примеры разметки пользователя:"; + + private const string MessageHeader = "Новое сообщение:"; + + /// + /// Заполненный промпт ИИ-фильтра + /// + /// Текст system-промпта фильтра для FilterRequest. + public async Task BuildFilterPromptAsync(CancellationToken ct) + { + TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); + return FillPrompt(SettingsKeys.AiFilterPrompt, SettingsDefaults.AiFilterPrompt, settingsSnapshot); + } + + /// + /// Заполненный system-промпт классификации + /// + /// Текст system_prompt ClassifyRequest. + public async Task BuildClassifySystemPromptAsync(CancellationToken ct) + { + // Промпты/«Сфера и ключи» — одним типизированным снимком (C30): один GetAllAsync вместо 6 GetAsync. + TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); + string prompt = FillPrompt(SettingsKeys.AiPrompt, SettingsDefaults.AiPrompt, settingsSnapshot); + string card = FillPrompt(SettingsKeys.CardPrompt, SettingsDefaults.CardPrompt, settingsSnapshot); + if (card.Length > 0) + { + prompt = prompt + "\n\n" + card; + } + + return prompt; + } + + /// + /// user-контекст классификации «Доски + примеры разметки + Сообщение». + /// + /// Текст сообщения (режется до ). + /// Текст user_context ClassifyRequest. + public async Task BuildClassifyUserContextAsync(string text, CancellationToken ct) + { + string boardMap = await BuildBoardMapAsync(ct); + IReadOnlyList examples = await kanjStore.GetAiMarkupExamplesAsync(MaxLearningExamples, ct); + + var context = new StringBuilder(); + context.Append(BoardsHeader).Append(boardMap).Append("\n\n"); + if (examples.Count > 0) + { + context.Append(ExamplesHeader).Append('\n'); + for (int index = 0; index < examples.Count; index++) + { + if (index > 0) + { + context.Append('\n'); + } + + context.Append("текст: ") + .Append(MessageTextCleaner.SliceCodePoints(examples[index].Text, MaxMarkupTextCodePoints)) + .Append("\n→ колонка: ") + .Append(examples[index].Board); + } + + context.Append("\n\n"); + } + + context.Append(MessageHeader).Append('\n') + .Append(MessageTextCleaner.SliceCodePoints(text, MaxMessageTextCodePoints)); + return context.ToString(); + } + + private async Task BuildBoardMapAsync(CancellationToken ct) + { + IReadOnlyList containers = await kanjStore.ListContainersAsync(ContainerSpaces.Dashboard, ct); + var lines = new List(); + foreach (ContainerDto container in containers) + { + if (container.Kind != ContainerKinds.Board || container.Suggested) + { + continue; // в классификации участвуют только принятые пользовательские колонки + } + + lines.Add(BuildBoardLine(container)); + } + + return lines.Count == 0 ? NoBoardsLine : string.Join("\n", lines); + } + + private static string BuildBoardLine(ContainerDto container) + { + string line = "- " + container.Id + ": " + container.Name; + if (ColumnRules.HasActiveRules(container.Rules)) + { + line += " (критерии: " + RulesDescriber.Describe(container.Rules) + ")"; + } + + string description = container.Description.Trim(); + if (description.Length > 0) + { + line += " — " + MessageTextCleaner.SliceCodePoints(description, MaxBoardDescriptionCodePoints); + } + + return line; + } + + private static string FillPrompt( + string key, + string defaultValue, + TenantSettingsSnapshot settingsSnapshot) + { + string prompt = settingsSnapshot.GetString(key, defaultValue); + string domain = settingsSnapshot.GetString( + SettingsKeys.DomainDescription, SettingsDefaults.DomainDescription); + IReadOnlyList keywords = settingsSnapshot.GetStringList( + SettingsKeys.DomainKeywords, SettingsDefaults.DomainKeywords); + return PromptFiller.Fill(prompt, domain, keywords); + } +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/AiRawCardMapper.cs b/src/core/Deal.Modules.Pipeline/Application/Services/AiRawCardMapper.cs index e4760e6..ff1f111 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/AiRawCardMapper.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/AiRawCardMapper.cs @@ -1,293 +1,293 @@ -using System.Globalization; -using System.Text.Json; -using System.Text.Json.Nodes; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Parse; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Строгий маппинг JSON-ответа ИИ-классификатора в контрактный разбор карточки. -/// -public static class AiRawCardMapper -{ - private const int MaxTitleCodePoints = 140; - - private const int MaxFallbackTextCodePoints = 140; - - private const string BudgetAmountPattern = @"[0-9.,]+\s*[кkКK]?"; - - private const string CurrencyFieldName = "currency"; - - private const string CurrencyFieldNameShort = "cur"; - - // Регулярное выражение числа бюджета: число + необязательные «к»/валюта-суффиксы. - private static readonly System.Text.RegularExpressions.Regex BudgetNumRe = - new(@"\A" + BudgetAmountPattern + @"(?:руб|р|₽)?\z", System.Text.RegularExpressions.RegexOptions.CultureInvariant); - - /// - /// Маппит JSON-ответ модели в контрактный разбор карточки. - /// - /// Сырой JSON-ответ модели (ClassifyReply.Json; типовую схему задаёт промпт). - /// Исходный текст сообщения. - /// Разбор: структурированный блок «О заявке»/суть, стек, бюджет, контакты, тип/спам/доска. - /// Ответ не объект/не разбирается — «ИИ не дал разбора» (ветка aiFail воркера). - public static AiParsedCardDto Map(string json, string text) - { - JsonObject? root = JsonNode.Parse(json) as JsonObject - ?? throw new JsonException("Ответ ИИ-классификатора — не JSON-объект (ok=true, но схема нарушена)."); - - string title = MessageTextCleaner.CleanShort(ReadRawString(root, "title"), MaxTitleCodePoints); - if (title.Length == 0) - { - title = MessageTextCleaner.CleanShort(text, MaxFallbackTextCodePoints); // python L455 fallback - } - - IReadOnlyList stack = ReadStack(root); - AiBudgetDto? budget = ReadBudget(root); - IReadOnlyList contacts = ReadContacts(root, text); - - string? board = ReadRawString(root, "board").Trim(); - if (board.Length == 0) - { - board = null; - } - - return new AiParsedCardDto( - Title: title, - Company: ReadNullableString(root, "company"), - Format: ReadNullableString(root, "format"), - Task: ReadNullableString(root, "task"), - Requirements: ReadItems(root, "requirements"), - Plus: ReadItems(root, "plus"), - Conditions: ReadNullableString(root, "conditions"), - Summary: ReadNullableString(root, "summary"), - Stack: stack, - Budget: budget, - Contacts: contacts, - IsVacancy: ReadBool(root, "is_vacancy"), - IsVacancyKnown: ReadBool(root, "is_vacancy_known"), // false — воркер стемпит true после успешного ИИ - IsSpam: ReadBool(root, "is_spam"), - Board: board); - } - - private static string ReadRawString(JsonObject root, string field) - { - if (!root.TryGetPropertyValue(field, out JsonNode? node) || node is null) - { - return string.Empty; - } - - return node is JsonValue value ? ValueToString(value) : string.Empty; - } - - private static string? ReadNullableString(JsonObject root, string field) - { - string value = ReadRawString(root, field); - return value.Length == 0 ? null : value; - } - - private static bool ReadBool(JsonObject root, string field) - { - if (!root.TryGetPropertyValue(field, out JsonNode? node) || node is null) - { - return false; - } - - if (node is not JsonValue value) - { - return false; - } - - if (value.TryGetValue(out bool flag)) - { - return flag; - } - - if (value.TryGetValue(out int number)) - { - return number != 0; - } - - if (value.TryGetValue(out string? text)) - { - string lower = (text ?? string.Empty).Trim().ToLowerInvariant(); - return lower.Length > 0 && lower is not ("0" or "false" or "no" or "нет" or "null" or "none"); - } - - return false; - } - - private static IReadOnlyList? ReadItems(JsonObject root, string field) - { - if (!root.TryGetPropertyValue(field, out JsonNode? node) || node is null) - { - return null; - } - - IReadOnlyList normalized = node is JsonValue scalarValue - ? MessageListNormalizer.NormalizeList(NodeToString(scalarValue)) // python: строка разбивается по [;|\n] - : node is JsonArray array - ? MessageListNormalizer.NormalizeList(array.Select(NodeToString)) - : Array.Empty(); - var result = new List(normalized.Count); - foreach (string item in normalized) - { - string cleaned = MessageTextCleaner.CleanShort(item); - if (cleaned.Length > 0) - { - result.Add(cleaned); - } - } - - return result; - } - - private static IReadOnlyList ReadStack(JsonObject root) - { - if (!root.TryGetPropertyValue("stack", out JsonNode? node) || node is null) - { - return Array.Empty(); - } - - return node is JsonValue value - ? MessageListNormalizer.NormalizeStack(ValueToString(value)) - : node is JsonArray array - ? MessageListNormalizer.NormalizeStack(array.Select(NodeToString)) - : Array.Empty(); - } - - private static AiBudgetDto? ReadBudget(JsonObject root) - { - if (!root.TryGetPropertyValue("budget", out JsonNode? node) || node is not JsonObject budget) - { - return null; - } - - string currency = ReadRawString(budget, CurrencyFieldName); - if (currency.Length == 0) - { - currency = ReadRawString(budget, CurrencyFieldNameShort); // ai.py L327: currency или cur - } - - double? from = ParseBudgetBound(budget, "from"); - double? to = ParseBudgetBound(budget, "to"); - CardBudgetDto? normalized = BudgetNormalizer.Normalize(new BudgetRangeDto(from, to, currency)); - return normalized is null - ? null - : new AiBudgetDto(normalized.From, normalized.To, normalized.Cur); - } - - private static double? ParseBudgetBound(JsonObject budget, string field) - { - if (!budget.TryGetPropertyValue(field, out JsonNode? node) || node is null) - { - return null; - } - - if (node is JsonValue value && value.TryGetValue(out double direct)) - { - return direct == 0 ? null : direct; // python L313: x == 0 → None - } - - string text = ReadRawString(budget, field).Replace("\u00a0", string.Empty).Replace(" ", string.Empty).ToLowerInvariant(); - if (text.Length == 0) - { - return null; - } - - double multiplier = 1.0; - if (text.EndsWith('к') || text.EndsWith('k')) - { - multiplier = 1000.0; - text = text[..^1]; - } - - if (text.EndsWith("руб", StringComparison.Ordinal)) - { - text = text[..^3]; - } - else if (text.EndsWith('р') || text.EndsWith('₽')) - { - text = text[..^1]; - } - - if (!BudgetNumRe.IsMatch(text) || !double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double amount)) - { - return null; - } - - double result = amount * multiplier; - return result == 0 ? null : result; - } - - private static IReadOnlyList ReadContacts(JsonObject root, string text) - { - List candidates = new(); - if (root.TryGetPropertyValue("contacts", out JsonNode? node) && node is not null) - { - if (node is JsonValue) - { - candidates.Add(ValueToString((JsonValue)node)); // строка: разобьётся разделителями ниже - } - else if (node is JsonArray array) - { - foreach (JsonNode? item in array) - { - switch (item) - { - case null: - continue; - case JsonValue value: - candidates.Add(ValueToString(value)); - break; - case JsonObject contactObject: - { - string value = ReadRawString(contactObject, "value"); // python L402–403: dict.get("value") - if (value.Length > 0) - { - candidates.Add(value); - } - - break; - } - } - } - } - else if (candidates.Count == 0) - { - return Array.Empty(); - } - } - - IReadOnlyList qualified = ContactsQualifier.Build( - candidates.Count > 0 ? candidates : null, text); - var result = new List(qualified.Count); - foreach (CardContactDto contact in qualified) - { - result.Add(new AiContactDto(contact.Type, contact.Value)); - } - - return result; - } - - private static string NodeToString(JsonNode? node) - => node is JsonValue value ? ValueToString(value) : string.Empty; - - private static string ValueToString(JsonValue value) - { - if (value.TryGetValue(out string? text)) - { - return text ?? string.Empty; - } - - if (value.TryGetValue(out bool flag)) - { - return flag ? "true" : "false"; - } - - return value.ToJsonString().Trim('"'); - } -} +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Parse; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Строгий маппинг JSON-ответа ИИ-классификатора в контрактный разбор карточки. +/// +public static class AiRawCardMapper +{ + private const int MaxTitleCodePoints = 140; + + private const int MaxFallbackTextCodePoints = 140; + + private const string BudgetAmountPattern = @"[0-9.,]+\s*[кkКK]?"; + + private const string CurrencyFieldName = "currency"; + + private const string CurrencyFieldNameShort = "cur"; + + // Регулярное выражение числа бюджета: число + необязательные «к»/валюта-суффиксы. + private static readonly System.Text.RegularExpressions.Regex BudgetNumRe = + new(@"\A" + BudgetAmountPattern + @"(?:руб|р|₽)?\z", System.Text.RegularExpressions.RegexOptions.CultureInvariant); + + /// + /// Маппит JSON-ответ модели в контрактный разбор карточки. + /// + /// Сырой JSON-ответ модели (ClassifyReply.Json; типовую схему задаёт промпт). + /// Исходный текст сообщения. + /// Разбор: структурированный блок «О заявке»/суть, стек, бюджет, контакты, тип/спам/доска. + /// Ответ не объект/не разбирается — «ИИ не дал разбора» (ветка aiFail воркера). + public static AiParsedCardDto Map(string json, string text) + { + JsonObject? root = JsonNode.Parse(json) as JsonObject + ?? throw new JsonException("Ответ ИИ-классификатора — не JSON-объект (ok=true, но схема нарушена)."); + + string title = MessageTextCleaner.CleanShort(ReadRawString(root, "title"), MaxTitleCodePoints); + if (title.Length == 0) + { + title = MessageTextCleaner.CleanShort(text, MaxFallbackTextCodePoints); // python L455 fallback + } + + IReadOnlyList stack = ReadStack(root); + AiBudgetDto? budget = ReadBudget(root); + IReadOnlyList contacts = ReadContacts(root, text); + + string? board = ReadRawString(root, "board").Trim(); + if (board.Length == 0) + { + board = null; + } + + return new AiParsedCardDto( + Title: title, + Company: ReadNullableString(root, "company"), + Format: ReadNullableString(root, "format"), + Task: ReadNullableString(root, "task"), + Requirements: ReadItems(root, "requirements"), + Plus: ReadItems(root, "plus"), + Conditions: ReadNullableString(root, "conditions"), + Summary: ReadNullableString(root, "summary"), + Stack: stack, + Budget: budget, + Contacts: contacts, + IsVacancy: ReadBool(root, "is_vacancy"), + IsVacancyKnown: ReadBool(root, "is_vacancy_known"), // false — воркер стемпит true после успешного ИИ + IsSpam: ReadBool(root, "is_spam"), + Board: board); + } + + private static string ReadRawString(JsonObject root, string field) + { + if (!root.TryGetPropertyValue(field, out JsonNode? node) || node is null) + { + return string.Empty; + } + + return node is JsonValue value ? ValueToString(value) : string.Empty; + } + + private static string? ReadNullableString(JsonObject root, string field) + { + string value = ReadRawString(root, field); + return value.Length == 0 ? null : value; + } + + private static bool ReadBool(JsonObject root, string field) + { + if (!root.TryGetPropertyValue(field, out JsonNode? node) || node is null) + { + return false; + } + + if (node is not JsonValue value) + { + return false; + } + + if (value.TryGetValue(out bool flag)) + { + return flag; + } + + if (value.TryGetValue(out int number)) + { + return number != 0; + } + + if (value.TryGetValue(out string? text)) + { + string lower = (text ?? string.Empty).Trim().ToLowerInvariant(); + return lower.Length > 0 && lower is not ("0" or "false" or "no" or "нет" or "null" or "none"); + } + + return false; + } + + private static IReadOnlyList? ReadItems(JsonObject root, string field) + { + if (!root.TryGetPropertyValue(field, out JsonNode? node) || node is null) + { + return null; + } + + IReadOnlyList normalized = node is JsonValue scalarValue + ? MessageListNormalizer.NormalizeList(NodeToString(scalarValue)) // python: строка разбивается по [;|\n] + : node is JsonArray array + ? MessageListNormalizer.NormalizeList(array.Select(NodeToString)) + : Array.Empty(); + var result = new List(normalized.Count); + foreach (string item in normalized) + { + string cleaned = MessageTextCleaner.CleanShort(item); + if (cleaned.Length > 0) + { + result.Add(cleaned); + } + } + + return result; + } + + private static IReadOnlyList ReadStack(JsonObject root) + { + if (!root.TryGetPropertyValue("stack", out JsonNode? node) || node is null) + { + return Array.Empty(); + } + + return node is JsonValue value + ? MessageListNormalizer.NormalizeStack(ValueToString(value)) + : node is JsonArray array + ? MessageListNormalizer.NormalizeStack(array.Select(NodeToString)) + : Array.Empty(); + } + + private static AiBudgetDto? ReadBudget(JsonObject root) + { + if (!root.TryGetPropertyValue("budget", out JsonNode? node) || node is not JsonObject budget) + { + return null; + } + + string currency = ReadRawString(budget, CurrencyFieldName); + if (currency.Length == 0) + { + currency = ReadRawString(budget, CurrencyFieldNameShort); // ai.py L327: currency или cur + } + + double? from = ParseBudgetBound(budget, "from"); + double? to = ParseBudgetBound(budget, "to"); + CardBudgetDto? normalized = BudgetNormalizer.Normalize(new BudgetRangeDto(from, to, currency)); + return normalized is null + ? null + : new AiBudgetDto(normalized.From, normalized.To, normalized.Cur); + } + + private static double? ParseBudgetBound(JsonObject budget, string field) + { + if (!budget.TryGetPropertyValue(field, out JsonNode? node) || node is null) + { + return null; + } + + if (node is JsonValue value && value.TryGetValue(out double direct)) + { + return direct == 0 ? null : direct; // python L313: x == 0 → None + } + + string text = ReadRawString(budget, field).Replace("\u00a0", string.Empty).Replace(" ", string.Empty).ToLowerInvariant(); + if (text.Length == 0) + { + return null; + } + + double multiplier = 1.0; + if (text.EndsWith('к') || text.EndsWith('k')) + { + multiplier = 1000.0; + text = text[..^1]; + } + + if (text.EndsWith("руб", StringComparison.Ordinal)) + { + text = text[..^3]; + } + else if (text.EndsWith('р') || text.EndsWith('₽')) + { + text = text[..^1]; + } + + if (!BudgetNumRe.IsMatch(text) || !double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double amount)) + { + return null; + } + + double result = amount * multiplier; + return result == 0 ? null : result; + } + + private static IReadOnlyList ReadContacts(JsonObject root, string text) + { + List candidates = new(); + if (root.TryGetPropertyValue("contacts", out JsonNode? node) && node is not null) + { + if (node is JsonValue) + { + candidates.Add(ValueToString((JsonValue)node)); // строка: разобьётся разделителями ниже + } + else if (node is JsonArray array) + { + foreach (JsonNode? item in array) + { + switch (item) + { + case null: + continue; + case JsonValue value: + candidates.Add(ValueToString(value)); + break; + case JsonObject contactObject: + { + string value = ReadRawString(contactObject, "value"); // python L402–403: dict.get("value") + if (value.Length > 0) + { + candidates.Add(value); + } + + break; + } + } + } + } + else if (candidates.Count == 0) + { + return Array.Empty(); + } + } + + IReadOnlyList qualified = ContactsQualifier.Build( + candidates.Count > 0 ? candidates : null, text); + var result = new List(qualified.Count); + foreach (CardContactDto contact in qualified) + { + result.Add(new AiContactDto(contact.Type, contact.Value)); + } + + return result; + } + + private static string NodeToString(JsonNode? node) + => node is JsonValue value ? ValueToString(value) : string.Empty; + + private static string ValueToString(JsonValue value) + { + if (value.TryGetValue(out string? text)) + { + return text ?? string.Empty; + } + + if (value.TryGetValue(out bool flag)) + { + return flag ? "true" : "false"; + } + + return value.ToJsonString().Trim('"'); + } +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/CardComposer.cs b/src/core/Deal.Modules.Pipeline/Application/Services/CardComposer.cs index aff2787..58297ef 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/CardComposer.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/CardComposer.cs @@ -1,160 +1,160 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.ColumnRules; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Services; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Сборка снимка карточки из разобранного сообщения для записи через Kanban. -/// -public sealed class CardComposer(ICardStore kanjStore, ISettingsStore settings) -{ - private const int MaxTitleCodePoints = 140; - - private const int MaxSummaryCodePoints = 2000; - - private const int MaxSourceMsgCodePoints = 4000; - - private const int MaxPrimaryContactCodePoints = 200; - - /// - /// Собирает полный снимок новой карточки из разбора и строки сообщения. - /// - /// Разбор сообщения (ИИ-классификатор или локальный путь; контакты квалифицированы). - /// Строка очереди с сообщением-источником (метаданные канала, текст, время). - /// Готовый id карточки (c_...; генерирует PipelineCardWriter через PrefixId). - /// Полный снимок карточки для (CreatedAt проставит хранилище). - public async Task BuildAsync( - AiParsedCardDto parsed, - QueueItemDto message, - string cardId, - CancellationToken ct) - { - // «О заявке» всегда собирается из одинаковых блоков (Компания → … → Условия); структуры нет — суть - // как есть либо «О задаче: …» из исходника (SummaryComposer.Compose), сверху clean_block 2000. - ParsedCardContent content = ToParsedContent(parsed); - string summary = MessageTextCleaner.CleanBlock(SummaryComposer.Compose(content, message.Text), MaxSummaryCodePoints); - if (summary.Length == 0) - { - summary = MessageTextCleaner.CleanShort(message.Text, MaxSummaryCodePoints); // python L458 fallback - } - - string title = MessageTextCleaner.CleanShort(parsed.Title, MaxTitleCodePoints); - if (title.Length == 0) - { - title = MessageTextCleaner.CleanShort(message.Text, MaxTitleCodePoints); // python L455 fallback - } - - IReadOnlyList stack = MessageListNormalizer.NormalizeStack(parsed.Stack); - CardBudgetDto? budget = ComposeBudget(parsed.Budget, message.Text, summary); - - // Доска разбора (страховка ContainerAccepts) и конверсия бюджета требуют курсы: типизированный снимок - // настроек читается ОДИН раз на карточку (C30) — мок-фолбэк при отсутствии кэша, как раньше LoadRatesAsync. - string? boardCandidate = string.IsNullOrWhiteSpace(parsed.Board) ? null : parsed.Board.Trim(); - TenantSettingsSnapshot? settingsSnapshot = budget is not null || boardCandidate is not null - ? await TenantSettingsSnapshot.LoadAsync(settings, ct) - : null; - IReadOnlyDictionary? rates = null; - if (settingsSnapshot is not null) - { - rates = settingsSnapshot.TryGetRatesCache()?.Rates ?? MockRates.Values; - } - - CardBudgetDto? converted = null; - if (budget is not null) - { - bool conversionOn = settingsSnapshot!.GetBool(SettingsKeys.ConversionOn, SettingsDefaults.ConversionOn); - string targetCurrency = NormalizeTargetCurrency( - settingsSnapshot.GetString(SettingsKeys.TargetCurrency, SettingsDefaults.TargetCurrency)); - converted = BudgetNormalizer.ToTarget(budget, conversionOn, targetCurrency, rates); - } - - string col = CardIds.Inbox; - IReadOnlyList matchHits = Array.Empty(); - if (boardCandidate is not null) - { - ContainerDto? board = await kanjStore.GetContainerAsync(boardCandidate, ct); - if (board is not null && ColumnRules.ContainerAccepts(board.Rules, message.Text, rates)) - { - col = board.Id; - matchHits = ColumnRules.ComputeHits(board.Rules, message.Text, rates); - } - } - - IReadOnlyList contacts = ContactsQualifier.Build( - parsed.Contacts.Select(contact => contact.Value).ToList(), message.Text); - string contact = MessageTextCleaner.SliceCodePoints(ContactsQualifier.Primary(contacts), MaxPrimaryContactCodePoints); - DateTimeOffset receivedAt = message.MsgAtMs != 0 - ? DateTimeOffset.FromUnixTimeMilliseconds(message.MsgAtMs) - : message.Source.ReceivedAt != default - ? message.Source.ReceivedAt - : DateTimeOffset.UtcNow; - - return new CardSnapshot - { - Id = cardId, - Col = col, - IsNew = true, // создание — точка «новое» (Ruling 2) - IsVacancy = parsed.IsVacancy, - IsVacancyKnown = parsed.IsVacancyKnown, - Title = title, - Summary = summary, - Stack = stack, - BudgetFrom = budget?.From, - BudgetTo = budget?.To, - BudgetCur = budget?.Cur ?? string.Empty, - ConvFrom = converted?.From, - ConvTo = converted?.To, - ConvCur = converted?.Cur ?? string.Empty, - Contact = contact, - Contacts = contacts, - Source = message.Source, - Content = message.Content with { Text = MessageTextCleaner.SliceCodePoints(message.Text, MaxSourceMsgCodePoints) }, - ReceivedAt = receivedAt, - PrevCol = CardIds.Inbox, - ArchivedAt = null, - MatchHits = matchHits, - }; - } - - private static CardBudgetDto? ComposeBudget( - AiBudgetDto? parsedBudget, - string? text, - string summary) - { - if (parsedBudget is not null) - { - return BudgetNormalizer.Normalize(new BudgetRangeDto(parsedBudget.From, parsedBudget.To, parsedBudget.Cur)); - } - - BudgetRangeDto? fallback = AmountRangeBudgetFallback.Extract(text, summary); - return fallback is null ? null : BudgetNormalizer.Normalize(fallback); - } - - private static ParsedCardContent ToParsedContent(AiParsedCardDto parsed) => new( - Company: parsed.Company, - Format: parsed.Format, - Task: parsed.Task, - Requirements: parsed.Requirements, - Plus: parsed.Plus, - Conditions: parsed.Conditions, - Summary: parsed.Summary); - - // Код целевой валюты для конверсии: trim + верхний регистр; пусто → дефолт RUB (как писал PATCH). - // value: Значение настройки targetCurrency (JSON-строка). - // Возвращает: Код валюты (RUB/USD/…) либо дефолт. - private static string NormalizeTargetCurrency(string value) - { - string currency = value.Trim().ToUpperInvariant(); - return currency.Length > 0 ? currency : SettingsDefaults.TargetCurrency; - } -} +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Сборка снимка карточки из разобранного сообщения для записи через Kanban. +/// +public sealed class CardComposer(ICardStore kanjStore, ISettingsStore settings) +{ + private const int MaxTitleCodePoints = 140; + + private const int MaxSummaryCodePoints = 2000; + + private const int MaxSourceMsgCodePoints = 4000; + + private const int MaxPrimaryContactCodePoints = 200; + + /// + /// Собирает полный снимок новой карточки из разбора и строки сообщения. + /// + /// Разбор сообщения (ИИ-классификатор или локальный путь; контакты квалифицированы). + /// Строка очереди с сообщением-источником (метаданные канала, текст, время). + /// Готовый id карточки (c_...; генерирует PipelineCardWriter через PrefixId). + /// Полный снимок карточки для (CreatedAt проставит хранилище). + public async Task BuildAsync( + AiParsedCardDto parsed, + QueueItemDto message, + string cardId, + CancellationToken ct) + { + // «О заявке» всегда собирается из одинаковых блоков (Компания → … → Условия); структуры нет — суть + // как есть либо «О задаче: …» из исходника (SummaryComposer.Compose), сверху clean_block 2000. + ParsedCardContent content = ToParsedContent(parsed); + string summary = MessageTextCleaner.CleanBlock(SummaryComposer.Compose(content, message.Text), MaxSummaryCodePoints); + if (summary.Length == 0) + { + summary = MessageTextCleaner.CleanShort(message.Text, MaxSummaryCodePoints); // python L458 fallback + } + + string title = MessageTextCleaner.CleanShort(parsed.Title, MaxTitleCodePoints); + if (title.Length == 0) + { + title = MessageTextCleaner.CleanShort(message.Text, MaxTitleCodePoints); // python L455 fallback + } + + IReadOnlyList stack = MessageListNormalizer.NormalizeStack(parsed.Stack); + CardBudgetDto? budget = ComposeBudget(parsed.Budget, message.Text, summary); + + // Доска разбора (страховка ContainerAccepts) и конверсия бюджета требуют курсы: типизированный снимок + // настроек читается ОДИН раз на карточку (C30) — мок-фолбэк при отсутствии кэша, как раньше LoadRatesAsync. + string? boardCandidate = string.IsNullOrWhiteSpace(parsed.Board) ? null : parsed.Board.Trim(); + TenantSettingsSnapshot? settingsSnapshot = budget is not null || boardCandidate is not null + ? await TenantSettingsSnapshot.LoadAsync(settings, ct) + : null; + IReadOnlyDictionary? rates = null; + if (settingsSnapshot is not null) + { + rates = settingsSnapshot.TryGetRatesCache()?.Rates ?? MockRates.Values; + } + + CardBudgetDto? converted = null; + if (budget is not null) + { + bool conversionOn = settingsSnapshot!.GetBool(SettingsKeys.ConversionOn, SettingsDefaults.ConversionOn); + string targetCurrency = NormalizeTargetCurrency( + settingsSnapshot.GetString(SettingsKeys.TargetCurrency, SettingsDefaults.TargetCurrency)); + converted = BudgetNormalizer.ToTarget(budget, conversionOn, targetCurrency, rates); + } + + string col = CardIds.Inbox; + IReadOnlyList matchHits = Array.Empty(); + if (boardCandidate is not null) + { + ContainerDto? board = await kanjStore.GetContainerAsync(boardCandidate, ct); + if (board is not null && ColumnRules.ContainerAccepts(board.Rules, message.Text, rates)) + { + col = board.Id; + matchHits = ColumnRules.ComputeHits(board.Rules, message.Text, rates); + } + } + + IReadOnlyList contacts = ContactsQualifier.Build( + parsed.Contacts.Select(contact => contact.Value).ToList(), message.Text); + string contact = MessageTextCleaner.SliceCodePoints(ContactsQualifier.Primary(contacts), MaxPrimaryContactCodePoints); + DateTimeOffset receivedAt = message.MsgAtMs != 0 + ? DateTimeOffset.FromUnixTimeMilliseconds(message.MsgAtMs) + : message.Source.ReceivedAt != default + ? message.Source.ReceivedAt + : DateTimeOffset.UtcNow; + + return new CardSnapshot + { + Id = cardId, + Col = col, + IsNew = true, // создание — точка «новое» (Ruling 2) + IsVacancy = parsed.IsVacancy, + IsVacancyKnown = parsed.IsVacancyKnown, + Title = title, + Summary = summary, + Stack = stack, + BudgetFrom = budget?.From, + BudgetTo = budget?.To, + BudgetCur = budget?.Cur ?? string.Empty, + ConvFrom = converted?.From, + ConvTo = converted?.To, + ConvCur = converted?.Cur ?? string.Empty, + Contact = contact, + Contacts = contacts, + Source = message.Source, + Content = message.Content with { Text = MessageTextCleaner.SliceCodePoints(message.Text, MaxSourceMsgCodePoints) }, + ReceivedAt = receivedAt, + PrevCol = CardIds.Inbox, + ArchivedAt = null, + MatchHits = matchHits, + }; + } + + private static CardBudgetDto? ComposeBudget( + AiBudgetDto? parsedBudget, + string? text, + string summary) + { + if (parsedBudget is not null) + { + return BudgetNormalizer.Normalize(new BudgetRangeDto(parsedBudget.From, parsedBudget.To, parsedBudget.Cur)); + } + + BudgetRangeDto? fallback = AmountRangeBudgetFallback.Extract(text, summary); + return fallback is null ? null : BudgetNormalizer.Normalize(fallback); + } + + private static ParsedCardContent ToParsedContent(AiParsedCardDto parsed) => new( + Company: parsed.Company, + Format: parsed.Format, + Task: parsed.Task, + Requirements: parsed.Requirements, + Plus: parsed.Plus, + Conditions: parsed.Conditions, + Summary: parsed.Summary); + + // Код целевой валюты для конверсии: trim + верхний регистр; пусто → дефолт RUB (как писал PATCH). + // value: Значение настройки targetCurrency (JSON-строка). + // Возвращает: Код валюты (RUB/USD/…) либо дефолт. + private static string NormalizeTargetCurrency(string value) + { + string currency = value.Trim().ToUpperInvariant(); + return currency.Length > 0 ? currency : SettingsDefaults.TargetCurrency; + } +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs b/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs index ad864dd..0c245c4 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs @@ -1,368 +1,368 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Ручная переклассификация карточек -/// -/// Единый порт хранилища карточек (чтение inbox, обновление полей классификации). -/// KV-хранилище настроек тенанта (выключатели aiEnabled/aiFilterEnabled, маркеры парсера). -/// Порт ИИ: фильтр и классификация (в Local-режиме — детерминированный). -/// Локальный структуратор (путь без ИИ / сбой классификатора). -/// Сборка контента карточки (заголовок/суть/стек/бюджет/контакты/колонка). -/// Доменные операции карточки (перенос в корзину без дублей логики). -/// Клиент ML: обучающие сигналы переклассификации (вес гипотезы ИИ). -/// Single-flight-замок переклассификации (одна за раз). -public sealed class CardReclassifier( - ICardStore store, - ISettingsStore settings, - IAiClassifier aiClassifier, - LocalFieldsParser fieldsParser, - CardComposer composer, - CardsService cardsService, - IMlClient mlClient, - ReclassifyGate gate) -{ - /// - /// Причина: в «Неразобранном» нет карточек для переклассификации - /// - public const string EmptyInboxReason = "В «Неразобранном» нет карточек для переклассификации"; - - /// - /// Причина: у карточки нет исходного текста - /// - public const string NoSourceTextReason = "У карточки нет исходного текста для переклассификации"; - - private static readonly AiFilterResultDto PassSkipped = new(Pass: true, Reason: null, Skipped: true); - - // Частота отчётов о прогрессе пакетного прохода (каждые N карточек + финальный отчёт). - private const int ProgressStep = 5; - - /// - /// Пакетная переклассификация «Неразобранного» - /// - /// Опциональный список id (null/пусто — все карточки inbox). - /// Токен отмены. - /// Наблюдатель прогресса (SSE); null — без отчётов. - /// Итог прохода (счётчики исхода) либо busy, если проход уже идёт. - public async Task ReclassifyInboxAsync( - IReadOnlyList? ids, - CancellationToken ct, - IProgress? progress = null) - { - if (!gate.TryEnter()) - { - return Busy(); - } - - try - { - Pass pass = await CreatePassAsync(ct); - IReadOnlyList inbox = await store.ListCardsAsync(new CardsQuery(CardIds.Inbox), ct); - List target = SelectTarget(inbox, ids); - if (target.Count == 0) - { - return Build(pass, attempted: 0, started: false, reason: EmptyInboxReason); - } - - progress?.Report(new ReclassifyProgressDto(0, target.Count, 0, 0, 0, 0)); - int done = 0; - foreach (CardDto card in target) - { - await ReclassifyOneAsync(card, pass, ct); - done++; - if (progress is not null && (done % ProgressStep == 0 || done == target.Count)) - { - progress.Report(new ReclassifyProgressDto(done, target.Count, pass.Moved, pass.Kept, pass.Trashed, pass.Skipped)); - } - } - - return Build(pass, attempted: target.Count, started: true, reason: null); - } - finally - { - gate.Exit(); - } - } - - /// - /// Переклассификация одной карточки - /// - /// Карточка (уже прочитана вызывающим — 404 остаётся за эндпоинтом). - /// Итог прохода либо busy, если проход уже идёт. - public async Task ReclassifyCardAsync(CardDto card, CancellationToken ct) - { - if (!gate.TryEnter()) - { - return Busy(); - } - - try - { - Pass pass = await CreatePassAsync(ct); - if (string.IsNullOrWhiteSpace(card.Content.Text)) - { - pass.Skipped++; - return Build(pass, attempted: 1, started: false, reason: NoSourceTextReason); - } - - await ReclassifyOneAsync(card, pass, ct); - return Build(pass, attempted: 1, started: true, reason: null); - } - finally - { - gate.Exit(); - } - } - - // ── Проход одной карточки ────────────────────────────────────────────── - - // Прогоняет одну карточку по конвейеру переклассификации (фильтр → разбор → сборка → запись → ML). - // Спам/непройденный фильтр отправляют карточку в корзину; остальные обновляются результатом разбора. - // card: Карточка. - // pass: Накопители прохода (настройки/счётчики/признак ИИ). - // ct: Токен отмены. - private async Task ReclassifyOneAsync( - CardDto card, - Pass pass, - CancellationToken ct) - { - string text = card.Content.Text ?? string.Empty; - if (string.IsNullOrWhiteSpace(text)) - { - pass.Skipped++; - return; - } - - AiParsedCardDto? parsed = null; - if (pass.AiEnabled) - { - AiFilterResultDto filter = pass.AiFilterEnabled ? await FilterSafelyAsync(text, ct) : PassSkipped; - if (!filter.Pass) - { - await TrashAsync(card, text, pass, ct); - return; - } - - try - { - parsed = await aiClassifier.ClassifyAsync(text, ct); - } - catch (Exception) - { - parsed = null; - } - - if (parsed is not null) - { - parsed = parsed with { IsVacancyKnown = true }; - pass.AiUsed = true; - } - } - - if (parsed is null) - { - parsed = AiCardMapper.FromLocal(fieldsParser.Parse(text, pass.Snapshot), text); - } - - if (parsed.IsSpam) - { - await TrashAsync(card, text, pass, ct); - return; - } - - CardSnapshot snapshot = await composer.BuildAsync(parsed, BuildMessage(card, text), card.Id, ct); - await store.ApplyReclassificationAsync( - new CardReclassificationDto( - CardId: card.Id, - Col: snapshot.Col, - IsNew: true, - IsVacancy: snapshot.IsVacancy, - IsVacancyKnown: snapshot.IsVacancyKnown, - Title: snapshot.Title, - Summary: snapshot.Summary, - Stack: snapshot.Stack, - Budget: ToBudget(snapshot.BudgetFrom, snapshot.BudgetTo, snapshot.BudgetCur), - Converted: ToBudget(snapshot.ConvFrom, snapshot.ConvTo, snapshot.ConvCur), - Contact: ResolveContact(card, snapshot.Contact), - Contacts: snapshot.Contacts, - MatchHits: snapshot.MatchHits), - ct); - - if (snapshot.Col == CardIds.Inbox) - { - pass.Kept++; - } - else - { - pass.Moved++; - } - - await AiCardLearning.PushSignalsAsync(store, mlClient, snapshot.Col, parsed, text, MlLearningLabels.AiPushWeight, ct); - } - - private async Task TrashAsync( - CardDto card, - string text, - Pass pass, - CancellationToken ct) - { - // teach=false: журнал action=trash пишется, но сигнал «спам» кладём явно ниже — с весом ИИ (0.4). - await cardsService.TrashCardAsync(card.Id, teach: false, ct); - await mlClient.PushAsync(text, MlLearningLabels.Spam, MlLearningLabels.AiPushWeight, ct); - pass.Trashed++; - } - - // ИИ-фильтр со сбоем-пропуском (недоступность фильтра не прерывает переклассификацию). - // text: Текст сообщения. - // ct: Токен отмены. - // Возвращает: Решение фильтра либо «пропуск» при сбое. - private async Task FilterSafelyAsync(string text, CancellationToken ct) - { - try - { - return await aiClassifier.FilterAsync(text, ct); - } - catch (Exception) - { - return PassSkipped; - } - } - - // ── Сборка входа/выхода ──────────────────────────────────────────────── - - // Собирает строку-сообщение для CardComposer из полей карточки (источник/время/исходный текст). - // card: Карточка-источник (источник и содержимое сообщения). - // text: Исходный текст записи источника. - // Возвращает: Строка очереди, эквивалентная исходному сообщению карточки. - private static QueueItemDto BuildMessage(CardDto card, string text) => new() - { - Source = card.Source, - Content = card.Content with { Text = text }, - Text = text, - MsgAtMs = card.ReceivedAtMs, - }; - - // Восстанавливает бюджет из полей снимка: пустая валюта — бюджета нет (null). - // from: Нижняя граница. - // to: Верхняя граница. - // cur: Валюта (пусто — нет). - // Возвращает: Бюджет карточки либо null. - private static CardBudgetDto? ToBudget( - double? from, - double? to, - string cur) => - cur.Length == 0 ? null : new CardBudgetDto(from, to, cur); - - private static string ResolveContact(CardDto card, string computed) - { - if (computed.Length > 0) - { - return computed; - } - - string oldContact = card.Contact.Trim(); - return oldContact.Length > 0 && ContactsQualifier.Qualify(oldContact) is not null ? oldContact : string.Empty; - } - - private static List SelectTarget(IReadOnlyList inbox, IReadOnlyList? ids) - { - if (ids is null || ids.Count == 0) - { - return [.. inbox]; - } - - var wanted = new HashSet(ids, StringComparer.Ordinal); - var target = new List(); - foreach (CardDto card in inbox) - { - if (wanted.Contains(card.Id)) - { - target.Add(card); - } - } - - return target; - } - - // Читает настройки прохода: снимок тенанта + выключатели ИИ/ИИ-фильтра. - // ct: Токен отмены. - // Возвращает: Накопители прохода с настройками. - private async Task CreatePassAsync(CancellationToken ct) - { - TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); - return new Pass( - snapshot, - snapshot.GetBool(SettingsKeys.AiEnabled, SettingsDefaults.AiEnabled), - snapshot.GetBool(SettingsKeys.AiFilterEnabled, SettingsDefaults.AiFilterEnabled)); - } - - // Ответ занятости: проход уже выполняется. - // Возвращает: Итог с busy = true. - private static ReclassifyResultDto Busy() => - new(Started: false, Busy: true, Attempted: 0, Reclassified: 0, Moved: 0, Kept: 0, Trashed: 0, Skipped: 0, UsedAi: false, Reason: null); - - // Собирает итог прохода из накопителей. - // pass: Накопители прохода. - // attempted: Сколько карточек отобрано. - // started: Проход выполнен. - // reason: Причина (если проход не выполнен) либо null. - // Возвращает: Итог с полями wire-контракта. - private static ReclassifyResultDto Build( - Pass pass, - int attempted, - bool started, - string? reason) => - new( - Started: started, - Busy: false, - Attempted: attempted, - Reclassified: pass.Moved + pass.Kept + pass.Trashed, - Moved: pass.Moved, - Kept: pass.Kept, - Trashed: pass.Trashed, - Skipped: pass.Skipped, - UsedAi: pass.AiUsed, - Reason: reason); - - // Накопители одного прохода переклассификации: настройки, счётчики исхода, признак ИИ. - // Snapshot: Снимок настроек тенанта (для локального парсера). - // AiEnabled: ИИ-слот включён (фильтр/классификация через порт). - // AiFilterEnabled: ИИ-фильтр включён. - private sealed record Pass(TenantSettingsSnapshot Snapshot, bool AiEnabled, bool AiFilterEnabled) - { - /// - /// Сколько карточек ушло в смысловую колонку. - /// - public int Moved { get; set; } - - /// - /// Сколько карточек осталось в «Неразобранном». - /// - public int Kept { get; set; } - - /// - /// Сколько карточек отправлено в корзину. - /// - public int Trashed { get; set; } - - /// - /// Сколько карточек пропущено - /// - public int Skipped { get; set; } - - /// - /// True — разбор хотя бы одной карточки выполнен через порт ИИ. - /// - public bool AiUsed { get; set; } - } -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Ручная переклассификация карточек +/// +/// Единый порт хранилища карточек (чтение inbox, обновление полей классификации). +/// KV-хранилище настроек тенанта (выключатели aiEnabled/aiFilterEnabled, маркеры парсера). +/// Порт ИИ: фильтр и классификация (в Local-режиме — детерминированный). +/// Локальный структуратор (путь без ИИ / сбой классификатора). +/// Сборка контента карточки (заголовок/суть/стек/бюджет/контакты/колонка). +/// Доменные операции карточки (перенос в корзину без дублей логики). +/// Клиент ML: обучающие сигналы переклассификации (вес гипотезы ИИ). +/// Single-flight-замок переклассификации (одна за раз). +public sealed class CardReclassifier( + ICardStore store, + ISettingsStore settings, + IAiClassifier aiClassifier, + LocalFieldsParser fieldsParser, + CardComposer composer, + CardsService cardsService, + IMlClient mlClient, + ReclassifyGate gate) +{ + /// + /// Причина: в «Неразобранном» нет карточек для переклассификации + /// + public const string EmptyInboxReason = "В «Неразобранном» нет карточек для переклассификации"; + + /// + /// Причина: у карточки нет исходного текста + /// + public const string NoSourceTextReason = "У карточки нет исходного текста для переклассификации"; + + private static readonly AiFilterResultDto PassSkipped = new(Pass: true, Reason: null, Skipped: true); + + // Частота отчётов о прогрессе пакетного прохода (каждые N карточек + финальный отчёт). + private const int ProgressStep = 5; + + /// + /// Пакетная переклассификация «Неразобранного» + /// + /// Опциональный список id (null/пусто — все карточки inbox). + /// Токен отмены. + /// Наблюдатель прогресса (SSE); null — без отчётов. + /// Итог прохода (счётчики исхода) либо busy, если проход уже идёт. + public async Task ReclassifyInboxAsync( + IReadOnlyList? ids, + CancellationToken ct, + IProgress? progress = null) + { + if (!gate.TryEnter()) + { + return Busy(); + } + + try + { + Pass pass = await CreatePassAsync(ct); + IReadOnlyList inbox = await store.ListCardsAsync(new CardsQuery(CardIds.Inbox), ct); + List target = SelectTarget(inbox, ids); + if (target.Count == 0) + { + return Build(pass, attempted: 0, started: false, reason: EmptyInboxReason); + } + + progress?.Report(new ReclassifyProgressDto(0, target.Count, 0, 0, 0, 0)); + int done = 0; + foreach (CardDto card in target) + { + await ReclassifyOneAsync(card, pass, ct); + done++; + if (progress is not null && (done % ProgressStep == 0 || done == target.Count)) + { + progress.Report(new ReclassifyProgressDto(done, target.Count, pass.Moved, pass.Kept, pass.Trashed, pass.Skipped)); + } + } + + return Build(pass, attempted: target.Count, started: true, reason: null); + } + finally + { + gate.Exit(); + } + } + + /// + /// Переклассификация одной карточки + /// + /// Карточка (уже прочитана вызывающим — 404 остаётся за эндпоинтом). + /// Итог прохода либо busy, если проход уже идёт. + public async Task ReclassifyCardAsync(CardDto card, CancellationToken ct) + { + if (!gate.TryEnter()) + { + return Busy(); + } + + try + { + Pass pass = await CreatePassAsync(ct); + if (string.IsNullOrWhiteSpace(card.Content.Text)) + { + pass.Skipped++; + return Build(pass, attempted: 1, started: false, reason: NoSourceTextReason); + } + + await ReclassifyOneAsync(card, pass, ct); + return Build(pass, attempted: 1, started: true, reason: null); + } + finally + { + gate.Exit(); + } + } + + // ── Проход одной карточки ────────────────────────────────────────────── + + // Прогоняет одну карточку по конвейеру переклассификации (фильтр → разбор → сборка → запись → ML). + // Спам/непройденный фильтр отправляют карточку в корзину; остальные обновляются результатом разбора. + // card: Карточка. + // pass: Накопители прохода (настройки/счётчики/признак ИИ). + // ct: Токен отмены. + private async Task ReclassifyOneAsync( + CardDto card, + Pass pass, + CancellationToken ct) + { + string text = card.Content.Text ?? string.Empty; + if (string.IsNullOrWhiteSpace(text)) + { + pass.Skipped++; + return; + } + + AiParsedCardDto? parsed = null; + if (pass.AiEnabled) + { + AiFilterResultDto filter = pass.AiFilterEnabled ? await FilterSafelyAsync(text, ct) : PassSkipped; + if (!filter.Pass) + { + await TrashAsync(card, text, pass, ct); + return; + } + + try + { + parsed = await aiClassifier.ClassifyAsync(text, ct); + } + catch (Exception) + { + parsed = null; + } + + if (parsed is not null) + { + parsed = parsed with { IsVacancyKnown = true }; + pass.AiUsed = true; + } + } + + if (parsed is null) + { + parsed = AiCardMapper.FromLocal(fieldsParser.Parse(text, pass.Snapshot), text); + } + + if (parsed.IsSpam) + { + await TrashAsync(card, text, pass, ct); + return; + } + + CardSnapshot snapshot = await composer.BuildAsync(parsed, BuildMessage(card, text), card.Id, ct); + await store.ApplyReclassificationAsync( + new CardReclassificationDto( + CardId: card.Id, + Col: snapshot.Col, + IsNew: true, + IsVacancy: snapshot.IsVacancy, + IsVacancyKnown: snapshot.IsVacancyKnown, + Title: snapshot.Title, + Summary: snapshot.Summary, + Stack: snapshot.Stack, + Budget: ToBudget(snapshot.BudgetFrom, snapshot.BudgetTo, snapshot.BudgetCur), + Converted: ToBudget(snapshot.ConvFrom, snapshot.ConvTo, snapshot.ConvCur), + Contact: ResolveContact(card, snapshot.Contact), + Contacts: snapshot.Contacts, + MatchHits: snapshot.MatchHits), + ct); + + if (snapshot.Col == CardIds.Inbox) + { + pass.Kept++; + } + else + { + pass.Moved++; + } + + await AiCardLearning.PushSignalsAsync(store, mlClient, snapshot.Col, parsed, text, MlLearningLabels.AiPushWeight, ct); + } + + private async Task TrashAsync( + CardDto card, + string text, + Pass pass, + CancellationToken ct) + { + // teach=false: журнал action=trash пишется, но сигнал «спам» кладём явно ниже — с весом ИИ (0.4). + await cardsService.TrashCardAsync(card.Id, teach: false, ct); + await mlClient.PushAsync(text, MlLearningLabels.Spam, MlLearningLabels.AiPushWeight, ct); + pass.Trashed++; + } + + // ИИ-фильтр со сбоем-пропуском (недоступность фильтра не прерывает переклассификацию). + // text: Текст сообщения. + // ct: Токен отмены. + // Возвращает: Решение фильтра либо «пропуск» при сбое. + private async Task FilterSafelyAsync(string text, CancellationToken ct) + { + try + { + return await aiClassifier.FilterAsync(text, ct); + } + catch (Exception) + { + return PassSkipped; + } + } + + // ── Сборка входа/выхода ──────────────────────────────────────────────── + + // Собирает строку-сообщение для CardComposer из полей карточки (источник/время/исходный текст). + // card: Карточка-источник (источник и содержимое сообщения). + // text: Исходный текст записи источника. + // Возвращает: Строка очереди, эквивалентная исходному сообщению карточки. + private static QueueItemDto BuildMessage(CardDto card, string text) => new() + { + Source = card.Source, + Content = card.Content with { Text = text }, + Text = text, + MsgAtMs = card.ReceivedAtMs, + }; + + // Восстанавливает бюджет из полей снимка: пустая валюта — бюджета нет (null). + // from: Нижняя граница. + // to: Верхняя граница. + // cur: Валюта (пусто — нет). + // Возвращает: Бюджет карточки либо null. + private static CardBudgetDto? ToBudget( + double? from, + double? to, + string cur) => + cur.Length == 0 ? null : new CardBudgetDto(from, to, cur); + + private static string ResolveContact(CardDto card, string computed) + { + if (computed.Length > 0) + { + return computed; + } + + string oldContact = card.Contact.Trim(); + return oldContact.Length > 0 && ContactsQualifier.Qualify(oldContact) is not null ? oldContact : string.Empty; + } + + private static List SelectTarget(IReadOnlyList inbox, IReadOnlyList? ids) + { + if (ids is null || ids.Count == 0) + { + return [.. inbox]; + } + + var wanted = new HashSet(ids, StringComparer.Ordinal); + var target = new List(); + foreach (CardDto card in inbox) + { + if (wanted.Contains(card.Id)) + { + target.Add(card); + } + } + + return target; + } + + // Читает настройки прохода: снимок тенанта + выключатели ИИ/ИИ-фильтра. + // ct: Токен отмены. + // Возвращает: Накопители прохода с настройками. + private async Task CreatePassAsync(CancellationToken ct) + { + TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); + return new Pass( + snapshot, + snapshot.GetBool(SettingsKeys.AiEnabled, SettingsDefaults.AiEnabled), + snapshot.GetBool(SettingsKeys.AiFilterEnabled, SettingsDefaults.AiFilterEnabled)); + } + + // Ответ занятости: проход уже выполняется. + // Возвращает: Итог с busy = true. + private static ReclassifyResultDto Busy() => + new(Started: false, Busy: true, Attempted: 0, Reclassified: 0, Moved: 0, Kept: 0, Trashed: 0, Skipped: 0, UsedAi: false, Reason: null); + + // Собирает итог прохода из накопителей. + // pass: Накопители прохода. + // attempted: Сколько карточек отобрано. + // started: Проход выполнен. + // reason: Причина (если проход не выполнен) либо null. + // Возвращает: Итог с полями wire-контракта. + private static ReclassifyResultDto Build( + Pass pass, + int attempted, + bool started, + string? reason) => + new( + Started: started, + Busy: false, + Attempted: attempted, + Reclassified: pass.Moved + pass.Kept + pass.Trashed, + Moved: pass.Moved, + Kept: pass.Kept, + Trashed: pass.Trashed, + Skipped: pass.Skipped, + UsedAi: pass.AiUsed, + Reason: reason); + + // Накопители одного прохода переклассификации: настройки, счётчики исхода, признак ИИ. + // Snapshot: Снимок настроек тенанта (для локального парсера). + // AiEnabled: ИИ-слот включён (фильтр/классификация через порт). + // AiFilterEnabled: ИИ-фильтр включён. + private sealed record Pass(TenantSettingsSnapshot Snapshot, bool AiEnabled, bool AiFilterEnabled) + { + /// + /// Сколько карточек ушло в смысловую колонку. + /// + public int Moved { get; set; } + + /// + /// Сколько карточек осталось в «Неразобранном». + /// + public int Kept { get; set; } + + /// + /// Сколько карточек отправлено в корзину. + /// + public int Trashed { get; set; } + + /// + /// Сколько карточек пропущено + /// + public int Skipped { get; set; } + + /// + /// True — разбор хотя бы одной карточки выполнен через порт ИИ. + /// + public bool AiUsed { get; set; } + } +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/GlobalExclusionRules.cs b/src/core/Deal.Modules.Pipeline/Application/Services/GlobalExclusionRules.cs index ec32b0a..4fcf06e 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/GlobalExclusionRules.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/GlobalExclusionRules.cs @@ -1,188 +1,188 @@ -using Deal.Modules.Kanban.Application.ColumnRules; -using Deal.Modules.Pipeline.Application.Models; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Глобальные исключения тенанта — стоп-фильтр ДО ML/ИИ. -/// -public static class GlobalExclusionRules -{ - /// - /// Этап/правило: исключение по ключевым словам/технологиям. - /// - public const string KindKeywords = "exclude_kw"; - - /// - /// Этап/правило: исключение по локации/языку. - /// - public const string KindLocation = "exclude_location"; - - /// - /// Этап/правило: исключение по типу заявки. - /// - public const string KindType = "exclude_type"; - - /// - /// Этап/правило: исключение по бюджету - /// - public const string KindBudget = "exclude_budget"; - - // Формат причины для слова/локации/типа: «{0}» — конкретный терм. - private const string ReasonKeywordsFormat = "глобальное исключение: слово/технология «{0}»"; - - private const string ReasonLocationFormat = "глобальное исключение: локация/язык «{0}»"; - - private const string ReasonTypeFormat = "глобальное исключение: тип «{0}»"; - - // Формат причины для бюджета: «{0}» — человекочитаемый диапазон. - private const string ReasonBudgetFormat = "глобальное исключение: бюджет {0}"; - - /// - /// Проверяет текст на срабатывание глобальных исключений - /// - /// Текст сообщения. - /// Снимок глобальных исключений тенанта. - /// Результат исключения либо null — исключения не сработали. - public static GlobalExclusionResult? Match(string? text, GlobalExcludeSettings settings) - { - ArgumentNullException.ThrowIfNull(settings); - - if (string.IsNullOrEmpty(text)) - { - return null; - } - - string lower = ContentNormalizer.ContentText(text).ToLowerInvariant(); - - GlobalExclusionResult? termHit = MatchTerms(lower, settings.Keywords, KindKeywords, ReasonKeywordsFormat); - if (termHit is not null) - { - return termHit; - } - - GlobalExclusionResult? locationHit = MatchTerms(lower, settings.Locations, KindLocation, ReasonLocationFormat); - if (locationHit is not null) - { - return locationHit; - } - - GlobalExclusionResult? typeHit = MatchTypes(lower, settings.Types); - if (typeHit is not null) - { - return typeHit; - } - - return MatchBudget(text, settings); - } - - // Совпадение с текстовой группой: подстрочное вхождение терма (терм как сохранён в reason). - // lower: Текст в нижнем регистре (без ссылок). - // terms: Термы группы. - // kind: Код правила. - // reasonFormat: Формат причины с термом. - // Возвращает: Первое совпадение либо null. - private static GlobalExclusionResult? MatchTerms( - string lower, - IReadOnlyList terms, - string kind, - string reasonFormat) - { - foreach (string? raw in terms) - { - string term = (raw ?? string.Empty).Trim(); - if (term.Length > 0 && lower.Contains(term.ToLowerInvariant(), StringComparison.Ordinal)) - { - return new GlobalExclusionResult(kind, string.Format(reasonFormat, term), term); - } - } - - return null; - } - - // Совпадение с группой типов (синонимы TypeAliases). - // lower: Текст в нижнем регистре. - // types: Теги типов. - // Возвращает: Первое совпадение либо null. - private static GlobalExclusionResult? MatchTypes(string lower, IReadOnlyList types) - { - foreach (string? raw in types) - { - string term = (raw ?? string.Empty).Trim(); - if (term.Length == 0) - { - continue; - } - - foreach (string alias in TypeAliases.ExpandTerms(new[] { raw })) - { - if (lower.Contains(alias, StringComparison.Ordinal)) - { - return new GlobalExclusionResult(KindType, string.Format(ReasonTypeFormat, term), term); - } - } - } - - return null; - } - - // Совпадение с бюджетным диапазоном исключения (любая числовая сумма текста в границах). - // text: Исходный текст сообщения. - // settings: Снимок исключений (границы). - // Возвращает: Результат исключения по бюджету либо null (границы не заданы/сумма вне диапазона). - private static GlobalExclusionResult? MatchBudget(string text, GlobalExcludeSettings settings) - { - if (settings.BudgetFrom is null && settings.BudgetTo is null) - { - return null; - } - - foreach (AmountRange amount in AmountParser.Parse(text)) - { - double? value = amount.From ?? amount.To; - if (value is null) - { - continue; - } - - if (settings.BudgetFrom is not null && value < settings.BudgetFrom) - { - continue; - } - - if (settings.BudgetTo is not null && value > settings.BudgetTo) - { - continue; - } - - string described = DescribeBudget(settings); - return new GlobalExclusionResult(KindBudget, string.Format(ReasonBudgetFormat, described), string.Empty); - } - - return null; - } - - // Человекочитаемый диапазон бюджета исключения («от A до B», «до B», «от A»). - // settings: Снимок исключений. - // Возвращает: Строка диапазона с валютой. - private static string DescribeBudget(GlobalExcludeSettings settings) - { - if (settings.BudgetFrom is not null && settings.BudgetTo is not null) - { - return $"от {Format(settings.BudgetFrom.Value)} до {Format(settings.BudgetTo.Value)}"; - } - - if (settings.BudgetTo is not null) - { - return $"до {Format(settings.BudgetTo.Value)}"; - } - - return $"от {Format(settings.BudgetFrom!.Value)}"; - } - - // Формат числа границы без хвостовых нулей (инвариантная культура). - // value: Значение границы. - // Возвращает: Строковое представление. - private static string Format(double value) => - value.ToString("G6", System.Globalization.CultureInfo.InvariantCulture).Replace('E', 'e'); -} +using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Pipeline.Application.Models; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Глобальные исключения тенанта — стоп-фильтр ДО ML/ИИ. +/// +public static class GlobalExclusionRules +{ + /// + /// Этап/правило: исключение по ключевым словам/технологиям. + /// + public const string KindKeywords = "exclude_kw"; + + /// + /// Этап/правило: исключение по локации/языку. + /// + public const string KindLocation = "exclude_location"; + + /// + /// Этап/правило: исключение по типу заявки. + /// + public const string KindType = "exclude_type"; + + /// + /// Этап/правило: исключение по бюджету + /// + public const string KindBudget = "exclude_budget"; + + // Формат причины для слова/локации/типа: «{0}» — конкретный терм. + private const string ReasonKeywordsFormat = "глобальное исключение: слово/технология «{0}»"; + + private const string ReasonLocationFormat = "глобальное исключение: локация/язык «{0}»"; + + private const string ReasonTypeFormat = "глобальное исключение: тип «{0}»"; + + // Формат причины для бюджета: «{0}» — человекочитаемый диапазон. + private const string ReasonBudgetFormat = "глобальное исключение: бюджет {0}"; + + /// + /// Проверяет текст на срабатывание глобальных исключений + /// + /// Текст сообщения. + /// Снимок глобальных исключений тенанта. + /// Результат исключения либо null — исключения не сработали. + public static GlobalExclusionResult? Match(string? text, GlobalExcludeSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + + if (string.IsNullOrEmpty(text)) + { + return null; + } + + string lower = ContentNormalizer.ContentText(text).ToLowerInvariant(); + + GlobalExclusionResult? termHit = MatchTerms(lower, settings.Keywords, KindKeywords, ReasonKeywordsFormat); + if (termHit is not null) + { + return termHit; + } + + GlobalExclusionResult? locationHit = MatchTerms(lower, settings.Locations, KindLocation, ReasonLocationFormat); + if (locationHit is not null) + { + return locationHit; + } + + GlobalExclusionResult? typeHit = MatchTypes(lower, settings.Types); + if (typeHit is not null) + { + return typeHit; + } + + return MatchBudget(text, settings); + } + + // Совпадение с текстовой группой: подстрочное вхождение терма (терм как сохранён в reason). + // lower: Текст в нижнем регистре (без ссылок). + // terms: Термы группы. + // kind: Код правила. + // reasonFormat: Формат причины с термом. + // Возвращает: Первое совпадение либо null. + private static GlobalExclusionResult? MatchTerms( + string lower, + IReadOnlyList terms, + string kind, + string reasonFormat) + { + foreach (string? raw in terms) + { + string term = (raw ?? string.Empty).Trim(); + if (term.Length > 0 && lower.Contains(term.ToLowerInvariant(), StringComparison.Ordinal)) + { + return new GlobalExclusionResult(kind, string.Format(reasonFormat, term), term); + } + } + + return null; + } + + // Совпадение с группой типов (синонимы TypeAliases). + // lower: Текст в нижнем регистре. + // types: Теги типов. + // Возвращает: Первое совпадение либо null. + private static GlobalExclusionResult? MatchTypes(string lower, IReadOnlyList types) + { + foreach (string? raw in types) + { + string term = (raw ?? string.Empty).Trim(); + if (term.Length == 0) + { + continue; + } + + foreach (string alias in TypeAliases.ExpandTerms(new[] { raw })) + { + if (lower.Contains(alias, StringComparison.Ordinal)) + { + return new GlobalExclusionResult(KindType, string.Format(ReasonTypeFormat, term), term); + } + } + } + + return null; + } + + // Совпадение с бюджетным диапазоном исключения (любая числовая сумма текста в границах). + // text: Исходный текст сообщения. + // settings: Снимок исключений (границы). + // Возвращает: Результат исключения по бюджету либо null (границы не заданы/сумма вне диапазона). + private static GlobalExclusionResult? MatchBudget(string text, GlobalExcludeSettings settings) + { + if (settings.BudgetFrom is null && settings.BudgetTo is null) + { + return null; + } + + foreach (AmountRange amount in AmountParser.Parse(text)) + { + double? value = amount.From ?? amount.To; + if (value is null) + { + continue; + } + + if (settings.BudgetFrom is not null && value < settings.BudgetFrom) + { + continue; + } + + if (settings.BudgetTo is not null && value > settings.BudgetTo) + { + continue; + } + + string described = DescribeBudget(settings); + return new GlobalExclusionResult(KindBudget, string.Format(ReasonBudgetFormat, described), string.Empty); + } + + return null; + } + + // Человекочитаемый диапазон бюджета исключения («от A до B», «до B», «от A»). + // settings: Снимок исключений. + // Возвращает: Строка диапазона с валютой. + private static string DescribeBudget(GlobalExcludeSettings settings) + { + if (settings.BudgetFrom is not null && settings.BudgetTo is not null) + { + return $"от {Format(settings.BudgetFrom.Value)} до {Format(settings.BudgetTo.Value)}"; + } + + if (settings.BudgetTo is not null) + { + return $"до {Format(settings.BudgetTo.Value)}"; + } + + return $"от {Format(settings.BudgetFrom!.Value)}"; + } + + // Формат числа границы без хвостовых нулей (инвариантная культура). + // value: Значение границы. + // Возвращает: Строковое представление. + private static string Format(double value) => + value.ToString("G6", System.Globalization.CultureInfo.InvariantCulture).Replace('E', 'e'); +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs b/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs index ba81da6..2fabdbf 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs @@ -1,471 +1,471 @@ -using System.Globalization; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Ручная проверка/разметка ML на записях источников. -/// -public sealed class MlReviewService( - IPipelineStore pipelineStore, - ICardStore cardStore, - CardsService cards, - PipelineProcessingService processing, - IMlClient mlClient) -{ - /// - /// Минимум сообщений в выборке кандидатов. - /// - public const int MinCandidates = 1; - - /// - /// Максимум сообщений в выборке кандидатов. - /// - public const int MaxCandidates = 60; - - // Размер одного чтения из очереди/отсева при объединении кандидатов. - private const int MaxScan = 500; - - private const int TextPreviewLength = 600; - - /// - /// Вердикт кандидата - /// - public const string VerdictCard = "card"; - - /// - /// Вердикт кандидата - /// - public const string VerdictRejected = "rejected"; - - /// - /// Вердикт кандидата - /// - public const string VerdictQueued = "queued"; - - /// - /// Действие: пропустить без обучения. - /// - public const string ActionSkip = "skip"; - - /// - /// Действие: спам — учим ML и - /// - public const string ActionSpam = "spam"; - - /// - /// Префикс действия «в колонку» - /// - public const string ActionBoardPrefix = "board:"; - - /// - /// 400 apply: неизвестная доска-цель. - /// - public const string UnknownBoardDetail = "Неизвестная доска"; - - /// - /// 400 apply: неизвестное действие. - /// - public const string UnknownActionDetail = "Неизвестное действие"; - - // Причина отсева при ручной разметке «спам» ещё не обработанного сообщения. - private const string ManualSpamReason = "ручная разметка ML: спам"; - - // Этап отсева при ручной разметке «спам» (отсев решением ML). - private const string ManualSpamStage = "spam_ml"; - - // Источник решения при ручной разметке. - private const string ManualSource = "ml"; - - private const double UserPushWeight = 1.0; - - /// - /// Отбирает записи-кандидаты для проверки ML по источнику и/или размеру выборки. - /// - /// Оригинал источника (OriginRef); пусто — выборка по всем источникам тенанта. - /// Сколько последних записей вернуть (кламп 1.., дефолт вызывающего). - /// Кандидаты (свежие первыми): текст, текущий вердикт и мнение ML по каждому. - public async Task> CandidatesAsync( - string? dialogId, - int limit, - CancellationToken ct) - { - int take = Math.Clamp(limit, MinCandidates, MaxCandidates); - string dialog = (dialogId ?? string.Empty).Trim(); - - IReadOnlyList queue = await pipelineStore.ListAsync(status: null, MaxScan, ct); - IReadOnlyList rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct); - IReadOnlyList cardList = await cardStore.ListCardsAsync(new CardsQuery(null), ct); - - // Объединение по ключу дедупа источника: очередь → отсев → карточка (последняя перекрывает предыдущие). - var merged = new Dictionary(StringComparer.Ordinal); - foreach (QueueItemDto row in queue) - { - if (!IsMessage(row.Source) || !MatchesDialog(dialog, row.Source.OriginRef)) - { - continue; - } - - merged[row.Source.DedupeKey()] = BuildQueued(row); - } - - foreach (RejectedItemDto row in rejected) - { - if (!IsMessage(row.Source) || !MatchesDialog(dialog, row.Source.OriginRef)) - { - continue; - } - - merged[row.Source.DedupeKey()] = BuildRejected(row); - } - - foreach (CardDto card in cardList) - { - if (!IsMessage(card.Source) || !MatchesDialog(dialog, card.Source.OriginRef)) - { - continue; - } - - merged[card.Source.DedupeKey()] = BuildCard(card); - } - - List ordered = merged.Values - .OrderByDescending(candidate => candidate.Time ?? 0) - .Take(take) - .ToList(); - - var withPredictions = new List(ordered.Count); - foreach (MlCandidateDto candidate in ordered) - { - withPredictions.Add(candidate with { Pred = await PredictSafelyAsync(candidate.Text, ct) }); - } - - return withPredictions; - } - - /// - /// Применяет ручное решение по записи источника - /// - /// Оригинал источника (OriginRef) записи. - /// Внешний id записи в источнике. - /// Действие: skip | spam | board:<id>. - /// Результат решения; null — исходная запись не найдена (404-семантика эндпоинта). - public async Task ApplyAsync( - string dialogId, - long msgId, - string? action, - CancellationToken ct) - { - string normalized = (action ?? string.Empty).Trim(); - string dialog = (dialogId ?? string.Empty).Trim(); - string externalId = msgId.ToString(CultureInfo.InvariantCulture); - - SourceRef? source = await ResolveSourceAsync(dialog, externalId, ct); - if (source is null) - { - return null; // 404: исходная запись не найдена - } - - CardDto? card = await cardStore.GetCardBySourceAsync(source, ct); - string? text = await FindTextAsync(dialog, externalId, card, ct); - if (string.IsNullOrWhiteSpace(text)) - { - return null; // 404: исходная запись не найдена - } - - if (normalized == ActionSkip) - { - return new MlApplyResult(Error: null, Ok: true, Learned: false, Moved: null, LeadId: null); - } - - if (normalized == ActionSpam) - { - return await ApplySpamAsync(dialog, externalId, card, text, ct); - } - - if (normalized.StartsWith(ActionBoardPrefix, StringComparison.Ordinal)) - { - string boardId = normalized[ActionBoardPrefix.Length..].Trim(); - return await ApplyBoardAsync(boardId, card, text, ct); - } - - return new MlApplyResult(UnknownActionDetail, Ok: false, Learned: false, Moved: null, LeadId: null); - } - - // Действие «спам»: карточку — в корзину (с обучением), запись из очереди — в отсев; иначе учим ML. - // dialog: Оригинал источника. - // externalId: Внешний id записи. - // card: Карточка сообщения (null — сообщение не становилось карточкой). - // text: Текст сообщения. - // ct: Токен отмены. - // Возвращает: Результат решения. - private async Task ApplySpamAsync( - string dialog, - string externalId, - CardDto? card, - string text, - CancellationToken ct) - { - if (card is not null) - { - // TrashCardAsync(teach:true) сам шлёт обучающий сигнал «спам» — второй сигнал не нужен. - CardDto? trashed = await cards.TrashCardAsync(card.Id, teach: true, ct); - return new MlApplyResult(null, Ok: true, Learned: true, Moved: "trash", LeadId: trashed?.Id ?? card.Id); - } - - await mlClient.PushAsync(text, MlLearningLabels.Spam, UserPushWeight, ct); - - // Сообщение ещё в очереди — отсеиваем его (решение пользователя), снимая строку. - QueueItemDto? queued = await FindQueuedAsync(dialog, externalId, ct); - if (queued is not null) - { - await processing.RejectAsync(new RejectRecord - { - Source = queued.Source, - Content = queued.Content, - Text = queued.Text, - MsgAtMs = queued.MsgAtMs, - DecidedBy = ManualSource, - Stage = ManualSpamStage, - Reason = ManualSpamReason, - Kw = string.Empty, - }, ct); - await pipelineStore.RemoveAsync(queued.Id, ct); - } - - return new MlApplyResult(null, Ok: true, Learned: true, Moved: null, LeadId: null); - } - - // Действие «в колонку»: карточку — переносим, уже в колонке — только учим; иначе учим ML. - // boardId: Id колонки-цели (inbox или b_...). - // card: Карточка сообщения (null — сообщение не становилось карточкой). - // text: Текст сообщения. - // ct: Токен отмены. - // Возвращает: Результат решения. - private async Task ApplyBoardAsync( - string boardId, - CardDto? card, - string text, - CancellationToken ct) - { - if (boardId != CardIds.Inbox && await cardStore.GetContainerAsync(boardId, ct) is null) - { - return new MlApplyResult(UnknownBoardDetail, Ok: false, Learned: false, Moved: null, LeadId: null); - } - - if (card is null) - { - await mlClient.PushAsync(text, boardId, UserPushWeight, ct); - return new MlApplyResult(null, Ok: true, Learned: true, Moved: null, LeadId: null); - } - - if (card.Col == boardId) - { - await mlClient.PushAsync(text, boardId, UserPushWeight, ct); - return new MlApplyResult(null, Ok: true, Learned: true, Moved: null, LeadId: card.Id); - } - - // MoveDashboardCardAsync сам учит колонку (toCol ≠ inbox) — второй сигнал не нужен. - CardResultDto moved = await cards.MoveDashboardCardAsync(card.Id, boardId, ct); - if (moved.Error is not null) - { - return new MlApplyResult(moved.Error, Ok: false, Learned: false, Moved: null, LeadId: card.Id); - } - - return new MlApplyResult(null, Ok: true, Learned: true, Moved: boardId, LeadId: card.Id); - } - - // Ссылка на источник записи: очередь → карточка → отсев. - // dialog: Оригинал источника. - // externalId: Внешний id записи. - // ct: Токен отмены. - // Возвращает: Ссылку на источник либо null, если записи нет. - private async Task ResolveSourceAsync( - string dialog, - string externalId, - CancellationToken ct) - { - QueueItemDto? queued = await FindQueuedAsync(dialog, externalId, ct); - if (queued is not null) - { - return queued.Source; - } - - IReadOnlyList cardList = await cardStore.ListCardsAsync(new CardsQuery(null), ct); - foreach (CardDto card in cardList) - { - if (MatchesSource(card.Source, dialog, externalId)) - { - return card.Source; - } - } - - IReadOnlyList rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct); - foreach (RejectedItemDto row in rejected) - { - if (MatchesSource(row.Source, dialog, externalId)) - { - return row.Source; - } - } - - return null; - } - - // Текст исходного сообщения: текст карточки, иначе текст строки очереди/записи отсева. - // dialog: Оригинал источника. - // externalId: Внешний id записи. - // card: Карточка сообщения (уже прочитана вызывающим). - // ct: Токен отмены. - // Возвращает: Непустой текст либо null, если записи нет ни в одном источнике. - private async Task FindTextAsync( - string dialog, - string externalId, - CardDto? card, - CancellationToken ct) - { - if (card is not null && !string.IsNullOrWhiteSpace(card.Content.Text)) - { - return card.Content.Text; - } - - QueueItemDto? queued = await FindQueuedAsync(dialog, externalId, ct); - if (queued is not null && !string.IsNullOrWhiteSpace(queued.Text)) - { - return queued.Text; - } - - IReadOnlyList rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct); - foreach (RejectedItemDto row in rejected) - { - if (MatchesSource(row.Source, dialog, externalId)) - { - return row.Text; - } - } - - return null; - } - - // Строка очереди записи (для отсева при ручной разметке «спам»). - // dialog: Оригинал источника. - // externalId: Внешний id записи. - // ct: Токен отмены. - // Возвращает: Строка очереди либо null — сообщение уже обработано/не в очереди. - private async Task FindQueuedAsync( - string dialog, - string externalId, - CancellationToken ct) - { - IReadOnlyList queue = await pipelineStore.ListAsync(status: null, MaxScan, ct); - foreach (QueueItemDto row in queue) - { - if (MatchesSource(row.Source, dialog, externalId)) - { - return row; - } - } - - return null; - } - - // Прогноз ML по тексту с защитой от сбоя (недоступный сервис — кандидат без мнения). - // text: Текст сообщения. - // ct: Токен отмены. - // Возвращает: Мнение ML либо null при сбое. - private async Task PredictSafelyAsync(string text, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(text)) - { - return null; - } - - try - { - MlPredictResultDto result = await mlClient.PredictAsync(text, ct); - return new MlCandidatePredictionDto(result.Take, result.Label, result.Scores); - } - catch (Exception) - { - return null; - } - } - - // Идентифицирована ли запись источника (есть внешний id). - // source: Ссылка на источник. - // Возвращает: True — запись адресуема как сообщение источника. - private static bool IsMessage(SourceRef source) => source.ExternalId is { Length: > 0 }; - - // Соответствует ли источник фильтру оригинала (пустой фильтр — все источники). - // filter: Запрошенный оригинал (пусто — без фильтра). - // originRef: Оригинал источника записи. - // Возвращает: True — кандидат подходит. - private static bool MatchesDialog(string filter, string? originRef) => - filter.Length == 0 || string.Equals(filter, originRef ?? string.Empty, StringComparison.Ordinal); - - // Совпадает ли источник с оригиналом и внешним id записи. - // source: Ссылка на источник. - // dialog: Оригинал источника. - // externalId: Внешний id записи. - // Возвращает: True — источник указывает на ту же запись. - private static bool MatchesSource(SourceRef source, string dialog, string externalId) => - string.Equals(source.OriginRef ?? string.Empty, dialog, StringComparison.Ordinal) - && string.Equals(source.ExternalId ?? string.Empty, externalId, StringComparison.Ordinal); - - // Кандидат из строки очереди (вердикт queued). - // row: Строка очереди. - // Возвращает: Кандидат. - private static MlCandidateDto BuildQueued(QueueItemDto row) => new() - { - Source = row.Source, - Content = row.Content, - Text = Truncate(row.Text), - Time = row.MsgAtMs == 0 ? null : row.MsgAtMs, - Lead = false, - Verdict = VerdictQueued, - Stage = row.Status, - }; - - // Кандидат из записи отсева (вердикт rejected). - // row: Запись отсева. - // Возвращает: Кандидат. - private static MlCandidateDto BuildRejected(RejectedItemDto row) => new() - { - Source = row.Source, - Content = row.Content, - Text = Truncate(row.Text), - Time = row.MsgAtMs == 0 ? null : row.MsgAtMs, - Lead = false, - Verdict = VerdictRejected, - Stage = row.Stage, - Reason = row.Reason, - }; - - // Кандидат из карточки (вердикт card). - // card: Карточка. - // Возвращает: Кандидат. - private static MlCandidateDto BuildCard(CardDto card) => new() - { - Source = card.Source, - Content = card.Content, - Text = Truncate(card.Content.Text ?? string.Empty), - Time = card.ReceivedAtMs == 0 ? null : card.ReceivedAtMs, - Lead = true, - Verdict = VerdictCard, - Col = card.Col, - }; - - // Обрезает текст кандидата до TextPreviewLength символов. - // text: Исходный текст. - // Возвращает: Обрезанный текст. - private static string Truncate(string text) => - text.Length <= TextPreviewLength ? text : text[..TextPreviewLength]; -} +using System.Globalization; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Ручная проверка/разметка ML на записях источников. +/// +public sealed class MlReviewService( + IPipelineStore pipelineStore, + ICardStore cardStore, + CardsService cards, + PipelineProcessingService processing, + IMlClient mlClient) +{ + /// + /// Минимум сообщений в выборке кандидатов. + /// + public const int MinCandidates = 1; + + /// + /// Максимум сообщений в выборке кандидатов. + /// + public const int MaxCandidates = 60; + + // Размер одного чтения из очереди/отсева при объединении кандидатов. + private const int MaxScan = 500; + + private const int TextPreviewLength = 600; + + /// + /// Вердикт кандидата + /// + public const string VerdictCard = "card"; + + /// + /// Вердикт кандидата + /// + public const string VerdictRejected = "rejected"; + + /// + /// Вердикт кандидата + /// + public const string VerdictQueued = "queued"; + + /// + /// Действие: пропустить без обучения. + /// + public const string ActionSkip = "skip"; + + /// + /// Действие: спам — учим ML и + /// + public const string ActionSpam = "spam"; + + /// + /// Префикс действия «в колонку» + /// + public const string ActionBoardPrefix = "board:"; + + /// + /// 400 apply: неизвестная доска-цель. + /// + public const string UnknownBoardDetail = "Неизвестная доска"; + + /// + /// 400 apply: неизвестное действие. + /// + public const string UnknownActionDetail = "Неизвестное действие"; + + // Причина отсева при ручной разметке «спам» ещё не обработанного сообщения. + private const string ManualSpamReason = "ручная разметка ML: спам"; + + // Этап отсева при ручной разметке «спам» (отсев решением ML). + private const string ManualSpamStage = "spam_ml"; + + // Источник решения при ручной разметке. + private const string ManualSource = "ml"; + + private const double UserPushWeight = 1.0; + + /// + /// Отбирает записи-кандидаты для проверки ML по источнику и/или размеру выборки. + /// + /// Оригинал источника (OriginRef); пусто — выборка по всем источникам тенанта. + /// Сколько последних записей вернуть (кламп 1.., дефолт вызывающего). + /// Кандидаты (свежие первыми): текст, текущий вердикт и мнение ML по каждому. + public async Task> CandidatesAsync( + string? dialogId, + int limit, + CancellationToken ct) + { + int take = Math.Clamp(limit, MinCandidates, MaxCandidates); + string dialog = (dialogId ?? string.Empty).Trim(); + + IReadOnlyList queue = await pipelineStore.ListAsync(status: null, MaxScan, ct); + IReadOnlyList rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct); + IReadOnlyList cardList = await cardStore.ListCardsAsync(new CardsQuery(null), ct); + + // Объединение по ключу дедупа источника: очередь → отсев → карточка (последняя перекрывает предыдущие). + var merged = new Dictionary(StringComparer.Ordinal); + foreach (QueueItemDto row in queue) + { + if (!IsMessage(row.Source) || !MatchesDialog(dialog, row.Source.OriginRef)) + { + continue; + } + + merged[row.Source.DedupeKey()] = BuildQueued(row); + } + + foreach (RejectedItemDto row in rejected) + { + if (!IsMessage(row.Source) || !MatchesDialog(dialog, row.Source.OriginRef)) + { + continue; + } + + merged[row.Source.DedupeKey()] = BuildRejected(row); + } + + foreach (CardDto card in cardList) + { + if (!IsMessage(card.Source) || !MatchesDialog(dialog, card.Source.OriginRef)) + { + continue; + } + + merged[card.Source.DedupeKey()] = BuildCard(card); + } + + List ordered = merged.Values + .OrderByDescending(candidate => candidate.Time ?? 0) + .Take(take) + .ToList(); + + var withPredictions = new List(ordered.Count); + foreach (MlCandidateDto candidate in ordered) + { + withPredictions.Add(candidate with { Pred = await PredictSafelyAsync(candidate.Text, ct) }); + } + + return withPredictions; + } + + /// + /// Применяет ручное решение по записи источника + /// + /// Оригинал источника (OriginRef) записи. + /// Внешний id записи в источнике. + /// Действие: skip | spam | board:<id>. + /// Результат решения; null — исходная запись не найдена (404-семантика эндпоинта). + public async Task ApplyAsync( + string dialogId, + long msgId, + string? action, + CancellationToken ct) + { + string normalized = (action ?? string.Empty).Trim(); + string dialog = (dialogId ?? string.Empty).Trim(); + string externalId = msgId.ToString(CultureInfo.InvariantCulture); + + SourceRef? source = await ResolveSourceAsync(dialog, externalId, ct); + if (source is null) + { + return null; // 404: исходная запись не найдена + } + + CardDto? card = await cardStore.GetCardBySourceAsync(source, ct); + string? text = await FindTextAsync(dialog, externalId, card, ct); + if (string.IsNullOrWhiteSpace(text)) + { + return null; // 404: исходная запись не найдена + } + + if (normalized == ActionSkip) + { + return new MlApplyResult(Error: null, Ok: true, Learned: false, Moved: null, LeadId: null); + } + + if (normalized == ActionSpam) + { + return await ApplySpamAsync(dialog, externalId, card, text, ct); + } + + if (normalized.StartsWith(ActionBoardPrefix, StringComparison.Ordinal)) + { + string boardId = normalized[ActionBoardPrefix.Length..].Trim(); + return await ApplyBoardAsync(boardId, card, text, ct); + } + + return new MlApplyResult(UnknownActionDetail, Ok: false, Learned: false, Moved: null, LeadId: null); + } + + // Действие «спам»: карточку — в корзину (с обучением), запись из очереди — в отсев; иначе учим ML. + // dialog: Оригинал источника. + // externalId: Внешний id записи. + // card: Карточка сообщения (null — сообщение не становилось карточкой). + // text: Текст сообщения. + // ct: Токен отмены. + // Возвращает: Результат решения. + private async Task ApplySpamAsync( + string dialog, + string externalId, + CardDto? card, + string text, + CancellationToken ct) + { + if (card is not null) + { + // TrashCardAsync(teach:true) сам шлёт обучающий сигнал «спам» — второй сигнал не нужен. + CardDto? trashed = await cards.TrashCardAsync(card.Id, teach: true, ct); + return new MlApplyResult(null, Ok: true, Learned: true, Moved: "trash", LeadId: trashed?.Id ?? card.Id); + } + + await mlClient.PushAsync(text, MlLearningLabels.Spam, UserPushWeight, ct); + + // Сообщение ещё в очереди — отсеиваем его (решение пользователя), снимая строку. + QueueItemDto? queued = await FindQueuedAsync(dialog, externalId, ct); + if (queued is not null) + { + await processing.RejectAsync(new RejectRecord + { + Source = queued.Source, + Content = queued.Content, + Text = queued.Text, + MsgAtMs = queued.MsgAtMs, + DecidedBy = ManualSource, + Stage = ManualSpamStage, + Reason = ManualSpamReason, + Kw = string.Empty, + }, ct); + await pipelineStore.RemoveAsync(queued.Id, ct); + } + + return new MlApplyResult(null, Ok: true, Learned: true, Moved: null, LeadId: null); + } + + // Действие «в колонку»: карточку — переносим, уже в колонке — только учим; иначе учим ML. + // boardId: Id колонки-цели (inbox или b_...). + // card: Карточка сообщения (null — сообщение не становилось карточкой). + // text: Текст сообщения. + // ct: Токен отмены. + // Возвращает: Результат решения. + private async Task ApplyBoardAsync( + string boardId, + CardDto? card, + string text, + CancellationToken ct) + { + if (boardId != CardIds.Inbox && await cardStore.GetContainerAsync(boardId, ct) is null) + { + return new MlApplyResult(UnknownBoardDetail, Ok: false, Learned: false, Moved: null, LeadId: null); + } + + if (card is null) + { + await mlClient.PushAsync(text, boardId, UserPushWeight, ct); + return new MlApplyResult(null, Ok: true, Learned: true, Moved: null, LeadId: null); + } + + if (card.Col == boardId) + { + await mlClient.PushAsync(text, boardId, UserPushWeight, ct); + return new MlApplyResult(null, Ok: true, Learned: true, Moved: null, LeadId: card.Id); + } + + // MoveDashboardCardAsync сам учит колонку (toCol ≠ inbox) — второй сигнал не нужен. + CardResultDto moved = await cards.MoveDashboardCardAsync(card.Id, boardId, ct); + if (moved.Error is not null) + { + return new MlApplyResult(moved.Error, Ok: false, Learned: false, Moved: null, LeadId: card.Id); + } + + return new MlApplyResult(null, Ok: true, Learned: true, Moved: boardId, LeadId: card.Id); + } + + // Ссылка на источник записи: очередь → карточка → отсев. + // dialog: Оригинал источника. + // externalId: Внешний id записи. + // ct: Токен отмены. + // Возвращает: Ссылку на источник либо null, если записи нет. + private async Task ResolveSourceAsync( + string dialog, + string externalId, + CancellationToken ct) + { + QueueItemDto? queued = await FindQueuedAsync(dialog, externalId, ct); + if (queued is not null) + { + return queued.Source; + } + + IReadOnlyList cardList = await cardStore.ListCardsAsync(new CardsQuery(null), ct); + foreach (CardDto card in cardList) + { + if (MatchesSource(card.Source, dialog, externalId)) + { + return card.Source; + } + } + + IReadOnlyList rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct); + foreach (RejectedItemDto row in rejected) + { + if (MatchesSource(row.Source, dialog, externalId)) + { + return row.Source; + } + } + + return null; + } + + // Текст исходного сообщения: текст карточки, иначе текст строки очереди/записи отсева. + // dialog: Оригинал источника. + // externalId: Внешний id записи. + // card: Карточка сообщения (уже прочитана вызывающим). + // ct: Токен отмены. + // Возвращает: Непустой текст либо null, если записи нет ни в одном источнике. + private async Task FindTextAsync( + string dialog, + string externalId, + CardDto? card, + CancellationToken ct) + { + if (card is not null && !string.IsNullOrWhiteSpace(card.Content.Text)) + { + return card.Content.Text; + } + + QueueItemDto? queued = await FindQueuedAsync(dialog, externalId, ct); + if (queued is not null && !string.IsNullOrWhiteSpace(queued.Text)) + { + return queued.Text; + } + + IReadOnlyList rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct); + foreach (RejectedItemDto row in rejected) + { + if (MatchesSource(row.Source, dialog, externalId)) + { + return row.Text; + } + } + + return null; + } + + // Строка очереди записи (для отсева при ручной разметке «спам»). + // dialog: Оригинал источника. + // externalId: Внешний id записи. + // ct: Токен отмены. + // Возвращает: Строка очереди либо null — сообщение уже обработано/не в очереди. + private async Task FindQueuedAsync( + string dialog, + string externalId, + CancellationToken ct) + { + IReadOnlyList queue = await pipelineStore.ListAsync(status: null, MaxScan, ct); + foreach (QueueItemDto row in queue) + { + if (MatchesSource(row.Source, dialog, externalId)) + { + return row; + } + } + + return null; + } + + // Прогноз ML по тексту с защитой от сбоя (недоступный сервис — кандидат без мнения). + // text: Текст сообщения. + // ct: Токен отмены. + // Возвращает: Мнение ML либо null при сбое. + private async Task PredictSafelyAsync(string text, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + try + { + MlPredictResultDto result = await mlClient.PredictAsync(text, ct); + return new MlCandidatePredictionDto(result.Take, result.Label, result.Scores); + } + catch (Exception) + { + return null; + } + } + + // Идентифицирована ли запись источника (есть внешний id). + // source: Ссылка на источник. + // Возвращает: True — запись адресуема как сообщение источника. + private static bool IsMessage(SourceRef source) => source.ExternalId is { Length: > 0 }; + + // Соответствует ли источник фильтру оригинала (пустой фильтр — все источники). + // filter: Запрошенный оригинал (пусто — без фильтра). + // originRef: Оригинал источника записи. + // Возвращает: True — кандидат подходит. + private static bool MatchesDialog(string filter, string? originRef) => + filter.Length == 0 || string.Equals(filter, originRef ?? string.Empty, StringComparison.Ordinal); + + // Совпадает ли источник с оригиналом и внешним id записи. + // source: Ссылка на источник. + // dialog: Оригинал источника. + // externalId: Внешний id записи. + // Возвращает: True — источник указывает на ту же запись. + private static bool MatchesSource(SourceRef source, string dialog, string externalId) => + string.Equals(source.OriginRef ?? string.Empty, dialog, StringComparison.Ordinal) + && string.Equals(source.ExternalId ?? string.Empty, externalId, StringComparison.Ordinal); + + // Кандидат из строки очереди (вердикт queued). + // row: Строка очереди. + // Возвращает: Кандидат. + private static MlCandidateDto BuildQueued(QueueItemDto row) => new() + { + Source = row.Source, + Content = row.Content, + Text = Truncate(row.Text), + Time = row.MsgAtMs == 0 ? null : row.MsgAtMs, + Lead = false, + Verdict = VerdictQueued, + Stage = row.Status, + }; + + // Кандидат из записи отсева (вердикт rejected). + // row: Запись отсева. + // Возвращает: Кандидат. + private static MlCandidateDto BuildRejected(RejectedItemDto row) => new() + { + Source = row.Source, + Content = row.Content, + Text = Truncate(row.Text), + Time = row.MsgAtMs == 0 ? null : row.MsgAtMs, + Lead = false, + Verdict = VerdictRejected, + Stage = row.Stage, + Reason = row.Reason, + }; + + // Кандидат из карточки (вердикт card). + // card: Карточка. + // Возвращает: Кандидат. + private static MlCandidateDto BuildCard(CardDto card) => new() + { + Source = card.Source, + Content = card.Content, + Text = Truncate(card.Content.Text ?? string.Empty), + Time = card.ReceivedAtMs == 0 ? null : card.ReceivedAtMs, + Lead = true, + Verdict = VerdictCard, + Col = card.Col, + }; + + // Обрезает текст кандидата до TextPreviewLength символов. + // text: Исходный текст. + // Возвращает: Обрезанный текст. + private static string Truncate(string text) => + text.Length <= TextPreviewLength ? text : text[..TextPreviewLength]; +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineCardWriter.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineCardWriter.cs index 106130d..0bffead 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineCardWriter.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineCardWriter.cs @@ -1,35 +1,35 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Создание карточки пайплайна через публичный интерфейс Kanban. -/// -public sealed class PipelineCardWriter(ICardStore kanjStore, IPipelineStore pipelineStore, CardComposer composer) -{ - /// - /// Создаёт карточку из разобранного сообщения и связывает её с заявкой дедупа. - /// - /// Разбор сообщения (ИИ/локальный путь; колонка разбора пройдёт ContainerAccepts-страховку). - /// Строка очереди с сообщением-источником (метаданные канала/текст/время). - /// SHA1-hex хэша текста. - /// Полная карточка. - /// Карточка не прочиталась сразу после создания. - public async Task CreateCardAsync( - AiParsedCardDto parsed, - QueueItemDto message, - string dedupHash, - CancellationToken ct) - { - string cardId = PrefixId.New(KanbanIdPrefixes.Card); - CardSnapshot snapshot = await composer.BuildAsync(parsed, message, cardId, ct); - await kanjStore.AddCardAsync(snapshot, ct); - await pipelineStore.LinkAsync(dedupHash, cardId, ct); - return await kanjStore.GetCardAsync(cardId, ct) - ?? throw new InvalidOperationException("Карточка не прочиталась после создания: " + cardId); - } -} +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Создание карточки пайплайна через публичный интерфейс Kanban. +/// +public sealed class PipelineCardWriter(ICardStore kanjStore, IPipelineStore pipelineStore, CardComposer composer) +{ + /// + /// Создаёт карточку из разобранного сообщения и связывает её с заявкой дедупа. + /// + /// Разбор сообщения (ИИ/локальный путь; колонка разбора пройдёт ContainerAccepts-страховку). + /// Строка очереди с сообщением-источником (метаданные канала/текст/время). + /// SHA1-hex хэша текста. + /// Полная карточка. + /// Карточка не прочиталась сразу после создания. + public async Task CreateCardAsync( + AiParsedCardDto parsed, + QueueItemDto message, + string dedupHash, + CancellationToken ct) + { + string cardId = PrefixId.New(KanbanIdPrefixes.Card); + CardSnapshot snapshot = await composer.BuildAsync(parsed, message, cardId, ct); + await kanjStore.AddCardAsync(snapshot, ct); + await pipelineStore.LinkAsync(dedupHash, cardId, ct); + return await kanjStore.GetCardAsync(cardId, ct) + ?? throw new InvalidOperationException("Карточка не прочиталась после создания: " + cardId); + } +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineIngestService.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineIngestService.cs index 0ae9d9f..832e0e1 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineIngestService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineIngestService.cs @@ -1,54 +1,54 @@ -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Приём входящих сообщений пайплайна — постановка сырой записи источника в очередь QueueItems. -/// -public sealed class PipelineIngestService(IPipelineStore store) -{ - private const int MaxQueueTextLength = 6000; - - /// - /// Ставит входящую запись источника в очередь - /// - /// Запись источника и флаг «возвращено из отсева». - /// Результат: Id строки (p_...) либо null, если запись не принята; Duplicate — дубль источника. - public async Task EnqueueAsync(QueuedMessage message, CancellationToken ct) - { - string text = (message.Item.Content.Text ?? string.Empty).Trim(); - if (text.Length == 0) - { - return new PipelineIngestResultDto(null, false); - } - - long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - if (await store.ExistsDuplicateAsync(message.Item.Source, ct)) - { - return new PipelineIngestResultDto(null, true); - } - - string sliced = MessageTextCleaner.SliceCodePoints(text, MaxQueueTextLength); - DateTimeOffset receivedAt = message.Item.Source.ReceivedAt; - long msgAtMs = receivedAt == default ? 0 : receivedAt.ToUnixTimeMilliseconds(); - - var item = new QueueItemDto - { - Id = PrefixId.New(PipelineIdPrefixes.Queue), - Source = message.Item.Source, - Content = message.Item.Content with { Text = sliced }, - Text = sliced, - Status = PipelineQueueStatuses.New, - MsgAtMs = msgAtMs, - QueuedAtMs = nowMs, - Force = message.Force, - }; - await store.AddAsync(item, ct); - return new PipelineIngestResultDto(item.Id, false); - } -} +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Приём входящих сообщений пайплайна — постановка сырой записи источника в очередь QueueItems. +/// +public sealed class PipelineIngestService(IPipelineStore store) +{ + private const int MaxQueueTextLength = 6000; + + /// + /// Ставит входящую запись источника в очередь + /// + /// Запись источника и флаг «возвращено из отсева». + /// Результат: Id строки (p_...) либо null, если запись не принята; Duplicate — дубль источника. + public async Task EnqueueAsync(QueuedMessage message, CancellationToken ct) + { + string text = (message.Item.Content.Text ?? string.Empty).Trim(); + if (text.Length == 0) + { + return new PipelineIngestResultDto(null, false); + } + + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + if (await store.ExistsDuplicateAsync(message.Item.Source, ct)) + { + return new PipelineIngestResultDto(null, true); + } + + string sliced = MessageTextCleaner.SliceCodePoints(text, MaxQueueTextLength); + DateTimeOffset receivedAt = message.Item.Source.ReceivedAt; + long msgAtMs = receivedAt == default ? 0 : receivedAt.ToUnixTimeMilliseconds(); + + var item = new QueueItemDto + { + Id = PrefixId.New(PipelineIdPrefixes.Queue), + Source = message.Item.Source, + Content = message.Item.Content with { Text = sliced }, + Text = sliced, + Status = PipelineQueueStatuses.New, + MsgAtMs = msgAtMs, + QueuedAtMs = nowMs, + Force = message.Force, + }; + await store.AddAsync(item, ct); + return new PipelineIngestResultDto(item.Id, false); + } +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineProcessingService.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineProcessingService.cs index 2d9fa03..d845dd0 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineProcessingService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineProcessingService.cs @@ -1,257 +1,257 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Мониторинг и обслуживание пайплайна — вкладка «Обработка» -/// -public sealed class PipelineProcessingService( - IPipelineStore store, - IMlClient mlClient, - PipelineIngestService ingest) -{ - - /// - /// 400 return: запись уже возвращалась в обработку. - /// - public const string AlreadyReturnedDetail = "Сообщение уже возвращено в обработку"; - - /// - /// 400 return: повтор по дедупу — карточка с текстом уже в системе. - /// - public const string DuplicateReturnDetail = "Повтор: карточка с таким текстом уже есть в системе — возвращать нечего"; - - /// - /// 400 return: в записи нет текста сообщения. - /// - public const string EmptyReturnTextDetail = "В записи нет текста сообщения"; - - - /// - /// Размер страницы по умолчанию для списков очереди/отсева. - /// - public const int DefaultPageSize = 100; - - /// - /// Максимальный размер страницы списков - /// - public const int MaxPageSize = 500; - - - // Вес снятия метки «спам»: реальное действие пользователя «это не спам» (delta=−1.0). - private const double SpamUnlearnDelta = -1.0; - - private const string DuplicateSource = "dup"; - - private static readonly HashSet SpamStages = new(StringComparer.Ordinal) - { - "spam_ml", - "spam_ai", - "filter_ai", - }; - - - private const int MaxRejectedTextLength = 6000; - - private const int MaxReasonLength = 500; - - private const int MaxKwLength = 200; - - private const int MaxReturnReasonLength = 500; - - - /// - /// Сохраняет отброшенное сообщение в отсев. - /// - /// Команда записи отсева (поля без ограничений длин — сервис нормализует). - /// Задача завершается после записи (upsert) или no-op пустого текста. - public Task RejectAsync(RejectRecord record, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(record.Text)) - { - return Task.CompletedTask; - } - - return store.UpsertAsync(record with - { - Text = MessageTextCleaner.SliceCodePoints(record.Text, MaxRejectedTextLength), - Reason = MessageTextCleaner.SliceCodePoints(record.Reason, MaxReasonLength), - Kw = MessageTextCleaner.SliceCodePoints(record.Kw, MaxKwLength), - }, ct); - } - - - /// - /// Сырые сообщения очереди в порядке постановки - /// - /// Максимум строк; clamp 1..500 — запрашивается больше — вернётся не больше 500. - /// Строки очереди. - public async Task> ListQueueAsync(int limit, CancellationToken ct) - { - int clamped = Math.Clamp(limit, 1, MaxPageSize); - return await store.ListAsync(null, clamped, ct); - } - - /// - /// Счётчики очереди по статусам - /// - /// new/ai/total (форма counts ответа /queue и queue ответа /stats). - public async Task QueueCountsAsync(CancellationToken ct) - { - int fresh = await store.CountByStatusAsync(PipelineQueueStatuses.New, ct); - int ai = await store.CountByStatusAsync(PipelineQueueStatuses.Filtered, ct); - return new QueueCountsDto { New = fresh, Ai = ai, Total = fresh + ai }; - } - - /// - /// Число записей в отсеве. - /// - /// Всего записей RejectedItems. - public Task RejectedCountAsync(CancellationToken ct) - { - return store.CountAsync(ct); - } - - - /// - /// Страница отсева - /// - /// Поисковый запрос (текст/причина/фраза/имя канала); пустой — весь отсев. - /// Сдвиг от начала (clamp ≥ 0). - /// Размер страницы (clamp 1..500). - /// Страница {items, total, offset, limit}. - public async Task ListRejectedAsync( - string q, - int offset, - int limit, - CancellationToken ct) - { - int clampedOffset = Math.Max(0, offset); - int clampedLimit = Math.Clamp(limit, 1, MaxPageSize); - string query = (q ?? string.Empty).Trim().ToLowerInvariant(); - - if (query.Length == 0) - { - int total = await store.CountAsync(ct); - IReadOnlyList page = await store.ListPageAsync(clampedOffset, clampedLimit, ct); - return new RejectedPageDto(page, total, clampedOffset, clampedLimit); - } - - IReadOnlyList candidates = await store.SearchAsync(query, clampedLimit, clampedLimit * 2, ct); - IReadOnlyList items = candidates.Skip(clampedOffset).Take(clampedLimit).ToList(); - - return new RejectedPageDto(items, candidates.Count, clampedOffset, clampedLimit); - } - - - /// - /// Возвращает отсеянное сообщение в обработку - /// - /// Id записи отсева (r_...). - /// Причина возврата (trim, ≤500; пишется на запись для аудита). - /// null — записи нет (404); иначе результат: Error (400) либо {id, returned:true, returnedAt}. - public async Task ReturnAsync( - string rejectedId, - string reason, - CancellationToken ct) - { - RejectedItemDto? row = await store.GetAsync(rejectedId, ct); - if (row is null) - { - return null; - } - - if (row.Returned) - { - return new RejectReturnResultDto(AlreadyReturnedDetail, rejectedId, false, 0); - } - - if (row.DecidedBy == DuplicateSource) - { - return new RejectReturnResultDto(DuplicateReturnDetail, rejectedId, false, 0); - } - - string text = row.Text.Trim(); - if (text.Length == 0) - { - return new RejectReturnResultDto(EmptyReturnTextDetail, rejectedId, false, 0); - } - - if (SpamStages.Contains(row.Stage)) - { - await mlClient.PushAsync(text, MlLearningLabels.Spam, SpamUnlearnDelta, ct); - } - - long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - await store.MarkReturnedAsync( - rejectedId, - MessageTextCleaner.SliceCodePoints(reason.Trim(), MaxReturnReasonLength), - DateTimeOffset.FromUnixTimeMilliseconds(nowMs), - ct); - - var extra = new Dictionary(row.Source.Extra ?? new Dictionary()) - { - [SourceRefs.HueKey] = row.Source.ResolveHue(), - }; - SourceRef source = row.Source with { Extra = extra }; - - await ingest.EnqueueAsync(new QueuedMessage - { - Item = new SourceItem - { - Source = source, - Content = row.Content with { Text = text }, - }, - Force = true, - }, ct); - - return new RejectReturnResultDto(null, rejectedId, true, nowMs); - } - - - /// - /// Удаляет одну запись отсева безвозвратно. - /// - /// Id записи (r_...). - /// Задача завершается после удаления. - public Task DeleteAsync(string rejectedId, CancellationToken ct) - { - return store.DeleteAsync(rejectedId, ct); - } - - /// - /// Полная ручная очистка отсева. - /// - /// Сколько записей удалено (0 — отсев пуст). - public Task ClearAsync(CancellationToken ct) - { - return store.ClearAsync(ct); - } - - /// - /// Автоочистка отсева - /// - /// Сколько записей удалено. - public Task PurgeExpiredAsync(CancellationToken ct) - { - DateTimeOffset olderThan = DateTimeOffset.UtcNow.AddDays(-PipelineRejectConstants.RetentionDays); - return store.PurgeExpiredAsync(olderThan, ct); - } - - - /// - /// Сводка вкладки «Обработка» — форма GET /api/pipeline/stats - /// - /// Счётчики очереди и число записей отсева. - public async Task StatsAsync(CancellationToken ct) - { - QueueCountsDto queue = await QueueCountsAsync(ct); - int rejected = await store.CountAsync(ct); - return new PipelineStatsDto(queue, rejected); - } -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Мониторинг и обслуживание пайплайна — вкладка «Обработка» +/// +public sealed class PipelineProcessingService( + IPipelineStore store, + IMlClient mlClient, + PipelineIngestService ingest) +{ + + /// + /// 400 return: запись уже возвращалась в обработку. + /// + public const string AlreadyReturnedDetail = "Сообщение уже возвращено в обработку"; + + /// + /// 400 return: повтор по дедупу — карточка с текстом уже в системе. + /// + public const string DuplicateReturnDetail = "Повтор: карточка с таким текстом уже есть в системе — возвращать нечего"; + + /// + /// 400 return: в записи нет текста сообщения. + /// + public const string EmptyReturnTextDetail = "В записи нет текста сообщения"; + + + /// + /// Размер страницы по умолчанию для списков очереди/отсева. + /// + public const int DefaultPageSize = 100; + + /// + /// Максимальный размер страницы списков + /// + public const int MaxPageSize = 500; + + + // Вес снятия метки «спам»: реальное действие пользователя «это не спам» (delta=−1.0). + private const double SpamUnlearnDelta = -1.0; + + private const string DuplicateSource = "dup"; + + private static readonly HashSet SpamStages = new(StringComparer.Ordinal) + { + "spam_ml", + "spam_ai", + "filter_ai", + }; + + + private const int MaxRejectedTextLength = 6000; + + private const int MaxReasonLength = 500; + + private const int MaxKwLength = 200; + + private const int MaxReturnReasonLength = 500; + + + /// + /// Сохраняет отброшенное сообщение в отсев. + /// + /// Команда записи отсева (поля без ограничений длин — сервис нормализует). + /// Задача завершается после записи (upsert) или no-op пустого текста. + public Task RejectAsync(RejectRecord record, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(record.Text)) + { + return Task.CompletedTask; + } + + return store.UpsertAsync(record with + { + Text = MessageTextCleaner.SliceCodePoints(record.Text, MaxRejectedTextLength), + Reason = MessageTextCleaner.SliceCodePoints(record.Reason, MaxReasonLength), + Kw = MessageTextCleaner.SliceCodePoints(record.Kw, MaxKwLength), + }, ct); + } + + + /// + /// Сырые сообщения очереди в порядке постановки + /// + /// Максимум строк; clamp 1..500 — запрашивается больше — вернётся не больше 500. + /// Строки очереди. + public async Task> ListQueueAsync(int limit, CancellationToken ct) + { + int clamped = Math.Clamp(limit, 1, MaxPageSize); + return await store.ListAsync(null, clamped, ct); + } + + /// + /// Счётчики очереди по статусам + /// + /// new/ai/total (форма counts ответа /queue и queue ответа /stats). + public async Task QueueCountsAsync(CancellationToken ct) + { + int fresh = await store.CountByStatusAsync(PipelineQueueStatuses.New, ct); + int ai = await store.CountByStatusAsync(PipelineQueueStatuses.Filtered, ct); + return new QueueCountsDto { New = fresh, Ai = ai, Total = fresh + ai }; + } + + /// + /// Число записей в отсеве. + /// + /// Всего записей RejectedItems. + public Task RejectedCountAsync(CancellationToken ct) + { + return store.CountAsync(ct); + } + + + /// + /// Страница отсева + /// + /// Поисковый запрос (текст/причина/фраза/имя канала); пустой — весь отсев. + /// Сдвиг от начала (clamp ≥ 0). + /// Размер страницы (clamp 1..500). + /// Страница {items, total, offset, limit}. + public async Task ListRejectedAsync( + string q, + int offset, + int limit, + CancellationToken ct) + { + int clampedOffset = Math.Max(0, offset); + int clampedLimit = Math.Clamp(limit, 1, MaxPageSize); + string query = (q ?? string.Empty).Trim().ToLowerInvariant(); + + if (query.Length == 0) + { + int total = await store.CountAsync(ct); + IReadOnlyList page = await store.ListPageAsync(clampedOffset, clampedLimit, ct); + return new RejectedPageDto(page, total, clampedOffset, clampedLimit); + } + + IReadOnlyList candidates = await store.SearchAsync(query, clampedLimit, clampedLimit * 2, ct); + IReadOnlyList items = candidates.Skip(clampedOffset).Take(clampedLimit).ToList(); + + return new RejectedPageDto(items, candidates.Count, clampedOffset, clampedLimit); + } + + + /// + /// Возвращает отсеянное сообщение в обработку + /// + /// Id записи отсева (r_...). + /// Причина возврата (trim, ≤500; пишется на запись для аудита). + /// null — записи нет (404); иначе результат: Error (400) либо {id, returned:true, returnedAt}. + public async Task ReturnAsync( + string rejectedId, + string reason, + CancellationToken ct) + { + RejectedItemDto? row = await store.GetAsync(rejectedId, ct); + if (row is null) + { + return null; + } + + if (row.Returned) + { + return new RejectReturnResultDto(AlreadyReturnedDetail, rejectedId, false, 0); + } + + if (row.DecidedBy == DuplicateSource) + { + return new RejectReturnResultDto(DuplicateReturnDetail, rejectedId, false, 0); + } + + string text = row.Text.Trim(); + if (text.Length == 0) + { + return new RejectReturnResultDto(EmptyReturnTextDetail, rejectedId, false, 0); + } + + if (SpamStages.Contains(row.Stage)) + { + await mlClient.PushAsync(text, MlLearningLabels.Spam, SpamUnlearnDelta, ct); + } + + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + await store.MarkReturnedAsync( + rejectedId, + MessageTextCleaner.SliceCodePoints(reason.Trim(), MaxReturnReasonLength), + DateTimeOffset.FromUnixTimeMilliseconds(nowMs), + ct); + + var extra = new Dictionary(row.Source.Extra ?? new Dictionary()) + { + [SourceRefs.HueKey] = row.Source.ResolveHue(), + }; + SourceRef source = row.Source with { Extra = extra }; + + await ingest.EnqueueAsync(new QueuedMessage + { + Item = new SourceItem + { + Source = source, + Content = row.Content with { Text = text }, + }, + Force = true, + }, ct); + + return new RejectReturnResultDto(null, rejectedId, true, nowMs); + } + + + /// + /// Удаляет одну запись отсева безвозвратно. + /// + /// Id записи (r_...). + /// Задача завершается после удаления. + public Task DeleteAsync(string rejectedId, CancellationToken ct) + { + return store.DeleteAsync(rejectedId, ct); + } + + /// + /// Полная ручная очистка отсева. + /// + /// Сколько записей удалено (0 — отсев пуст). + public Task ClearAsync(CancellationToken ct) + { + return store.ClearAsync(ct); + } + + /// + /// Автоочистка отсева + /// + /// Сколько записей удалено. + public Task PurgeExpiredAsync(CancellationToken ct) + { + DateTimeOffset olderThan = DateTimeOffset.UtcNow.AddDays(-PipelineRejectConstants.RetentionDays); + return store.PurgeExpiredAsync(olderThan, ct); + } + + + /// + /// Сводка вкладки «Обработка» — форма GET /api/pipeline/stats + /// + /// Счётчики очереди и число записей отсева. + public async Task StatsAsync(CancellationToken ct) + { + QueueCountsDto queue = await QueueCountsAsync(ct); + int rejected = await store.CountAsync(ct); + return new PipelineStatsDto(queue, rejected); + } +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Checks.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Checks.cs index 06e239e..ad7adf1 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Checks.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Checks.cs @@ -1,120 +1,120 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.ColumnRules; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Проверки воркера — partial-часть -/// -public sealed partial class PipelineWorkerService -{ - // ── Проверки воркера ─────────────────────────────────────────────────── - - private bool IsStaleAsync(QueueItemDto row, WorkerRunSettings run) - { - if (row.MsgAtMs == 0) - { - return false; // python: not msg_at → False (L831–832) - } - - if (!run.AutoArchive) - { - return false; - } - - if (run.ArchiveAfterDays <= 0) - { - return false; - } - - long maxAgeMs = run.ArchiveAfterDays * DayMs; - long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - return nowMs - row.MsgAtMs > maxAgeMs; - } - - private Task SkipNoBudgetAsync( - AiParsedCardDto parsed, - string text, - WorkerRunSettings run, - CancellationToken ct) - { - if (!run.BudgetRequiredHire && !run.BudgetRequiredOrder) - { - return Task.FromResult(false); - } - - bool isHire = parsed.IsVacancy; - if ((isHire && !run.BudgetRequiredHire) || (!isHire && !run.BudgetRequiredOrder)) - { - return Task.FromResult(false); - } - - if (parsed.Budget is not null) - { - return Task.FromResult(false); - } - - return Task.FromResult(AmountParser.Parse(text).Count == 0); - } - - private static IReadOnlyList MergeMlTerms(IReadOnlyList terms, IReadOnlyList stack) - { - var merged = new List(stack); - int added = 0; - foreach (string term in terms) - { - string cleaned = (term ?? string.Empty).Trim().Trim('@', '+', '#', '.'); - if (cleaned.Length < 2 || cleaned.StartsWith("~", StringComparison.Ordinal)) - { - continue; - } - - string lower = cleaned.ToLowerInvariant(); - if (MessageListNormalizer.StackStopWords.Contains(lower)) - { - continue; - } - - if (merged.Any(item => item.ToLowerInvariant() == lower)) - { - continue; - } - - merged.Add(cleaned); - added++; - if (added >= MaxMlTermsAdded) - { - break; - } - } - - return merged; - } - - private async Task PredictSafelyAsync(string text, CancellationToken ct) - { - try - { - return await _mlClient.PredictAsync(text, ct); - } - catch (Exception) - { - return NotReadyPrediction; - } - } - - private async Task FilterSafelyAsync(string text, CancellationToken ct) - { - try - { - return await _aiClassifier.FilterAsync(text, ct); - } - catch (Exception) - { - return PassSkipped; - } - } - -} +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Проверки воркера — partial-часть +/// +public sealed partial class PipelineWorkerService +{ + // ── Проверки воркера ─────────────────────────────────────────────────── + + private bool IsStaleAsync(QueueItemDto row, WorkerRunSettings run) + { + if (row.MsgAtMs == 0) + { + return false; // python: not msg_at → False (L831–832) + } + + if (!run.AutoArchive) + { + return false; + } + + if (run.ArchiveAfterDays <= 0) + { + return false; + } + + long maxAgeMs = run.ArchiveAfterDays * DayMs; + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return nowMs - row.MsgAtMs > maxAgeMs; + } + + private Task SkipNoBudgetAsync( + AiParsedCardDto parsed, + string text, + WorkerRunSettings run, + CancellationToken ct) + { + if (!run.BudgetRequiredHire && !run.BudgetRequiredOrder) + { + return Task.FromResult(false); + } + + bool isHire = parsed.IsVacancy; + if ((isHire && !run.BudgetRequiredHire) || (!isHire && !run.BudgetRequiredOrder)) + { + return Task.FromResult(false); + } + + if (parsed.Budget is not null) + { + return Task.FromResult(false); + } + + return Task.FromResult(AmountParser.Parse(text).Count == 0); + } + + private static IReadOnlyList MergeMlTerms(IReadOnlyList terms, IReadOnlyList stack) + { + var merged = new List(stack); + int added = 0; + foreach (string term in terms) + { + string cleaned = (term ?? string.Empty).Trim().Trim('@', '+', '#', '.'); + if (cleaned.Length < 2 || cleaned.StartsWith("~", StringComparison.Ordinal)) + { + continue; + } + + string lower = cleaned.ToLowerInvariant(); + if (MessageListNormalizer.StackStopWords.Contains(lower)) + { + continue; + } + + if (merged.Any(item => item.ToLowerInvariant() == lower)) + { + continue; + } + + merged.Add(cleaned); + added++; + if (added >= MaxMlTermsAdded) + { + break; + } + } + + return merged; + } + + private async Task PredictSafelyAsync(string text, CancellationToken ct) + { + try + { + return await _mlClient.PredictAsync(text, ct); + } + catch (Exception) + { + return NotReadyPrediction; + } + } + + private async Task FilterSafelyAsync(string text, CancellationToken ct) + { + try + { + return await _aiClassifier.FilterAsync(text, ct); + } + catch (Exception) + { + return PassSkipped; + } + } + +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Decisions.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Decisions.cs index af52578..a724750 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Decisions.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Decisions.cs @@ -1,58 +1,58 @@ -using System.Text.Json; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// KV-счётчики решений — partial-часть -/// -public sealed partial class PipelineWorkerService -{ - - private async Task TrackDecisionsAsync(PumpState state, CancellationToken ct) - { - int ml = state.MlStored + state.MlDrop; - int ai = state.AiStored + state.AiDrop; - if (ml > 0) - { - await IncrementCounterAsync(SettingsKeys.MlDecisions, ml, ct); - } - - if (ai > 0) - { - await IncrementCounterAsync(SettingsKeys.AiDecisions, ai, ct); - } - } - - // Read-modify-write целочисленного KV-счётчика (отсутствие/повреждение строки → 0). - // key: Ключ счётчика (mlDecisions|aiDecisions). - // delta: Приращение (>0 — только такие и пишем). - // ct: Токен отмены. - private async Task IncrementCounterAsync( - string key, - int delta, - CancellationToken ct) - { - SettingValue? row = await _settings.GetAsync(key, ct); - int current = 0; - if (row is not null) - { - try - { - using JsonDocument document = JsonDocument.Parse(row.ValueJson); - if (document.RootElement.ValueKind == JsonValueKind.Number - && document.RootElement.TryGetInt64(out long wide)) - { - current = (int)Math.Clamp(wide, int.MinValue, int.MaxValue); - } - } - catch (JsonException) - { - // Повреждённая строка — 0 (мягкая семантика, как LocalMlClient). - } - } - - await _settings.SetAsync(key, JsonSerializer.Serialize(current + delta), ct); - } - -} +using System.Text.Json; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// KV-счётчики решений — partial-часть +/// +public sealed partial class PipelineWorkerService +{ + + private async Task TrackDecisionsAsync(PumpState state, CancellationToken ct) + { + int ml = state.MlStored + state.MlDrop; + int ai = state.AiStored + state.AiDrop; + if (ml > 0) + { + await IncrementCounterAsync(SettingsKeys.MlDecisions, ml, ct); + } + + if (ai > 0) + { + await IncrementCounterAsync(SettingsKeys.AiDecisions, ai, ct); + } + } + + // Read-modify-write целочисленного KV-счётчика (отсутствие/повреждение строки → 0). + // key: Ключ счётчика (mlDecisions|aiDecisions). + // delta: Приращение (>0 — только такие и пишем). + // ct: Токен отмены. + private async Task IncrementCounterAsync( + string key, + int delta, + CancellationToken ct) + { + SettingValue? row = await _settings.GetAsync(key, ct); + int current = 0; + if (row is not null) + { + try + { + using JsonDocument document = JsonDocument.Parse(row.ValueJson); + if (document.RootElement.ValueKind == JsonValueKind.Number + && document.RootElement.TryGetInt64(out long wide)) + { + current = (int)Math.Clamp(wide, int.MinValue, int.MaxValue); + } + } + catch (JsonException) + { + // Повреждённая строка — 0 (мягкая семантика, как LocalMlClient). + } + } + + await _settings.SetAsync(key, JsonSerializer.Serialize(current + delta), ct); + } + +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.DryRun.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.DryRun.cs index b3282a0..1bd7bb0 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.DryRun.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.DryRun.cs @@ -1,206 +1,206 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.ColumnRules; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Services; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Сухой прогон текста по конвейеру — partial-часть -/// -public sealed partial class PipelineWorkerService -{ - private const string StageExcludeDry = "exclude"; - - private const string StageMlDry = "ml"; - - private const string StageAiDry = "ai"; - - private const string StageBudgetDry = "budget"; - - private const string EmptyTextReason = "пустое сообщение"; - - private const string MlSpamDryReason = "ML уверен, что это спам/не заявка"; - - /// - /// Прогоняет текст по этапам конвейера без записи в систему. - /// - /// Текст для проверки. - /// Токен отмены. - /// Результаты этапов, разбор и целевой контейнер (если карточка была бы создана). - public async Task DryRunAsync(string text, CancellationToken ct) - { - string trimmed = (text ?? string.Empty).Trim(); - var stages = new List(); - - if (trimmed.Length == 0) - { - stages.Add(new PipelineDryRunStageDto(SourceStop, false, false, EmptyTextReason)); - return Rejected(stages); - } - - WorkerRunSettings run = await LoadRunSettingsAsync(_settings, ct); - - IncomingRulesResult stop = await _rules.CheckAsync(trimmed, ct); - stages.Add(new PipelineDryRunStageDto(StopStageCode(stop), stop.Pass, false, stop.Reason ?? string.Empty, stop.Kw)); - if (!stop.Pass) - { - return Rejected(stages); - } - - GlobalExclusionResult? exclusion = GlobalExclusionRules.Match(trimmed, run.GlobalExclusions); - if (exclusion is not null) - { - stages.Add(new PipelineDryRunStageDto(StageExcludeDry, false, false, exclusion.Reason, exclusion.Kw)); - return Rejected(stages); - } - - stages.Add(new PipelineDryRunStageDto(StageExcludeDry, true, false, string.Empty)); - - PipelineDryRunDto? mlRejection = await CheckMlAsync(trimmed, run, stages, ct); - if (mlRejection is not null) - { - return mlRejection; - } - - AiParsedCardDto? parsed = await ResolveParsedAsync(trimmed, run, stages, ct); - if (parsed is null) - { - return Rejected(stages); - } - - if (await SkipNoBudgetAsync(parsed, trimmed, run, ct)) - { - stages.Add(new PipelineDryRunStageDto(StageBudgetDry, false, false, NoBudgetReason)); - return Rejected(stages, parsed); - } - - stages.Add(new PipelineDryRunStageDto(StageBudgetDry, true, false, string.Empty)); - - (string target, IReadOnlyList hits) = await ResolveTargetAsync(parsed, trimmed, run, ct); - return new PipelineDryRunDto(true, true, target, hits, parsed, stages); - } - - // Этап ML: спам-вердикт и несовпадение типа возвращают отказ, иначе этап отмечен пройденным. - private async Task CheckMlAsync( - string text, - WorkerRunSettings run, - List stages, - CancellationToken ct) - { - if (!run.MlEnabled) - { - stages.Add(new PipelineDryRunStageDto(StageMlDry, true, true, string.Empty)); - return null; - } - - MlPredictResultDto decision = await PredictSafelyAsync(text, ct); - if (decision.Ready && decision.Take && decision.Label == MlLearningLabels.Spam) - { - stages.Add(new PipelineDryRunStageDto(StageMlDry, false, false, MlSpamDryReason, Label: decision.Label)); - return Rejected(stages); - } - - stages.Add(new PipelineDryRunStageDto(StageMlDry, true, false, string.Empty, Label: decision.Label)); - - if (decision.Ready && decision.Type is { Take: true } typeDecision) - { - bool isHire = typeDecision.Label == MlLearningLabels.TypeHireLabel; - if (WantedTypeRejects(run.WantedType, isHire)) - { - string kindName = isHire ? MlHireKindName : MlOrderKindName; - string reason = string.Format(MlTypeDropReasonFormat, kindName, run.WantedType); - stages.Add(new PipelineDryRunStageDto(StageType, false, false, reason, Label: typeDecision.Label)); - return Rejected(stages); - } - } - - return null; - } - - // Этап ИИ: фильтр, классификация и спам-вердикт; выключенный ИИ — локальный разбор. - private async Task ResolveParsedAsync( - string text, - WorkerRunSettings run, - List stages, - CancellationToken ct) - { - if (!run.AiEnabled) - { - stages.Add(new PipelineDryRunStageDto(StageAiDry, true, true, string.Empty)); - return AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text); - } - - AiFilterResultDto filter = run.AiFilterEnabled ? await FilterSafelyAsync(text, ct) : PassSkipped; - stages.Add(new PipelineDryRunStageDto(StageAiDry, filter.Pass, filter.Skipped, filter.Reason ?? string.Empty)); - if (!filter.Pass) - { - return null; - } - - AiParsedCardDto? parsed = null; - try - { - parsed = await _aiClassifier.ClassifyAsync(text, ct); - } - catch (Exception) - { - parsed = null; - } - - if (parsed is not null) - { - parsed = parsed with { IsVacancyKnown = true }; - if (parsed.IsSpam) - { - stages.Add(new PipelineDryRunStageDto(StageSpamAi, false, false, AiSpamReason)); - return null; - } - - return parsed; - } - - return AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text); - } - - // Целевой контейнер: доска разбора при соответствии правилам, иначе «Неразобранное». - private async Task<(string Target, IReadOnlyList Hits)> ResolveTargetAsync( - AiParsedCardDto parsed, - string text, - WorkerRunSettings run, - CancellationToken ct) - { - string? boardCandidate = string.IsNullOrWhiteSpace(parsed.Board) ? null : parsed.Board.Trim(); - if (boardCandidate is null) - { - return (CardIds.Inbox, []); - } - - IReadOnlyDictionary rates = run.Settings.TryGetRatesCache()?.Rates ?? MockRates.Values; - ContainerDto? board = await _kanjStore.GetContainerAsync(boardCandidate, ct); - return board is not null && ColumnRules.ContainerAccepts(board.Rules, text, rates) - ? (board.Id, ColumnRules.ComputeHits(board.Rules, text, rates)) - : (CardIds.Inbox, []); - } - - private static PipelineDryRunDto Rejected( - IReadOnlyList stages, - AiParsedCardDto? parsed = null) => - new(false, false, null, [], parsed, stages); - - private static string StopStageCode(IncomingRulesResult stop) => - stop.Kind.Length > 0 ? stop.Kind : SourceStop; - - private static bool WantedTypeRejects(string wanted, bool isHire) => - wanted switch - { - WantedTypeFreelance => isHire, - WantedTypeVacancy => !isHire, - _ => false, - }; -} +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Сухой прогон текста по конвейеру — partial-часть +/// +public sealed partial class PipelineWorkerService +{ + private const string StageExcludeDry = "exclude"; + + private const string StageMlDry = "ml"; + + private const string StageAiDry = "ai"; + + private const string StageBudgetDry = "budget"; + + private const string EmptyTextReason = "пустое сообщение"; + + private const string MlSpamDryReason = "ML уверен, что это спам/не заявка"; + + /// + /// Прогоняет текст по этапам конвейера без записи в систему. + /// + /// Текст для проверки. + /// Токен отмены. + /// Результаты этапов, разбор и целевой контейнер (если карточка была бы создана). + public async Task DryRunAsync(string text, CancellationToken ct) + { + string trimmed = (text ?? string.Empty).Trim(); + var stages = new List(); + + if (trimmed.Length == 0) + { + stages.Add(new PipelineDryRunStageDto(SourceStop, false, false, EmptyTextReason)); + return Rejected(stages); + } + + WorkerRunSettings run = await LoadRunSettingsAsync(_settings, ct); + + IncomingRulesResult stop = await _rules.CheckAsync(trimmed, ct); + stages.Add(new PipelineDryRunStageDto(StopStageCode(stop), stop.Pass, false, stop.Reason ?? string.Empty, stop.Kw)); + if (!stop.Pass) + { + return Rejected(stages); + } + + GlobalExclusionResult? exclusion = GlobalExclusionRules.Match(trimmed, run.GlobalExclusions); + if (exclusion is not null) + { + stages.Add(new PipelineDryRunStageDto(StageExcludeDry, false, false, exclusion.Reason, exclusion.Kw)); + return Rejected(stages); + } + + stages.Add(new PipelineDryRunStageDto(StageExcludeDry, true, false, string.Empty)); + + PipelineDryRunDto? mlRejection = await CheckMlAsync(trimmed, run, stages, ct); + if (mlRejection is not null) + { + return mlRejection; + } + + AiParsedCardDto? parsed = await ResolveParsedAsync(trimmed, run, stages, ct); + if (parsed is null) + { + return Rejected(stages); + } + + if (await SkipNoBudgetAsync(parsed, trimmed, run, ct)) + { + stages.Add(new PipelineDryRunStageDto(StageBudgetDry, false, false, NoBudgetReason)); + return Rejected(stages, parsed); + } + + stages.Add(new PipelineDryRunStageDto(StageBudgetDry, true, false, string.Empty)); + + (string target, IReadOnlyList hits) = await ResolveTargetAsync(parsed, trimmed, run, ct); + return new PipelineDryRunDto(true, true, target, hits, parsed, stages); + } + + // Этап ML: спам-вердикт и несовпадение типа возвращают отказ, иначе этап отмечен пройденным. + private async Task CheckMlAsync( + string text, + WorkerRunSettings run, + List stages, + CancellationToken ct) + { + if (!run.MlEnabled) + { + stages.Add(new PipelineDryRunStageDto(StageMlDry, true, true, string.Empty)); + return null; + } + + MlPredictResultDto decision = await PredictSafelyAsync(text, ct); + if (decision.Ready && decision.Take && decision.Label == MlLearningLabels.Spam) + { + stages.Add(new PipelineDryRunStageDto(StageMlDry, false, false, MlSpamDryReason, Label: decision.Label)); + return Rejected(stages); + } + + stages.Add(new PipelineDryRunStageDto(StageMlDry, true, false, string.Empty, Label: decision.Label)); + + if (decision.Ready && decision.Type is { Take: true } typeDecision) + { + bool isHire = typeDecision.Label == MlLearningLabels.TypeHireLabel; + if (WantedTypeRejects(run.WantedType, isHire)) + { + string kindName = isHire ? MlHireKindName : MlOrderKindName; + string reason = string.Format(MlTypeDropReasonFormat, kindName, run.WantedType); + stages.Add(new PipelineDryRunStageDto(StageType, false, false, reason, Label: typeDecision.Label)); + return Rejected(stages); + } + } + + return null; + } + + // Этап ИИ: фильтр, классификация и спам-вердикт; выключенный ИИ — локальный разбор. + private async Task ResolveParsedAsync( + string text, + WorkerRunSettings run, + List stages, + CancellationToken ct) + { + if (!run.AiEnabled) + { + stages.Add(new PipelineDryRunStageDto(StageAiDry, true, true, string.Empty)); + return AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text); + } + + AiFilterResultDto filter = run.AiFilterEnabled ? await FilterSafelyAsync(text, ct) : PassSkipped; + stages.Add(new PipelineDryRunStageDto(StageAiDry, filter.Pass, filter.Skipped, filter.Reason ?? string.Empty)); + if (!filter.Pass) + { + return null; + } + + AiParsedCardDto? parsed = null; + try + { + parsed = await _aiClassifier.ClassifyAsync(text, ct); + } + catch (Exception) + { + parsed = null; + } + + if (parsed is not null) + { + parsed = parsed with { IsVacancyKnown = true }; + if (parsed.IsSpam) + { + stages.Add(new PipelineDryRunStageDto(StageSpamAi, false, false, AiSpamReason)); + return null; + } + + return parsed; + } + + return AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text); + } + + // Целевой контейнер: доска разбора при соответствии правилам, иначе «Неразобранное». + private async Task<(string Target, IReadOnlyList Hits)> ResolveTargetAsync( + AiParsedCardDto parsed, + string text, + WorkerRunSettings run, + CancellationToken ct) + { + string? boardCandidate = string.IsNullOrWhiteSpace(parsed.Board) ? null : parsed.Board.Trim(); + if (boardCandidate is null) + { + return (CardIds.Inbox, []); + } + + IReadOnlyDictionary rates = run.Settings.TryGetRatesCache()?.Rates ?? MockRates.Values; + ContainerDto? board = await _kanjStore.GetContainerAsync(boardCandidate, ct); + return board is not null && ColumnRules.ContainerAccepts(board.Rules, text, rates) + ? (board.Id, ColumnRules.ComputeHits(board.Rules, text, rates)) + : (CardIds.Inbox, []); + } + + private static PipelineDryRunDto Rejected( + IReadOnlyList stages, + AiParsedCardDto? parsed = null) => + new(false, false, null, [], parsed, stages); + + private static string StopStageCode(IncomingRulesResult stop) => + stop.Kind.Length > 0 ? stop.Kind : SourceStop; + + private static bool WantedTypeRejects(string wanted, bool isHire) => + wanted switch + { + WantedTypeFreelance => isHire, + WantedTypeVacancy => !isHire, + _ => false, + }; +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Learning.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Learning.cs index e0a6d14..0897f68 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Learning.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Learning.cs @@ -1,47 +1,47 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Models; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Карточка и обучение ML — partial-часть -/// -public sealed partial class PipelineWorkerService -{ - // ── Карточка и обучение ML ──────────────────────────────────────────── - - private async Task CreateCardAndDropAsync( - PumpState state, - AiParsedCardDto parsed, - QueueItemDto row, - string digest, - bool isAiPath, - CancellationToken ct) - { - CardDto card = await _cardWriter.CreateCardAsync(parsed, row, digest, ct); - state.Created.Add(card); - await _store.RemoveAsync(row.Id, ct); - if (isAiPath) - { - state.AiStored++; - } - else - { - state.MlStored++; - } - - return card; - } - - private Task LearnFromAiCardAsync( - CardDto card, - AiParsedCardDto parsed, - string text, - CancellationToken ct) - { - // Логика сигналов вынесена в общий AiCardLearning (её же использует ручная переклассификация). - return AiCardLearning.PushSignalsAsync(_kanjStore, _mlClient, card.Col, parsed, text, MlLearningLabels.AiPushWeight, ct); - } - -} +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Pipeline.Application.Models; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Карточка и обучение ML — partial-часть +/// +public sealed partial class PipelineWorkerService +{ + // ── Карточка и обучение ML ──────────────────────────────────────────── + + private async Task CreateCardAndDropAsync( + PumpState state, + AiParsedCardDto parsed, + QueueItemDto row, + string digest, + bool isAiPath, + CancellationToken ct) + { + CardDto card = await _cardWriter.CreateCardAsync(parsed, row, digest, ct); + state.Created.Add(card); + await _store.RemoveAsync(row.Id, ct); + if (isAiPath) + { + state.AiStored++; + } + else + { + state.MlStored++; + } + + return card; + } + + private Task LearnFromAiCardAsync( + CardDto card, + AiParsedCardDto parsed, + string text, + CancellationToken ct) + { + // Логика сигналов вынесена в общий AiCardLearning (её же использует ручная переклассификация). + return AiCardLearning.PushSignalsAsync(_kanjStore, _mlClient, card.Col, parsed, text, MlLearningLabels.AiPushWeight, ct); + } + +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Pump.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Pump.cs index 86175d0..91c9cb5 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Pump.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Pump.cs @@ -1,287 +1,287 @@ -using System.Globalization; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.ColumnRules; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Проход pump — partial-часть -/// -public sealed partial class PipelineWorkerService -{ - // ── Проход воркера ───────────────────────────────────────────────────── - - /// - /// Один проход по очереди - /// - /// Сводка прохода: счётчики решений + созданные карточки (SSE new_card). - public async Task PumpOnceAsync(CancellationToken ct) - { - WorkerRunSettings runSettings = await LoadRunSettingsAsync(_settings, ct); - var state = new PumpState(); - await PumpNewPassAsync(state, runSettings, ct); - await PumpFilteredPassAsync(state, runSettings, ct); - await TrackDecisionsAsync(state, ct); - return state.ToResult(); - } - - - private async Task PumpNewPassAsync( - PumpState state, - WorkerRunSettings run, - CancellationToken ct) - { - IReadOnlyList rows = await _store.ListAsync(PipelineQueueStatuses.New, NewBatchLimit, ct); - foreach (QueueItemDto row in rows) - { - bool force = row.Force; - - if (!force && IsStaleAsync(row, run)) - { - await DropStaleAsync(row, run, ct); - continue; - } - - if (!force) - { - IncomingRulesResult verdict = await _rules.CheckAsync(row.Text, ct); - if (!verdict.Pass) - { - await RejectRowAsync(row, SourceStop, verdict.Kind, verdict.Reason ?? string.Empty, verdict.Kw, ct); - await DropRowAsync(row, DedupHasher.Hash(row.Text), withDedup: true, ct); - continue; - } - - GlobalExclusionResult? exclusion = GlobalExclusionRules.Match(row.Text, run.GlobalExclusions); - if (exclusion is not null) - { - await RejectRowAsync(row, SourceStop, exclusion.Kind, exclusion.Reason, exclusion.Kw, ct); - await DropRowAsync(row, DedupHasher.Hash(row.Text), withDedup: true, ct); - continue; - } - } - - string digest = DedupHasher.Hash(row.Text); - if (await _store.ExistsAsync(digest, ct)) - { - await RejectRowAsync(row, SourceDup, StageDup, DupReason, string.Empty, ct); - await DropRowAsync(row, digest, withDedup: true, ct); - continue; - } - - if (!await _store.ClaimAsync(digest, ct)) - { - await RejectRowAsync(row, SourceDup, StageDup, DupReason, string.Empty, ct); - await _store.RemoveAsync(row.Id, ct); - continue; - } - - state.Staged++; - - if (run.MlEnabled && !force) - { - MlPredictResultDto decision = await PredictSafelyAsync(row.Text, ct); - string? label = decision.Label; - - if (decision.Ready && decision.Take && !string.IsNullOrEmpty(label)) - { - if (label == MlLearningLabels.Spam) - { - double score = decision.Scores.TryGetValue(MlLearningLabels.Spam, out double value) ? value : 0.0; - string reason = string.Format( - CultureInfo.InvariantCulture, - MlSpamReasonFormat, - score.ToString("F2", CultureInfo.InvariantCulture)); - await RejectRowAsync(row, SourceMl, StageSpamMl, reason, string.Empty, ct); - await DropRowAsync(row, digest, withDedup: true, ct); - state.MlDrop++; - continue; - } - - ContainerDto? board = await _kanjStore.GetContainerAsync(label, ct); - bool boardAllowed = board is not null && !board.Suggested && !ColumnRules.HasActiveRules(board.Rules); - if (boardAllowed) - { - // Карточка из локальных полей + доска ML + тип ML (если уверен) + термины в стек. - LocalParsedFields fields = _fieldsParser.Parse(row.Text, run.Settings); - AiParsedCardDto parsed = AiCardMapper.FromLocal(fields, row.Text) with { Board = label }; - if (decision.Type is { Take: true } mlType) - { - parsed = parsed with - { - IsVacancy = mlType.Label == MlLearningLabels.TypeHireLabel, - IsVacancyKnown = true, - }; - } - - parsed = parsed with { Stack = MergeMlTerms(decision.Terms, parsed.Stack) }; - if (await SkipNoBudgetAsync(parsed, row.Text, run, ct)) - { - state.NoBudget++; - await RejectRowAsync(row, SourceStop, StageBudget, NoBudgetReason, string.Empty, ct); - await DropRowAsync(row, digest, withDedup: true, ct); - continue; - } - - await CreateCardAndDropAsync(state, parsed, row, digest, isAiPath: false, ct); - continue; - } - } - - if (decision.Ready && decision.Type is { Take: true } typeDecision) - { - bool isHire = typeDecision.Label == MlLearningLabels.TypeHireLabel; - string wanted = run.WantedType; - - if (wanted is WantedTypeVacancy or WantedTypeFreelance) - { - bool bad = (wanted == WantedTypeFreelance && isHire) - || (wanted == WantedTypeVacancy && !isHire); - if (bad) - { - string kindName = isHire ? MlHireKindName : MlOrderKindName; - string reason = string.Format(MlTypeDropReasonFormat, kindName, wanted); - await RejectRowAsync(row, SourceMl, StageType, reason, string.Empty, ct); - await DropRowAsync(row, digest, withDedup: true, ct); - state.TypeDrop++; - continue; - } - } - - if (!run.AiEnabled) - { - LocalParsedFields fields = _fieldsParser.Parse(row.Text, run.Settings); - AiParsedCardDto parsed = AiCardMapper.FromLocal(fields, row.Text) with - { - IsVacancy = isHire, - IsVacancyKnown = true, - }; - - if (await SkipNoBudgetAsync(parsed, row.Text, run, ct)) - { - state.NoBudget++; - await RejectRowAsync(row, SourceStop, StageBudget, NoBudgetReason, string.Empty, ct); - await DropRowAsync(row, digest, withDedup: true, ct); - continue; - } - - await CreateCardAndDropAsync(state, parsed, row, digest, isAiPath: false, ct); - continue; - } - } - } - - await _store.SetStatusAsync(row.Id, PipelineQueueStatuses.Filtered, ct); - } - } - - - private async Task PumpFilteredPassAsync( - PumpState state, - WorkerRunSettings run, - CancellationToken ct) - { - IReadOnlyList rows = await _store.ListAsync(PipelineQueueStatuses.Filtered, FilteredBatchLimit, ct); - foreach (QueueItemDto row in rows) - { - bool force = row.Force; - - if (!force && IsStaleAsync(row, run)) - { - await DropStaleAsync(row, run, ct); - continue; - } - - string text = row.Text; - string digest = DedupHasher.Hash(text); - - if (!run.AiEnabled) - { - // Карточку собирает локальный разбор (без вызова порта ИИ). - AiParsedCardDto localParsed = AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text); - if (!force && await SkipNoBudgetAsync(localParsed, text, run, ct)) - { - state.NoBudget++; - await RejectRowAsync(row, SourceStop, StageBudget, NoBudgetReason, string.Empty, ct); - await DropRowAsync(row, digest, withDedup: true, ct); - continue; - } - - await CreateCardAndDropAsync(state, localParsed, row, digest, isAiPath: true, ct); - continue; - } - - AiFilterResultDto filter; - if (force) - { - filter = PassSkipped; - } - else - { - bool filterEnabled = run.AiFilterEnabled; - filter = filterEnabled ? await FilterSafelyAsync(text, ct) : PassSkipped; - } - - // Классификация: только если фильтр пропустил; сбой — «разбора нет» (локальный путь ниже). - AiParsedCardDto? parsed = null; - if (filter.Pass) - { - try - { - parsed = await _aiClassifier.ClassifyAsync(text, ct); - } - catch (Exception) - { - parsed = null; - } - - if (parsed is not null) - { - parsed = parsed with { IsVacancyKnown = true }; - } - } - - bool isSpam = !filter.Pass || (parsed?.IsSpam ?? false); - if (force && filter.Pass && parsed?.IsSpam == true) - { - parsed = parsed with { IsSpam = false }; - isSpam = false; - } - - if (isSpam) - { - string stage = !filter.Pass ? StageFilterAi : StageSpamAi; - string reason = !filter.Pass - ? AiFilterReasonPrefix + (string.IsNullOrEmpty(filter.Reason) ? AiFilterDefaultReason : filter.Reason) - : AiSpamReason; - await RejectRowAsync(row, SourceAi, stage, reason, string.Empty, ct); - await _mlClient.PushAsync(text, MlLearningLabels.Spam, MlLearningLabels.AiPushWeight, ct); - await DropRowAsync(row, digest, withDedup: true, ct); - state.AiDrop++; - continue; - } - - if (parsed is null) - { - parsed = AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text); - state.AiFail++; - } - - if (!force && await SkipNoBudgetAsync(parsed, text, run, ct)) - { - state.NoBudget++; - await RejectRowAsync(row, SourceStop, StageBudget, NoBudgetReason, string.Empty, ct); - await DropRowAsync(row, digest, withDedup: true, ct); - continue; - } - - CardDto card = await CreateCardAndDropAsync(state, parsed, row, digest, isAiPath: true, ct); - await LearnFromAiCardAsync(card, parsed, text, ct); - } - } - -} +using System.Globalization; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Проход pump — partial-часть +/// +public sealed partial class PipelineWorkerService +{ + // ── Проход воркера ───────────────────────────────────────────────────── + + /// + /// Один проход по очереди + /// + /// Сводка прохода: счётчики решений + созданные карточки (SSE new_card). + public async Task PumpOnceAsync(CancellationToken ct) + { + WorkerRunSettings runSettings = await LoadRunSettingsAsync(_settings, ct); + var state = new PumpState(); + await PumpNewPassAsync(state, runSettings, ct); + await PumpFilteredPassAsync(state, runSettings, ct); + await TrackDecisionsAsync(state, ct); + return state.ToResult(); + } + + + private async Task PumpNewPassAsync( + PumpState state, + WorkerRunSettings run, + CancellationToken ct) + { + IReadOnlyList rows = await _store.ListAsync(PipelineQueueStatuses.New, NewBatchLimit, ct); + foreach (QueueItemDto row in rows) + { + bool force = row.Force; + + if (!force && IsStaleAsync(row, run)) + { + await DropStaleAsync(row, run, ct); + continue; + } + + if (!force) + { + IncomingRulesResult verdict = await _rules.CheckAsync(row.Text, ct); + if (!verdict.Pass) + { + await RejectRowAsync(row, SourceStop, verdict.Kind, verdict.Reason ?? string.Empty, verdict.Kw, ct); + await DropRowAsync(row, DedupHasher.Hash(row.Text), withDedup: true, ct); + continue; + } + + GlobalExclusionResult? exclusion = GlobalExclusionRules.Match(row.Text, run.GlobalExclusions); + if (exclusion is not null) + { + await RejectRowAsync(row, SourceStop, exclusion.Kind, exclusion.Reason, exclusion.Kw, ct); + await DropRowAsync(row, DedupHasher.Hash(row.Text), withDedup: true, ct); + continue; + } + } + + string digest = DedupHasher.Hash(row.Text); + if (await _store.ExistsAsync(digest, ct)) + { + await RejectRowAsync(row, SourceDup, StageDup, DupReason, string.Empty, ct); + await DropRowAsync(row, digest, withDedup: true, ct); + continue; + } + + if (!await _store.ClaimAsync(digest, ct)) + { + await RejectRowAsync(row, SourceDup, StageDup, DupReason, string.Empty, ct); + await _store.RemoveAsync(row.Id, ct); + continue; + } + + state.Staged++; + + if (run.MlEnabled && !force) + { + MlPredictResultDto decision = await PredictSafelyAsync(row.Text, ct); + string? label = decision.Label; + + if (decision.Ready && decision.Take && !string.IsNullOrEmpty(label)) + { + if (label == MlLearningLabels.Spam) + { + double score = decision.Scores.TryGetValue(MlLearningLabels.Spam, out double value) ? value : 0.0; + string reason = string.Format( + CultureInfo.InvariantCulture, + MlSpamReasonFormat, + score.ToString("F2", CultureInfo.InvariantCulture)); + await RejectRowAsync(row, SourceMl, StageSpamMl, reason, string.Empty, ct); + await DropRowAsync(row, digest, withDedup: true, ct); + state.MlDrop++; + continue; + } + + ContainerDto? board = await _kanjStore.GetContainerAsync(label, ct); + bool boardAllowed = board is not null && !board.Suggested && !ColumnRules.HasActiveRules(board.Rules); + if (boardAllowed) + { + // Карточка из локальных полей + доска ML + тип ML (если уверен) + термины в стек. + LocalParsedFields fields = _fieldsParser.Parse(row.Text, run.Settings); + AiParsedCardDto parsed = AiCardMapper.FromLocal(fields, row.Text) with { Board = label }; + if (decision.Type is { Take: true } mlType) + { + parsed = parsed with + { + IsVacancy = mlType.Label == MlLearningLabels.TypeHireLabel, + IsVacancyKnown = true, + }; + } + + parsed = parsed with { Stack = MergeMlTerms(decision.Terms, parsed.Stack) }; + if (await SkipNoBudgetAsync(parsed, row.Text, run, ct)) + { + state.NoBudget++; + await RejectRowAsync(row, SourceStop, StageBudget, NoBudgetReason, string.Empty, ct); + await DropRowAsync(row, digest, withDedup: true, ct); + continue; + } + + await CreateCardAndDropAsync(state, parsed, row, digest, isAiPath: false, ct); + continue; + } + } + + if (decision.Ready && decision.Type is { Take: true } typeDecision) + { + bool isHire = typeDecision.Label == MlLearningLabels.TypeHireLabel; + string wanted = run.WantedType; + + if (wanted is WantedTypeVacancy or WantedTypeFreelance) + { + bool bad = (wanted == WantedTypeFreelance && isHire) + || (wanted == WantedTypeVacancy && !isHire); + if (bad) + { + string kindName = isHire ? MlHireKindName : MlOrderKindName; + string reason = string.Format(MlTypeDropReasonFormat, kindName, wanted); + await RejectRowAsync(row, SourceMl, StageType, reason, string.Empty, ct); + await DropRowAsync(row, digest, withDedup: true, ct); + state.TypeDrop++; + continue; + } + } + + if (!run.AiEnabled) + { + LocalParsedFields fields = _fieldsParser.Parse(row.Text, run.Settings); + AiParsedCardDto parsed = AiCardMapper.FromLocal(fields, row.Text) with + { + IsVacancy = isHire, + IsVacancyKnown = true, + }; + + if (await SkipNoBudgetAsync(parsed, row.Text, run, ct)) + { + state.NoBudget++; + await RejectRowAsync(row, SourceStop, StageBudget, NoBudgetReason, string.Empty, ct); + await DropRowAsync(row, digest, withDedup: true, ct); + continue; + } + + await CreateCardAndDropAsync(state, parsed, row, digest, isAiPath: false, ct); + continue; + } + } + } + + await _store.SetStatusAsync(row.Id, PipelineQueueStatuses.Filtered, ct); + } + } + + + private async Task PumpFilteredPassAsync( + PumpState state, + WorkerRunSettings run, + CancellationToken ct) + { + IReadOnlyList rows = await _store.ListAsync(PipelineQueueStatuses.Filtered, FilteredBatchLimit, ct); + foreach (QueueItemDto row in rows) + { + bool force = row.Force; + + if (!force && IsStaleAsync(row, run)) + { + await DropStaleAsync(row, run, ct); + continue; + } + + string text = row.Text; + string digest = DedupHasher.Hash(text); + + if (!run.AiEnabled) + { + // Карточку собирает локальный разбор (без вызова порта ИИ). + AiParsedCardDto localParsed = AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text); + if (!force && await SkipNoBudgetAsync(localParsed, text, run, ct)) + { + state.NoBudget++; + await RejectRowAsync(row, SourceStop, StageBudget, NoBudgetReason, string.Empty, ct); + await DropRowAsync(row, digest, withDedup: true, ct); + continue; + } + + await CreateCardAndDropAsync(state, localParsed, row, digest, isAiPath: true, ct); + continue; + } + + AiFilterResultDto filter; + if (force) + { + filter = PassSkipped; + } + else + { + bool filterEnabled = run.AiFilterEnabled; + filter = filterEnabled ? await FilterSafelyAsync(text, ct) : PassSkipped; + } + + // Классификация: только если фильтр пропустил; сбой — «разбора нет» (локальный путь ниже). + AiParsedCardDto? parsed = null; + if (filter.Pass) + { + try + { + parsed = await _aiClassifier.ClassifyAsync(text, ct); + } + catch (Exception) + { + parsed = null; + } + + if (parsed is not null) + { + parsed = parsed with { IsVacancyKnown = true }; + } + } + + bool isSpam = !filter.Pass || (parsed?.IsSpam ?? false); + if (force && filter.Pass && parsed?.IsSpam == true) + { + parsed = parsed with { IsSpam = false }; + isSpam = false; + } + + if (isSpam) + { + string stage = !filter.Pass ? StageFilterAi : StageSpamAi; + string reason = !filter.Pass + ? AiFilterReasonPrefix + (string.IsNullOrEmpty(filter.Reason) ? AiFilterDefaultReason : filter.Reason) + : AiSpamReason; + await RejectRowAsync(row, SourceAi, stage, reason, string.Empty, ct); + await _mlClient.PushAsync(text, MlLearningLabels.Spam, MlLearningLabels.AiPushWeight, ct); + await DropRowAsync(row, digest, withDedup: true, ct); + state.AiDrop++; + continue; + } + + if (parsed is null) + { + parsed = AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text); + state.AiFail++; + } + + if (!force && await SkipNoBudgetAsync(parsed, text, run, ct)) + { + state.NoBudget++; + await RejectRowAsync(row, SourceStop, StageBudget, NoBudgetReason, string.Empty, ct); + await DropRowAsync(row, digest, withDedup: true, ct); + continue; + } + + CardDto card = await CreateCardAndDropAsync(state, parsed, row, digest, isAiPath: true, ct); + await LearnFromAiCardAsync(card, parsed, text, ct); + } + } + +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Rejections.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Rejections.cs index f3af402..610cbfb 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Rejections.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Rejections.cs @@ -1,57 +1,57 @@ -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Отсев и удаление строк — partial-часть -/// -public sealed partial class PipelineWorkerService -{ - // ── Отсев / удаление строк ───────────────────────────────────────────── - - private Task RejectRowAsync( - QueueItemDto row, - string source, - string stage, - string reason, - string kw, - CancellationToken ct) - { - return _processing.RejectAsync(new RejectRecord - { - Source = row.Source, - Content = row.Content, - Text = row.Text, - MsgAtMs = row.MsgAtMs, - DecidedBy = source, - Stage = stage, - Reason = reason, - Kw = kw, - }, ct); - } - - private async Task DropRowAsync( - QueueItemDto row, - string digest, - bool withDedup, - CancellationToken ct) - { - if (withDedup) - { - await _store.DeleteClaimAsync(digest, ct); - } - - await _store.RemoveAsync(row.Id, ct); - } - - private async Task DropStaleAsync( - QueueItemDto row, - WorkerRunSettings run, - CancellationToken ct) - { - await RejectRowAsync(row, SourceStale, StageStale, string.Format(StaleReasonFormat, run.ArchiveAfterDays), string.Empty, ct); - await DropRowAsync(row, DedupHasher.Hash(row.Text), withDedup: true, ct); - } - -} +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Отсев и удаление строк — partial-часть +/// +public sealed partial class PipelineWorkerService +{ + // ── Отсев / удаление строк ───────────────────────────────────────────── + + private Task RejectRowAsync( + QueueItemDto row, + string source, + string stage, + string reason, + string kw, + CancellationToken ct) + { + return _processing.RejectAsync(new RejectRecord + { + Source = row.Source, + Content = row.Content, + Text = row.Text, + MsgAtMs = row.MsgAtMs, + DecidedBy = source, + Stage = stage, + Reason = reason, + Kw = kw, + }, ct); + } + + private async Task DropRowAsync( + QueueItemDto row, + string digest, + bool withDedup, + CancellationToken ct) + { + if (withDedup) + { + await _store.DeleteClaimAsync(digest, ct); + } + + await _store.RemoveAsync(row.Id, ct); + } + + private async Task DropStaleAsync( + QueueItemDto row, + WorkerRunSettings run, + CancellationToken ct) + { + await RejectRowAsync(row, SourceStale, StageStale, string.Format(StaleReasonFormat, run.ArchiveAfterDays), string.Empty, ct); + await DropRowAsync(row, DedupHasher.Hash(row.Text), withDedup: true, ct); + } + +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Settings.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Settings.cs index 2bc9fd6..0cbd40c 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Settings.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Settings.cs @@ -1,63 +1,63 @@ -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Снимок настроек прохода — partial-часть -/// -public sealed partial class PipelineWorkerService -{ - - private sealed record WorkerRunSettings( - TenantSettingsSnapshot Settings, - bool AutoArchive, - int ArchiveAfterDays, - bool MlEnabled, - bool AiEnabled, - bool AiFilterEnabled, - bool BudgetRequiredHire, - bool BudgetRequiredOrder, - string WantedType, - GlobalExcludeSettings GlobalExclusions); - - // Читает настройки воркера одним запросом (GetAllAsync) и резолвит значения с дефолтами - // SettingsDefaults через типизированный снимок (C30). - // settingsStore: KV-хранилище настроек тенанта. - // ct: Токен отмены. - // Возвращает: Снимок на проход pump (один на PumpOnce, передаётся в проверки обоих проходов). - private static async Task LoadRunSettingsAsync(ISettingsStore settingsStore, CancellationToken ct) - { - TenantSettingsSnapshot settings = await TenantSettingsSnapshot.LoadAsync(settingsStore, ct); - return new WorkerRunSettings( - settings, - settings.GetBool(SettingsKeys.AutoArchive, SettingsDefaults.AutoArchive), - settings.GetInt(SettingsKeys.ArchiveAfterDays, SettingsDefaults.ArchiveAfterDays), - settings.GetBool(SettingsKeys.MlEnabled, SettingsDefaults.MlEnabled), - settings.GetBool(SettingsKeys.AiEnabled, SettingsDefaults.AiEnabled), - settings.GetBool(SettingsKeys.AiFilterEnabled, SettingsDefaults.AiFilterEnabled), - settings.GetBool(SettingsKeys.BudgetRequiredHire, SettingsDefaults.BudgetRequiredHire), - settings.GetBool(SettingsKeys.BudgetRequiredOrder, SettingsDefaults.BudgetRequiredOrder), - NormalizeWantedType(settings.GetString(SettingsKeys.WantedType, SettingsDefaults.WantedType)), - LoadGlobalExclusions(settings)); - } - - private static GlobalExcludeSettings LoadGlobalExclusions(TenantSettingsSnapshot settings) - { - int from = settings.GetInt(SettingsKeys.ExcludeBudgetFrom, SettingsDefaults.ExcludeBudgetFrom); - int to = settings.GetInt(SettingsKeys.ExcludeBudgetTo, SettingsDefaults.ExcludeBudgetTo); - return new GlobalExcludeSettings( - Keywords: settings.GetStringList(SettingsKeys.ExcludeKeywords, SettingsDefaults.ExcludeKeywords), - Locations: settings.GetStringList(SettingsKeys.ExcludeLocations, SettingsDefaults.ExcludeLocations), - Types: settings.GetStringList(SettingsKeys.ExcludeTypes, SettingsDefaults.ExcludeTypes), - BudgetFrom: from > 0 ? from : null, - BudgetTo: to > 0 ? to : null); - } - - private static string NormalizeWantedType(string value) - { - return value.Trim().ToLowerInvariant(); - } - -} +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Снимок настроек прохода — partial-часть +/// +public sealed partial class PipelineWorkerService +{ + + private sealed record WorkerRunSettings( + TenantSettingsSnapshot Settings, + bool AutoArchive, + int ArchiveAfterDays, + bool MlEnabled, + bool AiEnabled, + bool AiFilterEnabled, + bool BudgetRequiredHire, + bool BudgetRequiredOrder, + string WantedType, + GlobalExcludeSettings GlobalExclusions); + + // Читает настройки воркера одним запросом (GetAllAsync) и резолвит значения с дефолтами + // SettingsDefaults через типизированный снимок (C30). + // settingsStore: KV-хранилище настроек тенанта. + // ct: Токен отмены. + // Возвращает: Снимок на проход pump (один на PumpOnce, передаётся в проверки обоих проходов). + private static async Task LoadRunSettingsAsync(ISettingsStore settingsStore, CancellationToken ct) + { + TenantSettingsSnapshot settings = await TenantSettingsSnapshot.LoadAsync(settingsStore, ct); + return new WorkerRunSettings( + settings, + settings.GetBool(SettingsKeys.AutoArchive, SettingsDefaults.AutoArchive), + settings.GetInt(SettingsKeys.ArchiveAfterDays, SettingsDefaults.ArchiveAfterDays), + settings.GetBool(SettingsKeys.MlEnabled, SettingsDefaults.MlEnabled), + settings.GetBool(SettingsKeys.AiEnabled, SettingsDefaults.AiEnabled), + settings.GetBool(SettingsKeys.AiFilterEnabled, SettingsDefaults.AiFilterEnabled), + settings.GetBool(SettingsKeys.BudgetRequiredHire, SettingsDefaults.BudgetRequiredHire), + settings.GetBool(SettingsKeys.BudgetRequiredOrder, SettingsDefaults.BudgetRequiredOrder), + NormalizeWantedType(settings.GetString(SettingsKeys.WantedType, SettingsDefaults.WantedType)), + LoadGlobalExclusions(settings)); + } + + private static GlobalExcludeSettings LoadGlobalExclusions(TenantSettingsSnapshot settings) + { + int from = settings.GetInt(SettingsKeys.ExcludeBudgetFrom, SettingsDefaults.ExcludeBudgetFrom); + int to = settings.GetInt(SettingsKeys.ExcludeBudgetTo, SettingsDefaults.ExcludeBudgetTo); + return new GlobalExcludeSettings( + Keywords: settings.GetStringList(SettingsKeys.ExcludeKeywords, SettingsDefaults.ExcludeKeywords), + Locations: settings.GetStringList(SettingsKeys.ExcludeLocations, SettingsDefaults.ExcludeLocations), + Types: settings.GetStringList(SettingsKeys.ExcludeTypes, SettingsDefaults.ExcludeTypes), + BudgetFrom: from > 0 ? from : null, + BudgetTo: to > 0 ? to : null); + } + + private static string NormalizeWantedType(string value) + { + return value.Trim().ToLowerInvariant(); + } + +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.State.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.State.cs index 8cf309e..53cd54b 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.State.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.State.cs @@ -1,77 +1,77 @@ -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Models; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Накопители результата pump — partial-часть -/// -public sealed partial class PipelineWorkerService -{ - // ── Накопители результата pump (счётчики и карточки одного прохода) ── - - private sealed class PumpState - { - /// - /// Прошли «new»-проход и переведены в filtered. - /// - public int Staged { get; set; } - - /// - /// Карточек создано ML-веткой - /// - public int MlStored { get; set; } - - /// - /// Отсевов решением ML «спам» - /// - public int MlDrop { get; set; } - - /// - /// Отсевов «тип не под режим» по решению ML - /// - public int TypeDrop { get; set; } - - /// - /// Карточек создано ИИ-веткой/локальным путём - /// - public int AiStored { get; set; } - - /// - /// Отсевов решением ИИ - /// - public int AiDrop { get; set; } - - /// - /// Сообщений, где ИИ не дал разбора — собран локальный разбор - /// - public int AiFail { get; set; } - - /// - /// Отсевов фильтром «без суммы» - /// - public int NoBudget { get; set; } - - /// - /// Карточки, созданные за проход - /// - public List Created { get; } = []; - - /// - /// Собирает неизменяемый результат прохода. - /// - /// Результат pump для admin/tick и фонового цикла. - public PipelinePumpResult ToResult() => new() - { - Staged = Staged, - MlStored = MlStored, - MlDrop = MlDrop, - TypeDrop = TypeDrop, - AiStored = AiStored, - AiDrop = AiDrop, - AiFail = AiFail, - NoBudget = NoBudget, - CreatedCards = Created, - }; - } -} +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Pipeline.Application.Models; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Накопители результата pump — partial-часть +/// +public sealed partial class PipelineWorkerService +{ + // ── Накопители результата pump (счётчики и карточки одного прохода) ── + + private sealed class PumpState + { + /// + /// Прошли «new»-проход и переведены в filtered. + /// + public int Staged { get; set; } + + /// + /// Карточек создано ML-веткой + /// + public int MlStored { get; set; } + + /// + /// Отсевов решением ML «спам» + /// + public int MlDrop { get; set; } + + /// + /// Отсевов «тип не под режим» по решению ML + /// + public int TypeDrop { get; set; } + + /// + /// Карточек создано ИИ-веткой/локальным путём + /// + public int AiStored { get; set; } + + /// + /// Отсевов решением ИИ + /// + public int AiDrop { get; set; } + + /// + /// Сообщений, где ИИ не дал разбора — собран локальный разбор + /// + public int AiFail { get; set; } + + /// + /// Отсевов фильтром «без суммы» + /// + public int NoBudget { get; set; } + + /// + /// Карточки, созданные за проход + /// + public List Created { get; } = []; + + /// + /// Собирает неизменяемый результат прохода. + /// + /// Результат pump для admin/tick и фонового цикла. + public PipelinePumpResult ToResult() => new() + { + Staged = Staged, + MlStored = MlStored, + MlDrop = MlDrop, + TypeDrop = TypeDrop, + AiStored = AiStored, + AiDrop = AiDrop, + AiFail = AiFail, + NoBudget = NoBudget, + CreatedCards = Created, + }; + } +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.cs index 93ac90f..ccb193b 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.cs @@ -1,145 +1,145 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Воркер разбора очереди входящих — один проход pump. -/// -public sealed partial class PipelineWorkerService -{ - private readonly IPipelineStore _store; - private readonly ISettingsStore _settings; - private readonly IncomingRules _rules; - private readonly ICardStore _kanjStore; - private readonly IMlClient _mlClient; - private readonly IAiClassifier _aiClassifier; - private readonly PipelineProcessingService _processing; - private readonly PipelineCardWriter _cardWriter; - private readonly LocalFieldsParser _fieldsParser; - - /// - /// Создаёт воркер pump над портами модуля Pipeline - /// - /// Хранилище очереди/отсева/дедупа (порт IPipelineStore). - /// KV-настройки тенанта. - /// Этап-1 правила фильтра входящих (длина/стоп-фразы/резюме/тип, Settings). - /// Порт канбана: доски (проверка allowed-колонок ML/обучения) и чтение карточки. - /// Порт ML-сервиса: predict (решения «решил сам») и push (обучение). - /// Порт ИИ: фильтр и классификация. - /// Запись отсева (RejectAsync) и обслуживание вкладки «Обработка». - /// Создание карточки через публичный интерфейс Kanban + связь дедупа. - /// Локальный структуратор (aiEnabled=false / сбой ИИ / локальные поля ML-ветки). - public PipelineWorkerService( - IPipelineStore store, - ISettingsStore settings, - IncomingRules rules, - ICardStore kanjStore, - IMlClient mlClient, - IAiClassifier aiClassifier, - PipelineProcessingService processing, - PipelineCardWriter cardWriter, - LocalFieldsParser fieldsParser) - { - _store = store; - _settings = settings; - _rules = rules; - _kanjStore = kanjStore; - _mlClient = mlClient; - _aiClassifier = aiClassifier; - _processing = processing; - _cardWriter = cardWriter; - _fieldsParser = fieldsParser; - } - - // Лимит «new»-прохода: сколько сообщений за проход проходят правила/дедуп/ML. - private const int NewBatchLimit = 12; - - // Лимит «filtered»-прохода: сколько сообщений за проход идут на ИИ (дорогой шаг). - private const int FilteredBatchLimit = 4; - - private const long DayMs = 86_400_000; - - - private const string SourceStop = "stop"; - - // Источник «ML» (решения модели). - private const string SourceMl = "ml"; - - // Источник «ИИ» (фильтр/классификатор). - private const string SourceAi = "ai"; - - // Источник «система»: устарело. - private const string SourceStale = "stale"; - - // Источник «система»: повтор по дедупу. - private const string SourceDup = "dup"; - - // Этап отсева «спам (ML)». - private const string StageSpamMl = "spam_ml"; - - // Этап отсева «спам (ИИ)». - private const string StageSpamAi = "spam_ai"; - - // Этап отсева «ИИ-фильтр». - private const string StageFilterAi = "filter_ai"; - - private const string StageDup = "dup"; - - private const string StageStale = "stale"; - - private const string StageBudget = "budget"; - - private const string StageType = "type"; - - - private const int MaxMlTermsAdded = 4; - - // ── Типы заявок wantedType (строка настройки, как у IncomingRules) ── - - // wantedType: только вакансии/занятость. - private const string WantedTypeVacancy = "vacancy"; - - // wantedType: только разовые заказы. - private const string WantedTypeFreelance = "freelance"; - - - private const string StaleReasonFormat = "сообщение старше {0} дн. (срок до автоархива) — не заводим в систему"; - - private const string DupReason = "сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается"; - - private const string NoBudgetReason = "включён фильтр «не создавать карточку без суммы» — в тексте не указан бюджет"; - - private const string MlSpamReasonFormat = "ML уверен, что это спам/не заявка (score {0})"; - - private const string MlTypeDropReasonFormat = "ML: тип «{0}», а вы ищете только «{1}»"; - - private const string MlHireKindName = "найм/занятость"; - - private const string MlOrderKindName = "разовые заказы"; - - private const string AiSpamReason = "ИИ: не заявка — спам, реклама, скам или служебное сообщение"; - - private const string AiFilterReasonPrefix = "ИИ-фильтр: "; - - private const string AiFilterDefaultReason = "сообщение не относится к вашим интересам"; - - private static readonly MlPredictResultDto NotReadyPrediction = new( - Take: false, - Label: null, - Scores: new Dictionary(), - Hits: 0, - Ready: false, - Margin: null, - Terms: Array.Empty(), - Type: null); - - private static readonly AiFilterResultDto PassSkipped = new(Pass: true, Reason: null, Skipped: true); - -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Воркер разбора очереди входящих — один проход pump. +/// +public sealed partial class PipelineWorkerService +{ + private readonly IPipelineStore _store; + private readonly ISettingsStore _settings; + private readonly IncomingRules _rules; + private readonly ICardStore _kanjStore; + private readonly IMlClient _mlClient; + private readonly IAiClassifier _aiClassifier; + private readonly PipelineProcessingService _processing; + private readonly PipelineCardWriter _cardWriter; + private readonly LocalFieldsParser _fieldsParser; + + /// + /// Создаёт воркер pump над портами модуля Pipeline + /// + /// Хранилище очереди/отсева/дедупа (порт IPipelineStore). + /// KV-настройки тенанта. + /// Этап-1 правила фильтра входящих (длина/стоп-фразы/резюме/тип, Settings). + /// Порт канбана: доски (проверка allowed-колонок ML/обучения) и чтение карточки. + /// Порт ML-сервиса: predict (решения «решил сам») и push (обучение). + /// Порт ИИ: фильтр и классификация. + /// Запись отсева (RejectAsync) и обслуживание вкладки «Обработка». + /// Создание карточки через публичный интерфейс Kanban + связь дедупа. + /// Локальный структуратор (aiEnabled=false / сбой ИИ / локальные поля ML-ветки). + public PipelineWorkerService( + IPipelineStore store, + ISettingsStore settings, + IncomingRules rules, + ICardStore kanjStore, + IMlClient mlClient, + IAiClassifier aiClassifier, + PipelineProcessingService processing, + PipelineCardWriter cardWriter, + LocalFieldsParser fieldsParser) + { + _store = store; + _settings = settings; + _rules = rules; + _kanjStore = kanjStore; + _mlClient = mlClient; + _aiClassifier = aiClassifier; + _processing = processing; + _cardWriter = cardWriter; + _fieldsParser = fieldsParser; + } + + // Лимит «new»-прохода: сколько сообщений за проход проходят правила/дедуп/ML. + private const int NewBatchLimit = 12; + + // Лимит «filtered»-прохода: сколько сообщений за проход идут на ИИ (дорогой шаг). + private const int FilteredBatchLimit = 4; + + private const long DayMs = 86_400_000; + + + private const string SourceStop = "stop"; + + // Источник «ML» (решения модели). + private const string SourceMl = "ml"; + + // Источник «ИИ» (фильтр/классификатор). + private const string SourceAi = "ai"; + + // Источник «система»: устарело. + private const string SourceStale = "stale"; + + // Источник «система»: повтор по дедупу. + private const string SourceDup = "dup"; + + // Этап отсева «спам (ML)». + private const string StageSpamMl = "spam_ml"; + + // Этап отсева «спам (ИИ)». + private const string StageSpamAi = "spam_ai"; + + // Этап отсева «ИИ-фильтр». + private const string StageFilterAi = "filter_ai"; + + private const string StageDup = "dup"; + + private const string StageStale = "stale"; + + private const string StageBudget = "budget"; + + private const string StageType = "type"; + + + private const int MaxMlTermsAdded = 4; + + // ── Типы заявок wantedType (строка настройки, как у IncomingRules) ── + + // wantedType: только вакансии/занятость. + private const string WantedTypeVacancy = "vacancy"; + + // wantedType: только разовые заказы. + private const string WantedTypeFreelance = "freelance"; + + + private const string StaleReasonFormat = "сообщение старше {0} дн. (срок до автоархива) — не заводим в систему"; + + private const string DupReason = "сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается"; + + private const string NoBudgetReason = "включён фильтр «не создавать карточку без суммы» — в тексте не указан бюджет"; + + private const string MlSpamReasonFormat = "ML уверен, что это спам/не заявка (score {0})"; + + private const string MlTypeDropReasonFormat = "ML: тип «{0}», а вы ищете только «{1}»"; + + private const string MlHireKindName = "найм/занятость"; + + private const string MlOrderKindName = "разовые заказы"; + + private const string AiSpamReason = "ИИ: не заявка — спам, реклама, скам или служебное сообщение"; + + private const string AiFilterReasonPrefix = "ИИ-фильтр: "; + + private const string AiFilterDefaultReason = "сообщение не относится к вашим интересам"; + + private static readonly MlPredictResultDto NotReadyPrediction = new( + Take: false, + Label: null, + Scores: new Dictionary(), + Hits: 0, + Ready: false, + Margin: null, + Terms: Array.Empty(), + Type: null); + + private static readonly AiFilterResultDto PassSkipped = new(Pass: true, Reason: null, Skipped: true); + +} diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/ReclassifyGate.cs b/src/core/Deal.Modules.Pipeline/Application/Services/ReclassifyGate.cs index 2cb2959..b70c78e 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/ReclassifyGate.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/ReclassifyGate.cs @@ -1,23 +1,23 @@ -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Single-flight-замок ручной переклассификации -/// -public sealed class ReclassifyGate : IDisposable -{ - private readonly SemaphoreSlim _gate = new(initialCount: 1, maxCount: 1); - - /// - /// Пытается войти в критическую секцию без ожидания. - /// - /// True — вход получен (вызывающий обязан вызвать ); false — проход уже идёт. - public bool TryEnter() => _gate.Wait(0); - - /// - /// Освобождает вход после завершения прохода. - /// - public void Exit() => _gate.Release(); - - /// - public void Dispose() => _gate.Dispose(); -} +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Single-flight-замок ручной переклассификации +/// +public sealed class ReclassifyGate : IDisposable +{ + private readonly SemaphoreSlim _gate = new(initialCount: 1, maxCount: 1); + + /// + /// Пытается войти в критическую секцию без ожидания. + /// + /// True — вход получен (вызывающий обязан вызвать ); false — проход уже идёт. + public bool TryEnter() => _gate.Wait(0); + + /// + /// Освобождает вход после завершения прохода. + /// + public void Exit() => _gate.Release(); + + /// + public void Dispose() => _gate.Dispose(); +} diff --git a/src/core/Deal.Modules.Pipeline/Deal.Modules.Pipeline.csproj b/src/core/Deal.Modules.Pipeline/Deal.Modules.Pipeline.csproj index a94d5ba..f91e46f 100644 --- a/src/core/Deal.Modules.Pipeline/Deal.Modules.Pipeline.csproj +++ b/src/core/Deal.Modules.Pipeline/Deal.Modules.Pipeline.csproj @@ -1,20 +1,20 @@ - - - - - - - - - - - - - - - net10.0 - enable - enable - - - + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/core/Deal.Modules.Settings/Application/Abstractions/IGlobalSettingsStore.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/IGlobalSettingsStore.cs index 63967d7..2d825d3 100644 --- a/src/core/Deal.Modules.Settings/Application/Abstractions/IGlobalSettingsStore.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/IGlobalSettingsStore.cs @@ -1,26 +1,26 @@ -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Settings.Application.Abstractions; - -/// -/// Порт KV-хранилища глобальных -/// -public interface IGlobalSettingsStore -{ - /// - /// Читает одно глобальное значение по ключу - /// - /// Ключ глобальной настройки. - /// Значение или null, если строка отсутствует (настройка не задана оператором). - public Task GetAsync(string key, CancellationToken ct); - - /// - /// Записывает (создаёт или обновляет) глобальное значение; UpdatedAt — UTC-now. - /// - /// Ключ глобальной настройки. - /// Значение, сериализованное в JSON. - public Task SetAsync( - string key, - string valueJson, - CancellationToken ct); -} +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Settings.Application.Abstractions; + +/// +/// Порт KV-хранилища глобальных +/// +public interface IGlobalSettingsStore +{ + /// + /// Читает одно глобальное значение по ключу + /// + /// Ключ глобальной настройки. + /// Значение или null, если строка отсутствует (настройка не задана оператором). + public Task GetAsync(string key, CancellationToken ct); + + /// + /// Записывает (создаёт или обновляет) глобальное значение; UpdatedAt — UTC-now. + /// + /// Ключ глобальной настройки. + /// Значение, сериализованное в JSON. + public Task SetAsync( + string key, + string valueJson, + CancellationToken ct); +} diff --git a/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs b/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs index 73502f4..3185601 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs @@ -1,9 +1,9 @@ -namespace Deal.Modules.Settings.Application.Models; - -/// -/// Каталог ключей глобальных -/// -public static class GlobalSettingsKeys -{ - public const string TelegramKeys = "telegramKeys"; -} +namespace Deal.Modules.Settings.Application.Models; + +/// +/// Каталог ключей глобальных +/// +public static class GlobalSettingsKeys +{ + public const string TelegramKeys = "telegramKeys"; +} diff --git a/src/core/Deal.Modules.Settings/Application/Models/IncomingRules.cs b/src/core/Deal.Modules.Settings/Application/Models/IncomingRules.cs index d2da8e4..456a330 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/IncomingRules.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/IncomingRules.cs @@ -1,220 +1,220 @@ -using Deal.Modules.Settings.Application.Abstractions; - -namespace Deal.Modules.Settings.Application.Models; - -/// -/// Этап-1 правила фильтра входящих — чистая реализация stage1_plain. -/// -public sealed class IncomingRules(ISettingsStore store) -{ - /// - /// Номер пайплайна для результата правил - /// - public const int Stage = 1; - - /// - /// kind: текст короче minLen. - /// - public const string KindLength = "length"; - - /// - /// kind: найдена стоп-фраза. - /// - public const string KindStop = "stop"; - - /// - /// kind: текст опознан как резюме соискателя. - /// - public const string KindResume = "resume"; - - /// - /// kind: тип текста не совпал с wantedType. - /// - public const string KindType = "type"; - - /// - /// kind на проходе — пустая строка. - /// - public const string KindPass = ""; - - private const string ResumeMarkerGuardWord = "резюме"; - - // wantedType: собираем и вакансии, и разовые заказы (дефолт). - private const string WantedTypeBoth = "both"; - - // wantedType: только вакансии/занятость. - private const string WantedTypeVacancy = "vacancy"; - - // wantedType: только разовые заказы/фриланс. - private const string WantedTypeFreelance = "freelance"; - - private const string ReasonFormatLength = "короче {0} символов"; - - private const string ReasonFormatStop = "стоп-фраза «{0}»"; - - private const string ReasonFormatResume = "резюме соискателя («{0}»)"; - - private const string ReasonTypeFreelanceOnly = "ищете только разовые заявки — сообщение похоже на вакансию/занятость"; - - private const string ReasonTypeVacancyOnly = "ищете только занятость — сообщение похоже на разовый заказ/услугу"; - - /// - /// Проверяет текст -1 фильтра по настройкам тенанта. - /// - /// Текст сообщения; null/пустой трактуется как «короче minLen» (как (text or "").strip). - /// Вердикт: {pass, reason, stage:1, kind, kw}. - public async Task CheckAsync(string? text, CancellationToken ct) - { - RulesSettings settings = ReadSettings(await TenantSettingsSnapshot.LoadAsync(store, ct)); - return Evaluate(text, settings); - } - - private static IncomingRulesResult Evaluate(string? text, RulesSettings settings) - { - string trimmed = (text ?? string.Empty).Trim(); - if (trimmed.Length < settings.MinLen) - { - return Fail( - KindLength, - string.Format(ReasonFormatLength, settings.MinLen), - Kw: string.Empty); - } - - string lower = trimmed.ToLowerInvariant(); - - foreach (string phrase in settings.StopPhrases) - { - if (string.IsNullOrEmpty(phrase)) - { - continue; - } - - if (lower.Contains(phrase.ToLowerInvariant(), StringComparison.Ordinal)) - { - return Fail(KindStop, string.Format(ReasonFormatStop, phrase), phrase); - } - } - - if (settings.BlockResumes) - { - string? marker = FindResumeMarker(lower, settings.ResumeMarkers, settings.HireMarkers); - if (marker is not null) - { - return Fail(KindResume, string.Format(ReasonFormatResume, marker), marker); - } - } - - string wantedType = settings.WantedType; - if (wantedType == WantedTypeVacancy || wantedType == WantedTypeFreelance) - { - bool looksVacancy = ContainsAny(lower, settings.HireMarkers); - if (wantedType == WantedTypeFreelance && looksVacancy) - { - return Fail(KindType, ReasonTypeFreelanceOnly, Kw: string.Empty); - } - - if (wantedType == WantedTypeVacancy && !looksVacancy) - { - return Fail(KindType, ReasonTypeVacancyOnly, Kw: string.Empty); - } - } - - return new IncomingRulesResult(Pass: true, Reason: null, Stage, KindPass, Kw: string.Empty); - } - - private static string? FindResumeMarker( - string lower, - IReadOnlyList resumeMarkers, - IReadOnlyList hireMarkers) - { - foreach (string marker in resumeMarkers) - { - int position = lower.IndexOf(marker, StringComparison.Ordinal); - if (position < 0) - { - continue; - } - - if (marker == ResumeMarkerGuardWord - && ContainsAny(lower.Substring(0, position), hireMarkers)) - { - continue; - } - - return marker; - } - - return null; - } - - // Содержит ли текст хотя бы один маркер из списка (подстрочное сравнение). - // lower: Текст в нижнем регистре. - // markers: Нормализованные (нижний регистр) маркеры. - // Возвращает: True — найден хотя бы один маркер. - private static bool ContainsAny(string lower, IReadOnlyList markers) - { - foreach (string marker in markers) - { - if (lower.Contains(marker, StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } - - private static IncomingRulesResult Fail( - string kind, - string reason, - string Kw) - { - return new IncomingRulesResult(Pass: false, Reason: reason, Stage, kind, Kw); - } - - private static RulesSettings ReadSettings(TenantSettingsSnapshot settings) - { - return new RulesSettings( - MinLen: settings.GetInt(SettingsKeys.MinLen, SettingsDefaults.MinLen), - StopPhrases: settings.GetStringList(SettingsKeys.StopPhrases, SettingsDefaults.StopPhrases), - BlockResumes: settings.GetBool(SettingsKeys.BlockResumes, SettingsDefaults.BlockResumes), - ResumeMarkers: NormalizeMarkers( - settings.GetStringList(SettingsKeys.ResumeMarkers, SettingsDefaults.ResumeMarkers)), - HireMarkers: NormalizeMarkers( - settings.GetStringList(SettingsKeys.HireMarkers, SettingsDefaults.HireMarkers)), - WantedType: ReadWantedType(settings)); - } - - // Читает wantedType: строка, trim + lowercase; пустая/отсутствующая/повреждённая → "both". - // settings: Снимок настроек тенанта. - // Возвращает: Значение wantedType (любая строка; тип-гейт активен только для vacancy/freelance). - private static string ReadWantedType(TenantSettingsSnapshot settings) - { - string wanted = settings.GetString(SettingsKeys.WantedType, SettingsDefaults.WantedType) - .Trim().ToLowerInvariant(); - return wanted.Length > 0 ? wanted : WantedTypeBoth; - } - - private static IReadOnlyList NormalizeMarkers(IReadOnlyList markers) - { - var result = new List(markers.Count); - foreach (string marker in markers) - { - string normalized = marker.Trim().ToLowerInvariant(); - if (normalized.Length > 0 && !result.Contains(normalized, StringComparer.Ordinal)) - { - result.Add(normalized); - } - } - - return result; - } - - private sealed record RulesSettings( - int MinLen, - IReadOnlyList StopPhrases, - bool BlockResumes, - IReadOnlyList ResumeMarkers, - IReadOnlyList HireMarkers, - string WantedType); -} +using Deal.Modules.Settings.Application.Abstractions; + +namespace Deal.Modules.Settings.Application.Models; + +/// +/// Этап-1 правила фильтра входящих — чистая реализация stage1_plain. +/// +public sealed class IncomingRules(ISettingsStore store) +{ + /// + /// Номер пайплайна для результата правил + /// + public const int Stage = 1; + + /// + /// kind: текст короче minLen. + /// + public const string KindLength = "length"; + + /// + /// kind: найдена стоп-фраза. + /// + public const string KindStop = "stop"; + + /// + /// kind: текст опознан как резюме соискателя. + /// + public const string KindResume = "resume"; + + /// + /// kind: тип текста не совпал с wantedType. + /// + public const string KindType = "type"; + + /// + /// kind на проходе — пустая строка. + /// + public const string KindPass = ""; + + private const string ResumeMarkerGuardWord = "резюме"; + + // wantedType: собираем и вакансии, и разовые заказы (дефолт). + private const string WantedTypeBoth = "both"; + + // wantedType: только вакансии/занятость. + private const string WantedTypeVacancy = "vacancy"; + + // wantedType: только разовые заказы/фриланс. + private const string WantedTypeFreelance = "freelance"; + + private const string ReasonFormatLength = "короче {0} символов"; + + private const string ReasonFormatStop = "стоп-фраза «{0}»"; + + private const string ReasonFormatResume = "резюме соискателя («{0}»)"; + + private const string ReasonTypeFreelanceOnly = "ищете только разовые заявки — сообщение похоже на вакансию/занятость"; + + private const string ReasonTypeVacancyOnly = "ищете только занятость — сообщение похоже на разовый заказ/услугу"; + + /// + /// Проверяет текст -1 фильтра по настройкам тенанта. + /// + /// Текст сообщения; null/пустой трактуется как «короче minLen» (как (text or "").strip). + /// Вердикт: {pass, reason, stage:1, kind, kw}. + public async Task CheckAsync(string? text, CancellationToken ct) + { + RulesSettings settings = ReadSettings(await TenantSettingsSnapshot.LoadAsync(store, ct)); + return Evaluate(text, settings); + } + + private static IncomingRulesResult Evaluate(string? text, RulesSettings settings) + { + string trimmed = (text ?? string.Empty).Trim(); + if (trimmed.Length < settings.MinLen) + { + return Fail( + KindLength, + string.Format(ReasonFormatLength, settings.MinLen), + Kw: string.Empty); + } + + string lower = trimmed.ToLowerInvariant(); + + foreach (string phrase in settings.StopPhrases) + { + if (string.IsNullOrEmpty(phrase)) + { + continue; + } + + if (lower.Contains(phrase.ToLowerInvariant(), StringComparison.Ordinal)) + { + return Fail(KindStop, string.Format(ReasonFormatStop, phrase), phrase); + } + } + + if (settings.BlockResumes) + { + string? marker = FindResumeMarker(lower, settings.ResumeMarkers, settings.HireMarkers); + if (marker is not null) + { + return Fail(KindResume, string.Format(ReasonFormatResume, marker), marker); + } + } + + string wantedType = settings.WantedType; + if (wantedType == WantedTypeVacancy || wantedType == WantedTypeFreelance) + { + bool looksVacancy = ContainsAny(lower, settings.HireMarkers); + if (wantedType == WantedTypeFreelance && looksVacancy) + { + return Fail(KindType, ReasonTypeFreelanceOnly, Kw: string.Empty); + } + + if (wantedType == WantedTypeVacancy && !looksVacancy) + { + return Fail(KindType, ReasonTypeVacancyOnly, Kw: string.Empty); + } + } + + return new IncomingRulesResult(Pass: true, Reason: null, Stage, KindPass, Kw: string.Empty); + } + + private static string? FindResumeMarker( + string lower, + IReadOnlyList resumeMarkers, + IReadOnlyList hireMarkers) + { + foreach (string marker in resumeMarkers) + { + int position = lower.IndexOf(marker, StringComparison.Ordinal); + if (position < 0) + { + continue; + } + + if (marker == ResumeMarkerGuardWord + && ContainsAny(lower.Substring(0, position), hireMarkers)) + { + continue; + } + + return marker; + } + + return null; + } + + // Содержит ли текст хотя бы один маркер из списка (подстрочное сравнение). + // lower: Текст в нижнем регистре. + // markers: Нормализованные (нижний регистр) маркеры. + // Возвращает: True — найден хотя бы один маркер. + private static bool ContainsAny(string lower, IReadOnlyList markers) + { + foreach (string marker in markers) + { + if (lower.Contains(marker, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static IncomingRulesResult Fail( + string kind, + string reason, + string Kw) + { + return new IncomingRulesResult(Pass: false, Reason: reason, Stage, kind, Kw); + } + + private static RulesSettings ReadSettings(TenantSettingsSnapshot settings) + { + return new RulesSettings( + MinLen: settings.GetInt(SettingsKeys.MinLen, SettingsDefaults.MinLen), + StopPhrases: settings.GetStringList(SettingsKeys.StopPhrases, SettingsDefaults.StopPhrases), + BlockResumes: settings.GetBool(SettingsKeys.BlockResumes, SettingsDefaults.BlockResumes), + ResumeMarkers: NormalizeMarkers( + settings.GetStringList(SettingsKeys.ResumeMarkers, SettingsDefaults.ResumeMarkers)), + HireMarkers: NormalizeMarkers( + settings.GetStringList(SettingsKeys.HireMarkers, SettingsDefaults.HireMarkers)), + WantedType: ReadWantedType(settings)); + } + + // Читает wantedType: строка, trim + lowercase; пустая/отсутствующая/повреждённая → "both". + // settings: Снимок настроек тенанта. + // Возвращает: Значение wantedType (любая строка; тип-гейт активен только для vacancy/freelance). + private static string ReadWantedType(TenantSettingsSnapshot settings) + { + string wanted = settings.GetString(SettingsKeys.WantedType, SettingsDefaults.WantedType) + .Trim().ToLowerInvariant(); + return wanted.Length > 0 ? wanted : WantedTypeBoth; + } + + private static IReadOnlyList NormalizeMarkers(IReadOnlyList markers) + { + var result = new List(markers.Count); + foreach (string marker in markers) + { + string normalized = marker.Trim().ToLowerInvariant(); + if (normalized.Length > 0 && !result.Contains(normalized, StringComparer.Ordinal)) + { + result.Add(normalized); + } + } + + return result; + } + + private sealed record RulesSettings( + int MinLen, + IReadOnlyList StopPhrases, + bool BlockResumes, + IReadOnlyList ResumeMarkers, + IReadOnlyList HireMarkers, + string WantedType); +} diff --git a/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs b/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs index 5aecc27..d9b7648 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs @@ -1,270 +1,270 @@ -namespace Deal.Modules.Settings.Application.Models; - -/// -/// Дефолтные значения настроек тенанта. -/// -public static class SettingsDefaults -{ - // ── Хранилище ── - - /// - /// Дефолт «autoArchive» - /// - public const bool AutoArchive = true; - - /// - /// Дефолт «archiveAfterDays» - /// - public const int ArchiveAfterDays = 14; - - /// - /// Дефолт «archiveClearDays» - /// - public const int ArchiveClearDays = 90; - - /// - /// Дефолт «trashClearDays» - /// - public const int TrashClearDays = 7; - - // ── Фильтры входящих ── - - /// - /// Дефолт «minLen» - /// - public const int MinLen = 24; - - /// - /// Дефолтные стоп-фразы. - /// - public static readonly IReadOnlyList StopPhrases = new[] - { - "взаимный пиар", - "резюме", - "ищу работу", - "набор в команду", - }; - - /// - /// Дефолт «mlEnabled» - /// - public const bool MlEnabled = true; - - /// - /// Дефолт «aiEnabled» - /// - public const bool AiEnabled = true; - - /// - /// Дефолт «aiFilterEnabled» - /// - public const bool AiFilterEnabled = true; - - /// - /// Дефолт «aiPrompt» — текст из DefaultPrompts. - /// - public static readonly string AiPrompt = DefaultPrompts.DefaultAiPrompt; - - /// - /// Дефолт «aiFilterPrompt» — текст из DefaultPrompts. - /// - public static readonly string AiFilterPrompt = DefaultPrompts.DefaultAiFilterPrompt; - - /// - /// Дефолт « » — текст из DefaultPrompts. - /// - public static readonly string CardPrompt = DefaultPrompts.DefaultCardPrompt; - - // ── Сфера и ключи ── - - /// - /// Дефолт «domainDescription» - /// - public const string DomainDescription = ""; - - /// - /// Дефолт «domainKeywords» - /// - public static readonly IReadOnlyList DomainKeywords = Array.Empty(); - - /// - /// Дефолтные маркеры найма. - /// - public static readonly IReadOnlyList HireMarkers = new[] - { - "вакансия", "вакансию", "вакансии", "вакантна", "вакант", "нанимаем", "найм", "full-time", - "на постоянную", "занятость", "в офис", "официальное оформление", "пятидневка", - "грейд", "в компанию", "полная занятость", "на постоянную работу", "в команду", "нанимает", - "на постоянную основу", "в штат", - }; - - /// - /// Дефолтные термины грейдов/уровней. - /// - public static readonly IReadOnlyList LevelTerms = new[] - { - "junior", "джун", "джуниор", "middle", "мидл", "mid", "senior", "сеньор", "сеньйор", - "lead", "тимлид", "тиэмлид", "architect", "архитектор", "стажёр", "стажер", "intern", "trainee", - }; - - /// - /// Дефолтные маркеры резюме соискателей. - /// - public static readonly IReadOnlyList ResumeMarkers = new[] - { - "резюме", "#резюме", "моё резюме", "мое резюме", "ищу работу", "ищу вакансию", - "рассмотрю предложения", "готов к собеседованию", "в поиске работы", - "ищу проект", "ищу подработку", - }; - - /// - /// Дефолт «blockResumes» - /// - public const bool BlockResumes = true; - - - /// - /// Дефолт «excludeKeywords» - /// - public static readonly IReadOnlyList ExcludeKeywords = Array.Empty(); - - /// - /// Дефолт «excludeLocations» - /// - public static readonly IReadOnlyList ExcludeLocations = Array.Empty(); - - /// - /// Дефолт «excludeTypes» - /// - public static readonly IReadOnlyList ExcludeTypes = Array.Empty(); - - /// - /// Дефолт «excludeBudgetFrom» - /// - public const int ExcludeBudgetFrom = 0; - - /// - /// Дефолт «excludeBudgetTo» - /// - public const int ExcludeBudgetTo = 0; - - /// - /// Дефолт «wantedType» - /// - public const string WantedType = "both"; - - /// - /// Дефолт «budgetRequiredHire» - /// - public const bool BudgetRequiredHire = false; - - /// - /// Дефолт «budgetRequiredOrder» - /// - public const bool BudgetRequiredOrder = false; - - /// - /// Дефолт «hireLabel» - /// - public const string HireLabel = "вакансия"; - - /// - /// Дефолт «orderLabel» - /// - public const string OrderLabel = "фриланс"; - - // ── Мои промпты / напоминания ── - - /// - /// Дефолт «myPrompts» - /// - public static readonly IReadOnlyList MyPrompts = Array.Empty(); - - /// - /// Дефолт «remindersEnabled» - /// - public const bool RemindersEnabled = true; - - // ── Валюта и курсы ── - - /// - /// Дефолт «conversionOn» - /// - public const bool ConversionOn = true; - - /// - /// Дефолт «targetCurrency» - /// - public const string TargetCurrency = "RUB"; - - /// - /// Дефолт «rateSource» - /// - public const string RateSource = "cbr"; - - /// - /// Дефолт «colState» - /// - public static readonly IReadOnlyDictionary ColState = new Dictionary(); - - // ── ИИ ── - - /// - /// Дефолт «aiProvider» - /// - public const string AiProvider = "deepseek"; - - /// - /// Дефолт «aiConfigs» - /// - public static readonly IReadOnlyDictionary AiConfigs = BuildDefaultAiConfigs(); - - // ── Telegram / Discovery ── - - /// - /// Дефолт «autoMonitorNew» - /// - public const bool AutoMonitorNew = true; - - /// - /// Дефолт «discJoinLimit» - /// - public const int DiscJoinLimit = 50; - - /// - /// Дефолт «discJoinDelayMin» - /// - public const int DiscJoinDelayMin = 50; - - /// - /// Дефолт «discJoinDelayMax» - /// - public const int DiscJoinDelayMax = 70; - - /// - /// Дефолт «discEvalSample» - /// - public const int DiscEvalSample = 10; - - /// - /// Дефолт «discEvalThreshold» - /// - public const int DiscEvalThreshold = 40; - - /// - /// Дефолт «discPaused» - /// - public const bool DiscPaused = false; - - private static IReadOnlyDictionary BuildDefaultAiConfigs() - { - var result = new Dictionary(AiProviders.All.Count); - foreach (AiProviderDefinition provider in AiProviders.All) - { - string firstModel = provider.Models.FirstOrDefault() ?? string.Empty; - result.Add(provider.Id, new AiConfigSetting(ApiKey: "", BaseUrl: provider.Base, Model: firstModel)); - } - - return result; - } -} +namespace Deal.Modules.Settings.Application.Models; + +/// +/// Дефолтные значения настроек тенанта. +/// +public static class SettingsDefaults +{ + // ── Хранилище ── + + /// + /// Дефолт «autoArchive» + /// + public const bool AutoArchive = true; + + /// + /// Дефолт «archiveAfterDays» + /// + public const int ArchiveAfterDays = 14; + + /// + /// Дефолт «archiveClearDays» + /// + public const int ArchiveClearDays = 90; + + /// + /// Дефолт «trashClearDays» + /// + public const int TrashClearDays = 7; + + // ── Фильтры входящих ── + + /// + /// Дефолт «minLen» + /// + public const int MinLen = 24; + + /// + /// Дефолтные стоп-фразы. + /// + public static readonly IReadOnlyList StopPhrases = new[] + { + "взаимный пиар", + "резюме", + "ищу работу", + "набор в команду", + }; + + /// + /// Дефолт «mlEnabled» + /// + public const bool MlEnabled = true; + + /// + /// Дефолт «aiEnabled» + /// + public const bool AiEnabled = true; + + /// + /// Дефолт «aiFilterEnabled» + /// + public const bool AiFilterEnabled = true; + + /// + /// Дефолт «aiPrompt» — текст из DefaultPrompts. + /// + public static readonly string AiPrompt = DefaultPrompts.DefaultAiPrompt; + + /// + /// Дефолт «aiFilterPrompt» — текст из DefaultPrompts. + /// + public static readonly string AiFilterPrompt = DefaultPrompts.DefaultAiFilterPrompt; + + /// + /// Дефолт « » — текст из DefaultPrompts. + /// + public static readonly string CardPrompt = DefaultPrompts.DefaultCardPrompt; + + // ── Сфера и ключи ── + + /// + /// Дефолт «domainDescription» + /// + public const string DomainDescription = ""; + + /// + /// Дефолт «domainKeywords» + /// + public static readonly IReadOnlyList DomainKeywords = Array.Empty(); + + /// + /// Дефолтные маркеры найма. + /// + public static readonly IReadOnlyList HireMarkers = new[] + { + "вакансия", "вакансию", "вакансии", "вакантна", "вакант", "нанимаем", "найм", "full-time", + "на постоянную", "занятость", "в офис", "официальное оформление", "пятидневка", + "грейд", "в компанию", "полная занятость", "на постоянную работу", "в команду", "нанимает", + "на постоянную основу", "в штат", + }; + + /// + /// Дефолтные термины грейдов/уровней. + /// + public static readonly IReadOnlyList LevelTerms = new[] + { + "junior", "джун", "джуниор", "middle", "мидл", "mid", "senior", "сеньор", "сеньйор", + "lead", "тимлид", "тиэмлид", "architect", "архитектор", "стажёр", "стажер", "intern", "trainee", + }; + + /// + /// Дефолтные маркеры резюме соискателей. + /// + public static readonly IReadOnlyList ResumeMarkers = new[] + { + "резюме", "#резюме", "моё резюме", "мое резюме", "ищу работу", "ищу вакансию", + "рассмотрю предложения", "готов к собеседованию", "в поиске работы", + "ищу проект", "ищу подработку", + }; + + /// + /// Дефолт «blockResumes» + /// + public const bool BlockResumes = true; + + + /// + /// Дефолт «excludeKeywords» + /// + public static readonly IReadOnlyList ExcludeKeywords = Array.Empty(); + + /// + /// Дефолт «excludeLocations» + /// + public static readonly IReadOnlyList ExcludeLocations = Array.Empty(); + + /// + /// Дефолт «excludeTypes» + /// + public static readonly IReadOnlyList ExcludeTypes = Array.Empty(); + + /// + /// Дефолт «excludeBudgetFrom» + /// + public const int ExcludeBudgetFrom = 0; + + /// + /// Дефолт «excludeBudgetTo» + /// + public const int ExcludeBudgetTo = 0; + + /// + /// Дефолт «wantedType» + /// + public const string WantedType = "both"; + + /// + /// Дефолт «budgetRequiredHire» + /// + public const bool BudgetRequiredHire = false; + + /// + /// Дефолт «budgetRequiredOrder» + /// + public const bool BudgetRequiredOrder = false; + + /// + /// Дефолт «hireLabel» + /// + public const string HireLabel = "вакансия"; + + /// + /// Дефолт «orderLabel» + /// + public const string OrderLabel = "фриланс"; + + // ── Мои промпты / напоминания ── + + /// + /// Дефолт «myPrompts» + /// + public static readonly IReadOnlyList MyPrompts = Array.Empty(); + + /// + /// Дефолт «remindersEnabled» + /// + public const bool RemindersEnabled = true; + + // ── Валюта и курсы ── + + /// + /// Дефолт «conversionOn» + /// + public const bool ConversionOn = true; + + /// + /// Дефолт «targetCurrency» + /// + public const string TargetCurrency = "RUB"; + + /// + /// Дефолт «rateSource» + /// + public const string RateSource = "cbr"; + + /// + /// Дефолт «colState» + /// + public static readonly IReadOnlyDictionary ColState = new Dictionary(); + + // ── ИИ ── + + /// + /// Дефолт «aiProvider» + /// + public const string AiProvider = "deepseek"; + + /// + /// Дефолт «aiConfigs» + /// + public static readonly IReadOnlyDictionary AiConfigs = BuildDefaultAiConfigs(); + + // ── Telegram / Discovery ── + + /// + /// Дефолт «autoMonitorNew» + /// + public const bool AutoMonitorNew = true; + + /// + /// Дефолт «discJoinLimit» + /// + public const int DiscJoinLimit = 50; + + /// + /// Дефолт «discJoinDelayMin» + /// + public const int DiscJoinDelayMin = 50; + + /// + /// Дефолт «discJoinDelayMax» + /// + public const int DiscJoinDelayMax = 70; + + /// + /// Дефолт «discEvalSample» + /// + public const int DiscEvalSample = 10; + + /// + /// Дефолт «discEvalThreshold» + /// + public const int DiscEvalThreshold = 40; + + /// + /// Дефолт «discPaused» + /// + public const bool DiscPaused = false; + + private static IReadOnlyDictionary BuildDefaultAiConfigs() + { + var result = new Dictionary(AiProviders.All.Count); + foreach (AiProviderDefinition provider in AiProviders.All) + { + string firstModel = provider.Models.FirstOrDefault() ?? string.Empty; + result.Add(provider.Id, new AiConfigSetting(ApiKey: "", BaseUrl: provider.Base, Model: firstModel)); + } + + return result; + } +} diff --git a/src/core/Deal.Modules.Settings/Application/Models/TenantSettingsSnapshot.cs b/src/core/Deal.Modules.Settings/Application/Models/TenantSettingsSnapshot.cs index f23f8a6..d304be9 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/TenantSettingsSnapshot.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/TenantSettingsSnapshot.cs @@ -1,227 +1,227 @@ -using System.Globalization; -using System.Text.Json; -using Deal.Modules.Settings.Application.Abstractions; - -namespace Deal.Modules.Settings.Application.Models; - -/// -/// Типизированный снимок настроек тенанта -/// -public sealed class TenantSettingsSnapshot -{ - private static readonly JsonSerializerOptions RatesCacheJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - // Переопределения тенанта: ключ → строка settings (копия на момент загрузки). - private readonly IReadOnlyDictionary _overrides; - - // Создаёт снимок из готовых строк (конструктор приватный — вход через LoadAsync/FromRows). - // overrides: Переопределения настроек тенанта (ключ → строка). - private TenantSettingsSnapshot(IReadOnlyDictionary overrides) - { - _overrides = overrides; - } - - /// - /// Читает ВСЕ переопределения тенанта одним запросом и строит снимок. - /// - /// KV-хранилище настроек тенанта (таблица settings). - /// Снимок (значения резолвятся лениво методами Get* с дефолтами SettingsDefaults). - public static async Task LoadAsync(ISettingsStore store, CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(store); - IReadOnlyCollection rows = await store.GetAllAsync(ct); - return FromRows(rows); - } - - /// - /// Строит снимок из готовых строк — переиспользование одного чтения на операцию/батч. - /// - /// Строки настроек (например, из одного GetAllAsync вызывающего). - /// Снимок над переданными строками. - public static TenantSettingsSnapshot FromRows(IEnumerable rows) - { - ArgumentNullException.ThrowIfNull(rows); - var map = new Dictionary(StringComparer.Ordinal); - foreach (SettingValue row in rows) - { - map[row.Key] = row; - } - - return new TenantSettingsSnapshot(map); - } - - /// - /// Булева настройка - /// - /// Ключ настройки (bool-категория каталога ). - /// Дефолт из . - /// Значение настройки либо дефолт (отсутствие строки/повреждение/другой JSON-вид). - public bool GetBool(string key, bool defaultValue) - { - JsonElement? value = ReadValue(key); - if (value is { ValueKind: JsonValueKind.True }) - { - return true; - } - - if (value is { ValueKind: JsonValueKind.False }) - { - return false; - } - - return defaultValue; - } - - /// - /// Целочисленная настройка - /// - /// Ключ настройки (int-категория каталога ). - /// Дефолт из . - /// Значение настройки (диапазон long сжимается до int) либо дефолт. - public int GetInt(string key, int defaultValue) - { - return TryReadInt64(key, out long wide) - ? (int)Math.Clamp(wide, int.MinValue, int.MaxValue) - : defaultValue; - } - - /// - /// Длинная целочисленная настройка - /// - /// Ключ настройки (числовой JSON). - /// Дефолт (0 для внутренних счётчиков). - /// Значение настройки либо дефолт. - public long GetLong(string key, long defaultValue) - { - return TryReadInt64(key, out long wide) ? wide : defaultValue; - } - - /// - /// Строковая настройка - /// - /// Ключ настройки (string-категория каталога ). - /// Дефолт из . - /// Значение настройки либо дефолт. - public string GetString(string key, string defaultValue) - { - JsonElement? value = ReadValue(key); - if (value is { ValueKind: JsonValueKind.String }) - { - return value.Value.GetString() ?? defaultValue; - } - - return defaultValue; - } - - /// - /// Список-настройка строк - /// - /// Ключ настройки (list-категория каталога ). - /// Дефолт из . - /// Список строк либо дефолт. - public IReadOnlyList GetStringList(string key, IReadOnlyList defaultValue) - { - JsonElement? value = ReadValue(key); - if (value is null) - { - return defaultValue; - } - - switch (value.Value.ValueKind) - { - case JsonValueKind.Array: - var items = new List(); - foreach (JsonElement element in value.Value.EnumerateArray()) - { - if (element.ValueKind == JsonValueKind.String) - { - items.Add(element.GetString() ?? string.Empty); - } - } - - return items; - case JsonValueKind.String: - return new[] { value.Value.GetString() ?? string.Empty }; - default: - return defaultValue; - } - } - - /// - /// Внутренний кэш курсов ratesCache - /// - /// Кэш {rates, source, updatedAtMs} либо null — строки нет / JSON повреждён / rates не задан (дефолт — мок-курсы, решает вызывающий, как RatesService.LoadCacheAsync). - public RatesCacheValue? TryGetRatesCache() - { - JsonElement? value = ReadValue(SettingsKeys.RatesCache); - if (value is null) - { - return null; - } - - try - { - RatesCacheValue? cache = value.Value.Deserialize(RatesCacheJsonOptions); - return cache is { Rates: not null } ? cache : null; - } - catch (JsonException) - { - // Повреждённый кэш трактуем как отсутствующий (мягкая семантика, как в SettingsService). - return null; - } - } - - // Читает целочисленное значение настройки (JSON-число или числовая строка). - // key: Ключ настройки. - // value: Прочитанное число (int64; диапазон шире int32 — сжимает вызывающий). - // Возвращает: True — значение прочитано; False — отсутствует/повреждено/нечисловое. - private bool TryReadInt64(string key, out long value) - { - JsonElement? element = ReadValue(key); - if (element is null) - { - value = 0; - return false; - } - - if (element.Value.ValueKind == JsonValueKind.Number) - { - return element.Value.TryGetInt64(out value); - } - - if (element.Value.ValueKind == JsonValueKind.String) - { - return long.TryParse( - element.Value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value); - } - - value = 0; - return false; - } - - // Распарсенное JSON-значение сохранённой настройки; отсутствие строки/повреждённый JSON → null. - // key: Ключ настройки. - // Возвращает: Корневой JsonElement (копия, живёт после вызова) или null. - private JsonElement? ReadValue(string key) - { - if (!_overrides.TryGetValue(key, out SettingValue? row)) - { - return null; - } - - try - { - using JsonDocument document = JsonDocument.Parse(row.ValueJson); - return document.RootElement.Clone(); - } - catch (JsonException) - { - // Повреждённая строка — трактуем как отсутствующую (мягкая семантика, как в SettingsService). - return null; - } - } -} +using System.Globalization; +using System.Text.Json; +using Deal.Modules.Settings.Application.Abstractions; + +namespace Deal.Modules.Settings.Application.Models; + +/// +/// Типизированный снимок настроек тенанта +/// +public sealed class TenantSettingsSnapshot +{ + private static readonly JsonSerializerOptions RatesCacheJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + // Переопределения тенанта: ключ → строка settings (копия на момент загрузки). + private readonly IReadOnlyDictionary _overrides; + + // Создаёт снимок из готовых строк (конструктор приватный — вход через LoadAsync/FromRows). + // overrides: Переопределения настроек тенанта (ключ → строка). + private TenantSettingsSnapshot(IReadOnlyDictionary overrides) + { + _overrides = overrides; + } + + /// + /// Читает ВСЕ переопределения тенанта одним запросом и строит снимок. + /// + /// KV-хранилище настроек тенанта (таблица settings). + /// Снимок (значения резолвятся лениво методами Get* с дефолтами SettingsDefaults). + public static async Task LoadAsync(ISettingsStore store, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(store); + IReadOnlyCollection rows = await store.GetAllAsync(ct); + return FromRows(rows); + } + + /// + /// Строит снимок из готовых строк — переиспользование одного чтения на операцию/батч. + /// + /// Строки настроек (например, из одного GetAllAsync вызывающего). + /// Снимок над переданными строками. + public static TenantSettingsSnapshot FromRows(IEnumerable rows) + { + ArgumentNullException.ThrowIfNull(rows); + var map = new Dictionary(StringComparer.Ordinal); + foreach (SettingValue row in rows) + { + map[row.Key] = row; + } + + return new TenantSettingsSnapshot(map); + } + + /// + /// Булева настройка + /// + /// Ключ настройки (bool-категория каталога ). + /// Дефолт из . + /// Значение настройки либо дефолт (отсутствие строки/повреждение/другой JSON-вид). + public bool GetBool(string key, bool defaultValue) + { + JsonElement? value = ReadValue(key); + if (value is { ValueKind: JsonValueKind.True }) + { + return true; + } + + if (value is { ValueKind: JsonValueKind.False }) + { + return false; + } + + return defaultValue; + } + + /// + /// Целочисленная настройка + /// + /// Ключ настройки (int-категория каталога ). + /// Дефолт из . + /// Значение настройки (диапазон long сжимается до int) либо дефолт. + public int GetInt(string key, int defaultValue) + { + return TryReadInt64(key, out long wide) + ? (int)Math.Clamp(wide, int.MinValue, int.MaxValue) + : defaultValue; + } + + /// + /// Длинная целочисленная настройка + /// + /// Ключ настройки (числовой JSON). + /// Дефолт (0 для внутренних счётчиков). + /// Значение настройки либо дефолт. + public long GetLong(string key, long defaultValue) + { + return TryReadInt64(key, out long wide) ? wide : defaultValue; + } + + /// + /// Строковая настройка + /// + /// Ключ настройки (string-категория каталога ). + /// Дефолт из . + /// Значение настройки либо дефолт. + public string GetString(string key, string defaultValue) + { + JsonElement? value = ReadValue(key); + if (value is { ValueKind: JsonValueKind.String }) + { + return value.Value.GetString() ?? defaultValue; + } + + return defaultValue; + } + + /// + /// Список-настройка строк + /// + /// Ключ настройки (list-категория каталога ). + /// Дефолт из . + /// Список строк либо дефолт. + public IReadOnlyList GetStringList(string key, IReadOnlyList defaultValue) + { + JsonElement? value = ReadValue(key); + if (value is null) + { + return defaultValue; + } + + switch (value.Value.ValueKind) + { + case JsonValueKind.Array: + var items = new List(); + foreach (JsonElement element in value.Value.EnumerateArray()) + { + if (element.ValueKind == JsonValueKind.String) + { + items.Add(element.GetString() ?? string.Empty); + } + } + + return items; + case JsonValueKind.String: + return new[] { value.Value.GetString() ?? string.Empty }; + default: + return defaultValue; + } + } + + /// + /// Внутренний кэш курсов ratesCache + /// + /// Кэш {rates, source, updatedAtMs} либо null — строки нет / JSON повреждён / rates не задан (дефолт — мок-курсы, решает вызывающий, как RatesService.LoadCacheAsync). + public RatesCacheValue? TryGetRatesCache() + { + JsonElement? value = ReadValue(SettingsKeys.RatesCache); + if (value is null) + { + return null; + } + + try + { + RatesCacheValue? cache = value.Value.Deserialize(RatesCacheJsonOptions); + return cache is { Rates: not null } ? cache : null; + } + catch (JsonException) + { + // Повреждённый кэш трактуем как отсутствующий (мягкая семантика, как в SettingsService). + return null; + } + } + + // Читает целочисленное значение настройки (JSON-число или числовая строка). + // key: Ключ настройки. + // value: Прочитанное число (int64; диапазон шире int32 — сжимает вызывающий). + // Возвращает: True — значение прочитано; False — отсутствует/повреждено/нечисловое. + private bool TryReadInt64(string key, out long value) + { + JsonElement? element = ReadValue(key); + if (element is null) + { + value = 0; + return false; + } + + if (element.Value.ValueKind == JsonValueKind.Number) + { + return element.Value.TryGetInt64(out value); + } + + if (element.Value.ValueKind == JsonValueKind.String) + { + return long.TryParse( + element.Value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value); + } + + value = 0; + return false; + } + + // Распарсенное JSON-значение сохранённой настройки; отсутствие строки/повреждённый JSON → null. + // key: Ключ настройки. + // Возвращает: Корневой JsonElement (копия, живёт после вызова) или null. + private JsonElement? ReadValue(string key) + { + if (!_overrides.TryGetValue(key, out SettingValue? row)) + { + return null; + } + + try + { + using JsonDocument document = JsonDocument.Parse(row.ValueJson); + return document.RootElement.Clone(); + } + catch (JsonException) + { + // Повреждённая строка — трактуем как отсутствующую (мягкая семантика, как в SettingsService). + return null; + } + } +} diff --git a/src/core/Deal.Modules.Settings/Application/Registrars/SettingsModuleRegistrar.cs b/src/core/Deal.Modules.Settings/Application/Registrars/SettingsModuleRegistrar.cs index 4d06ef9..9b0eb00 100644 --- a/src/core/Deal.Modules.Settings/Application/Registrars/SettingsModuleRegistrar.cs +++ b/src/core/Deal.Modules.Settings/Application/Registrars/SettingsModuleRegistrar.cs @@ -1,22 +1,22 @@ -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Services; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Modules.Settings.Application.Registrars; - -/// -/// DI-регистрация модуля Settings. -/// -public static class SettingsModuleRegistrar -{ - /// - /// Регистрирует сервисы модуля Settings в контейнере. - /// - public static IServiceCollection AddSettingsModule(this IServiceCollection services) - { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - return services; - } -} +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Modules.Settings.Application.Registrars; + +/// +/// DI-регистрация модуля Settings. +/// +public static class SettingsModuleRegistrar +{ + /// + /// Регистрирует сервисы модуля Settings в контейнере. + /// + public static IServiceCollection AddSettingsModule(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/src/core/Deal.Modules.Settings/Application/Services/RatesService.cs b/src/core/Deal.Modules.Settings/Application/Services/RatesService.cs index c32f5cd..a555c0b 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/RatesService.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/RatesService.cs @@ -1,170 +1,170 @@ -using System.Text.Json; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Settings.Application.Services; - -/// -/// Курсы валют: кэш в tenant-настройке ratesCache, источник по rateSource. -/// -/// KV-хранилище настроек тенанта (таблица settings). -/// Порт источника курсов (ЦБ РФ); не используется в мок-режиме. -/// Слушатели смены курсов; пусто — no-op. -public sealed class RatesService( - ISettingsStore store, - IRatesSource ratesSource, - IEnumerable listeners) -{ - private const string BaseCurrency = "RUB"; - - // Source кэша для мок-курсов. - private const string MockSource = "mock"; - - // Source кэша после успешного запроса к ЦБ. - private const string CbrSource = "cbr"; - - // Значение настройки rateSource, включающее мок-режим. - private const string MockRateSource = "mock"; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - /// - /// Текущее состояние кэша курсов - /// - /// DTO: кэш из ratesCache или дефолт — мок-курсы, source "mock", updatedAt null. - public async Task GetAsync(CancellationToken ct) - { - RatesCacheValue? cache = (await TenantSettingsSnapshot.LoadAsync(store, ct)).TryGetRatesCache(); - if (cache is not null) - { - return ToDto(cache); - } - - return new RatesDto(BaseCurrency, MockRates.Values, MockSource, UpdatedAtMs: null); - } - - /// - /// Обновляет кэш курсов по текущей настройке rateSource - /// - /// True — кэш обновлён (мок-режим всегда успешен); False — сбой источника, кэш не тронут. - public async Task RefreshAsync(CancellationToken ct) - { - string source = (await TenantSettingsSnapshot.LoadAsync(store, ct)) - .GetString(SettingsKeys.RateSource, SettingsDefaults.RateSource); - if (source == MockRateSource) - { - await SaveCacheAsync(new RatesCacheValue(MockRates.Values, MockSource, UtcNowMs()), ct); - await NotifyRatesChangedAsync(ct); - return true; - } - - Dictionary? rates = await ratesSource.FetchAsync(ct); - if (rates is null) - { - return false; - } - - await SaveCacheAsync(new RatesCacheValue(rates, CbrSource, UtcNowMs()), ct); - await NotifyRatesChangedAsync(ct); - return true; - } - - private async Task NotifyRatesChangedAsync(CancellationToken ct) - { - foreach (IRatesChangedListener listener in listeners) - { - await listener.OnRatesChangedAsync(fullRecompute: true, ct); - } - } - - /// - /// Нужно ли обновить кэш - /// - /// True — GET /api/rates должен запустить фоновое обновление. - public async Task ShouldFetchAsync(CancellationToken ct) - { - TenantSettingsSnapshot settings = await TenantSettingsSnapshot.LoadAsync(store, ct); - RatesCacheValue? cache = settings.TryGetRatesCache(); - if (cache is null) - { - return true; - } - - string source = NormalizeSource(settings.GetString(SettingsKeys.RateSource, SettingsDefaults.RateSource)); - if (cache.Source != source) - { - return true; - } - - long intervalMs = (long)MockRates.RatesFetchInterval.TotalMilliseconds; - return UtcNowMs() - cache.UpdatedAtMs >= intervalMs; - } - - /// - /// Конвертирует сумму из одной валюты в другую по курсам к рублю. - /// - /// Сумма в исходной валюте; null → null (поле бюджета не заполнено). - /// Код исходной валюты (RUB/USD/EUR/USDT/…). - /// Код целевой валюты. - /// Курсы к рублю (кэш ratesCache или мок-курсы). - /// Сумма в целевой валюте, округлённая до 2 знаков, или null при отсутствии валюты в курсах. - public static double? ConvertAmount( - double? amount, - string fromCurrency, - string toCurrency, - IReadOnlyDictionary rates) - { - if (amount is null) - { - return null; - } - - double? fromRate = ResolveRate(rates, fromCurrency); - double? toRate = ResolveRate(rates, toCurrency); - if (fromRate is null || toRate is null) - { - return null; - } - - return Math.Round(amount.Value * fromRate.Value / toRate.Value, 2); - } - - // Курс валюты: для USDT — курс USD, если он есть (иначе собственный курс USDT). - // rates: Курсы к рублю. - // currencyCode: Код валюты. - // Возвращает: Курс или null, если валюта отсутствует в курсах. - private static double? ResolveRate(IReadOnlyDictionary rates, string currencyCode) - { - if (currencyCode == "USDT" && rates.TryGetValue("USD", out double usdRate)) - { - return usdRate; - } - - return rates.TryGetValue(currencyCode, out double rate) ? rate : null; - } - - // Строит DTO ответа из значения кэша. - // cache: Значение кэша ratesCache. - // Возвращает: DTO {base, rates, source, updatedAt}. - private static RatesDto ToDto(RatesCacheValue cache) - { - return new RatesDto(BaseCurrency, cache.Rates, cache.Source, cache.UpdatedAtMs); - } - - // Сохраняет кэш курсов в tenant-настройку ratesCache (upsert через порт). - // После неё RefreshAsync вызывает слушателей пересчёта (см. NotifyRatesChangedAsync). - // cache: Новое значение кэша. - // ct: Токен отмены. - private async Task SaveCacheAsync(RatesCacheValue cache, CancellationToken ct) - { - await store.SetAsync(SettingsKeys.RatesCache, JsonSerializer.Serialize(cache, JsonOptions), ct); - } - - private static string NormalizeSource(string setting) => setting == MockRateSource ? MockSource : CbrSource; - - private static long UtcNowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); -} +using System.Text.Json; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Settings.Application.Services; + +/// +/// Курсы валют: кэш в tenant-настройке ratesCache, источник по rateSource. +/// +/// KV-хранилище настроек тенанта (таблица settings). +/// Порт источника курсов (ЦБ РФ); не используется в мок-режиме. +/// Слушатели смены курсов; пусто — no-op. +public sealed class RatesService( + ISettingsStore store, + IRatesSource ratesSource, + IEnumerable listeners) +{ + private const string BaseCurrency = "RUB"; + + // Source кэша для мок-курсов. + private const string MockSource = "mock"; + + // Source кэша после успешного запроса к ЦБ. + private const string CbrSource = "cbr"; + + // Значение настройки rateSource, включающее мок-режим. + private const string MockRateSource = "mock"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + /// + /// Текущее состояние кэша курсов + /// + /// DTO: кэш из ratesCache или дефолт — мок-курсы, source "mock", updatedAt null. + public async Task GetAsync(CancellationToken ct) + { + RatesCacheValue? cache = (await TenantSettingsSnapshot.LoadAsync(store, ct)).TryGetRatesCache(); + if (cache is not null) + { + return ToDto(cache); + } + + return new RatesDto(BaseCurrency, MockRates.Values, MockSource, UpdatedAtMs: null); + } + + /// + /// Обновляет кэш курсов по текущей настройке rateSource + /// + /// True — кэш обновлён (мок-режим всегда успешен); False — сбой источника, кэш не тронут. + public async Task RefreshAsync(CancellationToken ct) + { + string source = (await TenantSettingsSnapshot.LoadAsync(store, ct)) + .GetString(SettingsKeys.RateSource, SettingsDefaults.RateSource); + if (source == MockRateSource) + { + await SaveCacheAsync(new RatesCacheValue(MockRates.Values, MockSource, UtcNowMs()), ct); + await NotifyRatesChangedAsync(ct); + return true; + } + + Dictionary? rates = await ratesSource.FetchAsync(ct); + if (rates is null) + { + return false; + } + + await SaveCacheAsync(new RatesCacheValue(rates, CbrSource, UtcNowMs()), ct); + await NotifyRatesChangedAsync(ct); + return true; + } + + private async Task NotifyRatesChangedAsync(CancellationToken ct) + { + foreach (IRatesChangedListener listener in listeners) + { + await listener.OnRatesChangedAsync(fullRecompute: true, ct); + } + } + + /// + /// Нужно ли обновить кэш + /// + /// True — GET /api/rates должен запустить фоновое обновление. + public async Task ShouldFetchAsync(CancellationToken ct) + { + TenantSettingsSnapshot settings = await TenantSettingsSnapshot.LoadAsync(store, ct); + RatesCacheValue? cache = settings.TryGetRatesCache(); + if (cache is null) + { + return true; + } + + string source = NormalizeSource(settings.GetString(SettingsKeys.RateSource, SettingsDefaults.RateSource)); + if (cache.Source != source) + { + return true; + } + + long intervalMs = (long)MockRates.RatesFetchInterval.TotalMilliseconds; + return UtcNowMs() - cache.UpdatedAtMs >= intervalMs; + } + + /// + /// Конвертирует сумму из одной валюты в другую по курсам к рублю. + /// + /// Сумма в исходной валюте; null → null (поле бюджета не заполнено). + /// Код исходной валюты (RUB/USD/EUR/USDT/…). + /// Код целевой валюты. + /// Курсы к рублю (кэш ratesCache или мок-курсы). + /// Сумма в целевой валюте, округлённая до 2 знаков, или null при отсутствии валюты в курсах. + public static double? ConvertAmount( + double? amount, + string fromCurrency, + string toCurrency, + IReadOnlyDictionary rates) + { + if (amount is null) + { + return null; + } + + double? fromRate = ResolveRate(rates, fromCurrency); + double? toRate = ResolveRate(rates, toCurrency); + if (fromRate is null || toRate is null) + { + return null; + } + + return Math.Round(amount.Value * fromRate.Value / toRate.Value, 2); + } + + // Курс валюты: для USDT — курс USD, если он есть (иначе собственный курс USDT). + // rates: Курсы к рублю. + // currencyCode: Код валюты. + // Возвращает: Курс или null, если валюта отсутствует в курсах. + private static double? ResolveRate(IReadOnlyDictionary rates, string currencyCode) + { + if (currencyCode == "USDT" && rates.TryGetValue("USD", out double usdRate)) + { + return usdRate; + } + + return rates.TryGetValue(currencyCode, out double rate) ? rate : null; + } + + // Строит DTO ответа из значения кэша. + // cache: Значение кэша ratesCache. + // Возвращает: DTO {base, rates, source, updatedAt}. + private static RatesDto ToDto(RatesCacheValue cache) + { + return new RatesDto(BaseCurrency, cache.Rates, cache.Source, cache.UpdatedAtMs); + } + + // Сохраняет кэш курсов в tenant-настройку ratesCache (upsert через порт). + // После неё RefreshAsync вызывает слушателей пересчёта (см. NotifyRatesChangedAsync). + // cache: Новое значение кэша. + // ct: Токен отмены. + private async Task SaveCacheAsync(RatesCacheValue cache, CancellationToken ct) + { + await store.SetAsync(SettingsKeys.RatesCache, JsonSerializer.Serialize(cache, JsonOptions), ct); + } + + private static string NormalizeSource(string setting) => setting == MockRateSource ? MockSource : CbrSource; + + private static long UtcNowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); +} diff --git a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchMyPrompts.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchMyPrompts.cs index 8a766cd..06d379d 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchMyPrompts.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchMyPrompts.cs @@ -1,79 +1,79 @@ -using System.Security.Cryptography; -using System.Text.Json; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Settings.Application.Services; - -// Часть SettingsService: PATCH-ключ myPrompts — чистка и сериализация «Моих промптов» (SerializeCleanMyPrompts -// и чтение полей элемента: ReadTrimmedField/ReadPromptId). colState — passthrough в главном ApplyPatchAsync. -public sealed partial class SettingsService -{ - private static string SerializeCleanMyPrompts(JsonElement array) - { - var clean = new List(); - int considered = 0; - - foreach (JsonElement item in array.EnumerateArray()) - { - if (considered >= MaxMyPrompts) - { - break; // срез value[:100] — как в прототипе, до фильтрации - } - - considered++; - if (item.ValueKind != JsonValueKind.Object) - { - continue; - } - - string name = ReadTrimmedField(item, "name", PromptNameMaxLength); - string prompt = ReadTrimmedField(item, "prompt", PromptTextMaxLength); - if (name.Length == 0 || prompt.Length == 0) - { - continue; // пустые name/prompt — дроп - } - - string description = ReadTrimmedField(item, "description", PromptDescriptionMaxLength); - string id = ReadPromptId(item); - clean.Add(new MyPromptDto(id, name, description, prompt)); - } - - return JsonSerializer.Serialize(clean, JsonOptions); - } - - // Читает строковое поле промпта: trim + срез длины; отсутствие/не-скаляр → "". - // item: Элемент «Моих промптов» (JSON-объект). - // field: Имя поля (name/prompt/description). - // maxLength: Максимальная длина после trim. - // Возвращает: Значение поля (может быть пустым). - private static string ReadTrimmedField( - JsonElement item, - string field, - int maxLength) - { - if (!TryReadFieldText(item, field, out string text)) - { - return string.Empty; - } - - string trimmed = text.Trim(); - return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; - } - - // Читает id промпта: как есть (срез 40) или генерирует pp_ + 8 hex. - // item: Элемент «Моих промптов» (JSON-объект). - // Возвращает: Идентификатор промпта. - private static string ReadPromptId(JsonElement item) - { - if (TryReadFieldText(item, "id", out string id)) - { - string candidate = id.Length <= PromptIdMaxLength ? id : id[..PromptIdMaxLength]; - if (candidate.Length > 0) - { - return candidate; - } - } - - return PromptIdPrefix + Convert.ToHexString(RandomNumberGenerator.GetBytes(PromptIdRandomBytes)).ToLowerInvariant(); - } -} +using System.Security.Cryptography; +using System.Text.Json; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Settings.Application.Services; + +// Часть SettingsService: PATCH-ключ myPrompts — чистка и сериализация «Моих промптов» (SerializeCleanMyPrompts +// и чтение полей элемента: ReadTrimmedField/ReadPromptId). colState — passthrough в главном ApplyPatchAsync. +public sealed partial class SettingsService +{ + private static string SerializeCleanMyPrompts(JsonElement array) + { + var clean = new List(); + int considered = 0; + + foreach (JsonElement item in array.EnumerateArray()) + { + if (considered >= MaxMyPrompts) + { + break; // срез value[:100] — как в прототипе, до фильтрации + } + + considered++; + if (item.ValueKind != JsonValueKind.Object) + { + continue; + } + + string name = ReadTrimmedField(item, "name", PromptNameMaxLength); + string prompt = ReadTrimmedField(item, "prompt", PromptTextMaxLength); + if (name.Length == 0 || prompt.Length == 0) + { + continue; // пустые name/prompt — дроп + } + + string description = ReadTrimmedField(item, "description", PromptDescriptionMaxLength); + string id = ReadPromptId(item); + clean.Add(new MyPromptDto(id, name, description, prompt)); + } + + return JsonSerializer.Serialize(clean, JsonOptions); + } + + // Читает строковое поле промпта: trim + срез длины; отсутствие/не-скаляр → "". + // item: Элемент «Моих промптов» (JSON-объект). + // field: Имя поля (name/prompt/description). + // maxLength: Максимальная длина после trim. + // Возвращает: Значение поля (может быть пустым). + private static string ReadTrimmedField( + JsonElement item, + string field, + int maxLength) + { + if (!TryReadFieldText(item, field, out string text)) + { + return string.Empty; + } + + string trimmed = text.Trim(); + return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; + } + + // Читает id промпта: как есть (срез 40) или генерирует pp_ + 8 hex. + // item: Элемент «Моих промптов» (JSON-объект). + // Возвращает: Идентификатор промпта. + private static string ReadPromptId(JsonElement item) + { + if (TryReadFieldText(item, "id", out string id)) + { + string candidate = id.Length <= PromptIdMaxLength ? id : id[..PromptIdMaxLength]; + if (candidate.Length > 0) + { + return candidate; + } + } + + return PromptIdPrefix + Convert.ToHexString(RandomNumberGenerator.GetBytes(PromptIdRandomBytes)).ToLowerInvariant(); + } +} diff --git a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs index d40fec3..37dfd35 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs @@ -1,140 +1,140 @@ -using System.Text.Json; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Settings.Application.Services; - -// Часть SettingsService: применение скалярных PATCH-ключей — пары пауз авто-вступлений (PrepareDelayClamps), -// int/string-ключи и списки строк (ApplyIntKey/ApplyStringKey/SerializeStringList). -public sealed partial class SettingsService -{ - private static Dictionary PrepareDelayClamps(Dictionary body, Dictionary overrides) - { - var result = new Dictionary(StringComparer.Ordinal); - (int min, int max) = IntClampRanges[SettingsKeys.DiscJoinDelayMin]; - - bool hasMin = body.TryGetValue(SettingsKeys.DiscJoinDelayMin, out JsonElement minElement); - bool hasMax = body.TryGetValue(SettingsKeys.DiscJoinDelayMax, out JsonElement maxElement); - - if (hasMin && hasMax) - { - // Пришли оба конца: клампы (5..600) и min ≤ max; если нет — меняем местами, как делает фронт. - bool minOk = TryReadInt(minElement, out int pairMin); - bool maxOk = TryReadInt(maxElement, out int pairMax); - if (minOk && maxOk) - { - pairMin = Clamp(pairMin, min, max); - pairMax = Clamp(pairMax, min, max); - if (pairMin > pairMax) - { - (pairMin, pairMax) = (pairMax, pairMin); - } - - result[SettingsKeys.DiscJoinDelayMin] = pairMin; - result[SettingsKeys.DiscJoinDelayMax] = pairMax; - } - - // Оба конца пришли, но не «числовые» — общий цикл разберётся по ключам (нечисловое пропустит). - return result; - } - - if (hasMin) - { - // Один конец: кламп относительно сохранённого другого конца (дефолт, если тот не переопределён). - if (TryReadInt(minElement, out int singleMin)) - { - singleMin = Clamp(singleMin, min, max); - int currentMax = MergeInt(overrides, SettingsKeys.DiscJoinDelayMax, SettingsDefaults.DiscJoinDelayMax); - if (currentMax >= min) - { - singleMin = Math.Min(singleMin, currentMax); - } - - result[SettingsKeys.DiscJoinDelayMin] = singleMin; - } - - return result; - } - - if (hasMax && TryReadInt(maxElement, out int singleMax)) - { - singleMax = Clamp(singleMax, min, max); - int currentMin = MergeInt(overrides, SettingsKeys.DiscJoinDelayMin, SettingsDefaults.DiscJoinDelayMin); - if (currentMin <= max) - { - singleMax = Math.Max(singleMax, currentMin); - } - - result[SettingsKeys.DiscJoinDelayMax] = singleMax; - } - - return result; - } - - // Применяет целочисленный ключ: кламп по каталогу, нечисловое — пропуск ключа. - // key: Имя ключа. - // value: JSON-значение из тела PATCH. - // writes: Накопитель записей (key → valueJson). - private static void ApplyIntKey( - string key, - JsonElement value, - Dictionary writes) - { - if (!TryReadInt(value, out int number)) - { - return; // нечисловое — пропуск ключа (L111–115) - } - - if (IntClampRanges.TryGetValue(key, out (int Min, int Max) range)) - { - number = Clamp(number, range.Min, range.Max); - } - - writes[key] = JsonSerializer.Serialize(number); - } - - // Применяет строковый ключ: targetCurrency — Upper; aiProvider — только из каталога провайдеров. - // key: Имя ключа. - // value: JSON-значение из тела PATCH. - // writes: Накопитель записей (key → valueJson). - private static void ApplyStringKey( - string key, - JsonElement value, - Dictionary writes) - { - if (!TryReadText(value, out string? text) || text is null) - { - return; // не-скаляр — пропуск ключа - } - - if (key == SettingsKeys.TargetCurrency) - { - text = text.ToUpperInvariant(); - } - - if (key == SettingsKeys.AiProvider && !ProviderIds.Contains(text)) - { - return; // aiProvider вне списка провайдеров — пропуск (L134–135) - } - - writes[key] = JsonSerializer.Serialize(text); - } - - private static string SerializeStringList(JsonElement array) - { - var items = new List(); - foreach (JsonElement item in array.EnumerateArray()) - { - if (TryReadText(item, out string? text) && text is not null) - { - items.Add(text); - } - } - - if (items.Count > MaxListItems) - { - items = items.Take(MaxListItems).ToList(); - } - - return JsonSerializer.Serialize(items); - } -} +using System.Text.Json; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Settings.Application.Services; + +// Часть SettingsService: применение скалярных PATCH-ключей — пары пауз авто-вступлений (PrepareDelayClamps), +// int/string-ключи и списки строк (ApplyIntKey/ApplyStringKey/SerializeStringList). +public sealed partial class SettingsService +{ + private static Dictionary PrepareDelayClamps(Dictionary body, Dictionary overrides) + { + var result = new Dictionary(StringComparer.Ordinal); + (int min, int max) = IntClampRanges[SettingsKeys.DiscJoinDelayMin]; + + bool hasMin = body.TryGetValue(SettingsKeys.DiscJoinDelayMin, out JsonElement minElement); + bool hasMax = body.TryGetValue(SettingsKeys.DiscJoinDelayMax, out JsonElement maxElement); + + if (hasMin && hasMax) + { + // Пришли оба конца: клампы (5..600) и min ≤ max; если нет — меняем местами, как делает фронт. + bool minOk = TryReadInt(minElement, out int pairMin); + bool maxOk = TryReadInt(maxElement, out int pairMax); + if (minOk && maxOk) + { + pairMin = Clamp(pairMin, min, max); + pairMax = Clamp(pairMax, min, max); + if (pairMin > pairMax) + { + (pairMin, pairMax) = (pairMax, pairMin); + } + + result[SettingsKeys.DiscJoinDelayMin] = pairMin; + result[SettingsKeys.DiscJoinDelayMax] = pairMax; + } + + // Оба конца пришли, но не «числовые» — общий цикл разберётся по ключам (нечисловое пропустит). + return result; + } + + if (hasMin) + { + // Один конец: кламп относительно сохранённого другого конца (дефолт, если тот не переопределён). + if (TryReadInt(minElement, out int singleMin)) + { + singleMin = Clamp(singleMin, min, max); + int currentMax = MergeInt(overrides, SettingsKeys.DiscJoinDelayMax, SettingsDefaults.DiscJoinDelayMax); + if (currentMax >= min) + { + singleMin = Math.Min(singleMin, currentMax); + } + + result[SettingsKeys.DiscJoinDelayMin] = singleMin; + } + + return result; + } + + if (hasMax && TryReadInt(maxElement, out int singleMax)) + { + singleMax = Clamp(singleMax, min, max); + int currentMin = MergeInt(overrides, SettingsKeys.DiscJoinDelayMin, SettingsDefaults.DiscJoinDelayMin); + if (currentMin <= max) + { + singleMax = Math.Max(singleMax, currentMin); + } + + result[SettingsKeys.DiscJoinDelayMax] = singleMax; + } + + return result; + } + + // Применяет целочисленный ключ: кламп по каталогу, нечисловое — пропуск ключа. + // key: Имя ключа. + // value: JSON-значение из тела PATCH. + // writes: Накопитель записей (key → valueJson). + private static void ApplyIntKey( + string key, + JsonElement value, + Dictionary writes) + { + if (!TryReadInt(value, out int number)) + { + return; // нечисловое — пропуск ключа (L111–115) + } + + if (IntClampRanges.TryGetValue(key, out (int Min, int Max) range)) + { + number = Clamp(number, range.Min, range.Max); + } + + writes[key] = JsonSerializer.Serialize(number); + } + + // Применяет строковый ключ: targetCurrency — Upper; aiProvider — только из каталога провайдеров. + // key: Имя ключа. + // value: JSON-значение из тела PATCH. + // writes: Накопитель записей (key → valueJson). + private static void ApplyStringKey( + string key, + JsonElement value, + Dictionary writes) + { + if (!TryReadText(value, out string? text) || text is null) + { + return; // не-скаляр — пропуск ключа + } + + if (key == SettingsKeys.TargetCurrency) + { + text = text.ToUpperInvariant(); + } + + if (key == SettingsKeys.AiProvider && !ProviderIds.Contains(text)) + { + return; // aiProvider вне списка провайдеров — пропуск (L134–135) + } + + writes[key] = JsonSerializer.Serialize(text); + } + + private static string SerializeStringList(JsonElement array) + { + var items = new List(); + foreach (JsonElement item in array.EnumerateArray()) + { + if (TryReadText(item, out string? text) && text is not null) + { + items.Add(text); + } + } + + if (items.Count > MaxListItems) + { + items = items.Take(MaxListItems).ToList(); + } + + return JsonSerializer.Serialize(items); + } +} diff --git a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchSecrets.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchSecrets.cs index 2d42920..1576558 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchSecrets.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchSecrets.cs @@ -1,102 +1,102 @@ -using System.Text.Json; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Settings.Application.Services; - -// Часть SettingsService: PATCH-ключ секрета aiConfigs (ApplyAiConfigsKey, SSRF-гейт baseUrl, шифрование -// apiKey), включая удаление переопределения, вернувшегося к дефолту (EqualsDefault). -public sealed partial class SettingsService -{ - private void ApplyAiConfigsKey( - JsonElement value, - Dictionary overrides, - Dictionary writes, - List removals) - { - if (value.ValueKind != JsonValueKind.Object) - { - return; - } - - // База — эффективные конфиги (дефолты + сохранённые переопределения): PATCH дописывает поля. - var effective = new Dictionary( - MergeAiConfigs(SettingsDefaults.AiConfigs, overrides), - StringComparer.Ordinal); - - foreach (JsonProperty provider in value.EnumerateObject()) - { - if (!effective.ContainsKey(provider.Name) || provider.Value.ValueKind != JsonValueKind.Object) - { - continue; - } - - AiConfigSetting config = effective[provider.Name]; - string providerId = provider.Name; - string apiKey = config.ApiKey; - string baseUrl = config.BaseUrl; - string model = config.Model; - JsonElement entry = provider.Value; - - // Каталог провайдеров: для baseUrl-гейта нужен признак local и фиксированный адрес каталога. - AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(p => p.Id == providerId); - - // baseUrl: переопределение принимается ТОЛЬКО для локальных провайдеров (Ollama/LM Studio — свой - // адрес на машине пользователя) и custom (OpenAI-совместимый — адрес задаёт сам пользователь). - // У каталоговых облачных провайдеров (deepseek/openai/openrouter/anthropic) адрес фиксирован - // каталогом — иначе тенант мог бы перенаправить ключ/запрос на произвольный внутренний адрес - // (SSRF, Security review) и проверка подключения ушла бы на него. - bool baseUrlMutable = meta is { Local: true } || providerId == CustomProviderId; - if (baseUrlMutable && TryReadFieldText(entry, "baseUrl", out string newBaseUrl)) - { - baseUrl = newBaseUrl; - } - - if (TryReadFieldText(entry, "model", out string newModel)) - { - model = newModel; - } - - // apiKey: непустой, ≥8 симв., без префикса enc: и без маски (содержит "…") → шифруется; - // иначе (пустой/маска) ключ не меняется. - if (TryReadFieldText(entry, "apiKey", out string newKey) - && newKey.Length >= ApiKeyMinLength - && !newKey.StartsWith(EncryptedValuePrefix, StringComparison.Ordinal) - && !newKey.Contains(MaskEllipsis, StringComparison.Ordinal)) - { - apiKey = secretCipher.Encrypt(newKey); - } - - effective[provider.Name] = new AiConfigSetting(apiKey, baseUrl, model); - } - - if (EqualsDefault(effective)) - { - removals.Add(SettingsKeys.AiConfigs); - } - else - { - writes[SettingsKeys.AiConfigs] = JsonSerializer.Serialize(effective, JsonOptions); - } - } - - // True — эффективный aiConfigs совпадает с дефолтами (переопределение можно не хранить). - // configs: Эффективные конфиги. - // Возвращает: True — хранить переопределение не нужно. - private static bool EqualsDefault(IReadOnlyDictionary configs) - { - if (configs.Count != SettingsDefaults.AiConfigs.Count) - { - return false; - } - - foreach ((string providerId, AiConfigSetting config) in configs) - { - if (!SettingsDefaults.AiConfigs.ContainsKey(providerId) || config != SettingsDefaults.AiConfigs[providerId]) - { - return false; - } - } - - return true; - } -} +using System.Text.Json; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Settings.Application.Services; + +// Часть SettingsService: PATCH-ключ секрета aiConfigs (ApplyAiConfigsKey, SSRF-гейт baseUrl, шифрование +// apiKey), включая удаление переопределения, вернувшегося к дефолту (EqualsDefault). +public sealed partial class SettingsService +{ + private void ApplyAiConfigsKey( + JsonElement value, + Dictionary overrides, + Dictionary writes, + List removals) + { + if (value.ValueKind != JsonValueKind.Object) + { + return; + } + + // База — эффективные конфиги (дефолты + сохранённые переопределения): PATCH дописывает поля. + var effective = new Dictionary( + MergeAiConfigs(SettingsDefaults.AiConfigs, overrides), + StringComparer.Ordinal); + + foreach (JsonProperty provider in value.EnumerateObject()) + { + if (!effective.ContainsKey(provider.Name) || provider.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + + AiConfigSetting config = effective[provider.Name]; + string providerId = provider.Name; + string apiKey = config.ApiKey; + string baseUrl = config.BaseUrl; + string model = config.Model; + JsonElement entry = provider.Value; + + // Каталог провайдеров: для baseUrl-гейта нужен признак local и фиксированный адрес каталога. + AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(p => p.Id == providerId); + + // baseUrl: переопределение принимается ТОЛЬКО для локальных провайдеров (Ollama/LM Studio — свой + // адрес на машине пользователя) и custom (OpenAI-совместимый — адрес задаёт сам пользователь). + // У каталоговых облачных провайдеров (deepseek/openai/openrouter/anthropic) адрес фиксирован + // каталогом — иначе тенант мог бы перенаправить ключ/запрос на произвольный внутренний адрес + // (SSRF, Security review) и проверка подключения ушла бы на него. + bool baseUrlMutable = meta is { Local: true } || providerId == CustomProviderId; + if (baseUrlMutable && TryReadFieldText(entry, "baseUrl", out string newBaseUrl)) + { + baseUrl = newBaseUrl; + } + + if (TryReadFieldText(entry, "model", out string newModel)) + { + model = newModel; + } + + // apiKey: непустой, ≥8 симв., без префикса enc: и без маски (содержит "…") → шифруется; + // иначе (пустой/маска) ключ не меняется. + if (TryReadFieldText(entry, "apiKey", out string newKey) + && newKey.Length >= ApiKeyMinLength + && !newKey.StartsWith(EncryptedValuePrefix, StringComparison.Ordinal) + && !newKey.Contains(MaskEllipsis, StringComparison.Ordinal)) + { + apiKey = secretCipher.Encrypt(newKey); + } + + effective[provider.Name] = new AiConfigSetting(apiKey, baseUrl, model); + } + + if (EqualsDefault(effective)) + { + removals.Add(SettingsKeys.AiConfigs); + } + else + { + writes[SettingsKeys.AiConfigs] = JsonSerializer.Serialize(effective, JsonOptions); + } + } + + // True — эффективный aiConfigs совпадает с дефолтами (переопределение можно не хранить). + // configs: Эффективные конфиги. + // Возвращает: True — хранить переопределение не нужно. + private static bool EqualsDefault(IReadOnlyDictionary configs) + { + if (configs.Count != SettingsDefaults.AiConfigs.Count) + { + return false; + } + + foreach ((string providerId, AiConfigSetting config) in configs) + { + if (!SettingsDefaults.AiConfigs.ContainsKey(providerId) || config != SettingsDefaults.AiConfigs[providerId]) + { + return false; + } + } + + return true; + } +} diff --git a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PublicForms.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PublicForms.cs index 13c893a..5df5241 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PublicForms.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PublicForms.cs @@ -1,26 +1,26 @@ -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Settings.Application.Services; - -public sealed partial class SettingsService -{ - private AiConfigPublicDto ToPublic(AiConfigSetting config) - { - string plainKey = secretCipher.Decrypt(config.ApiKey); - return new AiConfigPublicDto( - BaseUrl: config.BaseUrl, - Model: config.Model, - KeySet: !string.IsNullOrEmpty(plainKey), - KeyMasked: MaskSecret(plainKey)); - } - - private static string MaskSecret(string value) - { - return value.Length switch - { - 0 => string.Empty, - <= 8 => string.Concat(value.AsSpan(0, 1), MaskEllipsis), - _ => string.Concat(value.AsSpan(0, 4), MaskEllipsis, value.AsSpan(value.Length - 4)), - }; - } -} +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Settings.Application.Services; + +public sealed partial class SettingsService +{ + private AiConfigPublicDto ToPublic(AiConfigSetting config) + { + string plainKey = secretCipher.Decrypt(config.ApiKey); + return new AiConfigPublicDto( + BaseUrl: config.BaseUrl, + Model: config.Model, + KeySet: !string.IsNullOrEmpty(plainKey), + KeyMasked: MaskSecret(plainKey)); + } + + private static string MaskSecret(string value) + { + return value.Length switch + { + 0 => string.Empty, + <= 8 => string.Concat(value.AsSpan(0, 1), MaskEllipsis), + _ => string.Concat(value.AsSpan(0, 4), MaskEllipsis, value.AsSpan(value.Length - 4)), + }; + } +} diff --git a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs index 8446961..f2865d3 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs @@ -1,251 +1,251 @@ -using System.Globalization; -using System.Text.Json; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Settings.Application.Services; - -public sealed partial class SettingsService -{ - private async Task> LoadStoredOverridesAsync(CancellationToken ct) - { - var result = new Dictionary(StringComparer.Ordinal); - IReadOnlyCollection rows = await store.GetAllAsync(ct); - - foreach (SettingValue row in rows) - { - try - { - using JsonDocument document = JsonDocument.Parse(row.ValueJson); - result[row.Key] = document.RootElement.Clone(); - } - catch (JsonException) - { - // Повреждённая строка не должна ронять снимок: значение трактуется как отсутствующее (дефолт). - } - } - - return result; - } - - private static bool TryReadInt(JsonElement element, out int value) - { - long wide; - if (element.ValueKind == JsonValueKind.Number) - { - if (!element.TryGetInt64(out wide)) - { - value = 0; - return false; - } - } - else if (element.ValueKind == JsonValueKind.String) - { - if (!long.TryParse(element.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out wide)) - { - value = 0; - return false; - } - } - else - { - value = 0; - return false; - } - - // Диапазон long шире int: сжимаем (клампы PATCH всё равно ограничены диапазоном меньше int32). - value = (int)Math.Clamp(wide, int.MinValue, int.MaxValue); - return true; - } - - private static bool TryReadBool(JsonElement element, out bool value) - { - if (element.ValueKind is JsonValueKind.True or JsonValueKind.False) - { - value = element.GetBoolean(); - return true; - } - - value = false; - return false; - } - - private static bool TryReadText(JsonElement element, out string? text) - { - switch (element.ValueKind) - { - case JsonValueKind.String: - text = element.GetString(); - return true; - - case JsonValueKind.Number: - text = element.GetRawText(); - return true; - - case JsonValueKind.True: - text = "true"; - return true; - - case JsonValueKind.False: - text = "false"; - return true; - - default: - text = null; - return false; - } - } - - // Читает строковое поле объекта (JSON null / отсутствие / не-скаляр → False). - // obj: JSON-объект. - // name: Имя поля. - // text: Значение поля. - // Возвращает: True — поле присутствует и скалярно. - private static bool TryReadFieldText( - JsonElement obj, - string name, - out string text) - { - if (obj.TryGetProperty(name, out JsonElement element) && TryReadText(element, out string? value) && value is not null) - { - text = value; - return true; - } - - text = string.Empty; - return false; - } - - // Кламп целого в диапазон. - // value: Исходное значение. - // min: Нижняя граница. - // max: Верхняя граница. - // Возвращает: Значение в диапазоне [min..max]. - private static int Clamp( - int value, - int min, - int max) => Math.Max(min, Math.Min(max, value)); - - // Объединяет дефолт и переопределение целочисленного ключа. - private static int MergeInt( - Dictionary overrides, - string key, - int defaultValue) - { - return overrides.TryGetValue(key, out JsonElement element) && TryReadInt(element, out int value) - ? value - : defaultValue; - } - - // Объединяет дефолт и переопределение булева ключа. - private static bool MergeBool( - Dictionary overrides, - string key, - bool defaultValue) - { - return overrides.TryGetValue(key, out JsonElement element) && TryReadBool(element, out bool value) - ? value - : defaultValue; - } - - // Объединяет дефолт и переопределение строкового ключа. - private static string MergeString( - Dictionary overrides, - string key, - string defaultValue) - { - return overrides.TryGetValue(key, out JsonElement element) - && TryReadText(element, out string? value) - && value is not null - ? value - : defaultValue; - } - - // Объединяет дефолт и переопределение списка строк. - private static IReadOnlyList MergeStringList( - Dictionary overrides, - string key, - IReadOnlyList defaultValue) - { - if (overrides.TryGetValue(key, out JsonElement element) && element.ValueKind == JsonValueKind.Array) - { - var items = new List(); - foreach (JsonElement item in element.EnumerateArray()) - { - if (TryReadText(item, out string? text) && text is not null) - { - items.Add(text); - } - } - - return items; - } - - return defaultValue; - } - - // Объединяет дефолт и переопределение словаря (colState): значения сохраняются как JsonElement. - private static IReadOnlyDictionary MergeDict(Dictionary overrides, string key) - { - if (overrides.TryGetValue(key, out JsonElement element) && element.ValueKind == JsonValueKind.Object) - { - var result = new Dictionary(StringComparer.Ordinal); - foreach (JsonProperty property in element.EnumerateObject()) - { - result[property.Name] = property.Value.Clone(); - } - - return result; - } - - return new Dictionary(StringComparer.Ordinal); - } - - // Объединяет дефолты aiConfigs и сохранённое переопределение (переопределение — на провайдера). - // defaults: Дефолтные конфиги на всех провайдеров каталога. - // overrides: Сохранённые переопределения. - // Возвращает: Эффективные конфиги: провайдеры дефолтов + перекрытия из хранилища (неизвестные пропускаются). - private static IReadOnlyDictionary MergeAiConfigs(IReadOnlyDictionary defaults, Dictionary overrides) - { - var merged = defaults.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); - - if (overrides.TryGetValue(SettingsKeys.AiConfigs, out JsonElement element) && element.ValueKind == JsonValueKind.Object) - { - foreach (JsonProperty provider in element.EnumerateObject()) - { - // «Только существующие провайдеры»: неизвестные id из хранилища не подмешиваются. - if (!merged.ContainsKey(provider.Name) || provider.Value.ValueKind != JsonValueKind.Object) - { - continue; - } - - string apiKey = TryReadFieldText(provider.Value, "apiKey", out string storedKey) ? storedKey : string.Empty; - string baseUrl = TryReadFieldText(provider.Value, "baseUrl", out string storedBase) ? storedBase : string.Empty; - string model = TryReadFieldText(provider.Value, "model", out string storedModel) ? storedModel : string.Empty; - merged[provider.Name] = new AiConfigSetting(apiKey, baseUrl, model); - } - } - - return merged; - } - - // Читает сохранённые «Мои промпты»; повреждённое значение → пустой список (дефолт). - // overrides: Сохранённые переопределения. - // Возвращает: Список промптов из хранилища или дефолт (пустой). - private static IReadOnlyList MergeMyPrompts(Dictionary overrides) - { - if (overrides.TryGetValue(SettingsKeys.MyPrompts, out JsonElement element) && element.ValueKind == JsonValueKind.Array) - { - try - { - return JsonSerializer.Deserialize>(element.GetRawText(), JsonOptions) - ?? new List(); - } - catch (JsonException) - { - return Array.Empty(); - } - } - - return SettingsDefaults.MyPrompts; - } -} +using System.Globalization; +using System.Text.Json; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Settings.Application.Services; + +public sealed partial class SettingsService +{ + private async Task> LoadStoredOverridesAsync(CancellationToken ct) + { + var result = new Dictionary(StringComparer.Ordinal); + IReadOnlyCollection rows = await store.GetAllAsync(ct); + + foreach (SettingValue row in rows) + { + try + { + using JsonDocument document = JsonDocument.Parse(row.ValueJson); + result[row.Key] = document.RootElement.Clone(); + } + catch (JsonException) + { + // Повреждённая строка не должна ронять снимок: значение трактуется как отсутствующее (дефолт). + } + } + + return result; + } + + private static bool TryReadInt(JsonElement element, out int value) + { + long wide; + if (element.ValueKind == JsonValueKind.Number) + { + if (!element.TryGetInt64(out wide)) + { + value = 0; + return false; + } + } + else if (element.ValueKind == JsonValueKind.String) + { + if (!long.TryParse(element.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out wide)) + { + value = 0; + return false; + } + } + else + { + value = 0; + return false; + } + + // Диапазон long шире int: сжимаем (клампы PATCH всё равно ограничены диапазоном меньше int32). + value = (int)Math.Clamp(wide, int.MinValue, int.MaxValue); + return true; + } + + private static bool TryReadBool(JsonElement element, out bool value) + { + if (element.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + value = element.GetBoolean(); + return true; + } + + value = false; + return false; + } + + private static bool TryReadText(JsonElement element, out string? text) + { + switch (element.ValueKind) + { + case JsonValueKind.String: + text = element.GetString(); + return true; + + case JsonValueKind.Number: + text = element.GetRawText(); + return true; + + case JsonValueKind.True: + text = "true"; + return true; + + case JsonValueKind.False: + text = "false"; + return true; + + default: + text = null; + return false; + } + } + + // Читает строковое поле объекта (JSON null / отсутствие / не-скаляр → False). + // obj: JSON-объект. + // name: Имя поля. + // text: Значение поля. + // Возвращает: True — поле присутствует и скалярно. + private static bool TryReadFieldText( + JsonElement obj, + string name, + out string text) + { + if (obj.TryGetProperty(name, out JsonElement element) && TryReadText(element, out string? value) && value is not null) + { + text = value; + return true; + } + + text = string.Empty; + return false; + } + + // Кламп целого в диапазон. + // value: Исходное значение. + // min: Нижняя граница. + // max: Верхняя граница. + // Возвращает: Значение в диапазоне [min..max]. + private static int Clamp( + int value, + int min, + int max) => Math.Max(min, Math.Min(max, value)); + + // Объединяет дефолт и переопределение целочисленного ключа. + private static int MergeInt( + Dictionary overrides, + string key, + int defaultValue) + { + return overrides.TryGetValue(key, out JsonElement element) && TryReadInt(element, out int value) + ? value + : defaultValue; + } + + // Объединяет дефолт и переопределение булева ключа. + private static bool MergeBool( + Dictionary overrides, + string key, + bool defaultValue) + { + return overrides.TryGetValue(key, out JsonElement element) && TryReadBool(element, out bool value) + ? value + : defaultValue; + } + + // Объединяет дефолт и переопределение строкового ключа. + private static string MergeString( + Dictionary overrides, + string key, + string defaultValue) + { + return overrides.TryGetValue(key, out JsonElement element) + && TryReadText(element, out string? value) + && value is not null + ? value + : defaultValue; + } + + // Объединяет дефолт и переопределение списка строк. + private static IReadOnlyList MergeStringList( + Dictionary overrides, + string key, + IReadOnlyList defaultValue) + { + if (overrides.TryGetValue(key, out JsonElement element) && element.ValueKind == JsonValueKind.Array) + { + var items = new List(); + foreach (JsonElement item in element.EnumerateArray()) + { + if (TryReadText(item, out string? text) && text is not null) + { + items.Add(text); + } + } + + return items; + } + + return defaultValue; + } + + // Объединяет дефолт и переопределение словаря (colState): значения сохраняются как JsonElement. + private static IReadOnlyDictionary MergeDict(Dictionary overrides, string key) + { + if (overrides.TryGetValue(key, out JsonElement element) && element.ValueKind == JsonValueKind.Object) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + result[property.Name] = property.Value.Clone(); + } + + return result; + } + + return new Dictionary(StringComparer.Ordinal); + } + + // Объединяет дефолты aiConfigs и сохранённое переопределение (переопределение — на провайдера). + // defaults: Дефолтные конфиги на всех провайдеров каталога. + // overrides: Сохранённые переопределения. + // Возвращает: Эффективные конфиги: провайдеры дефолтов + перекрытия из хранилища (неизвестные пропускаются). + private static IReadOnlyDictionary MergeAiConfigs(IReadOnlyDictionary defaults, Dictionary overrides) + { + var merged = defaults.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + + if (overrides.TryGetValue(SettingsKeys.AiConfigs, out JsonElement element) && element.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty provider in element.EnumerateObject()) + { + // «Только существующие провайдеры»: неизвестные id из хранилища не подмешиваются. + if (!merged.ContainsKey(provider.Name) || provider.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + + string apiKey = TryReadFieldText(provider.Value, "apiKey", out string storedKey) ? storedKey : string.Empty; + string baseUrl = TryReadFieldText(provider.Value, "baseUrl", out string storedBase) ? storedBase : string.Empty; + string model = TryReadFieldText(provider.Value, "model", out string storedModel) ? storedModel : string.Empty; + merged[provider.Name] = new AiConfigSetting(apiKey, baseUrl, model); + } + } + + return merged; + } + + // Читает сохранённые «Мои промпты»; повреждённое значение → пустой список (дефолт). + // overrides: Сохранённые переопределения. + // Возвращает: Список промптов из хранилища или дефолт (пустой). + private static IReadOnlyList MergeMyPrompts(Dictionary overrides) + { + if (overrides.TryGetValue(SettingsKeys.MyPrompts, out JsonElement element) && element.ValueKind == JsonValueKind.Array) + { + try + { + return JsonSerializer.Deserialize>(element.GetRawText(), JsonOptions) + ?? new List(); + } + catch (JsonException) + { + return Array.Empty(); + } + } + + return SettingsDefaults.MyPrompts; + } +} diff --git a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs index 8038b4f..eb8a3ee 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs @@ -1,281 +1,281 @@ -using System.Text.Json; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Settings.Application.Services; - -/// -/// Сервис настроек тенанта -/// -/// KV-хранилище настроек тенанта (таблица settings). -/// Шифр секретов. -/// Слушатели смены настроек конверсии; пусто — no-op. -public sealed partial class SettingsService( - ISettingsStore store, - ISecretCipher secretCipher, - IEnumerable listeners) -{ - - private const string EncryptedValuePrefix = "enc:"; - - // Символ-заполнитель маски секрета (U+2026, «1234…5678»): строка, содержащая его, - // — это маска из public-снимка, а не новый секрет (Security review: не шифровать маску). - private const string MaskEllipsis = "…"; - - // Id провайдера «Другой (OpenAI-совместимый)» — единственный не-local, чей baseUrl - // задаёт пользователь (остальным адрес фиксирован каталогом, SSRF-гейт). - private const string CustomProviderId = "custom"; - - // Максимум элементов списка-настройки при записи из PATCH (срез 200). - private const int MaxListItems = 200; - - // Минимальная длина нового API-ключа, который принимает PATCH (≥8 симв.). - private const int ApiKeyMinLength = 8; - - // Максимум элементов «Моих промптов» при записи из PATCH (≤100). - private const int MaxMyPrompts = 100; - - // Максимальная длина названия промпта (≤80). - private const int PromptNameMaxLength = 80; - - // Максимальная длина описания промпта (≤300). - private const int PromptDescriptionMaxLength = 300; - - // Максимальная длина текста промпта (≤8000). - private const int PromptTextMaxLength = 8000; - - // Максимальная длина id промпта из клиента (≤40); иначе генерируется. - private const int PromptIdMaxLength = 40; - - // Префикс генерируемого id промпта (референс store.uid("pp_")). - private const string PromptIdPrefix = "pp_"; - - // Размер случайного суффикса id промпта в байтах (8 hex-символов). - private const int PromptIdRandomBytes = 4; - - private static readonly IReadOnlyDictionary IntClampRanges = - new Dictionary - { - [SettingsKeys.ArchiveAfterDays] = (1, 30), - [SettingsKeys.MinLen] = (10, 500), - [SettingsKeys.DiscJoinLimit] = (1, 200), - [SettingsKeys.DiscJoinDelayMin] = (5, 600), - [SettingsKeys.DiscJoinDelayMax] = (5, 600), - [SettingsKeys.DiscEvalSample] = (3, 30), - [SettingsKeys.DiscEvalThreshold] = (1, 100), - }; - - // Идентификаторы известных провайдеров (валидация aiProvider/aiConfigs). - private static readonly IReadOnlySet ProviderIds = - AiProviders.All.Select(provider => provider.Id).ToHashSet(StringComparer.Ordinal); - - private static readonly IReadOnlyList PublicProviders = - AiProviders.All - .Select(provider => new ProviderPublicDto( - provider.Id, provider.Name, provider.Base, provider.Local, provider.Models)) - .ToList(); - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - /// - /// Публичный снимок настроек - /// - /// Полный public-снимок (тело GET/PATCH /api/settings). - public async Task GetPublicAsync(CancellationToken ct) - { - Dictionary overrides = await LoadStoredOverridesAsync(ct); - return BuildSnapshot(overrides); - } - - /// - /// Частичное обновление настроек - /// - /// Тело PATCH — произвольный JSON-объект из публичных ключей. - /// Полный public-снимок после применения (как GET). - public async Task ApplyPatchAsync(Dictionary body, CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(body); - - Dictionary overrides = await LoadStoredOverridesAsync(ct); - - Dictionary preparedDelays = PrepareDelayClamps(body, overrides); - - var writes = new Dictionary(StringComparer.Ordinal); - var removals = new List(); - - foreach ((string key, JsonElement value) in body) - { - if (preparedDelays.TryGetValue(key, out int preparedValue)) - { - writes[key] = JsonSerializer.Serialize(preparedValue); - continue; - } - - SettingKind? kind = SettingsKeys.FindPublicKind(key); - if (kind is null) - { - continue; // неизвестный или внутренний ключ — игнорируем (Ruling 1) - } - - switch (kind.Value) - { - case SettingKind.Int: - ApplyIntKey(key, value, writes); - break; - - case SettingKind.Bool: - if (TryReadBool(value, out bool flag)) - { - writes[key] = JsonSerializer.Serialize(flag); - } - - break; - - case SettingKind.String: - ApplyStringKey(key, value, writes); - break; - - case SettingKind.List: - if (value.ValueKind == JsonValueKind.Array) - { - writes[key] = SerializeStringList(value); - } - - break; - - case SettingKind.Dict: - if (value.ValueKind == JsonValueKind.Object) - { - writes[key] = value.GetRawText(); - } - - break; - - case SettingKind.MyPrompts: - if (value.ValueKind == JsonValueKind.Array) - { - writes[key] = SerializeCleanMyPrompts(value); - } - - break; - - case SettingKind.AiConfigs: - ApplyAiConfigsKey(value, overrides, writes, removals); - break; - } - } - - foreach ((string key, string valueJson) in writes) - { - await store.SetAsync(key, valueJson, ct); - } - - foreach (string key in removals) - { - await store.RemoveAsync(key, ct); - } - - if (AffectsConversion(body)) - { - await NotifyRatesChangedAsync(ct); - } - - return await GetPublicAsync(ct); - } - - private static bool AffectsConversion(Dictionary body) - { - return HasNonNull(body, SettingsKeys.TargetCurrency) || HasNonNull(body, SettingsKeys.ConversionOn); - } - - // Есть ли в теле ключ с не-null JSON-значением. - // body: Тело PATCH. - // key: Имя ключа. - // Возвращает: True — ключ присутствует и значение не JSON-null. - private static bool HasNonNull(Dictionary body, string key) - { - return body.TryGetValue(key, out JsonElement value) && value.ValueKind != JsonValueKind.Null; - } - - private async Task NotifyRatesChangedAsync(CancellationToken ct) - { - foreach (IRatesChangedListener listener in listeners) - { - await listener.OnRatesChangedAsync(fullRecompute: true, ct); - } - } - - // Собирает public-снимок из дефолтов и переопределений (дефолты хранилище не пишет). - // overrides: Сохранённые переопределения: ключ → распарсенный JSON. - // Возвращает: DTO public-снимка. - private PublicSettingsDto BuildSnapshot(Dictionary overrides) - { - IReadOnlyDictionary aiConfigs = MergeAiConfigs(SettingsDefaults.AiConfigs, overrides); - - var result = new PublicSettingsDto - { - // Хранилище / обработка - AutoArchive = MergeBool(overrides, SettingsKeys.AutoArchive, SettingsDefaults.AutoArchive), - ArchiveAfterDays = MergeInt(overrides, SettingsKeys.ArchiveAfterDays, SettingsDefaults.ArchiveAfterDays), - ArchiveClearDays = MergeInt(overrides, SettingsKeys.ArchiveClearDays, SettingsDefaults.ArchiveClearDays), - TrashClearDays = MergeInt(overrides, SettingsKeys.TrashClearDays, SettingsDefaults.TrashClearDays), - MinLen = MergeInt(overrides, SettingsKeys.MinLen, SettingsDefaults.MinLen), - StopPhrases = MergeStringList(overrides, SettingsKeys.StopPhrases, SettingsDefaults.StopPhrases), - - // ИИ - MlEnabled = MergeBool(overrides, SettingsKeys.MlEnabled, SettingsDefaults.MlEnabled), - AiEnabled = MergeBool(overrides, SettingsKeys.AiEnabled, SettingsDefaults.AiEnabled), - AiFilterEnabled = MergeBool(overrides, SettingsKeys.AiFilterEnabled, SettingsDefaults.AiFilterEnabled), - AiPrompt = MergeString(overrides, SettingsKeys.AiPrompt, SettingsDefaults.AiPrompt), - AiFilterPrompt = MergeString(overrides, SettingsKeys.AiFilterPrompt, SettingsDefaults.AiFilterPrompt), - CardPrompt = MergeString(overrides, SettingsKeys.CardPrompt, SettingsDefaults.CardPrompt), - - // Сфера и ключи - WantedType = MergeString(overrides, SettingsKeys.WantedType, SettingsDefaults.WantedType), - BudgetRequiredHire = MergeBool(overrides, SettingsKeys.BudgetRequiredHire, SettingsDefaults.BudgetRequiredHire), - BudgetRequiredOrder = MergeBool(overrides, SettingsKeys.BudgetRequiredOrder, SettingsDefaults.BudgetRequiredOrder), - HireLabel = MergeString(overrides, SettingsKeys.HireLabel, SettingsDefaults.HireLabel), - OrderLabel = MergeString(overrides, SettingsKeys.OrderLabel, SettingsDefaults.OrderLabel), - DomainDescription = MergeString(overrides, SettingsKeys.DomainDescription, SettingsDefaults.DomainDescription), - DomainKeywords = MergeStringList(overrides, SettingsKeys.DomainKeywords, SettingsDefaults.DomainKeywords), - HireMarkers = MergeStringList(overrides, SettingsKeys.HireMarkers, SettingsDefaults.HireMarkers), - LevelTerms = MergeStringList(overrides, SettingsKeys.LevelTerms, SettingsDefaults.LevelTerms), - ResumeMarkers = MergeStringList(overrides, SettingsKeys.ResumeMarkers, SettingsDefaults.ResumeMarkers), - BlockResumes = MergeBool(overrides, SettingsKeys.BlockResumes, SettingsDefaults.BlockResumes), - - ExcludeKeywords = MergeStringList(overrides, SettingsKeys.ExcludeKeywords, SettingsDefaults.ExcludeKeywords), - ExcludeLocations = MergeStringList(overrides, SettingsKeys.ExcludeLocations, SettingsDefaults.ExcludeLocations), - ExcludeTypes = MergeStringList(overrides, SettingsKeys.ExcludeTypes, SettingsDefaults.ExcludeTypes), - ExcludeBudgetFrom = MergeInt(overrides, SettingsKeys.ExcludeBudgetFrom, SettingsDefaults.ExcludeBudgetFrom), - ExcludeBudgetTo = MergeInt(overrides, SettingsKeys.ExcludeBudgetTo, SettingsDefaults.ExcludeBudgetTo), - MyPrompts = MergeMyPrompts(overrides), - - // Напоминания / валюта - RemindersEnabled = MergeBool(overrides, SettingsKeys.RemindersEnabled, SettingsDefaults.RemindersEnabled), - ConversionOn = MergeBool(overrides, SettingsKeys.ConversionOn, SettingsDefaults.ConversionOn), - TargetCurrency = MergeString(overrides, SettingsKeys.TargetCurrency, SettingsDefaults.TargetCurrency), - RateSource = MergeString(overrides, SettingsKeys.RateSource, SettingsDefaults.RateSource), - - // Telegram / Discovery - AutoMonitorNew = MergeBool(overrides, SettingsKeys.AutoMonitorNew, SettingsDefaults.AutoMonitorNew), - DiscJoinLimit = MergeInt(overrides, SettingsKeys.DiscJoinLimit, SettingsDefaults.DiscJoinLimit), - DiscJoinDelayMin = MergeInt(overrides, SettingsKeys.DiscJoinDelayMin, SettingsDefaults.DiscJoinDelayMin), - DiscJoinDelayMax = MergeInt(overrides, SettingsKeys.DiscJoinDelayMax, SettingsDefaults.DiscJoinDelayMax), - DiscEvalSample = MergeInt(overrides, SettingsKeys.DiscEvalSample, SettingsDefaults.DiscEvalSample), - DiscEvalThreshold = MergeInt(overrides, SettingsKeys.DiscEvalThreshold, SettingsDefaults.DiscEvalThreshold), - DiscPaused = MergeBool(overrides, SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused), - ColState = MergeDict(overrides, SettingsKeys.ColState), - - // ИИ-провайдеры (публичная форма) - AiProvider = MergeString(overrides, SettingsKeys.AiProvider, SettingsDefaults.AiProvider), - AiConfigs = aiConfigs.ToDictionary(pair => pair.Key, pair => ToPublic(pair.Value), StringComparer.Ordinal), - Providers = PublicProviders, - }; - - return result; - } -} +using System.Text.Json; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Settings.Application.Services; + +/// +/// Сервис настроек тенанта +/// +/// KV-хранилище настроек тенанта (таблица settings). +/// Шифр секретов. +/// Слушатели смены настроек конверсии; пусто — no-op. +public sealed partial class SettingsService( + ISettingsStore store, + ISecretCipher secretCipher, + IEnumerable listeners) +{ + + private const string EncryptedValuePrefix = "enc:"; + + // Символ-заполнитель маски секрета (U+2026, «1234…5678»): строка, содержащая его, + // — это маска из public-снимка, а не новый секрет (Security review: не шифровать маску). + private const string MaskEllipsis = "…"; + + // Id провайдера «Другой (OpenAI-совместимый)» — единственный не-local, чей baseUrl + // задаёт пользователь (остальным адрес фиксирован каталогом, SSRF-гейт). + private const string CustomProviderId = "custom"; + + // Максимум элементов списка-настройки при записи из PATCH (срез 200). + private const int MaxListItems = 200; + + // Минимальная длина нового API-ключа, который принимает PATCH (≥8 симв.). + private const int ApiKeyMinLength = 8; + + // Максимум элементов «Моих промптов» при записи из PATCH (≤100). + private const int MaxMyPrompts = 100; + + // Максимальная длина названия промпта (≤80). + private const int PromptNameMaxLength = 80; + + // Максимальная длина описания промпта (≤300). + private const int PromptDescriptionMaxLength = 300; + + // Максимальная длина текста промпта (≤8000). + private const int PromptTextMaxLength = 8000; + + // Максимальная длина id промпта из клиента (≤40); иначе генерируется. + private const int PromptIdMaxLength = 40; + + // Префикс генерируемого id промпта (референс store.uid("pp_")). + private const string PromptIdPrefix = "pp_"; + + // Размер случайного суффикса id промпта в байтах (8 hex-символов). + private const int PromptIdRandomBytes = 4; + + private static readonly IReadOnlyDictionary IntClampRanges = + new Dictionary + { + [SettingsKeys.ArchiveAfterDays] = (1, 30), + [SettingsKeys.MinLen] = (10, 500), + [SettingsKeys.DiscJoinLimit] = (1, 200), + [SettingsKeys.DiscJoinDelayMin] = (5, 600), + [SettingsKeys.DiscJoinDelayMax] = (5, 600), + [SettingsKeys.DiscEvalSample] = (3, 30), + [SettingsKeys.DiscEvalThreshold] = (1, 100), + }; + + // Идентификаторы известных провайдеров (валидация aiProvider/aiConfigs). + private static readonly IReadOnlySet ProviderIds = + AiProviders.All.Select(provider => provider.Id).ToHashSet(StringComparer.Ordinal); + + private static readonly IReadOnlyList PublicProviders = + AiProviders.All + .Select(provider => new ProviderPublicDto( + provider.Id, provider.Name, provider.Base, provider.Local, provider.Models)) + .ToList(); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + /// + /// Публичный снимок настроек + /// + /// Полный public-снимок (тело GET/PATCH /api/settings). + public async Task GetPublicAsync(CancellationToken ct) + { + Dictionary overrides = await LoadStoredOverridesAsync(ct); + return BuildSnapshot(overrides); + } + + /// + /// Частичное обновление настроек + /// + /// Тело PATCH — произвольный JSON-объект из публичных ключей. + /// Полный public-снимок после применения (как GET). + public async Task ApplyPatchAsync(Dictionary body, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(body); + + Dictionary overrides = await LoadStoredOverridesAsync(ct); + + Dictionary preparedDelays = PrepareDelayClamps(body, overrides); + + var writes = new Dictionary(StringComparer.Ordinal); + var removals = new List(); + + foreach ((string key, JsonElement value) in body) + { + if (preparedDelays.TryGetValue(key, out int preparedValue)) + { + writes[key] = JsonSerializer.Serialize(preparedValue); + continue; + } + + SettingKind? kind = SettingsKeys.FindPublicKind(key); + if (kind is null) + { + continue; // неизвестный или внутренний ключ — игнорируем (Ruling 1) + } + + switch (kind.Value) + { + case SettingKind.Int: + ApplyIntKey(key, value, writes); + break; + + case SettingKind.Bool: + if (TryReadBool(value, out bool flag)) + { + writes[key] = JsonSerializer.Serialize(flag); + } + + break; + + case SettingKind.String: + ApplyStringKey(key, value, writes); + break; + + case SettingKind.List: + if (value.ValueKind == JsonValueKind.Array) + { + writes[key] = SerializeStringList(value); + } + + break; + + case SettingKind.Dict: + if (value.ValueKind == JsonValueKind.Object) + { + writes[key] = value.GetRawText(); + } + + break; + + case SettingKind.MyPrompts: + if (value.ValueKind == JsonValueKind.Array) + { + writes[key] = SerializeCleanMyPrompts(value); + } + + break; + + case SettingKind.AiConfigs: + ApplyAiConfigsKey(value, overrides, writes, removals); + break; + } + } + + foreach ((string key, string valueJson) in writes) + { + await store.SetAsync(key, valueJson, ct); + } + + foreach (string key in removals) + { + await store.RemoveAsync(key, ct); + } + + if (AffectsConversion(body)) + { + await NotifyRatesChangedAsync(ct); + } + + return await GetPublicAsync(ct); + } + + private static bool AffectsConversion(Dictionary body) + { + return HasNonNull(body, SettingsKeys.TargetCurrency) || HasNonNull(body, SettingsKeys.ConversionOn); + } + + // Есть ли в теле ключ с не-null JSON-значением. + // body: Тело PATCH. + // key: Имя ключа. + // Возвращает: True — ключ присутствует и значение не JSON-null. + private static bool HasNonNull(Dictionary body, string key) + { + return body.TryGetValue(key, out JsonElement value) && value.ValueKind != JsonValueKind.Null; + } + + private async Task NotifyRatesChangedAsync(CancellationToken ct) + { + foreach (IRatesChangedListener listener in listeners) + { + await listener.OnRatesChangedAsync(fullRecompute: true, ct); + } + } + + // Собирает public-снимок из дефолтов и переопределений (дефолты хранилище не пишет). + // overrides: Сохранённые переопределения: ключ → распарсенный JSON. + // Возвращает: DTO public-снимка. + private PublicSettingsDto BuildSnapshot(Dictionary overrides) + { + IReadOnlyDictionary aiConfigs = MergeAiConfigs(SettingsDefaults.AiConfigs, overrides); + + var result = new PublicSettingsDto + { + // Хранилище / обработка + AutoArchive = MergeBool(overrides, SettingsKeys.AutoArchive, SettingsDefaults.AutoArchive), + ArchiveAfterDays = MergeInt(overrides, SettingsKeys.ArchiveAfterDays, SettingsDefaults.ArchiveAfterDays), + ArchiveClearDays = MergeInt(overrides, SettingsKeys.ArchiveClearDays, SettingsDefaults.ArchiveClearDays), + TrashClearDays = MergeInt(overrides, SettingsKeys.TrashClearDays, SettingsDefaults.TrashClearDays), + MinLen = MergeInt(overrides, SettingsKeys.MinLen, SettingsDefaults.MinLen), + StopPhrases = MergeStringList(overrides, SettingsKeys.StopPhrases, SettingsDefaults.StopPhrases), + + // ИИ + MlEnabled = MergeBool(overrides, SettingsKeys.MlEnabled, SettingsDefaults.MlEnabled), + AiEnabled = MergeBool(overrides, SettingsKeys.AiEnabled, SettingsDefaults.AiEnabled), + AiFilterEnabled = MergeBool(overrides, SettingsKeys.AiFilterEnabled, SettingsDefaults.AiFilterEnabled), + AiPrompt = MergeString(overrides, SettingsKeys.AiPrompt, SettingsDefaults.AiPrompt), + AiFilterPrompt = MergeString(overrides, SettingsKeys.AiFilterPrompt, SettingsDefaults.AiFilterPrompt), + CardPrompt = MergeString(overrides, SettingsKeys.CardPrompt, SettingsDefaults.CardPrompt), + + // Сфера и ключи + WantedType = MergeString(overrides, SettingsKeys.WantedType, SettingsDefaults.WantedType), + BudgetRequiredHire = MergeBool(overrides, SettingsKeys.BudgetRequiredHire, SettingsDefaults.BudgetRequiredHire), + BudgetRequiredOrder = MergeBool(overrides, SettingsKeys.BudgetRequiredOrder, SettingsDefaults.BudgetRequiredOrder), + HireLabel = MergeString(overrides, SettingsKeys.HireLabel, SettingsDefaults.HireLabel), + OrderLabel = MergeString(overrides, SettingsKeys.OrderLabel, SettingsDefaults.OrderLabel), + DomainDescription = MergeString(overrides, SettingsKeys.DomainDescription, SettingsDefaults.DomainDescription), + DomainKeywords = MergeStringList(overrides, SettingsKeys.DomainKeywords, SettingsDefaults.DomainKeywords), + HireMarkers = MergeStringList(overrides, SettingsKeys.HireMarkers, SettingsDefaults.HireMarkers), + LevelTerms = MergeStringList(overrides, SettingsKeys.LevelTerms, SettingsDefaults.LevelTerms), + ResumeMarkers = MergeStringList(overrides, SettingsKeys.ResumeMarkers, SettingsDefaults.ResumeMarkers), + BlockResumes = MergeBool(overrides, SettingsKeys.BlockResumes, SettingsDefaults.BlockResumes), + + ExcludeKeywords = MergeStringList(overrides, SettingsKeys.ExcludeKeywords, SettingsDefaults.ExcludeKeywords), + ExcludeLocations = MergeStringList(overrides, SettingsKeys.ExcludeLocations, SettingsDefaults.ExcludeLocations), + ExcludeTypes = MergeStringList(overrides, SettingsKeys.ExcludeTypes, SettingsDefaults.ExcludeTypes), + ExcludeBudgetFrom = MergeInt(overrides, SettingsKeys.ExcludeBudgetFrom, SettingsDefaults.ExcludeBudgetFrom), + ExcludeBudgetTo = MergeInt(overrides, SettingsKeys.ExcludeBudgetTo, SettingsDefaults.ExcludeBudgetTo), + MyPrompts = MergeMyPrompts(overrides), + + // Напоминания / валюта + RemindersEnabled = MergeBool(overrides, SettingsKeys.RemindersEnabled, SettingsDefaults.RemindersEnabled), + ConversionOn = MergeBool(overrides, SettingsKeys.ConversionOn, SettingsDefaults.ConversionOn), + TargetCurrency = MergeString(overrides, SettingsKeys.TargetCurrency, SettingsDefaults.TargetCurrency), + RateSource = MergeString(overrides, SettingsKeys.RateSource, SettingsDefaults.RateSource), + + // Telegram / Discovery + AutoMonitorNew = MergeBool(overrides, SettingsKeys.AutoMonitorNew, SettingsDefaults.AutoMonitorNew), + DiscJoinLimit = MergeInt(overrides, SettingsKeys.DiscJoinLimit, SettingsDefaults.DiscJoinLimit), + DiscJoinDelayMin = MergeInt(overrides, SettingsKeys.DiscJoinDelayMin, SettingsDefaults.DiscJoinDelayMin), + DiscJoinDelayMax = MergeInt(overrides, SettingsKeys.DiscJoinDelayMax, SettingsDefaults.DiscJoinDelayMax), + DiscEvalSample = MergeInt(overrides, SettingsKeys.DiscEvalSample, SettingsDefaults.DiscEvalSample), + DiscEvalThreshold = MergeInt(overrides, SettingsKeys.DiscEvalThreshold, SettingsDefaults.DiscEvalThreshold), + DiscPaused = MergeBool(overrides, SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused), + ColState = MergeDict(overrides, SettingsKeys.ColState), + + // ИИ-провайдеры (публичная форма) + AiProvider = MergeString(overrides, SettingsKeys.AiProvider, SettingsDefaults.AiProvider), + AiConfigs = aiConfigs.ToDictionary(pair => pair.Key, pair => ToPublic(pair.Value), StringComparer.Ordinal), + Providers = PublicProviders, + }; + + return result; + } +} diff --git a/src/core/Deal.Modules.Settings/Deal.Modules.Settings.csproj b/src/core/Deal.Modules.Settings/Deal.Modules.Settings.csproj index 3c22b91..15bf5ca 100644 --- a/src/core/Deal.Modules.Settings/Deal.Modules.Settings.csproj +++ b/src/core/Deal.Modules.Settings/Deal.Modules.Settings.csproj @@ -1,18 +1,18 @@ - - - - - - - - - - - - - net10.0 - enable - enable - - - + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/core/Deal.Modules.Telegram/Application/TelegramSourceIngestObserver.cs b/src/core/Deal.Modules.Telegram/Application/TelegramSourceIngestObserver.cs index b630016..bb3f848 100644 --- a/src/core/Deal.Modules.Telegram/Application/TelegramSourceIngestObserver.cs +++ b/src/core/Deal.Modules.Telegram/Application/TelegramSourceIngestObserver.cs @@ -1,47 +1,47 @@ -using Deal.Modules.Cards.Application.Sources; -using Microsoft.Extensions.Logging; - -namespace Deal.Modules.Telegram.Application; - -/// -/// Превью принятых сообщений источника telegram: последнее сообщение каталога и TgMessages. -/// -/// Каталог диалогов тенанта. -/// Логгер сбоев записи. -public sealed class TelegramSourceIngestObserver( - DialogsService dialogs, - ILogger logger) : ISourceIngestObserver -{ - private const string TelegramKind = "telegram"; - - /// - public async Task OnIngestedAsync(SourceItem item, CancellationToken ct) - { - if (!string.Equals(item.Source.Kind, TelegramKind, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - string dialogId = item.Source.OriginRef ?? string.Empty; - if (dialogId.Length == 0) - { - return; - } - - long? msgId = long.TryParse(item.Source.ExternalId, out long parsed) ? parsed : null; - try - { - await dialogs - .SavePreviewAsync(dialogId, msgId, item.Content.Text ?? string.Empty, item.Source.ReceivedAt, ct) - .ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - logger.LogDebug(exception, "Превью {DialogId} не сохранено (приём не затронут)", dialogId); - } - } -} +using Deal.Modules.Cards.Application.Sources; +using Microsoft.Extensions.Logging; + +namespace Deal.Modules.Telegram.Application; + +/// +/// Превью принятых сообщений источника telegram: последнее сообщение каталога и TgMessages. +/// +/// Каталог диалогов тенанта. +/// Логгер сбоев записи. +public sealed class TelegramSourceIngestObserver( + DialogsService dialogs, + ILogger logger) : ISourceIngestObserver +{ + private const string TelegramKind = "telegram"; + + /// + public async Task OnIngestedAsync(SourceItem item, CancellationToken ct) + { + if (!string.Equals(item.Source.Kind, TelegramKind, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + string dialogId = item.Source.OriginRef ?? string.Empty; + if (dialogId.Length == 0) + { + return; + } + + long? msgId = long.TryParse(item.Source.ExternalId, out long parsed) ? parsed : null; + try + { + await dialogs + .SavePreviewAsync(dialogId, msgId, item.Content.Text ?? string.Empty, item.Source.ReceivedAt, ct) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + logger.LogDebug(exception, "Превью {DialogId} не сохранено (приём не затронут)", dialogId); + } + } +} diff --git a/src/core/Deal.Modules.Telegram/Deal.Modules.Telegram.csproj b/src/core/Deal.Modules.Telegram/Deal.Modules.Telegram.csproj index 3a1cd3b..78a5d13 100644 --- a/src/core/Deal.Modules.Telegram/Deal.Modules.Telegram.csproj +++ b/src/core/Deal.Modules.Telegram/Deal.Modules.Telegram.csproj @@ -1,21 +1,21 @@ - - - - - - - - - - - - - - - - net10.0 - enable - enable - - - + + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuditLogStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuditLogStore.cs index 51df87f..d026810 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuditLogStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuditLogStore.cs @@ -1,37 +1,37 @@ -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Modules.Tenants.Application.Abstractions; - -/// -/// Порт хранилища аудита -/// -public interface IAuditLogStore -{ - /// - /// Добавляет запись аудита - /// - /// Запись для сохранения. - public Task AppendAsync(AuditRecordDto record, CancellationToken ct); - - /// - /// Записи по фильтру, новые сверху - /// - /// Фильтр выборки. - /// Записи от новых к старым (пусто — записей нет). - public Task> QueryAsync(AuditQueryDto filter, CancellationToken ct); - - /// - /// Число записей, удовлетворяющих фильтру - /// - /// Фильтр выборки (Limit не влияет на подсчёт). - /// Полное число записей по фильтру. - public Task CountAsync(AuditQueryDto filter, CancellationToken ct); - - /// - /// Удаляет записи старше retention-границы - /// - /// Граница: удаляются записи со строгим At < cutoff (UTC). - /// Число удалённых записей. - public Task PurgeOlderThanAsync(DateTimeOffset cutoff, CancellationToken ct); -} +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Modules.Tenants.Application.Abstractions; + +/// +/// Порт хранилища аудита +/// +public interface IAuditLogStore +{ + /// + /// Добавляет запись аудита + /// + /// Запись для сохранения. + public Task AppendAsync(AuditRecordDto record, CancellationToken ct); + + /// + /// Записи по фильтру, новые сверху + /// + /// Фильтр выборки. + /// Записи от новых к старым (пусто — записей нет). + public Task> QueryAsync(AuditQueryDto filter, CancellationToken ct); + + /// + /// Число записей, удовлетворяющих фильтру + /// + /// Фильтр выборки (Limit не влияет на подсчёт). + /// Полное число записей по фильтру. + public Task CountAsync(AuditQueryDto filter, CancellationToken ct); + + /// + /// Удаляет записи старше retention-границы + /// + /// Граница: удаляются записи со строгим At < cutoff (UTC). + /// Число удалённых записей. + public Task PurgeOlderThanAsync(DateTimeOffset cutoff, CancellationToken ct); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuthStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuthStore.cs index 30e629c..62fac8c 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuthStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuthStore.cs @@ -1,76 +1,76 @@ -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Modules.Tenants.Application.Abstractions; - -/// -/// Порт хранилища аутентификации -/// -public interface IAuthStore -{ - /// - /// Ищет пользователя по нормализованному логину - /// - /// Нормализованный логин. - /// Пользователь с хэшем пароля или null. - public Task FindUserByLoginAsync(string login, CancellationToken ct); - - /// - /// Создаёт пользователя - /// - /// Данные нового пользователя (логин нормализован, хэш пароля готов). - public Task CreateUserAsync(StoredUserDto user, CancellationToken ct); - - /// - /// Ищет сессию по SHA-256-хэшу токена; протухшие сессии не возвращает. - /// - /// SHA-256-хэш raw-токена. - /// Сессия или null. - public Task FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct); - - /// - /// Ищет пользователя по идентификатору. - /// - /// Идентификатор пользователя. - /// Идентичность пользователя (без хэша пароля) или null. - public Task FindUserByIdAsync(Guid userId, CancellationToken ct); - - /// - /// Возвращает пользователей тенанта. - /// - /// Идентификатор тенанта. - /// Пользователи тенанта, упорядоченные по времени создания (пусто — пользователей нет). - public Task> ListUsersByTenantIdAsync(Guid tenantId, CancellationToken ct); - - /// - /// Сохраняет новую сессию. - /// - /// Сессия для сохранения. - public Task CreateSessionAsync(SessionDto session, CancellationToken ct); - - /// - /// Удаляет сессию по SHA-256-хэшу токена - /// - /// SHA-256-хэш raw-токена. - public Task DeleteSessionAsync(string tokenHash, CancellationToken ct); - - /// - /// Удаляет все сессии пользователя - /// - /// Идентификатор пользователя. - public Task DeleteSessionsByUserIdAsync(Guid userId, CancellationToken ct); - - /// - /// Обновляет хэш пароля пользователя. - /// - /// Идентификатор пользователя. - /// Новая encoded-строка хэша. - public Task UpdatePasswordHashAsync( - Guid userId, - string passwordHash, - CancellationToken ct); - - /// - /// Удаляет все сессии со сроком жизни не позднее текущего момента. - /// - public Task DeleteExpiredSessionsAsync(CancellationToken ct); -} +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Modules.Tenants.Application.Abstractions; + +/// +/// Порт хранилища аутентификации +/// +public interface IAuthStore +{ + /// + /// Ищет пользователя по нормализованному логину + /// + /// Нормализованный логин. + /// Пользователь с хэшем пароля или null. + public Task FindUserByLoginAsync(string login, CancellationToken ct); + + /// + /// Создаёт пользователя + /// + /// Данные нового пользователя (логин нормализован, хэш пароля готов). + public Task CreateUserAsync(StoredUserDto user, CancellationToken ct); + + /// + /// Ищет сессию по SHA-256-хэшу токена; протухшие сессии не возвращает. + /// + /// SHA-256-хэш raw-токена. + /// Сессия или null. + public Task FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct); + + /// + /// Ищет пользователя по идентификатору. + /// + /// Идентификатор пользователя. + /// Идентичность пользователя (без хэша пароля) или null. + public Task FindUserByIdAsync(Guid userId, CancellationToken ct); + + /// + /// Возвращает пользователей тенанта. + /// + /// Идентификатор тенанта. + /// Пользователи тенанта, упорядоченные по времени создания (пусто — пользователей нет). + public Task> ListUsersByTenantIdAsync(Guid tenantId, CancellationToken ct); + + /// + /// Сохраняет новую сессию. + /// + /// Сессия для сохранения. + public Task CreateSessionAsync(SessionDto session, CancellationToken ct); + + /// + /// Удаляет сессию по SHA-256-хэшу токена + /// + /// SHA-256-хэш raw-токена. + public Task DeleteSessionAsync(string tokenHash, CancellationToken ct); + + /// + /// Удаляет все сессии пользователя + /// + /// Идентификатор пользователя. + public Task DeleteSessionsByUserIdAsync(Guid userId, CancellationToken ct); + + /// + /// Обновляет хэш пароля пользователя. + /// + /// Идентификатор пользователя. + /// Новая encoded-строка хэша. + public Task UpdatePasswordHashAsync( + Guid userId, + string passwordHash, + CancellationToken ct); + + /// + /// Удаляет все сессии со сроком жизни не позднее текущего момента. + /// + public Task DeleteExpiredSessionsAsync(CancellationToken ct); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/IInviteStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IInviteStore.cs index f5f3cd8..37b815b 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/IInviteStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IInviteStore.cs @@ -1,60 +1,60 @@ -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Modules.Tenants.Application.Abstractions; - -/// -/// Порт хранилища приглашений -/// -public interface IInviteStore -{ - /// - /// Сохраняет новое приглашение - /// - /// Новое приглашение (код/email/tenantId/срок/автор заданы сервисом). - public Task CreateAsync(InviteDto invite, CancellationToken ct); - - /// - /// Приглашение по коду - /// - /// Код приглашения. - /// Приглашение или null, если кода нет. - public Task GetByCodeAsync(string code, CancellationToken ct); - - /// - /// Все приглашения, новые сверху - /// - /// Список приглашений (пусто — приглашений нет). - public Task> ListAsync(CancellationToken ct); - - /// - /// Меняет статус приглашения по коду; для активации обновляет и ActivatedAt. - /// - /// Код приглашения. - /// Новый статус (константа ). - /// Момент активации при статусе activated; иначе null. - /// true, если приглашение с таким кодом найдено и статус изменён; false — кода нет. - public Task UpdateStatusAsync( - string code, - string status, - DateTimeOffset? activatedAt, - CancellationToken ct); - - /// - /// Атомарно активирует приглашение - /// - /// Код приглашения. - /// Момент активации (UTC) для колонки ActivatedAt. - /// true, если строка была в статусе pending и переведена в activated; false — кода нет или статус уже иной. - public Task TryActivateAsync( - string code, - DateTimeOffset activatedAt, - CancellationToken ct); - - /// - /// Активное (pending) приглашение на email — антидубль создания. - /// - /// Нормализованный email. - /// Pending-приглашение на email (включая протухшее, но ещё не помеченное expired — его переводит сервис) или null. - public Task FindActiveByEmailAsync(string email, CancellationToken ct); -} +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Modules.Tenants.Application.Abstractions; + +/// +/// Порт хранилища приглашений +/// +public interface IInviteStore +{ + /// + /// Сохраняет новое приглашение + /// + /// Новое приглашение (код/email/tenantId/срок/автор заданы сервисом). + public Task CreateAsync(InviteDto invite, CancellationToken ct); + + /// + /// Приглашение по коду + /// + /// Код приглашения. + /// Приглашение или null, если кода нет. + public Task GetByCodeAsync(string code, CancellationToken ct); + + /// + /// Все приглашения, новые сверху + /// + /// Список приглашений (пусто — приглашений нет). + public Task> ListAsync(CancellationToken ct); + + /// + /// Меняет статус приглашения по коду; для активации обновляет и ActivatedAt. + /// + /// Код приглашения. + /// Новый статус (константа ). + /// Момент активации при статусе activated; иначе null. + /// true, если приглашение с таким кодом найдено и статус изменён; false — кода нет. + public Task UpdateStatusAsync( + string code, + string status, + DateTimeOffset? activatedAt, + CancellationToken ct); + + /// + /// Атомарно активирует приглашение + /// + /// Код приглашения. + /// Момент активации (UTC) для колонки ActivatedAt. + /// true, если строка была в статусе pending и переведена в activated; false — кода нет или статус уже иной. + public Task TryActivateAsync( + string code, + DateTimeOffset activatedAt, + CancellationToken ct); + + /// + /// Активное (pending) приглашение на email — антидубль создания. + /// + /// Нормализованный email. + /// Pending-приглашение на email (включая протухшее, но ещё не помеченное expired — его переводит сервис) или null. + public Task FindActiveByEmailAsync(string email, CancellationToken ct); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/IOperatorAuthStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IOperatorAuthStore.cs index 1b2f7d0..08e0547 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/IOperatorAuthStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IOperatorAuthStore.cs @@ -1,46 +1,46 @@ -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Modules.Tenants.Application.Abstractions; - -/// -/// Порт хранилища аутентификации оператора -/// -public interface IOperatorAuthStore -{ - /// - /// Ищет оператора по нормализованному логину - /// - /// Нормализованный логин. - /// Оператор с хэшем пароля или null. - public Task FindByLoginAsync(string login, CancellationToken ct); - - /// - /// Создаёт оператора - /// - /// Данные нового оператора (логин нормализован, хэш пароля готов). - public Task CreateAsync(StoredOperatorDto operatorRecord, CancellationToken ct); - - /// - /// Ищет сессию по SHA-256-хэшу токена. - /// - /// SHA-256-хэш raw-токена. - /// Сессия или null. - public Task FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct); - - /// - /// Сохраняет новую сессию оператора. - /// - /// Сессия для сохранения. - public Task CreateSessionAsync(OperatorSessionDto session, CancellationToken ct); - - /// - /// Удаляет сессию по SHA-256-хэшу токена - /// - /// SHA-256-хэш raw-токена. - public Task DeleteSessionAsync(string tokenHash, CancellationToken ct); - - /// - /// Удаляет все сессии со сроком жизни не позднее текущего момента. - /// - public Task DeleteExpiredSessionsAsync(CancellationToken ct); -} +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Modules.Tenants.Application.Abstractions; + +/// +/// Порт хранилища аутентификации оператора +/// +public interface IOperatorAuthStore +{ + /// + /// Ищет оператора по нормализованному логину + /// + /// Нормализованный логин. + /// Оператор с хэшем пароля или null. + public Task FindByLoginAsync(string login, CancellationToken ct); + + /// + /// Создаёт оператора + /// + /// Данные нового оператора (логин нормализован, хэш пароля готов). + public Task CreateAsync(StoredOperatorDto operatorRecord, CancellationToken ct); + + /// + /// Ищет сессию по SHA-256-хэшу токена. + /// + /// SHA-256-хэш raw-токена. + /// Сессия или null. + public Task FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct); + + /// + /// Сохраняет новую сессию оператора. + /// + /// Сессия для сохранения. + public Task CreateSessionAsync(OperatorSessionDto session, CancellationToken ct); + + /// + /// Удаляет сессию по SHA-256-хэшу токена + /// + /// SHA-256-хэш raw-токена. + public Task DeleteSessionAsync(string tokenHash, CancellationToken ct); + + /// + /// Удаляет все сессии со сроком жизни не позднее текущего момента. + /// + public Task DeleteExpiredSessionsAsync(CancellationToken ct); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/IRateLimitCounterStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IRateLimitCounterStore.cs index 72a961c..ddbf93c 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/IRateLimitCounterStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IRateLimitCounterStore.cs @@ -1,46 +1,46 @@ -namespace Deal.Modules.Tenants.Application.Abstractions; - -/// -/// Порт распределённого счётчика фиксированного окна -/// -public interface IRateLimitCounterStore -{ - /// - /// Атомарно увеличивает счётчик окна - /// - /// Уникальный ключ счётчика (политика/тип + партиция). - /// Начало текущего фиксированного окна (UTC). - /// Конец окна (UTC) — срок уборки строки (ExpiresAt). - /// Величина приращения (обычно 1; должно быть ≥0). - /// Значение счётчика в текущем окне после операции. - public Task IncrementAsync( - string key, - DateTimeOffset windowStart, - DateTimeOffset windowEnd, - int amount, - CancellationToken ct); - - /// - /// Читает счётчик текущего окна - /// - /// Уникальный ключ счётчика. - /// Начало текущего фиксированного окна (UTC). - /// Значение счётчика в окне (0, если окно иное/строки нет). - public Task GetCountAsync( - string key, - DateTimeOffset windowStart, - CancellationToken ct); - - /// - /// Удаляет счётчик ключа. - /// - /// Уникальный ключ счётчика. - public Task ResetAsync(string key, CancellationToken ct); - - /// - /// Удаляет строки завершившихся окон - /// - /// Текущий момент (UTC). - /// Число удалённых строк. - public Task DeleteExpiredAsync(DateTimeOffset now, CancellationToken ct); -} +namespace Deal.Modules.Tenants.Application.Abstractions; + +/// +/// Порт распределённого счётчика фиксированного окна +/// +public interface IRateLimitCounterStore +{ + /// + /// Атомарно увеличивает счётчик окна + /// + /// Уникальный ключ счётчика (политика/тип + партиция). + /// Начало текущего фиксированного окна (UTC). + /// Конец окна (UTC) — срок уборки строки (ExpiresAt). + /// Величина приращения (обычно 1; должно быть ≥0). + /// Значение счётчика в текущем окне после операции. + public Task IncrementAsync( + string key, + DateTimeOffset windowStart, + DateTimeOffset windowEnd, + int amount, + CancellationToken ct); + + /// + /// Читает счётчик текущего окна + /// + /// Уникальный ключ счётчика. + /// Начало текущего фиксированного окна (UTC). + /// Значение счётчика в окне (0, если окно иное/строки нет). + public Task GetCountAsync( + string key, + DateTimeOffset windowStart, + CancellationToken ct); + + /// + /// Удаляет счётчик ключа. + /// + /// Уникальный ключ счётчика. + public Task ResetAsync(string key, CancellationToken ct); + + /// + /// Удаляет строки завершившихся окон + /// + /// Текущий момент (UTC). + /// Число удалённых строк. + public Task DeleteExpiredAsync(DateTimeOffset now, CancellationToken ct); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantLimitStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantLimitStore.cs index 6ed0a8b..adc4e02 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantLimitStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantLimitStore.cs @@ -1,73 +1,73 @@ -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Modules.Tenants.Application.Abstractions; - -/// -/// Порт хранилища лимитов ИИ-бюджета тенанта -/// -public interface ITenantLimitStore -{ - /// - /// Возвращает строку лимита тенанта, при отсутствии — лениво создаёт с дефолт-бюджетом - /// - /// Тенант (существующая строка public.tenants). - /// Дефолт-параметры создаваемой строки; null — дефолт адаптера (конфигурация/env). - /// Строка лимита (созданная или существующая). - public Task GetOrCreateAsync( - Guid tenantId, - CancellationToken ct, - TokenLimitDefaults? defaults = null); - - /// - /// Текущее состояние бюджета тенанта. - /// - /// Тенант. - /// Состояние бюджета на сейчас. - public Task GetStateAsync(Guid tenantId, CancellationToken ct); - - /// - /// Списывает с бюджета тенанта - /// - /// Тенант. - /// Списываемые токены (usage.Total ответа ai-service; ≤0 — no-op). - /// Состояние бюджета после списания (источник для гейта/сводки usage). - public Task AddUsageAsync( - Guid tenantId, - long tokens, - CancellationToken ct); - - /// - /// Меняет бюджет/период тенанта и сбрасывает флаги Warned80/ NotifiedExhausted. - /// - /// Тенант. - /// Новый бюджет периода (≥0; 0 — ИИ запрещён). - /// Новый тип периода (). - /// Состояние бюджета после изменения. - public Task UpdateBudgetAsync( - Guid tenantId, - long budgetTokens, - string period, - CancellationToken ct); - - /// - /// Атомарно выставляет флаг Warned80, если порог 80% достигнут и флаг ещё не стоял. - /// - /// Тенант. - /// True — флаг только что установлен (нужно публиковать тост); false — уже стоял/порог не достигнут. - public Task TryMarkWarnedAsync(Guid tenantId, CancellationToken ct); - - /// - /// Атомарно выставляет флаг NotifiedExhausted, если бюджет исчерпан и флаг ещё не стоял. - /// - /// Тенант. - /// True — флаг только что установлен; false — уже стоял/бюджет не исчерпан. - public Task TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct); - - /// - /// Авто-очистка накопительных полей прошедших периодов - /// - /// Текущий момент (UTC) — источник проверки завершения периода. - /// Число строк, у которых период был сброшен. - public Task ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct); -} +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Modules.Tenants.Application.Abstractions; + +/// +/// Порт хранилища лимитов ИИ-бюджета тенанта +/// +public interface ITenantLimitStore +{ + /// + /// Возвращает строку лимита тенанта, при отсутствии — лениво создаёт с дефолт-бюджетом + /// + /// Тенант (существующая строка public.tenants). + /// Дефолт-параметры создаваемой строки; null — дефолт адаптера (конфигурация/env). + /// Строка лимита (созданная или существующая). + public Task GetOrCreateAsync( + Guid tenantId, + CancellationToken ct, + TokenLimitDefaults? defaults = null); + + /// + /// Текущее состояние бюджета тенанта. + /// + /// Тенант. + /// Состояние бюджета на сейчас. + public Task GetStateAsync(Guid tenantId, CancellationToken ct); + + /// + /// Списывает с бюджета тенанта + /// + /// Тенант. + /// Списываемые токены (usage.Total ответа ai-service; ≤0 — no-op). + /// Состояние бюджета после списания (источник для гейта/сводки usage). + public Task AddUsageAsync( + Guid tenantId, + long tokens, + CancellationToken ct); + + /// + /// Меняет бюджет/период тенанта и сбрасывает флаги Warned80/ NotifiedExhausted. + /// + /// Тенант. + /// Новый бюджет периода (≥0; 0 — ИИ запрещён). + /// Новый тип периода (). + /// Состояние бюджета после изменения. + public Task UpdateBudgetAsync( + Guid tenantId, + long budgetTokens, + string period, + CancellationToken ct); + + /// + /// Атомарно выставляет флаг Warned80, если порог 80% достигнут и флаг ещё не стоял. + /// + /// Тенант. + /// True — флаг только что установлен (нужно публиковать тост); false — уже стоял/порог не достигнут. + public Task TryMarkWarnedAsync(Guid tenantId, CancellationToken ct); + + /// + /// Атомарно выставляет флаг NotifiedExhausted, если бюджет исчерпан и флаг ещё не стоял. + /// + /// Тенант. + /// True — флаг только что установлен; false — уже стоял/бюджет не исчерпан. + public Task TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct); + + /// + /// Авто-очистка накопительных полей прошедших периодов + /// + /// Текущий момент (UTC) — источник проверки завершения периода. + /// Число строк, у которых период был сброшен. + public Task ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantProvisioner.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantProvisioner.cs index 5b750d1..7e29440 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantProvisioner.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantProvisioner.cs @@ -1,15 +1,15 @@ -using Deal.SharedKernel.Tenants.Models; - -namespace Deal.Modules.Tenants.Application.Abstractions; - -/// -/// Порт провижининга схемы тенанта -/// -public interface ITenantProvisioner -{ - /// - /// Провижинит схему тенанта - /// - /// Идентификатор тенанта. - public Task ProvisionAsync(TenantId tenantId, CancellationToken ct); -} +using Deal.SharedKernel.Tenants.Models; + +namespace Deal.Modules.Tenants.Application.Abstractions; + +/// +/// Порт провижининга схемы тенанта +/// +public interface ITenantProvisioner +{ + /// + /// Провижинит схему тенанта + /// + /// Идентификатор тенанта. + public Task ProvisionAsync(TenantId tenantId, CancellationToken ct); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantRepository.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantRepository.cs index d723593..d526252 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantRepository.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantRepository.cs @@ -1,50 +1,50 @@ -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Modules.Tenants.Application.Abstractions; - -/// -/// Порт реестра тенантов -/// -public interface ITenantRepository -{ - /// - /// Ищет тенанта по идентификатору. - /// - /// Идентификатор тенанта. - /// Запись тенанта или null. - public Task FindByIdAsync(Guid id, CancellationToken ct); - - /// - /// Сохраняет нового тенанта. - /// - /// Запись тенанта. - public Task CreateAsync(TenantRecordDto tenant, CancellationToken ct); - - /// - /// Возвращает список всех тенантов. - /// - /// Список тенантов. - public Task> ListAsync(CancellationToken ct); - - /// - /// Страница реестра тенантов (шардированный обход для 1000+ схем). - /// - /// Сдвиг от начала (устойчивый порядок — CreatedAt, затем Id). - /// Размер страницы (≥1; валидирует потребитель). - /// Записи страницы; пусто — страниц больше нет. - public Task> ListPageAsync( - int offset, - int limit, - CancellationToken ct); - - /// - /// Устанавливает статус тенанта. - /// - /// Идентификатор тенанта. - /// Новый статус — константа TenantStatuses. - /// true, если тенант существовал и статус обновлён; false — записи нет (404 на HTTP-слое). - public Task UpdateStatusAsync( - Guid id, - string status, - CancellationToken ct); -} +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Modules.Tenants.Application.Abstractions; + +/// +/// Порт реестра тенантов +/// +public interface ITenantRepository +{ + /// + /// Ищет тенанта по идентификатору. + /// + /// Идентификатор тенанта. + /// Запись тенанта или null. + public Task FindByIdAsync(Guid id, CancellationToken ct); + + /// + /// Сохраняет нового тенанта. + /// + /// Запись тенанта. + public Task CreateAsync(TenantRecordDto tenant, CancellationToken ct); + + /// + /// Возвращает список всех тенантов. + /// + /// Список тенантов. + public Task> ListAsync(CancellationToken ct); + + /// + /// Страница реестра тенантов (шардированный обход для 1000+ схем). + /// + /// Сдвиг от начала (устойчивый порядок — CreatedAt, затем Id). + /// Размер страницы (≥1; валидирует потребитель). + /// Записи страницы; пусто — страниц больше нет. + public Task> ListPageAsync( + int offset, + int limit, + CancellationToken ct); + + /// + /// Устанавливает статус тенанта. + /// + /// Идентификатор тенанта. + /// Новый статус — константа TenantStatuses. + /// true, если тенант существовал и статус обновлён; false — записи нет (404 на HTTP-слое). + public Task UpdateStatusAsync( + Guid id, + string status, + CancellationToken ct); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITokenUsageEventStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITokenUsageEventStore.cs index 79bc046..8a8f2f8 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITokenUsageEventStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITokenUsageEventStore.cs @@ -1,23 +1,23 @@ -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Modules.Tenants.Application.Abstractions; - -/// -/// Порт хранилища истории расхода токенов -/// -public interface ITokenUsageEventStore -{ - /// - /// Добавляет событие расхода токенов - /// - /// Событие для сохранения. - public Task AppendAsync(TokenUsageEventDto record, CancellationToken ct); - - /// - /// Агрегаты по фильтру и способу группировки - /// - /// Фильтр/группировка. - /// Строки агрегатов (пусто — событий нет). - public Task> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct); -} +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Modules.Tenants.Application.Abstractions; + +/// +/// Порт хранилища истории расхода токенов +/// +public interface ITokenUsageEventStore +{ + /// + /// Добавляет событие расхода токенов + /// + /// Событие для сохранения. + public Task AppendAsync(TokenUsageEventDto record, CancellationToken ct); + + /// + /// Агрегаты по фильтру и способу группировки + /// + /// Фильтр/группировка. + /// Строки агрегатов (пусто — событий нет). + public Task> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Extensions/InviteDtoExtensions.cs b/src/core/Deal.Modules.Tenants/Application/Extensions/InviteDtoExtensions.cs index 55effca..649fc7a 100644 --- a/src/core/Deal.Modules.Tenants/Application/Extensions/InviteDtoExtensions.cs +++ b/src/core/Deal.Modules.Tenants/Application/Extensions/InviteDtoExtensions.cs @@ -1,13 +1,13 @@ -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Modules.Tenants.Application.Extensions; - -internal static class InviteDtoExtensions -{ - /// - /// Истёк ли срок действия приглашения - /// - /// Приглашение. - /// True — срок действия уже прошёл. - public static bool IsExpired(this InviteDto invite) => invite.ExpiresAt <= DateTimeOffset.UtcNow; -} +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Modules.Tenants.Application.Extensions; + +internal static class InviteDtoExtensions +{ + /// + /// Истёк ли срок действия приглашения + /// + /// Приглашение. + /// True — срок действия уже прошёл. + public static bool IsExpired(this InviteDto invite) => invite.ExpiresAt <= DateTimeOffset.UtcNow; +} diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsActivityDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsActivityDto.cs index c963edf..50d4d70 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsActivityDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsActivityDto.cs @@ -1,14 +1,14 @@ -namespace Deal.Modules.Tenants.Application.Models; - -/// -/// Лента действий тенантов/операторов. -/// -/// Записи аудита, новые сверху (в пределах страницы offset/limit). -/// Полное число записей по фильтру (без учёта limit/offset). -/// Применённый размер страницы (кламп 1..500). -/// Применённое смещение (≥0). -public sealed record AnalyticsActivityDto( - IReadOnlyList Items, - int Total, - int Limit, - int Offset); +namespace Deal.Modules.Tenants.Application.Models; + +/// +/// Лента действий тенантов/операторов. +/// +/// Записи аудита, новые сверху (в пределах страницы offset/limit). +/// Полное число записей по фильтру (без учёта limit/offset). +/// Применённый размер страницы (кламп 1..500). +/// Применённое смещение (≥0). +public sealed record AnalyticsActivityDto( + IReadOnlyList Items, + int Total, + int Limit, + int Offset); diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsOverviewDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsOverviewDto.cs index 7b6f9e1..132c518 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsOverviewDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsOverviewDto.cs @@ -1,30 +1,30 @@ -namespace Deal.Modules.Tenants.Application.Models; - -/// -/// Сводка операторской аналитики. -/// -/// Всего тенантов. -/// Активных тенантов (статус active). -/// Токены запроса за период (сумма). -/// Токены ответа за период (сумма). -/// Всего токенов за период (сумма). -/// Число событий расхода токенов за период. -/// Число событий аудита за период. -/// Успешные входы (tenant+operator) за период. -/// Выходы (tenant+operator) за период. -/// Неудачные входы (tenant+operator) за период. -/// Начало периода (включительно; null — без границы). -/// Конец периода (включительно; null — без границы). -public sealed record AnalyticsOverviewDto( - int TenantsTotal, - int TenantsActive, - long PromptTokens, - long CompletionTokens, - long TotalTokens, - long TokenEvents, - int Events, - int Logins, - int Logouts, - int FailedLogins, - DateTimeOffset? From, - DateTimeOffset? To); +namespace Deal.Modules.Tenants.Application.Models; + +/// +/// Сводка операторской аналитики. +/// +/// Всего тенантов. +/// Активных тенантов (статус active). +/// Токены запроса за период (сумма). +/// Токены ответа за период (сумма). +/// Всего токенов за период (сумма). +/// Число событий расхода токенов за период. +/// Число событий аудита за период. +/// Успешные входы (tenant+operator) за период. +/// Выходы (tenant+operator) за период. +/// Неудачные входы (tenant+operator) за период. +/// Начало периода (включительно; null — без границы). +/// Конец периода (включительно; null — без границы). +public sealed record AnalyticsOverviewDto( + int TenantsTotal, + int TenantsActive, + long PromptTokens, + long CompletionTokens, + long TotalTokens, + long TokenEvents, + int Events, + int Logins, + int Logouts, + int FailedLogins, + DateTimeOffset? From, + DateTimeOffset? To); diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsTokensDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsTokensDto.cs index 9525c5f..535923f 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsTokensDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsTokensDto.cs @@ -1,16 +1,16 @@ -namespace Deal.Modules.Tenants.Application.Models; - -/// -/// Агрегаты расхода токенов. -/// -/// Способ группировки: day|tenant|provider|model. -/// Начало периода (включительно; null — без границы). -/// Конец периода (включительно; null — без границы). -/// Строки агрегатов (day — по возрастанию даты, остальные — по убыванию total). -/// Итог по всем строкам (Key = "total"). -public sealed record AnalyticsTokensDto( - string GroupBy, - DateTimeOffset? From, - DateTimeOffset? To, - IReadOnlyList Items, - TokenUsageAggregateDto Total); +namespace Deal.Modules.Tenants.Application.Models; + +/// +/// Агрегаты расхода токенов. +/// +/// Способ группировки: day|tenant|provider|model. +/// Начало периода (включительно; null — без границы). +/// Конец периода (включительно; null — без границы). +/// Строки агрегатов (day — по возрастанию даты, остальные — по убыванию total). +/// Итог по всем строкам (Key = "total"). +public sealed record AnalyticsTokensDto( + string GroupBy, + DateTimeOffset? From, + DateTimeOffset? To, + IReadOnlyList Items, + TokenUsageAggregateDto Total); diff --git a/src/core/Deal.Modules.Tenants/Application/Models/SuspiciousActivityDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/SuspiciousActivityDto.cs index 469c590..23aec7d 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/SuspiciousActivityDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/SuspiciousActivityDto.cs @@ -1,32 +1,32 @@ -namespace Deal.Modules.Tenants.Application.Models; - -/// -/// Сводка детектора подозрительной активности — тело ответа GET /api/operator/analytics/suspicious. -/// -public sealed record SuspiciousActivityDto -{ - /// - /// Начало окна анализа - /// - public DateTimeOffset From { get; init; } - - /// - /// Конец окна анализа - /// - public DateTimeOffset To { get; init; } - - /// - /// Сколько записей аудита разобрано в окне. - /// - public int Scanned { get; init; } - - /// - /// True — в окне было больше записей, чем предел выборки - /// - public bool Truncated { get; init; } - - /// - /// Найденные подозрительные паттерны - /// - public IReadOnlyList Items { get; init; } = Array.Empty(); -} +namespace Deal.Modules.Tenants.Application.Models; + +/// +/// Сводка детектора подозрительной активности — тело ответа GET /api/operator/analytics/suspicious. +/// +public sealed record SuspiciousActivityDto +{ + /// + /// Начало окна анализа + /// + public DateTimeOffset From { get; init; } + + /// + /// Конец окна анализа + /// + public DateTimeOffset To { get; init; } + + /// + /// Сколько записей аудита разобрано в окне. + /// + public int Scanned { get; init; } + + /// + /// True — в окне было больше записей, чем предел выборки + /// + public bool Truncated { get; init; } + + /// + /// Найденные подозрительные паттерны + /// + public IReadOnlyList Items { get; init; } = Array.Empty(); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Models/SuspiciousFindingDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/SuspiciousFindingDto.cs index a8a949c..9556431 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/SuspiciousFindingDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/SuspiciousFindingDto.cs @@ -1,16 +1,16 @@ -namespace Deal.Modules.Tenants.Application.Models; - -/// -/// Одна находка детектора подозрительной активности -/// -/// Правило, сработавшее на логах безопасности (константы SuspiciousActivityService). -/// Уровень: high | medium (константы сервиса). -/// Субъект находки: IP, логин, id актора или id тенанта. -/// Число событий, приведших к срабатыванию правила. -/// Человекочитаемое пояснение для оператора. -public sealed record SuspiciousFindingDto( - string Kind, - string Severity, - string Subject, - int Count, - string Detail); +namespace Deal.Modules.Tenants.Application.Models; + +/// +/// Одна находка детектора подозрительной активности +/// +/// Правило, сработавшее на логах безопасности (константы SuspiciousActivityService). +/// Уровень: high | medium (константы сервиса). +/// Субъект находки: IP, логин, id актора или id тенанта. +/// Число событий, приведших к срабатыванию правила. +/// Человекочитаемое пояснение для оператора. +public sealed record SuspiciousFindingDto( + string Kind, + string Severity, + string Subject, + int Count, + string Detail); diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TokenBudgetDefaults.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenBudgetDefaults.cs index 171d9f2..ea25f60 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenBudgetDefaults.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenBudgetDefaults.cs @@ -1,22 +1,22 @@ -namespace Deal.Modules.Tenants.Application.Models; - -/// -/// Дефолт-бюджет нового тенанта. -/// -public static class TokenBudgetDefaults -{ - /// - /// Дефолт бюджета, токенов в месяц. - /// - public const long DefaultBudgetTokens = 10_000_000; - - /// - /// Дефолт периода нового тенанта. - /// - public const string DefaultPeriod = TenantLimitPeriods.Month; - - /// - /// Готовый набор дефолтов - /// - public static TokenLimitDefaults Default => new(DefaultBudgetTokens, DefaultPeriod); -} +namespace Deal.Modules.Tenants.Application.Models; + +/// +/// Дефолт-бюджет нового тенанта. +/// +public static class TokenBudgetDefaults +{ + /// + /// Дефолт бюджета, токенов в месяц. + /// + public const long DefaultBudgetTokens = 10_000_000; + + /// + /// Дефолт периода нового тенанта. + /// + public const string DefaultPeriod = TenantLimitPeriods.Month; + + /// + /// Готовый набор дефолтов + /// + public static TokenLimitDefaults Default => new(DefaultBudgetTokens, DefaultPeriod); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageAggregateDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageAggregateDto.cs index f6777fd..1eae61e 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageAggregateDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageAggregateDto.cs @@ -1,16 +1,16 @@ -namespace Deal.Modules.Tenants.Application.Models; - -/// -/// Строка агрегата расхода токенов. -/// -/// Ключ группы: ГГГГ-ММ-ДД (day), Guid (tenant), id провайдера, модель. -/// Сумма токенов запроса в группе. -/// Сумма токенов ответа в группе. -/// Сумма total токенов в группе. -/// Число событий в группе. -public sealed record TokenUsageAggregateDto( - string Key, - long PromptTokens, - long CompletionTokens, - long TotalTokens, - long EventCount); +namespace Deal.Modules.Tenants.Application.Models; + +/// +/// Строка агрегата расхода токенов. +/// +/// Ключ группы: ГГГГ-ММ-ДД (day), Guid (tenant), id провайдера, модель. +/// Сумма токенов запроса в группе. +/// Сумма токенов ответа в группе. +/// Сумма total токенов в группе. +/// Число событий в группе. +public sealed record TokenUsageAggregateDto( + string Key, + long PromptTokens, + long CompletionTokens, + long TotalTokens, + long EventCount); diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventDto.cs index f353dec..08a040f 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventDto.cs @@ -1,24 +1,24 @@ -namespace Deal.Modules.Tenants.Application.Models; - -/// -/// Событие расхода токенов — time-series для аналитики. -/// -/// Тенант события (Guid строки public.tenants). -/// Время события (UTC; проставляет сервис). -/// Провайдер/источник (deepseek/openai/anthropic/local/ml). -/// Модель провайдера (напр. модель aiConfigs либо "ml" для локальной ML-модели). -/// Вид вызова — константа (ai|ml). -/// Токены запроса (system+user). -/// Токены ответа модели. -/// Суммарно (может отличаться от суммы при подсчёте провайдером — берём как есть). -/// Детали события в JSON (camelCase) без секретов; null — деталей нет. -public sealed record TokenUsageEventDto( - Guid TenantId, - DateTimeOffset At, - string Provider, - string Model, - string Kind, - long PromptTokens, - long CompletionTokens, - long TotalTokens, - string? DetailJson); +namespace Deal.Modules.Tenants.Application.Models; + +/// +/// Событие расхода токенов — time-series для аналитики. +/// +/// Тенант события (Guid строки public.tenants). +/// Время события (UTC; проставляет сервис). +/// Провайдер/источник (deepseek/openai/anthropic/local/ml). +/// Модель провайдера (напр. модель aiConfigs либо "ml" для локальной ML-модели). +/// Вид вызова — константа (ai|ml). +/// Токены запроса (system+user). +/// Токены ответа модели. +/// Суммарно (может отличаться от суммы при подсчёте провайдером — берём как есть). +/// Детали события в JSON (camelCase) без секретов; null — деталей нет. +public sealed record TokenUsageEventDto( + Guid TenantId, + DateTimeOffset At, + string Provider, + string Model, + string Kind, + long PromptTokens, + long CompletionTokens, + long TotalTokens, + string? DetailJson); diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventKinds.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventKinds.cs index a344dfe..215761c 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventKinds.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventKinds.cs @@ -1,17 +1,17 @@ -namespace Deal.Modules.Tenants.Application.Models; - -/// -/// Виды вызова расхода токенов. -/// -public static class TokenUsageEventKinds -{ - /// - /// Платный вызов LLM через ai-service - /// - public const string Ai = "ai"; - - /// - /// Локальный вызов ml-service/ML - /// - public const string Ml = "ml"; -} +namespace Deal.Modules.Tenants.Application.Models; + +/// +/// Виды вызова расхода токенов. +/// +public static class TokenUsageEventKinds +{ + /// + /// Платный вызов LLM через ai-service + /// + public const string Ai = "ai"; + + /// + /// Локальный вызов ml-service/ML + /// + public const string Ml = "ml"; +} diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventQueryDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventQueryDto.cs index fe57405..cd91ef1 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventQueryDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventQueryDto.cs @@ -1,20 +1,20 @@ -namespace Deal.Modules.Tenants.Application.Models; - -/// -/// Фильтр агрегации расхода токенов. -/// -/// Тенант (равенство; null — все тенанты). -/// Провайдер (равенство; null — без фильтра). -/// Модель (равенство; null — без фильтра). -/// Вид вызова ai|ml (равенство; null — без фильтра). -/// Нижняя граница At (включительно; null — без фильтра). -/// Верхняя граница At (включительно; null — без фильтра). -/// Способ группировки — константа . -public sealed record TokenUsageEventQueryDto( - Guid? TenantId, - string? Provider, - string? Model, - string? Kind, - DateTimeOffset? From, - DateTimeOffset? To, - string GroupBy); +namespace Deal.Modules.Tenants.Application.Models; + +/// +/// Фильтр агрегации расхода токенов. +/// +/// Тенант (равенство; null — все тенанты). +/// Провайдер (равенство; null — без фильтра). +/// Модель (равенство; null — без фильтра). +/// Вид вызова ai|ml (равенство; null — без фильтра). +/// Нижняя граница At (включительно; null — без фильтра). +/// Верхняя граница At (включительно; null — без фильтра). +/// Способ группировки — константа . +public sealed record TokenUsageEventQueryDto( + Guid? TenantId, + string? Provider, + string? Model, + string? Kind, + DateTimeOffset? From, + DateTimeOffset? To, + string GroupBy); diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageGroupBys.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageGroupBys.cs index 15392b9..b2adce7 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageGroupBys.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageGroupBys.cs @@ -1,27 +1,27 @@ -namespace Deal.Modules.Tenants.Application.Models; - -/// -/// Способы группировки агрегатов расхода токенов. -/// -public static class TokenUsageGroupBys -{ - /// - /// Серия по суткам UTC - /// - public const string Day = "day"; - - /// - /// Агрегат по тенантам - /// - public const string Tenant = "tenant"; - - /// - /// Агрегат по провайдерам - /// - public const string Provider = "provider"; - - /// - /// Агрегат по моделям - /// - public const string Model = "model"; -} +namespace Deal.Modules.Tenants.Application.Models; + +/// +/// Способы группировки агрегатов расхода токенов. +/// +public static class TokenUsageGroupBys +{ + /// + /// Серия по суткам UTC + /// + public const string Day = "day"; + + /// + /// Агрегат по тенантам + /// + public const string Tenant = "tenant"; + + /// + /// Агрегат по провайдерам + /// + public const string Provider = "provider"; + + /// + /// Агрегат по моделям + /// + public const string Model = "model"; +} diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageSources.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageSources.cs index 3064778..3c4743b 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageSources.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageSources.cs @@ -1,17 +1,17 @@ -namespace Deal.Modules.Tenants.Application.Models; - -/// -/// Источники расхода токенов. -/// -public static class TokenUsageSources -{ - /// - /// Локальный источник - /// - public const string Local = "local"; - - /// - /// Модель локального ML-вызова - /// - public const string Ml = "ml"; -} +namespace Deal.Modules.Tenants.Application.Models; + +/// +/// Источники расхода токенов. +/// +public static class TokenUsageSources +{ + /// + /// Локальный источник + /// + public const string Local = "local"; + + /// + /// Модель локального ML-вызова + /// + public const string Ml = "ml"; +} diff --git a/src/core/Deal.Modules.Tenants/Application/Registrars/TenantModuleRegistrar.cs b/src/core/Deal.Modules.Tenants/Application/Registrars/TenantModuleRegistrar.cs index f512953..d5f6eb4 100644 --- a/src/core/Deal.Modules.Tenants/Application/Registrars/TenantModuleRegistrar.cs +++ b/src/core/Deal.Modules.Tenants/Application/Registrars/TenantModuleRegistrar.cs @@ -1,31 +1,31 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Modules.Tenants.Application.Registrars; - -/// -/// DI-регистрация модуля Tenants. -/// -public static class TenantModuleRegistrar -{ - /// - /// Регистрирует сервисы модуля Tenants в контейнере. - /// - public static IServiceCollection AddTenantsModule(this IServiceCollection services) - { - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - return services; - } -} +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Modules.Tenants.Application.Registrars; + +/// +/// DI-регистрация модуля Tenants. +/// +public static class TenantModuleRegistrar +{ + /// + /// Регистрирует сервисы модуля Tenants в контейнере. + /// + public static IServiceCollection AddTenantsModule(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/src/core/Deal.Modules.Tenants/Application/Services/AnalyticsService.cs b/src/core/Deal.Modules.Tenants/Application/Services/AnalyticsService.cs index 199daf6..aa80d7c 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/AnalyticsService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/AnalyticsService.cs @@ -1,160 +1,160 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Прикладной сервис операторской аналитики -/// -public sealed class AnalyticsService( - ITenantRepository tenants, - AuditService audit, - TokenUsageEventService tokenUsage) -{ - /// - /// Размер страницы ленты действий по умолчанию - /// - public const int DefaultActivityLimit = AuditService.DefaultQueryLimit; - - /// - /// Верхняя граница размера страницы ленты действий - /// - public const int MaxActivityLimit = AuditService.MaxQueryLimit; - - /// - /// Сводка: тенанты - /// - /// Начало периода (включительно; null — без границы). - /// Конец периода (включительно; null — без границы). - /// Сводка аналитики. - public async Task OverviewAsync( - DateTimeOffset? from, - DateTimeOffset? to, - CancellationToken ct) - { - IReadOnlyList allTenants = await tenants.ListAsync(ct); - int active = allTenants.Count(tenant => tenant.Status == TenantStatuses.Active); - - // Токены за период — суммируем по тенантам (агрегат по тенантам: ключ не важен, берём суммы). - IReadOnlyList perTenant = await tokenUsage.AggregateAsync( - new TokenUsageEventQueryDto(null, null, null, null, from, to, TokenUsageGroupBys.Tenant), ct); - long promptTokens = perTenant.Sum(row => row.PromptTokens); - long completionTokens = perTenant.Sum(row => row.CompletionTokens); - long totalTokens = perTenant.Sum(row => row.TotalTokens); - long tokenEvents = perTenant.Sum(row => row.EventCount); - - int events = await audit.CountAsync( - new AuditQueryDto(null, null, null, from, to, AuditService.DefaultQueryLimit), ct); - int logins = await CountEventTypesAsync( - from, to, [AuditEvents.TenantLoginOk, AuditEvents.OperatorLoginOk], ct); - int logouts = await CountEventTypesAsync( - from, to, [AuditEvents.TenantLogout, AuditEvents.OperatorLogout], ct); - int failedLogins = await CountEventTypesAsync( - from, to, [AuditEvents.TenantLoginFailed, AuditEvents.OperatorLoginFailed], ct); - - return new AnalyticsOverviewDto( - TenantsTotal: allTenants.Count, - TenantsActive: active, - PromptTokens: promptTokens, - CompletionTokens: completionTokens, - TotalTokens: totalTokens, - TokenEvents: tokenEvents, - Events: events, - Logins: logins, - Logouts: logouts, - FailedLogins: failedLogins, - From: from, - To: to); - } - - /// - /// Агрегаты расхода токенов по группировке и фильтрам - /// - /// Группировка day|tenant|provider|model (валидирует HTTP-слой). - /// Тенант (равенство; null — все тенанты). - /// Начало периода (включительно; null — без границы). - /// Конец периода (включительно; null — без границы). - /// Строки агрегатов и итог. - public async Task TokensAsync( - string groupBy, - Guid? tenantId, - DateTimeOffset? from, - DateTimeOffset? to, - CancellationToken ct) - { - IReadOnlyList items = await tokenUsage.AggregateAsync( - new TokenUsageEventQueryDto(tenantId, null, null, null, from, to, groupBy), ct); - var total = new TokenUsageAggregateDto( - Key: "total", - PromptTokens: items.Sum(row => row.PromptTokens), - CompletionTokens: items.Sum(row => row.CompletionTokens), - TotalTokens: items.Sum(row => row.TotalTokens), - EventCount: items.Sum(row => row.EventCount)); - return new AnalyticsTokensDto(groupBy, from, to, items, total); - } - - /// - /// Лента действий - /// - /// Тип события (равенство; null — без фильтра). - /// Тип актора operator|tenant|system (равенство; null — без фильтра). - /// Идентификатор актора (равенство; null — без фильтра). - /// Тенант (равенство; null — без фильтра). - /// Нижняя граница At (включительно; null — без границы). - /// Верхняя граница At (включительно; null — без границы). - /// Размер страницы (дефолт 100, кламп 1..500). - /// Смещение страницы (≥0). - /// Страница записей и полное число по фильтру. - public async Task ActivityAsync( - string? eventType, - string? actorType, - Guid? actorId, - Guid? tenantId, - DateTimeOffset? from, - DateTimeOffset? to, - int? limit, - int? offset, - CancellationToken ct) - { - int normalizedLimit = NormalizeActivityLimit(limit); - int normalizedOffset = Math.Max(0, offset ?? 0); - var filter = new AuditQueryDto( - eventType, actorType, tenantId, from, to, normalizedLimit, actorId, normalizedOffset); - - IReadOnlyList items = await audit.QueryAsync(filter, ct); - int total = await audit.CountAsync(filter, ct); - return new AnalyticsActivityDto(items, total, normalizedLimit, normalizedOffset); - } - - /// - /// Нормализует limit ленты действий - /// - /// Запрошенный размер (null — не задан). - /// Значение для фильтра. - public static int NormalizeActivityLimit(int? limit) => - limit is null - ? DefaultActivityLimit - : Math.Max(1, Math.Min(MaxActivityLimit, limit.Value)); - - // Суммарное число записей аудита по списку типов событий за период. - // from: Нижняя граница At (включительно; null — без границы). - // to: Верхняя граница At (включительно; null — без границы). - // eventTypes: Типы событий (каждый считается отдельным равенством). - // ct: Токен отмены. - // Возвращает: Сумма по типам событий. - private async Task CountEventTypesAsync( - DateTimeOffset? from, - DateTimeOffset? to, - string[] eventTypes, - CancellationToken ct) - { - int total = 0; - foreach (string eventType in eventTypes) - { - total += await audit.CountAsync( - new AuditQueryDto(eventType, null, null, from, to, AuditService.DefaultQueryLimit), ct); - } - - return total; - } -} +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Modules.Tenants.Application.Services; + +/// +/// Прикладной сервис операторской аналитики +/// +public sealed class AnalyticsService( + ITenantRepository tenants, + AuditService audit, + TokenUsageEventService tokenUsage) +{ + /// + /// Размер страницы ленты действий по умолчанию + /// + public const int DefaultActivityLimit = AuditService.DefaultQueryLimit; + + /// + /// Верхняя граница размера страницы ленты действий + /// + public const int MaxActivityLimit = AuditService.MaxQueryLimit; + + /// + /// Сводка: тенанты + /// + /// Начало периода (включительно; null — без границы). + /// Конец периода (включительно; null — без границы). + /// Сводка аналитики. + public async Task OverviewAsync( + DateTimeOffset? from, + DateTimeOffset? to, + CancellationToken ct) + { + IReadOnlyList allTenants = await tenants.ListAsync(ct); + int active = allTenants.Count(tenant => tenant.Status == TenantStatuses.Active); + + // Токены за период — суммируем по тенантам (агрегат по тенантам: ключ не важен, берём суммы). + IReadOnlyList perTenant = await tokenUsage.AggregateAsync( + new TokenUsageEventQueryDto(null, null, null, null, from, to, TokenUsageGroupBys.Tenant), ct); + long promptTokens = perTenant.Sum(row => row.PromptTokens); + long completionTokens = perTenant.Sum(row => row.CompletionTokens); + long totalTokens = perTenant.Sum(row => row.TotalTokens); + long tokenEvents = perTenant.Sum(row => row.EventCount); + + int events = await audit.CountAsync( + new AuditQueryDto(null, null, null, from, to, AuditService.DefaultQueryLimit), ct); + int logins = await CountEventTypesAsync( + from, to, [AuditEvents.TenantLoginOk, AuditEvents.OperatorLoginOk], ct); + int logouts = await CountEventTypesAsync( + from, to, [AuditEvents.TenantLogout, AuditEvents.OperatorLogout], ct); + int failedLogins = await CountEventTypesAsync( + from, to, [AuditEvents.TenantLoginFailed, AuditEvents.OperatorLoginFailed], ct); + + return new AnalyticsOverviewDto( + TenantsTotal: allTenants.Count, + TenantsActive: active, + PromptTokens: promptTokens, + CompletionTokens: completionTokens, + TotalTokens: totalTokens, + TokenEvents: tokenEvents, + Events: events, + Logins: logins, + Logouts: logouts, + FailedLogins: failedLogins, + From: from, + To: to); + } + + /// + /// Агрегаты расхода токенов по группировке и фильтрам + /// + /// Группировка day|tenant|provider|model (валидирует HTTP-слой). + /// Тенант (равенство; null — все тенанты). + /// Начало периода (включительно; null — без границы). + /// Конец периода (включительно; null — без границы). + /// Строки агрегатов и итог. + public async Task TokensAsync( + string groupBy, + Guid? tenantId, + DateTimeOffset? from, + DateTimeOffset? to, + CancellationToken ct) + { + IReadOnlyList items = await tokenUsage.AggregateAsync( + new TokenUsageEventQueryDto(tenantId, null, null, null, from, to, groupBy), ct); + var total = new TokenUsageAggregateDto( + Key: "total", + PromptTokens: items.Sum(row => row.PromptTokens), + CompletionTokens: items.Sum(row => row.CompletionTokens), + TotalTokens: items.Sum(row => row.TotalTokens), + EventCount: items.Sum(row => row.EventCount)); + return new AnalyticsTokensDto(groupBy, from, to, items, total); + } + + /// + /// Лента действий + /// + /// Тип события (равенство; null — без фильтра). + /// Тип актора operator|tenant|system (равенство; null — без фильтра). + /// Идентификатор актора (равенство; null — без фильтра). + /// Тенант (равенство; null — без фильтра). + /// Нижняя граница At (включительно; null — без границы). + /// Верхняя граница At (включительно; null — без границы). + /// Размер страницы (дефолт 100, кламп 1..500). + /// Смещение страницы (≥0). + /// Страница записей и полное число по фильтру. + public async Task ActivityAsync( + string? eventType, + string? actorType, + Guid? actorId, + Guid? tenantId, + DateTimeOffset? from, + DateTimeOffset? to, + int? limit, + int? offset, + CancellationToken ct) + { + int normalizedLimit = NormalizeActivityLimit(limit); + int normalizedOffset = Math.Max(0, offset ?? 0); + var filter = new AuditQueryDto( + eventType, actorType, tenantId, from, to, normalizedLimit, actorId, normalizedOffset); + + IReadOnlyList items = await audit.QueryAsync(filter, ct); + int total = await audit.CountAsync(filter, ct); + return new AnalyticsActivityDto(items, total, normalizedLimit, normalizedOffset); + } + + /// + /// Нормализует limit ленты действий + /// + /// Запрошенный размер (null — не задан). + /// Значение для фильтра. + public static int NormalizeActivityLimit(int? limit) => + limit is null + ? DefaultActivityLimit + : Math.Max(1, Math.Min(MaxActivityLimit, limit.Value)); + + // Суммарное число записей аудита по списку типов событий за период. + // from: Нижняя граница At (включительно; null — без границы). + // to: Верхняя граница At (включительно; null — без границы). + // eventTypes: Типы событий (каждый считается отдельным равенством). + // ct: Токен отмены. + // Возвращает: Сумма по типам событий. + private async Task CountEventTypesAsync( + DateTimeOffset? from, + DateTimeOffset? to, + string[] eventTypes, + CancellationToken ct) + { + int total = 0; + foreach (string eventType in eventTypes) + { + total += await audit.CountAsync( + new AuditQueryDto(eventType, null, null, from, to, AuditService.DefaultQueryLimit), ct); + } + + return total; + } +} diff --git a/src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs b/src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs index ac82d82..acf49fc 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs @@ -1,73 +1,73 @@ -using System.Text.Json; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Observability; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Прикладной сервис аудита -/// -public sealed class AuditService(IAuditLogStore store) -{ - /// - /// Верхняя граница выборки аудита. - /// - public const int MaxQueryLimit = 500; - - /// - /// Размер выборки по умолчанию при отсутствии limit в запросе - /// - public const int DefaultQueryLimit = 100; - - // Опции JSON деталей: camelCase (конвенция DetailJson/JSON проекта). - private static readonly JsonSerializerOptions DetailJsonOptions = new(JsonSerializerDefaults.Web); - - /// - /// Записывает событие аудита - /// - /// Запись события (At и Id игнорируются: At проставляет сервис, Id — БД). - public async Task AppendAsync(AuditRecordDto record, CancellationToken ct) - { - await store.AppendAsync(record with { At = DateTimeOffset.UtcNow }, ct); - DealMetrics.RecordAuditEvent(record.EventType, record.ActorType); - } - - /// - /// Записи по фильтру, новые сверху - /// - /// Фильтр выборки. - /// Записи от новых к старым. - public Task> QueryAsync(AuditQueryDto filter, CancellationToken ct) => - store.QueryAsync(filter, ct); - - /// - /// Число записей по фильтру - /// - /// Фильтр выборки. - /// Полное число записей по фильтру. - public Task CountAsync(AuditQueryDto filter, CancellationToken ct) => store.CountAsync(filter, ct); - - /// - /// Сериализует детали события в JSON. - /// - /// Объект деталей (обычно анонимный: { login =... }). - /// JSON-строка деталей. - public static string ToDetailJson(object? details) => JsonSerializer.Serialize(details, DetailJsonOptions); - - /// - /// Актор «пользователь тенанта» по разрешённой сессии - /// - /// Идентичность пользователя тенанта. - /// Кортеж актора для полей записи аудита. - public static (string ActorType, Guid? ActorId, Guid? TenantId) ActorFromUser(UserIdentityDto user) => - (AuditActorTypes.Tenant, user.Id, user.TenantId); - - /// - /// Актор «оператор» по разрешённой операторской сессии - /// - /// Идентичность оператора. - /// Кортеж актора для полей записи аудита. - public static (string ActorType, Guid? ActorId, Guid? TenantId) ActorFromOperator(OperatorIdentityDto operatorIdentity) => - (AuditActorTypes.Operator, operatorIdentity.Id, null); -} +using System.Text.Json; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Observability; + +namespace Deal.Modules.Tenants.Application.Services; + +/// +/// Прикладной сервис аудита +/// +public sealed class AuditService(IAuditLogStore store) +{ + /// + /// Верхняя граница выборки аудита. + /// + public const int MaxQueryLimit = 500; + + /// + /// Размер выборки по умолчанию при отсутствии limit в запросе + /// + public const int DefaultQueryLimit = 100; + + // Опции JSON деталей: camelCase (конвенция DetailJson/JSON проекта). + private static readonly JsonSerializerOptions DetailJsonOptions = new(JsonSerializerDefaults.Web); + + /// + /// Записывает событие аудита + /// + /// Запись события (At и Id игнорируются: At проставляет сервис, Id — БД). + public async Task AppendAsync(AuditRecordDto record, CancellationToken ct) + { + await store.AppendAsync(record with { At = DateTimeOffset.UtcNow }, ct); + DealMetrics.RecordAuditEvent(record.EventType, record.ActorType); + } + + /// + /// Записи по фильтру, новые сверху + /// + /// Фильтр выборки. + /// Записи от новых к старым. + public Task> QueryAsync(AuditQueryDto filter, CancellationToken ct) => + store.QueryAsync(filter, ct); + + /// + /// Число записей по фильтру + /// + /// Фильтр выборки. + /// Полное число записей по фильтру. + public Task CountAsync(AuditQueryDto filter, CancellationToken ct) => store.CountAsync(filter, ct); + + /// + /// Сериализует детали события в JSON. + /// + /// Объект деталей (обычно анонимный: { login =... }). + /// JSON-строка деталей. + public static string ToDetailJson(object? details) => JsonSerializer.Serialize(details, DetailJsonOptions); + + /// + /// Актор «пользователь тенанта» по разрешённой сессии + /// + /// Идентичность пользователя тенанта. + /// Кортеж актора для полей записи аудита. + public static (string ActorType, Guid? ActorId, Guid? TenantId) ActorFromUser(UserIdentityDto user) => + (AuditActorTypes.Tenant, user.Id, user.TenantId); + + /// + /// Актор «оператор» по разрешённой операторской сессии + /// + /// Идентичность оператора. + /// Кортеж актора для полей записи аудита. + public static (string ActorType, Guid? ActorId, Guid? TenantId) ActorFromOperator(OperatorIdentityDto operatorIdentity) => + (AuditActorTypes.Operator, operatorIdentity.Id, null); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs b/src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs index 6cb8fb1..ba7e7c3 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs @@ -1,16 +1,16 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Isopoh.Cryptography.Argon2; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Реализация на Argon2id. -/// -public sealed class DefaultPasswordHasher : IPasswordHasher -{ - /// - public string Hash(string password) => Argon2.Hash(password); - - /// - public bool Verify(string password, string encodedHash) => Argon2.Verify(encodedHash, password); -} +using Deal.Modules.Tenants.Application.Abstractions; +using Isopoh.Cryptography.Argon2; + +namespace Deal.Modules.Tenants.Application.Services; + +/// +/// Реализация на Argon2id. +/// +public sealed class DefaultPasswordHasher : IPasswordHasher +{ + /// + public string Hash(string password) => Argon2.Hash(password); + + /// + public bool Verify(string password, string encodedHash) => Argon2.Verify(encodedHash, password); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Services/InviteCodeGenerator.cs b/src/core/Deal.Modules.Tenants/Application/Services/InviteCodeGenerator.cs index 68baf15..6671df3 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/InviteCodeGenerator.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/InviteCodeGenerator.cs @@ -1,24 +1,24 @@ -using Deal.SharedKernel; -using Deal.SharedKernel.Utilities; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Генератор кодов приглашений -/// -public static class InviteCodeGenerator -{ - /// - /// Длина кода в символах. - /// - public const int CodeLength = 16; - - // Случайные байты одного кода: 12 → ровно 16 символов Base64Url. - private const int RandomByteCount = 12; - - /// - /// Новый код приглашения - /// - /// Строка кода длиной символов. - public static string NewCode() => UrlSafeToken.New(RandomByteCount); -} +using Deal.SharedKernel; +using Deal.SharedKernel.Utilities; + +namespace Deal.Modules.Tenants.Application.Services; + +/// +/// Генератор кодов приглашений +/// +public static class InviteCodeGenerator +{ + /// + /// Длина кода в символах. + /// + public const int CodeLength = 16; + + // Случайные байты одного кода: 12 → ровно 16 символов Base64Url. + private const int RandomByteCount = 12; + + /// + /// Новый код приглашения + /// + /// Строка кода длиной символов. + public static string NewCode() => UrlSafeToken.New(RandomByteCount); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Services/InvitesService.cs b/src/core/Deal.Modules.Tenants/Application/Services/InvitesService.cs index 66d531c..f243580 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/InvitesService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/InvitesService.cs @@ -1,169 +1,169 @@ -using System.Text.RegularExpressions; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Прикладной сервис приглашений -/// -public sealed partial class InvitesService(IInviteStore inviteStore) -{ - /// - /// Срок действия приглашения, часов. - /// - public const int ExpiryHours = 72; - - private const int MaxEmailLength = 200; - - // Проверяемый формат: один '@', непустые локальная часть и домен с точкой, без пробелов. - private const string EmailFormatPattern = @"^[^@\s]+@[^@\s]+\.[^@\s]+$"; - - // Компилируется из EmailFormatPattern; вход ограничен 200 символами, паттерн линейный. - [GeneratedRegex(EmailFormatPattern, RegexOptions.CultureInvariant)] - private static partial Regex EmailFormatRegex(); - - /// - /// Создаёт приглашение оператором - /// - /// Идентификатор оператора (CreatedById приглашения). - /// Email приглашённого (регистр/пробелы не важны — нормализуется). - /// Целевой тенант; null — при активации будет создан новый тенант. - /// Ok=true + созданное приглашение, либо код ошибки (текст — HTTP-слой). - public async Task CreateInviteAsync( - Guid operatorId, - string? email, - Guid? tenantId, - CancellationToken ct) - { - string normalizedEmail = NormalizeEmail(email); - if (!IsValidEmail(normalizedEmail)) - { - return new InviteCreateResultDto(Ok: false, Error: InviteCreateResultDto.ErrorInvalidEmail, Invite: null); - } - - var active = await inviteStore.FindActiveByEmailAsync(normalizedEmail, ct); - if (active is not null) - { - if (active.IsExpired()) - { - await inviteStore.UpdateStatusAsync(active.Code, InviteStatuses.Expired, null, ct); - } - else - { - return new InviteCreateResultDto(Ok: false, Error: InviteCreateResultDto.ErrorDuplicateActive, Invite: null); - } - } - - var now = DateTimeOffset.UtcNow; - var invite = new InviteDto( - Code: InviteCodeGenerator.NewCode(), - Email: normalizedEmail, - TenantId: tenantId, - Status: InviteStatuses.Pending, - ExpiresAt: now.AddHours(ExpiryHours), - ActivatedAt: null, - CreatedById: operatorId, - CreatedAt: now); - await inviteStore.CreateAsync(invite, ct); - return new InviteCreateResultDto(Ok: true, Error: null, Invite: invite); - } - - /// - /// Отзывает приглашение - /// - /// Код приглашения. - /// Ok=true + отозванное приглашение, либо код ошибки (текст — HTTP-слой). - public async Task RevokeAsync(string code, CancellationToken ct) - { - var invite = await inviteStore.GetByCodeAsync(code, ct); - if (invite is null) - { - return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotFound, Invite: null); - } - - if (invite.Status != InviteStatuses.Pending) - { - return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotPending, Invite: invite); - } - - bool updated = await inviteStore.UpdateStatusAsync(code, InviteStatuses.Revoked, null, ct); - if (!updated) - { - // Строка исчезла между чтением и обновлением — считаем «не найдено». - return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotFound, Invite: null); - } - - return new InviteRevokeResultDto(Ok: true, Error: null, Invite: invite with { Status = InviteStatuses.Revoked }); - } - - /// - /// Список приглашений для оператора - /// - /// Приглашения в порядке CreatedAt DESC; протухшие pending приходят со статусом expired. - public async Task> ListAsync(CancellationToken ct) - { - IReadOnlyList invites = await inviteStore.ListAsync(ct); - var result = new List(invites.Count); - foreach (var invite in invites) - { - if (invite.Status == InviteStatuses.Pending && invite.ExpiresAt <= DateTimeOffset.UtcNow) - { - await inviteStore.UpdateStatusAsync(invite.Code, InviteStatuses.Expired, null, ct); - result.Add(invite with { Status = InviteStatuses.Expired }); - } - else - { - result.Add(invite); - } - } - - return result; - } - - /// - /// Читает приглашение по коду, вычисляя статус expired при чтении. - /// - /// Код приглашения. - /// Приглашение (протухшее pending — со статусом expired и сохранённым переходом) или null. - public async Task GetByCodeAsync(string code, CancellationToken ct) - { - var invite = await inviteStore.GetByCodeAsync(code, ct); - if (invite is null || invite.Status != InviteStatuses.Pending || !invite.IsExpired()) - { - return invite; - } - - await inviteStore.UpdateStatusAsync(code, InviteStatuses.Expired, null, ct); - return invite with { Status = InviteStatuses.Expired }; - } - - /// - /// Активирует приглашение CAS-переходом - /// - /// Код приглашения. - /// true, если активация выполнена; false — строка не в статусе pending. - public Task TryActivateAsync(string code, CancellationToken ct) => - inviteStore.TryActivateAsync(code, DateTimeOffset.UtcNow, ct); - - /// - /// Нормализация email - /// - /// Входной email (может быть null). - /// Нормализованный email (пустая строка, если вход был пустым). - public static string NormalizeEmail(string? email) => (email ?? string.Empty).Trim().ToLowerInvariant(); - - /// - /// Проверка формата email - /// - /// Входной email (регистр/пробелы не важны). - /// true, если email выглядит корректно. - public static bool IsValidEmail(string? email) - { - string normalized = NormalizeEmail(email); - return normalized.Length > 0 - && normalized.Length <= MaxEmailLength - && EmailFormatRegex().IsMatch(normalized); - } -} +using System.Text.RegularExpressions; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Modules.Tenants.Application.Services; + +/// +/// Прикладной сервис приглашений +/// +public sealed partial class InvitesService(IInviteStore inviteStore) +{ + /// + /// Срок действия приглашения, часов. + /// + public const int ExpiryHours = 72; + + private const int MaxEmailLength = 200; + + // Проверяемый формат: один '@', непустые локальная часть и домен с точкой, без пробелов. + private const string EmailFormatPattern = @"^[^@\s]+@[^@\s]+\.[^@\s]+$"; + + // Компилируется из EmailFormatPattern; вход ограничен 200 символами, паттерн линейный. + [GeneratedRegex(EmailFormatPattern, RegexOptions.CultureInvariant)] + private static partial Regex EmailFormatRegex(); + + /// + /// Создаёт приглашение оператором + /// + /// Идентификатор оператора (CreatedById приглашения). + /// Email приглашённого (регистр/пробелы не важны — нормализуется). + /// Целевой тенант; null — при активации будет создан новый тенант. + /// Ok=true + созданное приглашение, либо код ошибки (текст — HTTP-слой). + public async Task CreateInviteAsync( + Guid operatorId, + string? email, + Guid? tenantId, + CancellationToken ct) + { + string normalizedEmail = NormalizeEmail(email); + if (!IsValidEmail(normalizedEmail)) + { + return new InviteCreateResultDto(Ok: false, Error: InviteCreateResultDto.ErrorInvalidEmail, Invite: null); + } + + var active = await inviteStore.FindActiveByEmailAsync(normalizedEmail, ct); + if (active is not null) + { + if (active.IsExpired()) + { + await inviteStore.UpdateStatusAsync(active.Code, InviteStatuses.Expired, null, ct); + } + else + { + return new InviteCreateResultDto(Ok: false, Error: InviteCreateResultDto.ErrorDuplicateActive, Invite: null); + } + } + + var now = DateTimeOffset.UtcNow; + var invite = new InviteDto( + Code: InviteCodeGenerator.NewCode(), + Email: normalizedEmail, + TenantId: tenantId, + Status: InviteStatuses.Pending, + ExpiresAt: now.AddHours(ExpiryHours), + ActivatedAt: null, + CreatedById: operatorId, + CreatedAt: now); + await inviteStore.CreateAsync(invite, ct); + return new InviteCreateResultDto(Ok: true, Error: null, Invite: invite); + } + + /// + /// Отзывает приглашение + /// + /// Код приглашения. + /// Ok=true + отозванное приглашение, либо код ошибки (текст — HTTP-слой). + public async Task RevokeAsync(string code, CancellationToken ct) + { + var invite = await inviteStore.GetByCodeAsync(code, ct); + if (invite is null) + { + return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotFound, Invite: null); + } + + if (invite.Status != InviteStatuses.Pending) + { + return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotPending, Invite: invite); + } + + bool updated = await inviteStore.UpdateStatusAsync(code, InviteStatuses.Revoked, null, ct); + if (!updated) + { + // Строка исчезла между чтением и обновлением — считаем «не найдено». + return new InviteRevokeResultDto(Ok: false, Error: InviteRevokeResultDto.ErrorNotFound, Invite: null); + } + + return new InviteRevokeResultDto(Ok: true, Error: null, Invite: invite with { Status = InviteStatuses.Revoked }); + } + + /// + /// Список приглашений для оператора + /// + /// Приглашения в порядке CreatedAt DESC; протухшие pending приходят со статусом expired. + public async Task> ListAsync(CancellationToken ct) + { + IReadOnlyList invites = await inviteStore.ListAsync(ct); + var result = new List(invites.Count); + foreach (var invite in invites) + { + if (invite.Status == InviteStatuses.Pending && invite.ExpiresAt <= DateTimeOffset.UtcNow) + { + await inviteStore.UpdateStatusAsync(invite.Code, InviteStatuses.Expired, null, ct); + result.Add(invite with { Status = InviteStatuses.Expired }); + } + else + { + result.Add(invite); + } + } + + return result; + } + + /// + /// Читает приглашение по коду, вычисляя статус expired при чтении. + /// + /// Код приглашения. + /// Приглашение (протухшее pending — со статусом expired и сохранённым переходом) или null. + public async Task GetByCodeAsync(string code, CancellationToken ct) + { + var invite = await inviteStore.GetByCodeAsync(code, ct); + if (invite is null || invite.Status != InviteStatuses.Pending || !invite.IsExpired()) + { + return invite; + } + + await inviteStore.UpdateStatusAsync(code, InviteStatuses.Expired, null, ct); + return invite with { Status = InviteStatuses.Expired }; + } + + /// + /// Активирует приглашение CAS-переходом + /// + /// Код приглашения. + /// true, если активация выполнена; false — строка не в статусе pending. + public Task TryActivateAsync(string code, CancellationToken ct) => + inviteStore.TryActivateAsync(code, DateTimeOffset.UtcNow, ct); + + /// + /// Нормализация email + /// + /// Входной email (может быть null). + /// Нормализованный email (пустая строка, если вход был пустым). + public static string NormalizeEmail(string? email) => (email ?? string.Empty).Trim().ToLowerInvariant(); + + /// + /// Проверка формата email + /// + /// Входной email (регистр/пробелы не важны). + /// true, если email выглядит корректно. + public static bool IsValidEmail(string? email) + { + string normalized = NormalizeEmail(email); + return normalized.Length > 0 + && normalized.Length <= MaxEmailLength + && EmailFormatRegex().IsMatch(normalized); + } +} diff --git a/src/core/Deal.Modules.Tenants/Application/Services/JoinService.cs b/src/core/Deal.Modules.Tenants/Application/Services/JoinService.cs index d4438f4..992f499 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/JoinService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/JoinService.cs @@ -1,131 +1,131 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Прикладной сервис активации инвайта через публичную ручку POST /api/join -/// -public sealed class JoinService( - InvitesService invitesService, - TenantService tenantService, - IAuthStore authStore, - IPasswordHasher passwordHasher, - ITenantRepository tenantRepository) -{ - // Статус создаваемого пользователя (как у seed-пользователя TenantBootstrapService). - private const string UserActiveStatus = "active"; - - /// - /// Активирует инвайт - /// - /// Код приглашения (пробелы по краям не важны). - /// Email активирующего; обязан совпасть с email приглашения (регистр/пробелы не важны). - /// Имя тенанта при создании нового (TenantId инвайта пуст); null/пустое — имя = email. - /// Пароль пользователя (открытым текстом; минимум ). - /// При успехе — Ok=true, Login (нормализованный email), UserId и TenantId; иначе код ошибки (см. ). - public async Task ActivateAsync( - string? code, - string? email, - string? name, - string? password, - CancellationToken ct) - { - string normalizedEmail = InvitesService.NormalizeEmail(email); - string normalizedCode = code?.Trim() ?? string.Empty; - - var invite = await invitesService.GetByCodeAsync(normalizedCode, ct); - if (invite is null) - { - return Failed(JoinResultDto.ErrorNotFound); - } - - if (invite.Status != InviteStatuses.Pending) - { - return Failed(ErrorFromStatus(invite.Status)); - } - - if (normalizedEmail != invite.Email) - { - return Failed(JoinResultDto.ErrorEmailMismatch); - } - - // 3. Пароль: минимум AuthService.MinNewPasswordLength (единый источник), текст на HTTP-слое. - if (password is null || password.Length < AuthService.MinNewPasswordLength) - { - return Failed(JoinResultDto.ErrorPasswordTooShort); - } - - var existingUser = await authStore.FindUserByLoginAsync(normalizedEmail, ct); - if (existingUser is not null) - { - return Failed(JoinResultDto.ErrorEmailTaken); - } - - // 5. Целевой тенант (инвайт с TenantId): обязан существовать и быть активным ДО резервирования кода — - // «битый» инвайт (тенант удалён/приостановлен) не должен ни расходовать код, ни создавать пользователя - // на несуществующем/замороженном тенанте (Security review). - if (invite.TenantId is not null) - { - var target = await tenantRepository.FindByIdAsync(invite.TenantId.Value, ct); - if (target is null) - { - return Failed(JoinResultDto.ErrorTenantNotFound); - } - - if (target.Status == TenantStatuses.Suspended) - { - return Failed(JoinResultDto.ErrorTenantSuspended); - } - } - - // 6. CAS-резервирование инвайта (pending → activated). Если параллельно инвайт отозван/активирован — - // переход не выполнен: перечитываем фактический статус и отвечаем соответствующей ошибкой без побочных - // эффектов (ни тенант, ни пользователь не создаются — создаёт только победитель гонки). - if (!await invitesService.TryActivateAsync(normalizedCode, ct)) - { - var current = await invitesService.GetByCodeAsync(normalizedCode, ct); - return current is null - ? Failed(JoinResultDto.ErrorNotFound) - : Failed(ErrorFromStatus(current.Status)); - } - - // 7. Тенант: TenantId инвайта задан — присоединение к существующему (провижининг не нужен — схема уже есть); - // пуст — создание нового с явным id (TenantService сам провижинит схему) и именем name ?? email. - Guid tenantId = invite.TenantId ?? Guid.NewGuid(); - if (invite.TenantId is null) - { - string tenantName = string.IsNullOrWhiteSpace(name) ? normalizedEmail : name.Trim(); - await tenantService.CreateTenantAsync(tenantName, tenantId, ct); - } - - // 8. Пользователь в public.users (login=email, Argon2id-хэш пароля). - var userId = Guid.NewGuid(); - await authStore.CreateUserAsync( - new StoredUserDto( - Id: userId, - Login: normalizedEmail, - TenantId: tenantId, - Status: UserActiveStatus, - PasswordHash: passwordHasher.Hash(password)), - ct); - - return new JoinResultDto(Ok: true, Error: null, Login: normalizedEmail, UserId: userId, TenantId: tenantId); - } - - // Код ошибки по фактическому статусу приглашения (используется при отказе CAS и при не-pending чтении). - // status: Статус из InviteStatuses. - // Возвращает: Код JoinResultDto: activated → used, revoked → revoked, иначе expired (для не-pending чтения). - private static string ErrorFromStatus(string status) => - status switch - { - InviteStatuses.Activated => JoinResultDto.ErrorUsed, - InviteStatuses.Revoked => JoinResultDto.ErrorRevoked, - _ => JoinResultDto.ErrorExpired, - }; - - // Неуспешный результат без полезной нагрузки. - // error: Код ошибки JoinResultDto. - // Возвращает: JoinResultDto с Ok=false. - private static JoinResultDto Failed(string error) => new(Ok: false, Error: error, Login: null, UserId: null, TenantId: null); -} +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Modules.Tenants.Application.Services; + +/// +/// Прикладной сервис активации инвайта через публичную ручку POST /api/join +/// +public sealed class JoinService( + InvitesService invitesService, + TenantService tenantService, + IAuthStore authStore, + IPasswordHasher passwordHasher, + ITenantRepository tenantRepository) +{ + // Статус создаваемого пользователя (как у seed-пользователя TenantBootstrapService). + private const string UserActiveStatus = "active"; + + /// + /// Активирует инвайт + /// + /// Код приглашения (пробелы по краям не важны). + /// Email активирующего; обязан совпасть с email приглашения (регистр/пробелы не важны). + /// Имя тенанта при создании нового (TenantId инвайта пуст); null/пустое — имя = email. + /// Пароль пользователя (открытым текстом; минимум ). + /// При успехе — Ok=true, Login (нормализованный email), UserId и TenantId; иначе код ошибки (см. ). + public async Task ActivateAsync( + string? code, + string? email, + string? name, + string? password, + CancellationToken ct) + { + string normalizedEmail = InvitesService.NormalizeEmail(email); + string normalizedCode = code?.Trim() ?? string.Empty; + + var invite = await invitesService.GetByCodeAsync(normalizedCode, ct); + if (invite is null) + { + return Failed(JoinResultDto.ErrorNotFound); + } + + if (invite.Status != InviteStatuses.Pending) + { + return Failed(ErrorFromStatus(invite.Status)); + } + + if (normalizedEmail != invite.Email) + { + return Failed(JoinResultDto.ErrorEmailMismatch); + } + + // 3. Пароль: минимум AuthService.MinNewPasswordLength (единый источник), текст на HTTP-слое. + if (password is null || password.Length < AuthService.MinNewPasswordLength) + { + return Failed(JoinResultDto.ErrorPasswordTooShort); + } + + var existingUser = await authStore.FindUserByLoginAsync(normalizedEmail, ct); + if (existingUser is not null) + { + return Failed(JoinResultDto.ErrorEmailTaken); + } + + // 5. Целевой тенант (инвайт с TenantId): обязан существовать и быть активным ДО резервирования кода — + // «битый» инвайт (тенант удалён/приостановлен) не должен ни расходовать код, ни создавать пользователя + // на несуществующем/замороженном тенанте (Security review). + if (invite.TenantId is not null) + { + var target = await tenantRepository.FindByIdAsync(invite.TenantId.Value, ct); + if (target is null) + { + return Failed(JoinResultDto.ErrorTenantNotFound); + } + + if (target.Status == TenantStatuses.Suspended) + { + return Failed(JoinResultDto.ErrorTenantSuspended); + } + } + + // 6. CAS-резервирование инвайта (pending → activated). Если параллельно инвайт отозван/активирован — + // переход не выполнен: перечитываем фактический статус и отвечаем соответствующей ошибкой без побочных + // эффектов (ни тенант, ни пользователь не создаются — создаёт только победитель гонки). + if (!await invitesService.TryActivateAsync(normalizedCode, ct)) + { + var current = await invitesService.GetByCodeAsync(normalizedCode, ct); + return current is null + ? Failed(JoinResultDto.ErrorNotFound) + : Failed(ErrorFromStatus(current.Status)); + } + + // 7. Тенант: TenantId инвайта задан — присоединение к существующему (провижининг не нужен — схема уже есть); + // пуст — создание нового с явным id (TenantService сам провижинит схему) и именем name ?? email. + Guid tenantId = invite.TenantId ?? Guid.NewGuid(); + if (invite.TenantId is null) + { + string tenantName = string.IsNullOrWhiteSpace(name) ? normalizedEmail : name.Trim(); + await tenantService.CreateTenantAsync(tenantName, tenantId, ct); + } + + // 8. Пользователь в public.users (login=email, Argon2id-хэш пароля). + var userId = Guid.NewGuid(); + await authStore.CreateUserAsync( + new StoredUserDto( + Id: userId, + Login: normalizedEmail, + TenantId: tenantId, + Status: UserActiveStatus, + PasswordHash: passwordHasher.Hash(password)), + ct); + + return new JoinResultDto(Ok: true, Error: null, Login: normalizedEmail, UserId: userId, TenantId: tenantId); + } + + // Код ошибки по фактическому статусу приглашения (используется при отказе CAS и при не-pending чтении). + // status: Статус из InviteStatuses. + // Возвращает: Код JoinResultDto: activated → used, revoked → revoked, иначе expired (для не-pending чтения). + private static string ErrorFromStatus(string status) => + status switch + { + InviteStatuses.Activated => JoinResultDto.ErrorUsed, + InviteStatuses.Revoked => JoinResultDto.ErrorRevoked, + _ => JoinResultDto.ErrorExpired, + }; + + // Неуспешный результат без полезной нагрузки. + // error: Код ошибки JoinResultDto. + // Возвращает: JoinResultDto с Ok=false. + private static JoinResultDto Failed(string error) => new(Ok: false, Error: error, Login: null, UserId: null, TenantId: null); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs b/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs index 8e96083..11ba330 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs @@ -1,385 +1,385 @@ -using System.Text.Json; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Детектор подозрительной активности по логам безопасности -/// -public sealed class SuspiciousActivityService -{ - /// - /// Размер окна анализа по умолчанию, часов - /// - public const int DefaultWindowHours = 24; - - /// - /// Предел разбираемых записей за окно - /// - public const int MaxScanRecords = AuditService.MaxQueryLimit; - - /// - /// Порог неудачных входов с одного IP за окно. - /// - public const int FailedLoginsPerIpThreshold = 10; - - /// - /// Порог неудачных входов по одному логину за окно. - /// - public const int FailedLoginsPerLoginThreshold = 5; - - /// - /// Порог числа разных IP успешных входов одного актора за окно. - /// - public const int DistinctIpsPerActorThreshold = 5; - - /// - /// Порог неудачных входов по одному тенанту за окно. - /// - public const int AuthFailuresPerTenantThreshold = 20; - - /// - /// Порог числа разных логинов в неудачных входах с одного IP за окно. - /// - public const int DistinctLoginsPerIpThreshold = 5; - - // Кратность порога, с которой уровень поднимается до high (2× порог). - private const int HighSeverityMultiplier = 2; - - /// - /// Правило: всплеск неудачных входов с одного IP. - /// - public const string KindFailedLoginsPerIp = "failed_logins_per_ip"; - - /// - /// Правило: всплеск неудачных входов по одному логину. - /// - public const string KindFailedLoginsPerLogin = "failed_logins_per_login"; - - /// - /// Правило: успешные входы актора с множества IP. - /// - public const string KindManyIpsPerActor = "many_ips_per_actor"; - - /// - /// Правило: повторные неудачные входы по тенанту. - /// - public const string KindAuthFailuresPerTenant = "auth_failures_per_tenant"; - - /// - /// Правило: перебор разных логинов с одного IP. - /// - public const string KindDistinctLoginsPerIp = "distinct_logins_per_ip"; - - /// - /// Уровень находки - /// - public const string SeverityHigh = "high"; - - /// - /// Уровень находки - /// - public const string SeverityMedium = "medium"; - - private readonly IAuditLogStore _store; - private readonly Func _clock; - - /// - /// Создаёт детектор с системными часами UTC-«сейчас» - /// - /// Хранилище аудита (append-only лента). - public SuspiciousActivityService(IAuditLogStore store) - : this(store, () => DateTimeOffset.UtcNow) - { - } - - /// - /// Создаёт детектор с инъекцией часов - /// - /// Хранилище аудита (append-only лента). - /// Источник «сейчас». - public SuspiciousActivityService(IAuditLogStore store, Func clock) - { - ArgumentNullException.ThrowIfNull(store); - ArgumentNullException.ThrowIfNull(clock); - _store = store; - _clock = clock; - } - - /// - /// Анализирует логи аудита за окно и возвращает находки подозрительной активности. - /// - /// Начало окна (включительно); null — «сейчас минус часов». - /// Конец окна (включительно); null — «сейчас». - /// Сводка: окно, число разобранных записей, признак усечения и список находок. - public async Task AnalyzeAsync( - DateTimeOffset? from, - DateTimeOffset? to, - CancellationToken ct) - { - DateTimeOffset windowTo = to ?? _clock(); - DateTimeOffset windowFrom = from ?? windowTo.AddHours(-DefaultWindowHours); - - var filter = new AuditQueryDto( - EventType: null, - ActorType: null, - TenantId: null, - From: windowFrom, - To: windowTo, - Limit: MaxScanRecords); - IReadOnlyList records = await _store.QueryAsync(filter, ct); - int total = await _store.CountAsync(filter, ct); - - var findings = new List(); - AddFailedLoginsPerIp(records, findings); - AddFailedLoginsPerLogin(records, findings); - AddManyIpsPerActor(records, findings); - AddAuthFailuresPerTenant(records, findings); - AddDistinctLoginsPerIp(records, findings); - - findings.Sort(static (left, right) => - { - int severity = SeverityRank(right.Severity).CompareTo(SeverityRank(left.Severity)); - if (severity != 0) - { - return severity; - } - - int count = right.Count.CompareTo(left.Count); - return count != 0 ? count : string.CompareOrdinal(left.Subject, right.Subject); - }); - - return new SuspiciousActivityDto - { - From = windowFrom, - To = windowTo, - Scanned = records.Count, - Truncated = total > records.Count, - Items = findings, - }; - } - - // Правило «всплеск неудачных входов с одного IP». - // records: Записи окна. - // findings: Накопитель находок. - private static void AddFailedLoginsPerIp(IReadOnlyList records, List findings) - { - Dictionary counts = CountBy(records, record => record.IsFailedLogin(), record => record.Ip); - foreach ((string ip, int count) in counts) - { - if (count >= FailedLoginsPerIpThreshold) - { - findings.Add(new SuspiciousFindingDto( - KindFailedLoginsPerIp, - SeverityFor(count, FailedLoginsPerIpThreshold), - ip, - count, - $"Неудачных входов с IP {ip}: {count} за окно")); - } - } - } - - // Правило «всплеск неудачных входов по одному логину». - // records: Записи окна. - // findings: Накопитель находок. - private static void AddFailedLoginsPerLogin(IReadOnlyList records, List findings) - { - Dictionary counts = CountBy(records, record => record.IsFailedLogin(), ExtractLogin); - foreach ((string login, int count) in counts) - { - if (count >= FailedLoginsPerLoginThreshold) - { - findings.Add(new SuspiciousFindingDto( - KindFailedLoginsPerLogin, - SeverityFor(count, FailedLoginsPerLoginThreshold), - login, - count, - $"Неудачных входов по логину «{login}»: {count} за окно")); - } - } - } - - // Правило «успешные входы одного актора с множества IP». - // records: Записи окна. - // findings: Накопитель находок. - private static void AddManyIpsPerActor(IReadOnlyList records, List findings) - { - var ipsByActor = new Dictionary>(); - foreach (AuditRecordDto record in records) - { - if (!record.IsSuccessfulLogin() || record.ActorId is not { } actorId || string.IsNullOrWhiteSpace(record.Ip)) - { - continue; - } - - if (!ipsByActor.TryGetValue(actorId, out HashSet? ips)) - { - ips = new HashSet(StringComparer.Ordinal); - ipsByActor[actorId] = ips; - } - - ips.Add(record.Ip); - } - - foreach ((Guid actorId, HashSet ips) in ipsByActor) - { - if (ips.Count >= DistinctIpsPerActorThreshold) - { - findings.Add(new SuspiciousFindingDto( - KindManyIpsPerActor, - SeverityFor(ips.Count, DistinctIpsPerActorThreshold), - actorId.ToString("D"), - ips.Count, - $"Входы актора {actorId:D} с {ips.Count} разных IP за окно")); - } - } - } - - // Правило «повторные неудачные входы по одному тенанту». - // records: Записи окна. - // findings: Накопитель находок. - private static void AddAuthFailuresPerTenant(IReadOnlyList records, List findings) - { - var counts = new Dictionary(); - foreach (AuditRecordDto record in records) - { - if (!record.IsFailedLogin() || record.TenantId is not { } tenantId) - { - continue; - } - - counts[tenantId] = counts.TryGetValue(tenantId, out int current) ? current + 1 : 1; - } - - foreach ((Guid tenantId, int count) in counts) - { - if (count >= AuthFailuresPerTenantThreshold) - { - findings.Add(new SuspiciousFindingDto( - KindAuthFailuresPerTenant, - SeverityFor(count, AuthFailuresPerTenantThreshold), - tenantId.ToString("D"), - count, - $"Неудачных входов по тенанту {tenantId:D}: {count} за окно")); - } - } - } - - // Правило «перебор разных логинов с одного IP» (credential stuffing). - // records: Записи окна. - // findings: Накопитель находок. - private static void AddDistinctLoginsPerIp(IReadOnlyList records, List findings) - { - var loginsByIp = new Dictionary>(StringComparer.Ordinal); - foreach (AuditRecordDto record in records) - { - if (!record.IsFailedLogin() || string.IsNullOrWhiteSpace(record.Ip)) - { - continue; - } - - string? login = ExtractLogin(record); - if (string.IsNullOrWhiteSpace(login)) - { - continue; - } - - if (!loginsByIp.TryGetValue(record.Ip, out HashSet? logins)) - { - logins = new HashSet(StringComparer.Ordinal); - loginsByIp[record.Ip] = logins; - } - - logins.Add(login); - } - - foreach ((string ip, HashSet logins) in loginsByIp) - { - if (logins.Count >= DistinctLoginsPerIpThreshold) - { - findings.Add(new SuspiciousFindingDto( - KindDistinctLoginsPerIp, - SeverityFor(logins.Count, DistinctLoginsPerIpThreshold), - ip, - logins.Count, - $"Разных логинов с IP {ip}: {logins.Count} за окно (перебор)")); - } - } - } - - // Считает записи по ключу-селектору (пустые/неразобранные ключи пропускаются). - // records: Записи окна. - // predicate: Отбор записей правила. - // keySelector: Ключ группировки (null/пусто — запись пропускается). - // Возвращает: Словарь ключ → число записей. - private static Dictionary CountBy( - IReadOnlyList records, - Func predicate, - Func keySelector) - { - var counts = new Dictionary(StringComparer.Ordinal); - foreach (AuditRecordDto record in records) - { - if (!predicate(record)) - { - continue; - } - - string? key = keySelector(record); - if (string.IsNullOrWhiteSpace(key)) - { - continue; - } - - counts[key] = counts.TryGetValue(key, out int current) ? current + 1 : 1; - } - - return counts; - } - - // Извлекает логин из DetailJson записи (поле login); сбой разбора — null. - // record: Запись аудита. - // Возвращает: Логин либо null (деталей нет/не строка/повреждённый JSON). - private static string? ExtractLogin(AuditRecordDto record) - { - if (string.IsNullOrWhiteSpace(record.DetailJson)) - { - return null; - } - - try - { - using JsonDocument document = JsonDocument.Parse(record.DetailJson); - if (document.RootElement.ValueKind == JsonValueKind.Object - && document.RootElement.TryGetProperty("login", out JsonElement login) - && login.ValueKind == JsonValueKind.String) - { - return login.GetString(); - } - } - catch (JsonException) - { - // Повреждённые детали — логин неизвестен; запись в правиле не участвует. - } - - return null; - } - - // Уровень находки по превышению порога (≥2× порог — high, иначе medium). - // count: Фактическое значение правила. - // threshold: Порог правила. - // Возвращает: Константа уровня (SeverityHigh либо SeverityMedium). - private static string SeverityFor(int count, int threshold) => - count >= threshold * HighSeverityMultiplier ? SeverityHigh : SeverityMedium; - - // Ранг серьёзности для сортировки (high выше medium; неизвестное — ниже). - // severity: Уровень находки. - // Возвращает: Числовой ранг. - private static int SeverityRank(string severity) => severity switch - { - SeverityHigh => 2, - SeverityMedium => 1, - _ => 0, - }; -} +using System.Text.Json; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Modules.Tenants.Application.Services; + +/// +/// Детектор подозрительной активности по логам безопасности +/// +public sealed class SuspiciousActivityService +{ + /// + /// Размер окна анализа по умолчанию, часов + /// + public const int DefaultWindowHours = 24; + + /// + /// Предел разбираемых записей за окно + /// + public const int MaxScanRecords = AuditService.MaxQueryLimit; + + /// + /// Порог неудачных входов с одного IP за окно. + /// + public const int FailedLoginsPerIpThreshold = 10; + + /// + /// Порог неудачных входов по одному логину за окно. + /// + public const int FailedLoginsPerLoginThreshold = 5; + + /// + /// Порог числа разных IP успешных входов одного актора за окно. + /// + public const int DistinctIpsPerActorThreshold = 5; + + /// + /// Порог неудачных входов по одному тенанту за окно. + /// + public const int AuthFailuresPerTenantThreshold = 20; + + /// + /// Порог числа разных логинов в неудачных входах с одного IP за окно. + /// + public const int DistinctLoginsPerIpThreshold = 5; + + // Кратность порога, с которой уровень поднимается до high (2× порог). + private const int HighSeverityMultiplier = 2; + + /// + /// Правило: всплеск неудачных входов с одного IP. + /// + public const string KindFailedLoginsPerIp = "failed_logins_per_ip"; + + /// + /// Правило: всплеск неудачных входов по одному логину. + /// + public const string KindFailedLoginsPerLogin = "failed_logins_per_login"; + + /// + /// Правило: успешные входы актора с множества IP. + /// + public const string KindManyIpsPerActor = "many_ips_per_actor"; + + /// + /// Правило: повторные неудачные входы по тенанту. + /// + public const string KindAuthFailuresPerTenant = "auth_failures_per_tenant"; + + /// + /// Правило: перебор разных логинов с одного IP. + /// + public const string KindDistinctLoginsPerIp = "distinct_logins_per_ip"; + + /// + /// Уровень находки + /// + public const string SeverityHigh = "high"; + + /// + /// Уровень находки + /// + public const string SeverityMedium = "medium"; + + private readonly IAuditLogStore _store; + private readonly Func _clock; + + /// + /// Создаёт детектор с системными часами UTC-«сейчас» + /// + /// Хранилище аудита (append-only лента). + public SuspiciousActivityService(IAuditLogStore store) + : this(store, () => DateTimeOffset.UtcNow) + { + } + + /// + /// Создаёт детектор с инъекцией часов + /// + /// Хранилище аудита (append-only лента). + /// Источник «сейчас». + public SuspiciousActivityService(IAuditLogStore store, Func clock) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(clock); + _store = store; + _clock = clock; + } + + /// + /// Анализирует логи аудита за окно и возвращает находки подозрительной активности. + /// + /// Начало окна (включительно); null — «сейчас минус часов». + /// Конец окна (включительно); null — «сейчас». + /// Сводка: окно, число разобранных записей, признак усечения и список находок. + public async Task AnalyzeAsync( + DateTimeOffset? from, + DateTimeOffset? to, + CancellationToken ct) + { + DateTimeOffset windowTo = to ?? _clock(); + DateTimeOffset windowFrom = from ?? windowTo.AddHours(-DefaultWindowHours); + + var filter = new AuditQueryDto( + EventType: null, + ActorType: null, + TenantId: null, + From: windowFrom, + To: windowTo, + Limit: MaxScanRecords); + IReadOnlyList records = await _store.QueryAsync(filter, ct); + int total = await _store.CountAsync(filter, ct); + + var findings = new List(); + AddFailedLoginsPerIp(records, findings); + AddFailedLoginsPerLogin(records, findings); + AddManyIpsPerActor(records, findings); + AddAuthFailuresPerTenant(records, findings); + AddDistinctLoginsPerIp(records, findings); + + findings.Sort(static (left, right) => + { + int severity = SeverityRank(right.Severity).CompareTo(SeverityRank(left.Severity)); + if (severity != 0) + { + return severity; + } + + int count = right.Count.CompareTo(left.Count); + return count != 0 ? count : string.CompareOrdinal(left.Subject, right.Subject); + }); + + return new SuspiciousActivityDto + { + From = windowFrom, + To = windowTo, + Scanned = records.Count, + Truncated = total > records.Count, + Items = findings, + }; + } + + // Правило «всплеск неудачных входов с одного IP». + // records: Записи окна. + // findings: Накопитель находок. + private static void AddFailedLoginsPerIp(IReadOnlyList records, List findings) + { + Dictionary counts = CountBy(records, record => record.IsFailedLogin(), record => record.Ip); + foreach ((string ip, int count) in counts) + { + if (count >= FailedLoginsPerIpThreshold) + { + findings.Add(new SuspiciousFindingDto( + KindFailedLoginsPerIp, + SeverityFor(count, FailedLoginsPerIpThreshold), + ip, + count, + $"Неудачных входов с IP {ip}: {count} за окно")); + } + } + } + + // Правило «всплеск неудачных входов по одному логину». + // records: Записи окна. + // findings: Накопитель находок. + private static void AddFailedLoginsPerLogin(IReadOnlyList records, List findings) + { + Dictionary counts = CountBy(records, record => record.IsFailedLogin(), ExtractLogin); + foreach ((string login, int count) in counts) + { + if (count >= FailedLoginsPerLoginThreshold) + { + findings.Add(new SuspiciousFindingDto( + KindFailedLoginsPerLogin, + SeverityFor(count, FailedLoginsPerLoginThreshold), + login, + count, + $"Неудачных входов по логину «{login}»: {count} за окно")); + } + } + } + + // Правило «успешные входы одного актора с множества IP». + // records: Записи окна. + // findings: Накопитель находок. + private static void AddManyIpsPerActor(IReadOnlyList records, List findings) + { + var ipsByActor = new Dictionary>(); + foreach (AuditRecordDto record in records) + { + if (!record.IsSuccessfulLogin() || record.ActorId is not { } actorId || string.IsNullOrWhiteSpace(record.Ip)) + { + continue; + } + + if (!ipsByActor.TryGetValue(actorId, out HashSet? ips)) + { + ips = new HashSet(StringComparer.Ordinal); + ipsByActor[actorId] = ips; + } + + ips.Add(record.Ip); + } + + foreach ((Guid actorId, HashSet ips) in ipsByActor) + { + if (ips.Count >= DistinctIpsPerActorThreshold) + { + findings.Add(new SuspiciousFindingDto( + KindManyIpsPerActor, + SeverityFor(ips.Count, DistinctIpsPerActorThreshold), + actorId.ToString("D"), + ips.Count, + $"Входы актора {actorId:D} с {ips.Count} разных IP за окно")); + } + } + } + + // Правило «повторные неудачные входы по одному тенанту». + // records: Записи окна. + // findings: Накопитель находок. + private static void AddAuthFailuresPerTenant(IReadOnlyList records, List findings) + { + var counts = new Dictionary(); + foreach (AuditRecordDto record in records) + { + if (!record.IsFailedLogin() || record.TenantId is not { } tenantId) + { + continue; + } + + counts[tenantId] = counts.TryGetValue(tenantId, out int current) ? current + 1 : 1; + } + + foreach ((Guid tenantId, int count) in counts) + { + if (count >= AuthFailuresPerTenantThreshold) + { + findings.Add(new SuspiciousFindingDto( + KindAuthFailuresPerTenant, + SeverityFor(count, AuthFailuresPerTenantThreshold), + tenantId.ToString("D"), + count, + $"Неудачных входов по тенанту {tenantId:D}: {count} за окно")); + } + } + } + + // Правило «перебор разных логинов с одного IP» (credential stuffing). + // records: Записи окна. + // findings: Накопитель находок. + private static void AddDistinctLoginsPerIp(IReadOnlyList records, List findings) + { + var loginsByIp = new Dictionary>(StringComparer.Ordinal); + foreach (AuditRecordDto record in records) + { + if (!record.IsFailedLogin() || string.IsNullOrWhiteSpace(record.Ip)) + { + continue; + } + + string? login = ExtractLogin(record); + if (string.IsNullOrWhiteSpace(login)) + { + continue; + } + + if (!loginsByIp.TryGetValue(record.Ip, out HashSet? logins)) + { + logins = new HashSet(StringComparer.Ordinal); + loginsByIp[record.Ip] = logins; + } + + logins.Add(login); + } + + foreach ((string ip, HashSet logins) in loginsByIp) + { + if (logins.Count >= DistinctLoginsPerIpThreshold) + { + findings.Add(new SuspiciousFindingDto( + KindDistinctLoginsPerIp, + SeverityFor(logins.Count, DistinctLoginsPerIpThreshold), + ip, + logins.Count, + $"Разных логинов с IP {ip}: {logins.Count} за окно (перебор)")); + } + } + } + + // Считает записи по ключу-селектору (пустые/неразобранные ключи пропускаются). + // records: Записи окна. + // predicate: Отбор записей правила. + // keySelector: Ключ группировки (null/пусто — запись пропускается). + // Возвращает: Словарь ключ → число записей. + private static Dictionary CountBy( + IReadOnlyList records, + Func predicate, + Func keySelector) + { + var counts = new Dictionary(StringComparer.Ordinal); + foreach (AuditRecordDto record in records) + { + if (!predicate(record)) + { + continue; + } + + string? key = keySelector(record); + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + + counts[key] = counts.TryGetValue(key, out int current) ? current + 1 : 1; + } + + return counts; + } + + // Извлекает логин из DetailJson записи (поле login); сбой разбора — null. + // record: Запись аудита. + // Возвращает: Логин либо null (деталей нет/не строка/повреждённый JSON). + private static string? ExtractLogin(AuditRecordDto record) + { + if (string.IsNullOrWhiteSpace(record.DetailJson)) + { + return null; + } + + try + { + using JsonDocument document = JsonDocument.Parse(record.DetailJson); + if (document.RootElement.ValueKind == JsonValueKind.Object + && document.RootElement.TryGetProperty("login", out JsonElement login) + && login.ValueKind == JsonValueKind.String) + { + return login.GetString(); + } + } + catch (JsonException) + { + // Повреждённые детали — логин неизвестен; запись в правиле не участвует. + } + + return null; + } + + // Уровень находки по превышению порога (≥2× порог — high, иначе medium). + // count: Фактическое значение правила. + // threshold: Порог правила. + // Возвращает: Константа уровня (SeverityHigh либо SeverityMedium). + private static string SeverityFor(int count, int threshold) => + count >= threshold * HighSeverityMultiplier ? SeverityHigh : SeverityMedium; + + // Ранг серьёзности для сортировки (high выше medium; неизвестное — ниже). + // severity: Уровень находки. + // Возвращает: Числовой ранг. + private static int SeverityRank(string severity) => severity switch + { + SeverityHigh => 2, + SeverityMedium => 1, + _ => 0, + }; +} diff --git a/src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs b/src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs index 9f2023f..7a4a553 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs @@ -1,149 +1,149 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel; -using Deal.SharedKernel.Utilities; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Прикладной сервис операторского реестра тенантов -/// -public sealed class TenantAdminService( - ITenantRepository tenantRepository, - IAuthStore authStore, - TenantService tenantService, - IPasswordHasher passwordHasher) -{ - // Случайные байты одноразового пароля владельца: 12 → ровно 16 символов Base64Url (как InviteCodeGenerator). - private const int InitialPasswordRandomByteCount = 12; - - /// - /// Создаёт тенанта оператором - /// - /// Имя тенанта (обязательно; обрезается). - /// Email владельца (опционально): создаёт пользователя-владельца сразу, иначе владелец заводится инвайтом. - /// Ok=true — тенант создан (Tenant); при email дополнительно OwnerUserId/OwnerLogin/InitialPassword. Ok=false — код ошибки (имя пусто, email невалиден/уже зарегистрирован); текст — HTTP-слой. - public async Task CreateAsync( - string? name, - string? email, - CancellationToken ct) - { - string tenantName = name?.Trim() ?? string.Empty; - if (string.IsNullOrEmpty(tenantName)) - { - return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorNameRequired); - } - - string? normalizedEmail = null; - if (!string.IsNullOrWhiteSpace(email)) - { - normalizedEmail = InvitesService.NormalizeEmail(email); - if (!InvitesService.IsValidEmail(email)) - { - return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorInvalidEmail); - } - - var existingUser = await authStore.FindUserByLoginAsync(normalizedEmail, ct); - if (existingUser is not null) - { - return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorEmailTaken); - } - } - - // Создание тенанта — единая операция: строка public.tenants (Status active) + провижининг схемы. - var tenantId = Guid.NewGuid(); - await tenantService.CreateTenantAsync(tenantName, tenantId, ct); - var tenant = new TenantRecordDto(tenantId, tenantName, TenantStatuses.Active, DateTimeOffset.UtcNow); - - if (normalizedEmail is null) - { - return new TenantCreateResultDto(Ok: true, Error: null, Tenant: tenant); - } - - // Пользователь-владелец с одноразовым паролем: raw-пароль возвращается один раз (наружу), в хранилище — - // только хэш; в аудит/логи не пишется. - string initialPassword = GenerateInitialPassword(); - var ownerUserId = Guid.NewGuid(); - await authStore.CreateUserAsync( - new StoredUserDto( - Id: ownerUserId, - Login: normalizedEmail, - TenantId: tenantId, - Status: "active", - PasswordHash: passwordHasher.Hash(initialPassword)), - ct); - - return new TenantCreateResultDto( - Ok: true, - Error: null, - Tenant: tenant, - OwnerUserId: ownerUserId, - OwnerLogin: normalizedEmail, - InitialPassword: initialPassword); - } - - /// - /// Список тенантов со счётчиками пользователей. - /// - /// Тенанты в порядке создания (реестр) с числом пользователей каждого. - public async Task> ListAsync(CancellationToken ct) - { - var tenants = await tenantRepository.ListAsync(ct); - var items = new List(tenants.Count); - foreach (TenantRecordDto tenant in tenants) - { - IReadOnlyList users = await authStore.ListUsersByTenantIdAsync(tenant.Id, ct); - items.Add(new TenantListItemDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users.Count)); - } - - return items; - } - - /// - /// Детали тенанта с пользователями - /// - /// Идентификатор тенанта. - /// Детали и пользователи тенанта (по CreatedAt) или null, если тенанта нет. - public async Task GetAsync(Guid id, CancellationToken ct) - { - var tenant = await tenantRepository.FindByIdAsync(id, ct); - if (tenant is null) - { - return null; - } - - IReadOnlyList users = await authStore.ListUsersByTenantIdAsync(id, ct); - return new TenantDetailDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users); - } - - /// - /// Меняет статус тенанта - /// - /// Идентификатор тенанта. - /// Новый статус — константа TenantStatuses. - /// Результат: тенант не найден (404) или применение с признаком реального изменения (Changed). - public async Task ChangeStatusAsync( - Guid id, - string status, - CancellationToken ct) - { - var tenant = await tenantRepository.FindByIdAsync(id, ct); - if (tenant is null) - { - return new TenantStatusChangeResultDto(Ok: false, Error: TenantStatusChangeResultDto.ErrorNotFound, Changed: false, Tenant: null); - } - - // Идемпотентность: повторный suspend уже приостановленного — Ok без изменения (аудит не дублируется). - if (tenant.Status == status) - { - return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: false, Tenant: tenant); - } - - bool updated = await tenantRepository.UpdateStatusAsync(id, status, ct); - return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: updated, Tenant: tenant with { Status = status }); - } - - // Новый одноразовый пароль владельца: 16 url-safe символов (общий UrlSafeToken, Security review C36). - // Возвращает: Строка пароля. - private static string GenerateInitialPassword() => UrlSafeToken.New(InitialPasswordRandomByteCount); -} +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel; +using Deal.SharedKernel.Utilities; + +namespace Deal.Modules.Tenants.Application.Services; + +/// +/// Прикладной сервис операторского реестра тенантов +/// +public sealed class TenantAdminService( + ITenantRepository tenantRepository, + IAuthStore authStore, + TenantService tenantService, + IPasswordHasher passwordHasher) +{ + // Случайные байты одноразового пароля владельца: 12 → ровно 16 символов Base64Url (как InviteCodeGenerator). + private const int InitialPasswordRandomByteCount = 12; + + /// + /// Создаёт тенанта оператором + /// + /// Имя тенанта (обязательно; обрезается). + /// Email владельца (опционально): создаёт пользователя-владельца сразу, иначе владелец заводится инвайтом. + /// Ok=true — тенант создан (Tenant); при email дополнительно OwnerUserId/OwnerLogin/InitialPassword. Ok=false — код ошибки (имя пусто, email невалиден/уже зарегистрирован); текст — HTTP-слой. + public async Task CreateAsync( + string? name, + string? email, + CancellationToken ct) + { + string tenantName = name?.Trim() ?? string.Empty; + if (string.IsNullOrEmpty(tenantName)) + { + return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorNameRequired); + } + + string? normalizedEmail = null; + if (!string.IsNullOrWhiteSpace(email)) + { + normalizedEmail = InvitesService.NormalizeEmail(email); + if (!InvitesService.IsValidEmail(email)) + { + return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorInvalidEmail); + } + + var existingUser = await authStore.FindUserByLoginAsync(normalizedEmail, ct); + if (existingUser is not null) + { + return TenantCreateResultDto.Failed(TenantCreateResultDto.ErrorEmailTaken); + } + } + + // Создание тенанта — единая операция: строка public.tenants (Status active) + провижининг схемы. + var tenantId = Guid.NewGuid(); + await tenantService.CreateTenantAsync(tenantName, tenantId, ct); + var tenant = new TenantRecordDto(tenantId, tenantName, TenantStatuses.Active, DateTimeOffset.UtcNow); + + if (normalizedEmail is null) + { + return new TenantCreateResultDto(Ok: true, Error: null, Tenant: tenant); + } + + // Пользователь-владелец с одноразовым паролем: raw-пароль возвращается один раз (наружу), в хранилище — + // только хэш; в аудит/логи не пишется. + string initialPassword = GenerateInitialPassword(); + var ownerUserId = Guid.NewGuid(); + await authStore.CreateUserAsync( + new StoredUserDto( + Id: ownerUserId, + Login: normalizedEmail, + TenantId: tenantId, + Status: "active", + PasswordHash: passwordHasher.Hash(initialPassword)), + ct); + + return new TenantCreateResultDto( + Ok: true, + Error: null, + Tenant: tenant, + OwnerUserId: ownerUserId, + OwnerLogin: normalizedEmail, + InitialPassword: initialPassword); + } + + /// + /// Список тенантов со счётчиками пользователей. + /// + /// Тенанты в порядке создания (реестр) с числом пользователей каждого. + public async Task> ListAsync(CancellationToken ct) + { + var tenants = await tenantRepository.ListAsync(ct); + var items = new List(tenants.Count); + foreach (TenantRecordDto tenant in tenants) + { + IReadOnlyList users = await authStore.ListUsersByTenantIdAsync(tenant.Id, ct); + items.Add(new TenantListItemDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users.Count)); + } + + return items; + } + + /// + /// Детали тенанта с пользователями + /// + /// Идентификатор тенанта. + /// Детали и пользователи тенанта (по CreatedAt) или null, если тенанта нет. + public async Task GetAsync(Guid id, CancellationToken ct) + { + var tenant = await tenantRepository.FindByIdAsync(id, ct); + if (tenant is null) + { + return null; + } + + IReadOnlyList users = await authStore.ListUsersByTenantIdAsync(id, ct); + return new TenantDetailDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users); + } + + /// + /// Меняет статус тенанта + /// + /// Идентификатор тенанта. + /// Новый статус — константа TenantStatuses. + /// Результат: тенант не найден (404) или применение с признаком реального изменения (Changed). + public async Task ChangeStatusAsync( + Guid id, + string status, + CancellationToken ct) + { + var tenant = await tenantRepository.FindByIdAsync(id, ct); + if (tenant is null) + { + return new TenantStatusChangeResultDto(Ok: false, Error: TenantStatusChangeResultDto.ErrorNotFound, Changed: false, Tenant: null); + } + + // Идемпотентность: повторный suspend уже приостановленного — Ok без изменения (аудит не дублируется). + if (tenant.Status == status) + { + return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: false, Tenant: tenant); + } + + bool updated = await tenantRepository.UpdateStatusAsync(id, status, ct); + return new TenantStatusChangeResultDto(Ok: true, Error: null, Changed: updated, Tenant: tenant with { Status = status }); + } + + // Новый одноразовый пароль владельца: 16 url-safe символов (общий UrlSafeToken, Security review C36). + // Возвращает: Строка пароля. + private static string GenerateInitialPassword() => UrlSafeToken.New(InitialPasswordRandomByteCount); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Services/TokenUsageEventService.cs b/src/core/Deal.Modules.Tenants/Application/Services/TokenUsageEventService.cs index 9a3377d..c3dc762 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/TokenUsageEventService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/TokenUsageEventService.cs @@ -1,27 +1,27 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Прикладной сервис истории расхода токенов -/// -public sealed class TokenUsageEventService(ITokenUsageEventStore store) -{ - /// - /// Записывает событие расхода токенов - /// - /// Событие (At перезаписывается сервисом). - public async Task AppendAsync(TokenUsageEventDto record, CancellationToken ct) - { - await store.AppendAsync(record with { At = DateTimeOffset.UtcNow }, ct); - } - - /// - /// Агрегаты расхода по фильтру и группировке - /// - /// Фильтр/группировка. - /// Строки агрегатов. - public Task> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct) => - store.AggregateAsync(query, ct); -} +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Modules.Tenants.Application.Services; + +/// +/// Прикладной сервис истории расхода токенов +/// +public sealed class TokenUsageEventService(ITokenUsageEventStore store) +{ + /// + /// Записывает событие расхода токенов + /// + /// Событие (At перезаписывается сервисом). + public async Task AppendAsync(TokenUsageEventDto record, CancellationToken ct) + { + await store.AppendAsync(record with { At = DateTimeOffset.UtcNow }, ct); + } + + /// + /// Агрегаты расхода по фильтру и группировке + /// + /// Фильтр/группировка. + /// Строки агрегатов. + public Task> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct) => + store.AggregateAsync(query, ct); +} diff --git a/src/core/Deal.Modules.Tenants/Deal.Modules.Tenants.csproj b/src/core/Deal.Modules.Tenants/Deal.Modules.Tenants.csproj index 8cb5c18..bc9fbf4 100644 --- a/src/core/Deal.Modules.Tenants/Deal.Modules.Tenants.csproj +++ b/src/core/Deal.Modules.Tenants/Deal.Modules.Tenants.csproj @@ -1,19 +1,19 @@ - - - - - - - - - - - - - - net10.0 - enable - enable - - - + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/core/Deal.SharedKernel/Deal.SharedKernel.csproj b/src/core/Deal.SharedKernel/Deal.SharedKernel.csproj index 6d36c6d..b760144 100644 --- a/src/core/Deal.SharedKernel/Deal.SharedKernel.csproj +++ b/src/core/Deal.SharedKernel/Deal.SharedKernel.csproj @@ -1,9 +1,9 @@ - - - - net10.0 - enable - enable - - - + + + + net10.0 + enable + enable + + + diff --git a/src/core/Deal.SharedKernel/Observability/DealMetrics.cs b/src/core/Deal.SharedKernel/Observability/DealMetrics.cs index 3930b22..137dabe 100644 --- a/src/core/Deal.SharedKernel/Observability/DealMetrics.cs +++ b/src/core/Deal.SharedKernel/Observability/DealMetrics.cs @@ -1,218 +1,218 @@ -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Diagnostics.Metrics; - -namespace Deal.SharedKernel.Observability; - -/// -/// Прикладные метрики Deal -/// -public static class DealMetrics -{ - /// - /// Имя meter'а прикладных метрик - /// - public const string MeterName = "Deal"; - - /// - /// Имя метки вида токенов - /// - public const string TokenTypeTagName = "type"; - - /// - /// Значение метки вида токенов — токены запроса. - /// - public const string TokenTypePrompt = "prompt"; - - /// - /// Значение метки вида токенов — токены ответа. - /// - public const string TokenTypeCompletion = "completion"; - - /// - /// Имя метки типа события аудита - /// - public const string AuditEventTagName = "event"; - - /// - /// Имя метки типа актора события аудита - /// - public const string AuditActorTagName = "actor"; - - /// - /// Имя метки тенанта - /// - public const string TenantTagName = "tenant"; - - /// - /// Имя метки вида события безопасности - /// - public const string SecurityKindTagName = "kind"; - - /// - /// Имя метрики доли израсходованного ИИ-бюджета - /// - public const string AiBudgetUsedRatioName = "deal.ai.budget.used.ratio"; - - // Meter прикладных метрик (static — один на процесс, как того требует System.Diagnostics.Metrics). - private static readonly Meter Meter = new(MeterName); - - /// - /// Успешные вызовы платного ИИ - /// - public static readonly Counter AiCalls = - Meter.CreateCounter("deal.ai.calls", description: "Успешные вызовы платного ИИ (ai-service)."); - - /// - /// Токены платного ИИ по видам - /// - public static readonly Counter AiTokens = - Meter.CreateCounter("deal.ai.tokens", description: "Токены платных ИИ-вызовов (prompt/completion)."); - - /// - /// Вызовы локального ML - /// - public static readonly Counter MlCalls = - Meter.CreateCounter("deal.ml.calls", description: "Вызовы локального ML-предсказания."); - - /// - /// Оценка токенов локальных ML-вызовов - /// - public static readonly Counter MlTokens = - Meter.CreateCounter("deal.ml.tokens", description: "Оценка токенов локальных ML-вызовов."); - - /// - /// События аудита по типам и акторам - /// - public static readonly Counter AuditEvents = - Meter.CreateCounter("deal.audit.events", description: "Записи аудита по типам и акторам."); - - /// - /// События подозрительной активности по видам - /// - public static readonly Counter SecurityEvents = - Meter.CreateCounter("deal.security.suspicious", description: "События подозрительной активности по видам (rate_limit, login_blocked)."); - - /// - /// Суммарная глубина очереди пайплайна - /// - public static readonly ObservableGauge PipelineQueueDepth = - Meter.CreateObservableGauge( - "deal.pipeline.queue.depth", - (Func)(() => (long)Interlocked.Read(ref DealMetrics._pipelineQueueDepth)), - description: "Суммарная глубина очереди пайплайна (new+filtered) по всем тенантам."); - - /// - /// Суммарная глубина очереди обучения ML - /// - public static readonly ObservableGauge MlOutboxDepth = - Meter.CreateObservableGauge( - "deal.ml.outbox.depth", - (Func)(() => (long)Interlocked.Read(ref DealMetrics._mlOutboxDepth)), - description: "Суммарная глубина очереди обучения ML (MlOutbox) по всем тенантам."); - - /// - /// Число активных сессий - /// - public static readonly ObservableGauge ActiveSessions = - Meter.CreateObservableGauge( - "deal.sessions.active", - (Func)(() => (long)Interlocked.Read(ref DealMetrics._activeSessions)), - description: "Активные непросроченные сессии пользователей и операторов."); - - /// - /// Доля израсходованного ИИ-бюджета по тенантам - /// - public static readonly ObservableGauge AiBudgetUsedRatio = - Meter.CreateObservableGauge( - AiBudgetUsedRatioName, - ObserveBudgetRatios, - description: "Доля израсходованного ИИ-бюджета периода по тенантам (0..1)."); - - private static readonly ConcurrentDictionary BudgetRatios = new(StringComparer.Ordinal); - - private static long _pipelineQueueDepth; - private static long _mlOutboxDepth; - private static long _activeSessions; - - /// - /// Записывает успешный платный ИИ-вызов - /// - /// Токены запроса (неотрицательные). - /// Токены ответа (неотрицательные). - public static void RecordAiUsage(long promptTokens, long completionTokens) - { - AiCalls.Add(1); - if (promptTokens > 0) - { - AiTokens.Add(promptTokens, new TagList { { TokenTypeTagName, TokenTypePrompt } }); - } - - if (completionTokens > 0) - { - AiTokens.Add(completionTokens, new TagList { { TokenTypeTagName, TokenTypeCompletion } }); - } - } - - /// - /// Записывает вызов локального ML - /// - /// Оценка токенов входного текста (неотрицательная). - public static void RecordMlUsage(long promptTokens) - { - MlCalls.Add(1); - if (promptTokens > 0) - { - MlTokens.Add(promptTokens, new TagList { { TokenTypeTagName, TokenTypePrompt } }); - } - } - - /// - /// Записывает событие аудита по типу и актору - /// - /// Тип события (константа AuditEvents). - /// Тип актора (tenant/operator/system). - public static void RecordAuditEvent(string eventType, string actorType) - { - ArgumentException.ThrowIfNullOrWhiteSpace(eventType); - ArgumentException.ThrowIfNullOrWhiteSpace(actorType); - AuditEvents.Add(1, new TagList { { AuditEventTagName, eventType }, { AuditActorTagName, actorType } }); - } - - /// - /// Публикует глубину очереди пайплайна - /// - /// Суммарная глубина (new+filtered) по всем тенантам. - public static void SetPipelineQueueDepth(long depth) => Interlocked.Exchange(ref _pipelineQueueDepth, depth); - - /// - /// Публикует глубину очереди обучения ML - /// - /// Суммарная глубина MlOutbox по всем тенантам. - public static void SetMlOutboxDepth(long depth) => Interlocked.Exchange(ref _mlOutboxDepth, depth); - - /// - /// Публикует число активных сессий - /// - /// Активные непросроченные сессии пользователей и операторов. - public static void SetActiveSessions(long count) => Interlocked.Exchange(ref _activeSessions, count); - - /// - /// Публикует доли расхода ИИ-бюджета по тенантам (заменяет предыдущий снимок) - /// - /// Пары (id тенанта, доля 0..1). - public static void ReplaceBudgetRatios(IEnumerable<(string TenantId, double Ratio)> ratios) - { - BudgetRatios.Clear(); - foreach ((string tenantId, double ratio) in ratios) - { - BudgetRatios[tenantId] = Math.Clamp(ratio, 0.0, 1.0); - } - } - - // Снимок долей расхода бюджета для observable-gauge (по измерению на тенант). - private static IEnumerable> ObserveBudgetRatios() - => BudgetRatios.Select(pair => new Measurement( - pair.Value, - new TagList { { TenantTagName, pair.Key } })); -} +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace Deal.SharedKernel.Observability; + +/// +/// Прикладные метрики Deal +/// +public static class DealMetrics +{ + /// + /// Имя meter'а прикладных метрик + /// + public const string MeterName = "Deal"; + + /// + /// Имя метки вида токенов + /// + public const string TokenTypeTagName = "type"; + + /// + /// Значение метки вида токенов — токены запроса. + /// + public const string TokenTypePrompt = "prompt"; + + /// + /// Значение метки вида токенов — токены ответа. + /// + public const string TokenTypeCompletion = "completion"; + + /// + /// Имя метки типа события аудита + /// + public const string AuditEventTagName = "event"; + + /// + /// Имя метки типа актора события аудита + /// + public const string AuditActorTagName = "actor"; + + /// + /// Имя метки тенанта + /// + public const string TenantTagName = "tenant"; + + /// + /// Имя метки вида события безопасности + /// + public const string SecurityKindTagName = "kind"; + + /// + /// Имя метрики доли израсходованного ИИ-бюджета + /// + public const string AiBudgetUsedRatioName = "deal.ai.budget.used.ratio"; + + // Meter прикладных метрик (static — один на процесс, как того требует System.Diagnostics.Metrics). + private static readonly Meter Meter = new(MeterName); + + /// + /// Успешные вызовы платного ИИ + /// + public static readonly Counter AiCalls = + Meter.CreateCounter("deal.ai.calls", description: "Успешные вызовы платного ИИ (ai-service)."); + + /// + /// Токены платного ИИ по видам + /// + public static readonly Counter AiTokens = + Meter.CreateCounter("deal.ai.tokens", description: "Токены платных ИИ-вызовов (prompt/completion)."); + + /// + /// Вызовы локального ML + /// + public static readonly Counter MlCalls = + Meter.CreateCounter("deal.ml.calls", description: "Вызовы локального ML-предсказания."); + + /// + /// Оценка токенов локальных ML-вызовов + /// + public static readonly Counter MlTokens = + Meter.CreateCounter("deal.ml.tokens", description: "Оценка токенов локальных ML-вызовов."); + + /// + /// События аудита по типам и акторам + /// + public static readonly Counter AuditEvents = + Meter.CreateCounter("deal.audit.events", description: "Записи аудита по типам и акторам."); + + /// + /// События подозрительной активности по видам + /// + public static readonly Counter SecurityEvents = + Meter.CreateCounter("deal.security.suspicious", description: "События подозрительной активности по видам (rate_limit, login_blocked)."); + + /// + /// Суммарная глубина очереди пайплайна + /// + public static readonly ObservableGauge PipelineQueueDepth = + Meter.CreateObservableGauge( + "deal.pipeline.queue.depth", + (Func)(() => (long)Interlocked.Read(ref DealMetrics._pipelineQueueDepth)), + description: "Суммарная глубина очереди пайплайна (new+filtered) по всем тенантам."); + + /// + /// Суммарная глубина очереди обучения ML + /// + public static readonly ObservableGauge MlOutboxDepth = + Meter.CreateObservableGauge( + "deal.ml.outbox.depth", + (Func)(() => (long)Interlocked.Read(ref DealMetrics._mlOutboxDepth)), + description: "Суммарная глубина очереди обучения ML (MlOutbox) по всем тенантам."); + + /// + /// Число активных сессий + /// + public static readonly ObservableGauge ActiveSessions = + Meter.CreateObservableGauge( + "deal.sessions.active", + (Func)(() => (long)Interlocked.Read(ref DealMetrics._activeSessions)), + description: "Активные непросроченные сессии пользователей и операторов."); + + /// + /// Доля израсходованного ИИ-бюджета по тенантам + /// + public static readonly ObservableGauge AiBudgetUsedRatio = + Meter.CreateObservableGauge( + AiBudgetUsedRatioName, + ObserveBudgetRatios, + description: "Доля израсходованного ИИ-бюджета периода по тенантам (0..1)."); + + private static readonly ConcurrentDictionary BudgetRatios = new(StringComparer.Ordinal); + + private static long _pipelineQueueDepth; + private static long _mlOutboxDepth; + private static long _activeSessions; + + /// + /// Записывает успешный платный ИИ-вызов + /// + /// Токены запроса (неотрицательные). + /// Токены ответа (неотрицательные). + public static void RecordAiUsage(long promptTokens, long completionTokens) + { + AiCalls.Add(1); + if (promptTokens > 0) + { + AiTokens.Add(promptTokens, new TagList { { TokenTypeTagName, TokenTypePrompt } }); + } + + if (completionTokens > 0) + { + AiTokens.Add(completionTokens, new TagList { { TokenTypeTagName, TokenTypeCompletion } }); + } + } + + /// + /// Записывает вызов локального ML + /// + /// Оценка токенов входного текста (неотрицательная). + public static void RecordMlUsage(long promptTokens) + { + MlCalls.Add(1); + if (promptTokens > 0) + { + MlTokens.Add(promptTokens, new TagList { { TokenTypeTagName, TokenTypePrompt } }); + } + } + + /// + /// Записывает событие аудита по типу и актору + /// + /// Тип события (константа AuditEvents). + /// Тип актора (tenant/operator/system). + public static void RecordAuditEvent(string eventType, string actorType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(eventType); + ArgumentException.ThrowIfNullOrWhiteSpace(actorType); + AuditEvents.Add(1, new TagList { { AuditEventTagName, eventType }, { AuditActorTagName, actorType } }); + } + + /// + /// Публикует глубину очереди пайплайна + /// + /// Суммарная глубина (new+filtered) по всем тенантам. + public static void SetPipelineQueueDepth(long depth) => Interlocked.Exchange(ref _pipelineQueueDepth, depth); + + /// + /// Публикует глубину очереди обучения ML + /// + /// Суммарная глубина MlOutbox по всем тенантам. + public static void SetMlOutboxDepth(long depth) => Interlocked.Exchange(ref _mlOutboxDepth, depth); + + /// + /// Публикует число активных сессий + /// + /// Активные непросроченные сессии пользователей и операторов. + public static void SetActiveSessions(long count) => Interlocked.Exchange(ref _activeSessions, count); + + /// + /// Публикует доли расхода ИИ-бюджета по тенантам (заменяет предыдущий снимок) + /// + /// Пары (id тенанта, доля 0..1). + public static void ReplaceBudgetRatios(IEnumerable<(string TenantId, double Ratio)> ratios) + { + BudgetRatios.Clear(); + foreach ((string tenantId, double ratio) in ratios) + { + BudgetRatios[tenantId] = Math.Clamp(ratio, 0.0, 1.0); + } + } + + // Снимок долей расхода бюджета для observable-gauge (по измерению на тенант). + private static IEnumerable> ObserveBudgetRatios() + => BudgetRatios.Select(pair => new Measurement( + pair.Value, + new TagList { { TenantTagName, pair.Key } })); +} diff --git a/src/core/Deal.sln b/src/core/Deal.sln index 2a55ffd..9ff4316 100644 --- a/src/core/Deal.sln +++ b/src/core/Deal.sln @@ -1,193 +1,193 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Api", "Deal.Api\Deal.Api.csproj", "{8AF917B0-3E58-4799-AE74-CB0B57277B20}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Modules.Pipeline", "Deal.Modules.Pipeline\Deal.Modules.Pipeline.csproj", "{D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Modules.Kanban", "Deal.Modules.Kanban\Deal.Modules.Kanban.csproj", "{69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Modules.Discovery", "Deal.Modules.Discovery\Deal.Modules.Discovery.csproj", "{035AA8CB-CB05-4C28-8F82-69849F355677}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Modules.Settings", "Deal.Modules.Settings\Deal.Modules.Settings.csproj", "{C36C1F91-8E9F-492B-86A8-ED0340769666}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Modules.Tenants", "Deal.Modules.Tenants\Deal.Modules.Tenants.csproj", "{6299B1AA-7DBA-4068-B49F-18CC7D126F56}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.SharedKernel", "Deal.SharedKernel\Deal.SharedKernel.csproj", "{B6731274-85C1-4128-8BB1-42F205477854}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Infrastructure", "Deal.Infrastructure\Deal.Infrastructure.csproj", "{F903696B-3D7B-474F-9404-4D056954BAE0}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Contracts", "Deal.Contracts\Deal.Contracts.csproj", "{C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Tests.Unit", "tests\Deal.Tests.Unit\Deal.Tests.Unit.csproj", "{C01017F8-24D4-4936-95B1-BB15B7B37CC4}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Modules.Telegram", "Deal.Modules.Telegram\Deal.Modules.Telegram.csproj", "{718A67F1-4F2D-442E-BA90-5DA0401729ED}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Modules.Cards", "Deal.Modules.Cards\Deal.Modules.Cards.csproj", "{05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Debug|x64.ActiveCfg = Debug|Any CPU - {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Debug|x64.Build.0 = Debug|Any CPU - {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Debug|x86.ActiveCfg = Debug|Any CPU - {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Debug|x86.Build.0 = Debug|Any CPU - {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Release|Any CPU.Build.0 = Release|Any CPU - {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Release|x64.ActiveCfg = Release|Any CPU - {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Release|x64.Build.0 = Release|Any CPU - {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Release|x86.ActiveCfg = Release|Any CPU - {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Release|x86.Build.0 = Release|Any CPU - {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Debug|x64.ActiveCfg = Debug|Any CPU - {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Debug|x64.Build.0 = Debug|Any CPU - {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Debug|x86.ActiveCfg = Debug|Any CPU - {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Debug|x86.Build.0 = Debug|Any CPU - {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Release|Any CPU.Build.0 = Release|Any CPU - {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Release|x64.ActiveCfg = Release|Any CPU - {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Release|x64.Build.0 = Release|Any CPU - {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Release|x86.ActiveCfg = Release|Any CPU - {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Release|x86.Build.0 = Release|Any CPU - {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Debug|x64.ActiveCfg = Debug|Any CPU - {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Debug|x64.Build.0 = Debug|Any CPU - {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Debug|x86.ActiveCfg = Debug|Any CPU - {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Debug|x86.Build.0 = Debug|Any CPU - {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Release|Any CPU.Build.0 = Release|Any CPU - {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Release|x64.ActiveCfg = Release|Any CPU - {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Release|x64.Build.0 = Release|Any CPU - {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Release|x86.ActiveCfg = Release|Any CPU - {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Release|x86.Build.0 = Release|Any CPU - {035AA8CB-CB05-4C28-8F82-69849F355677}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {035AA8CB-CB05-4C28-8F82-69849F355677}.Debug|Any CPU.Build.0 = Debug|Any CPU - {035AA8CB-CB05-4C28-8F82-69849F355677}.Debug|x64.ActiveCfg = Debug|Any CPU - {035AA8CB-CB05-4C28-8F82-69849F355677}.Debug|x64.Build.0 = Debug|Any CPU - {035AA8CB-CB05-4C28-8F82-69849F355677}.Debug|x86.ActiveCfg = Debug|Any CPU - {035AA8CB-CB05-4C28-8F82-69849F355677}.Debug|x86.Build.0 = Debug|Any CPU - {035AA8CB-CB05-4C28-8F82-69849F355677}.Release|Any CPU.ActiveCfg = Release|Any CPU - {035AA8CB-CB05-4C28-8F82-69849F355677}.Release|Any CPU.Build.0 = Release|Any CPU - {035AA8CB-CB05-4C28-8F82-69849F355677}.Release|x64.ActiveCfg = Release|Any CPU - {035AA8CB-CB05-4C28-8F82-69849F355677}.Release|x64.Build.0 = Release|Any CPU - {035AA8CB-CB05-4C28-8F82-69849F355677}.Release|x86.ActiveCfg = Release|Any CPU - {035AA8CB-CB05-4C28-8F82-69849F355677}.Release|x86.Build.0 = Release|Any CPU - {C36C1F91-8E9F-492B-86A8-ED0340769666}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C36C1F91-8E9F-492B-86A8-ED0340769666}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C36C1F91-8E9F-492B-86A8-ED0340769666}.Debug|x64.ActiveCfg = Debug|Any CPU - {C36C1F91-8E9F-492B-86A8-ED0340769666}.Debug|x64.Build.0 = Debug|Any CPU - {C36C1F91-8E9F-492B-86A8-ED0340769666}.Debug|x86.ActiveCfg = Debug|Any CPU - {C36C1F91-8E9F-492B-86A8-ED0340769666}.Debug|x86.Build.0 = Debug|Any CPU - {C36C1F91-8E9F-492B-86A8-ED0340769666}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C36C1F91-8E9F-492B-86A8-ED0340769666}.Release|Any CPU.Build.0 = Release|Any CPU - {C36C1F91-8E9F-492B-86A8-ED0340769666}.Release|x64.ActiveCfg = Release|Any CPU - {C36C1F91-8E9F-492B-86A8-ED0340769666}.Release|x64.Build.0 = Release|Any CPU - {C36C1F91-8E9F-492B-86A8-ED0340769666}.Release|x86.ActiveCfg = Release|Any CPU - {C36C1F91-8E9F-492B-86A8-ED0340769666}.Release|x86.Build.0 = Release|Any CPU - {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Debug|x64.ActiveCfg = Debug|Any CPU - {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Debug|x64.Build.0 = Debug|Any CPU - {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Debug|x86.ActiveCfg = Debug|Any CPU - {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Debug|x86.Build.0 = Debug|Any CPU - {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Release|Any CPU.Build.0 = Release|Any CPU - {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Release|x64.ActiveCfg = Release|Any CPU - {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Release|x64.Build.0 = Release|Any CPU - {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Release|x86.ActiveCfg = Release|Any CPU - {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Release|x86.Build.0 = Release|Any CPU - {B6731274-85C1-4128-8BB1-42F205477854}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B6731274-85C1-4128-8BB1-42F205477854}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B6731274-85C1-4128-8BB1-42F205477854}.Debug|x64.ActiveCfg = Debug|Any CPU - {B6731274-85C1-4128-8BB1-42F205477854}.Debug|x64.Build.0 = Debug|Any CPU - {B6731274-85C1-4128-8BB1-42F205477854}.Debug|x86.ActiveCfg = Debug|Any CPU - {B6731274-85C1-4128-8BB1-42F205477854}.Debug|x86.Build.0 = Debug|Any CPU - {B6731274-85C1-4128-8BB1-42F205477854}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B6731274-85C1-4128-8BB1-42F205477854}.Release|Any CPU.Build.0 = Release|Any CPU - {B6731274-85C1-4128-8BB1-42F205477854}.Release|x64.ActiveCfg = Release|Any CPU - {B6731274-85C1-4128-8BB1-42F205477854}.Release|x64.Build.0 = Release|Any CPU - {B6731274-85C1-4128-8BB1-42F205477854}.Release|x86.ActiveCfg = Release|Any CPU - {B6731274-85C1-4128-8BB1-42F205477854}.Release|x86.Build.0 = Release|Any CPU - {F903696B-3D7B-474F-9404-4D056954BAE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F903696B-3D7B-474F-9404-4D056954BAE0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F903696B-3D7B-474F-9404-4D056954BAE0}.Debug|x64.ActiveCfg = Debug|Any CPU - {F903696B-3D7B-474F-9404-4D056954BAE0}.Debug|x64.Build.0 = Debug|Any CPU - {F903696B-3D7B-474F-9404-4D056954BAE0}.Debug|x86.ActiveCfg = Debug|Any CPU - {F903696B-3D7B-474F-9404-4D056954BAE0}.Debug|x86.Build.0 = Debug|Any CPU - {F903696B-3D7B-474F-9404-4D056954BAE0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F903696B-3D7B-474F-9404-4D056954BAE0}.Release|Any CPU.Build.0 = Release|Any CPU - {F903696B-3D7B-474F-9404-4D056954BAE0}.Release|x64.ActiveCfg = Release|Any CPU - {F903696B-3D7B-474F-9404-4D056954BAE0}.Release|x64.Build.0 = Release|Any CPU - {F903696B-3D7B-474F-9404-4D056954BAE0}.Release|x86.ActiveCfg = Release|Any CPU - {F903696B-3D7B-474F-9404-4D056954BAE0}.Release|x86.Build.0 = Release|Any CPU - {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Debug|x64.ActiveCfg = Debug|Any CPU - {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Debug|x64.Build.0 = Debug|Any CPU - {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Debug|x86.ActiveCfg = Debug|Any CPU - {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Debug|x86.Build.0 = Debug|Any CPU - {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Release|Any CPU.Build.0 = Release|Any CPU - {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Release|x64.ActiveCfg = Release|Any CPU - {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Release|x64.Build.0 = Release|Any CPU - {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Release|x86.ActiveCfg = Release|Any CPU - {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Release|x86.Build.0 = Release|Any CPU - {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Debug|x64.ActiveCfg = Debug|Any CPU - {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Debug|x64.Build.0 = Debug|Any CPU - {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Debug|x86.ActiveCfg = Debug|Any CPU - {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Debug|x86.Build.0 = Debug|Any CPU - {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Release|Any CPU.Build.0 = Release|Any CPU - {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Release|x64.ActiveCfg = Release|Any CPU - {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Release|x64.Build.0 = Release|Any CPU - {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Release|x86.ActiveCfg = Release|Any CPU - {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Release|x86.Build.0 = Release|Any CPU - {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Debug|Any CPU.Build.0 = Debug|Any CPU - {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Debug|x64.ActiveCfg = Debug|Any CPU - {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Debug|x64.Build.0 = Debug|Any CPU - {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Debug|x86.ActiveCfg = Debug|Any CPU - {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Debug|x86.Build.0 = Debug|Any CPU - {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Release|Any CPU.ActiveCfg = Release|Any CPU - {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Release|Any CPU.Build.0 = Release|Any CPU - {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Release|x64.ActiveCfg = Release|Any CPU - {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Release|x64.Build.0 = Release|Any CPU - {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Release|x86.ActiveCfg = Release|Any CPU - {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Release|x86.Build.0 = Release|Any CPU - {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Debug|Any CPU.Build.0 = Debug|Any CPU - {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Debug|x64.ActiveCfg = Debug|Any CPU - {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Debug|x64.Build.0 = Debug|Any CPU - {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Debug|x86.ActiveCfg = Debug|Any CPU - {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Debug|x86.Build.0 = Debug|Any CPU - {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Release|Any CPU.ActiveCfg = Release|Any CPU - {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Release|Any CPU.Build.0 = Release|Any CPU - {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Release|x64.ActiveCfg = Release|Any CPU - {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Release|x64.Build.0 = Release|Any CPU - {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Release|x86.ActiveCfg = Release|Any CPU - {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {C01017F8-24D4-4936-95B1-BB15B7B37CC4} = {0AB3BF05-4346-4AA6-1389-037BE0695223} - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Api", "Deal.Api\Deal.Api.csproj", "{8AF917B0-3E58-4799-AE74-CB0B57277B20}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Modules.Pipeline", "Deal.Modules.Pipeline\Deal.Modules.Pipeline.csproj", "{D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Modules.Kanban", "Deal.Modules.Kanban\Deal.Modules.Kanban.csproj", "{69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Modules.Discovery", "Deal.Modules.Discovery\Deal.Modules.Discovery.csproj", "{035AA8CB-CB05-4C28-8F82-69849F355677}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Modules.Settings", "Deal.Modules.Settings\Deal.Modules.Settings.csproj", "{C36C1F91-8E9F-492B-86A8-ED0340769666}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Modules.Tenants", "Deal.Modules.Tenants\Deal.Modules.Tenants.csproj", "{6299B1AA-7DBA-4068-B49F-18CC7D126F56}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.SharedKernel", "Deal.SharedKernel\Deal.SharedKernel.csproj", "{B6731274-85C1-4128-8BB1-42F205477854}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Infrastructure", "Deal.Infrastructure\Deal.Infrastructure.csproj", "{F903696B-3D7B-474F-9404-4D056954BAE0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Contracts", "Deal.Contracts\Deal.Contracts.csproj", "{C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Tests.Unit", "tests\Deal.Tests.Unit\Deal.Tests.Unit.csproj", "{C01017F8-24D4-4936-95B1-BB15B7B37CC4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Modules.Telegram", "Deal.Modules.Telegram\Deal.Modules.Telegram.csproj", "{718A67F1-4F2D-442E-BA90-5DA0401729ED}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Modules.Cards", "Deal.Modules.Cards\Deal.Modules.Cards.csproj", "{05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Debug|x64.ActiveCfg = Debug|Any CPU + {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Debug|x64.Build.0 = Debug|Any CPU + {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Debug|x86.ActiveCfg = Debug|Any CPU + {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Debug|x86.Build.0 = Debug|Any CPU + {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Release|Any CPU.Build.0 = Release|Any CPU + {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Release|x64.ActiveCfg = Release|Any CPU + {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Release|x64.Build.0 = Release|Any CPU + {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Release|x86.ActiveCfg = Release|Any CPU + {8AF917B0-3E58-4799-AE74-CB0B57277B20}.Release|x86.Build.0 = Release|Any CPU + {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Debug|x64.ActiveCfg = Debug|Any CPU + {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Debug|x64.Build.0 = Debug|Any CPU + {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Debug|x86.ActiveCfg = Debug|Any CPU + {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Debug|x86.Build.0 = Debug|Any CPU + {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Release|Any CPU.Build.0 = Release|Any CPU + {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Release|x64.ActiveCfg = Release|Any CPU + {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Release|x64.Build.0 = Release|Any CPU + {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Release|x86.ActiveCfg = Release|Any CPU + {D2B64987-8E0E-4CF4-AD62-7CF5B5A5B7B5}.Release|x86.Build.0 = Release|Any CPU + {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Debug|x64.ActiveCfg = Debug|Any CPU + {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Debug|x64.Build.0 = Debug|Any CPU + {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Debug|x86.ActiveCfg = Debug|Any CPU + {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Debug|x86.Build.0 = Debug|Any CPU + {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Release|Any CPU.Build.0 = Release|Any CPU + {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Release|x64.ActiveCfg = Release|Any CPU + {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Release|x64.Build.0 = Release|Any CPU + {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Release|x86.ActiveCfg = Release|Any CPU + {69A8263E-7D7F-44EA-A7F7-CA7698DDDB4C}.Release|x86.Build.0 = Release|Any CPU + {035AA8CB-CB05-4C28-8F82-69849F355677}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {035AA8CB-CB05-4C28-8F82-69849F355677}.Debug|Any CPU.Build.0 = Debug|Any CPU + {035AA8CB-CB05-4C28-8F82-69849F355677}.Debug|x64.ActiveCfg = Debug|Any CPU + {035AA8CB-CB05-4C28-8F82-69849F355677}.Debug|x64.Build.0 = Debug|Any CPU + {035AA8CB-CB05-4C28-8F82-69849F355677}.Debug|x86.ActiveCfg = Debug|Any CPU + {035AA8CB-CB05-4C28-8F82-69849F355677}.Debug|x86.Build.0 = Debug|Any CPU + {035AA8CB-CB05-4C28-8F82-69849F355677}.Release|Any CPU.ActiveCfg = Release|Any CPU + {035AA8CB-CB05-4C28-8F82-69849F355677}.Release|Any CPU.Build.0 = Release|Any CPU + {035AA8CB-CB05-4C28-8F82-69849F355677}.Release|x64.ActiveCfg = Release|Any CPU + {035AA8CB-CB05-4C28-8F82-69849F355677}.Release|x64.Build.0 = Release|Any CPU + {035AA8CB-CB05-4C28-8F82-69849F355677}.Release|x86.ActiveCfg = Release|Any CPU + {035AA8CB-CB05-4C28-8F82-69849F355677}.Release|x86.Build.0 = Release|Any CPU + {C36C1F91-8E9F-492B-86A8-ED0340769666}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C36C1F91-8E9F-492B-86A8-ED0340769666}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C36C1F91-8E9F-492B-86A8-ED0340769666}.Debug|x64.ActiveCfg = Debug|Any CPU + {C36C1F91-8E9F-492B-86A8-ED0340769666}.Debug|x64.Build.0 = Debug|Any CPU + {C36C1F91-8E9F-492B-86A8-ED0340769666}.Debug|x86.ActiveCfg = Debug|Any CPU + {C36C1F91-8E9F-492B-86A8-ED0340769666}.Debug|x86.Build.0 = Debug|Any CPU + {C36C1F91-8E9F-492B-86A8-ED0340769666}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C36C1F91-8E9F-492B-86A8-ED0340769666}.Release|Any CPU.Build.0 = Release|Any CPU + {C36C1F91-8E9F-492B-86A8-ED0340769666}.Release|x64.ActiveCfg = Release|Any CPU + {C36C1F91-8E9F-492B-86A8-ED0340769666}.Release|x64.Build.0 = Release|Any CPU + {C36C1F91-8E9F-492B-86A8-ED0340769666}.Release|x86.ActiveCfg = Release|Any CPU + {C36C1F91-8E9F-492B-86A8-ED0340769666}.Release|x86.Build.0 = Release|Any CPU + {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Debug|x64.ActiveCfg = Debug|Any CPU + {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Debug|x64.Build.0 = Debug|Any CPU + {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Debug|x86.ActiveCfg = Debug|Any CPU + {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Debug|x86.Build.0 = Debug|Any CPU + {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Release|Any CPU.Build.0 = Release|Any CPU + {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Release|x64.ActiveCfg = Release|Any CPU + {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Release|x64.Build.0 = Release|Any CPU + {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Release|x86.ActiveCfg = Release|Any CPU + {6299B1AA-7DBA-4068-B49F-18CC7D126F56}.Release|x86.Build.0 = Release|Any CPU + {B6731274-85C1-4128-8BB1-42F205477854}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B6731274-85C1-4128-8BB1-42F205477854}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B6731274-85C1-4128-8BB1-42F205477854}.Debug|x64.ActiveCfg = Debug|Any CPU + {B6731274-85C1-4128-8BB1-42F205477854}.Debug|x64.Build.0 = Debug|Any CPU + {B6731274-85C1-4128-8BB1-42F205477854}.Debug|x86.ActiveCfg = Debug|Any CPU + {B6731274-85C1-4128-8BB1-42F205477854}.Debug|x86.Build.0 = Debug|Any CPU + {B6731274-85C1-4128-8BB1-42F205477854}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B6731274-85C1-4128-8BB1-42F205477854}.Release|Any CPU.Build.0 = Release|Any CPU + {B6731274-85C1-4128-8BB1-42F205477854}.Release|x64.ActiveCfg = Release|Any CPU + {B6731274-85C1-4128-8BB1-42F205477854}.Release|x64.Build.0 = Release|Any CPU + {B6731274-85C1-4128-8BB1-42F205477854}.Release|x86.ActiveCfg = Release|Any CPU + {B6731274-85C1-4128-8BB1-42F205477854}.Release|x86.Build.0 = Release|Any CPU + {F903696B-3D7B-474F-9404-4D056954BAE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F903696B-3D7B-474F-9404-4D056954BAE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F903696B-3D7B-474F-9404-4D056954BAE0}.Debug|x64.ActiveCfg = Debug|Any CPU + {F903696B-3D7B-474F-9404-4D056954BAE0}.Debug|x64.Build.0 = Debug|Any CPU + {F903696B-3D7B-474F-9404-4D056954BAE0}.Debug|x86.ActiveCfg = Debug|Any CPU + {F903696B-3D7B-474F-9404-4D056954BAE0}.Debug|x86.Build.0 = Debug|Any CPU + {F903696B-3D7B-474F-9404-4D056954BAE0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F903696B-3D7B-474F-9404-4D056954BAE0}.Release|Any CPU.Build.0 = Release|Any CPU + {F903696B-3D7B-474F-9404-4D056954BAE0}.Release|x64.ActiveCfg = Release|Any CPU + {F903696B-3D7B-474F-9404-4D056954BAE0}.Release|x64.Build.0 = Release|Any CPU + {F903696B-3D7B-474F-9404-4D056954BAE0}.Release|x86.ActiveCfg = Release|Any CPU + {F903696B-3D7B-474F-9404-4D056954BAE0}.Release|x86.Build.0 = Release|Any CPU + {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Debug|x64.ActiveCfg = Debug|Any CPU + {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Debug|x64.Build.0 = Debug|Any CPU + {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Debug|x86.ActiveCfg = Debug|Any CPU + {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Debug|x86.Build.0 = Debug|Any CPU + {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Release|Any CPU.Build.0 = Release|Any CPU + {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Release|x64.ActiveCfg = Release|Any CPU + {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Release|x64.Build.0 = Release|Any CPU + {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Release|x86.ActiveCfg = Release|Any CPU + {C03D2FB7-CD59-4C50-98D5-5FE20403E0C4}.Release|x86.Build.0 = Release|Any CPU + {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Debug|x64.ActiveCfg = Debug|Any CPU + {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Debug|x64.Build.0 = Debug|Any CPU + {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Debug|x86.ActiveCfg = Debug|Any CPU + {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Debug|x86.Build.0 = Debug|Any CPU + {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Release|Any CPU.Build.0 = Release|Any CPU + {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Release|x64.ActiveCfg = Release|Any CPU + {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Release|x64.Build.0 = Release|Any CPU + {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Release|x86.ActiveCfg = Release|Any CPU + {C01017F8-24D4-4936-95B1-BB15B7B37CC4}.Release|x86.Build.0 = Release|Any CPU + {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Debug|x64.ActiveCfg = Debug|Any CPU + {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Debug|x64.Build.0 = Debug|Any CPU + {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Debug|x86.ActiveCfg = Debug|Any CPU + {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Debug|x86.Build.0 = Debug|Any CPU + {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Release|Any CPU.Build.0 = Release|Any CPU + {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Release|x64.ActiveCfg = Release|Any CPU + {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Release|x64.Build.0 = Release|Any CPU + {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Release|x86.ActiveCfg = Release|Any CPU + {718A67F1-4F2D-442E-BA90-5DA0401729ED}.Release|x86.Build.0 = Release|Any CPU + {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Debug|Any CPU.Build.0 = Debug|Any CPU + {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Debug|x64.ActiveCfg = Debug|Any CPU + {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Debug|x64.Build.0 = Debug|Any CPU + {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Debug|x86.ActiveCfg = Debug|Any CPU + {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Debug|x86.Build.0 = Debug|Any CPU + {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Release|Any CPU.ActiveCfg = Release|Any CPU + {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Release|Any CPU.Build.0 = Release|Any CPU + {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Release|x64.ActiveCfg = Release|Any CPU + {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Release|x64.Build.0 = Release|Any CPU + {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Release|x86.ActiveCfg = Release|Any CPU + {05FAE852-E6FD-4EFD-95C6-6D71ABB76D66}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C01017F8-24D4-4936-95B1-BB15B7B37CC4} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/src/core/Directory.Build.props b/src/core/Directory.Build.props index b2a7175..0d969a1 100644 --- a/src/core/Directory.Build.props +++ b/src/core/Directory.Build.props @@ -1,11 +1,11 @@ - - - net10.0 - latest - enable - enable - true - latest - true - - + + + net10.0 + latest + enable + enable + true + latest + true + + diff --git a/src/core/dotnet-tools.json b/src/core/dotnet-tools.json index 00c546f..6030b24 100644 --- a/src/core/dotnet-tools.json +++ b/src/core/dotnet-tools.json @@ -1,13 +1,13 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - "dotnet-ef": { - "version": "10.0.11", - "commands": [ - "dotnet-ef" - ], - "rollForward": false - } - } +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.11", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } } \ No newline at end of file diff --git a/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs index 2c24114..54eae3f 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs @@ -1,105 +1,105 @@ -using Deal.Api.Configuration; -using Deal.Api.Hosting; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Tests.Unit.Modules.Tenants; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit.Api; - -/// -/// Тесты логики прохода -/// -public sealed class DataRetentionSchedulerTests -{ - // Тенант сценария (строка лимита). - private static readonly Guid TenantId = Guid.NewGuid(); - - // Срок хранения аудита сценария (дней). - private const int RetentionDays = 180; - - [Fact] - public async Task RunCycle_PurgesAgedAuditResetsExpiredLimitsAndDeletesExpiredCounters() - { - DateTimeOffset now = DateTimeOffset.UtcNow; - var audit = new FakeAuditLogStore(); - await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None); - await audit.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), CancellationToken.None); - - var limits = new FakeTenantLimitStore(); - // Период месяца начат два месяца назад → истёк; строка с накоплениями должна обнулиться. - limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 700, warned80: true); - - var counters = new FakeRateLimitCounterStore(); - await counters.IncrementAsync("expired", now.AddHours(-2), now.AddHours(-2).AddMinutes(1), 1, CancellationToken.None); - await counters.IncrementAsync("active", now, now.AddMinutes(1), 1, CancellationToken.None); - - await using ServiceProvider provider = BuildProvider(audit, limits, counters); - DataRetentionScheduler scheduler = new( - provider.GetRequiredService(), - new DataRetentionOptions { Enabled = true, AuditRetentionDays = RetentionDays }, - NullLogger.Instance); - - await scheduler.RunCycleAsync(CancellationToken.None); - - // Аудит: старая запись удалена, свежая осталась. - var remainingAudit = Assert.Single(audit.Records); - Assert.Equal(AuditEvents.TenantLoginOk, remainingAudit.EventType); - - // Лимиты: накопления прошедшего периода сброшены. - Assert.Equal(0, limits.UsedTokens(TenantId)); - - // Счётчики: завершившееся окно удалено, активное осталось. - Assert.Equal(0, await counters.GetCountAsync("expired", now.AddHours(-2), CancellationToken.None)); - Assert.Equal(1, await counters.GetCountAsync("active", now, CancellationToken.None)); - } - - /// - /// Повторный проход на тех же данных — идемпотентен - /// - [Fact] - public async Task RunCycle_IsIdempotent() - { - DateTimeOffset now = DateTimeOffset.UtcNow; - var audit = new FakeAuditLogStore(); - await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None); - var limits = new FakeTenantLimitStore(); - limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500); - await using ServiceProvider provider = BuildProvider(audit, limits, new FakeRateLimitCounterStore()); - DataRetentionScheduler scheduler = new( - provider.GetRequiredService(), - new DataRetentionOptions { AuditRetentionDays = RetentionDays }, - NullLogger.Instance); - - await scheduler.RunCycleAsync(CancellationToken.None); - await scheduler.RunCycleAsync(CancellationToken.None); - - Assert.Empty(audit.Records); - Assert.Equal(0, limits.UsedTokens(TenantId)); - } - - // Строит DI-провайдер теста: три фейк-хранилища в scope прохода. - // audit: Фейк-хранилище аудита (записи посеяны сценарием). - // limits: Фейк-хранилище лимитов (строки посеяны сценарием). - // counters: Фейк-хранилище счётчиков (окна посеяны сценарием). - // Возвращает: Провайдер с сервисами цикла. - private static ServiceProvider BuildProvider( - FakeAuditLogStore audit, - FakeTenantLimitStore limits, - FakeRateLimitCounterStore counters) - { - var services = new ServiceCollection(); - services.AddScoped(_ => audit); - services.AddScoped(_ => limits); - services.AddScoped(_ => counters); - return services.BuildServiceProvider(); - } - - // Запись аудита сценария. - // eventType: Тип события. - // at: Момент события (UTC). - // Возвращает: DTO записи аудита. - private static AuditRecordDto AuditRecord(string eventType, DateTimeOffset at) => - new(eventType, AuditActorTypes.Operator, ActorId: null, TenantId: null, Ip: "127.0.0.1", DetailJson: null, at); -} +using Deal.Api.Configuration; +using Deal.Api.Hosting; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Tests.Unit.Modules.Tenants; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Api; + +/// +/// Тесты логики прохода +/// +public sealed class DataRetentionSchedulerTests +{ + // Тенант сценария (строка лимита). + private static readonly Guid TenantId = Guid.NewGuid(); + + // Срок хранения аудита сценария (дней). + private const int RetentionDays = 180; + + [Fact] + public async Task RunCycle_PurgesAgedAuditResetsExpiredLimitsAndDeletesExpiredCounters() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + var audit = new FakeAuditLogStore(); + await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None); + await audit.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), CancellationToken.None); + + var limits = new FakeTenantLimitStore(); + // Период месяца начат два месяца назад → истёк; строка с накоплениями должна обнулиться. + limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 700, warned80: true); + + var counters = new FakeRateLimitCounterStore(); + await counters.IncrementAsync("expired", now.AddHours(-2), now.AddHours(-2).AddMinutes(1), 1, CancellationToken.None); + await counters.IncrementAsync("active", now, now.AddMinutes(1), 1, CancellationToken.None); + + await using ServiceProvider provider = BuildProvider(audit, limits, counters); + DataRetentionScheduler scheduler = new( + provider.GetRequiredService(), + new DataRetentionOptions { Enabled = true, AuditRetentionDays = RetentionDays }, + NullLogger.Instance); + + await scheduler.RunCycleAsync(CancellationToken.None); + + // Аудит: старая запись удалена, свежая осталась. + var remainingAudit = Assert.Single(audit.Records); + Assert.Equal(AuditEvents.TenantLoginOk, remainingAudit.EventType); + + // Лимиты: накопления прошедшего периода сброшены. + Assert.Equal(0, limits.UsedTokens(TenantId)); + + // Счётчики: завершившееся окно удалено, активное осталось. + Assert.Equal(0, await counters.GetCountAsync("expired", now.AddHours(-2), CancellationToken.None)); + Assert.Equal(1, await counters.GetCountAsync("active", now, CancellationToken.None)); + } + + /// + /// Повторный проход на тех же данных — идемпотентен + /// + [Fact] + public async Task RunCycle_IsIdempotent() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + var audit = new FakeAuditLogStore(); + await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None); + var limits = new FakeTenantLimitStore(); + limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500); + await using ServiceProvider provider = BuildProvider(audit, limits, new FakeRateLimitCounterStore()); + DataRetentionScheduler scheduler = new( + provider.GetRequiredService(), + new DataRetentionOptions { AuditRetentionDays = RetentionDays }, + NullLogger.Instance); + + await scheduler.RunCycleAsync(CancellationToken.None); + await scheduler.RunCycleAsync(CancellationToken.None); + + Assert.Empty(audit.Records); + Assert.Equal(0, limits.UsedTokens(TenantId)); + } + + // Строит DI-провайдер теста: три фейк-хранилища в scope прохода. + // audit: Фейк-хранилище аудита (записи посеяны сценарием). + // limits: Фейк-хранилище лимитов (строки посеяны сценарием). + // counters: Фейк-хранилище счётчиков (окна посеяны сценарием). + // Возвращает: Провайдер с сервисами цикла. + private static ServiceProvider BuildProvider( + FakeAuditLogStore audit, + FakeTenantLimitStore limits, + FakeRateLimitCounterStore counters) + { + var services = new ServiceCollection(); + services.AddScoped(_ => audit); + services.AddScoped(_ => limits); + services.AddScoped(_ => counters); + return services.BuildServiceProvider(); + } + + // Запись аудита сценария. + // eventType: Тип события. + // at: Момент события (UTC). + // Возвращает: DTO записи аудита. + private static AuditRecordDto AuditRecord(string eventType, DateTimeOffset at) => + new(eventType, AuditActorTypes.Operator, ActorId: null, TenantId: null, Ip: "127.0.0.1", DetailJson: null, at); +} diff --git a/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs index bbfec38..81cc408 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs @@ -1,208 +1,208 @@ -using Deal.Api.Hosting; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Data; -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Registrars; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Discovery; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Tenants; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Deal.Tests.Unit.Api; - -/// -/// Тесты DiscoveryWorkerScheduler — фоновый цикл воркера Discovery -/// -public sealed class DiscoveryWorkerSchedulerTests -{ - // Тенант A теста. - private static readonly Guid TenantA = Guid.NewGuid(); - - // Тенант B теста. - private static readonly Guid TenantB = Guid.NewGuid(); - - // Контекст теста: планировщик на общих фейках + хранилища/гейты тенантов. - private sealed record Context( - DiscoveryWorkerScheduler Scheduler, - FakeDiscoveryStore StoreA, - FakeDiscoveryStore StoreB, - FakeDiscoveryGateway GatewayA, - FakeDiscoveryGateway GatewayB, - TenantContext TenantContext, - ListLogger Logs); - - [Fact] - public async Task RunCycle_TicksEveryTenantInOwnScope() - { - Context ctx = CreateContext(); - - // A: план вступлений выполнен (joined ≥ planJoins) → done. B: поиск по ключу → кандидат. - ctx.StoreA.SeedTask(Task("dt_A", joined: 2, planJoins: 2)); - ctx.StoreB.SeedTask(Task("dt_B", keywords: ["заказ B"], searchDone: false)); - ctx.GatewayB.SearchResults = [Entry("dB", "Канал B", "ch_b", DiscoveryCandidateKinds.Channel)]; - - await ctx.Scheduler.RunCycleAsync(CancellationToken.None); - - // Оба тенанта оттикали в собственных scope: A — done (терминальный), B — кандидат от поиска. - Assert.Equal(DiscoveryTaskStatuses.Done, ctx.StoreA.Tasks.Single().Status); - Assert.Contains(ctx.StoreA.Log, log => log.Event == DiscoveryLogEvents.Done); - Assert.Equal(["заказ B"], ctx.GatewayB.SearchedKeywords); - DiscoveryCandidateDto candidateB = Assert.Single(ctx.StoreB.Candidates); - Assert.Equal("dB", candidateB.DialogId); - - // Изоляция: тенант A не трогал поиск/гейт B и наоборот; логи живут в своих хранилищах. - Assert.Empty(ctx.GatewayA.SearchedKeywords); - Assert.Empty(ctx.StoreA.Candidates); - Assert.DoesNotContain(ctx.StoreA.Log, log => log.TaskId == "dt_B"); - Assert.DoesNotContain(ctx.StoreB.Log, log => log.TaskId == "dt_A"); - - // Контекст AsyncLocal не должен переживать проход (Reset в finally каждого тика). - Assert.False(ctx.TenantContext.HasTenant); - } - - [Fact] - public async Task RunCycle_NoWork_QuietNoopAndNoTenantLeak() - { - Context ctx = CreateContext(); - - await ctx.Scheduler.RunCycleAsync(CancellationToken.None); - - Assert.Empty(ctx.StoreA.Tasks); - Assert.Empty(ctx.StoreB.Tasks); - Assert.Empty(ctx.GatewayA.SearchedKeywords); - Assert.Empty(ctx.GatewayB.SearchedKeywords); - Assert.False(ctx.TenantContext.HasTenant); - } - - // ─── Контекст и хелперы ───────────────────────────────────────────────── - - // Собирает планировщик на реальных сервисах модуля Discovery и тенант-фейках (без таймера). - private static Context CreateContext() - { - var tenants = new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)); - var tenantContext = new TenantContext(); - var storeA = new FakeDiscoveryStore(); - var storeB = new FakeDiscoveryStore(); - var settingsA = new FakeSettingsStore(); - var settingsB = new FakeSettingsStore(); - var gatewayA = new FakeDiscoveryGateway(); - var gatewayB = new FakeDiscoveryGateway(); - PreloadDiscoveryEvalOff(settingsA); - PreloadDiscoveryEvalOff(settingsB); - - var services = new ServiceCollection(); - services.AddSingleton(tenantContext); - services.AddSingleton(tenants); - services.AddSingleton(new FakeMlClient()); - services.AddSingleton(new FakeAiTools()); - // Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant - // (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant). - services.AddScoped(provider => TenantOf(provider) == TenantA ? storeA : storeB); - services.AddScoped(provider => TenantOf(provider) == TenantA ? settingsA : settingsB); - services.AddScoped(provider => TenantOf(provider) == TenantA ? gatewayA : gatewayB); - // Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope. - services.AddDiscoveryModule(); - - ServiceProvider provider = services.BuildServiceProvider(); - var logs = new ListLogger(); - var scheduler = new DiscoveryWorkerScheduler( - provider.GetRequiredService(), - logs); - - return new Context(scheduler, storeA, storeB, gatewayA, gatewayB, tenantContext, logs); - } - - // ML/ИИ выключены в настройках тенанта (оценка содержания — эвристика по ключам). - private static void PreloadDiscoveryEvalOff(FakeSettingsStore settings) - { - settings.Preload(SettingsKeys.MlEnabled, "false"); - settings.Preload(SettingsKeys.AiEnabled, "false"); - } - - // Id текущего тенанта из контекста (резолвер фейков, как ConnectionStringProvider.ForTenant). - private static Guid TenantOf(IServiceProvider provider) - { - TenantId tenantId = provider.GetRequiredService().TenantId - ?? throw new InvalidOperationException("Тест: тик вне tenant-контекста (SetTenant не выполнен)"); - return Guid.Parse(tenantId.Value); - } - - // Запись реестра тенанта (как строка public.tenants). - private static TenantRecordDto Tenant(Guid id) => - new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); - - // Задача как строка DiscTasks (адаптер-форма). - private static DiscoveryTaskDto Task( - string id, - string[]? keywords = null, - bool searchDone = true, - int planJoins = 5, - int joined = 0) - { - return new DiscoveryTaskDto( - id, - "Задача", - string.Empty, - keywords ?? new[] { "заказ" }, - MinSubscribers: 0, - Lang: "ru", - Threshold: 40, - SampleSize: 10, - planJoins, - AutoJoin: false, - Status: DiscoveryTaskStatuses.Running, - SearchIdx: 0, - searchDone, - Found: 0, - Evaluated: 0, - joined, - Rejected: 0, - CreatedAt: 1000, - UpdatedAt: 1000); - } - - // Запись результата глобального поиска (proto DialogEntry). - private static TelegramDialogEntryDto Entry( - string id, - string name, - string handle, - string kind) - => new(id, name, handle, kind, "#666"); - - // Логгер-коллектор: копит сообщения планировщика (диагностика в тестах цикла). - private sealed class ListLogger : ILogger - { - /// - /// Отформатированные сообщения лога в порядке записи. - /// - public List Messages { get; } = []; - - /// - public IDisposable? BeginScope(TState state) - where TState : notnull => null; - - /// - public bool IsEnabled(LogLevel logLevel) => true; - - /// - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - Messages.Add(formatter(state, exception)); - } - } -} +using Deal.Api.Hosting; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Infrastructure.Data; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; +using Deal.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Discovery; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Modules.Tenants; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Deal.Tests.Unit.Api; + +/// +/// Тесты DiscoveryWorkerScheduler — фоновый цикл воркера Discovery +/// +public sealed class DiscoveryWorkerSchedulerTests +{ + // Тенант A теста. + private static readonly Guid TenantA = Guid.NewGuid(); + + // Тенант B теста. + private static readonly Guid TenantB = Guid.NewGuid(); + + // Контекст теста: планировщик на общих фейках + хранилища/гейты тенантов. + private sealed record Context( + DiscoveryWorkerScheduler Scheduler, + FakeDiscoveryStore StoreA, + FakeDiscoveryStore StoreB, + FakeDiscoveryGateway GatewayA, + FakeDiscoveryGateway GatewayB, + TenantContext TenantContext, + ListLogger Logs); + + [Fact] + public async Task RunCycle_TicksEveryTenantInOwnScope() + { + Context ctx = CreateContext(); + + // A: план вступлений выполнен (joined ≥ planJoins) → done. B: поиск по ключу → кандидат. + ctx.StoreA.SeedTask(Task("dt_A", joined: 2, planJoins: 2)); + ctx.StoreB.SeedTask(Task("dt_B", keywords: ["заказ B"], searchDone: false)); + ctx.GatewayB.SearchResults = [Entry("dB", "Канал B", "ch_b", DiscoveryCandidateKinds.Channel)]; + + await ctx.Scheduler.RunCycleAsync(CancellationToken.None); + + // Оба тенанта оттикали в собственных scope: A — done (терминальный), B — кандидат от поиска. + Assert.Equal(DiscoveryTaskStatuses.Done, ctx.StoreA.Tasks.Single().Status); + Assert.Contains(ctx.StoreA.Log, log => log.Event == DiscoveryLogEvents.Done); + Assert.Equal(["заказ B"], ctx.GatewayB.SearchedKeywords); + DiscoveryCandidateDto candidateB = Assert.Single(ctx.StoreB.Candidates); + Assert.Equal("dB", candidateB.DialogId); + + // Изоляция: тенант A не трогал поиск/гейт B и наоборот; логи живут в своих хранилищах. + Assert.Empty(ctx.GatewayA.SearchedKeywords); + Assert.Empty(ctx.StoreA.Candidates); + Assert.DoesNotContain(ctx.StoreA.Log, log => log.TaskId == "dt_B"); + Assert.DoesNotContain(ctx.StoreB.Log, log => log.TaskId == "dt_A"); + + // Контекст AsyncLocal не должен переживать проход (Reset в finally каждого тика). + Assert.False(ctx.TenantContext.HasTenant); + } + + [Fact] + public async Task RunCycle_NoWork_QuietNoopAndNoTenantLeak() + { + Context ctx = CreateContext(); + + await ctx.Scheduler.RunCycleAsync(CancellationToken.None); + + Assert.Empty(ctx.StoreA.Tasks); + Assert.Empty(ctx.StoreB.Tasks); + Assert.Empty(ctx.GatewayA.SearchedKeywords); + Assert.Empty(ctx.GatewayB.SearchedKeywords); + Assert.False(ctx.TenantContext.HasTenant); + } + + // ─── Контекст и хелперы ───────────────────────────────────────────────── + + // Собирает планировщик на реальных сервисах модуля Discovery и тенант-фейках (без таймера). + private static Context CreateContext() + { + var tenants = new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)); + var tenantContext = new TenantContext(); + var storeA = new FakeDiscoveryStore(); + var storeB = new FakeDiscoveryStore(); + var settingsA = new FakeSettingsStore(); + var settingsB = new FakeSettingsStore(); + var gatewayA = new FakeDiscoveryGateway(); + var gatewayB = new FakeDiscoveryGateway(); + PreloadDiscoveryEvalOff(settingsA); + PreloadDiscoveryEvalOff(settingsB); + + var services = new ServiceCollection(); + services.AddSingleton(tenantContext); + services.AddSingleton(tenants); + services.AddSingleton(new FakeMlClient()); + services.AddSingleton(new FakeAiTools()); + // Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant + // (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant). + services.AddScoped(provider => TenantOf(provider) == TenantA ? storeA : storeB); + services.AddScoped(provider => TenantOf(provider) == TenantA ? settingsA : settingsB); + services.AddScoped(provider => TenantOf(provider) == TenantA ? gatewayA : gatewayB); + // Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope. + services.AddDiscoveryModule(); + + ServiceProvider provider = services.BuildServiceProvider(); + var logs = new ListLogger(); + var scheduler = new DiscoveryWorkerScheduler( + provider.GetRequiredService(), + logs); + + return new Context(scheduler, storeA, storeB, gatewayA, gatewayB, tenantContext, logs); + } + + // ML/ИИ выключены в настройках тенанта (оценка содержания — эвристика по ключам). + private static void PreloadDiscoveryEvalOff(FakeSettingsStore settings) + { + settings.Preload(SettingsKeys.MlEnabled, "false"); + settings.Preload(SettingsKeys.AiEnabled, "false"); + } + + // Id текущего тенанта из контекста (резолвер фейков, как ConnectionStringProvider.ForTenant). + private static Guid TenantOf(IServiceProvider provider) + { + TenantId tenantId = provider.GetRequiredService().TenantId + ?? throw new InvalidOperationException("Тест: тик вне tenant-контекста (SetTenant не выполнен)"); + return Guid.Parse(tenantId.Value); + } + + // Запись реестра тенанта (как строка public.tenants). + private static TenantRecordDto Tenant(Guid id) => + new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); + + // Задача как строка DiscTasks (адаптер-форма). + private static DiscoveryTaskDto Task( + string id, + string[]? keywords = null, + bool searchDone = true, + int planJoins = 5, + int joined = 0) + { + return new DiscoveryTaskDto( + id, + "Задача", + string.Empty, + keywords ?? new[] { "заказ" }, + MinSubscribers: 0, + Lang: "ru", + Threshold: 40, + SampleSize: 10, + planJoins, + AutoJoin: false, + Status: DiscoveryTaskStatuses.Running, + SearchIdx: 0, + searchDone, + Found: 0, + Evaluated: 0, + joined, + Rejected: 0, + CreatedAt: 1000, + UpdatedAt: 1000); + } + + // Запись результата глобального поиска (proto DialogEntry). + private static TelegramDialogEntryDto Entry( + string id, + string name, + string handle, + string kind) + => new(id, name, handle, kind, "#666"); + + // Логгер-коллектор: копит сообщения планировщика (диагностика в тестах цикла). + private sealed class ListLogger : ILogger + { + /// + /// Отформатированные сообщения лога в порядке записи. + /// + public List Messages { get; } = []; + + /// + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + /// + public bool IsEnabled(LogLevel logLevel) => true; + + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Messages.Add(formatter(state, exception)); + } + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Api/HttpAccessLogMiddlewareTests.cs b/src/core/tests/Deal.Tests.Unit/Api/HttpAccessLogMiddlewareTests.cs index b4b8558..d8d6fdc 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/HttpAccessLogMiddlewareTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/HttpAccessLogMiddlewareTests.cs @@ -1,161 +1,161 @@ -using Deal.Api.Extensions; -using Deal.Api.Middleware; -using Deal.Api.Models; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; - -namespace Deal.Tests.Unit.Api; - -/// -/// Юнит-тесты -/// -public sealed class HttpAccessLogMiddlewareTests -{ - private static readonly Guid TenantId = Guid.Parse("11112222-3333-4444-5555-666677778888"); - - [Fact] - public async Task InvokeAsync_UserInItems_LogsActorLoginAndTenantId() - { - RecordingLogger logger = new(); - var context = new DefaultHttpContext(); - context.SetCurrentUser(new CurrentUser(Guid.NewGuid(), "user.login", TenantId, "active")); - HttpAccessLogMiddleware middleware = new(_ => Task.CompletedTask, logger); - - await middleware.InvokeAsync(context); - - LogEntry entry = Assert.Single(logger.Entries); - Assert.Equal(LogLevel.Information, entry.Level); - Assert.Contains($"actor=user.login tenant={TenantId:N}", entry.Message); - } - - [Fact] - public async Task InvokeAsync_OperatorInItems_LogsActorLoginAndDashTenant() - { - RecordingLogger logger = new(); - var context = new DefaultHttpContext(); - context.SetCurrentOperator(new CurrentOperator(Guid.NewGuid(), "operator.login", "active")); - HttpAccessLogMiddleware middleware = new(_ => Task.CompletedTask, logger); - - await middleware.InvokeAsync(context); - - LogEntry entry = Assert.Single(logger.Entries); - Assert.Equal(LogLevel.Information, entry.Level); - Assert.Contains("actor=operator.login tenant=-", entry.Message); - } - - [Fact] - public async Task InvokeAsync_NoSession_LogsDashActorAndTenant() - { - RecordingLogger logger = new(); - var context = new DefaultHttpContext(); - HttpAccessLogMiddleware middleware = new(_ => Task.CompletedTask, logger); - - await middleware.InvokeAsync(context); - - LogEntry entry = Assert.Single(logger.Entries); - Assert.Equal(LogLevel.Information, entry.Level); - Assert.Contains("actor=- tenant=-", entry.Message); - } - - [Fact] - public async Task InvokeAsync_ResponseStatusCode_IsLogged() - { - RecordingLogger logger = new(); - var context = new DefaultHttpContext(); - HttpAccessLogMiddleware middleware = new( - ctx => - { - ctx.Response.StatusCode = StatusCodes.Status204NoContent; - return Task.CompletedTask; - }, - logger); - - await middleware.InvokeAsync(context); - - LogEntry entry = Assert.Single(logger.Entries); - Assert.Contains("-> 204", entry.Message); - } - - [Fact] - public async Task InvokeAsync_NextThrows_LogsErrorWithActorAndRethrows() - { - RecordingLogger logger = new(); - var context = new DefaultHttpContext(); - context.SetCurrentUser(new CurrentUser(Guid.NewGuid(), "user.login", TenantId, "active")); - InvalidOperationException failure = new("ошибка конвейера"); - HttpAccessLogMiddleware middleware = new(_ => throw failure, logger); - - InvalidOperationException thrown = await Assert.ThrowsAsync( - () => middleware.InvokeAsync(context)); - - Assert.Same(failure, thrown); - LogEntry entry = Assert.Single(logger.Entries); - Assert.Equal(LogLevel.Error, entry.Level); - Assert.Same(failure, entry.Exception); - Assert.Contains($"actor=user.login tenant={TenantId:N}", entry.Message); - } - - [Fact] - public async Task InvokeAsync_GrpcContentType_SkipsLogging() - { - RecordingLogger logger = new(); - var context = new DefaultHttpContext(); - context.Request.ContentType = "application/grpc"; - bool nextCalled = false; - HttpAccessLogMiddleware middleware = new( - _ => - { - nextCalled = true; - return Task.CompletedTask; - }, - logger); - - await middleware.InvokeAsync(context); - - Assert.True(nextCalled); - Assert.Empty(logger.Entries); - } - - // In-memory логгер: копит записи access-лога с уровнем, сообщением, исключением и полями. - private sealed class RecordingLogger : ILogger - { - /// - /// Записи лога в порядке поступления. - /// - public List Entries { get; } = []; - - /// - public IDisposable? BeginScope(TState state) - where TState : notnull => null; - - /// - public bool IsEnabled(LogLevel logLevel) => true; - - /// - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - Dictionary fields = new(StringComparer.Ordinal); - if (state is IEnumerable> pairs) - { - foreach ((string key, object? value) in pairs) - { - fields[key] = value; - } - } - - Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception, fields)); - } - } - - // Запись access-лога, снятая in-memory логгером. - private sealed record LogEntry( - LogLevel Level, - string Message, - Exception? Exception, - IReadOnlyDictionary Fields); -} +using Deal.Api.Extensions; +using Deal.Api.Middleware; +using Deal.Api.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Deal.Tests.Unit.Api; + +/// +/// Юнит-тесты +/// +public sealed class HttpAccessLogMiddlewareTests +{ + private static readonly Guid TenantId = Guid.Parse("11112222-3333-4444-5555-666677778888"); + + [Fact] + public async Task InvokeAsync_UserInItems_LogsActorLoginAndTenantId() + { + RecordingLogger logger = new(); + var context = new DefaultHttpContext(); + context.SetCurrentUser(new CurrentUser(Guid.NewGuid(), "user.login", TenantId, "active")); + HttpAccessLogMiddleware middleware = new(_ => Task.CompletedTask, logger); + + await middleware.InvokeAsync(context); + + LogEntry entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Information, entry.Level); + Assert.Contains($"actor=user.login tenant={TenantId:N}", entry.Message); + } + + [Fact] + public async Task InvokeAsync_OperatorInItems_LogsActorLoginAndDashTenant() + { + RecordingLogger logger = new(); + var context = new DefaultHttpContext(); + context.SetCurrentOperator(new CurrentOperator(Guid.NewGuid(), "operator.login", "active")); + HttpAccessLogMiddleware middleware = new(_ => Task.CompletedTask, logger); + + await middleware.InvokeAsync(context); + + LogEntry entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Information, entry.Level); + Assert.Contains("actor=operator.login tenant=-", entry.Message); + } + + [Fact] + public async Task InvokeAsync_NoSession_LogsDashActorAndTenant() + { + RecordingLogger logger = new(); + var context = new DefaultHttpContext(); + HttpAccessLogMiddleware middleware = new(_ => Task.CompletedTask, logger); + + await middleware.InvokeAsync(context); + + LogEntry entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Information, entry.Level); + Assert.Contains("actor=- tenant=-", entry.Message); + } + + [Fact] + public async Task InvokeAsync_ResponseStatusCode_IsLogged() + { + RecordingLogger logger = new(); + var context = new DefaultHttpContext(); + HttpAccessLogMiddleware middleware = new( + ctx => + { + ctx.Response.StatusCode = StatusCodes.Status204NoContent; + return Task.CompletedTask; + }, + logger); + + await middleware.InvokeAsync(context); + + LogEntry entry = Assert.Single(logger.Entries); + Assert.Contains("-> 204", entry.Message); + } + + [Fact] + public async Task InvokeAsync_NextThrows_LogsErrorWithActorAndRethrows() + { + RecordingLogger logger = new(); + var context = new DefaultHttpContext(); + context.SetCurrentUser(new CurrentUser(Guid.NewGuid(), "user.login", TenantId, "active")); + InvalidOperationException failure = new("ошибка конвейера"); + HttpAccessLogMiddleware middleware = new(_ => throw failure, logger); + + InvalidOperationException thrown = await Assert.ThrowsAsync( + () => middleware.InvokeAsync(context)); + + Assert.Same(failure, thrown); + LogEntry entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Error, entry.Level); + Assert.Same(failure, entry.Exception); + Assert.Contains($"actor=user.login tenant={TenantId:N}", entry.Message); + } + + [Fact] + public async Task InvokeAsync_GrpcContentType_SkipsLogging() + { + RecordingLogger logger = new(); + var context = new DefaultHttpContext(); + context.Request.ContentType = "application/grpc"; + bool nextCalled = false; + HttpAccessLogMiddleware middleware = new( + _ => + { + nextCalled = true; + return Task.CompletedTask; + }, + logger); + + await middleware.InvokeAsync(context); + + Assert.True(nextCalled); + Assert.Empty(logger.Entries); + } + + // In-memory логгер: копит записи access-лога с уровнем, сообщением, исключением и полями. + private sealed class RecordingLogger : ILogger + { + /// + /// Записи лога в порядке поступления. + /// + public List Entries { get; } = []; + + /// + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + /// + public bool IsEnabled(LogLevel logLevel) => true; + + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Dictionary fields = new(StringComparer.Ordinal); + if (state is IEnumerable> pairs) + { + foreach ((string key, object? value) in pairs) + { + fields[key] = value; + } + } + + Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception, fields)); + } + } + + // Запись access-лога, снятая in-memory логгером. + private sealed record LogEntry( + LogLevel Level, + string Message, + Exception? Exception, + IReadOnlyDictionary Fields); +} diff --git a/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs b/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs index 900ed26..f234ab4 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs @@ -1,158 +1,158 @@ -using Deal.Api.Configuration; -using Deal.Api.Middleware; -using Deal.Grpc.Sources; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Tenants; -using Deal.Tests.Unit.Support; -using Grpc.Core; -using Grpc.Health.V1; -using Grpc.Net.Client; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Tests.Unit.Api; - -/// -/// In-proc gRPC-тесты IngressRateLimitInterceptor -/// -public sealed class IngressRateLimitInterceptorTests -{ - // Токен сценариев теста. - private const string ValidToken = TelegramIngressTestHost.DefaultToken; - - // Тенант A сценариев (в реестре). - private static readonly Guid TenantA = Guid.NewGuid(); - - // Тенант B сценариев (в реестре) — проверка изоляции окон. - private static readonly Guid TenantB = Guid.NewGuid(); - - // Id диалога-оригинала сценариев PushSource. - private const string DialogId = "d_channel_rate_limit"; - - // Окно сценария по умолчанию: 2/мин — быстрый тест без ожидания реального окна. - private const int DefaultPermitsPerMinute = 2; - - // Окно health-сценария: 1/мин — единственный вызов исчерпывает лимит. - private const int HealthScenarioPermitsPerMinute = 1; - - // ─── Окно интерцептора ───────────────────────────────────────────────── - - /// - /// 3-й вызов тенанта в минуту - /// - [Fact] - public async Task PushSource_ExceedingTenantWindow_ThirdRejectedOtherTenantPasses() - { - var registry = new FakeTenantRegistry(Tenant(TenantA), Tenant(TenantB)); - - await RunAsync( - registry, - services => services.AddScoped(_ => new FakePipelineStore()), - async channel => - { - PushSourceReply first = await PushAsync(channel, TenantA); - PushSourceReply second = await PushAsync(channel, TenantA); - Assert.True(first.Accepted); - Assert.True(second.Accepted); - - RpcException rejection = await Assert.ThrowsAsync(() => PushAsync(channel, TenantA)); - Assert.Equal(StatusCode.ResourceExhausted, rejection.StatusCode); - Assert.Equal(RateLimitPolicies.RejectedDetail, rejection.Status.Detail); - - // Окно TenantB — отдельная партиция: вызов проходит. - PushSourceReply tenantB = await PushAsync(channel, TenantB); - Assert.True(tenantB.Accepted); - }); - } - - /// - /// Health (grpc.health.v1) освобождён от лимита - /// - [Fact] - public async Task HealthCheck_IsNotRateLimited_WhenIngressWindowExhausted() - { - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync( - registry, - services => services.AddScoped(_ => new FakePipelineStore()), - async channel => - { - // Окно ингресса — 1/мин: единственный разрешённый вызов исчерпывает лимит. - PushSourceReply push = await PushAsync(channel, TenantA); - Assert.True(push.Accepted); - - var healthClient = new Health.HealthClient(channel); - HealthCheckResponse health = await healthClient.CheckAsync( - new HealthCheckRequest { Service = string.Empty }, - deadline: Deadline()); - Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, health.Status); - - // Лимит ингресса по-прежнему исчерпан (health окно не тронул). - RpcException rejection = await Assert.ThrowsAsync(() => PushAsync(channel, TenantA)); - Assert.Equal(StatusCode.ResourceExhausted, rejection.StatusCode); - }, - permitsPerMinute: HealthScenarioPermitsPerMinute); - } - - // ─── Контекст и хелперы ───────────────────────────────────────────────── - - // Запись реестра тенанта (как строка public.tenants). - // id: Идентификатор тенанта. - private static TenantRecordDto Tenant(Guid id) => - new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); - - // Поднимает хост ингресса с включённым лимитом (окно permitsPerMinute на tenant-id). - // registry: Реестр тенантов сценария. - // registerTenantServices: Дополнительные tenant-scoped адаптеры сценария (IPipelineStore). - // scenario: Сценарий с gRPC-каналом. - // permitsPerMinute: Окно сценария (GrpcIngressPerMinute опций хоста). - private static Task RunAsync( - FakeTenantRegistry registry, - Action registerTenantServices, - Func scenario, - int permitsPerMinute = DefaultPermitsPerMinute) - => TelegramIngressTestHost.RunAsync( - ValidToken, - services => - { - services.AddSingleton(registry); - registerTenantServices(services); - }, - scenario, - rateLimitOptions: new RateLimitOptions - { - Enabled = true, - GrpcIngressPerMinute = permitsPerMinute, - }); - - // Вызывает PushSource с metadata сценария (service-token + tenant-id, deadline 10 с). - // channel: Канал к хосту. - // tenantId: Id тенанта в metadata. - private static Task PushAsync(GrpcChannel channel, Guid tenantId) - { - var client = new SourceIngressService.SourceIngressServiceClient(channel); - var request = new PushSourceRequest - { - Source = new SourceRefProto - { - Kind = "telegram", - OriginRef = DialogId, - ExternalId = "1", - DisplayName = "Канал", - }, - Content = new SourceContentProto { Text = "Сообщение сценария лимита" }, - }; - request.Source.Extra["hue"] = "#a33"; - AsyncUnaryCall call = client.PushSourceAsync( - request, - new CallOptions(TelegramIngressTestHost.CallMetadata(ValidToken, tenantId), deadline: Deadline())); - return call.ResponseAsync; - } - - // Deadline вызовов теста (контракт ингресса — 10 с). - private static DateTime Deadline() - => DateTime.UtcNow.AddSeconds(TelegramIngressTestHost.RpcDeadlineSeconds); -} +using Deal.Api.Configuration; +using Deal.Api.Middleware; +using Deal.Grpc.Sources; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Tests.Unit.Modules.Kanban; +using Deal.Tests.Unit.Modules.Tenants; +using Deal.Tests.Unit.Support; +using Grpc.Core; +using Grpc.Health.V1; +using Grpc.Net.Client; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Tests.Unit.Api; + +/// +/// In-proc gRPC-тесты IngressRateLimitInterceptor +/// +public sealed class IngressRateLimitInterceptorTests +{ + // Токен сценариев теста. + private const string ValidToken = TelegramIngressTestHost.DefaultToken; + + // Тенант A сценариев (в реестре). + private static readonly Guid TenantA = Guid.NewGuid(); + + // Тенант B сценариев (в реестре) — проверка изоляции окон. + private static readonly Guid TenantB = Guid.NewGuid(); + + // Id диалога-оригинала сценариев PushSource. + private const string DialogId = "d_channel_rate_limit"; + + // Окно сценария по умолчанию: 2/мин — быстрый тест без ожидания реального окна. + private const int DefaultPermitsPerMinute = 2; + + // Окно health-сценария: 1/мин — единственный вызов исчерпывает лимит. + private const int HealthScenarioPermitsPerMinute = 1; + + // ─── Окно интерцептора ───────────────────────────────────────────────── + + /// + /// 3-й вызов тенанта в минуту + /// + [Fact] + public async Task PushSource_ExceedingTenantWindow_ThirdRejectedOtherTenantPasses() + { + var registry = new FakeTenantRegistry(Tenant(TenantA), Tenant(TenantB)); + + await RunAsync( + registry, + services => services.AddScoped(_ => new FakePipelineStore()), + async channel => + { + PushSourceReply first = await PushAsync(channel, TenantA); + PushSourceReply second = await PushAsync(channel, TenantA); + Assert.True(first.Accepted); + Assert.True(second.Accepted); + + RpcException rejection = await Assert.ThrowsAsync(() => PushAsync(channel, TenantA)); + Assert.Equal(StatusCode.ResourceExhausted, rejection.StatusCode); + Assert.Equal(RateLimitPolicies.RejectedDetail, rejection.Status.Detail); + + // Окно TenantB — отдельная партиция: вызов проходит. + PushSourceReply tenantB = await PushAsync(channel, TenantB); + Assert.True(tenantB.Accepted); + }); + } + + /// + /// Health (grpc.health.v1) освобождён от лимита + /// + [Fact] + public async Task HealthCheck_IsNotRateLimited_WhenIngressWindowExhausted() + { + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync( + registry, + services => services.AddScoped(_ => new FakePipelineStore()), + async channel => + { + // Окно ингресса — 1/мин: единственный разрешённый вызов исчерпывает лимит. + PushSourceReply push = await PushAsync(channel, TenantA); + Assert.True(push.Accepted); + + var healthClient = new Health.HealthClient(channel); + HealthCheckResponse health = await healthClient.CheckAsync( + new HealthCheckRequest { Service = string.Empty }, + deadline: Deadline()); + Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, health.Status); + + // Лимит ингресса по-прежнему исчерпан (health окно не тронул). + RpcException rejection = await Assert.ThrowsAsync(() => PushAsync(channel, TenantA)); + Assert.Equal(StatusCode.ResourceExhausted, rejection.StatusCode); + }, + permitsPerMinute: HealthScenarioPermitsPerMinute); + } + + // ─── Контекст и хелперы ───────────────────────────────────────────────── + + // Запись реестра тенанта (как строка public.tenants). + // id: Идентификатор тенанта. + private static TenantRecordDto Tenant(Guid id) => + new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); + + // Поднимает хост ингресса с включённым лимитом (окно permitsPerMinute на tenant-id). + // registry: Реестр тенантов сценария. + // registerTenantServices: Дополнительные tenant-scoped адаптеры сценария (IPipelineStore). + // scenario: Сценарий с gRPC-каналом. + // permitsPerMinute: Окно сценария (GrpcIngressPerMinute опций хоста). + private static Task RunAsync( + FakeTenantRegistry registry, + Action registerTenantServices, + Func scenario, + int permitsPerMinute = DefaultPermitsPerMinute) + => TelegramIngressTestHost.RunAsync( + ValidToken, + services => + { + services.AddSingleton(registry); + registerTenantServices(services); + }, + scenario, + rateLimitOptions: new RateLimitOptions + { + Enabled = true, + GrpcIngressPerMinute = permitsPerMinute, + }); + + // Вызывает PushSource с metadata сценария (service-token + tenant-id, deadline 10 с). + // channel: Канал к хосту. + // tenantId: Id тенанта в metadata. + private static Task PushAsync(GrpcChannel channel, Guid tenantId) + { + var client = new SourceIngressService.SourceIngressServiceClient(channel); + var request = new PushSourceRequest + { + Source = new SourceRefProto + { + Kind = "telegram", + OriginRef = DialogId, + ExternalId = "1", + DisplayName = "Канал", + }, + Content = new SourceContentProto { Text = "Сообщение сценария лимита" }, + }; + request.Source.Extra["hue"] = "#a33"; + AsyncUnaryCall call = client.PushSourceAsync( + request, + new CallOptions(TelegramIngressTestHost.CallMetadata(ValidToken, tenantId), deadline: Deadline())); + return call.ResponseAsync; + } + + // Deadline вызовов теста (контракт ингресса — 10 с). + private static DateTime Deadline() + => DateTime.UtcNow.AddSeconds(TelegramIngressTestHost.RpcDeadlineSeconds); +} diff --git a/src/core/tests/Deal.Tests.Unit/Api/InlineProgressTests.cs b/src/core/tests/Deal.Tests.Unit/Api/InlineProgressTests.cs index 7fe0fe1..ae71593 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/InlineProgressTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/InlineProgressTests.cs @@ -1,29 +1,29 @@ -using Deal.Api.Services; -using Deal.Modules.Pipeline.Application.Models; - -namespace Deal.Tests.Unit.Api; - -/// -/// Тесты InlineProgress — синхронный IProgress. -/// -public sealed class InlineProgressTests -{ - [Fact] - public void Report_InvokesHandlerSynchronouslyWithValue() - { - int callerThread = Environment.CurrentManagedThreadId; - int handlerThread = -1; - ReclassifyProgressDto? seen = null; - var progress = new InlineProgress(value => - { - handlerThread = Environment.CurrentManagedThreadId; - seen = value; - }); - var expected = new ReclassifyProgressDto(5, 12, 3, 2, 0, 0); - - progress.Report(expected); - - Assert.Equal(expected, seen); - Assert.Equal(callerThread, handlerThread); - } -} +using Deal.Api.Services; +using Deal.Modules.Pipeline.Application.Models; + +namespace Deal.Tests.Unit.Api; + +/// +/// Тесты InlineProgress — синхронный IProgress. +/// +public sealed class InlineProgressTests +{ + [Fact] + public void Report_InvokesHandlerSynchronouslyWithValue() + { + int callerThread = Environment.CurrentManagedThreadId; + int handlerThread = -1; + ReclassifyProgressDto? seen = null; + var progress = new InlineProgress(value => + { + handlerThread = Environment.CurrentManagedThreadId; + seen = value; + }); + var expected = new ReclassifyProgressDto(5, 12, 3, 2, 0, 0); + + progress.Report(expected); + + Assert.Equal(expected, seen); + Assert.Equal(callerThread, handlerThread); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Api/LoginAttemptGuardTests.cs b/src/core/tests/Deal.Tests.Unit/Api/LoginAttemptGuardTests.cs index 2c83f0e..d8ef381 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/LoginAttemptGuardTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/LoginAttemptGuardTests.cs @@ -1,204 +1,204 @@ -using Deal.Api.Configuration; -using Deal.Api.Services; -using Deal.Tests.Unit.Modules.Tenants; - -namespace Deal.Tests.Unit.Api; - -/// -/// Unit-тесты LoginAttemptGuard -/// -public sealed class LoginAttemptGuardTests -{ - // IP клиента сценариев. - private const string Ip = "203.0.113.10"; - - // IP другого клиента (проверка изоляции ключей). - private const string OtherIp = "198.51.100.20"; - - // Логин сценариев (нормализованный, как в эндпоинтах). - private const string Login = "user@example.com"; - - // Другой логин (проверка изоляции ключей). - private const string OtherLogin = "another@example.com"; - - // ─── Порог блокировки ────────────────────────────────────────────────── - - /// - /// 5 неудач подряд в окне — следующий вход ключа блокирован. - /// - [Fact] - public async Task FiveFailuresWithinWindow_BlockNextAttempt() - { - DateTimeOffset now = DateTimeOffset.UtcNow; - var guard = NewGuard(() => now); - - for (int attempt = 0; attempt < 5; attempt++) - { - await guard.RecordFailureAsync(Ip, Login, CancellationToken.None); - } - - Assert.True(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); - } - - /// - /// 4 неудачи — порог не достигнут - /// - [Fact] - public async Task FourFailuresWithinWindow_AreNotBlocked() - { - DateTimeOffset now = DateTimeOffset.UtcNow; - var guard = NewGuard(() => now); - - for (int attempt = 0; attempt < 4; attempt++) - { - await guard.RecordFailureAsync(Ip, Login, CancellationToken.None); - } - - Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); - } - - - /// - /// Успешный вход сбрасывает счётчик - /// - [Fact] - public async Task SuccessReset_ClearsFailures_AndWindowStartsAnew() - { - DateTimeOffset now = DateTimeOffset.UtcNow; - var guard = NewGuard(() => now); - - for (int attempt = 0; attempt < 3; attempt++) - { - await guard.RecordFailureAsync(Ip, Login, CancellationToken.None); - } - - Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); - - await guard.ResetAsync(Ip, Login, CancellationToken.None); - - Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); - for (int attempt = 0; attempt < 5; attempt++) - { - await guard.RecordFailureAsync(Ip, Login, CancellationToken.None); - } - - // Сброс реально очистил счётчик: до блока — снова полные 5 неудач, а не 2 (3 старых + 2 новых). - Assert.True(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); - } - - /// - /// Уже заблокированный ключ разблокируется успешным входом - /// - [Fact] - public async Task SuccessReset_UnblocksBlockedKey() - { - DateTimeOffset now = DateTimeOffset.UtcNow; - var guard = NewGuard(() => now); - - for (int attempt = 0; attempt < 5; attempt++) - { - await guard.RecordFailureAsync(Ip, Login, CancellationToken.None); - } - - Assert.True(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); - - await guard.ResetAsync(Ip, Login, CancellationToken.None); - - Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); - Assert.False(await guard.IsBlockedAsync(Ip, OtherLogin, CancellationToken.None)); - } - - // ─── Истечение окна ──────────────────────────────────────────────────── - - /// - /// После завершения фиксированного окна блокировка снимается и отсчёт начинается заново. - /// - [Fact] - public async Task WindowExpiry_UnblocksKeyAndStartsFreshWindow() - { - DateTimeOffset now = DateTimeOffset.UtcNow; - var guard = NewGuard(() => now); - - for (int attempt = 0; attempt < 5; attempt++) - { - await guard.RecordFailureAsync(Ip, Login, CancellationToken.None); - } - - Assert.True(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); - - // Сдвиг больше длины окна (15 мин) гарантированно переводит в новое фиксированное окно. - now = now.AddMinutes(16); - Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); - - // Новая неудача в новом окне не восстанавливает старый счётчик. - await guard.RecordFailureAsync(Ip, Login, CancellationToken.None); - Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); - } - - // ─── Изоляция ключей ─────────────────────────────────────────────────── - - /// - /// Блокировка ключа ip|login не затрагивает другие IP и другие логины. - /// - [Fact] - public async Task BlockedKey_DoesNotAffectOtherIpsOrLogins() - { - DateTimeOffset now = DateTimeOffset.UtcNow; - var guard = NewGuard(() => now); - - for (int attempt = 0; attempt < 5; attempt++) - { - await guard.RecordFailureAsync(Ip, Login, CancellationToken.None); - } - - Assert.True(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); - Assert.False(await guard.IsBlockedAsync(Ip, OtherLogin, CancellationToken.None)); - Assert.False(await guard.IsBlockedAsync(OtherIp, Login, CancellationToken.None)); - Assert.False(await guard.IsBlockedAsync(OtherIp, OtherLogin, CancellationToken.None)); - } - - // ─── Флаг Enabled (dev/тесты) ────────────────────────────────────────── - - /// - /// RateLimit:Enabled=false - /// - [Fact] - public async Task DisabledByDefaultInDev_DoesNotCountOrBlock() - { - var guard = new LoginAttemptGuard(new RateLimitOptions(), new FakeRateLimitCounterStore()); - - for (int attempt = 0; attempt < 10; attempt++) - { - await guard.RecordFailureAsync(Ip, Login, CancellationToken.None); - } - - Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); - } - - /// - /// Пустой/пробельный логин ключа не имеет — блокировке не подлежит - /// - [Fact] - public async Task EmptyOrWhitespaceLogin_IsNeverBlocked() - { - DateTimeOffset now = DateTimeOffset.UtcNow; - var guard = NewGuard(() => now); - - for (int attempt = 0; attempt < 10; attempt++) - { - await guard.RecordFailureAsync(Ip, string.Empty, CancellationToken.None); - } - - Assert.False(await guard.IsBlockedAsync(Ip, string.Empty, CancellationToken.None)); - Assert.False(await guard.IsBlockedAsync(Ip, " ", CancellationToken.None)); - } - - // Создаёт включённый гвард (Enabled=true, дефолты 5/15) с фейк-хранилищем и инъекцией часов. - // clock: Источник «сейчас» (лямбда захватывает mutable-локальную теста — сдвиг окна виден гварду). - // Возвращает: Гвард с инъекцией часов. - private static LoginAttemptGuard NewGuard(Func clock) => - new( - new RateLimitOptions { Enabled = true }, - new FakeRateLimitCounterStore(), - clock); -} +using Deal.Api.Configuration; +using Deal.Api.Services; +using Deal.Tests.Unit.Modules.Tenants; + +namespace Deal.Tests.Unit.Api; + +/// +/// Unit-тесты LoginAttemptGuard +/// +public sealed class LoginAttemptGuardTests +{ + // IP клиента сценариев. + private const string Ip = "203.0.113.10"; + + // IP другого клиента (проверка изоляции ключей). + private const string OtherIp = "198.51.100.20"; + + // Логин сценариев (нормализованный, как в эндпоинтах). + private const string Login = "user@example.com"; + + // Другой логин (проверка изоляции ключей). + private const string OtherLogin = "another@example.com"; + + // ─── Порог блокировки ────────────────────────────────────────────────── + + /// + /// 5 неудач подряд в окне — следующий вход ключа блокирован. + /// + [Fact] + public async Task FiveFailuresWithinWindow_BlockNextAttempt() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + var guard = NewGuard(() => now); + + for (int attempt = 0; attempt < 5; attempt++) + { + await guard.RecordFailureAsync(Ip, Login, CancellationToken.None); + } + + Assert.True(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); + } + + /// + /// 4 неудачи — порог не достигнут + /// + [Fact] + public async Task FourFailuresWithinWindow_AreNotBlocked() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + var guard = NewGuard(() => now); + + for (int attempt = 0; attempt < 4; attempt++) + { + await guard.RecordFailureAsync(Ip, Login, CancellationToken.None); + } + + Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); + } + + + /// + /// Успешный вход сбрасывает счётчик + /// + [Fact] + public async Task SuccessReset_ClearsFailures_AndWindowStartsAnew() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + var guard = NewGuard(() => now); + + for (int attempt = 0; attempt < 3; attempt++) + { + await guard.RecordFailureAsync(Ip, Login, CancellationToken.None); + } + + Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); + + await guard.ResetAsync(Ip, Login, CancellationToken.None); + + Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); + for (int attempt = 0; attempt < 5; attempt++) + { + await guard.RecordFailureAsync(Ip, Login, CancellationToken.None); + } + + // Сброс реально очистил счётчик: до блока — снова полные 5 неудач, а не 2 (3 старых + 2 новых). + Assert.True(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); + } + + /// + /// Уже заблокированный ключ разблокируется успешным входом + /// + [Fact] + public async Task SuccessReset_UnblocksBlockedKey() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + var guard = NewGuard(() => now); + + for (int attempt = 0; attempt < 5; attempt++) + { + await guard.RecordFailureAsync(Ip, Login, CancellationToken.None); + } + + Assert.True(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); + + await guard.ResetAsync(Ip, Login, CancellationToken.None); + + Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); + Assert.False(await guard.IsBlockedAsync(Ip, OtherLogin, CancellationToken.None)); + } + + // ─── Истечение окна ──────────────────────────────────────────────────── + + /// + /// После завершения фиксированного окна блокировка снимается и отсчёт начинается заново. + /// + [Fact] + public async Task WindowExpiry_UnblocksKeyAndStartsFreshWindow() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + var guard = NewGuard(() => now); + + for (int attempt = 0; attempt < 5; attempt++) + { + await guard.RecordFailureAsync(Ip, Login, CancellationToken.None); + } + + Assert.True(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); + + // Сдвиг больше длины окна (15 мин) гарантированно переводит в новое фиксированное окно. + now = now.AddMinutes(16); + Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); + + // Новая неудача в новом окне не восстанавливает старый счётчик. + await guard.RecordFailureAsync(Ip, Login, CancellationToken.None); + Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); + } + + // ─── Изоляция ключей ─────────────────────────────────────────────────── + + /// + /// Блокировка ключа ip|login не затрагивает другие IP и другие логины. + /// + [Fact] + public async Task BlockedKey_DoesNotAffectOtherIpsOrLogins() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + var guard = NewGuard(() => now); + + for (int attempt = 0; attempt < 5; attempt++) + { + await guard.RecordFailureAsync(Ip, Login, CancellationToken.None); + } + + Assert.True(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); + Assert.False(await guard.IsBlockedAsync(Ip, OtherLogin, CancellationToken.None)); + Assert.False(await guard.IsBlockedAsync(OtherIp, Login, CancellationToken.None)); + Assert.False(await guard.IsBlockedAsync(OtherIp, OtherLogin, CancellationToken.None)); + } + + // ─── Флаг Enabled (dev/тесты) ────────────────────────────────────────── + + /// + /// RateLimit:Enabled=false + /// + [Fact] + public async Task DisabledByDefaultInDev_DoesNotCountOrBlock() + { + var guard = new LoginAttemptGuard(new RateLimitOptions(), new FakeRateLimitCounterStore()); + + for (int attempt = 0; attempt < 10; attempt++) + { + await guard.RecordFailureAsync(Ip, Login, CancellationToken.None); + } + + Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); + } + + /// + /// Пустой/пробельный логин ключа не имеет — блокировке не подлежит + /// + [Fact] + public async Task EmptyOrWhitespaceLogin_IsNeverBlocked() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + var guard = NewGuard(() => now); + + for (int attempt = 0; attempt < 10; attempt++) + { + await guard.RecordFailureAsync(Ip, string.Empty, CancellationToken.None); + } + + Assert.False(await guard.IsBlockedAsync(Ip, string.Empty, CancellationToken.None)); + Assert.False(await guard.IsBlockedAsync(Ip, " ", CancellationToken.None)); + } + + // Создаёт включённый гвард (Enabled=true, дефолты 5/15) с фейк-хранилищем и инъекцией часов. + // clock: Источник «сейчас» (лямбда захватывает mutable-локальную теста — сдвиг окна виден гварду). + // Возвращает: Гвард с инъекцией часов. + private static LoginAttemptGuard NewGuard(Func clock) => + new( + new RateLimitOptions { Enabled = true }, + new FakeRateLimitCounterStore(), + clock); +} diff --git a/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs index d5d13c7..1a1501d 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs @@ -1,214 +1,214 @@ -using Deal.Api.Hosting; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Deal.Tests.Unit.Grpc; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Tenants; -using Deal.Tests.Unit.Support; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit.Api; - -/// -/// Тесты MlOutboxFlushScheduler — логика прохода цикла выгрузки обучения ML -/// -[Collection("MlGrpcTests")] -public sealed class MlOutboxFlushSchedulerTests -{ - // Тенант A теста. - private static readonly Guid TenantA = Guid.NewGuid(); - - // Тенант B теста. - private static readonly Guid TenantB = Guid.NewGuid(); - - [Fact] - public async Task RunCycle_FlushesTwentyFiveRowsInThreeBatchesAndDeletesRows() - { - await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => - { - var store = new FakeMlLearningStore(); - SeedRows(store, count: 25, prefix: "a"); - var tenantContext = new TenantContext(); - await using ServiceProvider provider = BuildProvider( - port, - new FakeTenantRepository(Tenant(TenantA)), - tenantContext, - new Dictionary { [TenantA] = store }); - MlOutboxFlushScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - // 25 строк → 3 батча по 10/10/5 (ml_client.flush_outbox: chunk=10), строки удалены после успеха. - Assert.Equal(3, service.TrainCalls); - Assert.Equal(new[] { 10, 10, 5 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray()); - Assert.Equal(0, await store.CountOutboxAsync(CancellationToken.None)); - Assert.False(tenantContext.HasTenant); // контекст AsyncLocal не переживает проход - }); - } - - [Fact] - public async Task RunCycle_ServiceUnavailable_KeepsRowsAndRetriesOnNextCycle() - { - await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => - { - service.TrainUnavailable = true; - var store = new FakeMlLearningStore(); - SeedRows(store, count: 5, prefix: "a"); - var tenantContext = new TenantContext(); - await using ServiceProvider provider = BuildProvider( - port, - new FakeTenantRepository(Tenant(TenantA)), - tenantContext, - new Dictionary { [TenantA] = store }); - MlOutboxFlushScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None)); // строки остались - - // Следующий цикл — повторная попытка (ретрай на каждом тике; строки снова не удалены). - await scheduler.RunCycleAsync(CancellationToken.None); - Assert.Equal(2, service.TrainCalls); - Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None)); - Assert.False(tenantContext.HasTenant); - }); - } - - [Fact] - public async Task RunCycle_TwoTenants_FlushIndependentStoresInOwnScopes() - { - await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => - { - var storeA = new FakeMlLearningStore(); - SeedRows(storeA, count: 12, prefix: "a"); - var storeB = new FakeMlLearningStore(); - SeedRows(storeB, count: 3, prefix: "b"); - var tenantContext = new TenantContext(); - await using ServiceProvider provider = BuildProvider( - port, - new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), - tenantContext, - new Dictionary { [TenantA] = storeA, [TenantB] = storeB }); - MlOutboxFlushScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - // Каждый тенант выгрузил свою очередь в собственном scope (A: 10+2, B: 3) — строки удалены. - Assert.Equal(3, service.TrainCalls); - Assert.Equal(new[] { 10, 2, 3 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray()); - Assert.Equal(0, await storeA.CountOutboxAsync(CancellationToken.None)); - Assert.Equal(0, await storeB.CountOutboxAsync(CancellationToken.None)); - Assert.False(tenantContext.HasTenant); - }); - } - - [Fact] - public async Task RunCycle_LongQueue_FlushesAtMostHundredRowsPerCycle() - { - await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => - { - var store = new FakeMlLearningStore(); - SeedRows(store, count: 105, prefix: "a"); - var tenantContext = new TenantContext(); - await using ServiceProvider provider = BuildProvider( - port, - new FakeTenantRepository(Tenant(TenantA)), - tenantContext, - new Dictionary { [TenantA] = store }); - MlOutboxFlushScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - Assert.Equal(10, service.TrainCalls); - Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None)); - }); - } - - // ─── Хелперы ──────────────────────────────────────────────────────────── - - // Строит DI-провайдер теста: фейк реестра + tenant-scoped хранилища по ITenantContext + реальный - // GrpcMlClient/IMlTrainClient к фейк-ml-service (как в проде: выбор gRPC-режима UseLocal=false). - // port: Порт хоста-фейка ml-service. - // tenants: Фейк реестра тенантов (системный scope прохода). - // tenantContext: Реальный контекст тенанта (AsyncLocal, как в приложении). - // storesByTenant: Хранилища обучения по тенантам (резолвятся по текущему контексту). - // Возвращает: Провайдер с зарегистрированными сервисами теста. - private static ServiceProvider BuildProvider( - int port, - ITenantRepository tenants, - TenantContext tenantContext, - Dictionary storesByTenant) - { - var services = new ServiceCollection(); - services.AddSingleton(tenantContext); - services.AddSingleton(tenants); - // Транспорт ml-service создаётся сразу (как в AddDealIntegrations при UseLocal=false): env-токен уже - // выставлен харнессом MlGrpcTestHost, endpoint — эфемерный порт хоста-фейка. - services.AddSingleton(new MlGrpcConnection(new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" })); - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(provider => provider.GetRequiredService()); - services.AddScoped(provider => provider.GetRequiredService()); - // Tenant-scoped адаптеры: выбирают фейк по тому же ITenantContext, который планировщик заполняет SetTenant. - services.AddScoped(provider => storesByTenant[TenantOf(provider)]); - services.AddScoped(_ => new FakeSettingsStore()); - services.AddScoped(_ => new FakeTenantLimitStore()); - services.AddScoped(_ => new TokenUsageEventService(new FakeTokenUsageEventStore())); - services.AddScoped(); - services.AddLogging(); - return services.BuildServiceProvider(); - } - - // Создаёт планировщик на провайдере теста (без StartAsync — таймер 10 с не заводим). - // provider: DI-провайдер с сервисами цикла. - // Возвращает: Планировщик с NullLogger. - private static MlOutboxFlushScheduler CreateScheduler(ServiceProvider provider) - { - return new MlOutboxFlushScheduler( - provider.GetRequiredService(), - NullLogger.Instance); - } - - // Id текущего тенанта из контекста (резолвер фейков, как ConnectionStringProvider.ForTenant). - // provider: Scope, в котором выполняется выгрузка тенанта. - // Возвращает: Guid тенанта из TenantId (формат N). - private static Guid TenantOf(IServiceProvider provider) - { - TenantId tenantId = provider.GetRequiredService().TenantId - ?? throw new InvalidOperationException("Тест: выгрузка вне tenant-контекста (SetTenant не выполнен)"); - return Guid.Parse(tenantId.Value); - } - - // Сеет строки очереди в порядке created_at (id mle_<prefix>_<n>). - // store: Хранилище-фейк. - // count: Число строк. - // prefix: Префикс id (различает тенантов теста). - private static void SeedRows( - FakeMlLearningStore store, - int count, - string prefix) - { - for (int i = 0; i < count; i++) - { - store.SeedOutbox($"mle_{prefix}_{i:D2}", $"текст {prefix} {i}", i % 2 == 0 ? "b_col" : "spam", 1.0); - } - } - - // Запись реестра тенанта (как строка public.tenants). - // id: Идентификатор тенанта. - // Возвращает: Запись тенанта со статусом active. - private static TenantRecordDto Tenant(Guid id) => - new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); -} +using Deal.Api.Hosting; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Infrastructure.Data; +using Deal.Infrastructure.Integrations.Abstractions; +using Deal.Infrastructure.Integrations.Models; +using Deal.Infrastructure.Integrations.Options; +using Deal.Infrastructure.Integrations.Services; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; +using Deal.Tests.Unit.Grpc; +using Deal.Tests.Unit.Modules.Kanban; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Modules.Tenants; +using Deal.Tests.Unit.Support; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Api; + +/// +/// Тесты MlOutboxFlushScheduler — логика прохода цикла выгрузки обучения ML +/// +[Collection("MlGrpcTests")] +public sealed class MlOutboxFlushSchedulerTests +{ + // Тенант A теста. + private static readonly Guid TenantA = Guid.NewGuid(); + + // Тенант B теста. + private static readonly Guid TenantB = Guid.NewGuid(); + + [Fact] + public async Task RunCycle_FlushesTwentyFiveRowsInThreeBatchesAndDeletesRows() + { + await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => + { + var store = new FakeMlLearningStore(); + SeedRows(store, count: 25, prefix: "a"); + var tenantContext = new TenantContext(); + await using ServiceProvider provider = BuildProvider( + port, + new FakeTenantRepository(Tenant(TenantA)), + tenantContext, + new Dictionary { [TenantA] = store }); + MlOutboxFlushScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + // 25 строк → 3 батча по 10/10/5 (ml_client.flush_outbox: chunk=10), строки удалены после успеха. + Assert.Equal(3, service.TrainCalls); + Assert.Equal(new[] { 10, 10, 5 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray()); + Assert.Equal(0, await store.CountOutboxAsync(CancellationToken.None)); + Assert.False(tenantContext.HasTenant); // контекст AsyncLocal не переживает проход + }); + } + + [Fact] + public async Task RunCycle_ServiceUnavailable_KeepsRowsAndRetriesOnNextCycle() + { + await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => + { + service.TrainUnavailable = true; + var store = new FakeMlLearningStore(); + SeedRows(store, count: 5, prefix: "a"); + var tenantContext = new TenantContext(); + await using ServiceProvider provider = BuildProvider( + port, + new FakeTenantRepository(Tenant(TenantA)), + tenantContext, + new Dictionary { [TenantA] = store }); + MlOutboxFlushScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None)); // строки остались + + // Следующий цикл — повторная попытка (ретрай на каждом тике; строки снова не удалены). + await scheduler.RunCycleAsync(CancellationToken.None); + Assert.Equal(2, service.TrainCalls); + Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None)); + Assert.False(tenantContext.HasTenant); + }); + } + + [Fact] + public async Task RunCycle_TwoTenants_FlushIndependentStoresInOwnScopes() + { + await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => + { + var storeA = new FakeMlLearningStore(); + SeedRows(storeA, count: 12, prefix: "a"); + var storeB = new FakeMlLearningStore(); + SeedRows(storeB, count: 3, prefix: "b"); + var tenantContext = new TenantContext(); + await using ServiceProvider provider = BuildProvider( + port, + new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), + tenantContext, + new Dictionary { [TenantA] = storeA, [TenantB] = storeB }); + MlOutboxFlushScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + // Каждый тенант выгрузил свою очередь в собственном scope (A: 10+2, B: 3) — строки удалены. + Assert.Equal(3, service.TrainCalls); + Assert.Equal(new[] { 10, 2, 3 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray()); + Assert.Equal(0, await storeA.CountOutboxAsync(CancellationToken.None)); + Assert.Equal(0, await storeB.CountOutboxAsync(CancellationToken.None)); + Assert.False(tenantContext.HasTenant); + }); + } + + [Fact] + public async Task RunCycle_LongQueue_FlushesAtMostHundredRowsPerCycle() + { + await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => + { + var store = new FakeMlLearningStore(); + SeedRows(store, count: 105, prefix: "a"); + var tenantContext = new TenantContext(); + await using ServiceProvider provider = BuildProvider( + port, + new FakeTenantRepository(Tenant(TenantA)), + tenantContext, + new Dictionary { [TenantA] = store }); + MlOutboxFlushScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + Assert.Equal(10, service.TrainCalls); + Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None)); + }); + } + + // ─── Хелперы ──────────────────────────────────────────────────────────── + + // Строит DI-провайдер теста: фейк реестра + tenant-scoped хранилища по ITenantContext + реальный + // GrpcMlClient/IMlTrainClient к фейк-ml-service (как в проде: выбор gRPC-режима UseLocal=false). + // port: Порт хоста-фейка ml-service. + // tenants: Фейк реестра тенантов (системный scope прохода). + // tenantContext: Реальный контекст тенанта (AsyncLocal, как в приложении). + // storesByTenant: Хранилища обучения по тенантам (резолвятся по текущему контексту). + // Возвращает: Провайдер с зарегистрированными сервисами теста. + private static ServiceProvider BuildProvider( + int port, + ITenantRepository tenants, + TenantContext tenantContext, + Dictionary storesByTenant) + { + var services = new ServiceCollection(); + services.AddSingleton(tenantContext); + services.AddSingleton(tenants); + // Транспорт ml-service создаётся сразу (как в AddDealIntegrations при UseLocal=false): env-токен уже + // выставлен харнессом MlGrpcTestHost, endpoint — эфемерный порт хоста-фейка. + services.AddSingleton(new MlGrpcConnection(new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" })); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + // Tenant-scoped адаптеры: выбирают фейк по тому же ITenantContext, который планировщик заполняет SetTenant. + services.AddScoped(provider => storesByTenant[TenantOf(provider)]); + services.AddScoped(_ => new FakeSettingsStore()); + services.AddScoped(_ => new FakeTenantLimitStore()); + services.AddScoped(_ => new TokenUsageEventService(new FakeTokenUsageEventStore())); + services.AddScoped(); + services.AddLogging(); + return services.BuildServiceProvider(); + } + + // Создаёт планировщик на провайдере теста (без StartAsync — таймер 10 с не заводим). + // provider: DI-провайдер с сервисами цикла. + // Возвращает: Планировщик с NullLogger. + private static MlOutboxFlushScheduler CreateScheduler(ServiceProvider provider) + { + return new MlOutboxFlushScheduler( + provider.GetRequiredService(), + NullLogger.Instance); + } + + // Id текущего тенанта из контекста (резолвер фейков, как ConnectionStringProvider.ForTenant). + // provider: Scope, в котором выполняется выгрузка тенанта. + // Возвращает: Guid тенанта из TenantId (формат N). + private static Guid TenantOf(IServiceProvider provider) + { + TenantId tenantId = provider.GetRequiredService().TenantId + ?? throw new InvalidOperationException("Тест: выгрузка вне tenant-контекста (SetTenant не выполнен)"); + return Guid.Parse(tenantId.Value); + } + + // Сеет строки очереди в порядке created_at (id mle_<prefix>_<n>). + // store: Хранилище-фейк. + // count: Число строк. + // prefix: Префикс id (различает тенантов теста). + private static void SeedRows( + FakeMlLearningStore store, + int count, + string prefix) + { + for (int i = 0; i < count; i++) + { + store.SeedOutbox($"mle_{prefix}_{i:D2}", $"текст {prefix} {i}", i % 2 == 0 ? "b_col" : "spam", 1.0); + } + } + + // Запись реестра тенанта (как строка public.tenants). + // id: Идентификатор тенанта. + // Возвращает: Запись тенанта со статусом active. + private static TenantRecordDto Tenant(Guid id) => + new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); +} diff --git a/src/core/tests/Deal.Tests.Unit/Api/OperatorBootstrapHostedServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Api/OperatorBootstrapHostedServiceTests.cs index f406f27..27d0417 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/OperatorBootstrapHostedServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/OperatorBootstrapHostedServiceTests.cs @@ -1,198 +1,198 @@ -using Deal.Api.Hosting; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Modules.Tenants; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Deal.Tests.Unit.Api; - -/// -/// Тесты hosted-шага bootstrap оператора -/// -public sealed class OperatorBootstrapHostedServiceTests -{ - private const string DevelopmentEnvironmentName = "Development"; - private const string ProductionEnvironmentName = "Production"; - private const string CustomOperatorLogin = "root-admin"; - private const string CustomOperatorPassword = "root-password"; - - [Fact] - public async Task Start_InDevelopment_WithoutEnvCredentials_SeedsDefaultOperator() - { - var context = CreateContext(environmentName: DevelopmentEnvironmentName); - - await context.Hosted.StartAsync(CancellationToken.None); - - var operatorRecord = Assert.Single(context.Store.Operators); - Assert.Equal(OperatorBootstrapService.DefaultOperatorLogin, operatorRecord.Login); - Assert.Equal("active", operatorRecord.Status); - Assert.True(context.PasswordHasher.Verify(OperatorBootstrapService.DefaultOperatorPassword, operatorRecord.PasswordHash)); - Assert.Contains(context.Logs.Messages, m => m.Contains("dev-дефолт оператора")); - } - - [Fact] - public async Task Start_InDevelopment_WithPartialCredentials_LogsWarningAndSeedsDefaults() - { - var context = CreateContext( - environmentName: DevelopmentEnvironmentName, - login: CustomOperatorLogin); - - await context.Hosted.StartAsync(CancellationToken.None); - - Assert.Contains(context.Logs.Messages, m => m.Contains("неполна") && m.Contains(OperatorBootstrapService.PasswordEnvKey)); - var operatorRecord = Assert.Single(context.Store.Operators); - Assert.Equal(OperatorBootstrapService.DefaultOperatorLogin, operatorRecord.Login); - } - - [Fact] - public async Task Start_InProduction_WithoutEnvCredentials_SkipsWithWarning() - { - var context = CreateContext(environmentName: ProductionEnvironmentName); - - await context.Hosted.StartAsync(CancellationToken.None); - - Assert.Empty(context.Store.Operators); - Assert.Contains(context.Logs.Messages, m => m.Contains("пропущен")); - } - - [Fact] - public async Task Start_InProduction_WithPartialCredentials_SkipsWithWarning() - { - var context = CreateContext( - environmentName: ProductionEnvironmentName, - password: CustomOperatorPassword); - - await context.Hosted.StartAsync(CancellationToken.None); - - // Без логина оператора не заводим даже при заданном пароле (секрет не используется и не логируется). - Assert.Empty(context.Store.Operators); - Assert.Contains(context.Logs.Messages, m => m.Contains("неполна") && m.Contains("пропущен")); - } - - [Fact] - public async Task Start_InProduction_WithEnvCredentials_CreatesOperatorWithNormalizedLogin() - { - var context = CreateContext( - environmentName: ProductionEnvironmentName, - login: $" {CustomOperatorLogin} ", - password: CustomOperatorPassword); - - await context.Hosted.StartAsync(CancellationToken.None); - - var operatorRecord = Assert.Single(context.Store.Operators); - Assert.Equal(CustomOperatorLogin, operatorRecord.Login); - Assert.True(context.PasswordHasher.Verify(CustomOperatorPassword, operatorRecord.PasswordHash)); - // Секрет в лог не пишется. - Assert.DoesNotContain(context.Logs.Messages, m => m.Contains(CustomOperatorPassword)); - } - - [Fact] - public async Task Start_InDevelopment_WithEnvCredentials_CreatesProvidedOperator() - { - var context = CreateContext( - environmentName: DevelopmentEnvironmentName, - login: CustomOperatorLogin, - password: CustomOperatorPassword); - - await context.Hosted.StartAsync(CancellationToken.None); - - // Явные env-креды перекрывают dev-дефолт operator/operator. - var operatorRecord = Assert.Single(context.Store.Operators); - Assert.Equal(CustomOperatorLogin, operatorRecord.Login); - Assert.True(context.PasswordHasher.Verify(CustomOperatorPassword, operatorRecord.PasswordHash)); - Assert.DoesNotContain(context.Logs.Messages, m => m.Contains("пропущен")); - } - - // Контекст теста: hosted-шаг на фейк-хранилище + конфигурация из словаря + коллектор логов. - private static Context CreateContext( - string environmentName, - string? login = null, - string? password = null) - { - var values = new Dictionary(); - if (login is not null) - { - values[OperatorBootstrapService.LoginEnvKey] = login; - } - - if (password is not null) - { - values[OperatorBootstrapService.PasswordEnvKey] = password; - } - - IConfiguration configuration = new ConfigurationBuilder() - .AddInMemoryCollection(values) - .Build(); - var store = new FakeOperatorAuthStore(); - var passwordHasher = new FakePasswordHasher(); - var logger = new ListLogger(); - // Hosted-шаг резолвит scoped OperatorBootstrapService из scope (как TenantBootstrapService): - // провайдер собирается с реальной регистрацией модуля поверх фейков. - var services = new ServiceCollection(); - services.AddScoped(_ => store); - services.AddScoped(_ => passwordHasher); - services.AddScoped(); - // Провайдер живёт всё время теста (без using): hosted резолвит scope в StartAsync после создания. - ServiceProvider provider = services.BuildServiceProvider(); - var hosted = new OperatorBootstrapHostedService( - provider.GetRequiredService(), - configuration, - new FakeHostEnvironment(environmentName), - logger); - return new Context(hosted, store, passwordHasher, logger); - } - - // Контекст теста bootstrap-шага. - private sealed record Context( - OperatorBootstrapHostedService Hosted, - FakeOperatorAuthStore Store, - FakePasswordHasher PasswordHasher, - ListLogger Logs); - - // Окружение хоста с фиксированным именем (тестовый IHostEnvironment). - private sealed class FakeHostEnvironment(string environmentName) : IHostEnvironment - { - /// - public string ApplicationName { get; set; } = "Deal.Tests.Unit"; - - /// - public string EnvironmentName { get; set; } = environmentName; - - /// - public string ContentRootPath { get; set; } = AppContext.BaseDirectory; - - /// - public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); - } - - // Логгер-коллектор: копит отформатированные сообщения (эталон DiscoveryWorkerSchedulerTests). - private sealed class ListLogger : ILogger - { - /// - /// Отформатированные сообщения лога в порядке записи. - /// - public List Messages { get; } = []; - - /// - public IDisposable? BeginScope(TState state) - where TState : notnull => null; - - /// - public bool IsEnabled(LogLevel logLevel) => true; - - /// - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - Messages.Add(formatter(state, exception)); - } - } -} +using Deal.Api.Hosting; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Services; +using Deal.Tests.Unit.Modules.Tenants; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Deal.Tests.Unit.Api; + +/// +/// Тесты hosted-шага bootstrap оператора +/// +public sealed class OperatorBootstrapHostedServiceTests +{ + private const string DevelopmentEnvironmentName = "Development"; + private const string ProductionEnvironmentName = "Production"; + private const string CustomOperatorLogin = "root-admin"; + private const string CustomOperatorPassword = "root-password"; + + [Fact] + public async Task Start_InDevelopment_WithoutEnvCredentials_SeedsDefaultOperator() + { + var context = CreateContext(environmentName: DevelopmentEnvironmentName); + + await context.Hosted.StartAsync(CancellationToken.None); + + var operatorRecord = Assert.Single(context.Store.Operators); + Assert.Equal(OperatorBootstrapService.DefaultOperatorLogin, operatorRecord.Login); + Assert.Equal("active", operatorRecord.Status); + Assert.True(context.PasswordHasher.Verify(OperatorBootstrapService.DefaultOperatorPassword, operatorRecord.PasswordHash)); + Assert.Contains(context.Logs.Messages, m => m.Contains("dev-дефолт оператора")); + } + + [Fact] + public async Task Start_InDevelopment_WithPartialCredentials_LogsWarningAndSeedsDefaults() + { + var context = CreateContext( + environmentName: DevelopmentEnvironmentName, + login: CustomOperatorLogin); + + await context.Hosted.StartAsync(CancellationToken.None); + + Assert.Contains(context.Logs.Messages, m => m.Contains("неполна") && m.Contains(OperatorBootstrapService.PasswordEnvKey)); + var operatorRecord = Assert.Single(context.Store.Operators); + Assert.Equal(OperatorBootstrapService.DefaultOperatorLogin, operatorRecord.Login); + } + + [Fact] + public async Task Start_InProduction_WithoutEnvCredentials_SkipsWithWarning() + { + var context = CreateContext(environmentName: ProductionEnvironmentName); + + await context.Hosted.StartAsync(CancellationToken.None); + + Assert.Empty(context.Store.Operators); + Assert.Contains(context.Logs.Messages, m => m.Contains("пропущен")); + } + + [Fact] + public async Task Start_InProduction_WithPartialCredentials_SkipsWithWarning() + { + var context = CreateContext( + environmentName: ProductionEnvironmentName, + password: CustomOperatorPassword); + + await context.Hosted.StartAsync(CancellationToken.None); + + // Без логина оператора не заводим даже при заданном пароле (секрет не используется и не логируется). + Assert.Empty(context.Store.Operators); + Assert.Contains(context.Logs.Messages, m => m.Contains("неполна") && m.Contains("пропущен")); + } + + [Fact] + public async Task Start_InProduction_WithEnvCredentials_CreatesOperatorWithNormalizedLogin() + { + var context = CreateContext( + environmentName: ProductionEnvironmentName, + login: $" {CustomOperatorLogin} ", + password: CustomOperatorPassword); + + await context.Hosted.StartAsync(CancellationToken.None); + + var operatorRecord = Assert.Single(context.Store.Operators); + Assert.Equal(CustomOperatorLogin, operatorRecord.Login); + Assert.True(context.PasswordHasher.Verify(CustomOperatorPassword, operatorRecord.PasswordHash)); + // Секрет в лог не пишется. + Assert.DoesNotContain(context.Logs.Messages, m => m.Contains(CustomOperatorPassword)); + } + + [Fact] + public async Task Start_InDevelopment_WithEnvCredentials_CreatesProvidedOperator() + { + var context = CreateContext( + environmentName: DevelopmentEnvironmentName, + login: CustomOperatorLogin, + password: CustomOperatorPassword); + + await context.Hosted.StartAsync(CancellationToken.None); + + // Явные env-креды перекрывают dev-дефолт operator/operator. + var operatorRecord = Assert.Single(context.Store.Operators); + Assert.Equal(CustomOperatorLogin, operatorRecord.Login); + Assert.True(context.PasswordHasher.Verify(CustomOperatorPassword, operatorRecord.PasswordHash)); + Assert.DoesNotContain(context.Logs.Messages, m => m.Contains("пропущен")); + } + + // Контекст теста: hosted-шаг на фейк-хранилище + конфигурация из словаря + коллектор логов. + private static Context CreateContext( + string environmentName, + string? login = null, + string? password = null) + { + var values = new Dictionary(); + if (login is not null) + { + values[OperatorBootstrapService.LoginEnvKey] = login; + } + + if (password is not null) + { + values[OperatorBootstrapService.PasswordEnvKey] = password; + } + + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(values) + .Build(); + var store = new FakeOperatorAuthStore(); + var passwordHasher = new FakePasswordHasher(); + var logger = new ListLogger(); + // Hosted-шаг резолвит scoped OperatorBootstrapService из scope (как TenantBootstrapService): + // провайдер собирается с реальной регистрацией модуля поверх фейков. + var services = new ServiceCollection(); + services.AddScoped(_ => store); + services.AddScoped(_ => passwordHasher); + services.AddScoped(); + // Провайдер живёт всё время теста (без using): hosted резолвит scope в StartAsync после создания. + ServiceProvider provider = services.BuildServiceProvider(); + var hosted = new OperatorBootstrapHostedService( + provider.GetRequiredService(), + configuration, + new FakeHostEnvironment(environmentName), + logger); + return new Context(hosted, store, passwordHasher, logger); + } + + // Контекст теста bootstrap-шага. + private sealed record Context( + OperatorBootstrapHostedService Hosted, + FakeOperatorAuthStore Store, + FakePasswordHasher PasswordHasher, + ListLogger Logs); + + // Окружение хоста с фиксированным именем (тестовый IHostEnvironment). + private sealed class FakeHostEnvironment(string environmentName) : IHostEnvironment + { + /// + public string ApplicationName { get; set; } = "Deal.Tests.Unit"; + + /// + public string EnvironmentName { get; set; } = environmentName; + + /// + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + + /// + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } + + // Логгер-коллектор: копит отформатированные сообщения (эталон DiscoveryWorkerSchedulerTests). + private sealed class ListLogger : ILogger + { + /// + /// Отформатированные сообщения лога в порядке записи. + /// + public List Messages { get; } = []; + + /// + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + /// + public bool IsEnabled(LogLevel logLevel) => true; + + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Messages.Add(formatter(state, exception)); + } + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs b/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs index 3f315b1..b63ea70 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs @@ -1,174 +1,174 @@ -using Deal.Api.Observability; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Infrastructure.Data; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Tenants; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit.Api; - -/// -/// Тесты сборщика глубин очередей/сессий -/// -public sealed class RuntimeDepthsCollectorTests -{ - private static readonly Guid TenantA = Guid.Parse("11111111-1111-1111-1111-111111111111"); - private static readonly Guid TenantB = Guid.Parse("22222222-2222-2222-2222-222222222222"); - private static readonly Guid TenantC = Guid.Parse("33333333-3333-3333-3333-333333333333"); - - [Fact] - public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants() - { - var pipelineByTenant = new Dictionary - { - [TenantA.ToString("N")] = new FakePipelineStore(), - [TenantB.ToString("N")] = new FakePipelineStore(), - }; - pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a1")); - pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a2")); - pipelineByTenant[TenantB.ToString("N")].SeedQueue(QueueItem("p_b1")); - - var outboxByTenant = new Dictionary - { - [TenantA.ToString("N")] = new FakeMlLearningStore(), - [TenantB.ToString("N")] = new FakeMlLearningStore(), - }; - outboxByTenant[TenantA.ToString("N")].SeedOutbox("mle_1", "текст", "spam"); - - RuntimeDepthsCollector collector = Build(pipelineByTenant, outboxByTenant); - - RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); - - Assert.Equal(3, depths.PipelineQueue); - Assert.Equal(1, depths.MlOutbox); - Assert.Equal(0, depths.ActiveSessions); - Assert.NotNull(depths.Budgets); - Assert.Equal(2, depths.Budgets!.Count); - } - - [Fact] - public async Task CollectAsync_CollectsBudgetRatiosPerTenant() - { - var limits = new FakeTenantLimitStore(); - DateTimeOffset periodStart = DateTimeOffset.UtcNow; - limits.Preload(TenantA, 100, TenantLimitPeriods.Month, periodStart, 80); - limits.Preload(TenantB, 0, TenantLimitPeriods.Month, periodStart, 55); - limits.Preload(TenantC, 100, TenantLimitPeriods.Month, periodStart, 150); - - RuntimeDepthsCollector collector = Build( - PipelineStores(TenantA, TenantB, TenantC), - OutboxStores(TenantA, TenantB, TenantC), - TenantRecords(TenantA, TenantB, TenantC), - limits); - - RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); - - Assert.NotNull(depths.Budgets); - Assert.Equal(3, depths.Budgets!.Count); - Assert.Equal(0.8, RatioFor(depths.Budgets, TenantA), 3); - Assert.Equal(0.0, RatioFor(depths.Budgets, TenantB), 3); - Assert.Equal(1.0, RatioFor(depths.Budgets, TenantC), 3); - } - - [Fact] - public async Task CollectAsync_BudgetReadFailure_SkipsFailedTenant() - { - var limits = new FakeTenantLimitStore(); - limits.FailStateReads.Add(TenantA); - limits.Preload(TenantB, 100, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 80); - - RuntimeDepthsCollector collector = Build( - PipelineStores(TenantA, TenantB), - OutboxStores(TenantA, TenantB), - TenantRecords(TenantA, TenantB), - limits); - - RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); - - Assert.NotNull(depths.Budgets); - TenantBudgetUsageDto budget = Assert.Single(depths.Budgets!); - Assert.Equal(TenantB.ToString("N"), budget.TenantId); - Assert.Equal(0.8, budget.UsedRatio, 3); - } - - [Fact] - public async Task CollectAsync_NoTenants_ReturnsZeros() - { - RuntimeDepthsCollector collector = Build( - new Dictionary(), - new Dictionary(), - tenants: Array.Empty()); - - RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); - - Assert.Equal(0, depths.PipelineQueue); - Assert.Equal(0, depths.MlOutbox); - Assert.Equal(0, depths.ActiveSessions); - } - - private static QueueItemDto QueueItem(string id) => new() - { - Id = id, - Text = "текст", - Status = PipelineQueueStatuses.New, - }; - - private static Dictionary PipelineStores(params Guid[] tenants) - => tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakePipelineStore()); - - private static Dictionary OutboxStores(params Guid[] tenants) - => tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakeMlLearningStore()); - - private static TenantRecordDto[] TenantRecords(params Guid[] tenants) - => tenants - .Select((tenant, index) => new TenantRecordDto(tenant, $"T{index}", "active", DateTimeOffset.UtcNow)) - .ToArray(); - - private static double RatioFor(IReadOnlyList budgets, Guid tenant) - => budgets.Single(budget => budget.TenantId == tenant.ToString("N")).UsedRatio; - - // Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext). - private static RuntimeDepthsCollector Build( - IReadOnlyDictionary pipelineByTenant, - IReadOnlyDictionary outboxByTenant, - IReadOnlyList? tenants = null, - ITenantLimitStore? limitStore = null) - { - var services = new ServiceCollection(); - services.AddSingleton(); - services.AddSingleton(new FakeTenantRepository( - (tenants ?? new[] - { - new TenantRecordDto(TenantA, "A", "active", DateTimeOffset.UtcNow), - new TenantRecordDto(TenantB, "B", "active", DateTimeOffset.UtcNow), - }).ToArray())); - - services.AddScoped(provider => pipelineByTenant[CurrentTenant(provider)]); - services.AddScoped(_ => new FakeMlClient()); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(provider => outboxByTenant[CurrentTenant(provider)]); - services.AddSingleton(limitStore ?? new FakeTenantLimitStore()); - - ServiceProvider provider = services.BuildServiceProvider(); - return new RuntimeDepthsCollector( - provider.GetRequiredService(), - NullLogger.Instance); - } - - // Текущий tenant-id контекста (формат N — как схема tenant_<N>). - private static string CurrentTenant(IServiceProvider provider) - { - ITenantContext context = provider.GetRequiredService(); - return context.TenantId!.Value.Value; - } -} +using Deal.Api.Observability; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Infrastructure.Data; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Kanban; +using Deal.Tests.Unit.Modules.Tenants; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Api; + +/// +/// Тесты сборщика глубин очередей/сессий +/// +public sealed class RuntimeDepthsCollectorTests +{ + private static readonly Guid TenantA = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid TenantB = Guid.Parse("22222222-2222-2222-2222-222222222222"); + private static readonly Guid TenantC = Guid.Parse("33333333-3333-3333-3333-333333333333"); + + [Fact] + public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants() + { + var pipelineByTenant = new Dictionary + { + [TenantA.ToString("N")] = new FakePipelineStore(), + [TenantB.ToString("N")] = new FakePipelineStore(), + }; + pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a1")); + pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a2")); + pipelineByTenant[TenantB.ToString("N")].SeedQueue(QueueItem("p_b1")); + + var outboxByTenant = new Dictionary + { + [TenantA.ToString("N")] = new FakeMlLearningStore(), + [TenantB.ToString("N")] = new FakeMlLearningStore(), + }; + outboxByTenant[TenantA.ToString("N")].SeedOutbox("mle_1", "текст", "spam"); + + RuntimeDepthsCollector collector = Build(pipelineByTenant, outboxByTenant); + + RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); + + Assert.Equal(3, depths.PipelineQueue); + Assert.Equal(1, depths.MlOutbox); + Assert.Equal(0, depths.ActiveSessions); + Assert.NotNull(depths.Budgets); + Assert.Equal(2, depths.Budgets!.Count); + } + + [Fact] + public async Task CollectAsync_CollectsBudgetRatiosPerTenant() + { + var limits = new FakeTenantLimitStore(); + DateTimeOffset periodStart = DateTimeOffset.UtcNow; + limits.Preload(TenantA, 100, TenantLimitPeriods.Month, periodStart, 80); + limits.Preload(TenantB, 0, TenantLimitPeriods.Month, periodStart, 55); + limits.Preload(TenantC, 100, TenantLimitPeriods.Month, periodStart, 150); + + RuntimeDepthsCollector collector = Build( + PipelineStores(TenantA, TenantB, TenantC), + OutboxStores(TenantA, TenantB, TenantC), + TenantRecords(TenantA, TenantB, TenantC), + limits); + + RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); + + Assert.NotNull(depths.Budgets); + Assert.Equal(3, depths.Budgets!.Count); + Assert.Equal(0.8, RatioFor(depths.Budgets, TenantA), 3); + Assert.Equal(0.0, RatioFor(depths.Budgets, TenantB), 3); + Assert.Equal(1.0, RatioFor(depths.Budgets, TenantC), 3); + } + + [Fact] + public async Task CollectAsync_BudgetReadFailure_SkipsFailedTenant() + { + var limits = new FakeTenantLimitStore(); + limits.FailStateReads.Add(TenantA); + limits.Preload(TenantB, 100, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 80); + + RuntimeDepthsCollector collector = Build( + PipelineStores(TenantA, TenantB), + OutboxStores(TenantA, TenantB), + TenantRecords(TenantA, TenantB), + limits); + + RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); + + Assert.NotNull(depths.Budgets); + TenantBudgetUsageDto budget = Assert.Single(depths.Budgets!); + Assert.Equal(TenantB.ToString("N"), budget.TenantId); + Assert.Equal(0.8, budget.UsedRatio, 3); + } + + [Fact] + public async Task CollectAsync_NoTenants_ReturnsZeros() + { + RuntimeDepthsCollector collector = Build( + new Dictionary(), + new Dictionary(), + tenants: Array.Empty()); + + RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); + + Assert.Equal(0, depths.PipelineQueue); + Assert.Equal(0, depths.MlOutbox); + Assert.Equal(0, depths.ActiveSessions); + } + + private static QueueItemDto QueueItem(string id) => new() + { + Id = id, + Text = "текст", + Status = PipelineQueueStatuses.New, + }; + + private static Dictionary PipelineStores(params Guid[] tenants) + => tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakePipelineStore()); + + private static Dictionary OutboxStores(params Guid[] tenants) + => tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakeMlLearningStore()); + + private static TenantRecordDto[] TenantRecords(params Guid[] tenants) + => tenants + .Select((tenant, index) => new TenantRecordDto(tenant, $"T{index}", "active", DateTimeOffset.UtcNow)) + .ToArray(); + + private static double RatioFor(IReadOnlyList budgets, Guid tenant) + => budgets.Single(budget => budget.TenantId == tenant.ToString("N")).UsedRatio; + + // Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext). + private static RuntimeDepthsCollector Build( + IReadOnlyDictionary pipelineByTenant, + IReadOnlyDictionary outboxByTenant, + IReadOnlyList? tenants = null, + ITenantLimitStore? limitStore = null) + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(new FakeTenantRepository( + (tenants ?? new[] + { + new TenantRecordDto(TenantA, "A", "active", DateTimeOffset.UtcNow), + new TenantRecordDto(TenantB, "B", "active", DateTimeOffset.UtcNow), + }).ToArray())); + + services.AddScoped(provider => pipelineByTenant[CurrentTenant(provider)]); + services.AddScoped(_ => new FakeMlClient()); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(provider => outboxByTenant[CurrentTenant(provider)]); + services.AddSingleton(limitStore ?? new FakeTenantLimitStore()); + + ServiceProvider provider = services.BuildServiceProvider(); + return new RuntimeDepthsCollector( + provider.GetRequiredService(), + NullLogger.Instance); + } + + // Текущий tenant-id контекста (формат N — как схема tenant_<N>). + private static string CurrentTenant(IServiceProvider provider) + { + ITenantContext context = provider.GetRequiredService(); + return context.TenantId!.Value.Value; + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Api/SuspiciousActivityReporterTests.cs b/src/core/tests/Deal.Tests.Unit/Api/SuspiciousActivityReporterTests.cs index 71a7bc9..809d490 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/SuspiciousActivityReporterTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/SuspiciousActivityReporterTests.cs @@ -1,111 +1,111 @@ -using Deal.Api.Services; -using Deal.Tests.Unit.Support; -using Microsoft.Extensions.Logging; - -namespace Deal.Tests.Unit.Api; - -/// -/// Unit-тесты учёта подозрительной активности -/// -public sealed class SuspiciousActivityReporterTests -{ - // Актор сценариев. - private const string Actor = "tenant-1"; - - /// - /// Report инкрементит deal.security.suspicious с меткой kind - /// - [Fact] - public void Report_IncrementsSecurityCounter_WithKindTag() - { - using var capture = new SecurityEventCapture(); - var reporter = new SuspiciousActivityReporter(new RecordingLogger()); - - reporter.Report(SuspiciousActivityReporter.RateLimitKind, Actor); - - Assert.Equal(1, capture.Sum(SuspiciousActivityReporter.RateLimitKind)); - (string? Kind, long Value) measurement = Assert.Single( - capture.Snapshot(), - item => item.Kind == SuspiciousActivityReporter.RateLimitKind); - Assert.Equal(1, measurement.Value); - } - - /// - /// Report пишет предупреждение с видом и актором - /// - [Fact] - public void Report_WritesWarningWithKindAndActor() - { - var logger = new RecordingLogger(); - var reporter = new SuspiciousActivityReporter(logger); - - reporter.Report(SuspiciousActivityReporter.LoginBlockedKind, Actor); - - LogEntry entry = Assert.Single(logger.Entries); - Assert.Equal(LogLevel.Warning, entry.Level); - Assert.Contains(SuspiciousActivityReporter.LoginBlockedKind, entry.Message); - Assert.Contains($"actor={Actor}", entry.Message); - } - - /// - /// null/пусто/пробелы actor → в логе «-» - /// - /// Пустой актор сценария. - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void Report_UnknownActor_WritesDash(string? actor) - { - var logger = new RecordingLogger(); - var reporter = new SuspiciousActivityReporter(logger); - - reporter.Report(SuspiciousActivityReporter.RateLimitKind, actor); - - LogEntry entry = Assert.Single(logger.Entries); - Assert.Contains("actor=-", entry.Message); - } - - /// - /// Пустой/пробельный kind → ArgumentException - /// - /// Пустой вид события сценария. - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void Report_EmptyOrWhitespaceKind_Throws(string? kind) - { - var reporter = new SuspiciousActivityReporter(new RecordingLogger()); - - Assert.ThrowsAny(() => reporter.Report(kind!, Actor)); - } - - // In-memory логгер: копит записи с уровнем, сообщением и исключением. - private sealed class RecordingLogger : ILogger - { - /// - /// Записи лога в порядке поступления. - /// - public List Entries { get; } = []; - - /// - public IDisposable? BeginScope(TState state) - where TState : notnull => null; - - /// - public bool IsEnabled(LogLevel logLevel) => true; - - /// - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - => Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception)); - } - - // Запись лога, снятая in-memory логгером. - private sealed record LogEntry(LogLevel Level, string Message, Exception? Exception); -} +using Deal.Api.Services; +using Deal.Tests.Unit.Support; +using Microsoft.Extensions.Logging; + +namespace Deal.Tests.Unit.Api; + +/// +/// Unit-тесты учёта подозрительной активности +/// +public sealed class SuspiciousActivityReporterTests +{ + // Актор сценариев. + private const string Actor = "tenant-1"; + + /// + /// Report инкрементит deal.security.suspicious с меткой kind + /// + [Fact] + public void Report_IncrementsSecurityCounter_WithKindTag() + { + using var capture = new SecurityEventCapture(); + var reporter = new SuspiciousActivityReporter(new RecordingLogger()); + + reporter.Report(SuspiciousActivityReporter.RateLimitKind, Actor); + + Assert.Equal(1, capture.Sum(SuspiciousActivityReporter.RateLimitKind)); + (string? Kind, long Value) measurement = Assert.Single( + capture.Snapshot(), + item => item.Kind == SuspiciousActivityReporter.RateLimitKind); + Assert.Equal(1, measurement.Value); + } + + /// + /// Report пишет предупреждение с видом и актором + /// + [Fact] + public void Report_WritesWarningWithKindAndActor() + { + var logger = new RecordingLogger(); + var reporter = new SuspiciousActivityReporter(logger); + + reporter.Report(SuspiciousActivityReporter.LoginBlockedKind, Actor); + + LogEntry entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Contains(SuspiciousActivityReporter.LoginBlockedKind, entry.Message); + Assert.Contains($"actor={Actor}", entry.Message); + } + + /// + /// null/пусто/пробелы actor → в логе «-» + /// + /// Пустой актор сценария. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Report_UnknownActor_WritesDash(string? actor) + { + var logger = new RecordingLogger(); + var reporter = new SuspiciousActivityReporter(logger); + + reporter.Report(SuspiciousActivityReporter.RateLimitKind, actor); + + LogEntry entry = Assert.Single(logger.Entries); + Assert.Contains("actor=-", entry.Message); + } + + /// + /// Пустой/пробельный kind → ArgumentException + /// + /// Пустой вид события сценария. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Report_EmptyOrWhitespaceKind_Throws(string? kind) + { + var reporter = new SuspiciousActivityReporter(new RecordingLogger()); + + Assert.ThrowsAny(() => reporter.Report(kind!, Actor)); + } + + // In-memory логгер: копит записи с уровнем, сообщением и исключением. + private sealed class RecordingLogger : ILogger + { + /// + /// Записи лога в порядке поступления. + /// + public List Entries { get; } = []; + + /// + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + /// + public bool IsEnabled(LogLevel logLevel) => true; + + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + => Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception)); + } + + // Запись лога, снятая in-memory логгером. + private sealed record LogEntry(LogLevel Level, string Message, Exception? Exception); +} diff --git a/src/core/tests/Deal.Tests.Unit/Api/TelegramEndpointsMappingTests.cs b/src/core/tests/Deal.Tests.Unit/Api/TelegramEndpointsMappingTests.cs index e0c5c48..a59b35e 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/TelegramEndpointsMappingTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/TelegramEndpointsMappingTests.cs @@ -1,60 +1,60 @@ -using Deal.Api.Endpoints; -using Grpc.Core; - -namespace Deal.Tests.Unit.Api; - -/// -/// Тесты чистых хелперов эндпоинтов /api/tg -/// -public sealed class TelegramEndpointsMappingTests -{ - /// - /// EN-канон каталога → русская подпись вкладки «Каналы». - /// - [Theory] - [InlineData("channel", "канал")] - [InlineData("group", "группа")] - [InlineData("forum", "группа")] // форум отображается как группа (python: мегагруппа-форум → «группа») - [InlineData("chat", "чат")] - public void ToRussianDialogType_MapsEnCanonToRussianLabels(string kind, string expected) - { - Assert.Equal(expected, TelegramEndpoints.ToRussianDialogType(kind)); - } - - /// - /// Неизвестная подпись - /// - [Theory] - [InlineData("канал")] - [InlineData("")] - [InlineData("supergroup")] - public void ToRussianDialogType_UnknownKind_PassesThrough(string kind) - { - Assert.Equal(kind, TelegramEndpoints.ToRussianDialogType(kind)); - } - - /// - /// Доменная RPC-ошибка отдаёт канонический detail telegram-service. - /// - [Fact] - public void GatewayErrorText_RpcDomainError_ReturnsServiceDetail() - { - var rpc = new RpcException(new Status(StatusCode.FailedPrecondition, "Telegram не подключён")); - - Assert.Equal("Telegram не подключён", TelegramEndpoints.GatewayErrorText(rpc)); - } - - /// - /// Транспортный сбой/неизвестное исключение → «Telegram не подключён». - /// - [Theory] - [InlineData(StatusCode.Unavailable)] // сервис недоступен (detail пуст) - [InlineData(StatusCode.Unknown)] - public void GatewayErrorText_TransportFailure_ReturnsNotConnected(StatusCode statusCode) - { - var rpc = new RpcException(new Status(statusCode, string.Empty)); - - Assert.Equal("Telegram не подключён", TelegramEndpoints.GatewayErrorText(rpc)); - Assert.Equal("Telegram не подключён", TelegramEndpoints.GatewayErrorText(new InvalidOperationException("boom"))); - } -} +using Deal.Api.Endpoints; +using Grpc.Core; + +namespace Deal.Tests.Unit.Api; + +/// +/// Тесты чистых хелперов эндпоинтов /api/tg +/// +public sealed class TelegramEndpointsMappingTests +{ + /// + /// EN-канон каталога → русская подпись вкладки «Каналы». + /// + [Theory] + [InlineData("channel", "канал")] + [InlineData("group", "группа")] + [InlineData("forum", "группа")] // форум отображается как группа (python: мегагруппа-форум → «группа») + [InlineData("chat", "чат")] + public void ToRussianDialogType_MapsEnCanonToRussianLabels(string kind, string expected) + { + Assert.Equal(expected, TelegramEndpoints.ToRussianDialogType(kind)); + } + + /// + /// Неизвестная подпись + /// + [Theory] + [InlineData("канал")] + [InlineData("")] + [InlineData("supergroup")] + public void ToRussianDialogType_UnknownKind_PassesThrough(string kind) + { + Assert.Equal(kind, TelegramEndpoints.ToRussianDialogType(kind)); + } + + /// + /// Доменная RPC-ошибка отдаёт канонический detail telegram-service. + /// + [Fact] + public void GatewayErrorText_RpcDomainError_ReturnsServiceDetail() + { + var rpc = new RpcException(new Status(StatusCode.FailedPrecondition, "Telegram не подключён")); + + Assert.Equal("Telegram не подключён", TelegramEndpoints.GatewayErrorText(rpc)); + } + + /// + /// Транспортный сбой/неизвестное исключение → «Telegram не подключён». + /// + [Theory] + [InlineData(StatusCode.Unavailable)] // сервис недоступен (detail пуст) + [InlineData(StatusCode.Unknown)] + public void GatewayErrorText_TransportFailure_ReturnsNotConnected(StatusCode statusCode) + { + var rpc = new RpcException(new Status(statusCode, string.Empty)); + + Assert.Equal("Telegram не подключён", TelegramEndpoints.GatewayErrorText(rpc)); + Assert.Equal("Telegram не подключён", TelegramEndpoints.GatewayErrorText(new InvalidOperationException("boom"))); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Api/TelegramKeysServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Api/TelegramKeysServiceTests.cs index d09d566..39451c4 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/TelegramKeysServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/TelegramKeysServiceTests.cs @@ -1,169 +1,169 @@ -using Deal.Api.Telegram; -using Deal.Modules.Settings.Application.Models; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Api; - -/// -/// Тесты сервиса глобальных ключей Telegram -/// -public sealed class TelegramKeysServiceTests -{ - private readonly FakeGlobalSettingsStore _store = new(); - private readonly FakeSecretCipher _cipher = new(); - private readonly TelegramKeysService _service; - - public TelegramKeysServiceTests() - { - _service = new TelegramKeysService(_store, _cipher); - } - - [Fact] - public async Task GetAsync_NoStoredKeys_ReturnsEmptySnapshot() - { - TgKeysSnapshot snapshot = await _service.GetAsync(CancellationToken.None); - - Assert.Equal(string.Empty, snapshot.ApiId); - Assert.Equal(string.Empty, snapshot.ApiHash); - Assert.False(snapshot.KeysSet); - } - - [Fact] - public async Task SaveAsync_EncryptsHashAndStoresApiIdPlain() - { - await _service.SaveAsync("1234567", "abcdefghijklmnop", CancellationToken.None); - - string storedJson = _store.GetStoredJson(GlobalSettingsKeys.TelegramKeys)!; - Assert.NotNull(storedJson); - Assert.Contains("enc:", storedJson); - Assert.DoesNotContain("abcdefghijklmnop", storedJson); - Assert.Contains("\"apiId\":\"1234567\"", storedJson); - } - - [Fact] - public async Task GetAsync_AfterSave_ReturnsDecryptedSnapshot() - { - await _service.SaveAsync("1234567", "abcdefghijklmnop", CancellationToken.None); - - TgKeysSnapshot snapshot = await _service.GetAsync(CancellationToken.None); - - Assert.Equal("1234567", snapshot.ApiId); - Assert.Equal("abcdefghijklmnop", snapshot.ApiHash); - Assert.True(snapshot.KeysSet); - } - - [Fact] - public async Task GetMaskedAsync_AfterSave_ReturnsApiIdAndMaskedHash() - { - await _service.SaveAsync("1234567", "abcdefghijklmnop", CancellationToken.None); - - TelegramKeysMaskedDto masked = await _service.GetMaskedAsync(CancellationToken.None); - - Assert.Equal("1234567", masked.ApiId); // apiId — не секрет, отдаётся открытым - Assert.Equal("abcd…mnop", masked.ApiHash); - Assert.True(masked.KeysSet); - } - - [Fact] - public async Task GetMaskedAsync_NoKeys_ReturnsEmptyMaskedForm() - { - TelegramKeysMaskedDto masked = await _service.GetMaskedAsync(CancellationToken.None); - - Assert.Equal(string.Empty, masked.ApiId); - Assert.Equal(string.Empty, masked.ApiHash); - Assert.False(masked.KeysSet); - } - - [Fact] - public async Task GetMaskedAsync_ShortSecret_IsFullyMasked() - { - await _service.SaveAsync("12345", "short", CancellationToken.None); - - TelegramKeysMaskedDto masked = await _service.GetMaskedAsync(CancellationToken.None); - - Assert.Equal("s…", masked.ApiHash); - Assert.True(masked.KeysSet); - } - - [Fact] - public async Task GetAsync_MalformedStoredJson_ReturnsEmptySnapshot() - { - _store.Preload(GlobalSettingsKeys.TelegramKeys, "not-json"); - - TgKeysSnapshot snapshot = await _service.GetAsync(CancellationToken.None); - - Assert.False(snapshot.KeysSet); - } - - [Fact] - public async Task GetAsync_BrokenCiphertext_ReturnsEmptyHash() - { - _store.Preload(GlobalSettingsKeys.TelegramKeys, "{\"apiId\":\"1234567\",\"apiHash\":\"enc:не-base64!\"}"); - - TgKeysSnapshot snapshot = await _service.GetAsync(CancellationToken.None); - - Assert.Equal("1234567", snapshot.ApiId); - Assert.Equal(string.Empty, snapshot.ApiHash); - Assert.False(snapshot.KeysSet); - } - - [Theory] - [InlineData("12345")] // нижняя граница - [InlineData("123456789")] // верхняя граница - public void IsValidApiId_AcceptsFiveToNineDigits(string apiId) - { - Assert.True(TelegramKeysService.IsValidApiId(apiId)); - } - - [Theory] - [InlineData("")] - [InlineData("1234")] // короче 5 - [InlineData("1234567890")] // длиннее 9 - [InlineData("12a45")] - public void IsValidApiId_RejectsInvalidValues(string apiId) - { - Assert.False(TelegramKeysService.IsValidApiId(apiId)); - } - - [Theory] - [InlineData("abcdefghijklmnop")] - [InlineData("short")] - public void IsValidApiHash_AcceptsNonEmptyPlain(string apiHash) - { - Assert.True(TelegramKeysService.IsValidApiHash(apiHash)); - } - - [Theory] - [InlineData("")] - [InlineData("abcd…mnop")] // маска из public-снимка - [InlineData("enc:abcdefghijklmnop")] // двойное шифрование - public void IsValidApiHash_RejectsEmptyMaskAndEncrypted(string apiHash) - { - Assert.False(TelegramKeysService.IsValidApiHash(apiHash)); - } - - [Fact] - public async Task SaveAsync_InvalidApiId_Throws() - { - await Assert.ThrowsAsync( - () => _service.SaveAsync("abc", "abcdefghijklmnop", CancellationToken.None)); - } - - [Fact] - public async Task SaveAsync_EmptyApiHash_Throws() - { - await Assert.ThrowsAsync( - () => _service.SaveAsync("1234567", string.Empty, CancellationToken.None)); - } - - [Fact] - public async Task SaveAsync_TrimsWhitespaceBeforeValidationAndStorage() - { - await _service.SaveAsync(" 1234567 ", " abcdefghijklmnop ", CancellationToken.None); - - TgKeysSnapshot snapshot = await _service.GetAsync(CancellationToken.None); - Assert.Equal("1234567", snapshot.ApiId); - Assert.Equal("abcdefghijklmnop", snapshot.ApiHash); - } -} +using Deal.Api.Telegram; +using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Support; + +namespace Deal.Tests.Unit.Api; + +/// +/// Тесты сервиса глобальных ключей Telegram +/// +public sealed class TelegramKeysServiceTests +{ + private readonly FakeGlobalSettingsStore _store = new(); + private readonly FakeSecretCipher _cipher = new(); + private readonly TelegramKeysService _service; + + public TelegramKeysServiceTests() + { + _service = new TelegramKeysService(_store, _cipher); + } + + [Fact] + public async Task GetAsync_NoStoredKeys_ReturnsEmptySnapshot() + { + TgKeysSnapshot snapshot = await _service.GetAsync(CancellationToken.None); + + Assert.Equal(string.Empty, snapshot.ApiId); + Assert.Equal(string.Empty, snapshot.ApiHash); + Assert.False(snapshot.KeysSet); + } + + [Fact] + public async Task SaveAsync_EncryptsHashAndStoresApiIdPlain() + { + await _service.SaveAsync("1234567", "abcdefghijklmnop", CancellationToken.None); + + string storedJson = _store.GetStoredJson(GlobalSettingsKeys.TelegramKeys)!; + Assert.NotNull(storedJson); + Assert.Contains("enc:", storedJson); + Assert.DoesNotContain("abcdefghijklmnop", storedJson); + Assert.Contains("\"apiId\":\"1234567\"", storedJson); + } + + [Fact] + public async Task GetAsync_AfterSave_ReturnsDecryptedSnapshot() + { + await _service.SaveAsync("1234567", "abcdefghijklmnop", CancellationToken.None); + + TgKeysSnapshot snapshot = await _service.GetAsync(CancellationToken.None); + + Assert.Equal("1234567", snapshot.ApiId); + Assert.Equal("abcdefghijklmnop", snapshot.ApiHash); + Assert.True(snapshot.KeysSet); + } + + [Fact] + public async Task GetMaskedAsync_AfterSave_ReturnsApiIdAndMaskedHash() + { + await _service.SaveAsync("1234567", "abcdefghijklmnop", CancellationToken.None); + + TelegramKeysMaskedDto masked = await _service.GetMaskedAsync(CancellationToken.None); + + Assert.Equal("1234567", masked.ApiId); // apiId — не секрет, отдаётся открытым + Assert.Equal("abcd…mnop", masked.ApiHash); + Assert.True(masked.KeysSet); + } + + [Fact] + public async Task GetMaskedAsync_NoKeys_ReturnsEmptyMaskedForm() + { + TelegramKeysMaskedDto masked = await _service.GetMaskedAsync(CancellationToken.None); + + Assert.Equal(string.Empty, masked.ApiId); + Assert.Equal(string.Empty, masked.ApiHash); + Assert.False(masked.KeysSet); + } + + [Fact] + public async Task GetMaskedAsync_ShortSecret_IsFullyMasked() + { + await _service.SaveAsync("12345", "short", CancellationToken.None); + + TelegramKeysMaskedDto masked = await _service.GetMaskedAsync(CancellationToken.None); + + Assert.Equal("s…", masked.ApiHash); + Assert.True(masked.KeysSet); + } + + [Fact] + public async Task GetAsync_MalformedStoredJson_ReturnsEmptySnapshot() + { + _store.Preload(GlobalSettingsKeys.TelegramKeys, "not-json"); + + TgKeysSnapshot snapshot = await _service.GetAsync(CancellationToken.None); + + Assert.False(snapshot.KeysSet); + } + + [Fact] + public async Task GetAsync_BrokenCiphertext_ReturnsEmptyHash() + { + _store.Preload(GlobalSettingsKeys.TelegramKeys, "{\"apiId\":\"1234567\",\"apiHash\":\"enc:не-base64!\"}"); + + TgKeysSnapshot snapshot = await _service.GetAsync(CancellationToken.None); + + Assert.Equal("1234567", snapshot.ApiId); + Assert.Equal(string.Empty, snapshot.ApiHash); + Assert.False(snapshot.KeysSet); + } + + [Theory] + [InlineData("12345")] // нижняя граница + [InlineData("123456789")] // верхняя граница + public void IsValidApiId_AcceptsFiveToNineDigits(string apiId) + { + Assert.True(TelegramKeysService.IsValidApiId(apiId)); + } + + [Theory] + [InlineData("")] + [InlineData("1234")] // короче 5 + [InlineData("1234567890")] // длиннее 9 + [InlineData("12a45")] + public void IsValidApiId_RejectsInvalidValues(string apiId) + { + Assert.False(TelegramKeysService.IsValidApiId(apiId)); + } + + [Theory] + [InlineData("abcdefghijklmnop")] + [InlineData("short")] + public void IsValidApiHash_AcceptsNonEmptyPlain(string apiHash) + { + Assert.True(TelegramKeysService.IsValidApiHash(apiHash)); + } + + [Theory] + [InlineData("")] + [InlineData("abcd…mnop")] // маска из public-снимка + [InlineData("enc:abcdefghijklmnop")] // двойное шифрование + public void IsValidApiHash_RejectsEmptyMaskAndEncrypted(string apiHash) + { + Assert.False(TelegramKeysService.IsValidApiHash(apiHash)); + } + + [Fact] + public async Task SaveAsync_InvalidApiId_Throws() + { + await Assert.ThrowsAsync( + () => _service.SaveAsync("abc", "abcdefghijklmnop", CancellationToken.None)); + } + + [Fact] + public async Task SaveAsync_EmptyApiHash_Throws() + { + await Assert.ThrowsAsync( + () => _service.SaveAsync("1234567", string.Empty, CancellationToken.None)); + } + + [Fact] + public async Task SaveAsync_TrimsWhitespaceBeforeValidationAndStorage() + { + await _service.SaveAsync(" 1234567 ", " abcdefghijklmnop ", CancellationToken.None); + + TgKeysSnapshot snapshot = await _service.GetAsync(CancellationToken.None); + Assert.Equal("1234567", snapshot.ApiId); + Assert.Equal("abcdefghijklmnop", snapshot.ApiHash); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs index 5b03bdd..806ec82 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs @@ -1,141 +1,141 @@ -using Deal.Api.Telegram; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Telegram.Application.Models; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Support; -using Grpc.Core; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit.Api; - -/// -/// Тесты сборки статуса вкладки Telegram — GET /api/tg/status. -/// -public sealed class TgStatusServiceTests -{ - /// - /// Гейт по умолчанию - /// - [Fact] - public async Task GetAsync_GatewayIdleAndEmptyTenant_ReturnsIdleForm() - { - (TgStatusService service, _, _, _, _, _) = Create(); - - TgStatusDto status = await service.GetAsync(CancellationToken.None); - - Assert.Equal("idle", status.Phase); - Assert.False(status.Connected); - Assert.False(status.Listener); - Assert.Equal(string.Empty, status.Account); - Assert.Equal(0, status.Monitored); - Assert.False(status.KeysSet); - Assert.Null(status.Error); - Assert.Null(status.QrUrl); - } - - /// - /// Сервис недоступен - /// - [Fact] - public async Task GetAsync_GatewayUnavailable_ReturnsIdleFormWithKvAccountCountAndKeys() - { - (TgStatusService service, FakeTelegramStore store, FakeSettingsStore settings, FakeTelegramGateway gateway, _, FakeGlobalSettingsStore globalSettings) = Create(); - store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true)); - store.Seed(Dialog("d_2", "Группа", "group", Monitor: true)); - store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false)); - settings.Preload(SettingsKeys.TgAccount, "\"@user\""); - PreloadKeys(globalSettings, "123456", "abcdefghijklmnop"); - gateway.StatusFailure = new RpcException(new Status(StatusCode.Unavailable, "Telegram не подключён")); - - TgStatusDto status = await service.GetAsync(CancellationToken.None); - - Assert.Equal("idle", status.Phase); - Assert.False(status.Connected); - Assert.False(status.Listener); - Assert.Equal("@user", status.Account); - Assert.Equal(2, status.Monitored); - Assert.True(status.KeysSet); - Assert.Null(status.Error); - Assert.Null(status.QrUrl); - } - - /// - /// Готовый аккаунт - /// - [Fact] - public async Task GetAsync_ReadyGateway_ComposesLiveFieldsWithKvAccountMonitoredAndKeys() - { - (TgStatusService service, FakeTelegramStore store, FakeSettingsStore settings, FakeTelegramGateway gateway, _, FakeGlobalSettingsStore globalSettings) = Create(); - store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true)); - settings.Preload(SettingsKeys.TgAccount, "\"@realuser\""); - PreloadKeys(globalSettings, "123456", "abcdefghijklmnop"); - gateway.Status = new TelegramAccountStatusDto( - Phase: "ready", Connected: true, Listener: true, Account: "gateway-account", Error: null, QrUrl: null); - - TgStatusDto status = await service.GetAsync(CancellationToken.None); - - Assert.Equal("ready", status.Phase); - Assert.True(status.Connected); - Assert.True(status.Listener); - Assert.Equal("@realuser", status.Account); // KV tgAccount перекрывает справочное поле гейта (Ruling 8) - Assert.Equal(1, status.Monitored); - Assert.True(status.KeysSet); - } - - /// - /// Фаза qr несёт qrUrl из гейта - /// - [Fact] - public async Task GetAsync_QrPhase_ReturnsQrUrlFromGateway() - { - (TgStatusService service, _, _, FakeTelegramGateway gateway, _, _) = Create(); - gateway.Status = new TelegramAccountStatusDto( - Phase: "qr", Connected: false, Listener: false, Account: string.Empty, Error: null, QrUrl: "https://t.me/qr/test"); - - TgStatusDto status = await service.GetAsync(CancellationToken.None); - - Assert.Equal("qr", status.Phase); - Assert.Equal("https://t.me/qr/test", status.QrUrl); - Assert.False(status.Connected); - } - - // ─── Хелперы ──────────────────────────────────────────────────────────── - - // Собирает сервис на фейках (FakeTelegramGateway/Store/SettingsStore/GlobalSettingsStore + FakeSecretCipher). - private static (TgStatusService Service, FakeTelegramStore Store, FakeSettingsStore Settings, FakeTelegramGateway Gateway, FakeSecretCipher Cipher, FakeGlobalSettingsStore GlobalSettings) Create() - { - var store = new FakeTelegramStore(); - var settings = new FakeSettingsStore(); - var gateway = new FakeTelegramGateway(); - var cipher = new FakeSecretCipher(); - var globalSettings = new FakeGlobalSettingsStore(); - var keys = new TelegramKeysService(globalSettings, cipher); - var dialogs = new DialogsService(store, settings, gateway, NullLogger.Instance); - var service = new TgStatusService(gateway, dialogs, settings, keys); - return (service, store, settings, gateway, cipher, globalSettings); - } - - // Кладёт глобальную настройку telegramKeys (apiHash шифруется — enc: через шифр сценария). - // settings: Глобальное KV-хранилище сценария. - // apiId: apiId приложения (5..9 цифр). - // apiHash: Открытый api_hash (ядро шифрует при сохранении). - private static void PreloadKeys( - FakeGlobalSettingsStore settings, - string apiId, - string apiHash) - { - string encrypted = new FakeSecretCipher().Encrypt(apiHash); - settings.Preload(GlobalSettingsKeys.TelegramKeys, $"{{\"apiId\":\"{apiId}\",\"apiHash\":\"{encrypted}\"}}"); - } - - // Строка каталога минимально (id/имя/kind + флаг мониторинга). - private static FakeTelegramDialogRow Dialog( - string id, - string name, - string kind, - bool Monitor) - => new(id, name, Handle: string.Empty, kind, Hue: "#666", Monitor, LastText: string.Empty, LastAt: null, Backfilled: false, UpdatedAt: DateTimeOffset.UtcNow); -} +using Deal.Api.Telegram; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Telegram.Application; +using Deal.Modules.Telegram.Application.Models; +using Deal.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Support; +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Api; + +/// +/// Тесты сборки статуса вкладки Telegram — GET /api/tg/status. +/// +public sealed class TgStatusServiceTests +{ + /// + /// Гейт по умолчанию + /// + [Fact] + public async Task GetAsync_GatewayIdleAndEmptyTenant_ReturnsIdleForm() + { + (TgStatusService service, _, _, _, _, _) = Create(); + + TgStatusDto status = await service.GetAsync(CancellationToken.None); + + Assert.Equal("idle", status.Phase); + Assert.False(status.Connected); + Assert.False(status.Listener); + Assert.Equal(string.Empty, status.Account); + Assert.Equal(0, status.Monitored); + Assert.False(status.KeysSet); + Assert.Null(status.Error); + Assert.Null(status.QrUrl); + } + + /// + /// Сервис недоступен + /// + [Fact] + public async Task GetAsync_GatewayUnavailable_ReturnsIdleFormWithKvAccountCountAndKeys() + { + (TgStatusService service, FakeTelegramStore store, FakeSettingsStore settings, FakeTelegramGateway gateway, _, FakeGlobalSettingsStore globalSettings) = Create(); + store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true)); + store.Seed(Dialog("d_2", "Группа", "group", Monitor: true)); + store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false)); + settings.Preload(SettingsKeys.TgAccount, "\"@user\""); + PreloadKeys(globalSettings, "123456", "abcdefghijklmnop"); + gateway.StatusFailure = new RpcException(new Status(StatusCode.Unavailable, "Telegram не подключён")); + + TgStatusDto status = await service.GetAsync(CancellationToken.None); + + Assert.Equal("idle", status.Phase); + Assert.False(status.Connected); + Assert.False(status.Listener); + Assert.Equal("@user", status.Account); + Assert.Equal(2, status.Monitored); + Assert.True(status.KeysSet); + Assert.Null(status.Error); + Assert.Null(status.QrUrl); + } + + /// + /// Готовый аккаунт + /// + [Fact] + public async Task GetAsync_ReadyGateway_ComposesLiveFieldsWithKvAccountMonitoredAndKeys() + { + (TgStatusService service, FakeTelegramStore store, FakeSettingsStore settings, FakeTelegramGateway gateway, _, FakeGlobalSettingsStore globalSettings) = Create(); + store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true)); + settings.Preload(SettingsKeys.TgAccount, "\"@realuser\""); + PreloadKeys(globalSettings, "123456", "abcdefghijklmnop"); + gateway.Status = new TelegramAccountStatusDto( + Phase: "ready", Connected: true, Listener: true, Account: "gateway-account", Error: null, QrUrl: null); + + TgStatusDto status = await service.GetAsync(CancellationToken.None); + + Assert.Equal("ready", status.Phase); + Assert.True(status.Connected); + Assert.True(status.Listener); + Assert.Equal("@realuser", status.Account); // KV tgAccount перекрывает справочное поле гейта (Ruling 8) + Assert.Equal(1, status.Monitored); + Assert.True(status.KeysSet); + } + + /// + /// Фаза qr несёт qrUrl из гейта + /// + [Fact] + public async Task GetAsync_QrPhase_ReturnsQrUrlFromGateway() + { + (TgStatusService service, _, _, FakeTelegramGateway gateway, _, _) = Create(); + gateway.Status = new TelegramAccountStatusDto( + Phase: "qr", Connected: false, Listener: false, Account: string.Empty, Error: null, QrUrl: "https://t.me/qr/test"); + + TgStatusDto status = await service.GetAsync(CancellationToken.None); + + Assert.Equal("qr", status.Phase); + Assert.Equal("https://t.me/qr/test", status.QrUrl); + Assert.False(status.Connected); + } + + // ─── Хелперы ──────────────────────────────────────────────────────────── + + // Собирает сервис на фейках (FakeTelegramGateway/Store/SettingsStore/GlobalSettingsStore + FakeSecretCipher). + private static (TgStatusService Service, FakeTelegramStore Store, FakeSettingsStore Settings, FakeTelegramGateway Gateway, FakeSecretCipher Cipher, FakeGlobalSettingsStore GlobalSettings) Create() + { + var store = new FakeTelegramStore(); + var settings = new FakeSettingsStore(); + var gateway = new FakeTelegramGateway(); + var cipher = new FakeSecretCipher(); + var globalSettings = new FakeGlobalSettingsStore(); + var keys = new TelegramKeysService(globalSettings, cipher); + var dialogs = new DialogsService(store, settings, gateway, NullLogger.Instance); + var service = new TgStatusService(gateway, dialogs, settings, keys); + return (service, store, settings, gateway, cipher, globalSettings); + } + + // Кладёт глобальную настройку telegramKeys (apiHash шифруется — enc: через шифр сценария). + // settings: Глобальное KV-хранилище сценария. + // apiId: apiId приложения (5..9 цифр). + // apiHash: Открытый api_hash (ядро шифрует при сохранении). + private static void PreloadKeys( + FakeGlobalSettingsStore settings, + string apiId, + string apiHash) + { + string encrypted = new FakeSecretCipher().Encrypt(apiHash); + settings.Preload(GlobalSettingsKeys.TelegramKeys, $"{{\"apiId\":\"{apiId}\",\"apiHash\":\"{encrypted}\"}}"); + } + + // Строка каталога минимально (id/имя/kind + флаг мониторинга). + private static FakeTelegramDialogRow Dialog( + string id, + string name, + string kind, + bool Monitor) + => new(id, name, Handle: string.Empty, kind, Hue: "#666", Monitor, LastText: string.Empty, LastAt: null, Backfilled: false, UpdatedAt: DateTimeOffset.UtcNow); +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs index b4448fa..9b6ca3a 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs @@ -1,174 +1,174 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants.Models; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Tenants; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты декоратора бюджетного гейта -/// -public sealed class BudgetedAiClassifierTests -{ - // Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore. - private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; - - // Guid того же тенанта — ключ строки лимита. - private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue); - - // Начало периода строк лимита (не истёкший месяц). - private static readonly DateTimeOffset PeriodStart = new(2026, 9, 1, 0, 0, 0, TimeSpan.Zero); - - // Разбор «платной» ветки — маркер: если тест увидел его, вызов ушёл на платного исполнителя. - private static readonly AiParsedCardDto PaidParsedLead = new( - "Платная ветка", - Company: null, - Format: null, - Task: null, - Requirements: null, - Plus: null, - Conditions: null, - Summary: null, - Stack: new[] { "Go" }, - Budget: null, - Contacts: Array.Empty(), - IsVacancy: true, - IsVacancyKnown: true, - IsSpam: false, - Board: null); - - [Fact] - public async Task Classify_ZeroBudgetLimit_UsesLocalParseAndDoesNotCallPaid() - { - Context ctx = Create( - limits => limits.Preload(TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0)); - ctx.Paid.ClassifyResult = PaidParsedLead; - const string text = "Вакансия: Python-разработчик в команду, оплата до 2000$ в месяц"; - - AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None); - - Assert.Equal(0, ctx.Paid.ClassifyCalls); // платный ИИ не зван - Assert.NotNull(result); - Assert.NotEqual(PaidParsedLead, result); // разбор собран Local-реализацией, а не платной веткой - Assert.Null(result.Board); // локальный путь не назначает смысловые колонки - } - - [Fact] - public async Task Filter_ExhaustedBudget_ReturnsLocalPassSkippedAndDoesNotCallPaid() - { - Context ctx = Create( - limits => limits.Preload( - TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 1000)); - ctx.Paid.FilterResult = new AiFilterResultDto(Pass: false, Reason: "реклама", Skipped: false); - - AiFilterResultDto result = await ctx.Decorator.FilterAsync("Купите канал", CancellationToken.None); - - Assert.Equal(0, ctx.Paid.FilterCalls); - Assert.True(result.Pass); - Assert.True(result.Skipped); - Assert.Null(result.Reason); - } - - [Fact] - public async Task Classify_LargeBudgetLimit_CallsPaidAndReturnsItsResult() - { - Context ctx = Create(); - ctx.Paid.ClassifyResult = PaidParsedLead; - - AiParsedCardDto result = await ctx.Decorator.ClassifyAsync("текст", CancellationToken.None); - - Assert.Same(PaidParsedLead, result); - Assert.Equal(1, ctx.Paid.ClassifyCalls); - } - - [Fact] - public async Task Classify_SuspendedTenant_UsesLocalParseAndDoesNotCallPaid() - { - Context ctx = Create( - limits => limits.Preload( - TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 100, - status: TenantStatuses.Suspended)); - ctx.Paid.ClassifyResult = PaidParsedLead; - - AiParsedCardDto result = await ctx.Decorator.ClassifyAsync( - "Вакансия: разработчик в команду", CancellationToken.None); - - Assert.Equal(0, ctx.Paid.ClassifyCalls); - Assert.NotNull(result); - Assert.NotEqual(PaidParsedLead, result); - } - - [Fact] - public async Task Filter_SuspendedTenant_ReturnsLocalPassSkipped() - { - Context ctx = Create( - limits => limits.Preload( - TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 100, - status: TenantStatuses.Suspended)); - - AiFilterResultDto result = await ctx.Decorator.FilterAsync("текст", CancellationToken.None); - - Assert.Equal(0, ctx.Paid.FilterCalls); - Assert.True(result.Pass); - Assert.True(result.Skipped); - } - - [Fact] - public async Task Classify_LocalFallback_MatchesStandaloneLocalClassifier() - { - // Гейт-запрет уводит ровно на Local-реализацию: результат совпадает с прямым вызовом LocalAiClassifier. - Context ctx = Create( - limits => limits.Preload( - TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0)); - var standaloneLocal = new LocalAiClassifier(new LocalFieldsParser(ctx.Settings)); - const string text = "Ищем Python-разработчика на проект в команду\nБюджет: до 2к$"; - - AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None); - AiParsedCardDto expected = await standaloneLocal.ClassifyAsync(text, CancellationToken.None); - - Assert.Equal(0, ctx.Paid.ClassifyCalls); - // Record-равенство по списковым свойствам — ссылочное; сравниваем значимые поля разбора. - Assert.Equal(expected.Title, result.Title); - Assert.Equal(expected.Stack, result.Stack); - Assert.Equal(expected.Budget, result.Budget); - Assert.Equal(expected.IsVacancy, result.IsVacancy); - Assert.Equal(expected.Board, result.Board); - } - - // ─── Контекст сценария ───────────────────────────────────────────────── - - // Контекст теста: декоратор над платным фейком + реальным Local-классификатором (fallback). - // Decorator: Декоратор бюджетного гейта. - // Paid: Платный фейк-исполнитель (счётчики/ответы сценария). - // Settings: KV-настройки тенанта (маркеры LocalFieldsParser). - private sealed record Context( - BudgetedAiClassifier Decorator, - FakeAiClassifier Paid, - FakeSettingsStore Settings); - - // Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст - // (зеркало регистрации AddDealIntegrations при UseLocal=false). - // configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет). - // Возвращает: Контекст теста. - private static Context Create(Action? configure = null) - { - var settings = new FakeSettingsStore(); - var limits = new FakeTenantLimitStore(); - configure?.Invoke(limits); - var tenantContext = new TenantContext(); - tenantContext.SetTenant(new TenantId(TenantIdValue)); - var paid = new FakeAiClassifier(); - var decorator = new BudgetedAiClassifier( - paid, - new LocalAiClassifier(new LocalFieldsParser(settings)), - limits, - tenantContext, - NullLogger.Instance); - return new Context(decorator, paid, settings); - } -} +using Deal.Contracts.Integrations.Models; +using Deal.Infrastructure.Data; +using Deal.Infrastructure.Integrations.Services; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Models; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Modules.Tenants; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Contracts; + +/// +/// Тесты декоратора бюджетного гейта +/// +public sealed class BudgetedAiClassifierTests +{ + // Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore. + private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; + + // Guid того же тенанта — ключ строки лимита. + private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue); + + // Начало периода строк лимита (не истёкший месяц). + private static readonly DateTimeOffset PeriodStart = new(2026, 9, 1, 0, 0, 0, TimeSpan.Zero); + + // Разбор «платной» ветки — маркер: если тест увидел его, вызов ушёл на платного исполнителя. + private static readonly AiParsedCardDto PaidParsedLead = new( + "Платная ветка", + Company: null, + Format: null, + Task: null, + Requirements: null, + Plus: null, + Conditions: null, + Summary: null, + Stack: new[] { "Go" }, + Budget: null, + Contacts: Array.Empty(), + IsVacancy: true, + IsVacancyKnown: true, + IsSpam: false, + Board: null); + + [Fact] + public async Task Classify_ZeroBudgetLimit_UsesLocalParseAndDoesNotCallPaid() + { + Context ctx = Create( + limits => limits.Preload(TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0)); + ctx.Paid.ClassifyResult = PaidParsedLead; + const string text = "Вакансия: Python-разработчик в команду, оплата до 2000$ в месяц"; + + AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None); + + Assert.Equal(0, ctx.Paid.ClassifyCalls); // платный ИИ не зван + Assert.NotNull(result); + Assert.NotEqual(PaidParsedLead, result); // разбор собран Local-реализацией, а не платной веткой + Assert.Null(result.Board); // локальный путь не назначает смысловые колонки + } + + [Fact] + public async Task Filter_ExhaustedBudget_ReturnsLocalPassSkippedAndDoesNotCallPaid() + { + Context ctx = Create( + limits => limits.Preload( + TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 1000)); + ctx.Paid.FilterResult = new AiFilterResultDto(Pass: false, Reason: "реклама", Skipped: false); + + AiFilterResultDto result = await ctx.Decorator.FilterAsync("Купите канал", CancellationToken.None); + + Assert.Equal(0, ctx.Paid.FilterCalls); + Assert.True(result.Pass); + Assert.True(result.Skipped); + Assert.Null(result.Reason); + } + + [Fact] + public async Task Classify_LargeBudgetLimit_CallsPaidAndReturnsItsResult() + { + Context ctx = Create(); + ctx.Paid.ClassifyResult = PaidParsedLead; + + AiParsedCardDto result = await ctx.Decorator.ClassifyAsync("текст", CancellationToken.None); + + Assert.Same(PaidParsedLead, result); + Assert.Equal(1, ctx.Paid.ClassifyCalls); + } + + [Fact] + public async Task Classify_SuspendedTenant_UsesLocalParseAndDoesNotCallPaid() + { + Context ctx = Create( + limits => limits.Preload( + TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 100, + status: TenantStatuses.Suspended)); + ctx.Paid.ClassifyResult = PaidParsedLead; + + AiParsedCardDto result = await ctx.Decorator.ClassifyAsync( + "Вакансия: разработчик в команду", CancellationToken.None); + + Assert.Equal(0, ctx.Paid.ClassifyCalls); + Assert.NotNull(result); + Assert.NotEqual(PaidParsedLead, result); + } + + [Fact] + public async Task Filter_SuspendedTenant_ReturnsLocalPassSkipped() + { + Context ctx = Create( + limits => limits.Preload( + TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 100, + status: TenantStatuses.Suspended)); + + AiFilterResultDto result = await ctx.Decorator.FilterAsync("текст", CancellationToken.None); + + Assert.Equal(0, ctx.Paid.FilterCalls); + Assert.True(result.Pass); + Assert.True(result.Skipped); + } + + [Fact] + public async Task Classify_LocalFallback_MatchesStandaloneLocalClassifier() + { + // Гейт-запрет уводит ровно на Local-реализацию: результат совпадает с прямым вызовом LocalAiClassifier. + Context ctx = Create( + limits => limits.Preload( + TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0)); + var standaloneLocal = new LocalAiClassifier(new LocalFieldsParser(ctx.Settings)); + const string text = "Ищем Python-разработчика на проект в команду\nБюджет: до 2к$"; + + AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None); + AiParsedCardDto expected = await standaloneLocal.ClassifyAsync(text, CancellationToken.None); + + Assert.Equal(0, ctx.Paid.ClassifyCalls); + // Record-равенство по списковым свойствам — ссылочное; сравниваем значимые поля разбора. + Assert.Equal(expected.Title, result.Title); + Assert.Equal(expected.Stack, result.Stack); + Assert.Equal(expected.Budget, result.Budget); + Assert.Equal(expected.IsVacancy, result.IsVacancy); + Assert.Equal(expected.Board, result.Board); + } + + // ─── Контекст сценария ───────────────────────────────────────────────── + + // Контекст теста: декоратор над платным фейком + реальным Local-классификатором (fallback). + // Decorator: Декоратор бюджетного гейта. + // Paid: Платный фейк-исполнитель (счётчики/ответы сценария). + // Settings: KV-настройки тенанта (маркеры LocalFieldsParser). + private sealed record Context( + BudgetedAiClassifier Decorator, + FakeAiClassifier Paid, + FakeSettingsStore Settings); + + // Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст + // (зеркало регистрации AddDealIntegrations при UseLocal=false). + // configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет). + // Возвращает: Контекст теста. + private static Context Create(Action? configure = null) + { + var settings = new FakeSettingsStore(); + var limits = new FakeTenantLimitStore(); + configure?.Invoke(limits); + var tenantContext = new TenantContext(); + tenantContext.SetTenant(new TenantId(TenantIdValue)); + var paid = new FakeAiClassifier(); + var decorator = new BudgetedAiClassifier( + paid, + new LocalAiClassifier(new LocalFieldsParser(settings)), + limits, + tenantContext, + NullLogger.Instance); + return new Context(decorator, paid, settings); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs index 61d4c8a..f510f46 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs @@ -1,152 +1,152 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants.Models; -using Deal.Tests.Unit.Modules.Tenants; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты декоратора бюджетного гейта -/// -public sealed class BudgetedAiToolsTests -{ - // Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore. - private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; - - // Guid того же тенанта — ключ строки лимита. - private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue); - - // Начало периода строк лимита (не истёкший месяц). - private static readonly DateTimeOffset PeriodStart = new(2026, 9, 1, 0, 0, 0, TimeSpan.Zero); - - [Fact] - public async Task EvaluateFit_ExhaustedBudget_ThrowsAiUnavailableAndDoesNotCallPaid() - { - Context ctx = Create( - limits => limits.Preload( - TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 1000)); - ctx.Paid.Fit = new AiEvaluateFitResultDto(Fit: true, Reason: "подходит"); - - AiUnavailableException exception = await Assert.ThrowsAsync( - () => ctx.Decorator.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None)); - - Assert.Equal(0, ctx.Paid.EvaluateFitCalls); - Assert.Contains("исчерпан", exception.Message); - } - - [Fact] - public async Task EvaluateFit_ZeroBudgetLimit_ThrowsAiUnavailable() - { - Context ctx = Create( - limits => limits.Preload(TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0)); - - await Assert.ThrowsAsync( - () => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty(), CancellationToken.None)); - - Assert.Equal(0, ctx.Paid.EvaluateFitCalls); - } - - [Fact] - public async Task EvaluateFit_SuspendedTenant_ThrowsAiUnavailable() - { - Context ctx = Create( - limits => limits.Preload( - TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 100, - status: TenantStatuses.Suspended)); - - AiUnavailableException exception = await Assert.ThrowsAsync( - () => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty(), CancellationToken.None)); - - Assert.Equal(0, ctx.Paid.EvaluateFitCalls); - Assert.Contains("приостановлен", exception.Message); - } - - [Fact] - public async Task EvaluateFit_LargeBudget_CallsPaidAndReturnsItsResult() - { - // Строки лимита нет — ленивый дефолт (10 000 000/месяц, расход 0) → Allowed=true → платный вызов. - Context ctx = Create(); - ctx.Paid.Fit = new AiEvaluateFitResultDto(Fit: false, Reason: "не подходит"); - - AiEvaluateFitResultDto result = await ctx.Decorator.EvaluateFitAsync( - "текст", "описание", new[] { "ключ" }, CancellationToken.None); - - Assert.False(result.Fit); - Assert.Equal(1, ctx.Paid.EvaluateFitCalls); - } - - [Fact] - public async Task GenerateKeywords_ExhaustedBudget_ReturnsSoftErrorWithoutPaidCall() - { - Context ctx = Create( - limits => limits.Preload( - TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 1000)); - - AiGenerateKeywordsResultDto result = await ctx.Decorator.GenerateKeywordsAsync( - "описание ниши", CancellationToken.None); - - Assert.False(result.Ok); - Assert.Empty(result.Keywords); - Assert.Contains("исчерпан", result.Error); - // Платный GenerateKeywords бросает NotSupportedException — тест упал бы, если бы декоратор его позвал. - } - - [Fact] - public async Task GenerateKeywords_SuspendedTenant_ReturnsSoftError() - { - Context ctx = Create( - limits => limits.Preload( - TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 100, - status: TenantStatuses.Suspended)); - - AiGenerateKeywordsResultDto result = await ctx.Decorator.GenerateKeywordsAsync( - "описание ниши", CancellationToken.None); - - Assert.False(result.Ok); - Assert.Contains("приостановлен", result.Error); - } - - [Fact] - public async Task GenerateKeywords_LargeBudget_DelegatesToPaid() - { - // Строки лимита нет — ленивый дефолт → Allowed=true → декоратор делегирует платному исполнителю. - // FakeAiTools.GenerateKeywordsAsync всегда бросает NotSupportedException (вызов не ждали) — само - // исключение здесь и есть признак делегирования (упало бы в тесте, если бы декоратор не позвал платный). - Context ctx = Create(); - - await Assert.ThrowsAsync( - () => ctx.Decorator.GenerateKeywordsAsync("описание", CancellationToken.None)); - } - - // ─── Контекст сценария ───────────────────────────────────────────────── - - // Контекст теста: декоратор над платным фейком инструментов и фейком лимитов. - // Decorator: Декоратор бюджетного гейта. - // Paid: Платный фейк-исполнитель (счётчик/ответы сценария). - private sealed record Context( - BudgetedAiTools Decorator, - FakeAiTools Paid); - - // Собирает контекст: платный фейк + фейк лимитов и tenant-контекст (как регистрирует - // AddDealIntegrations при UseLocal=false). - // configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет). - // Возвращает: Контекст теста. - private static Context Create(Action? configure = null) - { - var limits = new FakeTenantLimitStore(); - configure?.Invoke(limits); - var tenantContext = new TenantContext(); - tenantContext.SetTenant(new TenantId(TenantIdValue)); - var paid = new FakeAiTools(); - var decorator = new BudgetedAiTools( - paid, - limits, - tenantContext, - NullLogger.Instance); - return new Context(decorator, paid); - } -} +using Deal.Contracts.Integrations.Models; +using Deal.Infrastructure.Data; +using Deal.Infrastructure.Integrations.Exceptions; +using Deal.Infrastructure.Integrations.Services; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Models; +using Deal.Tests.Unit.Modules.Tenants; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Contracts; + +/// +/// Тесты декоратора бюджетного гейта +/// +public sealed class BudgetedAiToolsTests +{ + // Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore. + private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; + + // Guid того же тенанта — ключ строки лимита. + private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue); + + // Начало периода строк лимита (не истёкший месяц). + private static readonly DateTimeOffset PeriodStart = new(2026, 9, 1, 0, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task EvaluateFit_ExhaustedBudget_ThrowsAiUnavailableAndDoesNotCallPaid() + { + Context ctx = Create( + limits => limits.Preload( + TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 1000)); + ctx.Paid.Fit = new AiEvaluateFitResultDto(Fit: true, Reason: "подходит"); + + AiUnavailableException exception = await Assert.ThrowsAsync( + () => ctx.Decorator.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None)); + + Assert.Equal(0, ctx.Paid.EvaluateFitCalls); + Assert.Contains("исчерпан", exception.Message); + } + + [Fact] + public async Task EvaluateFit_ZeroBudgetLimit_ThrowsAiUnavailable() + { + Context ctx = Create( + limits => limits.Preload(TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0)); + + await Assert.ThrowsAsync( + () => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty(), CancellationToken.None)); + + Assert.Equal(0, ctx.Paid.EvaluateFitCalls); + } + + [Fact] + public async Task EvaluateFit_SuspendedTenant_ThrowsAiUnavailable() + { + Context ctx = Create( + limits => limits.Preload( + TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 100, + status: TenantStatuses.Suspended)); + + AiUnavailableException exception = await Assert.ThrowsAsync( + () => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty(), CancellationToken.None)); + + Assert.Equal(0, ctx.Paid.EvaluateFitCalls); + Assert.Contains("приостановлен", exception.Message); + } + + [Fact] + public async Task EvaluateFit_LargeBudget_CallsPaidAndReturnsItsResult() + { + // Строки лимита нет — ленивый дефолт (10 000 000/месяц, расход 0) → Allowed=true → платный вызов. + Context ctx = Create(); + ctx.Paid.Fit = new AiEvaluateFitResultDto(Fit: false, Reason: "не подходит"); + + AiEvaluateFitResultDto result = await ctx.Decorator.EvaluateFitAsync( + "текст", "описание", new[] { "ключ" }, CancellationToken.None); + + Assert.False(result.Fit); + Assert.Equal(1, ctx.Paid.EvaluateFitCalls); + } + + [Fact] + public async Task GenerateKeywords_ExhaustedBudget_ReturnsSoftErrorWithoutPaidCall() + { + Context ctx = Create( + limits => limits.Preload( + TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 1000)); + + AiGenerateKeywordsResultDto result = await ctx.Decorator.GenerateKeywordsAsync( + "описание ниши", CancellationToken.None); + + Assert.False(result.Ok); + Assert.Empty(result.Keywords); + Assert.Contains("исчерпан", result.Error); + // Платный GenerateKeywords бросает NotSupportedException — тест упал бы, если бы декоратор его позвал. + } + + [Fact] + public async Task GenerateKeywords_SuspendedTenant_ReturnsSoftError() + { + Context ctx = Create( + limits => limits.Preload( + TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 100, + status: TenantStatuses.Suspended)); + + AiGenerateKeywordsResultDto result = await ctx.Decorator.GenerateKeywordsAsync( + "описание ниши", CancellationToken.None); + + Assert.False(result.Ok); + Assert.Contains("приостановлен", result.Error); + } + + [Fact] + public async Task GenerateKeywords_LargeBudget_DelegatesToPaid() + { + // Строки лимита нет — ленивый дефолт → Allowed=true → декоратор делегирует платному исполнителю. + // FakeAiTools.GenerateKeywordsAsync всегда бросает NotSupportedException (вызов не ждали) — само + // исключение здесь и есть признак делегирования (упало бы в тесте, если бы декоратор не позвал платный). + Context ctx = Create(); + + await Assert.ThrowsAsync( + () => ctx.Decorator.GenerateKeywordsAsync("описание", CancellationToken.None)); + } + + // ─── Контекст сценария ───────────────────────────────────────────────── + + // Контекст теста: декоратор над платным фейком инструментов и фейком лимитов. + // Decorator: Декоратор бюджетного гейта. + // Paid: Платный фейк-исполнитель (счётчик/ответы сценария). + private sealed record Context( + BudgetedAiTools Decorator, + FakeAiTools Paid); + + // Собирает контекст: платный фейк + фейк лимитов и tenant-контекст (как регистрирует + // AddDealIntegrations при UseLocal=false). + // configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет). + // Возвращает: Контекст теста. + private static Context Create(Action? configure = null) + { + var limits = new FakeTenantLimitStore(); + configure?.Invoke(limits); + var tenantContext = new TenantContext(); + tenantContext.SetTenant(new TenantId(TenantIdValue)); + var paid = new FakeAiTools(); + var decorator = new BudgetedAiTools( + paid, + limits, + tenantContext, + NullLogger.Instance); + return new Context(decorator, paid); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/CardComposerTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/CardComposerTests.cs index 5f4b510..5967b56 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/CardComposerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/CardComposerTests.cs @@ -1,308 +1,308 @@ -using System.Globalization; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты композитора карточки CardComposer. -/// -public sealed class CardComposerTests -{ - // ─── Сборка полной карточки (блоки «О заявке», бюджет+conv, контакты, источник) ─────── - - [Fact] - public async Task BuildAsync_FullParsedLead_ComposesCardSnapshotWithBlocksBudgetAndContacts() - { - (CardComposer composer, _, _) = Create(); - AiParsedCardDto parsed = Parsed( - title: "Python-разработчик на бота для CRM", - company: "ООО Ромашка", - format: "удалённо", - task: "разработка бота для CRM", - requirements: new[] { "Python", "aiogram" }, - plus: new[] { "Docker" }, - conditions: "оплата по результатам собеседования", - stack: new[] { "Python", "aiogram", "SQL" }, - budget: new AiBudgetDto(1500, 2000, "USD"), - contacts: new[] { new AiContactDto("tg", "@ivan_py"), new AiContactDto("email", "ivan@roma.ru") }, - isVacancy: true, - isVacancyKnown: true); - QueueItemDto message = Message("ООО Ромашка ищет Python-разработчика на бота для CRM, удалённо."); - - CardSnapshot snapshot = await composer.BuildAsync(parsed, message, cardId: "l_test1", CancellationToken.None); - - Assert.Equal("l_test1", snapshot.Id); - Assert.Equal(KanbanColumns.Inbox, snapshot.Col); - Assert.True(snapshot.IsNew); - Assert.Equal(KanbanColumns.Inbox, snapshot.PrevCol); - Assert.Empty(snapshot.MatchHits); // Ruling 2: для «Неразобранного» совпадений нет - Assert.True(snapshot.IsVacancy); - Assert.True(snapshot.IsVacancyKnown); // тип подтверждён ИИ (known=true разбора) - - Assert.Equal("Python-разработчик на бота для CRM", snapshot.Title); - Assert.Equal( - "Компания: ООО Ромашка\nФормат: удалённо\nО задаче: разработка бота для CRM\n" - + "Требования: Python, aiogram\nБудет плюсом: Docker\nУсловия: оплата по результатам собеседования", - snapshot.Summary); - Assert.Equal(new[] { "Python", "aiogram", "SQL" }, snapshot.Stack); - - // Бюджет как в разборе + конверсия «один раз при поступлении» (мок-курсы: USD → RUB). - Assert.Equal(new CardBudgetDto(1500, 2000, "USD"), Budget(snapshot)); - Assert.Equal( - new CardBudgetDto(1500 * MockRates.Values["USD"], 2000 * MockRates.Values["USD"], "RUB"), - Converted(snapshot)); - - // Контакты квалифицированы (значения разбора пере-квалифицированы), contact — основной (tg). - Assert.Equal( - new[] { new CardContactDto("tg", "@ivan_py"), new CardContactDto("email", "ivan@roma.ru") }, - snapshot.Contacts); - Assert.Equal("@ivan_py", snapshot.Contact); - - Assert.Equal("test", snapshot.Source.Kind); - Assert.Equal("d_1", snapshot.Source.OriginRef); - Assert.Equal("123", snapshot.Source.ExternalId); - Assert.Equal("Канал найма", snapshot.Source.DisplayName); - Assert.Equal("#ababab", snapshot.Source.ResolveHue()); - Assert.Equal(1_700_000_000_000L, snapshot.ReceivedAt.ToUnixTimeMilliseconds()); - Assert.Equal("ООО Ромашка ищет Python-разработчика на бота для CRM, удалённо.", snapshot.Content.Text); - } - - [Fact] - public async Task BuildAsync_LongSourceText_TruncatesContentTextTo4000CodePoints() - { - (CardComposer composer, _, _) = Create(); - string longText = new string('а', 4500); - - CardSnapshot snapshot = await composer.BuildAsync( - Parsed(title: "Заголовок"), - Message(longText), - cardId: "l_test2", - CancellationToken.None); - - Assert.Equal(4000, (snapshot.Content.Text ?? string.Empty).EnumerateRunes().Count()); // python text[:4000] (L503) - } - - [Fact] - public async Task BuildAsync_EmptyTitle_ReusesMessageStart() - { - (CardComposer composer, _, _) = Create(); - - CardSnapshot snapshot = await composer.BuildAsync( - Parsed(), - Message("Ищем Java-разработчика\nв команду на проект"), - cardId: "l_test3", - CancellationToken.None); - - Assert.Equal("Ищем Java-разработчика в команду на проект", snapshot.Title); // clean_short(text, 140) - } - - - [Fact] - public async Task BuildAsync_BoardWithoutRules_PlacesCardIntoBoardWithEmptyMatchHits() - { - (CardComposer composer, FakeKanjStore store, _) = Create(); - store.SeedBoard(new ContainerDto { Id = "b_py" }); - - CardSnapshot snapshot = await composer.BuildAsync( - Parsed(title: "Python-разработчик", board: "b_py"), - Message("Ищем Python-разработчика в команду"), - cardId: "l_test4", - CancellationToken.None); - - Assert.Equal("b_py", snapshot.Col); - Assert.Empty(snapshot.MatchHits); - } - - [Fact] - public async Task BuildAsync_BoardWithNonMatchingRules_FallsBackToInbox() - { - (CardComposer composer, FakeKanjStore store, _) = Create(); - store.SeedBoard(BoardWithRules("b_wpf", new[] { "wpf" })); - - CardSnapshot snapshot = await composer.BuildAsync( - Parsed(title: "Python-разработчик", board: "b_wpf"), - Message("Ищем Python-разработчика в команду"), - cardId: "l_test5", - CancellationToken.None); - - Assert.Equal(KanbanColumns.Inbox, snapshot.Col); - Assert.Empty(snapshot.MatchHits); - } - - [Fact] - public async Task BuildAsync_BoardWithMatchingRules_PlacesCardAndComputesMatchHits() - { - (CardComposer composer, FakeKanjStore store, _) = Create(); - store.SeedBoard(BoardWithRules("b_py", new[] { "python" })); - - CardSnapshot snapshot = await composer.BuildAsync( - Parsed(title: "Python-разработчик", board: "b_py"), - Message("Ищем Python-разработчика в команду"), - cardId: "l_test6", - CancellationToken.None); - - Assert.Equal("b_py", snapshot.Col); - MatchHitDto hit = Assert.Single(snapshot.MatchHits); - Assert.Equal("Слова", hit.Label); - Assert.Equal("python", hit.Term); - } - - [Fact] - public async Task BuildAsync_MissingBoard_FallsBackToInbox() - { - (CardComposer composer, _, _) = Create(); - - CardSnapshot snapshot = await composer.BuildAsync( - Parsed(title: "Python-разработчик", board: "b_deleted"), - Message("Ищем Python-разработчика в команду"), - cardId: "l_test7", - CancellationToken.None); - - Assert.Equal(KanbanColumns.Inbox, snapshot.Col); - Assert.Empty(snapshot.MatchHits); - } - - // ─── Бюджет: fallback из текста и выключатель конверсии ─────────────────────────────── - - [Fact] - public async Task BuildAsync_NoBudgetInParse_UsesFirstAmountFromText() - { - (CardComposer composer, _, _) = Create(); - - CardSnapshot snapshot = await composer.BuildAsync( - Parsed(title: "Лендинг для кофейни"), - Message("Заказ: лендинг для кофейни, дизайн и вёрстка. Бюджет 2 000$."), - cardId: "l_test8", - CancellationToken.None); - - Assert.Equal(new CardBudgetDto(2000, 2000, "USD"), Budget(snapshot)); - Assert.Equal( - new CardBudgetDto(2000 * MockRates.Values["USD"], 2000 * MockRates.Values["USD"], "RUB"), - Converted(snapshot)); - } - - [Fact] - public async Task BuildAsync_ConversionDisabled_LeavesConvFieldsEmpty() - { - (CardComposer composer, _, FakeSettingsStore settings) = Create(); - settings.Preload(SettingsKeys.ConversionOn, "false"); // budget_to_target L348–349 - - CardSnapshot snapshot = await composer.BuildAsync( - Parsed(budget: new AiBudgetDto(1500, 2000, "USD")), - Message("Ищем разработчика, оплата 1500–2000$"), - cardId: "l_test9", - CancellationToken.None); - - // Бюджет в исходной валюте сохраняется, конверсия снята (convCur = "" → пустые conv-поля). - Assert.Equal(new CardBudgetDto(1500, 2000, "USD"), Budget(snapshot)); - Assert.Empty(snapshot.ConvCur); - Assert.Null(snapshot.ConvFrom); - Assert.Null(snapshot.ConvTo); - } - - // ─── Контакты: fallback из текста и дефолт цвета канала ─────────────────────────────── - - [Fact] - public async Task BuildAsync_NoContactsInParse_ExtractsFromText() - { - (CardComposer composer, _, _) = Create(); - - CardSnapshot snapshot = await composer.BuildAsync( - Parsed(title: "Заказ на бота"), - Message("Нужен бот для CRM, пишите @client_py или на почту client@q.ru"), - cardId: "l_test10", - CancellationToken.None); - - Assert.Equal( - new[] { new CardContactDto("tg", "@client_py"), new CardContactDto("email", "client@q.ru") }, - snapshot.Contacts); - Assert.Equal("@client_py", snapshot.Contact); // primary_contact: tg важнее почты - } - - // ─── Хелперы ────────────────────────────────────────────────────────────────────────── - - // Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок). - // Возвращает: Кортеж (композитор, канбан-хранилище, KV-настройки). - private static (CardComposer Composer, FakeKanjStore Store, FakeSettingsStore Settings) Create() - { - var store = new FakeKanjStore(); - var settings = new FakeSettingsStore(); - return (new CardComposer(store, settings), store, settings); - } - - // Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля). - private static AiParsedCardDto Parsed( - string title = "", - string? company = null, - string? format = null, - string? task = null, - IReadOnlyList? requirements = null, - IReadOnlyList? plus = null, - string? conditions = null, - string? summary = null, - IReadOnlyList? stack = null, - AiBudgetDto? budget = null, - IReadOnlyList? contacts = null, - bool isVacancy = false, - bool isVacancyKnown = false, - bool isSpam = false, - string? board = null) => new( - title, company, format, task, requirements, plus, conditions, summary, - stack ?? Array.Empty(), - budget, - contacts ?? Array.Empty(), - isVacancy, isVacancyKnown, isSpam, board); - - // Строка очереди-источника со значениями по умолчанию (поля сообщения, не статус). - private static QueueItemDto Message( - string text, - string dialogId = "d_1", - long? msgId = 123, - string channelName = "Канал найма", - string channelHue = "#ababab", - long msgAtMs = 1_700_000_000_000) => SourceItemFactory.Queue( - "p_1", - text, - PipelineQueueStatuses.Filtered, - origin: dialogId, - externalId: msgId?.ToString(CultureInfo.InvariantCulture), - name: channelName, - hue: channelHue, - msgAtMs: msgAtMs, - queuedAtMs: msgAtMs); - - // Доска с правилами (для сценариев ContainerAccepts-страховки). - // id: Id доски (b_...). - // keywords: Группа ключевых слов правил (режим any). - private static ContainerDto BoardWithRules(string id, IReadOnlyList keywords) => new() - { - Id = id, - Rules = new ContainerRulesDto( - Mode: "any", - Direction: Array.Empty(), - Keywords: keywords, - Stack: Array.Empty(), - Grade: Array.Empty(), - Exclude: Array.Empty(), - Budget: null), - }; - - // Бюджет снимка (форма хранения — как CardBudgetDto карточки). - private static CardBudgetDto? Budget(CardSnapshot snapshot) => snapshot.BudgetCur.Length == 0 - ? null - : new CardBudgetDto(snapshot.BudgetFrom, snapshot.BudgetTo, snapshot.BudgetCur); - - // Конверсия снимка (форма хранения — как CardBudgetDto карточки). - private static CardBudgetDto? Converted(CardSnapshot snapshot) => snapshot.ConvCur.Length == 0 - ? null - : new CardBudgetDto(snapshot.ConvFrom, snapshot.ConvTo, snapshot.ConvCur); -} +using System.Globalization; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Services; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Support; + +namespace Deal.Tests.Unit.Contracts; + +/// +/// Тесты композитора карточки CardComposer. +/// +public sealed class CardComposerTests +{ + // ─── Сборка полной карточки (блоки «О заявке», бюджет+conv, контакты, источник) ─────── + + [Fact] + public async Task BuildAsync_FullParsedLead_ComposesCardSnapshotWithBlocksBudgetAndContacts() + { + (CardComposer composer, _, _) = Create(); + AiParsedCardDto parsed = Parsed( + title: "Python-разработчик на бота для CRM", + company: "ООО Ромашка", + format: "удалённо", + task: "разработка бота для CRM", + requirements: new[] { "Python", "aiogram" }, + plus: new[] { "Docker" }, + conditions: "оплата по результатам собеседования", + stack: new[] { "Python", "aiogram", "SQL" }, + budget: new AiBudgetDto(1500, 2000, "USD"), + contacts: new[] { new AiContactDto("tg", "@ivan_py"), new AiContactDto("email", "ivan@roma.ru") }, + isVacancy: true, + isVacancyKnown: true); + QueueItemDto message = Message("ООО Ромашка ищет Python-разработчика на бота для CRM, удалённо."); + + CardSnapshot snapshot = await composer.BuildAsync(parsed, message, cardId: "l_test1", CancellationToken.None); + + Assert.Equal("l_test1", snapshot.Id); + Assert.Equal(KanbanColumns.Inbox, snapshot.Col); + Assert.True(snapshot.IsNew); + Assert.Equal(KanbanColumns.Inbox, snapshot.PrevCol); + Assert.Empty(snapshot.MatchHits); // Ruling 2: для «Неразобранного» совпадений нет + Assert.True(snapshot.IsVacancy); + Assert.True(snapshot.IsVacancyKnown); // тип подтверждён ИИ (known=true разбора) + + Assert.Equal("Python-разработчик на бота для CRM", snapshot.Title); + Assert.Equal( + "Компания: ООО Ромашка\nФормат: удалённо\nО задаче: разработка бота для CRM\n" + + "Требования: Python, aiogram\nБудет плюсом: Docker\nУсловия: оплата по результатам собеседования", + snapshot.Summary); + Assert.Equal(new[] { "Python", "aiogram", "SQL" }, snapshot.Stack); + + // Бюджет как в разборе + конверсия «один раз при поступлении» (мок-курсы: USD → RUB). + Assert.Equal(new CardBudgetDto(1500, 2000, "USD"), Budget(snapshot)); + Assert.Equal( + new CardBudgetDto(1500 * MockRates.Values["USD"], 2000 * MockRates.Values["USD"], "RUB"), + Converted(snapshot)); + + // Контакты квалифицированы (значения разбора пере-квалифицированы), contact — основной (tg). + Assert.Equal( + new[] { new CardContactDto("tg", "@ivan_py"), new CardContactDto("email", "ivan@roma.ru") }, + snapshot.Contacts); + Assert.Equal("@ivan_py", snapshot.Contact); + + Assert.Equal("test", snapshot.Source.Kind); + Assert.Equal("d_1", snapshot.Source.OriginRef); + Assert.Equal("123", snapshot.Source.ExternalId); + Assert.Equal("Канал найма", snapshot.Source.DisplayName); + Assert.Equal("#ababab", snapshot.Source.ResolveHue()); + Assert.Equal(1_700_000_000_000L, snapshot.ReceivedAt.ToUnixTimeMilliseconds()); + Assert.Equal("ООО Ромашка ищет Python-разработчика на бота для CRM, удалённо.", snapshot.Content.Text); + } + + [Fact] + public async Task BuildAsync_LongSourceText_TruncatesContentTextTo4000CodePoints() + { + (CardComposer composer, _, _) = Create(); + string longText = new string('а', 4500); + + CardSnapshot snapshot = await composer.BuildAsync( + Parsed(title: "Заголовок"), + Message(longText), + cardId: "l_test2", + CancellationToken.None); + + Assert.Equal(4000, (snapshot.Content.Text ?? string.Empty).EnumerateRunes().Count()); // python text[:4000] (L503) + } + + [Fact] + public async Task BuildAsync_EmptyTitle_ReusesMessageStart() + { + (CardComposer composer, _, _) = Create(); + + CardSnapshot snapshot = await composer.BuildAsync( + Parsed(), + Message("Ищем Java-разработчика\nв команду на проект"), + cardId: "l_test3", + CancellationToken.None); + + Assert.Equal("Ищем Java-разработчика в команду на проект", snapshot.Title); // clean_short(text, 140) + } + + + [Fact] + public async Task BuildAsync_BoardWithoutRules_PlacesCardIntoBoardWithEmptyMatchHits() + { + (CardComposer composer, FakeKanjStore store, _) = Create(); + store.SeedBoard(new ContainerDto { Id = "b_py" }); + + CardSnapshot snapshot = await composer.BuildAsync( + Parsed(title: "Python-разработчик", board: "b_py"), + Message("Ищем Python-разработчика в команду"), + cardId: "l_test4", + CancellationToken.None); + + Assert.Equal("b_py", snapshot.Col); + Assert.Empty(snapshot.MatchHits); + } + + [Fact] + public async Task BuildAsync_BoardWithNonMatchingRules_FallsBackToInbox() + { + (CardComposer composer, FakeKanjStore store, _) = Create(); + store.SeedBoard(BoardWithRules("b_wpf", new[] { "wpf" })); + + CardSnapshot snapshot = await composer.BuildAsync( + Parsed(title: "Python-разработчик", board: "b_wpf"), + Message("Ищем Python-разработчика в команду"), + cardId: "l_test5", + CancellationToken.None); + + Assert.Equal(KanbanColumns.Inbox, snapshot.Col); + Assert.Empty(snapshot.MatchHits); + } + + [Fact] + public async Task BuildAsync_BoardWithMatchingRules_PlacesCardAndComputesMatchHits() + { + (CardComposer composer, FakeKanjStore store, _) = Create(); + store.SeedBoard(BoardWithRules("b_py", new[] { "python" })); + + CardSnapshot snapshot = await composer.BuildAsync( + Parsed(title: "Python-разработчик", board: "b_py"), + Message("Ищем Python-разработчика в команду"), + cardId: "l_test6", + CancellationToken.None); + + Assert.Equal("b_py", snapshot.Col); + MatchHitDto hit = Assert.Single(snapshot.MatchHits); + Assert.Equal("Слова", hit.Label); + Assert.Equal("python", hit.Term); + } + + [Fact] + public async Task BuildAsync_MissingBoard_FallsBackToInbox() + { + (CardComposer composer, _, _) = Create(); + + CardSnapshot snapshot = await composer.BuildAsync( + Parsed(title: "Python-разработчик", board: "b_deleted"), + Message("Ищем Python-разработчика в команду"), + cardId: "l_test7", + CancellationToken.None); + + Assert.Equal(KanbanColumns.Inbox, snapshot.Col); + Assert.Empty(snapshot.MatchHits); + } + + // ─── Бюджет: fallback из текста и выключатель конверсии ─────────────────────────────── + + [Fact] + public async Task BuildAsync_NoBudgetInParse_UsesFirstAmountFromText() + { + (CardComposer composer, _, _) = Create(); + + CardSnapshot snapshot = await composer.BuildAsync( + Parsed(title: "Лендинг для кофейни"), + Message("Заказ: лендинг для кофейни, дизайн и вёрстка. Бюджет 2 000$."), + cardId: "l_test8", + CancellationToken.None); + + Assert.Equal(new CardBudgetDto(2000, 2000, "USD"), Budget(snapshot)); + Assert.Equal( + new CardBudgetDto(2000 * MockRates.Values["USD"], 2000 * MockRates.Values["USD"], "RUB"), + Converted(snapshot)); + } + + [Fact] + public async Task BuildAsync_ConversionDisabled_LeavesConvFieldsEmpty() + { + (CardComposer composer, _, FakeSettingsStore settings) = Create(); + settings.Preload(SettingsKeys.ConversionOn, "false"); // budget_to_target L348–349 + + CardSnapshot snapshot = await composer.BuildAsync( + Parsed(budget: new AiBudgetDto(1500, 2000, "USD")), + Message("Ищем разработчика, оплата 1500–2000$"), + cardId: "l_test9", + CancellationToken.None); + + // Бюджет в исходной валюте сохраняется, конверсия снята (convCur = "" → пустые conv-поля). + Assert.Equal(new CardBudgetDto(1500, 2000, "USD"), Budget(snapshot)); + Assert.Empty(snapshot.ConvCur); + Assert.Null(snapshot.ConvFrom); + Assert.Null(snapshot.ConvTo); + } + + // ─── Контакты: fallback из текста и дефолт цвета канала ─────────────────────────────── + + [Fact] + public async Task BuildAsync_NoContactsInParse_ExtractsFromText() + { + (CardComposer composer, _, _) = Create(); + + CardSnapshot snapshot = await composer.BuildAsync( + Parsed(title: "Заказ на бота"), + Message("Нужен бот для CRM, пишите @client_py или на почту client@q.ru"), + cardId: "l_test10", + CancellationToken.None); + + Assert.Equal( + new[] { new CardContactDto("tg", "@client_py"), new CardContactDto("email", "client@q.ru") }, + snapshot.Contacts); + Assert.Equal("@client_py", snapshot.Contact); // primary_contact: tg важнее почты + } + + // ─── Хелперы ────────────────────────────────────────────────────────────────────────── + + // Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок). + // Возвращает: Кортеж (композитор, канбан-хранилище, KV-настройки). + private static (CardComposer Composer, FakeKanjStore Store, FakeSettingsStore Settings) Create() + { + var store = new FakeKanjStore(); + var settings = new FakeSettingsStore(); + return (new CardComposer(store, settings), store, settings); + } + + // Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля). + private static AiParsedCardDto Parsed( + string title = "", + string? company = null, + string? format = null, + string? task = null, + IReadOnlyList? requirements = null, + IReadOnlyList? plus = null, + string? conditions = null, + string? summary = null, + IReadOnlyList? stack = null, + AiBudgetDto? budget = null, + IReadOnlyList? contacts = null, + bool isVacancy = false, + bool isVacancyKnown = false, + bool isSpam = false, + string? board = null) => new( + title, company, format, task, requirements, plus, conditions, summary, + stack ?? Array.Empty(), + budget, + contacts ?? Array.Empty(), + isVacancy, isVacancyKnown, isSpam, board); + + // Строка очереди-источника со значениями по умолчанию (поля сообщения, не статус). + private static QueueItemDto Message( + string text, + string dialogId = "d_1", + long? msgId = 123, + string channelName = "Канал найма", + string channelHue = "#ababab", + long msgAtMs = 1_700_000_000_000) => SourceItemFactory.Queue( + "p_1", + text, + PipelineQueueStatuses.Filtered, + origin: dialogId, + externalId: msgId?.ToString(CultureInfo.InvariantCulture), + name: channelName, + hue: channelHue, + msgAtMs: msgAtMs, + queuedAtMs: msgAtMs); + + // Доска с правилами (для сценариев ContainerAccepts-страховки). + // id: Id доски (b_...). + // keywords: Группа ключевых слов правил (режим any). + private static ContainerDto BoardWithRules(string id, IReadOnlyList keywords) => new() + { + Id = id, + Rules = new ContainerRulesDto( + Mode: "any", + Direction: Array.Empty(), + Keywords: keywords, + Stack: Array.Empty(), + Grade: Array.Empty(), + Exclude: Array.Empty(), + Budget: null), + }; + + // Бюджет снимка (форма хранения — как CardBudgetDto карточки). + private static CardBudgetDto? Budget(CardSnapshot snapshot) => snapshot.BudgetCur.Length == 0 + ? null + : new CardBudgetDto(snapshot.BudgetFrom, snapshot.BudgetTo, snapshot.BudgetCur); + + // Конверсия снимка (форма хранения — как CardBudgetDto карточки). + private static CardBudgetDto? Converted(CardSnapshot snapshot) => snapshot.ConvCur.Length == 0 + ? null + : new CardBudgetDto(snapshot.ConvFrom, snapshot.ConvTo, snapshot.ConvCur); +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs index 85540e1..8de4b0a 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs @@ -1,792 +1,792 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты CardsService — карточки -/// -public sealed class CardsServiceTests -{ - - [Fact] - public async Task ListCards_NoCol_ReturnsAllOrderedByReceivedAtDesc() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("l_1", "inbox", receivedAtMs: 100)); - store.SeedCard(Card("l_2", "b_x", receivedAtMs: 300)); - - IReadOnlyList cards = await service.ListCardsAsync(col: null, CancellationToken.None); - - // Без фильтра — все карточки дашборда; порядок — received_at DESC. - Assert.Equal(new[] { "l_2", "l_1" }, cards.Select(card => card.Id)); - } - - [Fact] - public async Task ListCards_ByColumn_ReturnsOnlyColumnCards() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("l_1", "inbox")); - store.SeedCard(Card("l_2", KanbanColumns.Trash)); - - IReadOnlyList cards = await service.ListCardsAsync(KanbanColumns.Trash, CancellationToken.None); - - CardDto card = Assert.Single(cards); - Assert.Equal("l_2", card.Id); - } - - [Fact] - public async Task GetCard_Existing_ReturnsCard() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("l_1", "inbox", title: "Middle Python")); - - CardDto? card = await service.GetCardAsync("l_1", CancellationToken.None); - - Assert.NotNull(card); - Assert.Equal("Middle Python", card!.Title); - } - - [Fact] - public async Task GetCard_Missing_ReturnsNull() - { - (CardsService service, _, _, _) = Create(); - - CardDto? card = await service.GetCardAsync("l_missing", CancellationToken.None); - - Assert.Null(card); - } - - - [Fact] - public async Task Move_ToBoardWithRules_UpdatesColumnAndComputesMatchHits() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); - store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Middle Python-разработчик на бота, 1600$.")); - - CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); - - Assert.Null(result.Error); - Assert.Equal("b_py", result.Card!.Col); - Assert.False(result.Card.IsNew); // перенос снимает «новое» - Assert.Equal("inbox", result.Card.PrevCol); // prev_col = прежняя колонка - MatchHitDto hit = Assert.Single(result.Card.MatchHits); // «почему в колонке» (Ruling 2) - Assert.Equal("Стек", hit.Label); - Assert.Equal("python", hit.Term); - } - - [Fact] - public async Task Move_ToBoard_WritesJournalAndPush() - { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); - store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); - store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик")); - - await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); - - CardMoveDto move = Assert.Single(store.Moves); // журнал learning_log (Ruling 4) - Assert.StartsWith("lm_", move.Id); - Assert.Equal("l_1", move.LeadId); - Assert.Equal("move", move.Action); - Assert.Equal("inbox", move.FromCol); - Assert.Equal("b_py", move.ToCol); - (string text, string label, double delta) = Assert.Single(ml.Pushed); // обучение всегда (Ruling 4) - Assert.Equal("Нужен Python-разработчик", text); - Assert.Equal("b_py", label); - Assert.Equal(1.0, delta); - } - - [Fact] - public async Task Move_ToInbox_WritesJournalWithoutPush() - { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); - store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); - store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", prevCol: "inbox")); - - CardResultDto result = await service.MoveDashboardCardAsync("l_1", "inbox", CancellationToken.None); - - Assert.Null(result.Error); - Assert.Equal("inbox", result.Card!.Col); - CardMoveDto move = Assert.Single(store.Moves); - Assert.Equal("move", move.Action); - Assert.Equal("b_py", move.FromCol); - Assert.Equal("inbox", move.ToCol); - Assert.Empty(ml.Pushed); // «Неразобранное» не учим (Ruling 4, L188) - Assert.Empty(result.Card.MatchHits); // inbox — служебная колонка, hits пусто (Ruling 2) - } - - [Fact] - public async Task Move_ToUnknownBoard_Returns400Text() - { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); - store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик")); - - CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_ghost", CancellationToken.None); - - Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error); // 400 «Переносить можно только…» - Assert.Null(result.Card); - Assert.Empty(store.Moves); - Assert.Empty(ml.Pushed); - } - - [Fact] - public async Task Move_UnknownBoardAndMissingCard_Returns400TextFirst() - { - (CardsService service, _, _, _) = Create(); - - CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_ghost", CancellationToken.None); - - Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error); - } - - [Fact] - public async Task Move_CardMissing_ReturnsNullLead() - { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); - store.SeedBoard(Board("b_py")); - - CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None); - - Assert.Null(result.Error); - Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена» - Assert.Empty(store.Moves); - Assert.Empty(ml.Pushed); - } - - [Fact] - public async Task Move_SameColumn_NoOpWithoutJournalAndPush() - { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); - store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); - store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", isNew: true)); - - CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); - - Assert.Null(result.Error); - Assert.NotNull(result.Card); - Assert.Equal("b_py", result.Card!.Col); - Assert.True(result.Card.IsNew); // флаг не тронут - Assert.Empty(store.Moves); - Assert.Empty(ml.Pushed); - } - - [Fact] - public async Task Move_ToBoardWithoutRules_MatchHitsEmptyButPushSent() - { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); - store.SeedBoard(Board("b_free")); // правил нет - store.SeedCard(Card("l_1", "inbox", sourceMsg: "Любой текст")); - - CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_free", CancellationToken.None); - - Assert.Null(result.Error); - Assert.Equal("b_free", result.Card!.Col); - Assert.Empty(result.Card.MatchHits); // доска без активных правил → [] (Ruling 2) - Assert.Single(ml.Pushed); - Assert.Equal("b_free", Assert.Single(ml.Pushed).Label); - } - - [Fact] - public async Task Move_FromBoardToBoard_PrevColIsOldBoardAndPushNewLabel() - { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); - store.SeedBoard(Board("b_java", Rules(stack: new[] { "java" }))); - store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); - store.SeedCard(Card("l_1", "b_java", sourceMsg: "Нужен Java-разработчик", prevCol: "inbox")); - - CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); - - Assert.Equal("b_py", result.Card!.Col); - Assert.Equal("b_java", result.Card.PrevCol); - CardMoveDto move = Assert.Single(store.Moves); - Assert.Equal("b_java", move.FromCol); - Assert.Equal("b_py", move.ToCol); - (string _, string label, double _) = Assert.Single(ml.Pushed); - Assert.Equal("b_py", label); // учим НОВУЮ колонку (L188–191) - } - - [Fact] - public async Task Move_EmptySourceText_UsesTitleForMatchHitsAndPush() - { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); - store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); - store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", sourceMsg: " ")); - - CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); - - Assert.Single(result.Card!.MatchHits); - (string text, string _, double _) = Assert.Single(ml.Pushed); - Assert.Equal("Middle Python-разработчик", text); - } - - [Fact] - public async Task Move_EmptySourceAndTitle_JournalWithoutPush() - { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); - store.SeedBoard(Board("b_py")); - store.SeedCard(Card("l_1", "inbox")); - - await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); - - Assert.Single(store.Moves); // перенос записан - Assert.Empty(ml.Pushed); // пустой текст не учим (L190–191: if text) - } - - [Fact] - public async Task Move_ToBoardWithGradeAndBudget_HitsIncludeGradeWordAndBudget() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedBoard(Board("b_mid", Rules( - grade: new[] { "middle" }, - budget: new BudgetRangeDto(1000, 3000, "USD")))); - store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен мидл разработчик, 1 600–2 200$.")); - - CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_mid", CancellationToken.None); - - Assert.Equal(2, result.Card!.MatchHits.Count); - MatchHitDto grade = result.Card.MatchHits[0]; - Assert.Equal("Грейд/уровень", grade.Label); - Assert.Equal("middle", grade.Term); - Assert.Equal("мидл", grade.Word); // найденный синоним - MatchHitDto budget = result.Card.MatchHits[1]; - Assert.Equal("Бюджет", budget.Label); // сумма в USD в диапазоне 1000–3000 USD - } - - // ─── Move из служебных колонок запрещён (code-review: вывод — только restore) ── - - [Fact] - public async Task Move_FromArchive_Returns400AndWritesNothing() - { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); - store.SeedBoard(Board("b_py")); - store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая вакансия", prevCol: "b_py")); - - CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); - - Assert.Equal(CardsService.MoveSourceRestrictedDetail, result.Error); - Assert.Null(result.Card); - Assert.Equal(KanbanColumns.Archive, store.CardDtos.Single().Col); // карточка не тронута - Assert.Empty(store.Moves); // журнал не пишется - Assert.Empty(ml.Pushed); // обучение не шлётся - } - - [Fact] - public async Task Move_FromTrash_Returns400AndWritesNothing() - { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); - store.SeedBoard(Board("b_py")); - store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Спам-текст", prevCol: "b_py")); - - CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); - - Assert.Equal(CardsService.MoveSourceRestrictedDetail, result.Error); - Assert.Null(result.Card); - Assert.Equal(KanbanColumns.Trash, store.CardDtos.Single().Col); - Assert.Empty(store.Moves); - Assert.Empty(ml.Pushed); - } - - - [Fact] - public async Task Trash_FromInbox_MovesToTrashWithJournalAndSpamPush() - { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); - store.SeedCard(Card("l_1", "inbox", sourceMsg: "Предлагаю услуги продвижения", isNew: true)); - - CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None); - - Assert.Equal(KanbanColumns.Trash, result!.Col); - Assert.False(result.IsNew); - Assert.Equal("inbox", result.PrevCol); - Assert.Empty(result.MatchHits); // корзина — служебная, hits пусто (Ruling 2) - CardMoveDto move = Assert.Single(store.Moves); - Assert.Equal("trash", move.Action); - Assert.Equal("inbox", move.FromCol); - Assert.Equal(KanbanColumns.Trash, move.ToCol); - (string text, string label, double delta) = Assert.Single(ml.Pushed); // «в корзину» = спам (Ruling 4) - Assert.Equal("Предлагаю услуги продвижения", text); - Assert.Equal("spam", label); - Assert.Equal(1.0, delta); - } - - [Fact] - public async Task Trash_AlreadyInTrash_NoOpWithoutJournalAndPush() - { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); - store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст")); - - CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None); - - Assert.NotNull(result); - Assert.Empty(store.Moves); // _move L165–166: в ту же колонку не переносим - Assert.Empty(ml.Pushed); - } - - [Fact] - public async Task Trash_FromArchive_WritesJournalWithoutPush() - { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); - store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py")); - - CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None); - - Assert.Equal(KanbanColumns.Trash, result!.Col); - CardMoveDto move = Assert.Single(store.Moves); - Assert.Equal("trash", move.Action); - Assert.Equal(KanbanColumns.Archive, move.FromCol); - Assert.Empty(ml.Pushed); // карточка уже в archive — не «переучиваем» на спам (L198) - } - - [Fact] - public async Task Trash_CardMissing_ReturnsNull() - { - (CardsService service, _, _, FakeMlClient ml) = Create(); - - CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None); - - Assert.Null(result); // эндпоинт отвечает 404 «Карточка не найдена» - Assert.Empty(ml.Pushed); - } - - - [Fact] - public async Task Restore_FromTrash_ToPrevColBoard_UnlearnsSpam() - { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); - store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); - store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Нужен Python-разработчик", prevCol: "b_py")); - - string? back = await service.RestoreCardAsync("l_1", CancellationToken.None); - - Assert.Equal("b_py", back); // ответ {ok, col} - CardDto card = Assert.Single(store.CardDtos); - Assert.Equal("b_py", card.Col); - Assert.True(card.IsNew); // возврат — снова «новое» (L213) - Assert.Equal("inbox", card.PrevCol); // restore ставит prev_col='inbox' (Ruling 10) - Assert.Single(card.MatchHits); // hits пересчитаны для доски (Ruling 2) - CardMoveDto move = Assert.Single(store.Moves); - Assert.Equal("restore", move.Action); - Assert.Equal(KanbanColumns.Trash, move.FromCol); - Assert.Equal("b_py", move.ToCol); - (string text, string label, double delta) = Assert.Single(ml.Pushed); // возврат из корзины = не спам (L218–221) - Assert.Equal("Нужен Python-разработчик", text); - Assert.Equal("spam", label); - Assert.Equal(-1.0, delta); - } - - [Fact] - public async Task Restore_FromArchive_NoPush() - { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); - store.SeedBoard(Board("b_py")); - store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py")); - - string? back = await service.RestoreCardAsync("l_1", CancellationToken.None); - - Assert.Equal("b_py", back); - CardMoveDto move = Assert.Single(store.Moves); - Assert.Equal("restore", move.Action); - Assert.Equal(KanbanColumns.Archive, move.FromCol); - Assert.Empty(ml.Pushed); // только возврат ИЗ корзины снимает метку spam (L218) - } - - [Fact] - public async Task Restore_FromTrash_PrevColDeletedBoard_FallsBackToInbox() - { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); - store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "b_gone")); - - string? back = await service.RestoreCardAsync("l_1", CancellationToken.None); - - Assert.Equal("inbox", back); - CardDto card = Assert.Single(store.CardDtos); - Assert.Equal("inbox", card.Col); - Assert.Empty(card.MatchHits); - Assert.Equal(-1.0, Assert.Single(ml.Pushed).Delta); // из корзины — сигнал всё равно снимается - } - - [Fact] - public async Task Restore_FromTrash_PrevColInbox_ReturnsInbox() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "inbox")); - - string? back = await service.RestoreCardAsync("l_1", CancellationToken.None); - - Assert.Equal("inbox", back); - CardDto card = Assert.Single(store.CardDtos); - Assert.Equal("inbox", card.Col); - Assert.True(card.IsNew); - } - - [Fact] - public async Task Restore_CardMissing_ReturnsNull() - { - (CardsService service, _, _, _) = Create(); - - string? back = await service.RestoreCardAsync("l_ghost", CancellationToken.None); - - Assert.Null(back); // эндпоинт отвечает 404 «Карточка не найдена» - } - - - [Fact] - public async Task DeleteForever_RemovesCardAndComments_KeepsJournal() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("l_1", KanbanColumns.Trash, - sourceMsg: "Нужен Python-разработчик", - comments: [new CardCommentDto("cm_1", "Вы", "Перезвонить", "5 мин")])); - - bool deleted = await service.DeleteForeverAsync("l_1", CancellationToken.None); - - Assert.True(deleted); - Assert.DoesNotContain(store.CardDtos, card => card.Id == "l_1"); // карточка удалена - Assert.Empty(store.CardDtos); - } - - [Fact] - public async Task DeleteForever_JournalRowsSurviveCardDeletion() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); - store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик")); - - await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); // журнал: 1 запись move - await service.DeleteForeverAsync("l_1", CancellationToken.None); - - CardMoveDto move = Assert.Single(store.Moves); - Assert.Equal("move", move.Action); - Assert.Equal("l_1", move.LeadId); - } - - [Fact] - public async Task DeleteForever_CardMissing_ReturnsFalse() - { - (CardsService service, _, _, _) = Create(); - - bool deleted = await service.DeleteForeverAsync("l_ghost", CancellationToken.None); - - Assert.False(deleted); // эндпоинт отвечает 404 «Карточка не найдена» - } - - - [Fact] - public async Task ClearCol_Trash_ReturnsClearedCount() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("l_1", KanbanColumns.Trash)); - store.SeedCard(Card("l_2", KanbanColumns.Trash)); - store.SeedCard(Card("l_3", KanbanColumns.Archive)); - - ClearColResultDto result = await service.ClearColAsync(KanbanColumns.Trash, CancellationToken.None); - - Assert.Null(result.Error); - Assert.Equal(2, result.Cleared); - Assert.DoesNotContain(store.CardDtos, card => card.Col == KanbanColumns.Trash); - Assert.Single(store.CardDtos); // архив не тронут - } - - [Fact] - public async Task ClearCol_EmptyTrash_ReturnsZero() - { - (CardsService service, _, _, _) = Create(); - - ClearColResultDto result = await service.ClearColAsync(KanbanColumns.Trash, CancellationToken.None); - - Assert.Null(result.Error); - Assert.Equal(0, result.Cleared); - } - - [Fact] - public async Task ClearCol_Board_Returns400Text() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedBoard(Board("b_py")); - store.SeedCard(Card("l_1", "b_py")); - - ClearColResultDto result = await service.ClearColAsync("b_py", CancellationToken.None); - - Assert.Equal(CardsService.ClearColInvalidDetail, result.Error); // «Очищать можно только корзину или архив» - Assert.Single(store.CardDtos); // ничего не удалено - } - - [Fact] - public async Task ClearCol_Inbox_Returns400Text() - { - (CardsService service, _, _, _) = Create(); - - ClearColResultDto result = await service.ClearColAsync(KanbanColumns.Inbox, CancellationToken.None); - - Assert.Equal(CardsService.ClearColInvalidDetail, result.Error); - } - - - [Fact] - public async Task MarkSeen_ById_OnlyThatCard() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("l_1", "inbox", isNew: true)); - store.SeedCard(Card("l_2", "inbox", isNew: true)); - - await service.MarkSeenAsync("l_1", null, CancellationToken.None); - - Assert.False(Assert.Single(store.CardDtos, card => card.Id == "l_1").IsNew); - Assert.True(Assert.Single(store.CardDtos, card => card.Id == "l_2").IsNew); - } - - [Fact] - public async Task MarkSeen_ByCol_OnlyColumnCards() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("l_1", "inbox", isNew: true)); - store.SeedCard(Card("l_2", "inbox", isNew: true)); - store.SeedCard(Card("l_3", "b_py", isNew: true)); - - await service.MarkSeenAsync(null, "inbox", CancellationToken.None); - - Assert.DoesNotContain(store.CardDtos, card => card.Col == "inbox" && card.IsNew); - Assert.True(Assert.Single(store.CardDtos, card => card.Id == "l_3").IsNew); - } - - [Fact] - public async Task MarkSeen_All_WithoutParameters() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("l_1", "inbox", isNew: true)); - store.SeedCard(Card("l_2", KanbanColumns.Trash, isNew: true)); - - await service.MarkSeenAsync(null, null, CancellationToken.None); - - Assert.All(store.CardDtos, card => Assert.False(card.IsNew)); - } - - - [Fact] - public async Task AddComment_EmptyOrWhitespaceText_Returns400Text() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("l_1", "inbox")); - - AddCommentResultDto empty = await service.AddCommentAsync("l_1", " ", CancellationToken.None); - AddCommentResultDto nullText = await service.AddCommentAsync("l_1", string.Empty, CancellationToken.None); - - Assert.Equal(CardsService.EmptyCommentDetail, empty.Error); // «Пустой комментарий» - Assert.Equal(CardsService.EmptyCommentDetail, nullText.Error); - Assert.Empty(store.Moves); - } - - [Fact] - public async Task AddComment_Valid_AppendsTrimmedCommentWithJournal() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("l_1", "inbox")); - - AddCommentResultDto result = await service.AddCommentAsync("l_1", " Перезвонить завтра ", CancellationToken.None); - - Assert.Null(result.Error); - CardCommentDto comment = Assert.Single(result.Comments!); - Assert.StartsWith("cm_", comment.Id); // id комментария (Ruling 12) - Assert.Equal("Вы", comment.By); - Assert.Equal("Перезвонить завтра", comment.Text); // Trim (L262) - Assert.Equal("только что", comment.Time); - CardMoveDto move = Assert.Single(store.Moves); // комментарий — тоже действие журнала (L264) - Assert.Equal("comment", move.Action); - Assert.Null(move.FromCol); - Assert.Null(move.ToCol); - Assert.Single(Assert.Single(store.CardDtos).Comments); // комментарий сохранён на карточке - } - - [Fact] - public async Task AddComment_CardMissing_ReturnsNullComments() - { - (CardsService service, _, _, _) = Create(); - - AddCommentResultDto result = await service.AddCommentAsync("l_ghost", "Текст", CancellationToken.None); - - Assert.Null(result.Error); - Assert.Null(result.Comments); // эндпоинт отвечает 404 «Карточка не найдена» - } - - - [Fact] - public async Task Counts_ColumnsNewAndMlStats() - { - (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); - store.SeedCard(Card("l_1", "inbox", isNew: true)); - store.SeedCard(Card("l_2", "inbox")); - store.SeedCard(Card("l_3", "b_py")); - store.SeedCard(Card("l_4", KanbanColumns.Trash, isNew: true)); - ml.Status = MlStatus(learning: 7, ml: 2, ai: 3); - - CardCountsDto counts = await service.CountsAsync(CancellationToken.None); - - Assert.Equal(2, counts.New); // top-level new = сумма по колонкам (L274) - Assert.Equal(new CardColumnCountDto(2, 1), counts.Columns["inbox"]); - Assert.Equal(new CardColumnCountDto(1, 0), counts.Columns["b_py"]); - Assert.Equal(new CardColumnCountDto(1, 1), counts.Columns[KanbanColumns.Trash]); - Assert.Equal(7, counts.Learning); // из IMlClient.StatusAsync (план L321) - Assert.Equal(2, counts.Ml); - Assert.Equal(3, counts.Ai); - } - - [Fact] - public async Task Counts_EmptyStore_ZeroNewAndStats() - { - (CardsService service, _, _, _) = Create(); - - CardCountsDto counts = await service.CountsAsync(CancellationToken.None); - - Assert.Equal(0, counts.New); - Assert.Empty(counts.Columns); - Assert.Equal(0, counts.Learning); - } - - - [Fact] - public async Task Search_QueryShorterThanTwoChars_ReturnsEmptyAndDoesNotCallStore() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("l_1", "inbox", title: "Python-разработчик")); - - IReadOnlyList one = await service.SearchCardsAsync("p", CancellationToken.None); - IReadOnlyList blank = await service.SearchCardsAsync(" ", CancellationToken.None); - - Assert.Empty(one); // min 2 символа (L511–512) - Assert.Empty(blank); - Assert.Empty(store.SearchCalls); // порт не вызывается — поиск не имеет смысла - } - - [Fact] - public async Task Search_DelegatesToStoreWithQueryAndLimit() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300)); - - IReadOnlyList result = await service.SearchCardsAsync(" pYtHoN ", CancellationToken.None); - - CardDto card = Assert.Single(result); - Assert.Equal("l_1", card.Id); - (string query, int limit) = Assert.Single(store.SearchCalls); - Assert.Equal("python", query); - Assert.Equal(12, limit); - } - - [Fact] - public async Task Search_FindsByTitleSummaryContactSourceText_OrderedByReceivedAtDesc() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("l_title", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300)); - store.SeedCard(Card("l_summary", "b_py", summary: "проект на python", receivedAtMs: 200)); - store.SeedCard(Card("l_contact", "inbox", contact: "@python_dev", receivedAtMs: 100)); - store.SeedCard(Card("l_msg", "inbox", sourceMsg: "Нужен GO-разработчик", receivedAtMs: 400)); - - IReadOnlyList result = await service.SearchCardsAsync("pYtHoN", CancellationToken.None); - - // Регистр не важен (lower LIKE %q%), порядок — received_at DESC. - Assert.Equal(new[] { "l_title", "l_summary", "l_contact" }, result.Select(card => card.Id)); - } - - [Fact] - public async Task Search_LimitIsTwelve() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - for (int i = 0; i < 15; i++) - { - store.SeedCard(Card($"l_{i:00}", "inbox", title: $"python {i}", receivedAtMs: i)); - } - - IReadOnlyList result = await service.SearchCardsAsync("python", CancellationToken.None); - - Assert.Equal(12, result.Count); // limit=12 (L509) - Assert.Equal("l_14", result[0].Id); // свежие сверху - Assert.Equal("l_03", result[^1].Id); // 12 из 15 самых свежих - } - - // ─── Хелперы ────────────────────────────────────────────────────────── - - private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, FakeMlClient Ml) Create() - { - var store = new FakeKanjStore(); - var settings = new FakeSettingsStore(); - var ml = new FakeMlClient(); - return (new CardsService(store, settings, ml, new FakeFileStorage()), store, settings, ml); - } - - // Доска с правилами (ContainerRulesDto) либо без них. - private static ContainerDto Board(string id, ContainerRulesDto? rules = null) => - new() { Id = id, Name = id, Rules = rules }; - - // Правила колонки с непустыми группами из параметров (как в ColumnRulesTests). - private static ContainerRulesDto Rules( - string mode = "all", - string[]? stack = null, - string[]? grade = null, - BudgetRangeDto? budget = null) - { - return new ContainerRulesDto( - mode, - Array.Empty(), - Array.Empty(), - stack ?? Array.Empty(), - grade ?? Array.Empty(), - Array.Empty(), - budget); - } - - // Карточка с полями по умолчанию (col=inbox, isNew=false, prevCol=inbox). - private static CardDto Card( - string id, - string col, - bool isNew = false, - string title = "", - string summary = "", - string contact = "", - string sourceMsg = "", - string prevCol = KanbanColumns.Inbox, - long receivedAtMs = 0, - IReadOnlyList? comments = null) - { - return new CardDto - { - Id = id, - Col = col, - IsNew = isNew, - Title = title, - Summary = summary, - Contact = contact, - Content = new SourceContent { Text = sourceMsg }, - PrevCol = prevCol, - ReceivedAtMs = receivedAtMs, - Comments = comments ?? Array.Empty(), - }; - } - - // Ответ IMlClient.StatusAsync с заданными счётчиками (learning/ml/ai) для counts. - private static MlStatusResponseDto MlStatus( - int learning, - int ml, - int ai) - { - var service = new MlServiceStatusDto( - Ready: false, - Classes: new Dictionary(), - Learned: 0, - Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0)); - var stats = new MlStatsDto( - Ml: ml, - Ai: ai, - Learning: learning, - Ready: false, - Classes: new Dictionary(), - Learned: 0, - Reachable: true, - Outbox: 0); - return new MlStatusResponseDto(Enabled: true, Service: service, Reachable: true, Stats: stats); - } -} +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Settings; + +namespace Deal.Tests.Unit.Contracts; + +/// +/// Тесты CardsService — карточки +/// +public sealed class CardsServiceTests +{ + + [Fact] + public async Task ListCards_NoCol_ReturnsAllOrderedByReceivedAtDesc() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("l_1", "inbox", receivedAtMs: 100)); + store.SeedCard(Card("l_2", "b_x", receivedAtMs: 300)); + + IReadOnlyList cards = await service.ListCardsAsync(col: null, CancellationToken.None); + + // Без фильтра — все карточки дашборда; порядок — received_at DESC. + Assert.Equal(new[] { "l_2", "l_1" }, cards.Select(card => card.Id)); + } + + [Fact] + public async Task ListCards_ByColumn_ReturnsOnlyColumnCards() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("l_1", "inbox")); + store.SeedCard(Card("l_2", KanbanColumns.Trash)); + + IReadOnlyList cards = await service.ListCardsAsync(KanbanColumns.Trash, CancellationToken.None); + + CardDto card = Assert.Single(cards); + Assert.Equal("l_2", card.Id); + } + + [Fact] + public async Task GetCard_Existing_ReturnsCard() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("l_1", "inbox", title: "Middle Python")); + + CardDto? card = await service.GetCardAsync("l_1", CancellationToken.None); + + Assert.NotNull(card); + Assert.Equal("Middle Python", card!.Title); + } + + [Fact] + public async Task GetCard_Missing_ReturnsNull() + { + (CardsService service, _, _, _) = Create(); + + CardDto? card = await service.GetCardAsync("l_missing", CancellationToken.None); + + Assert.Null(card); + } + + + [Fact] + public async Task Move_ToBoardWithRules_UpdatesColumnAndComputesMatchHits() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); + store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Middle Python-разработчик на бота, 1600$.")); + + CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); + + Assert.Null(result.Error); + Assert.Equal("b_py", result.Card!.Col); + Assert.False(result.Card.IsNew); // перенос снимает «новое» + Assert.Equal("inbox", result.Card.PrevCol); // prev_col = прежняя колонка + MatchHitDto hit = Assert.Single(result.Card.MatchHits); // «почему в колонке» (Ruling 2) + Assert.Equal("Стек", hit.Label); + Assert.Equal("python", hit.Term); + } + + [Fact] + public async Task Move_ToBoard_WritesJournalAndPush() + { + (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); + store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик")); + + await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); + + CardMoveDto move = Assert.Single(store.Moves); // журнал learning_log (Ruling 4) + Assert.StartsWith("lm_", move.Id); + Assert.Equal("l_1", move.LeadId); + Assert.Equal("move", move.Action); + Assert.Equal("inbox", move.FromCol); + Assert.Equal("b_py", move.ToCol); + (string text, string label, double delta) = Assert.Single(ml.Pushed); // обучение всегда (Ruling 4) + Assert.Equal("Нужен Python-разработчик", text); + Assert.Equal("b_py", label); + Assert.Equal(1.0, delta); + } + + [Fact] + public async Task Move_ToInbox_WritesJournalWithoutPush() + { + (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); + store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", prevCol: "inbox")); + + CardResultDto result = await service.MoveDashboardCardAsync("l_1", "inbox", CancellationToken.None); + + Assert.Null(result.Error); + Assert.Equal("inbox", result.Card!.Col); + CardMoveDto move = Assert.Single(store.Moves); + Assert.Equal("move", move.Action); + Assert.Equal("b_py", move.FromCol); + Assert.Equal("inbox", move.ToCol); + Assert.Empty(ml.Pushed); // «Неразобранное» не учим (Ruling 4, L188) + Assert.Empty(result.Card.MatchHits); // inbox — служебная колонка, hits пусто (Ruling 2) + } + + [Fact] + public async Task Move_ToUnknownBoard_Returns400Text() + { + (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик")); + + CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_ghost", CancellationToken.None); + + Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error); // 400 «Переносить можно только…» + Assert.Null(result.Card); + Assert.Empty(store.Moves); + Assert.Empty(ml.Pushed); + } + + [Fact] + public async Task Move_UnknownBoardAndMissingCard_Returns400TextFirst() + { + (CardsService service, _, _, _) = Create(); + + CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_ghost", CancellationToken.None); + + Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error); + } + + [Fact] + public async Task Move_CardMissing_ReturnsNullLead() + { + (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + store.SeedBoard(Board("b_py")); + + CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None); + + Assert.Null(result.Error); + Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена» + Assert.Empty(store.Moves); + Assert.Empty(ml.Pushed); + } + + [Fact] + public async Task Move_SameColumn_NoOpWithoutJournalAndPush() + { + (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); + store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", isNew: true)); + + CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); + + Assert.Null(result.Error); + Assert.NotNull(result.Card); + Assert.Equal("b_py", result.Card!.Col); + Assert.True(result.Card.IsNew); // флаг не тронут + Assert.Empty(store.Moves); + Assert.Empty(ml.Pushed); + } + + [Fact] + public async Task Move_ToBoardWithoutRules_MatchHitsEmptyButPushSent() + { + (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + store.SeedBoard(Board("b_free")); // правил нет + store.SeedCard(Card("l_1", "inbox", sourceMsg: "Любой текст")); + + CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_free", CancellationToken.None); + + Assert.Null(result.Error); + Assert.Equal("b_free", result.Card!.Col); + Assert.Empty(result.Card.MatchHits); // доска без активных правил → [] (Ruling 2) + Assert.Single(ml.Pushed); + Assert.Equal("b_free", Assert.Single(ml.Pushed).Label); + } + + [Fact] + public async Task Move_FromBoardToBoard_PrevColIsOldBoardAndPushNewLabel() + { + (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + store.SeedBoard(Board("b_java", Rules(stack: new[] { "java" }))); + store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); + store.SeedCard(Card("l_1", "b_java", sourceMsg: "Нужен Java-разработчик", prevCol: "inbox")); + + CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); + + Assert.Equal("b_py", result.Card!.Col); + Assert.Equal("b_java", result.Card.PrevCol); + CardMoveDto move = Assert.Single(store.Moves); + Assert.Equal("b_java", move.FromCol); + Assert.Equal("b_py", move.ToCol); + (string _, string label, double _) = Assert.Single(ml.Pushed); + Assert.Equal("b_py", label); // учим НОВУЮ колонку (L188–191) + } + + [Fact] + public async Task Move_EmptySourceText_UsesTitleForMatchHitsAndPush() + { + (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); + store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", sourceMsg: " ")); + + CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); + + Assert.Single(result.Card!.MatchHits); + (string text, string _, double _) = Assert.Single(ml.Pushed); + Assert.Equal("Middle Python-разработчик", text); + } + + [Fact] + public async Task Move_EmptySourceAndTitle_JournalWithoutPush() + { + (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + store.SeedBoard(Board("b_py")); + store.SeedCard(Card("l_1", "inbox")); + + await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); + + Assert.Single(store.Moves); // перенос записан + Assert.Empty(ml.Pushed); // пустой текст не учим (L190–191: if text) + } + + [Fact] + public async Task Move_ToBoardWithGradeAndBudget_HitsIncludeGradeWordAndBudget() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedBoard(Board("b_mid", Rules( + grade: new[] { "middle" }, + budget: new BudgetRangeDto(1000, 3000, "USD")))); + store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен мидл разработчик, 1 600–2 200$.")); + + CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_mid", CancellationToken.None); + + Assert.Equal(2, result.Card!.MatchHits.Count); + MatchHitDto grade = result.Card.MatchHits[0]; + Assert.Equal("Грейд/уровень", grade.Label); + Assert.Equal("middle", grade.Term); + Assert.Equal("мидл", grade.Word); // найденный синоним + MatchHitDto budget = result.Card.MatchHits[1]; + Assert.Equal("Бюджет", budget.Label); // сумма в USD в диапазоне 1000–3000 USD + } + + // ─── Move из служебных колонок запрещён (code-review: вывод — только restore) ── + + [Fact] + public async Task Move_FromArchive_Returns400AndWritesNothing() + { + (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + store.SeedBoard(Board("b_py")); + store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая вакансия", prevCol: "b_py")); + + CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); + + Assert.Equal(CardsService.MoveSourceRestrictedDetail, result.Error); + Assert.Null(result.Card); + Assert.Equal(KanbanColumns.Archive, store.CardDtos.Single().Col); // карточка не тронута + Assert.Empty(store.Moves); // журнал не пишется + Assert.Empty(ml.Pushed); // обучение не шлётся + } + + [Fact] + public async Task Move_FromTrash_Returns400AndWritesNothing() + { + (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + store.SeedBoard(Board("b_py")); + store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Спам-текст", prevCol: "b_py")); + + CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); + + Assert.Equal(CardsService.MoveSourceRestrictedDetail, result.Error); + Assert.Null(result.Card); + Assert.Equal(KanbanColumns.Trash, store.CardDtos.Single().Col); + Assert.Empty(store.Moves); + Assert.Empty(ml.Pushed); + } + + + [Fact] + public async Task Trash_FromInbox_MovesToTrashWithJournalAndSpamPush() + { + (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + store.SeedCard(Card("l_1", "inbox", sourceMsg: "Предлагаю услуги продвижения", isNew: true)); + + CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None); + + Assert.Equal(KanbanColumns.Trash, result!.Col); + Assert.False(result.IsNew); + Assert.Equal("inbox", result.PrevCol); + Assert.Empty(result.MatchHits); // корзина — служебная, hits пусто (Ruling 2) + CardMoveDto move = Assert.Single(store.Moves); + Assert.Equal("trash", move.Action); + Assert.Equal("inbox", move.FromCol); + Assert.Equal(KanbanColumns.Trash, move.ToCol); + (string text, string label, double delta) = Assert.Single(ml.Pushed); // «в корзину» = спам (Ruling 4) + Assert.Equal("Предлагаю услуги продвижения", text); + Assert.Equal("spam", label); + Assert.Equal(1.0, delta); + } + + [Fact] + public async Task Trash_AlreadyInTrash_NoOpWithoutJournalAndPush() + { + (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст")); + + CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None); + + Assert.NotNull(result); + Assert.Empty(store.Moves); // _move L165–166: в ту же колонку не переносим + Assert.Empty(ml.Pushed); + } + + [Fact] + public async Task Trash_FromArchive_WritesJournalWithoutPush() + { + (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py")); + + CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None); + + Assert.Equal(KanbanColumns.Trash, result!.Col); + CardMoveDto move = Assert.Single(store.Moves); + Assert.Equal("trash", move.Action); + Assert.Equal(KanbanColumns.Archive, move.FromCol); + Assert.Empty(ml.Pushed); // карточка уже в archive — не «переучиваем» на спам (L198) + } + + [Fact] + public async Task Trash_CardMissing_ReturnsNull() + { + (CardsService service, _, _, FakeMlClient ml) = Create(); + + CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None); + + Assert.Null(result); // эндпоинт отвечает 404 «Карточка не найдена» + Assert.Empty(ml.Pushed); + } + + + [Fact] + public async Task Restore_FromTrash_ToPrevColBoard_UnlearnsSpam() + { + (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); + store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Нужен Python-разработчик", prevCol: "b_py")); + + string? back = await service.RestoreCardAsync("l_1", CancellationToken.None); + + Assert.Equal("b_py", back); // ответ {ok, col} + CardDto card = Assert.Single(store.CardDtos); + Assert.Equal("b_py", card.Col); + Assert.True(card.IsNew); // возврат — снова «новое» (L213) + Assert.Equal("inbox", card.PrevCol); // restore ставит prev_col='inbox' (Ruling 10) + Assert.Single(card.MatchHits); // hits пересчитаны для доски (Ruling 2) + CardMoveDto move = Assert.Single(store.Moves); + Assert.Equal("restore", move.Action); + Assert.Equal(KanbanColumns.Trash, move.FromCol); + Assert.Equal("b_py", move.ToCol); + (string text, string label, double delta) = Assert.Single(ml.Pushed); // возврат из корзины = не спам (L218–221) + Assert.Equal("Нужен Python-разработчик", text); + Assert.Equal("spam", label); + Assert.Equal(-1.0, delta); + } + + [Fact] + public async Task Restore_FromArchive_NoPush() + { + (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + store.SeedBoard(Board("b_py")); + store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py")); + + string? back = await service.RestoreCardAsync("l_1", CancellationToken.None); + + Assert.Equal("b_py", back); + CardMoveDto move = Assert.Single(store.Moves); + Assert.Equal("restore", move.Action); + Assert.Equal(KanbanColumns.Archive, move.FromCol); + Assert.Empty(ml.Pushed); // только возврат ИЗ корзины снимает метку spam (L218) + } + + [Fact] + public async Task Restore_FromTrash_PrevColDeletedBoard_FallsBackToInbox() + { + (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "b_gone")); + + string? back = await service.RestoreCardAsync("l_1", CancellationToken.None); + + Assert.Equal("inbox", back); + CardDto card = Assert.Single(store.CardDtos); + Assert.Equal("inbox", card.Col); + Assert.Empty(card.MatchHits); + Assert.Equal(-1.0, Assert.Single(ml.Pushed).Delta); // из корзины — сигнал всё равно снимается + } + + [Fact] + public async Task Restore_FromTrash_PrevColInbox_ReturnsInbox() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "inbox")); + + string? back = await service.RestoreCardAsync("l_1", CancellationToken.None); + + Assert.Equal("inbox", back); + CardDto card = Assert.Single(store.CardDtos); + Assert.Equal("inbox", card.Col); + Assert.True(card.IsNew); + } + + [Fact] + public async Task Restore_CardMissing_ReturnsNull() + { + (CardsService service, _, _, _) = Create(); + + string? back = await service.RestoreCardAsync("l_ghost", CancellationToken.None); + + Assert.Null(back); // эндпоинт отвечает 404 «Карточка не найдена» + } + + + [Fact] + public async Task DeleteForever_RemovesCardAndComments_KeepsJournal() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("l_1", KanbanColumns.Trash, + sourceMsg: "Нужен Python-разработчик", + comments: [new CardCommentDto("cm_1", "Вы", "Перезвонить", "5 мин")])); + + bool deleted = await service.DeleteForeverAsync("l_1", CancellationToken.None); + + Assert.True(deleted); + Assert.DoesNotContain(store.CardDtos, card => card.Id == "l_1"); // карточка удалена + Assert.Empty(store.CardDtos); + } + + [Fact] + public async Task DeleteForever_JournalRowsSurviveCardDeletion() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" }))); + store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик")); + + await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); // журнал: 1 запись move + await service.DeleteForeverAsync("l_1", CancellationToken.None); + + CardMoveDto move = Assert.Single(store.Moves); + Assert.Equal("move", move.Action); + Assert.Equal("l_1", move.LeadId); + } + + [Fact] + public async Task DeleteForever_CardMissing_ReturnsFalse() + { + (CardsService service, _, _, _) = Create(); + + bool deleted = await service.DeleteForeverAsync("l_ghost", CancellationToken.None); + + Assert.False(deleted); // эндпоинт отвечает 404 «Карточка не найдена» + } + + + [Fact] + public async Task ClearCol_Trash_ReturnsClearedCount() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("l_1", KanbanColumns.Trash)); + store.SeedCard(Card("l_2", KanbanColumns.Trash)); + store.SeedCard(Card("l_3", KanbanColumns.Archive)); + + ClearColResultDto result = await service.ClearColAsync(KanbanColumns.Trash, CancellationToken.None); + + Assert.Null(result.Error); + Assert.Equal(2, result.Cleared); + Assert.DoesNotContain(store.CardDtos, card => card.Col == KanbanColumns.Trash); + Assert.Single(store.CardDtos); // архив не тронут + } + + [Fact] + public async Task ClearCol_EmptyTrash_ReturnsZero() + { + (CardsService service, _, _, _) = Create(); + + ClearColResultDto result = await service.ClearColAsync(KanbanColumns.Trash, CancellationToken.None); + + Assert.Null(result.Error); + Assert.Equal(0, result.Cleared); + } + + [Fact] + public async Task ClearCol_Board_Returns400Text() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedBoard(Board("b_py")); + store.SeedCard(Card("l_1", "b_py")); + + ClearColResultDto result = await service.ClearColAsync("b_py", CancellationToken.None); + + Assert.Equal(CardsService.ClearColInvalidDetail, result.Error); // «Очищать можно только корзину или архив» + Assert.Single(store.CardDtos); // ничего не удалено + } + + [Fact] + public async Task ClearCol_Inbox_Returns400Text() + { + (CardsService service, _, _, _) = Create(); + + ClearColResultDto result = await service.ClearColAsync(KanbanColumns.Inbox, CancellationToken.None); + + Assert.Equal(CardsService.ClearColInvalidDetail, result.Error); + } + + + [Fact] + public async Task MarkSeen_ById_OnlyThatCard() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("l_1", "inbox", isNew: true)); + store.SeedCard(Card("l_2", "inbox", isNew: true)); + + await service.MarkSeenAsync("l_1", null, CancellationToken.None); + + Assert.False(Assert.Single(store.CardDtos, card => card.Id == "l_1").IsNew); + Assert.True(Assert.Single(store.CardDtos, card => card.Id == "l_2").IsNew); + } + + [Fact] + public async Task MarkSeen_ByCol_OnlyColumnCards() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("l_1", "inbox", isNew: true)); + store.SeedCard(Card("l_2", "inbox", isNew: true)); + store.SeedCard(Card("l_3", "b_py", isNew: true)); + + await service.MarkSeenAsync(null, "inbox", CancellationToken.None); + + Assert.DoesNotContain(store.CardDtos, card => card.Col == "inbox" && card.IsNew); + Assert.True(Assert.Single(store.CardDtos, card => card.Id == "l_3").IsNew); + } + + [Fact] + public async Task MarkSeen_All_WithoutParameters() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("l_1", "inbox", isNew: true)); + store.SeedCard(Card("l_2", KanbanColumns.Trash, isNew: true)); + + await service.MarkSeenAsync(null, null, CancellationToken.None); + + Assert.All(store.CardDtos, card => Assert.False(card.IsNew)); + } + + + [Fact] + public async Task AddComment_EmptyOrWhitespaceText_Returns400Text() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("l_1", "inbox")); + + AddCommentResultDto empty = await service.AddCommentAsync("l_1", " ", CancellationToken.None); + AddCommentResultDto nullText = await service.AddCommentAsync("l_1", string.Empty, CancellationToken.None); + + Assert.Equal(CardsService.EmptyCommentDetail, empty.Error); // «Пустой комментарий» + Assert.Equal(CardsService.EmptyCommentDetail, nullText.Error); + Assert.Empty(store.Moves); + } + + [Fact] + public async Task AddComment_Valid_AppendsTrimmedCommentWithJournal() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("l_1", "inbox")); + + AddCommentResultDto result = await service.AddCommentAsync("l_1", " Перезвонить завтра ", CancellationToken.None); + + Assert.Null(result.Error); + CardCommentDto comment = Assert.Single(result.Comments!); + Assert.StartsWith("cm_", comment.Id); // id комментария (Ruling 12) + Assert.Equal("Вы", comment.By); + Assert.Equal("Перезвонить завтра", comment.Text); // Trim (L262) + Assert.Equal("только что", comment.Time); + CardMoveDto move = Assert.Single(store.Moves); // комментарий — тоже действие журнала (L264) + Assert.Equal("comment", move.Action); + Assert.Null(move.FromCol); + Assert.Null(move.ToCol); + Assert.Single(Assert.Single(store.CardDtos).Comments); // комментарий сохранён на карточке + } + + [Fact] + public async Task AddComment_CardMissing_ReturnsNullComments() + { + (CardsService service, _, _, _) = Create(); + + AddCommentResultDto result = await service.AddCommentAsync("l_ghost", "Текст", CancellationToken.None); + + Assert.Null(result.Error); + Assert.Null(result.Comments); // эндпоинт отвечает 404 «Карточка не найдена» + } + + + [Fact] + public async Task Counts_ColumnsNewAndMlStats() + { + (CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create(); + store.SeedCard(Card("l_1", "inbox", isNew: true)); + store.SeedCard(Card("l_2", "inbox")); + store.SeedCard(Card("l_3", "b_py")); + store.SeedCard(Card("l_4", KanbanColumns.Trash, isNew: true)); + ml.Status = MlStatus(learning: 7, ml: 2, ai: 3); + + CardCountsDto counts = await service.CountsAsync(CancellationToken.None); + + Assert.Equal(2, counts.New); // top-level new = сумма по колонкам (L274) + Assert.Equal(new CardColumnCountDto(2, 1), counts.Columns["inbox"]); + Assert.Equal(new CardColumnCountDto(1, 0), counts.Columns["b_py"]); + Assert.Equal(new CardColumnCountDto(1, 1), counts.Columns[KanbanColumns.Trash]); + Assert.Equal(7, counts.Learning); // из IMlClient.StatusAsync (план L321) + Assert.Equal(2, counts.Ml); + Assert.Equal(3, counts.Ai); + } + + [Fact] + public async Task Counts_EmptyStore_ZeroNewAndStats() + { + (CardsService service, _, _, _) = Create(); + + CardCountsDto counts = await service.CountsAsync(CancellationToken.None); + + Assert.Equal(0, counts.New); + Assert.Empty(counts.Columns); + Assert.Equal(0, counts.Learning); + } + + + [Fact] + public async Task Search_QueryShorterThanTwoChars_ReturnsEmptyAndDoesNotCallStore() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("l_1", "inbox", title: "Python-разработчик")); + + IReadOnlyList one = await service.SearchCardsAsync("p", CancellationToken.None); + IReadOnlyList blank = await service.SearchCardsAsync(" ", CancellationToken.None); + + Assert.Empty(one); // min 2 символа (L511–512) + Assert.Empty(blank); + Assert.Empty(store.SearchCalls); // порт не вызывается — поиск не имеет смысла + } + + [Fact] + public async Task Search_DelegatesToStoreWithQueryAndLimit() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300)); + + IReadOnlyList result = await service.SearchCardsAsync(" pYtHoN ", CancellationToken.None); + + CardDto card = Assert.Single(result); + Assert.Equal("l_1", card.Id); + (string query, int limit) = Assert.Single(store.SearchCalls); + Assert.Equal("python", query); + Assert.Equal(12, limit); + } + + [Fact] + public async Task Search_FindsByTitleSummaryContactSourceText_OrderedByReceivedAtDesc() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("l_title", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300)); + store.SeedCard(Card("l_summary", "b_py", summary: "проект на python", receivedAtMs: 200)); + store.SeedCard(Card("l_contact", "inbox", contact: "@python_dev", receivedAtMs: 100)); + store.SeedCard(Card("l_msg", "inbox", sourceMsg: "Нужен GO-разработчик", receivedAtMs: 400)); + + IReadOnlyList result = await service.SearchCardsAsync("pYtHoN", CancellationToken.None); + + // Регистр не важен (lower LIKE %q%), порядок — received_at DESC. + Assert.Equal(new[] { "l_title", "l_summary", "l_contact" }, result.Select(card => card.Id)); + } + + [Fact] + public async Task Search_LimitIsTwelve() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + for (int i = 0; i < 15; i++) + { + store.SeedCard(Card($"l_{i:00}", "inbox", title: $"python {i}", receivedAtMs: i)); + } + + IReadOnlyList result = await service.SearchCardsAsync("python", CancellationToken.None); + + Assert.Equal(12, result.Count); // limit=12 (L509) + Assert.Equal("l_14", result[0].Id); // свежие сверху + Assert.Equal("l_03", result[^1].Id); // 12 из 15 самых свежих + } + + // ─── Хелперы ────────────────────────────────────────────────────────── + + private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, FakeMlClient Ml) Create() + { + var store = new FakeKanjStore(); + var settings = new FakeSettingsStore(); + var ml = new FakeMlClient(); + return (new CardsService(store, settings, ml, new FakeFileStorage()), store, settings, ml); + } + + // Доска с правилами (ContainerRulesDto) либо без них. + private static ContainerDto Board(string id, ContainerRulesDto? rules = null) => + new() { Id = id, Name = id, Rules = rules }; + + // Правила колонки с непустыми группами из параметров (как в ColumnRulesTests). + private static ContainerRulesDto Rules( + string mode = "all", + string[]? stack = null, + string[]? grade = null, + BudgetRangeDto? budget = null) + { + return new ContainerRulesDto( + mode, + Array.Empty(), + Array.Empty(), + stack ?? Array.Empty(), + grade ?? Array.Empty(), + Array.Empty(), + budget); + } + + // Карточка с полями по умолчанию (col=inbox, isNew=false, prevCol=inbox). + private static CardDto Card( + string id, + string col, + bool isNew = false, + string title = "", + string summary = "", + string contact = "", + string sourceMsg = "", + string prevCol = KanbanColumns.Inbox, + long receivedAtMs = 0, + IReadOnlyList? comments = null) + { + return new CardDto + { + Id = id, + Col = col, + IsNew = isNew, + Title = title, + Summary = summary, + Contact = contact, + Content = new SourceContent { Text = sourceMsg }, + PrevCol = prevCol, + ReceivedAtMs = receivedAtMs, + Comments = comments ?? Array.Empty(), + }; + } + + // Ответ IMlClient.StatusAsync с заданными счётчиками (learning/ml/ai) для counts. + private static MlStatusResponseDto MlStatus( + int learning, + int ml, + int ai) + { + var service = new MlServiceStatusDto( + Ready: false, + Classes: new Dictionary(), + Learned: 0, + Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0)); + var stats = new MlStatsDto( + Ml: ml, + Ai: ai, + Learning: learning, + Ready: false, + Classes: new Dictionary(), + Learned: 0, + Reachable: true, + Outbox: 0); + return new MlStatusResponseDto(Enabled: true, Service: service, Reachable: true, Stats: stats); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/DialogsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/DialogsServiceTests.cs index c906643..01f9f2b 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/DialogsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/DialogsServiceTests.cs @@ -1,528 +1,528 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Telegram.Application.Models; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Support; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты DialogsService — каталог диалогов/каналов и зеркало мониторинга ядра. -/// -public sealed class DialogsServiceTests -{ - // ─── SyncFromTelegram: авто-мониторинг новых, обновление, удаление отсутствующих ── - - /// - /// Новые диалоги при дефолтной autoMonitorNew=true появляются включёнными. - /// - [Fact] - public async Task Sync_NewDialogsAutoMonitorNewDefaultTrue_TurnMonitoringOn() - { - (DialogsService service, FakeTelegramStore store, _, _) = Create(); - - int count = await service.SyncFromTelegramAsync(Catalog( - Entry("d_1", "Канал", "channel"), - Entry("d_2", "Группа", "group")), CancellationToken.None); - - Assert.Equal(2, count); - Assert.Equal(["d_1", "d_2"], await service.ListMonitoredIdsAsync(CancellationToken.None)); - Assert.True(store.Dialogs.All(row => row.Monitor)); - } - - /// - /// autoMonitorNew=false - /// - [Fact] - public async Task Sync_AutoMonitorNewFalse_AddsNewDialogsUnmonitored() - { - (DialogsService service, FakeTelegramStore store, FakeSettingsStore settings, _) = Create(); - settings.Preload(SettingsKeys.AutoMonitorNew, "false"); - - int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_1", "Канал", "channel")), CancellationToken.None); - - Assert.Equal(1, count); - Assert.Single(store.Dialogs); - Assert.False(store.Dialogs[0].Monitor); - Assert.Empty(await service.ListMonitoredIdsAsync(CancellationToken.None)); - } - - /// - /// Существующий диалог обновляет имя/тип/handle, монитор пользователя не трогается. - /// - [Fact] - public async Task Sync_ExistingDialog_UpdatesMetadataAndKeepsUserMonitor() - { - (DialogsService service, FakeTelegramStore store, _, _) = Create(); - store.Seed(Dialog("d_1", "Старое имя", "channel", Monitor: false, Backfilled: true)); - - await service.SyncFromTelegramAsync( - Catalog(new TelegramDialogEntryDto("d_1", "Новое имя", "new_handle", "group", "#a33")), CancellationToken.None); - - FakeTelegramDialogRow row = Assert.Single(store.Dialogs); - Assert.Equal("Новое имя", row.Name); - Assert.Equal("group", row.Kind); - Assert.Equal("new_handle", row.Handle); - Assert.Equal("#a33", row.Hue); - Assert.False(row.Monitor); // монитор пользователя не трогаем - Assert.True(row.Backfilled); - } - - /// - /// Диалог, которого больше нет в каталоге - /// - [Fact] - public async Task Sync_DialogLeftCatalog_IsDeleted() - { - (DialogsService service, FakeTelegramStore store, _, _) = Create(); - store.Seed(Dialog("d_left", "Ушёл", "channel", Monitor: true, Backfilled: true)); - - int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_stay", "Остался", "group")), CancellationToken.None); - - Assert.Equal(1, count); - Assert.Equal(["d_stay"], store.Dialogs.Select(row => row.Id)); - // Новый диалог каталога авто-мониторится (дефолт autoMonitorNew=true), ушедший — удалён. - Assert.Equal(["d_stay"], await service.ListMonitoredIdsAsync(CancellationToken.None)); - } - - /// - /// Пустой каталог — no-op, существующие строки целы. - /// - [Fact] - public async Task Sync_EmptyCatalog_ReturnsZeroAndKeepsRows() - { - (DialogsService service, FakeTelegramStore store, _, _) = Create(); - store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true)); - - int count = await service.SyncFromTelegramAsync([], CancellationToken.None); - - Assert.Equal(0, count); - Assert.Single(store.Dialogs); - } - - // ─── SetMonitor: флаг + RPC в telegram-service + «нужен первый разбор» ── - - /// - /// Первое включение неразобранного диалога - /// - [Fact] - public async Task SetMonitor_EnableFirstTimeNotBackfilled_SetsFlagCallsGatewayAndNeedsBackfill() - { - (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); - store.Seed(Dialog("d_1", "Канал", "channel", Monitor: false, Backfilled: false)); - - TelegramMonitorToggleDto result = await service.SetMonitorAsync("d_1", enabled: true, CancellationToken.None); - - Assert.True(store.Dialogs[0].Monitor); - Assert.True(result.Enabled); - Assert.True(result.BackfillNeeded); - Assert.Equal([("d_1", true)], gateway.SetMonitorCalls); - } - - /// - /// Включение уже разобранного диалога - /// - [Fact] - public async Task SetMonitor_EnableAlreadyBackfilled_NoBackfillNeeded() - { - (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); - store.Seed(Dialog("d_1", "Канал", "channel", Monitor: false, Backfilled: true)); - - TelegramMonitorToggleDto result = await service.SetMonitorAsync("d_1", enabled: true, CancellationToken.None); - - Assert.True(store.Dialogs[0].Monitor); - Assert.False(result.BackfillNeeded); - Assert.Equal([("d_1", true)], gateway.SetMonitorCalls); - } - - /// - /// Выключение: флаг off + RPC SetMonitor(id,false), разбор не нужен. - /// - [Fact] - public async Task SetMonitor_Disable_TurnsOffAndNotifiesMirror() - { - (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); - store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true)); - - TelegramMonitorToggleDto result = await service.SetMonitorAsync("d_1", enabled: false, CancellationToken.None); - - Assert.False(store.Dialogs[0].Monitor); - Assert.False(result.Enabled); - Assert.False(result.BackfillNeeded); - Assert.Equal([("d_1", false)], gateway.SetMonitorCalls); - } - - /// - /// Диалога нет в каталоге — no-op без RPC. - /// - [Fact] - public async Task SetMonitor_UnknownDialog_NoOpWithoutGatewayCall() - { - (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); - - TelegramMonitorToggleDto result = await service.SetMonitorAsync("missing", enabled: true, CancellationToken.None); - - Assert.True(result.Enabled); - Assert.False(result.BackfillNeeded); - Assert.Empty(store.Dialogs); - Assert.Empty(gateway.SetMonitorCalls); - } - - // ─── SetMonitorAll: все диалоги + зеркало + список неразобранных ── - - /// - /// Включение всех: count строк, RPC SetMonitorAll(true), неразобранные — списком. - /// - [Fact] - public async Task SetMonitorAll_Enable_ReturnsCountAndNotBackfilledIdsAndNotifiesMirror() - { - (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); - store.Seed(Dialog("d_a", "А", "channel", Monitor: false, Backfilled: true)); - store.Seed(Dialog("d_b", "Б", "channel", Monitor: false, Backfilled: false)); - store.Seed(Dialog("d_c", "В", "group", Monitor: false, Backfilled: false)); - - TelegramMonitorAllDto result = await service.SetMonitorAllAsync(enabled: true, CancellationToken.None); - - Assert.Equal(3, result.Count); - Assert.Equal(["d_b", "d_c"], result.BackfillNeededIds); - Assert.True(store.Dialogs.All(row => row.Monitor)); - Assert.Equal([true], gateway.SetMonitorAllCalls); - } - - /// - /// Выключение всех - /// - [Fact] - public async Task SetMonitorAll_Disable_TurnsAllOffAndEmptyBackfillList() - { - (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); - store.Seed(Dialog("d_a", "А", "channel", Monitor: true, Backfilled: false)); - store.Seed(Dialog("d_b", "Б", "channel", Monitor: true, Backfilled: true)); - - TelegramMonitorAllDto result = await service.SetMonitorAllAsync(enabled: false, CancellationToken.None); - - Assert.Equal(2, result.Count); - Assert.Empty(result.BackfillNeededIds); - Assert.False(store.Dialogs.All(row => row.Monitor)); - Assert.Equal([false], gateway.SetMonitorAllCalls); - } - - /// - /// MarkBackfilled ставит флаг разобранности. - /// - [Fact] - public async Task MarkBackfilled_SetsFlagOnDialog() - { - (DialogsService service, FakeTelegramStore store, _, _) = Create(); - store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false)); - - await service.MarkBackfilledAsync("d_1", CancellationToken.None); - - Assert.True(store.Dialogs[0].Backfilled); - } - - - /// - /// «Перечитать» при падении одного канала продолжает остальные; флаг разбора — только после успеха. - /// - [Fact] - public async Task ReadRecent_OneDialogFails_ContinuesWithOthersAndMarksOnlySuccessful() - { - (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); - store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false)); - store.Seed(Dialog("d_2", "Группа", "group", Monitor: true, Backfilled: false)); - store.Seed(Dialog("d_3", "Форум", "forum", Monitor: true, Backfilled: false)); - gateway.BackfillFailures.Add("d_2"); - - int count = await service.ReadRecentAsync(CancellationToken.None); - - Assert.Equal(3, count); - Assert.Equal([("d_1", true), ("d_2", true), ("d_3", true)], gateway.BackfillCalls); - Assert.True(store.Dialogs.Single(row => row.Id == "d_1").Backfilled); - Assert.False(store.Dialogs.Single(row => row.Id == "d_2").Backfilled); // сбой RPC: флаг строго после успеха - Assert.True(store.Dialogs.Single(row => row.Id == "d_3").Backfilled); - } - - /// - /// BackfillOne: нет строки/уже разобран без force — 0 без RPC; успех помечает разобранным. - /// - [Fact] - public async Task BackfillOne_UnknownOrAlreadyBackfilledWithoutForce_ReturnsZeroWithoutRpc() - { - (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); - store.Seed(Dialog("d_known", "Канал", "channel", Monitor: true, Backfilled: true)); - store.Seed(Dialog("d_fresh", "Группа", "group", Monitor: false, Backfilled: false)); - - Assert.Equal(0, await service.BackfillOneAsync("d_missing", force: false, CancellationToken.None)); - Assert.Equal(0, await service.BackfillOneAsync("d_known", force: false, CancellationToken.None)); - Assert.Empty(gateway.BackfillCalls); - - int processed = await service.BackfillOneAsync("d_fresh", force: false, CancellationToken.None); - - Assert.Equal(3, processed); - Assert.Equal([("d_fresh", false)], gateway.BackfillCalls); - Assert.True(store.Dialogs.Single(row => row.Id == "d_fresh").Backfilled); // mark после успеха - } - - /// - /// BackfillOne c force=true обходит флаг разобранности. - /// - [Fact] - public async Task BackfillOne_ForceTrue_BackfillsAlreadyBackfilledDialogAndMarksAgain() - { - (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); - store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true)); - - int processed = await service.BackfillOneAsync("d_1", force: true, CancellationToken.None); - - Assert.Equal(3, processed); - Assert.Equal([("d_1", true)], gateway.BackfillCalls); - Assert.True(store.Dialogs.Single(row => row.Id == "d_1").Backfilled); - } - - // ─── ReadRecent («Перечитать»): только включённые каналы, force=true ── - - /// - /// Перечитываются только включённые диалоги - /// - [Fact] - public async Task ReadRecent_OnlyMonitoredDialogs_BackfillsEachMonitoredWithForce() - { - (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); - store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true)); - store.Seed(Dialog("d_2", "Группа", "group", Monitor: true, Backfilled: false)); - store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false, Backfilled: false)); - - int count = await service.ReadRecentAsync(CancellationToken.None); - - Assert.Equal(2, count); - Assert.Equal([("d_1", true), ("d_2", true)], gateway.BackfillCalls); - Assert.True(store.Dialogs.Single(row => row.Id == "d_2").Backfilled); // после успешного разбора - Assert.False(store.Dialogs.Single(row => row.Id == "d_off").Backfilled); // выключенный не тронут - } - - /// - /// Включённых нет — «Перечитать» возвращает 0 без вызовов гейта. - /// - [Fact] - public async Task ReadRecent_NoMonitoredDialogs_ReturnsZeroWithoutGatewayCalls() - { - (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); - store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false, Backfilled: false)); - - int count = await service.ReadRecentAsync(CancellationToken.None); - - Assert.Equal(0, count); - Assert.Empty(gateway.BackfillCalls); - } - - // ─── List ────────────────────────────────────────────────────────────── - - /// - /// List возвращает каталог - /// - [Fact] - public async Task List_ReturnsMonitoredFirstByNameOrderWithLastMessage() - { - (DialogsService service, FakeTelegramStore store, _, _) = Create(); - DateTimeOffset lastAt = DateTimeOffset.UtcNow.AddMinutes(-5); - store.Seed(Dialog("d_gamma", "Гамма", "channel", Monitor: true, Backfilled: true, LastText: "привет", LastAt: lastAt)); - store.Seed(Dialog("d_alpha", "Альфа", "group", Monitor: false, Backfilled: false)); - store.Seed(Dialog("d_beta", "Бета", "chat", Monitor: false, Backfilled: false)); - - IReadOnlyList items = await service.ListAsync(CancellationToken.None); - - Assert.Equal(["d_gamma", "d_alpha", "d_beta"], items.Select(item => item.Id)); - TelegramDialogDto first = items[0]; - Assert.Equal("channel", first.Type); - Assert.True(first.On); - Assert.NotNull(first.Last); - Assert.Equal("привет", first.Last!.Text); - Assert.Equal(lastAt.ToUnixTimeMilliseconds(), first.Last.TimeMs); - Assert.Equal("Альфа", items[1].Name); - } - - // ─── SavePreview: превью TgMessages + «последнее сообщение» каталога ── - - /// - /// SavePreview пишет строку превью - /// - [Fact] - public async Task SavePreview_PersistsPreviewRowAndTouchesDialogLast() - { - (DialogsService service, FakeTelegramStore store, _, _) = Create(); - store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false)); - string longText = new('т', 5000); - DateTimeOffset msgAt = DateTimeOffset.UtcNow.AddMinutes(-1); - - await service.SavePreviewAsync("d_1", msgId: 7, longText, msgAt, CancellationToken.None); - - FakeTelegramMessageRow preview = Assert.Single(store.Messages); - Assert.Equal("m_d_1_7", preview.Id); - Assert.Equal("d_1", preview.DialogId); - Assert.Equal(4000, preview.Text.Length); // python L604: m.text[:4000] - Assert.Equal(msgAt, preview.MsgAt); - FakeTelegramDialogRow row = Assert.Single(store.Dialogs); - Assert.Equal(200, row.LastText.Length); // python L272: strip()[:200] - Assert.Equal(msgAt, row.LastAt); - } - - /// - /// Пустой/пробельный текст — no-op. - /// - [Fact] - public async Task SavePreview_EmptyText_DoesNotTouchStore() - { - (DialogsService service, FakeTelegramStore store, _, _) = Create(); - store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false)); - - await service.SavePreviewAsync("d_1", msgId: 7, " ", DateTimeOffset.UtcNow, CancellationToken.None); - - Assert.Empty(store.Messages); - Assert.Empty(store.Dialogs[0].LastText); - Assert.Null(store.Dialogs[0].LastAt); - } - - /// - /// Без msg_id строка превью не пишется - /// - [Fact] - public async Task SavePreview_NoMsgId_UpdatesLastOnly() - { - (DialogsService service, FakeTelegramStore store, _, _) = Create(); - store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false)); - - await service.SavePreviewAsync("d_1", msgId: null, "Новое сообщение", null, CancellationToken.None); - - Assert.Empty(store.Messages); - Assert.Equal("Новое сообщение", store.Dialogs[0].LastText); - Assert.NotNull(store.Dialogs[0].LastAt); - } - - /// - /// Дубль превью того же сообщения не перезаписывает первую строку. - /// - [Fact] - public async Task SavePreview_DuplicateMessageId_KeepsFirstRow() - { - (DialogsService service, FakeTelegramStore store, _, _) = Create(); - store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false)); - DateTimeOffset firstAt = DateTimeOffset.UtcNow.AddMinutes(-2); - - await service.SavePreviewAsync("d_1", msgId: 7, "Первое", firstAt, CancellationToken.None); - await service.SavePreviewAsync("d_1", msgId: 7, "Второе", DateTimeOffset.UtcNow, CancellationToken.None); - - FakeTelegramMessageRow preview = Assert.Single(store.Messages); - Assert.Equal("Первое", preview.Text); - Assert.Equal(firstAt, preview.MsgAt); - } - - - /// - /// Новый источник после вступления - /// - [Fact] - public async Task AddDiscoveredMonitored_NewDialog_CreatesMonitoredRowAndNotifiesMirror() - { - (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); - - await service.AddDiscoveredMonitoredAsync("d_100", "Канал", "channel_handle", "channel", "#a33", CancellationToken.None); - - FakeTelegramDialogRow row = Assert.Single(store.Dialogs); - Assert.Equal("d_100", row.Id); - Assert.Equal("Канал", row.Name); - Assert.Equal("channel_handle", row.Handle); - Assert.Equal("channel", row.Kind); - Assert.Equal("#a33", row.Hue); - Assert.True(row.Monitor); - Assert.False(row.Backfilled); // первый фоновый разбор подхватит последние сообщения (python L855) - Assert.Equal([("d_100", true)], gateway.SetMonitorCalls); - } - - /// - /// Существующая строка - /// - [Fact] - public async Task AddDiscoveredMonitored_ExistingDialog_UpdatesMetadataAndForcesMonitorOn() - { - (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); - store.Seed(Dialog("d_100", "Старое имя", "group", Monitor: false, Backfilled: true)); - - await service.AddDiscoveredMonitoredAsync("d_100", "Новое имя", "new_handle", "channel", "#c21", CancellationToken.None); - - FakeTelegramDialogRow row = Assert.Single(store.Dialogs); - Assert.Equal("Новое имя", row.Name); - Assert.Equal("new_handle", row.Handle); - Assert.Equal("channel", row.Kind); - Assert.Equal("#c21", row.Hue); - Assert.True(row.Monitor); - Assert.False(row.Backfilled); // разбор нужен заново — строка могла быть до этого разобрана - Assert.Equal([("d_100", true)], gateway.SetMonitorCalls); - } - - /// - /// Пустые имя/hue нормализуются как - /// - [Fact] - public async Task AddDiscoveredMonitored_EmptyNameAndHue_FallBackToDialogIdAndDefaultHue() - { - (DialogsService service, FakeTelegramStore store, _, _) = Create(); - - await service.AddDiscoveredMonitoredAsync("d_100", " ", string.Empty, string.Empty, string.Empty, CancellationToken.None); - - FakeTelegramDialogRow row = Assert.Single(store.Dialogs); - Assert.Equal("d_100", row.Name); - Assert.Equal(string.Empty, row.Handle); - Assert.Equal(string.Empty, row.Kind); - Assert.Equal("#666", row.Hue); - } - - /// - /// Сбой зеркала - /// - [Fact] - public async Task AddDiscoveredMonitored_GatewayMirrorFails_RowStillWritten() - { - (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); - gateway.SetMonitorFailures.Add("d_100"); - - await service.AddDiscoveredMonitoredAsync("d_100", "Канал", string.Empty, "channel", "#666", CancellationToken.None); - - Assert.True(Assert.Single(store.Dialogs).Monitor); - Assert.Empty(gateway.SetMonitorCalls); - } - - // ─── Хелперы ──────────────────────────────────────────────────────────── - - // Собирает сервис на фейках (FakeTelegramStore — семантика адаптера; настройки/гейт чистые). - private static (DialogsService Service, FakeTelegramStore Store, FakeSettingsStore Settings, FakeTelegramGateway Gateway) Create() - { - var store = new FakeTelegramStore(); - var settings = new FakeSettingsStore(); - var gateway = new FakeTelegramGateway(); - return (new DialogsService(store, settings, gateway, NullLogger.Instance), store, settings, gateway); - } - - // Строка каталога минимально (id/имя/kind + флаги/хвост каталога). - private static FakeTelegramDialogRow Dialog( - string id, - string name, - string kind, - bool Monitor, - bool Backfilled, - string LastText = "", - DateTimeOffset? LastAt = null) - => new(id, name, Handle: string.Empty, kind, Hue: "#666", Monitor, LastText, LastAt, Backfilled, UpdatedAt: DateTimeOffset.UtcNow); - - // Запись каталога (id/имя/kind; handle/hue по умолчанию). - private static TelegramDialogEntryDto Entry( - string id, - string name, - string kind) - => new(id, name, Handle: string.Empty, kind, Hue: "#666"); - - // Каталог из записей. - private static IReadOnlyList Catalog(params TelegramDialogEntryDto[] entries) => entries; -} +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Telegram.Application; +using Deal.Modules.Telegram.Application.Models; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Support; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Contracts; + +/// +/// Тесты DialogsService — каталог диалогов/каналов и зеркало мониторинга ядра. +/// +public sealed class DialogsServiceTests +{ + // ─── SyncFromTelegram: авто-мониторинг новых, обновление, удаление отсутствующих ── + + /// + /// Новые диалоги при дефолтной autoMonitorNew=true появляются включёнными. + /// + [Fact] + public async Task Sync_NewDialogsAutoMonitorNewDefaultTrue_TurnMonitoringOn() + { + (DialogsService service, FakeTelegramStore store, _, _) = Create(); + + int count = await service.SyncFromTelegramAsync(Catalog( + Entry("d_1", "Канал", "channel"), + Entry("d_2", "Группа", "group")), CancellationToken.None); + + Assert.Equal(2, count); + Assert.Equal(["d_1", "d_2"], await service.ListMonitoredIdsAsync(CancellationToken.None)); + Assert.True(store.Dialogs.All(row => row.Monitor)); + } + + /// + /// autoMonitorNew=false + /// + [Fact] + public async Task Sync_AutoMonitorNewFalse_AddsNewDialogsUnmonitored() + { + (DialogsService service, FakeTelegramStore store, FakeSettingsStore settings, _) = Create(); + settings.Preload(SettingsKeys.AutoMonitorNew, "false"); + + int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_1", "Канал", "channel")), CancellationToken.None); + + Assert.Equal(1, count); + Assert.Single(store.Dialogs); + Assert.False(store.Dialogs[0].Monitor); + Assert.Empty(await service.ListMonitoredIdsAsync(CancellationToken.None)); + } + + /// + /// Существующий диалог обновляет имя/тип/handle, монитор пользователя не трогается. + /// + [Fact] + public async Task Sync_ExistingDialog_UpdatesMetadataAndKeepsUserMonitor() + { + (DialogsService service, FakeTelegramStore store, _, _) = Create(); + store.Seed(Dialog("d_1", "Старое имя", "channel", Monitor: false, Backfilled: true)); + + await service.SyncFromTelegramAsync( + Catalog(new TelegramDialogEntryDto("d_1", "Новое имя", "new_handle", "group", "#a33")), CancellationToken.None); + + FakeTelegramDialogRow row = Assert.Single(store.Dialogs); + Assert.Equal("Новое имя", row.Name); + Assert.Equal("group", row.Kind); + Assert.Equal("new_handle", row.Handle); + Assert.Equal("#a33", row.Hue); + Assert.False(row.Monitor); // монитор пользователя не трогаем + Assert.True(row.Backfilled); + } + + /// + /// Диалог, которого больше нет в каталоге + /// + [Fact] + public async Task Sync_DialogLeftCatalog_IsDeleted() + { + (DialogsService service, FakeTelegramStore store, _, _) = Create(); + store.Seed(Dialog("d_left", "Ушёл", "channel", Monitor: true, Backfilled: true)); + + int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_stay", "Остался", "group")), CancellationToken.None); + + Assert.Equal(1, count); + Assert.Equal(["d_stay"], store.Dialogs.Select(row => row.Id)); + // Новый диалог каталога авто-мониторится (дефолт autoMonitorNew=true), ушедший — удалён. + Assert.Equal(["d_stay"], await service.ListMonitoredIdsAsync(CancellationToken.None)); + } + + /// + /// Пустой каталог — no-op, существующие строки целы. + /// + [Fact] + public async Task Sync_EmptyCatalog_ReturnsZeroAndKeepsRows() + { + (DialogsService service, FakeTelegramStore store, _, _) = Create(); + store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true)); + + int count = await service.SyncFromTelegramAsync([], CancellationToken.None); + + Assert.Equal(0, count); + Assert.Single(store.Dialogs); + } + + // ─── SetMonitor: флаг + RPC в telegram-service + «нужен первый разбор» ── + + /// + /// Первое включение неразобранного диалога + /// + [Fact] + public async Task SetMonitor_EnableFirstTimeNotBackfilled_SetsFlagCallsGatewayAndNeedsBackfill() + { + (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); + store.Seed(Dialog("d_1", "Канал", "channel", Monitor: false, Backfilled: false)); + + TelegramMonitorToggleDto result = await service.SetMonitorAsync("d_1", enabled: true, CancellationToken.None); + + Assert.True(store.Dialogs[0].Monitor); + Assert.True(result.Enabled); + Assert.True(result.BackfillNeeded); + Assert.Equal([("d_1", true)], gateway.SetMonitorCalls); + } + + /// + /// Включение уже разобранного диалога + /// + [Fact] + public async Task SetMonitor_EnableAlreadyBackfilled_NoBackfillNeeded() + { + (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); + store.Seed(Dialog("d_1", "Канал", "channel", Monitor: false, Backfilled: true)); + + TelegramMonitorToggleDto result = await service.SetMonitorAsync("d_1", enabled: true, CancellationToken.None); + + Assert.True(store.Dialogs[0].Monitor); + Assert.False(result.BackfillNeeded); + Assert.Equal([("d_1", true)], gateway.SetMonitorCalls); + } + + /// + /// Выключение: флаг off + RPC SetMonitor(id,false), разбор не нужен. + /// + [Fact] + public async Task SetMonitor_Disable_TurnsOffAndNotifiesMirror() + { + (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); + store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true)); + + TelegramMonitorToggleDto result = await service.SetMonitorAsync("d_1", enabled: false, CancellationToken.None); + + Assert.False(store.Dialogs[0].Monitor); + Assert.False(result.Enabled); + Assert.False(result.BackfillNeeded); + Assert.Equal([("d_1", false)], gateway.SetMonitorCalls); + } + + /// + /// Диалога нет в каталоге — no-op без RPC. + /// + [Fact] + public async Task SetMonitor_UnknownDialog_NoOpWithoutGatewayCall() + { + (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); + + TelegramMonitorToggleDto result = await service.SetMonitorAsync("missing", enabled: true, CancellationToken.None); + + Assert.True(result.Enabled); + Assert.False(result.BackfillNeeded); + Assert.Empty(store.Dialogs); + Assert.Empty(gateway.SetMonitorCalls); + } + + // ─── SetMonitorAll: все диалоги + зеркало + список неразобранных ── + + /// + /// Включение всех: count строк, RPC SetMonitorAll(true), неразобранные — списком. + /// + [Fact] + public async Task SetMonitorAll_Enable_ReturnsCountAndNotBackfilledIdsAndNotifiesMirror() + { + (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); + store.Seed(Dialog("d_a", "А", "channel", Monitor: false, Backfilled: true)); + store.Seed(Dialog("d_b", "Б", "channel", Monitor: false, Backfilled: false)); + store.Seed(Dialog("d_c", "В", "group", Monitor: false, Backfilled: false)); + + TelegramMonitorAllDto result = await service.SetMonitorAllAsync(enabled: true, CancellationToken.None); + + Assert.Equal(3, result.Count); + Assert.Equal(["d_b", "d_c"], result.BackfillNeededIds); + Assert.True(store.Dialogs.All(row => row.Monitor)); + Assert.Equal([true], gateway.SetMonitorAllCalls); + } + + /// + /// Выключение всех + /// + [Fact] + public async Task SetMonitorAll_Disable_TurnsAllOffAndEmptyBackfillList() + { + (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); + store.Seed(Dialog("d_a", "А", "channel", Monitor: true, Backfilled: false)); + store.Seed(Dialog("d_b", "Б", "channel", Monitor: true, Backfilled: true)); + + TelegramMonitorAllDto result = await service.SetMonitorAllAsync(enabled: false, CancellationToken.None); + + Assert.Equal(2, result.Count); + Assert.Empty(result.BackfillNeededIds); + Assert.False(store.Dialogs.All(row => row.Monitor)); + Assert.Equal([false], gateway.SetMonitorAllCalls); + } + + /// + /// MarkBackfilled ставит флаг разобранности. + /// + [Fact] + public async Task MarkBackfilled_SetsFlagOnDialog() + { + (DialogsService service, FakeTelegramStore store, _, _) = Create(); + store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false)); + + await service.MarkBackfilledAsync("d_1", CancellationToken.None); + + Assert.True(store.Dialogs[0].Backfilled); + } + + + /// + /// «Перечитать» при падении одного канала продолжает остальные; флаг разбора — только после успеха. + /// + [Fact] + public async Task ReadRecent_OneDialogFails_ContinuesWithOthersAndMarksOnlySuccessful() + { + (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); + store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false)); + store.Seed(Dialog("d_2", "Группа", "group", Monitor: true, Backfilled: false)); + store.Seed(Dialog("d_3", "Форум", "forum", Monitor: true, Backfilled: false)); + gateway.BackfillFailures.Add("d_2"); + + int count = await service.ReadRecentAsync(CancellationToken.None); + + Assert.Equal(3, count); + Assert.Equal([("d_1", true), ("d_2", true), ("d_3", true)], gateway.BackfillCalls); + Assert.True(store.Dialogs.Single(row => row.Id == "d_1").Backfilled); + Assert.False(store.Dialogs.Single(row => row.Id == "d_2").Backfilled); // сбой RPC: флаг строго после успеха + Assert.True(store.Dialogs.Single(row => row.Id == "d_3").Backfilled); + } + + /// + /// BackfillOne: нет строки/уже разобран без force — 0 без RPC; успех помечает разобранным. + /// + [Fact] + public async Task BackfillOne_UnknownOrAlreadyBackfilledWithoutForce_ReturnsZeroWithoutRpc() + { + (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); + store.Seed(Dialog("d_known", "Канал", "channel", Monitor: true, Backfilled: true)); + store.Seed(Dialog("d_fresh", "Группа", "group", Monitor: false, Backfilled: false)); + + Assert.Equal(0, await service.BackfillOneAsync("d_missing", force: false, CancellationToken.None)); + Assert.Equal(0, await service.BackfillOneAsync("d_known", force: false, CancellationToken.None)); + Assert.Empty(gateway.BackfillCalls); + + int processed = await service.BackfillOneAsync("d_fresh", force: false, CancellationToken.None); + + Assert.Equal(3, processed); + Assert.Equal([("d_fresh", false)], gateway.BackfillCalls); + Assert.True(store.Dialogs.Single(row => row.Id == "d_fresh").Backfilled); // mark после успеха + } + + /// + /// BackfillOne c force=true обходит флаг разобранности. + /// + [Fact] + public async Task BackfillOne_ForceTrue_BackfillsAlreadyBackfilledDialogAndMarksAgain() + { + (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); + store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true)); + + int processed = await service.BackfillOneAsync("d_1", force: true, CancellationToken.None); + + Assert.Equal(3, processed); + Assert.Equal([("d_1", true)], gateway.BackfillCalls); + Assert.True(store.Dialogs.Single(row => row.Id == "d_1").Backfilled); + } + + // ─── ReadRecent («Перечитать»): только включённые каналы, force=true ── + + /// + /// Перечитываются только включённые диалоги + /// + [Fact] + public async Task ReadRecent_OnlyMonitoredDialogs_BackfillsEachMonitoredWithForce() + { + (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); + store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true)); + store.Seed(Dialog("d_2", "Группа", "group", Monitor: true, Backfilled: false)); + store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false, Backfilled: false)); + + int count = await service.ReadRecentAsync(CancellationToken.None); + + Assert.Equal(2, count); + Assert.Equal([("d_1", true), ("d_2", true)], gateway.BackfillCalls); + Assert.True(store.Dialogs.Single(row => row.Id == "d_2").Backfilled); // после успешного разбора + Assert.False(store.Dialogs.Single(row => row.Id == "d_off").Backfilled); // выключенный не тронут + } + + /// + /// Включённых нет — «Перечитать» возвращает 0 без вызовов гейта. + /// + [Fact] + public async Task ReadRecent_NoMonitoredDialogs_ReturnsZeroWithoutGatewayCalls() + { + (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); + store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false, Backfilled: false)); + + int count = await service.ReadRecentAsync(CancellationToken.None); + + Assert.Equal(0, count); + Assert.Empty(gateway.BackfillCalls); + } + + // ─── List ────────────────────────────────────────────────────────────── + + /// + /// List возвращает каталог + /// + [Fact] + public async Task List_ReturnsMonitoredFirstByNameOrderWithLastMessage() + { + (DialogsService service, FakeTelegramStore store, _, _) = Create(); + DateTimeOffset lastAt = DateTimeOffset.UtcNow.AddMinutes(-5); + store.Seed(Dialog("d_gamma", "Гамма", "channel", Monitor: true, Backfilled: true, LastText: "привет", LastAt: lastAt)); + store.Seed(Dialog("d_alpha", "Альфа", "group", Monitor: false, Backfilled: false)); + store.Seed(Dialog("d_beta", "Бета", "chat", Monitor: false, Backfilled: false)); + + IReadOnlyList items = await service.ListAsync(CancellationToken.None); + + Assert.Equal(["d_gamma", "d_alpha", "d_beta"], items.Select(item => item.Id)); + TelegramDialogDto first = items[0]; + Assert.Equal("channel", first.Type); + Assert.True(first.On); + Assert.NotNull(first.Last); + Assert.Equal("привет", first.Last!.Text); + Assert.Equal(lastAt.ToUnixTimeMilliseconds(), first.Last.TimeMs); + Assert.Equal("Альфа", items[1].Name); + } + + // ─── SavePreview: превью TgMessages + «последнее сообщение» каталога ── + + /// + /// SavePreview пишет строку превью + /// + [Fact] + public async Task SavePreview_PersistsPreviewRowAndTouchesDialogLast() + { + (DialogsService service, FakeTelegramStore store, _, _) = Create(); + store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false)); + string longText = new('т', 5000); + DateTimeOffset msgAt = DateTimeOffset.UtcNow.AddMinutes(-1); + + await service.SavePreviewAsync("d_1", msgId: 7, longText, msgAt, CancellationToken.None); + + FakeTelegramMessageRow preview = Assert.Single(store.Messages); + Assert.Equal("m_d_1_7", preview.Id); + Assert.Equal("d_1", preview.DialogId); + Assert.Equal(4000, preview.Text.Length); // python L604: m.text[:4000] + Assert.Equal(msgAt, preview.MsgAt); + FakeTelegramDialogRow row = Assert.Single(store.Dialogs); + Assert.Equal(200, row.LastText.Length); // python L272: strip()[:200] + Assert.Equal(msgAt, row.LastAt); + } + + /// + /// Пустой/пробельный текст — no-op. + /// + [Fact] + public async Task SavePreview_EmptyText_DoesNotTouchStore() + { + (DialogsService service, FakeTelegramStore store, _, _) = Create(); + store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false)); + + await service.SavePreviewAsync("d_1", msgId: 7, " ", DateTimeOffset.UtcNow, CancellationToken.None); + + Assert.Empty(store.Messages); + Assert.Empty(store.Dialogs[0].LastText); + Assert.Null(store.Dialogs[0].LastAt); + } + + /// + /// Без msg_id строка превью не пишется + /// + [Fact] + public async Task SavePreview_NoMsgId_UpdatesLastOnly() + { + (DialogsService service, FakeTelegramStore store, _, _) = Create(); + store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false)); + + await service.SavePreviewAsync("d_1", msgId: null, "Новое сообщение", null, CancellationToken.None); + + Assert.Empty(store.Messages); + Assert.Equal("Новое сообщение", store.Dialogs[0].LastText); + Assert.NotNull(store.Dialogs[0].LastAt); + } + + /// + /// Дубль превью того же сообщения не перезаписывает первую строку. + /// + [Fact] + public async Task SavePreview_DuplicateMessageId_KeepsFirstRow() + { + (DialogsService service, FakeTelegramStore store, _, _) = Create(); + store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false)); + DateTimeOffset firstAt = DateTimeOffset.UtcNow.AddMinutes(-2); + + await service.SavePreviewAsync("d_1", msgId: 7, "Первое", firstAt, CancellationToken.None); + await service.SavePreviewAsync("d_1", msgId: 7, "Второе", DateTimeOffset.UtcNow, CancellationToken.None); + + FakeTelegramMessageRow preview = Assert.Single(store.Messages); + Assert.Equal("Первое", preview.Text); + Assert.Equal(firstAt, preview.MsgAt); + } + + + /// + /// Новый источник после вступления + /// + [Fact] + public async Task AddDiscoveredMonitored_NewDialog_CreatesMonitoredRowAndNotifiesMirror() + { + (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); + + await service.AddDiscoveredMonitoredAsync("d_100", "Канал", "channel_handle", "channel", "#a33", CancellationToken.None); + + FakeTelegramDialogRow row = Assert.Single(store.Dialogs); + Assert.Equal("d_100", row.Id); + Assert.Equal("Канал", row.Name); + Assert.Equal("channel_handle", row.Handle); + Assert.Equal("channel", row.Kind); + Assert.Equal("#a33", row.Hue); + Assert.True(row.Monitor); + Assert.False(row.Backfilled); // первый фоновый разбор подхватит последние сообщения (python L855) + Assert.Equal([("d_100", true)], gateway.SetMonitorCalls); + } + + /// + /// Существующая строка + /// + [Fact] + public async Task AddDiscoveredMonitored_ExistingDialog_UpdatesMetadataAndForcesMonitorOn() + { + (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); + store.Seed(Dialog("d_100", "Старое имя", "group", Monitor: false, Backfilled: true)); + + await service.AddDiscoveredMonitoredAsync("d_100", "Новое имя", "new_handle", "channel", "#c21", CancellationToken.None); + + FakeTelegramDialogRow row = Assert.Single(store.Dialogs); + Assert.Equal("Новое имя", row.Name); + Assert.Equal("new_handle", row.Handle); + Assert.Equal("channel", row.Kind); + Assert.Equal("#c21", row.Hue); + Assert.True(row.Monitor); + Assert.False(row.Backfilled); // разбор нужен заново — строка могла быть до этого разобрана + Assert.Equal([("d_100", true)], gateway.SetMonitorCalls); + } + + /// + /// Пустые имя/hue нормализуются как + /// + [Fact] + public async Task AddDiscoveredMonitored_EmptyNameAndHue_FallBackToDialogIdAndDefaultHue() + { + (DialogsService service, FakeTelegramStore store, _, _) = Create(); + + await service.AddDiscoveredMonitoredAsync("d_100", " ", string.Empty, string.Empty, string.Empty, CancellationToken.None); + + FakeTelegramDialogRow row = Assert.Single(store.Dialogs); + Assert.Equal("d_100", row.Name); + Assert.Equal(string.Empty, row.Handle); + Assert.Equal(string.Empty, row.Kind); + Assert.Equal("#666", row.Hue); + } + + /// + /// Сбой зеркала + /// + [Fact] + public async Task AddDiscoveredMonitored_GatewayMirrorFails_RowStillWritten() + { + (DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create(); + gateway.SetMonitorFailures.Add("d_100"); + + await service.AddDiscoveredMonitoredAsync("d_100", "Канал", string.Empty, "channel", "#666", CancellationToken.None); + + Assert.True(Assert.Single(store.Dialogs).Monitor); + Assert.Empty(gateway.SetMonitorCalls); + } + + // ─── Хелперы ──────────────────────────────────────────────────────────── + + // Собирает сервис на фейках (FakeTelegramStore — семантика адаптера; настройки/гейт чистые). + private static (DialogsService Service, FakeTelegramStore Store, FakeSettingsStore Settings, FakeTelegramGateway Gateway) Create() + { + var store = new FakeTelegramStore(); + var settings = new FakeSettingsStore(); + var gateway = new FakeTelegramGateway(); + return (new DialogsService(store, settings, gateway, NullLogger.Instance), store, settings, gateway); + } + + // Строка каталога минимально (id/имя/kind + флаги/хвост каталога). + private static FakeTelegramDialogRow Dialog( + string id, + string name, + string kind, + bool Monitor, + bool Backfilled, + string LastText = "", + DateTimeOffset? LastAt = null) + => new(id, name, Handle: string.Empty, kind, Hue: "#666", Monitor, LastText, LastAt, Backfilled, UpdatedAt: DateTimeOffset.UtcNow); + + // Запись каталога (id/имя/kind; handle/hue по умолчанию). + private static TelegramDialogEntryDto Entry( + string id, + string name, + string kind) + => new(id, name, Handle: string.Empty, kind, Hue: "#666"); + + // Каталог из записей. + private static IReadOnlyList Catalog(params TelegramDialogEntryDto[] entries) => entries; +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryEvaluatorTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryEvaluatorTests.cs index dc8067b..a4072f7 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryEvaluatorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryEvaluatorTests.cs @@ -1,202 +1,202 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Services; -using Deal.Modules.Settings.Application.Models; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты DiscoveryEvaluator — каскад оценки сообщения и агрегаты выборки -/// -public sealed class DiscoveryEvaluatorTests -{ - [Fact] - public async Task EvaluateMessage_ShortText_NotFitHeuristic() - { - (DiscoveryEvaluator evaluator, _, _, _) = Create(aiEnabled: false, mlEnabled: false); - - DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( - TaskDto(keywords: ["заказ"]), "короткое", CancellationToken.None); - - Assert.False(fit.Fit); - Assert.Equal("слишком короткое", fit.Reason); - Assert.Equal("heuristic", fit.Source); - } - - [Fact] - public async Task EvaluateMessage_MlSpam_NotFit() - { - (DiscoveryEvaluator evaluator, _, FakeMlClient ml, _) = Create(aiEnabled: false, mlEnabled: true); - ml.Predict = new MlPredictResultDto( - Take: true, Label: "spam", Scores: new Dictionary(), Hits: 3, Ready: true, Margin: 0.9, - Terms: Array.Empty(), Type: null); - - DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( - TaskDto(keywords: ["заказ"]), "Рассылка спама про дешёвые заказы и подработку в телеграме", CancellationToken.None); - - Assert.False(fit.Fit); - Assert.Equal("ML: спам", fit.Reason); - Assert.Equal("ml", fit.Source); - } - - [Fact] - public async Task EvaluateMessage_AiFits_ReturnsAiVerdict() - { - (DiscoveryEvaluator evaluator, _, FakeMlClient ml, FakeAiTools ai) = Create(aiEnabled: true, mlEnabled: false); - ai.Fit = new AiEvaluateFitResultDto(Fit: true, "тема совпадает"); - - DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( - TaskDto(keywords: ["заказ"]), "Нужен заказ на разработку CRM для агентства недвижимости", CancellationToken.None); - - Assert.True(fit.Fit); - Assert.Equal("тема совпадает", fit.Reason); - Assert.Equal("ai", fit.Source); - Assert.Equal(1, ai.EvaluateFitCalls); - } - - [Fact] - public async Task EvaluateMessage_AiFailure_FallsBackToHeuristic() - { - (DiscoveryEvaluator evaluator, _, _, FakeAiTools ai) = Create(aiEnabled: true, mlEnabled: false); - ai.Error = new InvalidOperationException("ai-service недоступен"); // как gRPC AiUnavailableException - DiscoveryTaskDto task = TaskDto(keywords: ["заказ"]); - - DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( - task, "Нужен заказ на разработку CRM для агентства недвижимости", CancellationToken.None); - - Assert.True(fit.Fit); // по ключу «заказ» (эвристика, python L186–194) - Assert.StartsWith("совпал ключ", fit.Reason, StringComparison.Ordinal); - Assert.Equal("heuristic", fit.Source); - } - - [Fact] - public async Task EvaluateMessage_LocalNotSupported_FallsBackToHeuristic() - { - (DiscoveryEvaluator evaluator, _, _, _) = Create(aiEnabled: true, mlEnabled: false); - - DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( - TaskDto(keywords: ["CRM"]), "Разработка CRM под ключ для агентств и студий", CancellationToken.None); - - Assert.True(fit.Fit); - Assert.Equal("heuristic", fit.Source); - } - - [Fact] - public async Task EvaluateMessage_HeuristicMatchesKeywordCaseInsensitive() - { - (DiscoveryEvaluator evaluator, _, _, _) = Create(aiEnabled: false, mlEnabled: false); - - DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( - TaskDto(keywords: ["CRM"]), "Продаём crm-системы с внедрением", CancellationToken.None); - - Assert.True(fit.Fit); - Assert.Equal("heuristic", fit.Source); - } - - [Fact] - public async Task EvaluateMessage_NoKeywordMatch_NotFit() - { - (DiscoveryEvaluator evaluator, _, _, _) = Create(aiEnabled: false, mlEnabled: false); - - DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( - TaskDto(keywords: ["заказ"]), "Обсуждаем стек и архитектуру нового проекта", CancellationToken.None); - - Assert.False(fit.Fit); - Assert.Equal("нет совпадений с ключами", fit.Reason); - } - - [Fact] - public async Task EvaluateSample_ComputesAggregate() - { - (DiscoveryEvaluator evaluator, _, _, _) = Create(aiEnabled: false, mlEnabled: false); - DiscoveryTaskDto task = TaskDto(keywords: ["заказ"]); - - DiscoveryEvalSample sample = await evaluator.EvaluateSampleAsync( - task, - ["Нужен заказ на месяц", "Обсуждаем заказ на разработку", "Привет, как дела?", "Общие разговоры", "Снова заказ"], - CancellationToken.None); - - Assert.Equal(3, sample.FitCount); - Assert.Equal(5, sample.Total); - Assert.Equal(0.6, sample.FitRatio); - } - - [Fact] - public void Passed_RequiresThreeMessagesAndThresholdRatio() - { - Assert.True(DiscoveryEvaluator.Passed(new DiscoveryEvalSample(2, 4, 0.5, []), thresholdPercent: 40)); - Assert.True(DiscoveryEvaluator.Passed(new DiscoveryEvalSample(4, 4, 1.0, []), thresholdPercent: 40)); - Assert.False(DiscoveryEvaluator.Passed(new DiscoveryEvalSample(1, 4, 0.25, []), thresholdPercent: 40)); - Assert.False(DiscoveryEvaluator.Passed(new DiscoveryEvalSample(2, 2, 1.0, []), thresholdPercent: 40)); // < 3 сообщений - } - - [Fact] - public void GroupByTopic_GroupsAndSortsBySizeWithTitles() - { - IReadOnlyList messages = - [ - Msg(1, "Первая тема: ищем заказ", topic: 10), - Msg(2, "Проект для заказчика", topic: 10), - Msg(3, "Основной чат без темы", topic: null), - Msg(4, " Вторая тема ", topic: 20), - Msg(5, "Ещё основное сообщение", topic: null), - Msg(6, "Обсуждение заказа", topic: null), - ]; - - IReadOnlyList groups = DiscoveryEvaluator.GroupByTopic(messages); - - // Сортировка по числу сообщений (убыв.); topic_id=null → «main»; title — сниппет первого текста ≤60. - Assert.Equal(3, groups.Count); - Assert.Equal("main", groups[0].TopicKey); - Assert.Equal(3, groups[0].Messages.Count); - Assert.Equal("Основной чат без темы", groups[0].Title); - Assert.Equal("10", groups[1].TopicKey); - Assert.Equal("Первая тема: ищем заказ", groups[1].Title); - Assert.Equal("20", groups[2].TopicKey); - Assert.Equal("Вторая тема", groups[2].Title); - } - - // Сообщение выборки (текст; для форума — с темой). - private static TelegramEvalMessageDto Msg( - long id, - string text, - long? topic) - => new(id, text, DateMs: 1_700_000_000_000 + id, topic, TopicTitle: null); - - // Создаёт оценщик на фейках: флаги веток выставляются сценарием. - private static (DiscoveryEvaluator Evaluator, FakeSettingsStore Settings, FakeMlClient Ml, FakeAiTools Ai) Create(bool aiEnabled, bool mlEnabled) - { - var settings = new FakeSettingsStore(); - settings.Preload(SettingsKeys.AiEnabled, aiEnabled ? "true" : "false"); - settings.Preload(SettingsKeys.MlEnabled, mlEnabled ? "true" : "false"); - var ml = new FakeMlClient(); - var ai = new FakeAiTools(); - return (new DiscoveryEvaluator(settings, ml, ai), settings, ml, ai); - } - - // Задача как строка DiscTasks (адаптер-форма; нужны только keywords/description/threshold). - private static DiscoveryTaskDto TaskDto(string[] keywords, string description = "Ниша: разработка под заказ") - { - return new DiscoveryTaskDto( - "dt_1", - "Задача", - description, - keywords, - MinSubscribers: 0, - Lang: "ru", - Threshold: 40, - SampleSize: 10, - PlanJoins: 1, - AutoJoin: false, - Status: DiscoveryTaskStatuses.Running, - SearchIdx: 0, - SearchDone: true, - Found: 0, - Evaluated: 0, - Joined: 0, - Rejected: 0, - CreatedAt: 1000, - UpdatedAt: 1000); - } -} +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Services; +using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Modules.Settings; + +namespace Deal.Tests.Unit.Contracts; + +/// +/// Тесты DiscoveryEvaluator — каскад оценки сообщения и агрегаты выборки +/// +public sealed class DiscoveryEvaluatorTests +{ + [Fact] + public async Task EvaluateMessage_ShortText_NotFitHeuristic() + { + (DiscoveryEvaluator evaluator, _, _, _) = Create(aiEnabled: false, mlEnabled: false); + + DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( + TaskDto(keywords: ["заказ"]), "короткое", CancellationToken.None); + + Assert.False(fit.Fit); + Assert.Equal("слишком короткое", fit.Reason); + Assert.Equal("heuristic", fit.Source); + } + + [Fact] + public async Task EvaluateMessage_MlSpam_NotFit() + { + (DiscoveryEvaluator evaluator, _, FakeMlClient ml, _) = Create(aiEnabled: false, mlEnabled: true); + ml.Predict = new MlPredictResultDto( + Take: true, Label: "spam", Scores: new Dictionary(), Hits: 3, Ready: true, Margin: 0.9, + Terms: Array.Empty(), Type: null); + + DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( + TaskDto(keywords: ["заказ"]), "Рассылка спама про дешёвые заказы и подработку в телеграме", CancellationToken.None); + + Assert.False(fit.Fit); + Assert.Equal("ML: спам", fit.Reason); + Assert.Equal("ml", fit.Source); + } + + [Fact] + public async Task EvaluateMessage_AiFits_ReturnsAiVerdict() + { + (DiscoveryEvaluator evaluator, _, FakeMlClient ml, FakeAiTools ai) = Create(aiEnabled: true, mlEnabled: false); + ai.Fit = new AiEvaluateFitResultDto(Fit: true, "тема совпадает"); + + DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( + TaskDto(keywords: ["заказ"]), "Нужен заказ на разработку CRM для агентства недвижимости", CancellationToken.None); + + Assert.True(fit.Fit); + Assert.Equal("тема совпадает", fit.Reason); + Assert.Equal("ai", fit.Source); + Assert.Equal(1, ai.EvaluateFitCalls); + } + + [Fact] + public async Task EvaluateMessage_AiFailure_FallsBackToHeuristic() + { + (DiscoveryEvaluator evaluator, _, _, FakeAiTools ai) = Create(aiEnabled: true, mlEnabled: false); + ai.Error = new InvalidOperationException("ai-service недоступен"); // как gRPC AiUnavailableException + DiscoveryTaskDto task = TaskDto(keywords: ["заказ"]); + + DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( + task, "Нужен заказ на разработку CRM для агентства недвижимости", CancellationToken.None); + + Assert.True(fit.Fit); // по ключу «заказ» (эвристика, python L186–194) + Assert.StartsWith("совпал ключ", fit.Reason, StringComparison.Ordinal); + Assert.Equal("heuristic", fit.Source); + } + + [Fact] + public async Task EvaluateMessage_LocalNotSupported_FallsBackToHeuristic() + { + (DiscoveryEvaluator evaluator, _, _, _) = Create(aiEnabled: true, mlEnabled: false); + + DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( + TaskDto(keywords: ["CRM"]), "Разработка CRM под ключ для агентств и студий", CancellationToken.None); + + Assert.True(fit.Fit); + Assert.Equal("heuristic", fit.Source); + } + + [Fact] + public async Task EvaluateMessage_HeuristicMatchesKeywordCaseInsensitive() + { + (DiscoveryEvaluator evaluator, _, _, _) = Create(aiEnabled: false, mlEnabled: false); + + DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( + TaskDto(keywords: ["CRM"]), "Продаём crm-системы с внедрением", CancellationToken.None); + + Assert.True(fit.Fit); + Assert.Equal("heuristic", fit.Source); + } + + [Fact] + public async Task EvaluateMessage_NoKeywordMatch_NotFit() + { + (DiscoveryEvaluator evaluator, _, _, _) = Create(aiEnabled: false, mlEnabled: false); + + DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( + TaskDto(keywords: ["заказ"]), "Обсуждаем стек и архитектуру нового проекта", CancellationToken.None); + + Assert.False(fit.Fit); + Assert.Equal("нет совпадений с ключами", fit.Reason); + } + + [Fact] + public async Task EvaluateSample_ComputesAggregate() + { + (DiscoveryEvaluator evaluator, _, _, _) = Create(aiEnabled: false, mlEnabled: false); + DiscoveryTaskDto task = TaskDto(keywords: ["заказ"]); + + DiscoveryEvalSample sample = await evaluator.EvaluateSampleAsync( + task, + ["Нужен заказ на месяц", "Обсуждаем заказ на разработку", "Привет, как дела?", "Общие разговоры", "Снова заказ"], + CancellationToken.None); + + Assert.Equal(3, sample.FitCount); + Assert.Equal(5, sample.Total); + Assert.Equal(0.6, sample.FitRatio); + } + + [Fact] + public void Passed_RequiresThreeMessagesAndThresholdRatio() + { + Assert.True(DiscoveryEvaluator.Passed(new DiscoveryEvalSample(2, 4, 0.5, []), thresholdPercent: 40)); + Assert.True(DiscoveryEvaluator.Passed(new DiscoveryEvalSample(4, 4, 1.0, []), thresholdPercent: 40)); + Assert.False(DiscoveryEvaluator.Passed(new DiscoveryEvalSample(1, 4, 0.25, []), thresholdPercent: 40)); + Assert.False(DiscoveryEvaluator.Passed(new DiscoveryEvalSample(2, 2, 1.0, []), thresholdPercent: 40)); // < 3 сообщений + } + + [Fact] + public void GroupByTopic_GroupsAndSortsBySizeWithTitles() + { + IReadOnlyList messages = + [ + Msg(1, "Первая тема: ищем заказ", topic: 10), + Msg(2, "Проект для заказчика", topic: 10), + Msg(3, "Основной чат без темы", topic: null), + Msg(4, " Вторая тема ", topic: 20), + Msg(5, "Ещё основное сообщение", topic: null), + Msg(6, "Обсуждение заказа", topic: null), + ]; + + IReadOnlyList groups = DiscoveryEvaluator.GroupByTopic(messages); + + // Сортировка по числу сообщений (убыв.); topic_id=null → «main»; title — сниппет первого текста ≤60. + Assert.Equal(3, groups.Count); + Assert.Equal("main", groups[0].TopicKey); + Assert.Equal(3, groups[0].Messages.Count); + Assert.Equal("Основной чат без темы", groups[0].Title); + Assert.Equal("10", groups[1].TopicKey); + Assert.Equal("Первая тема: ищем заказ", groups[1].Title); + Assert.Equal("20", groups[2].TopicKey); + Assert.Equal("Вторая тема", groups[2].Title); + } + + // Сообщение выборки (текст; для форума — с темой). + private static TelegramEvalMessageDto Msg( + long id, + string text, + long? topic) + => new(id, text, DateMs: 1_700_000_000_000 + id, topic, TopicTitle: null); + + // Создаёт оценщик на фейках: флаги веток выставляются сценарием. + private static (DiscoveryEvaluator Evaluator, FakeSettingsStore Settings, FakeMlClient Ml, FakeAiTools Ai) Create(bool aiEnabled, bool mlEnabled) + { + var settings = new FakeSettingsStore(); + settings.Preload(SettingsKeys.AiEnabled, aiEnabled ? "true" : "false"); + settings.Preload(SettingsKeys.MlEnabled, mlEnabled ? "true" : "false"); + var ml = new FakeMlClient(); + var ai = new FakeAiTools(); + return (new DiscoveryEvaluator(settings, ml, ai), settings, ml, ai); + } + + // Задача как строка DiscTasks (адаптер-форма; нужны только keywords/description/threshold). + private static DiscoveryTaskDto TaskDto(string[] keywords, string description = "Ниша: разработка под заказ") + { + return new DiscoveryTaskDto( + "dt_1", + "Задача", + description, + keywords, + MinSubscribers: 0, + Lang: "ru", + Threshold: 40, + SampleSize: 10, + PlanJoins: 1, + AutoJoin: false, + Status: DiscoveryTaskStatuses.Running, + SearchIdx: 0, + SearchDone: true, + Found: 0, + Evaluated: 0, + Joined: 0, + Rejected: 0, + CreatedAt: 1000, + UpdatedAt: 1000); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs index 13347e8..ff1c28e 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs @@ -1,544 +1,544 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Services; -using Deal.Modules.Settings.Application.Models; -using Deal.Tests.Unit.Modules.Discovery; -using Deal.Tests.Unit.Modules.Settings; -using Grpc.Core; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты DiscoveryWorkerService — один шаг за тик -/// -public sealed class DiscoveryWorkerServiceTests -{ - // ─── Поиск ───────────────────────────────────────────────────────────── - - [Fact] - public async Task Tick_SearchCreatesCandidatesAndFinishesKeywordsPass() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", keywords: ["вакансия"], searchDone: false)); - fx.Gateway.SearchResults = - [ - Entry("d1", "Вакансии для разработчиков", "job_ch", DiscoveryCandidateKinds.Channel), - Entry("l1", "Лена", string.Empty, "chat"), - ]; - - DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal((DiscoveryWorkerService.ActionSearch, "dt_1"), (outcome.Action, outcome.TaskId)); - DiscoveryCandidateDto candidate = Assert.Single(fx.Store.Candidates); - Assert.Equal("d1", candidate.DialogId); - Assert.Equal("job_ch", candidate.Username); - Assert.Equal(DiscoveryCandidateKinds.Channel, candidate.Kind); - DiscoveryTaskDto task = fx.Store.Tasks.Single(); - Assert.Equal(1, task.SearchIdx); - Assert.True(task.SearchDone); - Assert.Equal(1, task.Found); - Assert.Equal(["вакансия"], fx.Gateway.SearchedKeywords); - Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Search && log.Text == "поиск завершён: 1 кандидатов"); - Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Skip && log.Text == "Лена: личный чат/бот"); - } - - [Fact] - public async Task Tick_SearchErrorThreeTimes_SkipsBrokenKeyword() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", keywords: ["битый ключ"], searchDone: false)); - fx.Gateway.SearchError = new InvalidOperationException("Telegram недоступен"); - - DiscoveryWorkerOutcome first = await fx.Worker.TickOnceAsync(CancellationToken.None); - DiscoveryWorkerOutcome second = await fx.Worker.TickOnceAsync(CancellationToken.None); - DiscoveryWorkerOutcome third = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal(DiscoveryWorkerService.ActionError, first.Action); - Assert.Equal(DiscoveryWorkerService.ActionError, second.Action); - Assert.Equal(DiscoveryWorkerService.ActionError, third.Action); // 3-я ошибка — ключ пропущен (python L196–203) - DiscoveryTaskDto task = fx.Store.Tasks.Single(); - Assert.Equal(1, task.SearchIdx); - Assert.True(task.SearchDone); - Assert.Contains(fx.Store.Log, log => log.Text.Contains("ключ пропущен (3 ошибки подряд)", StringComparison.Ordinal)); - Assert.Equal(["битый ключ", "битый ключ", "битый ключ"], fx.Gateway.SearchedKeywords); - } - - [Fact] - public async Task Tick_SearchFlood_NotesFloodDayAndStops() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", keywords: ["ключ"], searchDone: false)); - fx.Gateway.SearchError = FloodException(); - - DiscoveryWorkerOutcome flood = await fx.Worker.TickOnceAsync(CancellationToken.None); - DiscoveryWorkerOutcome next = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal(DiscoveryWorkerService.ActionFlood, flood.Action); - Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Flood && log.Text.Contains("flood — стоп до конца суток")); - Assert.Equal(DiscoveryWorkerService.ActionNone, next.Action); // flood-день: воркер стоит (python L448–451) - Assert.Single(fx.Gateway.SearchedKeywords); // второй тик сеть не дёргал - } - - // ─── Оценка кандидата ────────────────────────────────────────────────── - - [Fact] - public async Task Tick_EvalPassed_MovesCandidateToReviewWithFitRatio() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true)); - fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New, kind: DiscoveryCandidateKinds.Group, username: "grp1")); - fx.Gateway.InfoByDialog["d1"] = Info("d1", "Группа заказов", "grp1", "group", participants: 120); - fx.Gateway.ReadsByDialog["d1"] = Read( - "Нужен заказ на разработку CRM", "Ищем заказ на месяц для клиента", "Обсуждаем стек проекта", "Всем привет!"); - - DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal((DiscoveryWorkerService.ActionReview, "dt_1"), (outcome.Action, outcome.TaskId)); - DiscoveryCandidateDto candidate = fx.Store.Candidates.Single(); - Assert.Equal(DiscoveryCandidateStatuses.Review, candidate.Status); - Assert.True(candidate.LangRu); - Assert.Equal(0.5, candidate.FitRatio); - Assert.Empty(candidate.Marks); - Assert.Equal(1, fx.Store.Tasks.Single().Evaluated); - Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Review && log.Text.Contains("d1")); - Assert.Equal(120, candidate.Participants); - Assert.Equal("Группа заказов", candidate.Name); - } - - [Fact] - public async Task Tick_EvalTooFewSubscribers_DeletesCandidate() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true, minSubscribers: 500)); - fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New)); - fx.Gateway.InfoByDialog["d1"] = Info("d1", "Канал", string.Empty, "channel", participants: 120); - - DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal((DiscoveryWorkerService.ActionSkip, "dt_1"), (outcome.Action, outcome.TaskId)); - Assert.Empty(fx.Store.Candidates); - Assert.Equal(1, fx.Store.Tasks.Single().Evaluated); - Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Skip && log.Text == "d1: мало участников (120 < 500)"); - } - - [Fact] - public async Task Tick_EvalNotRussianLanguage_DeletesCandidate() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", keywords: ["order"], searchDone: true)); - fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New)); - fx.Gateway.InfoByDialog["d1"] = Info("d1", "Channel", string.Empty, "channel", participants: 10); - fx.Gateway.ReadsByDialog["d1"] = Read( - "We need order for crm", "Looking for developers", "Please contact us today", "Hello everyone"); - - DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal((DiscoveryWorkerService.ActionSkip, "dt_1"), (outcome.Action, outcome.TaskId)); - Assert.Empty(fx.Store.Candidates); - Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Skip && log.Text == "d1: язык не русский"); - } - - [Fact] - public async Task Tick_EvalFewMessages_ReviewWithMark() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true)); - fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New)); - fx.Gateway.InfoByDialog["d1"] = Info("d1", "Группа", string.Empty, "group", participants: 10); - fx.Gateway.ReadsByDialog["d1"] = Read("Нужен заказ на разработку", "Привет всем участникам"); - - DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal(DiscoveryWorkerService.ActionReview, outcome.Action); - DiscoveryCandidateDto candidate = fx.Store.Candidates.Single(); - Assert.Equal(DiscoveryCandidateStatuses.Review, candidate.Status); - Assert.Contains(DiscoveryWorkerService.MarkFewMessages, candidate.Marks); - Assert.Null(candidate.FitRatio); // контент не оценивался — решает человек (python L291–296) - } - - [Fact] - public async Task Tick_EvalNoHistory_ClosedGroupMarks() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true)); - fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New, kind: DiscoveryCandidateKinds.Group)); - fx.Gateway.InfoByDialog["d1"] = Info("d1", "Закрытая группа", string.Empty, "group", participants: 300); - - DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal(DiscoveryWorkerService.ActionReview, outcome.Action); - DiscoveryCandidateDto candidate = fx.Store.Candidates.Single(); - Assert.Contains(DiscoveryWorkerService.MarkClosedGroup, candidate.Marks); - Assert.Contains(DiscoveryWorkerService.MarkLangNotConfirmed, candidate.Marks); // ru-задача: язык не подтверждён - Assert.Null(candidate.FitRatio); - } - - [Fact] - public async Task Tick_EvalContentFailed_DeletesCandidateWithFewFitLog() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true, threshold: 60)); - fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New)); - fx.Gateway.InfoByDialog["d1"] = Info("d1", "Канал", string.Empty, "channel", participants: 10); - fx.Gateway.ReadsByDialog["d1"] = Read( - "Нужен заказ на разработку CRM", "Обсуждаем архитектуру", "Просто болтаем", "Привет всем участникам"); - - DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal((DiscoveryWorkerService.ActionSkip, "dt_1"), (outcome.Action, outcome.TaskId)); - Assert.Empty(fx.Store.Candidates); - Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Skip && log.Text == "d1: мало подходящих (1 из 4)"); - } - - [Fact] - public async Task Tick_EvalForum_ByTopicsWithAnyPassedTopic_MovesToReview() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true)); - fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New)); - fx.Gateway.InfoByDialog["d1"] = Info("d1", "Форум заказов", "forum1", "group", participants: 300, isForum: true); - fx.Gateway.ReadsByDialog["d1"] = new TelegramEvalReadDto( - Ok: true, - Error: null, - new List - { - MsgWithTopic(1, "Нужен заказ на разработку CRM", 10), - MsgWithTopic(2, "Ищем заказ на месяц", 10), - MsgWithTopic(3, "Обсуждаем интеграции", 10), - MsgWithTopic(4, "Заказ для студии", 20), - }); - - DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal((DiscoveryWorkerService.ActionReview, "dt_1"), (outcome.Action, outcome.TaskId)); - DiscoveryCandidateDto candidate = fx.Store.Candidates.Single(); - Assert.Equal(DiscoveryCandidateStatuses.Review, candidate.Status); - Assert.Equal(DiscoveryCandidateKinds.Forum, candidate.Kind); // is_forum → kind forum (Ruling 10) - DiscoveryTopicDto topic10 = Assert.Single(candidate.Topics, topic => topic.TopicId == "10"); - Assert.True(topic10.Passed); - DiscoveryTopicDto topic20 = Assert.Single(candidate.Topics, topic => topic.TopicId == "20"); - Assert.False(topic20.Passed); // тема из одного сообщения (<3) порог не проходит (passed L229–237) - Assert.Equal(3, candidate.Topics.Sum(topic => topic.FitCount)); - Assert.Equal(0.75, candidate.FitRatio); // агрегат fit из N по всей выборке форума - } - - // ─── План выполнен → done ────────────────────────────────────────────── - - [Fact] - public async Task Tick_PlanReached_MarksTaskDoneAndLogs() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", searchDone: false, joined: 1, planJoins: 1)); - - DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal((DiscoveryWorkerService.ActionDone, "dt_1"), (outcome.Action, outcome.TaskId)); - DiscoveryTaskDto task = fx.Store.Tasks.Single(); - Assert.Equal(DiscoveryTaskStatuses.Done, task.Status); - Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Done && log.Text == "план выполнен: вступили 1 из 1"); - } - - [Fact] - public async Task Tick_AfterDone_NoFurtherActions() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", searchDone: false, joined: 1, planJoins: 1)); - - Assert.Equal(DiscoveryWorkerService.ActionDone, (await fx.Worker.TickOnceAsync(CancellationToken.None)).Action); - DiscoveryWorkerOutcome next = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal(DiscoveryWorkerService.ActionNone, next.Action); // done — терминальный: тик пуст (идемпотентность) - Assert.Equal(1, fx.Store.Tasks.Single().Joined); // счётчики повторными тиками не меняются - } - - // ─── Авто-вступление ─────────────────────────────────────────────────── - - [Fact] - public async Task Tick_JoinWithPause_MarksJoinedAndTurnsMonitorOn() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true, autoJoin: true)); - fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.Review, kind: DiscoveryCandidateKinds.Group, username: "grp1")); - - DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal((DiscoveryWorkerService.ActionJoin, "dt_1"), (outcome.Action, outcome.TaskId)); - Assert.Equal(1, fx.Pacer.DelayCalls); // пауза между вступлениями была (в проде 50–70 с) - Assert.Equal(["grp1"], fx.Gateway.JoinedUsernames); - DiscoveryCandidateDto candidate = fx.Store.Candidates.Single(); - Assert.Equal(DiscoveryCandidateStatuses.Joined, candidate.Status); - Assert.True(candidate.AutoJoined); - Assert.Equal(1, fx.Store.Tasks.Single().Joined); - Assert.Equal([("d1", true)], fx.Gateway.SetMonitorCalls); // монитор-зеркало включено (python add_dialog_monitored) - Assert.Equal([("d1", false)], fx.Gateway.BackfillCalls); // фоновый разбор последних сообщений - Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.JoinAuto); - } - - [Fact] - public async Task Tick_JoinCandidateAlreadyMonitored_RejectsCandidate() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", searchDone: true, autoJoin: true)); - fx.Store.SeedMonitored("d1"); - fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.Review)); - - DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal((DiscoveryWorkerService.ActionReject, "dt_1"), (outcome.Action, outcome.TaskId)); - Assert.Empty(fx.Gateway.JoinedUsernames); - DiscoveryCandidateDto candidate = fx.Store.Candidates.Single(); - Assert.Equal(DiscoveryCandidateStatuses.Rejected, candidate.Status); - Assert.Single(fx.Store.Blacklist); - Assert.Equal(1, fx.Store.Tasks.Single().Rejected); - Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Reject && log.Text.Contains("уже вступили между оценкой и авто-вступлением")); - } - - [Fact] - public async Task Tick_JoinFlood_StopsAutoJoinsUntilEndOfDay() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", searchDone: true, autoJoin: true)); - fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.Review, username: "grp1")); - fx.Gateway.JoinError = FloodException(); - - DiscoveryWorkerOutcome flood = await fx.Worker.TickOnceAsync(CancellationToken.None); - DiscoveryWorkerOutcome next = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal(DiscoveryWorkerService.ActionFlood, flood.Action); - Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Flood && log.Text.Contains("авто-вступление d1: flood")); - Assert.Equal(DiscoveryWorkerService.ActionNone, next.Action); - DiscoveryCandidateDto candidate = fx.Store.Candidates.Single(); - Assert.Equal(DiscoveryCandidateStatuses.Review, candidate.Status); // кандидат остаётся review для повтора - } - - [Fact] - public async Task Tick_JoinFailures_AfterThreeDeletesCandidate() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", searchDone: true, autoJoin: true)); - fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.Review, username: "grp1")); - fx.Gateway.JoinError = new InvalidOperationException("JoinChannel 400: USERNAME_NOT_OCCUPIED"); - - DiscoveryWorkerOutcome first = await fx.Worker.TickOnceAsync(CancellationToken.None); - DiscoveryWorkerOutcome second = await fx.Worker.TickOnceAsync(CancellationToken.None); - DiscoveryWorkerOutcome third = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal(DiscoveryWorkerService.ActionError, first.Action); - Assert.Equal(DiscoveryWorkerService.ActionError, second.Action); - Assert.Equal(DiscoveryWorkerService.ActionSkip, third.Action); // 3-я неудача — кандидат удаляется (python L417–420) - Assert.Equal(3, fx.Pacer.DelayCalls); - Assert.Empty(fx.Store.Candidates); - Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Skip && log.Text.Contains("не удалось вступить (3 попытки)")); - } - - [Fact] - public async Task Tick_JoinStateChangedDuringPause_ExitsWithoutJoin() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", searchDone: true, autoJoin: true)); - fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.Review, username: "grp1")); - // За «паузу» 50–70 с задачу поставили на паузу вручную: повторная проверка после паузы → выход без join. - fx.Pacer.OnDelay = () => fx.Tasks.PauseAsync("dt_1", CancellationToken.None).GetAwaiter().GetResult(); - - DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal(DiscoveryWorkerService.ActionNone, outcome.Action); - Assert.Empty(fx.Gateway.JoinedUsernames); - DiscoveryCandidateDto candidate = fx.Store.Candidates.Single(); - Assert.Equal(DiscoveryCandidateStatuses.Review, candidate.Status); // кандидат не тронут - } - - [Fact] - public async Task Tick_JoinDailyQuotaReached_NoJoin() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", searchDone: true, autoJoin: true)); - fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.Review, username: "grp1")); - fx.Settings.Preload(SettingsKeys.DiscJoinLimit, "1"); - fx.Store.SeedLog(DiscoveryLogEvents.JoinAuto, UtcDayStart()); // суточная квота исчерпана - - DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal(DiscoveryWorkerService.ActionNone, outcome.Action); - Assert.Empty(fx.Gateway.JoinedUsernames); - Assert.Equal(0, fx.Pacer.DelayCalls); // join никому нельзя — пауза даже не запрашивалась (python L480–481) - } - - [Fact] - public async Task Tick_GlobalPaused_NoActionsAtAll() - { - Fixture fx = Create(); - fx.Store.SeedTask(Task("dt_1", searchDone: false)); - fx.Settings.Preload(SettingsKeys.DiscPaused, "true"); - - DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal(DiscoveryWorkerService.ActionNone, outcome.Action); - Assert.Empty(fx.Gateway.SearchedKeywords); - } - - // ─── Изоляция независимых тенантов ───────────────────────────────────── - - [Fact] - public async Task TicksAcrossTenantStores_DoNotInterfere() - { - // Два независимых набора «тенантов» (стор/гейт/настройки): поиск у A и оценка у B в одном проходе - // не пересекаются — каждое хранилище меняет только свои строки, лог/чёрный список своих тенантов. - Fixture fxA = Create(); - Fixture fxB = Create(); - - fxA.Store.SeedTask(Task("dt_A", keywords: ["заказ A"], searchDone: false)); - fxA.Gateway.SearchResults = [Entry("dA", "Канал A", "ch_a", DiscoveryCandidateKinds.Channel)]; - fxB.Store.SeedTask(Task("dt_B", keywords: ["заказ B"], searchDone: true)); - fxB.Store.SeedCandidate(Candidate("dB", "dt_B", DiscoveryCandidateStatuses.New)); - fxB.Gateway.InfoByDialog["dB"] = Info("dB", "Канал B", string.Empty, "channel", participants: 10); - fxB.Gateway.ReadsByDialog["dB"] = Read("Нужен заказ B на разработку", "Ищем заказ B на месяц", "Привет всем участникам", "Обсуждения"); - - DiscoveryWorkerOutcome outcomeA = await fxA.Worker.TickOnceAsync(CancellationToken.None); - DiscoveryWorkerOutcome outcomeB = await fxB.Worker.TickOnceAsync(CancellationToken.None); - - Assert.Equal(DiscoveryWorkerService.ActionSearch, outcomeA.Action); - Assert.Equal(DiscoveryWorkerService.ActionReview, outcomeB.Action); - Assert.Single(fxA.Store.Candidates, candidate => candidate.TaskId == "dt_A"); - DiscoveryCandidateDto candidateB = fxB.Store.Candidates.Single(); - Assert.Equal(DiscoveryCandidateStatuses.Review, candidateB.Status); - Assert.Equal(["заказ A"], fxA.Gateway.SearchedKeywords); - Assert.Empty(fxB.Gateway.SearchedKeywords); // задача B уже прошла поиск — тик шёл оценкой - Assert.Empty(fxA.Store.Blacklist); - Assert.Empty(fxB.Store.Blacklist); - Assert.DoesNotContain(fxA.Store.Log, log => log.TaskId == "dt_B"); - Assert.DoesNotContain(fxB.Store.Log, log => log.TaskId == "dt_A"); - } - - // ─── Контекст и хелперы ───────────────────────────────────────────────── - - // Контекст теста: воркер на фейках + ссылки для проверок состояния. - private sealed record Fixture( - FakeDiscoveryStore Store, - FakeSettingsStore Settings, - FakeDiscoveryGateway Gateway, - FakeDiscoveryPacer Pacer, - DiscoveryWorkerService Worker, - DiscoveryTasksService Tasks); - - // Создаёт воркер на общих фейках; ML/ИИ выключены — оценка идёт эвристикой по ключам. - private static Fixture Create() - { - var store = new FakeDiscoveryStore(); - var settings = new FakeSettingsStore(); - settings.Preload(SettingsKeys.MlEnabled, "false"); - settings.Preload(SettingsKeys.AiEnabled, "false"); - var ml = new FakeMlClient(); - var ai = new FakeAiTools(); - var log = new DiscoveryLogService(store); - var blacklist = new DiscoveryBlacklistService(store); - var candidates = new DiscoveryCandidatesService(store, log, blacklist); - var planGuard = new DiscoveryPlanGuard(store, settings); - var tasks = new DiscoveryTasksService(store, planGuard, settings); - var banGuard = new DiscoveryBanGuard(store, settings); - var pacer = new FakeDiscoveryPacer(); - var gateway = new FakeDiscoveryGateway(); - var worker = new DiscoveryWorkerService( - store, tasks, candidates, log, new DiscoveryEvaluator(settings, ml, ai), banGuard, pacer, gateway); - return new Fixture(store, settings, gateway, pacer, worker, tasks); - } - - // Задача как строка DiscTasks (адаптер-форма). - private static DiscoveryTaskDto Task( - string id, - string[]? keywords = null, - bool searchDone = true, - bool autoJoin = false, - int planJoins = 5, - int joined = 0, - int minSubscribers = 0, - int threshold = 40) - { - return new DiscoveryTaskDto( - id, - "Задача", - string.Empty, - keywords ?? new[] { "заказ" }, - minSubscribers, - Lang: "ru", - threshold, - SampleSize: 10, - planJoins, - autoJoin, - Status: DiscoveryTaskStatuses.Running, - SearchIdx: 0, - searchDone, - Found: 0, - Evaluated: 0, - joined, - Rejected: 0, - CreatedAt: 1000, - UpdatedAt: 1000); - } - - // Кандидат как строка DiscCandidates (адаптер-форма). - private static DiscoveryCandidateDto Candidate( - string dialogId, - string taskId, - string status, - string kind = DiscoveryCandidateKinds.Channel, - string username = "") - { - return new DiscoveryCandidateDto( - dialogId, - taskId, - dialogId, - username, - kind, - "#666", - Participants: null, - LangRu: null, - new List(), - new List(), - FitRatio: null, - status, - AutoJoined: false, - JoinFailures: 0, - CreatedAt: 1000, - UpdatedAt: 1000); - } - - // Запись результата глобального поиска (proto DialogEntry). - private static TelegramDialogEntryDto Entry( - string id, - string name, - string handle, - string kind) - => new(id, name, handle, kind, "#666"); - - // Инфо источника (proto ChannelInfo). - private static TelegramChannelInfoDto Info( - string id, - string name, - string username, - string kind, - int? participants, - bool isForum = false) - => new(id, name, username, kind, "#666", participants, isForum); - - // Сообщение форума с темой (proto EvalMessage). - private static TelegramEvalMessageDto MsgWithTopic( - long id, - string text, - long? topic) - => new(id, text, 1_700_000_000_000 + id, topic, TopicTitle: null); - - // Выборка сообщений (ok=true), тексты в порядке входных аргументов. - private static TelegramEvalReadDto Read(params string[] texts) - => new(Ok: true, Error: null, texts.Select((text, index) => new TelegramEvalMessageDto(index + 1, text, 1_700_000_000_000 + index, TopicId: null, TopicTitle: null)).ToList()); - - // Начало текущих UTC-суток (эпоха-ms; seed-квота бан-гарда, которая считает по UTC-дню). - private static DateTimeOffset UtcDayStart() - { - DateTime now = DateTime.UtcNow; - return new DateTimeOffset(now.Year, now.Month, now.Day, 0, 0, 0, TimeSpan.Zero); - } - - private static RpcException FloodException() => - new(new Status(StatusCode.ResourceExhausted, "flood: FLOOD_WAIT_120")); -} +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Services; +using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Modules.Discovery; +using Deal.Tests.Unit.Modules.Settings; +using Grpc.Core; + +namespace Deal.Tests.Unit.Contracts; + +/// +/// Тесты DiscoveryWorkerService — один шаг за тик +/// +public sealed class DiscoveryWorkerServiceTests +{ + // ─── Поиск ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Tick_SearchCreatesCandidatesAndFinishesKeywordsPass() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", keywords: ["вакансия"], searchDone: false)); + fx.Gateway.SearchResults = + [ + Entry("d1", "Вакансии для разработчиков", "job_ch", DiscoveryCandidateKinds.Channel), + Entry("l1", "Лена", string.Empty, "chat"), + ]; + + DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal((DiscoveryWorkerService.ActionSearch, "dt_1"), (outcome.Action, outcome.TaskId)); + DiscoveryCandidateDto candidate = Assert.Single(fx.Store.Candidates); + Assert.Equal("d1", candidate.DialogId); + Assert.Equal("job_ch", candidate.Username); + Assert.Equal(DiscoveryCandidateKinds.Channel, candidate.Kind); + DiscoveryTaskDto task = fx.Store.Tasks.Single(); + Assert.Equal(1, task.SearchIdx); + Assert.True(task.SearchDone); + Assert.Equal(1, task.Found); + Assert.Equal(["вакансия"], fx.Gateway.SearchedKeywords); + Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Search && log.Text == "поиск завершён: 1 кандидатов"); + Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Skip && log.Text == "Лена: личный чат/бот"); + } + + [Fact] + public async Task Tick_SearchErrorThreeTimes_SkipsBrokenKeyword() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", keywords: ["битый ключ"], searchDone: false)); + fx.Gateway.SearchError = new InvalidOperationException("Telegram недоступен"); + + DiscoveryWorkerOutcome first = await fx.Worker.TickOnceAsync(CancellationToken.None); + DiscoveryWorkerOutcome second = await fx.Worker.TickOnceAsync(CancellationToken.None); + DiscoveryWorkerOutcome third = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal(DiscoveryWorkerService.ActionError, first.Action); + Assert.Equal(DiscoveryWorkerService.ActionError, second.Action); + Assert.Equal(DiscoveryWorkerService.ActionError, third.Action); // 3-я ошибка — ключ пропущен (python L196–203) + DiscoveryTaskDto task = fx.Store.Tasks.Single(); + Assert.Equal(1, task.SearchIdx); + Assert.True(task.SearchDone); + Assert.Contains(fx.Store.Log, log => log.Text.Contains("ключ пропущен (3 ошибки подряд)", StringComparison.Ordinal)); + Assert.Equal(["битый ключ", "битый ключ", "битый ключ"], fx.Gateway.SearchedKeywords); + } + + [Fact] + public async Task Tick_SearchFlood_NotesFloodDayAndStops() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", keywords: ["ключ"], searchDone: false)); + fx.Gateway.SearchError = FloodException(); + + DiscoveryWorkerOutcome flood = await fx.Worker.TickOnceAsync(CancellationToken.None); + DiscoveryWorkerOutcome next = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal(DiscoveryWorkerService.ActionFlood, flood.Action); + Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Flood && log.Text.Contains("flood — стоп до конца суток")); + Assert.Equal(DiscoveryWorkerService.ActionNone, next.Action); // flood-день: воркер стоит (python L448–451) + Assert.Single(fx.Gateway.SearchedKeywords); // второй тик сеть не дёргал + } + + // ─── Оценка кандидата ────────────────────────────────────────────────── + + [Fact] + public async Task Tick_EvalPassed_MovesCandidateToReviewWithFitRatio() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true)); + fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New, kind: DiscoveryCandidateKinds.Group, username: "grp1")); + fx.Gateway.InfoByDialog["d1"] = Info("d1", "Группа заказов", "grp1", "group", participants: 120); + fx.Gateway.ReadsByDialog["d1"] = Read( + "Нужен заказ на разработку CRM", "Ищем заказ на месяц для клиента", "Обсуждаем стек проекта", "Всем привет!"); + + DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal((DiscoveryWorkerService.ActionReview, "dt_1"), (outcome.Action, outcome.TaskId)); + DiscoveryCandidateDto candidate = fx.Store.Candidates.Single(); + Assert.Equal(DiscoveryCandidateStatuses.Review, candidate.Status); + Assert.True(candidate.LangRu); + Assert.Equal(0.5, candidate.FitRatio); + Assert.Empty(candidate.Marks); + Assert.Equal(1, fx.Store.Tasks.Single().Evaluated); + Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Review && log.Text.Contains("d1")); + Assert.Equal(120, candidate.Participants); + Assert.Equal("Группа заказов", candidate.Name); + } + + [Fact] + public async Task Tick_EvalTooFewSubscribers_DeletesCandidate() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true, minSubscribers: 500)); + fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New)); + fx.Gateway.InfoByDialog["d1"] = Info("d1", "Канал", string.Empty, "channel", participants: 120); + + DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal((DiscoveryWorkerService.ActionSkip, "dt_1"), (outcome.Action, outcome.TaskId)); + Assert.Empty(fx.Store.Candidates); + Assert.Equal(1, fx.Store.Tasks.Single().Evaluated); + Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Skip && log.Text == "d1: мало участников (120 < 500)"); + } + + [Fact] + public async Task Tick_EvalNotRussianLanguage_DeletesCandidate() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", keywords: ["order"], searchDone: true)); + fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New)); + fx.Gateway.InfoByDialog["d1"] = Info("d1", "Channel", string.Empty, "channel", participants: 10); + fx.Gateway.ReadsByDialog["d1"] = Read( + "We need order for crm", "Looking for developers", "Please contact us today", "Hello everyone"); + + DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal((DiscoveryWorkerService.ActionSkip, "dt_1"), (outcome.Action, outcome.TaskId)); + Assert.Empty(fx.Store.Candidates); + Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Skip && log.Text == "d1: язык не русский"); + } + + [Fact] + public async Task Tick_EvalFewMessages_ReviewWithMark() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true)); + fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New)); + fx.Gateway.InfoByDialog["d1"] = Info("d1", "Группа", string.Empty, "group", participants: 10); + fx.Gateway.ReadsByDialog["d1"] = Read("Нужен заказ на разработку", "Привет всем участникам"); + + DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal(DiscoveryWorkerService.ActionReview, outcome.Action); + DiscoveryCandidateDto candidate = fx.Store.Candidates.Single(); + Assert.Equal(DiscoveryCandidateStatuses.Review, candidate.Status); + Assert.Contains(DiscoveryWorkerService.MarkFewMessages, candidate.Marks); + Assert.Null(candidate.FitRatio); // контент не оценивался — решает человек (python L291–296) + } + + [Fact] + public async Task Tick_EvalNoHistory_ClosedGroupMarks() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true)); + fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New, kind: DiscoveryCandidateKinds.Group)); + fx.Gateway.InfoByDialog["d1"] = Info("d1", "Закрытая группа", string.Empty, "group", participants: 300); + + DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal(DiscoveryWorkerService.ActionReview, outcome.Action); + DiscoveryCandidateDto candidate = fx.Store.Candidates.Single(); + Assert.Contains(DiscoveryWorkerService.MarkClosedGroup, candidate.Marks); + Assert.Contains(DiscoveryWorkerService.MarkLangNotConfirmed, candidate.Marks); // ru-задача: язык не подтверждён + Assert.Null(candidate.FitRatio); + } + + [Fact] + public async Task Tick_EvalContentFailed_DeletesCandidateWithFewFitLog() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true, threshold: 60)); + fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New)); + fx.Gateway.InfoByDialog["d1"] = Info("d1", "Канал", string.Empty, "channel", participants: 10); + fx.Gateway.ReadsByDialog["d1"] = Read( + "Нужен заказ на разработку CRM", "Обсуждаем архитектуру", "Просто болтаем", "Привет всем участникам"); + + DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal((DiscoveryWorkerService.ActionSkip, "dt_1"), (outcome.Action, outcome.TaskId)); + Assert.Empty(fx.Store.Candidates); + Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Skip && log.Text == "d1: мало подходящих (1 из 4)"); + } + + [Fact] + public async Task Tick_EvalForum_ByTopicsWithAnyPassedTopic_MovesToReview() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true)); + fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New)); + fx.Gateway.InfoByDialog["d1"] = Info("d1", "Форум заказов", "forum1", "group", participants: 300, isForum: true); + fx.Gateway.ReadsByDialog["d1"] = new TelegramEvalReadDto( + Ok: true, + Error: null, + new List + { + MsgWithTopic(1, "Нужен заказ на разработку CRM", 10), + MsgWithTopic(2, "Ищем заказ на месяц", 10), + MsgWithTopic(3, "Обсуждаем интеграции", 10), + MsgWithTopic(4, "Заказ для студии", 20), + }); + + DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal((DiscoveryWorkerService.ActionReview, "dt_1"), (outcome.Action, outcome.TaskId)); + DiscoveryCandidateDto candidate = fx.Store.Candidates.Single(); + Assert.Equal(DiscoveryCandidateStatuses.Review, candidate.Status); + Assert.Equal(DiscoveryCandidateKinds.Forum, candidate.Kind); // is_forum → kind forum (Ruling 10) + DiscoveryTopicDto topic10 = Assert.Single(candidate.Topics, topic => topic.TopicId == "10"); + Assert.True(topic10.Passed); + DiscoveryTopicDto topic20 = Assert.Single(candidate.Topics, topic => topic.TopicId == "20"); + Assert.False(topic20.Passed); // тема из одного сообщения (<3) порог не проходит (passed L229–237) + Assert.Equal(3, candidate.Topics.Sum(topic => topic.FitCount)); + Assert.Equal(0.75, candidate.FitRatio); // агрегат fit из N по всей выборке форума + } + + // ─── План выполнен → done ────────────────────────────────────────────── + + [Fact] + public async Task Tick_PlanReached_MarksTaskDoneAndLogs() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", searchDone: false, joined: 1, planJoins: 1)); + + DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal((DiscoveryWorkerService.ActionDone, "dt_1"), (outcome.Action, outcome.TaskId)); + DiscoveryTaskDto task = fx.Store.Tasks.Single(); + Assert.Equal(DiscoveryTaskStatuses.Done, task.Status); + Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Done && log.Text == "план выполнен: вступили 1 из 1"); + } + + [Fact] + public async Task Tick_AfterDone_NoFurtherActions() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", searchDone: false, joined: 1, planJoins: 1)); + + Assert.Equal(DiscoveryWorkerService.ActionDone, (await fx.Worker.TickOnceAsync(CancellationToken.None)).Action); + DiscoveryWorkerOutcome next = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal(DiscoveryWorkerService.ActionNone, next.Action); // done — терминальный: тик пуст (идемпотентность) + Assert.Equal(1, fx.Store.Tasks.Single().Joined); // счётчики повторными тиками не меняются + } + + // ─── Авто-вступление ─────────────────────────────────────────────────── + + [Fact] + public async Task Tick_JoinWithPause_MarksJoinedAndTurnsMonitorOn() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true, autoJoin: true)); + fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.Review, kind: DiscoveryCandidateKinds.Group, username: "grp1")); + + DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal((DiscoveryWorkerService.ActionJoin, "dt_1"), (outcome.Action, outcome.TaskId)); + Assert.Equal(1, fx.Pacer.DelayCalls); // пауза между вступлениями была (в проде 50–70 с) + Assert.Equal(["grp1"], fx.Gateway.JoinedUsernames); + DiscoveryCandidateDto candidate = fx.Store.Candidates.Single(); + Assert.Equal(DiscoveryCandidateStatuses.Joined, candidate.Status); + Assert.True(candidate.AutoJoined); + Assert.Equal(1, fx.Store.Tasks.Single().Joined); + Assert.Equal([("d1", true)], fx.Gateway.SetMonitorCalls); // монитор-зеркало включено (python add_dialog_monitored) + Assert.Equal([("d1", false)], fx.Gateway.BackfillCalls); // фоновый разбор последних сообщений + Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.JoinAuto); + } + + [Fact] + public async Task Tick_JoinCandidateAlreadyMonitored_RejectsCandidate() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", searchDone: true, autoJoin: true)); + fx.Store.SeedMonitored("d1"); + fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.Review)); + + DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal((DiscoveryWorkerService.ActionReject, "dt_1"), (outcome.Action, outcome.TaskId)); + Assert.Empty(fx.Gateway.JoinedUsernames); + DiscoveryCandidateDto candidate = fx.Store.Candidates.Single(); + Assert.Equal(DiscoveryCandidateStatuses.Rejected, candidate.Status); + Assert.Single(fx.Store.Blacklist); + Assert.Equal(1, fx.Store.Tasks.Single().Rejected); + Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Reject && log.Text.Contains("уже вступили между оценкой и авто-вступлением")); + } + + [Fact] + public async Task Tick_JoinFlood_StopsAutoJoinsUntilEndOfDay() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", searchDone: true, autoJoin: true)); + fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.Review, username: "grp1")); + fx.Gateway.JoinError = FloodException(); + + DiscoveryWorkerOutcome flood = await fx.Worker.TickOnceAsync(CancellationToken.None); + DiscoveryWorkerOutcome next = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal(DiscoveryWorkerService.ActionFlood, flood.Action); + Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Flood && log.Text.Contains("авто-вступление d1: flood")); + Assert.Equal(DiscoveryWorkerService.ActionNone, next.Action); + DiscoveryCandidateDto candidate = fx.Store.Candidates.Single(); + Assert.Equal(DiscoveryCandidateStatuses.Review, candidate.Status); // кандидат остаётся review для повтора + } + + [Fact] + public async Task Tick_JoinFailures_AfterThreeDeletesCandidate() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", searchDone: true, autoJoin: true)); + fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.Review, username: "grp1")); + fx.Gateway.JoinError = new InvalidOperationException("JoinChannel 400: USERNAME_NOT_OCCUPIED"); + + DiscoveryWorkerOutcome first = await fx.Worker.TickOnceAsync(CancellationToken.None); + DiscoveryWorkerOutcome second = await fx.Worker.TickOnceAsync(CancellationToken.None); + DiscoveryWorkerOutcome third = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal(DiscoveryWorkerService.ActionError, first.Action); + Assert.Equal(DiscoveryWorkerService.ActionError, second.Action); + Assert.Equal(DiscoveryWorkerService.ActionSkip, third.Action); // 3-я неудача — кандидат удаляется (python L417–420) + Assert.Equal(3, fx.Pacer.DelayCalls); + Assert.Empty(fx.Store.Candidates); + Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Skip && log.Text.Contains("не удалось вступить (3 попытки)")); + } + + [Fact] + public async Task Tick_JoinStateChangedDuringPause_ExitsWithoutJoin() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", searchDone: true, autoJoin: true)); + fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.Review, username: "grp1")); + // За «паузу» 50–70 с задачу поставили на паузу вручную: повторная проверка после паузы → выход без join. + fx.Pacer.OnDelay = () => fx.Tasks.PauseAsync("dt_1", CancellationToken.None).GetAwaiter().GetResult(); + + DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal(DiscoveryWorkerService.ActionNone, outcome.Action); + Assert.Empty(fx.Gateway.JoinedUsernames); + DiscoveryCandidateDto candidate = fx.Store.Candidates.Single(); + Assert.Equal(DiscoveryCandidateStatuses.Review, candidate.Status); // кандидат не тронут + } + + [Fact] + public async Task Tick_JoinDailyQuotaReached_NoJoin() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", searchDone: true, autoJoin: true)); + fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.Review, username: "grp1")); + fx.Settings.Preload(SettingsKeys.DiscJoinLimit, "1"); + fx.Store.SeedLog(DiscoveryLogEvents.JoinAuto, UtcDayStart()); // суточная квота исчерпана + + DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal(DiscoveryWorkerService.ActionNone, outcome.Action); + Assert.Empty(fx.Gateway.JoinedUsernames); + Assert.Equal(0, fx.Pacer.DelayCalls); // join никому нельзя — пауза даже не запрашивалась (python L480–481) + } + + [Fact] + public async Task Tick_GlobalPaused_NoActionsAtAll() + { + Fixture fx = Create(); + fx.Store.SeedTask(Task("dt_1", searchDone: false)); + fx.Settings.Preload(SettingsKeys.DiscPaused, "true"); + + DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal(DiscoveryWorkerService.ActionNone, outcome.Action); + Assert.Empty(fx.Gateway.SearchedKeywords); + } + + // ─── Изоляция независимых тенантов ───────────────────────────────────── + + [Fact] + public async Task TicksAcrossTenantStores_DoNotInterfere() + { + // Два независимых набора «тенантов» (стор/гейт/настройки): поиск у A и оценка у B в одном проходе + // не пересекаются — каждое хранилище меняет только свои строки, лог/чёрный список своих тенантов. + Fixture fxA = Create(); + Fixture fxB = Create(); + + fxA.Store.SeedTask(Task("dt_A", keywords: ["заказ A"], searchDone: false)); + fxA.Gateway.SearchResults = [Entry("dA", "Канал A", "ch_a", DiscoveryCandidateKinds.Channel)]; + fxB.Store.SeedTask(Task("dt_B", keywords: ["заказ B"], searchDone: true)); + fxB.Store.SeedCandidate(Candidate("dB", "dt_B", DiscoveryCandidateStatuses.New)); + fxB.Gateway.InfoByDialog["dB"] = Info("dB", "Канал B", string.Empty, "channel", participants: 10); + fxB.Gateway.ReadsByDialog["dB"] = Read("Нужен заказ B на разработку", "Ищем заказ B на месяц", "Привет всем участникам", "Обсуждения"); + + DiscoveryWorkerOutcome outcomeA = await fxA.Worker.TickOnceAsync(CancellationToken.None); + DiscoveryWorkerOutcome outcomeB = await fxB.Worker.TickOnceAsync(CancellationToken.None); + + Assert.Equal(DiscoveryWorkerService.ActionSearch, outcomeA.Action); + Assert.Equal(DiscoveryWorkerService.ActionReview, outcomeB.Action); + Assert.Single(fxA.Store.Candidates, candidate => candidate.TaskId == "dt_A"); + DiscoveryCandidateDto candidateB = fxB.Store.Candidates.Single(); + Assert.Equal(DiscoveryCandidateStatuses.Review, candidateB.Status); + Assert.Equal(["заказ A"], fxA.Gateway.SearchedKeywords); + Assert.Empty(fxB.Gateway.SearchedKeywords); // задача B уже прошла поиск — тик шёл оценкой + Assert.Empty(fxA.Store.Blacklist); + Assert.Empty(fxB.Store.Blacklist); + Assert.DoesNotContain(fxA.Store.Log, log => log.TaskId == "dt_B"); + Assert.DoesNotContain(fxB.Store.Log, log => log.TaskId == "dt_A"); + } + + // ─── Контекст и хелперы ───────────────────────────────────────────────── + + // Контекст теста: воркер на фейках + ссылки для проверок состояния. + private sealed record Fixture( + FakeDiscoveryStore Store, + FakeSettingsStore Settings, + FakeDiscoveryGateway Gateway, + FakeDiscoveryPacer Pacer, + DiscoveryWorkerService Worker, + DiscoveryTasksService Tasks); + + // Создаёт воркер на общих фейках; ML/ИИ выключены — оценка идёт эвристикой по ключам. + private static Fixture Create() + { + var store = new FakeDiscoveryStore(); + var settings = new FakeSettingsStore(); + settings.Preload(SettingsKeys.MlEnabled, "false"); + settings.Preload(SettingsKeys.AiEnabled, "false"); + var ml = new FakeMlClient(); + var ai = new FakeAiTools(); + var log = new DiscoveryLogService(store); + var blacklist = new DiscoveryBlacklistService(store); + var candidates = new DiscoveryCandidatesService(store, log, blacklist); + var planGuard = new DiscoveryPlanGuard(store, settings); + var tasks = new DiscoveryTasksService(store, planGuard, settings); + var banGuard = new DiscoveryBanGuard(store, settings); + var pacer = new FakeDiscoveryPacer(); + var gateway = new FakeDiscoveryGateway(); + var worker = new DiscoveryWorkerService( + store, tasks, candidates, log, new DiscoveryEvaluator(settings, ml, ai), banGuard, pacer, gateway); + return new Fixture(store, settings, gateway, pacer, worker, tasks); + } + + // Задача как строка DiscTasks (адаптер-форма). + private static DiscoveryTaskDto Task( + string id, + string[]? keywords = null, + bool searchDone = true, + bool autoJoin = false, + int planJoins = 5, + int joined = 0, + int minSubscribers = 0, + int threshold = 40) + { + return new DiscoveryTaskDto( + id, + "Задача", + string.Empty, + keywords ?? new[] { "заказ" }, + minSubscribers, + Lang: "ru", + threshold, + SampleSize: 10, + planJoins, + autoJoin, + Status: DiscoveryTaskStatuses.Running, + SearchIdx: 0, + searchDone, + Found: 0, + Evaluated: 0, + joined, + Rejected: 0, + CreatedAt: 1000, + UpdatedAt: 1000); + } + + // Кандидат как строка DiscCandidates (адаптер-форма). + private static DiscoveryCandidateDto Candidate( + string dialogId, + string taskId, + string status, + string kind = DiscoveryCandidateKinds.Channel, + string username = "") + { + return new DiscoveryCandidateDto( + dialogId, + taskId, + dialogId, + username, + kind, + "#666", + Participants: null, + LangRu: null, + new List(), + new List(), + FitRatio: null, + status, + AutoJoined: false, + JoinFailures: 0, + CreatedAt: 1000, + UpdatedAt: 1000); + } + + // Запись результата глобального поиска (proto DialogEntry). + private static TelegramDialogEntryDto Entry( + string id, + string name, + string handle, + string kind) + => new(id, name, handle, kind, "#666"); + + // Инфо источника (proto ChannelInfo). + private static TelegramChannelInfoDto Info( + string id, + string name, + string username, + string kind, + int? participants, + bool isForum = false) + => new(id, name, username, kind, "#666", participants, isForum); + + // Сообщение форума с темой (proto EvalMessage). + private static TelegramEvalMessageDto MsgWithTopic( + long id, + string text, + long? topic) + => new(id, text, 1_700_000_000_000 + id, topic, TopicTitle: null); + + // Выборка сообщений (ok=true), тексты в порядке входных аргументов. + private static TelegramEvalReadDto Read(params string[] texts) + => new(Ok: true, Error: null, texts.Select((text, index) => new TelegramEvalMessageDto(index + 1, text, 1_700_000_000_000 + index, TopicId: null, TopicTitle: null)).ToList()); + + // Начало текущих UTC-суток (эпоха-ms; seed-квота бан-гарда, которая считает по UTC-дню). + private static DateTimeOffset UtcDayStart() + { + DateTime now = DateTime.UtcNow; + return new DateTimeOffset(now.Year, now.Month, now.Day, 0, 0, 0, TimeSpan.Zero); + } + + private static RpcException FloodException() => + new(new Status(StatusCode.ResourceExhausted, "flood: FLOOD_WAIT_120")); +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramGateway.cs b/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramGateway.cs index baf05dd..0d25145 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramGateway.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramGateway.cs @@ -1,189 +1,189 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Grpc.Core; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// In-memory реализация для юнит-тестов модуля Telegram/ингресса. -/// -public sealed class FakeTelegramGateway : ITelegramGateway -{ - // Фаза idle-формы (по умолчанию не подключён). - private const string IdlePhase = "idle"; - - private const string NotConnectedDetail = "Telegram не подключён"; - - /// - /// Вызовы SetMonitor - /// - public List<(string DialogId, bool Enabled)> SetMonitorCalls { get; } = []; - - /// - /// Вызовы SetMonitorAll - /// - public List SetMonitorAllCalls { get; } = []; - - /// - /// Вызовы Backfill - /// - public List<(string DialogId, bool Force)> BackfillCalls { get; } = []; - - /// - /// Вызовы ReadSource - /// - public List<(string DialogId, long MsgId)> ReadSourceCalls { get; } = []; - - /// - /// Результат ReadSourceAsync - /// - public TelegramSourceContentDto SourceContent { get; set; } = new(false, null, null); - - /// - /// Сколько сообщений «разобрал» Backfill - /// - public int BackfillResult { get; set; } = 3; - - /// - /// Каталог, который возвращает RefreshDialogsAsync/SearchAsync - /// - public IReadOnlyList Catalog { get; set; } = []; - - /// - /// Статус, который возвращает StatusAsync - /// - public TelegramAccountStatusDto Status { get; set; } = - new(IdlePhase, Connected: false, Listener: false, Account: string.Empty, Error: null, QrUrl: null); - - /// - /// Заданное исключение StatusAsync - /// - public RpcException? StatusFailure { get; set; } - - /// - /// Диалоги, чей BackfillAsync падает - /// - public HashSet BackfillFailures { get; } = new(StringComparer.Ordinal); - - /// - /// Диалоги, чей SetMonitorAsync падает - /// - public HashSet SetMonitorFailures { get; } = new(StringComparer.Ordinal); - - /// - public Task StatusAsync(CancellationToken ct) - { - if (StatusFailure is not null) - { - throw StatusFailure; - } - - return Task.FromResult(Status); - } - - /// - public Task StartPhoneAsync( - string phone, - int apiId, - string apiHash, - CancellationToken ct) - => Task.FromResult(new TelegramAuthResultDto("code", null)); - - /// - public Task StartQrAsync( - int apiId, - string apiHash, - CancellationToken ct) - => Task.FromResult(new TelegramAuthResultDto("qr", "https://t.me/qr/test")); - - /// - public Task SendCodeAsync(string code, CancellationToken ct) => Task.FromResult("ready"); - - /// - public Task SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult("ready"); - - /// - public Task LogoutAsync(CancellationToken ct) => Task.CompletedTask; - - /// - public Task> RefreshDialogsAsync(CancellationToken ct) - => Task.FromResult(Catalog); - - /// - public Task SetMonitorAsync( - string dialogId, - bool enabled, - CancellationToken ct) - { - if (SetMonitorFailures.Contains(dialogId)) - { - throw new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail)); - } - - SetMonitorCalls.Add((dialogId, enabled)); - return Task.CompletedTask; - } - - /// - public Task SetMonitorAllAsync(bool enabled, CancellationToken ct) - { - SetMonitorAllCalls.Add(enabled); - return Task.CompletedTask; - } - - /// - public Task BackfillAsync( - string dialogId, - bool force, - CancellationToken ct) - { - BackfillCalls.Add((dialogId, force)); - if (BackfillFailures.Contains(dialogId)) - { - throw new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail)); - } - - return Task.FromResult(BackfillResult); - } - - /// - public Task> ReadRecentAsync( - string dialogId, - int limit, - CancellationToken ct) - => Task.FromResult>([]); - - /// - public Task ReadSourceAsync( - string dialogId, - long msgId, - CancellationToken ct) - { - ReadSourceCalls.Add((dialogId, msgId)); - return Task.FromResult(SourceContent); - } - - /// - public Task> SearchAsync( - string query, - int limit, - CancellationToken ct) - => Task.FromResult(Catalog); - - /// - public Task InfoAsync(string dialogId, CancellationToken ct) - => Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, "#666", null, false)); - - /// - public Task ReadForEvalAsync( - string dialogId, - int limit, - CancellationToken ct) - => Task.FromResult(new TelegramEvalReadDto(false, "no_history", [])); - - /// - public Task JoinAsync(string username, CancellationToken ct) => Task.CompletedTask; - - /// - public Task LeaveAsync(string dialogId, CancellationToken ct) => Task.CompletedTask; -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Grpc.Core; + +namespace Deal.Tests.Unit.Contracts; + +/// +/// In-memory реализация для юнит-тестов модуля Telegram/ингресса. +/// +public sealed class FakeTelegramGateway : ITelegramGateway +{ + // Фаза idle-формы (по умолчанию не подключён). + private const string IdlePhase = "idle"; + + private const string NotConnectedDetail = "Telegram не подключён"; + + /// + /// Вызовы SetMonitor + /// + public List<(string DialogId, bool Enabled)> SetMonitorCalls { get; } = []; + + /// + /// Вызовы SetMonitorAll + /// + public List SetMonitorAllCalls { get; } = []; + + /// + /// Вызовы Backfill + /// + public List<(string DialogId, bool Force)> BackfillCalls { get; } = []; + + /// + /// Вызовы ReadSource + /// + public List<(string DialogId, long MsgId)> ReadSourceCalls { get; } = []; + + /// + /// Результат ReadSourceAsync + /// + public TelegramSourceContentDto SourceContent { get; set; } = new(false, null, null); + + /// + /// Сколько сообщений «разобрал» Backfill + /// + public int BackfillResult { get; set; } = 3; + + /// + /// Каталог, который возвращает RefreshDialogsAsync/SearchAsync + /// + public IReadOnlyList Catalog { get; set; } = []; + + /// + /// Статус, который возвращает StatusAsync + /// + public TelegramAccountStatusDto Status { get; set; } = + new(IdlePhase, Connected: false, Listener: false, Account: string.Empty, Error: null, QrUrl: null); + + /// + /// Заданное исключение StatusAsync + /// + public RpcException? StatusFailure { get; set; } + + /// + /// Диалоги, чей BackfillAsync падает + /// + public HashSet BackfillFailures { get; } = new(StringComparer.Ordinal); + + /// + /// Диалоги, чей SetMonitorAsync падает + /// + public HashSet SetMonitorFailures { get; } = new(StringComparer.Ordinal); + + /// + public Task StatusAsync(CancellationToken ct) + { + if (StatusFailure is not null) + { + throw StatusFailure; + } + + return Task.FromResult(Status); + } + + /// + public Task StartPhoneAsync( + string phone, + int apiId, + string apiHash, + CancellationToken ct) + => Task.FromResult(new TelegramAuthResultDto("code", null)); + + /// + public Task StartQrAsync( + int apiId, + string apiHash, + CancellationToken ct) + => Task.FromResult(new TelegramAuthResultDto("qr", "https://t.me/qr/test")); + + /// + public Task SendCodeAsync(string code, CancellationToken ct) => Task.FromResult("ready"); + + /// + public Task SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult("ready"); + + /// + public Task LogoutAsync(CancellationToken ct) => Task.CompletedTask; + + /// + public Task> RefreshDialogsAsync(CancellationToken ct) + => Task.FromResult(Catalog); + + /// + public Task SetMonitorAsync( + string dialogId, + bool enabled, + CancellationToken ct) + { + if (SetMonitorFailures.Contains(dialogId)) + { + throw new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail)); + } + + SetMonitorCalls.Add((dialogId, enabled)); + return Task.CompletedTask; + } + + /// + public Task SetMonitorAllAsync(bool enabled, CancellationToken ct) + { + SetMonitorAllCalls.Add(enabled); + return Task.CompletedTask; + } + + /// + public Task BackfillAsync( + string dialogId, + bool force, + CancellationToken ct) + { + BackfillCalls.Add((dialogId, force)); + if (BackfillFailures.Contains(dialogId)) + { + throw new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail)); + } + + return Task.FromResult(BackfillResult); + } + + /// + public Task> ReadRecentAsync( + string dialogId, + int limit, + CancellationToken ct) + => Task.FromResult>([]); + + /// + public Task ReadSourceAsync( + string dialogId, + long msgId, + CancellationToken ct) + { + ReadSourceCalls.Add((dialogId, msgId)); + return Task.FromResult(SourceContent); + } + + /// + public Task> SearchAsync( + string query, + int limit, + CancellationToken ct) + => Task.FromResult(Catalog); + + /// + public Task InfoAsync(string dialogId, CancellationToken ct) + => Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, "#666", null, false)); + + /// + public Task ReadForEvalAsync( + string dialogId, + int limit, + CancellationToken ct) + => Task.FromResult(new TelegramEvalReadDto(false, "no_history", [])); + + /// + public Task JoinAsync(string username, CancellationToken ct) => Task.CompletedTask; + + /// + public Task LeaveAsync(string dialogId, CancellationToken ct) => Task.CompletedTask; +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs index 48c6ab5..5962a89 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs @@ -1,142 +1,142 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Ai; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Models; -using Deal.Tests.Unit.Grpc; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Tenants; -using Deal.Tests.Unit.Support; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты gRPC-адаптера IAiTools к ai-service -/// -[Collection("MlGrpcTests")] -public sealed class GrpcAiToolsTests -{ - // Id тенанта сценариев строкой (формат N) — ожидаемое значение metadata tenant-id. - private const string TenantIdValue = "fedcba9876543210fedcba9876543210"; - - private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue); - - [Fact] - public async Task GenerateKeywordsAsync_MapsKeywordsAndSendsProviderConfigAndMetadata() - { - await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => - { - var reply = new GenerateKeywordsReply { Usage = new Usage { Prompt = 300, Completion = 60, Total = 360 } }; - reply.Keywords.Add("python разработка"); - reply.Keywords.Add("telegram каналы"); - service.GenerateKeywordsReply = reply; - FakeSettingsStore settings = new(); - FakeSecretCipher cipher = new(); - FakeTenantLimitStore limits = new(); - - GrpcAiTools tools = CreateTools(port, settings, cipher, limits); - AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("Бэкенд-разработка на Python", CancellationToken.None); - - Assert.True(result.Ok); - Assert.Equal(new[] { "python разработка", "telegram каналы" }, result.Keywords); - Assert.Null(result.Error); - - Assert.NotNull(service.LastKeywords); - Assert.Equal("Бэкенд-разработка на Python", service.LastKeywords!.Description); - Assert.Equal("deepseek", service.LastKeywords.ProviderConfig.ProviderId); - Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds)); - Assert.Equal(AiGrpcTestHost.DefaultToken, Assert.Single(service.RequestTokens)); - Assert.Equal("{\"prompt\":300,\"completion\":60,\"total\":360}", settings.GetStoredJson(SettingsKeys.AiTokenUsage)); - Assert.Equal(360, limits.UsedTokens(TenantGuid)); - }); - } - - [Fact] - public async Task GenerateKeywordsAsync_Unavailable_ReturnsSoftErrorWithKeywordsEmpty() - { - await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => - { - service.KeywordsUnavailable = true; - GrpcAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher()); - - AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("описание", CancellationToken.None); - - Assert.False(result.Ok); - Assert.Empty(result.Keywords); - Assert.NotNull(result.Error); - Assert.Contains("не ответил корректно", result.Error); - }); - } - - [Fact] - public async Task EvaluateFitAsync_MapsReplyAndSendsTaskFields() - { - await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => - { - service.EvaluateFitReply = new EvaluateFitReply - { - Fit = false, - Reason = "другая сфера", - Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 }, - }; - FakeTenantLimitStore limits = new(); - GrpcAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher(), limits); - - AiEvaluateFitResultDto result = await tools.EvaluateFitAsync( - "Ищу дизайнера для лендинга", "Разработка сайтов на Python", new[] { "python", "бэкенд" }, CancellationToken.None); - - Assert.False(result.Fit); - Assert.Equal("другая сфера", result.Reason); - Assert.NotNull(service.LastFit); - Assert.Equal("Ищу дизайнера для лендинга", service.LastFit!.Text); - Assert.Equal("Разработка сайтов на Python", service.LastFit.Description); - Assert.Equal(new[] { "python", "бэкенд" }, service.LastFit.Keywords); - Assert.Equal("deepseek", service.LastFit.ProviderConfig.ProviderId); - Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds)); - Assert.Equal(AiGrpcTestHost.DefaultToken, Assert.Single(service.RequestTokens)); - Assert.Equal(210, limits.UsedTokens(TenantGuid)); - }); - } - - [Fact] - public async Task EvaluateFitAsync_Unavailable_ThrowsAiUnavailable() - { - await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => - { - service.FitUnavailable = true; - GrpcAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher()); - - await Assert.ThrowsAsync( - () => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None)); - }); - } - - // Создаёт GrpcAiTools к хосту-фейку на эфемерном порту в tenant-контексте теста. - // port: Порт хоста-фейка ai-service. - // settings: KV-хранилище тенанта (дефолты при пустом). - // cipher: Шифр секретов (расшифровка apiKey). - // limits: Фейк-хранилище лимитов (списание usage; при null — собственный экземпляр). - // Возвращает: Экземпляр GrpcAiTools. - private static GrpcAiTools CreateTools( - int port, - FakeSettingsStore settings, - FakeSecretCipher cipher, - FakeTenantLimitStore? limits = null) - { - var tenantContext = new TenantContext(); - tenantContext.SetTenant(new TenantId(TenantIdValue)); - var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); - return new GrpcAiTools( - tenantContext, - connection, - new AiProviderConfigBuilder(settings, cipher), - new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new FakeTokenUsageEventStore())), - NullLogger.Instance); - } -} +using Deal.Contracts.Integrations.Models; +using Deal.Grpc.Ai; +using Deal.Infrastructure.Data; +using Deal.Infrastructure.Integrations.Exceptions; +using Deal.Infrastructure.Integrations.Models; +using Deal.Infrastructure.Integrations.Options; +using Deal.Infrastructure.Integrations.Services; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Tenants.Application.Services; +using Deal.SharedKernel.Tenants.Models; +using Deal.Tests.Unit.Grpc; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Modules.Tenants; +using Deal.Tests.Unit.Support; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Contracts; + +/// +/// Тесты gRPC-адаптера IAiTools к ai-service +/// +[Collection("MlGrpcTests")] +public sealed class GrpcAiToolsTests +{ + // Id тенанта сценариев строкой (формат N) — ожидаемое значение metadata tenant-id. + private const string TenantIdValue = "fedcba9876543210fedcba9876543210"; + + private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue); + + [Fact] + public async Task GenerateKeywordsAsync_MapsKeywordsAndSendsProviderConfigAndMetadata() + { + await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => + { + var reply = new GenerateKeywordsReply { Usage = new Usage { Prompt = 300, Completion = 60, Total = 360 } }; + reply.Keywords.Add("python разработка"); + reply.Keywords.Add("telegram каналы"); + service.GenerateKeywordsReply = reply; + FakeSettingsStore settings = new(); + FakeSecretCipher cipher = new(); + FakeTenantLimitStore limits = new(); + + GrpcAiTools tools = CreateTools(port, settings, cipher, limits); + AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("Бэкенд-разработка на Python", CancellationToken.None); + + Assert.True(result.Ok); + Assert.Equal(new[] { "python разработка", "telegram каналы" }, result.Keywords); + Assert.Null(result.Error); + + Assert.NotNull(service.LastKeywords); + Assert.Equal("Бэкенд-разработка на Python", service.LastKeywords!.Description); + Assert.Equal("deepseek", service.LastKeywords.ProviderConfig.ProviderId); + Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds)); + Assert.Equal(AiGrpcTestHost.DefaultToken, Assert.Single(service.RequestTokens)); + Assert.Equal("{\"prompt\":300,\"completion\":60,\"total\":360}", settings.GetStoredJson(SettingsKeys.AiTokenUsage)); + Assert.Equal(360, limits.UsedTokens(TenantGuid)); + }); + } + + [Fact] + public async Task GenerateKeywordsAsync_Unavailable_ReturnsSoftErrorWithKeywordsEmpty() + { + await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => + { + service.KeywordsUnavailable = true; + GrpcAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher()); + + AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("описание", CancellationToken.None); + + Assert.False(result.Ok); + Assert.Empty(result.Keywords); + Assert.NotNull(result.Error); + Assert.Contains("не ответил корректно", result.Error); + }); + } + + [Fact] + public async Task EvaluateFitAsync_MapsReplyAndSendsTaskFields() + { + await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => + { + service.EvaluateFitReply = new EvaluateFitReply + { + Fit = false, + Reason = "другая сфера", + Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 }, + }; + FakeTenantLimitStore limits = new(); + GrpcAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher(), limits); + + AiEvaluateFitResultDto result = await tools.EvaluateFitAsync( + "Ищу дизайнера для лендинга", "Разработка сайтов на Python", new[] { "python", "бэкенд" }, CancellationToken.None); + + Assert.False(result.Fit); + Assert.Equal("другая сфера", result.Reason); + Assert.NotNull(service.LastFit); + Assert.Equal("Ищу дизайнера для лендинга", service.LastFit!.Text); + Assert.Equal("Разработка сайтов на Python", service.LastFit.Description); + Assert.Equal(new[] { "python", "бэкенд" }, service.LastFit.Keywords); + Assert.Equal("deepseek", service.LastFit.ProviderConfig.ProviderId); + Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds)); + Assert.Equal(AiGrpcTestHost.DefaultToken, Assert.Single(service.RequestTokens)); + Assert.Equal(210, limits.UsedTokens(TenantGuid)); + }); + } + + [Fact] + public async Task EvaluateFitAsync_Unavailable_ThrowsAiUnavailable() + { + await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => + { + service.FitUnavailable = true; + GrpcAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher()); + + await Assert.ThrowsAsync( + () => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None)); + }); + } + + // Создаёт GrpcAiTools к хосту-фейку на эфемерном порту в tenant-контексте теста. + // port: Порт хоста-фейка ai-service. + // settings: KV-хранилище тенанта (дефолты при пустом). + // cipher: Шифр секретов (расшифровка apiKey). + // limits: Фейк-хранилище лимитов (списание usage; при null — собственный экземпляр). + // Возвращает: Экземпляр GrpcAiTools. + private static GrpcAiTools CreateTools( + int port, + FakeSettingsStore settings, + FakeSecretCipher cipher, + FakeTenantLimitStore? limits = null) + { + var tenantContext = new TenantContext(); + tenantContext.SetTenant(new TenantId(TenantIdValue)); + var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); + return new GrpcAiTools( + tenantContext, + connection, + new AiProviderConfigBuilder(settings, cipher), + new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new FakeTokenUsageEventStore())), + NullLogger.Instance); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs index bd9bfb7..0f831d7 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs @@ -1,330 +1,330 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Ml; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Models; -using Deal.Tests.Unit.Grpc; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Tenants; -using Deal.Tests.Unit.Support; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты gRPC-адаптера IMlClient/IMlTrainClient к ml-service. -/// -[Collection("MlGrpcTests")] -public sealed class GrpcMlClientTests -{ - // Идентификатор тенанта сценариев (формат N — ключ пула модели ml-service). - private static readonly Guid Tenant = Guid.NewGuid(); - - // Id тенанта строкой (формат N) — ожидаемое значение metadata tenant-id. - private static readonly string TenantIdValue = Tenant.ToString("N"); - - // ─── PredictAsync: маппинг и фолбэк ───────────────────────────────────── - - [Fact] - public async Task PredictAsync_MapsFullReply_ToContractDtoAndSendsMetadata() - { - await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => - { - service.PredictReply = new PredictReply - { - Take = true, - Label = "b_junior", - Scores = { { "b_junior", 0.95 }, { "b_senior", 0.3 } }, - Hits = 3, - Ready = true, - Margin = 0.9, - Terms = { "python", "middle" }, - Type = new TypeDecision { Take = true, Label = "hire", Value = "t:hire", Margin = 0.5 }, - }; - - GrpcMlClient client = CreateClient(port); - MlPredictResultDto result = await client.PredictAsync("нужен middle python разработчик", CancellationToken.None); - - Assert.True(result.Take); - Assert.Equal("b_junior", result.Label); - Assert.Equal(0.95, result.Scores["b_junior"]); - Assert.Equal(0.3, result.Scores["b_senior"]); - Assert.Equal(3, result.Hits); - Assert.True(result.Ready); - Assert.Equal(0.9, result.Margin); - Assert.Equal(new[] { "python", "middle" }, result.Terms); - Assert.NotNull(result.Type); - Assert.True(result.Type.Take); - Assert.Equal("hire", result.Type.Label); - Assert.Equal("t:hire", result.Type.Value); - Assert.Equal(0.5, result.Type.Margin); - - Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds)); - Assert.Equal(MlGrpcTestHost.DefaultToken, Assert.Single(service.RequestTokens)); - }); - } - - [Fact] - public async Task PredictAsync_ServiceUnavailable_ReturnsNotReadyPrediction() - { - await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => - { - service.PredictUnavailable = true; - - GrpcMlClient client = CreateClient(port); - MlPredictResultDto result = await client.PredictAsync("текст", CancellationToken.None); - - Assert.False(result.Take); - Assert.Null(result.Label); - Assert.Empty(result.Scores); - Assert.Equal(0, result.Hits); - Assert.False(result.Ready); - Assert.Null(result.Margin); - Assert.Empty(result.Terms); - Assert.Null(result.Type); - Assert.Single(service.RequestTenantIds); - }); - } - - // ─── StatusAsync: статус сервиса + кэш 15 с + reachable ───────────────── - - [Fact] - public async Task StatusAsync_FetchesServiceStatusAndMergesLocalStats() - { - await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => - { - service.StatusReply = new StatusReply - { - Ready = true, - Classes = { { "b_junior", 12.0 }, { "spam", 5.0 } }, - Learned = 17, - Eval = new ModelEval { Count = 5, Correct = 4, Accuracy = 0.8 }, - }; - - var settings = new FakeSettingsStore(); - settings.Preload(SettingsKeys.MlDecisions, "7"); - settings.Preload(SettingsKeys.AiDecisions, "3"); - var learning = new FakeMlLearningStore { LearningCount = 9 }; - - GrpcMlClient client = CreateClient(port, settings, learning); - MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None); - - Assert.True(status.Enabled); // mlEnabled не задан — дефолт true - Assert.True(status.Reachable); - Assert.True(status.Service.Ready); - Assert.Equal(17, status.Service.Learned); - Assert.Equal(0.8, status.Service.Eval.Accuracy); - Assert.Equal(7, status.Stats.Ml); - Assert.Equal(3, status.Stats.Ai); - Assert.Equal(9, status.Stats.Learning); - Assert.Equal(0, status.Stats.Outbox); - Assert.True(status.Stats.Ready); - Assert.Equal(17, status.Stats.Learned); - Assert.True(status.Stats.Reachable); - Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds)); - }); - } - - [Fact] - public async Task StatusAsync_ServiceDown_ServesCachedDataWithReachableFalse_ThenRecoversAfterTtl() - { - await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => - { - DateTimeOffset now = DateTimeOffset.UtcNow; - var cache = new MlStatusCache(() => now); - GrpcMlClient client = CreateClient(port, cache: cache); - - service.StatusUnavailable = true; - MlStatusResponseDto down = await client.StatusAsync(CancellationToken.None); - Assert.False(down.Reachable); - Assert.False(down.Service.Ready); - Assert.False(down.Stats.Reachable); - Assert.Equal(1, service.StatusCalls); - - // «Поднялся»: после TTL 15 с следующий StatusAsync обновляет кэш (ready=true, reachable=true). - service.StatusUnavailable = false; - service.StatusReply = new StatusReply - { - Ready = true, - Classes = { { "b_x", 1.0 } }, - Learned = 3, - Eval = new ModelEval { Count = 2, Correct = 2, Accuracy = 1.0 }, - }; - now = now.AddSeconds(MlStatusCache.CacheTtlSeconds + 1); - MlStatusResponseDto up = await client.StatusAsync(CancellationToken.None); - - Assert.True(up.Reachable); - Assert.True(up.Service.Ready); - Assert.Equal(3, up.Service.Learned); - Assert.Equal(2, service.StatusCalls); - }); - } - - [Fact] - public async Task StatusAsync_CachesWithinTtl_SingleFetch() - { - await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => - { - DateTimeOffset now = DateTimeOffset.UtcNow; - GrpcMlClient client = CreateClient(port, cache: new MlStatusCache(() => now)); - - _ = await client.StatusAsync(CancellationToken.None); - now = now.AddSeconds(5); // в пределах TTL 15 с — повторный вызов не ходит в сервис - _ = await client.StatusAsync(CancellationToken.None); - Assert.Equal(1, service.StatusCalls); - - now = now.AddSeconds(MlStatusCache.CacheTtlSeconds + 1); // TTL истёк — новый fetch - _ = await client.StatusAsync(CancellationToken.None); - Assert.Equal(2, service.StatusCalls); - }); - } - - // ─── ResetAsync: gRPC Reset + очистка outbox только при успехе ────────── - - [Fact] - public async Task ResetAsync_ServiceOk_ClearsOutboxAndInvalidatesStatusCache() - { - await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => - { - var learning = new FakeMlLearningStore(); - learning.SeedOutbox("mle_1", "текст 1", "b_a", 1.0); - learning.SeedOutbox("mle_2", "текст 2", "spam", 1.0); - GrpcMlClient client = CreateClient(port, learning: learning); - - // Прогреть кэш статуса (до сброса — 1 вызов Status), затем сброс. - _ = await client.StatusAsync(CancellationToken.None); - MlResetResultDto reset = await client.ResetAsync(CancellationToken.None); - - Assert.True(reset.Ok); - Assert.Null(reset.Error); - Assert.Equal(0, await learning.CountOutboxAsync(CancellationToken.None)); // очередь очищена - Assert.Equal(1, service.ResetCalls); - - _ = await client.StatusAsync(CancellationToken.None); - Assert.Equal(2, service.StatusCalls); - }); - } - - [Fact] - public async Task ResetAsync_ServiceSoftError_ReturnsOkFalseAndKeepsOutbox() - { - await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => - { - service.ResetReply = new ResetReply { Ok = false, Error = "не удалось пересоздать файл модели" }; - var learning = new FakeMlLearningStore(); - learning.SeedOutbox("mle_1", "текст", "b_a", 1.0); - - GrpcMlClient client = CreateClient(port, learning: learning); - MlResetResultDto reset = await client.ResetAsync(CancellationToken.None); - - Assert.False(reset.Ok); - Assert.Equal("не удалось пересоздать файл модели", reset.Error); - Assert.Equal(1, await learning.CountOutboxAsync(CancellationToken.None)); - }); - } - - [Fact] - public async Task ResetAsync_ServiceUnavailable_ReturnsOkFalseAndKeepsOutbox() - { - await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => - { - service.ResetUnavailable = true; - var learning = new FakeMlLearningStore(); - learning.SeedOutbox("mle_1", "текст", "spam", 1.0); - - GrpcMlClient client = CreateClient(port, learning: learning); - MlResetResultDto reset = await client.ResetAsync(CancellationToken.None); - - Assert.False(reset.Ok); - Assert.Equal("ML-сервис недоступен", reset.Error); - Assert.Equal(1, await learning.CountOutboxAsync(CancellationToken.None)); - }); - } - - - [Fact] - public async Task PushAsync_WritesOutboxRow() - { - await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => - { - var learning = new FakeMlLearningStore(); - GrpcMlClient client = CreateClient(port, learning: learning); - - await client.PushAsync(" нужен python ", "b_junior", 1.0, CancellationToken.None); - - var row = Assert.Single(learning.AddedRows); - Assert.StartsWith("mle_", row.Id, StringComparison.Ordinal); - Assert.Equal("нужен python", row.Text); // trim - Assert.Equal("b_junior", row.Label); - Assert.Equal(1.0, row.Delta); - Assert.Empty(service.RequestTenantIds); // push сервис не зовёт — только локальная запись - }); - } - - // ─── TrainBatchAsync (IMlTrainClient): батч для флашера ───────────────── - - [Fact] - public async Task TrainBatchAsync_SendsItemsAndReturnsLearned() - { - await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => - { - GrpcMlClient client = CreateClient(port); - var items = new[] - { - new MlOutboxEntryDto("mle_1", "текст 1", "b_a", 1.0), - new MlOutboxEntryDto("mle_2", "текст 2", "spam", -1.0), - }; - - int learned = await ((IMlTrainClient)client).TrainBatchAsync(items, CancellationToken.None); - - Assert.Equal(2, learned); - TrainBatchRequest batch = Assert.Single(service.TrainBatches); - Assert.Equal(2, batch.Items.Count); - Assert.Equal("текст 1", batch.Items[0].Text); - Assert.Equal("b_a", batch.Items[0].Label); - Assert.Equal(1.0, batch.Items[0].Delta); - Assert.Equal("текст 2", batch.Items[1].Text); - Assert.Equal("spam", batch.Items[1].Label); - Assert.Equal(-1.0, batch.Items[1].Delta); - Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds)); - }); - } - - // ─── Хелперы ──────────────────────────────────────────────────────────── - - // Создаёт GrpcMlClient к хосту-фейку на эфемерном порту с пустыми фейками статистики. - // port: Порт хоста-фейка ml-service. - // settings: KV-хранилище тенанта (пустое — дефолты). - // learning: Хранилище обучения (пустое). - // cache: Кэш статуса (по умолчанию реальный — UtcNow). - // Возвращает: Экземпляр GrpcMlClient в tenant-контексте теста. - private static GrpcMlClient CreateClient( - int port, - FakeSettingsStore? settings = null, - FakeMlLearningStore? learning = null, - MlStatusCache? cache = null) - { - var tenantContext = new TenantContext(); - tenantContext.SetTenant(new TenantId(TenantIdValue)); - var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }; - return new GrpcMlClient( - tenantContext, - settings ?? new FakeSettingsStore(), - learning ?? new FakeMlLearningStore(), - new MlGrpcConnection(options), - cache ?? new MlStatusCache(), - new TokenUsageRecorder( - settings ?? new FakeSettingsStore(), - new FakeTenantLimitStore(), - tenantContext, - new TokenUsageEventService(new FakeTokenUsageEventStore())), - NullLogger.Instance); - } -} +using Deal.Contracts.Integrations.Models; +using Deal.Grpc.Ml; +using Deal.Infrastructure.Data; +using Deal.Infrastructure.Integrations.Abstractions; +using Deal.Infrastructure.Integrations.Models; +using Deal.Infrastructure.Integrations.Options; +using Deal.Infrastructure.Integrations.Services; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Tenants.Application.Services; +using Deal.SharedKernel.Tenants.Models; +using Deal.Tests.Unit.Grpc; +using Deal.Tests.Unit.Modules.Kanban; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Modules.Tenants; +using Deal.Tests.Unit.Support; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Contracts; + +/// +/// Тесты gRPC-адаптера IMlClient/IMlTrainClient к ml-service. +/// +[Collection("MlGrpcTests")] +public sealed class GrpcMlClientTests +{ + // Идентификатор тенанта сценариев (формат N — ключ пула модели ml-service). + private static readonly Guid Tenant = Guid.NewGuid(); + + // Id тенанта строкой (формат N) — ожидаемое значение metadata tenant-id. + private static readonly string TenantIdValue = Tenant.ToString("N"); + + // ─── PredictAsync: маппинг и фолбэк ───────────────────────────────────── + + [Fact] + public async Task PredictAsync_MapsFullReply_ToContractDtoAndSendsMetadata() + { + await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => + { + service.PredictReply = new PredictReply + { + Take = true, + Label = "b_junior", + Scores = { { "b_junior", 0.95 }, { "b_senior", 0.3 } }, + Hits = 3, + Ready = true, + Margin = 0.9, + Terms = { "python", "middle" }, + Type = new TypeDecision { Take = true, Label = "hire", Value = "t:hire", Margin = 0.5 }, + }; + + GrpcMlClient client = CreateClient(port); + MlPredictResultDto result = await client.PredictAsync("нужен middle python разработчик", CancellationToken.None); + + Assert.True(result.Take); + Assert.Equal("b_junior", result.Label); + Assert.Equal(0.95, result.Scores["b_junior"]); + Assert.Equal(0.3, result.Scores["b_senior"]); + Assert.Equal(3, result.Hits); + Assert.True(result.Ready); + Assert.Equal(0.9, result.Margin); + Assert.Equal(new[] { "python", "middle" }, result.Terms); + Assert.NotNull(result.Type); + Assert.True(result.Type.Take); + Assert.Equal("hire", result.Type.Label); + Assert.Equal("t:hire", result.Type.Value); + Assert.Equal(0.5, result.Type.Margin); + + Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds)); + Assert.Equal(MlGrpcTestHost.DefaultToken, Assert.Single(service.RequestTokens)); + }); + } + + [Fact] + public async Task PredictAsync_ServiceUnavailable_ReturnsNotReadyPrediction() + { + await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => + { + service.PredictUnavailable = true; + + GrpcMlClient client = CreateClient(port); + MlPredictResultDto result = await client.PredictAsync("текст", CancellationToken.None); + + Assert.False(result.Take); + Assert.Null(result.Label); + Assert.Empty(result.Scores); + Assert.Equal(0, result.Hits); + Assert.False(result.Ready); + Assert.Null(result.Margin); + Assert.Empty(result.Terms); + Assert.Null(result.Type); + Assert.Single(service.RequestTenantIds); + }); + } + + // ─── StatusAsync: статус сервиса + кэш 15 с + reachable ───────────────── + + [Fact] + public async Task StatusAsync_FetchesServiceStatusAndMergesLocalStats() + { + await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => + { + service.StatusReply = new StatusReply + { + Ready = true, + Classes = { { "b_junior", 12.0 }, { "spam", 5.0 } }, + Learned = 17, + Eval = new ModelEval { Count = 5, Correct = 4, Accuracy = 0.8 }, + }; + + var settings = new FakeSettingsStore(); + settings.Preload(SettingsKeys.MlDecisions, "7"); + settings.Preload(SettingsKeys.AiDecisions, "3"); + var learning = new FakeMlLearningStore { LearningCount = 9 }; + + GrpcMlClient client = CreateClient(port, settings, learning); + MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None); + + Assert.True(status.Enabled); // mlEnabled не задан — дефолт true + Assert.True(status.Reachable); + Assert.True(status.Service.Ready); + Assert.Equal(17, status.Service.Learned); + Assert.Equal(0.8, status.Service.Eval.Accuracy); + Assert.Equal(7, status.Stats.Ml); + Assert.Equal(3, status.Stats.Ai); + Assert.Equal(9, status.Stats.Learning); + Assert.Equal(0, status.Stats.Outbox); + Assert.True(status.Stats.Ready); + Assert.Equal(17, status.Stats.Learned); + Assert.True(status.Stats.Reachable); + Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds)); + }); + } + + [Fact] + public async Task StatusAsync_ServiceDown_ServesCachedDataWithReachableFalse_ThenRecoversAfterTtl() + { + await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => + { + DateTimeOffset now = DateTimeOffset.UtcNow; + var cache = new MlStatusCache(() => now); + GrpcMlClient client = CreateClient(port, cache: cache); + + service.StatusUnavailable = true; + MlStatusResponseDto down = await client.StatusAsync(CancellationToken.None); + Assert.False(down.Reachable); + Assert.False(down.Service.Ready); + Assert.False(down.Stats.Reachable); + Assert.Equal(1, service.StatusCalls); + + // «Поднялся»: после TTL 15 с следующий StatusAsync обновляет кэш (ready=true, reachable=true). + service.StatusUnavailable = false; + service.StatusReply = new StatusReply + { + Ready = true, + Classes = { { "b_x", 1.0 } }, + Learned = 3, + Eval = new ModelEval { Count = 2, Correct = 2, Accuracy = 1.0 }, + }; + now = now.AddSeconds(MlStatusCache.CacheTtlSeconds + 1); + MlStatusResponseDto up = await client.StatusAsync(CancellationToken.None); + + Assert.True(up.Reachable); + Assert.True(up.Service.Ready); + Assert.Equal(3, up.Service.Learned); + Assert.Equal(2, service.StatusCalls); + }); + } + + [Fact] + public async Task StatusAsync_CachesWithinTtl_SingleFetch() + { + await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => + { + DateTimeOffset now = DateTimeOffset.UtcNow; + GrpcMlClient client = CreateClient(port, cache: new MlStatusCache(() => now)); + + _ = await client.StatusAsync(CancellationToken.None); + now = now.AddSeconds(5); // в пределах TTL 15 с — повторный вызов не ходит в сервис + _ = await client.StatusAsync(CancellationToken.None); + Assert.Equal(1, service.StatusCalls); + + now = now.AddSeconds(MlStatusCache.CacheTtlSeconds + 1); // TTL истёк — новый fetch + _ = await client.StatusAsync(CancellationToken.None); + Assert.Equal(2, service.StatusCalls); + }); + } + + // ─── ResetAsync: gRPC Reset + очистка outbox только при успехе ────────── + + [Fact] + public async Task ResetAsync_ServiceOk_ClearsOutboxAndInvalidatesStatusCache() + { + await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => + { + var learning = new FakeMlLearningStore(); + learning.SeedOutbox("mle_1", "текст 1", "b_a", 1.0); + learning.SeedOutbox("mle_2", "текст 2", "spam", 1.0); + GrpcMlClient client = CreateClient(port, learning: learning); + + // Прогреть кэш статуса (до сброса — 1 вызов Status), затем сброс. + _ = await client.StatusAsync(CancellationToken.None); + MlResetResultDto reset = await client.ResetAsync(CancellationToken.None); + + Assert.True(reset.Ok); + Assert.Null(reset.Error); + Assert.Equal(0, await learning.CountOutboxAsync(CancellationToken.None)); // очередь очищена + Assert.Equal(1, service.ResetCalls); + + _ = await client.StatusAsync(CancellationToken.None); + Assert.Equal(2, service.StatusCalls); + }); + } + + [Fact] + public async Task ResetAsync_ServiceSoftError_ReturnsOkFalseAndKeepsOutbox() + { + await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => + { + service.ResetReply = new ResetReply { Ok = false, Error = "не удалось пересоздать файл модели" }; + var learning = new FakeMlLearningStore(); + learning.SeedOutbox("mle_1", "текст", "b_a", 1.0); + + GrpcMlClient client = CreateClient(port, learning: learning); + MlResetResultDto reset = await client.ResetAsync(CancellationToken.None); + + Assert.False(reset.Ok); + Assert.Equal("не удалось пересоздать файл модели", reset.Error); + Assert.Equal(1, await learning.CountOutboxAsync(CancellationToken.None)); + }); + } + + [Fact] + public async Task ResetAsync_ServiceUnavailable_ReturnsOkFalseAndKeepsOutbox() + { + await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => + { + service.ResetUnavailable = true; + var learning = new FakeMlLearningStore(); + learning.SeedOutbox("mle_1", "текст", "spam", 1.0); + + GrpcMlClient client = CreateClient(port, learning: learning); + MlResetResultDto reset = await client.ResetAsync(CancellationToken.None); + + Assert.False(reset.Ok); + Assert.Equal("ML-сервис недоступен", reset.Error); + Assert.Equal(1, await learning.CountOutboxAsync(CancellationToken.None)); + }); + } + + + [Fact] + public async Task PushAsync_WritesOutboxRow() + { + await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => + { + var learning = new FakeMlLearningStore(); + GrpcMlClient client = CreateClient(port, learning: learning); + + await client.PushAsync(" нужен python ", "b_junior", 1.0, CancellationToken.None); + + var row = Assert.Single(learning.AddedRows); + Assert.StartsWith("mle_", row.Id, StringComparison.Ordinal); + Assert.Equal("нужен python", row.Text); // trim + Assert.Equal("b_junior", row.Label); + Assert.Equal(1.0, row.Delta); + Assert.Empty(service.RequestTenantIds); // push сервис не зовёт — только локальная запись + }); + } + + // ─── TrainBatchAsync (IMlTrainClient): батч для флашера ───────────────── + + [Fact] + public async Task TrainBatchAsync_SendsItemsAndReturnsLearned() + { + await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => + { + GrpcMlClient client = CreateClient(port); + var items = new[] + { + new MlOutboxEntryDto("mle_1", "текст 1", "b_a", 1.0), + new MlOutboxEntryDto("mle_2", "текст 2", "spam", -1.0), + }; + + int learned = await ((IMlTrainClient)client).TrainBatchAsync(items, CancellationToken.None); + + Assert.Equal(2, learned); + TrainBatchRequest batch = Assert.Single(service.TrainBatches); + Assert.Equal(2, batch.Items.Count); + Assert.Equal("текст 1", batch.Items[0].Text); + Assert.Equal("b_a", batch.Items[0].Label); + Assert.Equal(1.0, batch.Items[0].Delta); + Assert.Equal("текст 2", batch.Items[1].Text); + Assert.Equal("spam", batch.Items[1].Label); + Assert.Equal(-1.0, batch.Items[1].Delta); + Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds)); + }); + } + + // ─── Хелперы ──────────────────────────────────────────────────────────── + + // Создаёт GrpcMlClient к хосту-фейку на эфемерном порту с пустыми фейками статистики. + // port: Порт хоста-фейка ml-service. + // settings: KV-хранилище тенанта (пустое — дефолты). + // learning: Хранилище обучения (пустое). + // cache: Кэш статуса (по умолчанию реальный — UtcNow). + // Возвращает: Экземпляр GrpcMlClient в tenant-контексте теста. + private static GrpcMlClient CreateClient( + int port, + FakeSettingsStore? settings = null, + FakeMlLearningStore? learning = null, + MlStatusCache? cache = null) + { + var tenantContext = new TenantContext(); + tenantContext.SetTenant(new TenantId(TenantIdValue)); + var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }; + return new GrpcMlClient( + tenantContext, + settings ?? new FakeSettingsStore(), + learning ?? new FakeMlLearningStore(), + new MlGrpcConnection(options), + cache ?? new MlStatusCache(), + new TokenUsageRecorder( + settings ?? new FakeSettingsStore(), + new FakeTenantLimitStore(), + tenantContext, + new TokenUsageEventService(new FakeTokenUsageEventStore())), + NullLogger.Instance); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcTelegramClientTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcTelegramClientTests.cs index 3061638..da9f44e 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcTelegramClientTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcTelegramClientTests.cs @@ -1,259 +1,259 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Telegram; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.SharedKernel.Tenants.Models; -using Deal.Tests.Unit.Grpc; -using Deal.Tests.Unit.Support; -using Grpc.Core; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// In-proc тесты gRPC-адаптера гейта GrpcTelegramClient «по проводу». -/// -[Collection("MlGrpcTests")] -public sealed class GrpcTelegramClientTests -{ - /// - /// StatusAsync маппит live-поля telegram-service - /// - [Fact] - public async Task StatusAsync_MapsLiveStatusFields() - { - var service = new RecordingTelegramService - { - StatusPhase = "ready", - StatusConnected = true, - StatusListener = true, - StatusAccount = "@user", - StatusError = "text", - }; - - await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, _) => - { - ITelegramGateway gateway = CreateClient(port); - TelegramAccountStatusDto status = await gateway.StatusAsync(CancellationToken.None); - - Assert.Equal("ready", status.Phase); - Assert.True(status.Connected); - Assert.True(status.Listener); - Assert.Equal("@user", status.Account); - Assert.Equal("text", status.Error); - }); - } - - /// - /// Доменный RPC-отказ сервиса пробрасывается как есть - /// - [Fact] - public async Task StatusAsync_ServiceDomainFailure_PropagatesRpcExceptionWithDetail() - { - var service = new RecordingTelegramService - { - DomainFailure = new RpcException(new Status(StatusCode.FailedPrecondition, "Telegram не подключён")), - }; - - await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, _) => - { - ITelegramGateway gateway = CreateClient(port); - RpcException failure = await Assert.ThrowsAsync(() => gateway.StatusAsync(CancellationToken.None)); - - Assert.Equal(StatusCode.FailedPrecondition, failure.StatusCode); - Assert.Equal("Telegram не подключён", failure.Status.Detail); - }); - } - - /// - /// StartPhone уходит с phone/api_id/api_hash и возвращает фазу - /// - [Fact] - public async Task StartPhoneAsync_SendsPhoneAndKeysAndReturnsPhase() - { - var service = new RecordingTelegramService(); - - await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, recorded) => - { - ITelegramGateway gateway = CreateClient(port); - TelegramAuthResultDto result = await gateway.StartPhoneAsync("+79001234567", 123456, "hash", CancellationToken.None); - - Assert.Equal("code", result.Phase); - Assert.NotNull(recorded.LastStartPhone); - Assert.Equal("+79001234567", recorded.LastStartPhone.Phone); - Assert.Equal(123456, recorded.LastStartPhone.ApiId); - Assert.Equal("hash", recorded.LastStartPhone.ApiHash); - }); - } - - /// - /// StartQr уходит с ключами и возвращает фазу + qrUrl - /// - [Fact] - public async Task StartQrAsync_SendsKeysAndReturnsPhaseWithQrUrl() - { - var service = new RecordingTelegramService { QrUrl = "https://t.me/qr/abc" }; - - await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, recorded) => - { - ITelegramGateway gateway = CreateClient(port); - TelegramAuthResultDto result = await gateway.StartQrAsync(123456, "hash", CancellationToken.None); - - Assert.Equal("qr", result.Phase); - Assert.Equal("https://t.me/qr/abc", result.QrUrl); - Assert.NotNull(recorded.LastStartQr); - Assert.Equal(123456, recorded.LastStartQr.ApiId); - Assert.Equal("hash", recorded.LastStartQr.ApiHash); - }); - } - - /// - /// RefreshDialogs маппит каталог. - /// - [Fact] - public async Task RefreshDialogsAsync_MapsCatalogEntries() - { - var service = new RecordingTelegramService(); - service.Catalog.Add(new DialogEntry { Id = "-1001", Name = "Канал", Username = "ch", Kind = "channel", Hue = "#a33" }); - service.Catalog.Add(new DialogEntry { Id = "-2", Name = "Группа", Username = string.Empty, Kind = "group", Hue = "#b44" }); - - await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, _) => - { - ITelegramGateway gateway = CreateClient(port); - IReadOnlyList entries = await gateway.RefreshDialogsAsync(CancellationToken.None); - - Assert.Equal(2, entries.Count); - Assert.Equal(new TelegramDialogEntryDto("-1001", "Канал", "ch", "channel", "#a33"), entries[0]); - Assert.Equal(new TelegramDialogEntryDto("-2", "Группа", string.Empty, "group", "#b44"), entries[1]); - }); - } - - /// - /// Backfill уходит с dialog_id/force и возвращает processed - /// - [Fact] - public async Task BackfillAsync_SendsDialogAndForceAndReturnsProcessed() - { - var service = new RecordingTelegramService { BackfillProcessed = 7 }; - - await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, recorded) => - { - ITelegramGateway gateway = CreateClient(port); - int processed = await gateway.BackfillAsync("-1001", force: true, CancellationToken.None); - - Assert.Equal(7, processed); - BackfillRequest request = Assert.Single(recorded.BackfillRequests); - Assert.Equal("-1001", request.DialogId); - Assert.True(request.Force); - }); - } - - /// - /// ReadRecent уходит с dialog_id/limit и маппит сообщения превью - /// - [Fact] - public async Task ReadRecentAsync_SendsDialogAndLimitAndMapsMessages() - { - var service = new RecordingTelegramService(); - - await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, recorded) => - { - ITelegramGateway gateway = CreateClient(port); - IReadOnlyList messages = await gateway.ReadRecentAsync("-1001", 24, CancellationToken.None); - - ReadRecentRequest request = Assert.Single(recorded.ReadRecentRequests); - Assert.Equal("-1001", request.DialogId); - Assert.Equal(24, request.Limit); - TelegramRecentMessageDto message = Assert.Single(messages); - Assert.Equal("100500", message.Id); - Assert.Equal("Свежее", message.Text); - Assert.Equal(1700000000000, message.TimeMs); - }); - } - - /// - /// ReadSource уходит с dialog_id/msg_id и маппит текст/время - /// - [Fact] - public async Task ReadSourceAsync_SendsDialogAndMsgIdAndMapsText() - { - var service = new RecordingTelegramService - { - ReadSourceFound = true, - ReadSourceText = "Исходник", - ReadSourceTime = 1700000000000, - }; - - await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, recorded) => - { - ITelegramGateway gateway = CreateClient(port); - TelegramSourceContentDto content = await gateway.ReadSourceAsync("-1001", 777, CancellationToken.None); - - ReadSourceRequest request = Assert.Single(recorded.ReadSourceRequests); - Assert.Equal("-1001", request.DialogId); - Assert.Equal(777, request.MsgId); - Assert.True(content.Found); - Assert.Equal("Исходник", content.Text); - Assert.Equal(1700000000000, content.TimeMs); - }); - } - - /// - /// ReadSource с found=false не отдаёт текст и время - /// - [Fact] - public async Task ReadSourceAsync_NotFound_MapsEmptyContent() - { - var service = new RecordingTelegramService { ReadSourceFound = false }; - - await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, _) => - { - ITelegramGateway gateway = CreateClient(port); - TelegramSourceContentDto content = await gateway.ReadSourceAsync("-1001", 777, CancellationToken.None); - - Assert.False(content.Found); - Assert.Null(content.Text); - Assert.Null(content.TimeMs); - }); - } - - /// - /// Вызов вне tenant-контекста — ошибка конфигурации. - /// - [Fact] - public async Task Call_WithoutTenantContext_ThrowsInvalidOperation() - { - await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, new RecordingTelegramService(), async (port, _) => - { - var connection = new TelegramGrpcConnection(new TelegramServiceOptions - { - UseLocal = false, - Endpoint = $"http://127.0.0.1:{port}", - }); - using (connection) - { - var gateway = new GrpcTelegramClient( - new TenantContext(), connection, NullLogger.Instance); - await Assert.ThrowsAsync(() => gateway.StatusAsync(CancellationToken.None)); - } - }); - } - - // Собирает гейт-клиент сценария на tenant-контексте теста и канале к фейк-сервису. - // port: Порт фейк-telegram-service (host сценария). - // Возвращает: Гейт (транспорт живёт на время сценария). - private static GrpcTelegramClient CreateClient(int port) - { - var tenantContext = new TenantContext(); - tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N"))); - var connection = new TelegramGrpcConnection(new TelegramServiceOptions - { - UseLocal = false, - Endpoint = $"http://127.0.0.1:{port}", - }); - return new GrpcTelegramClient(tenantContext, connection, NullLogger.Instance); - } -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Grpc.Telegram; +using Deal.Infrastructure.Data; +using Deal.Infrastructure.Integrations.Models; +using Deal.Infrastructure.Integrations.Options; +using Deal.Infrastructure.Integrations.Services; +using Deal.SharedKernel.Tenants.Models; +using Deal.Tests.Unit.Grpc; +using Deal.Tests.Unit.Support; +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Contracts; + +/// +/// In-proc тесты gRPC-адаптера гейта GrpcTelegramClient «по проводу». +/// +[Collection("MlGrpcTests")] +public sealed class GrpcTelegramClientTests +{ + /// + /// StatusAsync маппит live-поля telegram-service + /// + [Fact] + public async Task StatusAsync_MapsLiveStatusFields() + { + var service = new RecordingTelegramService + { + StatusPhase = "ready", + StatusConnected = true, + StatusListener = true, + StatusAccount = "@user", + StatusError = "text", + }; + + await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, _) => + { + ITelegramGateway gateway = CreateClient(port); + TelegramAccountStatusDto status = await gateway.StatusAsync(CancellationToken.None); + + Assert.Equal("ready", status.Phase); + Assert.True(status.Connected); + Assert.True(status.Listener); + Assert.Equal("@user", status.Account); + Assert.Equal("text", status.Error); + }); + } + + /// + /// Доменный RPC-отказ сервиса пробрасывается как есть + /// + [Fact] + public async Task StatusAsync_ServiceDomainFailure_PropagatesRpcExceptionWithDetail() + { + var service = new RecordingTelegramService + { + DomainFailure = new RpcException(new Status(StatusCode.FailedPrecondition, "Telegram не подключён")), + }; + + await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, _) => + { + ITelegramGateway gateway = CreateClient(port); + RpcException failure = await Assert.ThrowsAsync(() => gateway.StatusAsync(CancellationToken.None)); + + Assert.Equal(StatusCode.FailedPrecondition, failure.StatusCode); + Assert.Equal("Telegram не подключён", failure.Status.Detail); + }); + } + + /// + /// StartPhone уходит с phone/api_id/api_hash и возвращает фазу + /// + [Fact] + public async Task StartPhoneAsync_SendsPhoneAndKeysAndReturnsPhase() + { + var service = new RecordingTelegramService(); + + await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, recorded) => + { + ITelegramGateway gateway = CreateClient(port); + TelegramAuthResultDto result = await gateway.StartPhoneAsync("+79001234567", 123456, "hash", CancellationToken.None); + + Assert.Equal("code", result.Phase); + Assert.NotNull(recorded.LastStartPhone); + Assert.Equal("+79001234567", recorded.LastStartPhone.Phone); + Assert.Equal(123456, recorded.LastStartPhone.ApiId); + Assert.Equal("hash", recorded.LastStartPhone.ApiHash); + }); + } + + /// + /// StartQr уходит с ключами и возвращает фазу + qrUrl + /// + [Fact] + public async Task StartQrAsync_SendsKeysAndReturnsPhaseWithQrUrl() + { + var service = new RecordingTelegramService { QrUrl = "https://t.me/qr/abc" }; + + await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, recorded) => + { + ITelegramGateway gateway = CreateClient(port); + TelegramAuthResultDto result = await gateway.StartQrAsync(123456, "hash", CancellationToken.None); + + Assert.Equal("qr", result.Phase); + Assert.Equal("https://t.me/qr/abc", result.QrUrl); + Assert.NotNull(recorded.LastStartQr); + Assert.Equal(123456, recorded.LastStartQr.ApiId); + Assert.Equal("hash", recorded.LastStartQr.ApiHash); + }); + } + + /// + /// RefreshDialogs маппит каталог. + /// + [Fact] + public async Task RefreshDialogsAsync_MapsCatalogEntries() + { + var service = new RecordingTelegramService(); + service.Catalog.Add(new DialogEntry { Id = "-1001", Name = "Канал", Username = "ch", Kind = "channel", Hue = "#a33" }); + service.Catalog.Add(new DialogEntry { Id = "-2", Name = "Группа", Username = string.Empty, Kind = "group", Hue = "#b44" }); + + await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, _) => + { + ITelegramGateway gateway = CreateClient(port); + IReadOnlyList entries = await gateway.RefreshDialogsAsync(CancellationToken.None); + + Assert.Equal(2, entries.Count); + Assert.Equal(new TelegramDialogEntryDto("-1001", "Канал", "ch", "channel", "#a33"), entries[0]); + Assert.Equal(new TelegramDialogEntryDto("-2", "Группа", string.Empty, "group", "#b44"), entries[1]); + }); + } + + /// + /// Backfill уходит с dialog_id/force и возвращает processed + /// + [Fact] + public async Task BackfillAsync_SendsDialogAndForceAndReturnsProcessed() + { + var service = new RecordingTelegramService { BackfillProcessed = 7 }; + + await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, recorded) => + { + ITelegramGateway gateway = CreateClient(port); + int processed = await gateway.BackfillAsync("-1001", force: true, CancellationToken.None); + + Assert.Equal(7, processed); + BackfillRequest request = Assert.Single(recorded.BackfillRequests); + Assert.Equal("-1001", request.DialogId); + Assert.True(request.Force); + }); + } + + /// + /// ReadRecent уходит с dialog_id/limit и маппит сообщения превью + /// + [Fact] + public async Task ReadRecentAsync_SendsDialogAndLimitAndMapsMessages() + { + var service = new RecordingTelegramService(); + + await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, recorded) => + { + ITelegramGateway gateway = CreateClient(port); + IReadOnlyList messages = await gateway.ReadRecentAsync("-1001", 24, CancellationToken.None); + + ReadRecentRequest request = Assert.Single(recorded.ReadRecentRequests); + Assert.Equal("-1001", request.DialogId); + Assert.Equal(24, request.Limit); + TelegramRecentMessageDto message = Assert.Single(messages); + Assert.Equal("100500", message.Id); + Assert.Equal("Свежее", message.Text); + Assert.Equal(1700000000000, message.TimeMs); + }); + } + + /// + /// ReadSource уходит с dialog_id/msg_id и маппит текст/время + /// + [Fact] + public async Task ReadSourceAsync_SendsDialogAndMsgIdAndMapsText() + { + var service = new RecordingTelegramService + { + ReadSourceFound = true, + ReadSourceText = "Исходник", + ReadSourceTime = 1700000000000, + }; + + await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, recorded) => + { + ITelegramGateway gateway = CreateClient(port); + TelegramSourceContentDto content = await gateway.ReadSourceAsync("-1001", 777, CancellationToken.None); + + ReadSourceRequest request = Assert.Single(recorded.ReadSourceRequests); + Assert.Equal("-1001", request.DialogId); + Assert.Equal(777, request.MsgId); + Assert.True(content.Found); + Assert.Equal("Исходник", content.Text); + Assert.Equal(1700000000000, content.TimeMs); + }); + } + + /// + /// ReadSource с found=false не отдаёт текст и время + /// + [Fact] + public async Task ReadSourceAsync_NotFound_MapsEmptyContent() + { + var service = new RecordingTelegramService { ReadSourceFound = false }; + + await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, _) => + { + ITelegramGateway gateway = CreateClient(port); + TelegramSourceContentDto content = await gateway.ReadSourceAsync("-1001", 777, CancellationToken.None); + + Assert.False(content.Found); + Assert.Null(content.Text); + Assert.Null(content.TimeMs); + }); + } + + /// + /// Вызов вне tenant-контекста — ошибка конфигурации. + /// + [Fact] + public async Task Call_WithoutTenantContext_ThrowsInvalidOperation() + { + await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, new RecordingTelegramService(), async (port, _) => + { + var connection = new TelegramGrpcConnection(new TelegramServiceOptions + { + UseLocal = false, + Endpoint = $"http://127.0.0.1:{port}", + }); + using (connection) + { + var gateway = new GrpcTelegramClient( + new TenantContext(), connection, NullLogger.Instance); + await Assert.ThrowsAsync(() => gateway.StatusAsync(CancellationToken.None)); + } + }); + } + + // Собирает гейт-клиент сценария на tenant-контексте теста и канале к фейк-сервису. + // port: Порт фейк-telegram-service (host сценария). + // Возвращает: Гейт (транспорт живёт на время сценария). + private static GrpcTelegramClient CreateClient(int port) + { + var tenantContext = new TenantContext(); + tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N"))); + var connection = new TelegramGrpcConnection(new TelegramServiceOptions + { + UseLocal = false, + Endpoint = $"http://127.0.0.1:{port}", + }); + return new GrpcTelegramClient(tenantContext, connection, NullLogger.Instance); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs index cc455c0..f1f1919 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs @@ -1,159 +1,159 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Infrastructure; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Tenants; -using Deal.Tests.Unit.Support; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты выбора реализации портов интеграций по флагам Services:Ml|Ai|Telegram:UseLocal -/// -[Collection("MlGrpcTests")] -public sealed class IntegrationsDiTests -{ - [Fact] - public async Task AddDealIntegrations_UseLocalTrue_RegistersLocalClientsWithoutGrpcConnections() - { - await using ServiceProvider provider = BuildProvider( - new MlServiceOptions { UseLocal = true }, new AiServiceOptions { UseLocal = true }, - new TelegramServiceOptions { UseLocal = true }); - await using AsyncServiceScope scope = provider.CreateAsyncScope(); - - // Фолбэк по умолчанию: Local-заглушки; gRPC-транспортов и IMlTrainClient (для флашера) нет. - Assert.IsType(scope.ServiceProvider.GetRequiredService()); - Assert.Null(scope.ServiceProvider.GetService()); - Assert.IsType(scope.ServiceProvider.GetRequiredService()); - Assert.IsType(scope.ServiceProvider.GetRequiredService()); - Assert.IsType(scope.ServiceProvider.GetRequiredService()); - Assert.Null(scope.ServiceProvider.GetService()); - Assert.Null(scope.ServiceProvider.GetService()); - } - - [Fact] - public async Task AddDealIntegrations_UseLocalFalse_RegistersGrpcClientsUnderBothPorts() - { - string? originalToken = Environment.GetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey); - Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, MlGrpcTestHost.DefaultToken); - try - { - await using ServiceProvider provider = BuildProvider( - new MlServiceOptions { UseLocal = false }, new AiServiceOptions { UseLocal = false }, - new TelegramServiceOptions { UseLocal = false }); - await using AsyncServiceScope scope = provider.CreateAsyncScope(); - - IMlClient mlClient = scope.ServiceProvider.GetRequiredService(); - IMlTrainClient trainClient = scope.ServiceProvider.GetRequiredService(); - Assert.IsType(mlClient); - Assert.Same(mlClient, trainClient); - Assert.IsType(scope.ServiceProvider.GetRequiredService()); - Assert.IsType(scope.ServiceProvider.GetRequiredService()); - // Внутренние платные адаптеры остаются разрешимыми (декораторы построены поверх них). - Assert.IsType(scope.ServiceProvider.GetRequiredService()); - Assert.IsType(scope.ServiceProvider.GetRequiredService()); - Assert.IsType(scope.ServiceProvider.GetRequiredService()); - Assert.IsType(scope.ServiceProvider.GetRequiredService()); - Assert.IsType(scope.ServiceProvider.GetRequiredService()); - Assert.IsType(scope.ServiceProvider.GetRequiredService()); - } - finally - { - Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, originalToken); - } - } - - [Fact] - public void AddDealIntegrations_UseLocalFalseMl_WithoutServiceToken_ThrowsAtStartup() - { - string? originalToken = Environment.GetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey); - Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, null); - try - { - Assert.Throws(() => BuildProvider( - new MlServiceOptions { UseLocal = false }, new AiServiceOptions { UseLocal = true }, - new TelegramServiceOptions { UseLocal = true })); - } - finally - { - Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, originalToken); - } - } - - [Fact] - public void AddDealIntegrations_UseLocalFalseAi_WithoutServiceToken_ThrowsAtStartup() - { - string? originalToken = Environment.GetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey); - Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, null); - try - { - Assert.Throws(() => BuildProvider( - new MlServiceOptions { UseLocal = true }, new AiServiceOptions { UseLocal = false }, - new TelegramServiceOptions { UseLocal = true })); - } - finally - { - Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, originalToken); - } - } - - [Fact] - public void AddDealIntegrations_UseLocalFalseTelegram_WithoutServiceToken_ThrowsAtStartup() - { - string? originalToken = Environment.GetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey); - Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, null); - try - { - Assert.Throws(() => BuildProvider( - new MlServiceOptions { UseLocal = true }, new AiServiceOptions { UseLocal = true }, - new TelegramServiceOptions { UseLocal = false })); - } - finally - { - Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, originalToken); - } - } - - // Строит провайдер теста: AddDealIntegrations(mlOptions, aiOptions, telegramOptions) поверх tenant-фейков - // (как Program.cs; AiClassifyContextBuilder регистрирует AddPipelineModule). - // mlOptions: Конфигурация секции Services:Ml сценария. - // aiOptions: Конфигурация секции Services:Ai сценария. - // telegramOptions: Конфигурация секции Services:Telegram сценария. - // Возвращает: Провайдер с зарегистрированными сервисами теста. - private static ServiceProvider BuildProvider( - MlServiceOptions mlOptions, - AiServiceOptions aiOptions, - TelegramServiceOptions telegramOptions) - { - var services = new ServiceCollection(); - var tenantContext = new TenantContext(); - tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N"))); - services.AddSingleton(tenantContext); - services.AddScoped(_ => new FakeSettingsStore()); - services.AddScoped(_ => new FakeSecretCipher()); - services.AddScoped(_ => new FakeKanjStore()); - services.AddScoped(_ => new FakeMlLearningStore()); - services.AddScoped(_ => new FakeTenantLimitStore()); - services.AddScoped(_ => new TokenUsageEventService(new FakeTokenUsageEventStore())); - services.AddScoped(); - services.AddScoped(); - services.AddLogging(); - services.AddDealIntegrations(mlOptions, aiOptions, telegramOptions); - return services.BuildServiceProvider(); - } -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Infrastructure; +using Deal.Infrastructure.Data; +using Deal.Infrastructure.Integrations.Abstractions; +using Deal.Infrastructure.Integrations.Models; +using Deal.Infrastructure.Integrations.Options; +using Deal.Infrastructure.Integrations.Services; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Services; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Kanban; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Modules.Tenants; +using Deal.Tests.Unit.Support; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Tests.Unit.Contracts; + +/// +/// Тесты выбора реализации портов интеграций по флагам Services:Ml|Ai|Telegram:UseLocal +/// +[Collection("MlGrpcTests")] +public sealed class IntegrationsDiTests +{ + [Fact] + public async Task AddDealIntegrations_UseLocalTrue_RegistersLocalClientsWithoutGrpcConnections() + { + await using ServiceProvider provider = BuildProvider( + new MlServiceOptions { UseLocal = true }, new AiServiceOptions { UseLocal = true }, + new TelegramServiceOptions { UseLocal = true }); + await using AsyncServiceScope scope = provider.CreateAsyncScope(); + + // Фолбэк по умолчанию: Local-заглушки; gRPC-транспортов и IMlTrainClient (для флашера) нет. + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + Assert.Null(scope.ServiceProvider.GetService()); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + Assert.Null(scope.ServiceProvider.GetService()); + Assert.Null(scope.ServiceProvider.GetService()); + } + + [Fact] + public async Task AddDealIntegrations_UseLocalFalse_RegistersGrpcClientsUnderBothPorts() + { + string? originalToken = Environment.GetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey); + Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, MlGrpcTestHost.DefaultToken); + try + { + await using ServiceProvider provider = BuildProvider( + new MlServiceOptions { UseLocal = false }, new AiServiceOptions { UseLocal = false }, + new TelegramServiceOptions { UseLocal = false }); + await using AsyncServiceScope scope = provider.CreateAsyncScope(); + + IMlClient mlClient = scope.ServiceProvider.GetRequiredService(); + IMlTrainClient trainClient = scope.ServiceProvider.GetRequiredService(); + Assert.IsType(mlClient); + Assert.Same(mlClient, trainClient); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + // Внутренние платные адаптеры остаются разрешимыми (декораторы построены поверх них). + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + } + finally + { + Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, originalToken); + } + } + + [Fact] + public void AddDealIntegrations_UseLocalFalseMl_WithoutServiceToken_ThrowsAtStartup() + { + string? originalToken = Environment.GetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey); + Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, null); + try + { + Assert.Throws(() => BuildProvider( + new MlServiceOptions { UseLocal = false }, new AiServiceOptions { UseLocal = true }, + new TelegramServiceOptions { UseLocal = true })); + } + finally + { + Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, originalToken); + } + } + + [Fact] + public void AddDealIntegrations_UseLocalFalseAi_WithoutServiceToken_ThrowsAtStartup() + { + string? originalToken = Environment.GetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey); + Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, null); + try + { + Assert.Throws(() => BuildProvider( + new MlServiceOptions { UseLocal = true }, new AiServiceOptions { UseLocal = false }, + new TelegramServiceOptions { UseLocal = true })); + } + finally + { + Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, originalToken); + } + } + + [Fact] + public void AddDealIntegrations_UseLocalFalseTelegram_WithoutServiceToken_ThrowsAtStartup() + { + string? originalToken = Environment.GetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey); + Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, null); + try + { + Assert.Throws(() => BuildProvider( + new MlServiceOptions { UseLocal = true }, new AiServiceOptions { UseLocal = true }, + new TelegramServiceOptions { UseLocal = false })); + } + finally + { + Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, originalToken); + } + } + + // Строит провайдер теста: AddDealIntegrations(mlOptions, aiOptions, telegramOptions) поверх tenant-фейков + // (как Program.cs; AiClassifyContextBuilder регистрирует AddPipelineModule). + // mlOptions: Конфигурация секции Services:Ml сценария. + // aiOptions: Конфигурация секции Services:Ai сценария. + // telegramOptions: Конфигурация секции Services:Telegram сценария. + // Возвращает: Провайдер с зарегистрированными сервисами теста. + private static ServiceProvider BuildProvider( + MlServiceOptions mlOptions, + AiServiceOptions aiOptions, + TelegramServiceOptions telegramOptions) + { + var services = new ServiceCollection(); + var tenantContext = new TenantContext(); + tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N"))); + services.AddSingleton(tenantContext); + services.AddScoped(_ => new FakeSettingsStore()); + services.AddScoped(_ => new FakeSecretCipher()); + services.AddScoped(_ => new FakeKanjStore()); + services.AddScoped(_ => new FakeMlLearningStore()); + services.AddScoped(_ => new FakeTenantLimitStore()); + services.AddScoped(_ => new TokenUsageEventService(new FakeTokenUsageEventStore())); + services.AddScoped(); + services.AddScoped(); + services.AddLogging(); + services.AddDealIntegrations(mlOptions, aiOptions, telegramOptions); + return services.BuildServiceProvider(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiClassifierTests.cs index a5f7a31..8568af3 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiClassifierTests.cs @@ -1,153 +1,153 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application.Models; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты детерминированного ИИ-классификатора . -/// -public sealed class LocalAiClassifierTests -{ - // Создаёт контекст теста: пустое KV-хранилище (дефолты маркеров) + адаптер. - // Возвращает: Кортеж (KV-настройки, классификатор). - private static (FakeSettingsStore Settings, LocalAiClassifier Classifier) CreateContext() - { - var settings = new FakeSettingsStore(); - var classifier = new LocalAiClassifier(new LocalFieldsParser(settings)); - return (settings, classifier); - } - - // ─── ИИ-фильтр: локально всегда пропуск ───────────────────────────────── - - [Fact] - public async Task FilterAsync_AnyText_AlwaysPassesWithSkipped() - { - (_, LocalAiClassifier classifier) = CreateContext(); - - AiFilterResultDto result = await classifier.FilterAsync("Срочно ищем Java-разработчика, оплата 2000$", default); - - Assert.True(result.Pass); - Assert.Null(result.Reason); - Assert.True(result.Skipped); - } - - // ─── Классификация: вакансия с бюджетом/контактами ────────────────────── - - [Fact] - public async Task Classify_VacancyWithBudgetAndContacts_ProducesStructuredDto() - { - (_, LocalAiClassifier classifier) = CreateContext(); - const string text = - "Вакансия: Middle Java-разработчик в команду\n" - + "Стек: Java, C#, Kotlin\n" - + "Контакты: @dev_ivan, @some_bot, dev@q.ru\n" - + "Бюджет: 1500–2000 $\n" - + "Работа над внутренним продуктом компании, удалённая команда из пяти человек"; - - AiParsedCardDto result = await classifier.ClassifyAsync(text, default); - - Assert.Equal("Вакансия: Middle Java-разработчик в команду", result.Title); - Assert.Equal("Работа над внутренним продуктом компании, удалённая команда из пяти человек", result.Summary); - Assert.Equal(new[] { "Java", "C#", "Kotlin" }, result.Stack); - Assert.Equal(new AiBudgetDto(1500, 2000, "USD"), result.Budget); - // Контакты квалифицированы: @some_bot отброшен (бот), остались tg и email. - Assert.Equal( - new[] { new AiContactDto("tg", "@dev_ivan"), new AiContactDto("email", "dev@q.ru") }, - result.Contacts); - Assert.True(result.IsVacancy); // маркерная гипотеза «вакансия»/«в команду» - Assert.False(result.IsVacancyKnown); // тип подтверждает только ИИ по контексту (python L796) - Assert.False(result.IsSpam); - Assert.Null(result.Board); // смысловые колонки до ИИ не назначаем (python L797) - } - - // ─── Классификация: бюджет «до X» и валюта-суффикс ────────────────────── - - [Fact] - public async Task Classify_BudgetUpToWithKSuffix_NormalizesBudget() - { - (_, LocalAiClassifier classifier) = CreateContext(); - const string text = - "Ищем Python-разработчика на проект в команду\n" - + "Бюджет: до 2к$"; - - AiParsedCardDto result = await classifier.ClassifyAsync(text, default); - - Assert.Equal(new AiBudgetDto(From: null, To: 2000, Cur: "USD"), result.Budget); - } - - // ─── Классификация: заказ без бюджета — не вакансия ───────────────────── - - [Fact] - public async Task Classify_OrderWithoutBudget_IsNotVacancyAndBudgetNull() - { - (_, LocalAiClassifier classifier) = CreateContext(); - const string text = - "Нужно сделать лендинг для кофейни: дизайн в Figma и вёрстка, срок — две недели.\n" - + "Портфолио присылайте в личку."; - - AiParsedCardDto result = await classifier.ClassifyAsync(text, default); - - Assert.False(result.IsVacancy); // маркеров найма нет — разовая сделка - Assert.False(result.IsVacancyKnown); - Assert.Null(result.Budget); // суммы с валютой в тексте нет - Assert.Empty(result.Contacts); // @username/email/телефона в тексте нет - Assert.Empty(result.Stack); - Assert.Null(result.Board); - Assert.False(result.IsSpam); - // Блок «О заявке» локального пути: legacy-суть из содержательных строк без структурированных полей. - Assert.Null(result.Company); - Assert.Null(result.Format); - Assert.Null(result.Task); - Assert.Null(result.Requirements); - Assert.Null(result.Plus); - Assert.Null(result.Conditions); - Assert.False(string.IsNullOrWhiteSpace(result.Summary)); - } - - // ─── Детерминизм ──────────────────────────────────────────────────────── - - [Fact] - public async Task Classify_SameTextTwice_ReturnsIdenticalDto() - { - (_, LocalAiClassifier classifier) = CreateContext(); - const string text = - "Вакансия: Middle Java-разработчик в команду\n" - + "Стек: Java, C#, Kotlin\n" - + "Контакты: @dev_ivan, dev@q.ru\n" - + "Бюджет: 1500–2000 $\n" - + "Работа над внутренним продуктом компании, удалённая команда из пяти человек"; - - AiParsedCardDto first = await classifier.ClassifyAsync(text, default); - AiParsedCardDto second = await classifier.ClassifyAsync(text, default); - - // Классификатор детерминирован: одинаковый текст → одинаковый разбор (поля, бюджет, контакты, стек). - Assert.Equal(first.Title, second.Title); - Assert.Equal(first.Summary, second.Summary); - Assert.Equal(first.IsVacancy, second.IsVacancy); - Assert.Equal(first.IsVacancyKnown, second.IsVacancyKnown); - Assert.Equal(first.IsSpam, second.IsSpam); - Assert.Equal(first.Board, second.Board); - Assert.Equal(first.Budget, second.Budget); - Assert.Equal(first.Stack, second.Stack); - Assert.Equal(first.Contacts, second.Contacts); - } - - // ─── Маркеры найма из настроек тенанта ────────────────────────────────── - - [Fact] - public async Task Classify_CustomHireMarkersOverride_AreUsed() - { - (FakeSettingsStore settings, LocalAiClassifier classifier) = CreateContext(); - // Дефолтные маркеры заменены: «вакансия» больше не маркер найма, свой — маркер. - settings.Preload(SettingsKeys.HireMarkers, "[\"нанимаем джунов\"]"); - - AiParsedCardDto withMarker = await classifier.ClassifyAsync("Срочно нанимаем джунов на проект", default); - AiParsedCardDto withoutMarker = await classifier.ClassifyAsync("Вакансия Java-разработчика на проект", default); - - Assert.True(withMarker.IsVacancy); - Assert.False(withoutMarker.IsVacancy); - } -} +using Deal.Contracts.Integrations.Models; +using Deal.Infrastructure.Integrations.Services; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Modules.Settings; + +namespace Deal.Tests.Unit.Contracts; + +/// +/// Тесты детерминированного ИИ-классификатора . +/// +public sealed class LocalAiClassifierTests +{ + // Создаёт контекст теста: пустое KV-хранилище (дефолты маркеров) + адаптер. + // Возвращает: Кортеж (KV-настройки, классификатор). + private static (FakeSettingsStore Settings, LocalAiClassifier Classifier) CreateContext() + { + var settings = new FakeSettingsStore(); + var classifier = new LocalAiClassifier(new LocalFieldsParser(settings)); + return (settings, classifier); + } + + // ─── ИИ-фильтр: локально всегда пропуск ───────────────────────────────── + + [Fact] + public async Task FilterAsync_AnyText_AlwaysPassesWithSkipped() + { + (_, LocalAiClassifier classifier) = CreateContext(); + + AiFilterResultDto result = await classifier.FilterAsync("Срочно ищем Java-разработчика, оплата 2000$", default); + + Assert.True(result.Pass); + Assert.Null(result.Reason); + Assert.True(result.Skipped); + } + + // ─── Классификация: вакансия с бюджетом/контактами ────────────────────── + + [Fact] + public async Task Classify_VacancyWithBudgetAndContacts_ProducesStructuredDto() + { + (_, LocalAiClassifier classifier) = CreateContext(); + const string text = + "Вакансия: Middle Java-разработчик в команду\n" + + "Стек: Java, C#, Kotlin\n" + + "Контакты: @dev_ivan, @some_bot, dev@q.ru\n" + + "Бюджет: 1500–2000 $\n" + + "Работа над внутренним продуктом компании, удалённая команда из пяти человек"; + + AiParsedCardDto result = await classifier.ClassifyAsync(text, default); + + Assert.Equal("Вакансия: Middle Java-разработчик в команду", result.Title); + Assert.Equal("Работа над внутренним продуктом компании, удалённая команда из пяти человек", result.Summary); + Assert.Equal(new[] { "Java", "C#", "Kotlin" }, result.Stack); + Assert.Equal(new AiBudgetDto(1500, 2000, "USD"), result.Budget); + // Контакты квалифицированы: @some_bot отброшен (бот), остались tg и email. + Assert.Equal( + new[] { new AiContactDto("tg", "@dev_ivan"), new AiContactDto("email", "dev@q.ru") }, + result.Contacts); + Assert.True(result.IsVacancy); // маркерная гипотеза «вакансия»/«в команду» + Assert.False(result.IsVacancyKnown); // тип подтверждает только ИИ по контексту (python L796) + Assert.False(result.IsSpam); + Assert.Null(result.Board); // смысловые колонки до ИИ не назначаем (python L797) + } + + // ─── Классификация: бюджет «до X» и валюта-суффикс ────────────────────── + + [Fact] + public async Task Classify_BudgetUpToWithKSuffix_NormalizesBudget() + { + (_, LocalAiClassifier classifier) = CreateContext(); + const string text = + "Ищем Python-разработчика на проект в команду\n" + + "Бюджет: до 2к$"; + + AiParsedCardDto result = await classifier.ClassifyAsync(text, default); + + Assert.Equal(new AiBudgetDto(From: null, To: 2000, Cur: "USD"), result.Budget); + } + + // ─── Классификация: заказ без бюджета — не вакансия ───────────────────── + + [Fact] + public async Task Classify_OrderWithoutBudget_IsNotVacancyAndBudgetNull() + { + (_, LocalAiClassifier classifier) = CreateContext(); + const string text = + "Нужно сделать лендинг для кофейни: дизайн в Figma и вёрстка, срок — две недели.\n" + + "Портфолио присылайте в личку."; + + AiParsedCardDto result = await classifier.ClassifyAsync(text, default); + + Assert.False(result.IsVacancy); // маркеров найма нет — разовая сделка + Assert.False(result.IsVacancyKnown); + Assert.Null(result.Budget); // суммы с валютой в тексте нет + Assert.Empty(result.Contacts); // @username/email/телефона в тексте нет + Assert.Empty(result.Stack); + Assert.Null(result.Board); + Assert.False(result.IsSpam); + // Блок «О заявке» локального пути: legacy-суть из содержательных строк без структурированных полей. + Assert.Null(result.Company); + Assert.Null(result.Format); + Assert.Null(result.Task); + Assert.Null(result.Requirements); + Assert.Null(result.Plus); + Assert.Null(result.Conditions); + Assert.False(string.IsNullOrWhiteSpace(result.Summary)); + } + + // ─── Детерминизм ──────────────────────────────────────────────────────── + + [Fact] + public async Task Classify_SameTextTwice_ReturnsIdenticalDto() + { + (_, LocalAiClassifier classifier) = CreateContext(); + const string text = + "Вакансия: Middle Java-разработчик в команду\n" + + "Стек: Java, C#, Kotlin\n" + + "Контакты: @dev_ivan, dev@q.ru\n" + + "Бюджет: 1500–2000 $\n" + + "Работа над внутренним продуктом компании, удалённая команда из пяти человек"; + + AiParsedCardDto first = await classifier.ClassifyAsync(text, default); + AiParsedCardDto second = await classifier.ClassifyAsync(text, default); + + // Классификатор детерминирован: одинаковый текст → одинаковый разбор (поля, бюджет, контакты, стек). + Assert.Equal(first.Title, second.Title); + Assert.Equal(first.Summary, second.Summary); + Assert.Equal(first.IsVacancy, second.IsVacancy); + Assert.Equal(first.IsVacancyKnown, second.IsVacancyKnown); + Assert.Equal(first.IsSpam, second.IsSpam); + Assert.Equal(first.Board, second.Board); + Assert.Equal(first.Budget, second.Budget); + Assert.Equal(first.Stack, second.Stack); + Assert.Equal(first.Contacts, second.Contacts); + } + + // ─── Маркеры найма из настроек тенанта ────────────────────────────────── + + [Fact] + public async Task Classify_CustomHireMarkersOverride_AreUsed() + { + (FakeSettingsStore settings, LocalAiClassifier classifier) = CreateContext(); + // Дефолтные маркеры заменены: «вакансия» больше не маркер найма, свой — маркер. + settings.Preload(SettingsKeys.HireMarkers, "[\"нанимаем джунов\"]"); + + AiParsedCardDto withMarker = await classifier.ClassifyAsync("Срочно нанимаем джунов на проект", default); + AiParsedCardDto withoutMarker = await classifier.ClassifyAsync("Вакансия Java-разработчика на проект", default); + + Assert.True(withMarker.IsVacancy); + Assert.False(withoutMarker.IsVacancy); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs index 5e68689..e3b5a4e 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs @@ -1,120 +1,120 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты обёртки создания карточки PipelineCardWriter. -/// -public sealed class PipelineCardWriterTests -{ - - [Fact] - public async Task CreateCard_CreatesInboxCardAndLinksDedupClaim() - { - (PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create(); - const string hash = "abcdef0123456789abcdef0123456789abcdef01"; - await pipelineStore.ClaimAsync(hash, CancellationToken.None); // заявка воркера (LeadId=null, Ruling 8) - AiParsedCardDto parsed = Parsed( - title: "Python-разработчик на бота", - budget: new AiBudgetDto(2000, 2000, "USD"), - contacts: new[] { new AiContactDto("tg", "@ivan_py") }, - isVacancy: true); - - CardDto card = await writer.CreateCardAsync(parsed, Message("Ищем Python-разработчика, 2000$"), hash, CancellationToken.None); - - // Карточка создана через публичный интерфейс Kanban (l_, «Неразобранное») и перечитана (SSE-форма). - Assert.StartsWith(KanbanIdPrefixes.Card, card.Id); - Assert.Equal(KanbanColumns.Inbox, card.Col); - Assert.True(card.IsNew); - Assert.Equal("Python-разработчик на бота", card.Title); - Assert.Same(card, Assert.Single(store.CardDtos)); // чтение после записи — та же строка фейка - Assert.Equal(new CardBudgetDto(2000, 2000, "USD"), card.Budget); - - Assert.Equal(card.Id, pipelineStore.DedupLeadId(hash)); - Assert.True(await pipelineStore.ExistsAsync(hash, CancellationToken.None)); - } - - [Fact] - public async Task CreateCard_AssignedBoardAccepted_WritesCardIntoBoardColumn() - { - (PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create(); - store.SeedBoard(new ContainerDto { Id = "b_py" }); - const string hash = "abcdef0123456789abcdef0123456789abcdef01"; - await pipelineStore.ClaimAsync(hash, CancellationToken.None); - - CardDto card = await writer.CreateCardAsync( - Parsed(title: "Python-разработчик", board: "b_py"), - Message("Ищем Python-разработчика в команду"), - hash, - CancellationToken.None); - - // Доска без правил приняла текст (ContainerAccepts) — карточка легла в колонку доски, линк сделан. - Assert.Equal("b_py", card.Col); - Assert.Equal("b_py", Assert.Single(store.CardDtos).Col); - Assert.Equal(card.Id, pipelineStore.DedupLeadId(hash)); - } - - // ─── Ошибки: сбой записи карточки ──────────────────────────────────────────────────── - - [Fact] - public async Task CreateCard_AddCardFails_ThrowsAndDoesNotLinkDedup() - { - (PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create(); - store.FailAddCard = true; // сбой адаптера записи (например, недоступна БД тенанта) - const string hash = "abcdef0123456789abcdef0123456789abcdef01"; - await pipelineStore.ClaimAsync(hash, CancellationToken.None); - - await Assert.ThrowsAsync( - () => writer.CreateCardAsync(Parsed(title: "Python-разработчик"), Message("Текст"), hash, CancellationToken.None)); - - // Карточка не создана → связывать нечего: заявка дедупа осталась несвязанной (LeadId=null). - Assert.Empty(store.CardDtos); - Assert.Null(pipelineStore.DedupLeadId(hash)); - Assert.True(await pipelineStore.ExistsAsync(hash, CancellationToken.None)); // заявка на месте - } - - // ─── Хелперы ────────────────────────────────────────────────────────────────────────── - - // Создаёт контекст теста: фейки канбана, пайплайна и композитор поверх них. - // Возвращает: Кортеж (обёртка, канбан-хранилище, хранилище пайплайна). - private static (PipelineCardWriter Writer, FakeKanjStore Store, FakePipelineStore PipelineStore) Create() - { - var store = new FakeKanjStore(); - var pipelineStore = new FakePipelineStore(); - var composer = new CardComposer(store, new FakeSettingsStore()); - return (new PipelineCardWriter(store, pipelineStore, composer), store, pipelineStore); - } - - // Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля). - private static AiParsedCardDto Parsed( - string title = "", - AiBudgetDto? budget = null, - IReadOnlyList? contacts = null, - bool isVacancy = false, - bool isVacancyKnown = false, - string? board = null) => new( - title, Company: null, Format: null, Task: null, Requirements: null, Plus: null, Conditions: null, - Summary: null, - Stack: Array.Empty(), - budget, - contacts ?? Array.Empty(), - isVacancy, isVacancyKnown, IsSpam: false, board); - - // Строка очереди-источника (поля сообщения; статус/время постановки не важны). - private static QueueItemDto Message( - string text, - string dialogId = "d_1", - long? msgId = 123) => SourceItemFactory.Queue( - "p_1", - text, - PipelineQueueStatuses.Filtered, - origin: dialogId, - externalId: msgId?.ToString(System.Globalization.CultureInfo.InvariantCulture)); -} +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Kanban; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Support; + +namespace Deal.Tests.Unit.Contracts; + +/// +/// Тесты обёртки создания карточки PipelineCardWriter. +/// +public sealed class PipelineCardWriterTests +{ + + [Fact] + public async Task CreateCard_CreatesInboxCardAndLinksDedupClaim() + { + (PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create(); + const string hash = "abcdef0123456789abcdef0123456789abcdef01"; + await pipelineStore.ClaimAsync(hash, CancellationToken.None); // заявка воркера (LeadId=null, Ruling 8) + AiParsedCardDto parsed = Parsed( + title: "Python-разработчик на бота", + budget: new AiBudgetDto(2000, 2000, "USD"), + contacts: new[] { new AiContactDto("tg", "@ivan_py") }, + isVacancy: true); + + CardDto card = await writer.CreateCardAsync(parsed, Message("Ищем Python-разработчика, 2000$"), hash, CancellationToken.None); + + // Карточка создана через публичный интерфейс Kanban (l_, «Неразобранное») и перечитана (SSE-форма). + Assert.StartsWith(KanbanIdPrefixes.Card, card.Id); + Assert.Equal(KanbanColumns.Inbox, card.Col); + Assert.True(card.IsNew); + Assert.Equal("Python-разработчик на бота", card.Title); + Assert.Same(card, Assert.Single(store.CardDtos)); // чтение после записи — та же строка фейка + Assert.Equal(new CardBudgetDto(2000, 2000, "USD"), card.Budget); + + Assert.Equal(card.Id, pipelineStore.DedupLeadId(hash)); + Assert.True(await pipelineStore.ExistsAsync(hash, CancellationToken.None)); + } + + [Fact] + public async Task CreateCard_AssignedBoardAccepted_WritesCardIntoBoardColumn() + { + (PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create(); + store.SeedBoard(new ContainerDto { Id = "b_py" }); + const string hash = "abcdef0123456789abcdef0123456789abcdef01"; + await pipelineStore.ClaimAsync(hash, CancellationToken.None); + + CardDto card = await writer.CreateCardAsync( + Parsed(title: "Python-разработчик", board: "b_py"), + Message("Ищем Python-разработчика в команду"), + hash, + CancellationToken.None); + + // Доска без правил приняла текст (ContainerAccepts) — карточка легла в колонку доски, линк сделан. + Assert.Equal("b_py", card.Col); + Assert.Equal("b_py", Assert.Single(store.CardDtos).Col); + Assert.Equal(card.Id, pipelineStore.DedupLeadId(hash)); + } + + // ─── Ошибки: сбой записи карточки ──────────────────────────────────────────────────── + + [Fact] + public async Task CreateCard_AddCardFails_ThrowsAndDoesNotLinkDedup() + { + (PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create(); + store.FailAddCard = true; // сбой адаптера записи (например, недоступна БД тенанта) + const string hash = "abcdef0123456789abcdef0123456789abcdef01"; + await pipelineStore.ClaimAsync(hash, CancellationToken.None); + + await Assert.ThrowsAsync( + () => writer.CreateCardAsync(Parsed(title: "Python-разработчик"), Message("Текст"), hash, CancellationToken.None)); + + // Карточка не создана → связывать нечего: заявка дедупа осталась несвязанной (LeadId=null). + Assert.Empty(store.CardDtos); + Assert.Null(pipelineStore.DedupLeadId(hash)); + Assert.True(await pipelineStore.ExistsAsync(hash, CancellationToken.None)); // заявка на месте + } + + // ─── Хелперы ────────────────────────────────────────────────────────────────────────── + + // Создаёт контекст теста: фейки канбана, пайплайна и композитор поверх них. + // Возвращает: Кортеж (обёртка, канбан-хранилище, хранилище пайплайна). + private static (PipelineCardWriter Writer, FakeKanjStore Store, FakePipelineStore PipelineStore) Create() + { + var store = new FakeKanjStore(); + var pipelineStore = new FakePipelineStore(); + var composer = new CardComposer(store, new FakeSettingsStore()); + return (new PipelineCardWriter(store, pipelineStore, composer), store, pipelineStore); + } + + // Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля). + private static AiParsedCardDto Parsed( + string title = "", + AiBudgetDto? budget = null, + IReadOnlyList? contacts = null, + bool isVacancy = false, + bool isVacancyKnown = false, + string? board = null) => new( + title, Company: null, Format: null, Task: null, Requirements: null, Plus: null, Conditions: null, + Summary: null, + Stack: Array.Empty(), + budget, + contacts ?? Array.Empty(), + isVacancy, isVacancyKnown, IsSpam: false, board); + + // Строка очереди-источника (поля сообщения; статус/время постановки не важны). + private static QueueItemDto Message( + string text, + string dialogId = "d_1", + long? msgId = 123) => SourceItemFactory.Queue( + "p_1", + text, + PipelineQueueStatuses.Filtered, + origin: dialogId, + externalId: msgId?.ToString(System.Globalization.CultureInfo.InvariantCulture)); +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs index ae0ee66..2d7463c 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs @@ -1,222 +1,222 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Ai; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Models; -using Deal.Tests.Unit.Grpc; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Tenants; -using Deal.Tests.Unit.Support; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Сквозной тест воркера pump с gRPC-классификатором ai-service -/// -[Collection("MlGrpcTests")] -public sealed class PipelineWorkerGrpcAiTests -{ - // Id тенанта сценария строкой (формат N) — metadata tenant-id вызовов ai-service. - private const string TenantIdValue = "abcdefabcdefabcdefabcdefabcdefab"; - - // Guid того же тенанта — ключ строки лимита в FakeTenantLimitStore (списание usage). - private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue); - - [Fact] - public async Task Pump_GrpcAiClassifier_FilterAndClassifyAgainstInProcAiService() - { - await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => - { - service.FilterReply = new FilterReply - { - Pass = true, - Usage = new Usage { Prompt = 120, Completion = 10, Total = 130 }, - }; - service.ClassifyReply = new ClassifyReply - { - Ok = true, - Json = """ - {"title":"Middle Python в команду","is_vacancy":true,"is_spam":false, - "stack":["Python"],"budget":{"from":2000,"to":2500,"currency":"USD"}, - "contacts":["@ivan_dev"]} - """, - Usage = new Usage { Prompt = 2000, Completion = 400, Total = 2400 }, - }; - - Context ctx = CreateContext(port); - ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$")); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(CancellationToken.None); - - // Классификация реальным gRPC-адаптером: карточка создана ИИ-путём (не aiFail/локальная заглушка). - Assert.Equal(1, result.AiStored); - Assert.Equal(0, result.AiFail); - Assert.Single(result.CreatedCards); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(ctx.PipelineStore.Rejected); - - CardDto card = Assert.Single(result.CreatedCards); - Assert.Equal("Middle Python в команду", card.Title); - Assert.Equal(KanbanColumns.Inbox, card.Col); // board не назначен моделью → «Неразобранное» - Assert.True(card.IsVacancy); - Assert.True(card.IsVacancyKnown); // стемп успешной классификации воркера (python L1108–1111) - Assert.Equal(new CardBudgetDto(2000, 2500, "USD"), card.Budget); - - Assert.Equal(1, service.FilterCalls); - Assert.Equal(1, service.ClassifyCalls); - Assert.All(service.RequestTenantIds, id => Assert.Equal(TenantIdValue, id)); - Assert.All(service.RequestTokens, token => Assert.Equal(AiGrpcTestHost.DefaultToken, token)); - - Assert.Equal("{\"prompt\":2120,\"completion\":410,\"total\":2530}", ctx.Settings.GetStoredJson(SettingsKeys.AiTokenUsage)); - Assert.Equal(2530, ctx.Limits.UsedTokens(TenantGuid)); - }); - } - - [Fact] - public async Task Pump_GrpcAiServiceUnavailable_FallsBackToLocalParse() - { - await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => - { - service.FilterUnavailable = true; - service.ClassifyUnavailable = true; - Context ctx = CreateContext(port); - ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$")); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(CancellationToken.None); - - Assert.Equal(1, result.AiFail); - Assert.Equal(1, result.AiStored); - Assert.Single(result.CreatedCards); - Assert.Equal(1, service.FilterCalls); - Assert.Equal(1, service.ClassifyCalls); - }); - } - - [Fact] - public async Task Pump_BudgetExhausted_GateUsesLocalClassifierWithoutPaidRpc() - { - await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => - { - // Реальный ai-service ответил бы — но гейт (бюджет исчерпан) не должен пустить к нему ни одного RPC. - service.FilterReply = new FilterReply - { - Pass = true, - Usage = new Usage { Prompt = 120, Completion = 10, Total = 130 }, - }; - service.ClassifyReply = new ClassifyReply - { - Ok = true, - Json = """ - {"title":"Платная ветка","is_vacancy":true,"is_spam":false} - """, - Usage = new Usage { Prompt = 2000, Completion = 400, Total = 2400 }, - }; - - var limits = new FakeTenantLimitStore(); - limits.Preload(TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, usedTokens: 1000); - Context ctx = CreateContext(port, limits, budgeted: true); - ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$ в месяц")); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(CancellationToken.None); - - // Платные RPC не вызывались: гейт запретил (исчерпан), приём/обработка не заблокированы. - Assert.Equal(0, service.FilterCalls); - Assert.Equal(0, service.ClassifyCalls); - Assert.Empty(service.RequestTenantIds); - - Assert.Equal(1, result.AiStored); - Assert.Equal(0, result.AiFail); - Assert.Single(result.CreatedCards); - Assert.Equal(1000, limits.UsedTokens(TenantGuid)); // списаний не было (платных вызовов нет) - CardDto card = Assert.Single(ctx.KanjStore.CardDtos); - Assert.Equal(KanbanColumns.Inbox, card.Col); // локальный путь не назначает смысловые колонки - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(ctx.PipelineStore.Rejected); - }); - } - - // ─── Контекст воркера ─────────────────────────────────────────────────── - - private sealed record Context( - PipelineWorkerService Worker, - FakePipelineStore PipelineStore, - FakeKanjStore KanjStore, - FakeSettingsStore Settings, - FakeTenantLimitStore Limits); - - private static Context CreateContext( - int port, - FakeTenantLimitStore? limits = null, - bool budgeted = false) - { - var settings = new FakeSettingsStore(); - var pipelineStore = new FakePipelineStore(); - var kanjStore = new FakeKanjStore(); - var mlClient = new FakeMlClient { Predict = NotReadyPrediction() }; - var rules = new IncomingRules(settings); - var fieldsParser = new LocalFieldsParser(settings); - - var tenantContext = new TenantContext(); - tenantContext.SetTenant(new TenantId(TenantIdValue)); - var connection = new AiGrpcConnection( - new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); - limits ??= new FakeTenantLimitStore(); - var grpcClassifier = new GrpcAiClassifier( - tenantContext, - connection, - new AiProviderConfigBuilder(settings, new FakeSecretCipher()), - new AiClassifyContextBuilder(settings, kanjStore), - new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(new FakeTokenUsageEventStore())), - NullLogger.Instance); - IAiClassifier aiClassifier = budgeted - ? new BudgetedAiClassifier( - grpcClassifier, - new LocalAiClassifier(fieldsParser), - limits, - tenantContext, - NullLogger.Instance) - : grpcClassifier; - - var processing = new PipelineProcessingService(pipelineStore, mlClient, new PipelineIngestService(pipelineStore)); - var composer = new CardComposer(kanjStore, settings); - var writer = new PipelineCardWriter(kanjStore, pipelineStore, composer); - var worker = new PipelineWorkerService( - pipelineStore, settings, rules, kanjStore, mlClient, aiClassifier, processing, writer, fieldsParser); - return new Context(worker, pipelineStore, kanjStore, settings, limits); - } - - // Строка очереди статуса filtered (уже прошла «new»-проход и ждёт ИИ). - // id: Id строки (p_…). - // text: Текст сообщения. - // Возвращает: Строка как если бы была переведена в filtered воркером. - private static QueueItemDto QueueRowFiltered(string id, string text) => SourceItemFactory.Queue( - id, - text, - PipelineQueueStatuses.Filtered, - msgAtMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); - - // Неготовая модель: «не уверена» (ML-слот воркера не решает — сообщение уходит к ИИ). - // Возвращает: Фиксированный «не готов/не уверен». - private static MlPredictResultDto NotReadyPrediction() => new( - Take: false, - Label: null, - Scores: new Dictionary(), - Hits: 0, - Ready: false, - Margin: null, - Terms: Array.Empty(), - Type: null); -} +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Grpc.Ai; +using Deal.Infrastructure.Data; +using Deal.Infrastructure.Integrations.Models; +using Deal.Infrastructure.Integrations.Options; +using Deal.Infrastructure.Integrations.Services; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; +using Deal.SharedKernel.Tenants.Models; +using Deal.Tests.Unit.Grpc; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Kanban; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Modules.Tenants; +using Deal.Tests.Unit.Support; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Contracts; + +/// +/// Сквозной тест воркера pump с gRPC-классификатором ai-service +/// +[Collection("MlGrpcTests")] +public sealed class PipelineWorkerGrpcAiTests +{ + // Id тенанта сценария строкой (формат N) — metadata tenant-id вызовов ai-service. + private const string TenantIdValue = "abcdefabcdefabcdefabcdefabcdefab"; + + // Guid того же тенанта — ключ строки лимита в FakeTenantLimitStore (списание usage). + private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue); + + [Fact] + public async Task Pump_GrpcAiClassifier_FilterAndClassifyAgainstInProcAiService() + { + await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => + { + service.FilterReply = new FilterReply + { + Pass = true, + Usage = new Usage { Prompt = 120, Completion = 10, Total = 130 }, + }; + service.ClassifyReply = new ClassifyReply + { + Ok = true, + Json = """ + {"title":"Middle Python в команду","is_vacancy":true,"is_spam":false, + "stack":["Python"],"budget":{"from":2000,"to":2500,"currency":"USD"}, + "contacts":["@ivan_dev"]} + """, + Usage = new Usage { Prompt = 2000, Completion = 400, Total = 2400 }, + }; + + Context ctx = CreateContext(port); + ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$")); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(CancellationToken.None); + + // Классификация реальным gRPC-адаптером: карточка создана ИИ-путём (не aiFail/локальная заглушка). + Assert.Equal(1, result.AiStored); + Assert.Equal(0, result.AiFail); + Assert.Single(result.CreatedCards); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(ctx.PipelineStore.Rejected); + + CardDto card = Assert.Single(result.CreatedCards); + Assert.Equal("Middle Python в команду", card.Title); + Assert.Equal(KanbanColumns.Inbox, card.Col); // board не назначен моделью → «Неразобранное» + Assert.True(card.IsVacancy); + Assert.True(card.IsVacancyKnown); // стемп успешной классификации воркера (python L1108–1111) + Assert.Equal(new CardBudgetDto(2000, 2500, "USD"), card.Budget); + + Assert.Equal(1, service.FilterCalls); + Assert.Equal(1, service.ClassifyCalls); + Assert.All(service.RequestTenantIds, id => Assert.Equal(TenantIdValue, id)); + Assert.All(service.RequestTokens, token => Assert.Equal(AiGrpcTestHost.DefaultToken, token)); + + Assert.Equal("{\"prompt\":2120,\"completion\":410,\"total\":2530}", ctx.Settings.GetStoredJson(SettingsKeys.AiTokenUsage)); + Assert.Equal(2530, ctx.Limits.UsedTokens(TenantGuid)); + }); + } + + [Fact] + public async Task Pump_GrpcAiServiceUnavailable_FallsBackToLocalParse() + { + await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => + { + service.FilterUnavailable = true; + service.ClassifyUnavailable = true; + Context ctx = CreateContext(port); + ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$")); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(CancellationToken.None); + + Assert.Equal(1, result.AiFail); + Assert.Equal(1, result.AiStored); + Assert.Single(result.CreatedCards); + Assert.Equal(1, service.FilterCalls); + Assert.Equal(1, service.ClassifyCalls); + }); + } + + [Fact] + public async Task Pump_BudgetExhausted_GateUsesLocalClassifierWithoutPaidRpc() + { + await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => + { + // Реальный ai-service ответил бы — но гейт (бюджет исчерпан) не должен пустить к нему ни одного RPC. + service.FilterReply = new FilterReply + { + Pass = true, + Usage = new Usage { Prompt = 120, Completion = 10, Total = 130 }, + }; + service.ClassifyReply = new ClassifyReply + { + Ok = true, + Json = """ + {"title":"Платная ветка","is_vacancy":true,"is_spam":false} + """, + Usage = new Usage { Prompt = 2000, Completion = 400, Total = 2400 }, + }; + + var limits = new FakeTenantLimitStore(); + limits.Preload(TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, usedTokens: 1000); + Context ctx = CreateContext(port, limits, budgeted: true); + ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$ в месяц")); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(CancellationToken.None); + + // Платные RPC не вызывались: гейт запретил (исчерпан), приём/обработка не заблокированы. + Assert.Equal(0, service.FilterCalls); + Assert.Equal(0, service.ClassifyCalls); + Assert.Empty(service.RequestTenantIds); + + Assert.Equal(1, result.AiStored); + Assert.Equal(0, result.AiFail); + Assert.Single(result.CreatedCards); + Assert.Equal(1000, limits.UsedTokens(TenantGuid)); // списаний не было (платных вызовов нет) + CardDto card = Assert.Single(ctx.KanjStore.CardDtos); + Assert.Equal(KanbanColumns.Inbox, card.Col); // локальный путь не назначает смысловые колонки + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(ctx.PipelineStore.Rejected); + }); + } + + // ─── Контекст воркера ─────────────────────────────────────────────────── + + private sealed record Context( + PipelineWorkerService Worker, + FakePipelineStore PipelineStore, + FakeKanjStore KanjStore, + FakeSettingsStore Settings, + FakeTenantLimitStore Limits); + + private static Context CreateContext( + int port, + FakeTenantLimitStore? limits = null, + bool budgeted = false) + { + var settings = new FakeSettingsStore(); + var pipelineStore = new FakePipelineStore(); + var kanjStore = new FakeKanjStore(); + var mlClient = new FakeMlClient { Predict = NotReadyPrediction() }; + var rules = new IncomingRules(settings); + var fieldsParser = new LocalFieldsParser(settings); + + var tenantContext = new TenantContext(); + tenantContext.SetTenant(new TenantId(TenantIdValue)); + var connection = new AiGrpcConnection( + new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); + limits ??= new FakeTenantLimitStore(); + var grpcClassifier = new GrpcAiClassifier( + tenantContext, + connection, + new AiProviderConfigBuilder(settings, new FakeSecretCipher()), + new AiClassifyContextBuilder(settings, kanjStore), + new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(new FakeTokenUsageEventStore())), + NullLogger.Instance); + IAiClassifier aiClassifier = budgeted + ? new BudgetedAiClassifier( + grpcClassifier, + new LocalAiClassifier(fieldsParser), + limits, + tenantContext, + NullLogger.Instance) + : grpcClassifier; + + var processing = new PipelineProcessingService(pipelineStore, mlClient, new PipelineIngestService(pipelineStore)); + var composer = new CardComposer(kanjStore, settings); + var writer = new PipelineCardWriter(kanjStore, pipelineStore, composer); + var worker = new PipelineWorkerService( + pipelineStore, settings, rules, kanjStore, mlClient, aiClassifier, processing, writer, fieldsParser); + return new Context(worker, pipelineStore, kanjStore, settings, limits); + } + + // Строка очереди статуса filtered (уже прошла «new»-проход и ждёт ИИ). + // id: Id строки (p_…). + // text: Текст сообщения. + // Возвращает: Строка как если бы была переведена в filtered воркером. + private static QueueItemDto QueueRowFiltered(string id, string text) => SourceItemFactory.Queue( + id, + text, + PipelineQueueStatuses.Filtered, + msgAtMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + + // Неготовая модель: «не уверена» (ML-слот воркера не решает — сообщение уходит к ИИ). + // Возвращает: Фиксированный «не готов/не уверен». + private static MlPredictResultDto NotReadyPrediction() => new( + Take: false, + Label: null, + Scores: new Dictionary(), + Hits: 0, + Ready: false, + Margin: null, + Terms: Array.Empty(), + Type: null); +} diff --git a/src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj b/src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj index 98a5c9a..69b87bd 100644 --- a/src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj +++ b/src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj @@ -1,47 +1,47 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/core/tests/Deal.Tests.Unit/Grpc/RecordingAiService.cs b/src/core/tests/Deal.Tests.Unit/Grpc/RecordingAiService.cs index 5e7e83a..f7db2d9 100644 --- a/src/core/tests/Deal.Tests.Unit/Grpc/RecordingAiService.cs +++ b/src/core/tests/Deal.Tests.Unit/Grpc/RecordingAiService.cs @@ -1,165 +1,165 @@ -using Deal.Grpc.Ai; -using Grpc.Core; - -namespace Deal.Tests.Unit.Grpc; - -/// -/// In-proc фейк ai-service для тестов gRPC-адаптеров ядра -/// -public sealed class RecordingAiService : AiService.AiServiceBase -{ - /// - /// Ответ Filter по умолчанию - /// - public FilterReply FilterReply { get; set; } = new() { Pass = true }; - - /// - /// Ответ Classify по умолчанию - /// - public ClassifyReply ClassifyReply { get; set; } = new(); - - /// - /// Ответ GenerateKeywords по умолчанию - /// - public GenerateKeywordsReply GenerateKeywordsReply { get; set; } = new(); - - /// - /// Ответ EvaluateFit по умолчанию - /// - public EvaluateFitReply EvaluateFitReply { get; set; } = new() { Fit = true }; - - /// - /// Сбоить ли Filter статусом UNAVAILABLE - /// - public bool FilterUnavailable { get; set; } - - /// - /// Сбоить ли Classify статусом UNAVAILABLE. - /// - public bool ClassifyUnavailable { get; set; } - - /// - /// Сбоить ли GenerateKeywords статусом UNAVAILABLE. - /// - public bool KeywordsUnavailable { get; set; } - - /// - /// Сбоить ли EvaluateFit статусом UNAVAILABLE. - /// - public bool FitUnavailable { get; set; } - - /// - /// Число вызовов Filter. - /// - public int FilterCalls { get; private set; } - - /// - /// Число вызовов Classify. - /// - public int ClassifyCalls { get; private set; } - - /// - /// Число вызовов GenerateKeywords. - /// - public int KeywordsCalls { get; private set; } - - /// - /// Число вызовов EvaluateFit. - /// - public int FitCalls { get; private set; } - - /// - /// Последний запрос Filter - /// - public FilterRequest? LastFilter { get; private set; } - - /// - /// Последний запрос Classify - /// - public ClassifyRequest? LastClassify { get; private set; } - - /// - /// Последний запрос GenerateKeywords. - /// - public GenerateKeywordsRequest? LastKeywords { get; private set; } - - /// - /// Последний запрос EvaluateFit. - /// - public EvaluateFitRequest? LastFit { get; private set; } - - /// - /// tenant-id из metadata вызовов - /// - public List RequestTenantIds { get; } = []; - - /// - /// service-token из metadata вызовов - /// - public List RequestTokens { get; } = []; - - /// - public override Task Filter(FilterRequest request, ServerCallContext context) - { - FilterCalls++; - RecordMetadata(context); - if (FilterUnavailable) - { - throw Unavailable(); - } - - LastFilter = request; - return Task.FromResult(FilterReply); - } - - /// - public override Task Classify(ClassifyRequest request, ServerCallContext context) - { - ClassifyCalls++; - RecordMetadata(context); - if (ClassifyUnavailable) - { - throw Unavailable(); - } - - LastClassify = request; - return Task.FromResult(ClassifyReply); - } - - /// - public override Task GenerateKeywords(GenerateKeywordsRequest request, ServerCallContext context) - { - KeywordsCalls++; - RecordMetadata(context); - if (KeywordsUnavailable) - { - throw Unavailable(); - } - - LastKeywords = request; - return Task.FromResult(GenerateKeywordsReply); - } - - /// - public override Task EvaluateFit(EvaluateFitRequest request, ServerCallContext context) - { - FitCalls++; - RecordMetadata(context); - if (FitUnavailable) - { - throw Unavailable(); - } - - LastFit = request; - return Task.FromResult(EvaluateFitReply); - } - - private void RecordMetadata(ServerCallContext context) - { - RequestTenantIds.Add(context.RequestHeaders.GetValue("tenant-id") ?? string.Empty); - RequestTokens.Add(context.RequestHeaders.GetValue("service-token") ?? string.Empty); - } - - private static RpcException Unavailable() - => new(new Status(StatusCode.Unavailable, "ИИ (DeepSeek) не ответил корректно — повторите попытку через несколько секунд")); -} +using Deal.Grpc.Ai; +using Grpc.Core; + +namespace Deal.Tests.Unit.Grpc; + +/// +/// In-proc фейк ai-service для тестов gRPC-адаптеров ядра +/// +public sealed class RecordingAiService : AiService.AiServiceBase +{ + /// + /// Ответ Filter по умолчанию + /// + public FilterReply FilterReply { get; set; } = new() { Pass = true }; + + /// + /// Ответ Classify по умолчанию + /// + public ClassifyReply ClassifyReply { get; set; } = new(); + + /// + /// Ответ GenerateKeywords по умолчанию + /// + public GenerateKeywordsReply GenerateKeywordsReply { get; set; } = new(); + + /// + /// Ответ EvaluateFit по умолчанию + /// + public EvaluateFitReply EvaluateFitReply { get; set; } = new() { Fit = true }; + + /// + /// Сбоить ли Filter статусом UNAVAILABLE + /// + public bool FilterUnavailable { get; set; } + + /// + /// Сбоить ли Classify статусом UNAVAILABLE. + /// + public bool ClassifyUnavailable { get; set; } + + /// + /// Сбоить ли GenerateKeywords статусом UNAVAILABLE. + /// + public bool KeywordsUnavailable { get; set; } + + /// + /// Сбоить ли EvaluateFit статусом UNAVAILABLE. + /// + public bool FitUnavailable { get; set; } + + /// + /// Число вызовов Filter. + /// + public int FilterCalls { get; private set; } + + /// + /// Число вызовов Classify. + /// + public int ClassifyCalls { get; private set; } + + /// + /// Число вызовов GenerateKeywords. + /// + public int KeywordsCalls { get; private set; } + + /// + /// Число вызовов EvaluateFit. + /// + public int FitCalls { get; private set; } + + /// + /// Последний запрос Filter + /// + public FilterRequest? LastFilter { get; private set; } + + /// + /// Последний запрос Classify + /// + public ClassifyRequest? LastClassify { get; private set; } + + /// + /// Последний запрос GenerateKeywords. + /// + public GenerateKeywordsRequest? LastKeywords { get; private set; } + + /// + /// Последний запрос EvaluateFit. + /// + public EvaluateFitRequest? LastFit { get; private set; } + + /// + /// tenant-id из metadata вызовов + /// + public List RequestTenantIds { get; } = []; + + /// + /// service-token из metadata вызовов + /// + public List RequestTokens { get; } = []; + + /// + public override Task Filter(FilterRequest request, ServerCallContext context) + { + FilterCalls++; + RecordMetadata(context); + if (FilterUnavailable) + { + throw Unavailable(); + } + + LastFilter = request; + return Task.FromResult(FilterReply); + } + + /// + public override Task Classify(ClassifyRequest request, ServerCallContext context) + { + ClassifyCalls++; + RecordMetadata(context); + if (ClassifyUnavailable) + { + throw Unavailable(); + } + + LastClassify = request; + return Task.FromResult(ClassifyReply); + } + + /// + public override Task GenerateKeywords(GenerateKeywordsRequest request, ServerCallContext context) + { + KeywordsCalls++; + RecordMetadata(context); + if (KeywordsUnavailable) + { + throw Unavailable(); + } + + LastKeywords = request; + return Task.FromResult(GenerateKeywordsReply); + } + + /// + public override Task EvaluateFit(EvaluateFitRequest request, ServerCallContext context) + { + FitCalls++; + RecordMetadata(context); + if (FitUnavailable) + { + throw Unavailable(); + } + + LastFit = request; + return Task.FromResult(EvaluateFitReply); + } + + private void RecordMetadata(ServerCallContext context) + { + RequestTenantIds.Add(context.RequestHeaders.GetValue("tenant-id") ?? string.Empty); + RequestTokens.Add(context.RequestHeaders.GetValue("service-token") ?? string.Empty); + } + + private static RpcException Unavailable() + => new(new Status(StatusCode.Unavailable, "ИИ (DeepSeek) не ответил корректно — повторите попытку через несколько секунд")); +} diff --git a/src/core/tests/Deal.Tests.Unit/Grpc/RecordingMlService.cs b/src/core/tests/Deal.Tests.Unit/Grpc/RecordingMlService.cs index 33f0b8d..e35c2b3 100644 --- a/src/core/tests/Deal.Tests.Unit/Grpc/RecordingMlService.cs +++ b/src/core/tests/Deal.Tests.Unit/Grpc/RecordingMlService.cs @@ -1,142 +1,142 @@ -using Deal.Grpc.Ml; -using Grpc.Core; - -namespace Deal.Tests.Unit.Grpc; - -/// -/// In-proc фейк ml-service для тестов gRPC-клиента ядра -/// -public sealed class RecordingMlService : MlService.MlServiceBase -{ - /// - /// Ответ Predict по умолчанию - /// - public PredictReply PredictReply { get; set; } = new(); - - /// - /// Ответ Status по умолчанию - /// - public StatusReply StatusReply { get; set; } = new(); - - /// - /// Ответ Reset по умолчанию - /// - public ResetReply ResetReply { get; set; } = new() { Ok = true }; - - /// - /// Сбоить ли Predict статусом UNAVAILABLE - /// - public bool PredictUnavailable { get; set; } - - /// - /// Сбоить ли Status статусом UNAVAILABLE. - /// - public bool StatusUnavailable { get; set; } - - /// - /// Сбоить ли Reset статусом UNAVAILABLE. - /// - public bool ResetUnavailable { get; set; } - - /// - /// Сбоить ли TrainBatch статусом UNAVAILABLE. - /// - public bool TrainUnavailable { get; set; } - - /// - /// Число вызовов Predict. - /// - public int PredictCalls { get; private set; } - - /// - /// Число вызовов Status. - /// - public int StatusCalls { get; private set; } - - /// - /// Число вызовов Reset. - /// - public int ResetCalls { get; private set; } - - /// - /// Число вызовов TrainBatch. - /// - public int TrainCalls { get; private set; } - - /// - /// Полученные батчи обучения - /// - public List TrainBatches { get; } = []; - - /// - /// tenant-id из metadata вызовов - /// - public List RequestTenantIds { get; } = []; - - /// - /// service-token из metadata вызовов - /// - public List RequestTokens { get; } = []; - - /// - public override Task Predict(PredictRequest request, ServerCallContext context) - { - PredictCalls++; - RecordMetadata(context); - if (PredictUnavailable) - { - throw Unavailable(); - } - - return Task.FromResult(PredictReply); - } - - /// - public override Task Status(StatusRequest request, ServerCallContext context) - { - StatusCalls++; - RecordMetadata(context); - if (StatusUnavailable) - { - throw Unavailable(); - } - - return Task.FromResult(StatusReply); - } - - /// - public override Task Reset(ResetRequest request, ServerCallContext context) - { - ResetCalls++; - RecordMetadata(context); - if (ResetUnavailable) - { - throw Unavailable(); - } - - return Task.FromResult(ResetReply); - } - - /// - public override Task TrainBatch(TrainBatchRequest request, ServerCallContext context) - { - TrainCalls++; - RecordMetadata(context); - if (TrainUnavailable) - { - throw Unavailable(); - } - - TrainBatches.Add(request); - return Task.FromResult(new TrainBatchReply { Learned = request.Items.Count }); - } - - private void RecordMetadata(ServerCallContext context) - { - RequestTenantIds.Add(context.RequestHeaders.GetValue("tenant-id") ?? string.Empty); - RequestTokens.Add(context.RequestHeaders.GetValue("service-token") ?? string.Empty); - } - - private static RpcException Unavailable() - => new(new Status(StatusCode.Unavailable, "ml-service недоступен (тест)")); -} +using Deal.Grpc.Ml; +using Grpc.Core; + +namespace Deal.Tests.Unit.Grpc; + +/// +/// In-proc фейк ml-service для тестов gRPC-клиента ядра +/// +public sealed class RecordingMlService : MlService.MlServiceBase +{ + /// + /// Ответ Predict по умолчанию + /// + public PredictReply PredictReply { get; set; } = new(); + + /// + /// Ответ Status по умолчанию + /// + public StatusReply StatusReply { get; set; } = new(); + + /// + /// Ответ Reset по умолчанию + /// + public ResetReply ResetReply { get; set; } = new() { Ok = true }; + + /// + /// Сбоить ли Predict статусом UNAVAILABLE + /// + public bool PredictUnavailable { get; set; } + + /// + /// Сбоить ли Status статусом UNAVAILABLE. + /// + public bool StatusUnavailable { get; set; } + + /// + /// Сбоить ли Reset статусом UNAVAILABLE. + /// + public bool ResetUnavailable { get; set; } + + /// + /// Сбоить ли TrainBatch статусом UNAVAILABLE. + /// + public bool TrainUnavailable { get; set; } + + /// + /// Число вызовов Predict. + /// + public int PredictCalls { get; private set; } + + /// + /// Число вызовов Status. + /// + public int StatusCalls { get; private set; } + + /// + /// Число вызовов Reset. + /// + public int ResetCalls { get; private set; } + + /// + /// Число вызовов TrainBatch. + /// + public int TrainCalls { get; private set; } + + /// + /// Полученные батчи обучения + /// + public List TrainBatches { get; } = []; + + /// + /// tenant-id из metadata вызовов + /// + public List RequestTenantIds { get; } = []; + + /// + /// service-token из metadata вызовов + /// + public List RequestTokens { get; } = []; + + /// + public override Task Predict(PredictRequest request, ServerCallContext context) + { + PredictCalls++; + RecordMetadata(context); + if (PredictUnavailable) + { + throw Unavailable(); + } + + return Task.FromResult(PredictReply); + } + + /// + public override Task Status(StatusRequest request, ServerCallContext context) + { + StatusCalls++; + RecordMetadata(context); + if (StatusUnavailable) + { + throw Unavailable(); + } + + return Task.FromResult(StatusReply); + } + + /// + public override Task Reset(ResetRequest request, ServerCallContext context) + { + ResetCalls++; + RecordMetadata(context); + if (ResetUnavailable) + { + throw Unavailable(); + } + + return Task.FromResult(ResetReply); + } + + /// + public override Task TrainBatch(TrainBatchRequest request, ServerCallContext context) + { + TrainCalls++; + RecordMetadata(context); + if (TrainUnavailable) + { + throw Unavailable(); + } + + TrainBatches.Add(request); + return Task.FromResult(new TrainBatchReply { Learned = request.Items.Count }); + } + + private void RecordMetadata(ServerCallContext context) + { + RequestTenantIds.Add(context.RequestHeaders.GetValue("tenant-id") ?? string.Empty); + RequestTokens.Add(context.RequestHeaders.GetValue("service-token") ?? string.Empty); + } + + private static RpcException Unavailable() + => new(new Status(StatusCode.Unavailable, "ml-service недоступен (тест)")); +} diff --git a/src/core/tests/Deal.Tests.Unit/Grpc/RecordingTelegramService.cs b/src/core/tests/Deal.Tests.Unit/Grpc/RecordingTelegramService.cs index 09d562a..f1e4b37 100644 --- a/src/core/tests/Deal.Tests.Unit/Grpc/RecordingTelegramService.cs +++ b/src/core/tests/Deal.Tests.Unit/Grpc/RecordingTelegramService.cs @@ -1,197 +1,197 @@ -using Deal.Grpc.Telegram; -using Grpc.Core; - -namespace Deal.Tests.Unit.Grpc; - -/// -/// Фейк-сервер TelegramService -/// -public sealed class RecordingTelegramService : TelegramService.TelegramServiceBase -{ - /// - /// Последний запрос StartPhone - /// - public StartPhoneRequest? LastStartPhone { get; private set; } - - /// - /// Последний запрос StartQr - /// - public StartQrRequest? LastStartQr { get; private set; } - - /// - /// Запросы Backfill - /// - public List BackfillRequests { get; } = []; - - /// - /// Запросы ReadRecent - /// - public List ReadRecentRequests { get; } = []; - - /// - /// Запросы ReadSource - /// - public List ReadSourceRequests { get; } = []; - - /// - /// found ответа ReadSource - /// - public bool ReadSourceFound { get; set; } - - /// - /// text ответа ReadSource (null — поле не задано) - /// - public string? ReadSourceText { get; set; } - - /// - /// time ответа ReadSource (null — поле не задано) - /// - public long? ReadSourceTime { get; set; } - - /// - /// Каталог ответа RefreshDialogs - /// - public List Catalog { get; } = []; - - /// - /// Сколько сообщений «разобрал» Backfill - /// - public int BackfillProcessed { get; set; } = 5; - - /// - /// Доменный сбой следующего вызова - /// - public RpcException? DomainFailure { get; set; } - - /// - /// Фаза/поля ответа GetStatus. - /// - public string StatusPhase { get; set; } = "idle"; - - /// - /// Аккаунт ответа GetStatus - /// - public string StatusAccount { get; set; } = string.Empty; - - /// - /// Connected ответа GetStatus. - /// - public bool StatusConnected { get; set; } - - /// - /// Listener ответа GetStatus. - /// - public bool StatusListener { get; set; } - - /// - /// qrUrl ответа GetStatus/StartQr - /// - public string QrUrl { get; set; } = string.Empty; - - /// - /// error ответа GetStatus - /// - public string StatusError { get; set; } = string.Empty; - - /// - public override Task GetStatus(GetStatusRequest request, ServerCallContext context) - { - ThrowIfDomainFailure(context); - var reply = new GetStatusReply - { - Phase = StatusPhase, - Connected = StatusConnected, - Listener = StatusListener, - Account = StatusAccount, - }; - if (StatusError.Length > 0) - { - reply.Error = StatusError; - } - - if (QrUrl.Length > 0) - { - reply.QrUrl = QrUrl; - } - - return Task.FromResult(reply); - } - - /// - public override Task StartPhone(StartPhoneRequest request, ServerCallContext context) - { - ThrowIfDomainFailure(context); - LastStartPhone = request; - return Task.FromResult(new StartPhoneReply { Phase = "code" }); - } - - /// - public override Task StartQr(StartQrRequest request, ServerCallContext context) - { - ThrowIfDomainFailure(context); - LastStartQr = request; - var reply = new StartQrReply { Phase = "qr" }; - if (QrUrl.Length > 0) - { - reply.QrUrl = QrUrl; - } - - return Task.FromResult(reply); - } - - /// - public override Task RefreshDialogs(RefreshDialogsRequest request, ServerCallContext context) - { - ThrowIfDomainFailure(context); - var reply = new RefreshDialogsReply(); - reply.Entries.AddRange(Catalog); - return Task.FromResult(reply); - } - - /// - public override Task Backfill(BackfillRequest request, ServerCallContext context) - { - ThrowIfDomainFailure(context); - BackfillRequests.Add(request); - return Task.FromResult(new BackfillReply { Processed = BackfillProcessed }); - } - - /// - public override Task ReadRecent(ReadRecentRequest request, ServerCallContext context) - { - ThrowIfDomainFailure(context); - ReadRecentRequests.Add(request); - var reply = new ReadRecentReply(); - reply.Messages.Add(new PreviewMessage { Id = "100500", Text = "Свежее", Time = 1700000000000 }); - return Task.FromResult(reply); - } - - /// - public override Task ReadSource(ReadSourceRequest request, ServerCallContext context) - { - ThrowIfDomainFailure(context); - ReadSourceRequests.Add(request); - var reply = new ReadSourceReply { Found = ReadSourceFound }; - if (ReadSourceText is not null) - { - reply.Text = ReadSourceText; - } - - if (ReadSourceTime is not null) - { - reply.Time = ReadSourceTime.Value; - } - - return Task.FromResult(reply); - } - - // Бросает доменный сбой сценария (если задан) — как RPC-ошибка telegram-service. - // context: Контекст вызова (не используется, но держит сигнатуру единообразной). - private void ThrowIfDomainFailure(ServerCallContext context) - { - if (DomainFailure is not null) - { - throw DomainFailure; - } - } -} +using Deal.Grpc.Telegram; +using Grpc.Core; + +namespace Deal.Tests.Unit.Grpc; + +/// +/// Фейк-сервер TelegramService +/// +public sealed class RecordingTelegramService : TelegramService.TelegramServiceBase +{ + /// + /// Последний запрос StartPhone + /// + public StartPhoneRequest? LastStartPhone { get; private set; } + + /// + /// Последний запрос StartQr + /// + public StartQrRequest? LastStartQr { get; private set; } + + /// + /// Запросы Backfill + /// + public List BackfillRequests { get; } = []; + + /// + /// Запросы ReadRecent + /// + public List ReadRecentRequests { get; } = []; + + /// + /// Запросы ReadSource + /// + public List ReadSourceRequests { get; } = []; + + /// + /// found ответа ReadSource + /// + public bool ReadSourceFound { get; set; } + + /// + /// text ответа ReadSource (null — поле не задано) + /// + public string? ReadSourceText { get; set; } + + /// + /// time ответа ReadSource (null — поле не задано) + /// + public long? ReadSourceTime { get; set; } + + /// + /// Каталог ответа RefreshDialogs + /// + public List Catalog { get; } = []; + + /// + /// Сколько сообщений «разобрал» Backfill + /// + public int BackfillProcessed { get; set; } = 5; + + /// + /// Доменный сбой следующего вызова + /// + public RpcException? DomainFailure { get; set; } + + /// + /// Фаза/поля ответа GetStatus. + /// + public string StatusPhase { get; set; } = "idle"; + + /// + /// Аккаунт ответа GetStatus + /// + public string StatusAccount { get; set; } = string.Empty; + + /// + /// Connected ответа GetStatus. + /// + public bool StatusConnected { get; set; } + + /// + /// Listener ответа GetStatus. + /// + public bool StatusListener { get; set; } + + /// + /// qrUrl ответа GetStatus/StartQr + /// + public string QrUrl { get; set; } = string.Empty; + + /// + /// error ответа GetStatus + /// + public string StatusError { get; set; } = string.Empty; + + /// + public override Task GetStatus(GetStatusRequest request, ServerCallContext context) + { + ThrowIfDomainFailure(context); + var reply = new GetStatusReply + { + Phase = StatusPhase, + Connected = StatusConnected, + Listener = StatusListener, + Account = StatusAccount, + }; + if (StatusError.Length > 0) + { + reply.Error = StatusError; + } + + if (QrUrl.Length > 0) + { + reply.QrUrl = QrUrl; + } + + return Task.FromResult(reply); + } + + /// + public override Task StartPhone(StartPhoneRequest request, ServerCallContext context) + { + ThrowIfDomainFailure(context); + LastStartPhone = request; + return Task.FromResult(new StartPhoneReply { Phase = "code" }); + } + + /// + public override Task StartQr(StartQrRequest request, ServerCallContext context) + { + ThrowIfDomainFailure(context); + LastStartQr = request; + var reply = new StartQrReply { Phase = "qr" }; + if (QrUrl.Length > 0) + { + reply.QrUrl = QrUrl; + } + + return Task.FromResult(reply); + } + + /// + public override Task RefreshDialogs(RefreshDialogsRequest request, ServerCallContext context) + { + ThrowIfDomainFailure(context); + var reply = new RefreshDialogsReply(); + reply.Entries.AddRange(Catalog); + return Task.FromResult(reply); + } + + /// + public override Task Backfill(BackfillRequest request, ServerCallContext context) + { + ThrowIfDomainFailure(context); + BackfillRequests.Add(request); + return Task.FromResult(new BackfillReply { Processed = BackfillProcessed }); + } + + /// + public override Task ReadRecent(ReadRecentRequest request, ServerCallContext context) + { + ThrowIfDomainFailure(context); + ReadRecentRequests.Add(request); + var reply = new ReadRecentReply(); + reply.Messages.Add(new PreviewMessage { Id = "100500", Text = "Свежее", Time = 1700000000000 }); + return Task.FromResult(reply); + } + + /// + public override Task ReadSource(ReadSourceRequest request, ServerCallContext context) + { + ThrowIfDomainFailure(context); + ReadSourceRequests.Add(request); + var reply = new ReadSourceReply { Found = ReadSourceFound }; + if (ReadSourceText is not null) + { + reply.Text = ReadSourceText; + } + + if (ReadSourceTime is not null) + { + reply.Time = ReadSourceTime.Value; + } + + return Task.FromResult(reply); + } + + // Бросает доменный сбой сценария (если задан) — как RPC-ошибка telegram-service. + // context: Контекст вызова (не используется, но держит сигнатуру единообразной). + private void ThrowIfDomainFailure(ServerCallContext context) + { + if (DomainFailure is not null) + { + throw DomainFailure; + } + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/AuditLogStoreTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/AuditLogStoreTests.cs index b0fd7c9..479b680 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/AuditLogStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/AuditLogStoreTests.cs @@ -1,143 +1,143 @@ -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Юнит-тесты EF-адаптера на InMemory-провайдере -/// -public sealed class AuditLogStoreTests -{ - [Fact] - public async Task AppendAsync_ThenQueryAll_ReturnsMappedRecord() - { - var store = CreateStore(); - var actorId = Guid.NewGuid(); - var tenantId = Guid.NewGuid(); - var at = DateTimeOffset.UtcNow; - - await store.AppendAsync(new AuditRecordDto( - AuditEvents.TenantLoginOk, - AuditActorTypes.Tenant, - ActorId: actorId, - TenantId: tenantId, - Ip: "127.0.0.1", - DetailJson: """{"login":"admin"}""", - At: at), CancellationToken.None); - - var records = await store.QueryAsync(EmptyFilter(100), CancellationToken.None); - var record = Assert.Single(records); - Assert.Equal(AuditEvents.TenantLoginOk, record.EventType); - Assert.Equal(AuditActorTypes.Tenant, record.ActorType); - Assert.Equal(actorId, record.ActorId); - Assert.Equal(tenantId, record.TenantId); - Assert.Equal("127.0.0.1", record.Ip); - Assert.Equal("""{"login":"admin"}""", record.DetailJson); - Assert.Equal(at, record.At); - } - - [Fact] - public async Task QueryAsync_OrdersByAtDescending_AndAppliesFilters() - { - var store = CreateStore(); - var tenantId = Guid.NewGuid(); - var older = DateTimeOffset.UtcNow.AddMinutes(-10); - var middle = DateTimeOffset.UtcNow.AddMinutes(-5); - var newer = DateTimeOffset.UtcNow; - await store.AppendAsync(Record(AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, at: older), CancellationToken.None); - await store.AppendAsync(Record(AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, tenantId, middle), CancellationToken.None); - await store.AppendAsync(Record(AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId, newer), CancellationToken.None); - - var all = await store.QueryAsync(EmptyFilter(100), CancellationToken.None); - Assert.Equal(3, all.Count); - Assert.Equal(AuditEvents.TenantLoginOk, all[0].EventType); - Assert.Equal(AuditEvents.TenantLoginFailed, all[1].EventType); - Assert.Equal(AuditEvents.OperatorLoginOk, all[2].EventType); - - var byTenant = await store.QueryAsync( - new AuditQueryDto(null, null, tenantId, null, null, 100), CancellationToken.None); - Assert.Equal(2, byTenant.Count); - - var byEvent = await store.QueryAsync( - new AuditQueryDto(AuditEvents.TenantLoginFailed, null, null, null, null, 100), CancellationToken.None); - Assert.Equal(AuditEvents.TenantLoginFailed, Assert.Single(byEvent).EventType); - - var byRange = await store.QueryAsync( - new AuditQueryDto(null, null, null, middle, newer, 100), CancellationToken.None); - Assert.Equal(2, byRange.Count); - - var byActor = await store.QueryAsync( - new AuditQueryDto(null, AuditActorTypes.Tenant, null, null, null, 100), CancellationToken.None); - Assert.Equal(2, byActor.Count); - } - - [Fact] - public async Task CountAsync_CountsAllRowsMatchingFilterRegardlessOfLimit() - { - var store = CreateStore(); - for (int i = 0; i < 5; i++) - { - await store.AppendAsync(Record(AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow), CancellationToken.None); - } - - var filter = new AuditQueryDto(AuditEvents.OperatorLoginOk, null, null, null, null, Limit: 1); - Assert.Equal(5, await store.CountAsync(filter, CancellationToken.None)); - Assert.Single(await store.QueryAsync(filter, CancellationToken.None)); - } - - [Fact] - public async Task QueryAsync_ClampsLimitToFiveHundred() - { - var store = CreateStore(); - for (int i = 0; i < AuditService.MaxQueryLimit + 5; i++) - { - await store.AppendAsync(Record(AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow), CancellationToken.None); - } - - var records = await store.QueryAsync(EmptyFilter(int.MaxValue), CancellationToken.None); - - Assert.Equal(AuditService.MaxQueryLimit, records.Count); - } - - /// - /// Авто-очистка: удаляет только записи старше границы, свежие остаются. - /// - [Fact] - public async Task PurgeOlderThanAsync_RemovesOnlyRecordsOlderThanCutoff() - { - var store = CreateStore(); - var old = DateTimeOffset.UtcNow.AddDays(-200); - var recent = DateTimeOffset.UtcNow.AddDays(-10); - await store.AppendAsync(Record(AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, old), CancellationToken.None); - await store.AppendAsync(Record(AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId: Guid.NewGuid(), recent), CancellationToken.None); - - int removed = await store.PurgeOlderThanAsync(DateTimeOffset.UtcNow.AddDays(-180), CancellationToken.None); - - Assert.Equal(1, removed); - var remaining = Assert.Single(await store.QueryAsync(EmptyFilter(100), CancellationToken.None)); - Assert.Equal(AuditEvents.TenantLoginOk, remaining.EventType); - } - - // Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются). - private static AuditLogStore CreateStore() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) - .Options; - return new AuditLogStore(new DealDbContext(options)); - } - - // Запись аудита с деталями по умолчанию. - private static AuditRecordDto Record( - string eventType, - string actorType, - Guid? tenantId, - DateTimeOffset at) => - new(eventType, actorType, ActorId: null, tenantId, Ip: "127.0.0.1", DetailJson: null, at); - - private static AuditQueryDto EmptyFilter(int limit) => - new(EventType: null, ActorType: null, TenantId: null, From: null, To: null, Limit: limit); -} +using Deal.Infrastructure.Persistence; +using Deal.Infrastructure.Persistence.Repositories; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Tests.Unit.Infrastructure; + +/// +/// Юнит-тесты EF-адаптера на InMemory-провайдере +/// +public sealed class AuditLogStoreTests +{ + [Fact] + public async Task AppendAsync_ThenQueryAll_ReturnsMappedRecord() + { + var store = CreateStore(); + var actorId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var at = DateTimeOffset.UtcNow; + + await store.AppendAsync(new AuditRecordDto( + AuditEvents.TenantLoginOk, + AuditActorTypes.Tenant, + ActorId: actorId, + TenantId: tenantId, + Ip: "127.0.0.1", + DetailJson: """{"login":"admin"}""", + At: at), CancellationToken.None); + + var records = await store.QueryAsync(EmptyFilter(100), CancellationToken.None); + var record = Assert.Single(records); + Assert.Equal(AuditEvents.TenantLoginOk, record.EventType); + Assert.Equal(AuditActorTypes.Tenant, record.ActorType); + Assert.Equal(actorId, record.ActorId); + Assert.Equal(tenantId, record.TenantId); + Assert.Equal("127.0.0.1", record.Ip); + Assert.Equal("""{"login":"admin"}""", record.DetailJson); + Assert.Equal(at, record.At); + } + + [Fact] + public async Task QueryAsync_OrdersByAtDescending_AndAppliesFilters() + { + var store = CreateStore(); + var tenantId = Guid.NewGuid(); + var older = DateTimeOffset.UtcNow.AddMinutes(-10); + var middle = DateTimeOffset.UtcNow.AddMinutes(-5); + var newer = DateTimeOffset.UtcNow; + await store.AppendAsync(Record(AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, at: older), CancellationToken.None); + await store.AppendAsync(Record(AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, tenantId, middle), CancellationToken.None); + await store.AppendAsync(Record(AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId, newer), CancellationToken.None); + + var all = await store.QueryAsync(EmptyFilter(100), CancellationToken.None); + Assert.Equal(3, all.Count); + Assert.Equal(AuditEvents.TenantLoginOk, all[0].EventType); + Assert.Equal(AuditEvents.TenantLoginFailed, all[1].EventType); + Assert.Equal(AuditEvents.OperatorLoginOk, all[2].EventType); + + var byTenant = await store.QueryAsync( + new AuditQueryDto(null, null, tenantId, null, null, 100), CancellationToken.None); + Assert.Equal(2, byTenant.Count); + + var byEvent = await store.QueryAsync( + new AuditQueryDto(AuditEvents.TenantLoginFailed, null, null, null, null, 100), CancellationToken.None); + Assert.Equal(AuditEvents.TenantLoginFailed, Assert.Single(byEvent).EventType); + + var byRange = await store.QueryAsync( + new AuditQueryDto(null, null, null, middle, newer, 100), CancellationToken.None); + Assert.Equal(2, byRange.Count); + + var byActor = await store.QueryAsync( + new AuditQueryDto(null, AuditActorTypes.Tenant, null, null, null, 100), CancellationToken.None); + Assert.Equal(2, byActor.Count); + } + + [Fact] + public async Task CountAsync_CountsAllRowsMatchingFilterRegardlessOfLimit() + { + var store = CreateStore(); + for (int i = 0; i < 5; i++) + { + await store.AppendAsync(Record(AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow), CancellationToken.None); + } + + var filter = new AuditQueryDto(AuditEvents.OperatorLoginOk, null, null, null, null, Limit: 1); + Assert.Equal(5, await store.CountAsync(filter, CancellationToken.None)); + Assert.Single(await store.QueryAsync(filter, CancellationToken.None)); + } + + [Fact] + public async Task QueryAsync_ClampsLimitToFiveHundred() + { + var store = CreateStore(); + for (int i = 0; i < AuditService.MaxQueryLimit + 5; i++) + { + await store.AppendAsync(Record(AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow), CancellationToken.None); + } + + var records = await store.QueryAsync(EmptyFilter(int.MaxValue), CancellationToken.None); + + Assert.Equal(AuditService.MaxQueryLimit, records.Count); + } + + /// + /// Авто-очистка: удаляет только записи старше границы, свежие остаются. + /// + [Fact] + public async Task PurgeOlderThanAsync_RemovesOnlyRecordsOlderThanCutoff() + { + var store = CreateStore(); + var old = DateTimeOffset.UtcNow.AddDays(-200); + var recent = DateTimeOffset.UtcNow.AddDays(-10); + await store.AppendAsync(Record(AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, old), CancellationToken.None); + await store.AppendAsync(Record(AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId: Guid.NewGuid(), recent), CancellationToken.None); + + int removed = await store.PurgeOlderThanAsync(DateTimeOffset.UtcNow.AddDays(-180), CancellationToken.None); + + Assert.Equal(1, removed); + var remaining = Assert.Single(await store.QueryAsync(EmptyFilter(100), CancellationToken.None)); + Assert.Equal(AuditEvents.TenantLoginOk, remaining.EventType); + } + + // Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются). + private static AuditLogStore CreateStore() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options; + return new AuditLogStore(new DealDbContext(options)); + } + + // Запись аудита с деталями по умолчанию. + private static AuditRecordDto Record( + string eventType, + string actorType, + Guid? tenantId, + DateTimeOffset at) => + new(eventType, actorType, ActorId: null, tenantId, Ip: "127.0.0.1", DetailJson: null, at); + + private static AuditQueryDto EmptyFilter(int limit) => + new(EventType: null, ActorType: null, TenantId: null, From: null, To: null, Limit: limit); +} diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs index a4ab322..b0760a3 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs @@ -1,87 +1,87 @@ -using Deal.Infrastructure.Services; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Тесты единой точки перехода карточки -/// -public sealed class CardMoverTests -{ - private static readonly TransitionContext UserMove = new() { Actor = "user", Learn = true }; - - [Fact] - public async Task Move_ToStage_RoutesToStageMoveAndResetsReminder() - { - (CardMover mover, FakeKanjStore store) = Create(); - store.SeedCard(new CardDto - { - Id = "c_1", - Col = "inbox", - Title = "Заказ", - Reminder = new CardReminderDto(1000), - }); - - CardMoveResultDto result = await mover.MoveAsync("c_1", CardsDefaultContainers.Planned, UserMove, CancellationToken.None); - - Assert.Null(result.Error); - Assert.True(result.Exists); - CardDto card = Assert.Single(store.CardDtos); - Assert.Equal(CardsDefaultContainers.Planned, card.Col); - Assert.Null(card.Reminder); // move по стадии сбрасывает напоминание - CardHistoryDto entry = Assert.Single(card.History); - Assert.Equal(CardsDefaultContainers.Planned, entry.Stage); // запись истории о переносе - } - - [Fact] - public async Task Move_ToBoard_RoutesToDashboard() - { - (CardMover mover, FakeKanjStore store) = Create(); - store.SeedBoard(new ContainerDto { Id = "b_py", Name = "Python" }); - store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, Content = new SourceContent { Text = "Нужен Python" } }); - - CardMoveResultDto result = await mover.MoveAsync("c_1", "b_py", UserMove, CancellationToken.None); - - Assert.Null(result.Error); - Assert.True(result.Exists); - Assert.Equal("b_py", Assert.Single(store.CardDtos).Col); - Assert.Single(store.Moves); // журнал CardMoves дашборд-переноса - } - - [Fact] - public async Task Move_UnknownTarget_ReturnsDashboardInvalidTargetError() - { - (CardMover mover, FakeKanjStore store) = Create(); - store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, Content = new SourceContent { Text = "Текст" } }); - - CardMoveResultDto result = await mover.MoveAsync("c_1", "b_ghost", UserMove, CancellationToken.None); - - Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error); - Assert.True(result.Exists); // ошибка важнее признака наличия - } - - [Fact] - public async Task Move_ToStage_CardMissing_ReportsNotFound() - { - (CardMover mover, _) = Create(); - - CardMoveResultDto result = await mover.MoveAsync("c_ghost", CardsDefaultContainers.Planned, UserMove, CancellationToken.None); - - Assert.Null(result.Error); - Assert.False(result.Exists); - } - - private static (CardMover Mover, FakeKanjStore Store) Create() - { - var store = new FakeKanjStore(); - var cardsService = new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), new FakeFileStorage()); - return (new CardMover(cardsService), store); - } -} +using Deal.Infrastructure.Services; +using Deal.Modules.Cards.Application.Dtos; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Settings; + +namespace Deal.Tests.Unit.Infrastructure; + +/// +/// Тесты единой точки перехода карточки +/// +public sealed class CardMoverTests +{ + private static readonly TransitionContext UserMove = new() { Actor = "user", Learn = true }; + + [Fact] + public async Task Move_ToStage_RoutesToStageMoveAndResetsReminder() + { + (CardMover mover, FakeKanjStore store) = Create(); + store.SeedCard(new CardDto + { + Id = "c_1", + Col = "inbox", + Title = "Заказ", + Reminder = new CardReminderDto(1000), + }); + + CardMoveResultDto result = await mover.MoveAsync("c_1", CardsDefaultContainers.Planned, UserMove, CancellationToken.None); + + Assert.Null(result.Error); + Assert.True(result.Exists); + CardDto card = Assert.Single(store.CardDtos); + Assert.Equal(CardsDefaultContainers.Planned, card.Col); + Assert.Null(card.Reminder); // move по стадии сбрасывает напоминание + CardHistoryDto entry = Assert.Single(card.History); + Assert.Equal(CardsDefaultContainers.Planned, entry.Stage); // запись истории о переносе + } + + [Fact] + public async Task Move_ToBoard_RoutesToDashboard() + { + (CardMover mover, FakeKanjStore store) = Create(); + store.SeedBoard(new ContainerDto { Id = "b_py", Name = "Python" }); + store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, Content = new SourceContent { Text = "Нужен Python" } }); + + CardMoveResultDto result = await mover.MoveAsync("c_1", "b_py", UserMove, CancellationToken.None); + + Assert.Null(result.Error); + Assert.True(result.Exists); + Assert.Equal("b_py", Assert.Single(store.CardDtos).Col); + Assert.Single(store.Moves); // журнал CardMoves дашборд-переноса + } + + [Fact] + public async Task Move_UnknownTarget_ReturnsDashboardInvalidTargetError() + { + (CardMover mover, FakeKanjStore store) = Create(); + store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, Content = new SourceContent { Text = "Текст" } }); + + CardMoveResultDto result = await mover.MoveAsync("c_1", "b_ghost", UserMove, CancellationToken.None); + + Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error); + Assert.True(result.Exists); // ошибка важнее признака наличия + } + + [Fact] + public async Task Move_ToStage_CardMissing_ReportsNotFound() + { + (CardMover mover, _) = Create(); + + CardMoveResultDto result = await mover.MoveAsync("c_ghost", CardsDefaultContainers.Planned, UserMove, CancellationToken.None); + + Assert.Null(result.Error); + Assert.False(result.Exists); + } + + private static (CardMover Mover, FakeKanjStore Store) Create() + { + var store = new FakeKanjStore(); + var cardsService = new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), new FakeFileStorage()); + return (new CardMover(cardsService), store); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/GlobalSettingsStoreTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/GlobalSettingsStoreTests.cs index bfb50d5..08945ff 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/GlobalSettingsStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/GlobalSettingsStoreTests.cs @@ -1,56 +1,56 @@ -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Settings.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Юнит-тесты EF-адаптера на InMemory-провайдере -/// -public sealed class GlobalSettingsStoreTests -{ - [Fact] - public async Task GetAsync_MissingKey_ReturnsNull() - { - GlobalSettingsStore store = CreateStore(); - - SettingValue? value = await store.GetAsync(GlobalSettingsKeys.TelegramKeys, CancellationToken.None); - - Assert.Null(value); - } - - [Fact] - public async Task SetAsync_NewKey_CreatesRowWithUtcTimestamp() - { - GlobalSettingsStore store = CreateStore(); - - await store.SetAsync(GlobalSettingsKeys.TelegramKeys, "{\"apiId\":\"1234567\"}", CancellationToken.None); - - SettingValue value = (await store.GetAsync(GlobalSettingsKeys.TelegramKeys, CancellationToken.None))!; - Assert.Equal("{\"apiId\":\"1234567\"}", value.ValueJson); - Assert.True(value.UpdatedAt > DateTimeOffset.UtcNow.AddMinutes(-1)); - } - - [Fact] - public async Task SetAsync_ExistingKey_OverwritesValue() - { - GlobalSettingsStore store = CreateStore(); - await store.SetAsync(GlobalSettingsKeys.TelegramKeys, "{\"apiId\":\"1234567\"}", CancellationToken.None); - - await store.SetAsync(GlobalSettingsKeys.TelegramKeys, "{\"apiId\":\"7654321\"}", CancellationToken.None); - - SettingValue value = (await store.GetAsync(GlobalSettingsKeys.TelegramKeys, CancellationToken.None))!; - Assert.Equal("{\"apiId\":\"7654321\"}", value.ValueJson); - } - - // Создаёт адаптер на свежем InMemory-контексте. - // Возвращает: Адаптер глобального KV-хранилища. - private static GlobalSettingsStore CreateStore() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) - .Options; - return new GlobalSettingsStore(new DealDbContext(options)); - } -} +using Deal.Infrastructure.Persistence; +using Deal.Infrastructure.Persistence.Repositories; +using Deal.Modules.Settings.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Tests.Unit.Infrastructure; + +/// +/// Юнит-тесты EF-адаптера на InMemory-провайдере +/// +public sealed class GlobalSettingsStoreTests +{ + [Fact] + public async Task GetAsync_MissingKey_ReturnsNull() + { + GlobalSettingsStore store = CreateStore(); + + SettingValue? value = await store.GetAsync(GlobalSettingsKeys.TelegramKeys, CancellationToken.None); + + Assert.Null(value); + } + + [Fact] + public async Task SetAsync_NewKey_CreatesRowWithUtcTimestamp() + { + GlobalSettingsStore store = CreateStore(); + + await store.SetAsync(GlobalSettingsKeys.TelegramKeys, "{\"apiId\":\"1234567\"}", CancellationToken.None); + + SettingValue value = (await store.GetAsync(GlobalSettingsKeys.TelegramKeys, CancellationToken.None))!; + Assert.Equal("{\"apiId\":\"1234567\"}", value.ValueJson); + Assert.True(value.UpdatedAt > DateTimeOffset.UtcNow.AddMinutes(-1)); + } + + [Fact] + public async Task SetAsync_ExistingKey_OverwritesValue() + { + GlobalSettingsStore store = CreateStore(); + await store.SetAsync(GlobalSettingsKeys.TelegramKeys, "{\"apiId\":\"1234567\"}", CancellationToken.None); + + await store.SetAsync(GlobalSettingsKeys.TelegramKeys, "{\"apiId\":\"7654321\"}", CancellationToken.None); + + SettingValue value = (await store.GetAsync(GlobalSettingsKeys.TelegramKeys, CancellationToken.None))!; + Assert.Equal("{\"apiId\":\"7654321\"}", value.ValueJson); + } + + // Создаёт адаптер на свежем InMemory-контексте. + // Возвращает: Адаптер глобального KV-хранилища. + private static GlobalSettingsStore CreateStore() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options; + return new GlobalSettingsStore(new DealDbContext(options)); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/InviteStoreTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/InviteStoreTests.cs index f227764..67811c6 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/InviteStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/InviteStoreTests.cs @@ -1,120 +1,120 @@ -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Юнит-тесты EF-адаптера на InMemory-провайдере -/// -public sealed class InviteStoreTests -{ - [Fact] - public async Task Create_ThenGetByCode_ReturnsMappedInvite() - { - var store = CreateStore(); - var invite = NewInvite("code-12345678", "user@example.com", Guid.NewGuid()); - - await store.CreateAsync(invite, CancellationToken.None); - - var found = await store.GetByCodeAsync("code-12345678", CancellationToken.None); - Assert.NotNull(found); - Assert.Equal(invite.Code, found!.Code); - Assert.Equal(invite.Email, found.Email); - Assert.Equal(invite.TenantId, found.TenantId); - Assert.Equal(invite.Status, found.Status); - Assert.Equal(invite.ExpiresAt, found.ExpiresAt); - Assert.Equal(invite.ActivatedAt, found.ActivatedAt); - Assert.Equal(invite.CreatedById, found.CreatedById); - Assert.Equal(invite.CreatedAt, found.CreatedAt); - } - - [Fact] - public async Task List_ReturnsInvitesNewestFirst() - { - var store = CreateStore(); - var older = NewInvite("older-code", "older@example.com", tenantId: null, createdAt: DateTimeOffset.UtcNow.AddDays(-2)); - var newer = NewInvite("newer-code", "newer@example.com", tenantId: null, createdAt: DateTimeOffset.UtcNow.AddDays(-1)); - await store.CreateAsync(older, CancellationToken.None); - await store.CreateAsync(newer, CancellationToken.None); - - IReadOnlyList items = await store.ListAsync(CancellationToken.None); - - Assert.Equal(new[] { "newer-code", "older-code" }, items.Select(i => i.Code)); - } - - [Fact] - public async Task UpdateStatus_ChangesStatusAndActivatedAt() - { - var store = CreateStore(); - var activatedAt = DateTimeOffset.UtcNow.AddHours(-1); - await store.CreateAsync(NewInvite("code-1", "user@example.com", Guid.NewGuid()), CancellationToken.None); - - bool updated = await store.UpdateStatusAsync("code-1", InviteStatuses.Activated, activatedAt, CancellationToken.None); - - Assert.True(updated); - var found = await store.GetByCodeAsync("code-1", CancellationToken.None); - Assert.Equal(InviteStatuses.Activated, found!.Status); - Assert.Equal(activatedAt, found.ActivatedAt); - } - - [Fact] - public async Task UpdateStatus_WithUnknownCode_ReturnsFalse() - { - var store = CreateStore(); - - bool updated = await store.UpdateStatusAsync("no-such-code", InviteStatuses.Revoked, null, CancellationToken.None); - - Assert.False(updated); - } - - [Fact] - public async Task FindActiveByEmail_ReturnsOnlyPendingInvite() - { - var store = CreateStore(); - await store.CreateAsync(NewInvite("pending-code", "user@example.com", tenantId: null), CancellationToken.None); - await store.CreateAsync(NewInvite("revoked-code", "revoked@example.com", tenantId: null, status: InviteStatuses.Revoked), CancellationToken.None); - await store.CreateAsync(NewInvite("activated-code", "activated@example.com", tenantId: null, status: InviteStatuses.Activated), CancellationToken.None); - - Assert.Equal("pending-code", (await store.FindActiveByEmailAsync("user@example.com", CancellationToken.None))!.Code); - Assert.Null(await store.FindActiveByEmailAsync("revoked@example.com", CancellationToken.None)); - Assert.Null(await store.FindActiveByEmailAsync("activated@example.com", CancellationToken.None)); - Assert.Null(await store.FindActiveByEmailAsync("unknown@example.com", CancellationToken.None)); - } - - [Fact] - public async Task GetByCode_WithUnknownCode_ReturnsNull() - { - var store = CreateStore(); - - Assert.Null(await store.GetByCodeAsync("no-such-code", CancellationToken.None)); - } - - // Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются). - private static InviteStore CreateStore() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) - .Options; - return new InviteStore(new DealDbContext(options)); - } - - // Создаёт приглашение-строку для теста (дефолт — живой pending). - private static InviteDto NewInvite( - string code, - string email, - Guid? tenantId, - string status = InviteStatuses.Pending, - DateTimeOffset? createdAt = null) => - new( - code, - email, - tenantId, - status, - DateTimeOffset.UtcNow.AddHours(InvitesService.ExpiryHours), - ActivatedAt: null, - CreatedById: Guid.NewGuid(), - createdAt ?? DateTimeOffset.UtcNow); -} +using Deal.Infrastructure.Persistence; +using Deal.Infrastructure.Persistence.Repositories; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Tests.Unit.Infrastructure; + +/// +/// Юнит-тесты EF-адаптера на InMemory-провайдере +/// +public sealed class InviteStoreTests +{ + [Fact] + public async Task Create_ThenGetByCode_ReturnsMappedInvite() + { + var store = CreateStore(); + var invite = NewInvite("code-12345678", "user@example.com", Guid.NewGuid()); + + await store.CreateAsync(invite, CancellationToken.None); + + var found = await store.GetByCodeAsync("code-12345678", CancellationToken.None); + Assert.NotNull(found); + Assert.Equal(invite.Code, found!.Code); + Assert.Equal(invite.Email, found.Email); + Assert.Equal(invite.TenantId, found.TenantId); + Assert.Equal(invite.Status, found.Status); + Assert.Equal(invite.ExpiresAt, found.ExpiresAt); + Assert.Equal(invite.ActivatedAt, found.ActivatedAt); + Assert.Equal(invite.CreatedById, found.CreatedById); + Assert.Equal(invite.CreatedAt, found.CreatedAt); + } + + [Fact] + public async Task List_ReturnsInvitesNewestFirst() + { + var store = CreateStore(); + var older = NewInvite("older-code", "older@example.com", tenantId: null, createdAt: DateTimeOffset.UtcNow.AddDays(-2)); + var newer = NewInvite("newer-code", "newer@example.com", tenantId: null, createdAt: DateTimeOffset.UtcNow.AddDays(-1)); + await store.CreateAsync(older, CancellationToken.None); + await store.CreateAsync(newer, CancellationToken.None); + + IReadOnlyList items = await store.ListAsync(CancellationToken.None); + + Assert.Equal(new[] { "newer-code", "older-code" }, items.Select(i => i.Code)); + } + + [Fact] + public async Task UpdateStatus_ChangesStatusAndActivatedAt() + { + var store = CreateStore(); + var activatedAt = DateTimeOffset.UtcNow.AddHours(-1); + await store.CreateAsync(NewInvite("code-1", "user@example.com", Guid.NewGuid()), CancellationToken.None); + + bool updated = await store.UpdateStatusAsync("code-1", InviteStatuses.Activated, activatedAt, CancellationToken.None); + + Assert.True(updated); + var found = await store.GetByCodeAsync("code-1", CancellationToken.None); + Assert.Equal(InviteStatuses.Activated, found!.Status); + Assert.Equal(activatedAt, found.ActivatedAt); + } + + [Fact] + public async Task UpdateStatus_WithUnknownCode_ReturnsFalse() + { + var store = CreateStore(); + + bool updated = await store.UpdateStatusAsync("no-such-code", InviteStatuses.Revoked, null, CancellationToken.None); + + Assert.False(updated); + } + + [Fact] + public async Task FindActiveByEmail_ReturnsOnlyPendingInvite() + { + var store = CreateStore(); + await store.CreateAsync(NewInvite("pending-code", "user@example.com", tenantId: null), CancellationToken.None); + await store.CreateAsync(NewInvite("revoked-code", "revoked@example.com", tenantId: null, status: InviteStatuses.Revoked), CancellationToken.None); + await store.CreateAsync(NewInvite("activated-code", "activated@example.com", tenantId: null, status: InviteStatuses.Activated), CancellationToken.None); + + Assert.Equal("pending-code", (await store.FindActiveByEmailAsync("user@example.com", CancellationToken.None))!.Code); + Assert.Null(await store.FindActiveByEmailAsync("revoked@example.com", CancellationToken.None)); + Assert.Null(await store.FindActiveByEmailAsync("activated@example.com", CancellationToken.None)); + Assert.Null(await store.FindActiveByEmailAsync("unknown@example.com", CancellationToken.None)); + } + + [Fact] + public async Task GetByCode_WithUnknownCode_ReturnsNull() + { + var store = CreateStore(); + + Assert.Null(await store.GetByCodeAsync("no-such-code", CancellationToken.None)); + } + + // Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются). + private static InviteStore CreateStore() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options; + return new InviteStore(new DealDbContext(options)); + } + + // Создаёт приглашение-строку для теста (дефолт — живой pending). + private static InviteDto NewInvite( + string code, + string email, + Guid? tenantId, + string status = InviteStatuses.Pending, + DateTimeOffset? createdAt = null) => + new( + code, + email, + tenantId, + status, + DateTimeOffset.UtcNow.AddHours(InvitesService.ExpiryHours), + ActivatedAt: null, + CreatedById: Guid.NewGuid(), + createdAt ?? DateTimeOffset.UtcNow); +} diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/MtlsOptionsTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/MtlsOptionsTests.cs index 477abce..07bc2b5 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/MtlsOptionsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/MtlsOptionsTests.cs @@ -1,103 +1,103 @@ -using Deal.Infrastructure.Integrations.Options; -using Microsoft.Extensions.Configuration; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Тесты конфиг-парсинга mTLS -/// -public sealed class MtlsOptionsTests -{ - /// - /// Пустой конфиг — флаг выключен и все пути пустые - /// - [Fact] - public void FromConfiguration_WithoutEnvKeys_ReturnsDisabledDefaults() - { - MtlsOptions options = MtlsOptions.FromConfiguration(EmptyConfiguration()); - - Assert.False(options.Enabled); - Assert.Equal(string.Empty, options.CaPem); - Assert.Equal(string.Empty, options.ServerCertPfx); - Assert.Equal(string.Empty, options.ServerCertPassword); - Assert.Equal(string.Empty, options.ClientCertPfx); - Assert.Equal(string.Empty, options.ClientCertPassword); - } - - /// - /// «1»/«true» в любом регистре включают флаг mTLS. - /// - [Theory] - [InlineData("1")] - [InlineData("true")] - [InlineData("TRUE")] - [InlineData("True")] - public void FromConfiguration_EnablingEnvValues_EnabledTrue(string rawValue) - { - MtlsOptions options = MtlsOptions.FromConfiguration(Configuration( - (MtlsOptions.EnabledEnvKey, rawValue))); - - Assert.True(options.Enabled); - } - - /// - /// «0»/«false»/прочие значения/отсутствие флага — mTLS выключен - /// - [Theory] - [InlineData("0")] - [InlineData("false")] - [InlineData("no")] - [InlineData("yes")] - [InlineData("2")] - public void FromConfiguration_DisablingEnvValues_EnabledFalse(string rawValue) - { - MtlsOptions options = MtlsOptions.FromConfiguration(Configuration( - (MtlsOptions.EnabledEnvKey, rawValue))); - - Assert.False(options.Enabled); - } - - [Fact] - public void FromConfiguration_MissingEnabledKey_EnabledFalse() - { - MtlsOptions options = MtlsOptions.FromConfiguration(EmptyConfiguration()); - - Assert.False(options.Enabled); - } - - /// - /// Все env-ключи читаются в свои поля; пути обрезаются. - /// - [Fact] - public void FromConfiguration_AllEnvKeys_ParsedToOptions() - { - MtlsOptions options = MtlsOptions.FromConfiguration(Configuration( - (MtlsOptions.EnabledEnvKey, "1"), - (MtlsOptions.CaPemEnvKey, " /certs/ca.pem "), - (MtlsOptions.ServerCertPfxEnvKey, "/certs/core-server.pfx"), - (MtlsOptions.ServerCertPasswordEnvKey, "s3cret-server"), - (MtlsOptions.ClientCertPfxEnvKey, "/certs/deal-client.pfx"), - (MtlsOptions.ClientCertPasswordEnvKey, "s3cret-client"))); - - Assert.True(options.Enabled); - Assert.Equal("/certs/ca.pem", options.CaPem); - Assert.Equal("/certs/core-server.pfx", options.ServerCertPfx); - Assert.Equal("s3cret-server", options.ServerCertPassword); - Assert.Equal("/certs/deal-client.pfx", options.ClientCertPfx); - Assert.Equal("s3cret-client", options.ClientCertPassword); - } - - // Пустая конфигурация (env-ключей mTLS нет). - private static IConfiguration EmptyConfiguration() - => new ConfigurationBuilder().Build(); - - // Конфигурация из пар ключ-значение env (как их видит провайдер EnvironmentVariables). - // values: Пары (ключ конфигурации, значение). - private static IConfiguration Configuration(params (string Key, string Value)[] values) - { - Dictionary dictionary = values.ToDictionary( - pair => pair.Key, - pair => (string?)pair.Value); - return new ConfigurationBuilder().AddInMemoryCollection(dictionary).Build(); - } -} +using Deal.Infrastructure.Integrations.Options; +using Microsoft.Extensions.Configuration; + +namespace Deal.Tests.Unit.Infrastructure; + +/// +/// Тесты конфиг-парсинга mTLS +/// +public sealed class MtlsOptionsTests +{ + /// + /// Пустой конфиг — флаг выключен и все пути пустые + /// + [Fact] + public void FromConfiguration_WithoutEnvKeys_ReturnsDisabledDefaults() + { + MtlsOptions options = MtlsOptions.FromConfiguration(EmptyConfiguration()); + + Assert.False(options.Enabled); + Assert.Equal(string.Empty, options.CaPem); + Assert.Equal(string.Empty, options.ServerCertPfx); + Assert.Equal(string.Empty, options.ServerCertPassword); + Assert.Equal(string.Empty, options.ClientCertPfx); + Assert.Equal(string.Empty, options.ClientCertPassword); + } + + /// + /// «1»/«true» в любом регистре включают флаг mTLS. + /// + [Theory] + [InlineData("1")] + [InlineData("true")] + [InlineData("TRUE")] + [InlineData("True")] + public void FromConfiguration_EnablingEnvValues_EnabledTrue(string rawValue) + { + MtlsOptions options = MtlsOptions.FromConfiguration(Configuration( + (MtlsOptions.EnabledEnvKey, rawValue))); + + Assert.True(options.Enabled); + } + + /// + /// «0»/«false»/прочие значения/отсутствие флага — mTLS выключен + /// + [Theory] + [InlineData("0")] + [InlineData("false")] + [InlineData("no")] + [InlineData("yes")] + [InlineData("2")] + public void FromConfiguration_DisablingEnvValues_EnabledFalse(string rawValue) + { + MtlsOptions options = MtlsOptions.FromConfiguration(Configuration( + (MtlsOptions.EnabledEnvKey, rawValue))); + + Assert.False(options.Enabled); + } + + [Fact] + public void FromConfiguration_MissingEnabledKey_EnabledFalse() + { + MtlsOptions options = MtlsOptions.FromConfiguration(EmptyConfiguration()); + + Assert.False(options.Enabled); + } + + /// + /// Все env-ключи читаются в свои поля; пути обрезаются. + /// + [Fact] + public void FromConfiguration_AllEnvKeys_ParsedToOptions() + { + MtlsOptions options = MtlsOptions.FromConfiguration(Configuration( + (MtlsOptions.EnabledEnvKey, "1"), + (MtlsOptions.CaPemEnvKey, " /certs/ca.pem "), + (MtlsOptions.ServerCertPfxEnvKey, "/certs/core-server.pfx"), + (MtlsOptions.ServerCertPasswordEnvKey, "s3cret-server"), + (MtlsOptions.ClientCertPfxEnvKey, "/certs/deal-client.pfx"), + (MtlsOptions.ClientCertPasswordEnvKey, "s3cret-client"))); + + Assert.True(options.Enabled); + Assert.Equal("/certs/ca.pem", options.CaPem); + Assert.Equal("/certs/core-server.pfx", options.ServerCertPfx); + Assert.Equal("s3cret-server", options.ServerCertPassword); + Assert.Equal("/certs/deal-client.pfx", options.ClientCertPfx); + Assert.Equal("s3cret-client", options.ClientCertPassword); + } + + // Пустая конфигурация (env-ключей mTLS нет). + private static IConfiguration EmptyConfiguration() + => new ConfigurationBuilder().Build(); + + // Конфигурация из пар ключ-значение env (как их видит провайдер EnvironmentVariables). + // values: Пары (ключ конфигурации, значение). + private static IConfiguration Configuration(params (string Key, string Value)[] values) + { + Dictionary dictionary = values.ToDictionary( + pair => pair.Key, + pair => (string?)pair.Value); + return new ConfigurationBuilder().AddInMemoryCollection(dictionary).Build(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/OperatorAuthStoreTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/OperatorAuthStoreTests.cs index fc4882f..bc73904 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/OperatorAuthStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/OperatorAuthStoreTests.cs @@ -1,88 +1,88 @@ -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Tenants.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Юнит-тесты EF-адаптера на InMemory-провайдере -/// -public sealed class OperatorAuthStoreTests -{ - private const string OperatorPasswordHash = "fake-argon2-encoded-hash"; - - [Fact] - public async Task CreateOperator_ThenFindByLogin_ReturnsMappedRecord() - { - var store = CreateStore(); - - var id = Guid.NewGuid(); - await store.CreateAsync( - new StoredOperatorDto(id, "root-operator", "active", OperatorPasswordHash), - CancellationToken.None); - - var found = await store.FindByLoginAsync("root-operator", CancellationToken.None); - Assert.NotNull(found); - Assert.Equal(id, found!.Id); - Assert.Equal("root-operator", found.Login); - Assert.Equal("active", found.Status); - Assert.Equal(OperatorPasswordHash, found.PasswordHash); - } - - [Fact] - public async Task CreateSession_ThenFindByTokenHash_ReturnsMappedSession() - { - var store = CreateStore(); - var operatorId = Guid.NewGuid(); - await store.CreateAsync( - new StoredOperatorDto(operatorId, "root-operator", "active", OperatorPasswordHash), - CancellationToken.None); - var expiresAt = DateTimeOffset.UtcNow.AddHours(12); - - await store.CreateSessionAsync( - new OperatorSessionDto("token-hash-abc", operatorId, "root-operator", expiresAt), - CancellationToken.None); - - var found = await store.FindSessionByTokenHashAsync("token-hash-abc", CancellationToken.None); - Assert.NotNull(found); - Assert.Equal("token-hash-abc", found!.TokenHash); - Assert.Equal(operatorId, found.OperatorId); - Assert.Equal("root-operator", found.Login); - Assert.Equal(expiresAt, found.ExpiresAt); - } - - [Fact] - public async Task FindSessionByTokenHash_WithExpiredSession_ReturnsNull() - { - var store = CreateStore(); - var operatorId = Guid.NewGuid(); - await store.CreateAsync( - new StoredOperatorDto(operatorId, "root-operator", "active", OperatorPasswordHash), - CancellationToken.None); - - await store.CreateSessionAsync( - new OperatorSessionDto("token-hash-expired", operatorId, "root-operator", DateTimeOffset.UtcNow.AddHours(-1)), - CancellationToken.None); - - // Адаптер не возвращает протухшие сессии (зеркало AuthStore). - Assert.Null(await store.FindSessionByTokenHashAsync("token-hash-expired", CancellationToken.None)); - } - - [Fact] - public async Task FindByLogin_WithUnknownLogin_ReturnsNull() - { - var store = CreateStore(); - - Assert.Null(await store.FindByLoginAsync("no-such-operator", CancellationToken.None)); - } - - // Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются). - private static OperatorAuthStore CreateStore() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) - .Options; - return new OperatorAuthStore(new DealDbContext(options)); - } -} +using Deal.Infrastructure.Persistence; +using Deal.Infrastructure.Persistence.Repositories; +using Deal.Modules.Tenants.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Tests.Unit.Infrastructure; + +/// +/// Юнит-тесты EF-адаптера на InMemory-провайдере +/// +public sealed class OperatorAuthStoreTests +{ + private const string OperatorPasswordHash = "fake-argon2-encoded-hash"; + + [Fact] + public async Task CreateOperator_ThenFindByLogin_ReturnsMappedRecord() + { + var store = CreateStore(); + + var id = Guid.NewGuid(); + await store.CreateAsync( + new StoredOperatorDto(id, "root-operator", "active", OperatorPasswordHash), + CancellationToken.None); + + var found = await store.FindByLoginAsync("root-operator", CancellationToken.None); + Assert.NotNull(found); + Assert.Equal(id, found!.Id); + Assert.Equal("root-operator", found.Login); + Assert.Equal("active", found.Status); + Assert.Equal(OperatorPasswordHash, found.PasswordHash); + } + + [Fact] + public async Task CreateSession_ThenFindByTokenHash_ReturnsMappedSession() + { + var store = CreateStore(); + var operatorId = Guid.NewGuid(); + await store.CreateAsync( + new StoredOperatorDto(operatorId, "root-operator", "active", OperatorPasswordHash), + CancellationToken.None); + var expiresAt = DateTimeOffset.UtcNow.AddHours(12); + + await store.CreateSessionAsync( + new OperatorSessionDto("token-hash-abc", operatorId, "root-operator", expiresAt), + CancellationToken.None); + + var found = await store.FindSessionByTokenHashAsync("token-hash-abc", CancellationToken.None); + Assert.NotNull(found); + Assert.Equal("token-hash-abc", found!.TokenHash); + Assert.Equal(operatorId, found.OperatorId); + Assert.Equal("root-operator", found.Login); + Assert.Equal(expiresAt, found.ExpiresAt); + } + + [Fact] + public async Task FindSessionByTokenHash_WithExpiredSession_ReturnsNull() + { + var store = CreateStore(); + var operatorId = Guid.NewGuid(); + await store.CreateAsync( + new StoredOperatorDto(operatorId, "root-operator", "active", OperatorPasswordHash), + CancellationToken.None); + + await store.CreateSessionAsync( + new OperatorSessionDto("token-hash-expired", operatorId, "root-operator", DateTimeOffset.UtcNow.AddHours(-1)), + CancellationToken.None); + + // Адаптер не возвращает протухшие сессии (зеркало AuthStore). + Assert.Null(await store.FindSessionByTokenHashAsync("token-hash-expired", CancellationToken.None)); + } + + [Fact] + public async Task FindByLogin_WithUnknownLogin_ReturnsNull() + { + var store = CreateStore(); + + Assert.Null(await store.FindByLoginAsync("no-such-operator", CancellationToken.None)); + } + + // Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются). + private static OperatorAuthStore CreateStore() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options; + return new OperatorAuthStore(new DealDbContext(options)); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/RateLimitCounterStoreTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/RateLimitCounterStoreTests.cs index 4c3d0dd..85dce87 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/RateLimitCounterStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/RateLimitCounterStoreTests.cs @@ -1,106 +1,106 @@ -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Repositories; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Юнит-тесты EF-адаптера на InMemory-провайдере -/// -public sealed class RateLimitCounterStoreTests -{ - // Фиксированное «сейчас» тестов (UTC). - private static readonly DateTimeOffset Now = new(2026, 9, 1, 12, 0, 0, TimeSpan.Zero); - - // Длина окна сценариев — 1 минута (как у HTTP-политик). - private static readonly TimeSpan Window = TimeSpan.FromMinutes(1); - - // Ключ сценария (партиция политики). - private const string Key = "http:auth:ip:127.0.0.1"; - - [Fact] - public async Task IncrementAsync_FirstCall_CreatesCounterWithAmount() - { - (_, RateLimitCounterStore store) = CreateStore(); - - int count = await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None); - - Assert.Equal(1, count); - Assert.Equal(1, await store.GetCountAsync(Key, Now, CancellationToken.None)); - } - - [Fact] - public async Task IncrementAsync_SameWindow_Accumulates() - { - (_, RateLimitCounterStore store) = CreateStore(); - - await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None); - await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None); - int count = await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None); - - Assert.Equal(3, count); - } - - [Fact] - public async Task IncrementAsync_NewWindow_ResetsCounter() - { - (_, RateLimitCounterStore store) = CreateStore(); - DateTimeOffset nextWindow = Now.Add(Window); - - await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None); - await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None); - int count = await store.IncrementAsync(Key, nextWindow, nextWindow.Add(Window), 1, CancellationToken.None); - - // Смена окна сбрасывает счётчик (семантика FixedWindowRateLimiter). - Assert.Equal(1, count); - Assert.Equal(0, await store.GetCountAsync(Key, Now, CancellationToken.None)); - Assert.Equal(1, await store.GetCountAsync(Key, nextWindow, CancellationToken.None)); - } - - [Fact] - public async Task GetCountAsync_UnknownKeyOrWindow_ReturnsZero() - { - (_, RateLimitCounterStore store) = CreateStore(); - - await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None); - - Assert.Equal(0, await store.GetCountAsync("other:key", Now, CancellationToken.None)); - Assert.Equal(0, await store.GetCountAsync(Key, Now.Add(Window), CancellationToken.None)); - } - - [Fact] - public async Task ResetAsync_RemovesCounter() - { - (_, RateLimitCounterStore store) = CreateStore(); - await store.IncrementAsync(Key, Now, Now.Add(Window), 5, CancellationToken.None); - - await store.ResetAsync(Key, CancellationToken.None); - - Assert.Equal(0, await store.GetCountAsync(Key, Now, CancellationToken.None)); - } - - [Fact] - public async Task DeleteExpiredAsync_RemovesOnlyExpiredWindows() - { - (_, RateLimitCounterStore store) = CreateStore(); - await store.IncrementAsync("expired", Now.AddHours(-2), Now.AddHours(-2).Add(Window), 1, CancellationToken.None); - await store.IncrementAsync("active", Now, Now.Add(Window), 1, CancellationToken.None); - - int removed = await store.DeleteExpiredAsync(Now, CancellationToken.None); - - Assert.Equal(1, removed); - Assert.Equal(0, await store.GetCountAsync("expired", Now.AddHours(-2), CancellationToken.None)); - Assert.Equal(1, await store.GetCountAsync("active", Now, CancellationToken.None)); - } - - // Создаёт адаптер на свежем InMemory-контексте. - // Возвращает: Контекст и адаптер. - private static (DealDbContext Db, RateLimitCounterStore Store) CreateStore() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) - .Options; - var db = new DealDbContext(options); - return (db, new RateLimitCounterStore(db)); - } -} +using Deal.Infrastructure.Persistence; +using Deal.Infrastructure.Persistence.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Tests.Unit.Infrastructure; + +/// +/// Юнит-тесты EF-адаптера на InMemory-провайдере +/// +public sealed class RateLimitCounterStoreTests +{ + // Фиксированное «сейчас» тестов (UTC). + private static readonly DateTimeOffset Now = new(2026, 9, 1, 12, 0, 0, TimeSpan.Zero); + + // Длина окна сценариев — 1 минута (как у HTTP-политик). + private static readonly TimeSpan Window = TimeSpan.FromMinutes(1); + + // Ключ сценария (партиция политики). + private const string Key = "http:auth:ip:127.0.0.1"; + + [Fact] + public async Task IncrementAsync_FirstCall_CreatesCounterWithAmount() + { + (_, RateLimitCounterStore store) = CreateStore(); + + int count = await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None); + + Assert.Equal(1, count); + Assert.Equal(1, await store.GetCountAsync(Key, Now, CancellationToken.None)); + } + + [Fact] + public async Task IncrementAsync_SameWindow_Accumulates() + { + (_, RateLimitCounterStore store) = CreateStore(); + + await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None); + await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None); + int count = await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None); + + Assert.Equal(3, count); + } + + [Fact] + public async Task IncrementAsync_NewWindow_ResetsCounter() + { + (_, RateLimitCounterStore store) = CreateStore(); + DateTimeOffset nextWindow = Now.Add(Window); + + await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None); + await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None); + int count = await store.IncrementAsync(Key, nextWindow, nextWindow.Add(Window), 1, CancellationToken.None); + + // Смена окна сбрасывает счётчик (семантика FixedWindowRateLimiter). + Assert.Equal(1, count); + Assert.Equal(0, await store.GetCountAsync(Key, Now, CancellationToken.None)); + Assert.Equal(1, await store.GetCountAsync(Key, nextWindow, CancellationToken.None)); + } + + [Fact] + public async Task GetCountAsync_UnknownKeyOrWindow_ReturnsZero() + { + (_, RateLimitCounterStore store) = CreateStore(); + + await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None); + + Assert.Equal(0, await store.GetCountAsync("other:key", Now, CancellationToken.None)); + Assert.Equal(0, await store.GetCountAsync(Key, Now.Add(Window), CancellationToken.None)); + } + + [Fact] + public async Task ResetAsync_RemovesCounter() + { + (_, RateLimitCounterStore store) = CreateStore(); + await store.IncrementAsync(Key, Now, Now.Add(Window), 5, CancellationToken.None); + + await store.ResetAsync(Key, CancellationToken.None); + + Assert.Equal(0, await store.GetCountAsync(Key, Now, CancellationToken.None)); + } + + [Fact] + public async Task DeleteExpiredAsync_RemovesOnlyExpiredWindows() + { + (_, RateLimitCounterStore store) = CreateStore(); + await store.IncrementAsync("expired", Now.AddHours(-2), Now.AddHours(-2).Add(Window), 1, CancellationToken.None); + await store.IncrementAsync("active", Now, Now.Add(Window), 1, CancellationToken.None); + + int removed = await store.DeleteExpiredAsync(Now, CancellationToken.None); + + Assert.Equal(1, removed); + Assert.Equal(0, await store.GetCountAsync("expired", Now.AddHours(-2), CancellationToken.None)); + Assert.Equal(1, await store.GetCountAsync("active", Now, CancellationToken.None)); + } + + // Создаёт адаптер на свежем InMemory-контексте. + // Возвращает: Контекст и адаптер. + private static (DealDbContext Db, RateLimitCounterStore Store) CreateStore() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options; + var db = new DealDbContext(options); + return (db, new RateLimitCounterStore(db)); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantLimitStoreTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantLimitStoreTests.cs index 99ccd36..bab169f 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantLimitStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantLimitStoreTests.cs @@ -1,298 +1,298 @@ -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Юнит-тесты EF-адаптера на InMemory-провайдере -/// -public sealed class TenantLimitStoreTests -{ - // Фиксированное «сейчас» тестов (UTC) — детерминированный ленивый reset. - private static readonly DateTimeOffset Now = new(2026, 9, 1, 12, 0, 0, TimeSpan.Zero); - - [Fact] - public async Task GetOrCreateAsync_NoRow_CreatesDefaultBudget() - { - (DealDbContext db, TenantLimitStore store) = CreateStore(); - var tenantId = Guid.NewGuid(); - AddTenant(db, tenantId, TenantStatuses.Active); - - TenantLimitDto first = await store.GetOrCreateAsync(tenantId, CancellationToken.None); - - Assert.Equal(TokenBudgetDefaults.DefaultBudgetTokens, first.BudgetTokens); - Assert.Equal(TenantLimitPeriods.Month, first.Period); - Assert.Equal(0, first.UsedTokens); - Assert.False(first.Warned80); - Assert.False(first.NotifiedExhausted); - - // Строка реально сохранена: повторный вызов возвращает ту же, дублей нет. - TenantLimitDto second = await store.GetOrCreateAsync(tenantId, CancellationToken.None); - Assert.Equal(first, second); - Assert.Single(db.TenantLimits); - } - - [Fact] - public async Task GetOrCreateAsync_WithExplicitDefaults_UsesThem() - { - (DealDbContext db, TenantLimitStore store) = CreateStore(); - var tenantId = Guid.NewGuid(); - AddTenant(db, tenantId, TenantStatuses.Active); - - TenantLimitDto row = await store.GetOrCreateAsync( - tenantId, CancellationToken.None, new TokenLimitDefaults(BudgetTokens: 50_000, Period: TenantLimitPeriods.Day)); - - Assert.Equal(50_000, row.BudgetTokens); - Assert.Equal(TenantLimitPeriods.Day, row.Period); - } - - [Fact] - public async Task GetStateAsync_ReturnsStatusAndAllowed() - { - var tenantId = Guid.NewGuid(); - (DealDbContext db, TenantLimitStore store) = CreateStore(); - - // Активный тенант, расход ниже бюджета → Allowed=true. - AddTenant(db, tenantId, TenantStatuses.Active); - AddLimit(db, tenantId, budgetTokens: 1000, period: TenantLimitPeriods.Month, periodStart: Now, usedTokens: 300); - - BudgetStateDto active = await store.GetStateAsync(tenantId, CancellationToken.None); - Assert.Equal(TenantStatuses.Active, active.Status); - Assert.True(active.Allowed); - - var suspendedTenant = Guid.NewGuid(); - AddTenant(db, suspendedTenant, TenantStatuses.Suspended); - BudgetStateDto suspended = await store.GetStateAsync(suspendedTenant, CancellationToken.None); - Assert.Equal(TenantStatuses.Suspended, suspended.Status); - Assert.False(suspended.Allowed); - } - - [Fact] - public async Task AddUsageAsync_WhenPeriodExpired_ResetsUsedAndStartsNewPeriod() - { - var tenantId = Guid.NewGuid(); - (DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); - AddTenant(db, tenantId, TenantStatuses.Active); - AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, periodStart: Now.AddMonths(-2), usedTokens: 700, warned80: true, notifiedExhausted: true); - - BudgetStateDto state = await store.AddUsageAsync(tenantId, tokens: 500, CancellationToken.None); - - Assert.Equal(500, state.UsedTokens); // 700 старого периода не переносятся - Assert.False(state.Warned80); - Assert.False(state.NotifiedExhausted); - TenantLimitEntity entity = db.TenantLimits.Single(x => x.TenantId == tenantId); - Assert.Equal(Now, entity.PeriodStart); - Assert.Equal(500, entity.UsedTokens); - } - - [Fact] - public async Task AddUsageAsync_WithinPeriod_Accumulates() - { - var tenantId = Guid.NewGuid(); - (DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); - AddTenant(db, tenantId, TenantStatuses.Active); - AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 300); - - BudgetStateDto state = await store.AddUsageAsync(tenantId, tokens: 200, CancellationToken.None); - - Assert.Equal(500, state.UsedTokens); - Assert.Equal(Now, state.PeriodStart); // период не истёк — начало не сдвинуто - } - - [Fact] - public async Task AddUsageAsync_Crossing80Percent_DoesNotSetWarned80() - { - var tenantId = Guid.NewGuid(); - (DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); - AddTenant(db, tenantId, TenantStatuses.Active); - AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 700); - - BudgetStateDto state = await store.AddUsageAsync(tenantId, tokens: 100, CancellationToken.None); - - Assert.Equal(800, state.UsedTokens); - Assert.False(state.Warned80); // списание флаг не выставляет (переход наблюдает планировщик через TryMark*) - Assert.False(state.NotifiedExhausted); - Assert.True(state.Allowed); // Allowed считается от used/budget, не от флагов - } - - [Fact] - public async Task AddUsageAsync_Exhaustion_DoesNotSetFlagsButDisallows() - { - var tenantId = Guid.NewGuid(); - (DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); - AddTenant(db, tenantId, TenantStatuses.Active); - AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 950); - - BudgetStateDto state = await store.AddUsageAsync(tenantId, tokens: 100, CancellationToken.None); - - Assert.Equal(1050, state.UsedTokens); - Assert.False(state.Warned80); - Assert.False(state.NotifiedExhausted); - Assert.False(state.Allowed); // бюджет исчерпан → ИИ запрещён (гейт Task 9) — от used/budget, без флагов - } - - [Fact] - public async Task AddUsageAsync_ZeroTokens_CreatesLazyRowWithoutIncrement() - { - // Нулевой расход не инкрементирует, но строка лимита заводится лениво (закрыт путь записи). - var tenantId = Guid.NewGuid(); - (DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); - AddTenant(db, tenantId, TenantStatuses.Active); - - BudgetStateDto state = await store.AddUsageAsync(tenantId, tokens: 0, CancellationToken.None); - - Assert.Equal(0, state.UsedTokens); - Assert.True(db.TenantLimits.Any(x => x.TenantId == tenantId)); - } - - [Fact] - public async Task UpdateBudgetAsync_AppliesNewBudgetAndPeriod_AndResetsFlags() - { - var tenantId = Guid.NewGuid(); - (DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); - AddTenant(db, tenantId, TenantStatuses.Active); - AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 900, warned80: true, notifiedExhausted: true); - - BudgetStateDto state = await store.UpdateBudgetAsync(tenantId, budgetTokens: 2000, period: TenantLimitPeriods.Day, CancellationToken.None); - - Assert.Equal(2000, state.BudgetTokens); - Assert.Equal(TenantLimitPeriods.Day, state.Period); - Assert.False(state.Warned80); - Assert.False(state.NotifiedExhausted); - Assert.Equal(900, state.UsedTokens); // расход периода сохраняется - } - - [Fact] - public async Task UpdateBudgetAsync_InvalidArguments_Throw() - { - var tenantId = Guid.NewGuid(); - (DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); - AddTenant(db, tenantId, TenantStatuses.Active); - - await Assert.ThrowsAsync( - () => store.UpdateBudgetAsync(tenantId, budgetTokens: -1, period: TenantLimitPeriods.Month, CancellationToken.None)); - await Assert.ThrowsAsync( - () => store.UpdateBudgetAsync(tenantId, budgetTokens: 1000, period: "week", CancellationToken.None)); - } - - [Fact] - public async Task TryMarkWarnedAsync_MarksOnceAtThreshold() - { - var tenantId = Guid.NewGuid(); - (DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); - AddTenant(db, tenantId, TenantStatuses.Active); - AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 800); - - Assert.True(await store.TryMarkWarnedAsync(tenantId, CancellationToken.None)); - Assert.False(await store.TryMarkWarnedAsync(tenantId, CancellationToken.None)); - Assert.True(db.TenantLimits.Single(x => x.TenantId == tenantId).Warned80); - - // Ниже порога флаг не выставляется. - var lowTenant = Guid.NewGuid(); - AddTenant(db, lowTenant, TenantStatuses.Active); - AddLimit(db, lowTenant, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 700); - Assert.False(await store.TryMarkWarnedAsync(lowTenant, CancellationToken.None)); - Assert.False(db.TenantLimits.Single(x => x.TenantId == lowTenant).Warned80); - } - - [Fact] - public async Task TryMarkNotifiedExhaustedAsync_MarksOnceAtExhaustion() - { - var tenantId = Guid.NewGuid(); - (DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); - AddTenant(db, tenantId, TenantStatuses.Active); - AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 1000); - - Assert.True(await store.TryMarkNotifiedExhaustedAsync(tenantId, CancellationToken.None)); - Assert.False(await store.TryMarkNotifiedExhaustedAsync(tenantId, CancellationToken.None)); - Assert.True(db.TenantLimits.Single(x => x.TenantId == tenantId).NotifiedExhausted); - } - - /// - /// Авто-очистка: строки с завершившимся периодом сбрасываются, текущие — нет. - /// - [Fact] - public async Task ResetExpiredPeriodsAsync_ResetsOnlyExpiredPeriods() - { - (DealDbContext db, TenantLimitStore store) = CreateStore(); - var expiredTenant = Guid.NewGuid(); - var currentTenant = Guid.NewGuid(); - // Период месяца начат за 2 месяца назад → истёк к Now; расход и флаги должны обнулиться. - AddLimit(db, expiredTenant, budgetTokens: 1000, TenantLimitPeriods.Month, Now.AddMonths(-2), - usedTokens: 800, warned80: true); - // Текущий период (месяц не истёк) — не трогается. - AddLimit(db, currentTenant, budgetTokens: 1000, TenantLimitPeriods.Month, Now.AddDays(-1), usedTokens: 300); - - int reset = await store.ResetExpiredPeriodsAsync(Now, CancellationToken.None); - - Assert.Equal(1, reset); - TenantLimitEntity expired = db.TenantLimits.Single(x => x.TenantId == expiredTenant); - Assert.Equal(0, expired.UsedTokens); - Assert.False(expired.Warned80); - Assert.False(expired.NotifiedExhausted); - Assert.Equal(Now, expired.PeriodStart); - Assert.Equal(300, db.TenantLimits.Single(x => x.TenantId == currentTenant).UsedTokens); - - // Идемпотентно: повторный проход на тех же данных ничего не сбрасывает. - Assert.Equal(0, await store.ResetExpiredPeriodsAsync(Now, CancellationToken.None)); - } - - // Создаёт адаптер на уникальной InMemory-БД с фиксированными/системными часами. - // clock: Источник «сейчас» (null — системные часы). - // Возвращает: Кортеж (контекст для seed/проверок, адаптер). - private static (DealDbContext Db, TenantLimitStore Store) CreateStore(Func? clock = null) - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) - .Options; - var db = new DealDbContext(options); - var store = new TenantLimitStore( - db, TokenBudgetDefaults.Default, new TokenBudgetService(), clock ?? (() => DateTimeOffset.UtcNow)); - return (db, store); - } - - // Заводит строку public.tenants (статус нужен BudgetStateDto). - private static void AddTenant( - DealDbContext db, - Guid tenantId, - string status) - { - db.Tenants.Add(new TenantEntity - { - Id = tenantId, - Name = $"Тенант {tenantId:N}", - Status = status, - CreatedAt = Now, - }); - db.SaveChanges(); - } - - // Заводит строку tenant_limits с заданными полями (сценарий «уже есть расход/флаги»). - private static void AddLimit( - DealDbContext db, - Guid tenantId, - long budgetTokens, - string period, - DateTimeOffset periodStart, - long usedTokens, - bool warned80 = false, - bool notifiedExhausted = false) - { - db.TenantLimits.Add(new TenantLimitEntity - { - TenantId = tenantId, - BudgetTokens = budgetTokens, - Period = period, - PeriodStart = periodStart, - UsedTokens = usedTokens, - Warned80 = warned80, - NotifiedExhausted = notifiedExhausted, - UpdatedAt = Now, - }); - db.SaveChanges(); - } -} +using Deal.Infrastructure.Persistence; +using Deal.Infrastructure.Persistence.Entities; +using Deal.Infrastructure.Persistence.Repositories; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Tests.Unit.Infrastructure; + +/// +/// Юнит-тесты EF-адаптера на InMemory-провайдере +/// +public sealed class TenantLimitStoreTests +{ + // Фиксированное «сейчас» тестов (UTC) — детерминированный ленивый reset. + private static readonly DateTimeOffset Now = new(2026, 9, 1, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task GetOrCreateAsync_NoRow_CreatesDefaultBudget() + { + (DealDbContext db, TenantLimitStore store) = CreateStore(); + var tenantId = Guid.NewGuid(); + AddTenant(db, tenantId, TenantStatuses.Active); + + TenantLimitDto first = await store.GetOrCreateAsync(tenantId, CancellationToken.None); + + Assert.Equal(TokenBudgetDefaults.DefaultBudgetTokens, first.BudgetTokens); + Assert.Equal(TenantLimitPeriods.Month, first.Period); + Assert.Equal(0, first.UsedTokens); + Assert.False(first.Warned80); + Assert.False(first.NotifiedExhausted); + + // Строка реально сохранена: повторный вызов возвращает ту же, дублей нет. + TenantLimitDto second = await store.GetOrCreateAsync(tenantId, CancellationToken.None); + Assert.Equal(first, second); + Assert.Single(db.TenantLimits); + } + + [Fact] + public async Task GetOrCreateAsync_WithExplicitDefaults_UsesThem() + { + (DealDbContext db, TenantLimitStore store) = CreateStore(); + var tenantId = Guid.NewGuid(); + AddTenant(db, tenantId, TenantStatuses.Active); + + TenantLimitDto row = await store.GetOrCreateAsync( + tenantId, CancellationToken.None, new TokenLimitDefaults(BudgetTokens: 50_000, Period: TenantLimitPeriods.Day)); + + Assert.Equal(50_000, row.BudgetTokens); + Assert.Equal(TenantLimitPeriods.Day, row.Period); + } + + [Fact] + public async Task GetStateAsync_ReturnsStatusAndAllowed() + { + var tenantId = Guid.NewGuid(); + (DealDbContext db, TenantLimitStore store) = CreateStore(); + + // Активный тенант, расход ниже бюджета → Allowed=true. + AddTenant(db, tenantId, TenantStatuses.Active); + AddLimit(db, tenantId, budgetTokens: 1000, period: TenantLimitPeriods.Month, periodStart: Now, usedTokens: 300); + + BudgetStateDto active = await store.GetStateAsync(tenantId, CancellationToken.None); + Assert.Equal(TenantStatuses.Active, active.Status); + Assert.True(active.Allowed); + + var suspendedTenant = Guid.NewGuid(); + AddTenant(db, suspendedTenant, TenantStatuses.Suspended); + BudgetStateDto suspended = await store.GetStateAsync(suspendedTenant, CancellationToken.None); + Assert.Equal(TenantStatuses.Suspended, suspended.Status); + Assert.False(suspended.Allowed); + } + + [Fact] + public async Task AddUsageAsync_WhenPeriodExpired_ResetsUsedAndStartsNewPeriod() + { + var tenantId = Guid.NewGuid(); + (DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); + AddTenant(db, tenantId, TenantStatuses.Active); + AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, periodStart: Now.AddMonths(-2), usedTokens: 700, warned80: true, notifiedExhausted: true); + + BudgetStateDto state = await store.AddUsageAsync(tenantId, tokens: 500, CancellationToken.None); + + Assert.Equal(500, state.UsedTokens); // 700 старого периода не переносятся + Assert.False(state.Warned80); + Assert.False(state.NotifiedExhausted); + TenantLimitEntity entity = db.TenantLimits.Single(x => x.TenantId == tenantId); + Assert.Equal(Now, entity.PeriodStart); + Assert.Equal(500, entity.UsedTokens); + } + + [Fact] + public async Task AddUsageAsync_WithinPeriod_Accumulates() + { + var tenantId = Guid.NewGuid(); + (DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); + AddTenant(db, tenantId, TenantStatuses.Active); + AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 300); + + BudgetStateDto state = await store.AddUsageAsync(tenantId, tokens: 200, CancellationToken.None); + + Assert.Equal(500, state.UsedTokens); + Assert.Equal(Now, state.PeriodStart); // период не истёк — начало не сдвинуто + } + + [Fact] + public async Task AddUsageAsync_Crossing80Percent_DoesNotSetWarned80() + { + var tenantId = Guid.NewGuid(); + (DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); + AddTenant(db, tenantId, TenantStatuses.Active); + AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 700); + + BudgetStateDto state = await store.AddUsageAsync(tenantId, tokens: 100, CancellationToken.None); + + Assert.Equal(800, state.UsedTokens); + Assert.False(state.Warned80); // списание флаг не выставляет (переход наблюдает планировщик через TryMark*) + Assert.False(state.NotifiedExhausted); + Assert.True(state.Allowed); // Allowed считается от used/budget, не от флагов + } + + [Fact] + public async Task AddUsageAsync_Exhaustion_DoesNotSetFlagsButDisallows() + { + var tenantId = Guid.NewGuid(); + (DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); + AddTenant(db, tenantId, TenantStatuses.Active); + AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 950); + + BudgetStateDto state = await store.AddUsageAsync(tenantId, tokens: 100, CancellationToken.None); + + Assert.Equal(1050, state.UsedTokens); + Assert.False(state.Warned80); + Assert.False(state.NotifiedExhausted); + Assert.False(state.Allowed); // бюджет исчерпан → ИИ запрещён (гейт Task 9) — от used/budget, без флагов + } + + [Fact] + public async Task AddUsageAsync_ZeroTokens_CreatesLazyRowWithoutIncrement() + { + // Нулевой расход не инкрементирует, но строка лимита заводится лениво (закрыт путь записи). + var tenantId = Guid.NewGuid(); + (DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); + AddTenant(db, tenantId, TenantStatuses.Active); + + BudgetStateDto state = await store.AddUsageAsync(tenantId, tokens: 0, CancellationToken.None); + + Assert.Equal(0, state.UsedTokens); + Assert.True(db.TenantLimits.Any(x => x.TenantId == tenantId)); + } + + [Fact] + public async Task UpdateBudgetAsync_AppliesNewBudgetAndPeriod_AndResetsFlags() + { + var tenantId = Guid.NewGuid(); + (DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); + AddTenant(db, tenantId, TenantStatuses.Active); + AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 900, warned80: true, notifiedExhausted: true); + + BudgetStateDto state = await store.UpdateBudgetAsync(tenantId, budgetTokens: 2000, period: TenantLimitPeriods.Day, CancellationToken.None); + + Assert.Equal(2000, state.BudgetTokens); + Assert.Equal(TenantLimitPeriods.Day, state.Period); + Assert.False(state.Warned80); + Assert.False(state.NotifiedExhausted); + Assert.Equal(900, state.UsedTokens); // расход периода сохраняется + } + + [Fact] + public async Task UpdateBudgetAsync_InvalidArguments_Throw() + { + var tenantId = Guid.NewGuid(); + (DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); + AddTenant(db, tenantId, TenantStatuses.Active); + + await Assert.ThrowsAsync( + () => store.UpdateBudgetAsync(tenantId, budgetTokens: -1, period: TenantLimitPeriods.Month, CancellationToken.None)); + await Assert.ThrowsAsync( + () => store.UpdateBudgetAsync(tenantId, budgetTokens: 1000, period: "week", CancellationToken.None)); + } + + [Fact] + public async Task TryMarkWarnedAsync_MarksOnceAtThreshold() + { + var tenantId = Guid.NewGuid(); + (DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); + AddTenant(db, tenantId, TenantStatuses.Active); + AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 800); + + Assert.True(await store.TryMarkWarnedAsync(tenantId, CancellationToken.None)); + Assert.False(await store.TryMarkWarnedAsync(tenantId, CancellationToken.None)); + Assert.True(db.TenantLimits.Single(x => x.TenantId == tenantId).Warned80); + + // Ниже порога флаг не выставляется. + var lowTenant = Guid.NewGuid(); + AddTenant(db, lowTenant, TenantStatuses.Active); + AddLimit(db, lowTenant, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 700); + Assert.False(await store.TryMarkWarnedAsync(lowTenant, CancellationToken.None)); + Assert.False(db.TenantLimits.Single(x => x.TenantId == lowTenant).Warned80); + } + + [Fact] + public async Task TryMarkNotifiedExhaustedAsync_MarksOnceAtExhaustion() + { + var tenantId = Guid.NewGuid(); + (DealDbContext db, TenantLimitStore store) = CreateStore(clock: () => Now); + AddTenant(db, tenantId, TenantStatuses.Active); + AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 1000); + + Assert.True(await store.TryMarkNotifiedExhaustedAsync(tenantId, CancellationToken.None)); + Assert.False(await store.TryMarkNotifiedExhaustedAsync(tenantId, CancellationToken.None)); + Assert.True(db.TenantLimits.Single(x => x.TenantId == tenantId).NotifiedExhausted); + } + + /// + /// Авто-очистка: строки с завершившимся периодом сбрасываются, текущие — нет. + /// + [Fact] + public async Task ResetExpiredPeriodsAsync_ResetsOnlyExpiredPeriods() + { + (DealDbContext db, TenantLimitStore store) = CreateStore(); + var expiredTenant = Guid.NewGuid(); + var currentTenant = Guid.NewGuid(); + // Период месяца начат за 2 месяца назад → истёк к Now; расход и флаги должны обнулиться. + AddLimit(db, expiredTenant, budgetTokens: 1000, TenantLimitPeriods.Month, Now.AddMonths(-2), + usedTokens: 800, warned80: true); + // Текущий период (месяц не истёк) — не трогается. + AddLimit(db, currentTenant, budgetTokens: 1000, TenantLimitPeriods.Month, Now.AddDays(-1), usedTokens: 300); + + int reset = await store.ResetExpiredPeriodsAsync(Now, CancellationToken.None); + + Assert.Equal(1, reset); + TenantLimitEntity expired = db.TenantLimits.Single(x => x.TenantId == expiredTenant); + Assert.Equal(0, expired.UsedTokens); + Assert.False(expired.Warned80); + Assert.False(expired.NotifiedExhausted); + Assert.Equal(Now, expired.PeriodStart); + Assert.Equal(300, db.TenantLimits.Single(x => x.TenantId == currentTenant).UsedTokens); + + // Идемпотентно: повторный проход на тех же данных ничего не сбрасывает. + Assert.Equal(0, await store.ResetExpiredPeriodsAsync(Now, CancellationToken.None)); + } + + // Создаёт адаптер на уникальной InMemory-БД с фиксированными/системными часами. + // clock: Источник «сейчас» (null — системные часы). + // Возвращает: Кортеж (контекст для seed/проверок, адаптер). + private static (DealDbContext Db, TenantLimitStore Store) CreateStore(Func? clock = null) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options; + var db = new DealDbContext(options); + var store = new TenantLimitStore( + db, TokenBudgetDefaults.Default, new TokenBudgetService(), clock ?? (() => DateTimeOffset.UtcNow)); + return (db, store); + } + + // Заводит строку public.tenants (статус нужен BudgetStateDto). + private static void AddTenant( + DealDbContext db, + Guid tenantId, + string status) + { + db.Tenants.Add(new TenantEntity + { + Id = tenantId, + Name = $"Тенант {tenantId:N}", + Status = status, + CreatedAt = Now, + }); + db.SaveChanges(); + } + + // Заводит строку tenant_limits с заданными полями (сценарий «уже есть расход/флаги»). + private static void AddLimit( + DealDbContext db, + Guid tenantId, + long budgetTokens, + string period, + DateTimeOffset periodStart, + long usedTokens, + bool warned80 = false, + bool notifiedExhausted = false) + { + db.TenantLimits.Add(new TenantLimitEntity + { + TenantId = tenantId, + BudgetTokens = budgetTokens, + Period = period, + PeriodStart = periodStart, + UsedTokens = usedTokens, + Warned80 = warned80, + NotifiedExhausted = notifiedExhausted, + UpdatedAt = Now, + }); + db.SaveChanges(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantRepositoryTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantRepositoryTests.cs index 2e29dd5..0c57e26 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantRepositoryTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantRepositoryTests.cs @@ -1,48 +1,48 @@ -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Tenants.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Юнит-тесты EF-адаптера на InMemory-провайдере -/// -public sealed class TenantRepositoryTests -{ - [Fact] - public async Task UpdateStatusAsync_ForExistingTenant_SetsStatusAndReturnsTrue() - { - var store = CreateStore(); - var tenantId = Guid.NewGuid(); - await store.CreateAsync( - new TenantRecordDto(tenantId, "Тенант", TenantStatuses.Active, DateTimeOffset.UtcNow), - CancellationToken.None); - - bool updated = await store.UpdateStatusAsync(tenantId, TenantStatuses.Suspended, CancellationToken.None); - - Assert.True(updated); - TenantRecordDto? tenant = await store.FindByIdAsync(tenantId, CancellationToken.None); - Assert.NotNull(tenant); - Assert.Equal(TenantStatuses.Suspended, tenant!.Status); - } - - [Fact] - public async Task UpdateStatusAsync_ForUnknownTenant_ReturnsFalse() - { - var store = CreateStore(); - - bool updated = await store.UpdateStatusAsync(Guid.NewGuid(), TenantStatuses.Suspended, CancellationToken.None); - - Assert.False(updated); - } - - // Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются). - private static TenantRepository CreateStore() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) - .Options; - return new TenantRepository(new DealDbContext(options)); - } -} +using Deal.Infrastructure.Persistence; +using Deal.Infrastructure.Persistence.Repositories; +using Deal.Modules.Tenants.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Tests.Unit.Infrastructure; + +/// +/// Юнит-тесты EF-адаптера на InMemory-провайдере +/// +public sealed class TenantRepositoryTests +{ + [Fact] + public async Task UpdateStatusAsync_ForExistingTenant_SetsStatusAndReturnsTrue() + { + var store = CreateStore(); + var tenantId = Guid.NewGuid(); + await store.CreateAsync( + new TenantRecordDto(tenantId, "Тенант", TenantStatuses.Active, DateTimeOffset.UtcNow), + CancellationToken.None); + + bool updated = await store.UpdateStatusAsync(tenantId, TenantStatuses.Suspended, CancellationToken.None); + + Assert.True(updated); + TenantRecordDto? tenant = await store.FindByIdAsync(tenantId, CancellationToken.None); + Assert.NotNull(tenant); + Assert.Equal(TenantStatuses.Suspended, tenant!.Status); + } + + [Fact] + public async Task UpdateStatusAsync_ForUnknownTenant_ReturnsFalse() + { + var store = CreateStore(); + + bool updated = await store.UpdateStatusAsync(Guid.NewGuid(), TenantStatuses.Suspended, CancellationToken.None); + + Assert.False(updated); + } + + // Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются). + private static TenantRepository CreateStore() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options; + return new TenantRepository(new DealDbContext(options)); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryBanGuardTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryBanGuardTests.cs index b5cb23d..7c3a5f4 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryBanGuardTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryBanGuardTests.cs @@ -1,118 +1,118 @@ -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Services; -using Deal.Modules.Settings.Application.Models; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Modules.Discovery; - -/// -/// Тесты DiscoveryBanGuard — суточный лимит авто-вступлений, flood-день, стоп-кран. -/// -public sealed class DiscoveryBanGuardTests -{ - // Фиксированный «сейчас» теста (UTC-полдень; границы суток далеко от краёв). - private static readonly DateTimeOffset Now = DateTimeOffset.Parse("2026-09-07T12:34:56Z"); - - // Начало UTC-суток Now (для seed-строк настроек/логов). - private static readonly DateTimeOffset TodayStart = new(Now.Year, Now.Month, Now.Day, 0, 0, 0, TimeSpan.Zero); - - [Fact] - public async Task CanAutoJoin_NoJoinsAndDefaults_Allowed() - { - (DiscoveryBanGuard guard, _, _) = Create(); - - Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None)); - Assert.False(await guard.GlobalPausedAsync(CancellationToken.None)); - } - - [Fact] - public async Task CanAutoJoin_LimitReachedToday_Blocked() - { - (DiscoveryBanGuard guard, FakeDiscoveryStore store, _) = Create(); - - for (int i = 0; i < 50; i++) - { - store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i)); - } - - Assert.False(await guard.CanAutoJoinAsync(CancellationToken.None)); - - // Вчерашние вступления в суточную квоту не входят: 49 сегодня → ещё можно. - FakeDiscoveryStore yesterdayStore = new(() => Now); - var settings = new FakeSettingsStore(); - var secondGuard = new DiscoveryBanGuard(yesterdayStore, settings, () => Now); - for (int i = 0; i < 49; i++) - { - yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i)); - } - - yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddDays(-1)); - Assert.True(await secondGuard.CanAutoJoinAsync(CancellationToken.None)); - } - - [Fact] - public async Task CountsOnlyJoinAutoEventsToday() - { - (DiscoveryBanGuard guard, FakeDiscoveryStore store, _) = Create(); - - store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddHours(1)); - store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddHours(2)); - store.SeedLog(DiscoveryLogEvents.JoinManual, TodayStart.AddHours(3)); // ручные — вне квоты - store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddDays(-1)); // вчера — вне квоты - - Assert.Equal(2, await guard.JoinsTodayAutoAsync(CancellationToken.None)); - } - - [Fact] - public async Task NoteFlood_BlocksAutoJoinsUntilEndOfDay() - { - (DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create(); - - await guard.NoteFloodAsync(CancellationToken.None); - - Assert.True(await guard.FloodTodayAsync(CancellationToken.None)); - Assert.False(await guard.CanAutoJoinAsync(CancellationToken.None)); - Assert.Equal(TodayStart.ToUnixTimeMilliseconds().ToString(), settings.GetStoredJson(SettingsKeys.DiscFloodDay)); - } - - [Fact] - public async Task FloodYesterday_DoesNotBlockToday() - { - (DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create(); - settings.Preload(SettingsKeys.DiscFloodDay, TodayStart.AddDays(-1).ToUnixTimeMilliseconds().ToString()); - - Assert.False(await guard.FloodTodayAsync(CancellationToken.None)); - Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None)); - } - - [Fact] - public async Task GlobalPaused_BlocksAutoJoins() - { - (DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create(); - settings.Preload(SettingsKeys.DiscPaused, "true"); - - Assert.True(await guard.GlobalPausedAsync(CancellationToken.None)); - Assert.False(await guard.CanAutoJoinAsync(CancellationToken.None)); - } - - [Fact] - public async Task CustomJoinLimit_AppliedWhenSettingStored() - { - (DiscoveryBanGuard guard, FakeDiscoveryStore store, FakeSettingsStore settings) = Create(); - settings.Preload(SettingsKeys.DiscJoinLimit, "2"); - - Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None)); - store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddHours(1)); - store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddHours(2)); - Assert.False(await guard.CanAutoJoinAsync(CancellationToken.None)); - } - - // Создаёт связку гард + фейки на фиксированных «часах» (store и гард смотрят одно время). - private static (DiscoveryBanGuard Guard, FakeDiscoveryStore Store, FakeSettingsStore Settings) Create() - { - var store = new FakeDiscoveryStore(() => Now); - var settings = new FakeSettingsStore(); - var guard = new DiscoveryBanGuard(store, settings, () => Now); - return (guard, store, settings); - } -} +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Services; +using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Modules.Settings; + +namespace Deal.Tests.Unit.Modules.Discovery; + +/// +/// Тесты DiscoveryBanGuard — суточный лимит авто-вступлений, flood-день, стоп-кран. +/// +public sealed class DiscoveryBanGuardTests +{ + // Фиксированный «сейчас» теста (UTC-полдень; границы суток далеко от краёв). + private static readonly DateTimeOffset Now = DateTimeOffset.Parse("2026-09-07T12:34:56Z"); + + // Начало UTC-суток Now (для seed-строк настроек/логов). + private static readonly DateTimeOffset TodayStart = new(Now.Year, Now.Month, Now.Day, 0, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task CanAutoJoin_NoJoinsAndDefaults_Allowed() + { + (DiscoveryBanGuard guard, _, _) = Create(); + + Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None)); + Assert.False(await guard.GlobalPausedAsync(CancellationToken.None)); + } + + [Fact] + public async Task CanAutoJoin_LimitReachedToday_Blocked() + { + (DiscoveryBanGuard guard, FakeDiscoveryStore store, _) = Create(); + + for (int i = 0; i < 50; i++) + { + store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i)); + } + + Assert.False(await guard.CanAutoJoinAsync(CancellationToken.None)); + + // Вчерашние вступления в суточную квоту не входят: 49 сегодня → ещё можно. + FakeDiscoveryStore yesterdayStore = new(() => Now); + var settings = new FakeSettingsStore(); + var secondGuard = new DiscoveryBanGuard(yesterdayStore, settings, () => Now); + for (int i = 0; i < 49; i++) + { + yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i)); + } + + yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddDays(-1)); + Assert.True(await secondGuard.CanAutoJoinAsync(CancellationToken.None)); + } + + [Fact] + public async Task CountsOnlyJoinAutoEventsToday() + { + (DiscoveryBanGuard guard, FakeDiscoveryStore store, _) = Create(); + + store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddHours(1)); + store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddHours(2)); + store.SeedLog(DiscoveryLogEvents.JoinManual, TodayStart.AddHours(3)); // ручные — вне квоты + store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddDays(-1)); // вчера — вне квоты + + Assert.Equal(2, await guard.JoinsTodayAutoAsync(CancellationToken.None)); + } + + [Fact] + public async Task NoteFlood_BlocksAutoJoinsUntilEndOfDay() + { + (DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create(); + + await guard.NoteFloodAsync(CancellationToken.None); + + Assert.True(await guard.FloodTodayAsync(CancellationToken.None)); + Assert.False(await guard.CanAutoJoinAsync(CancellationToken.None)); + Assert.Equal(TodayStart.ToUnixTimeMilliseconds().ToString(), settings.GetStoredJson(SettingsKeys.DiscFloodDay)); + } + + [Fact] + public async Task FloodYesterday_DoesNotBlockToday() + { + (DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create(); + settings.Preload(SettingsKeys.DiscFloodDay, TodayStart.AddDays(-1).ToUnixTimeMilliseconds().ToString()); + + Assert.False(await guard.FloodTodayAsync(CancellationToken.None)); + Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None)); + } + + [Fact] + public async Task GlobalPaused_BlocksAutoJoins() + { + (DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create(); + settings.Preload(SettingsKeys.DiscPaused, "true"); + + Assert.True(await guard.GlobalPausedAsync(CancellationToken.None)); + Assert.False(await guard.CanAutoJoinAsync(CancellationToken.None)); + } + + [Fact] + public async Task CustomJoinLimit_AppliedWhenSettingStored() + { + (DiscoveryBanGuard guard, FakeDiscoveryStore store, FakeSettingsStore settings) = Create(); + settings.Preload(SettingsKeys.DiscJoinLimit, "2"); + + Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None)); + store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddHours(1)); + store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddHours(2)); + Assert.False(await guard.CanAutoJoinAsync(CancellationToken.None)); + } + + // Создаёт связку гард + фейки на фиксированных «часах» (store и гард смотрят одно время). + private static (DiscoveryBanGuard Guard, FakeDiscoveryStore Store, FakeSettingsStore Settings) Create() + { + var store = new FakeDiscoveryStore(() => Now); + var settings = new FakeSettingsStore(); + var guard = new DiscoveryBanGuard(store, settings, () => Now); + return (guard, store, settings); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs index 0a9dcec..ab854c0 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs @@ -1,403 +1,403 @@ -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Services; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Modules.Discovery; - -/// -/// Тесты DiscoveryTasksService — создание/патч/удаление/start/pause и план-бюджет. -/// -public sealed class DiscoveryTasksServiceTests -{ - [Fact] - public async Task Create_EmptyName_ThrowsAndCreatesNothing() - { - (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); - - DiscoveryValidationException error = await Assert.ThrowsAsync( - () => service.CreateAsync(new DiscoveryTaskDraft { Name = " " }, CancellationToken.None)); - - Assert.Equal(DiscoveryTasksService.NameRequiredDetail, error.Message); - Assert.Empty(store.Tasks); - } - - [Fact] - public async Task Create_PlanBelowOne_Throws() - { - (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); - - DiscoveryValidationException error = await Assert.ThrowsAsync( - () => service.CreateAsync(NewDraft(planJoins: 0), CancellationToken.None)); - - Assert.Equal(DiscoveryPlanGuard.PlanTooSmallDetail, error.Message); - Assert.Empty(store.Tasks); - } - - [Fact] - public async Task Create_PlanAboveDailyLimit_Throws() - { - (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); - - DiscoveryValidationException error = await Assert.ThrowsAsync( - () => service.CreateAsync(NewDraft(planJoins: 51), CancellationToken.None)); - - Assert.Equal(string.Format(DiscoveryPlanGuard.PlanTooBigFormat, 51, 50), error.Message); - Assert.Empty(store.Tasks); - } - - [Fact] - public async Task Create_TaskAtLimitLeavesNoBudget_Throws() - { - (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); - store.SeedTask(Task("dt_1", planJoins: 50)); - - DiscoveryValidationException error = await Assert.ThrowsAsync( - () => service.CreateAsync(NewDraft(planJoins: 1), CancellationToken.None)); - - Assert.Equal(string.Format(DiscoveryPlanGuard.BudgetExceededFormat, 50, 50, 1), error.Message); - Assert.Single(store.Tasks); - } - - [Fact] - public async Task Create_RemainingQuota_FillsUpToLimitAndRejectsOver() - { - (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); - store.SeedTask(Task("dt_1", planJoins: 25)); - - // 25 + 25 = 50 — вторая задача на 25 влезает (сумма планов ≤ discJoinLimit). - DiscoveryTaskDto second = await service.CreateAsync(NewDraft(planJoins: 25), CancellationToken.None); - Assert.Equal(25, second.PlanJoins); - - // Остатка нет: третья задача (даже на 1) не создаётся. - DiscoveryValidationException error = await Assert.ThrowsAsync( - () => service.CreateAsync(NewDraft(planJoins: 1), CancellationToken.None)); - Assert.Equal(string.Format(DiscoveryPlanGuard.BudgetExceededFormat, 50, 50, 1), error.Message); - Assert.Equal(2, store.Tasks.Count); - } - - [Fact] - public async Task Create_NormalizesAndCreatesDraft() - { - (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); - - DiscoveryTaskDto task = await service.CreateAsync( - new DiscoveryTaskDraft - { - Name = " Ищем клиентов ", - Description = "Ниша: CRM для агентств", - Keywords = new[] { " crm ", "", " агентство " }, - MinSubscribers = -10, - Lang = "fr", - Threshold = 0, - AutoJoin = true, - }, - CancellationToken.None); - - Assert.StartsWith("dt_", task.Id); - Assert.Equal("Ищем клиентов", task.Name); // name — Trim - Assert.Equal(DiscoveryTaskStatuses.Draft, task.Status); - Assert.Equal(new[] { "crm", "агентство" }, task.Keywords); // без пустых, Trim - Assert.Equal(0, task.MinSubscribers); // кламп ≥0 - Assert.Equal("ru", task.Lang); // не ru/any → ru - Assert.Equal(1, task.Threshold); // кламп 1..100 - Assert.Equal(10, task.SampleSize); // дефолт discEvalSample - Assert.Equal(1, task.PlanJoins); // дефолт плана - Assert.True(task.AutoJoin); - Assert.Equal(0, task.SearchIdx); - Assert.False(task.SearchDone); - Assert.Equal(0, task.Found); - Assert.Equal(0, task.Evaluated); - Assert.Equal(0, task.Joined); - Assert.Equal(0, task.Rejected); - Assert.Single(store.Tasks); - } - - [Fact] - public async Task Create_CustomThresholdAndSample_Respected() - { - (DiscoveryTasksService service, FakeDiscoveryStore store, FakeSettingsStore settings) = Create(); - settings.Preload("discEvalThreshold", "60"); - settings.Preload("discEvalSample", "20"); - - DiscoveryTaskDto task = await service.CreateAsync( - new DiscoveryTaskDraft { Name = "Задача", Threshold = 80, SampleSize = 15 }, - CancellationToken.None); - - Assert.Equal(80, task.Threshold); - Assert.Equal(15, task.SampleSize); - Assert.Single(store.Tasks); - } - - [Fact] - public async Task Patch_MissingTask_ReturnsNull() - { - (DiscoveryTasksService service, _, _) = Create(); - - DiscoveryTaskDto? patched = await service.PatchAsync( - "dt_missing", new DiscoveryTaskPatch { Name = "Новое" }, CancellationToken.None); - - Assert.Null(patched); - } - - [Fact] - public async Task Patch_AppliesFieldsAndClamps() - { - (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); - store.SeedTask(Task("dt_1", keywords: new[] { "старое" })); - - DiscoveryTaskDto? task = await service.PatchAsync( - "dt_1", - new DiscoveryTaskPatch - { - Name = " Новое имя ", - Threshold = 150, - MinSubscribers = -3, - Lang = "xx", - Keywords = new[] { " ", "ключ" }, - }, - CancellationToken.None); - - Assert.NotNull(task); - Assert.Equal("Новое имя", task!.Name); // Trim - Assert.Equal(100, task.Threshold); // кламп 1..100 - Assert.Equal(0, task.MinSubscribers); // кламп ≥0 - Assert.Equal("ru", task.Lang); // не ru/any → ru - Assert.Equal(new[] { "ключ" }, task.Keywords); // пустые выброшены - Assert.True(task.UpdatedAt > 0); // bump UpdatedAt - } - - [Fact] - public async Task Patch_NullOnlyPatch_ReturnsCurrentWithoutWrite() - { - (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); - store.SeedTask(Task("dt_1", name: "Старое")); - - DiscoveryTaskDto? task = await service.PatchAsync("dt_1", new DiscoveryTaskPatch(), CancellationToken.None); - - Assert.NotNull(task); - Assert.Equal("Старое", task!.Name); - Assert.Single(store.Tasks); - } - - [Fact] - public async Task Patch_PlanGrowthBeyondBudget_Throws() - { - (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); - store.SeedTask(Task("dt_1", planJoins: 20)); - store.SeedTask(Task("dt_2", planJoins: 20)); - - // Рост плана dt_1 до 30: занято другими 20 + 30 = 50 — влезает. - DiscoveryTaskDto? task = await service.PatchAsync( - "dt_1", new DiscoveryTaskPatch { PlanJoins = 30 }, CancellationToken.None); - Assert.NotNull(task); - Assert.Equal(30, task!.PlanJoins); - - // Рост до 31: 20 + 31 = 51 > 50 — бюджет исчерпан (исключая саму задачу). - DiscoveryValidationException error = await Assert.ThrowsAsync( - () => service.PatchAsync("dt_1", new DiscoveryTaskPatch { PlanJoins = 31 }, CancellationToken.None)); - Assert.Equal(string.Format(DiscoveryPlanGuard.BudgetExceededFormat, 20, 50, 31), error.Message); - } - - [Fact] - public async Task Start_MissingTask_ReturnsNull() - { - (DiscoveryTasksService service, _, _) = Create(); - - DiscoveryTaskDto? task = await service.StartAsync("dt_missing", CancellationToken.None); - - Assert.Null(task); - } - - [Fact] - public async Task Start_NoKeywords_Throws() - { - (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); - store.SeedTask(Task("dt_1", keywords: Array.Empty())); - - DiscoveryValidationException error = await Assert.ThrowsAsync( - () => service.StartAsync("dt_1", CancellationToken.None)); - - Assert.Equal(DiscoveryTasksService.NoKeywordsDetail, error.Message); - } - - [Fact] - public async Task Start_FromPaused_KeepsSearchProgress() - { - (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); - store.SeedTask(Task("dt_1", status: DiscoveryTaskStatuses.Paused, keywords: new[] { "a", "b" }, searchIdx: 1)); - - DiscoveryTaskDto? task = await service.StartAsync("dt_1", CancellationToken.None); - - Assert.NotNull(task); - Assert.Equal(DiscoveryTaskStatuses.Running, task!.Status); - Assert.Equal(1, task.SearchIdx); // продолжение из paused — прогресс сохраняется (python L342–344) - Assert.False(task.SearchDone); - } - - [Fact] - public async Task Start_FromDone_ResetsSearchProgress() - { - (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); - store.SeedTask(Task( - "dt_1", - status: DiscoveryTaskStatuses.Done, - keywords: new[] { "a", "b" }, - searchIdx: 2, - searchDone: true, - found: 7, - joined: 50)); - - DiscoveryTaskDto? task = await service.StartAsync("dt_1", CancellationToken.None); - - Assert.NotNull(task); - Assert.Equal(DiscoveryTaskStatuses.Running, task!.Status); - Assert.Equal(0, task.SearchIdx); // повторный прогон завершённой — свежий проход (python L332–339) - Assert.False(task.SearchDone); - Assert.Equal(0, task.Found); - Assert.Equal(0, task.Joined); - } - - [Fact] - public async Task Pause_SetsPaused() - { - (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); - store.SeedTask(Task("dt_1", status: DiscoveryTaskStatuses.Running, keywords: new[] { "a" })); - - DiscoveryTaskDto? task = await service.PauseAsync("dt_1", CancellationToken.None); - - Assert.NotNull(task); - Assert.Equal(DiscoveryTaskStatuses.Paused, task!.Status); - } - - [Fact] - public async Task Delete_RemovesTaskCandidatesAndLog_KeepsBlacklist() - { - (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); - store.SeedTask(Task("dt_1")); - store.SeedTask(Task("dt_2")); - store.SeedCandidate(Candidate("c_1", "dt_1")); - store.SeedCandidate(Candidate("c_2", "dt_2")); - await store.UpsertBlacklistAsync("c_1", "Источник", "причина", CancellationToken.None); - - bool deleted = await service.DeleteAsync("dt_1", CancellationToken.None); - - Assert.True(deleted); - Assert.Single(store.Tasks); // dt_2 осталась - Assert.Equal("dt_2", Assert.Single(store.Tasks).Id); - Assert.Single(store.Candidates); // кандидат dt_2 остался - Assert.Equal("c_2", Assert.Single(store.Candidates).DialogId); - Assert.Single(store.Blacklist); // чёрный список общий — не удаляется - } - - [Fact] - public async Task Delete_MissingTask_ReturnsFalse() - { - (DiscoveryTasksService service, _, _) = Create(); - - bool deleted = await service.DeleteAsync("dt_missing", CancellationToken.None); - - Assert.False(deleted); - } - - [Fact] - public async Task AdvanceSearch_MovesIndexAndFinishes() - { - (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); - store.SeedTask(Task("dt_1", keywords: new[] { "a", "b" })); - - Assert.True(await service.AdvanceSearchAsync("dt_1", CancellationToken.None)); - DiscoveryTaskDto? afterFirst = store.Tasks.Single(); - Assert.Equal(1, afterFirst.SearchIdx); - Assert.False(afterFirst.SearchDone); - - Assert.True(await service.AdvanceSearchAsync("dt_1", CancellationToken.None)); - DiscoveryTaskDto afterLast = store.Tasks.Single(); - Assert.Equal(2, afterLast.SearchIdx); - Assert.True(afterLast.SearchDone); // индекс за последним ключом — проход закрыт (python L376–377) - } - - [Fact] - public async Task BumpCounter_IncrementsField() - { - (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); - store.SeedTask(Task("dt_1")); - - Assert.True(await service.BumpCounterAsync("dt_1", DiscoveryCounterField.Evaluated, 3, CancellationToken.None)); - Assert.Equal(3, store.Tasks.Single().Evaluated); - Assert.False(await service.BumpCounterAsync("dt_missing", DiscoveryCounterField.Found, 1, CancellationToken.None)); - } - - // Создаёт связку сервис + фейки (пустые настройки → дефолты SettingsDefaults). - private static (DiscoveryTasksService Service, FakeDiscoveryStore Store, FakeSettingsStore Settings) Create() - { - var store = new FakeDiscoveryStore(); - var settings = new FakeSettingsStore(); - var planGuard = new DiscoveryPlanGuard(store, settings); - return (new DiscoveryTasksService(store, planGuard, settings), store, settings); - } - - // Draft задачи с дефолтными полями (name/planJoins переопределяются). - private static DiscoveryTaskDraft NewDraft(string name = "Задача", int? planJoins = null) - { - return new DiscoveryTaskDraft { Name = name, PlanJoins = planJoins }; - } - - // Задача как строка DiscTasks (адаптер-форма; неиспользуемые поля — нейтральные значения). - private static DiscoveryTaskDto Task( - string id, - string name = "Задача", - string status = DiscoveryTaskStatuses.Running, - string[]? keywords = null, - int planJoins = 5, - int searchIdx = 0, - bool searchDone = false, - int found = 0, - int evaluated = 0, - int joined = 0, - int rejected = 0) - { - return new DiscoveryTaskDto( - id, - name, - string.Empty, - keywords ?? new[] { "ключ" }, - MinSubscribers: 0, - Lang: "ru", - Threshold: 40, - SampleSize: 10, - planJoins, - AutoJoin: true, - status, - searchIdx, - searchDone, - found, - evaluated, - joined, - rejected, - CreatedAt: 1000, - UpdatedAt: 1000); - } - - // Кандидат как строка DiscCandidates (адаптер-форма). - private static DiscoveryCandidateDto Candidate(string dialogId, string taskId) - { - return new DiscoveryCandidateDto( - dialogId, - taskId, - dialogId, - string.Empty, - DiscoveryCandidateKinds.Channel, - "#666", - Participants: null, - LangRu: null, - new List(), - new List(), - FitRatio: null, - DiscoveryCandidateStatuses.New, - AutoJoined: false, - JoinFailures: 0, - CreatedAt: 1000, - UpdatedAt: 1000); - } -} +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Services; +using Deal.Tests.Unit.Modules.Settings; + +namespace Deal.Tests.Unit.Modules.Discovery; + +/// +/// Тесты DiscoveryTasksService — создание/патч/удаление/start/pause и план-бюджет. +/// +public sealed class DiscoveryTasksServiceTests +{ + [Fact] + public async Task Create_EmptyName_ThrowsAndCreatesNothing() + { + (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); + + DiscoveryValidationException error = await Assert.ThrowsAsync( + () => service.CreateAsync(new DiscoveryTaskDraft { Name = " " }, CancellationToken.None)); + + Assert.Equal(DiscoveryTasksService.NameRequiredDetail, error.Message); + Assert.Empty(store.Tasks); + } + + [Fact] + public async Task Create_PlanBelowOne_Throws() + { + (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); + + DiscoveryValidationException error = await Assert.ThrowsAsync( + () => service.CreateAsync(NewDraft(planJoins: 0), CancellationToken.None)); + + Assert.Equal(DiscoveryPlanGuard.PlanTooSmallDetail, error.Message); + Assert.Empty(store.Tasks); + } + + [Fact] + public async Task Create_PlanAboveDailyLimit_Throws() + { + (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); + + DiscoveryValidationException error = await Assert.ThrowsAsync( + () => service.CreateAsync(NewDraft(planJoins: 51), CancellationToken.None)); + + Assert.Equal(string.Format(DiscoveryPlanGuard.PlanTooBigFormat, 51, 50), error.Message); + Assert.Empty(store.Tasks); + } + + [Fact] + public async Task Create_TaskAtLimitLeavesNoBudget_Throws() + { + (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); + store.SeedTask(Task("dt_1", planJoins: 50)); + + DiscoveryValidationException error = await Assert.ThrowsAsync( + () => service.CreateAsync(NewDraft(planJoins: 1), CancellationToken.None)); + + Assert.Equal(string.Format(DiscoveryPlanGuard.BudgetExceededFormat, 50, 50, 1), error.Message); + Assert.Single(store.Tasks); + } + + [Fact] + public async Task Create_RemainingQuota_FillsUpToLimitAndRejectsOver() + { + (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); + store.SeedTask(Task("dt_1", planJoins: 25)); + + // 25 + 25 = 50 — вторая задача на 25 влезает (сумма планов ≤ discJoinLimit). + DiscoveryTaskDto second = await service.CreateAsync(NewDraft(planJoins: 25), CancellationToken.None); + Assert.Equal(25, second.PlanJoins); + + // Остатка нет: третья задача (даже на 1) не создаётся. + DiscoveryValidationException error = await Assert.ThrowsAsync( + () => service.CreateAsync(NewDraft(planJoins: 1), CancellationToken.None)); + Assert.Equal(string.Format(DiscoveryPlanGuard.BudgetExceededFormat, 50, 50, 1), error.Message); + Assert.Equal(2, store.Tasks.Count); + } + + [Fact] + public async Task Create_NormalizesAndCreatesDraft() + { + (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); + + DiscoveryTaskDto task = await service.CreateAsync( + new DiscoveryTaskDraft + { + Name = " Ищем клиентов ", + Description = "Ниша: CRM для агентств", + Keywords = new[] { " crm ", "", " агентство " }, + MinSubscribers = -10, + Lang = "fr", + Threshold = 0, + AutoJoin = true, + }, + CancellationToken.None); + + Assert.StartsWith("dt_", task.Id); + Assert.Equal("Ищем клиентов", task.Name); // name — Trim + Assert.Equal(DiscoveryTaskStatuses.Draft, task.Status); + Assert.Equal(new[] { "crm", "агентство" }, task.Keywords); // без пустых, Trim + Assert.Equal(0, task.MinSubscribers); // кламп ≥0 + Assert.Equal("ru", task.Lang); // не ru/any → ru + Assert.Equal(1, task.Threshold); // кламп 1..100 + Assert.Equal(10, task.SampleSize); // дефолт discEvalSample + Assert.Equal(1, task.PlanJoins); // дефолт плана + Assert.True(task.AutoJoin); + Assert.Equal(0, task.SearchIdx); + Assert.False(task.SearchDone); + Assert.Equal(0, task.Found); + Assert.Equal(0, task.Evaluated); + Assert.Equal(0, task.Joined); + Assert.Equal(0, task.Rejected); + Assert.Single(store.Tasks); + } + + [Fact] + public async Task Create_CustomThresholdAndSample_Respected() + { + (DiscoveryTasksService service, FakeDiscoveryStore store, FakeSettingsStore settings) = Create(); + settings.Preload("discEvalThreshold", "60"); + settings.Preload("discEvalSample", "20"); + + DiscoveryTaskDto task = await service.CreateAsync( + new DiscoveryTaskDraft { Name = "Задача", Threshold = 80, SampleSize = 15 }, + CancellationToken.None); + + Assert.Equal(80, task.Threshold); + Assert.Equal(15, task.SampleSize); + Assert.Single(store.Tasks); + } + + [Fact] + public async Task Patch_MissingTask_ReturnsNull() + { + (DiscoveryTasksService service, _, _) = Create(); + + DiscoveryTaskDto? patched = await service.PatchAsync( + "dt_missing", new DiscoveryTaskPatch { Name = "Новое" }, CancellationToken.None); + + Assert.Null(patched); + } + + [Fact] + public async Task Patch_AppliesFieldsAndClamps() + { + (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); + store.SeedTask(Task("dt_1", keywords: new[] { "старое" })); + + DiscoveryTaskDto? task = await service.PatchAsync( + "dt_1", + new DiscoveryTaskPatch + { + Name = " Новое имя ", + Threshold = 150, + MinSubscribers = -3, + Lang = "xx", + Keywords = new[] { " ", "ключ" }, + }, + CancellationToken.None); + + Assert.NotNull(task); + Assert.Equal("Новое имя", task!.Name); // Trim + Assert.Equal(100, task.Threshold); // кламп 1..100 + Assert.Equal(0, task.MinSubscribers); // кламп ≥0 + Assert.Equal("ru", task.Lang); // не ru/any → ru + Assert.Equal(new[] { "ключ" }, task.Keywords); // пустые выброшены + Assert.True(task.UpdatedAt > 0); // bump UpdatedAt + } + + [Fact] + public async Task Patch_NullOnlyPatch_ReturnsCurrentWithoutWrite() + { + (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); + store.SeedTask(Task("dt_1", name: "Старое")); + + DiscoveryTaskDto? task = await service.PatchAsync("dt_1", new DiscoveryTaskPatch(), CancellationToken.None); + + Assert.NotNull(task); + Assert.Equal("Старое", task!.Name); + Assert.Single(store.Tasks); + } + + [Fact] + public async Task Patch_PlanGrowthBeyondBudget_Throws() + { + (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); + store.SeedTask(Task("dt_1", planJoins: 20)); + store.SeedTask(Task("dt_2", planJoins: 20)); + + // Рост плана dt_1 до 30: занято другими 20 + 30 = 50 — влезает. + DiscoveryTaskDto? task = await service.PatchAsync( + "dt_1", new DiscoveryTaskPatch { PlanJoins = 30 }, CancellationToken.None); + Assert.NotNull(task); + Assert.Equal(30, task!.PlanJoins); + + // Рост до 31: 20 + 31 = 51 > 50 — бюджет исчерпан (исключая саму задачу). + DiscoveryValidationException error = await Assert.ThrowsAsync( + () => service.PatchAsync("dt_1", new DiscoveryTaskPatch { PlanJoins = 31 }, CancellationToken.None)); + Assert.Equal(string.Format(DiscoveryPlanGuard.BudgetExceededFormat, 20, 50, 31), error.Message); + } + + [Fact] + public async Task Start_MissingTask_ReturnsNull() + { + (DiscoveryTasksService service, _, _) = Create(); + + DiscoveryTaskDto? task = await service.StartAsync("dt_missing", CancellationToken.None); + + Assert.Null(task); + } + + [Fact] + public async Task Start_NoKeywords_Throws() + { + (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); + store.SeedTask(Task("dt_1", keywords: Array.Empty())); + + DiscoveryValidationException error = await Assert.ThrowsAsync( + () => service.StartAsync("dt_1", CancellationToken.None)); + + Assert.Equal(DiscoveryTasksService.NoKeywordsDetail, error.Message); + } + + [Fact] + public async Task Start_FromPaused_KeepsSearchProgress() + { + (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); + store.SeedTask(Task("dt_1", status: DiscoveryTaskStatuses.Paused, keywords: new[] { "a", "b" }, searchIdx: 1)); + + DiscoveryTaskDto? task = await service.StartAsync("dt_1", CancellationToken.None); + + Assert.NotNull(task); + Assert.Equal(DiscoveryTaskStatuses.Running, task!.Status); + Assert.Equal(1, task.SearchIdx); // продолжение из paused — прогресс сохраняется (python L342–344) + Assert.False(task.SearchDone); + } + + [Fact] + public async Task Start_FromDone_ResetsSearchProgress() + { + (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); + store.SeedTask(Task( + "dt_1", + status: DiscoveryTaskStatuses.Done, + keywords: new[] { "a", "b" }, + searchIdx: 2, + searchDone: true, + found: 7, + joined: 50)); + + DiscoveryTaskDto? task = await service.StartAsync("dt_1", CancellationToken.None); + + Assert.NotNull(task); + Assert.Equal(DiscoveryTaskStatuses.Running, task!.Status); + Assert.Equal(0, task.SearchIdx); // повторный прогон завершённой — свежий проход (python L332–339) + Assert.False(task.SearchDone); + Assert.Equal(0, task.Found); + Assert.Equal(0, task.Joined); + } + + [Fact] + public async Task Pause_SetsPaused() + { + (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); + store.SeedTask(Task("dt_1", status: DiscoveryTaskStatuses.Running, keywords: new[] { "a" })); + + DiscoveryTaskDto? task = await service.PauseAsync("dt_1", CancellationToken.None); + + Assert.NotNull(task); + Assert.Equal(DiscoveryTaskStatuses.Paused, task!.Status); + } + + [Fact] + public async Task Delete_RemovesTaskCandidatesAndLog_KeepsBlacklist() + { + (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); + store.SeedTask(Task("dt_1")); + store.SeedTask(Task("dt_2")); + store.SeedCandidate(Candidate("c_1", "dt_1")); + store.SeedCandidate(Candidate("c_2", "dt_2")); + await store.UpsertBlacklistAsync("c_1", "Источник", "причина", CancellationToken.None); + + bool deleted = await service.DeleteAsync("dt_1", CancellationToken.None); + + Assert.True(deleted); + Assert.Single(store.Tasks); // dt_2 осталась + Assert.Equal("dt_2", Assert.Single(store.Tasks).Id); + Assert.Single(store.Candidates); // кандидат dt_2 остался + Assert.Equal("c_2", Assert.Single(store.Candidates).DialogId); + Assert.Single(store.Blacklist); // чёрный список общий — не удаляется + } + + [Fact] + public async Task Delete_MissingTask_ReturnsFalse() + { + (DiscoveryTasksService service, _, _) = Create(); + + bool deleted = await service.DeleteAsync("dt_missing", CancellationToken.None); + + Assert.False(deleted); + } + + [Fact] + public async Task AdvanceSearch_MovesIndexAndFinishes() + { + (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); + store.SeedTask(Task("dt_1", keywords: new[] { "a", "b" })); + + Assert.True(await service.AdvanceSearchAsync("dt_1", CancellationToken.None)); + DiscoveryTaskDto? afterFirst = store.Tasks.Single(); + Assert.Equal(1, afterFirst.SearchIdx); + Assert.False(afterFirst.SearchDone); + + Assert.True(await service.AdvanceSearchAsync("dt_1", CancellationToken.None)); + DiscoveryTaskDto afterLast = store.Tasks.Single(); + Assert.Equal(2, afterLast.SearchIdx); + Assert.True(afterLast.SearchDone); // индекс за последним ключом — проход закрыт (python L376–377) + } + + [Fact] + public async Task BumpCounter_IncrementsField() + { + (DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); + store.SeedTask(Task("dt_1")); + + Assert.True(await service.BumpCounterAsync("dt_1", DiscoveryCounterField.Evaluated, 3, CancellationToken.None)); + Assert.Equal(3, store.Tasks.Single().Evaluated); + Assert.False(await service.BumpCounterAsync("dt_missing", DiscoveryCounterField.Found, 1, CancellationToken.None)); + } + + // Создаёт связку сервис + фейки (пустые настройки → дефолты SettingsDefaults). + private static (DiscoveryTasksService Service, FakeDiscoveryStore Store, FakeSettingsStore Settings) Create() + { + var store = new FakeDiscoveryStore(); + var settings = new FakeSettingsStore(); + var planGuard = new DiscoveryPlanGuard(store, settings); + return (new DiscoveryTasksService(store, planGuard, settings), store, settings); + } + + // Draft задачи с дефолтными полями (name/planJoins переопределяются). + private static DiscoveryTaskDraft NewDraft(string name = "Задача", int? planJoins = null) + { + return new DiscoveryTaskDraft { Name = name, PlanJoins = planJoins }; + } + + // Задача как строка DiscTasks (адаптер-форма; неиспользуемые поля — нейтральные значения). + private static DiscoveryTaskDto Task( + string id, + string name = "Задача", + string status = DiscoveryTaskStatuses.Running, + string[]? keywords = null, + int planJoins = 5, + int searchIdx = 0, + bool searchDone = false, + int found = 0, + int evaluated = 0, + int joined = 0, + int rejected = 0) + { + return new DiscoveryTaskDto( + id, + name, + string.Empty, + keywords ?? new[] { "ключ" }, + MinSubscribers: 0, + Lang: "ru", + Threshold: 40, + SampleSize: 10, + planJoins, + AutoJoin: true, + status, + searchIdx, + searchDone, + found, + evaluated, + joined, + rejected, + CreatedAt: 1000, + UpdatedAt: 1000); + } + + // Кандидат как строка DiscCandidates (адаптер-форма). + private static DiscoveryCandidateDto Candidate(string dialogId, string taskId) + { + return new DiscoveryCandidateDto( + dialogId, + taskId, + dialogId, + string.Empty, + DiscoveryCandidateKinds.Channel, + "#666", + Participants: null, + LangRu: null, + new List(), + new List(), + FitRatio: null, + DiscoveryCandidateStatuses.New, + AutoJoined: false, + JoinFailures: 0, + CreatedAt: 1000, + UpdatedAt: 1000); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AiClassifyContextBuilderTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AiClassifyContextBuilderTests.cs index 8ca2fdb..0e0628e 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AiClassifyContextBuilderTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AiClassifyContextBuilderTests.cs @@ -1,181 +1,181 @@ -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Modules.Kanban; - -/// -/// Тесты контекст-билдера ИИ-классификации . -/// -public sealed class AiClassifyContextBuilderTests -{ - private const string Domain = "IT-разработка"; - - private static readonly string[] Keywords = { "бот", "сайт" }; - - private static string Json(object value) => System.Text.Json.JsonSerializer.Serialize(value); - - // ─── Промпты: fill_prompt ({domain}/{keywords}) ───────────────────────── - - [Fact] - public async Task BuildFilterPrompt_FillsDomainAndKeywords() - { - FakeSettingsStore settings = Context(out FakeKanjStore kanj); - settings.Preload(SettingsKeys.AiFilterPrompt, Json("Страж. Сфера: {domain}. Маркеры: {keywords}.")); - settings.Preload(SettingsKeys.DomainDescription, Json(Domain)); - settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords)); - var builder = new AiClassifyContextBuilder(settings, kanj); - - string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None); - - Assert.Equal("Страж. Сфера: IT-разработка. Маркеры: бот, сайт.", prompt); - } - - [Fact] - public async Task BuildFilterPrompt_NoDomain_UsesFallbackTextAndHint() - { - FakeSettingsStore settings = Context(out FakeKanjStore kanj); - settings.Preload(SettingsKeys.AiFilterPrompt, Json("[{domain}] {keywords}")); - var builder = new AiClassifyContextBuilder(settings, kanj); - - string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None); - - Assert.Equal( - $"[{PromptFiller.FallbackDomain}] {PromptFiller.NoKeywordsHint}", - prompt); - } - - [Fact] - public async Task BuildClassifySystemPrompt_AppendsCardPromptWhenSet() - { - FakeSettingsStore settings = Context(out FakeKanjStore kanj); - settings.Preload(SettingsKeys.AiPrompt, Json("Классифицируй {domain}.")); - settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке» {keywords}.")); - settings.Preload(SettingsKeys.DomainDescription, Json(Domain)); - settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords)); - var builder = new AiClassifyContextBuilder(settings, kanj); - - string prompt = await builder.BuildClassifySystemPromptAsync(CancellationToken.None); - - Assert.Equal("Классифицируй IT-разработка.\n\nВерни блок «О заявке» бот, сайт.", prompt); - } - - // ─── User-контекст: доски + примеры + сообщение ───────────────────────── - - [Fact] - public async Task BuildClassifyUserContext_EmptyBoards_ShowsNoBoardsPhrase() - { - FakeSettingsStore settings = Context(out FakeKanjStore kanj); - var builder = new AiClassifyContextBuilder(settings, kanj); - - string context = await builder.BuildClassifyUserContextAsync("Нужен разработчик", CancellationToken.None); - - Assert.Contains("- (колонок пока нет — верните board: null)", context); - Assert.DoesNotContain("Примеры разметки", context); - Assert.EndsWith("Новое сообщение:\nНужен разработчик", context); - } - - [Fact] - public async Task BuildClassifyUserContext_ListsBoardsWithKeywordsAndRules() - { - FakeSettingsStore settings = Context(out FakeKanjStore kanj); - kanj.SeedBoard(new ContainerDto - { - Id = "b_py", - Name = "Python-разработка", - Description = "Бэкенд и боты на Python", - Rules = new ContainerRulesDto( - Mode: "any", - Direction: Array.Empty(), - Keywords: new[] { "python", "django", "fastapi" }, - Stack: Array.Empty(), - Grade: Array.Empty(), - Exclude: Array.Empty(), - Budget: null), - Order = 0, - }); - kanj.SeedBoard(new ContainerDto - { - Id = "b_site", - Name = "Сайты", - Rules = new ContainerRulesDto( - Mode: "all", - Direction: new[] { "веб-разработка" }, - Keywords: Array.Empty(), - Stack: Array.Empty(), - Grade: Array.Empty(), - Exclude: Array.Empty(), - Budget: null), - Order = 1, - }); - kanj.SeedBoard(new ContainerDto { Id = "b_sug", Name = "Предложение", Suggested = true, Order = 2 }); - var builder = new AiClassifyContextBuilder(settings, kanj); - - string context = await builder.BuildClassifyUserContextAsync("Ищу Python-разработчика", CancellationToken.None); - - // Колонка с правилами — критерии RulesDescriber (слова/направление) и описание. - Assert.Contains("b_py: Python-разработка", context); - Assert.Contains("слова: python, django, fastapi", context); - Assert.Contains("— Бэкенд и боты на Python", context); - Assert.Contains("- b_site: Сайты (критерии: все условия · направление: веб-разработка)", context); - Assert.DoesNotContain("b_sug", context); - } - - [Fact] - public async Task BuildClassifyUserContext_IncludesMarkupExamplesNewestFirst() - { - FakeSettingsStore settings = Context(out FakeKanjStore kanj); - SeedCardWithMove(kanj, "l_1", "b_py", "Пример: middle python (старый)"); - SeedCardWithMove(kanj, "l_2", "b_py", "Пример: senior go (свежий)"); - var builder = new AiClassifyContextBuilder(settings, kanj); - - string context = await builder.BuildClassifyUserContextAsync("Ищу разработчика", CancellationToken.None); - - Assert.Contains( - "Примеры разметки пользователя:\nтекст: Пример: senior go (свежий)\n→ колонка: b_py\n" - + "текст: Пример: middle python (старый)\n→ колонка: b_py", - context); - } - - [Fact] - public async Task BuildClassifyUserContext_LongMessage_TruncatesTo5000CodePoints() - { - FakeSettingsStore settings = Context(out FakeKanjStore kanj); - var builder = new AiClassifyContextBuilder(settings, kanj); - string text = new string('д', 6000); - - string context = await builder.BuildClassifyUserContextAsync(text, CancellationToken.None); - - Assert.EndsWith("Новое сообщение:\n" + new string('д', 5000), context); - } - - // ─── Хелперы ──────────────────────────────────────────────────────────── - - // Создаёт контекст: пустое KV + пустой канбан-фейк. - // kanj: Канбан-фейк (доски/журнал примеров). - // Возвращает: KV-хранилище тенанта. - private static FakeSettingsStore Context(out FakeKanjStore kanj) - { - kanj = new FakeKanjStore(); - return new FakeSettingsStore(); - } - - // Кладёт карточку с исходным текстом и запись журнала move на неё (источник few-shot-примера). - // kanj: Канбан-фейк. - // cardId: Id карточки (l_…). - // board: Колонка-назначение (доска). - // text: Исходный текст карточки (source_msg). - private static void SeedCardWithMove( - FakeKanjStore kanj, - string cardId, - string board, - string text) - { - kanj.SeedCard(new CardDto { Id = cardId, Col = board, Content = new SourceContent { Text = text } }); - kanj.AddMoveAsync(new CardMoveDto("lm_" + cardId, cardId, "move", "inbox", board), CancellationToken.None).GetAwaiter().GetResult(); - } -} +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Services; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Settings; + +namespace Deal.Tests.Unit.Modules.Kanban; + +/// +/// Тесты контекст-билдера ИИ-классификации . +/// +public sealed class AiClassifyContextBuilderTests +{ + private const string Domain = "IT-разработка"; + + private static readonly string[] Keywords = { "бот", "сайт" }; + + private static string Json(object value) => System.Text.Json.JsonSerializer.Serialize(value); + + // ─── Промпты: fill_prompt ({domain}/{keywords}) ───────────────────────── + + [Fact] + public async Task BuildFilterPrompt_FillsDomainAndKeywords() + { + FakeSettingsStore settings = Context(out FakeKanjStore kanj); + settings.Preload(SettingsKeys.AiFilterPrompt, Json("Страж. Сфера: {domain}. Маркеры: {keywords}.")); + settings.Preload(SettingsKeys.DomainDescription, Json(Domain)); + settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords)); + var builder = new AiClassifyContextBuilder(settings, kanj); + + string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None); + + Assert.Equal("Страж. Сфера: IT-разработка. Маркеры: бот, сайт.", prompt); + } + + [Fact] + public async Task BuildFilterPrompt_NoDomain_UsesFallbackTextAndHint() + { + FakeSettingsStore settings = Context(out FakeKanjStore kanj); + settings.Preload(SettingsKeys.AiFilterPrompt, Json("[{domain}] {keywords}")); + var builder = new AiClassifyContextBuilder(settings, kanj); + + string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None); + + Assert.Equal( + $"[{PromptFiller.FallbackDomain}] {PromptFiller.NoKeywordsHint}", + prompt); + } + + [Fact] + public async Task BuildClassifySystemPrompt_AppendsCardPromptWhenSet() + { + FakeSettingsStore settings = Context(out FakeKanjStore kanj); + settings.Preload(SettingsKeys.AiPrompt, Json("Классифицируй {domain}.")); + settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке» {keywords}.")); + settings.Preload(SettingsKeys.DomainDescription, Json(Domain)); + settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords)); + var builder = new AiClassifyContextBuilder(settings, kanj); + + string prompt = await builder.BuildClassifySystemPromptAsync(CancellationToken.None); + + Assert.Equal("Классифицируй IT-разработка.\n\nВерни блок «О заявке» бот, сайт.", prompt); + } + + // ─── User-контекст: доски + примеры + сообщение ───────────────────────── + + [Fact] + public async Task BuildClassifyUserContext_EmptyBoards_ShowsNoBoardsPhrase() + { + FakeSettingsStore settings = Context(out FakeKanjStore kanj); + var builder = new AiClassifyContextBuilder(settings, kanj); + + string context = await builder.BuildClassifyUserContextAsync("Нужен разработчик", CancellationToken.None); + + Assert.Contains("- (колонок пока нет — верните board: null)", context); + Assert.DoesNotContain("Примеры разметки", context); + Assert.EndsWith("Новое сообщение:\nНужен разработчик", context); + } + + [Fact] + public async Task BuildClassifyUserContext_ListsBoardsWithKeywordsAndRules() + { + FakeSettingsStore settings = Context(out FakeKanjStore kanj); + kanj.SeedBoard(new ContainerDto + { + Id = "b_py", + Name = "Python-разработка", + Description = "Бэкенд и боты на Python", + Rules = new ContainerRulesDto( + Mode: "any", + Direction: Array.Empty(), + Keywords: new[] { "python", "django", "fastapi" }, + Stack: Array.Empty(), + Grade: Array.Empty(), + Exclude: Array.Empty(), + Budget: null), + Order = 0, + }); + kanj.SeedBoard(new ContainerDto + { + Id = "b_site", + Name = "Сайты", + Rules = new ContainerRulesDto( + Mode: "all", + Direction: new[] { "веб-разработка" }, + Keywords: Array.Empty(), + Stack: Array.Empty(), + Grade: Array.Empty(), + Exclude: Array.Empty(), + Budget: null), + Order = 1, + }); + kanj.SeedBoard(new ContainerDto { Id = "b_sug", Name = "Предложение", Suggested = true, Order = 2 }); + var builder = new AiClassifyContextBuilder(settings, kanj); + + string context = await builder.BuildClassifyUserContextAsync("Ищу Python-разработчика", CancellationToken.None); + + // Колонка с правилами — критерии RulesDescriber (слова/направление) и описание. + Assert.Contains("b_py: Python-разработка", context); + Assert.Contains("слова: python, django, fastapi", context); + Assert.Contains("— Бэкенд и боты на Python", context); + Assert.Contains("- b_site: Сайты (критерии: все условия · направление: веб-разработка)", context); + Assert.DoesNotContain("b_sug", context); + } + + [Fact] + public async Task BuildClassifyUserContext_IncludesMarkupExamplesNewestFirst() + { + FakeSettingsStore settings = Context(out FakeKanjStore kanj); + SeedCardWithMove(kanj, "l_1", "b_py", "Пример: middle python (старый)"); + SeedCardWithMove(kanj, "l_2", "b_py", "Пример: senior go (свежий)"); + var builder = new AiClassifyContextBuilder(settings, kanj); + + string context = await builder.BuildClassifyUserContextAsync("Ищу разработчика", CancellationToken.None); + + Assert.Contains( + "Примеры разметки пользователя:\nтекст: Пример: senior go (свежий)\n→ колонка: b_py\n" + + "текст: Пример: middle python (старый)\n→ колонка: b_py", + context); + } + + [Fact] + public async Task BuildClassifyUserContext_LongMessage_TruncatesTo5000CodePoints() + { + FakeSettingsStore settings = Context(out FakeKanjStore kanj); + var builder = new AiClassifyContextBuilder(settings, kanj); + string text = new string('д', 6000); + + string context = await builder.BuildClassifyUserContextAsync(text, CancellationToken.None); + + Assert.EndsWith("Новое сообщение:\n" + new string('д', 5000), context); + } + + // ─── Хелперы ──────────────────────────────────────────────────────────── + + // Создаёт контекст: пустое KV + пустой канбан-фейк. + // kanj: Канбан-фейк (доски/журнал примеров). + // Возвращает: KV-хранилище тенанта. + private static FakeSettingsStore Context(out FakeKanjStore kanj) + { + kanj = new FakeKanjStore(); + return new FakeSettingsStore(); + } + + // Кладёт карточку с исходным текстом и запись журнала move на неё (источник few-shot-примера). + // kanj: Канбан-фейк. + // cardId: Id карточки (l_…). + // board: Колонка-назначение (доска). + // text: Исходный текст карточки (source_msg). + private static void SeedCardWithMove( + FakeKanjStore kanj, + string cardId, + string board, + string text) + { + kanj.SeedCard(new CardDto { Id = cardId, Col = board, Content = new SourceContent { Text = text } }); + kanj.AddMoveAsync(new CardMoveDto("lm_" + cardId, cardId, "move", "inbox", board), CancellationToken.None).GetAwaiter().GetResult(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/CardsServiceRemindersTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/CardsServiceRemindersTests.cs index de9f5c5..9577c28 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/CardsServiceRemindersTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/CardsServiceRemindersTests.cs @@ -1,219 +1,219 @@ -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Settings.Application.Models; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Modules.Kanban; - -/// -/// Тесты напоминаний «Отложено» — -/// -public sealed class CardsServiceRemindersTests -{ - private const long DayMs = 86_400_000; - - - [Fact] - public async Task Set_RemindersDisabled_Returns400TextAndWritesNothing() - { - (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); - store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот")); - - CardResultDto result = await service.SetReminderAsync("c_1", NowMs() + DayMs, CancellationToken.None); - - Assert.Equal(CardsService.RemindersDisabledDetail, result.Error); // 400 у эндпоинта - Assert.Null(result.Card); - Assert.Null(Assert.Single(store.CardDtos).Reminder); // запись не выполнена - } - - [Fact] - public async Task Set_Enabled_SetsReminderAndReturnsCard() - { - (CardsService service, FakeKanjStore store, _) = Create(); - store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот", updatedAtMs: 1)); - long atMs = NowMs() + DayMs; - - CardResultDto result = await service.SetReminderAsync("c_1", atMs, CancellationToken.None); - - Assert.Null(result.Error); - CardDto card = result.Card!; - Assert.Equal("c_1", card.Id); - Assert.Equal(atMs, card.Reminder!.At); // наружу reminder={at}, epoch-ms - CardDto stored = Assert.Single(store.CardDtos); - Assert.Equal(atMs, stored.Reminder!.At); - Assert.True(stored.UpdatedAtMs > 1); // set_reminder бампит updated_at - } - - [Fact] - public async Task Set_MissingCard_Returns404EvenWhenDisabled() - { - (CardsService service, _, _) = Create(remindersEnabled: false); - - CardResultDto result = await service.SetReminderAsync("c_missing", NowMs(), CancellationToken.None); - - Assert.Null(result.Error); - Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена» - } - - [Fact] - public async Task Set_StageNotHold_Allowed() - { - (CardsService service, FakeKanjStore store, _) = Create(); - store.SeedCard(Card("c_1", stage: "work", title: "В работе")); - long atMs = NowMs() + DayMs; - - CardResultDto result = await service.SetReminderAsync("c_1", atMs, CancellationToken.None); - - Assert.Null(result.Error); - Assert.Equal("work", result.Card!.Col); - Assert.Equal(atMs, result.Card!.Reminder!.At); - } - - - [Fact] - public async Task Clear_WithReminder_ClearsItAndReturnsTrue() - { - (CardsService service, FakeKanjStore store, _) = Create(); - store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) }); - - bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None); - - Assert.True(cleared); - Assert.Null(Assert.Single(store.CardDtos).Reminder); // reminder_at=NULL, fired сброшен - } - - [Fact] - public async Task Clear_MissingCard_ReturnsFalse() - { - (CardsService service, _, _) = Create(); - - bool cleared = await service.ClearReminderAsync("c_missing", CancellationToken.None); - - Assert.False(cleared); // эндпоинт отвечает 404 «Карточка не найдена» - } - - [Fact] - public async Task Clear_RemindersDisabled_StillClears() - { - (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); - store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() + DayMs) }); - - bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None); - - Assert.True(cleared); - Assert.Null(Assert.Single(store.CardDtos).Reminder); - } - - - [Fact] - public async Task Snooze_MovesReminderToNowPlus24h() - { - (CardsService service, FakeKanjStore store, _) = Create(); - store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) }); - long beforeMs = NowMs(); - - bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None); - - Assert.True(snoozed); - long afterMs = NowMs(); - CardReminderDto reminder = Assert.Single(store.CardDtos).Reminder!; - Assert.InRange(reminder.At, beforeMs + DayMs, afterMs + DayMs); // now + 24 ч - } - - [Fact] - public async Task Snooze_MissingCard_ReturnsFalse() - { - (CardsService service, _, _) = Create(); - - bool snoozed = await service.SnoozeReminderAsync("c_missing", CancellationToken.None); - - Assert.False(snoozed); // эндпоинт отвечает 404 «Карточка не найдена» - } - - [Fact] - public async Task Snooze_RemindersDisabled_StillSnoozes() - { - (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); - store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) }); - - bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None); - - Assert.True(snoozed); - Assert.NotNull(Assert.Single(store.CardDtos).Reminder); - } - - - [Fact] - public async Task CheckDue_Disabled_ClearsExpiredAndReturnsEmpty() - { - (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); - long pastMs = NowMs() - 1; - store.SeedCard(Card("c_hold_past", stage: "hold") with { Reminder = new CardReminderDto(pastMs) }); - store.SeedCard(Card("c_work_past", stage: "work") with { Reminder = new CardReminderDto(pastMs) }); - store.SeedCard(Card("c_future", stage: "hold") with { Reminder = new CardReminderDto(NowMs() + DayMs) }); - - IReadOnlyList due = await service.CheckDueRemindersAsync(CancellationToken.None); - - Assert.Empty(due); // выключено → событий нет - // Протухшие очищены БЕЗ учёта stage/fired, будущее не тронуто. - Assert.Null(store.CardDtos.Single(card => card.Id == "c_hold_past").Reminder); - Assert.Null(store.CardDtos.Single(card => card.Id == "c_work_past").Reminder); - Assert.NotNull(store.CardDtos.Single(card => card.Id == "c_future").Reminder); - } - - [Fact] - public async Task CheckDue_Enabled_ReturnsDueAndMarksFired() - { - (CardsService service, FakeKanjStore store, _) = Create(); - store.SeedCard(Card("c_d1", stage: "hold", title: "Ранний") with { Reminder = new CardReminderDto(NowMs() - 2 * DayMs) }); - store.SeedCard(Card("c_d2", stage: "hold", title: "Поздний") with { Reminder = new CardReminderDto(NowMs() - 1) }); - store.SeedCard(Card("c_future", stage: "hold", title: "Будущий") with { Reminder = new CardReminderDto(NowMs() + DayMs) }); - store.SeedCard(Card("c_work", stage: "work", title: "В работе") with { Reminder = new CardReminderDto(NowMs() - 1) }); - - IReadOnlyList due = await service.CheckDueRemindersAsync(CancellationToken.None); - - // Только hold-карточки с наступившим и не сработавшим напоминанием, ORDER BY reminder_at. - Assert.Equal( - new[] { ("c_d1", "Ранний"), ("c_d2", "Поздний") }, - due.Select(item => (item.Id, item.Title))); - Assert.All(due, item => Assert.Equal("hold", item.ContainerId)); - // «Выстрелившие» помечены fired: повторная выборка due пуста (признак держит строка БД). - Assert.Empty(await store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None)); - } - - // ─── Хелперы ────────────────────────────────────────────────────────── - - private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create(bool remindersEnabled = true) - { - var store = new FakeKanjStore(); - var settings = new FakeSettingsStore(); - if (!remindersEnabled) - { - settings.Preload(SettingsKeys.RemindersEnabled, "false"); - } - - return (new CardsService(store, settings, new FakeMlClient(), new FakeFileStorage()), store, settings); - } - - // Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем. - private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - // Карточка с полями по умолчанию (planned, CreatedAtMs=1, UpdatedAtMs=1). - private static CardDto Card( - string id, - string stage = "planned", - string title = "", - long updatedAtMs = 1) - { - return new CardDto - { - Id = id, - Col = stage, - Title = title, - CreatedAtMs = 1, - UpdatedAtMs = updatedAtMs, - }; - } -} +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Settings; + +namespace Deal.Tests.Unit.Modules.Kanban; + +/// +/// Тесты напоминаний «Отложено» — +/// +public sealed class CardsServiceRemindersTests +{ + private const long DayMs = 86_400_000; + + + [Fact] + public async Task Set_RemindersDisabled_Returns400TextAndWritesNothing() + { + (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); + store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот")); + + CardResultDto result = await service.SetReminderAsync("c_1", NowMs() + DayMs, CancellationToken.None); + + Assert.Equal(CardsService.RemindersDisabledDetail, result.Error); // 400 у эндпоинта + Assert.Null(result.Card); + Assert.Null(Assert.Single(store.CardDtos).Reminder); // запись не выполнена + } + + [Fact] + public async Task Set_Enabled_SetsReminderAndReturnsCard() + { + (CardsService service, FakeKanjStore store, _) = Create(); + store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот", updatedAtMs: 1)); + long atMs = NowMs() + DayMs; + + CardResultDto result = await service.SetReminderAsync("c_1", atMs, CancellationToken.None); + + Assert.Null(result.Error); + CardDto card = result.Card!; + Assert.Equal("c_1", card.Id); + Assert.Equal(atMs, card.Reminder!.At); // наружу reminder={at}, epoch-ms + CardDto stored = Assert.Single(store.CardDtos); + Assert.Equal(atMs, stored.Reminder!.At); + Assert.True(stored.UpdatedAtMs > 1); // set_reminder бампит updated_at + } + + [Fact] + public async Task Set_MissingCard_Returns404EvenWhenDisabled() + { + (CardsService service, _, _) = Create(remindersEnabled: false); + + CardResultDto result = await service.SetReminderAsync("c_missing", NowMs(), CancellationToken.None); + + Assert.Null(result.Error); + Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена» + } + + [Fact] + public async Task Set_StageNotHold_Allowed() + { + (CardsService service, FakeKanjStore store, _) = Create(); + store.SeedCard(Card("c_1", stage: "work", title: "В работе")); + long atMs = NowMs() + DayMs; + + CardResultDto result = await service.SetReminderAsync("c_1", atMs, CancellationToken.None); + + Assert.Null(result.Error); + Assert.Equal("work", result.Card!.Col); + Assert.Equal(atMs, result.Card!.Reminder!.At); + } + + + [Fact] + public async Task Clear_WithReminder_ClearsItAndReturnsTrue() + { + (CardsService service, FakeKanjStore store, _) = Create(); + store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) }); + + bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None); + + Assert.True(cleared); + Assert.Null(Assert.Single(store.CardDtos).Reminder); // reminder_at=NULL, fired сброшен + } + + [Fact] + public async Task Clear_MissingCard_ReturnsFalse() + { + (CardsService service, _, _) = Create(); + + bool cleared = await service.ClearReminderAsync("c_missing", CancellationToken.None); + + Assert.False(cleared); // эндпоинт отвечает 404 «Карточка не найдена» + } + + [Fact] + public async Task Clear_RemindersDisabled_StillClears() + { + (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); + store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() + DayMs) }); + + bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None); + + Assert.True(cleared); + Assert.Null(Assert.Single(store.CardDtos).Reminder); + } + + + [Fact] + public async Task Snooze_MovesReminderToNowPlus24h() + { + (CardsService service, FakeKanjStore store, _) = Create(); + store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) }); + long beforeMs = NowMs(); + + bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None); + + Assert.True(snoozed); + long afterMs = NowMs(); + CardReminderDto reminder = Assert.Single(store.CardDtos).Reminder!; + Assert.InRange(reminder.At, beforeMs + DayMs, afterMs + DayMs); // now + 24 ч + } + + [Fact] + public async Task Snooze_MissingCard_ReturnsFalse() + { + (CardsService service, _, _) = Create(); + + bool snoozed = await service.SnoozeReminderAsync("c_missing", CancellationToken.None); + + Assert.False(snoozed); // эндпоинт отвечает 404 «Карточка не найдена» + } + + [Fact] + public async Task Snooze_RemindersDisabled_StillSnoozes() + { + (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); + store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) }); + + bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None); + + Assert.True(snoozed); + Assert.NotNull(Assert.Single(store.CardDtos).Reminder); + } + + + [Fact] + public async Task CheckDue_Disabled_ClearsExpiredAndReturnsEmpty() + { + (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); + long pastMs = NowMs() - 1; + store.SeedCard(Card("c_hold_past", stage: "hold") with { Reminder = new CardReminderDto(pastMs) }); + store.SeedCard(Card("c_work_past", stage: "work") with { Reminder = new CardReminderDto(pastMs) }); + store.SeedCard(Card("c_future", stage: "hold") with { Reminder = new CardReminderDto(NowMs() + DayMs) }); + + IReadOnlyList due = await service.CheckDueRemindersAsync(CancellationToken.None); + + Assert.Empty(due); // выключено → событий нет + // Протухшие очищены БЕЗ учёта stage/fired, будущее не тронуто. + Assert.Null(store.CardDtos.Single(card => card.Id == "c_hold_past").Reminder); + Assert.Null(store.CardDtos.Single(card => card.Id == "c_work_past").Reminder); + Assert.NotNull(store.CardDtos.Single(card => card.Id == "c_future").Reminder); + } + + [Fact] + public async Task CheckDue_Enabled_ReturnsDueAndMarksFired() + { + (CardsService service, FakeKanjStore store, _) = Create(); + store.SeedCard(Card("c_d1", stage: "hold", title: "Ранний") with { Reminder = new CardReminderDto(NowMs() - 2 * DayMs) }); + store.SeedCard(Card("c_d2", stage: "hold", title: "Поздний") with { Reminder = new CardReminderDto(NowMs() - 1) }); + store.SeedCard(Card("c_future", stage: "hold", title: "Будущий") with { Reminder = new CardReminderDto(NowMs() + DayMs) }); + store.SeedCard(Card("c_work", stage: "work", title: "В работе") with { Reminder = new CardReminderDto(NowMs() - 1) }); + + IReadOnlyList due = await service.CheckDueRemindersAsync(CancellationToken.None); + + // Только hold-карточки с наступившим и не сработавшим напоминанием, ORDER BY reminder_at. + Assert.Equal( + new[] { ("c_d1", "Ранний"), ("c_d2", "Поздний") }, + due.Select(item => (item.Id, item.Title))); + Assert.All(due, item => Assert.Equal("hold", item.ContainerId)); + // «Выстрелившие» помечены fired: повторная выборка due пуста (признак держит строка БД). + Assert.Empty(await store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None)); + } + + // ─── Хелперы ────────────────────────────────────────────────────────── + + private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create(bool remindersEnabled = true) + { + var store = new FakeKanjStore(); + var settings = new FakeSettingsStore(); + if (!remindersEnabled) + { + settings.Preload(SettingsKeys.RemindersEnabled, "false"); + } + + return (new CardsService(store, settings, new FakeMlClient(), new FakeFileStorage()), store, settings); + } + + // Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем. + private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + // Карточка с полями по умолчанию (planned, CreatedAtMs=1, UpdatedAtMs=1). + private static CardDto Card( + string id, + string stage = "planned", + string title = "", + long updatedAtMs = 1) + { + return new CardDto + { + Id = id, + Col = stage, + Title = title, + CreatedAtMs = 1, + UpdatedAtMs = updatedAtMs, + }; + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs index 9724891..fbd0d38 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs @@ -1,253 +1,253 @@ -using System.Globalization; -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Modules.Kanban; - -/// -/// Тесты ручной проверки/разметки ML -/// -public sealed class MlReviewServiceTests -{ - private const string Dialog = "d_1"; - - private static MlReviewService Create( - FakePipelineStore pipeline, - FakeKanjStore kanj, - FakeMlClient ml, - out CardsService cards) - { - var settings = new FakeSettingsStore(); - cards = new CardsService(kanj, settings, ml, new FakeFileStorage()); - var processing = new PipelineProcessingService(pipeline, ml, new PipelineIngestService(pipeline)); - return new MlReviewService(pipeline, kanj, cards, processing, ml); - } - - private static QueueItemDto QueueRow( - long msgId, - string text, - string dialog = Dialog) => SourceItemFactory.Queue( - $"p_{msgId}", - text, - PipelineQueueStatuses.New, - origin: dialog, - externalId: msgId.ToString(CultureInfo.InvariantCulture), - msgAtMs: 1_700_000_000_000 + msgId); - - private static RejectedItemDto RejectedRow( - long msgId, - string text, - string dialog = Dialog) => SourceItemFactory.Rejected( - $"r_{msgId}", - text, - stage: "spam_ml", - decidedBy: "ml", - reason: "спам", - origin: dialog, - externalId: msgId.ToString(CultureInfo.InvariantCulture), - msgAtMs: 1_700_000_000_000 + msgId); - - private static CardDto Card( - long msgId, - string text, - string col = "inbox", - string dialog = Dialog) => new() - { - Id = $"c_{msgId}", - Col = col, - Title = text, - Source = SourceItemFactory.Source( - origin: dialog, - externalId: msgId.ToString(CultureInfo.InvariantCulture)), - Content = SourceItemFactory.Content(text), - ReceivedAtMs = 1_700_000_000_000 + msgId, - }; - - [Fact] - public async Task Candidates_MergesQueueRejectedAndCardsWithVerdicts() - { - var pipeline = new FakePipelineStore(); - pipeline.SeedQueue(QueueRow(101, "из очереди")); - pipeline.SeedRejected(RejectedRow(102, "из отсева")); - var kanj = new FakeKanjStore(); - kanj.SeedCard(Card(103, "из карточки")); - MlReviewService service = Create(pipeline, kanj, new FakeMlClient(), out _); - - IReadOnlyList items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None); - - Assert.Equal(3, items.Count); - Assert.Contains(items, item => item.Source.ExternalId == "101" && item.Verdict == MlReviewService.VerdictQueued && !item.Lead); - Assert.Contains(items, item => item.Source.ExternalId == "102" && item.Verdict == MlReviewService.VerdictRejected); - MlCandidateDto cardItem = Assert.Single(items, item => item.Source.ExternalId == "103"); - Assert.True(cardItem.Lead); - Assert.Equal(MlReviewService.VerdictCard, cardItem.Verdict); - Assert.Equal("inbox", cardItem.Col); - } - - [Fact] - public async Task Candidates_FiltersByDialog() - { - var pipeline = new FakePipelineStore(); - pipeline.SeedQueue(QueueRow(101, "нужный", Dialog)); - pipeline.SeedQueue(QueueRow(201, "другой", "d_2")); - MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); - - IReadOnlyList items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None); - - MlCandidateDto only = Assert.Single(items); - Assert.Equal("101", only.Source.ExternalId); - } - - [Fact] - public async Task Candidates_NoDialog_ReturnsAllSources() - { - var pipeline = new FakePipelineStore(); - pipeline.SeedQueue(QueueRow(101, "a", "d_1")); - pipeline.SeedQueue(QueueRow(201, "b", "d_2")); - MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); - - IReadOnlyList items = await service.CandidatesAsync(null, 10, CancellationToken.None); - - Assert.Equal(2, items.Count); - } - - [Fact] - public async Task Candidates_ClampsLimitToMax() - { - var pipeline = new FakePipelineStore(); - for (int i = 0; i < 5; i++) - { - pipeline.SeedQueue(QueueRow(100 + i, $"текст {i}")); - } - - MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); - - IReadOnlyList items = await service.CandidatesAsync(Dialog, 2, CancellationToken.None); - - Assert.Equal(2, items.Count); - } - - [Fact] - public async Task Apply_Skip_DoesNotLearn() - { - var pipeline = new FakePipelineStore(); - pipeline.SeedQueue(QueueRow(101, "текст")); - var ml = new FakeMlClient(); - MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _); - - MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSkip, CancellationToken.None); - - Assert.NotNull(result); - Assert.True(result!.Ok); - Assert.False(result.Learned); - Assert.Empty(ml.Pushed); - Assert.Single(pipeline.Queue); - } - - [Fact] - public async Task Apply_Spam_WithCard_TrashesAndLearns() - { - var kanj = new FakeKanjStore(); - kanj.SeedCard(Card(101, "спамный текст")); - var ml = new FakeMlClient(); - MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _); - - MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None); - - Assert.NotNull(result); - Assert.True(result!.Learned); - Assert.Equal("trash", result.Moved); - Assert.Equal("c_101", result.LeadId); - Assert.Equal("trash", Assert.Single(kanj.CardDtos).Col); - Assert.Contains(("спамный текст", "spam", 1.0), ml.Pushed); - } - - [Fact] - public async Task Apply_Spam_QueuedMessage_RejectsAndRemovesFromQueue() - { - var pipeline = new FakePipelineStore(); - pipeline.SeedQueue(QueueRow(101, "рекламный текст")); - var ml = new FakeMlClient(); - MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _); - - MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None); - - Assert.NotNull(result); - Assert.True(result!.Learned); - Assert.Null(result.Moved); - Assert.Empty(pipeline.Queue); - RejectedItemDto rejected = Assert.Single(pipeline.Rejected); - Assert.Equal("spam_ml", rejected.Stage); - Assert.Equal("ml", rejected.DecidedBy); - Assert.Contains(("рекламный текст", "spam", 1.0), ml.Pushed); - } - - [Fact] - public async Task Apply_Board_MovesCardAndLearns() - { - var kanj = new FakeKanjStore(); - kanj.SeedBoard(new ContainerDto - { - Id = "b_py", - Name = "Python", - Space = ContainerSpaces.Dashboard, - Kind = ContainerKinds.Board, - }); - kanj.SeedCard(Card(101, "python разработчик")); - var ml = new FakeMlClient(); - MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _); - - MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:b_py", CancellationToken.None); - - Assert.NotNull(result); - Assert.True(result!.Learned); - Assert.Equal("b_py", result.Moved); - Assert.Equal("b_py", Assert.Single(kanj.CardDtos).Col); - Assert.Contains(("python разработчик", "b_py", 1.0), ml.Pushed); - } - - [Fact] - public async Task Apply_UnknownBoard_ReturnsError() - { - var kanj = new FakeKanjStore(); - kanj.SeedCard(Card(101, "текст")); - MlReviewService service = Create(new FakePipelineStore(), kanj, new FakeMlClient(), out _); - - MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:missing", CancellationToken.None); - - Assert.NotNull(result); - Assert.False(result!.Ok); - Assert.Equal(MlReviewService.UnknownBoardDetail, result.Error); - } - - [Fact] - public async Task Apply_UnknownAction_ReturnsError() - { - var pipeline = new FakePipelineStore(); - pipeline.SeedQueue(QueueRow(101, "текст")); - MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); - - MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "что-то", CancellationToken.None); - - Assert.NotNull(result); - Assert.False(result!.Ok); - Assert.Equal(MlReviewService.UnknownActionDetail, result.Error); - } - - [Fact] - public async Task Apply_MessageNotFound_ReturnsNull() - { - MlReviewService service = Create(new FakePipelineStore(), new FakeKanjStore(), new FakeMlClient(), out _); - - MlApplyResult? result = await service.ApplyAsync(Dialog, 999, MlReviewService.ActionSpam, CancellationToken.None); - - Assert.Null(result); - } -} +using System.Globalization; +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Support; + +namespace Deal.Tests.Unit.Modules.Kanban; + +/// +/// Тесты ручной проверки/разметки ML +/// +public sealed class MlReviewServiceTests +{ + private const string Dialog = "d_1"; + + private static MlReviewService Create( + FakePipelineStore pipeline, + FakeKanjStore kanj, + FakeMlClient ml, + out CardsService cards) + { + var settings = new FakeSettingsStore(); + cards = new CardsService(kanj, settings, ml, new FakeFileStorage()); + var processing = new PipelineProcessingService(pipeline, ml, new PipelineIngestService(pipeline)); + return new MlReviewService(pipeline, kanj, cards, processing, ml); + } + + private static QueueItemDto QueueRow( + long msgId, + string text, + string dialog = Dialog) => SourceItemFactory.Queue( + $"p_{msgId}", + text, + PipelineQueueStatuses.New, + origin: dialog, + externalId: msgId.ToString(CultureInfo.InvariantCulture), + msgAtMs: 1_700_000_000_000 + msgId); + + private static RejectedItemDto RejectedRow( + long msgId, + string text, + string dialog = Dialog) => SourceItemFactory.Rejected( + $"r_{msgId}", + text, + stage: "spam_ml", + decidedBy: "ml", + reason: "спам", + origin: dialog, + externalId: msgId.ToString(CultureInfo.InvariantCulture), + msgAtMs: 1_700_000_000_000 + msgId); + + private static CardDto Card( + long msgId, + string text, + string col = "inbox", + string dialog = Dialog) => new() + { + Id = $"c_{msgId}", + Col = col, + Title = text, + Source = SourceItemFactory.Source( + origin: dialog, + externalId: msgId.ToString(CultureInfo.InvariantCulture)), + Content = SourceItemFactory.Content(text), + ReceivedAtMs = 1_700_000_000_000 + msgId, + }; + + [Fact] + public async Task Candidates_MergesQueueRejectedAndCardsWithVerdicts() + { + var pipeline = new FakePipelineStore(); + pipeline.SeedQueue(QueueRow(101, "из очереди")); + pipeline.SeedRejected(RejectedRow(102, "из отсева")); + var kanj = new FakeKanjStore(); + kanj.SeedCard(Card(103, "из карточки")); + MlReviewService service = Create(pipeline, kanj, new FakeMlClient(), out _); + + IReadOnlyList items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None); + + Assert.Equal(3, items.Count); + Assert.Contains(items, item => item.Source.ExternalId == "101" && item.Verdict == MlReviewService.VerdictQueued && !item.Lead); + Assert.Contains(items, item => item.Source.ExternalId == "102" && item.Verdict == MlReviewService.VerdictRejected); + MlCandidateDto cardItem = Assert.Single(items, item => item.Source.ExternalId == "103"); + Assert.True(cardItem.Lead); + Assert.Equal(MlReviewService.VerdictCard, cardItem.Verdict); + Assert.Equal("inbox", cardItem.Col); + } + + [Fact] + public async Task Candidates_FiltersByDialog() + { + var pipeline = new FakePipelineStore(); + pipeline.SeedQueue(QueueRow(101, "нужный", Dialog)); + pipeline.SeedQueue(QueueRow(201, "другой", "d_2")); + MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); + + IReadOnlyList items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None); + + MlCandidateDto only = Assert.Single(items); + Assert.Equal("101", only.Source.ExternalId); + } + + [Fact] + public async Task Candidates_NoDialog_ReturnsAllSources() + { + var pipeline = new FakePipelineStore(); + pipeline.SeedQueue(QueueRow(101, "a", "d_1")); + pipeline.SeedQueue(QueueRow(201, "b", "d_2")); + MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); + + IReadOnlyList items = await service.CandidatesAsync(null, 10, CancellationToken.None); + + Assert.Equal(2, items.Count); + } + + [Fact] + public async Task Candidates_ClampsLimitToMax() + { + var pipeline = new FakePipelineStore(); + for (int i = 0; i < 5; i++) + { + pipeline.SeedQueue(QueueRow(100 + i, $"текст {i}")); + } + + MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); + + IReadOnlyList items = await service.CandidatesAsync(Dialog, 2, CancellationToken.None); + + Assert.Equal(2, items.Count); + } + + [Fact] + public async Task Apply_Skip_DoesNotLearn() + { + var pipeline = new FakePipelineStore(); + pipeline.SeedQueue(QueueRow(101, "текст")); + var ml = new FakeMlClient(); + MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _); + + MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSkip, CancellationToken.None); + + Assert.NotNull(result); + Assert.True(result!.Ok); + Assert.False(result.Learned); + Assert.Empty(ml.Pushed); + Assert.Single(pipeline.Queue); + } + + [Fact] + public async Task Apply_Spam_WithCard_TrashesAndLearns() + { + var kanj = new FakeKanjStore(); + kanj.SeedCard(Card(101, "спамный текст")); + var ml = new FakeMlClient(); + MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _); + + MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None); + + Assert.NotNull(result); + Assert.True(result!.Learned); + Assert.Equal("trash", result.Moved); + Assert.Equal("c_101", result.LeadId); + Assert.Equal("trash", Assert.Single(kanj.CardDtos).Col); + Assert.Contains(("спамный текст", "spam", 1.0), ml.Pushed); + } + + [Fact] + public async Task Apply_Spam_QueuedMessage_RejectsAndRemovesFromQueue() + { + var pipeline = new FakePipelineStore(); + pipeline.SeedQueue(QueueRow(101, "рекламный текст")); + var ml = new FakeMlClient(); + MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _); + + MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None); + + Assert.NotNull(result); + Assert.True(result!.Learned); + Assert.Null(result.Moved); + Assert.Empty(pipeline.Queue); + RejectedItemDto rejected = Assert.Single(pipeline.Rejected); + Assert.Equal("spam_ml", rejected.Stage); + Assert.Equal("ml", rejected.DecidedBy); + Assert.Contains(("рекламный текст", "spam", 1.0), ml.Pushed); + } + + [Fact] + public async Task Apply_Board_MovesCardAndLearns() + { + var kanj = new FakeKanjStore(); + kanj.SeedBoard(new ContainerDto + { + Id = "b_py", + Name = "Python", + Space = ContainerSpaces.Dashboard, + Kind = ContainerKinds.Board, + }); + kanj.SeedCard(Card(101, "python разработчик")); + var ml = new FakeMlClient(); + MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _); + + MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:b_py", CancellationToken.None); + + Assert.NotNull(result); + Assert.True(result!.Learned); + Assert.Equal("b_py", result.Moved); + Assert.Equal("b_py", Assert.Single(kanj.CardDtos).Col); + Assert.Contains(("python разработчик", "b_py", 1.0), ml.Pushed); + } + + [Fact] + public async Task Apply_UnknownBoard_ReturnsError() + { + var kanj = new FakeKanjStore(); + kanj.SeedCard(Card(101, "текст")); + MlReviewService service = Create(new FakePipelineStore(), kanj, new FakeMlClient(), out _); + + MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:missing", CancellationToken.None); + + Assert.NotNull(result); + Assert.False(result!.Ok); + Assert.Equal(MlReviewService.UnknownBoardDetail, result.Error); + } + + [Fact] + public async Task Apply_UnknownAction_ReturnsError() + { + var pipeline = new FakePipelineStore(); + pipeline.SeedQueue(QueueRow(101, "текст")); + MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); + + MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "что-то", CancellationToken.None); + + Assert.NotNull(result); + Assert.False(result!.Ok); + Assert.Equal(MlReviewService.UnknownActionDetail, result.Error); + } + + [Fact] + public async Task Apply_MessageNotFound_ReturnsNull() + { + MlReviewService service = Create(new FakePipelineStore(), new FakeKanjStore(), new FakeMlClient(), out _); + + MlApplyResult? result = await service.ApplyAsync(Dialog, 999, MlReviewService.ActionSpam, CancellationToken.None); + + Assert.Null(result); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/StorageTickServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/StorageTickServiceTests.cs index 74be848..47ef4a8 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/StorageTickServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/StorageTickServiceTests.cs @@ -1,227 +1,227 @@ -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Settings.Application.Models; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Modules.Kanban; - -/// -/// Тесты StorageTickService — тик правил хранения. -/// -public sealed class StorageTickServiceTests -{ - - [Fact] - public async Task Tick_DefaultSettings_ArchivesExpiredBoardAndInboxCards() - { - (StorageTickService service, FakeKanjStore store, _) = Create(); - store.SeedBoard(Board("b_1")); - store.SeedCard(Card("l_board_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(16)))); - store.SeedCard(Card("l_board_young", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(13)))); // < 14 дн. — не кандидат - store.SeedCard(Card("l_inbox_old", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromDays(20)))); - store.SeedCard(Card("l_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1)))); - - StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None); - - Assert.Equal(2, stats.Archived); - Assert.Equal(0, stats.PurgedArchive); - Assert.Equal(0, stats.PurgedTrash); - Assert.Equal(0, stats.PurgedRejected); - - // Старые карточки доски и inbox → archive, is_new=false, archived_at≈now, matchHits пусто. - Assert.Equal(KanbanColumns.Archive, ColOf(store, "l_board_old")); - Assert.False(IsNewOf(store, "l_board_old")); - Assert.Empty(MatchHitsOf(store, "l_board_old")); - DateTimeOffset? archivedAt = store.ArchivedAtOf("l_board_old"); - Assert.NotNull(archivedAt); - Assert.True(DateTimeOffset.UtcNow - archivedAt!.Value < TimeSpan.FromMinutes(1), "archived_at ≈ now"); - Assert.Equal(KanbanColumns.Archive, ColOf(store, "l_inbox_old")); - - // Молодые карточки остались на своих местах (is_new и archived_at не тронуты). - Assert.Equal("b_1", ColOf(store, "l_board_young")); - Assert.Equal(KanbanColumns.Inbox, ColOf(store, "l_fresh")); - Assert.Null(store.ArchivedAtOf("l_fresh")); - } - - [Fact] - public async Task Tick_AutoArchiveSettingFalse_KeepsExpiredCardsInPlace() - { - (StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); - settings.Preload(SettingsKeys.AutoArchive, "false"); - store.SeedBoard(Board("b_1")); - store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(30)))); - - StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None); - - Assert.Equal(0, stats.Archived); - Assert.Equal("b_1", ColOf(store, "l_old")); - Assert.Null(store.ArchivedAtOf("l_old")); - } - - [Fact] - public async Task Tick_ArchiveAfterDays30_ArchivesOnlyCardsOlderThanConfiguredMonth() - { - (StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); - settings.Preload(SettingsKeys.ArchiveAfterDays, "30"); - store.SeedBoard(Board("b_1")); - store.SeedCard(Card("l_older_month", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(32)))); - store.SeedCard(Card("l_young", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(2)))); - - StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None); - - Assert.Equal(1, stats.Archived); - Assert.Equal(KanbanColumns.Archive, ColOf(store, "l_older_month")); - Assert.Equal("b_1", ColOf(store, "l_young")); - } - - [Fact] - public async Task Tick_ArchiveAfterDays1_ArchivesTwoDayOldCard() - { - (StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); - settings.Preload(SettingsKeys.ArchiveAfterDays, "1"); - store.SeedCard(Card("l_inbox_old", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromDays(2)))); - - StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None); - - Assert.Equal(1, stats.Archived); - Assert.Equal(KanbanColumns.Archive, ColOf(store, "l_inbox_old")); - } - - [Fact] - public async Task Tick_SecondRun_DoesNotReArchiveNorPurgeJustArchivedCards() - { - (StorageTickService service, FakeKanjStore store, _) = Create(); - store.SeedBoard(Board("b_1")); - store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(20)))); - - StorageTickStatsDto first = await service.TickAsync(CancellationToken.None); - StorageTickStatsDto second = await service.TickAsync(CancellationToken.None); - - Assert.Equal(1, first.Archived); - Assert.Equal(0, second.Archived); // карточка уже в archive — не кандидат автоархива - Assert.Equal(0, second.PurgedArchive); // archived_at свежий (~now), до 90 дней не дошёл - Assert.Equal(0, second.PurgedTrash); - Assert.Equal(KanbanColumns.Archive, ColOf(store, "l_old")); - } - - - [Fact] - public async Task Tick_PurgesOnlyArchiveCardsWithArchivedAtOlderThanArchiveClearDays() - { - (StorageTickService service, FakeKanjStore store, _) = Create(); - DateTimeOffset now = DateTimeOffset.UtcNow; - store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(30)))); - store.SetArchivedAt("l_expired", now.AddDays(-100)); // старше 90 дн. - store.SeedCard(Card("l_fresh", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(30)))); - store.SetArchivedAt("l_fresh", now.AddDays(-1)); // свежий — остаётся - - StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None); - - Assert.Equal(0, stats.Archived); // колонка archive — не кандидат автоархива - Assert.Equal(1, stats.PurgedArchive); - Assert.Equal(0, stats.PurgedTrash); - Assert.Equal(0, stats.PurgedRejected); - Assert.DoesNotContain(store.CardDtos, card => card.Id == "l_expired"); - Assert.Contains(store.CardDtos, card => card.Id == "l_fresh"); - } - - [Fact] - public async Task Tick_ArchiveClearDaysFromSettings_ControlsArchivePurgeBoundary() - { - (StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); - settings.Preload(SettingsKeys.ArchiveClearDays, "30"); - DateTimeOffset now = DateTimeOffset.UtcNow; - store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(10)))); - store.SetArchivedAt("l_expired", now.AddDays(-40)); // старше 30 дн. - store.SeedCard(Card("l_fresh", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(10)))); - store.SetArchivedAt("l_fresh", now.AddDays(-10)); // моложе 30 дн. - - StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None); - - Assert.Equal(1, stats.PurgedArchive); - Assert.DoesNotContain(store.CardDtos, card => card.Id == "l_expired"); - Assert.Contains(store.CardDtos, card => card.Id == "l_fresh"); - } - - - [Fact] - public async Task Tick_PurgesOnlyTrashCardsWithReceivedAtOlderThanTrashClearDays() - { - (StorageTickService service, FakeKanjStore store, _) = Create(); - store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(10)))); // старше 7 дн. - store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromHours(6)))); - - StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None); - - Assert.Equal(0, stats.Archived); - Assert.Equal(0, stats.PurgedArchive); - Assert.Equal(1, stats.PurgedTrash); - Assert.Equal(0, stats.PurgedRejected); - Assert.DoesNotContain(store.CardDtos, card => card.Id == "l_old"); - Assert.Contains(store.CardDtos, card => card.Id == "l_fresh"); - } - - [Fact] - public async Task Tick_TrashClearDaysFromSettings_ControlsTrashPurgeBoundary() - { - (StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); - settings.Preload(SettingsKeys.TrashClearDays, "3"); - store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(5)))); // старше 3 дн. - store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(1)))); - - StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None); - - Assert.Equal(1, stats.PurgedTrash); - Assert.DoesNotContain(store.CardDtos, card => card.Id == "l_old"); - Assert.Contains(store.CardDtos, card => card.Id == "l_fresh"); - } - - // ─── Пустой тик / статистика ──────────────────────────────────────────── - - [Fact] - public async Task Tick_EmptyStore_ReturnsZeroStats() - { - (StorageTickService service, _, _) = Create(); - - StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None); - - Assert.Equal(0, stats.Archived); - Assert.Equal(0, stats.PurgedArchive); - Assert.Equal(0, stats.PurgedTrash); - Assert.Equal(0, stats.PurgedRejected); // отсев пайплайна — этап 4, в этапе 3 всегда 0 (Ruling 8) - } - - // ─── Хелперы ──────────────────────────────────────────────────────────── - - private static (StorageTickService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create() - { - var store = new FakeKanjStore(); - var settings = new FakeSettingsStore(); - return (new StorageTickService(store, settings), store, settings); - } - - // Доска-колонка минимально (id/имя достаточно для списка кандидатов фейка). - private static ContainerDto Board(string id) => new() { Id = id, Name = id, Order = 0 }; - - // Карточка в колонке с моментом получения (epoch-ms). - private static CardDto Card( - string id, - string col, - long receivedAtMs) => - new() { Id = id, Col = col, ReceivedAtMs = receivedAtMs }; - - // Epoch-ms момента «now − возраст» (ReceivedAt карточки-кандидата). - private static long ReceivedAtMsAgo(TimeSpan age) => DateTimeOffset.UtcNow.Subtract(age).ToUnixTimeMilliseconds(); - - // Колонка карточки в фейке после тика. - private static string ColOf(FakeKanjStore store, string cardId) => - store.CardDtos.Single(card => card.Id == cardId).Col; - - // Флаг is_new карточки после тика. - private static bool IsNewOf(FakeKanjStore store, string cardId) => - store.CardDtos.Single(card => card.Id == cardId).IsNew; - - private static IReadOnlyList MatchHitsOf(FakeKanjStore store, string cardId) => - store.CardDtos.Single(card => card.Id == cardId).MatchHits; -} +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Settings; + +namespace Deal.Tests.Unit.Modules.Kanban; + +/// +/// Тесты StorageTickService — тик правил хранения. +/// +public sealed class StorageTickServiceTests +{ + + [Fact] + public async Task Tick_DefaultSettings_ArchivesExpiredBoardAndInboxCards() + { + (StorageTickService service, FakeKanjStore store, _) = Create(); + store.SeedBoard(Board("b_1")); + store.SeedCard(Card("l_board_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(16)))); + store.SeedCard(Card("l_board_young", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(13)))); // < 14 дн. — не кандидат + store.SeedCard(Card("l_inbox_old", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromDays(20)))); + store.SeedCard(Card("l_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1)))); + + StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None); + + Assert.Equal(2, stats.Archived); + Assert.Equal(0, stats.PurgedArchive); + Assert.Equal(0, stats.PurgedTrash); + Assert.Equal(0, stats.PurgedRejected); + + // Старые карточки доски и inbox → archive, is_new=false, archived_at≈now, matchHits пусто. + Assert.Equal(KanbanColumns.Archive, ColOf(store, "l_board_old")); + Assert.False(IsNewOf(store, "l_board_old")); + Assert.Empty(MatchHitsOf(store, "l_board_old")); + DateTimeOffset? archivedAt = store.ArchivedAtOf("l_board_old"); + Assert.NotNull(archivedAt); + Assert.True(DateTimeOffset.UtcNow - archivedAt!.Value < TimeSpan.FromMinutes(1), "archived_at ≈ now"); + Assert.Equal(KanbanColumns.Archive, ColOf(store, "l_inbox_old")); + + // Молодые карточки остались на своих местах (is_new и archived_at не тронуты). + Assert.Equal("b_1", ColOf(store, "l_board_young")); + Assert.Equal(KanbanColumns.Inbox, ColOf(store, "l_fresh")); + Assert.Null(store.ArchivedAtOf("l_fresh")); + } + + [Fact] + public async Task Tick_AutoArchiveSettingFalse_KeepsExpiredCardsInPlace() + { + (StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + settings.Preload(SettingsKeys.AutoArchive, "false"); + store.SeedBoard(Board("b_1")); + store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(30)))); + + StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None); + + Assert.Equal(0, stats.Archived); + Assert.Equal("b_1", ColOf(store, "l_old")); + Assert.Null(store.ArchivedAtOf("l_old")); + } + + [Fact] + public async Task Tick_ArchiveAfterDays30_ArchivesOnlyCardsOlderThanConfiguredMonth() + { + (StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + settings.Preload(SettingsKeys.ArchiveAfterDays, "30"); + store.SeedBoard(Board("b_1")); + store.SeedCard(Card("l_older_month", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(32)))); + store.SeedCard(Card("l_young", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(2)))); + + StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None); + + Assert.Equal(1, stats.Archived); + Assert.Equal(KanbanColumns.Archive, ColOf(store, "l_older_month")); + Assert.Equal("b_1", ColOf(store, "l_young")); + } + + [Fact] + public async Task Tick_ArchiveAfterDays1_ArchivesTwoDayOldCard() + { + (StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + settings.Preload(SettingsKeys.ArchiveAfterDays, "1"); + store.SeedCard(Card("l_inbox_old", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromDays(2)))); + + StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None); + + Assert.Equal(1, stats.Archived); + Assert.Equal(KanbanColumns.Archive, ColOf(store, "l_inbox_old")); + } + + [Fact] + public async Task Tick_SecondRun_DoesNotReArchiveNorPurgeJustArchivedCards() + { + (StorageTickService service, FakeKanjStore store, _) = Create(); + store.SeedBoard(Board("b_1")); + store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(20)))); + + StorageTickStatsDto first = await service.TickAsync(CancellationToken.None); + StorageTickStatsDto second = await service.TickAsync(CancellationToken.None); + + Assert.Equal(1, first.Archived); + Assert.Equal(0, second.Archived); // карточка уже в archive — не кандидат автоархива + Assert.Equal(0, second.PurgedArchive); // archived_at свежий (~now), до 90 дней не дошёл + Assert.Equal(0, second.PurgedTrash); + Assert.Equal(KanbanColumns.Archive, ColOf(store, "l_old")); + } + + + [Fact] + public async Task Tick_PurgesOnlyArchiveCardsWithArchivedAtOlderThanArchiveClearDays() + { + (StorageTickService service, FakeKanjStore store, _) = Create(); + DateTimeOffset now = DateTimeOffset.UtcNow; + store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(30)))); + store.SetArchivedAt("l_expired", now.AddDays(-100)); // старше 90 дн. + store.SeedCard(Card("l_fresh", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(30)))); + store.SetArchivedAt("l_fresh", now.AddDays(-1)); // свежий — остаётся + + StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None); + + Assert.Equal(0, stats.Archived); // колонка archive — не кандидат автоархива + Assert.Equal(1, stats.PurgedArchive); + Assert.Equal(0, stats.PurgedTrash); + Assert.Equal(0, stats.PurgedRejected); + Assert.DoesNotContain(store.CardDtos, card => card.Id == "l_expired"); + Assert.Contains(store.CardDtos, card => card.Id == "l_fresh"); + } + + [Fact] + public async Task Tick_ArchiveClearDaysFromSettings_ControlsArchivePurgeBoundary() + { + (StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + settings.Preload(SettingsKeys.ArchiveClearDays, "30"); + DateTimeOffset now = DateTimeOffset.UtcNow; + store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(10)))); + store.SetArchivedAt("l_expired", now.AddDays(-40)); // старше 30 дн. + store.SeedCard(Card("l_fresh", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(10)))); + store.SetArchivedAt("l_fresh", now.AddDays(-10)); // моложе 30 дн. + + StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None); + + Assert.Equal(1, stats.PurgedArchive); + Assert.DoesNotContain(store.CardDtos, card => card.Id == "l_expired"); + Assert.Contains(store.CardDtos, card => card.Id == "l_fresh"); + } + + + [Fact] + public async Task Tick_PurgesOnlyTrashCardsWithReceivedAtOlderThanTrashClearDays() + { + (StorageTickService service, FakeKanjStore store, _) = Create(); + store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(10)))); // старше 7 дн. + store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromHours(6)))); + + StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None); + + Assert.Equal(0, stats.Archived); + Assert.Equal(0, stats.PurgedArchive); + Assert.Equal(1, stats.PurgedTrash); + Assert.Equal(0, stats.PurgedRejected); + Assert.DoesNotContain(store.CardDtos, card => card.Id == "l_old"); + Assert.Contains(store.CardDtos, card => card.Id == "l_fresh"); + } + + [Fact] + public async Task Tick_TrashClearDaysFromSettings_ControlsTrashPurgeBoundary() + { + (StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); + settings.Preload(SettingsKeys.TrashClearDays, "3"); + store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(5)))); // старше 3 дн. + store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(1)))); + + StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None); + + Assert.Equal(1, stats.PurgedTrash); + Assert.DoesNotContain(store.CardDtos, card => card.Id == "l_old"); + Assert.Contains(store.CardDtos, card => card.Id == "l_fresh"); + } + + // ─── Пустой тик / статистика ──────────────────────────────────────────── + + [Fact] + public async Task Tick_EmptyStore_ReturnsZeroStats() + { + (StorageTickService service, _, _) = Create(); + + StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None); + + Assert.Equal(0, stats.Archived); + Assert.Equal(0, stats.PurgedArchive); + Assert.Equal(0, stats.PurgedTrash); + Assert.Equal(0, stats.PurgedRejected); // отсев пайплайна — этап 4, в этапе 3 всегда 0 (Ruling 8) + } + + // ─── Хелперы ──────────────────────────────────────────────────────────── + + private static (StorageTickService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create() + { + var store = new FakeKanjStore(); + var settings = new FakeSettingsStore(); + return (new StorageTickService(store, settings), store, settings); + } + + // Доска-колонка минимально (id/имя достаточно для списка кандидатов фейка). + private static ContainerDto Board(string id) => new() { Id = id, Name = id, Order = 0 }; + + // Карточка в колонке с моментом получения (epoch-ms). + private static CardDto Card( + string id, + string col, + long receivedAtMs) => + new() { Id = id, Col = col, ReceivedAtMs = receivedAtMs }; + + // Epoch-ms момента «now − возраст» (ReceivedAt карточки-кандидата). + private static long ReceivedAtMsAgo(TimeSpan age) => DateTimeOffset.UtcNow.Subtract(age).ToUnixTimeMilliseconds(); + + // Колонка карточки в фейке после тика. + private static string ColOf(FakeKanjStore store, string cardId) => + store.CardDtos.Single(card => card.Id == cardId).Col; + + // Флаг is_new карточки после тика. + private static bool IsNewOf(FakeKanjStore store, string cardId) => + store.CardDtos.Single(card => card.Id == cardId).IsNew; + + private static IReadOnlyList MatchHitsOf(FakeKanjStore store, string cardId) => + store.CardDtos.Single(card => card.Id == cardId).MatchHits; +} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs index cde4725..3262c9d 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs @@ -1,166 +1,166 @@ -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Modules.Pipeline; - -/// -/// Тесты приёма входящих сообщений — PipelineIngestService. -/// -public sealed class PipelineIngestServiceTests -{ - // ─── Приём: нормализация и строка очереди ─────────────────────────────── - - [Fact] - public async Task EnqueueAsync_TrimsTextAndWritesNewRowWithPId() - { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); - - PipelineIngestResultDto result = await service.EnqueueAsync( - Message( - " Нужен Python-разработчик, удалёнка ", - origin: "d_1", - externalId: "42", - name: "Канал", - hue: "#ababab", - receivedAt: DateTimeOffset.FromUnixTimeMilliseconds(1_000)), - CancellationToken.None); - - Assert.StartsWith(PipelineIdPrefixes.Queue, result.Id); - Assert.False(result.Duplicate); - QueueItemDto item = Assert.Single(store.Queue); - Assert.Equal(result.Id, item.Id); - Assert.Equal("d_1", item.Source.OriginRef); - Assert.Equal("42", item.Source.ExternalId); - Assert.Equal("Канал", item.Source.DisplayName); - Assert.Equal("#ababab", item.Source.ResolveHue()); - Assert.Equal("Нужен Python-разработчик, удалёнка", item.Content.Text); // trim краёв (enqueue L68) - Assert.Equal("Нужен Python-разработчик, удалёнка", item.Text); - Assert.Equal(PipelineQueueStatuses.New, item.Status); - Assert.Equal(1_000, item.MsgAtMs); - Assert.True(item.QueuedAtMs > 0, "queuedAt = now (CreatedAt/UpdatedAt строки)"); - Assert.False(item.Force); - } - - [Fact] - public async Task EnqueueAsync_MsgAtAbsent_ZeroMsgAtAndQueuedNow() - { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); - - await service.EnqueueAsync( - Message("Сообщение без времени", receivedAt: default(DateTimeOffset)), - CancellationToken.None); - - QueueItemDto item = Assert.Single(store.Queue); - Assert.Equal(0, item.MsgAtMs); // нет времени источника — нулевая отметка (Ruling 2) - Assert.True(item.QueuedAtMs > 0, "queuedAt = now"); - } - - [Fact] - public async Task EnqueueAsync_ForceFlag_PropagatedToQueueRow() - { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); - - await service.EnqueueAsync(Message("Возврат из отсева", force: true), CancellationToken.None); - - Assert.True(Assert.Single(store.Queue).Force); - } - - // ─── Приём: no-op (пустой текст) ──────────────────────────────────────── - - [Fact] - public async Task EnqueueAsync_EmptyOrWhitespaceText_Noop() - { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); - - PipelineIngestResultDto empty = await service.EnqueueAsync(Message(text: string.Empty), CancellationToken.None); - PipelineIngestResultDto spaces = await service.EnqueueAsync(Message(text: " "), CancellationToken.None); - - Assert.Null(empty.Id); - Assert.False(empty.Duplicate); - Assert.Null(spaces.Id); - Assert.Empty(store.Queue); // пустой текст — no-op (enqueue L69) - } - - // ─── Приём: дубль-гвард (ключ записи источника) ───────────────────────── - - [Fact] - public async Task EnqueueAsync_SameSourceTwice_SecondIsDuplicate() - { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); - QueuedMessage first = Message("Сообщение", origin: "d_1", externalId: "7"); - QueuedMessage second = Message("Сообщение снова", origin: "d_1", externalId: "7"); - - PipelineIngestResultDto accepted = await service.EnqueueAsync(first, CancellationToken.None); - PipelineIngestResultDto duplicate = await service.EnqueueAsync(second, CancellationToken.None); - - Assert.NotNull(accepted.Id); - Assert.Null(duplicate.Id); - Assert.True(duplicate.Duplicate); // повтор записи источника не пишем - Assert.Single(store.Queue); - Assert.Equal("Сообщение", Assert.Single(store.Queue).Text); // первая вставка не перезаписана - } - - [Fact] - public async Task EnqueueAsync_SameSourceDifferentExternalIds_BothAccepted() - { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); - - await service.EnqueueAsync(Message("Первое", origin: "d_1", externalId: "1"), CancellationToken.None); - PipelineIngestResultDto second = await service.EnqueueAsync(Message("Второе", origin: "d_1", externalId: "2"), CancellationToken.None); - - Assert.NotNull(second.Id); - Assert.Equal(2, store.Queue.Count); // гвард — по ключу источника, разные записи проходят - } - - [Fact] - public async Task EnqueueAsync_SameOriginWithoutExternalId_SecondIsDuplicate() - { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); - - PipelineIngestResultDto first = await service.EnqueueAsync( - Message("Первое", origin: "d_1", externalId: null), CancellationToken.None); - PipelineIngestResultDto second = await service.EnqueueAsync( - Message("Второе", origin: "d_1", externalId: null), CancellationToken.None); - - Assert.NotNull(first.Id); - Assert.True(second.Duplicate); // ключ источника без внешнего id совпадает - Assert.Single(store.Queue); - } - - // ─── Приём: лимит текста 6000 (text[:6000]) ───────────────────────────── - - [Fact] - public async Task EnqueueAsync_LongText_TruncatedTo6000CodePoints() - { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); - string longText = new string('а', 7_000); - - await service.EnqueueAsync(Message(longText), CancellationToken.None); - - QueueItemDto item = Assert.Single(store.Queue); - Assert.Equal(6_000, item.Text.EnumerateRunes().Count()); // python text[:6000] — кодовые точки - } - - // ─── Помощники ────────────────────────────────────────────────────────── - - // Команда приёма со значениями по умолчанию (сценарий теста перекрывает нужные поля). - private static QueuedMessage Message( - string text, - string? origin = SourceItemFactory.Origin, - string? externalId = SourceItemFactory.ExternalId, - string? name = SourceItemFactory.Name, - string? hue = SourceItemFactory.Hue, - DateTimeOffset? receivedAt = null, - bool force = false) => SourceItemFactory.Message(text, origin, externalId, name, hue, receivedAt, force); -} +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Tests.Unit.Modules.Kanban; +using Deal.Tests.Unit.Support; + +namespace Deal.Tests.Unit.Modules.Pipeline; + +/// +/// Тесты приёма входящих сообщений — PipelineIngestService. +/// +public sealed class PipelineIngestServiceTests +{ + // ─── Приём: нормализация и строка очереди ─────────────────────────────── + + [Fact] + public async Task EnqueueAsync_TrimsTextAndWritesNewRowWithPId() + { + FakePipelineStore store = new(); + PipelineIngestService service = new(store); + + PipelineIngestResultDto result = await service.EnqueueAsync( + Message( + " Нужен Python-разработчик, удалёнка ", + origin: "d_1", + externalId: "42", + name: "Канал", + hue: "#ababab", + receivedAt: DateTimeOffset.FromUnixTimeMilliseconds(1_000)), + CancellationToken.None); + + Assert.StartsWith(PipelineIdPrefixes.Queue, result.Id); + Assert.False(result.Duplicate); + QueueItemDto item = Assert.Single(store.Queue); + Assert.Equal(result.Id, item.Id); + Assert.Equal("d_1", item.Source.OriginRef); + Assert.Equal("42", item.Source.ExternalId); + Assert.Equal("Канал", item.Source.DisplayName); + Assert.Equal("#ababab", item.Source.ResolveHue()); + Assert.Equal("Нужен Python-разработчик, удалёнка", item.Content.Text); // trim краёв (enqueue L68) + Assert.Equal("Нужен Python-разработчик, удалёнка", item.Text); + Assert.Equal(PipelineQueueStatuses.New, item.Status); + Assert.Equal(1_000, item.MsgAtMs); + Assert.True(item.QueuedAtMs > 0, "queuedAt = now (CreatedAt/UpdatedAt строки)"); + Assert.False(item.Force); + } + + [Fact] + public async Task EnqueueAsync_MsgAtAbsent_ZeroMsgAtAndQueuedNow() + { + FakePipelineStore store = new(); + PipelineIngestService service = new(store); + + await service.EnqueueAsync( + Message("Сообщение без времени", receivedAt: default(DateTimeOffset)), + CancellationToken.None); + + QueueItemDto item = Assert.Single(store.Queue); + Assert.Equal(0, item.MsgAtMs); // нет времени источника — нулевая отметка (Ruling 2) + Assert.True(item.QueuedAtMs > 0, "queuedAt = now"); + } + + [Fact] + public async Task EnqueueAsync_ForceFlag_PropagatedToQueueRow() + { + FakePipelineStore store = new(); + PipelineIngestService service = new(store); + + await service.EnqueueAsync(Message("Возврат из отсева", force: true), CancellationToken.None); + + Assert.True(Assert.Single(store.Queue).Force); + } + + // ─── Приём: no-op (пустой текст) ──────────────────────────────────────── + + [Fact] + public async Task EnqueueAsync_EmptyOrWhitespaceText_Noop() + { + FakePipelineStore store = new(); + PipelineIngestService service = new(store); + + PipelineIngestResultDto empty = await service.EnqueueAsync(Message(text: string.Empty), CancellationToken.None); + PipelineIngestResultDto spaces = await service.EnqueueAsync(Message(text: " "), CancellationToken.None); + + Assert.Null(empty.Id); + Assert.False(empty.Duplicate); + Assert.Null(spaces.Id); + Assert.Empty(store.Queue); // пустой текст — no-op (enqueue L69) + } + + // ─── Приём: дубль-гвард (ключ записи источника) ───────────────────────── + + [Fact] + public async Task EnqueueAsync_SameSourceTwice_SecondIsDuplicate() + { + FakePipelineStore store = new(); + PipelineIngestService service = new(store); + QueuedMessage first = Message("Сообщение", origin: "d_1", externalId: "7"); + QueuedMessage second = Message("Сообщение снова", origin: "d_1", externalId: "7"); + + PipelineIngestResultDto accepted = await service.EnqueueAsync(first, CancellationToken.None); + PipelineIngestResultDto duplicate = await service.EnqueueAsync(second, CancellationToken.None); + + Assert.NotNull(accepted.Id); + Assert.Null(duplicate.Id); + Assert.True(duplicate.Duplicate); // повтор записи источника не пишем + Assert.Single(store.Queue); + Assert.Equal("Сообщение", Assert.Single(store.Queue).Text); // первая вставка не перезаписана + } + + [Fact] + public async Task EnqueueAsync_SameSourceDifferentExternalIds_BothAccepted() + { + FakePipelineStore store = new(); + PipelineIngestService service = new(store); + + await service.EnqueueAsync(Message("Первое", origin: "d_1", externalId: "1"), CancellationToken.None); + PipelineIngestResultDto second = await service.EnqueueAsync(Message("Второе", origin: "d_1", externalId: "2"), CancellationToken.None); + + Assert.NotNull(second.Id); + Assert.Equal(2, store.Queue.Count); // гвард — по ключу источника, разные записи проходят + } + + [Fact] + public async Task EnqueueAsync_SameOriginWithoutExternalId_SecondIsDuplicate() + { + FakePipelineStore store = new(); + PipelineIngestService service = new(store); + + PipelineIngestResultDto first = await service.EnqueueAsync( + Message("Первое", origin: "d_1", externalId: null), CancellationToken.None); + PipelineIngestResultDto second = await service.EnqueueAsync( + Message("Второе", origin: "d_1", externalId: null), CancellationToken.None); + + Assert.NotNull(first.Id); + Assert.True(second.Duplicate); // ключ источника без внешнего id совпадает + Assert.Single(store.Queue); + } + + // ─── Приём: лимит текста 6000 (text[:6000]) ───────────────────────────── + + [Fact] + public async Task EnqueueAsync_LongText_TruncatedTo6000CodePoints() + { + FakePipelineStore store = new(); + PipelineIngestService service = new(store); + string longText = new string('а', 7_000); + + await service.EnqueueAsync(Message(longText), CancellationToken.None); + + QueueItemDto item = Assert.Single(store.Queue); + Assert.Equal(6_000, item.Text.EnumerateRunes().Count()); // python text[:6000] — кодовые точки + } + + // ─── Помощники ────────────────────────────────────────────────────────── + + // Команда приёма со значениями по умолчанию (сценарий теста перекрывает нужные поля). + private static QueuedMessage Message( + string text, + string? origin = SourceItemFactory.Origin, + string? externalId = SourceItemFactory.ExternalId, + string? name = SourceItemFactory.Name, + string? hue = SourceItemFactory.Hue, + DateTimeOffset? receivedAt = null, + bool force = false) => SourceItemFactory.Message(text, origin, externalId, name, hue, receivedAt, force); +} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs index b5d780f..a0d8c0a 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs @@ -1,451 +1,451 @@ -using System.Globalization; -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Modules.Pipeline; - -/// -/// Тесты обслуживания пайплайна — PipelineProcessingService. -/// -public sealed class PipelineProcessingServiceTests -{ - - [Fact] - public async Task RejectAsync_WhitespaceText_Noop() - { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); - - await service.RejectAsync(new RejectRecord { Text = " ", DecidedBy = "stop", Stage = "length" }, CancellationToken.None); - - Assert.Empty(store.Rejected); // record L73–74: пустой текст — no-op - } - - [Fact] - public async Task RejectAsync_TruncatesFieldsDefaultsHueAndUsesDeterministicId() - { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); - - await service.RejectAsync(new RejectRecord - { - Source = SourceItemFactory.Source(origin: "d_1", externalId: "7", name: string.Empty, hue: null), - Content = SourceItemFactory.Content(new string('а', 7_000), string.Empty), - Text = new string('а', 7_000), - Reason = new string('р', 600), - Kw = new string('к', 300), - DecidedBy = "stop", - Stage = "stop", - }, CancellationToken.None); - - RejectedItemDto row = Assert.Single(store.Rejected); - Assert.Equal("r_test_d_1_7", row.Id); // детерминированный id по источнику (Ruling 1, L77) - Assert.Equal(6_000, row.Text.EnumerateRunes().Count()); // text[:6000] - Assert.Equal(500, row.Reason.EnumerateRunes().Count()); // reason[:500] - Assert.Equal(200, row.Kw.EnumerateRunes().Count()); // kw[:200] - Assert.Equal("#666", row.Source.ResolveHue()); // hue не задан — дефолт (L93) - Assert.Equal("stop", row.Stage); - Assert.Equal("stop", row.DecidedBy); - } - - [Fact] - public async Task RejectAsync_SameDialogAndMsgId_UpsertsSingleRow() - { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); - - await service.RejectAsync(Reject("d_1", 7, stage: "length", reason: "короткое"), CancellationToken.None); - await service.RejectAsync(Reject("d_1", 7, stage: "stop", reason: "стоп-фраза «реклама»"), CancellationToken.None); - - RejectedItemDto row = Assert.Single(store.Rejected); // повторное отбрасывание обновляет запись (L79–101) - Assert.Equal("r_test_d_1_7", row.Id); - Assert.Equal("stop", row.Stage); - Assert.Equal("стоп-фраза «реклама»", row.Reason); - } - - - [Fact] - public async Task QueueCountsAsync_CountsNewAndFiltered() - { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); - store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 1)); - store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.New, queuedAtMs: 2)); - store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.Filtered, queuedAtMs: 3)); - - QueueCountsDto counts = await service.QueueCountsAsync(CancellationToken.None); - - Assert.Equal(2, counts.New); - Assert.Equal(1, counts.Ai); // bucket «ai» = статус filtered (L212–213) - Assert.Equal(3, counts.Total); - } - - [Fact] - public async Task StatsAsync_ReturnsQueueCountsAndRejectedCount() - { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); - store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 1)); - store.SeedRejected(Rejected("r_1")); - store.SeedRejected(Rejected("r_2")); - - PipelineStatsDto stats = await service.StatsAsync(CancellationToken.None); - - Assert.Equal(1, stats.Queue.New); - Assert.Equal(0, stats.Queue.Ai); - Assert.Equal(1, stats.Queue.Total); - Assert.Equal(2, stats.Rejected); // форма /pipeline/stats: {queue:{new,ai,total}, rejected} (L315–320) - } - - [Fact] - public async Task ListQueueAsync_OrdersByQueuedAtAndClampsLimitToMinimumOne() - { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); - store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 30)); - store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.Filtered, queuedAtMs: 10)); - store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.New, queuedAtMs: 20)); - - IReadOnlyList clamped = await service.ListQueueAsync(limit: 0, CancellationToken.None); - IReadOnlyList oversized = await service.ListQueueAsync(limit: 600, CancellationToken.None); - - QueueItemDto earliest = Assert.Single(clamped); // min(max(1, limit), MAX_LIMIT) L219: 0 → 1 - Assert.Equal("p_2", earliest.Id); - Assert.Equal(new[] { "p_2", "p_3", "p_1" }, oversized.Select(item => item.Id).ToArray()); // CreatedAt ASC - } - - - [Fact] - public async Task ListRejectedAsync_NoQuery_NewestFirstWithOffsetAndLimit() - { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); - store.SeedRejected(Rejected("r_1", rejectedAtMs: 1_000)); - store.SeedRejected(Rejected("r_2", rejectedAtMs: 2_000)); - store.SeedRejected(Rejected("r_3", rejectedAtMs: 3_000)); - - RejectedPageDto page = await service.ListRejectedAsync(q: string.Empty, offset: 1, limit: 1, CancellationToken.None); - - Assert.Equal("r_2", Assert.Single(page.Items).Id); // RejectedAt DESC: r_3, r_2, r_1 → страница r_2 - Assert.Equal(3, page.Total); - Assert.Equal(1, page.Offset); // эхо запроса - Assert.Equal(1, page.Limit); - } - - [Fact] - public async Task ListRejectedAsync_QueryMatchesTextReasonKwAndSourceName() - { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); - store.SeedRejected(Rejected("r_text", text: "Middle Python разработчик", rejectedAtMs: 1_000)); - store.SeedRejected(Rejected("r_reason", reason: "стоп-фраза «реклама»", rejectedAtMs: 2_000)); - store.SeedRejected(Rejected("r_kw", kw: "спам", rejectedAtMs: 3_000)); - store.SeedRejected(Rejected("r_ch", text: "обычный текст", name: "Хороший чат", rejectedAtMs: 4_000)); - - RejectedPageDto byText = await service.ListRejectedAsync("Python", 0, 10, CancellationToken.None); - RejectedPageDto byReason = await service.ListRejectedAsync("реклама", 0, 10, CancellationToken.None); - RejectedPageDto byKw = await service.ListRejectedAsync("спам", 0, 10, CancellationToken.None); - RejectedPageDto bySource = await service.ListRejectedAsync("хороший", 0, 10, CancellationToken.None); - - Assert.Equal("r_text", Assert.Single(byText.Items).Id); // LIKE по text; регистр q не важен - Assert.Equal("r_reason", Assert.Single(byReason.Items).Id); // LIKE по reason - Assert.Equal("r_kw", Assert.Single(byKw.Items).Id); // LIKE по kw - Assert.Equal("r_ch", Assert.Single(bySource.Items).Id); // LIKE по подписи источника - } - - [Fact] - public async Task ListRejectedAsync_QueryWithOffsetPagesOverCandidatesAndEchoesTotal() - { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); - store.SeedRejected(Rejected("r_1", text: "middle a", rejectedAtMs: 1_000)); - store.SeedRejected(Rejected("r_2", text: "middle b", rejectedAtMs: 2_000)); - store.SeedRejected(Rejected("r_3", text: "middle c", rejectedAtMs: 3_000)); - store.SeedRejected(Rejected("r_x", text: "другой текст", rejectedAtMs: 4_000)); - - RejectedPageDto page = await service.ListRejectedAsync("middle", offset: 1, limit: 2, CancellationToken.None); - - Assert.Equal(new[] { "r_2", "r_1" }, page.Items.Select(item => item.Id).ToArray()); // кандидаты r_3,r_2,r_1 - Assert.Equal(3, page.Total); // total = размер объединения кандидатов (не весь отсев) - } - - - [Fact] - public async Task ReturnAsync_RecordNotFound_ReturnsNull() - { - (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); - - RejectReturnResultDto? result = await service.ReturnAsync("r_missing", string.Empty, CancellationToken.None); - - Assert.Null(result); // эндпоинт ответит 404 «Запись не найдена» (Ruling 10) - Assert.Empty(store.Queue); - Assert.Empty(ml.Pushed); - } - - [Fact] - public async Task ReturnAsync_AlreadyReturned_Returns400() - { - (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); - store.SeedRejected(Rejected("r_1", returned: true, returnReason: "было")); - - RejectReturnResultDto? result = await service.ReturnAsync("r_1", "ещё раз", CancellationToken.None); - - Assert.NotNull(result); - Assert.Equal(PipelineProcessingService.AlreadyReturnedDetail, result!.Error); // «Сообщение уже возвращено в обработку» - Assert.False(result.Returned); - Assert.Empty(store.Queue); // повторный возврат ничего не делает - Assert.Empty(ml.Pushed); - } - - [Fact] - public async Task ReturnAsync_DuplicateSource_Returns400() - { - (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); - store.SeedRejected(Rejected("r_1", decidedBy: "dup", stage: "dup")); - - RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None); - - Assert.NotNull(result); - Assert.Equal(PipelineProcessingService.DuplicateReturnDetail, result!.Error); // «Повтор: карточка…» - Assert.Empty(store.Queue); - Assert.Empty(ml.Pushed); - } - - [Fact] - public async Task ReturnAsync_EmptyText_Returns400() - { - (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); - store.SeedRejected(Rejected("r_1", text: " ")); - - RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None); - - Assert.NotNull(result); - Assert.Equal(PipelineProcessingService.EmptyReturnTextDetail, result!.Error); // «В записи нет текста сообщения» - Assert.Empty(store.Queue); - Assert.Empty(ml.Pushed); - } - - [Fact] - public async Task ReturnAsync_SpamStage_UnlearnsSpamMarksRecordAndEnqueuesForceRow() - { - (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); - store.SeedRejected(Rejected( - "r_1", - text: " Продвижение в каналах ", - stage: "spam_ai", - decidedBy: "ai", - origin: "d_9", - externalId: "5", - name: "Спам", - hue: "#ff0000", - msgAtMs: 5_000, - receivedAt: DateTimeOffset.FromUnixTimeMilliseconds(5_000))); - - RejectReturnResultDto? result = await service.ReturnAsync("r_1", "не спам, вернуть", CancellationToken.None); - - Assert.NotNull(result); - Assert.Null(result!.Error); - Assert.Equal("r_1", result.Id); - Assert.True(result.Returned); - Assert.True(result.ReturnedAtMs > 0); - - (string text, string label, double delta) = Assert.Single(ml.Pushed); // реальное действие: НЕ спам (L149–153) - Assert.Equal("Продвижение в каналах", text); - Assert.Equal("spam", label); - Assert.Equal(-1.0, delta); - - RejectedItemDto record = Assert.Single(store.Rejected); // запись не удаляется — аудит возврата - Assert.True(record.Returned); - Assert.True(record.ReturnedAtMs > 0); - Assert.Equal("не спам, вернуть", record.ReturnReason); - - QueueItemDto queued = Assert.Single(store.Queue); - Assert.StartsWith(PipelineIdPrefixes.Queue, queued.Id); - Assert.Equal(PipelineQueueStatuses.New, queued.Status); - Assert.True(queued.Force); // возврат: причины отсева игнорируются воркером (Ruling 5/10) - Assert.Equal("d_9", queued.Source.OriginRef); - Assert.Equal("5", queued.Source.ExternalId); - Assert.Equal("Продвижение в каналах", queued.Text); - Assert.Equal("Спам", queued.Source.DisplayName); - Assert.Equal("#ff0000", queued.Source.ResolveHue()); - Assert.Equal(5_000, queued.MsgAtMs); // msg_at исходного сообщения сохраняется (L171) - Assert.True(queued.QueuedAtMs > 0); - } - - [Fact] - public async Task ReturnAsync_NonSpamStage_NoLearningPushButEnqueuesForceRow() - { - (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); - store.SeedRejected(Rejected( - "r_1", - text: "Короткое сообщение", - stage: "length", - decidedBy: "stop", - origin: "d_1", - externalId: "3", - msgAtMs: 9_000, - receivedAt: DateTimeOffset.FromUnixTimeMilliseconds(9_000))); - - RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None); - - Assert.NotNull(result); - Assert.Null(result!.Error); - Assert.True(result.Returned); - Assert.Empty(ml.Pushed); // не «спам»-этап — обучения нет (L149) - QueueItemDto queued = Assert.Single(store.Queue); - Assert.True(queued.Force); - Assert.Equal("Короткое сообщение", queued.Text); - Assert.Equal(9_000, queued.MsgAtMs); - } - - [Fact] - public async Task ReturnAsync_LongReason_TruncatedTo500CodePointsOnRecord() - { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); - store.SeedRejected(Rejected("r_1", text: "Текст", stage: "stale", decidedBy: "stale", origin: "d_1")); - - await service.ReturnAsync("r_1", new string('п', 700), CancellationToken.None); - - RejectedItemDto record = Assert.Single(store.Rejected); - Assert.True(record.Returned); - Assert.Equal(500, record.ReturnReason.EnumerateRunes().Count()); // return_reason[:500] (L158) - } - - [Fact] - public async Task ReturnAsync_NoSourceInfo_EnqueuesDirectlyWithDefaultHue() - { - (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); - store.SeedRejected(Rejected( - "r_old", - text: "Старая запись без ссылки на исходное", - stage: "spam_ml", - decidedBy: "ml", - origin: string.Empty, - externalId: null, - name: string.Empty, - hue: null, - receivedAt: default(DateTimeOffset))); - - RejectReturnResultDto? result = await service.ReturnAsync("r_old", string.Empty, CancellationToken.None); - - Assert.NotNull(result); - Assert.Null(result!.Error); - Assert.True(result.Returned); - Assert.Single(ml.Pushed); // spam_ml — снятие веса спама идёт до веток возврата - QueueItemDto queued = Assert.Single(store.Queue); // прямая вставка без ссылки на исходное (L174–192) - Assert.StartsWith(PipelineIdPrefixes.Queue, queued.Id); - Assert.True(queued.Force); - Assert.Equal(string.Empty, queued.Source.OriginRef); - Assert.Null(queued.Source.ExternalId); - Assert.Equal("#666", queued.Source.ResolveHue()); // hue не сохранён — дефолт (L181) - Assert.Equal(0, queued.MsgAtMs); // времени источника нет — нулевая отметка - } - - - [Fact] - public async Task DeleteAsync_RemovesSingleRecord() - { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); - store.SeedRejected(Rejected("r_1")); - store.SeedRejected(Rejected("r_2")); - - await service.DeleteAsync("r_1", CancellationToken.None); - - Assert.Equal("r_2", Assert.Single(store.Rejected).Id); - } - - [Fact] - public async Task ClearAsync_ReturnsCountAndEmptiesRejects() - { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); - store.SeedRejected(Rejected("r_1")); - store.SeedRejected(Rejected("r_2")); - store.SeedRejected(Rejected("r_3")); - - int cleared = await service.ClearAsync(CancellationToken.None); - - Assert.Equal(3, cleared); // {ok, cleared} POST /rejected/clear - Assert.Empty(store.Rejected); - - Assert.Equal(0, await service.ClearAsync(CancellationToken.None)); // пустой отсев — 0 - } - - [Fact] - public async Task PurgeExpiredAsync_RemovesOnlyRowsOlderThanRetention() - { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); - long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - store.SeedRejected(Rejected("r_old", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds))); - store.SeedRejected(Rejected("r_fresh", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds))); - - int purged = await service.PurgeExpiredAsync(CancellationToken.None); - - Assert.Equal(1, purged); // автоочистка 3 суток от RejectedAt (Ruling 8, purge_expired L104–117) - Assert.Equal("r_fresh", Assert.Single(store.Rejected).Id); - } - - // ─── Помощники ────────────────────────────────────────────────────────── - - // Сервис на общих фейках: очередь/отсев и ML-клиент одного сценария. - private static (PipelineProcessingService Service, FakePipelineStore Store, FakeMlClient Ml) Create() - { - FakePipelineStore store = new(); - FakeMlClient ml = new(); - PipelineProcessingService service = new(store, ml, new PipelineIngestService(store)); - return (service, store, ml); - } - - // Команда записи отсева (поля по умолчанию — length/stop, текст без ограничений). - private static RejectRecord Reject( - string dialogId, - long? msgId, - string stage, - string reason) => SourceItemFactory.Reject( - "Текст сообщения", - origin: dialogId, - externalId: msgId?.ToString(CultureInfo.InvariantCulture), - name: string.Empty, - hue: null, - stage: stage, - decidedBy: "stop", - reason: reason); - - // Строка очереди для сидирования (id/статус/время постановки). - private static QueueItemDto QueueItem( - string id, - string status, - long queuedAtMs) => SourceItemFactory.Queue( - id, - "текст", - status, - msgAtMs: queuedAtMs, - queuedAtMs: queuedAtMs); - - // Запись отсева для сидирования (как строка БД после маппинга адаптера). - private static RejectedItemDto Rejected( - string id, - string text = "Текст сообщения", - string stage = "length", - string decidedBy = "stop", - string reason = "", - string kw = "", - string? origin = SourceItemFactory.Origin, - string? externalId = SourceItemFactory.ExternalId, - string? name = SourceItemFactory.Name, - string? hue = SourceItemFactory.Hue, - long msgAtMs = 0, - long rejectedAtMs = 1_000, - bool returned = false, - string returnReason = "", - DateTimeOffset? receivedAt = null) => SourceItemFactory.Rejected( - id, - text, - stage, - decidedBy, - reason, - kw, - origin, - externalId, - name, - hue, - msgAtMs, - rejectedAtMs, - returned, - returnReason, - receivedAt); -} +using System.Globalization; +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Kanban; +using Deal.Tests.Unit.Support; + +namespace Deal.Tests.Unit.Modules.Pipeline; + +/// +/// Тесты обслуживания пайплайна — PipelineProcessingService. +/// +public sealed class PipelineProcessingServiceTests +{ + + [Fact] + public async Task RejectAsync_WhitespaceText_Noop() + { + (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + + await service.RejectAsync(new RejectRecord { Text = " ", DecidedBy = "stop", Stage = "length" }, CancellationToken.None); + + Assert.Empty(store.Rejected); // record L73–74: пустой текст — no-op + } + + [Fact] + public async Task RejectAsync_TruncatesFieldsDefaultsHueAndUsesDeterministicId() + { + (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + + await service.RejectAsync(new RejectRecord + { + Source = SourceItemFactory.Source(origin: "d_1", externalId: "7", name: string.Empty, hue: null), + Content = SourceItemFactory.Content(new string('а', 7_000), string.Empty), + Text = new string('а', 7_000), + Reason = new string('р', 600), + Kw = new string('к', 300), + DecidedBy = "stop", + Stage = "stop", + }, CancellationToken.None); + + RejectedItemDto row = Assert.Single(store.Rejected); + Assert.Equal("r_test_d_1_7", row.Id); // детерминированный id по источнику (Ruling 1, L77) + Assert.Equal(6_000, row.Text.EnumerateRunes().Count()); // text[:6000] + Assert.Equal(500, row.Reason.EnumerateRunes().Count()); // reason[:500] + Assert.Equal(200, row.Kw.EnumerateRunes().Count()); // kw[:200] + Assert.Equal("#666", row.Source.ResolveHue()); // hue не задан — дефолт (L93) + Assert.Equal("stop", row.Stage); + Assert.Equal("stop", row.DecidedBy); + } + + [Fact] + public async Task RejectAsync_SameDialogAndMsgId_UpsertsSingleRow() + { + (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + + await service.RejectAsync(Reject("d_1", 7, stage: "length", reason: "короткое"), CancellationToken.None); + await service.RejectAsync(Reject("d_1", 7, stage: "stop", reason: "стоп-фраза «реклама»"), CancellationToken.None); + + RejectedItemDto row = Assert.Single(store.Rejected); // повторное отбрасывание обновляет запись (L79–101) + Assert.Equal("r_test_d_1_7", row.Id); + Assert.Equal("stop", row.Stage); + Assert.Equal("стоп-фраза «реклама»", row.Reason); + } + + + [Fact] + public async Task QueueCountsAsync_CountsNewAndFiltered() + { + (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 1)); + store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.New, queuedAtMs: 2)); + store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.Filtered, queuedAtMs: 3)); + + QueueCountsDto counts = await service.QueueCountsAsync(CancellationToken.None); + + Assert.Equal(2, counts.New); + Assert.Equal(1, counts.Ai); // bucket «ai» = статус filtered (L212–213) + Assert.Equal(3, counts.Total); + } + + [Fact] + public async Task StatsAsync_ReturnsQueueCountsAndRejectedCount() + { + (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 1)); + store.SeedRejected(Rejected("r_1")); + store.SeedRejected(Rejected("r_2")); + + PipelineStatsDto stats = await service.StatsAsync(CancellationToken.None); + + Assert.Equal(1, stats.Queue.New); + Assert.Equal(0, stats.Queue.Ai); + Assert.Equal(1, stats.Queue.Total); + Assert.Equal(2, stats.Rejected); // форма /pipeline/stats: {queue:{new,ai,total}, rejected} (L315–320) + } + + [Fact] + public async Task ListQueueAsync_OrdersByQueuedAtAndClampsLimitToMinimumOne() + { + (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 30)); + store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.Filtered, queuedAtMs: 10)); + store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.New, queuedAtMs: 20)); + + IReadOnlyList clamped = await service.ListQueueAsync(limit: 0, CancellationToken.None); + IReadOnlyList oversized = await service.ListQueueAsync(limit: 600, CancellationToken.None); + + QueueItemDto earliest = Assert.Single(clamped); // min(max(1, limit), MAX_LIMIT) L219: 0 → 1 + Assert.Equal("p_2", earliest.Id); + Assert.Equal(new[] { "p_2", "p_3", "p_1" }, oversized.Select(item => item.Id).ToArray()); // CreatedAt ASC + } + + + [Fact] + public async Task ListRejectedAsync_NoQuery_NewestFirstWithOffsetAndLimit() + { + (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + store.SeedRejected(Rejected("r_1", rejectedAtMs: 1_000)); + store.SeedRejected(Rejected("r_2", rejectedAtMs: 2_000)); + store.SeedRejected(Rejected("r_3", rejectedAtMs: 3_000)); + + RejectedPageDto page = await service.ListRejectedAsync(q: string.Empty, offset: 1, limit: 1, CancellationToken.None); + + Assert.Equal("r_2", Assert.Single(page.Items).Id); // RejectedAt DESC: r_3, r_2, r_1 → страница r_2 + Assert.Equal(3, page.Total); + Assert.Equal(1, page.Offset); // эхо запроса + Assert.Equal(1, page.Limit); + } + + [Fact] + public async Task ListRejectedAsync_QueryMatchesTextReasonKwAndSourceName() + { + (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + store.SeedRejected(Rejected("r_text", text: "Middle Python разработчик", rejectedAtMs: 1_000)); + store.SeedRejected(Rejected("r_reason", reason: "стоп-фраза «реклама»", rejectedAtMs: 2_000)); + store.SeedRejected(Rejected("r_kw", kw: "спам", rejectedAtMs: 3_000)); + store.SeedRejected(Rejected("r_ch", text: "обычный текст", name: "Хороший чат", rejectedAtMs: 4_000)); + + RejectedPageDto byText = await service.ListRejectedAsync("Python", 0, 10, CancellationToken.None); + RejectedPageDto byReason = await service.ListRejectedAsync("реклама", 0, 10, CancellationToken.None); + RejectedPageDto byKw = await service.ListRejectedAsync("спам", 0, 10, CancellationToken.None); + RejectedPageDto bySource = await service.ListRejectedAsync("хороший", 0, 10, CancellationToken.None); + + Assert.Equal("r_text", Assert.Single(byText.Items).Id); // LIKE по text; регистр q не важен + Assert.Equal("r_reason", Assert.Single(byReason.Items).Id); // LIKE по reason + Assert.Equal("r_kw", Assert.Single(byKw.Items).Id); // LIKE по kw + Assert.Equal("r_ch", Assert.Single(bySource.Items).Id); // LIKE по подписи источника + } + + [Fact] + public async Task ListRejectedAsync_QueryWithOffsetPagesOverCandidatesAndEchoesTotal() + { + (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + store.SeedRejected(Rejected("r_1", text: "middle a", rejectedAtMs: 1_000)); + store.SeedRejected(Rejected("r_2", text: "middle b", rejectedAtMs: 2_000)); + store.SeedRejected(Rejected("r_3", text: "middle c", rejectedAtMs: 3_000)); + store.SeedRejected(Rejected("r_x", text: "другой текст", rejectedAtMs: 4_000)); + + RejectedPageDto page = await service.ListRejectedAsync("middle", offset: 1, limit: 2, CancellationToken.None); + + Assert.Equal(new[] { "r_2", "r_1" }, page.Items.Select(item => item.Id).ToArray()); // кандидаты r_3,r_2,r_1 + Assert.Equal(3, page.Total); // total = размер объединения кандидатов (не весь отсев) + } + + + [Fact] + public async Task ReturnAsync_RecordNotFound_ReturnsNull() + { + (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); + + RejectReturnResultDto? result = await service.ReturnAsync("r_missing", string.Empty, CancellationToken.None); + + Assert.Null(result); // эндпоинт ответит 404 «Запись не найдена» (Ruling 10) + Assert.Empty(store.Queue); + Assert.Empty(ml.Pushed); + } + + [Fact] + public async Task ReturnAsync_AlreadyReturned_Returns400() + { + (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); + store.SeedRejected(Rejected("r_1", returned: true, returnReason: "было")); + + RejectReturnResultDto? result = await service.ReturnAsync("r_1", "ещё раз", CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal(PipelineProcessingService.AlreadyReturnedDetail, result!.Error); // «Сообщение уже возвращено в обработку» + Assert.False(result.Returned); + Assert.Empty(store.Queue); // повторный возврат ничего не делает + Assert.Empty(ml.Pushed); + } + + [Fact] + public async Task ReturnAsync_DuplicateSource_Returns400() + { + (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); + store.SeedRejected(Rejected("r_1", decidedBy: "dup", stage: "dup")); + + RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal(PipelineProcessingService.DuplicateReturnDetail, result!.Error); // «Повтор: карточка…» + Assert.Empty(store.Queue); + Assert.Empty(ml.Pushed); + } + + [Fact] + public async Task ReturnAsync_EmptyText_Returns400() + { + (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); + store.SeedRejected(Rejected("r_1", text: " ")); + + RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal(PipelineProcessingService.EmptyReturnTextDetail, result!.Error); // «В записи нет текста сообщения» + Assert.Empty(store.Queue); + Assert.Empty(ml.Pushed); + } + + [Fact] + public async Task ReturnAsync_SpamStage_UnlearnsSpamMarksRecordAndEnqueuesForceRow() + { + (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); + store.SeedRejected(Rejected( + "r_1", + text: " Продвижение в каналах ", + stage: "spam_ai", + decidedBy: "ai", + origin: "d_9", + externalId: "5", + name: "Спам", + hue: "#ff0000", + msgAtMs: 5_000, + receivedAt: DateTimeOffset.FromUnixTimeMilliseconds(5_000))); + + RejectReturnResultDto? result = await service.ReturnAsync("r_1", "не спам, вернуть", CancellationToken.None); + + Assert.NotNull(result); + Assert.Null(result!.Error); + Assert.Equal("r_1", result.Id); + Assert.True(result.Returned); + Assert.True(result.ReturnedAtMs > 0); + + (string text, string label, double delta) = Assert.Single(ml.Pushed); // реальное действие: НЕ спам (L149–153) + Assert.Equal("Продвижение в каналах", text); + Assert.Equal("spam", label); + Assert.Equal(-1.0, delta); + + RejectedItemDto record = Assert.Single(store.Rejected); // запись не удаляется — аудит возврата + Assert.True(record.Returned); + Assert.True(record.ReturnedAtMs > 0); + Assert.Equal("не спам, вернуть", record.ReturnReason); + + QueueItemDto queued = Assert.Single(store.Queue); + Assert.StartsWith(PipelineIdPrefixes.Queue, queued.Id); + Assert.Equal(PipelineQueueStatuses.New, queued.Status); + Assert.True(queued.Force); // возврат: причины отсева игнорируются воркером (Ruling 5/10) + Assert.Equal("d_9", queued.Source.OriginRef); + Assert.Equal("5", queued.Source.ExternalId); + Assert.Equal("Продвижение в каналах", queued.Text); + Assert.Equal("Спам", queued.Source.DisplayName); + Assert.Equal("#ff0000", queued.Source.ResolveHue()); + Assert.Equal(5_000, queued.MsgAtMs); // msg_at исходного сообщения сохраняется (L171) + Assert.True(queued.QueuedAtMs > 0); + } + + [Fact] + public async Task ReturnAsync_NonSpamStage_NoLearningPushButEnqueuesForceRow() + { + (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); + store.SeedRejected(Rejected( + "r_1", + text: "Короткое сообщение", + stage: "length", + decidedBy: "stop", + origin: "d_1", + externalId: "3", + msgAtMs: 9_000, + receivedAt: DateTimeOffset.FromUnixTimeMilliseconds(9_000))); + + RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None); + + Assert.NotNull(result); + Assert.Null(result!.Error); + Assert.True(result.Returned); + Assert.Empty(ml.Pushed); // не «спам»-этап — обучения нет (L149) + QueueItemDto queued = Assert.Single(store.Queue); + Assert.True(queued.Force); + Assert.Equal("Короткое сообщение", queued.Text); + Assert.Equal(9_000, queued.MsgAtMs); + } + + [Fact] + public async Task ReturnAsync_LongReason_TruncatedTo500CodePointsOnRecord() + { + (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + store.SeedRejected(Rejected("r_1", text: "Текст", stage: "stale", decidedBy: "stale", origin: "d_1")); + + await service.ReturnAsync("r_1", new string('п', 700), CancellationToken.None); + + RejectedItemDto record = Assert.Single(store.Rejected); + Assert.True(record.Returned); + Assert.Equal(500, record.ReturnReason.EnumerateRunes().Count()); // return_reason[:500] (L158) + } + + [Fact] + public async Task ReturnAsync_NoSourceInfo_EnqueuesDirectlyWithDefaultHue() + { + (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); + store.SeedRejected(Rejected( + "r_old", + text: "Старая запись без ссылки на исходное", + stage: "spam_ml", + decidedBy: "ml", + origin: string.Empty, + externalId: null, + name: string.Empty, + hue: null, + receivedAt: default(DateTimeOffset))); + + RejectReturnResultDto? result = await service.ReturnAsync("r_old", string.Empty, CancellationToken.None); + + Assert.NotNull(result); + Assert.Null(result!.Error); + Assert.True(result.Returned); + Assert.Single(ml.Pushed); // spam_ml — снятие веса спама идёт до веток возврата + QueueItemDto queued = Assert.Single(store.Queue); // прямая вставка без ссылки на исходное (L174–192) + Assert.StartsWith(PipelineIdPrefixes.Queue, queued.Id); + Assert.True(queued.Force); + Assert.Equal(string.Empty, queued.Source.OriginRef); + Assert.Null(queued.Source.ExternalId); + Assert.Equal("#666", queued.Source.ResolveHue()); // hue не сохранён — дефолт (L181) + Assert.Equal(0, queued.MsgAtMs); // времени источника нет — нулевая отметка + } + + + [Fact] + public async Task DeleteAsync_RemovesSingleRecord() + { + (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + store.SeedRejected(Rejected("r_1")); + store.SeedRejected(Rejected("r_2")); + + await service.DeleteAsync("r_1", CancellationToken.None); + + Assert.Equal("r_2", Assert.Single(store.Rejected).Id); + } + + [Fact] + public async Task ClearAsync_ReturnsCountAndEmptiesRejects() + { + (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + store.SeedRejected(Rejected("r_1")); + store.SeedRejected(Rejected("r_2")); + store.SeedRejected(Rejected("r_3")); + + int cleared = await service.ClearAsync(CancellationToken.None); + + Assert.Equal(3, cleared); // {ok, cleared} POST /rejected/clear + Assert.Empty(store.Rejected); + + Assert.Equal(0, await service.ClearAsync(CancellationToken.None)); // пустой отсев — 0 + } + + [Fact] + public async Task PurgeExpiredAsync_RemovesOnlyRowsOlderThanRetention() + { + (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + store.SeedRejected(Rejected("r_old", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds))); + store.SeedRejected(Rejected("r_fresh", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds))); + + int purged = await service.PurgeExpiredAsync(CancellationToken.None); + + Assert.Equal(1, purged); // автоочистка 3 суток от RejectedAt (Ruling 8, purge_expired L104–117) + Assert.Equal("r_fresh", Assert.Single(store.Rejected).Id); + } + + // ─── Помощники ────────────────────────────────────────────────────────── + + // Сервис на общих фейках: очередь/отсев и ML-клиент одного сценария. + private static (PipelineProcessingService Service, FakePipelineStore Store, FakeMlClient Ml) Create() + { + FakePipelineStore store = new(); + FakeMlClient ml = new(); + PipelineProcessingService service = new(store, ml, new PipelineIngestService(store)); + return (service, store, ml); + } + + // Команда записи отсева (поля по умолчанию — length/stop, текст без ограничений). + private static RejectRecord Reject( + string dialogId, + long? msgId, + string stage, + string reason) => SourceItemFactory.Reject( + "Текст сообщения", + origin: dialogId, + externalId: msgId?.ToString(CultureInfo.InvariantCulture), + name: string.Empty, + hue: null, + stage: stage, + decidedBy: "stop", + reason: reason); + + // Строка очереди для сидирования (id/статус/время постановки). + private static QueueItemDto QueueItem( + string id, + string status, + long queuedAtMs) => SourceItemFactory.Queue( + id, + "текст", + status, + msgAtMs: queuedAtMs, + queuedAtMs: queuedAtMs); + + // Запись отсева для сидирования (как строка БД после маппинга адаптера). + private static RejectedItemDto Rejected( + string id, + string text = "Текст сообщения", + string stage = "length", + string decidedBy = "stop", + string reason = "", + string kw = "", + string? origin = SourceItemFactory.Origin, + string? externalId = SourceItemFactory.ExternalId, + string? name = SourceItemFactory.Name, + string? hue = SourceItemFactory.Hue, + long msgAtMs = 0, + long rejectedAtMs = 1_000, + bool returned = false, + string returnReason = "", + DateTimeOffset? receivedAt = null) => SourceItemFactory.Rejected( + id, + text, + stage, + decidedBy, + reason, + kw, + origin, + externalId, + name, + hue, + msgAtMs, + rejectedAtMs, + returned, + returnReason, + receivedAt); +} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditEventsTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditEventsTests.cs index 1c66d6e..f1556d6 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditEventsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditEventsTests.cs @@ -1,32 +1,32 @@ -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Tests.Unit.Modules.Tenants; - -/// -/// Стабильность каталога типов событий аудита. -/// -public sealed class AuditEventsTests -{ - [Theory] - [InlineData("tenant_logout", AuditEvents.TenantLogout)] - [InlineData("operator_logout", AuditEvents.OperatorLogout)] - [InlineData("invite_joined", AuditEvents.InviteJoined)] - [InlineData("card_created", AuditEvents.CardCreated)] - [InlineData("card_moved", AuditEvents.CardMoved)] - [InlineData("card_trashed", AuditEvents.CardTrashed)] - [InlineData("card_restored", AuditEvents.CardRestored)] - [InlineData("card_deleted", AuditEvents.CardDeleted)] - [InlineData("card_comment_added", AuditEvents.CardCommentAdded)] - [InlineData("card_reclassified", AuditEvents.CardReclassified)] - [InlineData("container_created", AuditEvents.ContainerCreated)] - [InlineData("container_updated", AuditEvents.ContainerUpdated)] - [InlineData("container_deleted", AuditEvents.ContainerDeleted)] - [InlineData("settings_updated", AuditEvents.SettingsUpdated)] - [InlineData("channel_enabled", AuditEvents.ChannelEnabled)] - [InlineData("channel_created", AuditEvents.ChannelCreated)] - [InlineData("telegram_linked", AuditEvents.TelegramLinked)] - public void NewCatalogValues_AreStableStrings(string expected, string actual) - { - Assert.Equal(expected, actual); - } -} +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Tests.Unit.Modules.Tenants; + +/// +/// Стабильность каталога типов событий аудита. +/// +public sealed class AuditEventsTests +{ + [Theory] + [InlineData("tenant_logout", AuditEvents.TenantLogout)] + [InlineData("operator_logout", AuditEvents.OperatorLogout)] + [InlineData("invite_joined", AuditEvents.InviteJoined)] + [InlineData("card_created", AuditEvents.CardCreated)] + [InlineData("card_moved", AuditEvents.CardMoved)] + [InlineData("card_trashed", AuditEvents.CardTrashed)] + [InlineData("card_restored", AuditEvents.CardRestored)] + [InlineData("card_deleted", AuditEvents.CardDeleted)] + [InlineData("card_comment_added", AuditEvents.CardCommentAdded)] + [InlineData("card_reclassified", AuditEvents.CardReclassified)] + [InlineData("container_created", AuditEvents.ContainerCreated)] + [InlineData("container_updated", AuditEvents.ContainerUpdated)] + [InlineData("container_deleted", AuditEvents.ContainerDeleted)] + [InlineData("settings_updated", AuditEvents.SettingsUpdated)] + [InlineData("channel_enabled", AuditEvents.ChannelEnabled)] + [InlineData("channel_created", AuditEvents.ChannelCreated)] + [InlineData("telegram_linked", AuditEvents.TelegramLinked)] + public void NewCatalogValues_AreStableStrings(string expected, string actual) + { + Assert.Equal(expected, actual); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FailingTenantProvisioner.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FailingTenantProvisioner.cs index ee8c6cc..ba12abf 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FailingTenantProvisioner.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FailingTenantProvisioner.cs @@ -1,53 +1,53 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.SharedKernel.Tenants.Models; - -namespace Deal.Tests.Unit.Modules.Tenants; - -/// -/// In-memory реализация для тестов пакетной миграции -/// -public sealed class FailingTenantProvisioner : ITenantProvisioner -{ - private readonly HashSet _failingSchemaNames; - private readonly List _provisionedSchemaNames = []; - private readonly object _gate = new(); - - /// - /// Создаёт провижинер, роняющий провижининг указанных схем. - /// - /// Имена схем (tenant_<id>), провижининг которых бросает исключение. - public FailingTenantProvisioner(params string[] failingSchemaNames) - { - _failingSchemaNames = new HashSet(failingSchemaNames, StringComparer.Ordinal); - } - - /// - /// Имена успешно провижиненных схем в порядке вызовов. - /// - public IReadOnlyList ProvisionedSchemaNames - { - get - { - lock (_gate) - { - return _provisionedSchemaNames.ToArray(); - } - } - } - - /// - public Task ProvisionAsync(TenantId tenantId, CancellationToken ct) - { - if (_failingSchemaNames.Contains(tenantId.SchemaName)) - { - throw new InvalidOperationException($"Тест: схема {tenantId.SchemaName} недоступна"); - } - - lock (_gate) - { - _provisionedSchemaNames.Add(tenantId.SchemaName); - } - - return Task.CompletedTask; - } -} +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.SharedKernel.Tenants.Models; + +namespace Deal.Tests.Unit.Modules.Tenants; + +/// +/// In-memory реализация для тестов пакетной миграции +/// +public sealed class FailingTenantProvisioner : ITenantProvisioner +{ + private readonly HashSet _failingSchemaNames; + private readonly List _provisionedSchemaNames = []; + private readonly object _gate = new(); + + /// + /// Создаёт провижинер, роняющий провижининг указанных схем. + /// + /// Имена схем (tenant_<id>), провижининг которых бросает исключение. + public FailingTenantProvisioner(params string[] failingSchemaNames) + { + _failingSchemaNames = new HashSet(failingSchemaNames, StringComparer.Ordinal); + } + + /// + /// Имена успешно провижиненных схем в порядке вызовов. + /// + public IReadOnlyList ProvisionedSchemaNames + { + get + { + lock (_gate) + { + return _provisionedSchemaNames.ToArray(); + } + } + } + + /// + public Task ProvisionAsync(TenantId tenantId, CancellationToken ct) + { + if (_failingSchemaNames.Contains(tenantId.SchemaName)) + { + throw new InvalidOperationException($"Тест: схема {tenantId.SchemaName} недоступна"); + } + + lock (_gate) + { + _provisionedSchemaNames.Add(tenantId.SchemaName); + } + + return Task.CompletedTask; + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeRateLimitCounterStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeRateLimitCounterStore.cs index 250b121..a7baf02 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeRateLimitCounterStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeRateLimitCounterStore.cs @@ -1,73 +1,73 @@ -using Deal.Modules.Tenants.Application.Abstractions; - -namespace Deal.Tests.Unit.Modules.Tenants; - -/// -/// In-memory реализация для юнит/HTTP-тестов -/// -public sealed class FakeRateLimitCounterStore : IRateLimitCounterStore -{ - private sealed class Row - { - public DateTimeOffset WindowStart { get; set; } - - public DateTimeOffset ExpiresAt { get; set; } - - public int Count { get; set; } - } - - private readonly Dictionary _rows = new(); - - /// - public Task IncrementAsync( - string key, - DateTimeOffset windowStart, - DateTimeOffset windowEnd, - int amount, - CancellationToken ct) - { - if (!_rows.TryGetValue(key, out Row? row) || row.WindowStart != windowStart) - { - row = new Row { WindowStart = windowStart, ExpiresAt = windowEnd, Count = amount }; - _rows[key] = row; - } - else - { - row.Count += amount; - row.ExpiresAt = windowEnd; - } - - return Task.FromResult(row.Count); - } - - /// - public Task GetCountAsync( - string key, - DateTimeOffset windowStart, - CancellationToken ct) - => Task.FromResult(_rows.TryGetValue(key, out Row? row) && row.WindowStart == windowStart ? row.Count : 0); - - /// - public Task ResetAsync(string key, CancellationToken ct) - { - _rows.Remove(key); - return Task.CompletedTask; - } - - /// - public Task DeleteExpiredAsync(DateTimeOffset now, CancellationToken ct) - { - List expired = _rows.Where(pair => pair.Value.ExpiresAt < now).Select(pair => pair.Key).ToList(); - foreach (string key in expired) - { - _rows.Remove(key); - } - - return Task.FromResult(expired.Count); - } - - /// - /// Число заведённых счётчиков - /// - public int Count => _rows.Count; -} +using Deal.Modules.Tenants.Application.Abstractions; + +namespace Deal.Tests.Unit.Modules.Tenants; + +/// +/// In-memory реализация для юнит/HTTP-тестов +/// +public sealed class FakeRateLimitCounterStore : IRateLimitCounterStore +{ + private sealed class Row + { + public DateTimeOffset WindowStart { get; set; } + + public DateTimeOffset ExpiresAt { get; set; } + + public int Count { get; set; } + } + + private readonly Dictionary _rows = new(); + + /// + public Task IncrementAsync( + string key, + DateTimeOffset windowStart, + DateTimeOffset windowEnd, + int amount, + CancellationToken ct) + { + if (!_rows.TryGetValue(key, out Row? row) || row.WindowStart != windowStart) + { + row = new Row { WindowStart = windowStart, ExpiresAt = windowEnd, Count = amount }; + _rows[key] = row; + } + else + { + row.Count += amount; + row.ExpiresAt = windowEnd; + } + + return Task.FromResult(row.Count); + } + + /// + public Task GetCountAsync( + string key, + DateTimeOffset windowStart, + CancellationToken ct) + => Task.FromResult(_rows.TryGetValue(key, out Row? row) && row.WindowStart == windowStart ? row.Count : 0); + + /// + public Task ResetAsync(string key, CancellationToken ct) + { + _rows.Remove(key); + return Task.CompletedTask; + } + + /// + public Task DeleteExpiredAsync(DateTimeOffset now, CancellationToken ct) + { + List expired = _rows.Where(pair => pair.Value.ExpiresAt < now).Select(pair => pair.Key).ToList(); + foreach (string key in expired) + { + _rows.Remove(key); + } + + return Task.FromResult(expired.Count); + } + + /// + /// Число заведённых счётчиков + /// + public int Count => _rows.Count; +} diff --git a/src/core/tests/Deal.Tests.Unit/SharedKernel/Observability/DealMetricsBudgetRatioTests.cs b/src/core/tests/Deal.Tests.Unit/SharedKernel/Observability/DealMetricsBudgetRatioTests.cs index a1870b4..68cda98 100644 --- a/src/core/tests/Deal.Tests.Unit/SharedKernel/Observability/DealMetricsBudgetRatioTests.cs +++ b/src/core/tests/Deal.Tests.Unit/SharedKernel/Observability/DealMetricsBudgetRatioTests.cs @@ -1,59 +1,59 @@ -using System.Diagnostics.Metrics; -using Deal.SharedKernel.Observability; - -namespace Deal.Tests.Unit.SharedKernel.Observability; - -public sealed class DealMetricsBudgetRatioTests -{ - [Fact] - public void ReplaceBudgetRatios_ClampsAndReplacesSnapshot() - { - var measurements = new Dictionary(StringComparer.Ordinal); - using var listener = new MeterListener(); - listener.InstrumentPublished = (instrument, current) => - { - if (instrument.Meter.Name == DealMetrics.MeterName - && instrument.Name == DealMetrics.AiBudgetUsedRatioName) - { - current.EnableMeasurementEvents(instrument); - } - }; - listener.SetMeasurementEventCallback((_, value, tags, _) => - { - string? tenant = null; - foreach (KeyValuePair tag in tags) - { - if (tag.Key == DealMetrics.TenantTagName) - { - tenant = tag.Value?.ToString(); - } - } - - if (tenant is not null) - { - measurements[tenant] = value; - } - }); - listener.Start(); - - DealMetrics.ReplaceBudgetRatios(new[] - { - ("tenant-a", 1.5), - ("tenant-b", -0.2), - }); - measurements.Clear(); - listener.RecordObservableInstruments(); - - Assert.Equal(1.0, measurements["tenant-a"], 3); - Assert.Equal(0.0, measurements["tenant-b"], 3); - Assert.Equal(2, measurements.Count); - - DealMetrics.ReplaceBudgetRatios(new[] { ("tenant-c", 0.4) }); - measurements.Clear(); - listener.RecordObservableInstruments(); - - Assert.Equal(0.4, measurements["tenant-c"], 3); - Assert.False(measurements.ContainsKey("tenant-a")); - Assert.Single(measurements); - } -} +using System.Diagnostics.Metrics; +using Deal.SharedKernel.Observability; + +namespace Deal.Tests.Unit.SharedKernel.Observability; + +public sealed class DealMetricsBudgetRatioTests +{ + [Fact] + public void ReplaceBudgetRatios_ClampsAndReplacesSnapshot() + { + var measurements = new Dictionary(StringComparer.Ordinal); + using var listener = new MeterListener(); + listener.InstrumentPublished = (instrument, current) => + { + if (instrument.Meter.Name == DealMetrics.MeterName + && instrument.Name == DealMetrics.AiBudgetUsedRatioName) + { + current.EnableMeasurementEvents(instrument); + } + }; + listener.SetMeasurementEventCallback((_, value, tags, _) => + { + string? tenant = null; + foreach (KeyValuePair tag in tags) + { + if (tag.Key == DealMetrics.TenantTagName) + { + tenant = tag.Value?.ToString(); + } + } + + if (tenant is not null) + { + measurements[tenant] = value; + } + }); + listener.Start(); + + DealMetrics.ReplaceBudgetRatios(new[] + { + ("tenant-a", 1.5), + ("tenant-b", -0.2), + }); + measurements.Clear(); + listener.RecordObservableInstruments(); + + Assert.Equal(1.0, measurements["tenant-a"], 3); + Assert.Equal(0.0, measurements["tenant-b"], 3); + Assert.Equal(2, measurements.Count); + + DealMetrics.ReplaceBudgetRatios(new[] { ("tenant-c", 0.4) }); + measurements.Clear(); + listener.RecordObservableInstruments(); + + Assert.Equal(0.4, measurements["tenant-c"], 3); + Assert.False(measurements.ContainsKey("tenant-a")); + Assert.Single(measurements); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs b/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs index 64d5c4a..4cc10c0 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs @@ -1,327 +1,327 @@ -using System.Text.Json; -using Deal.Api.Dtos; -using Deal.Api.Events; -using Deal.Api.Services; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Settings.Application.Models; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Settings; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты AdminTickOrchestrator — состав POST /api/admin/tick -/// -public sealed class AdminTickOrchestratorTests -{ - // Тенант теста (канал подписки SSE). - private static readonly Guid TenantA = Guid.NewGuid(); - - // Контекст теста: оркестратор на общих фейках + канал подписки. - private sealed record Context( - AdminTickOrchestrator Orchestrator, - FakePipelineStore PipelineStore, - FakeKanjStore KanjStore, - FakeSettingsStore Settings, - FakeAiClassifier AiClassifier, - PipelinePumpGate PumpGate, - SseSubscription Subscription); - - // ─── Тик: хранение + отсев + pump + тосты/new_card ───────────────────── - - [Fact] - public async Task Tick_PurgesExpiredRejectsPumpsQueueAndPublishesToastsAndNewLead() - { - Context ctx = CreateContext(); - const string vacancyText = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; - ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик в команду", isVacancy: true); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", vacancyText)); - - // Отсев: 2 записи старше 3 суток (автоочистка тика), 1 свежая (переживает purge). - long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - ctx.PipelineStore.SeedRejected(Rejected("r_old_a", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds))); - ctx.PipelineStore.SeedRejected(Rejected("r_old_b", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(5).TotalMilliseconds))); - ctx.PipelineStore.SeedRejected(Rejected("r_fresh", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds))); - - AdminTickResultDto result = await ctx.Orchestrator.TickAsync(TenantA, CancellationToken.None); - - Assert.Equal(0, result.Storage.Archived); - Assert.Equal(0, result.Storage.PurgedArchive); - Assert.Equal(0, result.Storage.PurgedTrash); - Assert.Equal(2, result.Storage.PurgedRejected); - Assert.Empty(result.Reminders); - Assert.Equal(0, result.Queue); // queue_len после pump: строка ушла в карточку - - Assert.Equal(9, result.Pipeline.Count); - Assert.Equal(1, result.Pipeline["staged"]); - Assert.Equal(0, result.Pipeline["rulesStored"]); - Assert.Equal(0, result.Pipeline["mlStored"]); - Assert.Equal(0, result.Pipeline["mlDrop"]); - Assert.Equal(0, result.Pipeline["typeDrop"]); - Assert.Equal(1, result.Pipeline["aiStored"]); - Assert.Equal(0, result.Pipeline["aiDrop"]); - Assert.Equal(0, result.Pipeline["aiFail"]); - Assert.Equal(0, result.Pipeline["noBudget"]); - - // Pump создал карточку (inbox) и снял строку очереди; purge удалил только старые записи отсева. - CardDto card = Assert.Single(ctx.KanjStore.CardDtos); - Assert.Equal(KanbanColumns.Inbox, card.Col); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Equal("r_fresh", Assert.Single(ctx.PipelineStore.Rejected).Id); - - List<(string Type, string Json)> events = ReadEvents(ctx.Subscription); - (string Text, string Icon)[] toasts = events - .Where(ev => ev.Type == "toast") - .Select(ev => - { - using JsonDocument payload = JsonDocument.Parse(ev.Json); - return (payload.RootElement.GetProperty("text").GetString()!, payload.RootElement.GetProperty("icon").GetString()!); - }) - .ToArray(); - Assert.Equal([(Text: "Отсев очищен: 2 записей (3 дн.)", Icon: "trash")], toasts); - string newLeadJson = Assert.Single(events, ev => ev.Type == "new_card").Json; - using JsonDocument lead = JsonDocument.Parse(newLeadJson); - Assert.Equal(card.Id, lead.RootElement.GetProperty("id").GetString()); - } - - // ─── Тик: сбой pump не роняет тик ─────────────────────────────────────── - - [Fact] - public async Task Tick_PumpReadFailure_ReturnsStorageAndQueueWithEmptyPipeline() - { - Context ctx = CreateContext(withThrowingQueueRead: true); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", "Вакансия: разработчик в команду, удалённая работа, оплата 2000$")); - - AdminTickResultDto result = await ctx.Orchestrator.TickAsync(TenantA, CancellationToken.None); - - Assert.Equal(0, result.Storage.Archived); - Assert.Equal(0, result.Storage.PurgedRejected); - Assert.Empty(result.Pipeline); - Assert.Equal(1, result.Queue); // строка не обработана — queue_len считает её - Assert.Empty(ctx.KanjStore.CardDtos); - Assert.Empty(ctx.PipelineStore.Rejected); - Assert.False(ctx.Subscription.Events.TryRead(out _)); // счётчики тика нулевые — тостов нет - } - - // ─── Тик: гейт pump занят (фоновый цикл) → проход пропускается ────────── - - [Fact] - public async Task Tick_PumpGateBusy_SkipsPumpAndReturnsEmptyPipeline() - { - Context ctx = CreateContext(); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", "Вакансия: разработчик в команду, удалённая работа, оплата 2000$")); - - Assert.True(ctx.PumpGate.TryEnter(TenantA)); - AdminTickResultDto result = await ctx.Orchestrator.TickAsync(TenantA, CancellationToken.None); - - Assert.Equal(0, result.Storage.PurgedRejected); - Assert.Empty(result.Pipeline); // pump не выполнился — pipeline {} - Assert.Equal(1, result.Queue); // строка не обработана - Assert.Empty(ctx.KanjStore.CardDtos); - Assert.False(ctx.Subscription.Events.TryRead(out _)); // счётчики тика нулевые — тостов нет - - // Гейт остался за фоновым воркером (тик его не освобождает — он его не захватывал). - Assert.False(ctx.PumpGate.TryEnter(TenantA)); - ctx.PumpGate.Exit(TenantA); - } - - - [Fact] - public async Task Tick_DueReminder_ReturnsItInRemindersAndPublishesReminderDue() - { - Context ctx = CreateContext(); - ctx.KanjStore.SeedCard(HoldCard("c_hold_1", title: "Отложенный бот", reminderAtMs: NowMs() - 60_000)); - // Карточка hold с напоминанием в будущем и карточка не-hold с прошедшим — «выстрелить» не должны. - ctx.KanjStore.SeedCard(HoldCard("c_future", title: "Будущий", reminderAtMs: NowMs() + 60_000)); - ctx.KanjStore.SeedCard(HoldCard("c_work_past", stage: "work", title: "В работе", reminderAtMs: NowMs() - 60_000)); - - AdminTickResultDto result = await ctx.Orchestrator.TickAsync(TenantA, CancellationToken.None); - - // reminders ответа = due {id,title,containerId} (после пометки fired). - CardReminderDueDto reminder = Assert.Single(result.Reminders); - Assert.Equal("c_hold_1", reminder.Id); - Assert.Equal("Отложенный бот", reminder.Title); - Assert.Equal("hold", reminder.ContainerId); - - string dueJson = Assert.Single(ReadEvents(ctx.Subscription), ev => ev.Type == "reminder_due").Json; - using JsonDocument payload = JsonDocument.Parse(dueJson); - Assert.Equal("c_hold_1", payload.RootElement.GetProperty("id").GetString()); - Assert.Equal("Отложенный бот", payload.RootElement.GetProperty("title").GetString()); - Assert.Equal("hold", payload.RootElement.GetProperty("containerId").GetString()); - - // «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД). - Assert.Empty(await ctx.KanjStore.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None)); - } - - [Fact] - public async Task Tick_RemindersDisabled_ReturnsEmptyRemindersAndClearsExpired() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.RemindersEnabled, "false"); - ctx.KanjStore.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000)); - - AdminTickResultDto result = await ctx.Orchestrator.TickAsync(TenantA, CancellationToken.None); - - Assert.Empty(result.Reminders); - Assert.Null(Assert.Single(ctx.KanjStore.CardDtos).Reminder); // протухшее очищено - Assert.DoesNotContain(ReadEvents(ctx.Subscription), ev => ev.Type == "reminder_due"); - } - - [Fact] - public async Task Tick_ReminderCheckFailure_ReturnsEmptyRemindersAndKeepsTickAlive() - { - Context ctx = CreateContext(withThrowingReminderCheck: true); - ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик в команду", isVacancy: true); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", "Вакансия: разработчик в команду, удалённая работа, оплата 2000$")); - - // Сбой проверки напоминаний (ListDueAsync бросает) не роняет тик: reminders пуст, storage/pump работают. - AdminTickResultDto result = await ctx.Orchestrator.TickAsync(TenantA, CancellationToken.None); - - Assert.Empty(result.Reminders); - Assert.Equal(1, result.Pipeline["staged"]); // pump выполнился — тик жив - Assert.DoesNotContain(ReadEvents(ctx.Subscription), ev => ev.Type == "reminder_due"); - } - - // ─── Контекст и хелперы ───────────────────────────────────────────────── - - // Собирает оркестратор на общих фейках: StorageTickService и воркер делят KanjStore/Settings, - // обработка и воркер — PipelineStore, напоминания — тот же KanjStore/Settings (CardsService); - // ML «не готов» (FakeMlClient.Predict не задан → сбой → «не уверен»). - // withThrowingQueueRead: true — чтение очереди воркером бросает (сценарий сбоя pump). - // withThrowingReminderCheck: true — выборка due-напоминаний бросает (сценарий сбоя проверки). - // Возвращает: Оркестратор, фейки и подписку канала тенанта. - private static Context CreateContext(bool withThrowingQueueRead = false, bool withThrowingReminderCheck = false) - { - var settings = new FakeSettingsStore(); - // При сбое чтения очереди подменяем хранилище целиком (тот же объект идёт во все сервисы и в контекст). - FakePipelineStore store = withThrowingQueueRead ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore(); - var kanjStore = new FakeKanjStore(); - var mlClient = new FakeMlClient(); - var aiClassifier = new FakeAiClassifier(); - var rules = new IncomingRules(settings); - var fieldsParser = new LocalFieldsParser(settings); - var ingest = new PipelineIngestService(store); - var processing = new PipelineProcessingService(store, mlClient, ingest); - var composer = new CardComposer(kanjStore, settings); - var writer = new PipelineCardWriter(kanjStore, store, composer); - var worker = new PipelineWorkerService( - store, settings, rules, kanjStore, mlClient, aiClassifier, processing, writer, fieldsParser); - - var broker = new SseBroker(); - var tickService = new StorageTickService(kanjStore, settings); - var toastPublisher = new StorageToastPublisher(broker); - var pumpGate = new PipelinePumpGate(); - FakeKanjStore reminderStore = withThrowingReminderCheck ? new ThrowingDueKanjStore() : kanjStore; - var cardsService = new CardsService(reminderStore, settings, mlClient, new FakeFileStorage()); - var orchestrator = new AdminTickOrchestrator( - tickService, processing, worker, cardsService, toastPublisher, broker, pumpGate, - NullLogger.Instance); - - return new Context(orchestrator, store, kanjStore, settings, aiClassifier, pumpGate, - broker.Subscribe(TenantA)); - } - - // Хранилище карточек со сбоем выборки due-напоминаний: ListDueRemindersAsync бросает (сценарий - // «БД/схема недоступны» на проверке напоминаний — тик продолжается, reminders ответа пуст). - private sealed class ThrowingDueKanjStore : FakeKanjStore - { - /// - public override Task> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct) - { - throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync)."); - } - } - - // Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем. - private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - // Карточка hold с напоминанием (как строка Cards после маппинга адаптера). - private static CardDto HoldCard( - string id, - string title, - long reminderAtMs, - string stage = "hold") - { - return new CardDto - { - Id = id, - Col = stage, - Title = title, - Reminder = new CardReminderDto(reminderAtMs), - CreatedAtMs = 1, - UpdatedAtMs = 1, - }; - } - - // Хранилище со сбоем чтения очереди: ListAsync бросает (сценарий «БД/схема недоступны» на pump). - private sealed class ThrowingQueueReadPipelineStore : FakePipelineStore - { - /// - public override Task> ListAsync( - string? status, - int limit, - CancellationToken ct) - { - throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync)."); - } - } - - // Строка очереди (status new; сценарий перекрывает текст/время). - private static QueueItemDto QueueRow(string id, string text) => SourceItemFactory.Queue( - id, - text, - PipelineQueueStatuses.New, - msgAtMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); - - // Минимальный разбор карточки (карточку домыслит CardComposer; колонка не назначена → inbox). - private static AiParsedCardDto Parsed(string title, bool isVacancy = false) => new( - title, - Company: null, - Format: null, - Task: null, - Requirements: null, - Plus: null, - Conditions: null, - Summary: null, - Stack: Array.Empty(), - Budget: null, - Contacts: Array.Empty(), - isVacancy, - IsVacancyKnown: false, - IsSpam: false, - Board: null); - - // Запись отсева для сидирования (как строка БД после маппинга адаптера). - private static RejectedItemDto Rejected(string id, long rejectedAtMs) => SourceItemFactory.Rejected( - id, - origin: string.Empty, - externalId: null, - name: string.Empty, - hue: null, - stage: "length", - decidedBy: "stop", - msgAtMs: 0, - rejectedAtMs: rejectedAtMs); - - // Вычитывает все события канала: (тип, JSON) в порядке публикации. - // subscription: Подписка канала тенанта. - // Возвращает: События канала (toast/new_card) в порядке публикации. - private static List<(string Type, string Json)> ReadEvents(SseSubscription subscription) - { - var events = new List<(string Type, string Json)>(); - while (subscription.Events.TryRead(out SseEvent? sseEvent)) - { - events.Add((sseEvent!.Type, sseEvent.Json)); - } - - return events; - } -} +using System.Text.Json; +using Deal.Api.Dtos; +using Deal.Api.Events; +using Deal.Api.Services; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Kanban; +using Deal.Tests.Unit.Modules.Settings; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты AdminTickOrchestrator — состав POST /api/admin/tick +/// +public sealed class AdminTickOrchestratorTests +{ + // Тенант теста (канал подписки SSE). + private static readonly Guid TenantA = Guid.NewGuid(); + + // Контекст теста: оркестратор на общих фейках + канал подписки. + private sealed record Context( + AdminTickOrchestrator Orchestrator, + FakePipelineStore PipelineStore, + FakeKanjStore KanjStore, + FakeSettingsStore Settings, + FakeAiClassifier AiClassifier, + PipelinePumpGate PumpGate, + SseSubscription Subscription); + + // ─── Тик: хранение + отсев + pump + тосты/new_card ───────────────────── + + [Fact] + public async Task Tick_PurgesExpiredRejectsPumpsQueueAndPublishesToastsAndNewLead() + { + Context ctx = CreateContext(); + const string vacancyText = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; + ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик в команду", isVacancy: true); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", vacancyText)); + + // Отсев: 2 записи старше 3 суток (автоочистка тика), 1 свежая (переживает purge). + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + ctx.PipelineStore.SeedRejected(Rejected("r_old_a", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds))); + ctx.PipelineStore.SeedRejected(Rejected("r_old_b", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(5).TotalMilliseconds))); + ctx.PipelineStore.SeedRejected(Rejected("r_fresh", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds))); + + AdminTickResultDto result = await ctx.Orchestrator.TickAsync(TenantA, CancellationToken.None); + + Assert.Equal(0, result.Storage.Archived); + Assert.Equal(0, result.Storage.PurgedArchive); + Assert.Equal(0, result.Storage.PurgedTrash); + Assert.Equal(2, result.Storage.PurgedRejected); + Assert.Empty(result.Reminders); + Assert.Equal(0, result.Queue); // queue_len после pump: строка ушла в карточку + + Assert.Equal(9, result.Pipeline.Count); + Assert.Equal(1, result.Pipeline["staged"]); + Assert.Equal(0, result.Pipeline["rulesStored"]); + Assert.Equal(0, result.Pipeline["mlStored"]); + Assert.Equal(0, result.Pipeline["mlDrop"]); + Assert.Equal(0, result.Pipeline["typeDrop"]); + Assert.Equal(1, result.Pipeline["aiStored"]); + Assert.Equal(0, result.Pipeline["aiDrop"]); + Assert.Equal(0, result.Pipeline["aiFail"]); + Assert.Equal(0, result.Pipeline["noBudget"]); + + // Pump создал карточку (inbox) и снял строку очереди; purge удалил только старые записи отсева. + CardDto card = Assert.Single(ctx.KanjStore.CardDtos); + Assert.Equal(KanbanColumns.Inbox, card.Col); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Equal("r_fresh", Assert.Single(ctx.PipelineStore.Rejected).Id); + + List<(string Type, string Json)> events = ReadEvents(ctx.Subscription); + (string Text, string Icon)[] toasts = events + .Where(ev => ev.Type == "toast") + .Select(ev => + { + using JsonDocument payload = JsonDocument.Parse(ev.Json); + return (payload.RootElement.GetProperty("text").GetString()!, payload.RootElement.GetProperty("icon").GetString()!); + }) + .ToArray(); + Assert.Equal([(Text: "Отсев очищен: 2 записей (3 дн.)", Icon: "trash")], toasts); + string newLeadJson = Assert.Single(events, ev => ev.Type == "new_card").Json; + using JsonDocument lead = JsonDocument.Parse(newLeadJson); + Assert.Equal(card.Id, lead.RootElement.GetProperty("id").GetString()); + } + + // ─── Тик: сбой pump не роняет тик ─────────────────────────────────────── + + [Fact] + public async Task Tick_PumpReadFailure_ReturnsStorageAndQueueWithEmptyPipeline() + { + Context ctx = CreateContext(withThrowingQueueRead: true); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", "Вакансия: разработчик в команду, удалённая работа, оплата 2000$")); + + AdminTickResultDto result = await ctx.Orchestrator.TickAsync(TenantA, CancellationToken.None); + + Assert.Equal(0, result.Storage.Archived); + Assert.Equal(0, result.Storage.PurgedRejected); + Assert.Empty(result.Pipeline); + Assert.Equal(1, result.Queue); // строка не обработана — queue_len считает её + Assert.Empty(ctx.KanjStore.CardDtos); + Assert.Empty(ctx.PipelineStore.Rejected); + Assert.False(ctx.Subscription.Events.TryRead(out _)); // счётчики тика нулевые — тостов нет + } + + // ─── Тик: гейт pump занят (фоновый цикл) → проход пропускается ────────── + + [Fact] + public async Task Tick_PumpGateBusy_SkipsPumpAndReturnsEmptyPipeline() + { + Context ctx = CreateContext(); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", "Вакансия: разработчик в команду, удалённая работа, оплата 2000$")); + + Assert.True(ctx.PumpGate.TryEnter(TenantA)); + AdminTickResultDto result = await ctx.Orchestrator.TickAsync(TenantA, CancellationToken.None); + + Assert.Equal(0, result.Storage.PurgedRejected); + Assert.Empty(result.Pipeline); // pump не выполнился — pipeline {} + Assert.Equal(1, result.Queue); // строка не обработана + Assert.Empty(ctx.KanjStore.CardDtos); + Assert.False(ctx.Subscription.Events.TryRead(out _)); // счётчики тика нулевые — тостов нет + + // Гейт остался за фоновым воркером (тик его не освобождает — он его не захватывал). + Assert.False(ctx.PumpGate.TryEnter(TenantA)); + ctx.PumpGate.Exit(TenantA); + } + + + [Fact] + public async Task Tick_DueReminder_ReturnsItInRemindersAndPublishesReminderDue() + { + Context ctx = CreateContext(); + ctx.KanjStore.SeedCard(HoldCard("c_hold_1", title: "Отложенный бот", reminderAtMs: NowMs() - 60_000)); + // Карточка hold с напоминанием в будущем и карточка не-hold с прошедшим — «выстрелить» не должны. + ctx.KanjStore.SeedCard(HoldCard("c_future", title: "Будущий", reminderAtMs: NowMs() + 60_000)); + ctx.KanjStore.SeedCard(HoldCard("c_work_past", stage: "work", title: "В работе", reminderAtMs: NowMs() - 60_000)); + + AdminTickResultDto result = await ctx.Orchestrator.TickAsync(TenantA, CancellationToken.None); + + // reminders ответа = due {id,title,containerId} (после пометки fired). + CardReminderDueDto reminder = Assert.Single(result.Reminders); + Assert.Equal("c_hold_1", reminder.Id); + Assert.Equal("Отложенный бот", reminder.Title); + Assert.Equal("hold", reminder.ContainerId); + + string dueJson = Assert.Single(ReadEvents(ctx.Subscription), ev => ev.Type == "reminder_due").Json; + using JsonDocument payload = JsonDocument.Parse(dueJson); + Assert.Equal("c_hold_1", payload.RootElement.GetProperty("id").GetString()); + Assert.Equal("Отложенный бот", payload.RootElement.GetProperty("title").GetString()); + Assert.Equal("hold", payload.RootElement.GetProperty("containerId").GetString()); + + // «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД). + Assert.Empty(await ctx.KanjStore.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None)); + } + + [Fact] + public async Task Tick_RemindersDisabled_ReturnsEmptyRemindersAndClearsExpired() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.RemindersEnabled, "false"); + ctx.KanjStore.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000)); + + AdminTickResultDto result = await ctx.Orchestrator.TickAsync(TenantA, CancellationToken.None); + + Assert.Empty(result.Reminders); + Assert.Null(Assert.Single(ctx.KanjStore.CardDtos).Reminder); // протухшее очищено + Assert.DoesNotContain(ReadEvents(ctx.Subscription), ev => ev.Type == "reminder_due"); + } + + [Fact] + public async Task Tick_ReminderCheckFailure_ReturnsEmptyRemindersAndKeepsTickAlive() + { + Context ctx = CreateContext(withThrowingReminderCheck: true); + ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик в команду", isVacancy: true); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", "Вакансия: разработчик в команду, удалённая работа, оплата 2000$")); + + // Сбой проверки напоминаний (ListDueAsync бросает) не роняет тик: reminders пуст, storage/pump работают. + AdminTickResultDto result = await ctx.Orchestrator.TickAsync(TenantA, CancellationToken.None); + + Assert.Empty(result.Reminders); + Assert.Equal(1, result.Pipeline["staged"]); // pump выполнился — тик жив + Assert.DoesNotContain(ReadEvents(ctx.Subscription), ev => ev.Type == "reminder_due"); + } + + // ─── Контекст и хелперы ───────────────────────────────────────────────── + + // Собирает оркестратор на общих фейках: StorageTickService и воркер делят KanjStore/Settings, + // обработка и воркер — PipelineStore, напоминания — тот же KanjStore/Settings (CardsService); + // ML «не готов» (FakeMlClient.Predict не задан → сбой → «не уверен»). + // withThrowingQueueRead: true — чтение очереди воркером бросает (сценарий сбоя pump). + // withThrowingReminderCheck: true — выборка due-напоминаний бросает (сценарий сбоя проверки). + // Возвращает: Оркестратор, фейки и подписку канала тенанта. + private static Context CreateContext(bool withThrowingQueueRead = false, bool withThrowingReminderCheck = false) + { + var settings = new FakeSettingsStore(); + // При сбое чтения очереди подменяем хранилище целиком (тот же объект идёт во все сервисы и в контекст). + FakePipelineStore store = withThrowingQueueRead ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore(); + var kanjStore = new FakeKanjStore(); + var mlClient = new FakeMlClient(); + var aiClassifier = new FakeAiClassifier(); + var rules = new IncomingRules(settings); + var fieldsParser = new LocalFieldsParser(settings); + var ingest = new PipelineIngestService(store); + var processing = new PipelineProcessingService(store, mlClient, ingest); + var composer = new CardComposer(kanjStore, settings); + var writer = new PipelineCardWriter(kanjStore, store, composer); + var worker = new PipelineWorkerService( + store, settings, rules, kanjStore, mlClient, aiClassifier, processing, writer, fieldsParser); + + var broker = new SseBroker(); + var tickService = new StorageTickService(kanjStore, settings); + var toastPublisher = new StorageToastPublisher(broker); + var pumpGate = new PipelinePumpGate(); + FakeKanjStore reminderStore = withThrowingReminderCheck ? new ThrowingDueKanjStore() : kanjStore; + var cardsService = new CardsService(reminderStore, settings, mlClient, new FakeFileStorage()); + var orchestrator = new AdminTickOrchestrator( + tickService, processing, worker, cardsService, toastPublisher, broker, pumpGate, + NullLogger.Instance); + + return new Context(orchestrator, store, kanjStore, settings, aiClassifier, pumpGate, + broker.Subscribe(TenantA)); + } + + // Хранилище карточек со сбоем выборки due-напоминаний: ListDueRemindersAsync бросает (сценарий + // «БД/схема недоступны» на проверке напоминаний — тик продолжается, reminders ответа пуст). + private sealed class ThrowingDueKanjStore : FakeKanjStore + { + /// + public override Task> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct) + { + throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync)."); + } + } + + // Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем. + private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + // Карточка hold с напоминанием (как строка Cards после маппинга адаптера). + private static CardDto HoldCard( + string id, + string title, + long reminderAtMs, + string stage = "hold") + { + return new CardDto + { + Id = id, + Col = stage, + Title = title, + Reminder = new CardReminderDto(reminderAtMs), + CreatedAtMs = 1, + UpdatedAtMs = 1, + }; + } + + // Хранилище со сбоем чтения очереди: ListAsync бросает (сценарий «БД/схема недоступны» на pump). + private sealed class ThrowingQueueReadPipelineStore : FakePipelineStore + { + /// + public override Task> ListAsync( + string? status, + int limit, + CancellationToken ct) + { + throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync)."); + } + } + + // Строка очереди (status new; сценарий перекрывает текст/время). + private static QueueItemDto QueueRow(string id, string text) => SourceItemFactory.Queue( + id, + text, + PipelineQueueStatuses.New, + msgAtMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + + // Минимальный разбор карточки (карточку домыслит CardComposer; колонка не назначена → inbox). + private static AiParsedCardDto Parsed(string title, bool isVacancy = false) => new( + title, + Company: null, + Format: null, + Task: null, + Requirements: null, + Plus: null, + Conditions: null, + Summary: null, + Stack: Array.Empty(), + Budget: null, + Contacts: Array.Empty(), + isVacancy, + IsVacancyKnown: false, + IsSpam: false, + Board: null); + + // Запись отсева для сидирования (как строка БД после маппинга адаптера). + private static RejectedItemDto Rejected(string id, long rejectedAtMs) => SourceItemFactory.Rejected( + id, + origin: string.Empty, + externalId: null, + name: string.Empty, + hue: null, + stage: "length", + decidedBy: "stop", + msgAtMs: 0, + rejectedAtMs: rejectedAtMs); + + // Вычитывает все события канала: (тип, JSON) в порядке публикации. + // subscription: Подписка канала тенанта. + // Возвращает: События канала (toast/new_card) в порядке публикации. + private static List<(string Type, string Json)> ReadEvents(SseSubscription subscription) + { + var events = new List<(string Type, string Json)>(); + while (subscription.Events.TryRead(out SseEvent? sseEvent)) + { + events.Add((sseEvent!.Type, sseEvent.Json)); + } + + return events; + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/AiGrpcTestHost.cs b/src/core/tests/Deal.Tests.Unit/Support/AiGrpcTestHost.cs index 5ec5b3c..ad80ff0 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/AiGrpcTestHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/AiGrpcTestHost.cs @@ -1,60 +1,60 @@ -using System.Net; -using Deal.Infrastructure.Integrations.Models; -using Deal.Tests.Unit.Grpc; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Server.Kestrel.Core; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Tests.Unit.Support; - -internal static class AiGrpcTestHost -{ - /// - /// Токен сценариев теста - /// - public const string DefaultToken = "deal-ai-test-token"; - - /// - /// Прогоняет сценарий против поднятого фейк-ai-service с заданным env-токеном. - /// - /// Значение env DEAL_SERVICE_TOKEN (null — убрать переменную). - /// Сервис-фейк (состояние сценария). - /// Сценарий: получает порт хоста и сервис-фейк для проверок. - public static async Task RunAsync( - string? serviceToken, - RecordingAiService service, - Func scenario) - { - string? originalToken = Environment.GetEnvironmentVariable(AiGrpcConnection.ServiceTokenEnvKey); - Environment.SetEnvironmentVariable(AiGrpcConnection.ServiceTokenEnvKey, serviceToken); - - WebApplication? app = null; - try - { - int port = TestPort.Allocate(); - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.WebHost.ConfigureKestrel(kestrel => - kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2)); - - // gRPC-сервер ai.proto (фейк без токен-интерцептора — токен проверяет клиентский транспорт). - builder.Services.AddGrpc(); - builder.Services.AddSingleton(service); - app = builder.Build(); - app.MapGrpcService(); - await app.StartAsync(); - - await scenario(port, service); - } - finally - { - if (app is not null) - { - await app.StopAsync(); - await app.DisposeAsync(); - } - - Environment.SetEnvironmentVariable(AiGrpcConnection.ServiceTokenEnvKey, originalToken); - } - } -} +using System.Net; +using Deal.Infrastructure.Integrations.Models; +using Deal.Tests.Unit.Grpc; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Tests.Unit.Support; + +internal static class AiGrpcTestHost +{ + /// + /// Токен сценариев теста + /// + public const string DefaultToken = "deal-ai-test-token"; + + /// + /// Прогоняет сценарий против поднятого фейк-ai-service с заданным env-токеном. + /// + /// Значение env DEAL_SERVICE_TOKEN (null — убрать переменную). + /// Сервис-фейк (состояние сценария). + /// Сценарий: получает порт хоста и сервис-фейк для проверок. + public static async Task RunAsync( + string? serviceToken, + RecordingAiService service, + Func scenario) + { + string? originalToken = Environment.GetEnvironmentVariable(AiGrpcConnection.ServiceTokenEnvKey); + Environment.SetEnvironmentVariable(AiGrpcConnection.ServiceTokenEnvKey, serviceToken); + + WebApplication? app = null; + try + { + int port = TestPort.Allocate(); + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(kestrel => + kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2)); + + // gRPC-сервер ai.proto (фейк без токен-интерцептора — токен проверяет клиентский транспорт). + builder.Services.AddGrpc(); + builder.Services.AddSingleton(service); + app = builder.Build(); + app.MapGrpcService(); + await app.StartAsync(); + + await scenario(port, service); + } + finally + { + if (app is not null) + { + await app.StopAsync(); + await app.DisposeAsync(); + } + + Environment.SetEnvironmentVariable(AiGrpcConnection.ServiceTokenEnvKey, originalToken); + } + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs index c227a05..b63a926 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs @@ -1,174 +1,174 @@ -using System.Text.Json; -using Deal.Api.Events; -using Deal.Api.Hosting; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Tests.Unit.Modules.Tenants; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты логики прохода -/// -public sealed class BudgetAlertSchedulerTests -{ - // Тенант A теста (канал подписки). - private static readonly Guid TenantA = Guid.NewGuid(); - - // Тенант B теста (канал подписки). - private static readonly Guid TenantB = Guid.NewGuid(); - - private const string Warned80ToastText = "ИИ-бюджет израсходован на 80%"; - - private const string ExhaustedToastText = "ИИ-бюджет исчерпан — обработка в локальном режиме"; - - [Fact] - public async Task RunCycle_TwoCycles_PublishesToastOncePerThresholdPerTenant() - { - FakeTenantLimitStore limits = new(); - limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 800); - limits.Preload(TenantB, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 1000); - await using ServiceProvider provider = BuildProvider( - new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), limits); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - SseSubscription subscriptionB = broker.Subscribe(TenantB); - BudgetAlertScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - Assert.Equal(new[] { (Warned80ToastText, "bell") }, ReadToasts(subscriptionA)); - Assert.Equal( - new[] { (Warned80ToastText, "bell"), (ExhaustedToastText, "bell") }, - ReadToasts(subscriptionB)); - - // Второй проход: флаги уже стоят (TryMark* вернул false) — тосты не задваиваются. - await scheduler.RunCycleAsync(CancellationToken.None); - Assert.Empty(ReadToasts(subscriptionA)); - Assert.Empty(ReadToasts(subscriptionB)); - } - - [Fact] - public async Task RunCycle_TenantBelowThreshold_NoToastAndNoFlagSet() - { - FakeTenantLimitStore limits = new(); - limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 700); - await using ServiceProvider provider = BuildProvider(new FakeTenantRepository(Tenant(TenantA)), limits); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - BudgetAlertScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - Assert.Empty(ReadToasts(subscriptionA)); - // Порог не достигнут — TryMark* не выставил флаг (повторный проход после роста расхода даст тост). - Assert.False(await limits.TryMarkWarnedAsync(TenantA, CancellationToken.None)); - } - - [Fact] - public async Task RunCycle_ExhaustionWithoutWarned80_PublishesBothToastsOnce() - { - // Строка «уже исчерпан, но ни один флаг не стоял» (сценарий: оператор уменьшил бюджет — флаги сброшены, - // расход ≥ бюджета): за один проход выходят оба порога ровно по одному разу. - FakeTenantLimitStore limits = new(); - limits.Preload(TenantA, budgetTokens: 500, TenantLimitPeriods.Month, Now(), usedTokens: 700); - await using ServiceProvider provider = BuildProvider(new FakeTenantRepository(Tenant(TenantA)), limits); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - BudgetAlertScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - await scheduler.RunCycleAsync(CancellationToken.None); - - Assert.Equal( - new[] { (Warned80ToastText, "bell"), (ExhaustedToastText, "bell") }, - ReadToasts(subscriptionA)); - } - - [Fact] - public async Task RunCycle_NaturalSpendCrossing80Then100_PublishesOneToastPerThreshold() - { - FakeTenantLimitStore limits = new(); - limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 0); - await using ServiceProvider provider = BuildProvider(new FakeTenantRepository(Tenant(TenantA)), limits); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - BudgetAlertScheduler scheduler = CreateScheduler(provider); - - await limits.AddUsageAsync(TenantA, tokens: 850, CancellationToken.None); - - await scheduler.RunCycleAsync(CancellationToken.None); - Assert.Equal(new[] { (Warned80ToastText, "bell") }, ReadToasts(subscriptionA)); - - await scheduler.RunCycleAsync(CancellationToken.None); - Assert.Empty(ReadToasts(subscriptionA)); // повторный проход — флаг уже стоит, тост не дублируется - - // Расход до исчерпания (850 + 200 = 1050 ≥ 1000) → ещё ровно один тост (100%); 80% уже отмечен. - await limits.AddUsageAsync(TenantA, tokens: 200, CancellationToken.None); - await scheduler.RunCycleAsync(CancellationToken.None); - Assert.Equal(new[] { (ExhaustedToastText, "bell") }, ReadToasts(subscriptionA)); - - await scheduler.RunCycleAsync(CancellationToken.None); - Assert.Empty(ReadToasts(subscriptionA)); - } - - // ─── Контекст сценария ───────────────────────────────────────────────── - - // Строит DI-провайдер теста: реестр тенантов + scoped фейк лимитов + SseBroker. - // tenants: Фейк реестра тенантов (обход прохода). - // limits: Фейк-хранилище лимитов (строки посеяны сценарием до прохода). - // Возвращает: Провайдер с сервисами цикла. - private static ServiceProvider BuildProvider(ITenantRepository tenants, FakeTenantLimitStore limits) - { - var services = new ServiceCollection(); - services.AddSingleton(tenants); - services.AddScoped(_ => limits); - services.AddSingleton(); - return services.BuildServiceProvider(); - } - - // Создаёт планировщик на провайдере теста (без StartAsync — таймер 60 с не заводим). - // provider: DI-провайдер с сервисами цикла. - // Возвращает: Планировщик с NullLogger. - private static BudgetAlertScheduler CreateScheduler(ServiceProvider provider) - { - return new BudgetAlertScheduler( - provider.GetRequiredService(), - provider.GetRequiredService(), - NullLogger.Instance); - } - - // Запись реестра тенанта (как строка public.tenants). - // id: Идентификатор тенанта. - // Возвращает: Запись тенанта со статусом active. - private static TenantRecordDto Tenant(Guid id) => - new(id, Name: "tenant", Status: TenantStatuses.Active, CreatedAt: DateTimeOffset.UtcNow); - - // Начало периода строк лимита («сейчас» — месяц не истёк, ленивый reset не сработает). - // Возвращает: Текущий момент (UTC). - private static DateTimeOffset Now() => DateTimeOffset.UtcNow; - - // Читает опубликованные тосты канала: (text, icon) в порядке публикации. - // subscription: Подписка тенанта-получателя. - // Возвращает: Пары text/icon событий toast канала. - private static List<(string Text, string Icon)> ReadToasts(SseSubscription subscription) - { - var toasts = new List<(string Text, string Icon)>(); - while (subscription.Events.TryRead(out SseEvent? sseEvent)) - { - Assert.Equal("toast", sseEvent!.Type); - using JsonDocument payload = JsonDocument.Parse(sseEvent.Json); - toasts.Add(( - payload.RootElement.GetProperty("text").GetString()!, - payload.RootElement.GetProperty("icon").GetString()!)); - } - - return toasts; - } -} +using System.Text.Json; +using Deal.Api.Events; +using Deal.Api.Hosting; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Tests.Unit.Modules.Tenants; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты логики прохода +/// +public sealed class BudgetAlertSchedulerTests +{ + // Тенант A теста (канал подписки). + private static readonly Guid TenantA = Guid.NewGuid(); + + // Тенант B теста (канал подписки). + private static readonly Guid TenantB = Guid.NewGuid(); + + private const string Warned80ToastText = "ИИ-бюджет израсходован на 80%"; + + private const string ExhaustedToastText = "ИИ-бюджет исчерпан — обработка в локальном режиме"; + + [Fact] + public async Task RunCycle_TwoCycles_PublishesToastOncePerThresholdPerTenant() + { + FakeTenantLimitStore limits = new(); + limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 800); + limits.Preload(TenantB, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 1000); + await using ServiceProvider provider = BuildProvider( + new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), limits); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + SseSubscription subscriptionB = broker.Subscribe(TenantB); + BudgetAlertScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + Assert.Equal(new[] { (Warned80ToastText, "bell") }, ReadToasts(subscriptionA)); + Assert.Equal( + new[] { (Warned80ToastText, "bell"), (ExhaustedToastText, "bell") }, + ReadToasts(subscriptionB)); + + // Второй проход: флаги уже стоят (TryMark* вернул false) — тосты не задваиваются. + await scheduler.RunCycleAsync(CancellationToken.None); + Assert.Empty(ReadToasts(subscriptionA)); + Assert.Empty(ReadToasts(subscriptionB)); + } + + [Fact] + public async Task RunCycle_TenantBelowThreshold_NoToastAndNoFlagSet() + { + FakeTenantLimitStore limits = new(); + limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 700); + await using ServiceProvider provider = BuildProvider(new FakeTenantRepository(Tenant(TenantA)), limits); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + BudgetAlertScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + Assert.Empty(ReadToasts(subscriptionA)); + // Порог не достигнут — TryMark* не выставил флаг (повторный проход после роста расхода даст тост). + Assert.False(await limits.TryMarkWarnedAsync(TenantA, CancellationToken.None)); + } + + [Fact] + public async Task RunCycle_ExhaustionWithoutWarned80_PublishesBothToastsOnce() + { + // Строка «уже исчерпан, но ни один флаг не стоял» (сценарий: оператор уменьшил бюджет — флаги сброшены, + // расход ≥ бюджета): за один проход выходят оба порога ровно по одному разу. + FakeTenantLimitStore limits = new(); + limits.Preload(TenantA, budgetTokens: 500, TenantLimitPeriods.Month, Now(), usedTokens: 700); + await using ServiceProvider provider = BuildProvider(new FakeTenantRepository(Tenant(TenantA)), limits); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + BudgetAlertScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + await scheduler.RunCycleAsync(CancellationToken.None); + + Assert.Equal( + new[] { (Warned80ToastText, "bell"), (ExhaustedToastText, "bell") }, + ReadToasts(subscriptionA)); + } + + [Fact] + public async Task RunCycle_NaturalSpendCrossing80Then100_PublishesOneToastPerThreshold() + { + FakeTenantLimitStore limits = new(); + limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 0); + await using ServiceProvider provider = BuildProvider(new FakeTenantRepository(Tenant(TenantA)), limits); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + BudgetAlertScheduler scheduler = CreateScheduler(provider); + + await limits.AddUsageAsync(TenantA, tokens: 850, CancellationToken.None); + + await scheduler.RunCycleAsync(CancellationToken.None); + Assert.Equal(new[] { (Warned80ToastText, "bell") }, ReadToasts(subscriptionA)); + + await scheduler.RunCycleAsync(CancellationToken.None); + Assert.Empty(ReadToasts(subscriptionA)); // повторный проход — флаг уже стоит, тост не дублируется + + // Расход до исчерпания (850 + 200 = 1050 ≥ 1000) → ещё ровно один тост (100%); 80% уже отмечен. + await limits.AddUsageAsync(TenantA, tokens: 200, CancellationToken.None); + await scheduler.RunCycleAsync(CancellationToken.None); + Assert.Equal(new[] { (ExhaustedToastText, "bell") }, ReadToasts(subscriptionA)); + + await scheduler.RunCycleAsync(CancellationToken.None); + Assert.Empty(ReadToasts(subscriptionA)); + } + + // ─── Контекст сценария ───────────────────────────────────────────────── + + // Строит DI-провайдер теста: реестр тенантов + scoped фейк лимитов + SseBroker. + // tenants: Фейк реестра тенантов (обход прохода). + // limits: Фейк-хранилище лимитов (строки посеяны сценарием до прохода). + // Возвращает: Провайдер с сервисами цикла. + private static ServiceProvider BuildProvider(ITenantRepository tenants, FakeTenantLimitStore limits) + { + var services = new ServiceCollection(); + services.AddSingleton(tenants); + services.AddScoped(_ => limits); + services.AddSingleton(); + return services.BuildServiceProvider(); + } + + // Создаёт планировщик на провайдере теста (без StartAsync — таймер 60 с не заводим). + // provider: DI-провайдер с сервисами цикла. + // Возвращает: Планировщик с NullLogger. + private static BudgetAlertScheduler CreateScheduler(ServiceProvider provider) + { + return new BudgetAlertScheduler( + provider.GetRequiredService(), + provider.GetRequiredService(), + NullLogger.Instance); + } + + // Запись реестра тенанта (как строка public.tenants). + // id: Идентификатор тенанта. + // Возвращает: Запись тенанта со статусом active. + private static TenantRecordDto Tenant(Guid id) => + new(id, Name: "tenant", Status: TenantStatuses.Active, CreatedAt: DateTimeOffset.UtcNow); + + // Начало периода строк лимита («сейчас» — месяц не истёк, ленивый reset не сработает). + // Возвращает: Текущий момент (UTC). + private static DateTimeOffset Now() => DateTimeOffset.UtcNow; + + // Читает опубликованные тосты канала: (text, icon) в порядке публикации. + // subscription: Подписка тенанта-получателя. + // Возвращает: Пары text/icon событий toast канала. + private static List<(string Text, string Icon)> ReadToasts(SseSubscription subscription) + { + var toasts = new List<(string Text, string Icon)>(); + while (subscription.Events.TryRead(out SseEvent? sseEvent)) + { + Assert.Equal("toast", sseEvent!.Type); + using JsonDocument payload = JsonDocument.Parse(sseEvent.Json); + toasts.Add(( + payload.RootElement.GetProperty("text").GetString()!, + payload.RootElement.GetProperty("icon").GetString()!)); + } + + return toasts; + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs index 7518ada..a3c408c 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs @@ -1,458 +1,458 @@ -using System.Text.Json; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Settings.Application.Models; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты ручной переклассификации карточек — CardReclassifier. -/// -public sealed class CardReclassifierTests -{ - // Контекст теста: сервис поверх in-memory фейков. - private sealed record Context( - CardReclassifier Reclassifier, - FakeKanjStore Store, - FakeSettingsStore Settings, - FakeMlClient MlClient, - FakeAiClassifier AiClassifier, - ReclassifyGate Gate); - - // Собирает контекст: фейки хранилищ/портов, реальные композитор/парсер/сервис карточек. - // Возвращает: Сервис и фейки (сценарий до-настраивает карточки/доски/настройки). - private static Context CreateContext() - { - var settings = new FakeSettingsStore(); - var store = new FakeKanjStore(); - var mlClient = new FakeMlClient(); - var aiClassifier = new FakeAiClassifier(); - var fieldsParser = new LocalFieldsParser(settings); - var composer = new CardComposer(store, settings); - var cardsService = new CardsService(store, settings, mlClient, new FakeFileStorage()); - var gate = new ReclassifyGate(); - var reclassifier = new CardReclassifier( - store, settings, aiClassifier, fieldsParser, composer, cardsService, mlClient, gate); - return new Context(reclassifier, store, settings, mlClient, aiClassifier, gate); - } - - // Сериализует значение настройки в JSON (как пишет SettingsStore). - // value: Значение (число/булево/строка). - // Возвращает: JSON для Preload. - private static string Json(object value) => JsonSerializer.Serialize(value); - - // Карточка «Неразобранного» с исходным текстом (поля, нужные классификации). - // id: Id карточки. - // sourceText: Исходный текст. - // contact: Старый контакт карточки. - // receivedAtMs: Время получения (для порядка батча). - // Возвращает: Карточка как если бы была сохранена в БД. - private static CardDto Card( - string id, - string sourceText, - string contact = "", - long receivedAtMs = 1_700_000_000_000) => new() - { - Id = id, - Col = "inbox", - Content = new SourceContent { Text = sourceText }, - Contact = contact, - ReceivedAtMs = receivedAtMs, - }; - - // Минимальный разбор классификатора (сценарий перекрывает нужные поля). - // title: Заголовок. - // isVacancy: Признак найма. - // isSpam: Вердикт «не заявка». - // board: Назначенная колонка. - // Возвращает: Разбор классификатора. - private static AiParsedCardDto Parsed( - string title, - bool isVacancy = false, - bool isSpam = false, - string? board = null) => new( - Title: title, - Company: null, - Format: null, - Task: null, - Requirements: null, - Plus: null, - Conditions: null, - Summary: null, - Stack: Array.Empty(), - Budget: null, - Contacts: Array.Empty(), - IsVacancy: isVacancy, - IsVacancyKnown: false, - IsSpam: isSpam, - Board: board); - - // Доска с опциональными правилами (null — «правил нет»; свободная колонка). - // id: Id доски. - // keywords: Ключевые слова правил (пусто — правил нет). - // Возвращает: Контейнер доски. - private static ContainerDto Board(string id, IReadOnlyList? keywords = null) => new() - { - Id = id, - Name = id, - Rules = keywords is null - ? null - : new ContainerRulesDto( - Mode: "any", - Direction: Array.Empty(), - Keywords: keywords, - Stack: Array.Empty(), - Grade: Array.Empty(), - Exclude: Array.Empty(), - Budget: null), - }; - - // ─── Путь без ИИ (local fallback) ────────────────────────────────────── - - [Fact] - public async Task ReclassifyInbox_AiDisabled_UsesLocalFallbackWithoutCallingPort() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); - ctx.Store.SeedCard(Card("c_1", "Нужен сильный Python-разработчик, проект на полгода, оплата 200000 руб")); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.True(result.Started); - Assert.False(result.Busy); - Assert.Equal(1, result.Attempted); - Assert.Equal(1, result.Reclassified); - Assert.Equal(1, result.Kept); - Assert.Equal(0, result.Moved); - Assert.Equal(0, result.Trashed); - Assert.Equal(0, result.Skipped); - Assert.False(result.UsedAi); - Assert.Null(result.Reason); - - // ИИ-порт не звался вовсе; локальный разбор не подтверждает тип (маркерная гипотеза). - Assert.Equal(0, ctx.AiClassifier.FilterCalls); - Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); - CardDto card = Assert.Single(ctx.Store.CardDtos); - Assert.Equal("inbox", card.Col); - Assert.True(card.IsNew); - Assert.False(card.IsVacancyKnown); - Assert.Empty(ctx.MlClient.Pushed); - } - - [Fact] - public async Task ReclassifyInbox_AiEnabledButClassifierFails_FallsBackToLocal() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); - ctx.AiClassifier.ClassifyThrows = true; - ctx.Store.SeedCard(Card("c_1", "Нужен сильный Python-разработчик, проект на полгода, оплата 200000 руб")); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.True(result.Started); - Assert.Equal(1, result.Kept); - Assert.False(result.UsedAi); // сбой ИИ → локальный разбор, проход не падает - Assert.Equal(1, ctx.AiClassifier.ClassifyCalls); - Assert.Equal("inbox", Assert.Single(ctx.Store.CardDtos).Col); - } - - // ─── ИИ-путь: доска и обучающие сигналы ──────────────────────────────── - - [Fact] - public async Task ReclassifyInbox_AiAssignsFreeBoard_MovesCardAndPushesSignals() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); - ctx.Store.SeedBoard(Board("b_py")); - const string text = "Ищу Python-разработчика на проект, оплата 250000 руб, старт сразу"; - ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", isVacancy: true, board: "b_py"); - ctx.Store.SeedCard(Card("c_1", text)); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.True(result.UsedAi); - Assert.Equal(1, result.Moved); - Assert.Equal(0, result.Kept); - Assert.Equal(1, result.Attempted); - - CardDto card = Assert.Single(ctx.Store.CardDtos); - Assert.Equal("b_py", card.Col); - Assert.True(card.IsNew); - Assert.True(card.IsVacancy); - Assert.True(card.IsVacancyKnown); // успешная классификация ИИ подтверждает тип - - // Обучающие сигналы: доска (свободная колонка) и тип — вес гипотезы ИИ 0.4. - Assert.Contains(ctx.MlClient.Pushed, p => p.Label == "b_py" && p.Delta == 0.4); - Assert.Contains(ctx.MlClient.Pushed, p => p.Label == "t:hire" && p.Delta == 0.4); - } - - [Fact] - public async Task ReclassifyInbox_AiBoardWithRules_ComputesMatchHits() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); - ctx.Store.SeedBoard(Board("b_py", keywords: ["python"])); - ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", board: "b_py"); - ctx.Store.SeedCard(Card("c_1", "Срочно нужен python разработчик на длительный проект")); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.Equal(1, result.Moved); - CardDto card = Assert.Single(ctx.Store.CardDtos); - Assert.Equal("b_py", card.Col); - Assert.NotEmpty(card.MatchHits); // пересчитаны правила доски (Ruling 2) - } - - [Fact] - public async Task ReclassifyInbox_AiAssignsUnknownBoard_KeepsCardInInbox() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); - // Доска не создана: ContainerAccepts-страховка не даёт положить карточку в несуществующую колонку. - ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", board: "b_ghost"); - ctx.Store.SeedCard(Card("c_1", "Нужен python разработчик, проект на полгода, оплата достойная")); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.Equal(1, result.Kept); - Assert.Equal("inbox", Assert.Single(ctx.Store.CardDtos).Col); - } - - // ─── Спам / фильтр → корзина ─────────────────────────────────────────── - - [Fact] - public async Task ReclassifyInbox_AiMarksSpam_TrashesCardAndPushesSpamSignal() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); - const string text = "Реклама: продвижение каналов и чатов, взаимный пиар, без бюджета"; - ctx.AiClassifier.ClassifyResult = Parsed("Реклама", isSpam: true); - ctx.Store.SeedCard(Card("c_1", text)); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.True(result.UsedAi); - Assert.Equal(1, result.Trashed); - Assert.Equal(0, result.Moved); - Assert.Equal(0, result.Kept); - - CardDto card = Assert.Single(ctx.Store.CardDtos); - Assert.Equal("trash", card.Col); - Assert.False(card.IsNew); - Assert.Contains(ctx.MlClient.Pushed, p => p.Label == "spam" && p.Delta == 0.4); - Assert.Contains(ctx.Store.Moves, m => m.Action == "trash"); // журнал переноса пишется - } - - [Fact] - public async Task ReclassifyInbox_AiFilterRejects_TrashesWithoutClassification() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); - ctx.AiClassifier.FilterResult = new AiFilterResultDto(Pass: false, Reason: "не относится к интересам", Skipped: false); - ctx.Store.SeedCard(Card("c_1", "Служебное сообщение, не заявка, но длиной больше двадцати четырёх")); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.Equal(1, result.Trashed); - Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); // фильтр заблокировал классификацию - Assert.Equal("trash", Assert.Single(ctx.Store.CardDtos).Col); - Assert.Contains(ctx.MlClient.Pushed, p => p.Label == "spam" && p.Delta == 0.4); - } - - // ─── Счётчики/отбор ──────────────────────────────────────────────────── - - [Fact] - public async Task ReclassifyInbox_EmptyInbox_ReturnsReasonWithoutStart() - { - Context ctx = CreateContext(); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.False(result.Started); - Assert.False(result.Busy); - Assert.Equal(0, result.Attempted); - Assert.Equal(CardReclassifier.EmptyInboxReason, result.Reason); - } - - [Fact] - public async Task ReclassifyInbox_IdFilter_LimitsTarget() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); - ctx.Store.SeedCard(Card("c_1", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб", receivedAtMs: 2)); - ctx.Store.SeedCard(Card("c_2", "Нужен Java-разработчик, проект на год, оплата 300000 руб", receivedAtMs: 1)); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(["c_2"], CancellationToken.None); - - Assert.Equal(1, result.Attempted); - Assert.Equal(1, result.Kept); - CardDto card = Assert.Single(ctx.Store.CardDtos, c => c.Id == "c_2"); - Assert.True(card.IsNew); // переклассифицирована - CardDto untouched = Assert.Single(ctx.Store.CardDtos, c => c.Id == "c_1"); - Assert.False(untouched.IsNew); // вне ids - } - - [Fact] - public async Task ReclassifyInbox_SkipsCardWithoutSourceText() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); - ctx.Store.SeedCard(Card("c_empty", "")); - ctx.Store.SeedCard(Card("c_1", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб", receivedAtMs: 1)); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.True(result.Started); - Assert.Equal(2, result.Attempted); - Assert.Equal(1, result.Skipped); - Assert.Equal(1, result.Reclassified); - } - - // ─── Прогресс пакетного прохода ──────────────────────────────────────── - - [Fact] - public async Task ReclassifyInbox_EmptyTarget_DoesNotReportProgress() - { - Context ctx = CreateContext(); - var progress = new RecordingProgress(); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None, progress); - - Assert.Equal(CardReclassifier.EmptyInboxReason, result.Reason); - Assert.Empty(progress.Reports); - } - - [Fact] - public async Task ReclassifyInbox_TwelveCards_ReportsEveryFiveAndFinal() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); - SeedCards(ctx, 12); - var progress = new RecordingProgress(); - - await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None, progress); - - Assert.Equal(new[] { 0, 5, 10, 12 }, progress.Reports.Select(r => r.Done)); - Assert.All(progress.Reports, r => Assert.Equal(12, r.Total)); - Assert.All(progress.Reports, r => Assert.Equal(r.Done, r.Moved + r.Kept + r.Trashed + r.Skipped)); - } - - [Fact] - public async Task ReclassifyInbox_LessThanStep_ReportsOnlyStartAndFinal() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); - SeedCards(ctx, 3); - var progress = new RecordingProgress(); - - await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None, progress); - - Assert.Equal(new[] { 0, 3 }, progress.Reports.Select(r => r.Done)); - Assert.All(progress.Reports, r => Assert.Equal(3, r.Total)); - } - - [Fact] - public async Task ReclassifyInbox_ProgressDoneMonotonicAndFinalReachesTotal() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); - SeedCards(ctx, 12); - var progress = new RecordingProgress(); - - await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None, progress); - - Assert.NotEmpty(progress.Reports); - int previous = -1; - foreach (ReclassifyProgressDto report in progress.Reports) - { - Assert.True(report.Done >= previous); - previous = report.Done; - } - - Assert.Equal(12, progress.Reports[^1].Done); - Assert.Equal(progress.Reports[^1].Done, progress.Reports[^1].Total); - } - - // ─── Одна карточка и замок ───────────────────────────────────────────── - - [Fact] - public async Task ReclassifyCard_NoSourceText_ReturnsReason() - { - Context ctx = CreateContext(); - ctx.Store.SeedCard(Card("c_1", "")); - CardDto card = ctx.Store.CardDtos.Single(); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyCardAsync(card, CancellationToken.None); - - Assert.False(result.Started); - Assert.Equal(1, result.Attempted); - Assert.Equal(1, result.Skipped); - Assert.Equal(CardReclassifier.NoSourceTextReason, result.Reason); - } - - [Fact] - public async Task ReclassifyCard_KeepsValidOldContactWhenNewIsEmpty() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); - ctx.Store.SeedCard(Card("c_1", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб", contact: "@old_user")); - CardDto card = ctx.Store.CardDtos.Single(); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyCardAsync(card, CancellationToken.None); - - Assert.Equal(1, result.Kept); - Assert.Equal("@old_user", Assert.Single(ctx.Store.CardDtos).Contact); - } - - // Засевает N карточек «Неразобранного» с исходным текстом (порядок по времени получения). - // ctx: Контекст теста. - // count: Сколько карточек засеять. - private static void SeedCards(Context ctx, int count) - { - for (int i = 0; i < count; i++) - { - ctx.Store.SeedCard(Card($"c_{i}", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб", receivedAtMs: i)); - } - } - - [Fact] - public async Task Reclassify_Busy_WhenAnotherPassHoldsGate() - { - Context ctx = CreateContext(); - ctx.Store.SeedCard(Card("c_1", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб")); - Assert.True(ctx.Gate.TryEnter()); - - try - { - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.True(result.Busy); - Assert.False(result.Started); - Assert.Equal(0, result.Attempted); - } - finally - { - ctx.Gate.Exit(); - } - } - - // Записывающий наблюдатель прогресса: складывает отчёты в порядке поступления. - private sealed class RecordingProgress : IProgress - { - /// - /// Полученные отчёты о прогрессе. - /// - public List Reports { get; } = []; - - /// - public void Report(ReclassifyProgressDto value) => Reports.Add(value); - } -} +using System.Text.Json; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Settings; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты ручной переклассификации карточек — CardReclassifier. +/// +public sealed class CardReclassifierTests +{ + // Контекст теста: сервис поверх in-memory фейков. + private sealed record Context( + CardReclassifier Reclassifier, + FakeKanjStore Store, + FakeSettingsStore Settings, + FakeMlClient MlClient, + FakeAiClassifier AiClassifier, + ReclassifyGate Gate); + + // Собирает контекст: фейки хранилищ/портов, реальные композитор/парсер/сервис карточек. + // Возвращает: Сервис и фейки (сценарий до-настраивает карточки/доски/настройки). + private static Context CreateContext() + { + var settings = new FakeSettingsStore(); + var store = new FakeKanjStore(); + var mlClient = new FakeMlClient(); + var aiClassifier = new FakeAiClassifier(); + var fieldsParser = new LocalFieldsParser(settings); + var composer = new CardComposer(store, settings); + var cardsService = new CardsService(store, settings, mlClient, new FakeFileStorage()); + var gate = new ReclassifyGate(); + var reclassifier = new CardReclassifier( + store, settings, aiClassifier, fieldsParser, composer, cardsService, mlClient, gate); + return new Context(reclassifier, store, settings, mlClient, aiClassifier, gate); + } + + // Сериализует значение настройки в JSON (как пишет SettingsStore). + // value: Значение (число/булево/строка). + // Возвращает: JSON для Preload. + private static string Json(object value) => JsonSerializer.Serialize(value); + + // Карточка «Неразобранного» с исходным текстом (поля, нужные классификации). + // id: Id карточки. + // sourceText: Исходный текст. + // contact: Старый контакт карточки. + // receivedAtMs: Время получения (для порядка батча). + // Возвращает: Карточка как если бы была сохранена в БД. + private static CardDto Card( + string id, + string sourceText, + string contact = "", + long receivedAtMs = 1_700_000_000_000) => new() + { + Id = id, + Col = "inbox", + Content = new SourceContent { Text = sourceText }, + Contact = contact, + ReceivedAtMs = receivedAtMs, + }; + + // Минимальный разбор классификатора (сценарий перекрывает нужные поля). + // title: Заголовок. + // isVacancy: Признак найма. + // isSpam: Вердикт «не заявка». + // board: Назначенная колонка. + // Возвращает: Разбор классификатора. + private static AiParsedCardDto Parsed( + string title, + bool isVacancy = false, + bool isSpam = false, + string? board = null) => new( + Title: title, + Company: null, + Format: null, + Task: null, + Requirements: null, + Plus: null, + Conditions: null, + Summary: null, + Stack: Array.Empty(), + Budget: null, + Contacts: Array.Empty(), + IsVacancy: isVacancy, + IsVacancyKnown: false, + IsSpam: isSpam, + Board: board); + + // Доска с опциональными правилами (null — «правил нет»; свободная колонка). + // id: Id доски. + // keywords: Ключевые слова правил (пусто — правил нет). + // Возвращает: Контейнер доски. + private static ContainerDto Board(string id, IReadOnlyList? keywords = null) => new() + { + Id = id, + Name = id, + Rules = keywords is null + ? null + : new ContainerRulesDto( + Mode: "any", + Direction: Array.Empty(), + Keywords: keywords, + Stack: Array.Empty(), + Grade: Array.Empty(), + Exclude: Array.Empty(), + Budget: null), + }; + + // ─── Путь без ИИ (local fallback) ────────────────────────────────────── + + [Fact] + public async Task ReclassifyInbox_AiDisabled_UsesLocalFallbackWithoutCallingPort() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); + ctx.Store.SeedCard(Card("c_1", "Нужен сильный Python-разработчик, проект на полгода, оплата 200000 руб")); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.True(result.Started); + Assert.False(result.Busy); + Assert.Equal(1, result.Attempted); + Assert.Equal(1, result.Reclassified); + Assert.Equal(1, result.Kept); + Assert.Equal(0, result.Moved); + Assert.Equal(0, result.Trashed); + Assert.Equal(0, result.Skipped); + Assert.False(result.UsedAi); + Assert.Null(result.Reason); + + // ИИ-порт не звался вовсе; локальный разбор не подтверждает тип (маркерная гипотеза). + Assert.Equal(0, ctx.AiClassifier.FilterCalls); + Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); + CardDto card = Assert.Single(ctx.Store.CardDtos); + Assert.Equal("inbox", card.Col); + Assert.True(card.IsNew); + Assert.False(card.IsVacancyKnown); + Assert.Empty(ctx.MlClient.Pushed); + } + + [Fact] + public async Task ReclassifyInbox_AiEnabledButClassifierFails_FallsBackToLocal() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); + ctx.AiClassifier.ClassifyThrows = true; + ctx.Store.SeedCard(Card("c_1", "Нужен сильный Python-разработчик, проект на полгода, оплата 200000 руб")); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.True(result.Started); + Assert.Equal(1, result.Kept); + Assert.False(result.UsedAi); // сбой ИИ → локальный разбор, проход не падает + Assert.Equal(1, ctx.AiClassifier.ClassifyCalls); + Assert.Equal("inbox", Assert.Single(ctx.Store.CardDtos).Col); + } + + // ─── ИИ-путь: доска и обучающие сигналы ──────────────────────────────── + + [Fact] + public async Task ReclassifyInbox_AiAssignsFreeBoard_MovesCardAndPushesSignals() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); + ctx.Store.SeedBoard(Board("b_py")); + const string text = "Ищу Python-разработчика на проект, оплата 250000 руб, старт сразу"; + ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", isVacancy: true, board: "b_py"); + ctx.Store.SeedCard(Card("c_1", text)); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.True(result.UsedAi); + Assert.Equal(1, result.Moved); + Assert.Equal(0, result.Kept); + Assert.Equal(1, result.Attempted); + + CardDto card = Assert.Single(ctx.Store.CardDtos); + Assert.Equal("b_py", card.Col); + Assert.True(card.IsNew); + Assert.True(card.IsVacancy); + Assert.True(card.IsVacancyKnown); // успешная классификация ИИ подтверждает тип + + // Обучающие сигналы: доска (свободная колонка) и тип — вес гипотезы ИИ 0.4. + Assert.Contains(ctx.MlClient.Pushed, p => p.Label == "b_py" && p.Delta == 0.4); + Assert.Contains(ctx.MlClient.Pushed, p => p.Label == "t:hire" && p.Delta == 0.4); + } + + [Fact] + public async Task ReclassifyInbox_AiBoardWithRules_ComputesMatchHits() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); + ctx.Store.SeedBoard(Board("b_py", keywords: ["python"])); + ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", board: "b_py"); + ctx.Store.SeedCard(Card("c_1", "Срочно нужен python разработчик на длительный проект")); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.Equal(1, result.Moved); + CardDto card = Assert.Single(ctx.Store.CardDtos); + Assert.Equal("b_py", card.Col); + Assert.NotEmpty(card.MatchHits); // пересчитаны правила доски (Ruling 2) + } + + [Fact] + public async Task ReclassifyInbox_AiAssignsUnknownBoard_KeepsCardInInbox() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); + // Доска не создана: ContainerAccepts-страховка не даёт положить карточку в несуществующую колонку. + ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", board: "b_ghost"); + ctx.Store.SeedCard(Card("c_1", "Нужен python разработчик, проект на полгода, оплата достойная")); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.Equal(1, result.Kept); + Assert.Equal("inbox", Assert.Single(ctx.Store.CardDtos).Col); + } + + // ─── Спам / фильтр → корзина ─────────────────────────────────────────── + + [Fact] + public async Task ReclassifyInbox_AiMarksSpam_TrashesCardAndPushesSpamSignal() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); + const string text = "Реклама: продвижение каналов и чатов, взаимный пиар, без бюджета"; + ctx.AiClassifier.ClassifyResult = Parsed("Реклама", isSpam: true); + ctx.Store.SeedCard(Card("c_1", text)); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.True(result.UsedAi); + Assert.Equal(1, result.Trashed); + Assert.Equal(0, result.Moved); + Assert.Equal(0, result.Kept); + + CardDto card = Assert.Single(ctx.Store.CardDtos); + Assert.Equal("trash", card.Col); + Assert.False(card.IsNew); + Assert.Contains(ctx.MlClient.Pushed, p => p.Label == "spam" && p.Delta == 0.4); + Assert.Contains(ctx.Store.Moves, m => m.Action == "trash"); // журнал переноса пишется + } + + [Fact] + public async Task ReclassifyInbox_AiFilterRejects_TrashesWithoutClassification() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); + ctx.AiClassifier.FilterResult = new AiFilterResultDto(Pass: false, Reason: "не относится к интересам", Skipped: false); + ctx.Store.SeedCard(Card("c_1", "Служебное сообщение, не заявка, но длиной больше двадцати четырёх")); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.Equal(1, result.Trashed); + Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); // фильтр заблокировал классификацию + Assert.Equal("trash", Assert.Single(ctx.Store.CardDtos).Col); + Assert.Contains(ctx.MlClient.Pushed, p => p.Label == "spam" && p.Delta == 0.4); + } + + // ─── Счётчики/отбор ──────────────────────────────────────────────────── + + [Fact] + public async Task ReclassifyInbox_EmptyInbox_ReturnsReasonWithoutStart() + { + Context ctx = CreateContext(); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.False(result.Started); + Assert.False(result.Busy); + Assert.Equal(0, result.Attempted); + Assert.Equal(CardReclassifier.EmptyInboxReason, result.Reason); + } + + [Fact] + public async Task ReclassifyInbox_IdFilter_LimitsTarget() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); + ctx.Store.SeedCard(Card("c_1", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб", receivedAtMs: 2)); + ctx.Store.SeedCard(Card("c_2", "Нужен Java-разработчик, проект на год, оплата 300000 руб", receivedAtMs: 1)); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(["c_2"], CancellationToken.None); + + Assert.Equal(1, result.Attempted); + Assert.Equal(1, result.Kept); + CardDto card = Assert.Single(ctx.Store.CardDtos, c => c.Id == "c_2"); + Assert.True(card.IsNew); // переклассифицирована + CardDto untouched = Assert.Single(ctx.Store.CardDtos, c => c.Id == "c_1"); + Assert.False(untouched.IsNew); // вне ids + } + + [Fact] + public async Task ReclassifyInbox_SkipsCardWithoutSourceText() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); + ctx.Store.SeedCard(Card("c_empty", "")); + ctx.Store.SeedCard(Card("c_1", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб", receivedAtMs: 1)); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.True(result.Started); + Assert.Equal(2, result.Attempted); + Assert.Equal(1, result.Skipped); + Assert.Equal(1, result.Reclassified); + } + + // ─── Прогресс пакетного прохода ──────────────────────────────────────── + + [Fact] + public async Task ReclassifyInbox_EmptyTarget_DoesNotReportProgress() + { + Context ctx = CreateContext(); + var progress = new RecordingProgress(); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None, progress); + + Assert.Equal(CardReclassifier.EmptyInboxReason, result.Reason); + Assert.Empty(progress.Reports); + } + + [Fact] + public async Task ReclassifyInbox_TwelveCards_ReportsEveryFiveAndFinal() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); + SeedCards(ctx, 12); + var progress = new RecordingProgress(); + + await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None, progress); + + Assert.Equal(new[] { 0, 5, 10, 12 }, progress.Reports.Select(r => r.Done)); + Assert.All(progress.Reports, r => Assert.Equal(12, r.Total)); + Assert.All(progress.Reports, r => Assert.Equal(r.Done, r.Moved + r.Kept + r.Trashed + r.Skipped)); + } + + [Fact] + public async Task ReclassifyInbox_LessThanStep_ReportsOnlyStartAndFinal() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); + SeedCards(ctx, 3); + var progress = new RecordingProgress(); + + await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None, progress); + + Assert.Equal(new[] { 0, 3 }, progress.Reports.Select(r => r.Done)); + Assert.All(progress.Reports, r => Assert.Equal(3, r.Total)); + } + + [Fact] + public async Task ReclassifyInbox_ProgressDoneMonotonicAndFinalReachesTotal() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); + SeedCards(ctx, 12); + var progress = new RecordingProgress(); + + await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None, progress); + + Assert.NotEmpty(progress.Reports); + int previous = -1; + foreach (ReclassifyProgressDto report in progress.Reports) + { + Assert.True(report.Done >= previous); + previous = report.Done; + } + + Assert.Equal(12, progress.Reports[^1].Done); + Assert.Equal(progress.Reports[^1].Done, progress.Reports[^1].Total); + } + + // ─── Одна карточка и замок ───────────────────────────────────────────── + + [Fact] + public async Task ReclassifyCard_NoSourceText_ReturnsReason() + { + Context ctx = CreateContext(); + ctx.Store.SeedCard(Card("c_1", "")); + CardDto card = ctx.Store.CardDtos.Single(); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyCardAsync(card, CancellationToken.None); + + Assert.False(result.Started); + Assert.Equal(1, result.Attempted); + Assert.Equal(1, result.Skipped); + Assert.Equal(CardReclassifier.NoSourceTextReason, result.Reason); + } + + [Fact] + public async Task ReclassifyCard_KeepsValidOldContactWhenNewIsEmpty() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); + ctx.Store.SeedCard(Card("c_1", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб", contact: "@old_user")); + CardDto card = ctx.Store.CardDtos.Single(); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyCardAsync(card, CancellationToken.None); + + Assert.Equal(1, result.Kept); + Assert.Equal("@old_user", Assert.Single(ctx.Store.CardDtos).Contact); + } + + // Засевает N карточек «Неразобранного» с исходным текстом (порядок по времени получения). + // ctx: Контекст теста. + // count: Сколько карточек засеять. + private static void SeedCards(Context ctx, int count) + { + for (int i = 0; i < count; i++) + { + ctx.Store.SeedCard(Card($"c_{i}", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб", receivedAtMs: i)); + } + } + + [Fact] + public async Task Reclassify_Busy_WhenAnotherPassHoldsGate() + { + Context ctx = CreateContext(); + ctx.Store.SeedCard(Card("c_1", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб")); + Assert.True(ctx.Gate.TryEnter()); + + try + { + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.True(result.Busy); + Assert.False(result.Started); + Assert.Equal(0, result.Attempted); + } + finally + { + ctx.Gate.Exit(); + } + } + + // Записывающий наблюдатель прогресса: складывает отчёты в порядке поступления. + private sealed class RecordingProgress : IProgress + { + /// + /// Полученные отчёты о прогрессе. + /// + public List Reports { get; } = []; + + /// + public void Report(ReclassifyProgressDto value) => Reports.Add(value); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs index 0ff70a5..a2f7460 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs @@ -1,259 +1,259 @@ -using System.Text; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты вложений карточки — -/// -public sealed class CardsServiceFilesTests -{ - - [Fact] - public async Task Add_ImageMime_DetectsKindByMimeWritesObjectAndMeta() - { - (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); - store.SeedCard(Card("c_1", updatedAtMs: 1)); - byte[] content = Encoding.UTF8.GetBytes("данные-картинки"); - using MemoryStream stream = new(content); - - CardFileDto? entry = await service.AddFileAsync( - "c_1", "photo.bin", "image/png", stream, content.LongLength, CancellationToken.None); - - Assert.NotNull(entry); - Assert.StartsWith("pf_", entry!.Id); // id записи файла - Assert.Equal("photo.bin", entry.Name); // name — как прислано - Assert.Equal(content.LongLength, entry.Size); // size — длина содержимого - Assert.Equal("image", entry.Kind); // детект по MIME-префиксу image/ - Assert.Equal("Изображение", entry.Label); - // objectKey-форма: projects/{card}/{fileId}_{ms}_{safeName} — id записи делает ключ уникальным. - Assert.Matches("^projects/c_1/pf_[0-9a-f]{12}_\\d+_photo\\.bin$", entry.ObjectKey); - Assert.StartsWith($"projects/c_1/{entry.Id}_", entry.ObjectKey); // в ключ входит id этой записи - Assert.Equal(content, storage.ContentOf(entry.ObjectKey)); // объект записан в хранилище - CardDto card = Assert.Single(store.CardDtos); - Assert.Equal(entry, Assert.Single(card.Files)); // мета дописана в FilesJson карточки - Assert.True(card.UpdatedAtMs > 1); // атомарный AddFileAsync бампает UpdatedAt - } - - [Fact] - public async Task Add_PdfExtensionWithoutMime_DetectsKindByExtension() - { - (CardsService service, FakeKanjStore store, _) = Create(); - store.SeedCard(Card("c_1")); - - CardFileDto? entry = await service.AddFileAsync( - "c_1", "tz.pdf", contentType: null, new MemoryStream("тз"u8.ToArray()), size: 2, CancellationToken.None); - - Assert.NotNull(entry); - Assert.Equal("document", entry!.Kind); // без MIME — расширение по наборам files.py KIND_BY_EXT - Assert.Equal("Документ", entry.Label); - Assert.Equal("tz.pdf", entry.Name); - } - - [Fact] - public async Task Add_TwoFiles_AppendsPreservingOrderAndBothObjects() - { - (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); - store.SeedCard(Card("c_1")); - - CardFileDto? first = await service.AddFileAsync( - "c_1", "a.txt", "text/plain", new MemoryStream("aaa"u8.ToArray()), 3, CancellationToken.None); - CardFileDto? second = await service.AddFileAsync( - "c_1", "b.png", "image/png", new MemoryStream("bbb"u8.ToArray()), 3, CancellationToken.None); - - Assert.NotNull(first); - Assert.NotNull(second); - CardDto card = Assert.Single(store.CardDtos); - Assert.Equal(new[] { first, second }, card.Files); // порядок добавления сохраняется (append в конец) - Assert.Equal(2, storage.StoredObjectKeys.Count); // оба объекта сохранены - Assert.Equal(2, card.Files.Select(file => file.ObjectKey).Distinct().Count()); - } - - [Fact] - public async Task Add_CardMissing_ReturnsNullAndDoesNotWriteObject() - { - (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); - - CardFileDto? entry = await service.AddFileAsync( - "c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None); - - Assert.Null(entry); // эндпоинт отвечает 404 «Карточка не найдена» - Assert.Empty(storage.StoredObjectKeys); // «add на несуществующей карточке не пишет объект» - Assert.Empty(store.CardDtos); - } - - [Fact] - public async Task Add_EmptyOrWhitespaceFileName_DefaultsToPrototypeFile() - { - (CardsService service, FakeKanjStore store, _) = Create(); - store.SeedCard(Card("c_1")); - - CardFileDto? entry = await service.AddFileAsync( - "c_1", " ", contentType: null, new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None); - - Assert.NotNull(entry); - Assert.Equal("file", entry!.Name); - Assert.Equal("other", entry.Kind); // расширения нет — other/«Файл» - Assert.Equal("Файл", entry.Label); - Assert.Matches("^projects/c_1/pf_[0-9a-f]{12}_\\d+_file$", entry.ObjectKey); - } - - [Fact] - public async Task Add_FileNameWithPathAndQuoteChars_SanitizesObjectKeyButKeepsMetaName() - { - (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); - store.SeedCard(Card("c_1")); - string rawName = "..\\файл\"отчёта v2.pdf"; - - CardFileDto? entry = await service.AddFileAsync( - "c_1", rawName, contentType: null, new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None); - - Assert.NotNull(entry); - Assert.Equal(rawName, entry!.Name); // в метаданных имя остаётся как прислано - // В objectKey path-разделители и кавычки заменяются «_» — у ключа нет лишних сегментов пути. - Assert.Matches("^projects/c_1/pf_[0-9a-f]{12}_\\d+_\\.\\._файл_отчёта v2\\.pdf$", entry.ObjectKey); - Assert.NotNull(storage.ContentOf(entry.ObjectKey)); - Assert.Single(store.CardDtos); - } - - [Fact] - public async Task Add_StreamPositionNotZero_StoresWholeContent() - { - (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); - store.SeedCard(Card("c_1")); - byte[] content = Encoding.UTF8.GetBytes("полное-содержимое-файла"); - using MemoryStream stream = new(content) { Position = 5 }; // эндпоинт мог прочитать поток раньше - - CardFileDto? entry = await service.AddFileAsync( - "c_1", "doc.txt", "text/plain", stream, content.LongLength, CancellationToken.None); - - // Контракт порта: Put читает содержимое с позиции 0 — в хранилище весь файл, не хвост. - Assert.NotNull(entry); - Assert.Equal(content.LongLength, entry!.Size); - Assert.Equal(content, storage.ContentOf(entry.ObjectKey)); - } - - - [Fact] - public async Task GetEntry_ExistingFile_ReturnsEntryMeta() - { - (CardsService service, FakeKanjStore store, _) = Create(); - var file = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000000_tz.pdf"); - store.SeedCard(Card("c_1") with { Files = new[] { file } }); - - CardFileDto? entry = await service.GetFileEntryAsync("c_1", "pf_1", CancellationToken.None); - - Assert.NotNull(entry); - Assert.Equal(file, entry); // мета для download: id/name/size/kind/label/objectKey - } - - [Fact] - public async Task GetEntry_CardMissing_ReturnsNull() - { - (CardsService service, _, _) = Create(); - - CardFileDto? entry = await service.GetFileEntryAsync("c_missing", "pf_1", CancellationToken.None); - - Assert.Null(entry); // 404 «Карточка не найдена» у эндпоинта - } - - [Fact] - public async Task GetEntry_FileNotInMetadata_ReturnsNull() - { - (CardsService service, FakeKanjStore store, _) = Create(); - store.SeedCard(Card("c_1") - with { Files = new[] { new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "k") } }); - - CardFileDto? entry = await service.GetFileEntryAsync("c_1", "pf_ghost", CancellationToken.None); - - Assert.Null(entry); // файла нет в метаданных карточки — 404-семантика - } - - - [Fact] - public async Task Remove_Existing_DeletesObjectRemovesMetaAndReturnsCard() - { - (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); - var first = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000001_tz.pdf"); - var second = new CardFileDto("pf_2", "photo.png", 200, "image", "Изображение", "projects/c_1/1710000000002_photo.png"); - store.SeedCard(Card("c_1", updatedAtMs: 1) with { Files = new[] { first, second } }); - await storage.PutAsync(first.ObjectKey, new MemoryStream("t"u8.ToArray()), "application/pdf", CancellationToken.None); - await storage.PutAsync(second.ObjectKey, new MemoryStream("p"u8.ToArray()), "image/png", CancellationToken.None); - - CardDto? card = await service.RemoveFileAsync("c_1", "pf_1", CancellationToken.None); - - Assert.NotNull(card); - Assert.Equal("pf_2", Assert.Single(card!.Files).Id); // из мета убрана только указанная запись - Assert.Equal(new[] { first.ObjectKey }, storage.DeletedKeys); // объект удалён из хранилища - Assert.Null(storage.ContentOf(first.ObjectKey)); - Assert.NotNull(storage.ContentOf(second.ObjectKey)); // объект соседней записи не тронут - CardDto stored = Assert.Single(store.CardDtos); - Assert.Equal("pf_2", Assert.Single(stored.Files).Id); - Assert.True(stored.UpdatedAtMs > 1); // удаление бампает UpdatedAt - } - - [Fact] - public async Task Remove_UnknownFileId_ReturnsCardUnchangedWithoutStorageDelete() - { - (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); - var file = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000001_tz.pdf"); - store.SeedCard(Card("c_1") with { Files = new[] { file } }); - - CardDto? card = await service.RemoveFileAsync("c_1", "pf_ghost", CancellationToken.None); - - // Как remove_link: записи нет — список остаётся прежним, ошибки НЕТ. - Assert.NotNull(card); - Assert.Equal("pf_1", Assert.Single(card!.Files).Id); - Assert.Empty(storage.DeletedKeys); // объект не удалялся (записи с таким id нет) - } - - [Fact] - public async Task Remove_EntryWithEmptyObjectKey_SkipsStorageDelete() - { - (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); - store.SeedCard(Card("c_1") - with { Files = new[] { new CardFileDto("pf_mock", "meta-only.pdf", 10, "document", "Документ", ObjectKey: string.Empty) } }); - - CardDto? card = await service.RemoveFileAsync("c_1", "pf_mock", CancellationToken.None); - - Assert.NotNull(card); - Assert.Empty(card!.Files); - Assert.Empty(storage.DeletedKeys); - } - - [Fact] - public async Task Remove_CardMissing_ReturnsNullWithoutStorageDelete() - { - (CardsService service, _, FakeFileStorage storage) = Create(); - - CardDto? card = await service.RemoveFileAsync("c_missing", "pf_1", CancellationToken.None); - - Assert.Null(card); // эндпоинт отвечает 404 «Карточка не найдена» - Assert.Empty(storage.DeletedKeys); - } - - // ─── Хелперы ────────────────────────────────────────────────────────── - - private static (CardsService Service, FakeKanjStore Store, FakeFileStorage Storage) Create() - { - var store = new FakeKanjStore(); - var storage = new FakeFileStorage(); - return (new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), storage), store, storage); - } - - // Карточка с полями по умолчанию (planned, CreatedAtMs=1, UpdatedAtMs=1; Files пуст). - private static CardDto Card(string id, long updatedAtMs = 1) - { - return new CardDto - { - Id = id, - Col = "planned", - CreatedAtMs = 1, - UpdatedAtMs = updatedAtMs, - }; - } -} +using System.Text; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Settings; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты вложений карточки — +/// +public sealed class CardsServiceFilesTests +{ + + [Fact] + public async Task Add_ImageMime_DetectsKindByMimeWritesObjectAndMeta() + { + (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); + store.SeedCard(Card("c_1", updatedAtMs: 1)); + byte[] content = Encoding.UTF8.GetBytes("данные-картинки"); + using MemoryStream stream = new(content); + + CardFileDto? entry = await service.AddFileAsync( + "c_1", "photo.bin", "image/png", stream, content.LongLength, CancellationToken.None); + + Assert.NotNull(entry); + Assert.StartsWith("pf_", entry!.Id); // id записи файла + Assert.Equal("photo.bin", entry.Name); // name — как прислано + Assert.Equal(content.LongLength, entry.Size); // size — длина содержимого + Assert.Equal("image", entry.Kind); // детект по MIME-префиксу image/ + Assert.Equal("Изображение", entry.Label); + // objectKey-форма: projects/{card}/{fileId}_{ms}_{safeName} — id записи делает ключ уникальным. + Assert.Matches("^projects/c_1/pf_[0-9a-f]{12}_\\d+_photo\\.bin$", entry.ObjectKey); + Assert.StartsWith($"projects/c_1/{entry.Id}_", entry.ObjectKey); // в ключ входит id этой записи + Assert.Equal(content, storage.ContentOf(entry.ObjectKey)); // объект записан в хранилище + CardDto card = Assert.Single(store.CardDtos); + Assert.Equal(entry, Assert.Single(card.Files)); // мета дописана в FilesJson карточки + Assert.True(card.UpdatedAtMs > 1); // атомарный AddFileAsync бампает UpdatedAt + } + + [Fact] + public async Task Add_PdfExtensionWithoutMime_DetectsKindByExtension() + { + (CardsService service, FakeKanjStore store, _) = Create(); + store.SeedCard(Card("c_1")); + + CardFileDto? entry = await service.AddFileAsync( + "c_1", "tz.pdf", contentType: null, new MemoryStream("тз"u8.ToArray()), size: 2, CancellationToken.None); + + Assert.NotNull(entry); + Assert.Equal("document", entry!.Kind); // без MIME — расширение по наборам files.py KIND_BY_EXT + Assert.Equal("Документ", entry.Label); + Assert.Equal("tz.pdf", entry.Name); + } + + [Fact] + public async Task Add_TwoFiles_AppendsPreservingOrderAndBothObjects() + { + (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); + store.SeedCard(Card("c_1")); + + CardFileDto? first = await service.AddFileAsync( + "c_1", "a.txt", "text/plain", new MemoryStream("aaa"u8.ToArray()), 3, CancellationToken.None); + CardFileDto? second = await service.AddFileAsync( + "c_1", "b.png", "image/png", new MemoryStream("bbb"u8.ToArray()), 3, CancellationToken.None); + + Assert.NotNull(first); + Assert.NotNull(second); + CardDto card = Assert.Single(store.CardDtos); + Assert.Equal(new[] { first, second }, card.Files); // порядок добавления сохраняется (append в конец) + Assert.Equal(2, storage.StoredObjectKeys.Count); // оба объекта сохранены + Assert.Equal(2, card.Files.Select(file => file.ObjectKey).Distinct().Count()); + } + + [Fact] + public async Task Add_CardMissing_ReturnsNullAndDoesNotWriteObject() + { + (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); + + CardFileDto? entry = await service.AddFileAsync( + "c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None); + + Assert.Null(entry); // эндпоинт отвечает 404 «Карточка не найдена» + Assert.Empty(storage.StoredObjectKeys); // «add на несуществующей карточке не пишет объект» + Assert.Empty(store.CardDtos); + } + + [Fact] + public async Task Add_EmptyOrWhitespaceFileName_DefaultsToPrototypeFile() + { + (CardsService service, FakeKanjStore store, _) = Create(); + store.SeedCard(Card("c_1")); + + CardFileDto? entry = await service.AddFileAsync( + "c_1", " ", contentType: null, new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None); + + Assert.NotNull(entry); + Assert.Equal("file", entry!.Name); + Assert.Equal("other", entry.Kind); // расширения нет — other/«Файл» + Assert.Equal("Файл", entry.Label); + Assert.Matches("^projects/c_1/pf_[0-9a-f]{12}_\\d+_file$", entry.ObjectKey); + } + + [Fact] + public async Task Add_FileNameWithPathAndQuoteChars_SanitizesObjectKeyButKeepsMetaName() + { + (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); + store.SeedCard(Card("c_1")); + string rawName = "..\\файл\"отчёта v2.pdf"; + + CardFileDto? entry = await service.AddFileAsync( + "c_1", rawName, contentType: null, new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None); + + Assert.NotNull(entry); + Assert.Equal(rawName, entry!.Name); // в метаданных имя остаётся как прислано + // В objectKey path-разделители и кавычки заменяются «_» — у ключа нет лишних сегментов пути. + Assert.Matches("^projects/c_1/pf_[0-9a-f]{12}_\\d+_\\.\\._файл_отчёта v2\\.pdf$", entry.ObjectKey); + Assert.NotNull(storage.ContentOf(entry.ObjectKey)); + Assert.Single(store.CardDtos); + } + + [Fact] + public async Task Add_StreamPositionNotZero_StoresWholeContent() + { + (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); + store.SeedCard(Card("c_1")); + byte[] content = Encoding.UTF8.GetBytes("полное-содержимое-файла"); + using MemoryStream stream = new(content) { Position = 5 }; // эндпоинт мог прочитать поток раньше + + CardFileDto? entry = await service.AddFileAsync( + "c_1", "doc.txt", "text/plain", stream, content.LongLength, CancellationToken.None); + + // Контракт порта: Put читает содержимое с позиции 0 — в хранилище весь файл, не хвост. + Assert.NotNull(entry); + Assert.Equal(content.LongLength, entry!.Size); + Assert.Equal(content, storage.ContentOf(entry.ObjectKey)); + } + + + [Fact] + public async Task GetEntry_ExistingFile_ReturnsEntryMeta() + { + (CardsService service, FakeKanjStore store, _) = Create(); + var file = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000000_tz.pdf"); + store.SeedCard(Card("c_1") with { Files = new[] { file } }); + + CardFileDto? entry = await service.GetFileEntryAsync("c_1", "pf_1", CancellationToken.None); + + Assert.NotNull(entry); + Assert.Equal(file, entry); // мета для download: id/name/size/kind/label/objectKey + } + + [Fact] + public async Task GetEntry_CardMissing_ReturnsNull() + { + (CardsService service, _, _) = Create(); + + CardFileDto? entry = await service.GetFileEntryAsync("c_missing", "pf_1", CancellationToken.None); + + Assert.Null(entry); // 404 «Карточка не найдена» у эндпоинта + } + + [Fact] + public async Task GetEntry_FileNotInMetadata_ReturnsNull() + { + (CardsService service, FakeKanjStore store, _) = Create(); + store.SeedCard(Card("c_1") + with { Files = new[] { new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "k") } }); + + CardFileDto? entry = await service.GetFileEntryAsync("c_1", "pf_ghost", CancellationToken.None); + + Assert.Null(entry); // файла нет в метаданных карточки — 404-семантика + } + + + [Fact] + public async Task Remove_Existing_DeletesObjectRemovesMetaAndReturnsCard() + { + (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); + var first = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000001_tz.pdf"); + var second = new CardFileDto("pf_2", "photo.png", 200, "image", "Изображение", "projects/c_1/1710000000002_photo.png"); + store.SeedCard(Card("c_1", updatedAtMs: 1) with { Files = new[] { first, second } }); + await storage.PutAsync(first.ObjectKey, new MemoryStream("t"u8.ToArray()), "application/pdf", CancellationToken.None); + await storage.PutAsync(second.ObjectKey, new MemoryStream("p"u8.ToArray()), "image/png", CancellationToken.None); + + CardDto? card = await service.RemoveFileAsync("c_1", "pf_1", CancellationToken.None); + + Assert.NotNull(card); + Assert.Equal("pf_2", Assert.Single(card!.Files).Id); // из мета убрана только указанная запись + Assert.Equal(new[] { first.ObjectKey }, storage.DeletedKeys); // объект удалён из хранилища + Assert.Null(storage.ContentOf(first.ObjectKey)); + Assert.NotNull(storage.ContentOf(second.ObjectKey)); // объект соседней записи не тронут + CardDto stored = Assert.Single(store.CardDtos); + Assert.Equal("pf_2", Assert.Single(stored.Files).Id); + Assert.True(stored.UpdatedAtMs > 1); // удаление бампает UpdatedAt + } + + [Fact] + public async Task Remove_UnknownFileId_ReturnsCardUnchangedWithoutStorageDelete() + { + (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); + var file = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000001_tz.pdf"); + store.SeedCard(Card("c_1") with { Files = new[] { file } }); + + CardDto? card = await service.RemoveFileAsync("c_1", "pf_ghost", CancellationToken.None); + + // Как remove_link: записи нет — список остаётся прежним, ошибки НЕТ. + Assert.NotNull(card); + Assert.Equal("pf_1", Assert.Single(card!.Files).Id); + Assert.Empty(storage.DeletedKeys); // объект не удалялся (записи с таким id нет) + } + + [Fact] + public async Task Remove_EntryWithEmptyObjectKey_SkipsStorageDelete() + { + (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); + store.SeedCard(Card("c_1") + with { Files = new[] { new CardFileDto("pf_mock", "meta-only.pdf", 10, "document", "Документ", ObjectKey: string.Empty) } }); + + CardDto? card = await service.RemoveFileAsync("c_1", "pf_mock", CancellationToken.None); + + Assert.NotNull(card); + Assert.Empty(card!.Files); + Assert.Empty(storage.DeletedKeys); + } + + [Fact] + public async Task Remove_CardMissing_ReturnsNullWithoutStorageDelete() + { + (CardsService service, _, FakeFileStorage storage) = Create(); + + CardDto? card = await service.RemoveFileAsync("c_missing", "pf_1", CancellationToken.None); + + Assert.Null(card); // эндпоинт отвечает 404 «Карточка не найдена» + Assert.Empty(storage.DeletedKeys); + } + + // ─── Хелперы ────────────────────────────────────────────────────────── + + private static (CardsService Service, FakeKanjStore Store, FakeFileStorage Storage) Create() + { + var store = new FakeKanjStore(); + var storage = new FakeFileStorage(); + return (new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), storage), store, storage); + } + + // Карточка с полями по умолчанию (planned, CreatedAtMs=1, UpdatedAtMs=1; Files пуст). + private static CardDto Card(string id, long updatedAtMs = 1) + { + return new CardDto + { + Id = id, + Col = "planned", + CreatedAtMs = 1, + UpdatedAtMs = updatedAtMs, + }; + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs index 78845c0..fcda807 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs @@ -1,580 +1,580 @@ -using System.Text.Json; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты операций карточек пространства «Выбранные» — -/// -public sealed class CardsServiceSelectedTests -{ - - [Fact] - public async Task List_NoStage_ReturnsCardsOrderedByUpdatedAtDesc() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", updatedAtMs: 100)); - store.SeedCard(Card("c_2", updatedAtMs: 300)); - store.SeedCard(Card("c_3", stage: "work", updatedAtMs: 200)); - - IReadOnlyList cards = await service.ListSelectedCardsAsync(containerId: null, CancellationToken.None); - - Assert.Equal(new[] { "c_2", "c_3", "c_1" }, cards.Select(card => card.Id)); - } - - [Fact] - public async Task List_ByStage_ReturnsOnlyStageCards() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1")); - store.SeedCard(Card("c_2", stage: "work")); - - IReadOnlyList cards = await service.ListSelectedCardsAsync("work", CancellationToken.None); - - CardDto card = Assert.Single(cards); - Assert.Equal("c_2", card.Id); - } - - [Fact] - public async Task Get_Missing_ReturnsNull() - { - (CardsService service, _, _, _) = Create(); - - CardDto? card = await service.GetCardAsync("c_missing", CancellationToken.None); - - Assert.Null(card); - } - - - [Fact] - public async Task CreateLocal_WithStage_TrimsTitleAndWritesCreatedLocalHistory() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - - CardDto card = await service.CreateLocalCardAsync( - new CardLocalCreateDto(Title: " Задача на бота ", ContainerId: "work"), - CancellationToken.None); - - Assert.True(card.Local); - Assert.Equal("work", card.Col); - Assert.Equal("Задача на бота", card.Title); // title — Trim() (Ruling 6) - CardHistoryDto entry = Assert.Single(card.History); - Assert.StartsWith("h_", entry.Id); - Assert.Equal("createdLocal", entry.Type); - Assert.Null(entry.Stage); - Assert.True(entry.At > 0); - Assert.Single(store.CardDtos); - } - - [Fact] - public async Task CreateLocal_UnknownOrEmptyStage_FallsBackToPlanned() - { - (CardsService service, _, _, _) = Create(); - - CardDto unknown = await service.CreateLocalCardAsync( - new CardLocalCreateDto(Title: "Нет такой стадии", ContainerId: "stuck"), - CancellationToken.None); - CardDto empty = await service.CreateLocalCardAsync( - new CardLocalCreateDto(Title: "Стадии нет"), - CancellationToken.None); - - Assert.Equal("planned", unknown.Col); - Assert.Equal("planned", empty.Col); - Assert.True(unknown.Local); - } - - [Fact] - public async Task CreateLocal_EmptyTitle_Allowed() - { - (CardsService service, _, _, _) = Create(); - - CardDto card = await service.CreateLocalCardAsync(new CardLocalCreateDto(), CancellationToken.None); - - Assert.Equal(string.Empty, card.Title); // фронт шлёт {title:''} — 1:1 прототип (Ruling 6) - Assert.Equal("planned", card.Col); - } - - - [Fact] - public async Task TakeCard_CardMissing_ReturnsNullAndCreatesNothing() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - - CardDto? card = await service.TakeCardAsync("c_missing", CancellationToken.None); - - Assert.Null(card); - Assert.Empty(store.CardDtos); - } - - [Fact] - public async Task TakeCard_MovesCardToPlannedKeepingFields() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card( - "c_1", - stage: "inbox", - title: "Middle Python на бота", - summary: "Компания: X\nФормат: проект", - contact: "@user", - stack: new[] { "Python", "aiogram" }, - budget: new CardBudgetDto(From: 1600, To: 2200, Cur: "USD"))); - - CardDto card = await service.TakeCardAsync("c_1", CancellationToken.None) - ?? throw new InvalidOperationException("take вернул null при существующей карточке"); - - Assert.Equal("c_1", card.Id); - Assert.Equal("planned", card.Col); - Assert.False(card.Local); - Assert.Equal("Middle Python на бота", card.Title); - Assert.Equal("Компания: X\nФормат: проект", card.Summary); - Assert.Equal("@user", card.Contact); - Assert.Equal(new[] { "Python", "aiogram" }, card.Stack); - Assert.Equal(new CardBudgetDto(1600, 2200, "USD"), card.Budget); - // Комментарий-«взял в работу». - CardCommentDto comment = Assert.Single(card.Comments); - Assert.StartsWith("cm_", comment.Id); - Assert.Equal("Вы", comment.By); - Assert.Equal("Взял в работу.", comment.Text); - // История — запись переноса в planned. - CardHistoryDto history = Assert.Single(card.History); - Assert.StartsWith("h_", history.Id); - Assert.Null(history.Type); - Assert.Equal("planned", history.Stage); - Assert.Single(store.CardDtos); - } - - [Fact] - public async Task TakeCard_AlreadyInStage_ReturnsCardUnchanged() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", stage: "work", title: "Уже взята")); - - CardDto? card = await service.TakeCardAsync("c_1", CancellationToken.None); - - // Идемпотентность: карточка уже в стадии — возврат без изменений и без второго комментария. - Assert.NotNull(card); - Assert.Equal("c_1", card!.Id); - Assert.Equal("work", card.Col); - Assert.Empty(card.Comments); - Assert.Single(store.CardDtos); - } - - - [Fact] - public async Task Patch_PresentKeys_UpdateFieldsAndBumpUpdatedAt() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card( - "c_1", - title: "Старый заголовок", - summary: "Старое описание", - contact: "@old", - updatedAtMs: 1)); - - CardDto card = await service.PatchCardAsync( - "c_1", - PatchBody( - ("title", "Новый заголовок"), - ("summary", string.Empty), // присутствующий ключ со "" — очистка текста - ("tzText", "ТЗ"), - ("stack", new[] { "C#", ".NET" }), // стек — полная замена массива - ("budget", new { from = 500, cur = "EUR" })), - CancellationToken.None) - ?? throw new InvalidOperationException("patch вернул null при существующей карточке"); - - Assert.Equal("Новый заголовок", card.Title); - Assert.Equal(string.Empty, card.Summary); // summary очищена пустой строкой - Assert.Equal("@old", card.Contact); // ключ contact отсутствует — поле не меняется - Assert.Equal("ТЗ", card.TzText); - Assert.Equal(new[] { "C#", ".NET" }, card.Stack); - Assert.Equal(new CardBudgetDto(500, null, "EUR"), card.Budget); // бюджет из from/to/cur - Assert.True(card.UpdatedAtMs > 1); // bump UpdatedAt - } - - [Fact] - public async Task Patch_BudgetNull_ClearsBudget() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "С бюджетом", updatedAtMs: 1) - with { Budget = new CardBudgetDto(1000, 2000, "USD") }); - - CardDto? card = await service.PatchCardAsync("c_1", PatchBody(("budget", null)), CancellationToken.None); - - Assert.NotNull(card); - Assert.Null(card!.Budget); - CardDto stored = Assert.Single(store.CardDtos); - Assert.Null(stored.Budget); - } - - [Fact] - public async Task Patch_StackNull_ClearsStack() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "С стеком", updatedAtMs: 1) - with { Stack = new[] { "Python" } }); - - CardDto? card = await service.PatchCardAsync("c_1", PatchBody(("stack", null)), CancellationToken.None); - - Assert.NotNull(card); - Assert.Empty(card!.Stack); - } - - [Fact] - public async Task Patch_UnknownKeys_AreIgnored() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Старый")); - - CardDto? card = await service.PatchCardAsync( - "c_1", - PatchBody(("stage", "finished"), ("title", "Новый")), - CancellationToken.None); - - Assert.NotNull(card); - Assert.Equal("Новый", card!.Title); // известный ключ применён - Assert.Equal("planned", card.Col); // неизвестный ключ (stage) отброшен, как pydantic PatchBody - } - - [Fact] - public async Task Patch_NullTextKey_IsIgnored() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Старый", summary: "Описание", updatedAtMs: 1)); - - CardDto? card = await service.PatchCardAsync( - "c_1", - PatchBody(("title", null), ("summary", "Новое описание")), - CancellationToken.None); - - Assert.NotNull(card); - Assert.Equal("Старый", card!.Title); - Assert.Equal("Новое описание", card.Summary); - } - - [Fact] - public async Task Patch_CardMissing_ReturnsNull() - { - (CardsService service, _, _, _) = Create(); - - CardDto? card = await service.PatchCardAsync( - "c_missing", - PatchBody(("title", "Т")), - CancellationToken.None); - - Assert.Null(card); - } - - - [Fact] - public async Task Move_TwoMoves_AppendHistoryResetReminderAndBumpUpdatedAt() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - long seededAt = 5_000; - CardDto before = Card("c_1", stage: "hold", title: "Отложенная", updatedAtMs: seededAt) - with - { - Reminder = new CardReminderDto(seededAt + 1_000), - History = new[] { new CardHistoryDto("h_seed", seededAt, "created", null) }, - }; - store.SeedCard(before); - - CardResultDto first = await service.MoveStageCardAsync("c_1", "work", CancellationToken.None); - CardResultDto second = await service.MoveStageCardAsync("c_1", "review", CancellationToken.None); - CardDto? card = second.Card; - - Assert.Null(first.Error); - Assert.Null(second.Error); - Assert.NotNull(card); - Assert.Equal("review", card!.Col); - Assert.Null(card.Reminder); // любой move сбрасывает напоминание (Ruling 3) - - Assert.Equal(3, card.History.Count); - Assert.Equal( - new[] { "created", "work", "review" }, - card.History.Select(entry => entry.Stage ?? entry.Type ?? string.Empty)); - Assert.All(card.History, entry => Assert.StartsWith("h_", entry.Id)); - Assert.Equal(card.UpdatedAtMs, card.History[^1].At); // updated_at = время последнего переноса - Assert.True(card.UpdatedAtMs >= seededAt); - Assert.Equal(before.Id, card.Id); - - CardDto stored = Assert.Single(store.CardDtos); - Assert.Equal("review", stored.Col); - Assert.Null(stored.Reminder); - Assert.Equal(3, stored.History.Count); // в хранилище та же накопленная история - } - - [Fact] - public async Task Move_UnknownStage_Returns400ErrorAndLeavesCardUntouched() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", stage: "planned")); - - CardResultDto result = await service.MoveStageCardAsync("c_1", "stuck", CancellationToken.None); - - Assert.Equal(CardsService.UnknownStageDetail, result.Error); - Assert.Null(result.Card); - CardDto stored = Assert.Single(store.CardDtos); - Assert.Equal("planned", stored.Col); - Assert.Empty(stored.History); - } - - [Fact] - public async Task Move_CardMissing_ReturnsNullCard() - { - (CardsService service, _, _, _) = Create(); - - CardResultDto result = await service.MoveStageCardAsync("c_missing", "work", CancellationToken.None); - - Assert.Null(result.Error); - Assert.Null(result.Card); // 404 «Карточка не найдена» — текст у эндпоинта - } - - - [Fact] - public async Task ClearRejected_RemovesOnlyRejectedAndReturnsCount() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", stage: "rejected")); - store.SeedCard(Card("c_2", stage: "rejected")); - store.SeedCard(Card("c_3", stage: "finished")); - store.SeedCard(Card("c_4", stage: "planned")); - - int cleared = await service.ClearRejectedAsync(CancellationToken.None); - - Assert.Equal(2, cleared); - Assert.Equal(new[] { "c_3", "c_4" }, store.CardDtos.Select(card => card.Id).OrderBy(id => id)); - } - - [Fact] - public async Task ClearRejected_EmptyStage_ReturnsZero() - { - (CardsService service, _, _, _) = Create(); - - int cleared = await service.ClearRejectedAsync(CancellationToken.None); - - Assert.Equal(0, cleared); - } - - - [Fact] - public async Task AddComment_Valid_AppendsTrimmedCommentWithWireForm() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1)); - - AddCommentResultDto result = await service.AddCommentAsync("c_1", " Перезвонить завтра ", CancellationToken.None); - - Assert.Null(result.Error); - CardCommentDto comment = Assert.Single(result.Comments!); - Assert.StartsWith("cm_", comment.Id); // id комментария — общий префикс карточки - Assert.Equal("Вы", comment.By); - Assert.Equal("Перезвонить завтра", comment.Text); // текст — после Trim - Assert.Equal("только что", comment.Time); - CardDto stored = Assert.Single(store.CardDtos); - Assert.Equal(comment, Assert.Single(stored.Comments)); // комментарий сохранён на карточке - } - - [Fact] - public async Task AddComment_TwoComments_AppendsPreservingOrder() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Карточка") - with { Comments = new[] { new CardCommentDto("cm_1", "Вы", "Первый", "5 мин") } }); - - AddCommentResultDto result = await service.AddCommentAsync("c_1", "Второй", CancellationToken.None); - - Assert.Null(result.Error); - Assert.NotNull(result.Comments); - Assert.Equal(new[] { "Первый", "Второй" }, result.Comments!.Select(comment => comment.Text)); - Assert.Equal(2, Assert.Single(store.CardDtos).Comments.Count); // в хранилище тот же накопленный список - } - - [Fact] - public async Task AddComment_EmptyOrWhitespaceText_Returns400DetailAndWritesNothing() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Карточка")); - - AddCommentResultDto empty = await service.AddCommentAsync("c_1", string.Empty, CancellationToken.None); - AddCommentResultDto whitespace = await service.AddCommentAsync("c_1", " ", CancellationToken.None); - - Assert.Equal(CardsService.EmptyCommentDetail, empty.Error); // «Пустой комментарий» - Assert.Equal(CardsService.EmptyCommentDetail, whitespace.Error); - Assert.Null(empty.Comments); - Assert.Empty(Assert.Single(store.CardDtos).Comments); // комментарий не записан - } - - [Fact] - public async Task AddComment_CardMissing_ReturnsNullComments() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - - AddCommentResultDto result = await service.AddCommentAsync("c_missing", "Текст", CancellationToken.None); - - Assert.Null(result.Error); - Assert.Null(result.Comments); // эндпоинт отвечает 404 «Карточка не найдена» - Assert.Empty(store.CardDtos); - } - - - [Fact] - public async Task AddLink_NoScheme_PrefixesHttpsAndDefaultsNameToUrl() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1) - with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://example.com") } }); - - CardResultDto result = await service.AddLinkAsync("c_1", string.Empty, " example.com ", CancellationToken.None); - - Assert.Null(result.Error); - Assert.NotNull(result.Card); - Assert.Equal(2, result.Card!.Links.Count); // ссылка дописана в конец существующих - CardLinkDto link = result.Card.Links[^1]; - Assert.StartsWith("pl_", link.Id); - Assert.Equal("https://example.com", link.Url); // без схемы → префикс https:// - Assert.Equal("https://example.com", link.Name); // пустое имя → name = url - Assert.Equal("Сайт", result.Card.Links[0].Name); // прежняя ссылка на месте, порядок сохранён - Assert.True(Assert.Single(store.CardDtos).UpdatedAtMs > 1); // append бампает UpdatedAt - } - - [Fact] - public async Task AddLink_HasHttpScheme_KeepsSchemeAndUsesTrimmedName() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Карточка")); - - CardResultDto result = await service.AddLinkAsync("c_1", " Сайт ", "http://site.ru/abc", CancellationToken.None); - - Assert.Null(result.Error); - CardLinkDto link = Assert.Single(result.Card!.Links); - Assert.Equal("http://site.ru/abc", link.Url); // схема http сохраняется - Assert.Equal("Сайт", link.Name); // name — после Trim - } - - [Fact] - public async Task AddLink_EmptyUrl_Returns400DetailAndWritesNothing() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Карточка")); - - CardResultDto result = await service.AddLinkAsync("c_1", "Имя", " ", CancellationToken.None); - - Assert.Equal(CardsService.EmptyLinkDetail, result.Error); // «Пустая ссылка» - Assert.Null(result.Card); - Assert.Empty(Assert.Single(store.CardDtos).Links); // ссылка не записана - } - - [Fact] - public async Task AddLink_CardMissing_ReturnsNullCardBeforeUrlValidation() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - - CardResultDto result = await service.AddLinkAsync("c_missing", string.Empty, "example.com", CancellationToken.None); - - Assert.Null(result.Error); - Assert.Null(result.Card); // 404-семантика: карточки нет раньше валидации url - Assert.Empty(store.CardDtos); - } - - [Fact] - public async Task RemoveLink_ById_RemovesOnlyTargetAndReturnsCard() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1) - with - { - Links = new[] - { - new CardLinkDto("pl_1", "Старая", "https://a.b"), - new CardLinkDto("pl_2", "Новая", "https://c.d"), - }, - }); - - CardResultDto result = await service.RemoveLinkAsync("c_1", "pl_1", CancellationToken.None); - - Assert.Null(result.Error); - CardLinkDto link = Assert.Single(result.Card!.Links); - Assert.Equal("pl_2", link.Id); // удалена только указанная ссылка - CardDto stored = Assert.Single(store.CardDtos); - Assert.Equal("pl_2", Assert.Single(stored.Links).Id); - Assert.True(stored.UpdatedAtMs > 1); // удаление бампает UpdatedAt - } - - [Fact] - public async Task RemoveLink_UnknownLinkId_LeavesLinksUnchangedWithoutError() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Карточка") - with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://a.b") } }); - - CardResultDto result = await service.RemoveLinkAsync("c_1", "pl_ghost", CancellationToken.None); - - Assert.Null(result.Error); - Assert.Equal("pl_1", Assert.Single(result.Card!.Links).Id); // нет ошибки — ссылка просто не найдена - } - - [Fact] - public async Task RemoveLink_CardMissing_ReturnsNullCard() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - - CardResultDto result = await service.RemoveLinkAsync("c_missing", "pl_1", CancellationToken.None); - - Assert.Null(result.Error); - Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена» - Assert.Empty(store.CardDtos); - } - - // ─── Хелперы ────────────────────────────────────────────────────────── - - private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, FakeMlClient Ml) Create() - { - var store = new FakeKanjStore(); - var settings = new FakeSettingsStore(); - var ml = new FakeMlClient(); - return (new CardsService(store, settings, ml, new FakeFileStorage()), store, settings, ml); - } - - // Тело PATCH из пар «ключ → значение» (presence = наличие пары; null — явный JSON-null). - private static Dictionary PatchBody(params (string Key, object? Value)[] fields) - { - var body = new Dictionary(StringComparer.Ordinal); - foreach ((string key, object? value) in fields) - { - body[key] = JsonSerializer.SerializeToElement(value); - } - - return body; - } - - // Карточка с полями по умолчанию (planned, local=false, CreatedAtMs=1, UpdatedAtMs=1). - private static CardDto Card( - string id, - string stage = "planned", - string title = "", - string summary = "", - string contact = "", - bool local = false, - long updatedAtMs = 1, - IReadOnlyList? stack = null, - CardBudgetDto? budget = null) - { - return new CardDto - { - Id = id, - Col = stage, - Title = title, - Summary = summary, - Contact = contact, - Local = local, - Stack = stack ?? Array.Empty(), - Budget = budget, - CreatedAtMs = 1, - UpdatedAtMs = updatedAtMs, - }; - } -} +using System.Text.Json; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Settings; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты операций карточек пространства «Выбранные» — +/// +public sealed class CardsServiceSelectedTests +{ + + [Fact] + public async Task List_NoStage_ReturnsCardsOrderedByUpdatedAtDesc() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", updatedAtMs: 100)); + store.SeedCard(Card("c_2", updatedAtMs: 300)); + store.SeedCard(Card("c_3", stage: "work", updatedAtMs: 200)); + + IReadOnlyList cards = await service.ListSelectedCardsAsync(containerId: null, CancellationToken.None); + + Assert.Equal(new[] { "c_2", "c_3", "c_1" }, cards.Select(card => card.Id)); + } + + [Fact] + public async Task List_ByStage_ReturnsOnlyStageCards() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1")); + store.SeedCard(Card("c_2", stage: "work")); + + IReadOnlyList cards = await service.ListSelectedCardsAsync("work", CancellationToken.None); + + CardDto card = Assert.Single(cards); + Assert.Equal("c_2", card.Id); + } + + [Fact] + public async Task Get_Missing_ReturnsNull() + { + (CardsService service, _, _, _) = Create(); + + CardDto? card = await service.GetCardAsync("c_missing", CancellationToken.None); + + Assert.Null(card); + } + + + [Fact] + public async Task CreateLocal_WithStage_TrimsTitleAndWritesCreatedLocalHistory() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + + CardDto card = await service.CreateLocalCardAsync( + new CardLocalCreateDto(Title: " Задача на бота ", ContainerId: "work"), + CancellationToken.None); + + Assert.True(card.Local); + Assert.Equal("work", card.Col); + Assert.Equal("Задача на бота", card.Title); // title — Trim() (Ruling 6) + CardHistoryDto entry = Assert.Single(card.History); + Assert.StartsWith("h_", entry.Id); + Assert.Equal("createdLocal", entry.Type); + Assert.Null(entry.Stage); + Assert.True(entry.At > 0); + Assert.Single(store.CardDtos); + } + + [Fact] + public async Task CreateLocal_UnknownOrEmptyStage_FallsBackToPlanned() + { + (CardsService service, _, _, _) = Create(); + + CardDto unknown = await service.CreateLocalCardAsync( + new CardLocalCreateDto(Title: "Нет такой стадии", ContainerId: "stuck"), + CancellationToken.None); + CardDto empty = await service.CreateLocalCardAsync( + new CardLocalCreateDto(Title: "Стадии нет"), + CancellationToken.None); + + Assert.Equal("planned", unknown.Col); + Assert.Equal("planned", empty.Col); + Assert.True(unknown.Local); + } + + [Fact] + public async Task CreateLocal_EmptyTitle_Allowed() + { + (CardsService service, _, _, _) = Create(); + + CardDto card = await service.CreateLocalCardAsync(new CardLocalCreateDto(), CancellationToken.None); + + Assert.Equal(string.Empty, card.Title); // фронт шлёт {title:''} — 1:1 прототип (Ruling 6) + Assert.Equal("planned", card.Col); + } + + + [Fact] + public async Task TakeCard_CardMissing_ReturnsNullAndCreatesNothing() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + + CardDto? card = await service.TakeCardAsync("c_missing", CancellationToken.None); + + Assert.Null(card); + Assert.Empty(store.CardDtos); + } + + [Fact] + public async Task TakeCard_MovesCardToPlannedKeepingFields() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card( + "c_1", + stage: "inbox", + title: "Middle Python на бота", + summary: "Компания: X\nФормат: проект", + contact: "@user", + stack: new[] { "Python", "aiogram" }, + budget: new CardBudgetDto(From: 1600, To: 2200, Cur: "USD"))); + + CardDto card = await service.TakeCardAsync("c_1", CancellationToken.None) + ?? throw new InvalidOperationException("take вернул null при существующей карточке"); + + Assert.Equal("c_1", card.Id); + Assert.Equal("planned", card.Col); + Assert.False(card.Local); + Assert.Equal("Middle Python на бота", card.Title); + Assert.Equal("Компания: X\nФормат: проект", card.Summary); + Assert.Equal("@user", card.Contact); + Assert.Equal(new[] { "Python", "aiogram" }, card.Stack); + Assert.Equal(new CardBudgetDto(1600, 2200, "USD"), card.Budget); + // Комментарий-«взял в работу». + CardCommentDto comment = Assert.Single(card.Comments); + Assert.StartsWith("cm_", comment.Id); + Assert.Equal("Вы", comment.By); + Assert.Equal("Взял в работу.", comment.Text); + // История — запись переноса в planned. + CardHistoryDto history = Assert.Single(card.History); + Assert.StartsWith("h_", history.Id); + Assert.Null(history.Type); + Assert.Equal("planned", history.Stage); + Assert.Single(store.CardDtos); + } + + [Fact] + public async Task TakeCard_AlreadyInStage_ReturnsCardUnchanged() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", stage: "work", title: "Уже взята")); + + CardDto? card = await service.TakeCardAsync("c_1", CancellationToken.None); + + // Идемпотентность: карточка уже в стадии — возврат без изменений и без второго комментария. + Assert.NotNull(card); + Assert.Equal("c_1", card!.Id); + Assert.Equal("work", card.Col); + Assert.Empty(card.Comments); + Assert.Single(store.CardDtos); + } + + + [Fact] + public async Task Patch_PresentKeys_UpdateFieldsAndBumpUpdatedAt() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card( + "c_1", + title: "Старый заголовок", + summary: "Старое описание", + contact: "@old", + updatedAtMs: 1)); + + CardDto card = await service.PatchCardAsync( + "c_1", + PatchBody( + ("title", "Новый заголовок"), + ("summary", string.Empty), // присутствующий ключ со "" — очистка текста + ("tzText", "ТЗ"), + ("stack", new[] { "C#", ".NET" }), // стек — полная замена массива + ("budget", new { from = 500, cur = "EUR" })), + CancellationToken.None) + ?? throw new InvalidOperationException("patch вернул null при существующей карточке"); + + Assert.Equal("Новый заголовок", card.Title); + Assert.Equal(string.Empty, card.Summary); // summary очищена пустой строкой + Assert.Equal("@old", card.Contact); // ключ contact отсутствует — поле не меняется + Assert.Equal("ТЗ", card.TzText); + Assert.Equal(new[] { "C#", ".NET" }, card.Stack); + Assert.Equal(new CardBudgetDto(500, null, "EUR"), card.Budget); // бюджет из from/to/cur + Assert.True(card.UpdatedAtMs > 1); // bump UpdatedAt + } + + [Fact] + public async Task Patch_BudgetNull_ClearsBudget() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "С бюджетом", updatedAtMs: 1) + with { Budget = new CardBudgetDto(1000, 2000, "USD") }); + + CardDto? card = await service.PatchCardAsync("c_1", PatchBody(("budget", null)), CancellationToken.None); + + Assert.NotNull(card); + Assert.Null(card!.Budget); + CardDto stored = Assert.Single(store.CardDtos); + Assert.Null(stored.Budget); + } + + [Fact] + public async Task Patch_StackNull_ClearsStack() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "С стеком", updatedAtMs: 1) + with { Stack = new[] { "Python" } }); + + CardDto? card = await service.PatchCardAsync("c_1", PatchBody(("stack", null)), CancellationToken.None); + + Assert.NotNull(card); + Assert.Empty(card!.Stack); + } + + [Fact] + public async Task Patch_UnknownKeys_AreIgnored() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Старый")); + + CardDto? card = await service.PatchCardAsync( + "c_1", + PatchBody(("stage", "finished"), ("title", "Новый")), + CancellationToken.None); + + Assert.NotNull(card); + Assert.Equal("Новый", card!.Title); // известный ключ применён + Assert.Equal("planned", card.Col); // неизвестный ключ (stage) отброшен, как pydantic PatchBody + } + + [Fact] + public async Task Patch_NullTextKey_IsIgnored() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Старый", summary: "Описание", updatedAtMs: 1)); + + CardDto? card = await service.PatchCardAsync( + "c_1", + PatchBody(("title", null), ("summary", "Новое описание")), + CancellationToken.None); + + Assert.NotNull(card); + Assert.Equal("Старый", card!.Title); + Assert.Equal("Новое описание", card.Summary); + } + + [Fact] + public async Task Patch_CardMissing_ReturnsNull() + { + (CardsService service, _, _, _) = Create(); + + CardDto? card = await service.PatchCardAsync( + "c_missing", + PatchBody(("title", "Т")), + CancellationToken.None); + + Assert.Null(card); + } + + + [Fact] + public async Task Move_TwoMoves_AppendHistoryResetReminderAndBumpUpdatedAt() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + long seededAt = 5_000; + CardDto before = Card("c_1", stage: "hold", title: "Отложенная", updatedAtMs: seededAt) + with + { + Reminder = new CardReminderDto(seededAt + 1_000), + History = new[] { new CardHistoryDto("h_seed", seededAt, "created", null) }, + }; + store.SeedCard(before); + + CardResultDto first = await service.MoveStageCardAsync("c_1", "work", CancellationToken.None); + CardResultDto second = await service.MoveStageCardAsync("c_1", "review", CancellationToken.None); + CardDto? card = second.Card; + + Assert.Null(first.Error); + Assert.Null(second.Error); + Assert.NotNull(card); + Assert.Equal("review", card!.Col); + Assert.Null(card.Reminder); // любой move сбрасывает напоминание (Ruling 3) + + Assert.Equal(3, card.History.Count); + Assert.Equal( + new[] { "created", "work", "review" }, + card.History.Select(entry => entry.Stage ?? entry.Type ?? string.Empty)); + Assert.All(card.History, entry => Assert.StartsWith("h_", entry.Id)); + Assert.Equal(card.UpdatedAtMs, card.History[^1].At); // updated_at = время последнего переноса + Assert.True(card.UpdatedAtMs >= seededAt); + Assert.Equal(before.Id, card.Id); + + CardDto stored = Assert.Single(store.CardDtos); + Assert.Equal("review", stored.Col); + Assert.Null(stored.Reminder); + Assert.Equal(3, stored.History.Count); // в хранилище та же накопленная история + } + + [Fact] + public async Task Move_UnknownStage_Returns400ErrorAndLeavesCardUntouched() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", stage: "planned")); + + CardResultDto result = await service.MoveStageCardAsync("c_1", "stuck", CancellationToken.None); + + Assert.Equal(CardsService.UnknownStageDetail, result.Error); + Assert.Null(result.Card); + CardDto stored = Assert.Single(store.CardDtos); + Assert.Equal("planned", stored.Col); + Assert.Empty(stored.History); + } + + [Fact] + public async Task Move_CardMissing_ReturnsNullCard() + { + (CardsService service, _, _, _) = Create(); + + CardResultDto result = await service.MoveStageCardAsync("c_missing", "work", CancellationToken.None); + + Assert.Null(result.Error); + Assert.Null(result.Card); // 404 «Карточка не найдена» — текст у эндпоинта + } + + + [Fact] + public async Task ClearRejected_RemovesOnlyRejectedAndReturnsCount() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", stage: "rejected")); + store.SeedCard(Card("c_2", stage: "rejected")); + store.SeedCard(Card("c_3", stage: "finished")); + store.SeedCard(Card("c_4", stage: "planned")); + + int cleared = await service.ClearRejectedAsync(CancellationToken.None); + + Assert.Equal(2, cleared); + Assert.Equal(new[] { "c_3", "c_4" }, store.CardDtos.Select(card => card.Id).OrderBy(id => id)); + } + + [Fact] + public async Task ClearRejected_EmptyStage_ReturnsZero() + { + (CardsService service, _, _, _) = Create(); + + int cleared = await service.ClearRejectedAsync(CancellationToken.None); + + Assert.Equal(0, cleared); + } + + + [Fact] + public async Task AddComment_Valid_AppendsTrimmedCommentWithWireForm() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1)); + + AddCommentResultDto result = await service.AddCommentAsync("c_1", " Перезвонить завтра ", CancellationToken.None); + + Assert.Null(result.Error); + CardCommentDto comment = Assert.Single(result.Comments!); + Assert.StartsWith("cm_", comment.Id); // id комментария — общий префикс карточки + Assert.Equal("Вы", comment.By); + Assert.Equal("Перезвонить завтра", comment.Text); // текст — после Trim + Assert.Equal("только что", comment.Time); + CardDto stored = Assert.Single(store.CardDtos); + Assert.Equal(comment, Assert.Single(stored.Comments)); // комментарий сохранён на карточке + } + + [Fact] + public async Task AddComment_TwoComments_AppendsPreservingOrder() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Карточка") + with { Comments = new[] { new CardCommentDto("cm_1", "Вы", "Первый", "5 мин") } }); + + AddCommentResultDto result = await service.AddCommentAsync("c_1", "Второй", CancellationToken.None); + + Assert.Null(result.Error); + Assert.NotNull(result.Comments); + Assert.Equal(new[] { "Первый", "Второй" }, result.Comments!.Select(comment => comment.Text)); + Assert.Equal(2, Assert.Single(store.CardDtos).Comments.Count); // в хранилище тот же накопленный список + } + + [Fact] + public async Task AddComment_EmptyOrWhitespaceText_Returns400DetailAndWritesNothing() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Карточка")); + + AddCommentResultDto empty = await service.AddCommentAsync("c_1", string.Empty, CancellationToken.None); + AddCommentResultDto whitespace = await service.AddCommentAsync("c_1", " ", CancellationToken.None); + + Assert.Equal(CardsService.EmptyCommentDetail, empty.Error); // «Пустой комментарий» + Assert.Equal(CardsService.EmptyCommentDetail, whitespace.Error); + Assert.Null(empty.Comments); + Assert.Empty(Assert.Single(store.CardDtos).Comments); // комментарий не записан + } + + [Fact] + public async Task AddComment_CardMissing_ReturnsNullComments() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + + AddCommentResultDto result = await service.AddCommentAsync("c_missing", "Текст", CancellationToken.None); + + Assert.Null(result.Error); + Assert.Null(result.Comments); // эндпоинт отвечает 404 «Карточка не найдена» + Assert.Empty(store.CardDtos); + } + + + [Fact] + public async Task AddLink_NoScheme_PrefixesHttpsAndDefaultsNameToUrl() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1) + with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://example.com") } }); + + CardResultDto result = await service.AddLinkAsync("c_1", string.Empty, " example.com ", CancellationToken.None); + + Assert.Null(result.Error); + Assert.NotNull(result.Card); + Assert.Equal(2, result.Card!.Links.Count); // ссылка дописана в конец существующих + CardLinkDto link = result.Card.Links[^1]; + Assert.StartsWith("pl_", link.Id); + Assert.Equal("https://example.com", link.Url); // без схемы → префикс https:// + Assert.Equal("https://example.com", link.Name); // пустое имя → name = url + Assert.Equal("Сайт", result.Card.Links[0].Name); // прежняя ссылка на месте, порядок сохранён + Assert.True(Assert.Single(store.CardDtos).UpdatedAtMs > 1); // append бампает UpdatedAt + } + + [Fact] + public async Task AddLink_HasHttpScheme_KeepsSchemeAndUsesTrimmedName() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Карточка")); + + CardResultDto result = await service.AddLinkAsync("c_1", " Сайт ", "http://site.ru/abc", CancellationToken.None); + + Assert.Null(result.Error); + CardLinkDto link = Assert.Single(result.Card!.Links); + Assert.Equal("http://site.ru/abc", link.Url); // схема http сохраняется + Assert.Equal("Сайт", link.Name); // name — после Trim + } + + [Fact] + public async Task AddLink_EmptyUrl_Returns400DetailAndWritesNothing() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Карточка")); + + CardResultDto result = await service.AddLinkAsync("c_1", "Имя", " ", CancellationToken.None); + + Assert.Equal(CardsService.EmptyLinkDetail, result.Error); // «Пустая ссылка» + Assert.Null(result.Card); + Assert.Empty(Assert.Single(store.CardDtos).Links); // ссылка не записана + } + + [Fact] + public async Task AddLink_CardMissing_ReturnsNullCardBeforeUrlValidation() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + + CardResultDto result = await service.AddLinkAsync("c_missing", string.Empty, "example.com", CancellationToken.None); + + Assert.Null(result.Error); + Assert.Null(result.Card); // 404-семантика: карточки нет раньше валидации url + Assert.Empty(store.CardDtos); + } + + [Fact] + public async Task RemoveLink_ById_RemovesOnlyTargetAndReturnsCard() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1) + with + { + Links = new[] + { + new CardLinkDto("pl_1", "Старая", "https://a.b"), + new CardLinkDto("pl_2", "Новая", "https://c.d"), + }, + }); + + CardResultDto result = await service.RemoveLinkAsync("c_1", "pl_1", CancellationToken.None); + + Assert.Null(result.Error); + CardLinkDto link = Assert.Single(result.Card!.Links); + Assert.Equal("pl_2", link.Id); // удалена только указанная ссылка + CardDto stored = Assert.Single(store.CardDtos); + Assert.Equal("pl_2", Assert.Single(stored.Links).Id); + Assert.True(stored.UpdatedAtMs > 1); // удаление бампает UpdatedAt + } + + [Fact] + public async Task RemoveLink_UnknownLinkId_LeavesLinksUnchangedWithoutError() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Карточка") + with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://a.b") } }); + + CardResultDto result = await service.RemoveLinkAsync("c_1", "pl_ghost", CancellationToken.None); + + Assert.Null(result.Error); + Assert.Equal("pl_1", Assert.Single(result.Card!.Links).Id); // нет ошибки — ссылка просто не найдена + } + + [Fact] + public async Task RemoveLink_CardMissing_ReturnsNullCard() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + + CardResultDto result = await service.RemoveLinkAsync("c_missing", "pl_1", CancellationToken.None); + + Assert.Null(result.Error); + Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена» + Assert.Empty(store.CardDtos); + } + + // ─── Хелперы ────────────────────────────────────────────────────────── + + private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, FakeMlClient Ml) Create() + { + var store = new FakeKanjStore(); + var settings = new FakeSettingsStore(); + var ml = new FakeMlClient(); + return (new CardsService(store, settings, ml, new FakeFileStorage()), store, settings, ml); + } + + // Тело PATCH из пар «ключ → значение» (presence = наличие пары; null — явный JSON-null). + private static Dictionary PatchBody(params (string Key, object? Value)[] fields) + { + var body = new Dictionary(StringComparer.Ordinal); + foreach ((string key, object? value) in fields) + { + body[key] = JsonSerializer.SerializeToElement(value); + } + + return body; + } + + // Карточка с полями по умолчанию (planned, local=false, CreatedAtMs=1, UpdatedAtMs=1). + private static CardDto Card( + string id, + string stage = "planned", + string title = "", + string summary = "", + string contact = "", + bool local = false, + long updatedAtMs = 1, + IReadOnlyList? stack = null, + CardBudgetDto? budget = null) + { + return new CardDto + { + Id = id, + Col = stage, + Title = title, + Summary = summary, + Contact = contact, + Local = local, + Stack = stack ?? Array.Empty(), + Budget = budget, + CreatedAtMs = 1, + UpdatedAtMs = updatedAtMs, + }; + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/ColumnRulesNewGroupsTests.cs b/src/core/tests/Deal.Tests.Unit/Support/ColumnRulesNewGroupsTests.cs index 21f1374..d0f634f 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/ColumnRulesNewGroupsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/ColumnRulesNewGroupsTests.cs @@ -1,147 +1,147 @@ -using System.Text.Json; -using Deal.Modules.Kanban.Application.ColumnRules; -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты новых групп фильтров колонки -/// -public sealed class ColumnRulesNewGroupsTests -{ - // Правила только с новыми группами (остальные пусты). - private static ContainerRulesDto Rules( - string mode = "all", - string[]? levels = null, - string[]? locations = null, - string[]? types = null, - BudgetRangeDto? prices = null) - { - return new ContainerRulesDto( - mode, - Array.Empty(), - Array.Empty(), - Array.Empty(), - Array.Empty(), - Array.Empty(), - Budget: null, - Levels: levels ?? Array.Empty(), - Locations: locations ?? Array.Empty(), - Types: types ?? Array.Empty(), - Prices: prices); - } - - [Fact] - public void Matches_LevelsGroup_ExpandsAliases() - { - ContainerRulesDto rules = Rules(levels: new[] { "middle" }); - - Assert.True(ColumnRules.Matches(rules, "Нужен mid-разработчик")); - } - - [Fact] - public void Matches_LocationsGroup_MatchesSubstring() - { - ContainerRulesDto rules = Rules(locations: new[] { "москв" }); - - Assert.True(ColumnRules.Matches(rules, "Работа в Москве, офис")); - Assert.False(ColumnRules.Matches(rules, "Удалённая работа")); - } - - [Fact] - public void Matches_TypesGroup_MatchesVacancyBySynonym() - { - ContainerRulesDto rules = Rules(types: new[] { "vacancy" }); - - Assert.True(ColumnRules.Matches(rules, "Вакансия: python-разработчик")); - Assert.False(ColumnRules.Matches(rules, "Сделаю сайт на заказ")); - } - - [Fact] - public void Matches_PricesRange_ChecksAmount() - { - ContainerRulesDto rules = Rules(prices: new BudgetRangeDto(From: 50, To: 200, Cur: "USD")); - - Assert.True(ColumnRules.Matches(rules, "Бюджет 150$ за задачу")); - Assert.False(ColumnRules.Matches(rules, "Бюджет 500$ за задачу")); - } - - [Fact] - public void HasActiveRules_TrueForEachNewGroup() - { - Assert.True(ColumnRules.HasActiveRules(Rules(levels: new[] { "senior" }))); - Assert.True(ColumnRules.HasActiveRules(Rules(locations: new[] { "питер" }))); - Assert.True(ColumnRules.HasActiveRules(Rules(types: new[] { "freelance" }))); - Assert.True(ColumnRules.HasActiveRules(Rules(prices: new BudgetRangeDto(From: 10, To: null, Cur: "USD")))); - } - - [Fact] - public void ComputeHits_ReportsCriteriaForNewGroups() - { - ContainerRulesDto rules = Rules( - mode: "any", - levels: new[] { "senior" }, - locations: new[] { "москв" }, - types: new[] { "vacancy" }); - - IReadOnlyList hits = ColumnRules.ComputeHits(rules, "Вакансия senior в Москве"); - - Assert.Contains(hits, hit => hit.Label == "Уровень" && hit.Term == "senior"); - Assert.Contains(hits, hit => hit.Label == "Локация" && hit.Term == "москв"); - Assert.Contains(hits, hit => hit.Label == "Тип" && hit.Term == "vacancy"); - } - - [Fact] - public void Describe_IncludesNewGroups() - { - ContainerRulesDto rules = Rules(mode: "any", locations: new[] { "москв" }, types: new[] { "vacancy" }); - - string text = ColumnRules.Describe(rules); - - Assert.Contains("локация: москв", text); - Assert.Contains("тип: vacancy", text); - } - - [Fact] - public void Serialization_RoundTripsNewGroupsAsCamelCase() - { - var options = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - ContainerRulesDto rules = Rules( - locations: new[] { "москв" }, - types: new[] { "vacancy" }, - prices: new BudgetRangeDto(From: 100, To: 200, Cur: "USD")); - - string json = JsonSerializer.Serialize(rules, options); - Assert.Contains("\"locations\"", json); - Assert.Contains("\"types\"", json); - Assert.Contains("\"prices\"", json); - - ContainerRulesDto? restored = JsonSerializer.Deserialize(json, options); - Assert.NotNull(restored); - Assert.Equal(new[] { "москв" }, restored!.Locations); - Assert.Equal(new[] { "vacancy" }, restored.Types); - Assert.Equal(100, restored.Prices!.From); - } - - [Fact] - public void Deserialization_WithoutNewGroups_LeavesThemEmptyOrNull() - { - // Обратная совместимость: сохранённый RulesJson старого формата (без новых групп) разбирается. - const string json = """{"mode":"all","direction":[],"keywords":["python"],"stack":[],"grade":[],"exclude":[],"budget":null}"""; - - ContainerRulesDto? rules = JsonSerializer.Deserialize( - json, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - - Assert.NotNull(rules); - Assert.Null(rules!.Levels); - Assert.Null(rules.Locations); - Assert.Null(rules.Types); - Assert.Null(rules.Prices); - Assert.True(ColumnRules.Matches(rules, "Нужен python-разработчик")); - } -} +using System.Text.Json; +using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты новых групп фильтров колонки +/// +public sealed class ColumnRulesNewGroupsTests +{ + // Правила только с новыми группами (остальные пусты). + private static ContainerRulesDto Rules( + string mode = "all", + string[]? levels = null, + string[]? locations = null, + string[]? types = null, + BudgetRangeDto? prices = null) + { + return new ContainerRulesDto( + mode, + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + Budget: null, + Levels: levels ?? Array.Empty(), + Locations: locations ?? Array.Empty(), + Types: types ?? Array.Empty(), + Prices: prices); + } + + [Fact] + public void Matches_LevelsGroup_ExpandsAliases() + { + ContainerRulesDto rules = Rules(levels: new[] { "middle" }); + + Assert.True(ColumnRules.Matches(rules, "Нужен mid-разработчик")); + } + + [Fact] + public void Matches_LocationsGroup_MatchesSubstring() + { + ContainerRulesDto rules = Rules(locations: new[] { "москв" }); + + Assert.True(ColumnRules.Matches(rules, "Работа в Москве, офис")); + Assert.False(ColumnRules.Matches(rules, "Удалённая работа")); + } + + [Fact] + public void Matches_TypesGroup_MatchesVacancyBySynonym() + { + ContainerRulesDto rules = Rules(types: new[] { "vacancy" }); + + Assert.True(ColumnRules.Matches(rules, "Вакансия: python-разработчик")); + Assert.False(ColumnRules.Matches(rules, "Сделаю сайт на заказ")); + } + + [Fact] + public void Matches_PricesRange_ChecksAmount() + { + ContainerRulesDto rules = Rules(prices: new BudgetRangeDto(From: 50, To: 200, Cur: "USD")); + + Assert.True(ColumnRules.Matches(rules, "Бюджет 150$ за задачу")); + Assert.False(ColumnRules.Matches(rules, "Бюджет 500$ за задачу")); + } + + [Fact] + public void HasActiveRules_TrueForEachNewGroup() + { + Assert.True(ColumnRules.HasActiveRules(Rules(levels: new[] { "senior" }))); + Assert.True(ColumnRules.HasActiveRules(Rules(locations: new[] { "питер" }))); + Assert.True(ColumnRules.HasActiveRules(Rules(types: new[] { "freelance" }))); + Assert.True(ColumnRules.HasActiveRules(Rules(prices: new BudgetRangeDto(From: 10, To: null, Cur: "USD")))); + } + + [Fact] + public void ComputeHits_ReportsCriteriaForNewGroups() + { + ContainerRulesDto rules = Rules( + mode: "any", + levels: new[] { "senior" }, + locations: new[] { "москв" }, + types: new[] { "vacancy" }); + + IReadOnlyList hits = ColumnRules.ComputeHits(rules, "Вакансия senior в Москве"); + + Assert.Contains(hits, hit => hit.Label == "Уровень" && hit.Term == "senior"); + Assert.Contains(hits, hit => hit.Label == "Локация" && hit.Term == "москв"); + Assert.Contains(hits, hit => hit.Label == "Тип" && hit.Term == "vacancy"); + } + + [Fact] + public void Describe_IncludesNewGroups() + { + ContainerRulesDto rules = Rules(mode: "any", locations: new[] { "москв" }, types: new[] { "vacancy" }); + + string text = ColumnRules.Describe(rules); + + Assert.Contains("локация: москв", text); + Assert.Contains("тип: vacancy", text); + } + + [Fact] + public void Serialization_RoundTripsNewGroupsAsCamelCase() + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + ContainerRulesDto rules = Rules( + locations: new[] { "москв" }, + types: new[] { "vacancy" }, + prices: new BudgetRangeDto(From: 100, To: 200, Cur: "USD")); + + string json = JsonSerializer.Serialize(rules, options); + Assert.Contains("\"locations\"", json); + Assert.Contains("\"types\"", json); + Assert.Contains("\"prices\"", json); + + ContainerRulesDto? restored = JsonSerializer.Deserialize(json, options); + Assert.NotNull(restored); + Assert.Equal(new[] { "москв" }, restored!.Locations); + Assert.Equal(new[] { "vacancy" }, restored.Types); + Assert.Equal(100, restored.Prices!.From); + } + + [Fact] + public void Deserialization_WithoutNewGroups_LeavesThemEmptyOrNull() + { + // Обратная совместимость: сохранённый RulesJson старого формата (без новых групп) разбирается. + const string json = """{"mode":"all","direction":[],"keywords":["python"],"stack":[],"grade":[],"exclude":[],"budget":null}"""; + + ContainerRulesDto? rules = JsonSerializer.Deserialize( + json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + Assert.NotNull(rules); + Assert.Null(rules!.Levels); + Assert.Null(rules.Locations); + Assert.Null(rules.Types); + Assert.Null(rules.Prices); + Assert.True(ColumnRules.Matches(rules, "Нужен python-разработчик")); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/ForwardedHeadersHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/ForwardedHeadersHttpTests.cs index e75ff22..fb920fa 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/ForwardedHeadersHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/ForwardedHeadersHttpTests.cs @@ -1,206 +1,206 @@ -using System.Net; -using System.Text.Json; -using Deal.Api.Configuration; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.HttpOverrides; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты обработки прокси-заголовков -/// -public sealed class ForwardedHeadersHttpTests -{ - private const string ForwardedClientIp = "203.0.113.7"; - - // Путь read-only эндпоинта, возвращающего адрес/схему, которые видит приложение. - private const string ClientProbePath = "/api/client"; - - // ─── Unit: сборка опций из конфига ─────────────────────────────────────── - - /// - /// Из ForwardedHeadersConfig строятся ровно перечисленные KnownProxies/KnownNetworks - /// - [Fact] - public void BuildOptions_KnownProxiesAndNetworks_AreApplied() - { - var config = new ForwardedHeadersConfig - { - Enabled = true, - KnownProxies = ["192.0.2.10"], - KnownNetworks = ["172.16.0.0/12", "2001:db8::/32"], - }; - - ForwardedHeadersOptions options = Program.BuildForwardedHeadersOptions(config); - - Assert.Equal(ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, options.ForwardedHeaders); - Assert.Equal(1, options.ForwardLimit); - Assert.Equal([IPAddress.Parse("192.0.2.10")], options.KnownProxies); - Assert.Equal(2, options.KnownIPNetworks.Count); - Assert.Equal(IPAddress.Parse("172.16.0.0"), options.KnownIPNetworks[0].BaseAddress); - Assert.Equal(12, options.KnownIPNetworks[0].PrefixLength); - Assert.Equal(IPAddress.Parse("2001:db8::"), options.KnownIPNetworks[1].BaseAddress); - Assert.Equal(32, options.KnownIPNetworks[1].PrefixLength); - } - - /// - /// Пустые списки конфига не передаются middleware как пустые - /// - [Fact] - public void BuildOptions_EmptyTrustLists_FallBackToLoopback() - { - ForwardedHeadersOptions options = Program.BuildForwardedHeadersOptions(new ForwardedHeadersConfig()); - - Assert.Equal([IPAddress.Loopback, IPAddress.IPv6Loopback], options.KnownProxies); - Assert.Empty(options.KnownIPNetworks); - } - - /// - /// Невалидный IP в KnownProxies — ошибка запуска - /// - [Fact] - public void BuildOptions_InvalidProxyIp_Throws() - { - var config = new ForwardedHeadersConfig { KnownProxies = ["not-an-ip"] }; - - InvalidOperationException exception = Assert.Throws( - () => Program.BuildForwardedHeadersOptions(config)); - Assert.Contains("not-an-ip", exception.Message); - } - - /// - /// Невалидный CIDR в KnownNetworks - /// - [Fact] - public void BuildOptions_InvalidKnownNetwork_Throws() - { - Assert.Throws(() => - Program.BuildForwardedHeadersOptions(new ForwardedHeadersConfig { KnownNetworks = ["garbage"] })); - Assert.Throws(() => - Program.BuildForwardedHeadersOptions(new ForwardedHeadersConfig { KnownNetworks = ["10.0.0.0/33"] })); - } - - // ─── HTTP: доверенный прокси применяет заголовки ───────────────────────── - - /// - /// Клиент из KnownProxies - /// - [Fact] - public async Task KnownProxy_XForwardedForAndProto_AreApplied() - { - var config = new ForwardedHeadersConfig { KnownProxies = ["127.0.0.1"] }; - - await RunAsync(config, async (baseAddress, client) => - { - JsonElement response = await ProbeAsync(client, baseAddress, forwarded: true); - Assert.Equal(ForwardedClientIp, response.GetProperty("ip").GetString()); - Assert.Equal("https", response.GetProperty("scheme").GetString()); - }); - } - - /// - /// Доверие подсетью - /// - [Fact] - public async Task KnownNetwork_CoversProxy_ForwardedHeadersApplied() - { - var config = new ForwardedHeadersConfig { KnownNetworks = ["127.0.0.0/8"] }; - - await RunAsync(config, async (baseAddress, client) => - { - JsonElement response = await ProbeAsync(client, baseAddress, forwarded: true); - Assert.Equal(ForwardedClientIp, response.GetProperty("ip").GetString()); - Assert.Equal("https", response.GetProperty("scheme").GetString()); - }); - } - - /// - /// Клиент вне KnownProxies/KnownNetworks - /// - [Fact] - public async Task UnknownClient_ForwardedHeadersAreIgnored() - { - var config = new ForwardedHeadersConfig { KnownProxies = ["192.0.2.10"] }; - - await RunAsync(config, async (baseAddress, client) => - { - JsonElement response = await ProbeAsync(client, baseAddress, forwarded: true); - Assert.Equal(IPAddress.Loopback.ToString(), response.GetProperty("ip").GetString()); - Assert.Equal("http", response.GetProperty("scheme").GetString()); - }); - } - - // ─── Хост сценария ───────────────────────────────────────────────────── - - // Поднимает in-process Kestrel с UseForwardedHeaders(опции из конфига) и read-only - // эндпоинтом адреса/схемы, затем прогоняет сценарий. - // config: Секция ForwardedHeaders сценария (как из appsettings/env). - // scenario: Сценарий с base-адресом и клиентом. - private static async Task RunAsync(ForwardedHeadersConfig config, Func scenario) - { - int port = TestPort.Allocate(); - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, port)); - - WebApplication app = builder.Build(); - // Первый middleware конвейера — как в Program.cs (до слоёв, читающих RemoteIpAddress/Scheme). - app.UseForwardedHeaders(Program.BuildForwardedHeadersOptions(config)); - app.MapGet(ClientProbePath, (HttpContext context) => Results.Ok(new - { - ip = context.Connection.RemoteIpAddress?.ToString(), - scheme = context.Request.Scheme, - })); - - await app.StartAsync(); - try - { - string baseAddress = $"http://127.0.0.1:{port}"; - await scenario(baseAddress, CreateClient(baseAddress)); - } - finally - { - await app.StopAsync(); - await app.DisposeAsync(); - } - } - - // GET /api/client с X-Forwarded-For/X-Forwarded-Proto (как их ставит Caddy за TLS). - // client: HTTP-клиент сценария. - // baseAddress: Базовый адрес хоста. - // forwarded: Шлются ли прокси-заголовки. - // Возвращает: Тело ответа {ip, scheme}. - private static async Task ProbeAsync( - HttpClient client, - string baseAddress, - bool forwarded) - { - using var request = new HttpRequestMessage(HttpMethod.Get, baseAddress + ClientProbePath); - if (forwarded) - { - request.Headers.TryAddWithoutValidation("X-Forwarded-For", ForwardedClientIp); - request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", "https"); - } - - using HttpResponseMessage response = await client.SendAsync(request); - response.EnsureSuccessStatusCode(); - return await ReadJsonAsync(response); - } - - - // HTTP-клиент сценария. - private static HttpClient CreateClient(string baseAddress) => - new() - { - BaseAddress = new Uri(baseAddress), - }; - - // Читает тело ответа как JSON-документ (корень, отвязанный от документа). - private static async Task ReadJsonAsync(HttpResponseMessage response) - { - string body = await response.Content.ReadAsStringAsync(); - using var document = JsonDocument.Parse(body); - return document.RootElement.Clone(); - } -} +using System.Net; +using System.Text.Json; +using Deal.Api.Configuration; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты обработки прокси-заголовков +/// +public sealed class ForwardedHeadersHttpTests +{ + private const string ForwardedClientIp = "203.0.113.7"; + + // Путь read-only эндпоинта, возвращающего адрес/схему, которые видит приложение. + private const string ClientProbePath = "/api/client"; + + // ─── Unit: сборка опций из конфига ─────────────────────────────────────── + + /// + /// Из ForwardedHeadersConfig строятся ровно перечисленные KnownProxies/KnownNetworks + /// + [Fact] + public void BuildOptions_KnownProxiesAndNetworks_AreApplied() + { + var config = new ForwardedHeadersConfig + { + Enabled = true, + KnownProxies = ["192.0.2.10"], + KnownNetworks = ["172.16.0.0/12", "2001:db8::/32"], + }; + + ForwardedHeadersOptions options = Program.BuildForwardedHeadersOptions(config); + + Assert.Equal(ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, options.ForwardedHeaders); + Assert.Equal(1, options.ForwardLimit); + Assert.Equal([IPAddress.Parse("192.0.2.10")], options.KnownProxies); + Assert.Equal(2, options.KnownIPNetworks.Count); + Assert.Equal(IPAddress.Parse("172.16.0.0"), options.KnownIPNetworks[0].BaseAddress); + Assert.Equal(12, options.KnownIPNetworks[0].PrefixLength); + Assert.Equal(IPAddress.Parse("2001:db8::"), options.KnownIPNetworks[1].BaseAddress); + Assert.Equal(32, options.KnownIPNetworks[1].PrefixLength); + } + + /// + /// Пустые списки конфига не передаются middleware как пустые + /// + [Fact] + public void BuildOptions_EmptyTrustLists_FallBackToLoopback() + { + ForwardedHeadersOptions options = Program.BuildForwardedHeadersOptions(new ForwardedHeadersConfig()); + + Assert.Equal([IPAddress.Loopback, IPAddress.IPv6Loopback], options.KnownProxies); + Assert.Empty(options.KnownIPNetworks); + } + + /// + /// Невалидный IP в KnownProxies — ошибка запуска + /// + [Fact] + public void BuildOptions_InvalidProxyIp_Throws() + { + var config = new ForwardedHeadersConfig { KnownProxies = ["not-an-ip"] }; + + InvalidOperationException exception = Assert.Throws( + () => Program.BuildForwardedHeadersOptions(config)); + Assert.Contains("not-an-ip", exception.Message); + } + + /// + /// Невалидный CIDR в KnownNetworks + /// + [Fact] + public void BuildOptions_InvalidKnownNetwork_Throws() + { + Assert.Throws(() => + Program.BuildForwardedHeadersOptions(new ForwardedHeadersConfig { KnownNetworks = ["garbage"] })); + Assert.Throws(() => + Program.BuildForwardedHeadersOptions(new ForwardedHeadersConfig { KnownNetworks = ["10.0.0.0/33"] })); + } + + // ─── HTTP: доверенный прокси применяет заголовки ───────────────────────── + + /// + /// Клиент из KnownProxies + /// + [Fact] + public async Task KnownProxy_XForwardedForAndProto_AreApplied() + { + var config = new ForwardedHeadersConfig { KnownProxies = ["127.0.0.1"] }; + + await RunAsync(config, async (baseAddress, client) => + { + JsonElement response = await ProbeAsync(client, baseAddress, forwarded: true); + Assert.Equal(ForwardedClientIp, response.GetProperty("ip").GetString()); + Assert.Equal("https", response.GetProperty("scheme").GetString()); + }); + } + + /// + /// Доверие подсетью + /// + [Fact] + public async Task KnownNetwork_CoversProxy_ForwardedHeadersApplied() + { + var config = new ForwardedHeadersConfig { KnownNetworks = ["127.0.0.0/8"] }; + + await RunAsync(config, async (baseAddress, client) => + { + JsonElement response = await ProbeAsync(client, baseAddress, forwarded: true); + Assert.Equal(ForwardedClientIp, response.GetProperty("ip").GetString()); + Assert.Equal("https", response.GetProperty("scheme").GetString()); + }); + } + + /// + /// Клиент вне KnownProxies/KnownNetworks + /// + [Fact] + public async Task UnknownClient_ForwardedHeadersAreIgnored() + { + var config = new ForwardedHeadersConfig { KnownProxies = ["192.0.2.10"] }; + + await RunAsync(config, async (baseAddress, client) => + { + JsonElement response = await ProbeAsync(client, baseAddress, forwarded: true); + Assert.Equal(IPAddress.Loopback.ToString(), response.GetProperty("ip").GetString()); + Assert.Equal("http", response.GetProperty("scheme").GetString()); + }); + } + + // ─── Хост сценария ───────────────────────────────────────────────────── + + // Поднимает in-process Kestrel с UseForwardedHeaders(опции из конфига) и read-only + // эндпоинтом адреса/схемы, затем прогоняет сценарий. + // config: Секция ForwardedHeaders сценария (как из appsettings/env). + // scenario: Сценарий с base-адресом и клиентом. + private static async Task RunAsync(ForwardedHeadersConfig config, Func scenario) + { + int port = TestPort.Allocate(); + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, port)); + + WebApplication app = builder.Build(); + // Первый middleware конвейера — как в Program.cs (до слоёв, читающих RemoteIpAddress/Scheme). + app.UseForwardedHeaders(Program.BuildForwardedHeadersOptions(config)); + app.MapGet(ClientProbePath, (HttpContext context) => Results.Ok(new + { + ip = context.Connection.RemoteIpAddress?.ToString(), + scheme = context.Request.Scheme, + })); + + await app.StartAsync(); + try + { + string baseAddress = $"http://127.0.0.1:{port}"; + await scenario(baseAddress, CreateClient(baseAddress)); + } + finally + { + await app.StopAsync(); + await app.DisposeAsync(); + } + } + + // GET /api/client с X-Forwarded-For/X-Forwarded-Proto (как их ставит Caddy за TLS). + // client: HTTP-клиент сценария. + // baseAddress: Базовый адрес хоста. + // forwarded: Шлются ли прокси-заголовки. + // Возвращает: Тело ответа {ip, scheme}. + private static async Task ProbeAsync( + HttpClient client, + string baseAddress, + bool forwarded) + { + using var request = new HttpRequestMessage(HttpMethod.Get, baseAddress + ClientProbePath); + if (forwarded) + { + request.Headers.TryAddWithoutValidation("X-Forwarded-For", ForwardedClientIp); + request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", "https"); + } + + using HttpResponseMessage response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + return await ReadJsonAsync(response); + } + + + // HTTP-клиент сценария. + private static HttpClient CreateClient(string baseAddress) => + new() + { + BaseAddress = new Uri(baseAddress), + }; + + // Читает тело ответа как JSON-документ (корень, отвязанный от документа). + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + string body = await response.Content.ReadAsStringAsync(); + using var document = JsonDocument.Parse(body); + return document.RootElement.Clone(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs index 109e0ea..c6371cd 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs @@ -1,324 +1,324 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Ai; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Models; -using Deal.Tests.Unit.Grpc; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Tenants; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты gRPC-адаптера IAiClassifier к ai-service. -/// -[Collection("MlGrpcTests")] -public sealed class GrpcAiClassifierTests -{ - // Id тенанта сценариев строкой (формат N) — ожидаемое значение metadata tenant-id. - private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; - - private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue); - - private const string TestDomain = "IT-разработка"; - - private static readonly string[] TestKeywords = { "бот", "сайт" }; - - // ─── FilterAsync: маппинг ответа и заполненный промпт ─────────────────── - - [Fact] - public async Task FilterAsync_MapsReplyAndSendsFilledPromptAndProviderConfig() - { - await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => - { - service.FilterReply = new FilterReply - { - Pass = false, - Reason = "реклама", - Usage = new Usage { Prompt = 500, Completion = 40, Total = 540 }, - }; - (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); - settings.Preload(SettingsKeys.AiFilterPrompt, Json("Фильтруй. {domain} | {keywords}.")); - settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain)); - settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords)); - - FakeTenantLimitStore limits = new(); - GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); - AiFilterResultDto result = await classifier.FilterAsync("Купите телеграм-канал", CancellationToken.None); - - Assert.False(result.Pass); - Assert.Equal("реклама", result.Reason); - Assert.False(result.Skipped); - - // Тело запроса: заполненный aiFilterPrompt (подстановка {domain}/{keywords}), текст как есть. - Assert.NotNull(service.LastFilter); - Assert.Equal("Фильтруй. IT-разработка | бот, сайт.", service.LastFilter!.Prompt); - Assert.Equal("Купите телеграм-канал", service.LastFilter.Text); - Assert.Equal("deepseek", service.LastFilter.ProviderConfig.ProviderId); - Assert.Equal("https://api.deepseek.com", service.LastFilter.ProviderConfig.BaseUrl); - Assert.Equal("deepseek-v4-flash", service.LastFilter.ProviderConfig.Model); - Assert.False(service.LastFilter.ProviderConfig.HasApiKey); - Assert.False(service.LastFilter.ProviderConfig.HasApiStyle); - - Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds)); - Assert.Equal(AiGrpcTestHost.DefaultToken, Assert.Single(service.RequestTokens)); - await AssertUsageAsync(settings, prompt: 500, completion: 40, total: 540); - Assert.Equal(540, limits.UsedTokens(TenantGuid)); - }); - } - - [Fact] - public async Task FilterAsync_ServiceUnavailable_ThrowsAiUnavailable() - { - await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => - { - service.FilterUnavailable = true; - (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); - GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); - - await Assert.ThrowsAsync( - () => classifier.FilterAsync("текст", CancellationToken.None)); - }); - } - - [Fact] - public async Task FilterAsync_LongText_TruncatesTo4000CodePoints() - { - await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => - { - string text = new string('а', 5000); - (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); - GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); - - await classifier.FilterAsync(text, CancellationToken.None); - - Assert.Equal(4000, service.LastFilter!.Text.Length); - }); - } - - // ─── ClassifyAsync: маппинг JSON в разбор карточки ────────────────────── - - [Fact] - public async Task ClassifyAsync_OkWithJson_MapsParsedLeadAndSendsContext() - { - await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => - { - service.ClassifyReply = new ClassifyReply - { - Ok = true, - Json = """ - {"title":"Middle Python в команду","company":"Digital Cloud","format":"удалённо", - "task":"разработка бэкенда на Python","requirements":["опыт Python 3 года"], - "plus":["Django"],"conditions":"оплата по договорённости", - "stack":["Python","Django"],"budget":{"from":2000,"to":2500,"currency":"$"}, - "contacts":["@ivan_dev","dev@q.ru","@some_bot"], - "is_vacancy":true,"is_spam":false,"board":null} - """, - Usage = new Usage { Prompt = 3000, Completion = 700, Total = 3700 }, - }; - (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); - settings.Preload(SettingsKeys.AiPrompt, Json("Разбор: {domain}.")); - settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке».")); - settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain)); - settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords)); - kanjStore.SeedBoard(new ContainerDto - { - Id = "b_py", - Name = "Python-разработка", - Description = "Бэкенд и боты", - Rules = new ContainerRulesDto( - Mode: "any", - Direction: Array.Empty(), - Keywords: new[] { "python", "django" }, - Stack: Array.Empty(), - Grade: Array.Empty(), - Exclude: Array.Empty(), - Budget: null), - Order = 0, - }); - - FakeTenantLimitStore limits = new(); - GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); - AiParsedCardDto parsed = await classifier.ClassifyAsync("Нужен Python-разработчик, оплата от 2000$", CancellationToken.None); - - Assert.Equal("Middle Python в команду", parsed.Title); - Assert.Equal("Digital Cloud", parsed.Company); - Assert.Equal("удалённо", parsed.Format); - Assert.Equal("разработка бэкенда на Python", parsed.Task); - Assert.Equal(new[] { "опыт Python 3 года" }, parsed.Requirements); - Assert.Equal(new[] { "Django" }, parsed.Plus); - Assert.Equal("оплата по договорённости", parsed.Conditions); - Assert.Equal(new[] { "Python", "Django" }, parsed.Stack); - Assert.Equal(new AiBudgetDto(2000, 2500, "USD"), parsed.Budget); - Assert.Equal( - new[] { new AiContactDto("tg", "@ivan_dev"), new AiContactDto("email", "dev@q.ru") }, - parsed.Contacts); - Assert.True(parsed.IsVacancy); - Assert.False(parsed.IsSpam); - Assert.Null(parsed.Board); - - Assert.NotNull(service.LastClassify); - Assert.Equal("Разбор: IT-разработка.\n\nВерни блок «О заявке».", service.LastClassify!.SystemPrompt); - Assert.Contains("Доски: - b_py: Python-разработка (критерии: любое из условий · слова: python, django) — Бэкенд и боты", service.LastClassify.UserContext); - Assert.Contains("Новое сообщение:\nНужен Python-разработчик, оплата от 2000$", service.LastClassify.UserContext); - await AssertUsageAsync(settings, prompt: 3000, completion: 700, total: 3700); - Assert.Equal(3700, limits.UsedTokens(TenantGuid)); - }); - } - - [Fact] - public async Task ClassifyAsync_OkFalse_ThrowsAiUnavailableAndAccumulatesUsage() - { - await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => - { - service.ClassifyReply = new ClassifyReply - { - Ok = false, - Usage = new Usage { Prompt = 900, Completion = 0, Total = 900 }, - }; - (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); - - FakeTenantLimitStore limits = new(); - GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); - - // ok=false (модель без JSON) → AiUnavailableException: воркер падает в локальный разбор (aiFail). - await Assert.ThrowsAsync( - () => classifier.ClassifyAsync("текст", CancellationToken.None)); - - // Usage приходит и в ok=false (ответ модели был) — копится в KV и списывается с бюджета. - await AssertUsageAsync(settings, prompt: 900, completion: 0, total: 900); - Assert.Equal(900, limits.UsedTokens(TenantGuid)); - }); - } - - [Fact] - public async Task ClassifyAsync_ServiceUnavailable_ThrowsAiUnavailable() - { - await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => - { - service.ClassifyUnavailable = true; - (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); - GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); - - await Assert.ThrowsAsync( - () => classifier.ClassifyAsync("текст", CancellationToken.None)); - }); - } - - [Fact] - public async Task ClassifyAsync_ProviderConfig_ReadsDecryptedKeyAndOverridesFromSettings() - { - await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => - { - service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" }; - (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); - settings.Preload(SettingsKeys.AiProvider, Json("anthropic")); - settings.Preload( - SettingsKeys.AiConfigs, - new JsonObject - { - ["anthropic"] = new JsonObject - { - ["apiKey"] = cipher.Encrypt("sk-ant-test-secret"), - ["baseUrl"] = "https://llm.local", - ["model"] = "claude-custom", - }, - }.ToJsonString()); - - GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); - await classifier.ClassifyAsync("текст", CancellationToken.None); - - // ProviderConfig из настроек: id/base/model — переопределение, apiKey расшифрован, api_style — из каталога. - Assert.NotNull(service.LastClassify); - ProviderConfig config = service.LastClassify!.ProviderConfig; - Assert.Equal("anthropic", config.ProviderId); - Assert.Equal("https://llm.local", config.BaseUrl); - Assert.Equal("claude-custom", config.Model); - Assert.True(config.HasApiKey); - Assert.Equal("sk-ant-test-secret", config.ApiKey); - Assert.True(config.HasApiStyle); - Assert.Equal("anthropic", config.ApiStyle); - }); - } - - [Fact] - public async Task ClassifyAsync_LongMessage_TruncatesUserContextTo5000CodePoints() - { - await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => - { - service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Т","stack":[],"is_spam":false}""" }; - string text = new string('б', 6000); - (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); - GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); - - await classifier.ClassifyAsync(text, CancellationToken.None); - - Assert.NotNull(service.LastClassify); - Assert.Contains("Новое сообщение:\n" + new string('б', 5000), service.LastClassify!.UserContext); - }); - } - - // ─── Хелперы ──────────────────────────────────────────────────────────── - - // Создаёт GrpcAiClassifier к хосту-фейку на эфемерном порту в tenant-контексте теста. - // port: Порт хоста-фейка ai-service. - // settings: KV-хранилище тенанта (дефолты при пустом). - // cipher: Шифр секретов (расшифровка apiKey). - // kanjStore: Канбан-фейк (доски/примеры контекста классификации). - // limits: Фейк-хранилище лимитов (списание usage; при null — собственный экземпляр). - // Возвращает: Экземпляр GrpcAiClassifier. - private static GrpcAiClassifier CreateClassifier( - int port, - FakeSettingsStore settings, - FakeSecretCipher cipher, - FakeKanjStore kanjStore, - FakeTenantLimitStore? limits = null) - { - var tenantContext = new TenantContext(); - tenantContext.SetTenant(new TenantId(TenantIdValue)); - var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); - return new GrpcAiClassifier( - tenantContext, - connection, - new AiProviderConfigBuilder(settings, cipher), - new AiClassifyContextBuilder(settings, kanjStore), - new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new FakeTokenUsageEventStore())), - NullLogger.Instance); - } - - // Создаёт контекст сценария (пустые фейки; сценарий переопределяет настройки/доски). - // port: Порт хоста (не используется контекстом — единый вид хелперов). - // Возвращает: Кортеж фейков (настройки, шифр, канбан). - private static (FakeSettingsStore Settings, FakeSecretCipher Cipher, FakeKanjStore Kanj) Context(int port) - => (new FakeSettingsStore(), new FakeSecretCipher(), new FakeKanjStore()); - - // Сериализует значение настройки в JSON-строку (как пишет SettingsStore). - // value: Значение (строка/список). - // Возвращает: JSON для Preload. - private static string Json(object value) => JsonSerializer.Serialize(value); - - private static async Task AssertUsageAsync( - FakeSettingsStore settings, - uint prompt, - uint completion, - uint total) - { - string? stored = settings.GetStoredJson(SettingsKeys.AiTokenUsage); - Assert.NotNull(stored); - using JsonDocument document = JsonDocument.Parse(stored); - Assert.Equal(prompt, document.RootElement.GetProperty("prompt").GetUInt32()); - Assert.Equal(completion, document.RootElement.GetProperty("completion").GetUInt32()); - Assert.Equal(total, document.RootElement.GetProperty("total").GetUInt32()); - } -} +using System.Text.Json; +using System.Text.Json.Nodes; +using Deal.Contracts.Integrations.Models; +using Deal.Grpc.Ai; +using Deal.Infrastructure.Data; +using Deal.Infrastructure.Integrations.Exceptions; +using Deal.Infrastructure.Integrations.Models; +using Deal.Infrastructure.Integrations.Options; +using Deal.Infrastructure.Integrations.Services; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Tenants.Application.Services; +using Deal.SharedKernel.Tenants.Models; +using Deal.Tests.Unit.Grpc; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Modules.Tenants; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты gRPC-адаптера IAiClassifier к ai-service. +/// +[Collection("MlGrpcTests")] +public sealed class GrpcAiClassifierTests +{ + // Id тенанта сценариев строкой (формат N) — ожидаемое значение metadata tenant-id. + private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; + + private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue); + + private const string TestDomain = "IT-разработка"; + + private static readonly string[] TestKeywords = { "бот", "сайт" }; + + // ─── FilterAsync: маппинг ответа и заполненный промпт ─────────────────── + + [Fact] + public async Task FilterAsync_MapsReplyAndSendsFilledPromptAndProviderConfig() + { + await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => + { + service.FilterReply = new FilterReply + { + Pass = false, + Reason = "реклама", + Usage = new Usage { Prompt = 500, Completion = 40, Total = 540 }, + }; + (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + settings.Preload(SettingsKeys.AiFilterPrompt, Json("Фильтруй. {domain} | {keywords}.")); + settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain)); + settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords)); + + FakeTenantLimitStore limits = new(); + GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); + AiFilterResultDto result = await classifier.FilterAsync("Купите телеграм-канал", CancellationToken.None); + + Assert.False(result.Pass); + Assert.Equal("реклама", result.Reason); + Assert.False(result.Skipped); + + // Тело запроса: заполненный aiFilterPrompt (подстановка {domain}/{keywords}), текст как есть. + Assert.NotNull(service.LastFilter); + Assert.Equal("Фильтруй. IT-разработка | бот, сайт.", service.LastFilter!.Prompt); + Assert.Equal("Купите телеграм-канал", service.LastFilter.Text); + Assert.Equal("deepseek", service.LastFilter.ProviderConfig.ProviderId); + Assert.Equal("https://api.deepseek.com", service.LastFilter.ProviderConfig.BaseUrl); + Assert.Equal("deepseek-v4-flash", service.LastFilter.ProviderConfig.Model); + Assert.False(service.LastFilter.ProviderConfig.HasApiKey); + Assert.False(service.LastFilter.ProviderConfig.HasApiStyle); + + Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds)); + Assert.Equal(AiGrpcTestHost.DefaultToken, Assert.Single(service.RequestTokens)); + await AssertUsageAsync(settings, prompt: 500, completion: 40, total: 540); + Assert.Equal(540, limits.UsedTokens(TenantGuid)); + }); + } + + [Fact] + public async Task FilterAsync_ServiceUnavailable_ThrowsAiUnavailable() + { + await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => + { + service.FilterUnavailable = true; + (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); + + await Assert.ThrowsAsync( + () => classifier.FilterAsync("текст", CancellationToken.None)); + }); + } + + [Fact] + public async Task FilterAsync_LongText_TruncatesTo4000CodePoints() + { + await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => + { + string text = new string('а', 5000); + (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); + + await classifier.FilterAsync(text, CancellationToken.None); + + Assert.Equal(4000, service.LastFilter!.Text.Length); + }); + } + + // ─── ClassifyAsync: маппинг JSON в разбор карточки ────────────────────── + + [Fact] + public async Task ClassifyAsync_OkWithJson_MapsParsedLeadAndSendsContext() + { + await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => + { + service.ClassifyReply = new ClassifyReply + { + Ok = true, + Json = """ + {"title":"Middle Python в команду","company":"Digital Cloud","format":"удалённо", + "task":"разработка бэкенда на Python","requirements":["опыт Python 3 года"], + "plus":["Django"],"conditions":"оплата по договорённости", + "stack":["Python","Django"],"budget":{"from":2000,"to":2500,"currency":"$"}, + "contacts":["@ivan_dev","dev@q.ru","@some_bot"], + "is_vacancy":true,"is_spam":false,"board":null} + """, + Usage = new Usage { Prompt = 3000, Completion = 700, Total = 3700 }, + }; + (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + settings.Preload(SettingsKeys.AiPrompt, Json("Разбор: {domain}.")); + settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке».")); + settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain)); + settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords)); + kanjStore.SeedBoard(new ContainerDto + { + Id = "b_py", + Name = "Python-разработка", + Description = "Бэкенд и боты", + Rules = new ContainerRulesDto( + Mode: "any", + Direction: Array.Empty(), + Keywords: new[] { "python", "django" }, + Stack: Array.Empty(), + Grade: Array.Empty(), + Exclude: Array.Empty(), + Budget: null), + Order = 0, + }); + + FakeTenantLimitStore limits = new(); + GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); + AiParsedCardDto parsed = await classifier.ClassifyAsync("Нужен Python-разработчик, оплата от 2000$", CancellationToken.None); + + Assert.Equal("Middle Python в команду", parsed.Title); + Assert.Equal("Digital Cloud", parsed.Company); + Assert.Equal("удалённо", parsed.Format); + Assert.Equal("разработка бэкенда на Python", parsed.Task); + Assert.Equal(new[] { "опыт Python 3 года" }, parsed.Requirements); + Assert.Equal(new[] { "Django" }, parsed.Plus); + Assert.Equal("оплата по договорённости", parsed.Conditions); + Assert.Equal(new[] { "Python", "Django" }, parsed.Stack); + Assert.Equal(new AiBudgetDto(2000, 2500, "USD"), parsed.Budget); + Assert.Equal( + new[] { new AiContactDto("tg", "@ivan_dev"), new AiContactDto("email", "dev@q.ru") }, + parsed.Contacts); + Assert.True(parsed.IsVacancy); + Assert.False(parsed.IsSpam); + Assert.Null(parsed.Board); + + Assert.NotNull(service.LastClassify); + Assert.Equal("Разбор: IT-разработка.\n\nВерни блок «О заявке».", service.LastClassify!.SystemPrompt); + Assert.Contains("Доски: - b_py: Python-разработка (критерии: любое из условий · слова: python, django) — Бэкенд и боты", service.LastClassify.UserContext); + Assert.Contains("Новое сообщение:\nНужен Python-разработчик, оплата от 2000$", service.LastClassify.UserContext); + await AssertUsageAsync(settings, prompt: 3000, completion: 700, total: 3700); + Assert.Equal(3700, limits.UsedTokens(TenantGuid)); + }); + } + + [Fact] + public async Task ClassifyAsync_OkFalse_ThrowsAiUnavailableAndAccumulatesUsage() + { + await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => + { + service.ClassifyReply = new ClassifyReply + { + Ok = false, + Usage = new Usage { Prompt = 900, Completion = 0, Total = 900 }, + }; + (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + + FakeTenantLimitStore limits = new(); + GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); + + // ok=false (модель без JSON) → AiUnavailableException: воркер падает в локальный разбор (aiFail). + await Assert.ThrowsAsync( + () => classifier.ClassifyAsync("текст", CancellationToken.None)); + + // Usage приходит и в ok=false (ответ модели был) — копится в KV и списывается с бюджета. + await AssertUsageAsync(settings, prompt: 900, completion: 0, total: 900); + Assert.Equal(900, limits.UsedTokens(TenantGuid)); + }); + } + + [Fact] + public async Task ClassifyAsync_ServiceUnavailable_ThrowsAiUnavailable() + { + await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => + { + service.ClassifyUnavailable = true; + (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); + + await Assert.ThrowsAsync( + () => classifier.ClassifyAsync("текст", CancellationToken.None)); + }); + } + + [Fact] + public async Task ClassifyAsync_ProviderConfig_ReadsDecryptedKeyAndOverridesFromSettings() + { + await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => + { + service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" }; + (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + settings.Preload(SettingsKeys.AiProvider, Json("anthropic")); + settings.Preload( + SettingsKeys.AiConfigs, + new JsonObject + { + ["anthropic"] = new JsonObject + { + ["apiKey"] = cipher.Encrypt("sk-ant-test-secret"), + ["baseUrl"] = "https://llm.local", + ["model"] = "claude-custom", + }, + }.ToJsonString()); + + GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); + await classifier.ClassifyAsync("текст", CancellationToken.None); + + // ProviderConfig из настроек: id/base/model — переопределение, apiKey расшифрован, api_style — из каталога. + Assert.NotNull(service.LastClassify); + ProviderConfig config = service.LastClassify!.ProviderConfig; + Assert.Equal("anthropic", config.ProviderId); + Assert.Equal("https://llm.local", config.BaseUrl); + Assert.Equal("claude-custom", config.Model); + Assert.True(config.HasApiKey); + Assert.Equal("sk-ant-test-secret", config.ApiKey); + Assert.True(config.HasApiStyle); + Assert.Equal("anthropic", config.ApiStyle); + }); + } + + [Fact] + public async Task ClassifyAsync_LongMessage_TruncatesUserContextTo5000CodePoints() + { + await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => + { + service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Т","stack":[],"is_spam":false}""" }; + string text = new string('б', 6000); + (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + GrpcAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); + + await classifier.ClassifyAsync(text, CancellationToken.None); + + Assert.NotNull(service.LastClassify); + Assert.Contains("Новое сообщение:\n" + new string('б', 5000), service.LastClassify!.UserContext); + }); + } + + // ─── Хелперы ──────────────────────────────────────────────────────────── + + // Создаёт GrpcAiClassifier к хосту-фейку на эфемерном порту в tenant-контексте теста. + // port: Порт хоста-фейка ai-service. + // settings: KV-хранилище тенанта (дефолты при пустом). + // cipher: Шифр секретов (расшифровка apiKey). + // kanjStore: Канбан-фейк (доски/примеры контекста классификации). + // limits: Фейк-хранилище лимитов (списание usage; при null — собственный экземпляр). + // Возвращает: Экземпляр GrpcAiClassifier. + private static GrpcAiClassifier CreateClassifier( + int port, + FakeSettingsStore settings, + FakeSecretCipher cipher, + FakeKanjStore kanjStore, + FakeTenantLimitStore? limits = null) + { + var tenantContext = new TenantContext(); + tenantContext.SetTenant(new TenantId(TenantIdValue)); + var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }); + return new GrpcAiClassifier( + tenantContext, + connection, + new AiProviderConfigBuilder(settings, cipher), + new AiClassifyContextBuilder(settings, kanjStore), + new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new FakeTokenUsageEventStore())), + NullLogger.Instance); + } + + // Создаёт контекст сценария (пустые фейки; сценарий переопределяет настройки/доски). + // port: Порт хоста (не используется контекстом — единый вид хелперов). + // Возвращает: Кортеж фейков (настройки, шифр, канбан). + private static (FakeSettingsStore Settings, FakeSecretCipher Cipher, FakeKanjStore Kanj) Context(int port) + => (new FakeSettingsStore(), new FakeSecretCipher(), new FakeKanjStore()); + + // Сериализует значение настройки в JSON-строку (как пишет SettingsStore). + // value: Значение (строка/список). + // Возвращает: JSON для Preload. + private static string Json(object value) => JsonSerializer.Serialize(value); + + private static async Task AssertUsageAsync( + FakeSettingsStore settings, + uint prompt, + uint completion, + uint total) + { + string? stored = settings.GetStoredJson(SettingsKeys.AiTokenUsage); + Assert.NotNull(stored); + using JsonDocument document = JsonDocument.Parse(stored); + Assert.Equal(prompt, document.RootElement.GetProperty("prompt").GetUInt32()); + Assert.Equal(completion, document.RootElement.GetProperty("completion").GetUInt32()); + Assert.Equal(total, document.RootElement.GetProperty("total").GetUInt32()); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs index 5c60f18..0bc6bb2 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs @@ -1,341 +1,341 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Deal.Api.Endpoints; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Modules.Tenants; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Tests.Unit.Support; - -/// -/// HTTP-тесты POST /api/join -/// -public sealed class JoinEndpointHttpTests -{ - private const string Email = "new-user@example.com"; - private const string OtherEmail = "other@example.com"; - private const string Password = "pass1234"; - private const string Code = "abcdefghijklmnop"; - - private const string InviteNotFoundDetail = "Приглашение не найдено"; - private const string InviteExpiredDetail = "Срок действия приглашения истёк"; - private const string InviteUsedDetail = "Приглашение уже использовано"; - private const string InviteRevokedDetail = "Приглашение отозвано"; - private const string EmailMismatchDetail = "Email не совпадает с приглашением"; - private const string EmailTakenDetail = "Этот email уже зарегистрирован"; - private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)"; - - [Fact] - public async Task Join_WithValidPendingInvite_ReturnsOkCreatesUserAndWritesAudit() - { - var inviteStore = new FakeInviteStore(); - inviteStore.AddInvite(NewInvite(Email)); - var authStore = new FakeAuthStore(); - var tenantStore = new FakeTenantStore(); - var provisioner = new FakeTenantProvisioner(); - var auditStore = new FakeAuditLogStore(); - - await RunAsync( - inviteStore, tenantStore, provisioner, authStore, auditStore, - async (baseAddress, client) => - { - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = Code, email = Email, name = "Acme", password = Password }); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - JsonElement body = await ReadJsonAsync(response); - Assert.True(body.GetProperty("ok").GetBoolean()); - Assert.Equal(Email, body.GetProperty("login").GetString()); - Assert.False(response.Headers.Contains("Set-Cookie")); - }); - - // Пользователь создан (login=email, хэш через фейк-хэшер), тенант создан и провижинен один раз, инвайт activated. - StoredUserDto user = Assert.Single(authStore.Users); - Assert.Equal(Email, user.Login); - Assert.Equal(new FakePasswordHasher().Hash(Password), user.PasswordHash); - TenantRecordDto tenant = Assert.Single(tenantStore.Tenants); - Assert.Equal("Acme", tenant.Name); - Assert.Equal(user.TenantId, tenant.Id); - Assert.Equal(new[] { $"tenant_{tenant.Id:N}" }, provisioner.ProvisionedSchemaNames); - InviteDto invite = Assert.Single(inviteStore.Invites); - Assert.Equal(InviteStatuses.Activated, invite.Status); - Assert.NotNull(invite.ActivatedAt); - - AuditRecordDto audit = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.InviteJoined); - Assert.Equal(AuditActorTypes.Tenant, audit.ActorType); - Assert.Equal(user.Id, audit.ActorId); - Assert.Equal(user.TenantId, audit.TenantId); - AssertDetailHasEmailAndCode(audit, Email, Code); - } - - [Fact] - public async Task Join_WithSameCodeTwice_SecondReturns400AlreadyUsed() - { - var inviteStore = new FakeInviteStore(); - inviteStore.AddInvite(NewInvite(Email)); - var authStore = new FakeAuthStore(); - - await RunAsync( - inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), authStore, new FakeAuditLogStore(), - async (baseAddress, client) => - { - using (HttpResponseMessage first = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password })) - { - Assert.Equal(HttpStatusCode.OK, first.StatusCode); - } - - using HttpResponseMessage second = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); - - Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); - Assert.Equal(InviteUsedDetail, (await ReadJsonAsync(second)).GetProperty("detail").GetString()); - }); - - // Побочных эффектов от повторной попытки нет (CAS: один пользователь/тенант). - Assert.Single(authStore.Users); - } - - [Fact] - public async Task Join_WithMismatchedEmail_Returns400EmailMismatchAndKeepsInvite() - { - var inviteStore = new FakeInviteStore(); - inviteStore.AddInvite(NewInvite(Email)); - var authStore = new FakeAuthStore(); - - await RunAsync( - inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), authStore, new FakeAuditLogStore(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = Code, email = OtherEmail, password = Password }); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(EmailMismatchDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - - Assert.Equal(InviteStatuses.Pending, Assert.Single(inviteStore.Invites).Status); - Assert.Empty(authStore.Users); - } - - [Fact] - public async Task Join_WithRevokedInvite_Returns400Revoked() - { - var inviteStore = new FakeInviteStore(); - inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked)); - - await RunAsync( - inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(InviteRevokedDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task Join_WithExpiredInvite_Returns400Expired() - { - var inviteStore = new FakeInviteStore(); - inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1))); - - await RunAsync( - inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(InviteExpiredDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task Join_WithUnknownCode_Returns400NotFound() - { - await RunAsync( - new FakeInviteStore(), new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = "no-such-code-1234", email = Email, password = Password }); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(InviteNotFoundDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task Join_WithShortPassword_Returns400PasswordTooShort() - { - var inviteStore = new FakeInviteStore(); - inviteStore.AddInvite(NewInvite(Email)); - - await RunAsync( - inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = "abc" }); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(PasswordTooShortDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - - Assert.Equal(InviteStatuses.Pending, Assert.Single(inviteStore.Invites).Status); - } - - [Fact] - public async Task Join_WithTakenEmail_Returns400EmailAlreadyRegistered() - { - var inviteStore = new FakeInviteStore(); - inviteStore.AddInvite(NewInvite(Email)); - var authStore = new FakeAuthStore(); - authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: "active", "hash")); - - await RunAsync( - inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), authStore, new FakeAuditLogStore(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(EmailTakenDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - - Assert.Equal(InviteStatuses.Pending, Assert.Single(inviteStore.Invites).Status); - Assert.Single(authStore.Users); - } - - [Fact] - public async Task Join_ExistingTenantInvite_JoinsTenantWithoutCreatingNewOne() - { - var tenantId = Guid.NewGuid(); - var inviteStore = new FakeInviteStore(); - inviteStore.AddInvite(NewInvite(Email, tenantId: tenantId)); - // Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему. - var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow)); - var provisioner = new FakeTenantProvisioner(); - var authStore = new FakeAuthStore(); - - await RunAsync( - inviteStore, tenantStore, provisioner, authStore, new FakeAuditLogStore(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - }); - - Assert.Equal(new[] { tenantId }, tenantStore.Tenants.Select(t => t.Id)); - Assert.Empty(provisioner.ProvisionedSchemaNames); - Assert.Equal(tenantId, Assert.Single(authStore.Users).TenantId); - } - - // ─── Хелперы ───────────────────────────────────────────────────────── - - // Поднимает in-process Kestrel с /api/join на фейк-хранилищах и прогоняет сценарий. - private static async Task RunAsync( - FakeInviteStore inviteStore, - FakeTenantStore tenantStore, - FakeTenantProvisioner provisioner, - FakeAuthStore authStore, - FakeAuditLogStore auditStore, - Func scenario) - { - int port = TestPort.Allocate(); - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, port)); - - builder.Services.AddTenantsModule(); - // FakePasswordHasher регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает - // последняя регистрация (зеркало OperatorAuthHttpHost). - builder.Services.AddSingleton(); - builder.Services.AddSingleton(authStore); - builder.Services.AddSingleton(auditStore); - builder.Services.AddSingleton(inviteStore); - builder.Services.AddSingleton(tenantStore); - builder.Services.AddSingleton(provisioner); - - WebApplication app = builder.Build(); - app.MapJoinEndpoint(); - await app.StartAsync(); - try - { - await scenario($"http://127.0.0.1:{port}", CreateClient($"http://127.0.0.1:{port}")); - } - finally - { - await app.StopAsync(); - await app.DisposeAsync(); - } - } - - // Создаёт приглашение-строку для сидирования (дефолт — живой pending). - private static InviteDto NewInvite( - string email, - Guid? tenantId = null, - string status = InviteStatuses.Pending, - DateTimeOffset? expiresAt = null) => - new( - Code, - email, - tenantId, - status, - expiresAt ?? DateTimeOffset.UtcNow.AddHours(InvitesService.ExpiryHours), - ActivatedAt: status == InviteStatuses.Activated ? DateTimeOffset.UtcNow.AddDays(-1) : null, - CreatedById: Guid.NewGuid(), - DateTimeOffset.UtcNow); - - // Проверяет DetailJson записи аудита: поля email и codeHash (Security review: код — хэш). - private static void AssertDetailHasEmailAndCode( - AuditRecordDto record, - string email, - string code) - { - Assert.NotNull(record.DetailJson); - using var document = JsonDocument.Parse(record.DetailJson!); - JsonElement root = document.RootElement; - Assert.Equal(email, root.GetProperty("email").GetString()); - Assert.Equal(SessionTokens.HashToken(code), root.GetProperty("codeHash").GetString()); - } - - - // HTTP-клиент с собственным CookieContainer. - private static HttpClient CreateClient(string baseAddress) => - new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) - { - BaseAddress = new Uri(baseAddress), - }; - - // POST JSON-тела и возврат ответа. - private static async Task PostJsonAsync( - HttpClient client, - string url, - object body) - { - string json = JsonSerializer.Serialize(body); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - return await client.PostAsync(url, content); - } - - // Читает тело ответа как JSON-документ (корень, отвязанный от документа). - private static async Task ReadJsonAsync(HttpResponseMessage response) - { - await using Stream stream = await response.Content.ReadAsStreamAsync(); - using var document = await JsonDocument.ParseAsync(stream); - return document.RootElement.Clone(); - } -} +using System.Net; +using System.Text; +using System.Text.Json; +using Deal.Api.Endpoints; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; +using Deal.Tests.Unit.Modules.Tenants; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Tests.Unit.Support; + +/// +/// HTTP-тесты POST /api/join +/// +public sealed class JoinEndpointHttpTests +{ + private const string Email = "new-user@example.com"; + private const string OtherEmail = "other@example.com"; + private const string Password = "pass1234"; + private const string Code = "abcdefghijklmnop"; + + private const string InviteNotFoundDetail = "Приглашение не найдено"; + private const string InviteExpiredDetail = "Срок действия приглашения истёк"; + private const string InviteUsedDetail = "Приглашение уже использовано"; + private const string InviteRevokedDetail = "Приглашение отозвано"; + private const string EmailMismatchDetail = "Email не совпадает с приглашением"; + private const string EmailTakenDetail = "Этот email уже зарегистрирован"; + private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)"; + + [Fact] + public async Task Join_WithValidPendingInvite_ReturnsOkCreatesUserAndWritesAudit() + { + var inviteStore = new FakeInviteStore(); + inviteStore.AddInvite(NewInvite(Email)); + var authStore = new FakeAuthStore(); + var tenantStore = new FakeTenantStore(); + var provisioner = new FakeTenantProvisioner(); + var auditStore = new FakeAuditLogStore(); + + await RunAsync( + inviteStore, tenantStore, provisioner, authStore, auditStore, + async (baseAddress, client) => + { + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = Code, email = Email, name = "Acme", password = Password }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + JsonElement body = await ReadJsonAsync(response); + Assert.True(body.GetProperty("ok").GetBoolean()); + Assert.Equal(Email, body.GetProperty("login").GetString()); + Assert.False(response.Headers.Contains("Set-Cookie")); + }); + + // Пользователь создан (login=email, хэш через фейк-хэшер), тенант создан и провижинен один раз, инвайт activated. + StoredUserDto user = Assert.Single(authStore.Users); + Assert.Equal(Email, user.Login); + Assert.Equal(new FakePasswordHasher().Hash(Password), user.PasswordHash); + TenantRecordDto tenant = Assert.Single(tenantStore.Tenants); + Assert.Equal("Acme", tenant.Name); + Assert.Equal(user.TenantId, tenant.Id); + Assert.Equal(new[] { $"tenant_{tenant.Id:N}" }, provisioner.ProvisionedSchemaNames); + InviteDto invite = Assert.Single(inviteStore.Invites); + Assert.Equal(InviteStatuses.Activated, invite.Status); + Assert.NotNull(invite.ActivatedAt); + + AuditRecordDto audit = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.InviteJoined); + Assert.Equal(AuditActorTypes.Tenant, audit.ActorType); + Assert.Equal(user.Id, audit.ActorId); + Assert.Equal(user.TenantId, audit.TenantId); + AssertDetailHasEmailAndCode(audit, Email, Code); + } + + [Fact] + public async Task Join_WithSameCodeTwice_SecondReturns400AlreadyUsed() + { + var inviteStore = new FakeInviteStore(); + inviteStore.AddInvite(NewInvite(Email)); + var authStore = new FakeAuthStore(); + + await RunAsync( + inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), authStore, new FakeAuditLogStore(), + async (baseAddress, client) => + { + using (HttpResponseMessage first = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password })) + { + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + } + + using HttpResponseMessage second = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); + + Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); + Assert.Equal(InviteUsedDetail, (await ReadJsonAsync(second)).GetProperty("detail").GetString()); + }); + + // Побочных эффектов от повторной попытки нет (CAS: один пользователь/тенант). + Assert.Single(authStore.Users); + } + + [Fact] + public async Task Join_WithMismatchedEmail_Returns400EmailMismatchAndKeepsInvite() + { + var inviteStore = new FakeInviteStore(); + inviteStore.AddInvite(NewInvite(Email)); + var authStore = new FakeAuthStore(); + + await RunAsync( + inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), authStore, new FakeAuditLogStore(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = Code, email = OtherEmail, password = Password }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(EmailMismatchDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + + Assert.Equal(InviteStatuses.Pending, Assert.Single(inviteStore.Invites).Status); + Assert.Empty(authStore.Users); + } + + [Fact] + public async Task Join_WithRevokedInvite_Returns400Revoked() + { + var inviteStore = new FakeInviteStore(); + inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked)); + + await RunAsync( + inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(InviteRevokedDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task Join_WithExpiredInvite_Returns400Expired() + { + var inviteStore = new FakeInviteStore(); + inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1))); + + await RunAsync( + inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(InviteExpiredDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task Join_WithUnknownCode_Returns400NotFound() + { + await RunAsync( + new FakeInviteStore(), new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = "no-such-code-1234", email = Email, password = Password }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(InviteNotFoundDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task Join_WithShortPassword_Returns400PasswordTooShort() + { + var inviteStore = new FakeInviteStore(); + inviteStore.AddInvite(NewInvite(Email)); + + await RunAsync( + inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = "abc" }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(PasswordTooShortDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + + Assert.Equal(InviteStatuses.Pending, Assert.Single(inviteStore.Invites).Status); + } + + [Fact] + public async Task Join_WithTakenEmail_Returns400EmailAlreadyRegistered() + { + var inviteStore = new FakeInviteStore(); + inviteStore.AddInvite(NewInvite(Email)); + var authStore = new FakeAuthStore(); + authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: "active", "hash")); + + await RunAsync( + inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), authStore, new FakeAuditLogStore(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(EmailTakenDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + + Assert.Equal(InviteStatuses.Pending, Assert.Single(inviteStore.Invites).Status); + Assert.Single(authStore.Users); + } + + [Fact] + public async Task Join_ExistingTenantInvite_JoinsTenantWithoutCreatingNewOne() + { + var tenantId = Guid.NewGuid(); + var inviteStore = new FakeInviteStore(); + inviteStore.AddInvite(NewInvite(Email, tenantId: tenantId)); + // Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему. + var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow)); + var provisioner = new FakeTenantProvisioner(); + var authStore = new FakeAuthStore(); + + await RunAsync( + inviteStore, tenantStore, provisioner, authStore, new FakeAuditLogStore(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + }); + + Assert.Equal(new[] { tenantId }, tenantStore.Tenants.Select(t => t.Id)); + Assert.Empty(provisioner.ProvisionedSchemaNames); + Assert.Equal(tenantId, Assert.Single(authStore.Users).TenantId); + } + + // ─── Хелперы ───────────────────────────────────────────────────────── + + // Поднимает in-process Kestrel с /api/join на фейк-хранилищах и прогоняет сценарий. + private static async Task RunAsync( + FakeInviteStore inviteStore, + FakeTenantStore tenantStore, + FakeTenantProvisioner provisioner, + FakeAuthStore authStore, + FakeAuditLogStore auditStore, + Func scenario) + { + int port = TestPort.Allocate(); + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, port)); + + builder.Services.AddTenantsModule(); + // FakePasswordHasher регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает + // последняя регистрация (зеркало OperatorAuthHttpHost). + builder.Services.AddSingleton(); + builder.Services.AddSingleton(authStore); + builder.Services.AddSingleton(auditStore); + builder.Services.AddSingleton(inviteStore); + builder.Services.AddSingleton(tenantStore); + builder.Services.AddSingleton(provisioner); + + WebApplication app = builder.Build(); + app.MapJoinEndpoint(); + await app.StartAsync(); + try + { + await scenario($"http://127.0.0.1:{port}", CreateClient($"http://127.0.0.1:{port}")); + } + finally + { + await app.StopAsync(); + await app.DisposeAsync(); + } + } + + // Создаёт приглашение-строку для сидирования (дефолт — живой pending). + private static InviteDto NewInvite( + string email, + Guid? tenantId = null, + string status = InviteStatuses.Pending, + DateTimeOffset? expiresAt = null) => + new( + Code, + email, + tenantId, + status, + expiresAt ?? DateTimeOffset.UtcNow.AddHours(InvitesService.ExpiryHours), + ActivatedAt: status == InviteStatuses.Activated ? DateTimeOffset.UtcNow.AddDays(-1) : null, + CreatedById: Guid.NewGuid(), + DateTimeOffset.UtcNow); + + // Проверяет DetailJson записи аудита: поля email и codeHash (Security review: код — хэш). + private static void AssertDetailHasEmailAndCode( + AuditRecordDto record, + string email, + string code) + { + Assert.NotNull(record.DetailJson); + using var document = JsonDocument.Parse(record.DetailJson!); + JsonElement root = document.RootElement; + Assert.Equal(email, root.GetProperty("email").GetString()); + Assert.Equal(SessionTokens.HashToken(code), root.GetProperty("codeHash").GetString()); + } + + + // HTTP-клиент с собственным CookieContainer. + private static HttpClient CreateClient(string baseAddress) => + new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) + { + BaseAddress = new Uri(baseAddress), + }; + + // POST JSON-тела и возврат ответа. + private static async Task PostJsonAsync( + HttpClient client, + string url, + object body) + { + string json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await client.PostAsync(url, content); + } + + // Читает тело ответа как JSON-документ (корень, отвязанный от документа). + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + await using Stream stream = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(stream); + return document.RootElement.Clone(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/LocalMlClientTests.cs b/src/core/tests/Deal.Tests.Unit/Support/LocalMlClientTests.cs index a01ae01..98206c0 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/LocalMlClientTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/LocalMlClientTests.cs @@ -1,338 +1,338 @@ -using System.Text.Json; -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application.Models; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Support; - -/// -/// Локальная реализация IMlClient без внешнего ML-сервиса. -/// -public sealed class LocalMlClientTests -{ - // Опции сериализации, как у минимальных API (JsonSerializerDefaults.Web → camelCase). - private static readonly JsonSerializerOptions CamelCaseOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - }; - - private const int MaxLearningTextLength = 6000; - - private const int RandomHexLength = 12; - - // Создаёт LocalMlClient поверх in-memory KV-хранилища и хранилища обучения. - // store: Хранилище настроек тенанта. - // learning: Хранилище обучения (по умолчанию пустое: счётчики 0). - // Возвращает: Экземпляр LocalMlClient. - private static LocalMlClient CreateClient(FakeSettingsStore store, FakeMlLearningStore? learning = null) - => new(store, learning ?? new FakeMlLearningStore()); - - - [Fact] - public async Task StatusAsync_NoSettings_ReturnsDeterministicNotReadyStatus() - { - LocalMlClient client = CreateClient(new FakeSettingsStore()); - - MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None); - - // Верхний уровень: enabled = дефолт mlEnabled (true), reachable=true (заглушка «жива»). - Assert.True(status.Enabled); - Assert.True(status.Reachable); - - Assert.False(status.Service.Ready); - Assert.Empty(status.Service.Classes); - Assert.Equal(0, status.Service.Learned); - Assert.Equal(0, status.Service.Eval.Count); - Assert.Equal(0, status.Service.Eval.Correct); - Assert.Equal(0.0, status.Service.Eval.Accuracy); - - // Статистика: счётчики ml/ai пусты (0), learning/outbox — 0 (таблиц нет), зеркало service. - Assert.Equal(0, status.Stats.Ml); - Assert.Equal(0, status.Stats.Ai); - Assert.Equal(0, status.Stats.Learning); - Assert.False(status.Stats.Ready); - Assert.Empty(status.Stats.Classes); - Assert.Equal(0, status.Stats.Learned); - Assert.True(status.Stats.Reachable); - Assert.Equal(0, status.Stats.Outbox); - } - - [Fact] - public async Task StatusAsync_StoredCounters_AreReflectedInStats() - { - var store = new FakeSettingsStore(); - store.Preload(SettingsKeys.MlDecisions, "7"); - store.Preload(SettingsKeys.AiDecisions, "3"); - LocalMlClient client = CreateClient(store); - - MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None); - - Assert.Equal(7, status.Stats.Ml); - Assert.Equal(3, status.Stats.Ai); - Assert.True(status.Enabled); // mlEnabled не задан — дефолт true - } - - [Fact] - public async Task StatusAsync_StoredMlEnabledFalse_Disables() - { - var store = new FakeSettingsStore(); - store.Preload(SettingsKeys.MlEnabled, "false"); - - MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); - - Assert.False(status.Enabled); - } - - [Fact] - public async Task StatusAsync_StoredMlEnabledTrue_Enables() - { - var store = new FakeSettingsStore(); - store.Preload(SettingsKeys.MlEnabled, "true"); - - MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); - - Assert.True(status.Enabled); - } - - [Theory] - [InlineData("\"yes\"")] // строка, а не bool — как python-значение 'yes' is not False - [InlineData("не json {{")] - public async Task StatusAsync_NonBoolOrDamagedMlEnabled_DegradesToDefault(string valueJson) - { - var store = new FakeSettingsStore(); - store.Preload(SettingsKeys.MlEnabled, valueJson); - - MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); - - Assert.True(status.Enabled); // мягкая семантика: повреждённое значение → дефолт (как SettingsService) - } - - [Fact] - public async Task StatusAsync_DamagedCounter_DegradesToZero() - { - var store = new FakeSettingsStore(); - store.Preload(SettingsKeys.MlDecisions, "не число"); - - MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); - - Assert.Equal(0, status.Stats.Ml); - } - - [Fact] - public async Task StatusAsync_ReportsLearningAndOutboxFromTables() - { - var store = new FakeSettingsStore(); - var learning = new FakeMlLearningStore { LearningCount = 3 }; // журнал CardMoves: 3 записи - LocalMlClient client = CreateClient(store, learning); - await client.PushAsync("Разработчик Python, готов к проекту", "b_alpha", 1.0, CancellationToken.None); - await client.PushAsync("Спам: купите курсы", "spam", -1.0, CancellationToken.None); - - MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None); - - Assert.Equal(3, status.Stats.Learning); - Assert.Equal(2, status.Stats.Outbox); - Assert.Equal(0, status.Stats.Ml); - Assert.Equal(0, status.Stats.Ai); - } - - - [Fact] - public async Task PredictAsync_AnyText_ReturnsFixedNotReadyShape() - { - LocalMlClient client = CreateClient(new FakeSettingsStore()); - - MlPredictResultDto result = await client.PredictAsync( - "Python backend на fastapi, бот в телеграм", CancellationToken.None); - - Assert.False(result.Take); - Assert.Null(result.Label); - Assert.Empty(result.Scores); - Assert.Equal(0, result.Hits); - Assert.False(result.Ready); - Assert.Null(result.Margin); - Assert.Empty(result.Terms); - Assert.Null(result.Type); - } - - - [Fact] - public async Task PushAsync_WritesOutboxRow_WithMleIdAndTrimmedFields() - { - var learning = new FakeMlLearningStore(); - LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); - - await client.PushAsync(" Python-разработчик, готов к работе ", " b_alpha ", 1.0, CancellationToken.None); - - (string Id, string Text, string Label, double Delta) row = Assert.Single(learning.AddedRows); - Assert.StartsWith(KanbanIdPrefixes.MlOutbox, row.Id); // короткий id: mle_ + случайный hex (store.uid) - Assert.Equal(KanbanIdPrefixes.MlOutbox.Length + RandomHexLength, row.Id.Length); - Assert.Equal("Python-разработчик, готов к работе", row.Text); // текст trim-нут - Assert.Equal("b_alpha", row.Label); // метка trim-нута - Assert.Equal(1.0, row.Delta); - } - - [Theory] - [InlineData("", "b_alpha")] // пустой text - [InlineData(" ", "spam")] // text из пробелов — пуст после trim - [InlineData("текст", "")] // пустая label - [InlineData("текст", " ")] // label из пробелов — пуста после trim - public async Task PushAsync_EmptyTextOrLabelAfterTrim_IsNoOp(string text, string label) - { - var learning = new FakeMlLearningStore(); - LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); - - await client.PushAsync(text, label, 1.0, CancellationToken.None); - - Assert.Empty(learning.AddedRows); - } - - [Fact] - public async Task PushAsync_TextOver6000_IsTruncatedToMaxLength() - { - var learning = new FakeMlLearningStore(); - LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); - string longText = new string('а', MaxLearningTextLength + 1000); - - await client.PushAsync(longText, "spam", 1.0, CancellationToken.None); - - (string Id, string Text, string Label, double Delta) row = Assert.Single(learning.AddedRows); - Assert.Equal(MaxLearningTextLength, row.Text.Length); // text[:6000] (ml_client.push L48) - Assert.Equal(longText[..MaxLearningTextLength], row.Text); - } - - [Fact] - public async Task PushAsync_TruncationDoesNotSplitSurrogatePair() - { - var learning = new FakeMlLearningStore(); - LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); - // Длина 6001 в UTF-16: 5999 'x' + «🙂» (суррогатная пара) — срез .NET на 6000 попадает в пару. - string text = new string('x', MaxLearningTextLength - 1) + "🙂"; - - await client.PushAsync(text, "b_alpha", 1.0, CancellationToken.None); - - (string Id, string Text, string Label, double Delta) row = Assert.Single(learning.AddedRows); - // Хвостовой high-surrogate срезан — в БД не уходит «битая» пара (Python режет по code points). - Assert.Equal(MaxLearningTextLength - 1, row.Text.Length); - Assert.Equal(new string('x', MaxLearningTextLength - 1), row.Text); - } - - [Fact] - public async Task PushAsync_NegativeDelta_IsStoredAsIs() - { - var learning = new FakeMlLearningStore(); - LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); - - await client.PushAsync("Это не спам — вернул из корзины", "spam", -1.0, CancellationToken.None); - - (string Id, string Text, string Label, double Delta) row = Assert.Single(learning.AddedRows); - Assert.Equal("spam", row.Label); - Assert.Equal(-1.0, row.Delta); - } - - - [Fact] - public async Task ResetAsync_ReturnsOk() - { - MlResetResultDto result = await CreateClient(new FakeSettingsStore()).ResetAsync(CancellationToken.None); - - Assert.True(result.Ok); - Assert.Null(result.Error); - } - - [Fact] - public async Task ResetAsync_DoesNotTouchSettingsRows() - { - var store = new FakeSettingsStore(); - store.Preload(SettingsKeys.MlDecisions, "7"); - LocalMlClient client = CreateClient(store); - - MlResetResultDto result = await client.ResetAsync(CancellationToken.None); - - Assert.True(result.Ok); - // Прототип чистит только очередь обучения (ml_outbox), KV-счётчики остаются на месте. - Assert.Equal("7", store.GetStoredJson(SettingsKeys.MlDecisions)); - } - - [Fact] - public async Task ResetAsync_ClearsOutbox_KeepsLearningJournalAndKvCounters() - { - var store = new FakeSettingsStore(); - store.Preload(SettingsKeys.MlDecisions, "7"); - var learning = new FakeMlLearningStore { LearningCount = 4 }; // журнал CardMoves: 4 записи - LocalMlClient client = CreateClient(store, learning); - await client.PushAsync("текст 1", "b_alpha", 1.0, CancellationToken.None); - await client.PushAsync("текст 2", "spam", -1.0, CancellationToken.None); - - MlResetResultDto result = await client.ResetAsync(CancellationToken.None); - - Assert.True(result.Ok); - Assert.Empty(learning.AddedRows); // очередь MlOutbox очищена (reset_model L122) - Assert.Equal(4, learning.LearningCount); // журнал CardMoves reset не трогает (Ruling 4) - Assert.Equal("7", store.GetStoredJson(SettingsKeys.MlDecisions)); // KV-счётчики не тронуты - } - - - [Fact] - public async Task MlStatusResponseDto_SerializesToPrototypeWireFormat() - { - var store = new FakeSettingsStore(); - store.Preload(SettingsKeys.MlEnabled, "false"); - store.Preload(SettingsKeys.MlDecisions, "7"); - store.Preload(SettingsKeys.AiDecisions, "3"); - MlStatusResponseDto dto = await CreateClient(store).StatusAsync(CancellationToken.None); - - string json = JsonSerializer.Serialize(dto, CamelCaseOptions); - - using JsonDocument document = JsonDocument.Parse(json); - JsonElement root = document.RootElement; - Assert.True(root.TryGetProperty("enabled", out JsonElement enabled) && enabled.GetBoolean() == false); - Assert.True(root.TryGetProperty("reachable", out JsonElement reachable) && reachable.GetBoolean()); - - // service: {ready, classes, learned, eval:{count, correct, accuracy}} - Assert.True(root.TryGetProperty("service", out JsonElement service) && service.ValueKind == JsonValueKind.Object); - Assert.True(service.TryGetProperty("ready", out JsonElement serviceReady) && serviceReady.GetBoolean() == false); - Assert.True(service.TryGetProperty("classes", out JsonElement classes) && classes.ValueKind == JsonValueKind.Object); - Assert.True(service.TryGetProperty("learned", out JsonElement learned) && learned.GetInt32() == 0); - Assert.True(service.TryGetProperty("eval", out JsonElement eval) && eval.ValueKind == JsonValueKind.Object); - Assert.True(eval.TryGetProperty("count", out JsonElement count) && count.GetInt32() == 0); - Assert.True(eval.TryGetProperty("correct", out JsonElement correct) && correct.GetInt32() == 0); - Assert.True(eval.TryGetProperty("accuracy", out JsonElement accuracy) && accuracy.GetDouble() == 0.0); - - // stats: {ml, ai, learning, ready, classes, learned, reachable, outbox} - Assert.True(root.TryGetProperty("stats", out JsonElement stats) && stats.ValueKind == JsonValueKind.Object); - Assert.True(stats.TryGetProperty("ml", out JsonElement ml) && ml.GetInt32() == 7); - Assert.True(stats.TryGetProperty("ai", out JsonElement ai) && ai.GetInt32() == 3); - Assert.True(stats.TryGetProperty("learning", out JsonElement learning) && learning.GetInt32() == 0); - Assert.True(stats.TryGetProperty("ready", out JsonElement statsReady) && statsReady.GetBoolean() == false); - Assert.True(stats.TryGetProperty("classes", out JsonElement statsClasses) && statsClasses.ValueKind == JsonValueKind.Object); - Assert.True(stats.TryGetProperty("learned", out JsonElement statsLearned) && statsLearned.GetInt32() == 0); - Assert.True(stats.TryGetProperty("reachable", out JsonElement statsReachable) && statsReachable.GetBoolean()); - Assert.True(stats.TryGetProperty("outbox", out JsonElement outbox) && outbox.GetInt32() == 0); - } - - [Fact] - public async Task MlPredictResultDto_SerializesToPrototypeWireFormat() - { - MlPredictResultDto dto = await CreateClient(new FakeSettingsStore()).PredictAsync("текст", CancellationToken.None); - - string json = JsonSerializer.Serialize(dto, CamelCaseOptions); - - Assert.Equal( - "{\"take\":false,\"label\":null,\"scores\":{},\"hits\":0,\"ready\":false," - + "\"margin\":null,\"terms\":[],\"type\":null}", - json); - } - - [Fact] - public async Task MlResetResultDto_Success_SerializesWithoutErrorKey() - { - MlResetResultDto dto = await CreateClient(new FakeSettingsStore()).ResetAsync(CancellationToken.None); - - string json = JsonSerializer.Serialize(dto, CamelCaseOptions); - - Assert.Equal("{\"ok\":true}", json); - } -} +using System.Text.Json; +using Deal.Contracts.Integrations.Models; +using Deal.Infrastructure.Integrations.Services; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Settings.Application.Models; +using Deal.Tests.Unit.Modules.Kanban; +using Deal.Tests.Unit.Modules.Settings; + +namespace Deal.Tests.Unit.Support; + +/// +/// Локальная реализация IMlClient без внешнего ML-сервиса. +/// +public sealed class LocalMlClientTests +{ + // Опции сериализации, как у минимальных API (JsonSerializerDefaults.Web → camelCase). + private static readonly JsonSerializerOptions CamelCaseOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private const int MaxLearningTextLength = 6000; + + private const int RandomHexLength = 12; + + // Создаёт LocalMlClient поверх in-memory KV-хранилища и хранилища обучения. + // store: Хранилище настроек тенанта. + // learning: Хранилище обучения (по умолчанию пустое: счётчики 0). + // Возвращает: Экземпляр LocalMlClient. + private static LocalMlClient CreateClient(FakeSettingsStore store, FakeMlLearningStore? learning = null) + => new(store, learning ?? new FakeMlLearningStore()); + + + [Fact] + public async Task StatusAsync_NoSettings_ReturnsDeterministicNotReadyStatus() + { + LocalMlClient client = CreateClient(new FakeSettingsStore()); + + MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None); + + // Верхний уровень: enabled = дефолт mlEnabled (true), reachable=true (заглушка «жива»). + Assert.True(status.Enabled); + Assert.True(status.Reachable); + + Assert.False(status.Service.Ready); + Assert.Empty(status.Service.Classes); + Assert.Equal(0, status.Service.Learned); + Assert.Equal(0, status.Service.Eval.Count); + Assert.Equal(0, status.Service.Eval.Correct); + Assert.Equal(0.0, status.Service.Eval.Accuracy); + + // Статистика: счётчики ml/ai пусты (0), learning/outbox — 0 (таблиц нет), зеркало service. + Assert.Equal(0, status.Stats.Ml); + Assert.Equal(0, status.Stats.Ai); + Assert.Equal(0, status.Stats.Learning); + Assert.False(status.Stats.Ready); + Assert.Empty(status.Stats.Classes); + Assert.Equal(0, status.Stats.Learned); + Assert.True(status.Stats.Reachable); + Assert.Equal(0, status.Stats.Outbox); + } + + [Fact] + public async Task StatusAsync_StoredCounters_AreReflectedInStats() + { + var store = new FakeSettingsStore(); + store.Preload(SettingsKeys.MlDecisions, "7"); + store.Preload(SettingsKeys.AiDecisions, "3"); + LocalMlClient client = CreateClient(store); + + MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None); + + Assert.Equal(7, status.Stats.Ml); + Assert.Equal(3, status.Stats.Ai); + Assert.True(status.Enabled); // mlEnabled не задан — дефолт true + } + + [Fact] + public async Task StatusAsync_StoredMlEnabledFalse_Disables() + { + var store = new FakeSettingsStore(); + store.Preload(SettingsKeys.MlEnabled, "false"); + + MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); + + Assert.False(status.Enabled); + } + + [Fact] + public async Task StatusAsync_StoredMlEnabledTrue_Enables() + { + var store = new FakeSettingsStore(); + store.Preload(SettingsKeys.MlEnabled, "true"); + + MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); + + Assert.True(status.Enabled); + } + + [Theory] + [InlineData("\"yes\"")] // строка, а не bool — как python-значение 'yes' is not False + [InlineData("не json {{")] + public async Task StatusAsync_NonBoolOrDamagedMlEnabled_DegradesToDefault(string valueJson) + { + var store = new FakeSettingsStore(); + store.Preload(SettingsKeys.MlEnabled, valueJson); + + MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); + + Assert.True(status.Enabled); // мягкая семантика: повреждённое значение → дефолт (как SettingsService) + } + + [Fact] + public async Task StatusAsync_DamagedCounter_DegradesToZero() + { + var store = new FakeSettingsStore(); + store.Preload(SettingsKeys.MlDecisions, "не число"); + + MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); + + Assert.Equal(0, status.Stats.Ml); + } + + [Fact] + public async Task StatusAsync_ReportsLearningAndOutboxFromTables() + { + var store = new FakeSettingsStore(); + var learning = new FakeMlLearningStore { LearningCount = 3 }; // журнал CardMoves: 3 записи + LocalMlClient client = CreateClient(store, learning); + await client.PushAsync("Разработчик Python, готов к проекту", "b_alpha", 1.0, CancellationToken.None); + await client.PushAsync("Спам: купите курсы", "spam", -1.0, CancellationToken.None); + + MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None); + + Assert.Equal(3, status.Stats.Learning); + Assert.Equal(2, status.Stats.Outbox); + Assert.Equal(0, status.Stats.Ml); + Assert.Equal(0, status.Stats.Ai); + } + + + [Fact] + public async Task PredictAsync_AnyText_ReturnsFixedNotReadyShape() + { + LocalMlClient client = CreateClient(new FakeSettingsStore()); + + MlPredictResultDto result = await client.PredictAsync( + "Python backend на fastapi, бот в телеграм", CancellationToken.None); + + Assert.False(result.Take); + Assert.Null(result.Label); + Assert.Empty(result.Scores); + Assert.Equal(0, result.Hits); + Assert.False(result.Ready); + Assert.Null(result.Margin); + Assert.Empty(result.Terms); + Assert.Null(result.Type); + } + + + [Fact] + public async Task PushAsync_WritesOutboxRow_WithMleIdAndTrimmedFields() + { + var learning = new FakeMlLearningStore(); + LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); + + await client.PushAsync(" Python-разработчик, готов к работе ", " b_alpha ", 1.0, CancellationToken.None); + + (string Id, string Text, string Label, double Delta) row = Assert.Single(learning.AddedRows); + Assert.StartsWith(KanbanIdPrefixes.MlOutbox, row.Id); // короткий id: mle_ + случайный hex (store.uid) + Assert.Equal(KanbanIdPrefixes.MlOutbox.Length + RandomHexLength, row.Id.Length); + Assert.Equal("Python-разработчик, готов к работе", row.Text); // текст trim-нут + Assert.Equal("b_alpha", row.Label); // метка trim-нута + Assert.Equal(1.0, row.Delta); + } + + [Theory] + [InlineData("", "b_alpha")] // пустой text + [InlineData(" ", "spam")] // text из пробелов — пуст после trim + [InlineData("текст", "")] // пустая label + [InlineData("текст", " ")] // label из пробелов — пуста после trim + public async Task PushAsync_EmptyTextOrLabelAfterTrim_IsNoOp(string text, string label) + { + var learning = new FakeMlLearningStore(); + LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); + + await client.PushAsync(text, label, 1.0, CancellationToken.None); + + Assert.Empty(learning.AddedRows); + } + + [Fact] + public async Task PushAsync_TextOver6000_IsTruncatedToMaxLength() + { + var learning = new FakeMlLearningStore(); + LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); + string longText = new string('а', MaxLearningTextLength + 1000); + + await client.PushAsync(longText, "spam", 1.0, CancellationToken.None); + + (string Id, string Text, string Label, double Delta) row = Assert.Single(learning.AddedRows); + Assert.Equal(MaxLearningTextLength, row.Text.Length); // text[:6000] (ml_client.push L48) + Assert.Equal(longText[..MaxLearningTextLength], row.Text); + } + + [Fact] + public async Task PushAsync_TruncationDoesNotSplitSurrogatePair() + { + var learning = new FakeMlLearningStore(); + LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); + // Длина 6001 в UTF-16: 5999 'x' + «🙂» (суррогатная пара) — срез .NET на 6000 попадает в пару. + string text = new string('x', MaxLearningTextLength - 1) + "🙂"; + + await client.PushAsync(text, "b_alpha", 1.0, CancellationToken.None); + + (string Id, string Text, string Label, double Delta) row = Assert.Single(learning.AddedRows); + // Хвостовой high-surrogate срезан — в БД не уходит «битая» пара (Python режет по code points). + Assert.Equal(MaxLearningTextLength - 1, row.Text.Length); + Assert.Equal(new string('x', MaxLearningTextLength - 1), row.Text); + } + + [Fact] + public async Task PushAsync_NegativeDelta_IsStoredAsIs() + { + var learning = new FakeMlLearningStore(); + LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); + + await client.PushAsync("Это не спам — вернул из корзины", "spam", -1.0, CancellationToken.None); + + (string Id, string Text, string Label, double Delta) row = Assert.Single(learning.AddedRows); + Assert.Equal("spam", row.Label); + Assert.Equal(-1.0, row.Delta); + } + + + [Fact] + public async Task ResetAsync_ReturnsOk() + { + MlResetResultDto result = await CreateClient(new FakeSettingsStore()).ResetAsync(CancellationToken.None); + + Assert.True(result.Ok); + Assert.Null(result.Error); + } + + [Fact] + public async Task ResetAsync_DoesNotTouchSettingsRows() + { + var store = new FakeSettingsStore(); + store.Preload(SettingsKeys.MlDecisions, "7"); + LocalMlClient client = CreateClient(store); + + MlResetResultDto result = await client.ResetAsync(CancellationToken.None); + + Assert.True(result.Ok); + // Прототип чистит только очередь обучения (ml_outbox), KV-счётчики остаются на месте. + Assert.Equal("7", store.GetStoredJson(SettingsKeys.MlDecisions)); + } + + [Fact] + public async Task ResetAsync_ClearsOutbox_KeepsLearningJournalAndKvCounters() + { + var store = new FakeSettingsStore(); + store.Preload(SettingsKeys.MlDecisions, "7"); + var learning = new FakeMlLearningStore { LearningCount = 4 }; // журнал CardMoves: 4 записи + LocalMlClient client = CreateClient(store, learning); + await client.PushAsync("текст 1", "b_alpha", 1.0, CancellationToken.None); + await client.PushAsync("текст 2", "spam", -1.0, CancellationToken.None); + + MlResetResultDto result = await client.ResetAsync(CancellationToken.None); + + Assert.True(result.Ok); + Assert.Empty(learning.AddedRows); // очередь MlOutbox очищена (reset_model L122) + Assert.Equal(4, learning.LearningCount); // журнал CardMoves reset не трогает (Ruling 4) + Assert.Equal("7", store.GetStoredJson(SettingsKeys.MlDecisions)); // KV-счётчики не тронуты + } + + + [Fact] + public async Task MlStatusResponseDto_SerializesToPrototypeWireFormat() + { + var store = new FakeSettingsStore(); + store.Preload(SettingsKeys.MlEnabled, "false"); + store.Preload(SettingsKeys.MlDecisions, "7"); + store.Preload(SettingsKeys.AiDecisions, "3"); + MlStatusResponseDto dto = await CreateClient(store).StatusAsync(CancellationToken.None); + + string json = JsonSerializer.Serialize(dto, CamelCaseOptions); + + using JsonDocument document = JsonDocument.Parse(json); + JsonElement root = document.RootElement; + Assert.True(root.TryGetProperty("enabled", out JsonElement enabled) && enabled.GetBoolean() == false); + Assert.True(root.TryGetProperty("reachable", out JsonElement reachable) && reachable.GetBoolean()); + + // service: {ready, classes, learned, eval:{count, correct, accuracy}} + Assert.True(root.TryGetProperty("service", out JsonElement service) && service.ValueKind == JsonValueKind.Object); + Assert.True(service.TryGetProperty("ready", out JsonElement serviceReady) && serviceReady.GetBoolean() == false); + Assert.True(service.TryGetProperty("classes", out JsonElement classes) && classes.ValueKind == JsonValueKind.Object); + Assert.True(service.TryGetProperty("learned", out JsonElement learned) && learned.GetInt32() == 0); + Assert.True(service.TryGetProperty("eval", out JsonElement eval) && eval.ValueKind == JsonValueKind.Object); + Assert.True(eval.TryGetProperty("count", out JsonElement count) && count.GetInt32() == 0); + Assert.True(eval.TryGetProperty("correct", out JsonElement correct) && correct.GetInt32() == 0); + Assert.True(eval.TryGetProperty("accuracy", out JsonElement accuracy) && accuracy.GetDouble() == 0.0); + + // stats: {ml, ai, learning, ready, classes, learned, reachable, outbox} + Assert.True(root.TryGetProperty("stats", out JsonElement stats) && stats.ValueKind == JsonValueKind.Object); + Assert.True(stats.TryGetProperty("ml", out JsonElement ml) && ml.GetInt32() == 7); + Assert.True(stats.TryGetProperty("ai", out JsonElement ai) && ai.GetInt32() == 3); + Assert.True(stats.TryGetProperty("learning", out JsonElement learning) && learning.GetInt32() == 0); + Assert.True(stats.TryGetProperty("ready", out JsonElement statsReady) && statsReady.GetBoolean() == false); + Assert.True(stats.TryGetProperty("classes", out JsonElement statsClasses) && statsClasses.ValueKind == JsonValueKind.Object); + Assert.True(stats.TryGetProperty("learned", out JsonElement statsLearned) && statsLearned.GetInt32() == 0); + Assert.True(stats.TryGetProperty("reachable", out JsonElement statsReachable) && statsReachable.GetBoolean()); + Assert.True(stats.TryGetProperty("outbox", out JsonElement outbox) && outbox.GetInt32() == 0); + } + + [Fact] + public async Task MlPredictResultDto_SerializesToPrototypeWireFormat() + { + MlPredictResultDto dto = await CreateClient(new FakeSettingsStore()).PredictAsync("текст", CancellationToken.None); + + string json = JsonSerializer.Serialize(dto, CamelCaseOptions); + + Assert.Equal( + "{\"take\":false,\"label\":null,\"scores\":{},\"hits\":0,\"ready\":false," + + "\"margin\":null,\"terms\":[],\"type\":null}", + json); + } + + [Fact] + public async Task MlResetResultDto_Success_SerializesWithoutErrorKey() + { + MlResetResultDto dto = await CreateClient(new FakeSettingsStore()).ResetAsync(CancellationToken.None); + + string json = JsonSerializer.Serialize(dto, CamelCaseOptions); + + Assert.Equal("{\"ok\":true}", json); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/LoginAttemptEndpointHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/LoginAttemptEndpointHttpTests.cs index 83b75cd..8d76fd5 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/LoginAttemptEndpointHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/LoginAttemptEndpointHttpTests.cs @@ -1,176 +1,176 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Deal.Api.Configuration; -using Deal.Api.Services; -using Deal.Modules.Tenants.Application.Models; -using Deal.Tests.Unit.Modules.Tenants; - -namespace Deal.Tests.Unit.Support; - -/// -/// HTTP-тесты защиты входа на ручке POST /api/auth/login -/// -public sealed class LoginAttemptEndpointHttpTests -{ - // Логин пользователя сценария. - private const string Login = "user@example.com"; - - // Верный пароль пользователя сценария. - private const string Password = "secret-password"; - - // Неверный пароль попыток. - private const string WrongPassword = "wrong-password"; - - // Порог блокировки сценариев (совпадает с дефолтом RateLimitOptions). - private const int MaxAttempts = 5; - - // ─── Блокировка после 5 неудач ───────────────────────────────────────── - - /// - /// 5 неудачных входов → 6-я попытка - /// - [Fact] - public async Task Login_AfterFiveWrongAttempts_Returns429WithBlockedDetail() - { - await OperatorAuthHttpHost.RunAsync( - new FakeOperatorAuthStore(), - NewUserStore(), - async (baseAddress, _, users, _, _, _, _) => - { - HttpClient client = CreateClient(baseAddress); - - for (int attempt = 0; attempt < MaxAttempts; attempt++) - { - using HttpResponseMessage failed = await PostJsonAsync( - client, $"{baseAddress}/api/auth/login", new { login = Login, password = WrongPassword }); - Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode); - } - - using HttpResponseMessage blocked = await PostJsonAsync( - client, $"{baseAddress}/api/auth/login", new { login = Login, password = Password }); - Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode); - Assert.Equal( - LoginAttemptGuard.BlockedDetail, - (await ReadJsonAsync(blocked)).GetProperty("detail").GetString()); - Assert.Single(users.Users); - }, - rateLimitOptions: EnabledRateLimitOptions()); - } - - /// - /// Успешный вход сбрасывает счётчик - /// - [Fact] - public async Task Login_SuccessResetsCounter_FurtherFailuresNotBlocked() - { - await OperatorAuthHttpHost.RunAsync( - new FakeOperatorAuthStore(), - NewUserStore(), - async (baseAddress, _, users, _, _, _, _) => - { - HttpClient client = CreateClient(baseAddress); - - for (int attempt = 0; attempt < MaxAttempts - 1; attempt++) - { - using HttpResponseMessage failed = await PostJsonAsync( - client, $"{baseAddress}/api/auth/login", new { login = Login, password = WrongPassword }); - Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode); - } - - using (HttpResponseMessage ok = await PostJsonAsync( - client, $"{baseAddress}/api/auth/login", new { login = Login, password = Password })) - { - Assert.Equal(HttpStatusCode.OK, ok.StatusCode); - Assert.True(ok.Headers.Contains("Set-Cookie")); - } - - // Успех сбросил 4 неудачи: следующие 2 сбоя — обычные 401 (без сброса 2-й был бы 429). - for (int attempt = 0; attempt < 2; attempt++) - { - using HttpResponseMessage failed = await PostJsonAsync( - client, $"{baseAddress}/api/auth/login", new { login = Login, password = WrongPassword }); - Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode); - } - }, - rateLimitOptions: EnabledRateLimitOptions()); - } - - // ─── Учёт подозрительной активности ────────────────────────────── - - /// - /// Блокировка входа после серии неудач → событие login_blocked в метрике - /// - [Fact] - public async Task Login_WhenBlocked_ReportsLoginBlockedSecurityEvent() - { - using var capture = new SecurityEventCapture(); - - await OperatorAuthHttpHost.RunAsync( - new FakeOperatorAuthStore(), - NewUserStore(), - async (baseAddress, _, _, _, _, _, _) => - { - HttpClient client = CreateClient(baseAddress); - - for (int attempt = 0; attempt < MaxAttempts; attempt++) - { - using HttpResponseMessage failed = await PostJsonAsync( - client, $"{baseAddress}/api/auth/login", new { login = Login, password = WrongPassword }); - Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode); - } - - using HttpResponseMessage blocked = await PostJsonAsync( - client, $"{baseAddress}/api/auth/login", new { login = Login, password = Password }); - Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode); - }, - rateLimitOptions: EnabledRateLimitOptions()); - - Assert.True(capture.Sum(SuspiciousActivityReporter.LoginBlockedKind) >= 1); - } - - // ─── Хелперы ───────────────────────────────────────────────────────── - - private static RateLimitOptions EnabledRateLimitOptions() => - new() { Enabled = true }; - - // Фейк-хранилище пользователей с активным пользователем Login/Password. - private static FakeAuthStore NewUserStore() - { - var passwordHasher = new FakePasswordHasher(); - var store = new FakeAuthStore(); - store.AddUser(new StoredUserDto( - Guid.NewGuid(), - Login, - TenantId: Guid.NewGuid(), - Status: "active", - PasswordHash: passwordHasher.Hash(Password))); - return store; - } - - // HTTP-клиент с собственным CookieContainer. - private static HttpClient CreateClient(string baseAddress) => - new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) - { - BaseAddress = new Uri(baseAddress), - }; - - // POST JSON-тела и возврат ответа. - private static async Task PostJsonAsync( - HttpClient client, - string url, - object body) - { - string json = JsonSerializer.Serialize(body); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - return await client.PostAsync(url, content); - } - - // Читает тело ответа как JSON-документ (корень, отвязанный от документа). - private static async Task ReadJsonAsync(HttpResponseMessage response) - { - await using Stream stream = await response.Content.ReadAsStreamAsync(); - using var document = await JsonDocument.ParseAsync(stream); - return document.RootElement.Clone(); - } -} +using System.Net; +using System.Text; +using System.Text.Json; +using Deal.Api.Configuration; +using Deal.Api.Services; +using Deal.Modules.Tenants.Application.Models; +using Deal.Tests.Unit.Modules.Tenants; + +namespace Deal.Tests.Unit.Support; + +/// +/// HTTP-тесты защиты входа на ручке POST /api/auth/login +/// +public sealed class LoginAttemptEndpointHttpTests +{ + // Логин пользователя сценария. + private const string Login = "user@example.com"; + + // Верный пароль пользователя сценария. + private const string Password = "secret-password"; + + // Неверный пароль попыток. + private const string WrongPassword = "wrong-password"; + + // Порог блокировки сценариев (совпадает с дефолтом RateLimitOptions). + private const int MaxAttempts = 5; + + // ─── Блокировка после 5 неудач ───────────────────────────────────────── + + /// + /// 5 неудачных входов → 6-я попытка + /// + [Fact] + public async Task Login_AfterFiveWrongAttempts_Returns429WithBlockedDetail() + { + await OperatorAuthHttpHost.RunAsync( + new FakeOperatorAuthStore(), + NewUserStore(), + async (baseAddress, _, users, _, _, _, _) => + { + HttpClient client = CreateClient(baseAddress); + + for (int attempt = 0; attempt < MaxAttempts; attempt++) + { + using HttpResponseMessage failed = await PostJsonAsync( + client, $"{baseAddress}/api/auth/login", new { login = Login, password = WrongPassword }); + Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode); + } + + using HttpResponseMessage blocked = await PostJsonAsync( + client, $"{baseAddress}/api/auth/login", new { login = Login, password = Password }); + Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode); + Assert.Equal( + LoginAttemptGuard.BlockedDetail, + (await ReadJsonAsync(blocked)).GetProperty("detail").GetString()); + Assert.Single(users.Users); + }, + rateLimitOptions: EnabledRateLimitOptions()); + } + + /// + /// Успешный вход сбрасывает счётчик + /// + [Fact] + public async Task Login_SuccessResetsCounter_FurtherFailuresNotBlocked() + { + await OperatorAuthHttpHost.RunAsync( + new FakeOperatorAuthStore(), + NewUserStore(), + async (baseAddress, _, users, _, _, _, _) => + { + HttpClient client = CreateClient(baseAddress); + + for (int attempt = 0; attempt < MaxAttempts - 1; attempt++) + { + using HttpResponseMessage failed = await PostJsonAsync( + client, $"{baseAddress}/api/auth/login", new { login = Login, password = WrongPassword }); + Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode); + } + + using (HttpResponseMessage ok = await PostJsonAsync( + client, $"{baseAddress}/api/auth/login", new { login = Login, password = Password })) + { + Assert.Equal(HttpStatusCode.OK, ok.StatusCode); + Assert.True(ok.Headers.Contains("Set-Cookie")); + } + + // Успех сбросил 4 неудачи: следующие 2 сбоя — обычные 401 (без сброса 2-й был бы 429). + for (int attempt = 0; attempt < 2; attempt++) + { + using HttpResponseMessage failed = await PostJsonAsync( + client, $"{baseAddress}/api/auth/login", new { login = Login, password = WrongPassword }); + Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode); + } + }, + rateLimitOptions: EnabledRateLimitOptions()); + } + + // ─── Учёт подозрительной активности ────────────────────────────── + + /// + /// Блокировка входа после серии неудач → событие login_blocked в метрике + /// + [Fact] + public async Task Login_WhenBlocked_ReportsLoginBlockedSecurityEvent() + { + using var capture = new SecurityEventCapture(); + + await OperatorAuthHttpHost.RunAsync( + new FakeOperatorAuthStore(), + NewUserStore(), + async (baseAddress, _, _, _, _, _, _) => + { + HttpClient client = CreateClient(baseAddress); + + for (int attempt = 0; attempt < MaxAttempts; attempt++) + { + using HttpResponseMessage failed = await PostJsonAsync( + client, $"{baseAddress}/api/auth/login", new { login = Login, password = WrongPassword }); + Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode); + } + + using HttpResponseMessage blocked = await PostJsonAsync( + client, $"{baseAddress}/api/auth/login", new { login = Login, password = Password }); + Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode); + }, + rateLimitOptions: EnabledRateLimitOptions()); + + Assert.True(capture.Sum(SuspiciousActivityReporter.LoginBlockedKind) >= 1); + } + + // ─── Хелперы ───────────────────────────────────────────────────────── + + private static RateLimitOptions EnabledRateLimitOptions() => + new() { Enabled = true }; + + // Фейк-хранилище пользователей с активным пользователем Login/Password. + private static FakeAuthStore NewUserStore() + { + var passwordHasher = new FakePasswordHasher(); + var store = new FakeAuthStore(); + store.AddUser(new StoredUserDto( + Guid.NewGuid(), + Login, + TenantId: Guid.NewGuid(), + Status: "active", + PasswordHash: passwordHasher.Hash(Password))); + return store; + } + + // HTTP-клиент с собственным CookieContainer. + private static HttpClient CreateClient(string baseAddress) => + new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) + { + BaseAddress = new Uri(baseAddress), + }; + + // POST JSON-тела и возврат ответа. + private static async Task PostJsonAsync( + HttpClient client, + string url, + object body) + { + string json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await client.PostAsync(url, content); + } + + // Читает тело ответа как JSON-документ (корень, отвязанный от документа). + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + await using Stream stream = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(stream); + return document.RootElement.Clone(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/MlGrpcTestHost.cs b/src/core/tests/Deal.Tests.Unit/Support/MlGrpcTestHost.cs index 46618be..f221dd8 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/MlGrpcTestHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/MlGrpcTestHost.cs @@ -1,64 +1,64 @@ -using System.Net; -using Deal.Tests.Unit.Grpc; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Server.Kestrel.Core; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Tests.Unit.Support; - -internal static class MlGrpcTestHost -{ - /// - /// Env-ключ service-token - /// - public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - - /// - /// Токен сценариев теста. - /// - public const string DefaultToken = "deal-ml-test-token"; - - /// - /// Прогоняет сценарий против поднятого фейк-ml-service с заданным env-токеном. - /// - /// Значение env DEAL_SERVICE_TOKEN (null — убрать переменную). - /// Сервис-фейк (состояние сценария). - /// Сценарий: получает порт хоста и сервис-фейк для проверок. - public static async Task RunAsync( - string? serviceToken, - RecordingMlService service, - Func scenario) - { - string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); - - WebApplication? app = null; - try - { - int port = TestPort.Allocate(); - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.WebHost.ConfigureKestrel(kestrel => - kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2)); - - // gRPC-сервер ml.proto (фейк без токен-интерцептора — токен проверяет клиентский транспорт). - builder.Services.AddGrpc(); - builder.Services.AddSingleton(service); - app = builder.Build(); - app.MapGrpcService(); - await app.StartAsync(); - - await scenario(port, service); - } - finally - { - if (app is not null) - { - await app.StopAsync(); - await app.DisposeAsync(); - } - - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); - } - } -} +using System.Net; +using Deal.Tests.Unit.Grpc; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Tests.Unit.Support; + +internal static class MlGrpcTestHost +{ + /// + /// Env-ключ service-token + /// + public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; + + /// + /// Токен сценариев теста. + /// + public const string DefaultToken = "deal-ml-test-token"; + + /// + /// Прогоняет сценарий против поднятого фейк-ml-service с заданным env-токеном. + /// + /// Значение env DEAL_SERVICE_TOKEN (null — убрать переменную). + /// Сервис-фейк (состояние сценария). + /// Сценарий: получает порт хоста и сервис-фейк для проверок. + public static async Task RunAsync( + string? serviceToken, + RecordingMlService service, + Func scenario) + { + string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); + Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); + + WebApplication? app = null; + try + { + int port = TestPort.Allocate(); + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(kestrel => + kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2)); + + // gRPC-сервер ml.proto (фейк без токен-интерцептора — токен проверяет клиентский транспорт). + builder.Services.AddGrpc(); + builder.Services.AddSingleton(service); + app = builder.Build(); + app.MapGrpcService(); + await app.StartAsync(); + + await scenario(port, service); + } + finally + { + if (app is not null) + { + await app.StopAsync(); + await app.DisposeAsync(); + } + + Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); + } + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/MtlsCertificatesTests.cs b/src/core/tests/Deal.Tests.Unit/Support/MtlsCertificatesTests.cs index c63d77d..a9efa25 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/MtlsCertificatesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/MtlsCertificatesTests.cs @@ -1,330 +1,330 @@ -using System.Net.Security; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты загрузки/проверки сертификатов mTLS -/// -public sealed class MtlsCertificatesTests -{ - // Пароль тестовых PFX (в тесте — фиксированное dev-значение). - private const string PfxPassword = "deal-mtls-test-password"; - - // OID EKU серверной аутентификации (SAN-сертификаты сервисов). - private const string ServerAuthEkuOid = "1.3.6.1.5.5.7.3.1"; - - // OID EKU клиентской аутентификации (deal-client). - private const string ClientAuthEkuOid = "1.3.6.1.5.5.7.3.2"; - - /// - /// Флаг выключен — Load возвращает null - /// - [Fact] - public void Load_Disabled_ReturnsNull() - { - var options = new MtlsOptions { Enabled = false }; - - Assert.Null(MtlsCertificates.Load(options)); - } - - /// - /// Флаг включён, путь CA пуст — fail-fast с именем env-ключа в тексте. - /// - [Fact] - public void Load_EnabledWithoutCaPath_ThrowsFailFast() - { - var options = new MtlsOptions - { - Enabled = true, - ServerCertPfx = "server.pfx", - ClientCertPfx = "client.pfx", - }; - - InvalidOperationException exception = Assert.Throws( - () => MtlsCertificates.Load(options)); - - Assert.Contains(MtlsOptions.CaPemEnvKey, exception.Message); - } - - /// - /// Флаг включён, файл не существует — fail-fast с путём в тексте. - /// - [Fact] - public void Load_EnabledWithMissingFile_ThrowsFailFast() - { - string missingPath = Path.Combine(Path.GetTempPath(), $"deal-mtls-missing-{Guid.NewGuid():N}.pfx"); - var options = new MtlsOptions - { - Enabled = true, - CaPem = missingPath, - ServerCertPfx = missingPath, - ClientCertPfx = missingPath, - }; - - InvalidOperationException exception = Assert.Throws( - () => MtlsCertificates.Load(options)); - - Assert.Contains("файл не найден", exception.Message); - Assert.Contains(missingPath, exception.Message); - } - - /// - /// Флаг включён, корректные файлы — загружены CA + серверный + клиентский сертификаты. - /// - [Fact] - public void Load_EnabledWithValidFiles_LoadsAllCertificates() - { - using TempCertDir certs = CreateTestCerts(); - MtlsOptions options = EnabledOptions(certs.DirectoryPath); - - MtlsCertificates certificates = MtlsCertificates.Load(options)!; - - Assert.Equal("CN=Deal mTLS Test CA", certificates.CaCertificate.Subject); - Assert.True(certificates.ServerCertificate.HasPrivateKey); - Assert.True(certificates.ClientCertificate.HasPrivateKey); - Assert.Contains("localhost", certificates.ServerCertificate.GetNameInfo(X509NameType.DnsName, false)); - } - - /// - /// Флаг включён, неверный пароль PFX — fail-fast с env-ключом пароля в тексте. - /// - [Fact] - public void Load_EnabledWithWrongPfxPassword_ThrowsFailFast() - { - using TempCertDir certs = CreateTestCerts(); - MtlsOptions options = EnabledOptions(certs.DirectoryPath); - var wrongPasswordOptions = new MtlsOptions - { - Enabled = true, - CaPem = options.CaPem, - ServerCertPfx = options.ServerCertPfx, - ServerCertPassword = "не тот пароль", - ClientCertPfx = options.ClientCertPfx, - ClientCertPassword = options.ClientCertPassword, - }; - - InvalidOperationException exception = Assert.Throws( - () => MtlsCertificates.Load(wrongPasswordOptions)); - - Assert.Contains(MtlsOptions.ServerCertPasswordEnvKey, exception.Message); - } - - /// - /// Серверная проверка - /// - [Fact] - public void ValidateClientCertificate_SignedByOurCa_ChainErrorsAccepted() - { - using TempCertDir certs = CreateTestCerts(); - MtlsCertificates certificates = MtlsCertificates.Load(EnabledOptions(certs.DirectoryPath))!; - using X509Certificate2 client = LoadPfx(certs.ClientPfxPath); - - bool accepted = certificates.ValidateClientCertificate( - client, null, SslPolicyErrors.RemoteCertificateChainErrors); - - Assert.True(accepted); - } - - /// - /// Серверная проверка - /// - [Fact] - public void ValidateClientCertificate_SignedByForeignCa_Rejected() - { - using TempCertDir certs = CreateTestCerts(); - MtlsCertificates certificates = MtlsCertificates.Load(EnabledOptions(certs.DirectoryPath))!; - using TempCertDir foreignCerts = CreateTestCerts(); - using X509Certificate2 foreignClient = LoadPfx(foreignCerts.ClientPfxPath); - - bool accepted = certificates.ValidateClientCertificate( - foreignClient, null, SslPolicyErrors.RemoteCertificateChainErrors); - - Assert.False(accepted); - } - - /// - /// Серверная проверка - /// - [Fact] - public void ValidateClientCertificate_NullOrNameMismatch_Rejected() - { - using TempCertDir certs = CreateTestCerts(); - MtlsCertificates certificates = MtlsCertificates.Load(EnabledOptions(certs.DirectoryPath))!; - - Assert.False(certificates.ValidateClientCertificate(null, null, SslPolicyErrors.None)); - Assert.False(certificates.ValidateClientCertificate(null, null, SslPolicyErrors.RemoteCertificateChainErrors)); - } - - /// - /// Клиентский хендлер канала несёт клиентский сертификат и callback проверки CA сервера. - /// - [Fact] - public void CreateClientHttpHandler_HasClientCertificateAndServerValidation() - { - using TempCertDir certs = CreateTestCerts(); - MtlsCertificates certificates = MtlsCertificates.Load(EnabledOptions(certs.DirectoryPath))!; - - using SocketsHttpHandler handler = certificates.CreateClientHttpHandler(); - SslClientAuthenticationOptions sslOptions = handler.SslOptions!; - X509CertificateCollection clientCertificates = sslOptions.ClientCertificates!; - - Assert.NotNull(sslOptions.RemoteCertificateValidationCallback); - object? single = Assert.Single(clientCertificates); - X509Certificate2 clientCertificate = Assert.IsType(single); - Assert.Same(certificates.ClientCertificate, clientCertificate); - } - - /// - /// Выбор режима: при выключенном флаге Load не читает файлы вовсе - /// - [Fact] - public void Load_DisabledDoesNotTouchFiles() - { - // Даже битые пути при выключенном флаге не проверяются — режим plaintext не требует файлов. - var options = new MtlsOptions - { - Enabled = false, - CaPem = "no-such-ca.pem", - ServerCertPfx = "no-such-server.pfx", - ClientCertPfx = "no-such-client.pfx", - }; - - Assert.Null(MtlsCertificates.Load(options)); - } - - // Опции включённого mTLS с путями временного каталога. - // certsDirectory: Каталог с ca.pem/server.pfx/client.pfx. - private static MtlsOptions EnabledOptions(string certsDirectory) => new() - { - Enabled = true, - CaPem = Path.Combine(certsDirectory, "ca.pem"), - ServerCertPfx = Path.Combine(certsDirectory, "server.pfx"), - ServerCertPassword = PfxPassword, - ClientCertPfx = Path.Combine(certsDirectory, "client.pfx"), - ClientCertPassword = PfxPassword, - }; - - // Читает PFX теста (как MtlsCertificates.LoadPfx — с эфемерным ключом). - // path: Путь к PFX. - private static X509Certificate2 LoadPfx(string path) - => X509CertificateLoader.LoadPkcs12FromFile(path, PfxPassword, X509KeyStorageFlags.EphemeralKeySet); - - // Генерирует во временном каталоге dev-CA + серверный PFX (localhost) + клиентский PFX. - private static TempCertDir CreateTestCerts() - { - TempCertDir temp = TempCertDir.Create(); - string caPemPath = Path.Combine(temp.DirectoryPath, "ca.pem"); - string serverPfxPath = Path.Combine(temp.DirectoryPath, "server.pfx"); - string clientPfxPath = Path.Combine(temp.DirectoryPath, "client.pfx"); - - using X509Certificate2 ca = CreateCaCertificate(); - byte[] serverPfx = CreateSignedLeafPfx(ca, "localhost", ServerAuthEkuOid); - byte[] clientPfx = CreateSignedLeafPfx(ca, "deal-client", ClientAuthEkuOid); - - File.WriteAllText(caPemPath, ca.ExportCertificatePem()); - File.WriteAllBytes(serverPfxPath, serverPfx); - File.WriteAllBytes(clientPfxPath, clientPfx); - - return temp; - } - - // Создаёт самоподписанный CA-сертификат dev-теста (BasicConstraints CA:true + KeyCertSign). - private static X509Certificate2 CreateCaCertificate() - { - // Ключ намеренно не диспозится: CreateSelfSigned держит ссылку на него, а листья подписываются - // тем же CA до выхода из теста (managed-финализатор освободит ключ после). - RSA key = RSA.Create(2048); - var request = new CertificateRequest("CN=Deal mTLS Test CA", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - request.CertificateExtensions.Add(new X509BasicConstraintsExtension( - certificateAuthority: true, - hasPathLengthConstraint: false, - pathLengthConstraint: 0, - critical: true)); - request.CertificateExtensions.Add(new X509KeyUsageExtension( - X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign | X509KeyUsageFlags.DigitalSignature, - critical: true)); - request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); - return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-2), DateTimeOffset.UtcNow.AddYears(2)); - } - - // Создаёт листовой сертификат, подписанный CA, и экспортирует его в PFX-байты (SAN + EKU). - // issuer: Подписывающий CA (с ключом). - // commonName: CN и DNS-имя SAN. - // ekuOid: OID EKU (serverAuth/clientAuth). - private static byte[] CreateSignedLeafPfx( - X509Certificate2 issuer, - string commonName, - string ekuOid) - { - using RSA key = RSA.Create(2048); - var request = new CertificateRequest( - $"CN={commonName}", - key, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1); - var san = new SubjectAlternativeNameBuilder(); - san.AddDnsName(commonName); - request.CertificateExtensions.Add(san.Build()); - request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( - new OidCollection { new Oid(ekuOid) }, - critical: false)); - - byte[] serial = RandomNumberGenerator.GetBytes(8); - serial[0] &= 0x7F; // серийный номер положительный (сериализация ASN.1 INTEGER) - using X509Certificate2 signed = request.Create( - issuer, - DateTimeOffset.UtcNow.AddDays(-1), - DateTimeOffset.UtcNow.AddYears(1), - serial); - // Create(issuer…) возвращает сертификат без приватного ключа — привязываем ключ запроса явно - // (иначе PFX без ключа: HasPrivateKey=false, рукопожатие mTLS невозможно). - using X509Certificate2 withKey = signed.CopyWithPrivateKey(key); - return withKey.Export(X509ContentType.Pfx, PfxPassword); - } - - // Временный каталог сертификатов теста с авто-очисткой. - private sealed class TempCertDir : IDisposable - { - private TempCertDir(string directoryPath) - { - DirectoryPath = directoryPath; - } - - /// - /// Путь каталога. - /// - public string DirectoryPath { get; } - - /// - /// Путь к клиентскому PFX - /// - public string ClientPfxPath => Path.Combine(DirectoryPath, "client.pfx"); - - /// - /// Создаёт временный каталог теста. - /// - public static TempCertDir Create() - { - string path = Path.Combine(Path.GetTempPath(), $"deal-mtls-tests-{Guid.NewGuid():N}"); - Directory.CreateDirectory(path); - return new TempCertDir(path); - } - - /// - public void Dispose() - { - try - { - Directory.Delete(DirectoryPath, recursive: true); - } - catch (IOException) - { - // Временный каталог не удалился (файл занят ОС) — тест не валим, ОС почистит сама. - } - } - } -} +using System.Net.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Deal.Infrastructure.Integrations.Models; +using Deal.Infrastructure.Integrations.Options; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты загрузки/проверки сертификатов mTLS +/// +public sealed class MtlsCertificatesTests +{ + // Пароль тестовых PFX (в тесте — фиксированное dev-значение). + private const string PfxPassword = "deal-mtls-test-password"; + + // OID EKU серверной аутентификации (SAN-сертификаты сервисов). + private const string ServerAuthEkuOid = "1.3.6.1.5.5.7.3.1"; + + // OID EKU клиентской аутентификации (deal-client). + private const string ClientAuthEkuOid = "1.3.6.1.5.5.7.3.2"; + + /// + /// Флаг выключен — Load возвращает null + /// + [Fact] + public void Load_Disabled_ReturnsNull() + { + var options = new MtlsOptions { Enabled = false }; + + Assert.Null(MtlsCertificates.Load(options)); + } + + /// + /// Флаг включён, путь CA пуст — fail-fast с именем env-ключа в тексте. + /// + [Fact] + public void Load_EnabledWithoutCaPath_ThrowsFailFast() + { + var options = new MtlsOptions + { + Enabled = true, + ServerCertPfx = "server.pfx", + ClientCertPfx = "client.pfx", + }; + + InvalidOperationException exception = Assert.Throws( + () => MtlsCertificates.Load(options)); + + Assert.Contains(MtlsOptions.CaPemEnvKey, exception.Message); + } + + /// + /// Флаг включён, файл не существует — fail-fast с путём в тексте. + /// + [Fact] + public void Load_EnabledWithMissingFile_ThrowsFailFast() + { + string missingPath = Path.Combine(Path.GetTempPath(), $"deal-mtls-missing-{Guid.NewGuid():N}.pfx"); + var options = new MtlsOptions + { + Enabled = true, + CaPem = missingPath, + ServerCertPfx = missingPath, + ClientCertPfx = missingPath, + }; + + InvalidOperationException exception = Assert.Throws( + () => MtlsCertificates.Load(options)); + + Assert.Contains("файл не найден", exception.Message); + Assert.Contains(missingPath, exception.Message); + } + + /// + /// Флаг включён, корректные файлы — загружены CA + серверный + клиентский сертификаты. + /// + [Fact] + public void Load_EnabledWithValidFiles_LoadsAllCertificates() + { + using TempCertDir certs = CreateTestCerts(); + MtlsOptions options = EnabledOptions(certs.DirectoryPath); + + MtlsCertificates certificates = MtlsCertificates.Load(options)!; + + Assert.Equal("CN=Deal mTLS Test CA", certificates.CaCertificate.Subject); + Assert.True(certificates.ServerCertificate.HasPrivateKey); + Assert.True(certificates.ClientCertificate.HasPrivateKey); + Assert.Contains("localhost", certificates.ServerCertificate.GetNameInfo(X509NameType.DnsName, false)); + } + + /// + /// Флаг включён, неверный пароль PFX — fail-fast с env-ключом пароля в тексте. + /// + [Fact] + public void Load_EnabledWithWrongPfxPassword_ThrowsFailFast() + { + using TempCertDir certs = CreateTestCerts(); + MtlsOptions options = EnabledOptions(certs.DirectoryPath); + var wrongPasswordOptions = new MtlsOptions + { + Enabled = true, + CaPem = options.CaPem, + ServerCertPfx = options.ServerCertPfx, + ServerCertPassword = "не тот пароль", + ClientCertPfx = options.ClientCertPfx, + ClientCertPassword = options.ClientCertPassword, + }; + + InvalidOperationException exception = Assert.Throws( + () => MtlsCertificates.Load(wrongPasswordOptions)); + + Assert.Contains(MtlsOptions.ServerCertPasswordEnvKey, exception.Message); + } + + /// + /// Серверная проверка + /// + [Fact] + public void ValidateClientCertificate_SignedByOurCa_ChainErrorsAccepted() + { + using TempCertDir certs = CreateTestCerts(); + MtlsCertificates certificates = MtlsCertificates.Load(EnabledOptions(certs.DirectoryPath))!; + using X509Certificate2 client = LoadPfx(certs.ClientPfxPath); + + bool accepted = certificates.ValidateClientCertificate( + client, null, SslPolicyErrors.RemoteCertificateChainErrors); + + Assert.True(accepted); + } + + /// + /// Серверная проверка + /// + [Fact] + public void ValidateClientCertificate_SignedByForeignCa_Rejected() + { + using TempCertDir certs = CreateTestCerts(); + MtlsCertificates certificates = MtlsCertificates.Load(EnabledOptions(certs.DirectoryPath))!; + using TempCertDir foreignCerts = CreateTestCerts(); + using X509Certificate2 foreignClient = LoadPfx(foreignCerts.ClientPfxPath); + + bool accepted = certificates.ValidateClientCertificate( + foreignClient, null, SslPolicyErrors.RemoteCertificateChainErrors); + + Assert.False(accepted); + } + + /// + /// Серверная проверка + /// + [Fact] + public void ValidateClientCertificate_NullOrNameMismatch_Rejected() + { + using TempCertDir certs = CreateTestCerts(); + MtlsCertificates certificates = MtlsCertificates.Load(EnabledOptions(certs.DirectoryPath))!; + + Assert.False(certificates.ValidateClientCertificate(null, null, SslPolicyErrors.None)); + Assert.False(certificates.ValidateClientCertificate(null, null, SslPolicyErrors.RemoteCertificateChainErrors)); + } + + /// + /// Клиентский хендлер канала несёт клиентский сертификат и callback проверки CA сервера. + /// + [Fact] + public void CreateClientHttpHandler_HasClientCertificateAndServerValidation() + { + using TempCertDir certs = CreateTestCerts(); + MtlsCertificates certificates = MtlsCertificates.Load(EnabledOptions(certs.DirectoryPath))!; + + using SocketsHttpHandler handler = certificates.CreateClientHttpHandler(); + SslClientAuthenticationOptions sslOptions = handler.SslOptions!; + X509CertificateCollection clientCertificates = sslOptions.ClientCertificates!; + + Assert.NotNull(sslOptions.RemoteCertificateValidationCallback); + object? single = Assert.Single(clientCertificates); + X509Certificate2 clientCertificate = Assert.IsType(single); + Assert.Same(certificates.ClientCertificate, clientCertificate); + } + + /// + /// Выбор режима: при выключенном флаге Load не читает файлы вовсе + /// + [Fact] + public void Load_DisabledDoesNotTouchFiles() + { + // Даже битые пути при выключенном флаге не проверяются — режим plaintext не требует файлов. + var options = new MtlsOptions + { + Enabled = false, + CaPem = "no-such-ca.pem", + ServerCertPfx = "no-such-server.pfx", + ClientCertPfx = "no-such-client.pfx", + }; + + Assert.Null(MtlsCertificates.Load(options)); + } + + // Опции включённого mTLS с путями временного каталога. + // certsDirectory: Каталог с ca.pem/server.pfx/client.pfx. + private static MtlsOptions EnabledOptions(string certsDirectory) => new() + { + Enabled = true, + CaPem = Path.Combine(certsDirectory, "ca.pem"), + ServerCertPfx = Path.Combine(certsDirectory, "server.pfx"), + ServerCertPassword = PfxPassword, + ClientCertPfx = Path.Combine(certsDirectory, "client.pfx"), + ClientCertPassword = PfxPassword, + }; + + // Читает PFX теста (как MtlsCertificates.LoadPfx — с эфемерным ключом). + // path: Путь к PFX. + private static X509Certificate2 LoadPfx(string path) + => X509CertificateLoader.LoadPkcs12FromFile(path, PfxPassword, X509KeyStorageFlags.EphemeralKeySet); + + // Генерирует во временном каталоге dev-CA + серверный PFX (localhost) + клиентский PFX. + private static TempCertDir CreateTestCerts() + { + TempCertDir temp = TempCertDir.Create(); + string caPemPath = Path.Combine(temp.DirectoryPath, "ca.pem"); + string serverPfxPath = Path.Combine(temp.DirectoryPath, "server.pfx"); + string clientPfxPath = Path.Combine(temp.DirectoryPath, "client.pfx"); + + using X509Certificate2 ca = CreateCaCertificate(); + byte[] serverPfx = CreateSignedLeafPfx(ca, "localhost", ServerAuthEkuOid); + byte[] clientPfx = CreateSignedLeafPfx(ca, "deal-client", ClientAuthEkuOid); + + File.WriteAllText(caPemPath, ca.ExportCertificatePem()); + File.WriteAllBytes(serverPfxPath, serverPfx); + File.WriteAllBytes(clientPfxPath, clientPfx); + + return temp; + } + + // Создаёт самоподписанный CA-сертификат dev-теста (BasicConstraints CA:true + KeyCertSign). + private static X509Certificate2 CreateCaCertificate() + { + // Ключ намеренно не диспозится: CreateSelfSigned держит ссылку на него, а листья подписываются + // тем же CA до выхода из теста (managed-финализатор освободит ключ после). + RSA key = RSA.Create(2048); + var request = new CertificateRequest("CN=Deal mTLS Test CA", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension( + certificateAuthority: true, + hasPathLengthConstraint: false, + pathLengthConstraint: 0, + critical: true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign | X509KeyUsageFlags.DigitalSignature, + critical: true)); + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-2), DateTimeOffset.UtcNow.AddYears(2)); + } + + // Создаёт листовой сертификат, подписанный CA, и экспортирует его в PFX-байты (SAN + EKU). + // issuer: Подписывающий CA (с ключом). + // commonName: CN и DNS-имя SAN. + // ekuOid: OID EKU (serverAuth/clientAuth). + private static byte[] CreateSignedLeafPfx( + X509Certificate2 issuer, + string commonName, + string ekuOid) + { + using RSA key = RSA.Create(2048); + var request = new CertificateRequest( + $"CN={commonName}", + key, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + var san = new SubjectAlternativeNameBuilder(); + san.AddDnsName(commonName); + request.CertificateExtensions.Add(san.Build()); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + new OidCollection { new Oid(ekuOid) }, + critical: false)); + + byte[] serial = RandomNumberGenerator.GetBytes(8); + serial[0] &= 0x7F; // серийный номер положительный (сериализация ASN.1 INTEGER) + using X509Certificate2 signed = request.Create( + issuer, + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddYears(1), + serial); + // Create(issuer…) возвращает сертификат без приватного ключа — привязываем ключ запроса явно + // (иначе PFX без ключа: HasPrivateKey=false, рукопожатие mTLS невозможно). + using X509Certificate2 withKey = signed.CopyWithPrivateKey(key); + return withKey.Export(X509ContentType.Pfx, PfxPassword); + } + + // Временный каталог сертификатов теста с авто-очисткой. + private sealed class TempCertDir : IDisposable + { + private TempCertDir(string directoryPath) + { + DirectoryPath = directoryPath; + } + + /// + /// Путь каталога. + /// + public string DirectoryPath { get; } + + /// + /// Путь к клиентскому PFX + /// + public string ClientPfxPath => Path.Combine(DirectoryPath, "client.pfx"); + + /// + /// Создаёт временный каталог теста. + /// + public static TempCertDir Create() + { + string path = Path.Combine(Path.GetTempPath(), $"deal-mtls-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return new TempCertDir(path); + } + + /// + public void Dispose() + { + try + { + Directory.Delete(DirectoryPath, recursive: true); + } + catch (IOException) + { + // Временный каталог не удалился (файл занят ОС) — тест не валим, ОС почистит сама. + } + } + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs index 252b9cb..f90afd4 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs @@ -1,248 +1,248 @@ -using System.Net; -using Deal.Api.Configuration; -using Deal.Api.Endpoints; -using Deal.Api.Middleware; -using Deal.Api.Services; -using Deal.Api.Telegram; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Tenancy; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Tenants; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions; -using TenantCookieOptions = Deal.Api.Configuration.CookieOptions; - -namespace Deal.Tests.Unit.Support; - -internal static class OperatorAuthHttpHost -{ - // Строка подключения health-ручки в тестах: dev-Postgres deal-postgres (:5433), Timeout=3 — - // при выключенном docker проверка БД быстро отвечает core.db=down (ручка не виснет). - private const string TestDatabaseConnectionString = - "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password;Timeout=3"; - - /// - /// Прогоняет сценарий против поднятого хоста с фейк-хранилищами оператора и пользователей. - /// - /// Фейк-хранилище оператора (IOperatorAuthStore). - /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. - /// Сценарий: получает base-адрес и хранилища для проверок/досидирования. - /// Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри. - public static async Task RunAsync( - FakeOperatorAuthStore operatorStore, - FakeAuthStore userStore, - Func scenario, - FakeAuditLogStore? auditStore = null) => - await RunCoreAsync( - operatorStore, - userStore, - auditStore, - inviteStore: null, - tenantStore: null, - limitStore: null, - (baseAddress, operators, users, _, _, _, _, _) => scenario(baseAddress, operators, users)); - - /// - /// Прогоняет сценарий - /// - /// Фейк-хранилище оператора (IOperatorAuthStore). - /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. - /// Сценарий: получает base-адрес и хранилища (без реестра тенантов). - /// Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри. - /// Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри. - public static async Task RunAsync( - FakeOperatorAuthStore operatorStore, - FakeAuthStore userStore, - Func scenario, - FakeAuditLogStore? auditStore = null, - FakeInviteStore? inviteStore = null) => - await RunCoreAsync( - operatorStore, - userStore, - auditStore, - inviteStore, - tenantStore: null, - limitStore: null, - (baseAddress, operators, users, _, invites, audit, _, _) => scenario(baseAddress, operators, users, invites, audit)); - - /// - /// Прогоняет сценарий - /// - /// Фейк-хранилище оператора (IOperatorAuthStore). - /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. - /// Сценарий: получает base-адрес и все хранилища, кроме лимитов. - /// Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри. - /// Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри. - /// Фейк-хранилище реестра тенантов (ITenantRepository); null — создаётся пустое внутри. - public static async Task RunAsync( - FakeOperatorAuthStore operatorStore, - FakeAuthStore userStore, - Func scenario, - FakeAuditLogStore? auditStore = null, - FakeInviteStore? inviteStore = null, - FakeTenantStore? tenantStore = null) => - await RunCoreAsync( - operatorStore, - userStore, - auditStore, - inviteStore, - tenantStore, - limitStore: null, - (baseAddress, operators, users, tenants, invites, audit, _, _) => scenario(baseAddress, operators, users, tenants, invites, audit)); - - /// - /// Прогоняет сценарий - /// - /// Фейк-хранилище оператора (IOperatorAuthStore). - /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. - /// Сценарий: получает base-адрес и все хранилища, включая лимиты. - /// Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри. - /// Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри. - /// Фейк-хранилище реестра тенантов (ITenantRepository); null — создаётся пустое внутри. - /// Фейк-хранилище лимитов (ITenantLimitStore); null — создаётся пустое внутри. - /// Опции rate limiting: null — выключенные по умолчанию (LoginAttemptGuard no-op, существующие сценарии не режутся); Enabled-опции — сценарии защиты входа. - public static async Task RunAsync( - FakeOperatorAuthStore operatorStore, - FakeAuthStore userStore, - Func scenario, - FakeAuditLogStore? auditStore = null, - FakeInviteStore? inviteStore = null, - FakeTenantStore? tenantStore = null, - FakeTenantLimitStore? limitStore = null, - RateLimitOptions? rateLimitOptions = null, - FakeTokenUsageEventStore? tokenUsageStore = null) => - await RunCoreAsync( - operatorStore, - userStore, - auditStore, - inviteStore, - tenantStore, - limitStore, - (baseAddress, operators, users, tenants, invites, audit, limits, _) => scenario(baseAddress, operators, users, tenants, invites, audit, limits), - rateLimitOptions, - tokenUsageStore); - - /// - /// Прогоняет сценарий с глобальными настройками - /// - /// Фейк-хранилище оператора (IOperatorAuthStore). - /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. - /// Сценарий: base-адрес, хранилища оператора/пользователей/глобальных настроек и аудита. - /// Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри. - /// Фейк глобального KV-хранилища (IGlobalSettingsStore); null — создаётся пустое. - public static async Task RunWithGlobalSettingsAsync( - FakeOperatorAuthStore operatorStore, - FakeAuthStore userStore, - Func scenario, - FakeAuditLogStore? auditStore = null, - FakeGlobalSettingsStore? globalSettingsStore = null) => - await RunCoreAsync( - operatorStore, - userStore, - auditStore, - inviteStore: null, - tenantStore: null, - limitStore: null, - (baseAddress, operators, users, _, _, audit, _, global) => scenario(baseAddress, operators, users, global, audit), - globalSettingsStore: globalSettingsStore); - - // Поднимает in-process Kestrel-хост и прогоняет сценарий (см. публичные перегрузки). - private static async Task RunCoreAsync( - FakeOperatorAuthStore operatorStore, - FakeAuthStore userStore, - FakeAuditLogStore? auditStore, - FakeInviteStore? inviteStore, - FakeTenantStore? tenantStore, - FakeTenantLimitStore? limitStore, - Func scenario, - RateLimitOptions? rateLimitOptions = null, - FakeTokenUsageEventStore? tokenUsageStore = null, - FakeGlobalSettingsStore? globalSettingsStore = null) - { - FakeAuditLogStore effectiveAuditStore = auditStore ?? new FakeAuditLogStore(); - FakeInviteStore effectiveInviteStore = inviteStore ?? new FakeInviteStore(); - FakeTenantStore effectiveTenantStore = tenantStore ?? new FakeTenantStore(); - FakeTenantLimitStore effectiveLimitStore = limitStore ?? new FakeTenantLimitStore(); - FakeTokenUsageEventStore effectiveTokenUsageStore = tokenUsageStore ?? new FakeTokenUsageEventStore(); - FakeGlobalSettingsStore effectiveGlobalSettingsStore = globalSettingsStore ?? new FakeGlobalSettingsStore(); - RateLimitOptions effectiveRateLimitOptions = rateLimitOptions ?? new RateLimitOptions(); - int port = TestPort.Allocate(); - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, port)); - - builder.Services.AddTenantsModule(); - // FakePasswordHasher регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает - // последняя регистрация (зеркало AddTenantsModule в тестах DiscoveryWorkerSchedulerTests). - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(userStore); - builder.Services.AddSingleton(operatorStore); - builder.Services.AddSingleton(effectiveAuditStore); - builder.Services.AddSingleton(effectiveInviteStore); - builder.Services.AddSingleton(effectiveTenantStore); - builder.Services.AddSingleton(effectiveLimitStore); - builder.Services.AddSingleton(effectiveGlobalSettingsStore); - builder.Services.AddSingleton(new FakeSecretCipher()); - // Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys). - builder.Services.AddScoped(); - builder.Services.AddSingleton(effectiveTokenUsageStore); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - // Опции кук — по умолчанию (deal_session/deal_operator_session, без конфиг-секции в тесте). - builder.Services.AddOptions(); - builder.Services.AddOptions(); - builder.Services.AddDbContext(options => options.UseNpgsql(TestDatabaseConnectionString)); - builder.Services.AddSingleton(new MlServiceOptions()); - builder.Services.AddSingleton(new AiServiceOptions()); - builder.Services.AddSingleton(new TelegramServiceOptions()); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(effectiveRateLimitOptions); - builder.Services.AddSingleton(new FakeRateLimitCounterStore()); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - - WebApplication app = builder.Build(); - app.UseMiddleware(); - app.UseMiddleware(); - app.MapAuthEndpoints(); - app.MapOperatorAuthEndpoints(); - app.MapOperatorAuditEndpoints(); - app.MapOperatorAnalyticsEndpoints(); - app.MapOperatorInvitesEndpoints(); - app.MapOperatorTenantsEndpoints(); - app.MapOperatorLimitsEndpoints(); - app.MapOperatorSettingsEndpoints(); - app.MapOperatorHealthEndpoints(); - app.MapOperatorMaintenanceEndpoints(); - await app.StartAsync(); - try - { - string baseAddress = $"http://127.0.0.1:{port}"; - await scenario( - baseAddress, - operatorStore, - userStore, - effectiveTenantStore, - effectiveInviteStore, - effectiveAuditStore, - effectiveLimitStore, - effectiveGlobalSettingsStore); - } - finally - { - await app.StopAsync(); - await app.DisposeAsync(); - } - } -} +using System.Net; +using Deal.Api.Configuration; +using Deal.Api.Endpoints; +using Deal.Api.Middleware; +using Deal.Api.Services; +using Deal.Api.Telegram; +using Deal.Infrastructure.Data; +using Deal.Infrastructure.Integrations.Options; +using Deal.Infrastructure.Integrations.Services; +using Deal.Infrastructure.Persistence; +using Deal.Infrastructure.Tenancy; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Modules.Tenants; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions; +using TenantCookieOptions = Deal.Api.Configuration.CookieOptions; + +namespace Deal.Tests.Unit.Support; + +internal static class OperatorAuthHttpHost +{ + // Строка подключения health-ручки в тестах: dev-Postgres deal-postgres (:5433), Timeout=3 — + // при выключенном docker проверка БД быстро отвечает core.db=down (ручка не виснет). + private const string TestDatabaseConnectionString = + "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password;Timeout=3"; + + /// + /// Прогоняет сценарий против поднятого хоста с фейк-хранилищами оператора и пользователей. + /// + /// Фейк-хранилище оператора (IOperatorAuthStore). + /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. + /// Сценарий: получает base-адрес и хранилища для проверок/досидирования. + /// Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри. + public static async Task RunAsync( + FakeOperatorAuthStore operatorStore, + FakeAuthStore userStore, + Func scenario, + FakeAuditLogStore? auditStore = null) => + await RunCoreAsync( + operatorStore, + userStore, + auditStore, + inviteStore: null, + tenantStore: null, + limitStore: null, + (baseAddress, operators, users, _, _, _, _, _) => scenario(baseAddress, operators, users)); + + /// + /// Прогоняет сценарий + /// + /// Фейк-хранилище оператора (IOperatorAuthStore). + /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. + /// Сценарий: получает base-адрес и хранилища (без реестра тенантов). + /// Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри. + /// Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри. + public static async Task RunAsync( + FakeOperatorAuthStore operatorStore, + FakeAuthStore userStore, + Func scenario, + FakeAuditLogStore? auditStore = null, + FakeInviteStore? inviteStore = null) => + await RunCoreAsync( + operatorStore, + userStore, + auditStore, + inviteStore, + tenantStore: null, + limitStore: null, + (baseAddress, operators, users, _, invites, audit, _, _) => scenario(baseAddress, operators, users, invites, audit)); + + /// + /// Прогоняет сценарий + /// + /// Фейк-хранилище оператора (IOperatorAuthStore). + /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. + /// Сценарий: получает base-адрес и все хранилища, кроме лимитов. + /// Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри. + /// Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри. + /// Фейк-хранилище реестра тенантов (ITenantRepository); null — создаётся пустое внутри. + public static async Task RunAsync( + FakeOperatorAuthStore operatorStore, + FakeAuthStore userStore, + Func scenario, + FakeAuditLogStore? auditStore = null, + FakeInviteStore? inviteStore = null, + FakeTenantStore? tenantStore = null) => + await RunCoreAsync( + operatorStore, + userStore, + auditStore, + inviteStore, + tenantStore, + limitStore: null, + (baseAddress, operators, users, tenants, invites, audit, _, _) => scenario(baseAddress, operators, users, tenants, invites, audit)); + + /// + /// Прогоняет сценарий + /// + /// Фейк-хранилище оператора (IOperatorAuthStore). + /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. + /// Сценарий: получает base-адрес и все хранилища, включая лимиты. + /// Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри. + /// Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри. + /// Фейк-хранилище реестра тенантов (ITenantRepository); null — создаётся пустое внутри. + /// Фейк-хранилище лимитов (ITenantLimitStore); null — создаётся пустое внутри. + /// Опции rate limiting: null — выключенные по умолчанию (LoginAttemptGuard no-op, существующие сценарии не режутся); Enabled-опции — сценарии защиты входа. + public static async Task RunAsync( + FakeOperatorAuthStore operatorStore, + FakeAuthStore userStore, + Func scenario, + FakeAuditLogStore? auditStore = null, + FakeInviteStore? inviteStore = null, + FakeTenantStore? tenantStore = null, + FakeTenantLimitStore? limitStore = null, + RateLimitOptions? rateLimitOptions = null, + FakeTokenUsageEventStore? tokenUsageStore = null) => + await RunCoreAsync( + operatorStore, + userStore, + auditStore, + inviteStore, + tenantStore, + limitStore, + (baseAddress, operators, users, tenants, invites, audit, limits, _) => scenario(baseAddress, operators, users, tenants, invites, audit, limits), + rateLimitOptions, + tokenUsageStore); + + /// + /// Прогоняет сценарий с глобальными настройками + /// + /// Фейк-хранилище оператора (IOperatorAuthStore). + /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. + /// Сценарий: base-адрес, хранилища оператора/пользователей/глобальных настроек и аудита. + /// Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри. + /// Фейк глобального KV-хранилища (IGlobalSettingsStore); null — создаётся пустое. + public static async Task RunWithGlobalSettingsAsync( + FakeOperatorAuthStore operatorStore, + FakeAuthStore userStore, + Func scenario, + FakeAuditLogStore? auditStore = null, + FakeGlobalSettingsStore? globalSettingsStore = null) => + await RunCoreAsync( + operatorStore, + userStore, + auditStore, + inviteStore: null, + tenantStore: null, + limitStore: null, + (baseAddress, operators, users, _, _, audit, _, global) => scenario(baseAddress, operators, users, global, audit), + globalSettingsStore: globalSettingsStore); + + // Поднимает in-process Kestrel-хост и прогоняет сценарий (см. публичные перегрузки). + private static async Task RunCoreAsync( + FakeOperatorAuthStore operatorStore, + FakeAuthStore userStore, + FakeAuditLogStore? auditStore, + FakeInviteStore? inviteStore, + FakeTenantStore? tenantStore, + FakeTenantLimitStore? limitStore, + Func scenario, + RateLimitOptions? rateLimitOptions = null, + FakeTokenUsageEventStore? tokenUsageStore = null, + FakeGlobalSettingsStore? globalSettingsStore = null) + { + FakeAuditLogStore effectiveAuditStore = auditStore ?? new FakeAuditLogStore(); + FakeInviteStore effectiveInviteStore = inviteStore ?? new FakeInviteStore(); + FakeTenantStore effectiveTenantStore = tenantStore ?? new FakeTenantStore(); + FakeTenantLimitStore effectiveLimitStore = limitStore ?? new FakeTenantLimitStore(); + FakeTokenUsageEventStore effectiveTokenUsageStore = tokenUsageStore ?? new FakeTokenUsageEventStore(); + FakeGlobalSettingsStore effectiveGlobalSettingsStore = globalSettingsStore ?? new FakeGlobalSettingsStore(); + RateLimitOptions effectiveRateLimitOptions = rateLimitOptions ?? new RateLimitOptions(); + int port = TestPort.Allocate(); + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, port)); + + builder.Services.AddTenantsModule(); + // FakePasswordHasher регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает + // последняя регистрация (зеркало AddTenantsModule в тестах DiscoveryWorkerSchedulerTests). + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(userStore); + builder.Services.AddSingleton(operatorStore); + builder.Services.AddSingleton(effectiveAuditStore); + builder.Services.AddSingleton(effectiveInviteStore); + builder.Services.AddSingleton(effectiveTenantStore); + builder.Services.AddSingleton(effectiveLimitStore); + builder.Services.AddSingleton(effectiveGlobalSettingsStore); + builder.Services.AddSingleton(new FakeSecretCipher()); + // Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys). + builder.Services.AddScoped(); + builder.Services.AddSingleton(effectiveTokenUsageStore); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + // Опции кук — по умолчанию (deal_session/deal_operator_session, без конфиг-секции в тесте). + builder.Services.AddOptions(); + builder.Services.AddOptions(); + builder.Services.AddDbContext(options => options.UseNpgsql(TestDatabaseConnectionString)); + builder.Services.AddSingleton(new MlServiceOptions()); + builder.Services.AddSingleton(new AiServiceOptions()); + builder.Services.AddSingleton(new TelegramServiceOptions()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(effectiveRateLimitOptions); + builder.Services.AddSingleton(new FakeRateLimitCounterStore()); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + WebApplication app = builder.Build(); + app.UseMiddleware(); + app.UseMiddleware(); + app.MapAuthEndpoints(); + app.MapOperatorAuthEndpoints(); + app.MapOperatorAuditEndpoints(); + app.MapOperatorAnalyticsEndpoints(); + app.MapOperatorInvitesEndpoints(); + app.MapOperatorTenantsEndpoints(); + app.MapOperatorLimitsEndpoints(); + app.MapOperatorSettingsEndpoints(); + app.MapOperatorHealthEndpoints(); + app.MapOperatorMaintenanceEndpoints(); + await app.StartAsync(); + try + { + string baseAddress = $"http://127.0.0.1:{port}"; + await scenario( + baseAddress, + operatorStore, + userStore, + effectiveTenantStore, + effectiveInviteStore, + effectiveAuditStore, + effectiveLimitStore, + effectiveGlobalSettingsStore); + } + finally + { + await app.StopAsync(); + await app.DisposeAsync(); + } + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs index f559f74..d4037bd 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs @@ -1,323 +1,323 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Tenants.Application.Models; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Tenants; - -namespace Deal.Tests.Unit.Support; - -/// -/// HTTP-тесты операторских ручек глобальных настроек Telegram -/// -public sealed class OperatorSettingsEndpointsHttpTests -{ - private const string OperatorLogin = "operator"; - private const string OperatorPassword = "operator"; - private const string InvalidApiIdDetail = "api_id должен состоять из 5–9 цифр"; - private const string InvalidApiHashDetail = "Укажите непустой api_hash"; - private const string EmptyBodyDetail = "Укажите api_id и api_hash"; - private const string MissingKeysDetail = "Ключи ещё не заданы — укажите и api_id, и api_hash"; - - [Fact] - public async Task TelegramKeys_WithoutOperatorSession_Returns401() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - using HttpClient anonymous = CreateClient(baseAddress); - - using HttpResponseMessage get = await anonymous.GetAsync(TgKeysUrl(baseAddress)); - Assert.Equal(HttpStatusCode.Unauthorized, get.StatusCode); - Assert.Equal("Требуется вход оператора", (await ReadJsonAsync(get)).GetProperty("detail").GetString()); - - using HttpResponseMessage put = await PutJsonAsync( - anonymous, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcdefghijklmnop" }); - Assert.Equal(HttpStatusCode.Unauthorized, put.StatusCode); - Assert.Equal("Требуется вход оператора", (await ReadJsonAsync(put)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task GetTelegramKeys_NoKeys_ReturnsEmptyMaskedForm() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage response = await operatorClient.GetAsync(TgKeysUrl(baseAddress)); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - JsonElement body = await ReadJsonAsync(response); - Assert.Equal(string.Empty, body.GetProperty("apiId").GetString()); - Assert.Equal(string.Empty, body.GetProperty("apiHash").GetString()); - Assert.False(body.GetProperty("keysSet").GetBoolean()); - }); - } - - [Fact] - public async Task PutTelegramKeys_SavesEncryptedKeysMasksResponseAndWritesAudit() - { - var auditStore = new FakeAuditLogStore(); - - await RunAsync( - async (baseAddress, operatorStore, _, globalSettings, audit) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage put = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcdefghijklmnop" }); - Assert.Equal(HttpStatusCode.OK, put.StatusCode); - JsonElement body = await ReadJsonAsync(put); - Assert.Equal("1234567", body.GetProperty("apiId").GetString()); - Assert.Equal("abcd…mnop", body.GetProperty("apiHash").GetString()); - Assert.True(body.GetProperty("keysSet").GetBoolean()); - - // В хранилище apiHash — только в enc:-форме, открытого секрета нет. - string storedJson = globalSettings.GetStoredJson(GlobalSettingsKeys.TelegramKeys)!; - Assert.Contains("enc:", storedJson); - Assert.DoesNotContain("abcdefghijklmnop", storedJson); - - // Аудит: актор-оператор, детали без секрета. - AuditRecordDto record = Assert.Single(audit.Records, r => r.EventType == AuditEvents.TelegramKeysChanged); - Assert.Equal(AuditActorTypes.Operator, record.ActorType); - Assert.Equal(operatorStore.Operators.Single().Id, record.ActorId); - Assert.Null(record.TenantId); - Assert.NotNull(record.DetailJson); - Assert.DoesNotContain("abcdefghijklmnop", record.DetailJson); - using var detail = JsonDocument.Parse(record.DetailJson!); - Assert.Equal("1234567", detail.RootElement.GetProperty("apiId").GetString()); - Assert.True(detail.RootElement.GetProperty("apiHashSet").GetBoolean()); - }, - auditStore); - } - - [Fact] - public async Task GetTelegramKeys_AfterPut_ReturnsMaskedStoredKeys() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage put = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiId = "12345", apiHash = "short" }); - Assert.Equal(HttpStatusCode.OK, put.StatusCode); - - using HttpResponseMessage get = await operatorClient.GetAsync(TgKeysUrl(baseAddress)); - JsonElement body = await ReadJsonAsync(get); - Assert.Equal("12345", body.GetProperty("apiId").GetString()); - Assert.Equal("s…", body.GetProperty("apiHash").GetString()); // короткий секрет полностью замаскирован - Assert.True(body.GetProperty("keysSet").GetBoolean()); - }); - } - - [Fact] - public async Task PutTelegramKeys_InvalidApiId_Returns400() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage response = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiId = "abc", apiHash = "abcdefghijklmnop" }); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(InvalidApiIdDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task PutTelegramKeys_EmptyOrMaskedApiHash_Returns400() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage empty = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "" }); - Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode); - Assert.Equal(InvalidApiHashDetail, (await ReadJsonAsync(empty)).GetProperty("detail").GetString()); - - using HttpResponseMessage masked = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcd…mnop" }); - Assert.Equal(HttpStatusCode.BadRequest, masked.StatusCode); - Assert.Equal(InvalidApiHashDetail, (await ReadJsonAsync(masked)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task PutTelegramKeys_OnlyApiId_KeepsExistingApiHash() - { - await RunAsync( - async (baseAddress, _, _, globalSettings, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage initial = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcdefghijklmnop" }); - Assert.Equal(HttpStatusCode.OK, initial.StatusCode); - - // Частичное обновление: только apiId, apiHash сохраняется из текущих ключей. - using HttpResponseMessage put = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiId = "7654321" }); - Assert.Equal(HttpStatusCode.OK, put.StatusCode); - JsonElement body = await ReadJsonAsync(put); - Assert.Equal("7654321", body.GetProperty("apiId").GetString()); - Assert.Equal("abcd…mnop", body.GetProperty("apiHash").GetString()); - Assert.True(body.GetProperty("keysSet").GetBoolean()); - - // Хранилище всё ещё держит прежний секрет в enc:-форме (не перезаписан пустым). - string storedJson = globalSettings.GetStoredJson(GlobalSettingsKeys.TelegramKeys)!; - Assert.Contains("enc:", storedJson); - Assert.DoesNotContain("abcdefghijklmnop", storedJson); - }); - } - - [Fact] - public async Task PutTelegramKeys_OnlyApiHash_KeepsExistingApiId() - { - await RunAsync( - async (baseAddress, _, _, globalSettings, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage initial = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcdefghijklmnop" }); - Assert.Equal(HttpStatusCode.OK, initial.StatusCode); - - // Частичное обновление: только apiHash, apiId сохраняется из текущих ключей. - using HttpResponseMessage put = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiHash = "newsecrethash12" }); - Assert.Equal(HttpStatusCode.OK, put.StatusCode); - JsonElement body = await ReadJsonAsync(put); - Assert.Equal("1234567", body.GetProperty("apiId").GetString()); - Assert.Equal("news…sh12", body.GetProperty("apiHash").GetString()); - Assert.True(body.GetProperty("keysSet").GetBoolean()); - - string storedJson = globalSettings.GetStoredJson(GlobalSettingsKeys.TelegramKeys)!; - Assert.Contains("enc:", storedJson); - Assert.DoesNotContain("newsecrethash12", storedJson); - }); - } - - [Fact] - public async Task PutTelegramKeys_PartialWithoutExistingKeys_Returns400() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - // Ключей нет: одно лишь apiId (без apiHash) неполно — нужны оба. - using HttpResponseMessage idOnly = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567" }); - Assert.Equal(HttpStatusCode.BadRequest, idOnly.StatusCode); - Assert.Equal(MissingKeysDetail, (await ReadJsonAsync(idOnly)).GetProperty("detail").GetString()); - - // Ключей нет: одно лишь apiHash (без apiId) неполно. - using HttpResponseMessage hashOnly = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiHash = "abcdefghijklmnop" }); - Assert.Equal(HttpStatusCode.BadRequest, hashOnly.StatusCode); - Assert.Equal(MissingKeysDetail, (await ReadJsonAsync(hashOnly)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task PutTelegramKeys_NoFields_Returns400() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage response = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { }); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(EmptyBodyDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - // ─── Хелперы ───────────────────────────────────────────────────────── - - // URL глобальных ключей Telegram (GET/PUT /api/operator/settings/telegram-keys). - private static string TgKeysUrl(string baseAddress) => - $"{baseAddress}/api/operator/settings/telegram-keys"; - - // Прогоняет сценарий на хосте с фейком глобального хранилища и аудита. - private static Task RunAsync(Func scenario, FakeAuditLogStore? auditStore = null) => - OperatorAuthHttpHost.RunWithGlobalSettingsAsync( - NewOperatorStore(), - new FakeAuthStore(), - scenario, - auditStore); - - // Логинит оператора (ожидается 200). - private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) - { - using HttpResponseMessage login = await PostJsonAsync( - client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); - Assert.Equal(HttpStatusCode.OK, login.StatusCode); - } - - // Фейк-хранилище оператора с активным оператором operator/operator. - private static FakeOperatorAuthStore NewOperatorStore() - { - var passwordHasher = new FakePasswordHasher(); - var store = new FakeOperatorAuthStore(); - store.AddOperator(new StoredOperatorDto( - Guid.NewGuid(), - OperatorLogin, - Status: "active", - PasswordHash: passwordHasher.Hash(OperatorPassword))); - return store; - } - - // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). - private static HttpClient CreateClient(string baseAddress) => - new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) - { - BaseAddress = new Uri(baseAddress), - }; - - // POST JSON-тела и возврат ответа. - private static async Task PostJsonAsync( - HttpClient client, - string url, - object body) - { - string json = JsonSerializer.Serialize(body); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - return await client.PostAsync(url, content); - } - - // PUT JSON-тела и возврат ответа. - private static async Task PutJsonAsync( - HttpClient client, - string url, - object body) - { - string json = JsonSerializer.Serialize(body); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - return await client.PutAsync(url, content); - } - - // Читает тело ответа как JSON-документ (корень, отвязанный от документа). - private static async Task ReadJsonAsync(HttpResponseMessage response) - { - await using Stream stream = await response.Content.ReadAsStreamAsync(); - using var document = await JsonDocument.ParseAsync(stream); - return document.RootElement.Clone(); - } -} +using System.Net; +using System.Text; +using System.Text.Json; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Tenants.Application.Models; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Modules.Tenants; + +namespace Deal.Tests.Unit.Support; + +/// +/// HTTP-тесты операторских ручек глобальных настроек Telegram +/// +public sealed class OperatorSettingsEndpointsHttpTests +{ + private const string OperatorLogin = "operator"; + private const string OperatorPassword = "operator"; + private const string InvalidApiIdDetail = "api_id должен состоять из 5–9 цифр"; + private const string InvalidApiHashDetail = "Укажите непустой api_hash"; + private const string EmptyBodyDetail = "Укажите api_id и api_hash"; + private const string MissingKeysDetail = "Ключи ещё не заданы — укажите и api_id, и api_hash"; + + [Fact] + public async Task TelegramKeys_WithoutOperatorSession_Returns401() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + using HttpClient anonymous = CreateClient(baseAddress); + + using HttpResponseMessage get = await anonymous.GetAsync(TgKeysUrl(baseAddress)); + Assert.Equal(HttpStatusCode.Unauthorized, get.StatusCode); + Assert.Equal("Требуется вход оператора", (await ReadJsonAsync(get)).GetProperty("detail").GetString()); + + using HttpResponseMessage put = await PutJsonAsync( + anonymous, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcdefghijklmnop" }); + Assert.Equal(HttpStatusCode.Unauthorized, put.StatusCode); + Assert.Equal("Требуется вход оператора", (await ReadJsonAsync(put)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task GetTelegramKeys_NoKeys_ReturnsEmptyMaskedForm() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await operatorClient.GetAsync(TgKeysUrl(baseAddress)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + JsonElement body = await ReadJsonAsync(response); + Assert.Equal(string.Empty, body.GetProperty("apiId").GetString()); + Assert.Equal(string.Empty, body.GetProperty("apiHash").GetString()); + Assert.False(body.GetProperty("keysSet").GetBoolean()); + }); + } + + [Fact] + public async Task PutTelegramKeys_SavesEncryptedKeysMasksResponseAndWritesAudit() + { + var auditStore = new FakeAuditLogStore(); + + await RunAsync( + async (baseAddress, operatorStore, _, globalSettings, audit) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage put = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcdefghijklmnop" }); + Assert.Equal(HttpStatusCode.OK, put.StatusCode); + JsonElement body = await ReadJsonAsync(put); + Assert.Equal("1234567", body.GetProperty("apiId").GetString()); + Assert.Equal("abcd…mnop", body.GetProperty("apiHash").GetString()); + Assert.True(body.GetProperty("keysSet").GetBoolean()); + + // В хранилище apiHash — только в enc:-форме, открытого секрета нет. + string storedJson = globalSettings.GetStoredJson(GlobalSettingsKeys.TelegramKeys)!; + Assert.Contains("enc:", storedJson); + Assert.DoesNotContain("abcdefghijklmnop", storedJson); + + // Аудит: актор-оператор, детали без секрета. + AuditRecordDto record = Assert.Single(audit.Records, r => r.EventType == AuditEvents.TelegramKeysChanged); + Assert.Equal(AuditActorTypes.Operator, record.ActorType); + Assert.Equal(operatorStore.Operators.Single().Id, record.ActorId); + Assert.Null(record.TenantId); + Assert.NotNull(record.DetailJson); + Assert.DoesNotContain("abcdefghijklmnop", record.DetailJson); + using var detail = JsonDocument.Parse(record.DetailJson!); + Assert.Equal("1234567", detail.RootElement.GetProperty("apiId").GetString()); + Assert.True(detail.RootElement.GetProperty("apiHashSet").GetBoolean()); + }, + auditStore); + } + + [Fact] + public async Task GetTelegramKeys_AfterPut_ReturnsMaskedStoredKeys() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage put = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiId = "12345", apiHash = "short" }); + Assert.Equal(HttpStatusCode.OK, put.StatusCode); + + using HttpResponseMessage get = await operatorClient.GetAsync(TgKeysUrl(baseAddress)); + JsonElement body = await ReadJsonAsync(get); + Assert.Equal("12345", body.GetProperty("apiId").GetString()); + Assert.Equal("s…", body.GetProperty("apiHash").GetString()); // короткий секрет полностью замаскирован + Assert.True(body.GetProperty("keysSet").GetBoolean()); + }); + } + + [Fact] + public async Task PutTelegramKeys_InvalidApiId_Returns400() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiId = "abc", apiHash = "abcdefghijklmnop" }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(InvalidApiIdDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task PutTelegramKeys_EmptyOrMaskedApiHash_Returns400() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage empty = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "" }); + Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode); + Assert.Equal(InvalidApiHashDetail, (await ReadJsonAsync(empty)).GetProperty("detail").GetString()); + + using HttpResponseMessage masked = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcd…mnop" }); + Assert.Equal(HttpStatusCode.BadRequest, masked.StatusCode); + Assert.Equal(InvalidApiHashDetail, (await ReadJsonAsync(masked)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task PutTelegramKeys_OnlyApiId_KeepsExistingApiHash() + { + await RunAsync( + async (baseAddress, _, _, globalSettings, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage initial = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcdefghijklmnop" }); + Assert.Equal(HttpStatusCode.OK, initial.StatusCode); + + // Частичное обновление: только apiId, apiHash сохраняется из текущих ключей. + using HttpResponseMessage put = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiId = "7654321" }); + Assert.Equal(HttpStatusCode.OK, put.StatusCode); + JsonElement body = await ReadJsonAsync(put); + Assert.Equal("7654321", body.GetProperty("apiId").GetString()); + Assert.Equal("abcd…mnop", body.GetProperty("apiHash").GetString()); + Assert.True(body.GetProperty("keysSet").GetBoolean()); + + // Хранилище всё ещё держит прежний секрет в enc:-форме (не перезаписан пустым). + string storedJson = globalSettings.GetStoredJson(GlobalSettingsKeys.TelegramKeys)!; + Assert.Contains("enc:", storedJson); + Assert.DoesNotContain("abcdefghijklmnop", storedJson); + }); + } + + [Fact] + public async Task PutTelegramKeys_OnlyApiHash_KeepsExistingApiId() + { + await RunAsync( + async (baseAddress, _, _, globalSettings, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage initial = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcdefghijklmnop" }); + Assert.Equal(HttpStatusCode.OK, initial.StatusCode); + + // Частичное обновление: только apiHash, apiId сохраняется из текущих ключей. + using HttpResponseMessage put = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiHash = "newsecrethash12" }); + Assert.Equal(HttpStatusCode.OK, put.StatusCode); + JsonElement body = await ReadJsonAsync(put); + Assert.Equal("1234567", body.GetProperty("apiId").GetString()); + Assert.Equal("news…sh12", body.GetProperty("apiHash").GetString()); + Assert.True(body.GetProperty("keysSet").GetBoolean()); + + string storedJson = globalSettings.GetStoredJson(GlobalSettingsKeys.TelegramKeys)!; + Assert.Contains("enc:", storedJson); + Assert.DoesNotContain("newsecrethash12", storedJson); + }); + } + + [Fact] + public async Task PutTelegramKeys_PartialWithoutExistingKeys_Returns400() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + // Ключей нет: одно лишь apiId (без apiHash) неполно — нужны оба. + using HttpResponseMessage idOnly = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567" }); + Assert.Equal(HttpStatusCode.BadRequest, idOnly.StatusCode); + Assert.Equal(MissingKeysDetail, (await ReadJsonAsync(idOnly)).GetProperty("detail").GetString()); + + // Ключей нет: одно лишь apiHash (без apiId) неполно. + using HttpResponseMessage hashOnly = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiHash = "abcdefghijklmnop" }); + Assert.Equal(HttpStatusCode.BadRequest, hashOnly.StatusCode); + Assert.Equal(MissingKeysDetail, (await ReadJsonAsync(hashOnly)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task PutTelegramKeys_NoFields_Returns400() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(EmptyBodyDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + // ─── Хелперы ───────────────────────────────────────────────────────── + + // URL глобальных ключей Telegram (GET/PUT /api/operator/settings/telegram-keys). + private static string TgKeysUrl(string baseAddress) => + $"{baseAddress}/api/operator/settings/telegram-keys"; + + // Прогоняет сценарий на хосте с фейком глобального хранилища и аудита. + private static Task RunAsync(Func scenario, FakeAuditLogStore? auditStore = null) => + OperatorAuthHttpHost.RunWithGlobalSettingsAsync( + NewOperatorStore(), + new FakeAuthStore(), + scenario, + auditStore); + + // Логинит оператора (ожидается 200). + private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) + { + using HttpResponseMessage login = await PostJsonAsync( + client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + } + + // Фейк-хранилище оператора с активным оператором operator/operator. + private static FakeOperatorAuthStore NewOperatorStore() + { + var passwordHasher = new FakePasswordHasher(); + var store = new FakeOperatorAuthStore(); + store.AddOperator(new StoredOperatorDto( + Guid.NewGuid(), + OperatorLogin, + Status: "active", + PasswordHash: passwordHasher.Hash(OperatorPassword))); + return store; + } + + // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). + private static HttpClient CreateClient(string baseAddress) => + new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) + { + BaseAddress = new Uri(baseAddress), + }; + + // POST JSON-тела и возврат ответа. + private static async Task PostJsonAsync( + HttpClient client, + string url, + object body) + { + string json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await client.PostAsync(url, content); + } + + // PUT JSON-тела и возврат ответа. + private static async Task PutJsonAsync( + HttpClient client, + string url, + object body) + { + string json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await client.PutAsync(url, content); + } + + // Читает тело ответа как JSON-документ (корень, отвязанный от документа). + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + await using Stream stream = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(stream); + return document.RootElement.Clone(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/OriginGuardHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OriginGuardHttpTests.cs index 27b45a8..70e522b 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OriginGuardHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OriginGuardHttpTests.cs @@ -1,211 +1,211 @@ -using System.Net; -using System.Text.Json; -using Deal.Api.Configuration; -using Deal.Api.Middleware; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Tests.Unit.Support; - -/// -/// HTTP-тесты Origin-проверки мутаций -/// -public sealed class OriginGuardHttpTests -{ - // Путь мутирующего эндпоинта сценария (POST /api/...). - private const string MutateProbePath = "/api/mutate"; - - private const string ForeignOrigin = "http://evil.example"; - - // Origin из явного allowlist конфига (Security:AllowedOrigins). - private const string AllowedOrigin = "http://allowed.example"; - - // ─── Чужой Origin → 403 ────────────────────────────────────────────────── - - /// - /// Мутация /api с Origin чужого сайта → 403 {detail} - /// - [Fact] - public async Task Post_ForeignOrigin_Returns403WithRejectedDetail() - { - await RunAsync( - new SecurityOptions(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await SendAsync( - client, HttpMethod.Post, baseAddress, MutateProbePath, ForeignOrigin); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - Assert.Equal( - OriginGuardMiddleware.OriginRejectedDetail, - (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - // ─── Свой Origin (схема + Host) → ok ───────────────────────────────────── - - /// - /// Мутация с Origin == собственному origin запроса - /// - [Fact] - public async Task Post_SelfOrigin_IsAllowed() - { - await RunAsync( - new SecurityOptions(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await SendAsync( - client, HttpMethod.Post, baseAddress, MutateProbePath, baseAddress); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - }); - } - - // ─── Allowlist из конфига ──────────────────────────────────────────────── - - /// - /// Origin из Security:AllowedOrigins проходит; чужой на том же хосте — 403 - /// - [Fact] - public async Task Post_AllowlistedOrigin_IsAllowed_ButForeignStillRejected() - { - var securityOptions = new SecurityOptions { AllowedOrigins = [AllowedOrigin] }; - - await RunAsync( - securityOptions, - async (baseAddress, client) => - { - using (HttpResponseMessage allowed = await SendAsync( - client, HttpMethod.Post, baseAddress, MutateProbePath, AllowedOrigin)) - { - Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); - } - - using HttpResponseMessage foreign = await SendAsync( - client, HttpMethod.Post, baseAddress, MutateProbePath, ForeignOrigin); - Assert.Equal(HttpStatusCode.Forbidden, foreign.StatusCode); - }); - } - - // ─── Без Origin (curl/сервер-сервер) → ok ──────────────────────────────── - - /// - /// Мутация без заголовка Origin - /// - [Fact] - public async Task Post_WithoutOrigin_IsAllowed() - { - await RunAsync( - new SecurityOptions(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await SendAsync(client, HttpMethod.Post, baseAddress, MutateProbePath, origin: null); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - }); - } - - // ─── Не-мутации не проверяются ─────────────────────────────────────────── - - /// - /// GET с чужим Origin не проверяется - /// - [Fact] - public async Task Get_WithForeignOrigin_IsNotChecked() - { - await RunAsync( - new SecurityOptions(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await SendAsync( - client, HttpMethod.Get, baseAddress, MutateProbePath, ForeignOrigin); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - }); - } - - /// - /// OPTIONS с чужим Origin не проверяется - /// - [Fact] - public async Task Options_WithForeignOrigin_IsNotChecked() - { - await RunAsync( - new SecurityOptions(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await SendAsync( - client, HttpMethod.Options, baseAddress, MutateProbePath, ForeignOrigin); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - }); - } - - // ─── Хост сценария ───────────────────────────────────────────────────── - - // Поднимает in-process Kestrel с OriginGuardMiddleware (как Program.cs) и прогоняет сценарий. - // securityOptions: Опции безопасности сценария (AllowedOrigins — allowlist проверки). - // scenario: Сценарий с base-адресом и клиентом. - private static async Task RunAsync(SecurityOptions securityOptions, Func scenario) - { - int port = TestPort.Allocate(); - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, port)); - builder.Services.AddSingleton(securityOptions); - - WebApplication app = builder.Build(); - app.UseMiddleware(); - // Мутирующий и read-only эндпоинты: Origin-проверка не зависит от семантики ручки — только метод/путь. - app.MapPost(MutateProbePath, () => Results.Ok(new { ok = true })); - app.MapGet(MutateProbePath, () => Results.Ok(new { ok = true })); - app.MapMethods(MutateProbePath, [HttpMethods.Options], () => Results.Ok(new { ok = true })); - - await app.StartAsync(); - try - { - string baseAddress = $"http://127.0.0.1:{port}"; - await scenario(baseAddress, CreateClient(baseAddress)); - } - finally - { - await app.StopAsync(); - await app.DisposeAsync(); - } - } - - // Шлёт запрос на эндпоинт сценария с опциональным заголовком Origin. - // client: HTTP-клиент сценария. - // method: HTTP-метод запроса. - // baseAddress: Базовый адрес хоста. - // path: Путь эндпоинта. - // origin: Значение заголовка Origin; null — заголовок не шлётся. - // Возвращает: Ответ эндпоинта. - private static async Task SendAsync( - HttpClient client, - HttpMethod method, - string baseAddress, - string path, - string? origin) - { - using var request = new HttpRequestMessage(method, baseAddress + path); - if (origin is not null) - { - request.Headers.TryAddWithoutValidation("Origin", origin); - } - - return await client.SendAsync(request); - } - - - // HTTP-клиент сценария. - private static HttpClient CreateClient(string baseAddress) => - new() - { - BaseAddress = new Uri(baseAddress), - }; - - // Читает тело ответа как JSON-документ (корень, отвязанный от документа). - private static async Task ReadJsonAsync(HttpResponseMessage response) - { - string body = await response.Content.ReadAsStringAsync(); - using var document = JsonDocument.Parse(body); - return document.RootElement.Clone(); - } -} +using System.Net; +using System.Text.Json; +using Deal.Api.Configuration; +using Deal.Api.Middleware; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Tests.Unit.Support; + +/// +/// HTTP-тесты Origin-проверки мутаций +/// +public sealed class OriginGuardHttpTests +{ + // Путь мутирующего эндпоинта сценария (POST /api/...). + private const string MutateProbePath = "/api/mutate"; + + private const string ForeignOrigin = "http://evil.example"; + + // Origin из явного allowlist конфига (Security:AllowedOrigins). + private const string AllowedOrigin = "http://allowed.example"; + + // ─── Чужой Origin → 403 ────────────────────────────────────────────────── + + /// + /// Мутация /api с Origin чужого сайта → 403 {detail} + /// + [Fact] + public async Task Post_ForeignOrigin_Returns403WithRejectedDetail() + { + await RunAsync( + new SecurityOptions(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await SendAsync( + client, HttpMethod.Post, baseAddress, MutateProbePath, ForeignOrigin); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal( + OriginGuardMiddleware.OriginRejectedDetail, + (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + // ─── Свой Origin (схема + Host) → ok ───────────────────────────────────── + + /// + /// Мутация с Origin == собственному origin запроса + /// + [Fact] + public async Task Post_SelfOrigin_IsAllowed() + { + await RunAsync( + new SecurityOptions(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await SendAsync( + client, HttpMethod.Post, baseAddress, MutateProbePath, baseAddress); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + }); + } + + // ─── Allowlist из конфига ──────────────────────────────────────────────── + + /// + /// Origin из Security:AllowedOrigins проходит; чужой на том же хосте — 403 + /// + [Fact] + public async Task Post_AllowlistedOrigin_IsAllowed_ButForeignStillRejected() + { + var securityOptions = new SecurityOptions { AllowedOrigins = [AllowedOrigin] }; + + await RunAsync( + securityOptions, + async (baseAddress, client) => + { + using (HttpResponseMessage allowed = await SendAsync( + client, HttpMethod.Post, baseAddress, MutateProbePath, AllowedOrigin)) + { + Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); + } + + using HttpResponseMessage foreign = await SendAsync( + client, HttpMethod.Post, baseAddress, MutateProbePath, ForeignOrigin); + Assert.Equal(HttpStatusCode.Forbidden, foreign.StatusCode); + }); + } + + // ─── Без Origin (curl/сервер-сервер) → ok ──────────────────────────────── + + /// + /// Мутация без заголовка Origin + /// + [Fact] + public async Task Post_WithoutOrigin_IsAllowed() + { + await RunAsync( + new SecurityOptions(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await SendAsync(client, HttpMethod.Post, baseAddress, MutateProbePath, origin: null); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + }); + } + + // ─── Не-мутации не проверяются ─────────────────────────────────────────── + + /// + /// GET с чужим Origin не проверяется + /// + [Fact] + public async Task Get_WithForeignOrigin_IsNotChecked() + { + await RunAsync( + new SecurityOptions(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await SendAsync( + client, HttpMethod.Get, baseAddress, MutateProbePath, ForeignOrigin); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + }); + } + + /// + /// OPTIONS с чужим Origin не проверяется + /// + [Fact] + public async Task Options_WithForeignOrigin_IsNotChecked() + { + await RunAsync( + new SecurityOptions(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await SendAsync( + client, HttpMethod.Options, baseAddress, MutateProbePath, ForeignOrigin); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + }); + } + + // ─── Хост сценария ───────────────────────────────────────────────────── + + // Поднимает in-process Kestrel с OriginGuardMiddleware (как Program.cs) и прогоняет сценарий. + // securityOptions: Опции безопасности сценария (AllowedOrigins — allowlist проверки). + // scenario: Сценарий с base-адресом и клиентом. + private static async Task RunAsync(SecurityOptions securityOptions, Func scenario) + { + int port = TestPort.Allocate(); + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, port)); + builder.Services.AddSingleton(securityOptions); + + WebApplication app = builder.Build(); + app.UseMiddleware(); + // Мутирующий и read-only эндпоинты: Origin-проверка не зависит от семантики ручки — только метод/путь. + app.MapPost(MutateProbePath, () => Results.Ok(new { ok = true })); + app.MapGet(MutateProbePath, () => Results.Ok(new { ok = true })); + app.MapMethods(MutateProbePath, [HttpMethods.Options], () => Results.Ok(new { ok = true })); + + await app.StartAsync(); + try + { + string baseAddress = $"http://127.0.0.1:{port}"; + await scenario(baseAddress, CreateClient(baseAddress)); + } + finally + { + await app.StopAsync(); + await app.DisposeAsync(); + } + } + + // Шлёт запрос на эндпоинт сценария с опциональным заголовком Origin. + // client: HTTP-клиент сценария. + // method: HTTP-метод запроса. + // baseAddress: Базовый адрес хоста. + // path: Путь эндпоинта. + // origin: Значение заголовка Origin; null — заголовок не шлётся. + // Возвращает: Ответ эндпоинта. + private static async Task SendAsync( + HttpClient client, + HttpMethod method, + string baseAddress, + string path, + string? origin) + { + using var request = new HttpRequestMessage(method, baseAddress + path); + if (origin is not null) + { + request.Headers.TryAddWithoutValidation("Origin", origin); + } + + return await client.SendAsync(request); + } + + + // HTTP-клиент сценария. + private static HttpClient CreateClient(string baseAddress) => + new() + { + BaseAddress = new Uri(baseAddress), + }; + + // Читает тело ответа как JSON-документ (корень, отвязанный от документа). + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + string body = await response.Content.ReadAsStringAsync(); + using var document = JsonDocument.Parse(body); + return document.RootElement.Clone(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs index 05a9632..acd1c14 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs @@ -1,307 +1,307 @@ -using System.Text.Json; -using Deal.Api.Events; -using Deal.Api.Services; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Data; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Tenants; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты PipelineWorkerScheduler — фоновый цикл разбора очереди входящих -/// -public sealed class PipelineWorkerSchedulerTests -{ - // Тенант A теста (канал подписки). - private static readonly Guid TenantA = Guid.NewGuid(); - - // Тенант B теста (канал подписки). - private static readonly Guid TenantB = Guid.NewGuid(); - - // Контекст теста: планировщик на общих фейках + каналы подписок тенантов. - private sealed record Context( - PipelineWorkerScheduler Scheduler, - FakePipelineStore PipelineA, - FakeKanjStore KanjA, - SseSubscription SubscriptionA, - FakePipelineStore PipelineB, - FakeKanjStore KanjB, - SseSubscription SubscriptionB, - PipelinePumpGate PumpGate, - TenantContext TenantContext, - ListLogger Logs); - - // ─── Цикл: pump каждого тенанта в собственном scope + new_card ───────── - - [Fact] - public async Task RunCycle_PumpsEveryTenantQueueAndPublishesNewLeadPerCard() - { - Context ctx = CreateContext(); - ctx.PipelineA.SeedQueue(QueueRow("p_a_1", VacancyText, "d_a")); - ctx.PipelineB.SeedQueue(QueueRow("p_b_1", VacancyText, "d_b")); - - await ctx.Scheduler.RunCycleAsync(CancellationToken.None); - - // Каждый тенант отpump'ился в собственном scope: очередь пуста, карточка создана в «Неразобранном». - Assert.Empty(ctx.PipelineA.Queue); - Assert.Empty(ctx.PipelineB.Queue); - CardDto cardA = Assert.Single(ctx.KanjA.CardDtos); - CardDto cardB = Assert.Single(ctx.KanjB.CardDtos); - Assert.Equal(KanbanColumns.Inbox, cardA.Col); - Assert.Equal(KanbanColumns.Inbox, cardB.Col); - - Assert.Equal([cardA.Id], ReadNewLeadIds(ctx.SubscriptionA)); - Assert.Equal([cardB.Id], ReadNewLeadIds(ctx.SubscriptionB)); - - // Контекст AsyncLocal не должен переживать проход (Reset в finally каждого pump). - Assert.False(ctx.TenantContext.HasTenant); - } - - [Fact] - public async Task RunCycle_EmptyQueues_NoEventsAndNoTenantLeak() - { - Context ctx = CreateContext(); - - await ctx.Scheduler.RunCycleAsync(CancellationToken.None); - - Assert.False(ctx.SubscriptionA.Events.TryRead(out _)); - Assert.False(ctx.SubscriptionB.Events.TryRead(out _)); - Assert.False(ctx.TenantContext.HasTenant); - } - - // ─── Цикл: сбой pump тенанта не роняет проход ────────────────────────── - - [Fact] - public async Task RunCycle_TenantPumpFailure_DoesNotAbortOtherTenants() - { - // Pump тенанта A падает на чтении очереди (имитация сбоя схемы/БД) — A логируется, B обрабатывается. - Context ctx = CreateContext(withThrowingQueueReadA: true); - ctx.PipelineA.SeedQueue(QueueRow("p_a_1", VacancyText, "d_a")); - ctx.PipelineB.SeedQueue(QueueRow("p_b_1", VacancyText, "d_b")); - - await ctx.Scheduler.RunCycleAsync(CancellationToken.None); - - // Pump A не удался (лог планировщика) — строка A осталась, карточки A нет; B обработан штатно. - Assert.Single(ctx.PipelineA.Queue); - Assert.Empty(ctx.KanjA.CardDtos); - Assert.False(ctx.SubscriptionA.Events.TryRead(out _)); - Assert.Contains(ctx.Logs.Messages, message => message.Contains("pump тенанта") && message.Contains("ListAsync")); - Assert.Empty(ctx.PipelineB.Queue); - CardDto cardB = Assert.Single(ctx.KanjB.CardDtos); - Assert.Equal([cardB.Id], ReadNewLeadIds(ctx.SubscriptionB)); - Assert.False(ctx.TenantContext.HasTenant); - } - - // ─── Цикл: общий гейт с admin/tick ───────────────────────────────────── - - [Fact] - public async Task RunCycle_GateBusyForTenant_SkipsPumpAndKeepsQueue() - { - Context ctx = CreateContext(); - ctx.PipelineA.SeedQueue(QueueRow("p_a_1", VacancyText, "d_a")); - ctx.PipelineB.SeedQueue(QueueRow("p_b_1", VacancyText, "d_b")); - - Assert.True(ctx.PumpGate.TryEnter(TenantA)); - await ctx.Scheduler.RunCycleAsync(CancellationToken.None); - - Assert.Single(ctx.PipelineA.Queue); - Assert.Empty(ctx.KanjA.CardDtos); - Assert.False(ctx.SubscriptionA.Events.TryRead(out _)); - Assert.Empty(ctx.PipelineB.Queue); - CardDto cardB = Assert.Single(ctx.KanjB.CardDtos); - Assert.Equal([cardB.Id], ReadNewLeadIds(ctx.SubscriptionB)); - - // Гейт A остался за ручным тиком (цикл его не освобождал — не захватывал); после тика гейт свободен. - Assert.False(ctx.PumpGate.TryEnter(TenantA)); - ctx.PumpGate.Exit(TenantA); - Assert.True(ctx.PumpGate.TryEnter(TenantA)); - ctx.PumpGate.Exit(TenantA); - Assert.False(ctx.TenantContext.HasTenant); - } - - // ─── Контекст и хелперы ───────────────────────────────────────────────── - - // Текст вакансии-сценария: проходит правила, дедуп, ML «спит» → ИИ-путь → карточка inbox. - private const string VacancyText = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; - - // Собирает планировщик на реальных сервисах модуля Pipeline и тенант-фейках (без таймера). - // withThrowingQueueReadA: true — чтение очереди тенанта A бросает (сценарий сбоя pump). - // Возвращает: Планировщик, фейки очередей/канбана тенантов, каналы подписок и гейт. - private static Context CreateContext(bool withThrowingQueueReadA = false) - { - var tenants = new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)); - var tenantContext = new TenantContext(); - FakePipelineStore pipelineA = withThrowingQueueReadA ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore(); - var pipelineB = new FakePipelineStore(); - var kanjA = new FakeKanjStore(); - var kanjB = new FakeKanjStore(); - var settingsA = new FakeSettingsStore(); - var settingsB = new FakeSettingsStore(); - var pumpGate = new PipelinePumpGate(); - var aiClassifier = new FakeAiClassifier - { - ClassifyResult = Parsed("Python-разработчик в команду", isVacancy: true), - }; - - var services = new ServiceCollection(); - services.AddSingleton(tenantContext); - services.AddSingleton(); - services.AddSingleton(tenants); - services.AddSingleton(pumpGate); - services.AddSingleton(new FakeMlClient()); - services.AddSingleton(aiClassifier); - // Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant - // (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant). - services.AddScoped(provider => TenantOf(provider) == TenantA ? kanjA : kanjB); - services.AddScoped(provider => TenantOf(provider) == TenantA ? settingsA : settingsB); - services.AddScoped(provider => TenantOf(provider) == TenantA ? pipelineA : pipelineB); - // Реальные сервисы модуля Pipeline — как AddPipelineModule в Program.cs: цикл резолвит их в tenant-scope. - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - ServiceProvider provider = services.BuildServiceProvider(); - SseBroker broker = provider.GetRequiredService(); - var logs = new ListLogger(); - var scheduler = new PipelineWorkerScheduler( - provider.GetRequiredService(), - pumpGate, - broker, - logs); - - return new Context( - scheduler, - pipelineA, - kanjA, - broker.Subscribe(TenantA), - pipelineB, - kanjB, - broker.Subscribe(TenantB), - pumpGate, - tenantContext, - logs); - } - - // Id текущего тенанта из контекста (резолвер фейков, как ConnectionStringProvider.ForTenant). - // provider: Scope, в котором выполняется pump тенанта. - // Возвращает: Guid тенанта из TenantId (формат N). - private static Guid TenantOf(IServiceProvider provider) - { - TenantId tenantId = provider.GetRequiredService().TenantId - ?? throw new InvalidOperationException("Тест: pump вне tenant-контекста (SetTenant не выполнен)"); - return Guid.Parse(tenantId.Value); - } - - // Запись реестра тенанта (как строка public.tenants). - // id: Идентификатор тенанта. - // Возвращает: Запись тенанта со статусом active. - private static TenantRecordDto Tenant(Guid id) => - new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); - - // Строка очереди (status new; сценарий перекрывает текст/время — сейчас, stale не сработает). - private static QueueItemDto QueueRow( - string id, - string text, - string dialogId) => SourceItemFactory.Queue( - id, - text, - PipelineQueueStatuses.New, - origin: dialogId, - msgAtMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); - - // Минимальный разбор карточки (карточку домыслит CardComposer; колонка не назначена → inbox). - private static AiParsedCardDto Parsed(string title, bool isVacancy = false) => new( - title, - Company: null, - Format: null, - Task: null, - Requirements: null, - Plus: null, - Conditions: null, - Summary: null, - Stack: Array.Empty(), - Budget: null, - Contacts: Array.Empty(), - isVacancy, - IsVacancyKnown: false, - IsSpam: false, - Board: null); - - // Id карточек из new_card-событий канала в порядке публикации. - // subscription: Подписка канала тенанта. - // Возвращает: Id созданных карточек (поле id payload'а new_card). - private static List ReadNewLeadIds(SseSubscription subscription) - { - var cardIds = new List(); - while (subscription.Events.TryRead(out SseEvent? sseEvent)) - { - Assert.Equal("new_card", sseEvent!.Type); - using JsonDocument payload = JsonDocument.Parse(sseEvent.Json); - cardIds.Add(payload.RootElement.GetProperty("id").GetString()!); - } - - return cardIds; - } - - // Хранилище со сбоем чтения очереди: ListAsync бросает (сценарий «БД/схема недоступны» на pump). - private sealed class ThrowingQueueReadPipelineStore : FakePipelineStore - { - /// - public override Task> ListAsync( - string? status, - int limit, - CancellationToken ct) - { - throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync)."); - } - } - - // Логгер-коллектор: копит сообщения планировщика (диагностика в тестах сбоя pump). - private sealed class ListLogger : ILogger - { - /// - /// Отформатированные сообщения лога в порядке записи. - /// - public List Messages { get; } = []; - - /// - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - /// - public bool IsEnabled(LogLevel logLevel) => true; - - /// - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - Messages.Add(exception is null ? formatter(state, exception) : formatter(state, exception) + " :: " + exception); - } - } -} +using System.Text.Json; +using Deal.Api.Events; +using Deal.Api.Services; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using Deal.Infrastructure.Data; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; +using Deal.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Kanban; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Modules.Tenants; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты PipelineWorkerScheduler — фоновый цикл разбора очереди входящих +/// +public sealed class PipelineWorkerSchedulerTests +{ + // Тенант A теста (канал подписки). + private static readonly Guid TenantA = Guid.NewGuid(); + + // Тенант B теста (канал подписки). + private static readonly Guid TenantB = Guid.NewGuid(); + + // Контекст теста: планировщик на общих фейках + каналы подписок тенантов. + private sealed record Context( + PipelineWorkerScheduler Scheduler, + FakePipelineStore PipelineA, + FakeKanjStore KanjA, + SseSubscription SubscriptionA, + FakePipelineStore PipelineB, + FakeKanjStore KanjB, + SseSubscription SubscriptionB, + PipelinePumpGate PumpGate, + TenantContext TenantContext, + ListLogger Logs); + + // ─── Цикл: pump каждого тенанта в собственном scope + new_card ───────── + + [Fact] + public async Task RunCycle_PumpsEveryTenantQueueAndPublishesNewLeadPerCard() + { + Context ctx = CreateContext(); + ctx.PipelineA.SeedQueue(QueueRow("p_a_1", VacancyText, "d_a")); + ctx.PipelineB.SeedQueue(QueueRow("p_b_1", VacancyText, "d_b")); + + await ctx.Scheduler.RunCycleAsync(CancellationToken.None); + + // Каждый тенант отpump'ился в собственном scope: очередь пуста, карточка создана в «Неразобранном». + Assert.Empty(ctx.PipelineA.Queue); + Assert.Empty(ctx.PipelineB.Queue); + CardDto cardA = Assert.Single(ctx.KanjA.CardDtos); + CardDto cardB = Assert.Single(ctx.KanjB.CardDtos); + Assert.Equal(KanbanColumns.Inbox, cardA.Col); + Assert.Equal(KanbanColumns.Inbox, cardB.Col); + + Assert.Equal([cardA.Id], ReadNewLeadIds(ctx.SubscriptionA)); + Assert.Equal([cardB.Id], ReadNewLeadIds(ctx.SubscriptionB)); + + // Контекст AsyncLocal не должен переживать проход (Reset в finally каждого pump). + Assert.False(ctx.TenantContext.HasTenant); + } + + [Fact] + public async Task RunCycle_EmptyQueues_NoEventsAndNoTenantLeak() + { + Context ctx = CreateContext(); + + await ctx.Scheduler.RunCycleAsync(CancellationToken.None); + + Assert.False(ctx.SubscriptionA.Events.TryRead(out _)); + Assert.False(ctx.SubscriptionB.Events.TryRead(out _)); + Assert.False(ctx.TenantContext.HasTenant); + } + + // ─── Цикл: сбой pump тенанта не роняет проход ────────────────────────── + + [Fact] + public async Task RunCycle_TenantPumpFailure_DoesNotAbortOtherTenants() + { + // Pump тенанта A падает на чтении очереди (имитация сбоя схемы/БД) — A логируется, B обрабатывается. + Context ctx = CreateContext(withThrowingQueueReadA: true); + ctx.PipelineA.SeedQueue(QueueRow("p_a_1", VacancyText, "d_a")); + ctx.PipelineB.SeedQueue(QueueRow("p_b_1", VacancyText, "d_b")); + + await ctx.Scheduler.RunCycleAsync(CancellationToken.None); + + // Pump A не удался (лог планировщика) — строка A осталась, карточки A нет; B обработан штатно. + Assert.Single(ctx.PipelineA.Queue); + Assert.Empty(ctx.KanjA.CardDtos); + Assert.False(ctx.SubscriptionA.Events.TryRead(out _)); + Assert.Contains(ctx.Logs.Messages, message => message.Contains("pump тенанта") && message.Contains("ListAsync")); + Assert.Empty(ctx.PipelineB.Queue); + CardDto cardB = Assert.Single(ctx.KanjB.CardDtos); + Assert.Equal([cardB.Id], ReadNewLeadIds(ctx.SubscriptionB)); + Assert.False(ctx.TenantContext.HasTenant); + } + + // ─── Цикл: общий гейт с admin/tick ───────────────────────────────────── + + [Fact] + public async Task RunCycle_GateBusyForTenant_SkipsPumpAndKeepsQueue() + { + Context ctx = CreateContext(); + ctx.PipelineA.SeedQueue(QueueRow("p_a_1", VacancyText, "d_a")); + ctx.PipelineB.SeedQueue(QueueRow("p_b_1", VacancyText, "d_b")); + + Assert.True(ctx.PumpGate.TryEnter(TenantA)); + await ctx.Scheduler.RunCycleAsync(CancellationToken.None); + + Assert.Single(ctx.PipelineA.Queue); + Assert.Empty(ctx.KanjA.CardDtos); + Assert.False(ctx.SubscriptionA.Events.TryRead(out _)); + Assert.Empty(ctx.PipelineB.Queue); + CardDto cardB = Assert.Single(ctx.KanjB.CardDtos); + Assert.Equal([cardB.Id], ReadNewLeadIds(ctx.SubscriptionB)); + + // Гейт A остался за ручным тиком (цикл его не освобождал — не захватывал); после тика гейт свободен. + Assert.False(ctx.PumpGate.TryEnter(TenantA)); + ctx.PumpGate.Exit(TenantA); + Assert.True(ctx.PumpGate.TryEnter(TenantA)); + ctx.PumpGate.Exit(TenantA); + Assert.False(ctx.TenantContext.HasTenant); + } + + // ─── Контекст и хелперы ───────────────────────────────────────────────── + + // Текст вакансии-сценария: проходит правила, дедуп, ML «спит» → ИИ-путь → карточка inbox. + private const string VacancyText = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; + + // Собирает планировщик на реальных сервисах модуля Pipeline и тенант-фейках (без таймера). + // withThrowingQueueReadA: true — чтение очереди тенанта A бросает (сценарий сбоя pump). + // Возвращает: Планировщик, фейки очередей/канбана тенантов, каналы подписок и гейт. + private static Context CreateContext(bool withThrowingQueueReadA = false) + { + var tenants = new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)); + var tenantContext = new TenantContext(); + FakePipelineStore pipelineA = withThrowingQueueReadA ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore(); + var pipelineB = new FakePipelineStore(); + var kanjA = new FakeKanjStore(); + var kanjB = new FakeKanjStore(); + var settingsA = new FakeSettingsStore(); + var settingsB = new FakeSettingsStore(); + var pumpGate = new PipelinePumpGate(); + var aiClassifier = new FakeAiClassifier + { + ClassifyResult = Parsed("Python-разработчик в команду", isVacancy: true), + }; + + var services = new ServiceCollection(); + services.AddSingleton(tenantContext); + services.AddSingleton(); + services.AddSingleton(tenants); + services.AddSingleton(pumpGate); + services.AddSingleton(new FakeMlClient()); + services.AddSingleton(aiClassifier); + // Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant + // (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant). + services.AddScoped(provider => TenantOf(provider) == TenantA ? kanjA : kanjB); + services.AddScoped(provider => TenantOf(provider) == TenantA ? settingsA : settingsB); + services.AddScoped(provider => TenantOf(provider) == TenantA ? pipelineA : pipelineB); + // Реальные сервисы модуля Pipeline — как AddPipelineModule в Program.cs: цикл резолвит их в tenant-scope. + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + ServiceProvider provider = services.BuildServiceProvider(); + SseBroker broker = provider.GetRequiredService(); + var logs = new ListLogger(); + var scheduler = new PipelineWorkerScheduler( + provider.GetRequiredService(), + pumpGate, + broker, + logs); + + return new Context( + scheduler, + pipelineA, + kanjA, + broker.Subscribe(TenantA), + pipelineB, + kanjB, + broker.Subscribe(TenantB), + pumpGate, + tenantContext, + logs); + } + + // Id текущего тенанта из контекста (резолвер фейков, как ConnectionStringProvider.ForTenant). + // provider: Scope, в котором выполняется pump тенанта. + // Возвращает: Guid тенанта из TenantId (формат N). + private static Guid TenantOf(IServiceProvider provider) + { + TenantId tenantId = provider.GetRequiredService().TenantId + ?? throw new InvalidOperationException("Тест: pump вне tenant-контекста (SetTenant не выполнен)"); + return Guid.Parse(tenantId.Value); + } + + // Запись реестра тенанта (как строка public.tenants). + // id: Идентификатор тенанта. + // Возвращает: Запись тенанта со статусом active. + private static TenantRecordDto Tenant(Guid id) => + new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); + + // Строка очереди (status new; сценарий перекрывает текст/время — сейчас, stale не сработает). + private static QueueItemDto QueueRow( + string id, + string text, + string dialogId) => SourceItemFactory.Queue( + id, + text, + PipelineQueueStatuses.New, + origin: dialogId, + msgAtMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + + // Минимальный разбор карточки (карточку домыслит CardComposer; колонка не назначена → inbox). + private static AiParsedCardDto Parsed(string title, bool isVacancy = false) => new( + title, + Company: null, + Format: null, + Task: null, + Requirements: null, + Plus: null, + Conditions: null, + Summary: null, + Stack: Array.Empty(), + Budget: null, + Contacts: Array.Empty(), + isVacancy, + IsVacancyKnown: false, + IsSpam: false, + Board: null); + + // Id карточек из new_card-событий канала в порядке публикации. + // subscription: Подписка канала тенанта. + // Возвращает: Id созданных карточек (поле id payload'а new_card). + private static List ReadNewLeadIds(SseSubscription subscription) + { + var cardIds = new List(); + while (subscription.Events.TryRead(out SseEvent? sseEvent)) + { + Assert.Equal("new_card", sseEvent!.Type); + using JsonDocument payload = JsonDocument.Parse(sseEvent.Json); + cardIds.Add(payload.RootElement.GetProperty("id").GetString()!); + } + + return cardIds; + } + + // Хранилище со сбоем чтения очереди: ListAsync бросает (сценарий «БД/схема недоступны» на pump). + private sealed class ThrowingQueueReadPipelineStore : FakePipelineStore + { + /// + public override Task> ListAsync( + string? status, + int limit, + CancellationToken ct) + { + throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync)."); + } + } + + // Логгер-коллектор: копит сообщения планировщика (диагностика в тестах сбоя pump). + private sealed class ListLogger : ILogger + { + /// + /// Отформатированные сообщения лога в порядке записи. + /// + public List Messages { get; } = []; + + /// + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + /// + public bool IsEnabled(LogLevel logLevel) => true; + + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Messages.Add(exception is null ? formatter(state, exception) : formatter(state, exception) + " :: " + exception); + } + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/RateLimitHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/RateLimitHttpTests.cs index 5ff2ee5..7fcef96 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/RateLimitHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/RateLimitHttpTests.cs @@ -1,220 +1,220 @@ -using System.Net; -using System.Text.Json; -using Deal.Api.Configuration; -using Deal.Api.Extensions; -using Deal.Api.Middleware; -using Deal.Api.Models; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Tests.Unit.Modules.Tenants; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Tests.Unit.Support; - -/// -/// HTTP-тесты политик встроенного rate limiter -/// -public sealed class RateLimitHttpTests -{ - // Путь эндпоинта под именованной политикой "auth" (эталон ручки входа). - private const string AuthProbePath = "/rate/auth"; - - // Путь эндпоинта под именованной политикой "api". - private const string ApiProbePath = "/rate/api"; - - // Путь эндпоинта без политики (проверка глобального лимитера API-партиции). - private const string GlobalProbePath = "/rate/global"; - - // Ключ query-параметра маркера сессии теста (?tenant=<guid> → CurrentUser). - private const string TenantQueryKey = "tenant"; - - // Тенант A сценариев изоляции партиций. - private static readonly Guid TenantA = Guid.NewGuid(); - - // Тенант B сценариев изоляции партиций. - private static readonly Guid TenantB = Guid.NewGuid(); - - // ─── Превышение окна → 429 ───────────────────────────────────────────── - - /// - /// Политика "auth" - /// - [Fact] - public async Task AuthPolicy_ExceedingWindow_Returns429WithRejectedDetail() - { - var options = new RateLimitOptions { Enabled = true, AuthPerMinute = 2 }; - - await RunAsync( - options, - async (baseAddress, client) => - { - using HttpResponseMessage first = await client.GetAsync($"{baseAddress}{AuthProbePath}"); - using HttpResponseMessage second = await client.GetAsync($"{baseAddress}{AuthProbePath}"); - Assert.Equal(HttpStatusCode.OK, first.StatusCode); - Assert.Equal(HttpStatusCode.OK, second.StatusCode); - - using HttpResponseMessage rejected = await client.GetAsync($"{baseAddress}{AuthProbePath}"); - Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode); - Assert.Equal( - RateLimitPolicies.RejectedDetail, - (await ReadJsonAsync(rejected)).GetProperty("detail").GetString()); - }); - } - - /// - /// Политика "api" - /// - [Fact] - public async Task ApiPolicy_AnonymousExceedingWindow_Returns429WithRejectedDetail() - { - var options = new RateLimitOptions { Enabled = true, ApiPerMinute = 2 }; - - await RunAsync( - options, - async (baseAddress, client) => - { - using HttpResponseMessage first = await client.GetAsync($"{baseAddress}{GlobalProbePath}"); - using HttpResponseMessage second = await client.GetAsync($"{baseAddress}{GlobalProbePath}"); - Assert.Equal(HttpStatusCode.OK, first.StatusCode); - Assert.Equal(HttpStatusCode.OK, second.StatusCode); - - using HttpResponseMessage rejected = await client.GetAsync($"{baseAddress}{GlobalProbePath}"); - Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode); - Assert.Equal( - RateLimitPolicies.RejectedDetail, - (await ReadJsonAsync(rejected)).GetProperty("detail").GetString()); - }); - } - - // ─── Ключ политики "api": тенант vs IP анонима ───────────────────────── - - /// - /// Партиции изолированы - /// - [Fact] - public async Task ApiPolicy_TenantBucket_IsSeparateFromAnonymousIpAndOtherTenants() - { - var options = new RateLimitOptions { Enabled = true, ApiPerMinute = 2 }; - - await RunAsync( - options, - async (baseAddress, client) => - { - // Исчерпываем анонимный бакет (ключ — IP): 3-й запрос 429. - using (HttpResponseMessage first = await client.GetAsync($"{baseAddress}{ApiProbePath}")) - using (HttpResponseMessage second = await client.GetAsync($"{baseAddress}{ApiProbePath}")) - using (HttpResponseMessage third = await client.GetAsync($"{baseAddress}{ApiProbePath}")) - { - Assert.Equal(HttpStatusCode.OK, first.StatusCode); - Assert.Equal(HttpStatusCode.OK, second.StatusCode); - Assert.Equal(HttpStatusCode.TooManyRequests, third.StatusCode); - } - - // Анонимный IP исчерпан, но ключ тенанта A — свой бакет: запросы проходят. - using (HttpResponseMessage tenantFirst = await client.GetAsync($"{baseAddress}{ApiProbePath}?{TenantQueryKey}={TenantA:N}")) - using (HttpResponseMessage tenantSecond = await client.GetAsync($"{baseAddress}{ApiProbePath}?{TenantQueryKey}={TenantA:N}")) - { - Assert.Equal(HttpStatusCode.OK, tenantFirst.StatusCode); - Assert.Equal(HttpStatusCode.OK, tenantSecond.StatusCode); - } - - // Окно тенанта A исчерпано, тенант B — отдельный бакет. - using HttpResponseMessage tenantAThird = await client.GetAsync($"{baseAddress}{ApiProbePath}?{TenantQueryKey}={TenantA:N}"); - using HttpResponseMessage tenantBFirst = await client.GetAsync($"{baseAddress}{ApiProbePath}?{TenantQueryKey}={TenantB:N}"); - Assert.Equal(HttpStatusCode.TooManyRequests, tenantAThird.StatusCode); - Assert.Equal(HttpStatusCode.OK, tenantBFirst.StatusCode); - }); - } - - // ─── Флаг Enabled=false (dev/тесты) ───────────────────────────────────── - - /// - /// RateLimit:Enabled=false — лимитеры/middleware не регистрируются - /// - [Fact] - public async Task RateLimiterDisabled_RequestsAreNotLimited() - { - await RunAsync( - new RateLimitOptions(), - async (baseAddress, client) => - { - for (int request = 0; request < 6; request++) - { - using HttpResponseMessage response = await client.GetAsync($"{baseAddress}{AuthProbePath}"); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - }); - } - - // ─── Хост сценария ───────────────────────────────────────────────────── - - // Поднимает in-process Kestrel с политиками rate limiter (как Program.cs) и прогоняет сценарий. - // options: Опции rate limiting сценария (Enabled управляет регистрацией лимитера). - // scenario: Сценарий с base-адресом и клиентом. - private static async Task RunAsync(RateLimitOptions options, Func scenario) - { - int port = TestPort.Allocate(); - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, port)); - - if (options.Enabled) - { - builder.Services.AddSingleton(new FakeRateLimitCounterStore()); - builder.Services.AddDealRateLimiter(options); - } - - WebApplication app = builder.Build(); - // «Сессионный» слой до UseRateLimiter (эталон SessionMiddleware): query ?tenant= кладёт - // CurrentUser — политика "api" ключует по TenantId вместо IP анонима. - app.Use(async (context, next) => - { - if (context.Request.Query.TryGetValue(TenantQueryKey, out var values) - && Guid.TryParse(values[0], out Guid tenantId)) - { - context.SetCurrentUser(new CurrentUser(Guid.NewGuid(), "test-user", tenantId, "active")); - } - - await next(context); - }); - - if (options.Enabled) - { - app.UseRateLimiter(); - } - - app.MapGet(AuthProbePath, () => Results.Ok(new { ok = true })).RequireRateLimiting(RateLimitPolicies.AuthPolicy); - app.MapGet(ApiProbePath, () => Results.Ok(new { ok = true })).RequireRateLimiting(RateLimitPolicies.ApiPolicy); - // Без RequireRateLimiting — эндпоинт ограничен глобальным лимитером (API-партиция), как весь /api. - app.MapGet(GlobalProbePath, () => Results.Ok(new { ok = true })); - - await app.StartAsync(); - try - { - string baseAddress = $"http://127.0.0.1:{port}"; - await scenario(baseAddress, CreateClient(baseAddress)); - } - finally - { - await app.StopAsync(); - await app.DisposeAsync(); - } - } - - - // HTTP-клиент сценария. - private static HttpClient CreateClient(string baseAddress) => - new() - { - BaseAddress = new Uri(baseAddress), - }; - - // Читает тело ответа как JSON-документ (корень, отвязанный от документа). - private static async Task ReadJsonAsync(HttpResponseMessage response) - { - string body = await response.Content.ReadAsStringAsync(); - using var document = JsonDocument.Parse(body); - return document.RootElement.Clone(); - } -} +using System.Net; +using System.Text.Json; +using Deal.Api.Configuration; +using Deal.Api.Extensions; +using Deal.Api.Middleware; +using Deal.Api.Models; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Tests.Unit.Modules.Tenants; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Tests.Unit.Support; + +/// +/// HTTP-тесты политик встроенного rate limiter +/// +public sealed class RateLimitHttpTests +{ + // Путь эндпоинта под именованной политикой "auth" (эталон ручки входа). + private const string AuthProbePath = "/rate/auth"; + + // Путь эндпоинта под именованной политикой "api". + private const string ApiProbePath = "/rate/api"; + + // Путь эндпоинта без политики (проверка глобального лимитера API-партиции). + private const string GlobalProbePath = "/rate/global"; + + // Ключ query-параметра маркера сессии теста (?tenant=<guid> → CurrentUser). + private const string TenantQueryKey = "tenant"; + + // Тенант A сценариев изоляции партиций. + private static readonly Guid TenantA = Guid.NewGuid(); + + // Тенант B сценариев изоляции партиций. + private static readonly Guid TenantB = Guid.NewGuid(); + + // ─── Превышение окна → 429 ───────────────────────────────────────────── + + /// + /// Политика "auth" + /// + [Fact] + public async Task AuthPolicy_ExceedingWindow_Returns429WithRejectedDetail() + { + var options = new RateLimitOptions { Enabled = true, AuthPerMinute = 2 }; + + await RunAsync( + options, + async (baseAddress, client) => + { + using HttpResponseMessage first = await client.GetAsync($"{baseAddress}{AuthProbePath}"); + using HttpResponseMessage second = await client.GetAsync($"{baseAddress}{AuthProbePath}"); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + + using HttpResponseMessage rejected = await client.GetAsync($"{baseAddress}{AuthProbePath}"); + Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode); + Assert.Equal( + RateLimitPolicies.RejectedDetail, + (await ReadJsonAsync(rejected)).GetProperty("detail").GetString()); + }); + } + + /// + /// Политика "api" + /// + [Fact] + public async Task ApiPolicy_AnonymousExceedingWindow_Returns429WithRejectedDetail() + { + var options = new RateLimitOptions { Enabled = true, ApiPerMinute = 2 }; + + await RunAsync( + options, + async (baseAddress, client) => + { + using HttpResponseMessage first = await client.GetAsync($"{baseAddress}{GlobalProbePath}"); + using HttpResponseMessage second = await client.GetAsync($"{baseAddress}{GlobalProbePath}"); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + + using HttpResponseMessage rejected = await client.GetAsync($"{baseAddress}{GlobalProbePath}"); + Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode); + Assert.Equal( + RateLimitPolicies.RejectedDetail, + (await ReadJsonAsync(rejected)).GetProperty("detail").GetString()); + }); + } + + // ─── Ключ политики "api": тенант vs IP анонима ───────────────────────── + + /// + /// Партиции изолированы + /// + [Fact] + public async Task ApiPolicy_TenantBucket_IsSeparateFromAnonymousIpAndOtherTenants() + { + var options = new RateLimitOptions { Enabled = true, ApiPerMinute = 2 }; + + await RunAsync( + options, + async (baseAddress, client) => + { + // Исчерпываем анонимный бакет (ключ — IP): 3-й запрос 429. + using (HttpResponseMessage first = await client.GetAsync($"{baseAddress}{ApiProbePath}")) + using (HttpResponseMessage second = await client.GetAsync($"{baseAddress}{ApiProbePath}")) + using (HttpResponseMessage third = await client.GetAsync($"{baseAddress}{ApiProbePath}")) + { + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + Assert.Equal(HttpStatusCode.TooManyRequests, third.StatusCode); + } + + // Анонимный IP исчерпан, но ключ тенанта A — свой бакет: запросы проходят. + using (HttpResponseMessage tenantFirst = await client.GetAsync($"{baseAddress}{ApiProbePath}?{TenantQueryKey}={TenantA:N}")) + using (HttpResponseMessage tenantSecond = await client.GetAsync($"{baseAddress}{ApiProbePath}?{TenantQueryKey}={TenantA:N}")) + { + Assert.Equal(HttpStatusCode.OK, tenantFirst.StatusCode); + Assert.Equal(HttpStatusCode.OK, tenantSecond.StatusCode); + } + + // Окно тенанта A исчерпано, тенант B — отдельный бакет. + using HttpResponseMessage tenantAThird = await client.GetAsync($"{baseAddress}{ApiProbePath}?{TenantQueryKey}={TenantA:N}"); + using HttpResponseMessage tenantBFirst = await client.GetAsync($"{baseAddress}{ApiProbePath}?{TenantQueryKey}={TenantB:N}"); + Assert.Equal(HttpStatusCode.TooManyRequests, tenantAThird.StatusCode); + Assert.Equal(HttpStatusCode.OK, tenantBFirst.StatusCode); + }); + } + + // ─── Флаг Enabled=false (dev/тесты) ───────────────────────────────────── + + /// + /// RateLimit:Enabled=false — лимитеры/middleware не регистрируются + /// + [Fact] + public async Task RateLimiterDisabled_RequestsAreNotLimited() + { + await RunAsync( + new RateLimitOptions(), + async (baseAddress, client) => + { + for (int request = 0; request < 6; request++) + { + using HttpResponseMessage response = await client.GetAsync($"{baseAddress}{AuthProbePath}"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + }); + } + + // ─── Хост сценария ───────────────────────────────────────────────────── + + // Поднимает in-process Kestrel с политиками rate limiter (как Program.cs) и прогоняет сценарий. + // options: Опции rate limiting сценария (Enabled управляет регистрацией лимитера). + // scenario: Сценарий с base-адресом и клиентом. + private static async Task RunAsync(RateLimitOptions options, Func scenario) + { + int port = TestPort.Allocate(); + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, port)); + + if (options.Enabled) + { + builder.Services.AddSingleton(new FakeRateLimitCounterStore()); + builder.Services.AddDealRateLimiter(options); + } + + WebApplication app = builder.Build(); + // «Сессионный» слой до UseRateLimiter (эталон SessionMiddleware): query ?tenant= кладёт + // CurrentUser — политика "api" ключует по TenantId вместо IP анонима. + app.Use(async (context, next) => + { + if (context.Request.Query.TryGetValue(TenantQueryKey, out var values) + && Guid.TryParse(values[0], out Guid tenantId)) + { + context.SetCurrentUser(new CurrentUser(Guid.NewGuid(), "test-user", tenantId, "active")); + } + + await next(context); + }); + + if (options.Enabled) + { + app.UseRateLimiter(); + } + + app.MapGet(AuthProbePath, () => Results.Ok(new { ok = true })).RequireRateLimiting(RateLimitPolicies.AuthPolicy); + app.MapGet(ApiProbePath, () => Results.Ok(new { ok = true })).RequireRateLimiting(RateLimitPolicies.ApiPolicy); + // Без RequireRateLimiting — эндпоинт ограничен глобальным лимитером (API-партиция), как весь /api. + app.MapGet(GlobalProbePath, () => Results.Ok(new { ok = true })); + + await app.StartAsync(); + try + { + string baseAddress = $"http://127.0.0.1:{port}"; + await scenario(baseAddress, CreateClient(baseAddress)); + } + finally + { + await app.StopAsync(); + await app.DisposeAsync(); + } + } + + + // HTTP-клиент сценария. + private static HttpClient CreateClient(string baseAddress) => + new() + { + BaseAddress = new Uri(baseAddress), + }; + + // Читает тело ответа как JSON-документ (корень, отвязанный от документа). + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + string body = await response.Content.ReadAsStringAsync(); + using var document = JsonDocument.Parse(body); + return document.RootElement.Clone(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/SecurityEventCapture.cs b/src/core/tests/Deal.Tests.Unit/Support/SecurityEventCapture.cs index 7a31785..a1f875d 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/SecurityEventCapture.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/SecurityEventCapture.cs @@ -1,78 +1,78 @@ -using System.Diagnostics.Metrics; -using Deal.SharedKernel.Observability; - -namespace Deal.Tests.Unit.Support; - -/// -/// Слушатель счётчика подозрительной активности deal.security.suspicious. -/// -public sealed class SecurityEventCapture : IDisposable -{ - // Защита накопленных измерений (callback может прийти из потока запроса). - private readonly object _gate = new(); - - private readonly List<(string? Kind, long Value)> _measurements = []; - - private readonly MeterListener _listener; - - /// - /// Начинает слушать счётчик подозрительной активности. - /// - public SecurityEventCapture() - { - _listener = new MeterListener(); - _listener.InstrumentPublished = (instrument, current) => - { - if (instrument.Meter.Name == DealMetrics.MeterName - && instrument.Name == DealMetrics.SecurityEvents.Name) - { - current.EnableMeasurementEvents(instrument); - } - }; - _listener.SetMeasurementEventCallback((_, value, tags, _) => - { - string? kind = null; - foreach (KeyValuePair tag in tags) - { - if (tag.Key == DealMetrics.SecurityKindTagName) - { - kind = tag.Value?.ToString(); - } - } - - lock (_gate) - { - _measurements.Add((kind, value)); - } - }); - _listener.Start(); - } - - /// - /// Снимок измерений с момента создания слушателя. - /// - /// Пары (вид события, значение инкремента). - public IReadOnlyList<(string? Kind, long Value)> Snapshot() - { - lock (_gate) - { - return [.. _measurements]; - } - } - - /// - /// Сумма инкрементов по виду события. - /// - /// Вид события (метка kind). - /// Суммарное значение измерений с указанным видом. - public long Sum(string kind) - { - lock (_gate) - { - return _measurements.Where(measurement => measurement.Kind == kind).Sum(measurement => measurement.Value); - } - } - - /// - public void Dispose() => _listener.Dispose(); -} +using System.Diagnostics.Metrics; +using Deal.SharedKernel.Observability; + +namespace Deal.Tests.Unit.Support; + +/// +/// Слушатель счётчика подозрительной активности deal.security.suspicious. +/// +public sealed class SecurityEventCapture : IDisposable +{ + // Защита накопленных измерений (callback может прийти из потока запроса). + private readonly object _gate = new(); + + private readonly List<(string? Kind, long Value)> _measurements = []; + + private readonly MeterListener _listener; + + /// + /// Начинает слушать счётчик подозрительной активности. + /// + public SecurityEventCapture() + { + _listener = new MeterListener(); + _listener.InstrumentPublished = (instrument, current) => + { + if (instrument.Meter.Name == DealMetrics.MeterName + && instrument.Name == DealMetrics.SecurityEvents.Name) + { + current.EnableMeasurementEvents(instrument); + } + }; + _listener.SetMeasurementEventCallback((_, value, tags, _) => + { + string? kind = null; + foreach (KeyValuePair tag in tags) + { + if (tag.Key == DealMetrics.SecurityKindTagName) + { + kind = tag.Value?.ToString(); + } + } + + lock (_gate) + { + _measurements.Add((kind, value)); + } + }); + _listener.Start(); + } + + /// + /// Снимок измерений с момента создания слушателя. + /// + /// Пары (вид события, значение инкремента). + public IReadOnlyList<(string? Kind, long Value)> Snapshot() + { + lock (_gate) + { + return [.. _measurements]; + } + } + + /// + /// Сумма инкрементов по виду события. + /// + /// Вид события (метка kind). + /// Суммарное значение измерений с указанным видом. + public long Sum(string kind) + { + lock (_gate) + { + return _measurements.Where(measurement => measurement.Kind == kind).Sum(measurement => measurement.Value); + } + } + + /// + public void Dispose() => _listener.Dispose(); +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/ServiceHealthProbeTests.cs b/src/core/tests/Deal.Tests.Unit/Support/ServiceHealthProbeTests.cs index 0ddb741..54be8fa 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/ServiceHealthProbeTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/ServiceHealthProbeTests.cs @@ -1,108 +1,108 @@ -using System.Net; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Services; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Server.Kestrel.Core; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты gRPC-health-пробы автономных сервисов -/// -public sealed class ServiceHealthProbeTests -{ - [Fact] - public async Task Probe_ServingHealthServer_ReturnsReachableAndServing() - { - await RunWithHealthServerAsync( - async () => HealthCheckResult.Healthy("сервис готов"), - async (port, probe) => - { - ServiceHealthResult result = await probe.ProbeAsync($"http://127.0.0.1:{port}", CancellationToken.None); - - Assert.True(result.Reachable); - Assert.True(result.Serving); - }); - } - - [Fact] - public async Task Probe_NotServingHealthServer_ReturnsReachableButNotServing() - { - await RunWithHealthServerAsync( - async () => HealthCheckResult.Unhealthy("проверка упала"), - async (port, probe) => - { - ServiceHealthResult result = await probe.ProbeAsync($"http://127.0.0.1:{port}", CancellationToken.None); - - // Сервис ответил на health-RPC, но статус не SERVING — оператор видит «unhealthy», не down. - Assert.True(result.Reachable); - Assert.False(result.Serving); - }); - } - - [Fact] - public async Task Probe_UnavailablePort_ReturnsNotReachable() - { - var probe = new ServiceHealthProbe(); - int closedPort = TestPort.Allocate(); // Порт освобождён — слушателя нет: RPC упадёт с Unavailable. - - ServiceHealthResult result = await probe.ProbeAsync($"http://127.0.0.1:{closedPort}", CancellationToken.None); - - Assert.False(result.Reachable); - Assert.False(result.Serving); - } - - [Fact] - public async Task Probe_SlowServer_ExceedsDeadline_ReturnsNotReachable() - { - await RunWithHealthServerAsync( - async () => - { - await Task.Delay(TimeSpan.FromSeconds(4)); - return HealthCheckResult.Healthy("опоздал"); - }, - async (port, probe) => - { - ServiceHealthResult result = await probe.ProbeAsync($"http://127.0.0.1:{port}", CancellationToken.None); - - Assert.False(result.Reachable); - Assert.False(result.Serving); - }); - } - - // Поднимает in-process gRPC-health-сервер с заданной проверкой "ready" и гоняет сценарий. - // check: Проверка "ready" (async: Healthy/Unhealthy; задержка — сценарий дедлайна). - // scenario: Сценарий: получает порт хоста и пробу. - private static async Task RunWithHealthServerAsync(Func> check, Func scenario) - { - WebApplication? app = null; - try - { - int port = TestPort.Allocate(); - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.WebHost.ConfigureKestrel(kestrel => - kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2)); - - // Стандартный grpc.health.v1 ASP.NET Core: AddGrpcHealthChecks + явная проверка "ready" - // (без проверок health-сервис отвечает UNKNOWN — как в Deal.Api, см. Program.cs). - builder.Services.AddGrpc(); - builder.Services.AddGrpcHealthChecks().AddAsyncCheck("ready", check); - app = builder.Build(); - app.MapGrpcHealthChecksService(); - await app.StartAsync(); - - await scenario(port, new ServiceHealthProbe()); - } - finally - { - if (app is not null) - { - await app.StopAsync(); - await app.DisposeAsync(); - } - } - } -} +using System.Net; +using Deal.Infrastructure.Integrations.Models; +using Deal.Infrastructure.Integrations.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты gRPC-health-пробы автономных сервисов +/// +public sealed class ServiceHealthProbeTests +{ + [Fact] + public async Task Probe_ServingHealthServer_ReturnsReachableAndServing() + { + await RunWithHealthServerAsync( + async () => HealthCheckResult.Healthy("сервис готов"), + async (port, probe) => + { + ServiceHealthResult result = await probe.ProbeAsync($"http://127.0.0.1:{port}", CancellationToken.None); + + Assert.True(result.Reachable); + Assert.True(result.Serving); + }); + } + + [Fact] + public async Task Probe_NotServingHealthServer_ReturnsReachableButNotServing() + { + await RunWithHealthServerAsync( + async () => HealthCheckResult.Unhealthy("проверка упала"), + async (port, probe) => + { + ServiceHealthResult result = await probe.ProbeAsync($"http://127.0.0.1:{port}", CancellationToken.None); + + // Сервис ответил на health-RPC, но статус не SERVING — оператор видит «unhealthy», не down. + Assert.True(result.Reachable); + Assert.False(result.Serving); + }); + } + + [Fact] + public async Task Probe_UnavailablePort_ReturnsNotReachable() + { + var probe = new ServiceHealthProbe(); + int closedPort = TestPort.Allocate(); // Порт освобождён — слушателя нет: RPC упадёт с Unavailable. + + ServiceHealthResult result = await probe.ProbeAsync($"http://127.0.0.1:{closedPort}", CancellationToken.None); + + Assert.False(result.Reachable); + Assert.False(result.Serving); + } + + [Fact] + public async Task Probe_SlowServer_ExceedsDeadline_ReturnsNotReachable() + { + await RunWithHealthServerAsync( + async () => + { + await Task.Delay(TimeSpan.FromSeconds(4)); + return HealthCheckResult.Healthy("опоздал"); + }, + async (port, probe) => + { + ServiceHealthResult result = await probe.ProbeAsync($"http://127.0.0.1:{port}", CancellationToken.None); + + Assert.False(result.Reachable); + Assert.False(result.Serving); + }); + } + + // Поднимает in-process gRPC-health-сервер с заданной проверкой "ready" и гоняет сценарий. + // check: Проверка "ready" (async: Healthy/Unhealthy; задержка — сценарий дедлайна). + // scenario: Сценарий: получает порт хоста и пробу. + private static async Task RunWithHealthServerAsync(Func> check, Func scenario) + { + WebApplication? app = null; + try + { + int port = TestPort.Allocate(); + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(kestrel => + kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2)); + + // Стандартный grpc.health.v1 ASP.NET Core: AddGrpcHealthChecks + явная проверка "ready" + // (без проверок health-сервис отвечает UNKNOWN — как в Deal.Api, см. Program.cs). + builder.Services.AddGrpc(); + builder.Services.AddGrpcHealthChecks().AddAsyncCheck("ready", check); + app = builder.Build(); + app.MapGrpcHealthChecksService(); + await app.StartAsync(); + + await scenario(port, new ServiceHealthProbe()); + } + finally + { + if (app is not null) + { + await app.StopAsync(); + await app.DisposeAsync(); + } + } + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/SourceContentResolverTests.cs b/src/core/tests/Deal.Tests.Unit/Support/SourceContentResolverTests.cs index 4639965..6159bc5 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/SourceContentResolverTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/SourceContentResolverTests.cs @@ -1,56 +1,56 @@ -using Deal.Modules.Cards.Application.Sources; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты выбора провайдера содержимого источника — SourceContentResolver. -/// -public sealed class SourceContentResolverTests -{ - [Fact] - public async Task ResolveAsync_KnownKind_ReturnsProviderContent() - { - var provider = new FakeProvider("test"); - var resolver = new SourceContentResolver([provider]); - SourceRef source = SourceItemFactory.Source(); - SourceContent expected = SourceItemFactory.Content("из провайдера"); - - provider.Content = expected; - - Assert.True(resolver.CanResolve(source)); - SourceContent? actual = await resolver.ResolveAsync(source, CancellationToken.None); - Assert.Same(expected, actual); - } - - [Fact] - public async Task ResolveAsync_UnknownKind_ReturnsNull() - { - var resolver = new SourceContentResolver([]); - SourceRef source = SourceItemFactory.Source(); - - Assert.False(resolver.CanResolve(source)); - Assert.Null(await resolver.ResolveAsync(source, CancellationToken.None)); - } - - [Fact] - public async Task ResolveAsync_KindMatchIsCaseInsensitive() - { - var provider = new FakeProvider("Telegram"); - var resolver = new SourceContentResolver([provider]); - SourceRef source = SourceItemFactory.Source(kind: "telegram"); - provider.Content = SourceItemFactory.Content("из провайдера"); - - Assert.True(resolver.CanResolve(source)); - Assert.NotNull(await resolver.ResolveAsync(source, CancellationToken.None)); - } - - private sealed class FakeProvider(string kind) : ISourceContentProvider - { - public string Kind { get; } = kind; - - public SourceContent? Content { get; set; } - - public Task LoadAsync(SourceRef source, CancellationToken ct) => - Task.FromResult(Content); - } -} +using Deal.Modules.Cards.Application.Sources; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты выбора провайдера содержимого источника — SourceContentResolver. +/// +public sealed class SourceContentResolverTests +{ + [Fact] + public async Task ResolveAsync_KnownKind_ReturnsProviderContent() + { + var provider = new FakeProvider("test"); + var resolver = new SourceContentResolver([provider]); + SourceRef source = SourceItemFactory.Source(); + SourceContent expected = SourceItemFactory.Content("из провайдера"); + + provider.Content = expected; + + Assert.True(resolver.CanResolve(source)); + SourceContent? actual = await resolver.ResolveAsync(source, CancellationToken.None); + Assert.Same(expected, actual); + } + + [Fact] + public async Task ResolveAsync_UnknownKind_ReturnsNull() + { + var resolver = new SourceContentResolver([]); + SourceRef source = SourceItemFactory.Source(); + + Assert.False(resolver.CanResolve(source)); + Assert.Null(await resolver.ResolveAsync(source, CancellationToken.None)); + } + + [Fact] + public async Task ResolveAsync_KindMatchIsCaseInsensitive() + { + var provider = new FakeProvider("Telegram"); + var resolver = new SourceContentResolver([provider]); + SourceRef source = SourceItemFactory.Source(kind: "telegram"); + provider.Content = SourceItemFactory.Content("из провайдера"); + + Assert.True(resolver.CanResolve(source)); + Assert.NotNull(await resolver.ResolveAsync(source, CancellationToken.None)); + } + + private sealed class FakeProvider(string kind) : ISourceContentProvider + { + public string Kind { get; } = kind; + + public SourceContent? Content { get; set; } + + public Task LoadAsync(SourceRef source, CancellationToken ct) => + Task.FromResult(Content); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/SourceIngressGrpcServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/SourceIngressGrpcServiceTests.cs index 0a3b07f..b325247 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/SourceIngressGrpcServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/SourceIngressGrpcServiceTests.cs @@ -1,288 +1,288 @@ -using Deal.Grpc.Sources; -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Tenants; -using Grpc.Core; -using Grpc.Net.Client; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Tests.Unit.Support; - -/// -/// In-proc gRPC-тесты приёма источников (PushSource). -/// -public sealed class SourceIngressGrpcServiceTests -{ - // Токен сценариев теста. - private const string ValidToken = TelegramIngressTestHost.DefaultToken; - - // Тенант A сценариев (в реестре). - private static readonly Guid TenantA = Guid.NewGuid(); - - // Вид источника сценариев. - private const string Kind = "telegram"; - - // Id диалога-оригинала сценариев. - private const string OriginRef = "d_channel_100"; - - // Подпись источника сценариев. - private const string DisplayName = "Канал"; - - // Цвет источника сценариев. - private const string Hue = "#a33"; - - // Время получения записи сценариев (epoch-ms). - private const long ReceivedAtMs = 1_700_000_000_000; - - // ─── PushSource: приём, дубль, несуществующий тенант ────────────────── - - /// - /// PushSource кладёт строку очереди тенанта и принимает запись - /// - [Fact] - public async Task PushSource_ValidTenant_EnqueuesQueueRowAndAccepts() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync( - registry, - services => services.AddScoped(_ => store), - async channel => - { - PushSourceReply reply = await PushAsync( - channel, - Request("Вакансия Python-разработчика", externalId: "7"), - TenantA, - ValidToken); - - Assert.True(reply.Accepted); - Assert.False(reply.Duplicate); - - QueueItemDto row = Assert.Single(store.Queue); - Assert.Equal(Kind, row.Source.Kind); - Assert.Equal(OriginRef, row.Source.OriginRef); - Assert.Equal("7", row.Source.ExternalId); - Assert.Equal(DisplayName, row.Source.DisplayName); - Assert.Equal(DateTimeOffset.FromUnixTimeMilliseconds(ReceivedAtMs), row.Source.ReceivedAt); - Assert.Equal(Hue, row.Source.ResolveHue()); - Assert.Equal("Вакансия Python-разработчика", row.Text); - }); - } - - /// - /// Повтор PushSource того же вида+оригинала+внешнего id — duplicate=true, очередь не растёт - /// - [Fact] - public async Task PushSource_SameSourceKeyTwice_SecondIsDuplicateAndQueueNotGrown() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync( - registry, - services => services.AddScoped(_ => store), - async channel => - { - PushSourceReply first = await PushAsync(channel, Request("Сообщение канала", externalId: "42"), TenantA, ValidToken); - PushSourceReply second = await PushAsync(channel, Request("Сообщение канала", externalId: "42"), TenantA, ValidToken); - - Assert.True(first.Accepted); - Assert.True(second.Accepted); - Assert.True(second.Duplicate); - Assert.Single(store.Queue); - }); - } - - /// - /// PushSource без текста не принимается и очередь не растёт - /// - [Fact] - public async Task PushSource_EmptyText_NotAcceptedAndQueueNotGrown() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync( - registry, - services => services.AddScoped(_ => store), - async channel => - { - PushSourceReply reply = await PushAsync(channel, Request(" ", externalId: "9"), TenantA, ValidToken); - - Assert.False(reply.Accepted); - Assert.False(reply.Duplicate); - Assert.Empty(store.Queue); - }); - } - - /// - /// PushSource для несуществующего тенанта не падает - /// - [Fact] - public async Task PushSource_UnknownTenant_NotAcceptedWithoutRpcError() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync( - registry, - services => services.AddScoped(_ => store), - async channel => - { - PushSourceReply reply = await PushAsync(channel, Request("Сообщение чужого тенанта", externalId: "5"), Guid.NewGuid(), ValidToken); - - Assert.False(reply.Accepted); - Assert.False(reply.Duplicate); - Assert.Empty(store.Queue); - }); - } - - // ─── Интерцептор service-token и metadata tenant-id ───────────────────── - - /// - /// Запрос без metadata «service-token» → UNAUTHENTICATED. - /// - [Fact] - public async Task PushSource_WithoutToken_IsUnauthenticated() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync(registry, services => services.AddScoped(_ => store), async channel => - { - RpcException exception = await Assert.ThrowsAsync( - () => PushAsync(channel, Request("текст", externalId: "1"), TenantA, null)); - Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); - }); - } - - /// - /// Запрос с неверным токеном → UNAUTHENTICATED. - /// - [Fact] - public async Task PushSource_WithWrongToken_IsUnauthenticated() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync(registry, services => services.AddScoped(_ => store), async channel => - { - RpcException exception = await Assert.ThrowsAsync( - () => PushAsync(channel, Request("текст", externalId: "2"), TenantA, "wrong-token")); - Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); - }); - } - - /// - /// Fail-closed: DEAL_SERVICE_TOKEN не задан — RPC ингресса отклоняется даже с «каким-то» токеном. - /// - [Fact] - public async Task PushSource_UnsetEnvToken_FailsClosed() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await TelegramIngressTestHost.RunAsync( - serviceToken: null, - configureServices: services => - { - services.AddSingleton(registry); - services.AddScoped(_ => store); - }, - scenario: async channel => - { - RpcException exception = await Assert.ThrowsAsync( - () => PushAsync(channel, Request("текст", externalId: "3"), TenantA, ValidToken)); - Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); - }); - } - - /// - /// Metadata tenant-id отсутствует → UNAUTHENTICATED - /// - [Fact] - public async Task PushSource_WithoutTenantIdMetadata_IsUnauthenticated() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync(registry, services => services.AddScoped(_ => store), async channel => - { - RpcException exception = await Assert.ThrowsAsync( - () => PushAsync(channel, Request("текст", externalId: "4"), null, ValidToken)); - Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); - }); - } - - // ─── Контекст и хелперы ───────────────────────────────────────────────── - - // Запись реестра тенанта (как строка public.tenants). - // id: Идентификатор тенанта. - private static TenantRecordDto Tenant(Guid id) => - new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); - - // Поднимает хост ингресса: реестр тенантов + tenant-адаптеры сценария (общий харнесс). - // registry: Реестр тенантов сценария. - // registerTenantServices: Дополнительные tenant-scoped адаптеры сценария (IPipelineStore). - // scenario: Сценарий с gRPC-каналом. - private static Task RunAsync( - FakeTenantRegistry registry, - Action registerTenantServices, - Func scenario) - => TelegramIngressTestHost.RunAsync( - ValidToken, - services => - { - services.AddSingleton(registry); - registerTenantServices(services); - }, - scenario); - - // Вызывает PushSource с metadata сценария (deadline 10 с — контракт README). - // channel: Канал к хосту. - // request: Запрос PushSource. - // tenantId: Id тенанта в metadata (null — без заголовка tenant-id). - // tokenHeader: Значение metadata «service-token» (null — без заголовка). - private static Task PushAsync( - GrpcChannel channel, - PushSourceRequest request, - Guid? tenantId, - string? tokenHeader) - { - var client = new SourceIngressService.SourceIngressServiceClient(channel); - AsyncUnaryCall call = client.PushSourceAsync( - request, - new CallOptions(TelegramIngressTestHost.CallMetadata(tokenHeader, tenantId), deadline: Deadline())); - return call.ResponseAsync; - } - - // Запрос PushSource сценария (вид/оригинал/подпись/hue фиксированы). - // text: Текст сообщения. - // externalId: Id записи в источнике. - private static PushSourceRequest Request(string text, string externalId) - { - var request = new PushSourceRequest - { - Source = new SourceRefProto - { - Kind = Kind, - ExternalId = externalId, - DisplayName = DisplayName, - OriginRef = OriginRef, - ReceivedAt = ReceivedAtMs, - }, - Content = new SourceContentProto { Text = text }, - }; - request.Source.Extra["hue"] = Hue; - return request; - } - - // Deadline вызовов теста (контракт ингресса — 10 с). - private static DateTime Deadline() - => DateTime.UtcNow.AddSeconds(TelegramIngressTestHost.RpcDeadlineSeconds); -} +using Deal.Grpc.Sources; +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Tests.Unit.Modules.Kanban; +using Deal.Tests.Unit.Modules.Tenants; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Tests.Unit.Support; + +/// +/// In-proc gRPC-тесты приёма источников (PushSource). +/// +public sealed class SourceIngressGrpcServiceTests +{ + // Токен сценариев теста. + private const string ValidToken = TelegramIngressTestHost.DefaultToken; + + // Тенант A сценариев (в реестре). + private static readonly Guid TenantA = Guid.NewGuid(); + + // Вид источника сценариев. + private const string Kind = "telegram"; + + // Id диалога-оригинала сценариев. + private const string OriginRef = "d_channel_100"; + + // Подпись источника сценариев. + private const string DisplayName = "Канал"; + + // Цвет источника сценариев. + private const string Hue = "#a33"; + + // Время получения записи сценариев (epoch-ms). + private const long ReceivedAtMs = 1_700_000_000_000; + + // ─── PushSource: приём, дубль, несуществующий тенант ────────────────── + + /// + /// PushSource кладёт строку очереди тенанта и принимает запись + /// + [Fact] + public async Task PushSource_ValidTenant_EnqueuesQueueRowAndAccepts() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync( + registry, + services => services.AddScoped(_ => store), + async channel => + { + PushSourceReply reply = await PushAsync( + channel, + Request("Вакансия Python-разработчика", externalId: "7"), + TenantA, + ValidToken); + + Assert.True(reply.Accepted); + Assert.False(reply.Duplicate); + + QueueItemDto row = Assert.Single(store.Queue); + Assert.Equal(Kind, row.Source.Kind); + Assert.Equal(OriginRef, row.Source.OriginRef); + Assert.Equal("7", row.Source.ExternalId); + Assert.Equal(DisplayName, row.Source.DisplayName); + Assert.Equal(DateTimeOffset.FromUnixTimeMilliseconds(ReceivedAtMs), row.Source.ReceivedAt); + Assert.Equal(Hue, row.Source.ResolveHue()); + Assert.Equal("Вакансия Python-разработчика", row.Text); + }); + } + + /// + /// Повтор PushSource того же вида+оригинала+внешнего id — duplicate=true, очередь не растёт + /// + [Fact] + public async Task PushSource_SameSourceKeyTwice_SecondIsDuplicateAndQueueNotGrown() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync( + registry, + services => services.AddScoped(_ => store), + async channel => + { + PushSourceReply first = await PushAsync(channel, Request("Сообщение канала", externalId: "42"), TenantA, ValidToken); + PushSourceReply second = await PushAsync(channel, Request("Сообщение канала", externalId: "42"), TenantA, ValidToken); + + Assert.True(first.Accepted); + Assert.True(second.Accepted); + Assert.True(second.Duplicate); + Assert.Single(store.Queue); + }); + } + + /// + /// PushSource без текста не принимается и очередь не растёт + /// + [Fact] + public async Task PushSource_EmptyText_NotAcceptedAndQueueNotGrown() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync( + registry, + services => services.AddScoped(_ => store), + async channel => + { + PushSourceReply reply = await PushAsync(channel, Request(" ", externalId: "9"), TenantA, ValidToken); + + Assert.False(reply.Accepted); + Assert.False(reply.Duplicate); + Assert.Empty(store.Queue); + }); + } + + /// + /// PushSource для несуществующего тенанта не падает + /// + [Fact] + public async Task PushSource_UnknownTenant_NotAcceptedWithoutRpcError() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync( + registry, + services => services.AddScoped(_ => store), + async channel => + { + PushSourceReply reply = await PushAsync(channel, Request("Сообщение чужого тенанта", externalId: "5"), Guid.NewGuid(), ValidToken); + + Assert.False(reply.Accepted); + Assert.False(reply.Duplicate); + Assert.Empty(store.Queue); + }); + } + + // ─── Интерцептор service-token и metadata tenant-id ───────────────────── + + /// + /// Запрос без metadata «service-token» → UNAUTHENTICATED. + /// + [Fact] + public async Task PushSource_WithoutToken_IsUnauthenticated() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync(registry, services => services.AddScoped(_ => store), async channel => + { + RpcException exception = await Assert.ThrowsAsync( + () => PushAsync(channel, Request("текст", externalId: "1"), TenantA, null)); + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + }); + } + + /// + /// Запрос с неверным токеном → UNAUTHENTICATED. + /// + [Fact] + public async Task PushSource_WithWrongToken_IsUnauthenticated() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync(registry, services => services.AddScoped(_ => store), async channel => + { + RpcException exception = await Assert.ThrowsAsync( + () => PushAsync(channel, Request("текст", externalId: "2"), TenantA, "wrong-token")); + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + }); + } + + /// + /// Fail-closed: DEAL_SERVICE_TOKEN не задан — RPC ингресса отклоняется даже с «каким-то» токеном. + /// + [Fact] + public async Task PushSource_UnsetEnvToken_FailsClosed() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await TelegramIngressTestHost.RunAsync( + serviceToken: null, + configureServices: services => + { + services.AddSingleton(registry); + services.AddScoped(_ => store); + }, + scenario: async channel => + { + RpcException exception = await Assert.ThrowsAsync( + () => PushAsync(channel, Request("текст", externalId: "3"), TenantA, ValidToken)); + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + }); + } + + /// + /// Metadata tenant-id отсутствует → UNAUTHENTICATED + /// + [Fact] + public async Task PushSource_WithoutTenantIdMetadata_IsUnauthenticated() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync(registry, services => services.AddScoped(_ => store), async channel => + { + RpcException exception = await Assert.ThrowsAsync( + () => PushAsync(channel, Request("текст", externalId: "4"), null, ValidToken)); + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + }); + } + + // ─── Контекст и хелперы ───────────────────────────────────────────────── + + // Запись реестра тенанта (как строка public.tenants). + // id: Идентификатор тенанта. + private static TenantRecordDto Tenant(Guid id) => + new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); + + // Поднимает хост ингресса: реестр тенантов + tenant-адаптеры сценария (общий харнесс). + // registry: Реестр тенантов сценария. + // registerTenantServices: Дополнительные tenant-scoped адаптеры сценария (IPipelineStore). + // scenario: Сценарий с gRPC-каналом. + private static Task RunAsync( + FakeTenantRegistry registry, + Action registerTenantServices, + Func scenario) + => TelegramIngressTestHost.RunAsync( + ValidToken, + services => + { + services.AddSingleton(registry); + registerTenantServices(services); + }, + scenario); + + // Вызывает PushSource с metadata сценария (deadline 10 с — контракт README). + // channel: Канал к хосту. + // request: Запрос PushSource. + // tenantId: Id тенанта в metadata (null — без заголовка tenant-id). + // tokenHeader: Значение metadata «service-token» (null — без заголовка). + private static Task PushAsync( + GrpcChannel channel, + PushSourceRequest request, + Guid? tenantId, + string? tokenHeader) + { + var client = new SourceIngressService.SourceIngressServiceClient(channel); + AsyncUnaryCall call = client.PushSourceAsync( + request, + new CallOptions(TelegramIngressTestHost.CallMetadata(tokenHeader, tenantId), deadline: Deadline())); + return call.ResponseAsync; + } + + // Запрос PushSource сценария (вид/оригинал/подпись/hue фиксированы). + // text: Текст сообщения. + // externalId: Id записи в источнике. + private static PushSourceRequest Request(string text, string externalId) + { + var request = new PushSourceRequest + { + Source = new SourceRefProto + { + Kind = Kind, + ExternalId = externalId, + DisplayName = DisplayName, + OriginRef = OriginRef, + ReceivedAt = ReceivedAtMs, + }, + Content = new SourceContentProto { Text = text }, + }; + request.Source.Extra["hue"] = Hue; + return request; + } + + // Deadline вызовов теста (контракт ингресса — 10 с). + private static DateTime Deadline() + => DateTime.UtcNow.AddSeconds(TelegramIngressTestHost.RpcDeadlineSeconds); +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/SourceItemFactory.cs b/src/core/tests/Deal.Tests.Unit/Support/SourceItemFactory.cs index 6f33128..aaa696e 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/SourceItemFactory.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/SourceItemFactory.cs @@ -1,201 +1,201 @@ -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Pipeline.Application.Models; - -namespace Deal.Tests.Unit.Support; - -/// -/// Сборка записей источника и строк конвейера для тестов. -/// -public static class SourceItemFactory -{ - /// - /// Вид источника по умолчанию для сценариев. - /// - public const string Kind = "test"; - - /// - /// Оригинал источника по умолчанию. - /// - public const string Origin = "d_1"; - - /// - /// Внешний id записи по умолчанию. - /// - public const string ExternalId = "100"; - - /// - /// Подпись источника по умолчанию. - /// - public const string Name = "Канал"; - - /// - /// Цвет источника по умолчанию. - /// - public const string Hue = "#333"; - - /// - /// Время получения записи по умолчанию. - /// - public static readonly DateTimeOffset ReceivedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_000); - - /// - /// Ссылка на источник; сценарий перекрывает нужные поля. - /// - /// Оригинал (url/путь/диалог); null — без оригинала. - /// Внешний id записи; null — записи без id. - /// Подпись источника; null — без подписи. - /// Цвет источника; null — без цвета. - /// Вид источника. - /// Время получения; null — значение по умолчанию. - /// Ссылка на источник. - public static SourceRef Source( - string? origin = Origin, - string? externalId = ExternalId, - string? name = Name, - string? hue = Hue, - string kind = Kind, - DateTimeOffset? receivedAt = null) - { - IReadOnlyDictionary? extra = hue is null - ? null - : new Dictionary { [SourceRefs.HueKey] = hue }; - return new SourceRef - { - Kind = kind, - OriginRef = origin, - ExternalId = externalId, - DisplayName = name, - ReceivedAt = receivedAt ?? ReceivedAt, - Extra = extra, - }; - } - - /// - /// Содержимое записи источника. - /// - /// Основной текст. - /// Отправитель; null — подпись источника по умолчанию. - /// Содержимое записи. - public static SourceContent Content(string? text, string? author = null) => new() - { - Text = text, - Author = author ?? Name, - }; - - /// - /// Запись источника: ссылка + содержимое. - /// - public static SourceItem Item( - string? text, - string? origin = Origin, - string? externalId = ExternalId, - string? name = Name, - string? hue = Hue, - DateTimeOffset? receivedAt = null) => new() - { - Source = Source(origin, externalId, name, hue, Kind, receivedAt), - Content = Content(text, name), - }; - - /// - /// Команда приёма записи. - /// - public static QueuedMessage Message( - string? text, - string? origin = Origin, - string? externalId = ExternalId, - string? name = Name, - string? hue = Hue, - DateTimeOffset? receivedAt = null, - bool force = false) => new() - { - Item = Item(text, origin, externalId, name, hue, receivedAt), - Force = force, - }; - - /// - /// Строка очереди для сидирования. - /// - public static QueueItemDto Queue( - string id, - string? text, - string status, - string? origin = Origin, - string? externalId = ExternalId, - string? name = Name, - string? hue = Hue, - long msgAtMs = 1_700_000_000_000, - long queuedAtMs = 1_700_000_000_000, - bool force = false) => new() - { - Id = id, - Source = Source(origin, externalId, name, hue), - Content = Content(text, name), - Text = text ?? string.Empty, - Status = status, - MsgAtMs = msgAtMs, - QueuedAtMs = queuedAtMs, - Force = force, - }; - - /// - /// Команда записи отсева. - /// - public static RejectRecord Reject( - string? text, - string? origin = Origin, - string? externalId = ExternalId, - string? name = Name, - string? hue = Hue, - string stage = "length", - string decidedBy = "stop", - string reason = "", - string kw = "", - long msgAtMs = 0) => new() - { - Source = Source(origin, externalId, name, hue), - Content = Content(text, name), - Text = text ?? string.Empty, - Stage = stage, - DecidedBy = decidedBy, - Reason = reason, - Kw = kw, - MsgAtMs = msgAtMs, - }; - - /// - /// Запись отсева для сидирования. - /// - public static RejectedItemDto Rejected( - string id, - string? text = "Текст сообщения", - string stage = "length", - string decidedBy = "stop", - string reason = "", - string kw = "", - string? origin = Origin, - string? externalId = ExternalId, - string? name = Name, - string? hue = Hue, - long msgAtMs = 0, - long rejectedAtMs = 1_000, - bool returned = false, - string returnReason = "", - DateTimeOffset? receivedAt = null) => new() - { - Id = id, - Source = Source(origin, externalId, name, hue, Kind, receivedAt), - Content = Content(text, name), - Text = text ?? string.Empty, - Stage = stage, - StageLabel = PipelineRejectConstants.StageLabel(stage), - Reason = reason, - Kw = kw, - DecidedBy = decidedBy, - DecidedByLabel = PipelineRejectConstants.SourceLabel(decidedBy), - MsgAtMs = msgAtMs, - RejectedAtMs = rejectedAtMs, - Returned = returned, - ReturnReason = returnReason, - }; -} +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Pipeline.Application.Models; + +namespace Deal.Tests.Unit.Support; + +/// +/// Сборка записей источника и строк конвейера для тестов. +/// +public static class SourceItemFactory +{ + /// + /// Вид источника по умолчанию для сценариев. + /// + public const string Kind = "test"; + + /// + /// Оригинал источника по умолчанию. + /// + public const string Origin = "d_1"; + + /// + /// Внешний id записи по умолчанию. + /// + public const string ExternalId = "100"; + + /// + /// Подпись источника по умолчанию. + /// + public const string Name = "Канал"; + + /// + /// Цвет источника по умолчанию. + /// + public const string Hue = "#333"; + + /// + /// Время получения записи по умолчанию. + /// + public static readonly DateTimeOffset ReceivedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_000); + + /// + /// Ссылка на источник; сценарий перекрывает нужные поля. + /// + /// Оригинал (url/путь/диалог); null — без оригинала. + /// Внешний id записи; null — записи без id. + /// Подпись источника; null — без подписи. + /// Цвет источника; null — без цвета. + /// Вид источника. + /// Время получения; null — значение по умолчанию. + /// Ссылка на источник. + public static SourceRef Source( + string? origin = Origin, + string? externalId = ExternalId, + string? name = Name, + string? hue = Hue, + string kind = Kind, + DateTimeOffset? receivedAt = null) + { + IReadOnlyDictionary? extra = hue is null + ? null + : new Dictionary { [SourceRefs.HueKey] = hue }; + return new SourceRef + { + Kind = kind, + OriginRef = origin, + ExternalId = externalId, + DisplayName = name, + ReceivedAt = receivedAt ?? ReceivedAt, + Extra = extra, + }; + } + + /// + /// Содержимое записи источника. + /// + /// Основной текст. + /// Отправитель; null — подпись источника по умолчанию. + /// Содержимое записи. + public static SourceContent Content(string? text, string? author = null) => new() + { + Text = text, + Author = author ?? Name, + }; + + /// + /// Запись источника: ссылка + содержимое. + /// + public static SourceItem Item( + string? text, + string? origin = Origin, + string? externalId = ExternalId, + string? name = Name, + string? hue = Hue, + DateTimeOffset? receivedAt = null) => new() + { + Source = Source(origin, externalId, name, hue, Kind, receivedAt), + Content = Content(text, name), + }; + + /// + /// Команда приёма записи. + /// + public static QueuedMessage Message( + string? text, + string? origin = Origin, + string? externalId = ExternalId, + string? name = Name, + string? hue = Hue, + DateTimeOffset? receivedAt = null, + bool force = false) => new() + { + Item = Item(text, origin, externalId, name, hue, receivedAt), + Force = force, + }; + + /// + /// Строка очереди для сидирования. + /// + public static QueueItemDto Queue( + string id, + string? text, + string status, + string? origin = Origin, + string? externalId = ExternalId, + string? name = Name, + string? hue = Hue, + long msgAtMs = 1_700_000_000_000, + long queuedAtMs = 1_700_000_000_000, + bool force = false) => new() + { + Id = id, + Source = Source(origin, externalId, name, hue), + Content = Content(text, name), + Text = text ?? string.Empty, + Status = status, + MsgAtMs = msgAtMs, + QueuedAtMs = queuedAtMs, + Force = force, + }; + + /// + /// Команда записи отсева. + /// + public static RejectRecord Reject( + string? text, + string? origin = Origin, + string? externalId = ExternalId, + string? name = Name, + string? hue = Hue, + string stage = "length", + string decidedBy = "stop", + string reason = "", + string kw = "", + long msgAtMs = 0) => new() + { + Source = Source(origin, externalId, name, hue), + Content = Content(text, name), + Text = text ?? string.Empty, + Stage = stage, + DecidedBy = decidedBy, + Reason = reason, + Kw = kw, + MsgAtMs = msgAtMs, + }; + + /// + /// Запись отсева для сидирования. + /// + public static RejectedItemDto Rejected( + string id, + string? text = "Текст сообщения", + string stage = "length", + string decidedBy = "stop", + string reason = "", + string kw = "", + string? origin = Origin, + string? externalId = ExternalId, + string? name = Name, + string? hue = Hue, + long msgAtMs = 0, + long rejectedAtMs = 1_000, + bool returned = false, + string returnReason = "", + DateTimeOffset? receivedAt = null) => new() + { + Id = id, + Source = Source(origin, externalId, name, hue, Kind, receivedAt), + Content = Content(text, name), + Text = text ?? string.Empty, + Stage = stage, + StageLabel = PipelineRejectConstants.StageLabel(stage), + Reason = reason, + Kw = kw, + DecidedBy = decidedBy, + DecidedByLabel = PipelineRejectConstants.SourceLabel(decidedBy), + MsgAtMs = msgAtMs, + RejectedAtMs = rejectedAtMs, + Returned = returned, + ReturnReason = returnReason, + }; +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/SourceProtoMapperTests.cs b/src/core/tests/Deal.Tests.Unit/Support/SourceProtoMapperTests.cs index 2f0339c..1e75ebc 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/SourceProtoMapperTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/SourceProtoMapperTests.cs @@ -1,211 +1,211 @@ -using Deal.Api.Sources; -using Deal.Grpc.Sources; -using Deal.Modules.Cards.Application.Sources; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты маппинга generic-контракта источника в доменную модель. -/// -public sealed class SourceProtoMapperTests -{ - /// - /// Заполненный запрос переносится в источник и содержимое целиком - /// - [Fact] - public void ToItem_FullyPopulatedRequest_MapsSourceAndContent() - { - var request = new PushSourceRequest - { - Source = new SourceRefProto - { - Kind = "telegram", - ExternalId = "42", - DisplayName = "Канал", - OriginRef = "d_1", - Author = "@author", - ReceivedAt = 1_700_000_000_000, - }, - Content = new SourceContentProto - { - Text = "Вакансия", - Html = "Вакансия", - Author = "Иван", - Subject = "Тема", - Links = { "https://a", "https://b" }, - }, - }; - request.Source.Extra["hue"] = "#a33"; - request.Content.Extra["lang"] = "ru"; - - SourceItem item = SourceProtoMapper.ToItem(request); - - Assert.Equal("telegram", item.Source.Kind); - Assert.Equal("42", item.Source.ExternalId); - Assert.Equal("Канал", item.Source.DisplayName); - Assert.Equal("d_1", item.Source.OriginRef); - Assert.Equal("@author", item.Source.Author); - Assert.Equal(DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_000), item.Source.ReceivedAt); - Assert.Equal("#a33", item.Source.Extra!["hue"]); - - Assert.Equal("Вакансия", item.Content.Text); - Assert.Equal("Вакансия", item.Content.Html); - Assert.Equal("Иван", item.Content.Author); - Assert.Equal("Тема", item.Content.Subject); - Assert.Equal(["https://a", "https://b"], item.Content.Links); - Assert.Equal("ru", item.Content.Extra!["lang"]); - Assert.Empty(item.Content.Data); - } - - /// - /// Вложения и контакты переносятся со всеми необязательными полями - /// - [Fact] - public void ToItem_DataAndContacts_MapsAllFields() - { - var request = new PushSourceRequest - { - Source = new SourceRefProto { Kind = "file" }, - Content = new SourceContentProto - { - Data = - { - new DataRefProto - { - Id = "obj_1", - Ref = "storage://obj_1", - Kind = "image", - MimeType = "image/png", - FileName = "screen.png", - Size = 2048, - Width = 800, - Height = 600, - DurationSec = 1.5, - PreviewRef = "storage://obj_1/thumb", - Caption = "Подпись", - Order = 2, - }, - }, - Contacts = - { - new ContactRefProto - { - Kind = "phone", - Name = "Иван", - Phone = "+79990000000", - Email = "ivan@example.com", - Url = "https://example.com", - }, - }, - }, - }; - request.Content.Data[0].Meta["source"] = "upload"; - - SourceItem item = SourceProtoMapper.ToItem(request); - - DataRef data = Assert.Single(item.Content.Data); - Assert.Equal("obj_1", data.Id); - Assert.Equal("storage://obj_1", data.Ref); - Assert.Equal("image", data.Kind); - Assert.Equal("image/png", data.MimeType); - Assert.Equal("screen.png", data.FileName); - Assert.Equal(2048, data.Size); - Assert.Equal(800, data.Width); - Assert.Equal(600, data.Height); - Assert.Equal(1.5, data.DurationSec); - Assert.Equal("storage://obj_1/thumb", data.PreviewRef); - Assert.Equal("Подпись", data.Caption); - Assert.Equal(2, data.Order); - Assert.Equal("upload", data.Meta!["source"]); - - ContactRef contact = Assert.Single(item.Content.Contacts!); - Assert.Equal("phone", contact.Kind); - Assert.Equal("Иван", contact.Name); - Assert.Equal("+79990000000", contact.Phone); - Assert.Equal("ivan@example.com", contact.Email); - Assert.Equal("https://example.com", contact.Url); - } - - /// - /// Незаполненные необязательные поля источника/содержимого дают null - /// - [Fact] - public void ToItem_MissingOptionals_MapToNull() - { - var request = new PushSourceRequest - { - Source = new SourceRefProto { Kind = "local" }, - Content = new SourceContentProto { Text = "x" }, - }; - - SourceItem item = SourceProtoMapper.ToItem(request); - - Assert.Null(item.Source.ExternalId); - Assert.Null(item.Source.DisplayName); - Assert.Null(item.Source.OriginRef); - Assert.Null(item.Source.Author); - Assert.Null(item.Source.Extra); - Assert.Null(item.Content.Html); - Assert.Null(item.Content.Author); - Assert.Null(item.Content.Subject); - Assert.Null(item.Content.Links); - Assert.Null(item.Content.Contacts); - Assert.Null(item.Content.Extra); - } - - /// - /// Незаполненные необязательные поля вложения/контакта дают null - /// - [Fact] - public void ToItem_DataAndContactsWithoutOptionals_MapToNull() - { - var request = new PushSourceRequest - { - Source = new SourceRefProto { Kind = "file" }, - Content = new SourceContentProto - { - Data = { new DataRefProto { Id = "obj_1", Ref = "storage://obj_1" } }, - Contacts = { new ContactRefProto() }, - }, - }; - - SourceItem item = SourceProtoMapper.ToItem(request); - - DataRef data = Assert.Single(item.Content.Data); - Assert.Null(data.Kind); - Assert.Null(data.MimeType); - Assert.Null(data.FileName); - Assert.Null(data.Width); - Assert.Null(data.Height); - Assert.Null(data.DurationSec); - Assert.Null(data.PreviewRef); - Assert.Null(data.Caption); - Assert.Null(data.Order); - Assert.Null(data.Meta); - - ContactRef contact = Assert.Single(item.Content.Contacts!); - Assert.Null(contact.Kind); - Assert.Null(contact.Name); - Assert.Null(contact.Phone); - Assert.Null(contact.Email); - Assert.Null(contact.Url); - } - - /// - /// received_at=0 подменяется текущим временем получения - /// - [Fact] - public void ToItem_ZeroReceivedAt_UsesCurrentTime() - { - DateTimeOffset before = DateTimeOffset.UtcNow; - var request = new PushSourceRequest - { - Source = new SourceRefProto { Kind = "local" }, - Content = new SourceContentProto { Text = "x" }, - }; - - SourceItem item = SourceProtoMapper.ToItem(request); - - Assert.InRange(item.Source.ReceivedAt, before.AddSeconds(-1), DateTimeOffset.UtcNow.AddSeconds(1)); - } -} +using Deal.Api.Sources; +using Deal.Grpc.Sources; +using Deal.Modules.Cards.Application.Sources; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты маппинга generic-контракта источника в доменную модель. +/// +public sealed class SourceProtoMapperTests +{ + /// + /// Заполненный запрос переносится в источник и содержимое целиком + /// + [Fact] + public void ToItem_FullyPopulatedRequest_MapsSourceAndContent() + { + var request = new PushSourceRequest + { + Source = new SourceRefProto + { + Kind = "telegram", + ExternalId = "42", + DisplayName = "Канал", + OriginRef = "d_1", + Author = "@author", + ReceivedAt = 1_700_000_000_000, + }, + Content = new SourceContentProto + { + Text = "Вакансия", + Html = "Вакансия", + Author = "Иван", + Subject = "Тема", + Links = { "https://a", "https://b" }, + }, + }; + request.Source.Extra["hue"] = "#a33"; + request.Content.Extra["lang"] = "ru"; + + SourceItem item = SourceProtoMapper.ToItem(request); + + Assert.Equal("telegram", item.Source.Kind); + Assert.Equal("42", item.Source.ExternalId); + Assert.Equal("Канал", item.Source.DisplayName); + Assert.Equal("d_1", item.Source.OriginRef); + Assert.Equal("@author", item.Source.Author); + Assert.Equal(DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_000), item.Source.ReceivedAt); + Assert.Equal("#a33", item.Source.Extra!["hue"]); + + Assert.Equal("Вакансия", item.Content.Text); + Assert.Equal("Вакансия", item.Content.Html); + Assert.Equal("Иван", item.Content.Author); + Assert.Equal("Тема", item.Content.Subject); + Assert.Equal(["https://a", "https://b"], item.Content.Links); + Assert.Equal("ru", item.Content.Extra!["lang"]); + Assert.Empty(item.Content.Data); + } + + /// + /// Вложения и контакты переносятся со всеми необязательными полями + /// + [Fact] + public void ToItem_DataAndContacts_MapsAllFields() + { + var request = new PushSourceRequest + { + Source = new SourceRefProto { Kind = "file" }, + Content = new SourceContentProto + { + Data = + { + new DataRefProto + { + Id = "obj_1", + Ref = "storage://obj_1", + Kind = "image", + MimeType = "image/png", + FileName = "screen.png", + Size = 2048, + Width = 800, + Height = 600, + DurationSec = 1.5, + PreviewRef = "storage://obj_1/thumb", + Caption = "Подпись", + Order = 2, + }, + }, + Contacts = + { + new ContactRefProto + { + Kind = "phone", + Name = "Иван", + Phone = "+79990000000", + Email = "ivan@example.com", + Url = "https://example.com", + }, + }, + }, + }; + request.Content.Data[0].Meta["source"] = "upload"; + + SourceItem item = SourceProtoMapper.ToItem(request); + + DataRef data = Assert.Single(item.Content.Data); + Assert.Equal("obj_1", data.Id); + Assert.Equal("storage://obj_1", data.Ref); + Assert.Equal("image", data.Kind); + Assert.Equal("image/png", data.MimeType); + Assert.Equal("screen.png", data.FileName); + Assert.Equal(2048, data.Size); + Assert.Equal(800, data.Width); + Assert.Equal(600, data.Height); + Assert.Equal(1.5, data.DurationSec); + Assert.Equal("storage://obj_1/thumb", data.PreviewRef); + Assert.Equal("Подпись", data.Caption); + Assert.Equal(2, data.Order); + Assert.Equal("upload", data.Meta!["source"]); + + ContactRef contact = Assert.Single(item.Content.Contacts!); + Assert.Equal("phone", contact.Kind); + Assert.Equal("Иван", contact.Name); + Assert.Equal("+79990000000", contact.Phone); + Assert.Equal("ivan@example.com", contact.Email); + Assert.Equal("https://example.com", contact.Url); + } + + /// + /// Незаполненные необязательные поля источника/содержимого дают null + /// + [Fact] + public void ToItem_MissingOptionals_MapToNull() + { + var request = new PushSourceRequest + { + Source = new SourceRefProto { Kind = "local" }, + Content = new SourceContentProto { Text = "x" }, + }; + + SourceItem item = SourceProtoMapper.ToItem(request); + + Assert.Null(item.Source.ExternalId); + Assert.Null(item.Source.DisplayName); + Assert.Null(item.Source.OriginRef); + Assert.Null(item.Source.Author); + Assert.Null(item.Source.Extra); + Assert.Null(item.Content.Html); + Assert.Null(item.Content.Author); + Assert.Null(item.Content.Subject); + Assert.Null(item.Content.Links); + Assert.Null(item.Content.Contacts); + Assert.Null(item.Content.Extra); + } + + /// + /// Незаполненные необязательные поля вложения/контакта дают null + /// + [Fact] + public void ToItem_DataAndContactsWithoutOptionals_MapToNull() + { + var request = new PushSourceRequest + { + Source = new SourceRefProto { Kind = "file" }, + Content = new SourceContentProto + { + Data = { new DataRefProto { Id = "obj_1", Ref = "storage://obj_1" } }, + Contacts = { new ContactRefProto() }, + }, + }; + + SourceItem item = SourceProtoMapper.ToItem(request); + + DataRef data = Assert.Single(item.Content.Data); + Assert.Null(data.Kind); + Assert.Null(data.MimeType); + Assert.Null(data.FileName); + Assert.Null(data.Width); + Assert.Null(data.Height); + Assert.Null(data.DurationSec); + Assert.Null(data.PreviewRef); + Assert.Null(data.Caption); + Assert.Null(data.Order); + Assert.Null(data.Meta); + + ContactRef contact = Assert.Single(item.Content.Contacts!); + Assert.Null(contact.Kind); + Assert.Null(contact.Name); + Assert.Null(contact.Phone); + Assert.Null(contact.Email); + Assert.Null(contact.Url); + } + + /// + /// received_at=0 подменяется текущим временем получения + /// + [Fact] + public void ToItem_ZeroReceivedAt_UsesCurrentTime() + { + DateTimeOffset before = DateTimeOffset.UtcNow; + var request = new PushSourceRequest + { + Source = new SourceRefProto { Kind = "local" }, + Content = new SourceContentProto { Text = "x" }, + }; + + SourceItem item = SourceProtoMapper.ToItem(request); + + Assert.InRange(item.Source.ReceivedAt, before.AddSeconds(-1), DateTimeOffset.UtcNow.AddSeconds(1)); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/SourceRefsTests.cs b/src/core/tests/Deal.Tests.Unit/Support/SourceRefsTests.cs index d5b3989..b9e8cc0 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/SourceRefsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/SourceRefsTests.cs @@ -1,43 +1,43 @@ -using Deal.Modules.Cards.Application.Sources; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты производных полей ссылки на источник — SourceRefs. -/// -public sealed class SourceRefsTests -{ - [Fact] - public void ResolveHue_NoExtra_ReturnsDefault() - { - SourceRef source = SourceItemFactory.Source(hue: null); - - Assert.Equal(SourceRefs.DefaultHue, source.ResolveHue()); - } - - [Fact] - public void ResolveHue_EmptyOrWhitespaceExtra_ReturnsDefault() - { - SourceRef empty = SourceItemFactory.Source(hue: string.Empty); - SourceRef whitespace = SourceItemFactory.Source(hue: " "); - - Assert.Equal(SourceRefs.DefaultHue, empty.ResolveHue()); - Assert.Equal(SourceRefs.DefaultHue, whitespace.ResolveHue()); - } - - [Fact] - public void ResolveHue_ExtraHue_ReturnsIt() - { - SourceRef source = SourceItemFactory.Source(hue: "#ababab"); - - Assert.Equal("#ababab", source.ResolveHue()); - } - - [Fact] - public void DedupeKey_JoinsKindOriginAndExternalId() - { - SourceRef source = SourceItemFactory.Source(origin: "d_1", externalId: "42", kind: "test"); - - Assert.Equal("test|d_1|42", source.DedupeKey()); - } -} +using Deal.Modules.Cards.Application.Sources; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты производных полей ссылки на источник — SourceRefs. +/// +public sealed class SourceRefsTests +{ + [Fact] + public void ResolveHue_NoExtra_ReturnsDefault() + { + SourceRef source = SourceItemFactory.Source(hue: null); + + Assert.Equal(SourceRefs.DefaultHue, source.ResolveHue()); + } + + [Fact] + public void ResolveHue_EmptyOrWhitespaceExtra_ReturnsDefault() + { + SourceRef empty = SourceItemFactory.Source(hue: string.Empty); + SourceRef whitespace = SourceItemFactory.Source(hue: " "); + + Assert.Equal(SourceRefs.DefaultHue, empty.ResolveHue()); + Assert.Equal(SourceRefs.DefaultHue, whitespace.ResolveHue()); + } + + [Fact] + public void ResolveHue_ExtraHue_ReturnsIt() + { + SourceRef source = SourceItemFactory.Source(hue: "#ababab"); + + Assert.Equal("#ababab", source.ResolveHue()); + } + + [Fact] + public void DedupeKey_JoinsKindOriginAndExternalId() + { + SourceRef source = SourceItemFactory.Source(origin: "d_1", externalId: "42", kind: "test"); + + Assert.Equal("test|d_1|42", source.DedupeKey()); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/SseBrokerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/SseBrokerTests.cs index 28a359b..7717c9c 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/SseBrokerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/SseBrokerTests.cs @@ -1,110 +1,110 @@ -using System.Text.Json; -using Deal.Api.Events; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты SSE-брокера -/// -public sealed class SseBrokerTests -{ - // Тенант A теста (канал подписки). - private static readonly Guid TenantA = Guid.NewGuid(); - - // Тенант B теста (проверка изоляции). - private static readonly Guid TenantB = Guid.NewGuid(); - - // Публикаций больше ёмкости очереди — проверяем вытеснение старых. - private const int OverflowPublishedCount = 250; - - [Fact] - public void Publish_OverflowQueue_DropsOldestKeepsCapacityEvents() - { - var broker = new SseBroker(); - SseSubscription subscription = broker.Subscribe(TenantA); - - for (int number = 1; number <= OverflowPublishedCount; number++) - { - broker.Publish(TenantA, "toast", new { number }); - } - - var received = new List(); - while (subscription.Events.TryRead(out SseEvent? sseEvent)) - { - received.Add(ReadNumber(sseEvent!.Json)); - } - - // Вытеснены 50 старейших (250 − 200): в очереди ровно ёмкость, первое — №51, последнее — №250. - Assert.Equal(SseBroker.SubscriberQueueCapacity, received.Count); - Assert.Equal(51, received[0]); - Assert.Equal(OverflowPublishedCount, received[^1]); - } - - [Fact] - public void Publish_BeforeSubscribe_IsNoOpAndDoesNotThrow() - { - var broker = new SseBroker(); - - // Подписчиков нет (и канала тенанта нет) — публикация не падает и ничего не буферизует. - broker.Publish(TenantA, "toast", new { text = "Привет", icon = "check" }); - - SseSubscription subscription = broker.Subscribe(TenantA); - Assert.False(subscription.Events.TryRead(out _)); - } - - [Fact] - public void Publish_AfterUnsubscribeOfLastSubscriber_DoesNotThrow() - { - var broker = new SseBroker(); - SseSubscription subscription = broker.Subscribe(TenantA); - broker.Unsubscribe(TenantA, subscription.Id); - - // Канал тенанта удалён при отписке последнего — публикация no-op, не падает. - broker.Publish(TenantA, "toast", new { text = "Привет", icon = "check" }); - } - - [Fact] - public void Publish_ToOneTenant_DoesNotDeliverToOtherTenantSubscriber() - { - var broker = new SseBroker(); - SseSubscription tenantASubscription = broker.Subscribe(TenantA); - SseSubscription tenantBSubscription = broker.Subscribe(TenantB); - - broker.Publish(TenantA, new SseEvent("new_card", """{"id":"l_1"}""")); - - Assert.True(tenantASubscription.Events.TryRead(out SseEvent? delivered)); - Assert.Equal("new_card", delivered!.Type); - Assert.Equal("""{"id":"l_1"}""", delivered.Json); - Assert.False(tenantBSubscription.Events.TryRead(out _)); - } - - [Fact] - public void Unsubscribe_RemovesOnlyThatSubscription() - { - var broker = new SseBroker(); - SseSubscription first = broker.Subscribe(TenantA); - SseSubscription second = broker.Subscribe(TenantA); - broker.Unsubscribe(TenantA, first.Id); - - broker.Publish(TenantA, new SseEvent("toast", """{"text":"x","icon":"check"}""")); - - // Отписанный подписчик больше не получает; второй подписчик того же тенанта — получает. - Assert.False(first.Events.TryRead(out _)); - Assert.True(second.Events.TryRead(out SseEvent? delivered)); - Assert.Equal("toast", delivered!.Type); - } - - [Fact] - public void SseEvent_RenderFrame_UsesSseProtocolFormat() - { - var sseEvent = new SseEvent("toast", """{"text":"Привет","icon":"check"}"""); - - Assert.Equal("event: toast\ndata: {\"text\":\"Привет\",\"icon\":\"check\"}\n\n", sseEvent.RenderFrame()); - } - - // Достаёт число из data-нагрузки {number:N} опубликованного события. - // json: JSON-нагрузка события ({"number":…}). - // Возвращает: Значение поля number. - private static int ReadNumber(string json) => - JsonSerializer.Deserialize(json).GetProperty("number").GetInt32(); -} +using System.Text.Json; +using Deal.Api.Events; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты SSE-брокера +/// +public sealed class SseBrokerTests +{ + // Тенант A теста (канал подписки). + private static readonly Guid TenantA = Guid.NewGuid(); + + // Тенант B теста (проверка изоляции). + private static readonly Guid TenantB = Guid.NewGuid(); + + // Публикаций больше ёмкости очереди — проверяем вытеснение старых. + private const int OverflowPublishedCount = 250; + + [Fact] + public void Publish_OverflowQueue_DropsOldestKeepsCapacityEvents() + { + var broker = new SseBroker(); + SseSubscription subscription = broker.Subscribe(TenantA); + + for (int number = 1; number <= OverflowPublishedCount; number++) + { + broker.Publish(TenantA, "toast", new { number }); + } + + var received = new List(); + while (subscription.Events.TryRead(out SseEvent? sseEvent)) + { + received.Add(ReadNumber(sseEvent!.Json)); + } + + // Вытеснены 50 старейших (250 − 200): в очереди ровно ёмкость, первое — №51, последнее — №250. + Assert.Equal(SseBroker.SubscriberQueueCapacity, received.Count); + Assert.Equal(51, received[0]); + Assert.Equal(OverflowPublishedCount, received[^1]); + } + + [Fact] + public void Publish_BeforeSubscribe_IsNoOpAndDoesNotThrow() + { + var broker = new SseBroker(); + + // Подписчиков нет (и канала тенанта нет) — публикация не падает и ничего не буферизует. + broker.Publish(TenantA, "toast", new { text = "Привет", icon = "check" }); + + SseSubscription subscription = broker.Subscribe(TenantA); + Assert.False(subscription.Events.TryRead(out _)); + } + + [Fact] + public void Publish_AfterUnsubscribeOfLastSubscriber_DoesNotThrow() + { + var broker = new SseBroker(); + SseSubscription subscription = broker.Subscribe(TenantA); + broker.Unsubscribe(TenantA, subscription.Id); + + // Канал тенанта удалён при отписке последнего — публикация no-op, не падает. + broker.Publish(TenantA, "toast", new { text = "Привет", icon = "check" }); + } + + [Fact] + public void Publish_ToOneTenant_DoesNotDeliverToOtherTenantSubscriber() + { + var broker = new SseBroker(); + SseSubscription tenantASubscription = broker.Subscribe(TenantA); + SseSubscription tenantBSubscription = broker.Subscribe(TenantB); + + broker.Publish(TenantA, new SseEvent("new_card", """{"id":"l_1"}""")); + + Assert.True(tenantASubscription.Events.TryRead(out SseEvent? delivered)); + Assert.Equal("new_card", delivered!.Type); + Assert.Equal("""{"id":"l_1"}""", delivered.Json); + Assert.False(tenantBSubscription.Events.TryRead(out _)); + } + + [Fact] + public void Unsubscribe_RemovesOnlyThatSubscription() + { + var broker = new SseBroker(); + SseSubscription first = broker.Subscribe(TenantA); + SseSubscription second = broker.Subscribe(TenantA); + broker.Unsubscribe(TenantA, first.Id); + + broker.Publish(TenantA, new SseEvent("toast", """{"text":"x","icon":"check"}""")); + + // Отписанный подписчик больше не получает; второй подписчик того же тенанта — получает. + Assert.False(first.Events.TryRead(out _)); + Assert.True(second.Events.TryRead(out SseEvent? delivered)); + Assert.Equal("toast", delivered!.Type); + } + + [Fact] + public void SseEvent_RenderFrame_UsesSseProtocolFormat() + { + var sseEvent = new SseEvent("toast", """{"text":"Привет","icon":"check"}"""); + + Assert.Equal("event: toast\ndata: {\"text\":\"Привет\",\"icon\":\"check\"}\n\n", sseEvent.RenderFrame()); + } + + // Достаёт число из data-нагрузки {number:N} опубликованного события. + // json: JSON-нагрузка события ({"number":…}). + // Возвращает: Значение поля number. + private static int ReadNumber(string json) => + JsonSerializer.Deserialize(json).GetProperty("number").GetInt32(); +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs index a4801bf..bc84d41 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs @@ -1,508 +1,508 @@ -using System.Text.Json; -using Deal.Api.Events; -using Deal.Api.Hosting; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Infrastructure.Data; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Tenants; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты StorageTickScheduler — логика прохода цикла правил хранения -/// -public sealed class StorageTickSchedulerTests -{ - // Тенант A теста (канал подписки). - private static readonly Guid TenantA = Guid.NewGuid(); - - // Тенант B теста (канал подписки). - private static readonly Guid TenantB = Guid.NewGuid(); - - // Возраст карточек-кандидатов автоархива: запас к дефолту archiveAfterDays=14 (SettingsDefaults). - private static readonly TimeSpan ExpiredAge = TimeSpan.FromDays(20); - - [Fact] - public async Task RunCycle_TicksEveryTenantInOwnScopeAndPublishesToastToEachTenantChannel() - { - FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old"); - FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); - var settings = new FakeSettingsStore(); - var tenants = new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)); - var tenantContext = new TenantContext(); - await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - SseSubscription subscriptionB = broker.Subscribe(TenantB); - StorageTickScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - // Каждый тенант оттикал в собственном scope (карточка ушла в archive) и получил свой тост. - Assert.Equal(KanbanColumns.Archive, storeA.CardDtos.Single(card => card.Id == "l_a_old").Col); - Assert.Equal(KanbanColumns.Archive, storeB.CardDtos.Single(card => card.Id == "l_b_old").Col); - (string Text, string Icon)[] autoArchiveToast = [(Text: "Автоархив: 1 карточек", Icon: "clock")]; - Assert.Equal(autoArchiveToast, ReadToasts(subscriptionA)); - Assert.Equal(autoArchiveToast, ReadToasts(subscriptionB)); - - // Контекст AsyncLocal не должен переживать проход (Reset в finally каждого тика). - Assert.False(tenantContext.HasTenant); - } - - [Fact] - public async Task RunCycle_ZeroCountersTenant_PublishesToastOnlyToTenantWithChanges() - { - FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old"); - var storeB = new FakeKanjStore(); - storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1)))); - var settings = new FakeSettingsStore(); - var tenantContext = new TenantContext(); - await using ServiceProvider provider = BuildProvider( - new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), - tenantContext, - storeA, - storeB, - settings); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - SseSubscription subscriptionB = broker.Subscribe(TenantB); - StorageTickScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - // Тенант B не архивировал (свежая карточка) — тост в его канал не публикуется. - Assert.Single(ReadToasts(subscriptionA)); - Assert.False(subscriptionB.Events.TryRead(out _)); - Assert.False(tenantContext.HasTenant); - } - - [Fact] - public async Task RunCycle_TenantTickFailure_DoesNotAbortOtherTenants() - { - // У тенанта A настройки падают (имитация сбоя схемы/БД) — тик A логирует ошибку, B обрабатывается. - FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); - var tenantContext = new TenantContext(); - var settingsByTenant = new Dictionary - { - [TenantA] = new ThrowingSettingsStore(), - [TenantB] = new FakeSettingsStore(), - }; - var storesByTenant = new Dictionary - { - [TenantA] = new FakeKanjStore(), - [TenantB] = storeB, - }; - await using ServiceProvider provider = BuildProvider( - new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), - tenantContext, - storesByTenant, - settingsByTenant); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - SseSubscription subscriptionB = broker.Subscribe(TenantB); - StorageTickScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - Assert.False(subscriptionA.Events.TryRead(out _)); - (string Text, string Icon)[] autoArchiveToast = [(Text: "Автоархив: 1 карточек", Icon: "clock")]; - Assert.Equal(autoArchiveToast, ReadToasts(subscriptionB)); - Assert.False(tenantContext.HasTenant); - } - - [Fact] - public async Task RunCycle_TenantListFailure_DoesNotThrow() - { - var tenantContext = new TenantContext(); - await using ServiceProvider provider = BuildProvider( - new ThrowingTenantRepository(), - tenantContext, - new Dictionary(), - new Dictionary()); - StorageTickScheduler scheduler = CreateScheduler(provider); - - // Сбой реестра логируется внутри цикла и не выбрасывается наружу (цикл живёт дальше). - await scheduler.RunCycleAsync(CancellationToken.None); - - Assert.False(tenantContext.HasTenant); - } - - [Fact] - public async Task RunCycle_PurgesExpiredRejectedRowsAndPublishesRejectedPurgeToast() - { - var kanjStore = new FakeKanjStore(); - var settings = new FakeSettingsStore(); - var tenantContext = new TenantContext(); - var pipelineStoreA = new FakePipelineStore(); - long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - pipelineStoreA.SeedRejected(Rejected("r_old_a", (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds))); - pipelineStoreA.SeedRejected(Rejected("r_fresh", (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds))); - await using ServiceProvider provider = BuildProvider( - new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), - tenantContext, - new Dictionary { [TenantA] = kanjStore, [TenantB] = new FakeKanjStore() }, - new Dictionary { [TenantA] = settings, [TenantB] = settings }, - new Dictionary { [TenantA] = pipelineStoreA, [TenantB] = new FakePipelineStore() }); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - SseSubscription subscriptionB = broker.Subscribe(TenantB); - StorageTickScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - Assert.Equal("r_fresh", Assert.Single(pipelineStoreA.Rejected).Id); - (string Text, string Icon)[] rejectedPurgeToast = [(Text: "Отсев очищен: 1 записей (3 дн.)", Icon: "trash")]; - Assert.Equal(rejectedPurgeToast, ReadToasts(subscriptionA)); - Assert.False(subscriptionB.Events.TryRead(out _)); - Assert.False(tenantContext.HasTenant); - } - - [Fact] - public async Task RunCycle_DueReminder_PublishesReminderDueToTenantChannelAndMarksFired() - { - var cardStoreA = new FakeKanjStore(); - cardStoreA.SeedCard(HoldCard("c_a_past", title: "Отложенный бот", reminderAtMs: NowMs() - 60_000)); - cardStoreA.SeedCard(HoldCard("c_a_future", title: "Будущий", reminderAtMs: NowMs() + 60_000)); - cardStoreA.SeedCard(HoldCard("c_a_work", stage: "work", title: "В работе", reminderAtMs: NowMs() - 60_000)); - var cardStoreB = new FakeKanjStore(); - var tenantContext = new TenantContext(); - await using ServiceProvider provider = BuildProvider( - new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), - tenantContext, - new Dictionary { [TenantA] = cardStoreA, [TenantB] = cardStoreB }, - new Dictionary { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() }, - new Dictionary { [TenantA] = new(), [TenantB] = new() }); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - SseSubscription subscriptionB = broker.Subscribe(TenantB); - StorageTickScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - (string Type, string Json) dueEvent = Assert.Single(ReadEvents(subscriptionA)); - Assert.Equal("reminder_due", dueEvent.Type); - using JsonDocument payload = JsonDocument.Parse(dueEvent.Json); - Assert.Equal("c_a_past", payload.RootElement.GetProperty("id").GetString()); - Assert.Equal("Отложенный бот", payload.RootElement.GetProperty("title").GetString()); - Assert.Equal("hold", payload.RootElement.GetProperty("containerId").GetString()); - Assert.False(subscriptionB.Events.TryRead(out _)); - - // «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД). - Assert.Empty(await cardStoreA.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None)); - Assert.False(tenantContext.HasTenant); - } - - [Fact] - public async Task RunCycle_RemindersDisabled_ClearsExpiredAndPublishesNoReminderDue() - { - var cardStoreA = new FakeKanjStore(); - cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000)); - var settingsA = new FakeSettingsStore(); - settingsA.Preload(SettingsKeys.RemindersEnabled, "false"); - var tenantContext = new TenantContext(); - await using ServiceProvider provider = BuildProvider( - new FakeTenantRepository(Tenant(TenantA)), - tenantContext, - new Dictionary { [TenantA] = cardStoreA }, - new Dictionary { [TenantA] = settingsA }, - new Dictionary { [TenantA] = new() }); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - StorageTickScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - Assert.False(subscriptionA.Events.TryRead(out _)); // ни reminder_due, ни тостов (счётчики нулевые) - Assert.Null(Assert.Single(cardStoreA.CardDtos).Reminder); // протухшее очищено (L268) - Assert.False(tenantContext.HasTenant); - } - - [Fact] - public async Task RunCycle_ReminderCheckFailure_DoesNotAbortTenantTickOrOtherTenants() - { - // У тенанта A проверка напоминаний падает (имитация сбоя схемы/БД на ListDueAsync) — ветка логируется - // и тик A завершается, тенант B обрабатывается (автоархив + тост) — проход жив. - var cardStoreA = new ThrowingDueKanjStore(); - FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); - var tenantContext = new TenantContext(); - await using ServiceProvider provider = BuildProvider( - new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), - tenantContext, - new Dictionary { [TenantA] = cardStoreA, [TenantB] = storeB }, - new Dictionary { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() }, - new Dictionary { [TenantA] = new(), [TenantB] = new() }); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - SseSubscription subscriptionB = broker.Subscribe(TenantB); - StorageTickScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - Assert.False(subscriptionA.Events.TryRead(out _)); // сбой ветки — событий у A нет, тик A не «упал» - Assert.Equal(KanbanColumns.Archive, storeB.CardDtos.Single(card => card.Id == "l_b_old").Col); - (string Text, string Icon)[] autoArchiveToast = [(Text: "Автоархив: 1 карточек", Icon: "clock")]; - Assert.Equal(autoArchiveToast, ReadToasts(subscriptionB)); - Assert.False(tenantContext.HasTenant); - } - - // ─── Хелперы ──────────────────────────────────────────────────────────── - - // Строит DI-провайдер теста: системный реестр + тенант-зависимые фейки по ITenantContext. - // tenants: Фейк реестра тенантов (системный scope прохода). - // tenantContext: Реальный контекст тенанта (AsyncLocal, как в приложении). - // storeA: Хранилище канбана тенанта A. - // storeB: Хранилище канбана тенанта B. - // settings: Настройки обоих тенантов (пустые — дефолты тика). - // Возвращает: Провайдер с зарегистрированными сервисами теста. - private static ServiceProvider BuildProvider( - FakeTenantRepository tenants, - TenantContext tenantContext, - FakeKanjStore storeA, - FakeKanjStore storeB, - FakeSettingsStore settings) - { - return BuildProvider( - tenants, - tenantContext, - new Dictionary { [TenantA] = storeA, [TenantB] = storeB }, - new Dictionary { [TenantA] = settings, [TenantB] = settings }); - } - - // Строит DI-провайдер теста по явным картам тенант → хранилище/настройки. - // tenants: Фейк реестра тенантов. - // tenantContext: Реальный контекст тенанта (AsyncLocal). - // storesByTenant: Хранилища канбана по тенантам (резолвятся по текущему контексту). - // settingsByTenant: Хранилища настроек по тенантам. - // pipelineStoresByTenant: Хранилища пайплайна по тенантам (очистка отсева тика); null — пустые. - // Возвращает: Провайдер с зарегистрированными сервисами теста. - private static ServiceProvider BuildProvider( - ITenantRepository tenants, - TenantContext tenantContext, - Dictionary storesByTenant, - Dictionary settingsByTenant, - Dictionary? pipelineStoresByTenant = null) - { - pipelineStoresByTenant ??= new Dictionary - { - [TenantA] = new FakePipelineStore(), - [TenantB] = new FakePipelineStore(), - }; - - var services = new ServiceCollection(); - services.AddSingleton(tenantContext); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(tenants); - // Тенант-scoped адаптеры: реальные строят TenantDbContext по схеме текущего тенанта — фейк - // выбирает хранилище по тому же ITenantContext, который планировщик заполняет SetTenant. - services.AddScoped(provider => storesByTenant[TenantOf(provider)]); - services.AddScoped(provider => settingsByTenant[TenantOf(provider)]); - services.AddScoped(provider => pipelineStoresByTenant[TenantOf(provider)]); - services.AddSingleton(new FakeMlClient()); - services.AddSingleton(new FakeFileStorage()); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - return services.BuildServiceProvider(); - } - - // Создаёт планировщик на провайдере теста (без StartAsync — таймер 30 с не заводим). - // provider: DI-провайдер с сервисами цикла. - // Возвращает: Планировщик с NullLogger. - private static StorageTickScheduler CreateScheduler(ServiceProvider provider) - { - return new StorageTickScheduler( - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - NullLogger.Instance); - } - - // Id текущего тенанта из контекста (резолвер фейков, как ConnectionStringProvider.ForTenant). - // provider: Scope, в котором выполняется тик тенанта. - // Возвращает: Guid тенанта из TenantId (формат N). - private static Guid TenantOf(IServiceProvider provider) - { - TenantId tenantId = provider.GetRequiredService().TenantId - ?? throw new InvalidOperationException("Тест: тик вне tenant-контекста (SetTenant не выполнен)"); - return Guid.Parse(tenantId.Value); - } - - // Запись реестра тенанта (как строка public.tenants). - // id: Идентификатор тенанта. - // Возвращает: Запись тенанта со статусом active. - private static TenantRecordDto Tenant(Guid id) => - new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); - - // Хранилище карточек со сбоем выборки due-напоминаний: ListDueRemindersAsync бросает (сценарий - // «БД/схема недоступны» на проверке напоминаний — ветка логируется, тик тенанта/проход живы, как в - // AdminTickOrchestratorTests). - private sealed class ThrowingDueKanjStore : FakeKanjStore - { - /// - public override Task> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct) - { - throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync)."); - } - } - - // Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем. - private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - private static CardDto HoldCard( - string id, - string title, - long reminderAtMs, - string stage = "hold") - { - return new CardDto - { - Id = id, - Col = stage, - Title = title, - Reminder = new CardReminderDto(reminderAtMs), - CreatedAtMs = 1, - UpdatedAtMs = 1, - }; - } - - // Хранилище с просроченной карточкой «Неразобранного» — кандидатом автоархива дефолтного тика. - // cardId: Id карточки. - // Возвращает: Фейк-хранилище с одной старой карточкой inbox. - private static FakeKanjStore StoreWithExpiredInboxCard(string cardId) - { - var store = new FakeKanjStore(); - store.SeedCard(Card(cardId, KanbanColumns.Inbox, ReceivedAtMsAgo(ExpiredAge))); - return store; - } - - // Карточка в колонке с моментом получения (epoch-ms). - private static CardDto Card( - string id, - string col, - long receivedAtMs) => - new() { Id = id, Col = col, ReceivedAtMs = receivedAtMs }; - - // Минимальная запись отсева для сидирования (purge смотрит только RejectedAtMs — 3 суток). - // id: Id записи. - // rejectedAtMs: Время отсева, epoch-ms. - // Возвращает: Запись отсева как строка БД (остальные поля пустые — не участвуют). - private static RejectedItemDto Rejected(string id, long rejectedAtMs) => new() - { - Id = id, - RejectedAtMs = rejectedAtMs, - }; - - // Epoch-ms момента «now − возраст» (ReceivedAt карточки-кандидата). - private static long ReceivedAtMsAgo(TimeSpan age) => DateTimeOffset.UtcNow.Subtract(age).ToUnixTimeMilliseconds(); - - // Читает опубликованные тосты канала: (text, icon) в порядке публикации. - // subscription: Подписка тенанта-получателя. - // Возвращает: Пары text/icon событий toast канала. - private static List<(string Text, string Icon)> ReadToasts(SseSubscription subscription) - { - var toasts = new List<(string Text, string Icon)>(); - while (subscription.Events.TryRead(out SseEvent? sseEvent)) - { - Assert.Equal("toast", sseEvent!.Type); - using JsonDocument payload = JsonDocument.Parse(sseEvent.Json); - toasts.Add(( - payload.RootElement.GetProperty("text").GetString()!, - payload.RootElement.GetProperty("icon").GetString()!)); - } - - return toasts; - } - - // Вычитывает все события канала: (тип, JSON) в порядке публикации (включая reminder_due). - // subscription: Подписка канала тенанта. - // Возвращает: События канала (toast/reminder_due) в порядке публикации. - private static List<(string Type, string Json)> ReadEvents(SseSubscription subscription) - { - var events = new List<(string Type, string Json)>(); - while (subscription.Events.TryRead(out SseEvent? sseEvent)) - { - events.Add((sseEvent!.Type, sseEvent.Json)); - } - - return events; - } - - // Реестр, у которого список тенантов падает (имитация недоступной системной БД). - private sealed class ThrowingTenantRepository : ITenantRepository - { - /// - public Task> ListAsync(CancellationToken ct) => - throw new InvalidOperationException("реестр тенантов недоступен"); - - /// - public Task> ListPageAsync( - int offset, - int limit, - CancellationToken ct) => - throw new InvalidOperationException("реестр тенантов недоступен"); - - /// - public Task FindByIdAsync(Guid id, CancellationToken ct) => - throw new NotSupportedException(); - - /// - public Task CreateAsync(TenantRecordDto tenant, CancellationToken ct) => - throw new NotSupportedException(); - - /// - public Task UpdateStatusAsync( - Guid id, - string status, - CancellationToken ct) => - throw new NotSupportedException(); - } - - // Настройки, у которых чтение падает (имитация сбоя схемы/БД одного тенанта). - private sealed class ThrowingSettingsStore : ISettingsStore - { - /// - public Task GetAsync(string key, CancellationToken ct) => - throw new InvalidOperationException($"настройка {key} недоступна"); - - /// - public Task> GetAllAsync(CancellationToken ct) => - throw new NotSupportedException(); - - /// - public Task SetAsync( - string key, - string valueJson, - CancellationToken ct) => - throw new NotSupportedException(); - - /// - public Task RemoveAsync(string key, CancellationToken ct) => - throw new NotSupportedException(); - } -} +using System.Text.Json; +using Deal.Api.Events; +using Deal.Api.Hosting; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Infrastructure.Data; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; +using Deal.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Kanban; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Modules.Tenants; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты StorageTickScheduler — логика прохода цикла правил хранения +/// +public sealed class StorageTickSchedulerTests +{ + // Тенант A теста (канал подписки). + private static readonly Guid TenantA = Guid.NewGuid(); + + // Тенант B теста (канал подписки). + private static readonly Guid TenantB = Guid.NewGuid(); + + // Возраст карточек-кандидатов автоархива: запас к дефолту archiveAfterDays=14 (SettingsDefaults). + private static readonly TimeSpan ExpiredAge = TimeSpan.FromDays(20); + + [Fact] + public async Task RunCycle_TicksEveryTenantInOwnScopeAndPublishesToastToEachTenantChannel() + { + FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old"); + FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); + var settings = new FakeSettingsStore(); + var tenants = new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)); + var tenantContext = new TenantContext(); + await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + SseSubscription subscriptionB = broker.Subscribe(TenantB); + StorageTickScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + // Каждый тенант оттикал в собственном scope (карточка ушла в archive) и получил свой тост. + Assert.Equal(KanbanColumns.Archive, storeA.CardDtos.Single(card => card.Id == "l_a_old").Col); + Assert.Equal(KanbanColumns.Archive, storeB.CardDtos.Single(card => card.Id == "l_b_old").Col); + (string Text, string Icon)[] autoArchiveToast = [(Text: "Автоархив: 1 карточек", Icon: "clock")]; + Assert.Equal(autoArchiveToast, ReadToasts(subscriptionA)); + Assert.Equal(autoArchiveToast, ReadToasts(subscriptionB)); + + // Контекст AsyncLocal не должен переживать проход (Reset в finally каждого тика). + Assert.False(tenantContext.HasTenant); + } + + [Fact] + public async Task RunCycle_ZeroCountersTenant_PublishesToastOnlyToTenantWithChanges() + { + FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old"); + var storeB = new FakeKanjStore(); + storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1)))); + var settings = new FakeSettingsStore(); + var tenantContext = new TenantContext(); + await using ServiceProvider provider = BuildProvider( + new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), + tenantContext, + storeA, + storeB, + settings); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + SseSubscription subscriptionB = broker.Subscribe(TenantB); + StorageTickScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + // Тенант B не архивировал (свежая карточка) — тост в его канал не публикуется. + Assert.Single(ReadToasts(subscriptionA)); + Assert.False(subscriptionB.Events.TryRead(out _)); + Assert.False(tenantContext.HasTenant); + } + + [Fact] + public async Task RunCycle_TenantTickFailure_DoesNotAbortOtherTenants() + { + // У тенанта A настройки падают (имитация сбоя схемы/БД) — тик A логирует ошибку, B обрабатывается. + FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); + var tenantContext = new TenantContext(); + var settingsByTenant = new Dictionary + { + [TenantA] = new ThrowingSettingsStore(), + [TenantB] = new FakeSettingsStore(), + }; + var storesByTenant = new Dictionary + { + [TenantA] = new FakeKanjStore(), + [TenantB] = storeB, + }; + await using ServiceProvider provider = BuildProvider( + new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), + tenantContext, + storesByTenant, + settingsByTenant); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + SseSubscription subscriptionB = broker.Subscribe(TenantB); + StorageTickScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + Assert.False(subscriptionA.Events.TryRead(out _)); + (string Text, string Icon)[] autoArchiveToast = [(Text: "Автоархив: 1 карточек", Icon: "clock")]; + Assert.Equal(autoArchiveToast, ReadToasts(subscriptionB)); + Assert.False(tenantContext.HasTenant); + } + + [Fact] + public async Task RunCycle_TenantListFailure_DoesNotThrow() + { + var tenantContext = new TenantContext(); + await using ServiceProvider provider = BuildProvider( + new ThrowingTenantRepository(), + tenantContext, + new Dictionary(), + new Dictionary()); + StorageTickScheduler scheduler = CreateScheduler(provider); + + // Сбой реестра логируется внутри цикла и не выбрасывается наружу (цикл живёт дальше). + await scheduler.RunCycleAsync(CancellationToken.None); + + Assert.False(tenantContext.HasTenant); + } + + [Fact] + public async Task RunCycle_PurgesExpiredRejectedRowsAndPublishesRejectedPurgeToast() + { + var kanjStore = new FakeKanjStore(); + var settings = new FakeSettingsStore(); + var tenantContext = new TenantContext(); + var pipelineStoreA = new FakePipelineStore(); + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + pipelineStoreA.SeedRejected(Rejected("r_old_a", (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds))); + pipelineStoreA.SeedRejected(Rejected("r_fresh", (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds))); + await using ServiceProvider provider = BuildProvider( + new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), + tenantContext, + new Dictionary { [TenantA] = kanjStore, [TenantB] = new FakeKanjStore() }, + new Dictionary { [TenantA] = settings, [TenantB] = settings }, + new Dictionary { [TenantA] = pipelineStoreA, [TenantB] = new FakePipelineStore() }); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + SseSubscription subscriptionB = broker.Subscribe(TenantB); + StorageTickScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + Assert.Equal("r_fresh", Assert.Single(pipelineStoreA.Rejected).Id); + (string Text, string Icon)[] rejectedPurgeToast = [(Text: "Отсев очищен: 1 записей (3 дн.)", Icon: "trash")]; + Assert.Equal(rejectedPurgeToast, ReadToasts(subscriptionA)); + Assert.False(subscriptionB.Events.TryRead(out _)); + Assert.False(tenantContext.HasTenant); + } + + [Fact] + public async Task RunCycle_DueReminder_PublishesReminderDueToTenantChannelAndMarksFired() + { + var cardStoreA = new FakeKanjStore(); + cardStoreA.SeedCard(HoldCard("c_a_past", title: "Отложенный бот", reminderAtMs: NowMs() - 60_000)); + cardStoreA.SeedCard(HoldCard("c_a_future", title: "Будущий", reminderAtMs: NowMs() + 60_000)); + cardStoreA.SeedCard(HoldCard("c_a_work", stage: "work", title: "В работе", reminderAtMs: NowMs() - 60_000)); + var cardStoreB = new FakeKanjStore(); + var tenantContext = new TenantContext(); + await using ServiceProvider provider = BuildProvider( + new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), + tenantContext, + new Dictionary { [TenantA] = cardStoreA, [TenantB] = cardStoreB }, + new Dictionary { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() }, + new Dictionary { [TenantA] = new(), [TenantB] = new() }); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + SseSubscription subscriptionB = broker.Subscribe(TenantB); + StorageTickScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + (string Type, string Json) dueEvent = Assert.Single(ReadEvents(subscriptionA)); + Assert.Equal("reminder_due", dueEvent.Type); + using JsonDocument payload = JsonDocument.Parse(dueEvent.Json); + Assert.Equal("c_a_past", payload.RootElement.GetProperty("id").GetString()); + Assert.Equal("Отложенный бот", payload.RootElement.GetProperty("title").GetString()); + Assert.Equal("hold", payload.RootElement.GetProperty("containerId").GetString()); + Assert.False(subscriptionB.Events.TryRead(out _)); + + // «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД). + Assert.Empty(await cardStoreA.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None)); + Assert.False(tenantContext.HasTenant); + } + + [Fact] + public async Task RunCycle_RemindersDisabled_ClearsExpiredAndPublishesNoReminderDue() + { + var cardStoreA = new FakeKanjStore(); + cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000)); + var settingsA = new FakeSettingsStore(); + settingsA.Preload(SettingsKeys.RemindersEnabled, "false"); + var tenantContext = new TenantContext(); + await using ServiceProvider provider = BuildProvider( + new FakeTenantRepository(Tenant(TenantA)), + tenantContext, + new Dictionary { [TenantA] = cardStoreA }, + new Dictionary { [TenantA] = settingsA }, + new Dictionary { [TenantA] = new() }); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + StorageTickScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + Assert.False(subscriptionA.Events.TryRead(out _)); // ни reminder_due, ни тостов (счётчики нулевые) + Assert.Null(Assert.Single(cardStoreA.CardDtos).Reminder); // протухшее очищено (L268) + Assert.False(tenantContext.HasTenant); + } + + [Fact] + public async Task RunCycle_ReminderCheckFailure_DoesNotAbortTenantTickOrOtherTenants() + { + // У тенанта A проверка напоминаний падает (имитация сбоя схемы/БД на ListDueAsync) — ветка логируется + // и тик A завершается, тенант B обрабатывается (автоархив + тост) — проход жив. + var cardStoreA = new ThrowingDueKanjStore(); + FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); + var tenantContext = new TenantContext(); + await using ServiceProvider provider = BuildProvider( + new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), + tenantContext, + new Dictionary { [TenantA] = cardStoreA, [TenantB] = storeB }, + new Dictionary { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() }, + new Dictionary { [TenantA] = new(), [TenantB] = new() }); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + SseSubscription subscriptionB = broker.Subscribe(TenantB); + StorageTickScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + Assert.False(subscriptionA.Events.TryRead(out _)); // сбой ветки — событий у A нет, тик A не «упал» + Assert.Equal(KanbanColumns.Archive, storeB.CardDtos.Single(card => card.Id == "l_b_old").Col); + (string Text, string Icon)[] autoArchiveToast = [(Text: "Автоархив: 1 карточек", Icon: "clock")]; + Assert.Equal(autoArchiveToast, ReadToasts(subscriptionB)); + Assert.False(tenantContext.HasTenant); + } + + // ─── Хелперы ──────────────────────────────────────────────────────────── + + // Строит DI-провайдер теста: системный реестр + тенант-зависимые фейки по ITenantContext. + // tenants: Фейк реестра тенантов (системный scope прохода). + // tenantContext: Реальный контекст тенанта (AsyncLocal, как в приложении). + // storeA: Хранилище канбана тенанта A. + // storeB: Хранилище канбана тенанта B. + // settings: Настройки обоих тенантов (пустые — дефолты тика). + // Возвращает: Провайдер с зарегистрированными сервисами теста. + private static ServiceProvider BuildProvider( + FakeTenantRepository tenants, + TenantContext tenantContext, + FakeKanjStore storeA, + FakeKanjStore storeB, + FakeSettingsStore settings) + { + return BuildProvider( + tenants, + tenantContext, + new Dictionary { [TenantA] = storeA, [TenantB] = storeB }, + new Dictionary { [TenantA] = settings, [TenantB] = settings }); + } + + // Строит DI-провайдер теста по явным картам тенант → хранилище/настройки. + // tenants: Фейк реестра тенантов. + // tenantContext: Реальный контекст тенанта (AsyncLocal). + // storesByTenant: Хранилища канбана по тенантам (резолвятся по текущему контексту). + // settingsByTenant: Хранилища настроек по тенантам. + // pipelineStoresByTenant: Хранилища пайплайна по тенантам (очистка отсева тика); null — пустые. + // Возвращает: Провайдер с зарегистрированными сервисами теста. + private static ServiceProvider BuildProvider( + ITenantRepository tenants, + TenantContext tenantContext, + Dictionary storesByTenant, + Dictionary settingsByTenant, + Dictionary? pipelineStoresByTenant = null) + { + pipelineStoresByTenant ??= new Dictionary + { + [TenantA] = new FakePipelineStore(), + [TenantB] = new FakePipelineStore(), + }; + + var services = new ServiceCollection(); + services.AddSingleton(tenantContext); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(tenants); + // Тенант-scoped адаптеры: реальные строят TenantDbContext по схеме текущего тенанта — фейк + // выбирает хранилище по тому же ITenantContext, который планировщик заполняет SetTenant. + services.AddScoped(provider => storesByTenant[TenantOf(provider)]); + services.AddScoped(provider => settingsByTenant[TenantOf(provider)]); + services.AddScoped(provider => pipelineStoresByTenant[TenantOf(provider)]); + services.AddSingleton(new FakeMlClient()); + services.AddSingleton(new FakeFileStorage()); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services.BuildServiceProvider(); + } + + // Создаёт планировщик на провайдере теста (без StartAsync — таймер 30 с не заводим). + // provider: DI-провайдер с сервисами цикла. + // Возвращает: Планировщик с NullLogger. + private static StorageTickScheduler CreateScheduler(ServiceProvider provider) + { + return new StorageTickScheduler( + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + NullLogger.Instance); + } + + // Id текущего тенанта из контекста (резолвер фейков, как ConnectionStringProvider.ForTenant). + // provider: Scope, в котором выполняется тик тенанта. + // Возвращает: Guid тенанта из TenantId (формат N). + private static Guid TenantOf(IServiceProvider provider) + { + TenantId tenantId = provider.GetRequiredService().TenantId + ?? throw new InvalidOperationException("Тест: тик вне tenant-контекста (SetTenant не выполнен)"); + return Guid.Parse(tenantId.Value); + } + + // Запись реестра тенанта (как строка public.tenants). + // id: Идентификатор тенанта. + // Возвращает: Запись тенанта со статусом active. + private static TenantRecordDto Tenant(Guid id) => + new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); + + // Хранилище карточек со сбоем выборки due-напоминаний: ListDueRemindersAsync бросает (сценарий + // «БД/схема недоступны» на проверке напоминаний — ветка логируется, тик тенанта/проход живы, как в + // AdminTickOrchestratorTests). + private sealed class ThrowingDueKanjStore : FakeKanjStore + { + /// + public override Task> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct) + { + throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync)."); + } + } + + // Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем. + private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + private static CardDto HoldCard( + string id, + string title, + long reminderAtMs, + string stage = "hold") + { + return new CardDto + { + Id = id, + Col = stage, + Title = title, + Reminder = new CardReminderDto(reminderAtMs), + CreatedAtMs = 1, + UpdatedAtMs = 1, + }; + } + + // Хранилище с просроченной карточкой «Неразобранного» — кандидатом автоархива дефолтного тика. + // cardId: Id карточки. + // Возвращает: Фейк-хранилище с одной старой карточкой inbox. + private static FakeKanjStore StoreWithExpiredInboxCard(string cardId) + { + var store = new FakeKanjStore(); + store.SeedCard(Card(cardId, KanbanColumns.Inbox, ReceivedAtMsAgo(ExpiredAge))); + return store; + } + + // Карточка в колонке с моментом получения (epoch-ms). + private static CardDto Card( + string id, + string col, + long receivedAtMs) => + new() { Id = id, Col = col, ReceivedAtMs = receivedAtMs }; + + // Минимальная запись отсева для сидирования (purge смотрит только RejectedAtMs — 3 суток). + // id: Id записи. + // rejectedAtMs: Время отсева, epoch-ms. + // Возвращает: Запись отсева как строка БД (остальные поля пустые — не участвуют). + private static RejectedItemDto Rejected(string id, long rejectedAtMs) => new() + { + Id = id, + RejectedAtMs = rejectedAtMs, + }; + + // Epoch-ms момента «now − возраст» (ReceivedAt карточки-кандидата). + private static long ReceivedAtMsAgo(TimeSpan age) => DateTimeOffset.UtcNow.Subtract(age).ToUnixTimeMilliseconds(); + + // Читает опубликованные тосты канала: (text, icon) в порядке публикации. + // subscription: Подписка тенанта-получателя. + // Возвращает: Пары text/icon событий toast канала. + private static List<(string Text, string Icon)> ReadToasts(SseSubscription subscription) + { + var toasts = new List<(string Text, string Icon)>(); + while (subscription.Events.TryRead(out SseEvent? sseEvent)) + { + Assert.Equal("toast", sseEvent!.Type); + using JsonDocument payload = JsonDocument.Parse(sseEvent.Json); + toasts.Add(( + payload.RootElement.GetProperty("text").GetString()!, + payload.RootElement.GetProperty("icon").GetString()!)); + } + + return toasts; + } + + // Вычитывает все события канала: (тип, JSON) в порядке публикации (включая reminder_due). + // subscription: Подписка канала тенанта. + // Возвращает: События канала (toast/reminder_due) в порядке публикации. + private static List<(string Type, string Json)> ReadEvents(SseSubscription subscription) + { + var events = new List<(string Type, string Json)>(); + while (subscription.Events.TryRead(out SseEvent? sseEvent)) + { + events.Add((sseEvent!.Type, sseEvent.Json)); + } + + return events; + } + + // Реестр, у которого список тенантов падает (имитация недоступной системной БД). + private sealed class ThrowingTenantRepository : ITenantRepository + { + /// + public Task> ListAsync(CancellationToken ct) => + throw new InvalidOperationException("реестр тенантов недоступен"); + + /// + public Task> ListPageAsync( + int offset, + int limit, + CancellationToken ct) => + throw new InvalidOperationException("реестр тенантов недоступен"); + + /// + public Task FindByIdAsync(Guid id, CancellationToken ct) => + throw new NotSupportedException(); + + /// + public Task CreateAsync(TenantRecordDto tenant, CancellationToken ct) => + throw new NotSupportedException(); + + /// + public Task UpdateStatusAsync( + Guid id, + string status, + CancellationToken ct) => + throw new NotSupportedException(); + } + + // Настройки, у которых чтение падает (имитация сбоя схемы/БД одного тенанта). + private sealed class ThrowingSettingsStore : ISettingsStore + { + /// + public Task GetAsync(string key, CancellationToken ct) => + throw new InvalidOperationException($"настройка {key} недоступна"); + + /// + public Task> GetAllAsync(CancellationToken ct) => + throw new NotSupportedException(); + + /// + public Task SetAsync( + string key, + string valueJson, + CancellationToken ct) => + throw new NotSupportedException(); + + /// + public Task RemoveAsync(string key, CancellationToken ct) => + throw new NotSupportedException(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/StorageToastPublisherTests.cs b/src/core/tests/Deal.Tests.Unit/Support/StorageToastPublisherTests.cs index 55c6bf0..e0fd344 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/StorageToastPublisherTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/StorageToastPublisherTests.cs @@ -1,74 +1,74 @@ -using System.Text.Json; -using Deal.Api.Events; -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты StorageToastPublisher — общий хелпер SSE-тостов статистики тика для POST /api/admin/tick и фонового StorageTickScheduler. -/// -public sealed class StorageToastPublisherTests -{ - // Тенант теста (канал подписки). - private static readonly Guid TenantA = Guid.NewGuid(); - - [Fact] - public void PublishTickToasts_NonZeroCounters_PublishesPrototypeTextsAndIcons() - { - var broker = new SseBroker(); - var publisher = new StorageToastPublisher(broker); - SseSubscription subscription = broker.Subscribe(TenantA); - - publisher.PublishTickToasts(TenantA, new StorageTickStatsDto(Archived: 1, PurgedArchive: 2, PurgedTrash: 3, PurgedRejected: 4)); - - List<(string Text, string Icon)> toasts = ReadToasts(subscription); - - Assert.Equal(4, toasts.Count); - Assert.Equal(("Автоархив: 1 карточек", "clock"), toasts[0]); - Assert.Equal(("Архив очищен: 2 (90 дн.)", "trash"), toasts[1]); - Assert.Equal(("Корзина очищена: 3 (7 дн.)", "trash"), toasts[2]); - Assert.Equal(("Отсев очищен: 4 записей (3 дн.)", "trash"), toasts[3]); - } - - [Fact] - public void PublishTickToasts_PurgedRejectedOnly_PublishesRejectedToast() - { - var broker = new SseBroker(); - var publisher = new StorageToastPublisher(broker); - SseSubscription subscription = broker.Subscribe(TenantA); - - publisher.PublishTickToasts(TenantA, new StorageTickStatsDto(Archived: 0, PurgedArchive: 0, PurgedTrash: 0, PurgedRejected: 2)); - - Assert.Equal([(Text: "Отсев очищен: 2 записей (3 дн.)", Icon: "trash")], ReadToasts(subscription)); - } - - [Fact] - public void PublishTickToasts_ZeroCounters_PublishesNothing() - { - var broker = new SseBroker(); - var publisher = new StorageToastPublisher(broker); - SseSubscription subscription = broker.Subscribe(TenantA); - - publisher.PublishTickToasts(TenantA, new StorageTickStatsDto(Archived: 0, PurgedArchive: 0, PurgedTrash: 0, PurgedRejected: 0)); - - Assert.False(subscription.Events.TryRead(out _)); - } - - // Читает опубликованные тосты канала: (text, icon) в порядке публикации. - // subscription: Подписка тенанта-получателя. - // Возвращает: Пары text/icon событий toast канала. - private static List<(string Text, string Icon)> ReadToasts(SseSubscription subscription) - { - var toasts = new List<(string Text, string Icon)>(); - while (subscription.Events.TryRead(out SseEvent? sseEvent)) - { - Assert.Equal("toast", sseEvent!.Type); - using JsonDocument payload = JsonDocument.Parse(sseEvent.Json); - toasts.Add(( - payload.RootElement.GetProperty("text").GetString()!, - payload.RootElement.GetProperty("icon").GetString()!)); - } - - return toasts; - } -} +using System.Text.Json; +using Deal.Api.Events; +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты StorageToastPublisher — общий хелпер SSE-тостов статистики тика для POST /api/admin/tick и фонового StorageTickScheduler. +/// +public sealed class StorageToastPublisherTests +{ + // Тенант теста (канал подписки). + private static readonly Guid TenantA = Guid.NewGuid(); + + [Fact] + public void PublishTickToasts_NonZeroCounters_PublishesPrototypeTextsAndIcons() + { + var broker = new SseBroker(); + var publisher = new StorageToastPublisher(broker); + SseSubscription subscription = broker.Subscribe(TenantA); + + publisher.PublishTickToasts(TenantA, new StorageTickStatsDto(Archived: 1, PurgedArchive: 2, PurgedTrash: 3, PurgedRejected: 4)); + + List<(string Text, string Icon)> toasts = ReadToasts(subscription); + + Assert.Equal(4, toasts.Count); + Assert.Equal(("Автоархив: 1 карточек", "clock"), toasts[0]); + Assert.Equal(("Архив очищен: 2 (90 дн.)", "trash"), toasts[1]); + Assert.Equal(("Корзина очищена: 3 (7 дн.)", "trash"), toasts[2]); + Assert.Equal(("Отсев очищен: 4 записей (3 дн.)", "trash"), toasts[3]); + } + + [Fact] + public void PublishTickToasts_PurgedRejectedOnly_PublishesRejectedToast() + { + var broker = new SseBroker(); + var publisher = new StorageToastPublisher(broker); + SseSubscription subscription = broker.Subscribe(TenantA); + + publisher.PublishTickToasts(TenantA, new StorageTickStatsDto(Archived: 0, PurgedArchive: 0, PurgedTrash: 0, PurgedRejected: 2)); + + Assert.Equal([(Text: "Отсев очищен: 2 записей (3 дн.)", Icon: "trash")], ReadToasts(subscription)); + } + + [Fact] + public void PublishTickToasts_ZeroCounters_PublishesNothing() + { + var broker = new SseBroker(); + var publisher = new StorageToastPublisher(broker); + SseSubscription subscription = broker.Subscribe(TenantA); + + publisher.PublishTickToasts(TenantA, new StorageTickStatsDto(Archived: 0, PurgedArchive: 0, PurgedTrash: 0, PurgedRejected: 0)); + + Assert.False(subscription.Events.TryRead(out _)); + } + + // Читает опубликованные тосты канала: (text, icon) в порядке публикации. + // subscription: Подписка тенанта-получателя. + // Возвращает: Пары text/icon событий toast канала. + private static List<(string Text, string Icon)> ReadToasts(SseSubscription subscription) + { + var toasts = new List<(string Text, string Icon)>(); + while (subscription.Events.TryRead(out SseEvent? sseEvent)) + { + Assert.Equal("toast", sseEvent!.Type); + using JsonDocument payload = JsonDocument.Parse(sseEvent.Json); + toasts.Add(( + payload.RootElement.GetProperty("text").GetString()!, + payload.RootElement.GetProperty("icon").GetString()!)); + } + + return toasts; + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/TelegramGrpcTestHost.cs b/src/core/tests/Deal.Tests.Unit/Support/TelegramGrpcTestHost.cs index b28b869..3034bba 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TelegramGrpcTestHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TelegramGrpcTestHost.cs @@ -1,64 +1,64 @@ -using System.Net; -using Deal.Tests.Unit.Grpc; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Server.Kestrel.Core; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Tests.Unit.Support; - -internal static class TelegramGrpcTestHost -{ - /// - /// Env-ключ service-token - /// - public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - - /// - /// Токен сценариев теста. - /// - public const string DefaultToken = "deal-telegram-test-token"; - - /// - /// Прогоняет сценарий против поднятого фейк-telegram-service с заданным env-токеном. - /// - /// Значение env DEAL_SERVICE_TOKEN (null — убрать переменную). - /// Сервис-фейк (состояние сценария). - /// Сценарий: получает порт хоста и сервис-фейк для проверок. - public static async Task RunAsync( - string? serviceToken, - RecordingTelegramService service, - Func scenario) - { - string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); - - WebApplication? app = null; - try - { - int port = TestPort.Allocate(); - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.WebHost.ConfigureKestrel(kestrel => - kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2)); - - // gRPC-сервер telegram.proto (фейк без токен-интерцептора — токен проверяет клиентский транспорт). - builder.Services.AddGrpc(); - builder.Services.AddSingleton(service); - app = builder.Build(); - app.MapGrpcService(); - await app.StartAsync(); - - await scenario(port, service); - } - finally - { - if (app is not null) - { - await app.StopAsync(); - await app.DisposeAsync(); - } - - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); - } - } -} +using System.Net; +using Deal.Tests.Unit.Grpc; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Tests.Unit.Support; + +internal static class TelegramGrpcTestHost +{ + /// + /// Env-ключ service-token + /// + public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; + + /// + /// Токен сценариев теста. + /// + public const string DefaultToken = "deal-telegram-test-token"; + + /// + /// Прогоняет сценарий против поднятого фейк-telegram-service с заданным env-токеном. + /// + /// Значение env DEAL_SERVICE_TOKEN (null — убрать переменную). + /// Сервис-фейк (состояние сценария). + /// Сценарий: получает порт хоста и сервис-фейк для проверок. + public static async Task RunAsync( + string? serviceToken, + RecordingTelegramService service, + Func scenario) + { + string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); + Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); + + WebApplication? app = null; + try + { + int port = TestPort.Allocate(); + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(kestrel => + kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2)); + + // gRPC-сервер telegram.proto (фейк без токен-интерцептора — токен проверяет клиентский транспорт). + builder.Services.AddGrpc(); + builder.Services.AddSingleton(service); + app = builder.Build(); + app.MapGrpcService(); + await app.StartAsync(); + + await scenario(port, service); + } + finally + { + if (app is not null) + { + await app.StopAsync(); + await app.DisposeAsync(); + } + + Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); + } + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs index fbf73ef..dc75a5e 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs @@ -1,274 +1,274 @@ -using System.Text.Json; -using Deal.Api.Events; -using Deal.Grpc.Telegram; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Tenants; -using Grpc.Core; -using Grpc.Net.Client; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Tests.Unit.Support; - -/// -/// In-proc gRPC-тесты служебного потока telegram-service → ядро (SyncDialogs/ReportStatus). -/// -public sealed class TelegramIngressServiceTests -{ - // Токен сценариев теста. - private const string ValidToken = TelegramIngressTestHost.DefaultToken; - - // Тенант A сценариев (в реестре). - private static readonly Guid TenantA = Guid.NewGuid(); - - // ─── SyncDialogs и ReportStatus ───────────────────────────────────────── - - /// - /// SyncDialogs применяет каталог тенанта к таблице Dialogs и отвечает актуальным списком monitored id - /// - [Fact] - public async Task SyncDialogs_KnownTenant_AppliesCatalogAndReturnsMonitoredIds() - { - var registry = new FakeTenantRegistry(Tenant(TenantA)); - var dialogs = new FakeTelegramStore(); - - await RunAsync( - registry, - services => services.AddScoped(_ => dialogs), - async channel => - { - var request = new SyncDialogsRequest(); - request.Entries.Add(new DialogEntry { Id = "d_1", Name = "Канал", Kind = "channel", Hue = "#a33" }); - request.Entries.Add(new DialogEntry { Id = "d_2", Name = "Группа", Kind = "group", Hue = "#b44" }); - - SyncDialogsReply reply = await SyncAsync(channel, request, TenantA); - - // Новые диалоги авто-мониторятся (дефолт autoMonitorNew=true) — reply несёт их id. - Assert.Equal(["d_1", "d_2"], reply.MonitoredIds); - Assert.True(dialogs.Dialogs.All(row => row.Monitor)); - }); - } - - /// - /// SyncDialogs при autoMonitorNew=false добавляет новые диалоги отключёнными — зеркало мониторинга пусто - /// - [Fact] - public async Task SyncDialogs_AutoMonitorNewDisabled_ReturnsEmptyMonitoredMirror() - { - var registry = new FakeTenantRegistry(Tenant(TenantA)); - var settings = new FakeSettingsStore(); - settings.Preload(SettingsKeys.AutoMonitorNew, "false"); - var dialogs = new FakeTelegramStore(); - - await RunAsync( - registry, - services => - { - services.AddScoped(_ => settings); - services.AddScoped(_ => dialogs); - }, - async channel => - { - var request = new SyncDialogsRequest(); - request.Entries.Add(new DialogEntry { Id = "d_1", Name = "Канал", Kind = "channel" }); - - SyncDialogsReply reply = await SyncAsync(channel, request, TenantA); - - Assert.Empty(reply.MonitoredIds); - Assert.Single(dialogs.Dialogs); - Assert.False(dialogs.Dialogs[0].Monitor); - }); - } - - /// - /// ReportStatus: KV tgStatus/tgAccount на каждый репорт, SSE system_status на каждый репорт и тосты только на переходах connected - /// - [Fact] - public async Task ReportStatus_PersistsStatusAndPublishesTransitionToasts() - { - var settings = new FakeSettingsStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - var broker = new SseBroker(); - - await RunAsync( - registry, - services => - { - services.AddSingleton(broker); - services.AddScoped(_ => settings); - }, - async channel => - { - SseSubscription subscription = broker.Subscribe(TenantA); - var client = new IngressService.IngressServiceClient(channel); - - // Первый репорт (подключение ещё не завершено): KV пишется, публикуется только system_status. - ReportStatusReply first = await ReportAsync(client, connected: false, phase: "idle", account: "@user1", tenantId: TenantA); - Assert.True(first.Ok); - AssertStatusStored(settings, phase: "idle", connected: false); - Assert.Equal("\"@user1\"", settings.GetStoredJson(SettingsKeys.TgAccount)); - Assert.Single(ReadEvents(subscription, SystemStatusEventType)); - Assert.Empty(ReadEvents(subscription, ToastEventType)); - - // Переход false→true (вход по QR завершён): тост «Telegram подключён, сессия сохранена». - ReportStatusReply connected = await ReportAsync(client, connected: true, phase: "ready", account: "@user1", tenantId: TenantA); - Assert.True(connected.Ok); - AssertStatusStored(settings, phase: "ready", connected: true); - Assert.Equal(ConnectedToastText, Assert.Single(ReadEvents(subscription, ToastEventType))); - - // Повторный connected (heartbeat ready-фазы) — тоста нет (гард переходов фаз). - await ReportAsync(client, connected: true, phase: "ready", account: "@user1", tenantId: TenantA); - Assert.Empty(ReadEvents(subscription, ToastEventType)); - - // Переход true→false (выход из аккаунта): тост «Telegram отключён». - await ReportAsync(client, connected: false, phase: "idle", account: string.Empty, tenantId: TenantA); - AssertStatusStored(settings, phase: "idle", connected: false); - Assert.Equal(DisconnectedToastText, Assert.Single(ReadEvents(subscription, ToastEventType))); - }); - } - - /// - /// ReportStatus для несуществующего тенанта не падает — ok=false, KV не тронут. - /// - [Fact] - public async Task ReportStatus_UnknownTenant_NotSavedWithoutRpcError() - { - var settings = new FakeSettingsStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync( - registry, - services => services.AddScoped(_ => settings), - async channel => - { - var client = new IngressService.IngressServiceClient(channel); - ReportStatusReply reply = await ReportAsync(client, connected: true, phase: "ready", account: "@user1", tenantId: Guid.NewGuid()); - - Assert.False(reply.Ok); - Assert.Null(settings.GetStoredJson(SettingsKeys.TgStatus)); - Assert.Null(settings.GetStoredJson(SettingsKeys.TgAccount)); - }); - } - - // ─── Контекст и хелперы ───────────────────────────────────────────────── - - // Тип SSE-события статуса Telegram (зеркало TelegramIngressService). - private const string SystemStatusEventType = "system_status"; - - // Тип SSE-события тоста (зеркало TelegramIngressService). - private const string ToastEventType = "toast"; - - private const string ConnectedToastText = "Telegram подключён, сессия сохранена"; - - private const string DisconnectedToastText = "Telegram отключён"; - - // Запись реестра тенанта (как строка public.tenants). - // id: Идентификатор тенанта. - private static TenantRecordDto Tenant(Guid id) => - new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); - - // Поднимает хост ингресса: реестр тенантов + tenant-адаптеры сценария (общий харнесс). - // registry: Реестр тенантов сценария. - // registerTenantServices: Дополнительные tenant-scoped адаптеры сценария - // (ISettingsStore/ITelegramStore/брокер). - // scenario: Сценарий с gRPC-каналом. - private static Task RunAsync( - FakeTenantRegistry registry, - Action registerTenantServices, - Func scenario) - => TelegramIngressTestHost.RunAsync( - ValidToken, - services => - { - services.AddSingleton(registry); - registerTenantServices(services); - }, - scenario); - - // Вызывает SyncDialogs с metadata сценария. - // channel: Канал к хосту. - // request: Запрос SyncDialogs. - // tenantId: Id тенанта в metadata (null — без заголовка tenant-id). - private static Task SyncAsync( - GrpcChannel channel, - SyncDialogsRequest request, - Guid? tenantId) - { - var client = new IngressService.IngressServiceClient(channel); - AsyncUnaryCall call = client.SyncDialogsAsync( - request, - new CallOptions(TelegramIngressTestHost.CallMetadata(ValidToken, tenantId), deadline: Deadline())); - return call.ResponseAsync; - } - - // Вызывает ReportStatus с metadata сценария. - // client: Клиент ингресса. - // connected: Флаг connected репорта. - // phase: Фаза репорта. - // account: Аккаунт репорта. - // tenantId: Id тенанта в metadata (null — без заголовка tenant-id). - private static Task ReportAsync( - IngressService.IngressServiceClient client, - bool connected, - string phase, - string account, - Guid? tenantId) - { - var request = new ReportStatusRequest - { - Phase = phase, - Connected = connected, - Listener = true, - Account = account, - }; - AsyncUnaryCall call = client.ReportStatusAsync( - request, - new CallOptions(TelegramIngressTestHost.CallMetadata(ValidToken, tenantId), deadline: Deadline())); - return call.ResponseAsync; - } - - // Deadline вызовов теста (контракт ингресса — 10 с). - private static DateTime Deadline() - => DateTime.UtcNow.AddSeconds(TelegramIngressTestHost.RpcDeadlineSeconds); - - // Читает события канала: текст тоста (поле text) либо JSON события для прочих типов. - // subscription: Подписка канала тенанта. - // eventType: Тип события. - private static List ReadEvents(SseSubscription subscription, string eventType) - { - var texts = new List(); - while (subscription.Events.TryRead(out SseEvent? sseEvent)) - { - if (sseEvent!.Type != eventType) - { - continue; - } - - using JsonDocument payload = JsonDocument.Parse(sseEvent.Json); - texts.Add(payload.RootElement.TryGetProperty("text", out JsonElement text) ? text.GetString()! : sseEvent.Json); - } - - return texts; - } - - // Проверяет сохранённый KV tgStatus (camelCase JSON): фаза и флаг connected. - // settings: KV-хранилище сценария. - // phase: Ожидаемая фаза. - // connected: Ожидаемый флаг connected. - private static void AssertStatusStored( - FakeSettingsStore settings, - string phase, - bool connected) - { - string? json = settings.GetStoredJson(SettingsKeys.TgStatus); - Assert.NotNull(json); - using JsonDocument stored = JsonDocument.Parse(json!); - Assert.Equal(phase, stored.RootElement.GetProperty("phase").GetString()); - Assert.Equal(connected, stored.RootElement.GetProperty("connected").GetBoolean()); - } -} +using System.Text.Json; +using Deal.Api.Events; +using Deal.Grpc.Telegram; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Telegram.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Modules.Tenants; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Tests.Unit.Support; + +/// +/// In-proc gRPC-тесты служебного потока telegram-service → ядро (SyncDialogs/ReportStatus). +/// +public sealed class TelegramIngressServiceTests +{ + // Токен сценариев теста. + private const string ValidToken = TelegramIngressTestHost.DefaultToken; + + // Тенант A сценариев (в реестре). + private static readonly Guid TenantA = Guid.NewGuid(); + + // ─── SyncDialogs и ReportStatus ───────────────────────────────────────── + + /// + /// SyncDialogs применяет каталог тенанта к таблице Dialogs и отвечает актуальным списком monitored id + /// + [Fact] + public async Task SyncDialogs_KnownTenant_AppliesCatalogAndReturnsMonitoredIds() + { + var registry = new FakeTenantRegistry(Tenant(TenantA)); + var dialogs = new FakeTelegramStore(); + + await RunAsync( + registry, + services => services.AddScoped(_ => dialogs), + async channel => + { + var request = new SyncDialogsRequest(); + request.Entries.Add(new DialogEntry { Id = "d_1", Name = "Канал", Kind = "channel", Hue = "#a33" }); + request.Entries.Add(new DialogEntry { Id = "d_2", Name = "Группа", Kind = "group", Hue = "#b44" }); + + SyncDialogsReply reply = await SyncAsync(channel, request, TenantA); + + // Новые диалоги авто-мониторятся (дефолт autoMonitorNew=true) — reply несёт их id. + Assert.Equal(["d_1", "d_2"], reply.MonitoredIds); + Assert.True(dialogs.Dialogs.All(row => row.Monitor)); + }); + } + + /// + /// SyncDialogs при autoMonitorNew=false добавляет новые диалоги отключёнными — зеркало мониторинга пусто + /// + [Fact] + public async Task SyncDialogs_AutoMonitorNewDisabled_ReturnsEmptyMonitoredMirror() + { + var registry = new FakeTenantRegistry(Tenant(TenantA)); + var settings = new FakeSettingsStore(); + settings.Preload(SettingsKeys.AutoMonitorNew, "false"); + var dialogs = new FakeTelegramStore(); + + await RunAsync( + registry, + services => + { + services.AddScoped(_ => settings); + services.AddScoped(_ => dialogs); + }, + async channel => + { + var request = new SyncDialogsRequest(); + request.Entries.Add(new DialogEntry { Id = "d_1", Name = "Канал", Kind = "channel" }); + + SyncDialogsReply reply = await SyncAsync(channel, request, TenantA); + + Assert.Empty(reply.MonitoredIds); + Assert.Single(dialogs.Dialogs); + Assert.False(dialogs.Dialogs[0].Monitor); + }); + } + + /// + /// ReportStatus: KV tgStatus/tgAccount на каждый репорт, SSE system_status на каждый репорт и тосты только на переходах connected + /// + [Fact] + public async Task ReportStatus_PersistsStatusAndPublishesTransitionToasts() + { + var settings = new FakeSettingsStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + var broker = new SseBroker(); + + await RunAsync( + registry, + services => + { + services.AddSingleton(broker); + services.AddScoped(_ => settings); + }, + async channel => + { + SseSubscription subscription = broker.Subscribe(TenantA); + var client = new IngressService.IngressServiceClient(channel); + + // Первый репорт (подключение ещё не завершено): KV пишется, публикуется только system_status. + ReportStatusReply first = await ReportAsync(client, connected: false, phase: "idle", account: "@user1", tenantId: TenantA); + Assert.True(first.Ok); + AssertStatusStored(settings, phase: "idle", connected: false); + Assert.Equal("\"@user1\"", settings.GetStoredJson(SettingsKeys.TgAccount)); + Assert.Single(ReadEvents(subscription, SystemStatusEventType)); + Assert.Empty(ReadEvents(subscription, ToastEventType)); + + // Переход false→true (вход по QR завершён): тост «Telegram подключён, сессия сохранена». + ReportStatusReply connected = await ReportAsync(client, connected: true, phase: "ready", account: "@user1", tenantId: TenantA); + Assert.True(connected.Ok); + AssertStatusStored(settings, phase: "ready", connected: true); + Assert.Equal(ConnectedToastText, Assert.Single(ReadEvents(subscription, ToastEventType))); + + // Повторный connected (heartbeat ready-фазы) — тоста нет (гард переходов фаз). + await ReportAsync(client, connected: true, phase: "ready", account: "@user1", tenantId: TenantA); + Assert.Empty(ReadEvents(subscription, ToastEventType)); + + // Переход true→false (выход из аккаунта): тост «Telegram отключён». + await ReportAsync(client, connected: false, phase: "idle", account: string.Empty, tenantId: TenantA); + AssertStatusStored(settings, phase: "idle", connected: false); + Assert.Equal(DisconnectedToastText, Assert.Single(ReadEvents(subscription, ToastEventType))); + }); + } + + /// + /// ReportStatus для несуществующего тенанта не падает — ok=false, KV не тронут. + /// + [Fact] + public async Task ReportStatus_UnknownTenant_NotSavedWithoutRpcError() + { + var settings = new FakeSettingsStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync( + registry, + services => services.AddScoped(_ => settings), + async channel => + { + var client = new IngressService.IngressServiceClient(channel); + ReportStatusReply reply = await ReportAsync(client, connected: true, phase: "ready", account: "@user1", tenantId: Guid.NewGuid()); + + Assert.False(reply.Ok); + Assert.Null(settings.GetStoredJson(SettingsKeys.TgStatus)); + Assert.Null(settings.GetStoredJson(SettingsKeys.TgAccount)); + }); + } + + // ─── Контекст и хелперы ───────────────────────────────────────────────── + + // Тип SSE-события статуса Telegram (зеркало TelegramIngressService). + private const string SystemStatusEventType = "system_status"; + + // Тип SSE-события тоста (зеркало TelegramIngressService). + private const string ToastEventType = "toast"; + + private const string ConnectedToastText = "Telegram подключён, сессия сохранена"; + + private const string DisconnectedToastText = "Telegram отключён"; + + // Запись реестра тенанта (как строка public.tenants). + // id: Идентификатор тенанта. + private static TenantRecordDto Tenant(Guid id) => + new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); + + // Поднимает хост ингресса: реестр тенантов + tenant-адаптеры сценария (общий харнесс). + // registry: Реестр тенантов сценария. + // registerTenantServices: Дополнительные tenant-scoped адаптеры сценария + // (ISettingsStore/ITelegramStore/брокер). + // scenario: Сценарий с gRPC-каналом. + private static Task RunAsync( + FakeTenantRegistry registry, + Action registerTenantServices, + Func scenario) + => TelegramIngressTestHost.RunAsync( + ValidToken, + services => + { + services.AddSingleton(registry); + registerTenantServices(services); + }, + scenario); + + // Вызывает SyncDialogs с metadata сценария. + // channel: Канал к хосту. + // request: Запрос SyncDialogs. + // tenantId: Id тенанта в metadata (null — без заголовка tenant-id). + private static Task SyncAsync( + GrpcChannel channel, + SyncDialogsRequest request, + Guid? tenantId) + { + var client = new IngressService.IngressServiceClient(channel); + AsyncUnaryCall call = client.SyncDialogsAsync( + request, + new CallOptions(TelegramIngressTestHost.CallMetadata(ValidToken, tenantId), deadline: Deadline())); + return call.ResponseAsync; + } + + // Вызывает ReportStatus с metadata сценария. + // client: Клиент ингресса. + // connected: Флаг connected репорта. + // phase: Фаза репорта. + // account: Аккаунт репорта. + // tenantId: Id тенанта в metadata (null — без заголовка tenant-id). + private static Task ReportAsync( + IngressService.IngressServiceClient client, + bool connected, + string phase, + string account, + Guid? tenantId) + { + var request = new ReportStatusRequest + { + Phase = phase, + Connected = connected, + Listener = true, + Account = account, + }; + AsyncUnaryCall call = client.ReportStatusAsync( + request, + new CallOptions(TelegramIngressTestHost.CallMetadata(ValidToken, tenantId), deadline: Deadline())); + return call.ResponseAsync; + } + + // Deadline вызовов теста (контракт ингресса — 10 с). + private static DateTime Deadline() + => DateTime.UtcNow.AddSeconds(TelegramIngressTestHost.RpcDeadlineSeconds); + + // Читает события канала: текст тоста (поле text) либо JSON события для прочих типов. + // subscription: Подписка канала тенанта. + // eventType: Тип события. + private static List ReadEvents(SseSubscription subscription, string eventType) + { + var texts = new List(); + while (subscription.Events.TryRead(out SseEvent? sseEvent)) + { + if (sseEvent!.Type != eventType) + { + continue; + } + + using JsonDocument payload = JsonDocument.Parse(sseEvent.Json); + texts.Add(payload.RootElement.TryGetProperty("text", out JsonElement text) ? text.GetString()! : sseEvent.Json); + } + + return texts; + } + + // Проверяет сохранённый KV tgStatus (camelCase JSON): фаза и флаг connected. + // settings: KV-хранилище сценария. + // phase: Ожидаемая фаза. + // connected: Ожидаемый флаг connected. + private static void AssertStatusStored( + FakeSettingsStore settings, + string phase, + bool connected) + { + string? json = settings.GetStoredJson(SettingsKeys.TgStatus); + Assert.NotNull(json); + using JsonDocument stored = JsonDocument.Parse(json!); + Assert.Equal(phase, stored.RootElement.GetProperty("phase").GetString()); + Assert.Equal(connected, stored.RootElement.GetProperty("connected").GetBoolean()); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs index 804cdaf..ad47b31 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs @@ -1,159 +1,159 @@ -using System.Net; -using Deal.Api.Configuration; -using Deal.Api.Events; -using Deal.Api.Sources; -using Deal.Api.Telegram; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Infrastructure.Data; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Tenants; -using Grpc.Core; -using Grpc.Net.Client; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Server.Kestrel.Core; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; - -namespace Deal.Tests.Unit.Support; - -internal static class TelegramIngressTestHost -{ - /// - /// Env-ключ ожидаемого service-token - /// - public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - - /// - /// Токен сценариев теста. - /// - public const string DefaultToken = "deal-test-token"; - - /// - /// Deadline RPC-вызовов теста - /// - public const int RpcDeadlineSeconds = 10; - - public const string ServiceTokenMetadataKey = IngressServiceTokenInterceptor.ServiceTokenMetadataKey; - - public const string TenantIdMetadataKey = TelegramIngressService.TenantIdMetadataKey; - - /// - /// Прогоняет сценарий против поднятого хоста с заданным env-токеном и фейками сценария. - /// - /// Значение env DEAL_SERVICE_TOKEN (null — убрать переменную). - /// Регистрация tenant-фейков сценария (реестр/хранилища/брокер). - /// Сценарий с gRPC-каналом к хосту. - /// Опции rate limiting: null — без интерцептора и health (существующие сценарии); Enabled-опции — хост добавляет IngressRateLimitInterceptor (+ grpc.health.v1 для проверки освобождения health от лимита). - public static async Task RunAsync( - string? serviceToken, - Action configureServices, - Func scenario, - RateLimitOptions? rateLimitOptions = null) - { - string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); - - WebApplication? app = null; - GrpcChannel? channel = null; - try - { - int port = TestPort.Allocate(); - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.WebHost.ConfigureKestrel(kestrel => - kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2)); - - // Регистрации ингресса — эталон Program.cs: интерцептор service-token (fail-closed), сервис - // открывает собственные scope с SetTenant (см. TelegramIngressService); контекст тенанта и - // SSE-брокер — общие singleton'ы (как в проде). - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddGrpc(grpc => - { - grpc.Interceptors.Add(); - if (rateLimitOptions is { Enabled: true }) - { - grpc.Interceptors.Add(); - } - }); - if (rateLimitOptions is { Enabled: true }) - { - builder.Services.AddSingleton(new FakeRateLimitCounterStore()); - builder.Services.AddSingleton(provider => - IngressRateLimitInterceptor.CreateLimiter( - provider.GetRequiredService(), - rateLimitOptions.GrpcIngressPerMinute)); - // grpc.health.v1 сценария освобождения health от лимита (как в Program.cs). - builder.Services - .AddGrpcHealthChecks() - .AddCheck("ready", () => HealthCheckResult.Healthy("хост готов")); - } - - builder.Services.AddScoped(_ => new FakeSettingsStore()); - builder.Services.AddScoped(_ => new FakeTelegramStore()); - builder.Services.AddScoped(_ => new FakeTelegramGateway()); - builder.Services.AddTelegramModule(); - - configureServices(builder.Services); - - app = builder.Build(); - app.MapGrpcService(); - app.MapGrpcService(); - if (rateLimitOptions is { Enabled: true }) - { - app.MapGrpcHealthChecksService(); - } - - await app.StartAsync(); - - channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}"); - await scenario(channel); - } - finally - { - if (channel is not null) - { - channel.Dispose(); - } - - if (app is not null) - { - await app.StopAsync(); - await app.DisposeAsync(); - } - - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); - } - } - - /// - /// Строит metadata вызова - /// - /// Значение заголовка service-token. - /// Id тенанта (null — без заголовка tenant-id). - public static Metadata CallMetadata(string? serviceToken, Guid? tenantId = null) - { - var metadata = new Metadata(); - if (serviceToken is not null) - { - metadata.Add(ServiceTokenMetadataKey, serviceToken); - } - - if (tenantId is not null) - { - metadata.Add(TenantIdMetadataKey, tenantId.Value.ToString("N")); - } - - return metadata; - } -} +using System.Net; +using Deal.Api.Configuration; +using Deal.Api.Events; +using Deal.Api.Sources; +using Deal.Api.Telegram; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Infrastructure.Data; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Telegram.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Modules.Tenants; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Deal.Tests.Unit.Support; + +internal static class TelegramIngressTestHost +{ + /// + /// Env-ключ ожидаемого service-token + /// + public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; + + /// + /// Токен сценариев теста. + /// + public const string DefaultToken = "deal-test-token"; + + /// + /// Deadline RPC-вызовов теста + /// + public const int RpcDeadlineSeconds = 10; + + public const string ServiceTokenMetadataKey = IngressServiceTokenInterceptor.ServiceTokenMetadataKey; + + public const string TenantIdMetadataKey = TelegramIngressService.TenantIdMetadataKey; + + /// + /// Прогоняет сценарий против поднятого хоста с заданным env-токеном и фейками сценария. + /// + /// Значение env DEAL_SERVICE_TOKEN (null — убрать переменную). + /// Регистрация tenant-фейков сценария (реестр/хранилища/брокер). + /// Сценарий с gRPC-каналом к хосту. + /// Опции rate limiting: null — без интерцептора и health (существующие сценарии); Enabled-опции — хост добавляет IngressRateLimitInterceptor (+ grpc.health.v1 для проверки освобождения health от лимита). + public static async Task RunAsync( + string? serviceToken, + Action configureServices, + Func scenario, + RateLimitOptions? rateLimitOptions = null) + { + string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); + Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); + + WebApplication? app = null; + GrpcChannel? channel = null; + try + { + int port = TestPort.Allocate(); + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(kestrel => + kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2)); + + // Регистрации ингресса — эталон Program.cs: интерцептор service-token (fail-closed), сервис + // открывает собственные scope с SetTenant (см. TelegramIngressService); контекст тенанта и + // SSE-брокер — общие singleton'ы (как в проде). + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddGrpc(grpc => + { + grpc.Interceptors.Add(); + if (rateLimitOptions is { Enabled: true }) + { + grpc.Interceptors.Add(); + } + }); + if (rateLimitOptions is { Enabled: true }) + { + builder.Services.AddSingleton(new FakeRateLimitCounterStore()); + builder.Services.AddSingleton(provider => + IngressRateLimitInterceptor.CreateLimiter( + provider.GetRequiredService(), + rateLimitOptions.GrpcIngressPerMinute)); + // grpc.health.v1 сценария освобождения health от лимита (как в Program.cs). + builder.Services + .AddGrpcHealthChecks() + .AddCheck("ready", () => HealthCheckResult.Healthy("хост готов")); + } + + builder.Services.AddScoped(_ => new FakeSettingsStore()); + builder.Services.AddScoped(_ => new FakeTelegramStore()); + builder.Services.AddScoped(_ => new FakeTelegramGateway()); + builder.Services.AddTelegramModule(); + + configureServices(builder.Services); + + app = builder.Build(); + app.MapGrpcService(); + app.MapGrpcService(); + if (rateLimitOptions is { Enabled: true }) + { + app.MapGrpcHealthChecksService(); + } + + await app.StartAsync(); + + channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}"); + await scenario(channel); + } + finally + { + if (channel is not null) + { + channel.Dispose(); + } + + if (app is not null) + { + await app.StopAsync(); + await app.DisposeAsync(); + } + + Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); + } + } + + /// + /// Строит metadata вызова + /// + /// Значение заголовка service-token. + /// Id тенанта (null — без заголовка tenant-id). + public static Metadata CallMetadata(string? serviceToken, Guid? tenantId = null) + { + var metadata = new Metadata(); + if (serviceToken is not null) + { + metadata.Add(ServiceTokenMetadataKey, serviceToken); + } + + if (tenantId is not null) + { + metadata.Add(TenantIdMetadataKey, tenantId.Value.ToString("N")); + } + + return metadata; + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/TelegramSourceContentProviderTests.cs b/src/core/tests/Deal.Tests.Unit/Support/TelegramSourceContentProviderTests.cs index cb4ed00..4228d83 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TelegramSourceContentProviderTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TelegramSourceContentProviderTests.cs @@ -1,110 +1,110 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Integrations.Sources; -using Deal.Modules.Cards.Application.Sources; -using Deal.Tests.Unit.Contracts; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты провайдера содержимого исходника telegram — TelegramSourceContentProvider. -/// -public sealed class TelegramSourceContentProviderTests -{ - [Fact] - public async Task LoadAsync_ForeignKind_ReturnsNullWithoutGatewayCall() - { - var gateway = new FakeTelegramGateway(); - var provider = new TelegramSourceContentProvider(gateway); - - SourceContent? content = await provider.LoadAsync( - SourceItemFactory.Source(kind: "local"), CancellationToken.None); - - Assert.Null(content); - Assert.Empty(gateway.ReadSourceCalls); - } - - [Fact] - public async Task LoadAsync_TelegramKindCaseInsensitive_LoadsContent() - { - var gateway = new FakeTelegramGateway - { - SourceContent = new TelegramSourceContentDto(true, "текст", null), - }; - var provider = new TelegramSourceContentProvider(gateway); - - SourceContent? content = await provider.LoadAsync( - SourceItemFactory.Source(kind: "Telegram", origin: "d_1", externalId: "100"), CancellationToken.None); - - Assert.NotNull(content); - Assert.Equal("текст", content.Text); - } - - [Fact] - public async Task LoadAsync_EmptyOriginRef_ReturnsNullWithoutGatewayCall() - { - var gateway = new FakeTelegramGateway(); - var provider = new TelegramSourceContentProvider(gateway); - - SourceContent? content = await provider.LoadAsync( - SourceItemFactory.Source(kind: "telegram", origin: null, externalId: "100"), CancellationToken.None); - - Assert.Null(content); - Assert.Empty(gateway.ReadSourceCalls); - } - - [Fact] - public async Task LoadAsync_NonNumericExternalId_ReturnsNullWithoutGatewayCall() - { - var gateway = new FakeTelegramGateway(); - var provider = new TelegramSourceContentProvider(gateway); - - SourceContent? content = await provider.LoadAsync( - SourceItemFactory.Source(kind: "telegram", origin: "d_1", externalId: "not-a-number"), CancellationToken.None); - - Assert.Null(content); - Assert.Empty(gateway.ReadSourceCalls); - } - - [Fact] - public async Task LoadAsync_GatewayNotFound_ReturnsNull() - { - var gateway = new FakeTelegramGateway - { - SourceContent = new TelegramSourceContentDto(false, null, null), - }; - var provider = new TelegramSourceContentProvider(gateway); - - SourceContent? content = await provider.LoadAsync( - SourceItemFactory.Source(kind: "telegram", origin: "d_1", externalId: "100"), CancellationToken.None); - - Assert.Null(content); - } - - [Fact] - public async Task LoadAsync_Found_ReturnsText() - { - var gateway = new FakeTelegramGateway - { - SourceContent = new TelegramSourceContentDto(true, "исходный текст", 1_700_000_000_000), - }; - var provider = new TelegramSourceContentProvider(gateway); - - SourceContent? content = await provider.LoadAsync( - SourceItemFactory.Source(kind: "telegram", origin: "d_42", externalId: "777"), CancellationToken.None); - - Assert.NotNull(content); - Assert.Equal("исходный текст", content.Text); - } - - [Fact] - public async Task LoadAsync_NumericExternalIdAndOriginRef_CallsGatewayWithParsedArguments() - { - var gateway = new FakeTelegramGateway(); - var provider = new TelegramSourceContentProvider(gateway); - - await provider.LoadAsync( - SourceItemFactory.Source(kind: "telegram", origin: "d_42", externalId: "777"), CancellationToken.None); - - Assert.Equal([("d_42", 777L)], gateway.ReadSourceCalls); - } -} +using Deal.Contracts.Integrations.Models; +using Deal.Infrastructure.Integrations.Sources; +using Deal.Modules.Cards.Application.Sources; +using Deal.Tests.Unit.Contracts; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты провайдера содержимого исходника telegram — TelegramSourceContentProvider. +/// +public sealed class TelegramSourceContentProviderTests +{ + [Fact] + public async Task LoadAsync_ForeignKind_ReturnsNullWithoutGatewayCall() + { + var gateway = new FakeTelegramGateway(); + var provider = new TelegramSourceContentProvider(gateway); + + SourceContent? content = await provider.LoadAsync( + SourceItemFactory.Source(kind: "local"), CancellationToken.None); + + Assert.Null(content); + Assert.Empty(gateway.ReadSourceCalls); + } + + [Fact] + public async Task LoadAsync_TelegramKindCaseInsensitive_LoadsContent() + { + var gateway = new FakeTelegramGateway + { + SourceContent = new TelegramSourceContentDto(true, "текст", null), + }; + var provider = new TelegramSourceContentProvider(gateway); + + SourceContent? content = await provider.LoadAsync( + SourceItemFactory.Source(kind: "Telegram", origin: "d_1", externalId: "100"), CancellationToken.None); + + Assert.NotNull(content); + Assert.Equal("текст", content.Text); + } + + [Fact] + public async Task LoadAsync_EmptyOriginRef_ReturnsNullWithoutGatewayCall() + { + var gateway = new FakeTelegramGateway(); + var provider = new TelegramSourceContentProvider(gateway); + + SourceContent? content = await provider.LoadAsync( + SourceItemFactory.Source(kind: "telegram", origin: null, externalId: "100"), CancellationToken.None); + + Assert.Null(content); + Assert.Empty(gateway.ReadSourceCalls); + } + + [Fact] + public async Task LoadAsync_NonNumericExternalId_ReturnsNullWithoutGatewayCall() + { + var gateway = new FakeTelegramGateway(); + var provider = new TelegramSourceContentProvider(gateway); + + SourceContent? content = await provider.LoadAsync( + SourceItemFactory.Source(kind: "telegram", origin: "d_1", externalId: "not-a-number"), CancellationToken.None); + + Assert.Null(content); + Assert.Empty(gateway.ReadSourceCalls); + } + + [Fact] + public async Task LoadAsync_GatewayNotFound_ReturnsNull() + { + var gateway = new FakeTelegramGateway + { + SourceContent = new TelegramSourceContentDto(false, null, null), + }; + var provider = new TelegramSourceContentProvider(gateway); + + SourceContent? content = await provider.LoadAsync( + SourceItemFactory.Source(kind: "telegram", origin: "d_1", externalId: "100"), CancellationToken.None); + + Assert.Null(content); + } + + [Fact] + public async Task LoadAsync_Found_ReturnsText() + { + var gateway = new FakeTelegramGateway + { + SourceContent = new TelegramSourceContentDto(true, "исходный текст", 1_700_000_000_000), + }; + var provider = new TelegramSourceContentProvider(gateway); + + SourceContent? content = await provider.LoadAsync( + SourceItemFactory.Source(kind: "telegram", origin: "d_42", externalId: "777"), CancellationToken.None); + + Assert.NotNull(content); + Assert.Equal("исходный текст", content.Text); + } + + [Fact] + public async Task LoadAsync_NumericExternalIdAndOriginRef_CallsGatewayWithParsedArguments() + { + var gateway = new FakeTelegramGateway(); + var provider = new TelegramSourceContentProvider(gateway); + + await provider.LoadAsync( + SourceItemFactory.Source(kind: "telegram", origin: "d_42", externalId: "777"), CancellationToken.None); + + Assert.Equal([("d_42", 777L)], gateway.ReadSourceCalls); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/TelegramSourceIngestObserverTests.cs b/src/core/tests/Deal.Tests.Unit/Support/TelegramSourceIngestObserverTests.cs index cb3165f..4ae3291 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TelegramSourceIngestObserverTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TelegramSourceIngestObserverTests.cs @@ -1,109 +1,109 @@ -using Deal.Modules.Cards.Application.Sources; -using Deal.Modules.Telegram.Application; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Settings; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты наблюдателя, сохраняющего превью принятых сообщений. -/// -public sealed class TelegramSourceIngestObserverTests -{ - // Id диалога-оригинала сценариев. - private const string DialogId = "d_1"; - - /// - /// Принятая запись известного вида сохраняет превью сообщения - /// - [Fact] - public async Task OnIngestedAsync_KnownKind_SavesPreview() - { - (TelegramSourceIngestObserver observer, FakeTelegramStore store) = Create(); - store.Seed(Dialog()); - DateTimeOffset receivedAt = DateTimeOffset.UtcNow.AddMinutes(-1); - - await observer.OnIngestedAsync(Item("telegram", DialogId, "7", "Нужен Python", receivedAt), CancellationToken.None); - - FakeTelegramMessageRow preview = Assert.Single(store.Messages); - Assert.Equal("m_d_1_7", preview.Id); - Assert.Equal("Нужен Python", preview.Text); - Assert.Equal(receivedAt, preview.MsgAt); - Assert.Equal("Нужен Python", store.Dialogs[0].LastText); - Assert.Equal(receivedAt, store.Dialogs[0].LastAt); - } - - /// - /// Чужой вид источника не трогает хранилище - /// - [Fact] - public async Task OnIngestedAsync_ForeignKind_DoesNotTouchStore() - { - (TelegramSourceIngestObserver observer, FakeTelegramStore store) = Create(); - store.Seed(Dialog()); - - await observer.OnIngestedAsync(Item("avito", DialogId, "7", "Текст", DateTimeOffset.UtcNow), CancellationToken.None); - - Assert.Empty(store.Messages); - Assert.Equal(string.Empty, store.Dialogs[0].LastText); - Assert.Null(store.Dialogs[0].LastAt); - } - - /// - /// Запись без оригинала не сохраняет превью - /// - [Fact] - public async Task OnIngestedAsync_NoOriginRef_DoesNotTouchStore() - { - (TelegramSourceIngestObserver observer, FakeTelegramStore store) = Create(); - store.Seed(Dialog()); - - await observer.OnIngestedAsync(Item("telegram", null, "7", "Текст", DateTimeOffset.UtcNow), CancellationToken.None); - - Assert.Empty(store.Messages); - Assert.Equal(string.Empty, store.Dialogs[0].LastText); - } - - // Собирает наблюдателя на фейковом хранилище диалогов. - private static (TelegramSourceIngestObserver Observer, FakeTelegramStore Store) Create() - { - var store = new FakeTelegramStore(); - var dialogs = new DialogsService( - store, - new FakeSettingsStore(), - new FakeTelegramGateway(), - NullLogger.Instance); - var observer = new TelegramSourceIngestObserver(dialogs, NullLogger.Instance); - return (observer, store); - } - - // Строка каталога для сценариев наблюдателя. - private static FakeTelegramDialogRow Dialog() => new( - DialogId, - Name: "Канал", - Handle: "kanal_handle", - Kind: "channel", - Hue: "#a33", - Monitor: true, - LastText: string.Empty, - LastAt: null, - Backfilled: false, - UpdatedAt: DateTimeOffset.UtcNow); - - // Запись источника сценариев. - // kind: Вид источника. - // originRef: Оригинал (диалог); null — без оригинала. - // externalId: Id сообщения. - private static SourceItem Item(string kind, string? originRef, string externalId, string text, DateTimeOffset receivedAt) => new() - { - Source = new SourceRef - { - Kind = kind, - OriginRef = originRef, - ExternalId = externalId, - ReceivedAt = receivedAt, - }, - Content = new SourceContent { Text = text }, - }; -} +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Telegram.Application; +using Deal.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Settings; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты наблюдателя, сохраняющего превью принятых сообщений. +/// +public sealed class TelegramSourceIngestObserverTests +{ + // Id диалога-оригинала сценариев. + private const string DialogId = "d_1"; + + /// + /// Принятая запись известного вида сохраняет превью сообщения + /// + [Fact] + public async Task OnIngestedAsync_KnownKind_SavesPreview() + { + (TelegramSourceIngestObserver observer, FakeTelegramStore store) = Create(); + store.Seed(Dialog()); + DateTimeOffset receivedAt = DateTimeOffset.UtcNow.AddMinutes(-1); + + await observer.OnIngestedAsync(Item("telegram", DialogId, "7", "Нужен Python", receivedAt), CancellationToken.None); + + FakeTelegramMessageRow preview = Assert.Single(store.Messages); + Assert.Equal("m_d_1_7", preview.Id); + Assert.Equal("Нужен Python", preview.Text); + Assert.Equal(receivedAt, preview.MsgAt); + Assert.Equal("Нужен Python", store.Dialogs[0].LastText); + Assert.Equal(receivedAt, store.Dialogs[0].LastAt); + } + + /// + /// Чужой вид источника не трогает хранилище + /// + [Fact] + public async Task OnIngestedAsync_ForeignKind_DoesNotTouchStore() + { + (TelegramSourceIngestObserver observer, FakeTelegramStore store) = Create(); + store.Seed(Dialog()); + + await observer.OnIngestedAsync(Item("avito", DialogId, "7", "Текст", DateTimeOffset.UtcNow), CancellationToken.None); + + Assert.Empty(store.Messages); + Assert.Equal(string.Empty, store.Dialogs[0].LastText); + Assert.Null(store.Dialogs[0].LastAt); + } + + /// + /// Запись без оригинала не сохраняет превью + /// + [Fact] + public async Task OnIngestedAsync_NoOriginRef_DoesNotTouchStore() + { + (TelegramSourceIngestObserver observer, FakeTelegramStore store) = Create(); + store.Seed(Dialog()); + + await observer.OnIngestedAsync(Item("telegram", null, "7", "Текст", DateTimeOffset.UtcNow), CancellationToken.None); + + Assert.Empty(store.Messages); + Assert.Equal(string.Empty, store.Dialogs[0].LastText); + } + + // Собирает наблюдателя на фейковом хранилище диалогов. + private static (TelegramSourceIngestObserver Observer, FakeTelegramStore Store) Create() + { + var store = new FakeTelegramStore(); + var dialogs = new DialogsService( + store, + new FakeSettingsStore(), + new FakeTelegramGateway(), + NullLogger.Instance); + var observer = new TelegramSourceIngestObserver(dialogs, NullLogger.Instance); + return (observer, store); + } + + // Строка каталога для сценариев наблюдателя. + private static FakeTelegramDialogRow Dialog() => new( + DialogId, + Name: "Канал", + Handle: "kanal_handle", + Kind: "channel", + Hue: "#a33", + Monitor: true, + LastText: string.Empty, + LastAt: null, + Backfilled: false, + UpdatedAt: DateTimeOffset.UtcNow); + + // Запись источника сценариев. + // kind: Вид источника. + // originRef: Оригинал (диалог); null — без оригинала. + // externalId: Id сообщения. + private static SourceItem Item(string kind, string? originRef, string externalId, string text, DateTimeOffset receivedAt) => new() + { + Source = new SourceRef + { + Kind = kind, + OriginRef = originRef, + ExternalId = externalId, + ReceivedAt = receivedAt, + }, + Content = new SourceContent { Text = text }, + }; +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestPort.cs b/src/core/tests/Deal.Tests.Unit/Support/TestPort.cs index 38d865b..6ccff03 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TestPort.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TestPort.cs @@ -1,42 +1,42 @@ -using System.Collections.Concurrent; -using System.Net; -using System.Net.Sockets; - -namespace Deal.Tests.Unit.Support; - -internal static class TestPort -{ - // Максимум повторов биндинга порта 0 при коллизии с уже выданным портом. - private const int MaxAttempts = 64; - - // Порты, уже выданные хелпером в текущем процессе: повторно не выдаются (значение — заглушка). - private static readonly ConcurrentDictionary HandedOut = new(); - - /// - /// Выдаёт свободный TCP-порт на 127.0.0.1, не использованный ранее в текущем процессе. - /// - /// Номер свободного порта (слушатель уже освобождён — порт биндит вызывающий тест). - /// Не удалось найти невыданный порт за попыток. - public static int Allocate() - { - for (int attempt = 0; attempt < MaxAttempts; attempt++) - { - int port = BindEphemeralPort(); - if (HandedOut.TryAdd(port, true)) - { - return port; - } - } - - throw new InvalidOperationException($"Не удалось выделить свободный TCP-порт за {MaxAttempts} попыток."); - } - - // Биндит 127.0.0.1:0, читает выданный ОС порт и освобождает слушатель. - // Возвращает: Эфемерный порт (может совпасть с ранее выданным — фильтруется вызывающим). - private static int BindEphemeralPort() - { - using var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - return ((IPEndPoint)listener.LocalEndpoint).Port; - } -} +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; + +namespace Deal.Tests.Unit.Support; + +internal static class TestPort +{ + // Максимум повторов биндинга порта 0 при коллизии с уже выданным портом. + private const int MaxAttempts = 64; + + // Порты, уже выданные хелпером в текущем процессе: повторно не выдаются (значение — заглушка). + private static readonly ConcurrentDictionary HandedOut = new(); + + /// + /// Выдаёт свободный TCP-порт на 127.0.0.1, не использованный ранее в текущем процессе. + /// + /// Номер свободного порта (слушатель уже освобождён — порт биндит вызывающий тест). + /// Не удалось найти невыданный порт за попыток. + public static int Allocate() + { + for (int attempt = 0; attempt < MaxAttempts; attempt++) + { + int port = BindEphemeralPort(); + if (HandedOut.TryAdd(port, true)) + { + return port; + } + } + + throw new InvalidOperationException($"Не удалось выделить свободный TCP-порт за {MaxAttempts} попыток."); + } + + // Биндит 127.0.0.1:0, читает выданный ОС порт и освобождает слушатель. + // Возвращает: Эфемерный порт (может совпасть с ранее выданным — фильтруется вызывающим). + private static int BindEphemeralPort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } +} diff --git a/src/frontend/.gitignore b/src/frontend/.gitignore index a9da783..a4d699a 100644 --- a/src/frontend/.gitignore +++ b/src/frontend/.gitignore @@ -1,4 +1,4 @@ -node_modules -dist -*.local -.DS_Store +node_modules +dist +*.local +.DS_Store diff --git a/src/frontend/README.md b/src/frontend/README.md index 3b4a854..ecf82aa 100644 --- a/src/frontend/README.md +++ b/src/frontend/README.md @@ -1,35 +1,35 @@ -# Дейл — фронтенд - -Vue 3 (Composition API) + Vite + Tailwind v4. Без внешних UI/state-библиотек. - -## Команды - -```sh -npm install # зависимости -npm run dev # dev-сервер на :5173 (проксирует /api на core :5080) -npm run build # production-сборка в dist/ -npm run preview # локальный просмотр собранного dist/ -npm run lint:i18n # линтер локализации -``` - -## Локализация - -Все пользовательские строки живут в словарях `src/i18n/locales/**` и доступны через -`t()` / `$t()`. В компонентах иных кириллических строк быть не должно — это проверяет -`npm run lint:i18n` (комментарии игнорируются, отдельные строки можно пометить -директивой `i18n-ignore` в той же строке). - -Линтер запускается вручную и в общем прогоне тестов; в `npm run build` он не входит, -чтобы не удлинять сборку. - -## Разбиение бандла - -`vite.config.js` делит сборку на кешируемые чанки: `vendor` (vue и прочие -`node_modules`), `i18n` (словари локалей) и основной `app`-чанк. Словарь остаётся в -статическом графе импортов (нужен на первом рендере) и грузится параллельно. - -## Длинные списки - -Колонки с большим числом карточек рендерятся порциями по 100 через примитив -`useProgressiveList` (`src/composables/progressive.js`) — первые 100 карточек и кнопка -«Показать ещё». На обычных объёмах вид не отличается. +# Дейл — фронтенд + +Vue 3 (Composition API) + Vite + Tailwind v4. Без внешних UI/state-библиотек. + +## Команды + +```sh +npm install # зависимости +npm run dev # dev-сервер на :5173 (проксирует /api на core :5080) +npm run build # production-сборка в dist/ +npm run preview # локальный просмотр собранного dist/ +npm run lint:i18n # линтер локализации +``` + +## Локализация + +Все пользовательские строки живут в словарях `src/i18n/locales/**` и доступны через +`t()` / `$t()`. В компонентах иных кириллических строк быть не должно — это проверяет +`npm run lint:i18n` (комментарии игнорируются, отдельные строки можно пометить +директивой `i18n-ignore` в той же строке). + +Линтер запускается вручную и в общем прогоне тестов; в `npm run build` он не входит, +чтобы не удлинять сборку. + +## Разбиение бандла + +`vite.config.js` делит сборку на кешируемые чанки: `vendor` (vue и прочие +`node_modules`), `i18n` (словари локалей) и основной `app`-чанк. Словарь остаётся в +статическом графе импортов (нужен на первом рендере) и грузится параллельно. + +## Длинные списки + +Колонки с большим числом карточек рендерятся порциями по 100 через примитив +`useProgressiveList` (`src/composables/progressive.js`) — первые 100 карточек и кнопка +«Показать ещё». На обычных объёмах вид не отличается. diff --git a/src/frontend/index.html b/src/frontend/index.html index 14f0090..9b1c90f 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -1,30 +1,30 @@ - - - - - - - Дейл — мониторинг заказов и клиентов - - - -
- - - + + + + + + + Дейл — мониторинг заказов и клиентов + + + +
+ + + diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index ae08a96..f8d5f36 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -1,2046 +1,2046 @@ -{ - "name": "deal-frontend", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "deal-frontend", - "version": "0.1.0", - "dependencies": { - "vue": "^3.5.13" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.1.4", - "@vitejs/plugin-vue": "^5.2.1", - "tailwindcss": "^4.1.4", - "vite": "^6.3.5" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", - "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", - "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", - "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", - "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", - "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", - "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", - "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", - "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", - "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", - "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", - "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", - "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", - "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", - "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", - "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", - "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", - "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", - "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", - "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", - "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", - "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", - "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", - "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", - "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", - "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@tailwindcss/node": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", - "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.24.1", - "jiti": "^2.7.0", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.3.3" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", - "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-x64": "4.3.3", - "@tailwindcss/oxide-freebsd-x64": "4.3.3", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-x64-musl": "4.3.3", - "@tailwindcss/oxide-wasm32-wasi": "4.3.3", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", - "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", - "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", - "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", - "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", - "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", - "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", - "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", - "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", - "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", - "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.11.1", - "@emnapi/runtime": "^1.11.1", - "@emnapi/wasi-threads": "^1.2.2", - "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.2", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", - "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", - "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", - "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.3.3", - "@tailwindcss/oxide": "4.3.3", - "tailwindcss": "4.3.3" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.42", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.42.tgz", - "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.8", - "@vue/shared": "3.5.42", - "entities": "^7.0.1", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.42", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz", - "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==", - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.42", - "@vue/shared": "3.5.42" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.42", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz", - "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.8", - "@vue/compiler-core": "3.5.42", - "@vue/compiler-dom": "3.5.42", - "@vue/compiler-ssr": "3.5.42", - "@vue/shared": "3.5.42", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.19", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.42", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz", - "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.42", - "@vue/shared": "3.5.42" - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.42", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz", - "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==", - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.42" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.42", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.42.tgz", - "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.42", - "@vue/shared": "3.5.42" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.42", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz", - "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.42", - "@vue/runtime-core": "3.5.42", - "@vue/shared": "3.5.42", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.42", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.42.tgz", - "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==", - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.42", - "@vue/runtime-dom": "3.5.42", - "@vue/shared": "3.5.42" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.42", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.42.tgz", - "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==", - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.24.5", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", - "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.17", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rollup": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", - "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.63.1", - "@rollup/rollup-android-arm64": "4.63.1", - "@rollup/rollup-darwin-arm64": "4.63.1", - "@rollup/rollup-darwin-x64": "4.63.1", - "@rollup/rollup-freebsd-arm64": "4.63.1", - "@rollup/rollup-freebsd-x64": "4.63.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", - "@rollup/rollup-linux-arm-musleabihf": "4.63.1", - "@rollup/rollup-linux-arm64-gnu": "4.63.1", - "@rollup/rollup-linux-arm64-musl": "4.63.1", - "@rollup/rollup-linux-loong64-gnu": "4.63.1", - "@rollup/rollup-linux-loong64-musl": "4.63.1", - "@rollup/rollup-linux-ppc64-gnu": "4.63.1", - "@rollup/rollup-linux-ppc64-musl": "4.63.1", - "@rollup/rollup-linux-riscv64-gnu": "4.63.1", - "@rollup/rollup-linux-riscv64-musl": "4.63.1", - "@rollup/rollup-linux-s390x-gnu": "4.63.1", - "@rollup/rollup-linux-x64-gnu": "4.63.1", - "@rollup/rollup-linux-x64-musl": "4.63.1", - "@rollup/rollup-openbsd-x64": "4.63.1", - "@rollup/rollup-openharmony-arm64": "4.63.1", - "@rollup/rollup-win32-arm64-msvc": "4.63.1", - "@rollup/rollup-win32-ia32-msvc": "4.63.1", - "@rollup/rollup-win32-x64-gnu": "4.63.1", - "@rollup/rollup-win32-x64-msvc": "4.63.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tailwindcss": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", - "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vue": { - "version": "3.5.42", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz", - "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.42", - "@vue/compiler-sfc": "3.5.42", - "@vue/runtime-dom": "3.5.42", - "@vue/server-renderer": "3.5.42", - "@vue/shared": "3.5.42" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - } - } -} +{ + "name": "deal-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "deal-frontend", + "version": "0.1.0", + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.4", + "@vitejs/plugin-vue": "^5.2.1", + "tailwindcss": "^4.1.4", + "vite": "^6.3.5" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.42.tgz", + "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.42", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz", + "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz", + "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.42", + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-ssr": "3.5.42", + "@vue/shared": "3.5.42", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz", + "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz", + "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.42.tgz", + "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz", + "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/runtime-core": "3.5.42", + "@vue/shared": "3.5.42", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.42.tgz", + "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.42.tgz", + "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz", + "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-sfc": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/server-renderer": "3.5.42", + "@vue/shared": "3.5.42" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/src/frontend/package.json b/src/frontend/package.json index 29f9c68..3f1f7eb 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -1,21 +1,21 @@ -{ - "name": "deal-frontend", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview", - "lint:i18n": "node scripts/i18n-lint.mjs" - }, - "dependencies": { - "vue": "^3.5.13" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.1.4", - "@vitejs/plugin-vue": "^5.2.1", - "tailwindcss": "^4.1.4", - "vite": "^6.3.5" - } -} +{ + "name": "deal-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint:i18n": "node scripts/i18n-lint.mjs" + }, + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.4", + "@vitejs/plugin-vue": "^5.2.1", + "tailwindcss": "^4.1.4", + "vite": "^6.3.5" + } +} diff --git a/src/frontend/scripts/i18n-lint.mjs b/src/frontend/scripts/i18n-lint.mjs index 6c6a6e6..9dde318 100644 --- a/src/frontend/scripts/i18n-lint.mjs +++ b/src/frontend/scripts/i18n-lint.mjs @@ -1,227 +1,227 @@ -// ─── Линтер локализации ─────────────────────────────────────────────────── -// Падает, если в src/**/*.{vue,js} вне src/i18n/locales/** остались -// пользовательские строки на кириллице. Комментарии (JS и HTML) игнорируются, -// поэтому русские пояснения в коде допустимы. Технические строки (dev-логи и -// т.п.) можно явно исключить директивой `i18n-ignore` в той же строке. -// -// Запуск: npm run lint:i18n -import { readdirSync, readFileSync, statSync } from 'node:fs' -import { join, relative } from 'node:path' - -const SRC = join(process.cwd(), 'src') -const I18N_DIR = join(SRC, 'i18n') -const CYR = /[\u0400-\u04FF]/ -const hasCyr = (s) => CYR.test(s) - -function walk(dir, out = []) { - for (const name of readdirSync(dir)) { - const p = join(dir, name) - if (p.startsWith(I18N_DIR)) continue - const s = statSync(p) - if (s.isDirectory()) walk(p, out) - else if (/\.(vue|js)$/.test(name)) out.push(p) - } - return out -} - -// Похоже ли на начало regex-литерала (а не деления): по предыдущему значимому символу. -function isRegexStart(out) { - for (let k = out.length - 1; k >= 0; k--) { - const ch = out[k] - if (/\s/.test(ch)) continue - return '([{;,=:!&|?+-*%^~<>'.includes(ch) - } - return true -} - -// Кириллические строковые литералы в JS-коде (вне комментариев). -function scanJs(code, out) { - let i = 0 - const n = code.length - let outTail = '' - while (i < n) { - const c = code[i] - if (c === '/' && code[i + 1] === '/') { - const j = code.indexOf('\n', i) - const end = j < 0 ? n : j - outTail += code.slice(i, end) - i = end - continue - } - if (c === '/' && code[i + 1] === '*') { - const j = code.indexOf('*/', i + 2) - const end = j < 0 ? n : j + 2 - outTail += code.slice(i, end) - i = end - continue - } - if (c === '/' && isRegexStart(outTail)) { - let j = i + 1 - let inClass = false - while (j < n) { - const ch = code[j] - if (ch === '\\') { - j += 2 - continue - } - if (ch === '[') inClass = true - else if (ch === ']') inClass = false - else if (ch === '/' && !inClass) { - j++ - break - } - j++ - } - while (j < n && /[a-z]/i.test(code[j])) j++ - outTail += code.slice(i, j) - i = j - continue - } - if (c === '"' || c === "'" || c === '`') { - const quote = c - let j = i + 1 - let inner = '' - while (j < n) { - if (code[j] === '\\') { - inner += code.slice(j, j + 2) - j += 2 - continue - } - if (code[j] === quote) break - inner += code[j] - j++ - } - outTail += code.slice(i, j + 1) - if (hasCyr(inner)) out.push({ start: i, end: j + 1, value: inner }) - i = j + 1 - continue - } - outTail += c - i++ - } -} - -function findTagEnd(s, start) { - let quote = null - for (let i = start + 1; i < s.length; i++) { - const c = s[i] - if (quote) { - if (c === quote) quote = null - } else if (c === '"' || c === "'") quote = c - else if (c === '>') return i - } - return s.length - 1 -} - -const SKIP_ATTR = /^(class|style)$/ - -// Кириллица в шаблоне: значения атрибутов и текстовые узлы. -function scanTemplate(tpl, base, out) { - let i = 0 - while (i < tpl.length) { - if (tpl.startsWith('', i) - i = j < 0 ? tpl.length : j + 3 - continue - } - if (tpl[i] === '<') { - const j = findTagEnd(tpl, i) - const tag = tpl.slice(i, j + 1) - const attrRe = /([:@#]?[A-Za-z_][\w-]*)\s*=\s*"([^"]*)"/g - let m - while ((m = attrRe.exec(tag))) { - const name = m[1] - const value = m[2] - if (name.startsWith('data-') || name === 'xmlns' || name.startsWith('xmlns:')) continue - if (SKIP_ATTR.test(name.replace(/^:/, ''))) continue - if (!hasCyr(value)) continue - const off = i + m.index + m[0].indexOf('"') + 1 - out.push({ start: base + off, end: base + off + value.length, value }) - } - i = j + 1 - continue - } - let j = tpl.indexOf('<', i) - if (j < 0) j = tpl.length - const text = tpl.slice(i, j) - if (hasCyr(text)) { - const parts = text.split(/(\{\{[\s\S]*?\}\})/) - let cursor = i - for (const part of parts) { - if (part.startsWith('{{') && part.endsWith('}}')) { - const inner = part.slice(2, -2) - const local = [] - scanJs(inner, local) - for (const v of local) { - out.push({ start: base + cursor + 2 + v.start, end: base + cursor + 2 + v.end, value: v.value }) - } - } else if (hasCyr(part)) { - const trimmed = part.trim() - if (trimmed) out.push({ start: base + cursor, end: base + cursor + part.length, value: trimmed }) - } - cursor += part.length - } - } - i = j - } -} - -function lineOf(text, offset) { - return text.slice(0, offset).split('\n').length -} - -function lineText(text, line) { - return text.split('\n')[line - 1] || '' -} - -function scanFile(abs) { - const text = readFileSync(abs, 'utf8') - const out = [] - const scriptRe = /]*>/g - let m - while ((m = scriptRe.exec(text))) { - const openEnd = m.index + m[0].length - const close = text.indexOf('', openEnd) - const end = close < 0 ? text.length : close - const local = [] - scanJs(text.slice(openEnd, end), local) - for (const v of local) out.push({ start: openEnd + v.start, end: openEnd + v.end, value: v.value }) - scriptRe.lastIndex = end - } - const tmplStart = text.indexOf('= 0) { - const open = text.indexOf('>', tmplStart) + 1 - const close = text.lastIndexOf('') - if (close > open) { - const local = [] - scanTemplate(text.slice(open, close), open, local) - out.push(...local) - } - } - // Чистый .js (без SFC-блоков) — сканируем целиком как скрипт. - if (!scriptRe.lastIndex && tmplStart < 0) { - const local = [] - scanJs(text, local) - out.push(...local) - } - return { text, violations: out } -} - -let total = 0 -for (const abs of walk(SRC)) { - const { text, violations } = scanFile(abs) - for (const v of violations) { - const line = lineOf(text, v.start) - if (/i18n-ignore/.test(lineText(text, line))) continue - total++ - const rel = relative(process.cwd(), abs).replace(/\\/g, '/') - console.log(`${rel}:${line}: ${v.value.replace(/\s+/g, ' ').slice(0, 90)}`) - } -} - -if (total) { - console.error(`\n✗ i18n: ${total} пользовательских строк(и) вне словарей.`) - process.exit(1) -} else { - console.log('✓ i18n: кириллических пользовательских строк вне словарей не найдено.') -} +// ─── Линтер локализации ─────────────────────────────────────────────────── +// Падает, если в src/**/*.{vue,js} вне src/i18n/locales/** остались +// пользовательские строки на кириллице. Комментарии (JS и HTML) игнорируются, +// поэтому русские пояснения в коде допустимы. Технические строки (dev-логи и +// т.п.) можно явно исключить директивой `i18n-ignore` в той же строке. +// +// Запуск: npm run lint:i18n +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' + +const SRC = join(process.cwd(), 'src') +const I18N_DIR = join(SRC, 'i18n') +const CYR = /[\u0400-\u04FF]/ +const hasCyr = (s) => CYR.test(s) + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + const p = join(dir, name) + if (p.startsWith(I18N_DIR)) continue + const s = statSync(p) + if (s.isDirectory()) walk(p, out) + else if (/\.(vue|js)$/.test(name)) out.push(p) + } + return out +} + +// Похоже ли на начало regex-литерала (а не деления): по предыдущему значимому символу. +function isRegexStart(out) { + for (let k = out.length - 1; k >= 0; k--) { + const ch = out[k] + if (/\s/.test(ch)) continue + return '([{;,=:!&|?+-*%^~<>'.includes(ch) + } + return true +} + +// Кириллические строковые литералы в JS-коде (вне комментариев). +function scanJs(code, out) { + let i = 0 + const n = code.length + let outTail = '' + while (i < n) { + const c = code[i] + if (c === '/' && code[i + 1] === '/') { + const j = code.indexOf('\n', i) + const end = j < 0 ? n : j + outTail += code.slice(i, end) + i = end + continue + } + if (c === '/' && code[i + 1] === '*') { + const j = code.indexOf('*/', i + 2) + const end = j < 0 ? n : j + 2 + outTail += code.slice(i, end) + i = end + continue + } + if (c === '/' && isRegexStart(outTail)) { + let j = i + 1 + let inClass = false + while (j < n) { + const ch = code[j] + if (ch === '\\') { + j += 2 + continue + } + if (ch === '[') inClass = true + else if (ch === ']') inClass = false + else if (ch === '/' && !inClass) { + j++ + break + } + j++ + } + while (j < n && /[a-z]/i.test(code[j])) j++ + outTail += code.slice(i, j) + i = j + continue + } + if (c === '"' || c === "'" || c === '`') { + const quote = c + let j = i + 1 + let inner = '' + while (j < n) { + if (code[j] === '\\') { + inner += code.slice(j, j + 2) + j += 2 + continue + } + if (code[j] === quote) break + inner += code[j] + j++ + } + outTail += code.slice(i, j + 1) + if (hasCyr(inner)) out.push({ start: i, end: j + 1, value: inner }) + i = j + 1 + continue + } + outTail += c + i++ + } +} + +function findTagEnd(s, start) { + let quote = null + for (let i = start + 1; i < s.length; i++) { + const c = s[i] + if (quote) { + if (c === quote) quote = null + } else if (c === '"' || c === "'") quote = c + else if (c === '>') return i + } + return s.length - 1 +} + +const SKIP_ATTR = /^(class|style)$/ + +// Кириллица в шаблоне: значения атрибутов и текстовые узлы. +function scanTemplate(tpl, base, out) { + let i = 0 + while (i < tpl.length) { + if (tpl.startsWith('', i) + i = j < 0 ? tpl.length : j + 3 + continue + } + if (tpl[i] === '<') { + const j = findTagEnd(tpl, i) + const tag = tpl.slice(i, j + 1) + const attrRe = /([:@#]?[A-Za-z_][\w-]*)\s*=\s*"([^"]*)"/g + let m + while ((m = attrRe.exec(tag))) { + const name = m[1] + const value = m[2] + if (name.startsWith('data-') || name === 'xmlns' || name.startsWith('xmlns:')) continue + if (SKIP_ATTR.test(name.replace(/^:/, ''))) continue + if (!hasCyr(value)) continue + const off = i + m.index + m[0].indexOf('"') + 1 + out.push({ start: base + off, end: base + off + value.length, value }) + } + i = j + 1 + continue + } + let j = tpl.indexOf('<', i) + if (j < 0) j = tpl.length + const text = tpl.slice(i, j) + if (hasCyr(text)) { + const parts = text.split(/(\{\{[\s\S]*?\}\})/) + let cursor = i + for (const part of parts) { + if (part.startsWith('{{') && part.endsWith('}}')) { + const inner = part.slice(2, -2) + const local = [] + scanJs(inner, local) + for (const v of local) { + out.push({ start: base + cursor + 2 + v.start, end: base + cursor + 2 + v.end, value: v.value }) + } + } else if (hasCyr(part)) { + const trimmed = part.trim() + if (trimmed) out.push({ start: base + cursor, end: base + cursor + part.length, value: trimmed }) + } + cursor += part.length + } + } + i = j + } +} + +function lineOf(text, offset) { + return text.slice(0, offset).split('\n').length +} + +function lineText(text, line) { + return text.split('\n')[line - 1] || '' +} + +function scanFile(abs) { + const text = readFileSync(abs, 'utf8') + const out = [] + const scriptRe = /]*>/g + let m + while ((m = scriptRe.exec(text))) { + const openEnd = m.index + m[0].length + const close = text.indexOf('', openEnd) + const end = close < 0 ? text.length : close + const local = [] + scanJs(text.slice(openEnd, end), local) + for (const v of local) out.push({ start: openEnd + v.start, end: openEnd + v.end, value: v.value }) + scriptRe.lastIndex = end + } + const tmplStart = text.indexOf('= 0) { + const open = text.indexOf('>', tmplStart) + 1 + const close = text.lastIndexOf('') + if (close > open) { + const local = [] + scanTemplate(text.slice(open, close), open, local) + out.push(...local) + } + } + // Чистый .js (без SFC-блоков) — сканируем целиком как скрипт. + if (!scriptRe.lastIndex && tmplStart < 0) { + const local = [] + scanJs(text, local) + out.push(...local) + } + return { text, violations: out } +} + +let total = 0 +for (const abs of walk(SRC)) { + const { text, violations } = scanFile(abs) + for (const v of violations) { + const line = lineOf(text, v.start) + if (/i18n-ignore/.test(lineText(text, line))) continue + total++ + const rel = relative(process.cwd(), abs).replace(/\\/g, '/') + console.log(`${rel}:${line}: ${v.value.replace(/\s+/g, ' ').slice(0, 90)}`) + } +} + +if (total) { + console.error(`\n✗ i18n: ${total} пользовательских строк(и) вне словарей.`) + process.exit(1) +} else { + console.log('✓ i18n: кириллических пользовательских строк вне словарей не найдено.') +} diff --git a/src/frontend/src/App.vue b/src/frontend/src/App.vue index a2c854a..3cb55fa 100644 --- a/src/frontend/src/App.vue +++ b/src/frontend/src/App.vue @@ -1,19 +1,19 @@ - - - + + + diff --git a/src/frontend/src/components/ConfirmDialog.vue b/src/frontend/src/components/ConfirmDialog.vue index f260e35..34721d2 100644 --- a/src/frontend/src/components/ConfirmDialog.vue +++ b/src/frontend/src/components/ConfirmDialog.vue @@ -1,66 +1,66 @@ - - - + + + diff --git a/src/frontend/src/components/Icon.vue b/src/frontend/src/components/Icon.vue index 6488773..a850487 100644 --- a/src/frontend/src/components/Icon.vue +++ b/src/frontend/src/components/Icon.vue @@ -1,359 +1,359 @@ - - - + + + diff --git a/src/frontend/src/components/PromptLibraryModal.vue b/src/frontend/src/components/PromptLibraryModal.vue index c47707b..8a40618 100644 --- a/src/frontend/src/components/PromptLibraryModal.vue +++ b/src/frontend/src/components/PromptLibraryModal.vue @@ -1,215 +1,215 @@ - - +import { t } from '@/i18n/index.js' +// Библиотека готовых промптов: поиск, категории, предпросмотр. +// «Применить» — кладёт текст в редактор настроек (сохраняется кнопкой «Сохранить промпт»); +// «В Мои промпты» — сохраняет со своим названием/описанием (можно отредактировать текст). +import { computed, ref, watch } from 'vue' +import { PROMPT_CATEGORIES, PROMPT_LIBRARY, buildClassifierPrompt } from '../data.js' +import { addMyPrompt, toast } from '../store.js' +import Icon from './Icon.vue' +import BaseModal from './ui/BaseModal.vue' + +const emit = defineEmits(['apply', 'close']) + +const query = ref('') +const cat = ref('all') +const selectedId = ref(PROMPT_LIBRARY[0]?.id || null) + +const filtered = computed(() => { + const q = query.value.trim().toLowerCase() + return PROMPT_LIBRARY.filter((it) => { + if (cat.value !== 'all' && it.cat !== cat.value) return false + if (!q) return true + return ( + it.title.toLowerCase().includes(q) || + it.desc.toLowerCase().includes(q) || + (it.domain || '').toLowerCase().includes(q) + ) + }) +}) + +const selected = computed(() => PROMPT_LIBRARY.find((it) => it.id === selectedId.value) || filtered.value[0] || null) +const selectedPrompt = computed(() => { + const it = selected.value + if (!it) return '' + if (it.prompt) return it.prompt // полностью готовый текст (вариант поведения) + return buildClassifierPrompt(it.domain) +}) + +// форма «Сохранить в Мои промпты»: текст превью всегда следует за выбранным +// шаблоном (его же можно отредактировать перед сохранением) +const saveOpen = ref(false) +const myName = ref('') +const myDesc = ref('') +const myText = ref('') + +watch( + selectedPrompt, + (v) => { + myText.value = v + }, + { immediate: true }, +) + +function openSave() { + if (!selected.value) return + myName.value = selected.value.title + myDesc.value = selected.value.desc + myText.value = selectedPrompt.value + saveOpen.value = true +} + +async function doSaveMine() { + const ok = await addMyPrompt({ name: myName.value, description: myDesc.value, prompt: myText.value }) + if (ok) { + saveOpen.value = false + toast(t('common.sohraneno-v-moi-prompty-razdel-v'), { icon: 'check' }) + } +} + +function apply() { + if (!selectedPrompt.value) return + emit('apply', selectedPrompt.value) +} + + + diff --git a/src/frontend/src/components/ReminderNotice.vue b/src/frontend/src/components/ReminderNotice.vue index fbc8dba..5def2ff 100644 --- a/src/frontend/src/components/ReminderNotice.vue +++ b/src/frontend/src/components/ReminderNotice.vue @@ -1,66 +1,66 @@ - - - + + + diff --git a/src/frontend/src/components/RenameBoardDialog.vue b/src/frontend/src/components/RenameBoardDialog.vue index 67e26ea..0aff0bd 100644 --- a/src/frontend/src/components/RenameBoardDialog.vue +++ b/src/frontend/src/components/RenameBoardDialog.vue @@ -1,83 +1,83 @@ - - - + + + diff --git a/src/frontend/src/components/SearchPalette.vue b/src/frontend/src/components/SearchPalette.vue index d9b5611..cfdb76d 100644 --- a/src/frontend/src/components/SearchPalette.vue +++ b/src/frontend/src/components/SearchPalette.vue @@ -1,55 +1,55 @@ - - - + + + diff --git a/src/frontend/src/components/Sidebar.vue b/src/frontend/src/components/Sidebar.vue index 2a6ec47..ddfa604 100644 --- a/src/frontend/src/components/Sidebar.vue +++ b/src/frontend/src/components/Sidebar.vue @@ -1,409 +1,409 @@ - - - + + + diff --git a/src/frontend/src/components/Toasts.vue b/src/frontend/src/components/Toasts.vue index 23fe77f..6971a7a 100644 --- a/src/frontend/src/components/Toasts.vue +++ b/src/frontend/src/components/Toasts.vue @@ -1,95 +1,95 @@ - - - - - + + + + + diff --git a/src/frontend/src/components/card/Card.vue b/src/frontend/src/components/card/Card.vue index e37aa90..04c0c62 100644 --- a/src/frontend/src/components/card/Card.vue +++ b/src/frontend/src/components/card/Card.vue @@ -1,330 +1,330 @@ - - - - - + + + + + diff --git a/src/frontend/src/components/card/MoveMenu.vue b/src/frontend/src/components/card/MoveMenu.vue index 8eba5d1..66371ee 100644 --- a/src/frontend/src/components/card/MoveMenu.vue +++ b/src/frontend/src/components/card/MoveMenu.vue @@ -1,104 +1,104 @@ - - - - - + + + + + diff --git a/src/frontend/src/components/discovery/DiscoveryBlacklistList.vue b/src/frontend/src/components/discovery/DiscoveryBlacklistList.vue index b576d9a..2f159c6 100644 --- a/src/frontend/src/components/discovery/DiscoveryBlacklistList.vue +++ b/src/frontend/src/components/discovery/DiscoveryBlacklistList.vue @@ -1,57 +1,57 @@ - - - + + + diff --git a/src/frontend/src/components/discovery/DiscoveryLogList.vue b/src/frontend/src/components/discovery/DiscoveryLogList.vue index b72a3d6..b228745 100644 --- a/src/frontend/src/components/discovery/DiscoveryLogList.vue +++ b/src/frontend/src/components/discovery/DiscoveryLogList.vue @@ -1,31 +1,31 @@ - - - + + + diff --git a/src/frontend/src/components/discovery/DiscoveryQuotaPanel.vue b/src/frontend/src/components/discovery/DiscoveryQuotaPanel.vue index f532354..3f588ea 100644 --- a/src/frontend/src/components/discovery/DiscoveryQuotaPanel.vue +++ b/src/frontend/src/components/discovery/DiscoveryQuotaPanel.vue @@ -1,161 +1,161 @@ - - - + + + diff --git a/src/frontend/src/components/discovery/DiscoveryTaskCard.vue b/src/frontend/src/components/discovery/DiscoveryTaskCard.vue index f96fdae..c5ee1fc 100644 --- a/src/frontend/src/components/discovery/DiscoveryTaskCard.vue +++ b/src/frontend/src/components/discovery/DiscoveryTaskCard.vue @@ -1,43 +1,43 @@ - - - + + + diff --git a/src/frontend/src/components/discovery/DiscoveryTaskMaster.vue b/src/frontend/src/components/discovery/DiscoveryTaskMaster.vue index 2bfe0f6..39cce7b 100644 --- a/src/frontend/src/components/discovery/DiscoveryTaskMaster.vue +++ b/src/frontend/src/components/discovery/DiscoveryTaskMaster.vue @@ -1,284 +1,284 @@ - - +import { t } from '@/i18n/index.js' +// Мастер задачи поиска: статус активной задачи, параметры (форма), ключевые +// слова и действия. Сама форма живёт в DiscoveryView (props.form) — компонент +// только редактирует её поля и сообщает о действиях событиями. +import { computed } from 'vue' +import Icon from '../Icon.vue' +import UiToggle from '../ui/ToggleSwitch.vue' +import TagInput from '../ui/TagInput.vue' +import { statusMeta, fmtDate } from './meta.js' + +const props = defineProps({ + activeTask: { type: Object, default: null }, + form: { type: Object, required: true }, + newDraft: { type: Boolean, default: false }, + busy: { type: Boolean, default: false }, + locked: { type: Boolean, default: false }, + genBusy: { type: Boolean, default: false }, +}) +const emit = defineEmits(['save', 'start', 'pause', 'remove', 'gen-keys']) + +const hasProgress = computed(() => { + const t = props.activeTask + return !!t && (t.found > 0 || t.evaluated > 0 || t.joined > 0 || t.rejected > 0) +}) +const canStart = computed(() => (props.form.keywords || []).length > 0 && !props.busy) +const startLabel = computed(() => { + const s = props.activeTask?.status + if (s === 'paused') return t('channels.prodolzhit') + if (s === 'done' || s === 'failed') return t('channels.zapustit-zanovo') + return t('channels.zapustit') +}) + +// ключи: добавление через TagInput (без дублей, регистронезависимо) +function addKeyword(v) { + if (props.locked) return + const val = String(v || '').trim() + if (!val) return + const keys = props.form.keywords || [] + if (!keys.some((k) => k.toLowerCase() === val.toLowerCase())) keys.push(val) +} + +function dropKeyword(v) { + const i = props.form.keywords.indexOf(v) + if (i >= 0) props.form.keywords.splice(i, 1) +} + + + diff --git a/src/frontend/src/components/discovery/meta.js b/src/frontend/src/components/discovery/meta.js index a4397d7..61690ec 100644 --- a/src/frontend/src/components/discovery/meta.js +++ b/src/frontend/src/components/discovery/meta.js @@ -1,48 +1,48 @@ import { t } from '@/i18n/index.js' -// Общие метаданные экрана «Поиск» (Discovery): статусы задач поиска, виды -// источников-кандидатов, события лога и формат даты. Раньше дублировались в -// DiscoveryView и карточке кандидата — теперь один источник для всех панелей. - -const STATUS_META = { - draft: { label: t('channels.chernovik'), cls: 'text-mid bg-white/5 border-white/10' }, - running: { label: t('channels.idet-poisk'), cls: 'text-online bg-online/10 border-online/25' }, - paused: { label: t('channels.pauza'), cls: 'text-warn bg-warn/10 border-warn/25' }, - done: { label: t('channels.zavershena'), cls: 'text-brand bg-brand/10 border-brand/25' }, - failed: { label: t('channels.oshibka'), cls: 'text-danger bg-danger/10 border-danger/25' }, -} -export const statusMeta = (s) => STATUS_META[s] || { label: s || '—', cls: 'text-mid bg-white/5 border-white/10' } - -// вид источника кандидата: чип с иконкой и цветом -const KIND_META = { - channel: { label: t('channels.kanal'), icon: 'megaphone', cls: 'text-brand bg-brand/10 border-brand/25' }, - group: { label: t('channels.gruppa'), icon: 'users', cls: 'text-online bg-online/10 border-online/25' }, - forum: { label: t('channels.forum'), icon: 'list', cls: 'text-warn bg-warn/10 border-warn/25' }, -} -export const kindMeta = (k) => KIND_META[k] || { label: k || '—', icon: 'comment', cls: 'text-mid bg-white/5 border-white/10' } - -// мета лога: иконка и цвет по событию -const LOG_EVENT_META = { - search: { icon: 'search', cls: 'text-brand bg-brand/10' }, - found: { icon: 'search', cls: 'text-brand bg-brand/10' }, - eval: { icon: 'sparkles', cls: 'text-brand bg-brand/10' }, - review: { icon: 'eye', cls: 'text-warn bg-warn/10' }, - skip: { icon: 'x', cls: 'text-low bg-white/5' }, - join_auto: { icon: 'bell', cls: 'text-online bg-online/10' }, - join_manual: { icon: 'bell', cls: 'text-online bg-online/10' }, - leave: { icon: 'logout', cls: 'text-low bg-white/5' }, - reject: { icon: 'trash', cls: 'text-danger bg-danger/10' }, - flood: { icon: 'warn', cls: 'text-danger bg-danger/10' }, - error: { icon: 'warn', cls: 'text-danger bg-danger/10' }, - done: { icon: 'check', cls: 'text-online bg-online/10' }, -} -export const logMeta = (e) => LOG_EVENT_META[e] || { icon: 'clock', cls: 'text-low bg-white/5' } - -export function fmtDate(ts) { - if (!ts) return '' - return new Date(ts).toLocaleString('ru-RU', { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit', - }) -} +// Общие метаданные экрана «Поиск» (Discovery): статусы задач поиска, виды +// источников-кандидатов, события лога и формат даты. Раньше дублировались в +// DiscoveryView и карточке кандидата — теперь один источник для всех панелей. + +const STATUS_META = { + draft: { label: t('channels.chernovik'), cls: 'text-mid bg-white/5 border-white/10' }, + running: { label: t('channels.idet-poisk'), cls: 'text-online bg-online/10 border-online/25' }, + paused: { label: t('channels.pauza'), cls: 'text-warn bg-warn/10 border-warn/25' }, + done: { label: t('channels.zavershena'), cls: 'text-brand bg-brand/10 border-brand/25' }, + failed: { label: t('channels.oshibka'), cls: 'text-danger bg-danger/10 border-danger/25' }, +} +export const statusMeta = (s) => STATUS_META[s] || { label: s || '—', cls: 'text-mid bg-white/5 border-white/10' } + +// вид источника кандидата: чип с иконкой и цветом +const KIND_META = { + channel: { label: t('channels.kanal'), icon: 'megaphone', cls: 'text-brand bg-brand/10 border-brand/25' }, + group: { label: t('channels.gruppa'), icon: 'users', cls: 'text-online bg-online/10 border-online/25' }, + forum: { label: t('channels.forum'), icon: 'list', cls: 'text-warn bg-warn/10 border-warn/25' }, +} +export const kindMeta = (k) => KIND_META[k] || { label: k || '—', icon: 'comment', cls: 'text-mid bg-white/5 border-white/10' } + +// мета лога: иконка и цвет по событию +const LOG_EVENT_META = { + search: { icon: 'search', cls: 'text-brand bg-brand/10' }, + found: { icon: 'search', cls: 'text-brand bg-brand/10' }, + eval: { icon: 'sparkles', cls: 'text-brand bg-brand/10' }, + review: { icon: 'eye', cls: 'text-warn bg-warn/10' }, + skip: { icon: 'x', cls: 'text-low bg-white/5' }, + join_auto: { icon: 'bell', cls: 'text-online bg-online/10' }, + join_manual: { icon: 'bell', cls: 'text-online bg-online/10' }, + leave: { icon: 'logout', cls: 'text-low bg-white/5' }, + reject: { icon: 'trash', cls: 'text-danger bg-danger/10' }, + flood: { icon: 'warn', cls: 'text-danger bg-danger/10' }, + error: { icon: 'warn', cls: 'text-danger bg-danger/10' }, + done: { icon: 'check', cls: 'text-online bg-online/10' }, +} +export const logMeta = (e) => LOG_EVENT_META[e] || { icon: 'clock', cls: 'text-low bg-white/5' } + +export function fmtDate(ts) { + if (!ts) return '' + return new Date(ts).toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} diff --git a/src/frontend/src/components/operator/AuditFilters.vue b/src/frontend/src/components/operator/AuditFilters.vue index 086dea9..1c698a1 100644 --- a/src/frontend/src/components/operator/AuditFilters.vue +++ b/src/frontend/src/components/operator/AuditFilters.vue @@ -1,64 +1,64 @@ - - - + + + diff --git a/src/frontend/src/components/operator/AuditTable.vue b/src/frontend/src/components/operator/AuditTable.vue index 3f8365a..14e9146 100644 --- a/src/frontend/src/components/operator/AuditTable.vue +++ b/src/frontend/src/components/operator/AuditTable.vue @@ -1,74 +1,74 @@ - - +import { t } from '@/i18n/index.js' +// Таблица записей аудита (AuditRecordDto): общая для /audit и analytics/activity. +// detailJson — строка JSON; раскрывается по клику на тип события. +import { ref } from 'vue' +import { fmtDateTimeCompact } from '../../store/core.js' +import Badge from '../ui/Badge.vue' +import DataTable from '../ui/DataTable.vue' + +defineProps({ + items: { type: Array, default: () => [] }, + loading: Boolean, + empty: { type: String, default: t('operator.sobytij-net') }, +}) + +const COLUMNS = [ + { key: 'eventType', label: t('operator.sobytie') }, + { key: 'actorType', label: t('operator.aktor') }, + { key: 'tenantId', label: t('operator.tenant') }, + { key: 'ip', label: 'IP' }, + { key: 'at', label: t('operator.vremya'), align: 'right' }, +] + +const expanded = ref(-1) + +const ACTOR_TONES = { operator: 'brand', tenant: 'neutral', system: 'warn' } + +function toggle(index) { + expanded.value = expanded.value === index ? -1 : index +} + +function pretty(json) { + if (!json) return '' + try { + return JSON.stringify(JSON.parse(json), null, 2) + } catch { + return json + } +} + + + diff --git a/src/frontend/src/components/operator/SectionLayout.vue b/src/frontend/src/components/operator/SectionLayout.vue index b957c57..f8286d5 100644 --- a/src/frontend/src/components/operator/SectionLayout.vue +++ b/src/frontend/src/components/operator/SectionLayout.vue @@ -1,24 +1,24 @@ - - - + + + diff --git a/src/frontend/src/components/settings/AppearanceTab.vue b/src/frontend/src/components/settings/AppearanceTab.vue index 03834b8..c0fe41f 100644 --- a/src/frontend/src/components/settings/AppearanceTab.vue +++ b/src/frontend/src/components/settings/AppearanceTab.vue @@ -1,73 +1,73 @@ - - - + + + diff --git a/src/frontend/src/components/ui/Badge.vue b/src/frontend/src/components/ui/Badge.vue index b29f315..3a70d04 100644 --- a/src/frontend/src/components/ui/Badge.vue +++ b/src/frontend/src/components/ui/Badge.vue @@ -1,29 +1,29 @@ - - - + + + diff --git a/src/frontend/src/components/ui/BarList.vue b/src/frontend/src/components/ui/BarList.vue index 08e2315..3fb8bb5 100644 --- a/src/frontend/src/components/ui/BarList.vue +++ b/src/frontend/src/components/ui/BarList.vue @@ -1,41 +1,41 @@ - - - + + + diff --git a/src/frontend/src/components/ui/BaseModal.vue b/src/frontend/src/components/ui/BaseModal.vue index 98936f8..9aa71b0 100644 --- a/src/frontend/src/components/ui/BaseModal.vue +++ b/src/frontend/src/components/ui/BaseModal.vue @@ -1,59 +1,59 @@ - - - + + + diff --git a/src/frontend/src/components/ui/Button.vue b/src/frontend/src/components/ui/Button.vue index a0a1f6e..75f350d 100644 --- a/src/frontend/src/components/ui/Button.vue +++ b/src/frontend/src/components/ui/Button.vue @@ -1,42 +1,42 @@ - - - + + + diff --git a/src/frontend/src/components/ui/Card.vue b/src/frontend/src/components/ui/Card.vue index 6c4987d..04b48df 100644 --- a/src/frontend/src/components/ui/Card.vue +++ b/src/frontend/src/components/ui/Card.vue @@ -1,28 +1,28 @@ - - - + + + diff --git a/src/frontend/src/components/ui/ChannelAvatar.vue b/src/frontend/src/components/ui/ChannelAvatar.vue index ecc4fcc..963612b 100644 --- a/src/frontend/src/components/ui/ChannelAvatar.vue +++ b/src/frontend/src/components/ui/ChannelAvatar.vue @@ -1,52 +1,52 @@ - - - + + + diff --git a/src/frontend/src/components/ui/CommentSection.vue b/src/frontend/src/components/ui/CommentSection.vue index ba60a59..aab4c25 100644 --- a/src/frontend/src/components/ui/CommentSection.vue +++ b/src/frontend/src/components/ui/CommentSection.vue @@ -1,92 +1,92 @@ - - - + + + diff --git a/src/frontend/src/components/ui/DataTable.vue b/src/frontend/src/components/ui/DataTable.vue index 540ca34..2d4d577 100644 --- a/src/frontend/src/components/ui/DataTable.vue +++ b/src/frontend/src/components/ui/DataTable.vue @@ -1,76 +1,76 @@ - - - + + + diff --git a/src/frontend/src/components/ui/EmptyState.vue b/src/frontend/src/components/ui/EmptyState.vue index d634061..c4aa44b 100644 --- a/src/frontend/src/components/ui/EmptyState.vue +++ b/src/frontend/src/components/ui/EmptyState.vue @@ -1,16 +1,16 @@ - - - + + + diff --git a/src/frontend/src/components/ui/Field.vue b/src/frontend/src/components/ui/Field.vue index 1cd8d98..c2e958f 100644 --- a/src/frontend/src/components/ui/Field.vue +++ b/src/frontend/src/components/ui/Field.vue @@ -1,21 +1,21 @@ - - - + + + diff --git a/src/frontend/src/components/ui/Pagination.vue b/src/frontend/src/components/ui/Pagination.vue index 1a9e8d2..9323d00 100644 --- a/src/frontend/src/components/ui/Pagination.vue +++ b/src/frontend/src/components/ui/Pagination.vue @@ -1,32 +1,32 @@ - - - + + + diff --git a/src/frontend/src/components/ui/PipelineDetails.vue b/src/frontend/src/components/ui/PipelineDetails.vue index 86ea9da..37b9d61 100644 --- a/src/frontend/src/components/ui/PipelineDetails.vue +++ b/src/frontend/src/components/ui/PipelineDetails.vue @@ -1,62 +1,62 @@ - - - + + + diff --git a/src/frontend/src/components/ui/SelectInput.vue b/src/frontend/src/components/ui/SelectInput.vue index 5babc57..8dff2b8 100644 --- a/src/frontend/src/components/ui/SelectInput.vue +++ b/src/frontend/src/components/ui/SelectInput.vue @@ -1,29 +1,29 @@ - - - + + + diff --git a/src/frontend/src/components/ui/SourceContentView.vue b/src/frontend/src/components/ui/SourceContentView.vue index d3e7788..0c2e395 100644 --- a/src/frontend/src/components/ui/SourceContentView.vue +++ b/src/frontend/src/components/ui/SourceContentView.vue @@ -1,168 +1,168 @@ - - - + + + diff --git a/src/frontend/src/components/ui/StackChips.vue b/src/frontend/src/components/ui/StackChips.vue index 6b7b8c0..3bd48d3 100644 --- a/src/frontend/src/components/ui/StackChips.vue +++ b/src/frontend/src/components/ui/StackChips.vue @@ -1,22 +1,22 @@ - - - + + + diff --git a/src/frontend/src/components/ui/StatCard.vue b/src/frontend/src/components/ui/StatCard.vue index 2bc271c..3119930 100644 --- a/src/frontend/src/components/ui/StatCard.vue +++ b/src/frontend/src/components/ui/StatCard.vue @@ -1,29 +1,29 @@ - - - + + + diff --git a/src/frontend/src/components/ui/TagInput.vue b/src/frontend/src/components/ui/TagInput.vue index 9c492b2..94217fd 100644 --- a/src/frontend/src/components/ui/TagInput.vue +++ b/src/frontend/src/components/ui/TagInput.vue @@ -1,110 +1,110 @@ - - - + + + diff --git a/src/frontend/src/components/ui/TextInput.vue b/src/frontend/src/components/ui/TextInput.vue index d8406ee..f75b1d9 100644 --- a/src/frontend/src/components/ui/TextInput.vue +++ b/src/frontend/src/components/ui/TextInput.vue @@ -1,41 +1,41 @@ - - - + + + diff --git a/src/frontend/src/components/ui/ToggleSwitch.vue b/src/frontend/src/components/ui/ToggleSwitch.vue index 79e5aee..c07c20a 100644 --- a/src/frontend/src/components/ui/ToggleSwitch.vue +++ b/src/frontend/src/components/ui/ToggleSwitch.vue @@ -1,34 +1,34 @@ - - - + + + diff --git a/src/frontend/src/components/ui/fileMeta.js b/src/frontend/src/components/ui/fileMeta.js index 059b30b..6b419ec 100644 --- a/src/frontend/src/components/ui/fileMeta.js +++ b/src/frontend/src/components/ui/fileMeta.js @@ -1,34 +1,34 @@ -// ─── Типы вложений: иконка и цвет ───────────────────────────────────────── -// Kind заполняет сервис данных (image/video/audio/document/archive/other). -// Общая карта для файлов карточки и вложений исходника — без дублирования. - -export const FILE_ICON = { - image: 'image', - video: 'video', - audio: 'audio', - archive: 'archive', - document: 'file', - other: 'file', -} - -export const FILE_STYLE = { - image: 'text-sky-400 bg-sky-400/10', - video: 'text-rose-400 bg-rose-400/10', - audio: 'text-amber-400 bg-amber-400/10', - archive: 'text-warn bg-warn/10', - document: 'text-brand bg-brand/10', - other: 'text-mid bg-white/5', -} - -export function fileIcon(kind) { - return FILE_ICON[kind] || FILE_ICON.other -} - -export function fileStyle(kind) { - return FILE_STYLE[kind] || FILE_STYLE.other -} - -// Только абсолютный http(s)-URL годится для href/src (страховка от мусора). -export function safeHref(url) { - return /^https?:\/\//i.test(String(url || '')) ? url : null -} +// ─── Типы вложений: иконка и цвет ───────────────────────────────────────── +// Kind заполняет сервис данных (image/video/audio/document/archive/other). +// Общая карта для файлов карточки и вложений исходника — без дублирования. + +export const FILE_ICON = { + image: 'image', + video: 'video', + audio: 'audio', + archive: 'archive', + document: 'file', + other: 'file', +} + +export const FILE_STYLE = { + image: 'text-sky-400 bg-sky-400/10', + video: 'text-rose-400 bg-rose-400/10', + audio: 'text-amber-400 bg-amber-400/10', + archive: 'text-warn bg-warn/10', + document: 'text-brand bg-brand/10', + other: 'text-mid bg-white/5', +} + +export function fileIcon(kind) { + return FILE_ICON[kind] || FILE_ICON.other +} + +export function fileStyle(kind) { + return FILE_STYLE[kind] || FILE_STYLE.other +} + +// Только абсолютный http(s)-URL годится для href/src (страховка от мусора). +export function safeHref(url) { + return /^https?:\/\//i.test(String(url || '')) ? url : null +} diff --git a/src/frontend/src/composables/dnd.js b/src/frontend/src/composables/dnd.js index 229f739..90227ff 100644 --- a/src/frontend/src/composables/dnd.js +++ b/src/frontend/src/composables/dnd.js @@ -1,66 +1,66 @@ -// Общая механика drag&drop «карточка ↔ колонка» для обоих канбанов. -// -// Базовый сценарий у канбана дашборда и канбана «Выбранных» один и тот же: -// карточка — источник перетаскивания, колонка/стадия — приёмник. Различия — -// только в state-полях и функции перехода. Всё остальное (проверка «из той же -// колонки», подсветка без мерцания, сброс на drop/leave) вынесено сюда, -// чтобы Column/ProjectColumn и LeadCard/ProjectCard не дублировали обработчики. -import { computed } from 'vue' - -// Поля state передаём парой (state, key): get/set на reactive-объекте. -function field(state, key) { - return { get: () => state[key], set: (v) => (state[key] = v) } -} - -// ── Карточка-источник ───────────────────────────────────────────────────── -// useCardDrag(state, dragKey, overKey, id: () => string|null) — id() вернёт -// null, если карточку тащить нельзя (служебные колонки «архив/корзина»). -export function useCardDrag(state, dragKey, overKey, id) { - const drag = field(state, dragKey) - const over = field(state, overKey) - - function onDragStart(e) { - const cardId = id() - if (cardId == null) return - drag.set(cardId) - e.dataTransfer.effectAllowed = 'move' - } - function onDragEnd() { - // dragend приходит и после успешного drop — сбрасываем оба поля - drag.set(null) - over.set(null) - } - return { onDragStart, onDragEnd } -} - -// ── Колонка/стадия-приёмник ─────────────────────────────────────────────── -// useColumnDrop({ state, dragKey, overKey, colId, canDrop(id), fromOf(id), -// move(id), rootEl }) -export function useColumnDrop(cfg) { - const drag = field(cfg.state, cfg.dragKey) - const over = field(cfg.state, cfg.overKey) - - const isOver = computed(() => !!drag.get() && over.get() === cfg.colId) - - function onDragEnter() { - if (!drag.get()) return - if (!cfg.canDrop(drag.get())) return - const from = cfg.fromOf(drag.get()) - if (from && from !== cfg.colId) over.set(cfg.colId) - } - - function onDragLeave(e) { - const el = cfg.rootEl.value - const stillInside = el && e.relatedTarget instanceof Node && el.contains(e.relatedTarget) - if (!stillInside && over.get() === cfg.colId) over.set(null) - } - - function onDrop() { - const id = drag.get() - drag.set(null) - over.set(null) - if (id && cfg.canDrop(id)) cfg.move(id) - } - - return { isOver, onDragEnter, onDragLeave, onDrop } -} +// Общая механика drag&drop «карточка ↔ колонка» для обоих канбанов. +// +// Базовый сценарий у канбана дашборда и канбана «Выбранных» один и тот же: +// карточка — источник перетаскивания, колонка/стадия — приёмник. Различия — +// только в state-полях и функции перехода. Всё остальное (проверка «из той же +// колонки», подсветка без мерцания, сброс на drop/leave) вынесено сюда, +// чтобы Column/ProjectColumn и LeadCard/ProjectCard не дублировали обработчики. +import { computed } from 'vue' + +// Поля state передаём парой (state, key): get/set на reactive-объекте. +function field(state, key) { + return { get: () => state[key], set: (v) => (state[key] = v) } +} + +// ── Карточка-источник ───────────────────────────────────────────────────── +// useCardDrag(state, dragKey, overKey, id: () => string|null) — id() вернёт +// null, если карточку тащить нельзя (служебные колонки «архив/корзина»). +export function useCardDrag(state, dragKey, overKey, id) { + const drag = field(state, dragKey) + const over = field(state, overKey) + + function onDragStart(e) { + const cardId = id() + if (cardId == null) return + drag.set(cardId) + e.dataTransfer.effectAllowed = 'move' + } + function onDragEnd() { + // dragend приходит и после успешного drop — сбрасываем оба поля + drag.set(null) + over.set(null) + } + return { onDragStart, onDragEnd } +} + +// ── Колонка/стадия-приёмник ─────────────────────────────────────────────── +// useColumnDrop({ state, dragKey, overKey, colId, canDrop(id), fromOf(id), +// move(id), rootEl }) +export function useColumnDrop(cfg) { + const drag = field(cfg.state, cfg.dragKey) + const over = field(cfg.state, cfg.overKey) + + const isOver = computed(() => !!drag.get() && over.get() === cfg.colId) + + function onDragEnter() { + if (!drag.get()) return + if (!cfg.canDrop(drag.get())) return + const from = cfg.fromOf(drag.get()) + if (from && from !== cfg.colId) over.set(cfg.colId) + } + + function onDragLeave(e) { + const el = cfg.rootEl.value + const stillInside = el && e.relatedTarget instanceof Node && el.contains(e.relatedTarget) + if (!stillInside && over.get() === cfg.colId) over.set(null) + } + + function onDrop() { + const id = drag.get() + drag.set(null) + over.set(null) + if (id && cfg.canDrop(id)) cfg.move(id) + } + + return { isOver, onDragEnter, onDragLeave, onDrop } +} diff --git a/src/frontend/src/composables/progressive.js b/src/frontend/src/composables/progressive.js index e85dde6..7377abe 100644 --- a/src/frontend/src/composables/progressive.js +++ b/src/frontend/src/composables/progressive.js @@ -1,40 +1,40 @@ -// ─── Прогрессивный рендер длинных списков ───────────────────────────────── -// Примитив для колонок/списков с большим числом карточек: рендерим первые -// `pageSize` элементов и по кнопке «Показать ещё» открываем следующую порцию. -// Это не виртуализация — DOM растёт по мере просмотра, но начальный рендер -// остаётся дешёвым. При малых списках поведение и вид не меняются: если -// элементов не больше `pageSize`, возвращается весь список без кнопки. -import { computed, ref, watch } from 'vue' - -/** Сколько элементов показываем за раз (и добавляем за одно нажатие). */ -export const PROGRESSIVE_PAGE_SIZE = 100 - -/** - * @param {import('vue').Ref|import('vue').ComputedRef} source - * реактивный источник списка (содержит массив) - * @param {{ pageSize?: number }} [options] - */ -export function useProgressiveList(source, options = {}) { - const pageSize = options.pageSize ?? PROGRESSIVE_PAGE_SIZE - const limit = ref(pageSize) - - const all = computed(() => source.value ?? []) - const total = computed(() => all.value.length) - const visible = computed(() => all.value.slice(0, limit.value)) - const hidden = computed(() => Math.max(0, total.value - limit.value)) - const hasMore = computed(() => hidden.value > 0) - // Сколько добавится при нажатии — для текста кнопки «Показать ещё N». - const moreStep = computed(() => Math.min(pageSize, hidden.value)) - - function showMore() { - if (hasMore.value) limit.value += pageSize - } - - // Если список уменьшился (очистка колонки, фильтр) — не держим лишний лимит, - // чтобы счётчик «спрятанных» элементов не расходился с реальным списком. - watch(total, (n) => { - if (limit.value > n) limit.value = Math.max(pageSize, n) - }) - - return { visible, total, hidden, hasMore, moreStep, showMore, pageSize } -} +// ─── Прогрессивный рендер длинных списков ───────────────────────────────── +// Примитив для колонок/списков с большим числом карточек: рендерим первые +// `pageSize` элементов и по кнопке «Показать ещё» открываем следующую порцию. +// Это не виртуализация — DOM растёт по мере просмотра, но начальный рендер +// остаётся дешёвым. При малых списках поведение и вид не меняются: если +// элементов не больше `pageSize`, возвращается весь список без кнопки. +import { computed, ref, watch } from 'vue' + +/** Сколько элементов показываем за раз (и добавляем за одно нажатие). */ +export const PROGRESSIVE_PAGE_SIZE = 100 + +/** + * @param {import('vue').Ref|import('vue').ComputedRef} source + * реактивный источник списка (содержит массив) + * @param {{ pageSize?: number }} [options] + */ +export function useProgressiveList(source, options = {}) { + const pageSize = options.pageSize ?? PROGRESSIVE_PAGE_SIZE + const limit = ref(pageSize) + + const all = computed(() => source.value ?? []) + const total = computed(() => all.value.length) + const visible = computed(() => all.value.slice(0, limit.value)) + const hidden = computed(() => Math.max(0, total.value - limit.value)) + const hasMore = computed(() => hidden.value > 0) + // Сколько добавится при нажатии — для текста кнопки «Показать ещё N». + const moreStep = computed(() => Math.min(pageSize, hidden.value)) + + function showMore() { + if (hasMore.value) limit.value += pageSize + } + + // Если список уменьшился (очистка колонки, фильтр) — не держим лишний лимит, + // чтобы счётчик «спрятанных» элементов не расходился с реальным списком. + watch(total, (n) => { + if (limit.value > n) limit.value = Math.max(pageSize, n) + }) + + return { visible, total, hidden, hasMore, moreStep, showMore, pageSize } +} diff --git a/src/frontend/src/composables/theme.js b/src/frontend/src/composables/theme.js index 5005ab2..413bce9 100644 --- a/src/frontend/src/composables/theme.js +++ b/src/frontend/src/composables/theme.js @@ -1,74 +1,74 @@ -// ─── Тема оформления ───────────────────────────────────────────────────── -// Тема — это атрибут data-theme на , который переключает значения -// токенов палитры в style.css (см. блок «Светлая тема»). Здесь хранится -// выбор пользователя: 'dark' (по умолчанию) | 'light' | 'system'. -// -// Выбор лежит в localStorage и применяется ещё до первого рендера инлайн- -// скриптом в index.html (чтобы не было «мигания» тёмной темы). Этот модуль -// повторяет ту же логику для реактивного переключателя в настройках и следит -// за сменой системной темы, когда выбран режим 'system'. -import { ref } from 'vue' - -export const THEME_DARK = 'dark' -export const THEME_LIGHT = 'light' -export const THEME_SYSTEM = 'system' - -/** Ключ localStorage с выбором темы (общий с инлайн-скриптом в index.html). */ -export const THEME_STORAGE_KEY = 'deal_theme' - -const THEMES = [THEME_DARK, THEME_LIGHT, THEME_SYSTEM] - -/** Системная тема: тёмная, если ОС не предпочитает светлую. */ -function systemTheme() { - if (typeof window === 'undefined' || !window.matchMedia) return THEME_DARK - return window.matchMedia('(prefers-color-scheme: light)').matches ? THEME_LIGHT : THEME_DARK -} - -/** Сохранённый выбор с валидацией: неизвестное значение → тёмная. */ -function readStoredTheme() { - try { - const raw = localStorage.getItem(THEME_STORAGE_KEY) - return THEMES.includes(raw) ? raw : THEME_DARK - } catch { - return THEME_DARK - } -} - -/** Реактивный выбор темы (то, что видит переключатель в настройках). */ -export const themePreference = ref(readStoredTheme()) - -/** Фактически применяемая тема с учётом режима 'system'. */ -export function resolveTheme(pref = themePreference.value) { - return pref === THEME_SYSTEM ? systemTheme() : pref -} - -/** Применить тему к документу. Возвращает фактическую тему. */ -export function applyTheme(pref = themePreference.value) { - const resolved = resolveTheme(pref) - if (typeof document !== 'undefined') { - document.documentElement.dataset.theme = resolved - } - return resolved -} - -/** Выбрать и сохранить тему. Возвращает фактически применённую тему. */ -export function setTheme(pref) { - const next = THEMES.includes(pref) ? pref : THEME_DARK - themePreference.value = next - try { - localStorage.setItem(THEME_STORAGE_KEY, next) - } catch { - // приватный режим/квота — выбор просто не переживёт перезагрузку - } - return applyTheme(next) -} - -// Режим 'system': перерисовываем тему при смене настроек ОС. -if (typeof window !== 'undefined' && window.matchMedia) { - const mq = window.matchMedia('(prefers-color-scheme: light)') - const onSystemChange = () => { - if (themePreference.value === THEME_SYSTEM) applyTheme(THEME_SYSTEM) - } - if (mq.addEventListener) mq.addEventListener('change', onSystemChange) - else if (mq.addListener) mq.addListener(onSystemChange) -} +// ─── Тема оформления ───────────────────────────────────────────────────── +// Тема — это атрибут data-theme на , который переключает значения +// токенов палитры в style.css (см. блок «Светлая тема»). Здесь хранится +// выбор пользователя: 'dark' (по умолчанию) | 'light' | 'system'. +// +// Выбор лежит в localStorage и применяется ещё до первого рендера инлайн- +// скриптом в index.html (чтобы не было «мигания» тёмной темы). Этот модуль +// повторяет ту же логику для реактивного переключателя в настройках и следит +// за сменой системной темы, когда выбран режим 'system'. +import { ref } from 'vue' + +export const THEME_DARK = 'dark' +export const THEME_LIGHT = 'light' +export const THEME_SYSTEM = 'system' + +/** Ключ localStorage с выбором темы (общий с инлайн-скриптом в index.html). */ +export const THEME_STORAGE_KEY = 'deal_theme' + +const THEMES = [THEME_DARK, THEME_LIGHT, THEME_SYSTEM] + +/** Системная тема: тёмная, если ОС не предпочитает светлую. */ +function systemTheme() { + if (typeof window === 'undefined' || !window.matchMedia) return THEME_DARK + return window.matchMedia('(prefers-color-scheme: light)').matches ? THEME_LIGHT : THEME_DARK +} + +/** Сохранённый выбор с валидацией: неизвестное значение → тёмная. */ +function readStoredTheme() { + try { + const raw = localStorage.getItem(THEME_STORAGE_KEY) + return THEMES.includes(raw) ? raw : THEME_DARK + } catch { + return THEME_DARK + } +} + +/** Реактивный выбор темы (то, что видит переключатель в настройках). */ +export const themePreference = ref(readStoredTheme()) + +/** Фактически применяемая тема с учётом режима 'system'. */ +export function resolveTheme(pref = themePreference.value) { + return pref === THEME_SYSTEM ? systemTheme() : pref +} + +/** Применить тему к документу. Возвращает фактическую тему. */ +export function applyTheme(pref = themePreference.value) { + const resolved = resolveTheme(pref) + if (typeof document !== 'undefined') { + document.documentElement.dataset.theme = resolved + } + return resolved +} + +/** Выбрать и сохранить тему. Возвращает фактически применённую тему. */ +export function setTheme(pref) { + const next = THEMES.includes(pref) ? pref : THEME_DARK + themePreference.value = next + try { + localStorage.setItem(THEME_STORAGE_KEY, next) + } catch { + // приватный режим/квота — выбор просто не переживёт перезагрузку + } + return applyTheme(next) +} + +// Режим 'system': перерисовываем тему при смене настроек ОС. +if (typeof window !== 'undefined' && window.matchMedia) { + const mq = window.matchMedia('(prefers-color-scheme: light)') + const onSystemChange = () => { + if (themePreference.value === THEME_SYSTEM) applyTheme(THEME_SYSTEM) + } + if (mq.addEventListener) mq.addEventListener('change', onSystemChange) + else if (mq.addListener) mq.addListener(onSystemChange) +} diff --git a/src/frontend/src/data.js b/src/frontend/src/data.js index 4a1a1ed..3fe1bc8 100644 --- a/src/frontend/src/data.js +++ b/src/frontend/src/data.js @@ -1,26 +1,26 @@ -// ─── Статические константы UI ───────────────────────────────────────────── -// Языковые данные (валюты, AI-провайдеры, промпты, категории) вынесены в -// ресурсы локали — см. src/i18n/locales/ru.data.js. Здесь остаются только -// не-языковые константы и чистая логика, чтобы компоненты импортировали -// привычные имена из '../data.js' без изменений. - -export const DAY = 86400000 - -// Языковой контент реэкспортируем из словаря локали. -export { - COLUMN_WIDTHS, - CURRENCIES, - AI_PROVIDERS, - DEFAULT_AI_PROMPT, - DEFAULT_AI_CARD_PROMPT, - DEFAULT_AI_FILTER_PROMPT, - PROMPT_CATEGORIES, - PROMPT_LIBRARY, -} from './i18n/locales/ru.data.js' - -import { DEFAULT_AI_PROMPT } from './i18n/locales/ru.data.js' - -export function buildClassifierPrompt(domainText = '') { - if (!domainText || !domainText.trim()) return DEFAULT_AI_PROMPT - return DEFAULT_AI_PROMPT.replace('{domain}', domainText.trim()) -} +// ─── Статические константы UI ───────────────────────────────────────────── +// Языковые данные (валюты, AI-провайдеры, промпты, категории) вынесены в +// ресурсы локали — см. src/i18n/locales/ru.data.js. Здесь остаются только +// не-языковые константы и чистая логика, чтобы компоненты импортировали +// привычные имена из '../data.js' без изменений. + +export const DAY = 86400000 + +// Языковой контент реэкспортируем из словаря локали. +export { + COLUMN_WIDTHS, + CURRENCIES, + AI_PROVIDERS, + DEFAULT_AI_PROMPT, + DEFAULT_AI_CARD_PROMPT, + DEFAULT_AI_FILTER_PROMPT, + PROMPT_CATEGORIES, + PROMPT_LIBRARY, +} from './i18n/locales/ru.data.js' + +import { DEFAULT_AI_PROMPT } from './i18n/locales/ru.data.js' + +export function buildClassifierPrompt(domainText = '') { + if (!domainText || !domainText.trim()) return DEFAULT_AI_PROMPT + return DEFAULT_AI_PROMPT.replace('{domain}', domainText.trim()) +} diff --git a/src/frontend/src/i18n/errors.js b/src/frontend/src/i18n/errors.js index cfafda7..7c14638 100644 --- a/src/frontend/src/i18n/errors.js +++ b/src/frontend/src/i18n/errors.js @@ -1,55 +1,55 @@ -// ─── Локализация ошибок и сетевых сбоев ─────────────────────────────────── -// Бэкенд отдаёт { detail } + HTTP-код (см. src/api.js). Известные статусы и -// типовые сетевые сбои переводим на ключи errors/*; незнакомый текст от бэка -// показываем как есть (перевод известных detail — задача бэка, он отдаёт код). -import { t } from './index.js' - -// HTTP-статус → ключ словаря. -const STATUS_KEYS = { - 400: 'errors.badRequest', - 401: 'errors.unauthorized', - 403: 'errors.forbidden', - 404: 'errors.notFound', - 409: 'errors.conflict', - 422: 'errors.unprocessable', - 429: 'errors.tooManyRequests', - 500: 'errors.server', - 502: 'errors.badGateway', - 503: 'errors.unavailable', - 504: 'errors.timeout', -} - -// Признаки сетевого сбоя в тексте ошибки (fetch/SSE). -const NETWORK_HINTS = ['failed to fetch', 'networkerror', 'load failed', 'network request failed'] -const TIMEOUT_HINTS = ['timeout', 'timed out', 'aborted'] - -function detailText(err) { - if (!err) return '' - if (typeof err === 'string') return err - if (typeof err.detail === 'string') return err.detail - if (typeof err.message === 'string') return err.message - return '' -} - -/** - * Локализует ошибку для показа пользователю. - * @param {unknown} err ошибка (ApiError, Error, строка) - * @param {string} fallbackKey ключ словаря для неизвестного случая - */ -export function localizeError(err, fallbackKey = 'errors.request') { - const detail = detailText(err) - const lower = detail.toLowerCase() - - if (TIMEOUT_HINTS.some((h) => lower.includes(h))) return t('errors.timeout') - if (NETWORK_HINTS.some((h) => lower.includes(h))) return t('errors.network') - - const status = err && typeof err === 'object' ? err.status : null - if (status && STATUS_KEYS[status]) { - // Осмысленное сообщение бэка сохраняем приоритетнее общей формулировки. - if (detail && !/^HTTP \d+$/.test(detail)) return detail - return t(STATUS_KEYS[status]) - } - - if (detail && !/^HTTP \d+$/.test(detail)) return detail - return t(fallbackKey) -} +// ─── Локализация ошибок и сетевых сбоев ─────────────────────────────────── +// Бэкенд отдаёт { detail } + HTTP-код (см. src/api.js). Известные статусы и +// типовые сетевые сбои переводим на ключи errors/*; незнакомый текст от бэка +// показываем как есть (перевод известных detail — задача бэка, он отдаёт код). +import { t } from './index.js' + +// HTTP-статус → ключ словаря. +const STATUS_KEYS = { + 400: 'errors.badRequest', + 401: 'errors.unauthorized', + 403: 'errors.forbidden', + 404: 'errors.notFound', + 409: 'errors.conflict', + 422: 'errors.unprocessable', + 429: 'errors.tooManyRequests', + 500: 'errors.server', + 502: 'errors.badGateway', + 503: 'errors.unavailable', + 504: 'errors.timeout', +} + +// Признаки сетевого сбоя в тексте ошибки (fetch/SSE). +const NETWORK_HINTS = ['failed to fetch', 'networkerror', 'load failed', 'network request failed'] +const TIMEOUT_HINTS = ['timeout', 'timed out', 'aborted'] + +function detailText(err) { + if (!err) return '' + if (typeof err === 'string') return err + if (typeof err.detail === 'string') return err.detail + if (typeof err.message === 'string') return err.message + return '' +} + +/** + * Локализует ошибку для показа пользователю. + * @param {unknown} err ошибка (ApiError, Error, строка) + * @param {string} fallbackKey ключ словаря для неизвестного случая + */ +export function localizeError(err, fallbackKey = 'errors.request') { + const detail = detailText(err) + const lower = detail.toLowerCase() + + if (TIMEOUT_HINTS.some((h) => lower.includes(h))) return t('errors.timeout') + if (NETWORK_HINTS.some((h) => lower.includes(h))) return t('errors.network') + + const status = err && typeof err === 'object' ? err.status : null + if (status && STATUS_KEYS[status]) { + // Осмысленное сообщение бэка сохраняем приоритетнее общей формулировки. + if (detail && !/^HTTP \d+$/.test(detail)) return detail + return t(STATUS_KEYS[status]) + } + + if (detail && !/^HTTP \d+$/.test(detail)) return detail + return t(fallbackKey) +} diff --git a/src/frontend/src/i18n/index.js b/src/frontend/src/i18n/index.js index be3143c..4cd70a4 100644 --- a/src/frontend/src/i18n/index.js +++ b/src/frontend/src/i18n/index.js @@ -1,103 +1,103 @@ -// ─── Ядро локализации ───────────────────────────────────────────────────── -// Лёгкий i18n без внешних зависимостей. Задача этапа — вынести все -// пользовательские строки в словари-ресурсы, чтобы новый язык добавлялся -// подключением словаря, без правок компонентов. -// -// Особенности: -// * `locale` — реактивная ссылка; смена языка перерисует все компоненты, -// которые читают `t()` (в шаблонах — через `$t`). -// * `t(key, params)` — возвращает строку активного языка, подставляет -// параметры вида `{name}`. При отсутствии ключа — фолбэк на ru, затем -// сам ключ (чтобы пропажа текста не роняла экран). -// * `setLocale()` — архитектурно готов; UI-переключателя на этом этапе нет -// (решение владельца: только русский). -import { ref } from 'vue' -import { ru } from './locales/ru.js' - -/** Язык по умолчанию и язык-фолбэк. */ -export const DEFAULT_LOCALE = 'ru' -const FALLBACK_LOCALE = 'ru' - -// Реестр подключённых словарей: код языка → словарь. Новый язык = register. -const dictionaries = { ru } - -export const locale = ref(DEFAULT_LOCALE) - -/** Подключить (или заменить) словарь языка. */ -export function registerLocale(code, dictionary) { - if (!code || typeof dictionary !== 'object' || dictionary === null) return - dictionaries[code] = dictionary -} - -/** Список подключённых языков. */ -export function availableLocales() { - return Object.keys(dictionaries) -} - -/** Есть ли словарь для языка. */ -export function hasLocale(code) { - return Object.prototype.hasOwnProperty.call(dictionaries, code) -} - -/** - * Переключить активный язык. Если словаря нет — остаёмся на прежнем языке - * (и предупреждаем в dev), чтобы фолбэк на ru продолжал работать. - */ -export function setLocale(code) { - if (hasLocale(code)) { - locale.value = code - } else if (import.meta.env?.DEV) { - console.warn(`[i18n] нет словаря для языка «${code}» — остаёмся на «${locale.value}»`) - } - return locale.value -} - -/** Текущий язык. */ -export function getLocale() { - return locale.value -} - -// Достаёт значение по «точечному» ключу ('cards.title') из словаря. -function lookup(dictionary, key) { - return String(key) - .split('.') - .reduce((acc, part) => (acc == null ? undefined : acc[part]), dictionary) -} - -// Подстановка параметров: '{name}' → value. Нет параметра — оставляем как есть. -function interpolate(template, params) { - if (!params) return template - return template.replace(/\{(\w+)\}/g, (match, name) => - Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match, - ) -} - -/** - * Перевод по ключу. Возвращает строку (или не-строку, если в словаре лежит - * объект/массив — так хранятся каталоги). Фолбэк: активный язык → ru → ключ. - */ -export function t(key, params) { - let value = lookup(dictionaries[locale.value], key) - if (value === undefined) value = lookup(dictionaries[FALLBACK_LOCALE], key) - if (value === undefined) return key - return typeof value === 'string' ? interpolate(value, params) : value -} - -/** Доступ из ` - - +import { t } from '@/i18n/index.js' +import { computed, onBeforeUnmount, onMounted, ref } from 'vue' +import { state, orderedCols, colMeta, markAllSeen, totalNew, suggestColumns, fmtTimeHM, spaceOf } from '../store.js' +import { CURRENCIES } from '../data.js' +import Icon from '../components/Icon.vue' +import ContainerColumn from '../components/card/ContainerColumn.vue' + +const now = ref(fmtTimeHM()) +let timer = null + +const weekdayLabel = new Date().toLocaleDateString('ru-RU', { weekday: 'long' }) +const dayMonthLabel = new Date().toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' }) +const leadsTotal = computed(() => state.cards.filter((c) => spaceOf(c.containerId) === 'dashboard').length) + +const focusMeta = computed(() => (state.focus ? colMeta(state.focus) : null)) + +onMounted(() => { + timer = setInterval(() => (now.value = fmtTimeHM()), 5000) +}) +onBeforeUnmount(() => clearInterval(timer)) + + + diff --git a/src/frontend/src/views/LoginView.vue b/src/frontend/src/views/LoginView.vue index 839a59f..acd3228 100644 --- a/src/frontend/src/views/LoginView.vue +++ b/src/frontend/src/views/LoginView.vue @@ -1,99 +1,99 @@ - - +import { t } from '@/i18n/index.js' +import { ref } from 'vue' +import { login, state } from '../store.js' +import Icon from '../components/Icon.vue' + +const username = ref(state.creds.login || 'admin') +const password = ref('') +const busy = ref(false) +const error = ref('') +const shaking = ref(false) + +async function submit() { + if (busy.value) return + busy.value = true + error.value = '' + const ok = await login(username.value, password.value) + busy.value = false + if (ok) return + error.value = t('auth.nevernyj-login-ili-parol') + shaking.value = true + setTimeout(() => (shaking.value = false), 450) +} + + + diff --git a/src/frontend/src/views/MainApp.vue b/src/frontend/src/views/MainApp.vue index 01194d3..94c081f 100644 --- a/src/frontend/src/views/MainApp.vue +++ b/src/frontend/src/views/MainApp.vue @@ -1,80 +1,80 @@ - - - + + + diff --git a/src/frontend/src/views/ProjectsView.vue b/src/frontend/src/views/ProjectsView.vue index 893a455..040d27b 100644 --- a/src/frontend/src/views/ProjectsView.vue +++ b/src/frontend/src/views/ProjectsView.vue @@ -1,47 +1,47 @@ - - - + + + diff --git a/src/frontend/src/views/operator/AuditSection.vue b/src/frontend/src/views/operator/AuditSection.vue index f4390cb..0c27e37 100644 --- a/src/frontend/src/views/operator/AuditSection.vue +++ b/src/frontend/src/views/operator/AuditSection.vue @@ -1,69 +1,69 @@ - - - + + + diff --git a/src/frontend/src/views/operator/LimitsSection.vue b/src/frontend/src/views/operator/LimitsSection.vue index 87a857d..3c3c0b5 100644 --- a/src/frontend/src/views/operator/LimitsSection.vue +++ b/src/frontend/src/views/operator/LimitsSection.vue @@ -1,166 +1,166 @@ - - +import { t } from '@/i18n/index.js' +// Раздел «Лимиты ИИ»: сводка бюджета/расхода по тенантам и правка бюджета/периода. +import { onMounted, ref } from 'vue' +import { fmtDateTimeCompact } from '../../store/core.js' +import { + fmtNum, + loadLimits, + loadTenantLimit, + op, + tenantTone, + updateTenantLimit, +} from '../../store/operator.js' +import Icon from '../../components/Icon.vue' +import Badge from '../../components/ui/Badge.vue' +import BaseModal from '../../components/ui/BaseModal.vue' +import Button from '../../components/ui/Button.vue' +import Card from '../../components/ui/Card.vue' +import DataTable from '../../components/ui/DataTable.vue' +import SelectInput from '../../components/ui/SelectInput.vue' +import TextInput from '../../components/ui/TextInput.vue' +import SectionLayout from '../../components/operator/SectionLayout.vue' + +const PERIOD_LABELS = { month: t('operator.mesyac'), day: t('operator.den') } +const PERIOD_OPTIONS = [ + { value: 'month', label: t('operator.mesyac') }, + { value: 'day', label: t('operator.den') }, +] + +const COLUMNS = [ + { key: 'name', label: t('operator.tenant') }, + { key: 'status', label: t('operator.status') }, + { key: 'period', label: t('operator.period') }, + { key: 'budget', label: t('operator.byudzhet'), align: 'right' }, + { key: 'used', label: t('operator.rashod'), align: 'right' }, + { key: 'percent', label: t('operator.ispolzovano'), width: '180px' }, + { key: 'actions', label: '', align: 'right', width: '120px' }, +] + +const editOpen = ref(false) +const editBusy = ref(false) +const editForm = ref({ budget: '0', period: 'month' }) + +onMounted(loadLimits) + +async function openEdit(row) { + editOpen.value = true + await loadTenantLimit(row.tenantId) + if (op.limit) { + editForm.value = { budget: String(op.limit.budget), period: op.limit.period } + } +} + +async function submitEdit() { + if (!op.limit || editBusy.value) return + const budget = Number(editForm.value.budget) + if (!Number.isFinite(budget) || budget < 0) return + editBusy.value = true + const r = await updateTenantLimit(op.limit.tenantId, { + budget, + period: editForm.value.period, + }) + editBusy.value = false + if (r) editOpen.value = false +} + +const percentTone = (v) => (v >= 100 ? 'bg-danger/70' : v >= 80 ? 'bg-warn/70' : 'bg-brand/70') + + + diff --git a/src/frontend/src/views/operator/OperatorLogin.vue b/src/frontend/src/views/operator/OperatorLogin.vue index e5125c2..dd80900 100644 --- a/src/frontend/src/views/operator/OperatorLogin.vue +++ b/src/frontend/src/views/operator/OperatorLogin.vue @@ -1,89 +1,89 @@ - - +import { t } from '@/i18n/index.js' +// Вход оператора: отдельная ручка /api/operator/auth/login и отдельная кука. +// Стилистика — как у пользовательского входа, но без «радарных» колец. +import { ref } from 'vue' +import { navigate } from '../../router.js' +import { operatorLogin } from '../../store/operator.js' +import Icon from '../../components/Icon.vue' + +const username = ref('operator') +const password = ref('') +const busy = ref(false) +const error = ref('') + +async function submit() { + if (busy.value) return + busy.value = true + error.value = '' + const ok = await operatorLogin(username.value, password.value) + busy.value = false + if (!ok) error.value = t('operator.nevernyj-login-ili-parol-operatora') +} + + + diff --git a/src/frontend/src/views/operator/TelegramSection.vue b/src/frontend/src/views/operator/TelegramSection.vue index b8e0025..7299776 100644 --- a/src/frontend/src/views/operator/TelegramSection.vue +++ b/src/frontend/src/views/operator/TelegramSection.vue @@ -1,103 +1,103 @@ - - - + + + diff --git a/src/frontend/vite.config.js b/src/frontend/vite.config.js index 1a1f06c..d092228 100644 --- a/src/frontend/vite.config.js +++ b/src/frontend/vite.config.js @@ -1,41 +1,41 @@ -import { fileURLToPath, URL } from 'node:url' -import { defineConfig } from 'vite' -import vue from '@vitejs/plugin-vue' -import tailwindcss from '@tailwindcss/vite' - -export default defineConfig({ - plugins: [vue(), tailwindcss()], - resolve: { - // '@' → src: единый импорт i18n (t/useI18n) из любого уровня вложенности. - alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, - }, - build: { - rollupOptions: { - output: { - // Разбиение бандла на стабильные кешируемые чанки: - // vendor — код из node_modules (vue и т.п.), меняется только при апдейте зависимостей; - // i18n — словари локалей (крупный, но статический ресурс, меняется вместе с текстами); - // app — код приложения (default-чанк). - // Словарь остаётся в статическом графе импортов (нужен на первом рендере), - // вынос в отдельный чанк не делает его ленивым — приложение просто грузит - // его параллельно основному чанку. - manualChunks(id) { - if (!id.includes('node_modules') && /src[\\/]i18n[\\/]locales[\\/]/.test(id)) return 'i18n' - if (id.includes('node_modules')) return 'vendor' - return undefined - }, - }, - }, - }, - server: { - port: 5173, - host: true, - proxy: { - // в dev фронт живёт на :5173, core API (deal-core) — на :5080; кука сессии проходит через прокси - '/api': { - target: 'http://localhost:5080', - changeOrigin: true, - }, - }, - }, -}) +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [vue(), tailwindcss()], + resolve: { + // '@' → src: единый импорт i18n (t/useI18n) из любого уровня вложенности. + alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, + }, + build: { + rollupOptions: { + output: { + // Разбиение бандла на стабильные кешируемые чанки: + // vendor — код из node_modules (vue и т.п.), меняется только при апдейте зависимостей; + // i18n — словари локалей (крупный, но статический ресурс, меняется вместе с текстами); + // app — код приложения (default-чанк). + // Словарь остаётся в статическом графе импортов (нужен на первом рендере), + // вынос в отдельный чанк не делает его ленивым — приложение просто грузит + // его параллельно основному чанку. + manualChunks(id) { + if (!id.includes('node_modules') && /src[\\/]i18n[\\/]locales[\\/]/.test(id)) return 'i18n' + if (id.includes('node_modules')) return 'vendor' + return undefined + }, + }, + }, + }, + server: { + port: 5173, + host: true, + proxy: { + // в dev фронт живёт на :5173, core API (deal-core) — на :5080; кука сессии проходит через прокси + '/api': { + target: 'http://localhost:5080', + changeOrigin: true, + }, + }, + }, +}) diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj b/src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj index eb4c788..b539560 100644 --- a/src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj +++ b/src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj @@ -1,54 +1,54 @@ - - - - - net10.0 - latest - enable - enable - true - latest - true - Deal.Grpc.Hosting - Deal.Grpc.Hosting - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + net10.0 + latest + enable + enable + true + latest + true + Deal.Grpc.Hosting + Deal.Grpc.Hosting + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Models/MtlsCertificates.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Models/MtlsCertificates.cs index 96ab05b..d177a9b 100644 --- a/src/grpc-hosting/Deal.Grpc.Hosting/Models/MtlsCertificates.cs +++ b/src/grpc-hosting/Deal.Grpc.Hosting/Models/MtlsCertificates.cs @@ -1,234 +1,234 @@ -using System.Net.Security; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using Deal.Grpc.Hosting.Interceptors; -using Deal.Grpc.Hosting.Options; -using Deal.Grpc.Hosting.Services; - -namespace Deal.Grpc.Hosting.Models; - -/// -/// Загруженный набор сертификатов mTLS внутреннего gRPC. -/// -public sealed class MtlsCertificates -{ - // Роль в сообщениях об ошибках: CA-сертификат (проверка второй стороны). - private const string CaRoleName = "CA-сертификат (проверка второй стороны)"; - - // Роль в сообщениях об ошибках: серверный сертификат Kestrel-gRPC. - private const string ServerRoleName = "серверный сертификат Kestrel-gRPC процесса"; - - // Роль в сообщениях об ошибках: клиентский сертификат исходящих каналов. - private const string ClientRoleName = "клиентский сертификат исходящих каналов (deal-client)"; - - private MtlsCertificates( - X509Certificate2 caCertificate, - X509Certificate2 serverCertificate, - X509Certificate2 clientCertificate) - { - CaCertificate = caCertificate; - ServerCertificate = serverCertificate; - ClientCertificate = clientCertificate; - } - - /// - /// CA-сертификат из CaPem - /// - public X509Certificate2 CaCertificate { get; } - - /// - /// Серверный сертификат процесса из PFX - /// - public X509Certificate2 ServerCertificate { get; } - - /// - /// Клиентский сертификат из PFX - /// - public X509Certificate2 ClientCertificate { get; } - - /// - /// Загружает сертификаты из - /// - /// Опции mTLS (env DEAL_MTLS_*). - /// Набор сертификатов либо null (флаг выключен). - /// Флаг включён, а путь не задан/файл не найден/не читается. - public static MtlsCertificates? Load(MtlsOptions options) - { - ArgumentNullException.ThrowIfNull(options); - if (!options.Enabled) - { - return null; - } - - X509Certificate2 ca = LoadCaFromPem(options); - X509Certificate2 server = LoadPfx(options.ServerCertPfx, options.ServerCertPassword, MtlsOptions.ServerCertPfxEnvKey, MtlsOptions.ServerCertPasswordEnvKey, ServerRoleName); - X509Certificate2 client = LoadPfx(options.ClientCertPfx, options.ClientCertPassword, MtlsOptions.ClientCertPfxEnvKey, MtlsOptions.ClientCertPasswordEnvKey, ClientRoleName); - return new MtlsCertificates(ca, server, client); - } - - /// - /// Серверная проверка клиентского сертификата для Kestrel - /// - /// Клиентский сертификат из рукопожатия (null — RequireCertificate не выполнен). - /// Цепочка стандартной проверки (игнорируется — пересобирается на нашу CA). - /// Ошибки стандартной проверки TLS. - public bool ValidateClientCertificate( - X509Certificate2? certificate, - X509Chain? chain, - SslPolicyErrors sslPolicyErrors) - { - if (certificate is null) - { - return false; - } - - if (sslPolicyErrors == SslPolicyErrors.None) - { - return true; - } - - if (sslPolicyErrors == SslPolicyErrors.RemoteCertificateChainErrors) - { - return IsTrustedByCa(certificate); - } - - return false; - } - - /// - /// Создаёт HTTP/2-хендлер исходящего канала - /// - /// Новый SocketsHttpHandler (владелец — создатель; канал GrpcChannel закроет его вместе с собой). - public SocketsHttpHandler CreateClientHttpHandler() - { - var handler = new SocketsHttpHandler - { - SslOptions = new SslClientAuthenticationOptions - { - ClientCertificates = new X509CertificateCollection { ClientCertificate }, - RemoteCertificateValidationCallback = ValidateServerCertificate, - }, - }; - return handler; - } - - // Клиентская проверка сертификата сервера (RemoteCertificateValidationCallback): имя из SAN + - // цепочка до нашей CA; сертификаты не нашей CA/чужое имя — отказ. - // sender: Отправитель (не используется). - // certificate: Сертификат сервера из рукопожатия. - // chain: Цепочка стандартной проверки (игнорируется — пересобирается на нашу CA). - // sslPolicyErrors: Ошибки стандартной проверки TLS. - private bool ValidateServerCertificate( - object? sender, - X509Certificate? certificate, - X509Chain? chain, - SslPolicyErrors sslPolicyErrors) - { - if (certificate is null) - { - return false; - } - - if (sslPolicyErrors == SslPolicyErrors.None) - { - return true; - } - - // Имя хоста проверяется отдельно от доверия: несовпадение SAN (подключились не к тому сервису) — - // безусловный отказ, даже если цепочка сошлась бы на нашу CA. - if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateNameMismatch) != 0 - || (sslPolicyErrors & SslPolicyErrors.RemoteCertificateNotAvailable) != 0) - { - return false; - } - - if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateChainErrors) != 0) - { - using var leaf = new X509Certificate2(certificate); - return IsTrustedByCa(leaf); - } - - return false; - } - - // Строит цепочку candidate → наша CA (CustomRootTrust, без revocation) — признак «свой» сертификат. - // candidate: Проверяемый сертификат второй стороны. - private bool IsTrustedByCa(X509Certificate2 candidate) - { - using var chain = new X509Chain(); - chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; - chain.ChainPolicy.CustomTrustStore.Add(CaCertificate); - chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; - return chain.Build(candidate); - } - - // Читает CA из PEM/DER (только публичный сертификат — ключ CA нужен лишь скрипту генерации). - // options: Опции mTLS. - private static X509Certificate2 LoadCaFromPem(MtlsOptions options) - { - string path = RequireExistingFile(options.CaPem, MtlsOptions.CaPemEnvKey, CaRoleName); - try - { - return X509CertificateLoader.LoadCertificateFromFile(path); - } - catch (CryptographicException exception) - { - throw new InvalidOperationException( - $"{CaRoleName} ({MtlsOptions.CaPemEnvKey}): не удалось прочитать \"{path}\" — ожидается PEM/DER X.509.", - exception); - } - } - - // Читает PFX (серверный/клиентский) с паролем; EphemeralKeySet — ключ не оседает в хранилище ОС. - // configuredPath: Путь из env. - // password: Пароль PFX. - // pathEnvKey: Env-ключ пути (для сообщения об ошибке). - // passwordEnvKey: Env-ключ пароля (для сообщения об ошибке). - // role: Роль сертификата (для сообщения об ошибке). - private static X509Certificate2 LoadPfx( - string configuredPath, - string password, - string pathEnvKey, - string passwordEnvKey, - string role) - { - string path = RequireExistingFile(configuredPath, pathEnvKey, role); - try - { - return X509CertificateLoader.LoadPkcs12FromFile( - path, - password, - X509KeyStorageFlags.EphemeralKeySet); - } - catch (CryptographicException exception) - { - throw new InvalidOperationException( - $"{role} ({pathEnvKey}): не удалось открыть \"{path}\" — проверьте путь и пароль ({passwordEnvKey}).", - exception); - } - } - - // Fail-fast: путь обязан быть задан и указывать на существующий файл. - // configuredPath: Путь из env. - // envKey: Env-ключ пути (для сообщения об ошибке). - // role: Роль сертификата (для сообщения об ошибке). - private static string RequireExistingFile( - string configuredPath, - string envKey, - string role) - { - if (string.IsNullOrWhiteSpace(configuredPath)) - { - throw new InvalidOperationException( - $"mTLS включён (DEAL_MTLS_ENABLED=1), но не задан путь {role}: env {envKey}."); - } - - string path = configuredPath.Trim(); - if (!File.Exists(path)) - { - throw new InvalidOperationException($"{role} ({envKey}): файл не найден \"{path}\"."); - } - - return path; - } -} +using System.Net.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Deal.Grpc.Hosting.Interceptors; +using Deal.Grpc.Hosting.Options; +using Deal.Grpc.Hosting.Services; + +namespace Deal.Grpc.Hosting.Models; + +/// +/// Загруженный набор сертификатов mTLS внутреннего gRPC. +/// +public sealed class MtlsCertificates +{ + // Роль в сообщениях об ошибках: CA-сертификат (проверка второй стороны). + private const string CaRoleName = "CA-сертификат (проверка второй стороны)"; + + // Роль в сообщениях об ошибках: серверный сертификат Kestrel-gRPC. + private const string ServerRoleName = "серверный сертификат Kestrel-gRPC процесса"; + + // Роль в сообщениях об ошибках: клиентский сертификат исходящих каналов. + private const string ClientRoleName = "клиентский сертификат исходящих каналов (deal-client)"; + + private MtlsCertificates( + X509Certificate2 caCertificate, + X509Certificate2 serverCertificate, + X509Certificate2 clientCertificate) + { + CaCertificate = caCertificate; + ServerCertificate = serverCertificate; + ClientCertificate = clientCertificate; + } + + /// + /// CA-сертификат из CaPem + /// + public X509Certificate2 CaCertificate { get; } + + /// + /// Серверный сертификат процесса из PFX + /// + public X509Certificate2 ServerCertificate { get; } + + /// + /// Клиентский сертификат из PFX + /// + public X509Certificate2 ClientCertificate { get; } + + /// + /// Загружает сертификаты из + /// + /// Опции mTLS (env DEAL_MTLS_*). + /// Набор сертификатов либо null (флаг выключен). + /// Флаг включён, а путь не задан/файл не найден/не читается. + public static MtlsCertificates? Load(MtlsOptions options) + { + ArgumentNullException.ThrowIfNull(options); + if (!options.Enabled) + { + return null; + } + + X509Certificate2 ca = LoadCaFromPem(options); + X509Certificate2 server = LoadPfx(options.ServerCertPfx, options.ServerCertPassword, MtlsOptions.ServerCertPfxEnvKey, MtlsOptions.ServerCertPasswordEnvKey, ServerRoleName); + X509Certificate2 client = LoadPfx(options.ClientCertPfx, options.ClientCertPassword, MtlsOptions.ClientCertPfxEnvKey, MtlsOptions.ClientCertPasswordEnvKey, ClientRoleName); + return new MtlsCertificates(ca, server, client); + } + + /// + /// Серверная проверка клиентского сертификата для Kestrel + /// + /// Клиентский сертификат из рукопожатия (null — RequireCertificate не выполнен). + /// Цепочка стандартной проверки (игнорируется — пересобирается на нашу CA). + /// Ошибки стандартной проверки TLS. + public bool ValidateClientCertificate( + X509Certificate2? certificate, + X509Chain? chain, + SslPolicyErrors sslPolicyErrors) + { + if (certificate is null) + { + return false; + } + + if (sslPolicyErrors == SslPolicyErrors.None) + { + return true; + } + + if (sslPolicyErrors == SslPolicyErrors.RemoteCertificateChainErrors) + { + return IsTrustedByCa(certificate); + } + + return false; + } + + /// + /// Создаёт HTTP/2-хендлер исходящего канала + /// + /// Новый SocketsHttpHandler (владелец — создатель; канал GrpcChannel закроет его вместе с собой). + public SocketsHttpHandler CreateClientHttpHandler() + { + var handler = new SocketsHttpHandler + { + SslOptions = new SslClientAuthenticationOptions + { + ClientCertificates = new X509CertificateCollection { ClientCertificate }, + RemoteCertificateValidationCallback = ValidateServerCertificate, + }, + }; + return handler; + } + + // Клиентская проверка сертификата сервера (RemoteCertificateValidationCallback): имя из SAN + + // цепочка до нашей CA; сертификаты не нашей CA/чужое имя — отказ. + // sender: Отправитель (не используется). + // certificate: Сертификат сервера из рукопожатия. + // chain: Цепочка стандартной проверки (игнорируется — пересобирается на нашу CA). + // sslPolicyErrors: Ошибки стандартной проверки TLS. + private bool ValidateServerCertificate( + object? sender, + X509Certificate? certificate, + X509Chain? chain, + SslPolicyErrors sslPolicyErrors) + { + if (certificate is null) + { + return false; + } + + if (sslPolicyErrors == SslPolicyErrors.None) + { + return true; + } + + // Имя хоста проверяется отдельно от доверия: несовпадение SAN (подключились не к тому сервису) — + // безусловный отказ, даже если цепочка сошлась бы на нашу CA. + if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateNameMismatch) != 0 + || (sslPolicyErrors & SslPolicyErrors.RemoteCertificateNotAvailable) != 0) + { + return false; + } + + if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateChainErrors) != 0) + { + using var leaf = new X509Certificate2(certificate); + return IsTrustedByCa(leaf); + } + + return false; + } + + // Строит цепочку candidate → наша CA (CustomRootTrust, без revocation) — признак «свой» сертификат. + // candidate: Проверяемый сертификат второй стороны. + private bool IsTrustedByCa(X509Certificate2 candidate) + { + using var chain = new X509Chain(); + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.CustomTrustStore.Add(CaCertificate); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + return chain.Build(candidate); + } + + // Читает CA из PEM/DER (только публичный сертификат — ключ CA нужен лишь скрипту генерации). + // options: Опции mTLS. + private static X509Certificate2 LoadCaFromPem(MtlsOptions options) + { + string path = RequireExistingFile(options.CaPem, MtlsOptions.CaPemEnvKey, CaRoleName); + try + { + return X509CertificateLoader.LoadCertificateFromFile(path); + } + catch (CryptographicException exception) + { + throw new InvalidOperationException( + $"{CaRoleName} ({MtlsOptions.CaPemEnvKey}): не удалось прочитать \"{path}\" — ожидается PEM/DER X.509.", + exception); + } + } + + // Читает PFX (серверный/клиентский) с паролем; EphemeralKeySet — ключ не оседает в хранилище ОС. + // configuredPath: Путь из env. + // password: Пароль PFX. + // pathEnvKey: Env-ключ пути (для сообщения об ошибке). + // passwordEnvKey: Env-ключ пароля (для сообщения об ошибке). + // role: Роль сертификата (для сообщения об ошибке). + private static X509Certificate2 LoadPfx( + string configuredPath, + string password, + string pathEnvKey, + string passwordEnvKey, + string role) + { + string path = RequireExistingFile(configuredPath, pathEnvKey, role); + try + { + return X509CertificateLoader.LoadPkcs12FromFile( + path, + password, + X509KeyStorageFlags.EphemeralKeySet); + } + catch (CryptographicException exception) + { + throw new InvalidOperationException( + $"{role} ({pathEnvKey}): не удалось открыть \"{path}\" — проверьте путь и пароль ({passwordEnvKey}).", + exception); + } + } + + // Fail-fast: путь обязан быть задан и указывать на существующий файл. + // configuredPath: Путь из env. + // envKey: Env-ключ пути (для сообщения об ошибке). + // role: Роль сертификата (для сообщения об ошибке). + private static string RequireExistingFile( + string configuredPath, + string envKey, + string role) + { + if (string.IsNullOrWhiteSpace(configuredPath)) + { + throw new InvalidOperationException( + $"mTLS включён (DEAL_MTLS_ENABLED=1), но не задан путь {role}: env {envKey}."); + } + + string path = configuredPath.Trim(); + if (!File.Exists(path)) + { + throw new InvalidOperationException($"{role} ({envKey}): файл не найден \"{path}\"."); + } + + return path; + } +} diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Services/GrpcHostEnvironment.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Services/GrpcHostEnvironment.cs index 0b0df34..a824146 100644 --- a/src/grpc-hosting/Deal.Grpc.Hosting/Services/GrpcHostEnvironment.cs +++ b/src/grpc-hosting/Deal.Grpc.Hosting/Services/GrpcHostEnvironment.cs @@ -1,66 +1,66 @@ -using Deal.Grpc.Hosting.Interceptors; -using Deal.Grpc.Hosting.Models; -using Deal.Grpc.Hosting.Options; - -namespace Deal.Grpc.Hosting.Services; - -/// -/// Общие стартовые проверки/разбор env для Program.cs Deal-сервисов -/// -public static class GrpcHostEnvironment -{ - // Env-ключ порта gRPC (контейнер). - private const string GrpcPortEnvVarName = "GRPC_PORT"; - - // Env-ключ порта (общий конвенциональный env хостинг-платформ). - private const string PortEnvVarName = "PORT"; - - // Env-ключ окружения ASP.NET Core (fail-closed mTLS: Production требует DEAL_MTLS_ENABLED=1). - private const string AspNetCoreEnvironmentVarName = "ASPNETCORE_ENVIRONMENT"; - - // Значение окружения Production (строгое сравнение — см. IsProductionEnvironment). - private const string ProductionEnvironmentValue = "Production"; - - // Текст отказа fail-closed: Production требует mTLS (сертификаты deploy/certs, scripts/mtls-certs.sh). - private const string MtlsRequiredInProductionDetail = - "Production требует mTLS: задайте DEAL_MTLS_ENABLED=1 и env DEAL_MTLS_* (сертификаты deploy/certs, генерация — scripts/mtls-certs.sh)"; - - /// - /// Порт Kestrel процесса - /// - /// Дефолтный порт сервиса. - public static int ResolveGrpcPort(int defaultPort) - => ParsePort(Environment.GetEnvironmentVariable(GrpcPortEnvVarName)) - ?? ParsePort(Environment.GetEnvironmentVariable(PortEnvVarName)) - ?? defaultPort; - - /// - /// Парсит порт из env-строки; пустое/нечисловое значение — null - /// - /// Сырое значение env. - public static int? ParsePort(string? rawValue) - => int.TryParse(rawValue, out int parsedPort) ? parsedPort : null; - - /// - /// True — окружение Production - /// - public static bool IsProductionEnvironment() - => string.Equals( - Environment.GetEnvironmentVariable(AspNetCoreEnvironmentVarName), - ProductionEnvironmentValue, - StringComparison.OrdinalIgnoreCase); - - /// - /// Fail-closed-гард транспорта - /// - /// Опции mTLS процесса (из env DEAL_MTLS_*). - /// Production без mTLS. - public static void RequireMtlsInProduction(MtlsOptions mtlsOptions) - { - ArgumentNullException.ThrowIfNull(mtlsOptions); - if (IsProductionEnvironment() && !mtlsOptions.Enabled) - { - throw new InvalidOperationException(MtlsRequiredInProductionDetail); - } - } -} +using Deal.Grpc.Hosting.Interceptors; +using Deal.Grpc.Hosting.Models; +using Deal.Grpc.Hosting.Options; + +namespace Deal.Grpc.Hosting.Services; + +/// +/// Общие стартовые проверки/разбор env для Program.cs Deal-сервисов +/// +public static class GrpcHostEnvironment +{ + // Env-ключ порта gRPC (контейнер). + private const string GrpcPortEnvVarName = "GRPC_PORT"; + + // Env-ключ порта (общий конвенциональный env хостинг-платформ). + private const string PortEnvVarName = "PORT"; + + // Env-ключ окружения ASP.NET Core (fail-closed mTLS: Production требует DEAL_MTLS_ENABLED=1). + private const string AspNetCoreEnvironmentVarName = "ASPNETCORE_ENVIRONMENT"; + + // Значение окружения Production (строгое сравнение — см. IsProductionEnvironment). + private const string ProductionEnvironmentValue = "Production"; + + // Текст отказа fail-closed: Production требует mTLS (сертификаты deploy/certs, scripts/mtls-certs.sh). + private const string MtlsRequiredInProductionDetail = + "Production требует mTLS: задайте DEAL_MTLS_ENABLED=1 и env DEAL_MTLS_* (сертификаты deploy/certs, генерация — scripts/mtls-certs.sh)"; + + /// + /// Порт Kestrel процесса + /// + /// Дефолтный порт сервиса. + public static int ResolveGrpcPort(int defaultPort) + => ParsePort(Environment.GetEnvironmentVariable(GrpcPortEnvVarName)) + ?? ParsePort(Environment.GetEnvironmentVariable(PortEnvVarName)) + ?? defaultPort; + + /// + /// Парсит порт из env-строки; пустое/нечисловое значение — null + /// + /// Сырое значение env. + public static int? ParsePort(string? rawValue) + => int.TryParse(rawValue, out int parsedPort) ? parsedPort : null; + + /// + /// True — окружение Production + /// + public static bool IsProductionEnvironment() + => string.Equals( + Environment.GetEnvironmentVariable(AspNetCoreEnvironmentVarName), + ProductionEnvironmentValue, + StringComparison.OrdinalIgnoreCase); + + /// + /// Fail-closed-гард транспорта + /// + /// Опции mTLS процесса (из env DEAL_MTLS_*). + /// Production без mTLS. + public static void RequireMtlsInProduction(MtlsOptions mtlsOptions) + { + ArgumentNullException.ThrowIfNull(mtlsOptions); + if (IsProductionEnvironment() && !mtlsOptions.Enabled) + { + throw new InvalidOperationException(MtlsRequiredInProductionDetail); + } + } +} diff --git a/src/ml-service/Deal.Ml.Tests/AssemblyInfo.cs b/src/ml-service/Deal.Ml.Tests/AssemblyInfo.cs index ec64fbf..f57a7d8 100644 --- a/src/ml-service/Deal.Ml.Tests/AssemblyInfo.cs +++ b/src/ml-service/Deal.Ml.Tests/AssemblyInfo.cs @@ -1,5 +1,5 @@ -// Интеграционные тесты ml-service поднимают реальные Kestrel-хосты и меняют процесс-глобальные -// env-переменные (DEAL_SERVICE_TOKEN/DEAL_ML_DATA_DIR) на время сценария (MlTestHost и -// MlServiceHostTests). Параллельный прогон классов дал бы гонки на env — тесты сериализованы -// (тот же шаблон, что AssemblyInfo тестов telegram-service). -[assembly: CollectionBehavior(DisableTestParallelization = true)] +// Интеграционные тесты ml-service поднимают реальные Kestrel-хосты и меняют процесс-глобальные +// env-переменные (DEAL_SERVICE_TOKEN/DEAL_ML_DATA_DIR) на время сценария (MlTestHost и +// MlServiceHostTests). Параллельный прогон классов дал бы гонки на env — тесты сериализованы +// (тот же шаблон, что AssemblyInfo тестов telegram-service). +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/src/ml-service/Deal.Ml.Tests/Deal.Ml.Tests.csproj b/src/ml-service/Deal.Ml.Tests/Deal.Ml.Tests.csproj index b2b892c..0a944df 100644 --- a/src/ml-service/Deal.Ml.Tests/Deal.Ml.Tests.csproj +++ b/src/ml-service/Deal.Ml.Tests/Deal.Ml.Tests.csproj @@ -1,45 +1,45 @@ - - - - - false - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ml-service/Deal.Ml.Tests/Grpc/MlRpcTests.cs b/src/ml-service/Deal.Ml.Tests/Grpc/MlRpcTests.cs index 21fe9c8..de5a065 100644 --- a/src/ml-service/Deal.Ml.Tests/Grpc/MlRpcTests.cs +++ b/src/ml-service/Deal.Ml.Tests/Grpc/MlRpcTests.cs @@ -1,281 +1,281 @@ -using Deal.Grpc.Ml; -using Deal.Ml.Tests.Ml; -using Grpc.Core; - -namespace Deal.Ml.Tests.Grpc; - -/// -/// In-proc gRPC-тесты MlService -/// -public sealed class MlRpcTests -{ - /// - /// Свежий тенант: Status — пустой ответ, Predict — фиксированный «не уверен». - /// - [Fact] - public async Task FreshTenant_StatusAndPredictReturnNotReadyShape() - { - await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => - { - var client = new MlService.MlServiceClient(channel); - CallOptions options = Options(MlTestHost.DefaultTenantId); - - StatusReply status = await client.StatusAsync(new StatusRequest(), options); - - Assert.False(status.Ready); - Assert.Empty(status.Classes); - Assert.Equal(0, status.Learned); - Assert.Equal(0, status.Eval.Count); - Assert.Equal(0.0, status.Eval.Accuracy); - - PredictReply predict = await client.PredictAsync( - new PredictRequest { Text = LearningData.DevMessage }, - options); - - Assert.False(predict.Take); - Assert.False(predict.HasLabel); - Assert.False(predict.Ready); - Assert.Empty(predict.Scores); - Assert.Equal(0, predict.Hits); - Assert.False(predict.HasMargin); - Assert.Empty(predict.Terms); - Assert.Null(predict.Type); - }); - } - - /// - /// TrainBatch батчем из 3 → learned=3; модель остаётся неготовой - /// - [Fact] - public async Task TrainBatch_ThreeItems_LearnsThree() - { - await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => - { - var client = new MlService.MlServiceClient(channel); - CallOptions options = Options(MlTestHost.DefaultTenantId); - - var request = new TrainBatchRequest(); - request.Items.Add(new TrainExample { Text = LearningData.DevMessage, Label = LearningData.ColumnDev, Delta = 1.0 }); - request.Items.Add(new TrainExample { Text = LearningData.DevMessage, Label = LearningData.ColumnDev, Delta = 1.0 }); - request.Items.Add(new TrainExample { Text = LearningData.SpamMessage, Label = LearningData.SpamLabel, Delta = 1.0 }); - - TrainBatchReply reply = await client.TrainBatchAsync(request, options); - - Assert.Equal(3, reply.Learned); - - StatusReply status = await client.StatusAsync(new StatusRequest(), options); - Assert.False(status.Ready); - Assert.Equal(3, status.Learned); - }); - } - - /// - /// Полный цикл: канонический батч → Status ready/learned → Predict колонки + типа. - /// - [Fact] - public async Task TrainBatch_FullCanonical_ThenStatusAndPredict() - { - await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => - { - var client = new MlService.MlServiceClient(channel); - CallOptions options = Options(MlTestHost.DefaultTenantId); - - TrainBatchReply batch = await client.TrainBatchAsync( - new TrainBatchRequest { Items = { LearningData.CanonicalTrainItems().Select(ToExample) } }, - options); - Assert.Equal(28, batch.Learned); - - StatusReply status = await client.StatusAsync(new StatusRequest(), options); - Assert.True(status.Ready); - Assert.Equal(28, status.Learned); - Assert.Equal(6.0, status.Classes[LearningData.SpamLabel]); - - PredictReply dev = await client.PredictAsync( - new PredictRequest { Text = LearningData.DevMessage }, - options); - Assert.True(dev.Take); - Assert.Equal(LearningData.ColumnDev, dev.Label); - Assert.True(dev.HasMargin); - Assert.NotNull(dev.Type); - Assert.Equal("hire", dev.Type.Label); - - PredictReply spam = await client.PredictAsync( - new PredictRequest { Text = LearningData.SpamMessage }, - options); - Assert.True(spam.Take); - Assert.Equal(LearningData.SpamLabel, spam.Label); - Assert.Null(spam.Type); - }); - } - - /// - /// Reset обнуляет модель тенанта - /// - [Fact] - public async Task Reset_AfterTraining_EmptiesModel() - { - await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => - { - var client = new MlService.MlServiceClient(channel); - CallOptions options = Options(MlTestHost.DefaultTenantId); - - await client.TrainBatchAsync( - new TrainBatchRequest { Items = { LearningData.CanonicalTrainItems().Select(ToExample) } }, - options); - - ResetReply reset = await client.ResetAsync(new ResetRequest(), options); - - Assert.True(reset.Ok); - Assert.False(reset.HasError); - - StatusReply status = await client.StatusAsync(new StatusRequest(), options); - Assert.False(status.Ready); - Assert.Empty(status.Classes); - Assert.Equal(0, status.Learned); - - PredictReply predict = await client.PredictAsync( - new PredictRequest { Text = LearningData.DevMessage }, - options); - Assert.False(predict.Take); - Assert.False(predict.Ready); - }); - } - - /// - /// Изоляция тенантов - /// - [Fact] - public async Task Tenants_AreIsolated() - { - await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => - { - var client = new MlService.MlServiceClient(channel); - - await client.TrainBatchAsync( - new TrainBatchRequest { Items = { LearningData.CanonicalTrainItems().Select(ToExample) } }, - Options("tenant-a")); - - StatusReply trained = await client.StatusAsync(new StatusRequest(), Options("tenant-a")); - Assert.True(trained.Ready); - Assert.Equal(28, trained.Learned); - - StatusReply untouched = await client.StatusAsync(new StatusRequest(), Options("tenant-b")); - Assert.False(untouched.Ready); - Assert.Equal(0, untouched.Learned); - - PredictReply predictB = await client.PredictAsync( - new PredictRequest { Text = LearningData.DevMessage }, - Options("tenant-b")); - Assert.False(predictB.Take); - Assert.False(predictB.Ready); - }); - } - - /// - /// Отсутствующий tenant-id в metadata → UNAUTHENTICATED. - /// - [Fact] - public async Task Status_WithoutTenantId_IsUnauthenticated() - { - await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => - { - var client = new MlService.MlServiceClient(channel); - Metadata metadata = MlTestHost.CallMetadata(MlTestHost.DefaultToken, tenantId: null); - - AsyncUnaryCall call = client.StatusAsync(new StatusRequest(), MlTestHost.CallOptions(metadata)); - RpcException exception = await Assert.ThrowsAsync(() => call.ResponseAsync); - - Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); - }); - } - - /// - /// Tenant-id с недопустимыми символами пути → INVALID_ARGUMENT - /// - [Fact] - public async Task Status_WithPathTraversalTenantId_IsInvalidArgument() - { - await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => - { - var client = new MlService.MlServiceClient(channel); - - AsyncUnaryCall call = client.StatusAsync( - new StatusRequest(), - MlTestHost.CallOptions(MlTestHost.CallMetadata(MlTestHost.DefaultToken, tenantId: "../escape"))); - RpcException exception = await Assert.ThrowsAsync(() => call.ResponseAsync); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - }); - } - - /// - /// Пустые text/label в батче пропускаются - /// - [Fact] - public async Task TrainBatch_EmptyItems_SkippedQuietly() - { - await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => - { - var client = new MlService.MlServiceClient(channel); - var request = new TrainBatchRequest(); - request.Items.Add(new TrainExample { Text = string.Empty, Label = LearningData.ColumnDev, Delta = 1.0 }); - request.Items.Add(new TrainExample { Text = LearningData.DevMessage, Label = " ", Delta = 1.0 }); - request.Items.Add(new TrainExample { Text = LearningData.DevMessage, Label = LearningData.ColumnDev, Delta = 1.0 }); - - TrainBatchReply reply = await client.TrainBatchAsync(request, Options(MlTestHost.DefaultTenantId)); - - Assert.Equal(1, reply.Learned); - }); - } - - /// - /// Серверный лимит батча - /// - [Fact] - public async Task TrainBatch_OverBatchLimit_IsInvalidArgument() - { - await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => - { - var client = new MlService.MlServiceClient(channel); - var request = new TrainBatchRequest(); - for (int i = 0; i < 101; i++) - { - request.Items.Add(new TrainExample { Text = LearningData.DevMessage, Label = LearningData.ColumnDev, Delta = 1.0 }); - } - - RpcException exception = await Assert.ThrowsAsync(() => - client.TrainBatchAsync(request, Options(MlTestHost.DefaultTenantId)).ResponseAsync); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - }); - } - - /// - /// Серверный лимит длины текста примера - /// - [Fact] - public async Task TrainBatch_TooLongExampleText_IsInvalidArgument() - { - await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => - { - var client = new MlService.MlServiceClient(channel); - var request = new TrainBatchRequest(); - request.Items.Add(new TrainExample { Text = new string('а', 4001), Label = LearningData.ColumnDev, Delta = 1.0 }); - - RpcException exception = await Assert.ThrowsAsync(() => - client.TrainBatchAsync(request, Options(MlTestHost.DefaultTenantId)).ResponseAsync); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - }); - } - - // CallOptions с metadata (service-token + tenant-id) и deadline. - // tenantId: Id тенанта. - private static CallOptions Options(string tenantId) - => MlTestHost.CallOptions(MlTestHost.CallMetadata(MlTestHost.DefaultToken, tenantId)); - - // Маппит пример обучения в wire-форму TrainExample (text/label/delta). - // item: Пример. - private static TrainExample ToExample(Deal.Ml.Model.LearnItem item) - => new() { Text = item.Text, Label = item.Label, Delta = item.Delta }; -} +using Deal.Grpc.Ml; +using Deal.Ml.Tests.Ml; +using Grpc.Core; + +namespace Deal.Ml.Tests.Grpc; + +/// +/// In-proc gRPC-тесты MlService +/// +public sealed class MlRpcTests +{ + /// + /// Свежий тенант: Status — пустой ответ, Predict — фиксированный «не уверен». + /// + [Fact] + public async Task FreshTenant_StatusAndPredictReturnNotReadyShape() + { + await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => + { + var client = new MlService.MlServiceClient(channel); + CallOptions options = Options(MlTestHost.DefaultTenantId); + + StatusReply status = await client.StatusAsync(new StatusRequest(), options); + + Assert.False(status.Ready); + Assert.Empty(status.Classes); + Assert.Equal(0, status.Learned); + Assert.Equal(0, status.Eval.Count); + Assert.Equal(0.0, status.Eval.Accuracy); + + PredictReply predict = await client.PredictAsync( + new PredictRequest { Text = LearningData.DevMessage }, + options); + + Assert.False(predict.Take); + Assert.False(predict.HasLabel); + Assert.False(predict.Ready); + Assert.Empty(predict.Scores); + Assert.Equal(0, predict.Hits); + Assert.False(predict.HasMargin); + Assert.Empty(predict.Terms); + Assert.Null(predict.Type); + }); + } + + /// + /// TrainBatch батчем из 3 → learned=3; модель остаётся неготовой + /// + [Fact] + public async Task TrainBatch_ThreeItems_LearnsThree() + { + await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => + { + var client = new MlService.MlServiceClient(channel); + CallOptions options = Options(MlTestHost.DefaultTenantId); + + var request = new TrainBatchRequest(); + request.Items.Add(new TrainExample { Text = LearningData.DevMessage, Label = LearningData.ColumnDev, Delta = 1.0 }); + request.Items.Add(new TrainExample { Text = LearningData.DevMessage, Label = LearningData.ColumnDev, Delta = 1.0 }); + request.Items.Add(new TrainExample { Text = LearningData.SpamMessage, Label = LearningData.SpamLabel, Delta = 1.0 }); + + TrainBatchReply reply = await client.TrainBatchAsync(request, options); + + Assert.Equal(3, reply.Learned); + + StatusReply status = await client.StatusAsync(new StatusRequest(), options); + Assert.False(status.Ready); + Assert.Equal(3, status.Learned); + }); + } + + /// + /// Полный цикл: канонический батч → Status ready/learned → Predict колонки + типа. + /// + [Fact] + public async Task TrainBatch_FullCanonical_ThenStatusAndPredict() + { + await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => + { + var client = new MlService.MlServiceClient(channel); + CallOptions options = Options(MlTestHost.DefaultTenantId); + + TrainBatchReply batch = await client.TrainBatchAsync( + new TrainBatchRequest { Items = { LearningData.CanonicalTrainItems().Select(ToExample) } }, + options); + Assert.Equal(28, batch.Learned); + + StatusReply status = await client.StatusAsync(new StatusRequest(), options); + Assert.True(status.Ready); + Assert.Equal(28, status.Learned); + Assert.Equal(6.0, status.Classes[LearningData.SpamLabel]); + + PredictReply dev = await client.PredictAsync( + new PredictRequest { Text = LearningData.DevMessage }, + options); + Assert.True(dev.Take); + Assert.Equal(LearningData.ColumnDev, dev.Label); + Assert.True(dev.HasMargin); + Assert.NotNull(dev.Type); + Assert.Equal("hire", dev.Type.Label); + + PredictReply spam = await client.PredictAsync( + new PredictRequest { Text = LearningData.SpamMessage }, + options); + Assert.True(spam.Take); + Assert.Equal(LearningData.SpamLabel, spam.Label); + Assert.Null(spam.Type); + }); + } + + /// + /// Reset обнуляет модель тенанта + /// + [Fact] + public async Task Reset_AfterTraining_EmptiesModel() + { + await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => + { + var client = new MlService.MlServiceClient(channel); + CallOptions options = Options(MlTestHost.DefaultTenantId); + + await client.TrainBatchAsync( + new TrainBatchRequest { Items = { LearningData.CanonicalTrainItems().Select(ToExample) } }, + options); + + ResetReply reset = await client.ResetAsync(new ResetRequest(), options); + + Assert.True(reset.Ok); + Assert.False(reset.HasError); + + StatusReply status = await client.StatusAsync(new StatusRequest(), options); + Assert.False(status.Ready); + Assert.Empty(status.Classes); + Assert.Equal(0, status.Learned); + + PredictReply predict = await client.PredictAsync( + new PredictRequest { Text = LearningData.DevMessage }, + options); + Assert.False(predict.Take); + Assert.False(predict.Ready); + }); + } + + /// + /// Изоляция тенантов + /// + [Fact] + public async Task Tenants_AreIsolated() + { + await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => + { + var client = new MlService.MlServiceClient(channel); + + await client.TrainBatchAsync( + new TrainBatchRequest { Items = { LearningData.CanonicalTrainItems().Select(ToExample) } }, + Options("tenant-a")); + + StatusReply trained = await client.StatusAsync(new StatusRequest(), Options("tenant-a")); + Assert.True(trained.Ready); + Assert.Equal(28, trained.Learned); + + StatusReply untouched = await client.StatusAsync(new StatusRequest(), Options("tenant-b")); + Assert.False(untouched.Ready); + Assert.Equal(0, untouched.Learned); + + PredictReply predictB = await client.PredictAsync( + new PredictRequest { Text = LearningData.DevMessage }, + Options("tenant-b")); + Assert.False(predictB.Take); + Assert.False(predictB.Ready); + }); + } + + /// + /// Отсутствующий tenant-id в metadata → UNAUTHENTICATED. + /// + [Fact] + public async Task Status_WithoutTenantId_IsUnauthenticated() + { + await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => + { + var client = new MlService.MlServiceClient(channel); + Metadata metadata = MlTestHost.CallMetadata(MlTestHost.DefaultToken, tenantId: null); + + AsyncUnaryCall call = client.StatusAsync(new StatusRequest(), MlTestHost.CallOptions(metadata)); + RpcException exception = await Assert.ThrowsAsync(() => call.ResponseAsync); + + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + }); + } + + /// + /// Tenant-id с недопустимыми символами пути → INVALID_ARGUMENT + /// + [Fact] + public async Task Status_WithPathTraversalTenantId_IsInvalidArgument() + { + await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => + { + var client = new MlService.MlServiceClient(channel); + + AsyncUnaryCall call = client.StatusAsync( + new StatusRequest(), + MlTestHost.CallOptions(MlTestHost.CallMetadata(MlTestHost.DefaultToken, tenantId: "../escape"))); + RpcException exception = await Assert.ThrowsAsync(() => call.ResponseAsync); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + }); + } + + /// + /// Пустые text/label в батче пропускаются + /// + [Fact] + public async Task TrainBatch_EmptyItems_SkippedQuietly() + { + await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => + { + var client = new MlService.MlServiceClient(channel); + var request = new TrainBatchRequest(); + request.Items.Add(new TrainExample { Text = string.Empty, Label = LearningData.ColumnDev, Delta = 1.0 }); + request.Items.Add(new TrainExample { Text = LearningData.DevMessage, Label = " ", Delta = 1.0 }); + request.Items.Add(new TrainExample { Text = LearningData.DevMessage, Label = LearningData.ColumnDev, Delta = 1.0 }); + + TrainBatchReply reply = await client.TrainBatchAsync(request, Options(MlTestHost.DefaultTenantId)); + + Assert.Equal(1, reply.Learned); + }); + } + + /// + /// Серверный лимит батча + /// + [Fact] + public async Task TrainBatch_OverBatchLimit_IsInvalidArgument() + { + await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => + { + var client = new MlService.MlServiceClient(channel); + var request = new TrainBatchRequest(); + for (int i = 0; i < 101; i++) + { + request.Items.Add(new TrainExample { Text = LearningData.DevMessage, Label = LearningData.ColumnDev, Delta = 1.0 }); + } + + RpcException exception = await Assert.ThrowsAsync(() => + client.TrainBatchAsync(request, Options(MlTestHost.DefaultTenantId)).ResponseAsync); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + }); + } + + /// + /// Серверный лимит длины текста примера + /// + [Fact] + public async Task TrainBatch_TooLongExampleText_IsInvalidArgument() + { + await MlTestHost.RunAsync(MlTestHost.DefaultToken, async channel => + { + var client = new MlService.MlServiceClient(channel); + var request = new TrainBatchRequest(); + request.Items.Add(new TrainExample { Text = new string('а', 4001), Label = LearningData.ColumnDev, Delta = 1.0 }); + + RpcException exception = await Assert.ThrowsAsync(() => + client.TrainBatchAsync(request, Options(MlTestHost.DefaultTenantId)).ResponseAsync); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + }); + } + + // CallOptions с metadata (service-token + tenant-id) и deadline. + // tenantId: Id тенанта. + private static CallOptions Options(string tenantId) + => MlTestHost.CallOptions(MlTestHost.CallMetadata(MlTestHost.DefaultToken, tenantId)); + + // Маппит пример обучения в wire-форму TrainExample (text/label/delta). + // item: Пример. + private static TrainExample ToExample(Deal.Ml.Model.LearnItem item) + => new() { Text = item.Text, Label = item.Label, Delta = item.Delta }; +} diff --git a/src/ml-service/Deal.Ml.Tests/Grpc/MlServiceHostTests.cs b/src/ml-service/Deal.Ml.Tests/Grpc/MlServiceHostTests.cs index 00f3fad..889c1dd 100644 --- a/src/ml-service/Deal.Ml.Tests/Grpc/MlServiceHostTests.cs +++ b/src/ml-service/Deal.Ml.Tests/Grpc/MlServiceHostTests.cs @@ -1,230 +1,230 @@ -using System.Net; -using System.Net.Sockets; -using Deal.Grpc.Ml; -using Grpc.Core; -using Grpc.Health.V1; -using Grpc.Net.Client; -using Microsoft.AspNetCore.Builder; - -namespace Deal.Ml.Tests.Grpc; - -/// -/// Интеграционные тесты хоста ml-service. -/// -public sealed class MlServiceHostTests -{ - // Env-ключ ожидаемого токена (зеркало ServiceTokenInterceptor). - private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - - // Env-ключ каталога моделей (зеркало MlOptions; Status создаёт модель тенанта). - private const string DataDirEnvKey = "DEAL_ML_DATA_DIR"; - - // Id тенанта сценариев теста (Status реализован задачами 5–6). - private const string TestTenantId = "tenant-test"; - - // Ключ gRPC-metadata с tenant-id (зеркало MlServiceImpl.TenantIdMetadataKey). - private const string TenantIdMetadataKey = "tenant-id"; - - // Ключ gRPC-metadata (зеркало ServiceTokenInterceptor.ServiceTokenMetadataKey). - private const string ServiceTokenMetadataKey = "service-token"; - - // Токен сценариев теста. - private const string ValidToken = "task3-test-token"; - - // Deadline RPC-вызовов теста (сек). - private const int RpcDeadlineSeconds = 10; - - /// - /// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура и health-сервис работают. - /// - [Fact] - public async Task HealthCheck_ReturnsServing() - { - await RunHostScenarioAsync( - ValidToken, - async channel => - { - var health = new Health.HealthClient(channel); - HealthCheckResponse response = await health.CheckAsync( - new HealthCheckRequest(), - deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); - - Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, response.Status); - }); - } - - /// - /// Запрос без metadata «service-token» → UNAUTHENTICATED. - /// - [Fact] - public async Task Status_WithoutToken_IsUnauthenticated() - { - await AssertDealRpcRejectedAsync( - ValidToken, - tokenHeader: null, - expected: StatusCode.Unauthenticated); - } - - /// - /// Запрос с неверным токеном → UNAUTHENTICATED. - /// - [Fact] - public async Task Status_WithWrongToken_IsUnauthenticated() - { - await AssertDealRpcRejectedAsync( - ValidToken, - tokenHeader: "wrong-token", - expected: StatusCode.Unauthenticated); - } - - /// - /// Верный токен проходит интерцептор к методу Status - /// - [Fact] - public async Task Status_WithValidToken_ReturnsEmptyStatus() - { - await RunHostScenarioAsync( - ValidToken, - async channel => - { - var client = new MlService.MlServiceClient(channel); - Metadata metadata = new(); - metadata.Add(ServiceTokenMetadataKey, ValidToken); - metadata.Add(TenantIdMetadataKey, TestTenantId); - - StatusReply response = await client.StatusAsync( - new StatusRequest(), - new CallOptions(metadata, deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds))); - - Assert.False(response.Ready); - Assert.Empty(response.Classes); - Assert.Equal(0, response.Learned); - }); - } - - /// - /// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, в т.ч. - /// - [Fact] - public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing() - { - await RunHostScenarioAsync( - serviceToken: null, - async channel => - { - var health = new Health.HealthClient(channel); - HealthCheckResponse healthResponse = await health.CheckAsync( - new HealthCheckRequest(), - deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); - Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, healthResponse.Status); - - // Пустое значение metadata — попытка обойти fail-closed (равно незаданному env-токену). - await AssertRejectedAsync(channel, string.Empty, StatusCode.Unauthenticated); - await AssertRejectedAsync(channel, ValidToken, StatusCode.Unauthenticated); - }); - } - - // Прогоняет Status с заданным заголовком «service-token» (null — без заголовка) и проверяет - // ожидаемый код статуса RPC-исключения. - // serviceToken: Ожидаемый токен хоста (env DEAL_SERVICE_TOKEN). - // tokenHeader: Значение metadata «service-token» запроса либо null (нет заголовка). - // expected: Ожидаемый StatusCode ответа. - private static async Task AssertDealRpcRejectedAsync( - string? serviceToken, - string? tokenHeader, - StatusCode expected) - { - await RunHostScenarioAsync( - serviceToken, - channel => AssertRejectedAsync(channel, tokenHeader, expected)); - } - - // Вызывает Status и проверяет, что сервер ответил ожидаемым кодом статуса. - // channel: Канал к хосту ml-service. - // tokenHeader: Значение metadata «service-token» либо null (нет заголовка). - // expected: Ожидаемый StatusCode. - private static async Task AssertRejectedAsync( - GrpcChannel channel, - string? tokenHeader, - StatusCode expected) - { - var client = new MlService.MlServiceClient(channel); - Metadata metadata = new(); - if (tokenHeader is not null) - { - metadata.Add(ServiceTokenMetadataKey, tokenHeader); - } - - var callOptions = new CallOptions( - metadata, - deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); - - AsyncUnaryCall call = client.StatusAsync(new StatusRequest(), callOptions); - RpcException exception = await Assert.ThrowsAsync(() => call.ResponseAsync); - - Assert.Equal(expected, exception.StatusCode); - } - - // Поднимает хост на эфемерном порту с заданным env-токеном, выполняет сценарий и гарантированно - // гасит хост/канал и восстанавливает исходный env. - // serviceToken: Значение env DEAL_SERVICE_TOKEN для сценария (null — убрать). - // scenario: Сценарий с каналом к поднятому хосту. - private static async Task RunHostScenarioAsync(string? serviceToken, Func scenario) - { - string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); - string? originalDataDir = Environment.GetEnvironmentVariable(DataDirEnvKey); - - // Status реализован (задачи 5–6): модель тенанта создаёт SQLite-файл — каталог теста - // направляем во временную папку, чтобы файлы не попали в data/ml репозитория. - string dataDir = Path.Combine(Path.GetTempPath(), "deal-ml-host-tests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(dataDir); - - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); - Environment.SetEnvironmentVariable(DataDirEnvKey, dataDir); - - WebApplication? app = null; - GrpcChannel? channel = null; - try - { - int port = FreeTcpPort(); - app = MlServiceHost.Create(port); - await app.StartAsync(); - - channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}"); - await scenario(channel); - } - finally - { - if (channel is not null) - { - channel.Dispose(); - } - - if (app is not null) - { - await app.StopAsync(); - await app.DisposeAsync(); - } - - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); - Environment.SetEnvironmentVariable(DataDirEnvKey, originalDataDir); - - try - { - Directory.Delete(dataDir, recursive: true); - } - catch (IOException) - { - // Каталог мог быть занят на момент удаления — тестовый мусор в temp допустим. - } - } - } - - // Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста). - private static int FreeTcpPort() - { - using var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - return ((IPEndPoint)listener.LocalEndpoint).Port; - } -} +using System.Net; +using System.Net.Sockets; +using Deal.Grpc.Ml; +using Grpc.Core; +using Grpc.Health.V1; +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; + +namespace Deal.Ml.Tests.Grpc; + +/// +/// Интеграционные тесты хоста ml-service. +/// +public sealed class MlServiceHostTests +{ + // Env-ключ ожидаемого токена (зеркало ServiceTokenInterceptor). + private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; + + // Env-ключ каталога моделей (зеркало MlOptions; Status создаёт модель тенанта). + private const string DataDirEnvKey = "DEAL_ML_DATA_DIR"; + + // Id тенанта сценариев теста (Status реализован задачами 5–6). + private const string TestTenantId = "tenant-test"; + + // Ключ gRPC-metadata с tenant-id (зеркало MlServiceImpl.TenantIdMetadataKey). + private const string TenantIdMetadataKey = "tenant-id"; + + // Ключ gRPC-metadata (зеркало ServiceTokenInterceptor.ServiceTokenMetadataKey). + private const string ServiceTokenMetadataKey = "service-token"; + + // Токен сценариев теста. + private const string ValidToken = "task3-test-token"; + + // Deadline RPC-вызовов теста (сек). + private const int RpcDeadlineSeconds = 10; + + /// + /// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура и health-сервис работают. + /// + [Fact] + public async Task HealthCheck_ReturnsServing() + { + await RunHostScenarioAsync( + ValidToken, + async channel => + { + var health = new Health.HealthClient(channel); + HealthCheckResponse response = await health.CheckAsync( + new HealthCheckRequest(), + deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); + + Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, response.Status); + }); + } + + /// + /// Запрос без metadata «service-token» → UNAUTHENTICATED. + /// + [Fact] + public async Task Status_WithoutToken_IsUnauthenticated() + { + await AssertDealRpcRejectedAsync( + ValidToken, + tokenHeader: null, + expected: StatusCode.Unauthenticated); + } + + /// + /// Запрос с неверным токеном → UNAUTHENTICATED. + /// + [Fact] + public async Task Status_WithWrongToken_IsUnauthenticated() + { + await AssertDealRpcRejectedAsync( + ValidToken, + tokenHeader: "wrong-token", + expected: StatusCode.Unauthenticated); + } + + /// + /// Верный токен проходит интерцептор к методу Status + /// + [Fact] + public async Task Status_WithValidToken_ReturnsEmptyStatus() + { + await RunHostScenarioAsync( + ValidToken, + async channel => + { + var client = new MlService.MlServiceClient(channel); + Metadata metadata = new(); + metadata.Add(ServiceTokenMetadataKey, ValidToken); + metadata.Add(TenantIdMetadataKey, TestTenantId); + + StatusReply response = await client.StatusAsync( + new StatusRequest(), + new CallOptions(metadata, deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds))); + + Assert.False(response.Ready); + Assert.Empty(response.Classes); + Assert.Equal(0, response.Learned); + }); + } + + /// + /// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, в т.ч. + /// + [Fact] + public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing() + { + await RunHostScenarioAsync( + serviceToken: null, + async channel => + { + var health = new Health.HealthClient(channel); + HealthCheckResponse healthResponse = await health.CheckAsync( + new HealthCheckRequest(), + deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); + Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, healthResponse.Status); + + // Пустое значение metadata — попытка обойти fail-closed (равно незаданному env-токену). + await AssertRejectedAsync(channel, string.Empty, StatusCode.Unauthenticated); + await AssertRejectedAsync(channel, ValidToken, StatusCode.Unauthenticated); + }); + } + + // Прогоняет Status с заданным заголовком «service-token» (null — без заголовка) и проверяет + // ожидаемый код статуса RPC-исключения. + // serviceToken: Ожидаемый токен хоста (env DEAL_SERVICE_TOKEN). + // tokenHeader: Значение metadata «service-token» запроса либо null (нет заголовка). + // expected: Ожидаемый StatusCode ответа. + private static async Task AssertDealRpcRejectedAsync( + string? serviceToken, + string? tokenHeader, + StatusCode expected) + { + await RunHostScenarioAsync( + serviceToken, + channel => AssertRejectedAsync(channel, tokenHeader, expected)); + } + + // Вызывает Status и проверяет, что сервер ответил ожидаемым кодом статуса. + // channel: Канал к хосту ml-service. + // tokenHeader: Значение metadata «service-token» либо null (нет заголовка). + // expected: Ожидаемый StatusCode. + private static async Task AssertRejectedAsync( + GrpcChannel channel, + string? tokenHeader, + StatusCode expected) + { + var client = new MlService.MlServiceClient(channel); + Metadata metadata = new(); + if (tokenHeader is not null) + { + metadata.Add(ServiceTokenMetadataKey, tokenHeader); + } + + var callOptions = new CallOptions( + metadata, + deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); + + AsyncUnaryCall call = client.StatusAsync(new StatusRequest(), callOptions); + RpcException exception = await Assert.ThrowsAsync(() => call.ResponseAsync); + + Assert.Equal(expected, exception.StatusCode); + } + + // Поднимает хост на эфемерном порту с заданным env-токеном, выполняет сценарий и гарантированно + // гасит хост/канал и восстанавливает исходный env. + // serviceToken: Значение env DEAL_SERVICE_TOKEN для сценария (null — убрать). + // scenario: Сценарий с каналом к поднятому хосту. + private static async Task RunHostScenarioAsync(string? serviceToken, Func scenario) + { + string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); + string? originalDataDir = Environment.GetEnvironmentVariable(DataDirEnvKey); + + // Status реализован (задачи 5–6): модель тенанта создаёт SQLite-файл — каталог теста + // направляем во временную папку, чтобы файлы не попали в data/ml репозитория. + string dataDir = Path.Combine(Path.GetTempPath(), "deal-ml-host-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dataDir); + + Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); + Environment.SetEnvironmentVariable(DataDirEnvKey, dataDir); + + WebApplication? app = null; + GrpcChannel? channel = null; + try + { + int port = FreeTcpPort(); + app = MlServiceHost.Create(port); + await app.StartAsync(); + + channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}"); + await scenario(channel); + } + finally + { + if (channel is not null) + { + channel.Dispose(); + } + + if (app is not null) + { + await app.StopAsync(); + await app.DisposeAsync(); + } + + Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); + Environment.SetEnvironmentVariable(DataDirEnvKey, originalDataDir); + + try + { + Directory.Delete(dataDir, recursive: true); + } + catch (IOException) + { + // Каталог мог быть занят на момент удаления — тестовый мусор в temp допустим. + } + } + } + + // Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста). + private static int FreeTcpPort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } +} diff --git a/src/ml-service/Deal.Ml.Tests/Grpc/MlTestHost.cs b/src/ml-service/Deal.Ml.Tests/Grpc/MlTestHost.cs index a26c22c..eac544b 100644 --- a/src/ml-service/Deal.Ml.Tests/Grpc/MlTestHost.cs +++ b/src/ml-service/Deal.Ml.Tests/Grpc/MlTestHost.cs @@ -1,134 +1,134 @@ -using System.Net; -using System.Net.Sockets; -using Deal.Grpc.Hosting.Interceptors; -using Deal.Grpc.Hosting.Models; -using Deal.Grpc.Hosting.Options; -using Deal.Grpc.Hosting.Services; -using Deal.Ml.Model; -using Grpc.Core; -using Grpc.Net.Client; -using Microsoft.AspNetCore.Builder; - -namespace Deal.Ml.Tests.Grpc; - -internal static class MlTestHost -{ - /// - /// Env-ключ ожидаемого service-token - /// - public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - - /// - /// Env-ключ каталога моделей - /// - public const string DataDirEnvKey = MlOptions.DataDirEnvVarName; - - public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey; - - public const string TenantIdMetadataKey = MlServiceImpl.TenantIdMetadataKey; - - /// - /// Токен сценариев теста. - /// - public const string DefaultToken = "deal-ml-test-token"; - - /// - /// Id тенанта сценариев по умолчанию. - /// - public const string DefaultTenantId = "tenant-test"; - - /// - /// Deadline RPC-вызовов теста - /// - public const int RpcDeadlineSeconds = 15; - - /// - /// Прогоняет сценарий против поднятого хоста с заданным env-токеном и тестовым каталогом моделей. - /// - /// Значение env DEAL_SERVICE_TOKEN (null — убрать переменную). - /// Сценарий с gRPC-каналом к хосту. - public static async Task RunAsync(string? serviceToken, Func scenario) - { - string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); - string? originalDataDir = Environment.GetEnvironmentVariable(DataDirEnvKey); - - string dataDir = Path.Combine(Path.GetTempPath(), "deal-ml-tests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(dataDir); - - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); - Environment.SetEnvironmentVariable(DataDirEnvKey, dataDir); - - WebApplication? app = null; - GrpcChannel? channel = null; - try - { - int port = FreeTcpPort(); - app = MlServiceHost.Create(port); - await app.StartAsync(); - - channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}"); - await scenario(channel); - } - finally - { - if (channel is not null) - { - channel.Dispose(); - } - - if (app is not null) - { - await app.StopAsync(); - await app.DisposeAsync(); - } - - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); - Environment.SetEnvironmentVariable(DataDirEnvKey, originalDataDir); - - try - { - Directory.Delete(dataDir, recursive: true); - } - catch (IOException) - { - // Каталог мог быть занят на момент удаления — тестовый мусор в temp допустим. - } - } - } - - /// - /// Строит metadata вызова - /// - /// Значение заголовка service-token. - /// Id тенанта (null — без заголовка tenant-id). - public static Metadata CallMetadata(string? serviceToken, string? tenantId = null) - { - var metadata = new Metadata(); - if (serviceToken is not null) - { - metadata.Add(ServiceTokenMetadataKey, serviceToken); - } - - if (tenantId is not null) - { - metadata.Add(TenantIdMetadataKey, tenantId); - } - - return metadata; - } - - /// - /// CallOptions RPC - /// - /// Metadata вызова. - public static CallOptions CallOptions(Metadata metadata) - => new(metadata, deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); - - // Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста). - private static int FreeTcpPort() - { - using var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - return ((IPEndPoint)listener.LocalEndpoint).Port; - } -} +using System.Net; +using System.Net.Sockets; +using Deal.Grpc.Hosting.Interceptors; +using Deal.Grpc.Hosting.Models; +using Deal.Grpc.Hosting.Options; +using Deal.Grpc.Hosting.Services; +using Deal.Ml.Model; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; + +namespace Deal.Ml.Tests.Grpc; + +internal static class MlTestHost +{ + /// + /// Env-ключ ожидаемого service-token + /// + public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; + + /// + /// Env-ключ каталога моделей + /// + public const string DataDirEnvKey = MlOptions.DataDirEnvVarName; + + public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey; + + public const string TenantIdMetadataKey = MlServiceImpl.TenantIdMetadataKey; + + /// + /// Токен сценариев теста. + /// + public const string DefaultToken = "deal-ml-test-token"; + + /// + /// Id тенанта сценариев по умолчанию. + /// + public const string DefaultTenantId = "tenant-test"; + + /// + /// Deadline RPC-вызовов теста + /// + public const int RpcDeadlineSeconds = 15; + + /// + /// Прогоняет сценарий против поднятого хоста с заданным env-токеном и тестовым каталогом моделей. + /// + /// Значение env DEAL_SERVICE_TOKEN (null — убрать переменную). + /// Сценарий с gRPC-каналом к хосту. + public static async Task RunAsync(string? serviceToken, Func scenario) + { + string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); + string? originalDataDir = Environment.GetEnvironmentVariable(DataDirEnvKey); + + string dataDir = Path.Combine(Path.GetTempPath(), "deal-ml-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dataDir); + + Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); + Environment.SetEnvironmentVariable(DataDirEnvKey, dataDir); + + WebApplication? app = null; + GrpcChannel? channel = null; + try + { + int port = FreeTcpPort(); + app = MlServiceHost.Create(port); + await app.StartAsync(); + + channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}"); + await scenario(channel); + } + finally + { + if (channel is not null) + { + channel.Dispose(); + } + + if (app is not null) + { + await app.StopAsync(); + await app.DisposeAsync(); + } + + Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); + Environment.SetEnvironmentVariable(DataDirEnvKey, originalDataDir); + + try + { + Directory.Delete(dataDir, recursive: true); + } + catch (IOException) + { + // Каталог мог быть занят на момент удаления — тестовый мусор в temp допустим. + } + } + } + + /// + /// Строит metadata вызова + /// + /// Значение заголовка service-token. + /// Id тенанта (null — без заголовка tenant-id). + public static Metadata CallMetadata(string? serviceToken, string? tenantId = null) + { + var metadata = new Metadata(); + if (serviceToken is not null) + { + metadata.Add(ServiceTokenMetadataKey, serviceToken); + } + + if (tenantId is not null) + { + metadata.Add(TenantIdMetadataKey, tenantId); + } + + return metadata; + } + + /// + /// CallOptions RPC + /// + /// Metadata вызова. + public static CallOptions CallOptions(Metadata metadata) + => new(metadata, deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); + + // Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста). + private static int FreeTcpPort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } +} diff --git a/src/ml-service/Deal.Ml.Tests/Ml/LearningData.cs b/src/ml-service/Deal.Ml.Tests/Ml/LearningData.cs index fefdfb3..58c7835 100644 --- a/src/ml-service/Deal.Ml.Tests/Ml/LearningData.cs +++ b/src/ml-service/Deal.Ml.Tests/Ml/LearningData.cs @@ -1,98 +1,98 @@ -using Deal.Ml.Model; - -namespace Deal.Ml.Tests.Ml; - -internal static class LearningData -{ - /// - /// Id колонки канбана «разработка/найм» - /// - public const string ColumnDev = "b_col_dev"; - - /// - /// Id колонки канбана «разовая сделка/заказ». - /// - public const string ColumnOrder = "b_col_order"; - - /// - /// Внутренний класс типа «найм». - /// - public const string TypeHire = "t:hire"; - - /// - /// Внутренний класс типа «разовая сделка». - /// - public const string TypeOrder = "t:order"; - - /// - /// Сообщение-заявка на разработчика - /// - public const string DevMessage = - "нужен middle python разработчик в команду, удаленная работа, стек django postgres, оффер конкурентный"; - - /// - /// Сообщение-заказ сайта/лендинга - /// - public const string OrderMessage = - "закажу сайт визитку и лендинг для малого бизнеса, недорого, дизайнер и верстка"; - - /// - /// Спам-резюме/рассылка - /// - public const string SpamMessage = - "резюме ищу подработку, разошлю отклик по вакансиям, рассылка кадровым агентствам"; - - /// - /// Пример обучения с заданным весом сигнала - /// - /// Метка класса. - /// Текст примера. - /// Вес сигнала. - public static LearnItem Signal( - string label, - string text, - double delta) => new(text, label, delta); - - /// - /// Пример обучения с дельтой 1.0 - /// - /// Метка класса. - /// Текст примера. - public static LearnItem User(string label, string text) => new(text, label, 1.0); - - /// - /// Канонический батч, доводящий модель до готовности и уверенных предсказаний - /// - public static List CanonicalTrainItems() - { - var items = new List(); - AddRepeated(items, User(ColumnDev, DevMessage), 6); - AddRepeated(items, User(ColumnOrder, OrderMessage), 6); - AddRepeated(items, User(LearningData.SpamLabel, SpamMessage), 4); - AddRepeated(items, User(TypeHire, DevMessage), 4); - AddRepeated(items, User(TypeOrder, OrderMessage), 4); - AddRepeated(items, User(ColumnDev, DevMessage), 2); - AddRepeated(items, User(LearningData.SpamLabel, SpamMessage), 2); - return items; - } - - /// - /// Метка spam (внутренняя константа модели; зеркало для читаемости батчей). - /// - public static string SpamLabel => ModelConstants.SpamLabel; - - // Добавляет count копий примера в список. - // target: Список-приёмник. - // item: Пример. - // count: Число копий. - private static void AddRepeated( - List target, - LearnItem item, - int count) - { - for (int i = 0; i < count; i++) - { - target.Add(item); - } - } -} +using Deal.Ml.Model; + +namespace Deal.Ml.Tests.Ml; + +internal static class LearningData +{ + /// + /// Id колонки канбана «разработка/найм» + /// + public const string ColumnDev = "b_col_dev"; + + /// + /// Id колонки канбана «разовая сделка/заказ». + /// + public const string ColumnOrder = "b_col_order"; + + /// + /// Внутренний класс типа «найм». + /// + public const string TypeHire = "t:hire"; + + /// + /// Внутренний класс типа «разовая сделка». + /// + public const string TypeOrder = "t:order"; + + /// + /// Сообщение-заявка на разработчика + /// + public const string DevMessage = + "нужен middle python разработчик в команду, удаленная работа, стек django postgres, оффер конкурентный"; + + /// + /// Сообщение-заказ сайта/лендинга + /// + public const string OrderMessage = + "закажу сайт визитку и лендинг для малого бизнеса, недорого, дизайнер и верстка"; + + /// + /// Спам-резюме/рассылка + /// + public const string SpamMessage = + "резюме ищу подработку, разошлю отклик по вакансиям, рассылка кадровым агентствам"; + + /// + /// Пример обучения с заданным весом сигнала + /// + /// Метка класса. + /// Текст примера. + /// Вес сигнала. + public static LearnItem Signal( + string label, + string text, + double delta) => new(text, label, delta); + + /// + /// Пример обучения с дельтой 1.0 + /// + /// Метка класса. + /// Текст примера. + public static LearnItem User(string label, string text) => new(text, label, 1.0); + + /// + /// Канонический батч, доводящий модель до готовности и уверенных предсказаний + /// + public static List CanonicalTrainItems() + { + var items = new List(); + AddRepeated(items, User(ColumnDev, DevMessage), 6); + AddRepeated(items, User(ColumnOrder, OrderMessage), 6); + AddRepeated(items, User(LearningData.SpamLabel, SpamMessage), 4); + AddRepeated(items, User(TypeHire, DevMessage), 4); + AddRepeated(items, User(TypeOrder, OrderMessage), 4); + AddRepeated(items, User(ColumnDev, DevMessage), 2); + AddRepeated(items, User(LearningData.SpamLabel, SpamMessage), 2); + return items; + } + + /// + /// Метка spam (внутренняя константа модели; зеркало для читаемости батчей). + /// + public static string SpamLabel => ModelConstants.SpamLabel; + + // Добавляет count копий примера в список. + // target: Список-приёмник. + // item: Пример. + // count: Число копий. + private static void AddRepeated( + List target, + LearnItem item, + int count) + { + for (int i = 0; i < count; i++) + { + target.Add(item); + } + } +} diff --git a/src/ml-service/Deal.Ml.Tests/Ml/MlTokenizerTests.cs b/src/ml-service/Deal.Ml.Tests/Ml/MlTokenizerTests.cs index 8bb1e40..ea3894d 100644 --- a/src/ml-service/Deal.Ml.Tests/Ml/MlTokenizerTests.cs +++ b/src/ml-service/Deal.Ml.Tests/Ml/MlTokenizerTests.cs @@ -1,75 +1,75 @@ -using Deal.Ml.Model; - -namespace Deal.Ml.Tests.Ml; - -/// -/// Тесты токенизации -/// -public sealed class MlTokenizerTests -{ - /// - /// Базовое: lowercase, дефис разделяет термины, длинные слова дают ~prefix. - /// - [Fact] - public void Tokenize_LowercasesAndAddsPrefixes() - { - string[] tokens = MlTokenizer.Tokenize("Нужен Python-разработчик"); - - Assert.Equal(new[] { "нужен", "python", "~pyth", "разработчик", "~разр" }, tokens); - } - - /// - /// Ссылки (http/www/markdown) не дают терминов и не ломают соседние слова. - /// - [Fact] - public void Tokenize_RemovesLinksAndMarkdownLinks() - { - string text = "Ссылка https://example.com/page?utm_source=x и www.example.org [текст](https://x.ru/a) конец"; - - string[] tokens = MlTokenizer.Tokenize(text); - - Assert.Equal(new[] { "ссылка", "~ссыл", "конец" }, tokens); - } - - /// - /// Слова короче 3 символов отбрасываются - /// - [Fact] - public void Tokenize_DropsShortWords() - { - Assert.Empty(MlTokenizer.Tokenize("а б вг")); - } - - /// - /// Кириллица/ё обрабатываются как в - /// - [Fact] - public void Tokenize_CyrillicWithYo() - { - string[] tokens = MlTokenizer.Tokenize("Программист Ёжик"); - - Assert.Equal(new[] { "программист", "~прог", "ёжик" }, tokens); - } - - /// - /// Разрешённый алфавит включает @ +. - /// - [Fact] - public void Tokenize_KeepsEmailAsSingleTerm() - { - string[] tokens = MlTokenizer.Tokenize("привет@mail.ru"); - - Assert.Equal(new[] { "привет@mail.ru", "~прив" }, tokens); - } - - /// - /// Null/пустой текст — пустой набор терминов. - /// - [Fact] - public void Tokenize_NullOrEmpty_ReturnsEmpty() - { - Assert.Empty(MlTokenizer.Tokenize(null)); - Assert.Empty(MlTokenizer.Tokenize(string.Empty)); - Assert.Empty(MlTokenizer.Tokenize(" ")); - } -} +using Deal.Ml.Model; + +namespace Deal.Ml.Tests.Ml; + +/// +/// Тесты токенизации +/// +public sealed class MlTokenizerTests +{ + /// + /// Базовое: lowercase, дефис разделяет термины, длинные слова дают ~prefix. + /// + [Fact] + public void Tokenize_LowercasesAndAddsPrefixes() + { + string[] tokens = MlTokenizer.Tokenize("Нужен Python-разработчик"); + + Assert.Equal(new[] { "нужен", "python", "~pyth", "разработчик", "~разр" }, tokens); + } + + /// + /// Ссылки (http/www/markdown) не дают терминов и не ломают соседние слова. + /// + [Fact] + public void Tokenize_RemovesLinksAndMarkdownLinks() + { + string text = "Ссылка https://example.com/page?utm_source=x и www.example.org [текст](https://x.ru/a) конец"; + + string[] tokens = MlTokenizer.Tokenize(text); + + Assert.Equal(new[] { "ссылка", "~ссыл", "конец" }, tokens); + } + + /// + /// Слова короче 3 символов отбрасываются + /// + [Fact] + public void Tokenize_DropsShortWords() + { + Assert.Empty(MlTokenizer.Tokenize("а б вг")); + } + + /// + /// Кириллица/ё обрабатываются как в + /// + [Fact] + public void Tokenize_CyrillicWithYo() + { + string[] tokens = MlTokenizer.Tokenize("Программист Ёжик"); + + Assert.Equal(new[] { "программист", "~прог", "ёжик" }, tokens); + } + + /// + /// Разрешённый алфавит включает @ +. + /// + [Fact] + public void Tokenize_KeepsEmailAsSingleTerm() + { + string[] tokens = MlTokenizer.Tokenize("привет@mail.ru"); + + Assert.Equal(new[] { "привет@mail.ru", "~прив" }, tokens); + } + + /// + /// Null/пустой текст — пустой набор терминов. + /// + [Fact] + public void Tokenize_NullOrEmpty_ReturnsEmpty() + { + Assert.Empty(MlTokenizer.Tokenize(null)); + Assert.Empty(MlTokenizer.Tokenize(string.Empty)); + Assert.Empty(MlTokenizer.Tokenize(" ")); + } +} diff --git a/src/ml-service/Deal.Ml.Tests/Ml/ModelPersistenceTests.cs b/src/ml-service/Deal.Ml.Tests/Ml/ModelPersistenceTests.cs index 1f19a27..0b417e5 100644 --- a/src/ml-service/Deal.Ml.Tests/Ml/ModelPersistenceTests.cs +++ b/src/ml-service/Deal.Ml.Tests/Ml/ModelPersistenceTests.cs @@ -1,154 +1,154 @@ -using Deal.Ml.Model; -using Microsoft.Data.Sqlite; - -namespace Deal.Ml.Tests.Ml; - -/// -/// Тесты персистентности модели -/// -public sealed class ModelPersistenceTests -{ - /// - /// Перезапуск пула сохраняет веса - /// - [Fact] - public void SecondPoolOnSameFile_ReloadsWeightsAndEval() - { - string dataDir = NewDataDir(); - try - { - MlStatusResult firstStatus; - MlPredictResult firstPredict; - using (var firstPool = new ModelPool(MlOptions.Create(dataDir))) - { - TenantModel model = firstPool.GetOrCreate("tenant-persist"); - List items = LearningData.CanonicalTrainItems(); - model.LearnBatch(items.Take(24).ToList()); - model.LearnBatch(items.Skip(24).ToList()); - - firstStatus = model.Status(); - firstPredict = model.Predict(LearningData.DevMessage); - } - - using var secondPool = new ModelPool(MlOptions.Create(dataDir)); - TenantModel reloaded = secondPool.GetOrCreate("tenant-persist"); - - MlStatusResult secondStatus = reloaded.Status(); - MlPredictResult secondPredict = reloaded.Predict(LearningData.DevMessage); - - Assert.True(secondStatus.Ready); - Assert.Equal(firstStatus.Learned, secondStatus.Learned); - Assert.Equal(firstStatus.Classes, secondStatus.Classes); - Assert.Equal(firstStatus.Eval, secondStatus.Eval); - Assert.True(secondPredict.Take); - Assert.Equal(firstPredict.Label, secondPredict.Label); - Assert.Equal(firstPredict.Scores, secondPredict.Scores); - } - finally - { - TryDelete(dataDir); - } - } - - /// - /// Окно самооценки - /// - [Fact] - public void EvalLog_PrunesToKeepAndWindowIsFifty() - { - string dataDir = NewDataDir(); - try - { - TenantModel model; - using (var pool = new ModelPool(MlOptions.Create(dataDir))) - { - model = pool.GetOrCreate("tenant-eval-window"); - List items = LearningData.CanonicalTrainItems(); - model.LearnBatch(items.Take(24).ToList()); - model.LearnBatch(items.Skip(24).ToList()); - - // 250 реальных действий (delta=1, метка совпадает с предсказанием) — самооценка пишется. - model.LearnBatch( - Enumerable.Repeat(LearningData.User(LearningData.ColumnDev, LearningData.DevMessage), 250).ToList()); - - MlStatusResult status = model.Status(); - - Assert.Equal(50, status.Eval.Count); // окно — последние EVAL_WINDOW - Assert.Equal(50, status.Eval.Correct); - Assert.Equal(1.0, status.Eval.Accuracy); - - Assert.Equal(200, CountEvalRows(model.DatabasePath)); // журнал прунится до EVAL_KEEP - } - } - finally - { - TryDelete(dataDir); - } - } - - /// - /// Прунинг переживает перезапуск - /// - [Fact] - public void EvalLog_PrunePersistedAcrossPoolRestart() - { - string dataDir = NewDataDir(); - try - { - using (var pool = new ModelPool(MlOptions.Create(dataDir))) - { - TenantModel model = pool.GetOrCreate("tenant-eval-restart"); - List items = LearningData.CanonicalTrainItems(); - model.LearnBatch(items.Take(24).ToList()); - model.LearnBatch(items.Skip(24).ToList()); - model.LearnBatch( - Enumerable.Repeat(LearningData.User(LearningData.ColumnDev, LearningData.DevMessage), 250).ToList()); - } - - using var secondPool = new ModelPool(MlOptions.Create(dataDir)); - TenantModel reloaded = secondPool.GetOrCreate("tenant-eval-restart"); - - MlStatusResult status = reloaded.Status(); - Assert.Equal(50, status.Eval.Count); - Assert.Equal(1.0, status.Eval.Accuracy); - Assert.Equal(200, CountEvalRows(reloaded.DatabasePath)); - } - finally - { - TryDelete(dataDir); - } - } - - // Читает число строк eval_log прямо из SQLite-файла модели. - // dbPath: Путь к файлу модели. - private static long CountEvalRows(string dbPath) - { - using var connection = new SqliteConnection($"Data Source={dbPath}"); - connection.Open(); - using SqliteCommand command = connection.CreateCommand(); - command.CommandText = "SELECT count(*) FROM eval_log"; - return (long)command.ExecuteScalar()!; - } - - // Новая временная папка данных моделей. - private static string NewDataDir() - { - string dataDir = Path.Combine(Path.GetTempPath(), "deal-ml-persist-tests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(dataDir); - return dataDir; - } - - // Удаляет временную папку (мусор в temp допустим при сбое). - // dataDir: Папка. - private static void TryDelete(string dataDir) - { - try - { - Directory.Delete(dataDir, recursive: true); - } - catch (IOException) - { - // Занятый каталог — тестовый мусор в temp допустим. - } - } -} +using Deal.Ml.Model; +using Microsoft.Data.Sqlite; + +namespace Deal.Ml.Tests.Ml; + +/// +/// Тесты персистентности модели +/// +public sealed class ModelPersistenceTests +{ + /// + /// Перезапуск пула сохраняет веса + /// + [Fact] + public void SecondPoolOnSameFile_ReloadsWeightsAndEval() + { + string dataDir = NewDataDir(); + try + { + MlStatusResult firstStatus; + MlPredictResult firstPredict; + using (var firstPool = new ModelPool(MlOptions.Create(dataDir))) + { + TenantModel model = firstPool.GetOrCreate("tenant-persist"); + List items = LearningData.CanonicalTrainItems(); + model.LearnBatch(items.Take(24).ToList()); + model.LearnBatch(items.Skip(24).ToList()); + + firstStatus = model.Status(); + firstPredict = model.Predict(LearningData.DevMessage); + } + + using var secondPool = new ModelPool(MlOptions.Create(dataDir)); + TenantModel reloaded = secondPool.GetOrCreate("tenant-persist"); + + MlStatusResult secondStatus = reloaded.Status(); + MlPredictResult secondPredict = reloaded.Predict(LearningData.DevMessage); + + Assert.True(secondStatus.Ready); + Assert.Equal(firstStatus.Learned, secondStatus.Learned); + Assert.Equal(firstStatus.Classes, secondStatus.Classes); + Assert.Equal(firstStatus.Eval, secondStatus.Eval); + Assert.True(secondPredict.Take); + Assert.Equal(firstPredict.Label, secondPredict.Label); + Assert.Equal(firstPredict.Scores, secondPredict.Scores); + } + finally + { + TryDelete(dataDir); + } + } + + /// + /// Окно самооценки + /// + [Fact] + public void EvalLog_PrunesToKeepAndWindowIsFifty() + { + string dataDir = NewDataDir(); + try + { + TenantModel model; + using (var pool = new ModelPool(MlOptions.Create(dataDir))) + { + model = pool.GetOrCreate("tenant-eval-window"); + List items = LearningData.CanonicalTrainItems(); + model.LearnBatch(items.Take(24).ToList()); + model.LearnBatch(items.Skip(24).ToList()); + + // 250 реальных действий (delta=1, метка совпадает с предсказанием) — самооценка пишется. + model.LearnBatch( + Enumerable.Repeat(LearningData.User(LearningData.ColumnDev, LearningData.DevMessage), 250).ToList()); + + MlStatusResult status = model.Status(); + + Assert.Equal(50, status.Eval.Count); // окно — последние EVAL_WINDOW + Assert.Equal(50, status.Eval.Correct); + Assert.Equal(1.0, status.Eval.Accuracy); + + Assert.Equal(200, CountEvalRows(model.DatabasePath)); // журнал прунится до EVAL_KEEP + } + } + finally + { + TryDelete(dataDir); + } + } + + /// + /// Прунинг переживает перезапуск + /// + [Fact] + public void EvalLog_PrunePersistedAcrossPoolRestart() + { + string dataDir = NewDataDir(); + try + { + using (var pool = new ModelPool(MlOptions.Create(dataDir))) + { + TenantModel model = pool.GetOrCreate("tenant-eval-restart"); + List items = LearningData.CanonicalTrainItems(); + model.LearnBatch(items.Take(24).ToList()); + model.LearnBatch(items.Skip(24).ToList()); + model.LearnBatch( + Enumerable.Repeat(LearningData.User(LearningData.ColumnDev, LearningData.DevMessage), 250).ToList()); + } + + using var secondPool = new ModelPool(MlOptions.Create(dataDir)); + TenantModel reloaded = secondPool.GetOrCreate("tenant-eval-restart"); + + MlStatusResult status = reloaded.Status(); + Assert.Equal(50, status.Eval.Count); + Assert.Equal(1.0, status.Eval.Accuracy); + Assert.Equal(200, CountEvalRows(reloaded.DatabasePath)); + } + finally + { + TryDelete(dataDir); + } + } + + // Читает число строк eval_log прямо из SQLite-файла модели. + // dbPath: Путь к файлу модели. + private static long CountEvalRows(string dbPath) + { + using var connection = new SqliteConnection($"Data Source={dbPath}"); + connection.Open(); + using SqliteCommand command = connection.CreateCommand(); + command.CommandText = "SELECT count(*) FROM eval_log"; + return (long)command.ExecuteScalar()!; + } + + // Новая временная папка данных моделей. + private static string NewDataDir() + { + string dataDir = Path.Combine(Path.GetTempPath(), "deal-ml-persist-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dataDir); + return dataDir; + } + + // Удаляет временную папку (мусор в temp допустим при сбое). + // dataDir: Папка. + private static void TryDelete(string dataDir) + { + try + { + Directory.Delete(dataDir, recursive: true); + } + catch (IOException) + { + // Занятый каталог — тестовый мусор в temp допустим. + } + } +} diff --git a/src/ml-service/Deal.Ml.Tests/Ml/ModelPoolTests.cs b/src/ml-service/Deal.Ml.Tests/Ml/ModelPoolTests.cs index ad0b26f..377a7c1 100644 --- a/src/ml-service/Deal.Ml.Tests/Ml/ModelPoolTests.cs +++ b/src/ml-service/Deal.Ml.Tests/Ml/ModelPoolTests.cs @@ -1,84 +1,84 @@ -using Deal.Ml.Model; - -namespace Deal.Ml.Tests.Ml; - -/// -/// Тесты пула моделей -/// -public sealed class ModelPoolTests -{ - /// - /// Один тенант → один инстанс модели; файл создаётся только по первому обращению. - /// - [Fact] - public void GetOrCreate_ReturnsSameModelPerTenant() - { - RunWithPool(pool => - { - TenantModel first = pool.GetOrCreate("tenant-a"); - TenantModel second = pool.GetOrCreate("tenant-a"); - TenantModel other = pool.GetOrCreate("tenant-b"); - - Assert.Same(first, second); - Assert.NotSame(first, other); - Assert.False(File.Exists(first.DatabasePath)); // лениво: файла ещё нет - }); - } - - /// - /// Имя файла модели — <tenantId>.sqlite в каталоге данных. - /// - [Fact] - public void GetOrCreate_DbPathIsTenantFileUnderDataDir() - { - RunWithPool(pool => - { - TenantModel model = pool.GetOrCreate("t1"); - model.Status(); // первое обращение создаёт файл - - string expected = Path.Combine(pool.DataDirectory, "t1.sqlite"); - Assert.Equal(expected, model.DatabasePath); - Assert.True(File.Exists(expected)); - }); - } - - /// - /// Некорректный tenant-id - /// - [Fact] - public void GetOrCreate_InvalidTenantId_Throws() - { - RunWithPool(pool => - { - Assert.Throws(() => pool.GetOrCreate(string.Empty)); - Assert.Throws(() => pool.GetOrCreate(" ")); - Assert.Throws(() => pool.GetOrCreate("../outside")); - Assert.Throws(() => pool.GetOrCreate("a\\b")); - Assert.Throws(() => pool.GetOrCreate("..")); - }); - } - - // Прогоняет сценарий над свежим пулом во временной папке (очистка после). - // scenario: Сценарий с пулом. - private static void RunWithPool(Action scenario) - { - string dataDir = Path.Combine(Path.GetTempPath(), "deal-ml-pool-tests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(dataDir); - try - { - using var pool = new ModelPool(MlOptions.Create(dataDir)); - scenario(pool); - } - finally - { - try - { - Directory.Delete(dataDir, recursive: true); - } - catch (IOException) - { - // Занятый каталог — тестовый мусор в temp допустим. - } - } - } -} +using Deal.Ml.Model; + +namespace Deal.Ml.Tests.Ml; + +/// +/// Тесты пула моделей +/// +public sealed class ModelPoolTests +{ + /// + /// Один тенант → один инстанс модели; файл создаётся только по первому обращению. + /// + [Fact] + public void GetOrCreate_ReturnsSameModelPerTenant() + { + RunWithPool(pool => + { + TenantModel first = pool.GetOrCreate("tenant-a"); + TenantModel second = pool.GetOrCreate("tenant-a"); + TenantModel other = pool.GetOrCreate("tenant-b"); + + Assert.Same(first, second); + Assert.NotSame(first, other); + Assert.False(File.Exists(first.DatabasePath)); // лениво: файла ещё нет + }); + } + + /// + /// Имя файла модели — <tenantId>.sqlite в каталоге данных. + /// + [Fact] + public void GetOrCreate_DbPathIsTenantFileUnderDataDir() + { + RunWithPool(pool => + { + TenantModel model = pool.GetOrCreate("t1"); + model.Status(); // первое обращение создаёт файл + + string expected = Path.Combine(pool.DataDirectory, "t1.sqlite"); + Assert.Equal(expected, model.DatabasePath); + Assert.True(File.Exists(expected)); + }); + } + + /// + /// Некорректный tenant-id + /// + [Fact] + public void GetOrCreate_InvalidTenantId_Throws() + { + RunWithPool(pool => + { + Assert.Throws(() => pool.GetOrCreate(string.Empty)); + Assert.Throws(() => pool.GetOrCreate(" ")); + Assert.Throws(() => pool.GetOrCreate("../outside")); + Assert.Throws(() => pool.GetOrCreate("a\\b")); + Assert.Throws(() => pool.GetOrCreate("..")); + }); + } + + // Прогоняет сценарий над свежим пулом во временной папке (очистка после). + // scenario: Сценарий с пулом. + private static void RunWithPool(Action scenario) + { + string dataDir = Path.Combine(Path.GetTempPath(), "deal-ml-pool-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dataDir); + try + { + using var pool = new ModelPool(MlOptions.Create(dataDir)); + scenario(pool); + } + finally + { + try + { + Directory.Delete(dataDir, recursive: true); + } + catch (IOException) + { + // Занятый каталог — тестовый мусор в temp допустим. + } + } + } +} diff --git a/src/ml-service/Deal.Ml.Tests/Ml/TenantModelLearningTests.cs b/src/ml-service/Deal.Ml.Tests/Ml/TenantModelLearningTests.cs index e508100..cb30ed9 100644 --- a/src/ml-service/Deal.Ml.Tests/Ml/TenantModelLearningTests.cs +++ b/src/ml-service/Deal.Ml.Tests/Ml/TenantModelLearningTests.cs @@ -1,369 +1,369 @@ -using Deal.Ml.Model; - -namespace Deal.Ml.Tests.Ml; - -/// -/// Тесты движка модели -/// -public sealed class TenantModelLearningTests -{ - /// - /// Пустая модель: predict — фиксированный «не уверен» - /// - [Fact] - public void EmptyModel_PredictReturnsNotReadyShape() - { - RunWithPool(pool => - { - TenantModel model = pool.GetOrCreate("tenant-empty"); - - MlPredictResult predict = model.Predict(LearningData.DevMessage); - - Assert.False(predict.Take); - Assert.Null(predict.Label); - Assert.False(predict.Ready); - Assert.Empty(predict.Scores); - Assert.Equal(0, predict.Hits); - Assert.Null(predict.Margin); - Assert.Empty(predict.Terms); - Assert.Null(predict.Type); - - MlStatusResult status = model.Status(); - Assert.False(status.Ready); - Assert.Empty(status.Classes); - Assert.Equal(0, status.Learned); - Assert.Equal(0, status.Eval.Count); - Assert.Equal(0, status.Eval.Correct); - Assert.Equal(0.0, status.Eval.Accuracy); - }); - } - - /// - /// Пока суммарно примеров < MIN_TOTAL - /// - [Fact] - public void BelowMinTotal_ModelNotReadyAndDoesNotDecide() - { - RunWithPool(pool => - { - TenantModel model = pool.GetOrCreate("tenant-below-threshold"); - Train(model, BelowThresholdItems()); - - MlStatusResult status = model.Status(); - - Assert.False(status.Ready); - Assert.Equal(16, status.Learned); - - MlPredictResult predict = model.Predict(LearningData.SpamMessage); - Assert.False(predict.Take); - Assert.Null(predict.Label); - Assert.False(predict.Ready); - }); - } - - /// - /// Порог ready (суммарно ≥ 20, spam ≥ 4, не-спам ≥ 6) - /// - [Fact] - public void ReachingReadyThreshold_StartsDeciding() - { - RunWithPool(pool => - { - TenantModel model = pool.GetOrCreate("tenant-ready-threshold"); - Train(model, BelowThresholdItems()); - Assert.False(model.Status().Ready); - - Train(model, TypeHireItems()); - MlStatusResult status = model.Status(); - - Assert.True(status.Ready); - - MlPredictResult predict = model.Predict(LearningData.DevMessage); - Assert.True(predict.Take); - Assert.Equal(LearningData.ColumnDev, predict.Label); - Assert.Null(predict.Type); // только один t:-класс — тип не определяется - }); - } - - /// - /// Полный сценарий приёмки - /// - [Fact] - public void CanonicalTraining_LearnsColumnsSpamAndTypes() - { - RunWithPool(pool => - { - TenantModel model = pool.GetOrCreate("tenant-canonical"); - List items = LearningData.CanonicalTrainItems(); - - int learnedFirst = model.LearnBatch(items.Take(24).ToList()); - int learnedSecond = model.LearnBatch(items.Skip(24).ToList()); - - Assert.Equal(24, learnedFirst); - Assert.Equal(4, learnedSecond); - - MlStatusResult status = model.Status(); - Assert.True(status.Ready); - Assert.Equal(28, status.Learned); - Assert.Equal(8.0, status.Classes[LearningData.ColumnDev]); - Assert.Equal(6.0, status.Classes[LearningData.ColumnOrder]); - Assert.Equal(6.0, status.Classes[LearningData.SpamLabel]); - Assert.Equal(4.0, status.Classes[LearningData.TypeHire]); - Assert.Equal(4.0, status.Classes[LearningData.TypeOrder]); - Assert.Equal(4, status.Eval.Count); // 2 dev + 2 spam «реальных» действия после ready - Assert.Equal(4, status.Eval.Correct); - Assert.Equal(1.0, status.Eval.Accuracy); - - MlPredictResult dev = model.Predict(LearningData.DevMessage); - Assert.True(dev.Take); - Assert.Equal(LearningData.ColumnDev, dev.Label); - Assert.Equal(0.9, dev.Margin); - Assert.Equal(21, dev.Hits); // паритет с python (mlservice/model.py, контрольный прогон) - Assert.Equal(37.333, dev.Scores[LearningData.ColumnDev]); - Assert.Equal(33.6, dev.Scores[LearningData.TypeHire]); - Assert.NotNull(dev.Type); - Assert.True(dev.Type.Take); - Assert.Equal("hire", dev.Type.Label); - Assert.Equal(LearningData.TypeHire, dev.Type.Value); - Assert.NotEmpty(dev.Terms); - - MlPredictResult order = model.Predict(LearningData.OrderMessage); - Assert.True(order.Take); - Assert.Equal(LearningData.ColumnOrder, order.Label); - Assert.Equal(18, order.Hits); - Assert.Equal(30.857, order.Scores[LearningData.ColumnOrder]); - Assert.Equal(28.8, order.Scores[LearningData.TypeOrder]); - Assert.NotNull(order.Type); - Assert.Equal("order", order.Type.Label); - - MlPredictResult spam = model.Predict(LearningData.SpamMessage); - Assert.True(spam.Take); - Assert.Equal(LearningData.SpamLabel, spam.Label); - Assert.Equal(17, spam.Hits); - Assert.Equal(29.143, spam.Scores[LearningData.SpamLabel]); - Assert.Null(spam.Type); - - MlPredictResult oneTerm = model.Predict("ищу"); - Assert.True(oneTerm.Ready); - Assert.False(oneTerm.Take); - Assert.Equal(1, oneTerm.Hits); - Assert.Equal(1.714, oneTerm.Scores[LearningData.SpamLabel]); - - MlPredictResult twoTerms = model.Predict("ищу подработку"); - Assert.True(twoTerms.Take); - Assert.Equal(LearningData.SpamLabel, twoTerms.Label); - Assert.Equal(5.143, twoTerms.Scores[LearningData.SpamLabel]); - }); - } - - /// - /// MIN_HITS=2: одно совпадение у победителя ещё не «взятие» - /// - [Fact] - public void MinHitsThreshold_NeedsTwoMatchedTerms() - { - RunWithPool(pool => - { - TenantModel model = pool.GetOrCreate("tenant-min-hits"); - Train(model, BelowThresholdItems()); - Train(model, TypeHireItems()); // суммарно 20 → ready - - MlPredictResult single = model.Predict("ищу"); - - Assert.True(single.Ready); - Assert.False(single.Take); - Assert.Null(single.Label); - Assert.Equal(1, single.Hits); - Assert.NotNull(single.Margin); - - MlPredictResult two = model.Predict("ищу подработку"); - Assert.True(two.Take); - Assert.Equal(LearningData.SpamLabel, two.Label); - }); - } - - /// - /// Незнакомый текст готовой модели - /// - [Fact] - public void UnknownText_NotTakenButReady() - { - RunWithPool(pool => - { - TenantModel model = pool.GetOrCreate("tenant-unknown"); - Train(model, LearningData.CanonicalTrainItems()); - - MlPredictResult predict = model.Predict("совершенно посторонний текст без общих терминов"); - - Assert.True(predict.Ready); - Assert.False(predict.Take); - Assert.Null(predict.Label); - Assert.Empty(predict.Scores); - Assert.Null(predict.Margin); - Assert.Null(predict.Type); - }); - } - - /// - /// Адаптивный margin - /// - [Fact] - public void AdaptiveMargin_DropsAfter60Examples() - { - RunWithPool(pool => - { - TenantModel model = pool.GetOrCreate("tenant-margin"); - Train(model, LearningData.CanonicalTrainItems()); - - Assert.Equal(0.9, model.Predict(LearningData.DevMessage).Margin); - - Train(model, Enumerable.Repeat(LearningData.User(LearningData.ColumnDev, LearningData.DevMessage), 40).ToList()); - - double? margin = model.Predict(LearningData.DevMessage).Margin; - Assert.Equal(0.7, margin); - }); - } - - /// - /// delta < 0 «разучивает» - /// - [Fact] - public void NegativeDelta_UnlearnsUntilClassRemoved() - { - RunWithPool(pool => - { - TenantModel model = pool.GetOrCreate("tenant-unlearn"); - Train(model, Enumerable.Repeat(LearningData.User(LearningData.SpamLabel, LearningData.SpamMessage), 3).ToList()); - - Assert.Equal(3.0, model.Status().Classes[LearningData.SpamLabel]); - Assert.Equal(3, model.Status().Learned); - - Train(model, Enumerable.Repeat(LearningData.Signal(LearningData.SpamLabel, LearningData.SpamMessage, -1.0), 1).ToList()); - Assert.Equal(2.0, model.Status().Classes[LearningData.SpamLabel]); - - Train(model, Enumerable.Repeat(LearningData.Signal(LearningData.SpamLabel, LearningData.SpamMessage, -1.0), 2).ToList()); - - MlStatusResult status = model.Status(); - Assert.False(status.Classes.ContainsKey(LearningData.SpamLabel)); - Assert.Equal(0, status.Learned); - Assert.False(model.Predict(LearningData.SpamMessage).Take); - }); - } - - /// - /// Дробные веса ИИ-сигналов - /// - [Fact] - public void FractionalDelta_TrainsAiHypotheses() - { - RunWithPool(pool => - { - TenantModel model = pool.GetOrCreate("tenant-ai-signal"); - Train(model, [LearningData.Signal(LearningData.TypeHire, LearningData.DevMessage, 0.4)]); - - MlStatusResult status = model.Status(); - - Assert.False(status.Ready); - Assert.Equal(0.4, status.Classes[LearningData.TypeHire]); - Assert.Equal(0, status.Learned); // int(0.4) — как python int(sum) - }); - } - - /// - /// Пустые/пробельные text или label пропускаются тихо. - /// - [Fact] - public void LearnBatch_SkipsEmptyItems() - { - RunWithPool(pool => - { - TenantModel model = pool.GetOrCreate("tenant-empty-items"); - var items = new List - { - LearningData.User(LearningData.ColumnDev, string.Empty), - LearningData.User(" ", LearningData.DevMessage), - LearningData.User(LearningData.ColumnOrder, LearningData.OrderMessage), - }; - - int learned = model.LearnBatch(items); - - Assert.Equal(1, learned); - MlStatusResult status = model.Status(); - Assert.Equal(1, status.Learned); - Assert.True(status.Classes.ContainsKey(LearningData.ColumnOrder)); - Assert.False(status.Classes.ContainsKey(LearningData.ColumnDev)); - }); - } - - /// - /// Reset очищает модель и пересоздаёт файл при следующем обучении. - /// - [Fact] - public void Reset_ClearsWeightsAndFileRecreatedOnNextLearn() - { - RunWithPool(pool => - { - TenantModel model = pool.GetOrCreate("tenant-reset"); - Train(model, LearningData.CanonicalTrainItems()); - string dbPath = model.DatabasePath; - Assert.True(File.Exists(dbPath)); - - model.Reset(); - - Assert.False(File.Exists(dbPath)); - Assert.False(model.Status().Ready); - Assert.Empty(model.Status().Classes); - Assert.Equal(0, model.Status().Learned); - Assert.False(model.Predict(LearningData.DevMessage).Take); - - Train(model, Enumerable.Repeat(LearningData.User(LearningData.ColumnDev, LearningData.DevMessage), 3).ToList()); - - Assert.True(File.Exists(dbPath)); - Assert.Equal(3, model.Status().Learned); - }); - } - - // Батч: 6 колонок dev, 6 колонок order, 4 спама (суммарно 16 — ниже MIN_TOTAL). - private static List BelowThresholdItems() - { - var items = new List(); - items.AddRange(Enumerable.Repeat(LearningData.User(LearningData.ColumnDev, LearningData.DevMessage), 6)); - items.AddRange(Enumerable.Repeat(LearningData.User(LearningData.ColumnOrder, LearningData.OrderMessage), 6)); - items.AddRange(Enumerable.Repeat(LearningData.User(LearningData.SpamLabel, LearningData.SpamMessage), 4)); - return items; - } - - // 4 примера типа t:hire (доводят суммарный счёт до MIN_TOTAL). - private static List TypeHireItems() - => Enumerable.Repeat(LearningData.User(LearningData.TypeHire, LearningData.DevMessage), 4).ToList(); - - // Обучает модель батчем (один вызов LearnBatch). - // model: Модель. - // items: Примеры. - private static void Train(TenantModel model, List items) - => model.LearnBatch(items); - - // Прогоняет сценарий над свежим пулом во временной папке (очистка после). - // scenario: Сценарий с пулом. - private static void RunWithPool(Action scenario) - { - string dataDir = Path.Combine(Path.GetTempPath(), "deal-ml-model-tests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(dataDir); - try - { - using var pool = new ModelPool(MlOptions.Create(dataDir)); - scenario(pool); - } - finally - { - try - { - Directory.Delete(dataDir, recursive: true); - } - catch (IOException) - { - // Мусор в temp допустим, если каталог занят (Windows). - } - } - } -} +using Deal.Ml.Model; + +namespace Deal.Ml.Tests.Ml; + +/// +/// Тесты движка модели +/// +public sealed class TenantModelLearningTests +{ + /// + /// Пустая модель: predict — фиксированный «не уверен» + /// + [Fact] + public void EmptyModel_PredictReturnsNotReadyShape() + { + RunWithPool(pool => + { + TenantModel model = pool.GetOrCreate("tenant-empty"); + + MlPredictResult predict = model.Predict(LearningData.DevMessage); + + Assert.False(predict.Take); + Assert.Null(predict.Label); + Assert.False(predict.Ready); + Assert.Empty(predict.Scores); + Assert.Equal(0, predict.Hits); + Assert.Null(predict.Margin); + Assert.Empty(predict.Terms); + Assert.Null(predict.Type); + + MlStatusResult status = model.Status(); + Assert.False(status.Ready); + Assert.Empty(status.Classes); + Assert.Equal(0, status.Learned); + Assert.Equal(0, status.Eval.Count); + Assert.Equal(0, status.Eval.Correct); + Assert.Equal(0.0, status.Eval.Accuracy); + }); + } + + /// + /// Пока суммарно примеров < MIN_TOTAL + /// + [Fact] + public void BelowMinTotal_ModelNotReadyAndDoesNotDecide() + { + RunWithPool(pool => + { + TenantModel model = pool.GetOrCreate("tenant-below-threshold"); + Train(model, BelowThresholdItems()); + + MlStatusResult status = model.Status(); + + Assert.False(status.Ready); + Assert.Equal(16, status.Learned); + + MlPredictResult predict = model.Predict(LearningData.SpamMessage); + Assert.False(predict.Take); + Assert.Null(predict.Label); + Assert.False(predict.Ready); + }); + } + + /// + /// Порог ready (суммарно ≥ 20, spam ≥ 4, не-спам ≥ 6) + /// + [Fact] + public void ReachingReadyThreshold_StartsDeciding() + { + RunWithPool(pool => + { + TenantModel model = pool.GetOrCreate("tenant-ready-threshold"); + Train(model, BelowThresholdItems()); + Assert.False(model.Status().Ready); + + Train(model, TypeHireItems()); + MlStatusResult status = model.Status(); + + Assert.True(status.Ready); + + MlPredictResult predict = model.Predict(LearningData.DevMessage); + Assert.True(predict.Take); + Assert.Equal(LearningData.ColumnDev, predict.Label); + Assert.Null(predict.Type); // только один t:-класс — тип не определяется + }); + } + + /// + /// Полный сценарий приёмки + /// + [Fact] + public void CanonicalTraining_LearnsColumnsSpamAndTypes() + { + RunWithPool(pool => + { + TenantModel model = pool.GetOrCreate("tenant-canonical"); + List items = LearningData.CanonicalTrainItems(); + + int learnedFirst = model.LearnBatch(items.Take(24).ToList()); + int learnedSecond = model.LearnBatch(items.Skip(24).ToList()); + + Assert.Equal(24, learnedFirst); + Assert.Equal(4, learnedSecond); + + MlStatusResult status = model.Status(); + Assert.True(status.Ready); + Assert.Equal(28, status.Learned); + Assert.Equal(8.0, status.Classes[LearningData.ColumnDev]); + Assert.Equal(6.0, status.Classes[LearningData.ColumnOrder]); + Assert.Equal(6.0, status.Classes[LearningData.SpamLabel]); + Assert.Equal(4.0, status.Classes[LearningData.TypeHire]); + Assert.Equal(4.0, status.Classes[LearningData.TypeOrder]); + Assert.Equal(4, status.Eval.Count); // 2 dev + 2 spam «реальных» действия после ready + Assert.Equal(4, status.Eval.Correct); + Assert.Equal(1.0, status.Eval.Accuracy); + + MlPredictResult dev = model.Predict(LearningData.DevMessage); + Assert.True(dev.Take); + Assert.Equal(LearningData.ColumnDev, dev.Label); + Assert.Equal(0.9, dev.Margin); + Assert.Equal(21, dev.Hits); // паритет с python (mlservice/model.py, контрольный прогон) + Assert.Equal(37.333, dev.Scores[LearningData.ColumnDev]); + Assert.Equal(33.6, dev.Scores[LearningData.TypeHire]); + Assert.NotNull(dev.Type); + Assert.True(dev.Type.Take); + Assert.Equal("hire", dev.Type.Label); + Assert.Equal(LearningData.TypeHire, dev.Type.Value); + Assert.NotEmpty(dev.Terms); + + MlPredictResult order = model.Predict(LearningData.OrderMessage); + Assert.True(order.Take); + Assert.Equal(LearningData.ColumnOrder, order.Label); + Assert.Equal(18, order.Hits); + Assert.Equal(30.857, order.Scores[LearningData.ColumnOrder]); + Assert.Equal(28.8, order.Scores[LearningData.TypeOrder]); + Assert.NotNull(order.Type); + Assert.Equal("order", order.Type.Label); + + MlPredictResult spam = model.Predict(LearningData.SpamMessage); + Assert.True(spam.Take); + Assert.Equal(LearningData.SpamLabel, spam.Label); + Assert.Equal(17, spam.Hits); + Assert.Equal(29.143, spam.Scores[LearningData.SpamLabel]); + Assert.Null(spam.Type); + + MlPredictResult oneTerm = model.Predict("ищу"); + Assert.True(oneTerm.Ready); + Assert.False(oneTerm.Take); + Assert.Equal(1, oneTerm.Hits); + Assert.Equal(1.714, oneTerm.Scores[LearningData.SpamLabel]); + + MlPredictResult twoTerms = model.Predict("ищу подработку"); + Assert.True(twoTerms.Take); + Assert.Equal(LearningData.SpamLabel, twoTerms.Label); + Assert.Equal(5.143, twoTerms.Scores[LearningData.SpamLabel]); + }); + } + + /// + /// MIN_HITS=2: одно совпадение у победителя ещё не «взятие» + /// + [Fact] + public void MinHitsThreshold_NeedsTwoMatchedTerms() + { + RunWithPool(pool => + { + TenantModel model = pool.GetOrCreate("tenant-min-hits"); + Train(model, BelowThresholdItems()); + Train(model, TypeHireItems()); // суммарно 20 → ready + + MlPredictResult single = model.Predict("ищу"); + + Assert.True(single.Ready); + Assert.False(single.Take); + Assert.Null(single.Label); + Assert.Equal(1, single.Hits); + Assert.NotNull(single.Margin); + + MlPredictResult two = model.Predict("ищу подработку"); + Assert.True(two.Take); + Assert.Equal(LearningData.SpamLabel, two.Label); + }); + } + + /// + /// Незнакомый текст готовой модели + /// + [Fact] + public void UnknownText_NotTakenButReady() + { + RunWithPool(pool => + { + TenantModel model = pool.GetOrCreate("tenant-unknown"); + Train(model, LearningData.CanonicalTrainItems()); + + MlPredictResult predict = model.Predict("совершенно посторонний текст без общих терминов"); + + Assert.True(predict.Ready); + Assert.False(predict.Take); + Assert.Null(predict.Label); + Assert.Empty(predict.Scores); + Assert.Null(predict.Margin); + Assert.Null(predict.Type); + }); + } + + /// + /// Адаптивный margin + /// + [Fact] + public void AdaptiveMargin_DropsAfter60Examples() + { + RunWithPool(pool => + { + TenantModel model = pool.GetOrCreate("tenant-margin"); + Train(model, LearningData.CanonicalTrainItems()); + + Assert.Equal(0.9, model.Predict(LearningData.DevMessage).Margin); + + Train(model, Enumerable.Repeat(LearningData.User(LearningData.ColumnDev, LearningData.DevMessage), 40).ToList()); + + double? margin = model.Predict(LearningData.DevMessage).Margin; + Assert.Equal(0.7, margin); + }); + } + + /// + /// delta < 0 «разучивает» + /// + [Fact] + public void NegativeDelta_UnlearnsUntilClassRemoved() + { + RunWithPool(pool => + { + TenantModel model = pool.GetOrCreate("tenant-unlearn"); + Train(model, Enumerable.Repeat(LearningData.User(LearningData.SpamLabel, LearningData.SpamMessage), 3).ToList()); + + Assert.Equal(3.0, model.Status().Classes[LearningData.SpamLabel]); + Assert.Equal(3, model.Status().Learned); + + Train(model, Enumerable.Repeat(LearningData.Signal(LearningData.SpamLabel, LearningData.SpamMessage, -1.0), 1).ToList()); + Assert.Equal(2.0, model.Status().Classes[LearningData.SpamLabel]); + + Train(model, Enumerable.Repeat(LearningData.Signal(LearningData.SpamLabel, LearningData.SpamMessage, -1.0), 2).ToList()); + + MlStatusResult status = model.Status(); + Assert.False(status.Classes.ContainsKey(LearningData.SpamLabel)); + Assert.Equal(0, status.Learned); + Assert.False(model.Predict(LearningData.SpamMessage).Take); + }); + } + + /// + /// Дробные веса ИИ-сигналов + /// + [Fact] + public void FractionalDelta_TrainsAiHypotheses() + { + RunWithPool(pool => + { + TenantModel model = pool.GetOrCreate("tenant-ai-signal"); + Train(model, [LearningData.Signal(LearningData.TypeHire, LearningData.DevMessage, 0.4)]); + + MlStatusResult status = model.Status(); + + Assert.False(status.Ready); + Assert.Equal(0.4, status.Classes[LearningData.TypeHire]); + Assert.Equal(0, status.Learned); // int(0.4) — как python int(sum) + }); + } + + /// + /// Пустые/пробельные text или label пропускаются тихо. + /// + [Fact] + public void LearnBatch_SkipsEmptyItems() + { + RunWithPool(pool => + { + TenantModel model = pool.GetOrCreate("tenant-empty-items"); + var items = new List + { + LearningData.User(LearningData.ColumnDev, string.Empty), + LearningData.User(" ", LearningData.DevMessage), + LearningData.User(LearningData.ColumnOrder, LearningData.OrderMessage), + }; + + int learned = model.LearnBatch(items); + + Assert.Equal(1, learned); + MlStatusResult status = model.Status(); + Assert.Equal(1, status.Learned); + Assert.True(status.Classes.ContainsKey(LearningData.ColumnOrder)); + Assert.False(status.Classes.ContainsKey(LearningData.ColumnDev)); + }); + } + + /// + /// Reset очищает модель и пересоздаёт файл при следующем обучении. + /// + [Fact] + public void Reset_ClearsWeightsAndFileRecreatedOnNextLearn() + { + RunWithPool(pool => + { + TenantModel model = pool.GetOrCreate("tenant-reset"); + Train(model, LearningData.CanonicalTrainItems()); + string dbPath = model.DatabasePath; + Assert.True(File.Exists(dbPath)); + + model.Reset(); + + Assert.False(File.Exists(dbPath)); + Assert.False(model.Status().Ready); + Assert.Empty(model.Status().Classes); + Assert.Equal(0, model.Status().Learned); + Assert.False(model.Predict(LearningData.DevMessage).Take); + + Train(model, Enumerable.Repeat(LearningData.User(LearningData.ColumnDev, LearningData.DevMessage), 3).ToList()); + + Assert.True(File.Exists(dbPath)); + Assert.Equal(3, model.Status().Learned); + }); + } + + // Батч: 6 колонок dev, 6 колонок order, 4 спама (суммарно 16 — ниже MIN_TOTAL). + private static List BelowThresholdItems() + { + var items = new List(); + items.AddRange(Enumerable.Repeat(LearningData.User(LearningData.ColumnDev, LearningData.DevMessage), 6)); + items.AddRange(Enumerable.Repeat(LearningData.User(LearningData.ColumnOrder, LearningData.OrderMessage), 6)); + items.AddRange(Enumerable.Repeat(LearningData.User(LearningData.SpamLabel, LearningData.SpamMessage), 4)); + return items; + } + + // 4 примера типа t:hire (доводят суммарный счёт до MIN_TOTAL). + private static List TypeHireItems() + => Enumerable.Repeat(LearningData.User(LearningData.TypeHire, LearningData.DevMessage), 4).ToList(); + + // Обучает модель батчем (один вызов LearnBatch). + // model: Модель. + // items: Примеры. + private static void Train(TenantModel model, List items) + => model.LearnBatch(items); + + // Прогоняет сценарий над свежим пулом во временной папке (очистка после). + // scenario: Сценарий с пулом. + private static void RunWithPool(Action scenario) + { + string dataDir = Path.Combine(Path.GetTempPath(), "deal-ml-model-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dataDir); + try + { + using var pool = new ModelPool(MlOptions.Create(dataDir)); + scenario(pool); + } + finally + { + try + { + Directory.Delete(dataDir, recursive: true); + } + catch (IOException) + { + // Мусор в temp допустим, если каталог занят (Windows). + } + } + } +} diff --git a/src/ml-service/Deal.Ml.sln b/src/ml-service/Deal.Ml.sln index 6991e23..e2a07a9 100644 --- a/src/ml-service/Deal.Ml.sln +++ b/src/ml-service/Deal.Ml.sln @@ -1,76 +1,76 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Ml", "Deal.Ml\Deal.Ml.csproj", "{5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Proto", "..\contracts\Deal.Proto.csproj", "{F439AFEB-753E-4052-A7DC-FA96F6326FED}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Ml.Tests", "Deal.Ml.Tests\Deal.Ml.Tests.csproj", "{77DCB23F-E79F-4EBB-81ED-487C922E00DF}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Grpc.Hosting", "..\grpc-hosting\Deal.Grpc.Hosting\Deal.Grpc.Hosting.csproj", "{4C184075-7864-425F-BAD5-9695B7771A99}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Debug|x64.ActiveCfg = Debug|Any CPU - {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Debug|x64.Build.0 = Debug|Any CPU - {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Debug|x86.ActiveCfg = Debug|Any CPU - {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Debug|x86.Build.0 = Debug|Any CPU - {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Release|Any CPU.Build.0 = Release|Any CPU - {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Release|x64.ActiveCfg = Release|Any CPU - {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Release|x64.Build.0 = Release|Any CPU - {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Release|x86.ActiveCfg = Release|Any CPU - {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Release|x86.Build.0 = Release|Any CPU - {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Debug|x64.ActiveCfg = Debug|Any CPU - {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Debug|x64.Build.0 = Debug|Any CPU - {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Debug|x86.ActiveCfg = Debug|Any CPU - {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Debug|x86.Build.0 = Debug|Any CPU - {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Release|Any CPU.Build.0 = Release|Any CPU - {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Release|x64.ActiveCfg = Release|Any CPU - {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Release|x64.Build.0 = Release|Any CPU - {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Release|x86.ActiveCfg = Release|Any CPU - {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Release|x86.Build.0 = Release|Any CPU - {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Debug|x64.ActiveCfg = Debug|Any CPU - {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Debug|x64.Build.0 = Debug|Any CPU - {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Debug|x86.ActiveCfg = Debug|Any CPU - {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Debug|x86.Build.0 = Debug|Any CPU - {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Release|Any CPU.Build.0 = Release|Any CPU - {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Release|x64.ActiveCfg = Release|Any CPU - {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Release|x64.Build.0 = Release|Any CPU - {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Release|x86.ActiveCfg = Release|Any CPU - {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Release|x86.Build.0 = Release|Any CPU - {4C184075-7864-425F-BAD5-9695B7771A99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4C184075-7864-425F-BAD5-9695B7771A99}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4C184075-7864-425F-BAD5-9695B7771A99}.Debug|x64.ActiveCfg = Debug|Any CPU - {4C184075-7864-425F-BAD5-9695B7771A99}.Debug|x64.Build.0 = Debug|Any CPU - {4C184075-7864-425F-BAD5-9695B7771A99}.Debug|x86.ActiveCfg = Debug|Any CPU - {4C184075-7864-425F-BAD5-9695B7771A99}.Debug|x86.Build.0 = Debug|Any CPU - {4C184075-7864-425F-BAD5-9695B7771A99}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4C184075-7864-425F-BAD5-9695B7771A99}.Release|Any CPU.Build.0 = Release|Any CPU - {4C184075-7864-425F-BAD5-9695B7771A99}.Release|x64.ActiveCfg = Release|Any CPU - {4C184075-7864-425F-BAD5-9695B7771A99}.Release|x64.Build.0 = Release|Any CPU - {4C184075-7864-425F-BAD5-9695B7771A99}.Release|x86.ActiveCfg = Release|Any CPU - {4C184075-7864-425F-BAD5-9695B7771A99}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Ml", "Deal.Ml\Deal.Ml.csproj", "{5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Proto", "..\contracts\Deal.Proto.csproj", "{F439AFEB-753E-4052-A7DC-FA96F6326FED}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Ml.Tests", "Deal.Ml.Tests\Deal.Ml.Tests.csproj", "{77DCB23F-E79F-4EBB-81ED-487C922E00DF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Grpc.Hosting", "..\grpc-hosting\Deal.Grpc.Hosting\Deal.Grpc.Hosting.csproj", "{4C184075-7864-425F-BAD5-9695B7771A99}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Debug|x64.ActiveCfg = Debug|Any CPU + {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Debug|x64.Build.0 = Debug|Any CPU + {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Debug|x86.ActiveCfg = Debug|Any CPU + {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Debug|x86.Build.0 = Debug|Any CPU + {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Release|Any CPU.Build.0 = Release|Any CPU + {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Release|x64.ActiveCfg = Release|Any CPU + {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Release|x64.Build.0 = Release|Any CPU + {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Release|x86.ActiveCfg = Release|Any CPU + {5B11BCE5-8FE5-4E7D-8B09-1AC2E663151A}.Release|x86.Build.0 = Release|Any CPU + {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Debug|x64.ActiveCfg = Debug|Any CPU + {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Debug|x64.Build.0 = Debug|Any CPU + {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Debug|x86.ActiveCfg = Debug|Any CPU + {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Debug|x86.Build.0 = Debug|Any CPU + {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Release|Any CPU.Build.0 = Release|Any CPU + {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Release|x64.ActiveCfg = Release|Any CPU + {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Release|x64.Build.0 = Release|Any CPU + {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Release|x86.ActiveCfg = Release|Any CPU + {F439AFEB-753E-4052-A7DC-FA96F6326FED}.Release|x86.Build.0 = Release|Any CPU + {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Debug|x64.ActiveCfg = Debug|Any CPU + {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Debug|x64.Build.0 = Debug|Any CPU + {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Debug|x86.ActiveCfg = Debug|Any CPU + {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Debug|x86.Build.0 = Debug|Any CPU + {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Release|Any CPU.Build.0 = Release|Any CPU + {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Release|x64.ActiveCfg = Release|Any CPU + {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Release|x64.Build.0 = Release|Any CPU + {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Release|x86.ActiveCfg = Release|Any CPU + {77DCB23F-E79F-4EBB-81ED-487C922E00DF}.Release|x86.Build.0 = Release|Any CPU + {4C184075-7864-425F-BAD5-9695B7771A99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4C184075-7864-425F-BAD5-9695B7771A99}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C184075-7864-425F-BAD5-9695B7771A99}.Debug|x64.ActiveCfg = Debug|Any CPU + {4C184075-7864-425F-BAD5-9695B7771A99}.Debug|x64.Build.0 = Debug|Any CPU + {4C184075-7864-425F-BAD5-9695B7771A99}.Debug|x86.ActiveCfg = Debug|Any CPU + {4C184075-7864-425F-BAD5-9695B7771A99}.Debug|x86.Build.0 = Debug|Any CPU + {4C184075-7864-425F-BAD5-9695B7771A99}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4C184075-7864-425F-BAD5-9695B7771A99}.Release|Any CPU.Build.0 = Release|Any CPU + {4C184075-7864-425F-BAD5-9695B7771A99}.Release|x64.ActiveCfg = Release|Any CPU + {4C184075-7864-425F-BAD5-9695B7771A99}.Release|x64.Build.0 = Release|Any CPU + {4C184075-7864-425F-BAD5-9695B7771A99}.Release|x86.ActiveCfg = Release|Any CPU + {4C184075-7864-425F-BAD5-9695B7771A99}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/src/ml-service/Deal.Ml/Deal.Ml.csproj b/src/ml-service/Deal.Ml/Deal.Ml.csproj index 80def8d..344e82c 100644 --- a/src/ml-service/Deal.Ml/Deal.Ml.csproj +++ b/src/ml-service/Deal.Ml/Deal.Ml.csproj @@ -1,48 +1,48 @@ - - - - - Deal.Ml - Deal.Ml - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + Deal.Ml + Deal.Ml + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ml-service/Deal.Ml/Dockerfile b/src/ml-service/Deal.Ml/Dockerfile index 1436414..4cdda67 100644 --- a/src/ml-service/Deal.Ml/Dockerfile +++ b/src/ml-service/Deal.Ml/Dockerfile @@ -1,45 +1,45 @@ -# ml-service: gRPC-хост ML (план Task 3; Ruling 12 — запись в deploy/compose.dev.yml). -# -# КОНТЕКСТ СБОРКИ — корень репозитория: Deal.Ml.csproj ссылается на src/contracts/Deal.Proto.csproj -# (общий проект кодогенерации, Task 1) вне каталога сервиса, поэтому нельзя собирать из -# src/ml-service. Запуск из корня: docker build -f src/ml-service/Deal.Ml/Dockerfile . -# Порт — env GRPC_PORT (Program.cs), в compose.dev.yml задан 5103. - -# --- Этап сборки: restore + publish --- -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /repo - -# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников). -COPY src/contracts/Deal.Proto.csproj src/contracts/ -COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/ -COPY src/ml-service/Directory.Build.props src/ml-service/ -COPY src/ml-service/Deal.Ml/Deal.Ml.csproj src/ml-service/Deal.Ml/ -RUN dotnet restore src/ml-service/Deal.Ml/Deal.Ml.csproj - -# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса. -COPY src/contracts/ src/contracts/ -COPY src/grpc-hosting/ src/grpc-hosting/ -COPY src/ml-service/Deal.Ml/ src/ml-service/Deal.Ml/ -RUN dotnet publish src/ml-service/Deal.Ml/Deal.Ml.csproj -c Release -o /app/publish - -# --- Runtime-этап --- -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final -WORKDIR /app -EXPOSE 5103 -COPY --from=build /app/publish . - -# grpc_health_probe — healthcheck контейнера (Ruling 12): gRPC-health освобождён от service-token -# (см. ServiceTokenInterceptor), поэтому проба идёт без metadata. -COPY --from=ghcr.io/grpc-ecosystem/grpc-health-probe:v0.4.35 /ko-app/grpc-health-probe /bin/grpc_health_probe - -# Файлы моделей тенантов — data/ml/.sqlite (Ruling 4): каталог монтируется volume-ом -# deal_ml_data из compose.dev.yml; каталог и файлы создаёт движок модели (задачи 5–6). - -# Non-root: процесс работает пользователем deal (UID 10001); /data/ml — каталог моделей (volume). -RUN useradd --uid 10001 --shell /usr/sbin/nologin --no-create-home deal \ - && mkdir -p /data/ml \ - && chown -R deal:deal /data/ml -ENV HOME=/tmp -USER deal - -ENTRYPOINT ["dotnet", "Deal.Ml.dll"] +# ml-service: gRPC-хост ML (план Task 3; Ruling 12 — запись в deploy/compose.dev.yml). +# +# КОНТЕКСТ СБОРКИ — корень репозитория: Deal.Ml.csproj ссылается на src/contracts/Deal.Proto.csproj +# (общий проект кодогенерации, Task 1) вне каталога сервиса, поэтому нельзя собирать из +# src/ml-service. Запуск из корня: docker build -f src/ml-service/Deal.Ml/Dockerfile . +# Порт — env GRPC_PORT (Program.cs), в compose.dev.yml задан 5103. + +# --- Этап сборки: restore + publish --- +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /repo + +# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников). +COPY src/contracts/Deal.Proto.csproj src/contracts/ +COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/ +COPY src/ml-service/Directory.Build.props src/ml-service/ +COPY src/ml-service/Deal.Ml/Deal.Ml.csproj src/ml-service/Deal.Ml/ +RUN dotnet restore src/ml-service/Deal.Ml/Deal.Ml.csproj + +# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса. +COPY src/contracts/ src/contracts/ +COPY src/grpc-hosting/ src/grpc-hosting/ +COPY src/ml-service/Deal.Ml/ src/ml-service/Deal.Ml/ +RUN dotnet publish src/ml-service/Deal.Ml/Deal.Ml.csproj -c Release -o /app/publish + +# --- Runtime-этап --- +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +EXPOSE 5103 +COPY --from=build /app/publish . + +# grpc_health_probe — healthcheck контейнера (Ruling 12): gRPC-health освобождён от service-token +# (см. ServiceTokenInterceptor), поэтому проба идёт без metadata. +COPY --from=ghcr.io/grpc-ecosystem/grpc-health-probe:v0.4.35 /ko-app/grpc-health-probe /bin/grpc_health_probe + +# Файлы моделей тенантов — data/ml/.sqlite (Ruling 4): каталог монтируется volume-ом +# deal_ml_data из compose.dev.yml; каталог и файлы создаёт движок модели (задачи 5–6). + +# Non-root: процесс работает пользователем deal (UID 10001); /data/ml — каталог моделей (volume). +RUN useradd --uid 10001 --shell /usr/sbin/nologin --no-create-home deal \ + && mkdir -p /data/ml \ + && chown -R deal:deal /data/ml +ENV HOME=/tmp +USER deal + +ENTRYPOINT ["dotnet", "Deal.Ml.dll"] diff --git a/src/ml-service/Deal.Ml/MlServiceHost.cs b/src/ml-service/Deal.Ml/MlServiceHost.cs index 2468153..6e64158 100644 --- a/src/ml-service/Deal.Ml/MlServiceHost.cs +++ b/src/ml-service/Deal.Ml/MlServiceHost.cs @@ -1,49 +1,49 @@ -using Deal.Grpc.Hosting.Interceptors; -using Deal.Grpc.Hosting.Models; -using Deal.Grpc.Hosting.Options; -using Deal.Grpc.Hosting.Services; -using Deal.Ml.Model; - -namespace Deal.Ml; - -/// -/// Собирает WebApplication gRPC-хоста ml-service. -/// -public static class MlServiceHost -{ - /// - /// Создаёт (не запускает) хост - /// - /// TCP-порт Kestrel. - /// Аргументы командной строки (Program.cs); в тестах не нужны. - /// Опциональный хук DI для тестов (подмена зависимостей фейками; для ml-логики обычно не нужен — харнессы тестов направляют каталог моделей env DEAL_ML_DATA_DIR во временную папку). - /// Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog. Тесты хост поднимают БЕЗ этого хука — логирование файлов/консоли тестам не нужно. - /// Собранный хост; запуск — StartAsync/RunAsync у вызывающего. - public static WebApplication Create( - int grpcPort, - string[]? args = null, - Action? configureServices = null, - Action? configureBuilder = null) - { - WebApplicationBuilder builder = WebApplication.CreateBuilder(args ?? []); - - MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder); - GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates); - builder.Services.AddDealGrpcServer(); - builder.Services.AddReadyHealthCheck("хост ml-service готов"); - - MlOptions mlOptions = MlOptions.FromConfiguration(builder.Configuration, builder.Environment); - builder.Services.AddSingleton(mlOptions); - builder.Services.AddSingleton(); - - configureServices?.Invoke(builder.Services); - configureBuilder?.Invoke(builder); - - WebApplication app = builder.Build(); - - app.MapGrpcService(); - app.MapGrpcHealthChecksService(); - - return app; - } -} +using Deal.Grpc.Hosting.Interceptors; +using Deal.Grpc.Hosting.Models; +using Deal.Grpc.Hosting.Options; +using Deal.Grpc.Hosting.Services; +using Deal.Ml.Model; + +namespace Deal.Ml; + +/// +/// Собирает WebApplication gRPC-хоста ml-service. +/// +public static class MlServiceHost +{ + /// + /// Создаёт (не запускает) хост + /// + /// TCP-порт Kestrel. + /// Аргументы командной строки (Program.cs); в тестах не нужны. + /// Опциональный хук DI для тестов (подмена зависимостей фейками; для ml-логики обычно не нужен — харнессы тестов направляют каталог моделей env DEAL_ML_DATA_DIR во временную папку). + /// Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog. Тесты хост поднимают БЕЗ этого хука — логирование файлов/консоли тестам не нужно. + /// Собранный хост; запуск — StartAsync/RunAsync у вызывающего. + public static WebApplication Create( + int grpcPort, + string[]? args = null, + Action? configureServices = null, + Action? configureBuilder = null) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(args ?? []); + + MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder); + GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates); + builder.Services.AddDealGrpcServer(); + builder.Services.AddReadyHealthCheck("хост ml-service готов"); + + MlOptions mlOptions = MlOptions.FromConfiguration(builder.Configuration, builder.Environment); + builder.Services.AddSingleton(mlOptions); + builder.Services.AddSingleton(); + + configureServices?.Invoke(builder.Services); + configureBuilder?.Invoke(builder); + + WebApplication app = builder.Build(); + + app.MapGrpcService(); + app.MapGrpcHealthChecksService(); + + return app; + } +} diff --git a/src/ml-service/Deal.Ml/MlServiceImpl.cs b/src/ml-service/Deal.Ml/MlServiceImpl.cs index 4855a6a..f8cb7d3 100644 --- a/src/ml-service/Deal.Ml/MlServiceImpl.cs +++ b/src/ml-service/Deal.Ml/MlServiceImpl.cs @@ -1,268 +1,268 @@ -using Deal.Grpc.Ml; -using Deal.Ml.Model; -using Grpc.Core; - -namespace Deal.Ml; - -/// -/// Реализация серверной стороны Deal.Grpc.Ml.MlService — команды ядра в ml-service. -/// -public sealed class MlServiceImpl : MlService.MlServiceBase -{ - public const string TenantIdMetadataKey = "tenant-id"; - - private const string TenantIdMissingDetail = "tenant-id отсутствует в metadata"; - - // Деталь отказа: tenant-id некорректен как имя файла модели (INVALID_ARGUMENT). - private const string InvalidTenantIdDetail = "Некорректный tenant-id"; - - private const string StorageUnavailableDetail = "Хранилище модели недоступно — повторите запрос позже"; - - private const int MaxTrainBatchItems = 100; - - // Потолок длины текста примера/предсказания (source_msg ядро обрезает до 4000). - private const int MaxTextLength = 4000; - - // Потолок длины метки примера (id доски/spam/t:hire/t:order — короткие значения). - private const int MaxExampleLabelLength = 64; - - // Деталь отказа: батч больше контрактного лимита (INVALID_ARGUMENT). - private const string TrainBatchTooLargeDetail = "Батч обучения больше 100 примеров"; - - // Деталь отказа: текст обучающего примера длиннее лимита (INVALID_ARGUMENT). - private const string ExampleTextTooLongDetail = "Слишком длинный текст обучающего примера"; - - // Деталь отказа: метка обучающего примера длиннее лимита (INVALID_ARGUMENT). - private const string ExampleLabelTooLongDetail = "Слишком длинная метка обучающего примера"; - - // Деталь отказа: текст предсказания длиннее лимита (INVALID_ARGUMENT). - private const string PredictTextTooLongDetail = "Слишком длинный текст сообщения"; - - private const string ResetFailedDetail = "Не удалось сбросить модель — повторите попытку позже"; - - private readonly ModelPool _pool; - private readonly ILogger _logger; - - /// - /// Создаёт сервис команд ядра поверх пула моделей. - /// - /// Пул моделей тенантов (ленивое создание/загрузка). - /// Логгер аудита. - public MlServiceImpl(ModelPool pool, ILogger logger) - { - _pool = pool; - _logger = logger; - } - - /// - /// Predict — решение по тексту сообщения - /// - public override Task Predict(PredictRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - EnsureLengthAtMost(request.Text, MaxTextLength, PredictTextTooLongDetail); - TenantModel model = ResolveModel(tenantId); - try - { - MlPredictResult result = model.Predict(request.Text); - _logger.LogInformation( - "Аудит: tenant {TenantId} predict → take={Take}, label={Label}, ready={Ready}", - tenantId, result.Take, result.Label, result.Ready); - return Task.FromResult(ToPredictReply(result)); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogWarning(exception, "Аудит: tenant {TenantId} predict → хранилище недоступно", tenantId); - throw new RpcException(new Status(StatusCode.Unavailable, StorageUnavailableDetail)); - } - } - - /// - /// Status — статус модели тенанта - /// - public override Task Status(StatusRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - TenantModel model = ResolveModel(tenantId); - try - { - MlStatusResult result = model.Status(); - _logger.LogInformation( - "Аудит: tenant {TenantId} status → ready={Ready}, learned={Learned}", - tenantId, result.Ready, result.Learned); - return Task.FromResult(ToStatusReply(result)); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogWarning(exception, "Аудит: tenant {TenantId} status → хранилище недоступно", tenantId); - throw new RpcException(new Status(StatusCode.Unavailable, StorageUnavailableDetail)); - } - } - - /// - /// Reset — полный сброс модели тенанта - /// - public override Task Reset(ResetRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - TenantModel model = ResolveModel(tenantId); - try - { - model.Reset(); - _logger.LogInformation("Аудит: tenant {TenantId} reset → ok", tenantId); - return Task.FromResult(new ResetReply { Ok = true }); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogWarning(exception, "Аудит: tenant {TenantId} reset → сбой хранилища", tenantId); - return Task.FromResult(new ResetReply { Ok = false, Error = ResetFailedDetail }); - } - } - - /// - /// TrainBatch — пакетное обучение - /// - public override Task TrainBatch(TrainBatchRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - EnsureTrainBatchWithinBounds(request); - TenantModel model = ResolveModel(tenantId); - try - { - var items = new List(request.Items.Count); - foreach (TrainExample example in request.Items) - { - items.Add(new LearnItem(example.Text, example.Label, example.Delta)); - } - - int learned = model.LearnBatch(items); - _logger.LogInformation( - "Аудит: tenant {TenantId} train_batch → learned={Learned} (items={Items})", - tenantId, learned, items.Count); - return Task.FromResult(new TrainBatchReply { Learned = learned }); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogWarning(exception, "Аудит: tenant {TenantId} train_batch → хранилище недоступно", tenantId); - throw new RpcException(new Status(StatusCode.Unavailable, StorageUnavailableDetail)); - } - } - - // INVALID_ARGUMENT при превышении лимита длины текстового поля (серверный enforcement ml.proto). - // value: Значение поля запроса (в proto строка не бывает null). - // maxLength: Допустимый максимум символов. - // detail: Текст отказа (detail RPC). - private static void EnsureLengthAtMost( - string value, - int maxLength, - string detail) - { - if (value.Length > maxLength) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, detail)); - } - } - - // Проверяет батч обучения на границе: число примеров ≤100 и длины text/label (INVALID_ARGUMENT). - // request: Запрос обучения. - private static void EnsureTrainBatchWithinBounds(TrainBatchRequest request) - { - if (request.Items.Count > MaxTrainBatchItems) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, TrainBatchTooLargeDetail)); - } - - foreach (TrainExample example in request.Items) - { - EnsureLengthAtMost(example.Text, MaxTextLength, ExampleTextTooLongDetail); - EnsureLengthAtMost(example.Label, MaxExampleLabelLength, ExampleLabelTooLongDetail); - } - } - - private static string RequireTenantId(ServerCallContext context) - { - string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey); - if (string.IsNullOrWhiteSpace(tenantId)) - { - throw new RpcException(new Status(StatusCode.Unauthenticated, TenantIdMissingDetail)); - } - - return tenantId; - } - - // Берёт модель тенанта из пула (создаёт лениво); некорректный id — INVALID_ARGUMENT. - // tenantId: Id тенанта (непустой). - private TenantModel ResolveModel(string tenantId) - { - try - { - return _pool.GetOrCreate(tenantId); - } - catch (ArgumentException) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, InvalidTenantIdDetail)); - } - } - - private static PredictReply ToPredictReply(MlPredictResult result) - { - var reply = new PredictReply - { - Take = result.Take, - Hits = result.Hits, - Ready = result.Ready, - }; - - if (result.Label is not null) - { - reply.Label = result.Label; - } - - foreach ((string label, double score) in result.Scores) - { - reply.Scores[label] = score; - } - - if (result.Margin.HasValue) - { - reply.Margin = result.Margin.Value; - } - - reply.Terms.AddRange(result.Terms); - - if (result.Type is not null) - { - reply.Type = new TypeDecision - { - Take = result.Type.Take, - Label = result.Type.Label, - Value = result.Type.Value, - Margin = result.Type.Margin, - }; - } - - return reply; - } - - private static StatusReply ToStatusReply(MlStatusResult result) - { - var reply = new StatusReply - { - Ready = result.Ready, - Learned = result.Learned, - Eval = new ModelEval - { - Count = result.Eval.Count, - Correct = result.Eval.Correct, - Accuracy = result.Eval.Accuracy, - }, - }; - - foreach ((string label, double weight) in result.Classes) - { - reply.Classes[label] = weight; - } - - return reply; - } -} +using Deal.Grpc.Ml; +using Deal.Ml.Model; +using Grpc.Core; + +namespace Deal.Ml; + +/// +/// Реализация серверной стороны Deal.Grpc.Ml.MlService — команды ядра в ml-service. +/// +public sealed class MlServiceImpl : MlService.MlServiceBase +{ + public const string TenantIdMetadataKey = "tenant-id"; + + private const string TenantIdMissingDetail = "tenant-id отсутствует в metadata"; + + // Деталь отказа: tenant-id некорректен как имя файла модели (INVALID_ARGUMENT). + private const string InvalidTenantIdDetail = "Некорректный tenant-id"; + + private const string StorageUnavailableDetail = "Хранилище модели недоступно — повторите запрос позже"; + + private const int MaxTrainBatchItems = 100; + + // Потолок длины текста примера/предсказания (source_msg ядро обрезает до 4000). + private const int MaxTextLength = 4000; + + // Потолок длины метки примера (id доски/spam/t:hire/t:order — короткие значения). + private const int MaxExampleLabelLength = 64; + + // Деталь отказа: батч больше контрактного лимита (INVALID_ARGUMENT). + private const string TrainBatchTooLargeDetail = "Батч обучения больше 100 примеров"; + + // Деталь отказа: текст обучающего примера длиннее лимита (INVALID_ARGUMENT). + private const string ExampleTextTooLongDetail = "Слишком длинный текст обучающего примера"; + + // Деталь отказа: метка обучающего примера длиннее лимита (INVALID_ARGUMENT). + private const string ExampleLabelTooLongDetail = "Слишком длинная метка обучающего примера"; + + // Деталь отказа: текст предсказания длиннее лимита (INVALID_ARGUMENT). + private const string PredictTextTooLongDetail = "Слишком длинный текст сообщения"; + + private const string ResetFailedDetail = "Не удалось сбросить модель — повторите попытку позже"; + + private readonly ModelPool _pool; + private readonly ILogger _logger; + + /// + /// Создаёт сервис команд ядра поверх пула моделей. + /// + /// Пул моделей тенантов (ленивое создание/загрузка). + /// Логгер аудита. + public MlServiceImpl(ModelPool pool, ILogger logger) + { + _pool = pool; + _logger = logger; + } + + /// + /// Predict — решение по тексту сообщения + /// + public override Task Predict(PredictRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + EnsureLengthAtMost(request.Text, MaxTextLength, PredictTextTooLongDetail); + TenantModel model = ResolveModel(tenantId); + try + { + MlPredictResult result = model.Predict(request.Text); + _logger.LogInformation( + "Аудит: tenant {TenantId} predict → take={Take}, label={Label}, ready={Ready}", + tenantId, result.Take, result.Label, result.Ready); + return Task.FromResult(ToPredictReply(result)); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "Аудит: tenant {TenantId} predict → хранилище недоступно", tenantId); + throw new RpcException(new Status(StatusCode.Unavailable, StorageUnavailableDetail)); + } + } + + /// + /// Status — статус модели тенанта + /// + public override Task Status(StatusRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + TenantModel model = ResolveModel(tenantId); + try + { + MlStatusResult result = model.Status(); + _logger.LogInformation( + "Аудит: tenant {TenantId} status → ready={Ready}, learned={Learned}", + tenantId, result.Ready, result.Learned); + return Task.FromResult(ToStatusReply(result)); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "Аудит: tenant {TenantId} status → хранилище недоступно", tenantId); + throw new RpcException(new Status(StatusCode.Unavailable, StorageUnavailableDetail)); + } + } + + /// + /// Reset — полный сброс модели тенанта + /// + public override Task Reset(ResetRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + TenantModel model = ResolveModel(tenantId); + try + { + model.Reset(); + _logger.LogInformation("Аудит: tenant {TenantId} reset → ok", tenantId); + return Task.FromResult(new ResetReply { Ok = true }); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "Аудит: tenant {TenantId} reset → сбой хранилища", tenantId); + return Task.FromResult(new ResetReply { Ok = false, Error = ResetFailedDetail }); + } + } + + /// + /// TrainBatch — пакетное обучение + /// + public override Task TrainBatch(TrainBatchRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + EnsureTrainBatchWithinBounds(request); + TenantModel model = ResolveModel(tenantId); + try + { + var items = new List(request.Items.Count); + foreach (TrainExample example in request.Items) + { + items.Add(new LearnItem(example.Text, example.Label, example.Delta)); + } + + int learned = model.LearnBatch(items); + _logger.LogInformation( + "Аудит: tenant {TenantId} train_batch → learned={Learned} (items={Items})", + tenantId, learned, items.Count); + return Task.FromResult(new TrainBatchReply { Learned = learned }); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "Аудит: tenant {TenantId} train_batch → хранилище недоступно", tenantId); + throw new RpcException(new Status(StatusCode.Unavailable, StorageUnavailableDetail)); + } + } + + // INVALID_ARGUMENT при превышении лимита длины текстового поля (серверный enforcement ml.proto). + // value: Значение поля запроса (в proto строка не бывает null). + // maxLength: Допустимый максимум символов. + // detail: Текст отказа (detail RPC). + private static void EnsureLengthAtMost( + string value, + int maxLength, + string detail) + { + if (value.Length > maxLength) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, detail)); + } + } + + // Проверяет батч обучения на границе: число примеров ≤100 и длины text/label (INVALID_ARGUMENT). + // request: Запрос обучения. + private static void EnsureTrainBatchWithinBounds(TrainBatchRequest request) + { + if (request.Items.Count > MaxTrainBatchItems) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, TrainBatchTooLargeDetail)); + } + + foreach (TrainExample example in request.Items) + { + EnsureLengthAtMost(example.Text, MaxTextLength, ExampleTextTooLongDetail); + EnsureLengthAtMost(example.Label, MaxExampleLabelLength, ExampleLabelTooLongDetail); + } + } + + private static string RequireTenantId(ServerCallContext context) + { + string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey); + if (string.IsNullOrWhiteSpace(tenantId)) + { + throw new RpcException(new Status(StatusCode.Unauthenticated, TenantIdMissingDetail)); + } + + return tenantId; + } + + // Берёт модель тенанта из пула (создаёт лениво); некорректный id — INVALID_ARGUMENT. + // tenantId: Id тенанта (непустой). + private TenantModel ResolveModel(string tenantId) + { + try + { + return _pool.GetOrCreate(tenantId); + } + catch (ArgumentException) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, InvalidTenantIdDetail)); + } + } + + private static PredictReply ToPredictReply(MlPredictResult result) + { + var reply = new PredictReply + { + Take = result.Take, + Hits = result.Hits, + Ready = result.Ready, + }; + + if (result.Label is not null) + { + reply.Label = result.Label; + } + + foreach ((string label, double score) in result.Scores) + { + reply.Scores[label] = score; + } + + if (result.Margin.HasValue) + { + reply.Margin = result.Margin.Value; + } + + reply.Terms.AddRange(result.Terms); + + if (result.Type is not null) + { + reply.Type = new TypeDecision + { + Take = result.Type.Take, + Label = result.Type.Label, + Value = result.Type.Value, + Margin = result.Type.Margin, + }; + } + + return reply; + } + + private static StatusReply ToStatusReply(MlStatusResult result) + { + var reply = new StatusReply + { + Ready = result.Ready, + Learned = result.Learned, + Eval = new ModelEval + { + Count = result.Eval.Count, + Correct = result.Eval.Correct, + Accuracy = result.Eval.Accuracy, + }, + }; + + foreach ((string label, double weight) in result.Classes) + { + reply.Classes[label] = weight; + } + + return reply; + } +} diff --git a/src/ml-service/Deal.Ml/Model/EvalEntry.cs b/src/ml-service/Deal.Ml/Model/EvalEntry.cs index 9e984ab..094c8e5 100644 --- a/src/ml-service/Deal.Ml/Model/EvalEntry.cs +++ b/src/ml-service/Deal.Ml/Model/EvalEntry.cs @@ -1,10 +1,10 @@ -namespace Deal.Ml.Model; - -/// -/// Строка журнала самооценки модели -/// -/// Момент решения (epoch-ms UTC). -/// Метка действия пользователя («правильный ответ»). -/// Метка, которую предсказала модель (пусто — не брала). -/// Совпало ли предсказание с действием. -public sealed record EvalEntry(long CreatedAtMs, string ExpectedLabel, string PredictedLabel, bool Correct); +namespace Deal.Ml.Model; + +/// +/// Строка журнала самооценки модели +/// +/// Момент решения (epoch-ms UTC). +/// Метка действия пользователя («правильный ответ»). +/// Метка, которую предсказала модель (пусто — не брала). +/// Совпало ли предсказание с действием. +public sealed record EvalEntry(long CreatedAtMs, string ExpectedLabel, string PredictedLabel, bool Correct); diff --git a/src/ml-service/Deal.Ml/Model/LearnItem.cs b/src/ml-service/Deal.Ml/Model/LearnItem.cs index 4967a14..9941a33 100644 --- a/src/ml-service/Deal.Ml/Model/LearnItem.cs +++ b/src/ml-service/Deal.Ml/Model/LearnItem.cs @@ -1,9 +1,9 @@ -namespace Deal.Ml.Model; - -/// -/// Один обучающий пример тенанта. -/// -/// Текст примера (source_msg карточки или title). -/// Метка: id колонки (b_…), spam либо тип t:hire/t:order. -/// Вес сигнала (знак — учить/разучивать). -public sealed record LearnItem(string Text, string Label, double Delta); +namespace Deal.Ml.Model; + +/// +/// Один обучающий пример тенанта. +/// +/// Текст примера (source_msg карточки или title). +/// Метка: id колонки (b_…), spam либо тип t:hire/t:order. +/// Вес сигнала (знак — учить/разучивать). +public sealed record LearnItem(string Text, string Label, double Delta); diff --git a/src/ml-service/Deal.Ml/Model/MlEvalInfo.cs b/src/ml-service/Deal.Ml/Model/MlEvalInfo.cs index 990ba9c..9f10f81 100644 --- a/src/ml-service/Deal.Ml/Model/MlEvalInfo.cs +++ b/src/ml-service/Deal.Ml/Model/MlEvalInfo.cs @@ -1,9 +1,9 @@ -namespace Deal.Ml.Model; - -/// -/// Окно самооценки модели. -/// -/// Решений в окне (последние EVAL_WINDOW подтверждённых решений). -/// Из них совпавших с действием пользователя. -/// Доля верных (correct/count, 0..1; 0 при пустом окне; round 3). -public sealed record MlEvalInfo(int Count, int Correct, double Accuracy); +namespace Deal.Ml.Model; + +/// +/// Окно самооценки модели. +/// +/// Решений в окне (последние EVAL_WINDOW подтверждённых решений). +/// Из них совпавших с действием пользователя. +/// Доля верных (correct/count, 0..1; 0 при пустом окне; round 3). +public sealed record MlEvalInfo(int Count, int Correct, double Accuracy); diff --git a/src/ml-service/Deal.Ml/Model/MlOptions.cs b/src/ml-service/Deal.Ml/Model/MlOptions.cs index bf757d0..f9b9962 100644 --- a/src/ml-service/Deal.Ml/Model/MlOptions.cs +++ b/src/ml-service/Deal.Ml/Model/MlOptions.cs @@ -1,70 +1,70 @@ -namespace Deal.Ml.Model; - -/// -/// Конфигурация хранения моделей ml-service. -/// -public sealed class MlOptions -{ - /// - /// Env-ключ каталога файлов моделей - /// - public const string DataDirEnvVarName = "DEAL_ML_DATA_DIR"; - - /// - /// Относительный каталог моделей по умолчанию - /// - public const string DefaultDataDirRelative = "data/ml"; - - private MlOptions(string dataDirectory) - { - DataDirectory = dataDirectory; - } - - /// - /// Абсолютный путь к каталогу файлов моделей data/ml/<tenantId>.sqlite. - /// - public string DataDirectory { get; } - - /// - /// Создаёт опции с уже известным каталогом - /// - /// Каталог файлов моделей. - public static MlOptions Create(string dataDirectory) - { - if (string.IsNullOrWhiteSpace(dataDirectory)) - { - throw new ArgumentException("Каталог данных моделей не задан.", nameof(dataDirectory)); - } - - return new MlOptions(Path.GetFullPath(dataDirectory.Trim())); - } - - /// - /// Читает конфигурацию из env и корня хоста - /// - /// Конфигурация хоста (env-провайдер WebApplicationBuilder). - /// Окружение хоста (ContentRootPath для каталога по умолчанию). - /// Опции хранения моделей. - public static MlOptions FromConfiguration(IConfiguration configuration, IHostEnvironment environment) - { - string? configuredDir = configuration[DataDirEnvVarName]; - string dataDirectory = ResolveDataDirectory(configuredDir, environment.ContentRootPath); - return new MlOptions(dataDirectory); - } - - // Разрешает каталог моделей: абсолютный env-путь как есть, иначе — под ContentRoot. - // configuredDir: Значение DEAL_ML_DATA_DIR (может быть пустым). - // contentRootPath: ContentRoot хоста. - private static string ResolveDataDirectory(string? configuredDir, string contentRootPath) - { - if (string.IsNullOrWhiteSpace(configuredDir)) - { - return Path.Combine(contentRootPath, DefaultDataDirRelative); - } - - string trimmed = configuredDir.Trim(); - return Path.IsPathRooted(trimmed) - ? trimmed - : Path.Combine(contentRootPath, trimmed); - } -} +namespace Deal.Ml.Model; + +/// +/// Конфигурация хранения моделей ml-service. +/// +public sealed class MlOptions +{ + /// + /// Env-ключ каталога файлов моделей + /// + public const string DataDirEnvVarName = "DEAL_ML_DATA_DIR"; + + /// + /// Относительный каталог моделей по умолчанию + /// + public const string DefaultDataDirRelative = "data/ml"; + + private MlOptions(string dataDirectory) + { + DataDirectory = dataDirectory; + } + + /// + /// Абсолютный путь к каталогу файлов моделей data/ml/<tenantId>.sqlite. + /// + public string DataDirectory { get; } + + /// + /// Создаёт опции с уже известным каталогом + /// + /// Каталог файлов моделей. + public static MlOptions Create(string dataDirectory) + { + if (string.IsNullOrWhiteSpace(dataDirectory)) + { + throw new ArgumentException("Каталог данных моделей не задан.", nameof(dataDirectory)); + } + + return new MlOptions(Path.GetFullPath(dataDirectory.Trim())); + } + + /// + /// Читает конфигурацию из env и корня хоста + /// + /// Конфигурация хоста (env-провайдер WebApplicationBuilder). + /// Окружение хоста (ContentRootPath для каталога по умолчанию). + /// Опции хранения моделей. + public static MlOptions FromConfiguration(IConfiguration configuration, IHostEnvironment environment) + { + string? configuredDir = configuration[DataDirEnvVarName]; + string dataDirectory = ResolveDataDirectory(configuredDir, environment.ContentRootPath); + return new MlOptions(dataDirectory); + } + + // Разрешает каталог моделей: абсолютный env-путь как есть, иначе — под ContentRoot. + // configuredDir: Значение DEAL_ML_DATA_DIR (может быть пустым). + // contentRootPath: ContentRoot хоста. + private static string ResolveDataDirectory(string? configuredDir, string contentRootPath) + { + if (string.IsNullOrWhiteSpace(configuredDir)) + { + return Path.Combine(contentRootPath, DefaultDataDirRelative); + } + + string trimmed = configuredDir.Trim(); + return Path.IsPathRooted(trimmed) + ? trimmed + : Path.Combine(contentRootPath, trimmed); + } +} diff --git a/src/ml-service/Deal.Ml/Model/MlPredictResult.cs b/src/ml-service/Deal.Ml/Model/MlPredictResult.cs index cd0aafe..e3e47e7 100644 --- a/src/ml-service/Deal.Ml/Model/MlPredictResult.cs +++ b/src/ml-service/Deal.Ml/Model/MlPredictResult.cs @@ -1,22 +1,22 @@ -namespace Deal.Ml.Model; - -/// -/// Результат предсказания модели. -/// -/// True — модель уверена и решение можно использовать без ИИ. -/// Класс решения: id колонки канбана (b_…) или spam (null — не уверена). -/// Веса классов: «label → вес» (до 5 лучших, round 3). -/// Сколько терминов класса-победителя модель узнала в тексте. -/// Модель обучена (набрала MIN_TOTAL/MIN_WINNER/MIN_WINNER_SPAM). -/// Порог уверенности (адаптивный отрыв, 2 знака) или null — нет решения. -/// Узнанные термины класса-победителя (подсказка структуры карточки, ≤8). -/// Решение о типе заявки (hire/order) или null. -public sealed record MlPredictResult( - bool Take, - string? Label, - IReadOnlyDictionary Scores, - int Hits, - bool Ready, - double? Margin, - IReadOnlyList Terms, - MlTypeDecision? Type); +namespace Deal.Ml.Model; + +/// +/// Результат предсказания модели. +/// +/// True — модель уверена и решение можно использовать без ИИ. +/// Класс решения: id колонки канбана (b_…) или spam (null — не уверена). +/// Веса классов: «label → вес» (до 5 лучших, round 3). +/// Сколько терминов класса-победителя модель узнала в тексте. +/// Модель обучена (набрала MIN_TOTAL/MIN_WINNER/MIN_WINNER_SPAM). +/// Порог уверенности (адаптивный отрыв, 2 знака) или null — нет решения. +/// Узнанные термины класса-победителя (подсказка структуры карточки, ≤8). +/// Решение о типе заявки (hire/order) или null. +public sealed record MlPredictResult( + bool Take, + string? Label, + IReadOnlyDictionary Scores, + int Hits, + bool Ready, + double? Margin, + IReadOnlyList Terms, + MlTypeDecision? Type); diff --git a/src/ml-service/Deal.Ml/Model/MlStatusResult.cs b/src/ml-service/Deal.Ml/Model/MlStatusResult.cs index 00b9f02..d236eb4 100644 --- a/src/ml-service/Deal.Ml/Model/MlStatusResult.cs +++ b/src/ml-service/Deal.Ml/Model/MlStatusResult.cs @@ -1,14 +1,14 @@ -namespace Deal.Ml.Model; - -/// -/// Статус модели тенанта -/// -/// Модель готова принимать решения. -/// Классы модели: «label → вес» (round 2, по убыванию). -/// Всего примеров, на которых модель обучалась (сумма по классам, int). -/// Самооценка по последним подтверждённым решениям. -public sealed record MlStatusResult( - bool Ready, - IReadOnlyDictionary Classes, - int Learned, - MlEvalInfo Eval); +namespace Deal.Ml.Model; + +/// +/// Статус модели тенанта +/// +/// Модель готова принимать решения. +/// Классы модели: «label → вес» (round 2, по убыванию). +/// Всего примеров, на которых модель обучалась (сумма по классам, int). +/// Самооценка по последним подтверждённым решениям. +public sealed record MlStatusResult( + bool Ready, + IReadOnlyDictionary Classes, + int Learned, + MlEvalInfo Eval); diff --git a/src/ml-service/Deal.Ml/Model/MlTokenizer.cs b/src/ml-service/Deal.Ml/Model/MlTokenizer.cs index 390d005..6fcce6f 100644 --- a/src/ml-service/Deal.Ml/Model/MlTokenizer.cs +++ b/src/ml-service/Deal.Ml/Model/MlTokenizer.cs @@ -1,49 +1,49 @@ -using System.Text.RegularExpressions; - -namespace Deal.Ml.Model; - -/// -/// Разбиение текста на термины модели. -/// -public static class MlTokenizer -{ - // Шаблон ссылок (http/https, www, markdown [text](url)) — удаляются целиком. - private const string LinkPattern = @"https?://[^\s<>""']+|www\.[^\s<>""']+|\[[^\]]*\]\([^)\s]+\)"; - - // Шаблон термина: буквы (латиница/кириллица/ё) в обеих регистрах, цифры, @ + . #. - private const string TokenPattern = "[a-zA-Zа-яА-ЯёЁ0-9@+.#]+"; - - private static readonly Regex LinkRegex = new( - LinkPattern, - RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant); - - private static readonly Regex TokenRegex = new( - TokenPattern, - RegexOptions.Compiled | RegexOptions.CultureInvariant); - - /// - /// Токенизирует текст в термины модели - /// - /// Текст сообщения/карточки (null — пустой). - /// Список терминов в порядке появления (включая повторы). - public static string[] Tokenize(string? text) - { - string cleared = LinkRegex.Replace(text ?? string.Empty, " "); - var terms = new List(); - foreach (Match match in TokenRegex.Matches(cleared)) - { - string word = match.Value.ToLowerInvariant(); - if (word.Length >= ModelConstants.MinTokenLengthForPrefix) - { - terms.Add(word); - terms.Add(ModelConstants.TokenPrefixMarker + word[..ModelConstants.PrefixLength]); - } - else if (word.Length >= ModelConstants.MinTokenLength) - { - terms.Add(word); - } - } - - return terms.ToArray(); - } -} +using System.Text.RegularExpressions; + +namespace Deal.Ml.Model; + +/// +/// Разбиение текста на термины модели. +/// +public static class MlTokenizer +{ + // Шаблон ссылок (http/https, www, markdown [text](url)) — удаляются целиком. + private const string LinkPattern = @"https?://[^\s<>""']+|www\.[^\s<>""']+|\[[^\]]*\]\([^)\s]+\)"; + + // Шаблон термина: буквы (латиница/кириллица/ё) в обеих регистрах, цифры, @ + . #. + private const string TokenPattern = "[a-zA-Zа-яА-ЯёЁ0-9@+.#]+"; + + private static readonly Regex LinkRegex = new( + LinkPattern, + RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex TokenRegex = new( + TokenPattern, + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + /// + /// Токенизирует текст в термины модели + /// + /// Текст сообщения/карточки (null — пустой). + /// Список терминов в порядке появления (включая повторы). + public static string[] Tokenize(string? text) + { + string cleared = LinkRegex.Replace(text ?? string.Empty, " "); + var terms = new List(); + foreach (Match match in TokenRegex.Matches(cleared)) + { + string word = match.Value.ToLowerInvariant(); + if (word.Length >= ModelConstants.MinTokenLengthForPrefix) + { + terms.Add(word); + terms.Add(ModelConstants.TokenPrefixMarker + word[..ModelConstants.PrefixLength]); + } + else if (word.Length >= ModelConstants.MinTokenLength) + { + terms.Add(word); + } + } + + return terms.ToArray(); + } +} diff --git a/src/ml-service/Deal.Ml/Model/MlTypeDecision.cs b/src/ml-service/Deal.Ml/Model/MlTypeDecision.cs index 6ef7368..cb65992 100644 --- a/src/ml-service/Deal.Ml/Model/MlTypeDecision.cs +++ b/src/ml-service/Deal.Ml/Model/MlTypeDecision.cs @@ -1,10 +1,10 @@ -namespace Deal.Ml.Model; - -/// -/// Решение ML о типе заявки. -/// -/// True — модель уверена в типе. -/// Тип для UI: hire | order. -/// Внутренний класс ML: t:hire | t:order (UI не показывается). -/// Запас уверенности (адаптивный порог, 2 знака). -public sealed record MlTypeDecision(bool Take, string Label, string Value, double Margin); +namespace Deal.Ml.Model; + +/// +/// Решение ML о типе заявки. +/// +/// True — модель уверена в типе. +/// Тип для UI: hire | order. +/// Внутренний класс ML: t:hire | t:order (UI не показывается). +/// Запас уверенности (адаптивный порог, 2 знака). +public sealed record MlTypeDecision(bool Take, string Label, string Value, double Margin); diff --git a/src/ml-service/Deal.Ml/Model/ModelConstants.cs b/src/ml-service/Deal.Ml/Model/ModelConstants.cs index 3d274cd..2b27b33 100644 --- a/src/ml-service/Deal.Ml/Model/ModelConstants.cs +++ b/src/ml-service/Deal.Ml/Model/ModelConstants.cs @@ -1,152 +1,152 @@ -namespace Deal.Ml.Model; - -/// -/// Пороги и константы наивно-байесовской модели по терминам. -/// -public static class ModelConstants -{ - /// - /// Суммарно примеров по всем классам, чтобы модель «включилась» - /// - public const double MinTotalExamples = 20.0; - - /// - /// Минимум примеров у класса-победителя - /// - public const double MinWinnerExamples = 6.0; - - /// - /// Минимум примеров у класса-победителя «spam» - /// - public const double MinWinnerSpamExamples = 4.0; - - /// - /// Минимум различных терминов, встреченных у победителя - /// - public const int MinHits = 2; - - /// - /// Минимум примеров класса типа t:*, чтобы ML выдавал тип - /// - public const double MinTypeWinnerExamples = 4.0; - - /// - /// Метка класса «спам» - /// - public const string SpamLabel = "spam"; - - /// - /// Префикс внутренних классов типа заявки - /// - public const string TypeLabelPrefix = "t:"; - - /// - /// Внутренний класс типа заявки «найм» - /// - public const string TypeClassHire = "t:hire"; - - /// - /// Внутренний класс типа заявки «разовая сделка» - /// - public const string TypeClassOrder = "t:order"; - - /// - /// Множитель prior при ранжировании классов - /// - public const double PriorWeight = 3.0; - - /// - /// ln-отрыв от второго класса на старте - /// - public const double InitialMargin = 0.9; - - /// - /// Суммарно примеров, после которых порог отрыва — 0.7. - /// - public const double TotalExamplesForMargin0_7 = 60.0; - - /// - /// Суммарно примеров, после которых порог отрыва — 0.5. - /// - public const double TotalExamplesForMargin0_5 = 150.0; - - /// - /// Суммарно примеров, после которых порог отрыва — 0.35. - /// - public const double TotalExamplesForMargin0_35 = 400.0; - - /// - /// Адаптивный отрыв после 60 примеров. - /// - public const double MarginAfter60Examples = 0.7; - - /// - /// Адаптивный отрыв после 150 примеров. - /// - public const double MarginAfter150Examples = 0.5; - - /// - /// Адаптивный отрыв после 400 примеров. - /// - public const double MarginAfter400Examples = 0.35; - - /// - /// Окно самооценки, которое отдаётся в /status - /// - public const int EvalWindowSize = 50; - - /// - /// Сколько последних решений самооценки хранится в БД модели - /// - public const int EvalKeepCount = 200; - - /// - /// Сколько лучших весов классов отдаётся в predict - /// - public const int MaxScoresInReply = 5; - - /// - /// Сколько узнанных терминов отдаётся в predict - /// - public const int MaxMatchedTerms = 8; - - /// - /// Точность округления весов в scores - /// - public const int ScoresPrecision = 3; - - /// - /// Точность округления весов классов в status - /// - public const int ClassesPrecision = 2; - - /// - /// Точность округления отступа margin - /// - public const int MarginPrecision = 2; - - /// - /// Точность округления доли верных в eval - /// - public const int AccuracyPrecision = 3; - - /// - /// Минимальная длина токена, попадающего в модель. - /// - public const int MinTokenLength = 3; - - /// - /// Длина токена, при которой добавляется «хвостовой» префикс-термин. - /// - public const int MinTokenLengthForPrefix = 6; - - /// - /// Длина префикса хвостового токена - /// - public const int PrefixLength = 4; - - /// - /// Префикс хвостового токена — не участвует в terms-подсказках. - /// - public const string TokenPrefixMarker = "~"; -} +namespace Deal.Ml.Model; + +/// +/// Пороги и константы наивно-байесовской модели по терминам. +/// +public static class ModelConstants +{ + /// + /// Суммарно примеров по всем классам, чтобы модель «включилась» + /// + public const double MinTotalExamples = 20.0; + + /// + /// Минимум примеров у класса-победителя + /// + public const double MinWinnerExamples = 6.0; + + /// + /// Минимум примеров у класса-победителя «spam» + /// + public const double MinWinnerSpamExamples = 4.0; + + /// + /// Минимум различных терминов, встреченных у победителя + /// + public const int MinHits = 2; + + /// + /// Минимум примеров класса типа t:*, чтобы ML выдавал тип + /// + public const double MinTypeWinnerExamples = 4.0; + + /// + /// Метка класса «спам» + /// + public const string SpamLabel = "spam"; + + /// + /// Префикс внутренних классов типа заявки + /// + public const string TypeLabelPrefix = "t:"; + + /// + /// Внутренний класс типа заявки «найм» + /// + public const string TypeClassHire = "t:hire"; + + /// + /// Внутренний класс типа заявки «разовая сделка» + /// + public const string TypeClassOrder = "t:order"; + + /// + /// Множитель prior при ранжировании классов + /// + public const double PriorWeight = 3.0; + + /// + /// ln-отрыв от второго класса на старте + /// + public const double InitialMargin = 0.9; + + /// + /// Суммарно примеров, после которых порог отрыва — 0.7. + /// + public const double TotalExamplesForMargin0_7 = 60.0; + + /// + /// Суммарно примеров, после которых порог отрыва — 0.5. + /// + public const double TotalExamplesForMargin0_5 = 150.0; + + /// + /// Суммарно примеров, после которых порог отрыва — 0.35. + /// + public const double TotalExamplesForMargin0_35 = 400.0; + + /// + /// Адаптивный отрыв после 60 примеров. + /// + public const double MarginAfter60Examples = 0.7; + + /// + /// Адаптивный отрыв после 150 примеров. + /// + public const double MarginAfter150Examples = 0.5; + + /// + /// Адаптивный отрыв после 400 примеров. + /// + public const double MarginAfter400Examples = 0.35; + + /// + /// Окно самооценки, которое отдаётся в /status + /// + public const int EvalWindowSize = 50; + + /// + /// Сколько последних решений самооценки хранится в БД модели + /// + public const int EvalKeepCount = 200; + + /// + /// Сколько лучших весов классов отдаётся в predict + /// + public const int MaxScoresInReply = 5; + + /// + /// Сколько узнанных терминов отдаётся в predict + /// + public const int MaxMatchedTerms = 8; + + /// + /// Точность округления весов в scores + /// + public const int ScoresPrecision = 3; + + /// + /// Точность округления весов классов в status + /// + public const int ClassesPrecision = 2; + + /// + /// Точность округления отступа margin + /// + public const int MarginPrecision = 2; + + /// + /// Точность округления доли верных в eval + /// + public const int AccuracyPrecision = 3; + + /// + /// Минимальная длина токена, попадающего в модель. + /// + public const int MinTokenLength = 3; + + /// + /// Длина токена, при которой добавляется «хвостовой» префикс-термин. + /// + public const int MinTokenLengthForPrefix = 6; + + /// + /// Длина префикса хвостового токена + /// + public const int PrefixLength = 4; + + /// + /// Префикс хвостового токена — не участвует в terms-подсказках. + /// + public const string TokenPrefixMarker = "~"; +} diff --git a/src/ml-service/Deal.Ml/Model/ModelPool.cs b/src/ml-service/Deal.Ml/Model/ModelPool.cs index 664ef06..8a560cb 100644 --- a/src/ml-service/Deal.Ml/Model/ModelPool.cs +++ b/src/ml-service/Deal.Ml/Model/ModelPool.cs @@ -1,85 +1,85 @@ -using System.Collections.Concurrent; -using Deal.Ml.Storage; - -namespace Deal.Ml.Model; - -/// -/// Пул моделей тенантов -/// -public sealed class ModelPool : IDisposable -{ - private const string DbFileExtension = ".sqlite"; - - // Верхняя граница длины tenant-id (защита пути; реальные id заметно короче). - private const int MaxTenantIdLength = 128; - - private static readonly char[] InvalidTenantCharacters = Path.GetInvalidFileNameChars(); - - private readonly MlOptions _options; - private readonly ConcurrentDictionary _models = new(StringComparer.Ordinal); - - /// - /// Каталог файлов моделей - /// - public string DataDirectory => _options.DataDirectory; - - /// - /// Создаёт пул над каталогом файлов моделей. - /// - /// Конфигурация хранения моделей (каталог data/ml). - public ModelPool(MlOptions options) - { - _options = options ?? throw new ArgumentNullException(nameof(options)); - } - - /// - /// Возвращает модель тенанта, создавая её лениво - /// - /// Id тенанта. - /// Модель тенанта (в пуле до Reset/Dispose). - /// Tenant-id пуст/некорректен для имени файла. - public TenantModel GetOrCreate(string tenantId) - { - ValidateTenantId(tenantId); - return _models.GetOrAdd(tenantId, id => new TenantModel(id, new MlDb(DatabasePath(id)))); - } - - /// - public void Dispose() - { - foreach (TenantModel model in _models.Values) - { - model.Dispose(); - } - - _models.Clear(); - } - - // Путь к файлу модели тенанта (data/ml/<tenantId>.sqlite). - // tenantId: Id тенанта (уже валидирован). - private string DatabasePath(string tenantId) - => Path.Combine(_options.DataDirectory, tenantId + DbFileExtension); - - // Проверяет tenant-id как безопасное имя файла: непустой, без разделителей/недопустимых - // символов путей, не «..», ограниченной длины. Иначе модель могла бы писаться вне каталога. - // tenantId: Id тенанта из metadata. - // Исключение ArgumentException: Tenant-id некорректен. - private static void ValidateTenantId(string tenantId) - { - if (string.IsNullOrWhiteSpace(tenantId)) - { - throw new ArgumentException("tenant-id не задан.", nameof(tenantId)); - } - - if (tenantId.Length > MaxTenantIdLength) - { - throw new ArgumentException($"tenant-id длиннее {MaxTenantIdLength} символов.", nameof(tenantId)); - } - - if (tenantId.IndexOfAny(InvalidTenantCharacters) >= 0 - || tenantId.Contains("..", StringComparison.Ordinal)) - { - throw new ArgumentException("tenant-id содержит недопустимые для имени файла символы.", nameof(tenantId)); - } - } -} +using System.Collections.Concurrent; +using Deal.Ml.Storage; + +namespace Deal.Ml.Model; + +/// +/// Пул моделей тенантов +/// +public sealed class ModelPool : IDisposable +{ + private const string DbFileExtension = ".sqlite"; + + // Верхняя граница длины tenant-id (защита пути; реальные id заметно короче). + private const int MaxTenantIdLength = 128; + + private static readonly char[] InvalidTenantCharacters = Path.GetInvalidFileNameChars(); + + private readonly MlOptions _options; + private readonly ConcurrentDictionary _models = new(StringComparer.Ordinal); + + /// + /// Каталог файлов моделей + /// + public string DataDirectory => _options.DataDirectory; + + /// + /// Создаёт пул над каталогом файлов моделей. + /// + /// Конфигурация хранения моделей (каталог data/ml). + public ModelPool(MlOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// Возвращает модель тенанта, создавая её лениво + /// + /// Id тенанта. + /// Модель тенанта (в пуле до Reset/Dispose). + /// Tenant-id пуст/некорректен для имени файла. + public TenantModel GetOrCreate(string tenantId) + { + ValidateTenantId(tenantId); + return _models.GetOrAdd(tenantId, id => new TenantModel(id, new MlDb(DatabasePath(id)))); + } + + /// + public void Dispose() + { + foreach (TenantModel model in _models.Values) + { + model.Dispose(); + } + + _models.Clear(); + } + + // Путь к файлу модели тенанта (data/ml/<tenantId>.sqlite). + // tenantId: Id тенанта (уже валидирован). + private string DatabasePath(string tenantId) + => Path.Combine(_options.DataDirectory, tenantId + DbFileExtension); + + // Проверяет tenant-id как безопасное имя файла: непустой, без разделителей/недопустимых + // символов путей, не «..», ограниченной длины. Иначе модель могла бы писаться вне каталога. + // tenantId: Id тенанта из metadata. + // Исключение ArgumentException: Tenant-id некорректен. + private static void ValidateTenantId(string tenantId) + { + if (string.IsNullOrWhiteSpace(tenantId)) + { + throw new ArgumentException("tenant-id не задан.", nameof(tenantId)); + } + + if (tenantId.Length > MaxTenantIdLength) + { + throw new ArgumentException($"tenant-id длиннее {MaxTenantIdLength} символов.", nameof(tenantId)); + } + + if (tenantId.IndexOfAny(InvalidTenantCharacters) >= 0 + || tenantId.Contains("..", StringComparison.Ordinal)) + { + throw new ArgumentException("tenant-id содержит недопустимые для имени файла символы.", nameof(tenantId)); + } + } +} diff --git a/src/ml-service/Deal.Ml/Model/ModelState.cs b/src/ml-service/Deal.Ml/Model/ModelState.cs index 5ac6767..3b2c80e 100644 --- a/src/ml-service/Deal.Ml/Model/ModelState.cs +++ b/src/ml-service/Deal.Ml/Model/ModelState.cs @@ -1,32 +1,32 @@ -namespace Deal.Ml.Model; - -/// -/// Состояние модели тенанта в памяти -/// -public sealed class ModelState -{ - /// - /// Веса классов: label → n. - /// - public Dictionary Classes { get; } = new(StringComparer.Ordinal); - - /// - /// Веса терминов по классам - /// - public Dictionary> TermsByLabel { get; } = new(StringComparer.Ordinal); - - /// - /// Журнал самооценки в порядке накопления - /// - public List EvalLog { get; } = []; - - /// - /// Очищает состояние. - /// - public void Clear() - { - Classes.Clear(); - TermsByLabel.Clear(); - EvalLog.Clear(); - } -} +namespace Deal.Ml.Model; + +/// +/// Состояние модели тенанта в памяти +/// +public sealed class ModelState +{ + /// + /// Веса классов: label → n. + /// + public Dictionary Classes { get; } = new(StringComparer.Ordinal); + + /// + /// Веса терминов по классам + /// + public Dictionary> TermsByLabel { get; } = new(StringComparer.Ordinal); + + /// + /// Журнал самооценки в порядке накопления + /// + public List EvalLog { get; } = []; + + /// + /// Очищает состояние. + /// + public void Clear() + { + Classes.Clear(); + TermsByLabel.Clear(); + EvalLog.Clear(); + } +} diff --git a/src/ml-service/Deal.Ml/Model/OnlineNaiveBayes.cs b/src/ml-service/Deal.Ml/Model/OnlineNaiveBayes.cs index 6670d65..47f5582 100644 --- a/src/ml-service/Deal.Ml/Model/OnlineNaiveBayes.cs +++ b/src/ml-service/Deal.Ml/Model/OnlineNaiveBayes.cs @@ -1,296 +1,296 @@ -using Deal.Ml.Extensions; - -namespace Deal.Ml.Model; - -/// -/// Инкрементальная наивно-байесовская модель по терминам. -/// -public static class OnlineNaiveBayes -{ - /// - /// Готовность модели - /// - /// Состояние модели. - public static bool Ready(ModelState state) - { - double total = Total(state); - if (total < ModelConstants.MinTotalExamples) - { - return false; - } - - double spam = state.Classes.GetValueOrDefault(ModelConstants.SpamLabel); - return spam >= ModelConstants.MinWinnerSpamExamples - && total - spam >= ModelConstants.MinWinnerExamples; - } - - /// - /// Адаптивный отрыв от второго класса - /// - /// Суммарно примеров по всем классам. - public static double AdaptiveMargin(double total) - { - if (total >= ModelConstants.TotalExamplesForMargin0_35) - { - return ModelConstants.MarginAfter400Examples; - } - - if (total >= ModelConstants.TotalExamplesForMargin0_5) - { - return ModelConstants.MarginAfter150Examples; - } - - if (total >= ModelConstants.TotalExamplesForMargin0_7) - { - return ModelConstants.MarginAfter60Examples; - } - - return ModelConstants.InitialMargin; - } - - /// - /// Суммарно примеров по всем классам - /// - /// Состояние модели. - public static double Total(ModelState state) => state.Classes.Values.Sum(); - - /// - /// Предсказание по тексту - /// - /// Состояние модели. - /// Текст сообщения. - public static MlPredictResult Predict(ModelState state, string text) - { - string[] tokens = MlTokenizer.Tokenize(text); - - if (state.Classes.Count == 0 || tokens.Length == 0) - { - return NotReady(Ready(state)); - } - - if (!Ready(state)) - { - return NotReady(ready: false); - } - - double total = Total(state); - var scores = new Dictionary(StringComparer.Ordinal); - var hits = new Dictionary(StringComparer.Ordinal); - string[] distinctTokens = tokens.Distinct(StringComparer.Ordinal).ToArray(); - foreach (string label in state.Classes.Keys) - { - Dictionary? weights = TryGetTerms(state, label); - double score = 0.0; - int hit = 0; - foreach (string token in distinctTokens) - { - if (weights is not null && weights.TryGetValue(token, out double weight) && weight > 0) - { - score += TermScore(weight); - hit += 1; - } - } - - if (hit > 0) - { - scores[label] = score; - hits[label] = hit; - } - } - - double margin = AdaptiveMargin(total); - var prior = new Dictionary(state.Classes.Count); - foreach ((string label, double n) in state.Classes) - { - prior[label] = n / total; - } - - MlTypeDecision? typeDecision = DecideType(state, scores, prior, margin); - - // ── колонка/спам: без t:* классов ──────────────────────────────────────────────── - List regularLabels = state.Classes.Keys.Where(label => !label.IsTypeLabel()).ToList(); - if (regularLabels.Count == 0 || scores.Count == 0) - { - return TakeFalse(state, typeDecision); - } - - List<(string Label, double Score)> ranked = regularLabels - .Where(scores.ContainsKey) - .Select(label => (Label: label, Score: scores[label])) - .OrderByDescending(pair => pair.Score) - .ToList(); - if (ranked.Count == 0) - { - return TakeFalse(state, typeDecision); - } - - string bestLabel = ranked[0].Label; - double bestScore = ranked[0].Score; - (string Label, double Score)? second = ranked.Count > 1 ? ranked[1] : null; - - double bestTotal = bestScore + ModelConstants.PriorWeight * prior.GetValueOrDefault(bestLabel); - double secondTotal = second.HasValue - ? second.Value.Score + ModelConstants.PriorWeight * prior.GetValueOrDefault(second.Value.Label) - : 0.0; - - bool isSpam = string.Equals(bestLabel, ModelConstants.SpamLabel, StringComparison.Ordinal); - double minWinner = isSpam ? ModelConstants.MinWinnerSpamExamples : ModelConstants.MinWinnerExamples; - bool take = state.Classes.GetValueOrDefault(bestLabel) >= minWinner - && hits.GetValueOrDefault(bestLabel) >= ModelConstants.MinHits - && bestTotal - secondTotal >= margin; - - IReadOnlyList matchedTerms = take && !isSpam - ? MatchTerms(state, bestLabel, tokens) - : Array.Empty(); - - Dictionary topScores = scores - .OrderByDescending(pair => pair.Value) - .Take(ModelConstants.MaxScoresInReply) - .ToDictionary(pair => pair.Key, pair => Round(pair.Value, ModelConstants.ScoresPrecision)); - - return new MlPredictResult( - Take: take, - Label: take ? bestLabel : null, - Scores: topScores, - Hits: hits.GetValueOrDefault(bestLabel), - Ready: true, - Margin: Round(margin, ModelConstants.MarginPrecision), - Terms: matchedTerms, - Type: typeDecision); - } - - /// - /// Статус модели: готовность, веса классов - /// - /// Состояние модели. - public static MlStatusResult Status(ModelState state) - { - bool ready = Ready(state); - - Dictionary classes = state.Classes - .OrderByDescending(pair => pair.Value) - .ToDictionary(pair => pair.Key, pair => Round(pair.Value, ModelConstants.ClassesPrecision)); - - int learned = (int)Total(state); - - return new MlStatusResult(ready, classes, learned, EvalWindow(state)); - } - - private static double TermScore(double weight) - => weight < 1.0 ? 1.0 : 1.0 + (weight - 1.0) / (weight + 1.0); - - private static MlTypeDecision? DecideType( - ModelState state, - IReadOnlyDictionary scores, - IReadOnlyDictionary prior, - double margin) - { - List typeClasses = state.Classes.Keys - .Where(label => label is ModelConstants.TypeClassHire or ModelConstants.TypeClassOrder) - .ToList(); - if (typeClasses.Count < 2) - { - return null; - } - - List<(string Label, double Score)> ranked = typeClasses - .Select(label => (Label: label, Score: scores.GetValueOrDefault(label))) - .OrderByDescending(pair => pair.Score) - .ToList(); - string bestLabel = ranked[0].Label; - double bestScore = ranked[0].Score; - double secondScore = ranked[1].Score; - - double bestTotal = bestScore + ModelConstants.PriorWeight * prior.GetValueOrDefault(bestLabel); - double secondTotal = secondScore + ModelConstants.PriorWeight * prior.GetValueOrDefault(ranked[1].Label); - - if (bestScore <= 0 - || state.Classes.GetValueOrDefault(bestLabel) < ModelConstants.MinTypeWinnerExamples - || bestTotal - secondTotal < margin) - { - return null; - } - - bool isHire = string.Equals(bestLabel, ModelConstants.TypeClassHire, StringComparison.Ordinal); - return new MlTypeDecision( - Take: true, - Label: isHire ? "hire" : "order", - Value: bestLabel, - Margin: Round(margin, ModelConstants.MarginPrecision)); - } - - private static IReadOnlyList MatchTerms( - ModelState state, - string label, - string[] tokens) - { - Dictionary? weights = TryGetTerms(state, label); - if (weights is null) - { - return Array.Empty(); - } - - var seen = new List(); - var known = new HashSet(StringComparer.Ordinal); - foreach (string token in tokens) - { - if (token.StartsWith(ModelConstants.TokenPrefixMarker, StringComparison.Ordinal) - || string.Equals(token, label, StringComparison.Ordinal) - || !weights.ContainsKey(token) - || !known.Add(token)) - { - continue; - } - - seen.Add(token); - } - - return seen - .OrderByDescending(term => weights[term]) - .Take(ModelConstants.MaxMatchedTerms) - .ToArray(); - } - - private static MlEvalInfo EvalWindow(ModelState state) - { - int count = Math.Min(state.EvalLog.Count, ModelConstants.EvalWindowSize); - int correct = state.EvalLog.Count == 0 - ? 0 - : state.EvalLog.Skip(Math.Max(0, state.EvalLog.Count - ModelConstants.EvalWindowSize)).Count(entry => entry.Correct); - - double accuracy = count > 0 ? Round((double)correct / count, ModelConstants.AccuracyPrecision) : 0.0; - return new MlEvalInfo(count, correct, accuracy); - } - - private static Dictionary? TryGetTerms(ModelState state, string label) - => state.TermsByLabel.TryGetValue(label, out Dictionary? terms) ? terms : null; - - // Фиксированный ответ «не уверен» (нет опыта/терминов — модель не решает). - // ready: Готовность модели на момент вызова. - private static MlPredictResult NotReady(bool ready) - => new( - Take: false, - Label: null, - Scores: new Dictionary(), - Hits: 0, - Ready: ready, - Margin: null, - Terms: Array.Empty(), - Type: null); - - // Ответ «не взяла» при готовой модели, но без уверенного класса (scores/ранг пусты). - // state: Состояние модели. - // typeDecision: Решение о типе заявки (если есть). - private static MlPredictResult TakeFalse(ModelState state, MlTypeDecision? typeDecision) - => new( - Take: false, - Label: null, - Scores: new Dictionary(), - Hits: 0, - Ready: true, - Margin: null, - Terms: Array.Empty(), - Type: typeDecision); - - private static double Round(double value, int digits) => Math.Round(value, digits); -} +using Deal.Ml.Extensions; + +namespace Deal.Ml.Model; + +/// +/// Инкрементальная наивно-байесовская модель по терминам. +/// +public static class OnlineNaiveBayes +{ + /// + /// Готовность модели + /// + /// Состояние модели. + public static bool Ready(ModelState state) + { + double total = Total(state); + if (total < ModelConstants.MinTotalExamples) + { + return false; + } + + double spam = state.Classes.GetValueOrDefault(ModelConstants.SpamLabel); + return spam >= ModelConstants.MinWinnerSpamExamples + && total - spam >= ModelConstants.MinWinnerExamples; + } + + /// + /// Адаптивный отрыв от второго класса + /// + /// Суммарно примеров по всем классам. + public static double AdaptiveMargin(double total) + { + if (total >= ModelConstants.TotalExamplesForMargin0_35) + { + return ModelConstants.MarginAfter400Examples; + } + + if (total >= ModelConstants.TotalExamplesForMargin0_5) + { + return ModelConstants.MarginAfter150Examples; + } + + if (total >= ModelConstants.TotalExamplesForMargin0_7) + { + return ModelConstants.MarginAfter60Examples; + } + + return ModelConstants.InitialMargin; + } + + /// + /// Суммарно примеров по всем классам + /// + /// Состояние модели. + public static double Total(ModelState state) => state.Classes.Values.Sum(); + + /// + /// Предсказание по тексту + /// + /// Состояние модели. + /// Текст сообщения. + public static MlPredictResult Predict(ModelState state, string text) + { + string[] tokens = MlTokenizer.Tokenize(text); + + if (state.Classes.Count == 0 || tokens.Length == 0) + { + return NotReady(Ready(state)); + } + + if (!Ready(state)) + { + return NotReady(ready: false); + } + + double total = Total(state); + var scores = new Dictionary(StringComparer.Ordinal); + var hits = new Dictionary(StringComparer.Ordinal); + string[] distinctTokens = tokens.Distinct(StringComparer.Ordinal).ToArray(); + foreach (string label in state.Classes.Keys) + { + Dictionary? weights = TryGetTerms(state, label); + double score = 0.0; + int hit = 0; + foreach (string token in distinctTokens) + { + if (weights is not null && weights.TryGetValue(token, out double weight) && weight > 0) + { + score += TermScore(weight); + hit += 1; + } + } + + if (hit > 0) + { + scores[label] = score; + hits[label] = hit; + } + } + + double margin = AdaptiveMargin(total); + var prior = new Dictionary(state.Classes.Count); + foreach ((string label, double n) in state.Classes) + { + prior[label] = n / total; + } + + MlTypeDecision? typeDecision = DecideType(state, scores, prior, margin); + + // ── колонка/спам: без t:* классов ──────────────────────────────────────────────── + List regularLabels = state.Classes.Keys.Where(label => !label.IsTypeLabel()).ToList(); + if (regularLabels.Count == 0 || scores.Count == 0) + { + return TakeFalse(state, typeDecision); + } + + List<(string Label, double Score)> ranked = regularLabels + .Where(scores.ContainsKey) + .Select(label => (Label: label, Score: scores[label])) + .OrderByDescending(pair => pair.Score) + .ToList(); + if (ranked.Count == 0) + { + return TakeFalse(state, typeDecision); + } + + string bestLabel = ranked[0].Label; + double bestScore = ranked[0].Score; + (string Label, double Score)? second = ranked.Count > 1 ? ranked[1] : null; + + double bestTotal = bestScore + ModelConstants.PriorWeight * prior.GetValueOrDefault(bestLabel); + double secondTotal = second.HasValue + ? second.Value.Score + ModelConstants.PriorWeight * prior.GetValueOrDefault(second.Value.Label) + : 0.0; + + bool isSpam = string.Equals(bestLabel, ModelConstants.SpamLabel, StringComparison.Ordinal); + double minWinner = isSpam ? ModelConstants.MinWinnerSpamExamples : ModelConstants.MinWinnerExamples; + bool take = state.Classes.GetValueOrDefault(bestLabel) >= minWinner + && hits.GetValueOrDefault(bestLabel) >= ModelConstants.MinHits + && bestTotal - secondTotal >= margin; + + IReadOnlyList matchedTerms = take && !isSpam + ? MatchTerms(state, bestLabel, tokens) + : Array.Empty(); + + Dictionary topScores = scores + .OrderByDescending(pair => pair.Value) + .Take(ModelConstants.MaxScoresInReply) + .ToDictionary(pair => pair.Key, pair => Round(pair.Value, ModelConstants.ScoresPrecision)); + + return new MlPredictResult( + Take: take, + Label: take ? bestLabel : null, + Scores: topScores, + Hits: hits.GetValueOrDefault(bestLabel), + Ready: true, + Margin: Round(margin, ModelConstants.MarginPrecision), + Terms: matchedTerms, + Type: typeDecision); + } + + /// + /// Статус модели: готовность, веса классов + /// + /// Состояние модели. + public static MlStatusResult Status(ModelState state) + { + bool ready = Ready(state); + + Dictionary classes = state.Classes + .OrderByDescending(pair => pair.Value) + .ToDictionary(pair => pair.Key, pair => Round(pair.Value, ModelConstants.ClassesPrecision)); + + int learned = (int)Total(state); + + return new MlStatusResult(ready, classes, learned, EvalWindow(state)); + } + + private static double TermScore(double weight) + => weight < 1.0 ? 1.0 : 1.0 + (weight - 1.0) / (weight + 1.0); + + private static MlTypeDecision? DecideType( + ModelState state, + IReadOnlyDictionary scores, + IReadOnlyDictionary prior, + double margin) + { + List typeClasses = state.Classes.Keys + .Where(label => label is ModelConstants.TypeClassHire or ModelConstants.TypeClassOrder) + .ToList(); + if (typeClasses.Count < 2) + { + return null; + } + + List<(string Label, double Score)> ranked = typeClasses + .Select(label => (Label: label, Score: scores.GetValueOrDefault(label))) + .OrderByDescending(pair => pair.Score) + .ToList(); + string bestLabel = ranked[0].Label; + double bestScore = ranked[0].Score; + double secondScore = ranked[1].Score; + + double bestTotal = bestScore + ModelConstants.PriorWeight * prior.GetValueOrDefault(bestLabel); + double secondTotal = secondScore + ModelConstants.PriorWeight * prior.GetValueOrDefault(ranked[1].Label); + + if (bestScore <= 0 + || state.Classes.GetValueOrDefault(bestLabel) < ModelConstants.MinTypeWinnerExamples + || bestTotal - secondTotal < margin) + { + return null; + } + + bool isHire = string.Equals(bestLabel, ModelConstants.TypeClassHire, StringComparison.Ordinal); + return new MlTypeDecision( + Take: true, + Label: isHire ? "hire" : "order", + Value: bestLabel, + Margin: Round(margin, ModelConstants.MarginPrecision)); + } + + private static IReadOnlyList MatchTerms( + ModelState state, + string label, + string[] tokens) + { + Dictionary? weights = TryGetTerms(state, label); + if (weights is null) + { + return Array.Empty(); + } + + var seen = new List(); + var known = new HashSet(StringComparer.Ordinal); + foreach (string token in tokens) + { + if (token.StartsWith(ModelConstants.TokenPrefixMarker, StringComparison.Ordinal) + || string.Equals(token, label, StringComparison.Ordinal) + || !weights.ContainsKey(token) + || !known.Add(token)) + { + continue; + } + + seen.Add(token); + } + + return seen + .OrderByDescending(term => weights[term]) + .Take(ModelConstants.MaxMatchedTerms) + .ToArray(); + } + + private static MlEvalInfo EvalWindow(ModelState state) + { + int count = Math.Min(state.EvalLog.Count, ModelConstants.EvalWindowSize); + int correct = state.EvalLog.Count == 0 + ? 0 + : state.EvalLog.Skip(Math.Max(0, state.EvalLog.Count - ModelConstants.EvalWindowSize)).Count(entry => entry.Correct); + + double accuracy = count > 0 ? Round((double)correct / count, ModelConstants.AccuracyPrecision) : 0.0; + return new MlEvalInfo(count, correct, accuracy); + } + + private static Dictionary? TryGetTerms(ModelState state, string label) + => state.TermsByLabel.TryGetValue(label, out Dictionary? terms) ? terms : null; + + // Фиксированный ответ «не уверен» (нет опыта/терминов — модель не решает). + // ready: Готовность модели на момент вызова. + private static MlPredictResult NotReady(bool ready) + => new( + Take: false, + Label: null, + Scores: new Dictionary(), + Hits: 0, + Ready: ready, + Margin: null, + Terms: Array.Empty(), + Type: null); + + // Ответ «не взяла» при готовой модели, но без уверенного класса (scores/ранг пусты). + // state: Состояние модели. + // typeDecision: Решение о типе заявки (если есть). + private static MlPredictResult TakeFalse(ModelState state, MlTypeDecision? typeDecision) + => new( + Take: false, + Label: null, + Scores: new Dictionary(), + Hits: 0, + Ready: true, + Margin: null, + Terms: Array.Empty(), + Type: typeDecision); + + private static double Round(double value, int digits) => Math.Round(value, digits); +} diff --git a/src/ml-service/Deal.Ml/Model/TenantModel.cs b/src/ml-service/Deal.Ml/Model/TenantModel.cs index e8c4da8..9027777 100644 --- a/src/ml-service/Deal.Ml/Model/TenantModel.cs +++ b/src/ml-service/Deal.Ml/Model/TenantModel.cs @@ -1,211 +1,211 @@ -using Deal.Ml.Storage; - -namespace Deal.Ml.Model; - -/// -/// Модель одного тенанта -/// -public sealed class TenantModel : IDisposable -{ - private readonly string _tenantId; - private readonly MlDb _db; - private readonly object _sync = new(); - private ModelState _state = new(); - private bool _loaded; - - /// - /// Создаёт модель тенанта над своим SQLite-файлом - /// - /// Id тенанта (владелец модели). - /// Хранилище весов модели (файл data/ml/<tenantId>.sqlite). - public TenantModel(string tenantId, MlDb db) - { - _tenantId = tenantId; - _db = db; - } - - /// - /// Путь к SQLite-файлу модели - /// - public string DatabasePath => _db.DatabasePath; - - /// - /// Предсказание по тексту - /// - /// Текст сообщения. - public MlPredictResult Predict(string text) - { - lock (_sync) - { - EnsureLoaded(); - return OnlineNaiveBayes.Predict(_state, text); - } - } - - /// - /// Статус модели. - /// - public MlStatusResult Status() - { - lock (_sync) - { - EnsureLoaded(); - return OnlineNaiveBayes.Status(_state); - } - } - - /// - /// Пакетное обучение - /// - /// Обучающие примеры (text/label/delta). - public int LearnBatch(IReadOnlyList items) - { - lock (_sync) - { - EnsureLoaded(); - - var applied = new List<(string Label, double Delta, string[] Tokens)>(); - var evalRows = new List(); - foreach (LearnItem item in items) - { - string label = (item.Label ?? string.Empty).Trim(); - string text = item.Text ?? string.Empty; - if (label.Length == 0 || string.IsNullOrWhiteSpace(text)) - { - continue; - } - - EvalEntry? evalRow = TrySelfEval(label, text, item.Delta); - if (evalRow is not null) - { - evalRows.Add(evalRow); - } - - applied.Add((label, item.Delta, MlTokenizer.Tokenize(text))); - } - - if (applied.Count == 0 && evalRows.Count == 0) - { - return 0; - } - - _db.ApplyLearnBatch(applied, evalRows); - foreach ((string label, double delta, string[] tokens) in applied) - { - ApplyToMemory(label, delta, tokens); - } - - AppendEvalToMemory(evalRows); - return applied.Count; - } - } - - /// - /// Полный сброс модели - /// - public void Reset() - { - lock (_sync) - { - _db.DeleteFile(); - _state.Clear(); - _loaded = false; - } - } - - /// - public void Dispose() => _db.Dispose(); - - private EvalEntry? TrySelfEval( - string label, - string text, - double delta) - { - if (delta != 1.0 - || label.StartsWith(ModelConstants.TypeLabelPrefix, StringComparison.Ordinal) - || !OnlineNaiveBayes.Ready(_state)) - { - return null; - } - - MlPredictResult prediction = OnlineNaiveBayes.Predict(_state, text); - if (!prediction.Take || prediction.Label is null) - { - return null; // модель не уверена — такое сообщение ушло бы ИИ, не считаем ошибкой - } - - return new EvalEntry( - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - label, - prediction.Label, - string.Equals(prediction.Label, label, StringComparison.Ordinal)); - } - - private void ApplyToMemory( - string label, - double delta, - string[] tokens) - { - double newCount = _state.Classes.GetValueOrDefault(label) + delta; - if (newCount > 0) - { - _state.Classes[label] = newCount; - } - else - { - _state.Classes.Remove(label); - } - - if (tokens.Length > 0) - { - if (!_state.TermsByLabel.TryGetValue(label, out Dictionary? terms)) - { - terms = new Dictionary(StringComparer.Ordinal); - _state.TermsByLabel[label] = terms; - } - - foreach (string token in tokens) - { - double tokenCount = terms.GetValueOrDefault(token) + delta; - if (tokenCount > 0) - { - terms[token] = tokenCount; - } - else - { - terms.Remove(token); - } - } - - if (terms.Count == 0) - { - _state.TermsByLabel.Remove(label); - } - } - } - - private void AppendEvalToMemory(IReadOnlyList rows) - { - if (rows.Count == 0) - { - return; - } - - _state.EvalLog.AddRange(rows); - if (_state.EvalLog.Count > ModelConstants.EvalKeepCount) - { - _state.EvalLog.RemoveRange(0, _state.EvalLog.Count - ModelConstants.EvalKeepCount); - } - } - - private void EnsureLoaded() - { - if (_loaded) - { - return; - } - - _state = _db.LoadState(); - _loaded = true; - } -} +using Deal.Ml.Storage; + +namespace Deal.Ml.Model; + +/// +/// Модель одного тенанта +/// +public sealed class TenantModel : IDisposable +{ + private readonly string _tenantId; + private readonly MlDb _db; + private readonly object _sync = new(); + private ModelState _state = new(); + private bool _loaded; + + /// + /// Создаёт модель тенанта над своим SQLite-файлом + /// + /// Id тенанта (владелец модели). + /// Хранилище весов модели (файл data/ml/<tenantId>.sqlite). + public TenantModel(string tenantId, MlDb db) + { + _tenantId = tenantId; + _db = db; + } + + /// + /// Путь к SQLite-файлу модели + /// + public string DatabasePath => _db.DatabasePath; + + /// + /// Предсказание по тексту + /// + /// Текст сообщения. + public MlPredictResult Predict(string text) + { + lock (_sync) + { + EnsureLoaded(); + return OnlineNaiveBayes.Predict(_state, text); + } + } + + /// + /// Статус модели. + /// + public MlStatusResult Status() + { + lock (_sync) + { + EnsureLoaded(); + return OnlineNaiveBayes.Status(_state); + } + } + + /// + /// Пакетное обучение + /// + /// Обучающие примеры (text/label/delta). + public int LearnBatch(IReadOnlyList items) + { + lock (_sync) + { + EnsureLoaded(); + + var applied = new List<(string Label, double Delta, string[] Tokens)>(); + var evalRows = new List(); + foreach (LearnItem item in items) + { + string label = (item.Label ?? string.Empty).Trim(); + string text = item.Text ?? string.Empty; + if (label.Length == 0 || string.IsNullOrWhiteSpace(text)) + { + continue; + } + + EvalEntry? evalRow = TrySelfEval(label, text, item.Delta); + if (evalRow is not null) + { + evalRows.Add(evalRow); + } + + applied.Add((label, item.Delta, MlTokenizer.Tokenize(text))); + } + + if (applied.Count == 0 && evalRows.Count == 0) + { + return 0; + } + + _db.ApplyLearnBatch(applied, evalRows); + foreach ((string label, double delta, string[] tokens) in applied) + { + ApplyToMemory(label, delta, tokens); + } + + AppendEvalToMemory(evalRows); + return applied.Count; + } + } + + /// + /// Полный сброс модели + /// + public void Reset() + { + lock (_sync) + { + _db.DeleteFile(); + _state.Clear(); + _loaded = false; + } + } + + /// + public void Dispose() => _db.Dispose(); + + private EvalEntry? TrySelfEval( + string label, + string text, + double delta) + { + if (delta != 1.0 + || label.StartsWith(ModelConstants.TypeLabelPrefix, StringComparison.Ordinal) + || !OnlineNaiveBayes.Ready(_state)) + { + return null; + } + + MlPredictResult prediction = OnlineNaiveBayes.Predict(_state, text); + if (!prediction.Take || prediction.Label is null) + { + return null; // модель не уверена — такое сообщение ушло бы ИИ, не считаем ошибкой + } + + return new EvalEntry( + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + label, + prediction.Label, + string.Equals(prediction.Label, label, StringComparison.Ordinal)); + } + + private void ApplyToMemory( + string label, + double delta, + string[] tokens) + { + double newCount = _state.Classes.GetValueOrDefault(label) + delta; + if (newCount > 0) + { + _state.Classes[label] = newCount; + } + else + { + _state.Classes.Remove(label); + } + + if (tokens.Length > 0) + { + if (!_state.TermsByLabel.TryGetValue(label, out Dictionary? terms)) + { + terms = new Dictionary(StringComparer.Ordinal); + _state.TermsByLabel[label] = terms; + } + + foreach (string token in tokens) + { + double tokenCount = terms.GetValueOrDefault(token) + delta; + if (tokenCount > 0) + { + terms[token] = tokenCount; + } + else + { + terms.Remove(token); + } + } + + if (terms.Count == 0) + { + _state.TermsByLabel.Remove(label); + } + } + } + + private void AppendEvalToMemory(IReadOnlyList rows) + { + if (rows.Count == 0) + { + return; + } + + _state.EvalLog.AddRange(rows); + if (_state.EvalLog.Count > ModelConstants.EvalKeepCount) + { + _state.EvalLog.RemoveRange(0, _state.EvalLog.Count - ModelConstants.EvalKeepCount); + } + } + + private void EnsureLoaded() + { + if (_loaded) + { + return; + } + + _state = _db.LoadState(); + _loaded = true; + } +} diff --git a/src/ml-service/Deal.Ml/Program.cs b/src/ml-service/Deal.Ml/Program.cs index 815e527..4f6fcf9 100644 --- a/src/ml-service/Deal.Ml/Program.cs +++ b/src/ml-service/Deal.Ml/Program.cs @@ -1,36 +1,36 @@ -// - -using Deal.Grpc.Hosting.Interceptors; -using Deal.Grpc.Hosting.Models; -using Deal.Grpc.Hosting.Options; -using Deal.Grpc.Hosting.Services; -using Deal.Ml; - -const int defaultGrpcPort = 5103; -const string mlProcessName = "ml"; - -int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort); -int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); - -WebApplication app = MlServiceHost.Create( - grpcPort, - configureBuilder: builder => - { - DealLogging.Configure(builder, mlProcessName); - DealMetricsHosting.AddDealMetrics(builder, metricsPort); - }); - -DealMetricsHosting.MapDealMetrics(app); - -MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration); - -// Fail-closed (замечание code-review): отсутствие/опечатка DEAL_MTLS_ENABLED не должны давать -// «тихого» plaintext в Production; Development (и прочие не-prod окружения) — как раньше. -GrpcHostEnvironment.RequireMtlsInProduction(mtlsOptions); - -app.Logger.LogInformation( - "ml-service стартует: gRPC {Transport} 0.0.0.0:{Port} (health /grpc.health.v1.Health/Check)", - mtlsOptions.Enabled ? "mTLS (TLS + клиентский сертификат)" : "plaintext + service-token", - grpcPort); - -await app.RunAsync(); +// + +using Deal.Grpc.Hosting.Interceptors; +using Deal.Grpc.Hosting.Models; +using Deal.Grpc.Hosting.Options; +using Deal.Grpc.Hosting.Services; +using Deal.Ml; + +const int defaultGrpcPort = 5103; +const string mlProcessName = "ml"; + +int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort); +int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); + +WebApplication app = MlServiceHost.Create( + grpcPort, + configureBuilder: builder => + { + DealLogging.Configure(builder, mlProcessName); + DealMetricsHosting.AddDealMetrics(builder, metricsPort); + }); + +DealMetricsHosting.MapDealMetrics(app); + +MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration); + +// Fail-closed (замечание code-review): отсутствие/опечатка DEAL_MTLS_ENABLED не должны давать +// «тихого» plaintext в Production; Development (и прочие не-prod окружения) — как раньше. +GrpcHostEnvironment.RequireMtlsInProduction(mtlsOptions); + +app.Logger.LogInformation( + "ml-service стартует: gRPC {Transport} 0.0.0.0:{Port} (health /grpc.health.v1.Health/Check)", + mtlsOptions.Enabled ? "mTLS (TLS + клиентский сертификат)" : "plaintext + service-token", + grpcPort); + +await app.RunAsync(); diff --git a/src/ml-service/Deal.Ml/Storage/MlDb.cs b/src/ml-service/Deal.Ml/Storage/MlDb.cs index 42bba98..a08f35a 100644 --- a/src/ml-service/Deal.Ml/Storage/MlDb.cs +++ b/src/ml-service/Deal.Ml/Storage/MlDb.cs @@ -1,276 +1,276 @@ -using Deal.Ml.Model; -using Microsoft.Data.Sqlite; - -namespace Deal.Ml.Storage; - -/// -/// SQLite-хранилище весов модели одного тенанта. -/// -public sealed class MlDb : IDisposable -{ - private const string ClassesDdl = - "CREATE TABLE IF NOT EXISTS classes (" + - "label TEXT NOT NULL PRIMARY KEY, n REAL NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0)"; - - private const string TermsDdl = - "CREATE TABLE IF NOT EXISTS terms (" + - "label TEXT NOT NULL, term TEXT NOT NULL, count REAL NOT NULL DEFAULT 0, PRIMARY KEY (label, term))"; - - private const string EvalLogDdl = - "CREATE TABLE IF NOT EXISTS eval_log (" + - "created_at INTEGER NOT NULL, expected TEXT NOT NULL, predicted TEXT NOT NULL DEFAULT '', " + - "correct INTEGER NOT NULL)"; - - private const string EvalLogIndexDdl = - "CREATE INDEX IF NOT EXISTS ix_eval_log_created_at ON eval_log (created_at)"; - - private readonly string _filePath; - private SqliteConnection? _connection; - - /// - /// Путь к SQLite-файлу модели - /// - public string DatabasePath => _filePath; - - /// - /// Создаёт хранилище модели для файла по пути - /// - /// Путь к SQLite-файлу модели (<dataDir>/<tenantId>.sqlite). - public MlDb(string filePath) - { - if (string.IsNullOrWhiteSpace(filePath)) - { - throw new ArgumentException("Путь к файлу модели не задан.", nameof(filePath)); - } - - _filePath = filePath; - } - - /// - /// Открывает соединение и создаёт схему при первом обращении. - /// - public void EnsureCreated() - { - if (_connection is not null) - { - return; - } - - string? directory = Path.GetDirectoryName(_filePath); - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } - - var connection = new SqliteConnection($"Data Source={_filePath};Pooling=False"); - connection.Open(); - using (SqliteCommand command = connection.CreateCommand()) - { - command.CommandText = $"{ClassesDdl}; {TermsDdl}; {EvalLogDdl}; {EvalLogIndexDdl}"; - command.ExecuteNonQuery(); - } - - _connection = connection; - } - - /// - /// Читает полное состояние модели из файла - /// - public ModelState LoadState() - { - EnsureCreated(); - var state = new ModelState(); - - using (SqliteCommand command = _connection!.CreateCommand()) - { - command.CommandText = "SELECT label, n FROM classes WHERE n > 0"; - using SqliteDataReader reader = command.ExecuteReader(); - while (reader.Read()) - { - state.Classes[reader.GetString(0)] = reader.GetDouble(1); - } - } - - using (SqliteCommand command = _connection.CreateCommand()) - { - command.CommandText = "SELECT label, term, count FROM terms WHERE count > 0"; - using SqliteDataReader reader = command.ExecuteReader(); - while (reader.Read()) - { - string label = reader.GetString(0); - if (!state.TermsByLabel.TryGetValue(label, out Dictionary? terms)) - { - terms = new Dictionary(StringComparer.Ordinal); - state.TermsByLabel[label] = terms; - } - - terms[reader.GetString(1)] = reader.GetDouble(2); - } - } - - using (SqliteCommand command = _connection.CreateCommand()) - { - command.CommandText = - "SELECT created_at, expected, predicted, correct FROM (" + - "SELECT created_at, expected, predicted, correct, rowid AS seq FROM eval_log " + - "ORDER BY created_at DESC, rowid DESC LIMIT $keep) ORDER BY created_at ASC, seq ASC"; - command.Parameters.AddWithValue("$keep", ModelConstants.EvalKeepCount); - using SqliteDataReader reader = command.ExecuteReader(); - while (reader.Read()) - { - state.EvalLog.Add(new EvalEntry( - reader.GetInt64(0), - reader.GetString(1), - reader.GetString(2), - reader.GetInt64(3) != 0)); - } - } - - return state; - } - - /// - /// Применяет батч обучения одной транзакцией - /// - /// Применяемые примеры: label, delta и термины текста (в порядке появления). - /// Новые строки самооценки (решения до применения батча). - public void ApplyLearnBatch(IReadOnlyList<(string Label, double Delta, string[] Tokens)> items, IReadOnlyList newEvalRows) - { - if (items.Count == 0 && newEvalRows.Count == 0) - { - return; - } - - EnsureCreated(); - using SqliteTransaction transaction = _connection!.BeginTransaction(); - try - { - foreach ((string label, double delta, string[] tokens) in items) - { - ApplyExample(transaction, label, delta, tokens); - } - - InsertEvalRows(transaction, newEvalRows); - if (newEvalRows.Count > 0) - { - PruneEvalLog(transaction); - } - - transaction.Commit(); - } - catch - { - transaction.Rollback(); - throw; - } - } - - /// - /// Пересоздаёт файл модели - /// - public void DeleteFile() - { - DisposeConnection(); - File.Delete(_filePath); - } - - /// - public void Dispose() => DisposeConnection(); - - private void ApplyExample( - SqliteTransaction transaction, - string label, - double delta, - string[] tokens) - { - long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - using (SqliteCommand command = _connection!.CreateCommand()) - { - command.Transaction = transaction; - command.CommandText = - "INSERT INTO classes (label, n, updated_at) VALUES ($label, $delta, $now) " + - "ON CONFLICT(label) DO UPDATE SET n = classes.n + excluded.n, updated_at = excluded.updated_at"; - command.Parameters.AddWithValue("$label", label); - command.Parameters.AddWithValue("$delta", delta); - command.Parameters.AddWithValue("$now", nowMs); - command.ExecuteNonQuery(); - } - - using (SqliteCommand command = _connection.CreateCommand()) - { - command.Transaction = transaction; - command.CommandText = - "INSERT INTO terms (label, term, count) VALUES ($label, $term, $delta) " + - "ON CONFLICT(label, term) DO UPDATE SET count = terms.count + excluded.count"; - command.Parameters.AddWithValue("$label", label); - SqliteParameter termParameter = command.Parameters.AddWithValue("$term", string.Empty); - command.Parameters.AddWithValue("$delta", delta); - foreach (string term in tokens) - { - termParameter.Value = term; - command.ExecuteNonQuery(); - } - } - - if (delta < 0) - { - using SqliteCommand deleteTerms = _connection.CreateCommand(); - deleteTerms.Transaction = transaction; - deleteTerms.CommandText = "DELETE FROM terms WHERE label = $label AND count <= 0"; - deleteTerms.Parameters.AddWithValue("$label", label); - deleteTerms.ExecuteNonQuery(); - - using SqliteCommand deleteClasses = _connection.CreateCommand(); - deleteClasses.Transaction = transaction; - deleteClasses.CommandText = "DELETE FROM classes WHERE n <= 0"; - deleteClasses.ExecuteNonQuery(); - } - } - - // Вставляет строки журнала самооценки (executemany-эквивалент). - // transaction: Транзакция батча. - // rows: Новые строки. - private void InsertEvalRows(SqliteTransaction transaction, IReadOnlyList rows) - { - if (rows.Count == 0) - { - return; - } - - using SqliteCommand command = _connection!.CreateCommand(); - command.Transaction = transaction; - command.CommandText = - "INSERT INTO eval_log (created_at, expected, predicted, correct) VALUES ($at, $expected, $predicted, $correct)"; - SqliteParameter atParameter = command.Parameters.AddWithValue("$at", 0L); - SqliteParameter expectedParameter = command.Parameters.AddWithValue("$expected", string.Empty); - SqliteParameter predictedParameter = command.Parameters.AddWithValue("$predicted", string.Empty); - SqliteParameter correctParameter = command.Parameters.AddWithValue("$correct", 0); - foreach (EvalEntry row in rows) - { - atParameter.Value = row.CreatedAtMs; - expectedParameter.Value = row.ExpectedLabel; - predictedParameter.Value = row.PredictedLabel; - correctParameter.Value = row.Correct ? 1 : 0; - command.ExecuteNonQuery(); - } - } - - private void PruneEvalLog(SqliteTransaction transaction) - { - using SqliteCommand command = _connection!.CreateCommand(); - command.Transaction = transaction; - command.CommandText = - "DELETE FROM eval_log WHERE rowid < (" + - "SELECT rowid FROM eval_log ORDER BY created_at DESC, rowid DESC LIMIT 1 OFFSET $skip)"; - command.Parameters.AddWithValue("$skip", ModelConstants.EvalKeepCount - 1); - command.ExecuteNonQuery(); - } - - // Закрывает и освобождает соединение (Dispose/DeleteFile). - private void DisposeConnection() - { - _connection?.Dispose(); - _connection = null; - } -} +using Deal.Ml.Model; +using Microsoft.Data.Sqlite; + +namespace Deal.Ml.Storage; + +/// +/// SQLite-хранилище весов модели одного тенанта. +/// +public sealed class MlDb : IDisposable +{ + private const string ClassesDdl = + "CREATE TABLE IF NOT EXISTS classes (" + + "label TEXT NOT NULL PRIMARY KEY, n REAL NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0)"; + + private const string TermsDdl = + "CREATE TABLE IF NOT EXISTS terms (" + + "label TEXT NOT NULL, term TEXT NOT NULL, count REAL NOT NULL DEFAULT 0, PRIMARY KEY (label, term))"; + + private const string EvalLogDdl = + "CREATE TABLE IF NOT EXISTS eval_log (" + + "created_at INTEGER NOT NULL, expected TEXT NOT NULL, predicted TEXT NOT NULL DEFAULT '', " + + "correct INTEGER NOT NULL)"; + + private const string EvalLogIndexDdl = + "CREATE INDEX IF NOT EXISTS ix_eval_log_created_at ON eval_log (created_at)"; + + private readonly string _filePath; + private SqliteConnection? _connection; + + /// + /// Путь к SQLite-файлу модели + /// + public string DatabasePath => _filePath; + + /// + /// Создаёт хранилище модели для файла по пути + /// + /// Путь к SQLite-файлу модели (<dataDir>/<tenantId>.sqlite). + public MlDb(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + throw new ArgumentException("Путь к файлу модели не задан.", nameof(filePath)); + } + + _filePath = filePath; + } + + /// + /// Открывает соединение и создаёт схему при первом обращении. + /// + public void EnsureCreated() + { + if (_connection is not null) + { + return; + } + + string? directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var connection = new SqliteConnection($"Data Source={_filePath};Pooling=False"); + connection.Open(); + using (SqliteCommand command = connection.CreateCommand()) + { + command.CommandText = $"{ClassesDdl}; {TermsDdl}; {EvalLogDdl}; {EvalLogIndexDdl}"; + command.ExecuteNonQuery(); + } + + _connection = connection; + } + + /// + /// Читает полное состояние модели из файла + /// + public ModelState LoadState() + { + EnsureCreated(); + var state = new ModelState(); + + using (SqliteCommand command = _connection!.CreateCommand()) + { + command.CommandText = "SELECT label, n FROM classes WHERE n > 0"; + using SqliteDataReader reader = command.ExecuteReader(); + while (reader.Read()) + { + state.Classes[reader.GetString(0)] = reader.GetDouble(1); + } + } + + using (SqliteCommand command = _connection.CreateCommand()) + { + command.CommandText = "SELECT label, term, count FROM terms WHERE count > 0"; + using SqliteDataReader reader = command.ExecuteReader(); + while (reader.Read()) + { + string label = reader.GetString(0); + if (!state.TermsByLabel.TryGetValue(label, out Dictionary? terms)) + { + terms = new Dictionary(StringComparer.Ordinal); + state.TermsByLabel[label] = terms; + } + + terms[reader.GetString(1)] = reader.GetDouble(2); + } + } + + using (SqliteCommand command = _connection.CreateCommand()) + { + command.CommandText = + "SELECT created_at, expected, predicted, correct FROM (" + + "SELECT created_at, expected, predicted, correct, rowid AS seq FROM eval_log " + + "ORDER BY created_at DESC, rowid DESC LIMIT $keep) ORDER BY created_at ASC, seq ASC"; + command.Parameters.AddWithValue("$keep", ModelConstants.EvalKeepCount); + using SqliteDataReader reader = command.ExecuteReader(); + while (reader.Read()) + { + state.EvalLog.Add(new EvalEntry( + reader.GetInt64(0), + reader.GetString(1), + reader.GetString(2), + reader.GetInt64(3) != 0)); + } + } + + return state; + } + + /// + /// Применяет батч обучения одной транзакцией + /// + /// Применяемые примеры: label, delta и термины текста (в порядке появления). + /// Новые строки самооценки (решения до применения батча). + public void ApplyLearnBatch(IReadOnlyList<(string Label, double Delta, string[] Tokens)> items, IReadOnlyList newEvalRows) + { + if (items.Count == 0 && newEvalRows.Count == 0) + { + return; + } + + EnsureCreated(); + using SqliteTransaction transaction = _connection!.BeginTransaction(); + try + { + foreach ((string label, double delta, string[] tokens) in items) + { + ApplyExample(transaction, label, delta, tokens); + } + + InsertEvalRows(transaction, newEvalRows); + if (newEvalRows.Count > 0) + { + PruneEvalLog(transaction); + } + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + + /// + /// Пересоздаёт файл модели + /// + public void DeleteFile() + { + DisposeConnection(); + File.Delete(_filePath); + } + + /// + public void Dispose() => DisposeConnection(); + + private void ApplyExample( + SqliteTransaction transaction, + string label, + double delta, + string[] tokens) + { + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + using (SqliteCommand command = _connection!.CreateCommand()) + { + command.Transaction = transaction; + command.CommandText = + "INSERT INTO classes (label, n, updated_at) VALUES ($label, $delta, $now) " + + "ON CONFLICT(label) DO UPDATE SET n = classes.n + excluded.n, updated_at = excluded.updated_at"; + command.Parameters.AddWithValue("$label", label); + command.Parameters.AddWithValue("$delta", delta); + command.Parameters.AddWithValue("$now", nowMs); + command.ExecuteNonQuery(); + } + + using (SqliteCommand command = _connection.CreateCommand()) + { + command.Transaction = transaction; + command.CommandText = + "INSERT INTO terms (label, term, count) VALUES ($label, $term, $delta) " + + "ON CONFLICT(label, term) DO UPDATE SET count = terms.count + excluded.count"; + command.Parameters.AddWithValue("$label", label); + SqliteParameter termParameter = command.Parameters.AddWithValue("$term", string.Empty); + command.Parameters.AddWithValue("$delta", delta); + foreach (string term in tokens) + { + termParameter.Value = term; + command.ExecuteNonQuery(); + } + } + + if (delta < 0) + { + using SqliteCommand deleteTerms = _connection.CreateCommand(); + deleteTerms.Transaction = transaction; + deleteTerms.CommandText = "DELETE FROM terms WHERE label = $label AND count <= 0"; + deleteTerms.Parameters.AddWithValue("$label", label); + deleteTerms.ExecuteNonQuery(); + + using SqliteCommand deleteClasses = _connection.CreateCommand(); + deleteClasses.Transaction = transaction; + deleteClasses.CommandText = "DELETE FROM classes WHERE n <= 0"; + deleteClasses.ExecuteNonQuery(); + } + } + + // Вставляет строки журнала самооценки (executemany-эквивалент). + // transaction: Транзакция батча. + // rows: Новые строки. + private void InsertEvalRows(SqliteTransaction transaction, IReadOnlyList rows) + { + if (rows.Count == 0) + { + return; + } + + using SqliteCommand command = _connection!.CreateCommand(); + command.Transaction = transaction; + command.CommandText = + "INSERT INTO eval_log (created_at, expected, predicted, correct) VALUES ($at, $expected, $predicted, $correct)"; + SqliteParameter atParameter = command.Parameters.AddWithValue("$at", 0L); + SqliteParameter expectedParameter = command.Parameters.AddWithValue("$expected", string.Empty); + SqliteParameter predictedParameter = command.Parameters.AddWithValue("$predicted", string.Empty); + SqliteParameter correctParameter = command.Parameters.AddWithValue("$correct", 0); + foreach (EvalEntry row in rows) + { + atParameter.Value = row.CreatedAtMs; + expectedParameter.Value = row.ExpectedLabel; + predictedParameter.Value = row.PredictedLabel; + correctParameter.Value = row.Correct ? 1 : 0; + command.ExecuteNonQuery(); + } + } + + private void PruneEvalLog(SqliteTransaction transaction) + { + using SqliteCommand command = _connection!.CreateCommand(); + command.Transaction = transaction; + command.CommandText = + "DELETE FROM eval_log WHERE rowid < (" + + "SELECT rowid FROM eval_log ORDER BY created_at DESC, rowid DESC LIMIT 1 OFFSET $skip)"; + command.Parameters.AddWithValue("$skip", ModelConstants.EvalKeepCount - 1); + command.ExecuteNonQuery(); + } + + // Закрывает и освобождает соединение (Dispose/DeleteFile). + private void DisposeConnection() + { + _connection?.Dispose(); + _connection = null; + } +} diff --git a/src/ml-service/Directory.Build.props b/src/ml-service/Directory.Build.props index b2a7175..0d969a1 100644 --- a/src/ml-service/Directory.Build.props +++ b/src/ml-service/Directory.Build.props @@ -1,11 +1,11 @@ - - - net10.0 - latest - enable - enable - true - latest - true - - + + + net10.0 + latest + enable + enable + true + latest + true + + diff --git a/src/storage-service/Deal.Storage.Tests/AssemblyInfo.cs b/src/storage-service/Deal.Storage.Tests/AssemblyInfo.cs index a88bdbf..2dece4a 100644 --- a/src/storage-service/Deal.Storage.Tests/AssemblyInfo.cs +++ b/src/storage-service/Deal.Storage.Tests/AssemblyInfo.cs @@ -1,3 +1,3 @@ -// Тесты storage-service поднимают Kestrel-хост и меняют env-переменные процесса на время сценария — -// параллельный прогон классов дал бы гонки на env, поэтому тесты сериализованы. -[assembly: CollectionBehavior(DisableTestParallelization = true)] +// Тесты storage-service поднимают Kestrel-хост и меняют env-переменные процесса на время сценария — +// параллельный прогон классов дал бы гонки на env, поэтому тесты сериализованы. +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/src/storage-service/Deal.Storage.Tests/Deal.Storage.Tests.csproj b/src/storage-service/Deal.Storage.Tests/Deal.Storage.Tests.csproj index b58b1e7..fc679cd 100644 --- a/src/storage-service/Deal.Storage.Tests/Deal.Storage.Tests.csproj +++ b/src/storage-service/Deal.Storage.Tests/Deal.Storage.Tests.csproj @@ -1,31 +1,31 @@ - - - - false - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/storage-service/Deal.Storage.Tests/Grpc/StorageServiceHostTests.cs b/src/storage-service/Deal.Storage.Tests/Grpc/StorageServiceHostTests.cs index 6c29f49..aa17ced 100644 --- a/src/storage-service/Deal.Storage.Tests/Grpc/StorageServiceHostTests.cs +++ b/src/storage-service/Deal.Storage.Tests/Grpc/StorageServiceHostTests.cs @@ -1,128 +1,128 @@ -using System.Net; -using System.Net.Sockets; -using Deal.Grpc.Storage; -using Grpc.Core; -using Grpc.Health.V1; -using Grpc.Net.Client; -using Microsoft.AspNetCore.Builder; - -namespace Deal.Storage.Tests.Grpc; - -/// -/// Тесты хоста storage-service: health и проверка токена. -/// -public sealed class StorageServiceHostTests -{ - private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - private const string EndpointEnvKey = "DEAL_STORAGE_ENDPOINT"; - private const string AccessKeyEnvKey = "DEAL_STORAGE_ACCESS_KEY"; - private const string SecretKeyEnvKey = "DEAL_STORAGE_SECRET_KEY"; - private const string ServiceTokenMetadataKey = "service-token"; - private const string ValidToken = "storage-test-token"; - private const int RpcDeadlineSeconds = 10; - - /// - /// Health отвечает SERVING — хост поднялся. - /// - [Fact] - public async Task HealthCheck_ReturnsServing() - { - await RunHostScenarioAsync( - ValidToken, - async channel => - { - var health = new Health.HealthClient(channel); - HealthCheckResponse response = await health.CheckAsync( - new HealthCheckRequest(), - deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); - Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, response.Status); - }); - } - - /// - /// Stat без токена отклоняется. - /// - [Fact] - public async Task Stat_WithoutToken_IsUnauthenticated() - { - await RunHostScenarioAsync( - ValidToken, - channel => AssertRejectedAsync(channel, tokenHeader: null)); - } - - /// - /// Stat с неверным токеном отклоняется. - /// - [Fact] - public async Task Stat_WithWrongToken_IsUnauthenticated() - { - await RunHostScenarioAsync( - ValidToken, - channel => AssertRejectedAsync(channel, tokenHeader: "wrong-token")); - } - - private static async Task AssertRejectedAsync(GrpcChannel channel, string? tokenHeader) - { - var client = new StorageService.StorageServiceClient(channel); - Metadata metadata = new(); - if (tokenHeader is not null) - { - metadata.Add(ServiceTokenMetadataKey, tokenHeader); - } - - var callOptions = new CallOptions(metadata, deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); - AsyncUnaryCall call = client.StatAsync(new StatRequest { Id = "x" }, callOptions); - RpcException exception = await Assert.ThrowsAsync(() => call.ResponseAsync); - Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); - } - - private static async Task RunHostScenarioAsync(string? serviceToken, Func scenario) - { - string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); - string? originalEndpoint = Environment.GetEnvironmentVariable(EndpointEnvKey); - string? originalAccess = Environment.GetEnvironmentVariable(AccessKeyEnvKey); - string? originalSecret = Environment.GetEnvironmentVariable(SecretKeyEnvKey); - - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); - Environment.SetEnvironmentVariable(EndpointEnvKey, "127.0.0.1:9000"); - Environment.SetEnvironmentVariable(AccessKeyEnvKey, "test"); - Environment.SetEnvironmentVariable(SecretKeyEnvKey, "test"); - - WebApplication? app = null; - GrpcChannel? channel = null; - try - { - int port = FreeTcpPort(); - app = StorageServiceHost.Create(port); - await app.StartAsync(); - - channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}"); - await scenario(channel); - } - finally - { - if (channel is not null) - { - channel.Dispose(); - } - - if (app is not null) - { - await app.StopAsync(); - await app.DisposeAsync(); - } - - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); - Environment.SetEnvironmentVariable(EndpointEnvKey, originalEndpoint); - Environment.SetEnvironmentVariable(AccessKeyEnvKey, originalAccess); - Environment.SetEnvironmentVariable(SecretKeyEnvKey, originalSecret); - } - } - - private static int FreeTcpPort() - { - using var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - return ((IPEndPoint)listener.LocalEndpoint).Port; - } -} +using System.Net; +using System.Net.Sockets; +using Deal.Grpc.Storage; +using Grpc.Core; +using Grpc.Health.V1; +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; + +namespace Deal.Storage.Tests.Grpc; + +/// +/// Тесты хоста storage-service: health и проверка токена. +/// +public sealed class StorageServiceHostTests +{ + private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; + private const string EndpointEnvKey = "DEAL_STORAGE_ENDPOINT"; + private const string AccessKeyEnvKey = "DEAL_STORAGE_ACCESS_KEY"; + private const string SecretKeyEnvKey = "DEAL_STORAGE_SECRET_KEY"; + private const string ServiceTokenMetadataKey = "service-token"; + private const string ValidToken = "storage-test-token"; + private const int RpcDeadlineSeconds = 10; + + /// + /// Health отвечает SERVING — хост поднялся. + /// + [Fact] + public async Task HealthCheck_ReturnsServing() + { + await RunHostScenarioAsync( + ValidToken, + async channel => + { + var health = new Health.HealthClient(channel); + HealthCheckResponse response = await health.CheckAsync( + new HealthCheckRequest(), + deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); + Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, response.Status); + }); + } + + /// + /// Stat без токена отклоняется. + /// + [Fact] + public async Task Stat_WithoutToken_IsUnauthenticated() + { + await RunHostScenarioAsync( + ValidToken, + channel => AssertRejectedAsync(channel, tokenHeader: null)); + } + + /// + /// Stat с неверным токеном отклоняется. + /// + [Fact] + public async Task Stat_WithWrongToken_IsUnauthenticated() + { + await RunHostScenarioAsync( + ValidToken, + channel => AssertRejectedAsync(channel, tokenHeader: "wrong-token")); + } + + private static async Task AssertRejectedAsync(GrpcChannel channel, string? tokenHeader) + { + var client = new StorageService.StorageServiceClient(channel); + Metadata metadata = new(); + if (tokenHeader is not null) + { + metadata.Add(ServiceTokenMetadataKey, tokenHeader); + } + + var callOptions = new CallOptions(metadata, deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)); + AsyncUnaryCall call = client.StatAsync(new StatRequest { Id = "x" }, callOptions); + RpcException exception = await Assert.ThrowsAsync(() => call.ResponseAsync); + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + } + + private static async Task RunHostScenarioAsync(string? serviceToken, Func scenario) + { + string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); + string? originalEndpoint = Environment.GetEnvironmentVariable(EndpointEnvKey); + string? originalAccess = Environment.GetEnvironmentVariable(AccessKeyEnvKey); + string? originalSecret = Environment.GetEnvironmentVariable(SecretKeyEnvKey); + + Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); + Environment.SetEnvironmentVariable(EndpointEnvKey, "127.0.0.1:9000"); + Environment.SetEnvironmentVariable(AccessKeyEnvKey, "test"); + Environment.SetEnvironmentVariable(SecretKeyEnvKey, "test"); + + WebApplication? app = null; + GrpcChannel? channel = null; + try + { + int port = FreeTcpPort(); + app = StorageServiceHost.Create(port); + await app.StartAsync(); + + channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}"); + await scenario(channel); + } + finally + { + if (channel is not null) + { + channel.Dispose(); + } + + if (app is not null) + { + await app.StopAsync(); + await app.DisposeAsync(); + } + + Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); + Environment.SetEnvironmentVariable(EndpointEnvKey, originalEndpoint); + Environment.SetEnvironmentVariable(AccessKeyEnvKey, originalAccess); + Environment.SetEnvironmentVariable(SecretKeyEnvKey, originalSecret); + } + } + + private static int FreeTcpPort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } +} diff --git a/src/storage-service/Deal.Storage.Tests/Services/FileKindSnifferTests.cs b/src/storage-service/Deal.Storage.Tests/Services/FileKindSnifferTests.cs index 26ac27a..6cd11a3 100644 --- a/src/storage-service/Deal.Storage.Tests/Services/FileKindSnifferTests.cs +++ b/src/storage-service/Deal.Storage.Tests/Services/FileKindSnifferTests.cs @@ -1,81 +1,81 @@ -using Deal.Storage.Services; - -namespace Deal.Storage.Tests.Services; - -/// -/// Тесты определения типа и MIME по содержимому. -/// -public sealed class FileKindSnifferTests -{ - /// - /// PNG определяется как изображение. - /// - [Fact] - public void Png_IsImage() - { - byte[] head = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; - (string kind, string mime) = FileKindSniffer.Detect(head, null); - Assert.Equal("image", kind); - Assert.Equal("image/png", mime); - } - - /// - /// PDF определяется как документ. - /// - [Fact] - public void Pdf_IsDocument() - { - byte[] head = [0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x37]; - (string kind, string mime) = FileKindSniffer.Detect(head, null); - Assert.Equal("document", kind); - Assert.Equal("application/pdf", mime); - } - - /// - /// MP4 определяется как видео. - /// - [Fact] - public void Mp4_IsVideo() - { - byte[] head = [0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x6D, 0x70, 0x34, 0x32]; - (string kind, string mime) = FileKindSniffer.Detect(head, null); - Assert.Equal("video", kind); - Assert.Equal("video/mp4", mime); - } - - /// - /// MP3 определяется как аудио. - /// - [Fact] - public void Mp3_IsAudio() - { - byte[] head = [0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00]; - (string kind, string mime) = FileKindSniffer.Detect(head, null); - Assert.Equal("audio", kind); - Assert.Equal("audio/mpeg", mime); - } - - /// - /// Неизвестное содержимое с известным MIME берёт тип из MIME. - /// - [Fact] - public void UnknownWithDeclaredText_IsDocument() - { - byte[] head = [0x00, 0x01, 0x02, 0x03]; - (string kind, string mime) = FileKindSniffer.Detect(head, "text/plain"); - Assert.Equal("document", kind); - Assert.Equal("text/plain", mime); - } - - /// - /// Полностью неизвестное содержимое — «other». - /// - [Fact] - public void UnknownWithoutDeclared_IsOther() - { - byte[] head = [0x00, 0x01, 0x02, 0x03]; - (string kind, string mime) = FileKindSniffer.Detect(head, null); - Assert.Equal(FileKindSniffer.KindOther, kind); - Assert.Equal("application/octet-stream", mime); - } -} +using Deal.Storage.Services; + +namespace Deal.Storage.Tests.Services; + +/// +/// Тесты определения типа и MIME по содержимому. +/// +public sealed class FileKindSnifferTests +{ + /// + /// PNG определяется как изображение. + /// + [Fact] + public void Png_IsImage() + { + byte[] head = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + (string kind, string mime) = FileKindSniffer.Detect(head, null); + Assert.Equal("image", kind); + Assert.Equal("image/png", mime); + } + + /// + /// PDF определяется как документ. + /// + [Fact] + public void Pdf_IsDocument() + { + byte[] head = [0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x37]; + (string kind, string mime) = FileKindSniffer.Detect(head, null); + Assert.Equal("document", kind); + Assert.Equal("application/pdf", mime); + } + + /// + /// MP4 определяется как видео. + /// + [Fact] + public void Mp4_IsVideo() + { + byte[] head = [0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x6D, 0x70, 0x34, 0x32]; + (string kind, string mime) = FileKindSniffer.Detect(head, null); + Assert.Equal("video", kind); + Assert.Equal("video/mp4", mime); + } + + /// + /// MP3 определяется как аудио. + /// + [Fact] + public void Mp3_IsAudio() + { + byte[] head = [0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00]; + (string kind, string mime) = FileKindSniffer.Detect(head, null); + Assert.Equal("audio", kind); + Assert.Equal("audio/mpeg", mime); + } + + /// + /// Неизвестное содержимое с известным MIME берёт тип из MIME. + /// + [Fact] + public void UnknownWithDeclaredText_IsDocument() + { + byte[] head = [0x00, 0x01, 0x02, 0x03]; + (string kind, string mime) = FileKindSniffer.Detect(head, "text/plain"); + Assert.Equal("document", kind); + Assert.Equal("text/plain", mime); + } + + /// + /// Полностью неизвестное содержимое — «other». + /// + [Fact] + public void UnknownWithoutDeclared_IsOther() + { + byte[] head = [0x00, 0x01, 0x02, 0x03]; + (string kind, string mime) = FileKindSniffer.Detect(head, null); + Assert.Equal(FileKindSniffer.KindOther, kind); + Assert.Equal("application/octet-stream", mime); + } +} diff --git a/src/storage-service/Deal.Storage.sln b/src/storage-service/Deal.Storage.sln index 72a5f16..83169dd 100644 --- a/src/storage-service/Deal.Storage.sln +++ b/src/storage-service/Deal.Storage.sln @@ -1,76 +1,76 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Storage", "Deal.Storage\Deal.Storage.csproj", "{0728C329-E3A0-4EDD-8305-FE558C01C085}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Grpc.Hosting", "..\grpc-hosting\Deal.Grpc.Hosting\Deal.Grpc.Hosting.csproj", "{67C12483-BC9B-46D8-A66E-B5510AE3E9C2}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Proto", "..\contracts\Deal.Proto.csproj", "{A3470473-FEE8-476C-96C4-0038F920A22A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Storage.Tests", "Deal.Storage.Tests\Deal.Storage.Tests.csproj", "{49D721BA-5380-421F-8072-4B7B0EE4480F}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {0728C329-E3A0-4EDD-8305-FE558C01C085}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0728C329-E3A0-4EDD-8305-FE558C01C085}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0728C329-E3A0-4EDD-8305-FE558C01C085}.Debug|x64.ActiveCfg = Debug|Any CPU - {0728C329-E3A0-4EDD-8305-FE558C01C085}.Debug|x64.Build.0 = Debug|Any CPU - {0728C329-E3A0-4EDD-8305-FE558C01C085}.Debug|x86.ActiveCfg = Debug|Any CPU - {0728C329-E3A0-4EDD-8305-FE558C01C085}.Debug|x86.Build.0 = Debug|Any CPU - {0728C329-E3A0-4EDD-8305-FE558C01C085}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0728C329-E3A0-4EDD-8305-FE558C01C085}.Release|Any CPU.Build.0 = Release|Any CPU - {0728C329-E3A0-4EDD-8305-FE558C01C085}.Release|x64.ActiveCfg = Release|Any CPU - {0728C329-E3A0-4EDD-8305-FE558C01C085}.Release|x64.Build.0 = Release|Any CPU - {0728C329-E3A0-4EDD-8305-FE558C01C085}.Release|x86.ActiveCfg = Release|Any CPU - {0728C329-E3A0-4EDD-8305-FE558C01C085}.Release|x86.Build.0 = Release|Any CPU - {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Debug|x64.ActiveCfg = Debug|Any CPU - {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Debug|x64.Build.0 = Debug|Any CPU - {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Debug|x86.ActiveCfg = Debug|Any CPU - {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Debug|x86.Build.0 = Debug|Any CPU - {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Release|Any CPU.Build.0 = Release|Any CPU - {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Release|x64.ActiveCfg = Release|Any CPU - {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Release|x64.Build.0 = Release|Any CPU - {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Release|x86.ActiveCfg = Release|Any CPU - {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Release|x86.Build.0 = Release|Any CPU - {A3470473-FEE8-476C-96C4-0038F920A22A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A3470473-FEE8-476C-96C4-0038F920A22A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A3470473-FEE8-476C-96C4-0038F920A22A}.Debug|x64.ActiveCfg = Debug|Any CPU - {A3470473-FEE8-476C-96C4-0038F920A22A}.Debug|x64.Build.0 = Debug|Any CPU - {A3470473-FEE8-476C-96C4-0038F920A22A}.Debug|x86.ActiveCfg = Debug|Any CPU - {A3470473-FEE8-476C-96C4-0038F920A22A}.Debug|x86.Build.0 = Debug|Any CPU - {A3470473-FEE8-476C-96C4-0038F920A22A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A3470473-FEE8-476C-96C4-0038F920A22A}.Release|Any CPU.Build.0 = Release|Any CPU - {A3470473-FEE8-476C-96C4-0038F920A22A}.Release|x64.ActiveCfg = Release|Any CPU - {A3470473-FEE8-476C-96C4-0038F920A22A}.Release|x64.Build.0 = Release|Any CPU - {A3470473-FEE8-476C-96C4-0038F920A22A}.Release|x86.ActiveCfg = Release|Any CPU - {A3470473-FEE8-476C-96C4-0038F920A22A}.Release|x86.Build.0 = Release|Any CPU - {49D721BA-5380-421F-8072-4B7B0EE4480F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {49D721BA-5380-421F-8072-4B7B0EE4480F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {49D721BA-5380-421F-8072-4B7B0EE4480F}.Debug|x64.ActiveCfg = Debug|Any CPU - {49D721BA-5380-421F-8072-4B7B0EE4480F}.Debug|x64.Build.0 = Debug|Any CPU - {49D721BA-5380-421F-8072-4B7B0EE4480F}.Debug|x86.ActiveCfg = Debug|Any CPU - {49D721BA-5380-421F-8072-4B7B0EE4480F}.Debug|x86.Build.0 = Debug|Any CPU - {49D721BA-5380-421F-8072-4B7B0EE4480F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {49D721BA-5380-421F-8072-4B7B0EE4480F}.Release|Any CPU.Build.0 = Release|Any CPU - {49D721BA-5380-421F-8072-4B7B0EE4480F}.Release|x64.ActiveCfg = Release|Any CPU - {49D721BA-5380-421F-8072-4B7B0EE4480F}.Release|x64.Build.0 = Release|Any CPU - {49D721BA-5380-421F-8072-4B7B0EE4480F}.Release|x86.ActiveCfg = Release|Any CPU - {49D721BA-5380-421F-8072-4B7B0EE4480F}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Storage", "Deal.Storage\Deal.Storage.csproj", "{0728C329-E3A0-4EDD-8305-FE558C01C085}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Grpc.Hosting", "..\grpc-hosting\Deal.Grpc.Hosting\Deal.Grpc.Hosting.csproj", "{67C12483-BC9B-46D8-A66E-B5510AE3E9C2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Proto", "..\contracts\Deal.Proto.csproj", "{A3470473-FEE8-476C-96C4-0038F920A22A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Storage.Tests", "Deal.Storage.Tests\Deal.Storage.Tests.csproj", "{49D721BA-5380-421F-8072-4B7B0EE4480F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {0728C329-E3A0-4EDD-8305-FE558C01C085}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0728C329-E3A0-4EDD-8305-FE558C01C085}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0728C329-E3A0-4EDD-8305-FE558C01C085}.Debug|x64.ActiveCfg = Debug|Any CPU + {0728C329-E3A0-4EDD-8305-FE558C01C085}.Debug|x64.Build.0 = Debug|Any CPU + {0728C329-E3A0-4EDD-8305-FE558C01C085}.Debug|x86.ActiveCfg = Debug|Any CPU + {0728C329-E3A0-4EDD-8305-FE558C01C085}.Debug|x86.Build.0 = Debug|Any CPU + {0728C329-E3A0-4EDD-8305-FE558C01C085}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0728C329-E3A0-4EDD-8305-FE558C01C085}.Release|Any CPU.Build.0 = Release|Any CPU + {0728C329-E3A0-4EDD-8305-FE558C01C085}.Release|x64.ActiveCfg = Release|Any CPU + {0728C329-E3A0-4EDD-8305-FE558C01C085}.Release|x64.Build.0 = Release|Any CPU + {0728C329-E3A0-4EDD-8305-FE558C01C085}.Release|x86.ActiveCfg = Release|Any CPU + {0728C329-E3A0-4EDD-8305-FE558C01C085}.Release|x86.Build.0 = Release|Any CPU + {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Debug|x64.ActiveCfg = Debug|Any CPU + {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Debug|x64.Build.0 = Debug|Any CPU + {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Debug|x86.ActiveCfg = Debug|Any CPU + {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Debug|x86.Build.0 = Debug|Any CPU + {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Release|Any CPU.Build.0 = Release|Any CPU + {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Release|x64.ActiveCfg = Release|Any CPU + {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Release|x64.Build.0 = Release|Any CPU + {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Release|x86.ActiveCfg = Release|Any CPU + {67C12483-BC9B-46D8-A66E-B5510AE3E9C2}.Release|x86.Build.0 = Release|Any CPU + {A3470473-FEE8-476C-96C4-0038F920A22A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A3470473-FEE8-476C-96C4-0038F920A22A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A3470473-FEE8-476C-96C4-0038F920A22A}.Debug|x64.ActiveCfg = Debug|Any CPU + {A3470473-FEE8-476C-96C4-0038F920A22A}.Debug|x64.Build.0 = Debug|Any CPU + {A3470473-FEE8-476C-96C4-0038F920A22A}.Debug|x86.ActiveCfg = Debug|Any CPU + {A3470473-FEE8-476C-96C4-0038F920A22A}.Debug|x86.Build.0 = Debug|Any CPU + {A3470473-FEE8-476C-96C4-0038F920A22A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A3470473-FEE8-476C-96C4-0038F920A22A}.Release|Any CPU.Build.0 = Release|Any CPU + {A3470473-FEE8-476C-96C4-0038F920A22A}.Release|x64.ActiveCfg = Release|Any CPU + {A3470473-FEE8-476C-96C4-0038F920A22A}.Release|x64.Build.0 = Release|Any CPU + {A3470473-FEE8-476C-96C4-0038F920A22A}.Release|x86.ActiveCfg = Release|Any CPU + {A3470473-FEE8-476C-96C4-0038F920A22A}.Release|x86.Build.0 = Release|Any CPU + {49D721BA-5380-421F-8072-4B7B0EE4480F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {49D721BA-5380-421F-8072-4B7B0EE4480F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {49D721BA-5380-421F-8072-4B7B0EE4480F}.Debug|x64.ActiveCfg = Debug|Any CPU + {49D721BA-5380-421F-8072-4B7B0EE4480F}.Debug|x64.Build.0 = Debug|Any CPU + {49D721BA-5380-421F-8072-4B7B0EE4480F}.Debug|x86.ActiveCfg = Debug|Any CPU + {49D721BA-5380-421F-8072-4B7B0EE4480F}.Debug|x86.Build.0 = Debug|Any CPU + {49D721BA-5380-421F-8072-4B7B0EE4480F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {49D721BA-5380-421F-8072-4B7B0EE4480F}.Release|Any CPU.Build.0 = Release|Any CPU + {49D721BA-5380-421F-8072-4B7B0EE4480F}.Release|x64.ActiveCfg = Release|Any CPU + {49D721BA-5380-421F-8072-4B7B0EE4480F}.Release|x64.Build.0 = Release|Any CPU + {49D721BA-5380-421F-8072-4B7B0EE4480F}.Release|x86.ActiveCfg = Release|Any CPU + {49D721BA-5380-421F-8072-4B7B0EE4480F}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/src/storage-service/Deal.Storage/Deal.Storage.csproj b/src/storage-service/Deal.Storage/Deal.Storage.csproj index e8dbacd..362bdd4 100644 --- a/src/storage-service/Deal.Storage/Deal.Storage.csproj +++ b/src/storage-service/Deal.Storage/Deal.Storage.csproj @@ -1,26 +1,26 @@ - - - - Deal.Storage - Deal.Storage - - - - - - - - - - - - - - - - - - - - - + + + + Deal.Storage + Deal.Storage + + + + + + + + + + + + + + + + + + + + + diff --git a/src/storage-service/Deal.Storage/Dockerfile b/src/storage-service/Deal.Storage/Dockerfile index 21705d1..20555c4 100644 --- a/src/storage-service/Deal.Storage/Dockerfile +++ b/src/storage-service/Deal.Storage/Dockerfile @@ -1,32 +1,32 @@ -# storage-service: общий gRPC-сервис данных (вложения источников). -# -# КОНТЕКСТ СБОРКИ — корень репозитория: Deal.Storage.csproj ссылается на src/contracts/Deal.Proto.csproj -# и src/grpc-hosting вне каталога сервиса. Запуск из корня: -# docker build -f src/storage-service/Deal.Storage/Dockerfile . - -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /repo - -COPY src/contracts/Deal.Proto.csproj src/contracts/ -COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/ -COPY src/storage-service/Directory.Build.props src/storage-service/ -COPY src/storage-service/Deal.Storage/Deal.Storage.csproj src/storage-service/Deal.Storage/ -RUN dotnet restore src/storage-service/Deal.Storage/Deal.Storage.csproj - -COPY src/contracts/ src/contracts/ -COPY src/grpc-hosting/ src/grpc-hosting/ -COPY src/storage-service/Deal.Storage/ src/storage-service/Deal.Storage/ -RUN dotnet publish src/storage-service/Deal.Storage/Deal.Storage.csproj -c Release -o /app/publish - -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final -WORKDIR /app -EXPOSE 5104 -COPY --from=build /app/publish . -COPY --from=ghcr.io/grpc-ecosystem/grpc-health-probe:v0.4.35 /ko-app/grpc-health-probe /bin/grpc_health_probe - -# Non-root: процесс работает пользователем deal (UID 10001); персистентных файлов нет. -RUN useradd --uid 10001 --shell /usr/sbin/nologin --no-create-home deal -ENV HOME=/tmp -USER deal - -ENTRYPOINT ["dotnet", "Deal.Storage.dll"] +# storage-service: общий gRPC-сервис данных (вложения источников). +# +# КОНТЕКСТ СБОРКИ — корень репозитория: Deal.Storage.csproj ссылается на src/contracts/Deal.Proto.csproj +# и src/grpc-hosting вне каталога сервиса. Запуск из корня: +# docker build -f src/storage-service/Deal.Storage/Dockerfile . + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /repo + +COPY src/contracts/Deal.Proto.csproj src/contracts/ +COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/ +COPY src/storage-service/Directory.Build.props src/storage-service/ +COPY src/storage-service/Deal.Storage/Deal.Storage.csproj src/storage-service/Deal.Storage/ +RUN dotnet restore src/storage-service/Deal.Storage/Deal.Storage.csproj + +COPY src/contracts/ src/contracts/ +COPY src/grpc-hosting/ src/grpc-hosting/ +COPY src/storage-service/Deal.Storage/ src/storage-service/Deal.Storage/ +RUN dotnet publish src/storage-service/Deal.Storage/Deal.Storage.csproj -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +EXPOSE 5104 +COPY --from=build /app/publish . +COPY --from=ghcr.io/grpc-ecosystem/grpc-health-probe:v0.4.35 /ko-app/grpc-health-probe /bin/grpc_health_probe + +# Non-root: процесс работает пользователем deal (UID 10001); персистентных файлов нет. +RUN useradd --uid 10001 --shell /usr/sbin/nologin --no-create-home deal +ENV HOME=/tmp +USER deal + +ENTRYPOINT ["dotnet", "Deal.Storage.dll"] diff --git a/src/storage-service/Deal.Storage/Models/StoredObject.cs b/src/storage-service/Deal.Storage/Models/StoredObject.cs index eca3161..d796ecc 100644 --- a/src/storage-service/Deal.Storage/Models/StoredObject.cs +++ b/src/storage-service/Deal.Storage/Models/StoredObject.cs @@ -1,12 +1,12 @@ -namespace Deal.Storage.Models; - -/// -/// Дескриптор сохранённого объекта. -/// -public sealed record StoredObject( - string Id, - string Ref, - string Kind, - string MimeType, - string FileName, - long Size); +namespace Deal.Storage.Models; + +/// +/// Дескриптор сохранённого объекта. +/// +public sealed record StoredObject( + string Id, + string Ref, + string Kind, + string MimeType, + string FileName, + long Size); diff --git a/src/storage-service/Deal.Storage/Options/StorageOptions.cs b/src/storage-service/Deal.Storage/Options/StorageOptions.cs index c536bb2..8fec200 100644 --- a/src/storage-service/Deal.Storage/Options/StorageOptions.cs +++ b/src/storage-service/Deal.Storage/Options/StorageOptions.cs @@ -1,73 +1,73 @@ -using Microsoft.Extensions.Configuration; - -namespace Deal.Storage.Options; - -/// -/// Настройки объектного хранилища сервиса данных. -/// -public sealed class StorageOptions -{ - /// - /// Значение бакета по умолчанию. - /// - public const string DefaultBucketName = "deal-attachments"; - - /// - /// Env-ключ адреса хранилища (host:port). - /// - public const string EndpointEnvKey = "DEAL_STORAGE_ENDPOINT"; - - /// - /// Env-ключ ключа доступа. - /// - public const string AccessKeyEnvKey = "DEAL_STORAGE_ACCESS_KEY"; - - /// - /// Env-ключ секрета доступа. - /// - public const string SecretKeyEnvKey = "DEAL_STORAGE_SECRET_KEY"; - - /// - /// Env-ключ имени бакета. - /// - public const string BucketEnvKey = "DEAL_STORAGE_BUCKET"; - - /// - /// Env-ключ флага TLS (1/true — https). - /// - public const string SecureEnvKey = "DEAL_STORAGE_SECURE"; - - public string Endpoint { get; init; } = string.Empty; - - public string AccessKey { get; init; } = string.Empty; - - public string SecretKey { get; init; } = string.Empty; - - public string Bucket { get; init; } = DefaultBucketName; - - public bool Secure { get; init; } - - /// - /// Читает настройки из конфигурации хоста. - /// - public static StorageOptions FromConfiguration(IConfiguration configuration) - { - ArgumentNullException.ThrowIfNull(configuration); - string bucket = (configuration[BucketEnvKey] ?? string.Empty).Trim(); - return new StorageOptions - { - Endpoint = (configuration[EndpointEnvKey] ?? string.Empty).Trim(), - AccessKey = (configuration[AccessKeyEnvKey] ?? string.Empty).Trim(), - SecretKey = configuration[SecretKeyEnvKey] ?? string.Empty, - Bucket = bucket.Length == 0 ? DefaultBucketName : bucket, - Secure = IsEnabled(configuration[SecureEnvKey]), - }; - } - - /// - /// Разбирает значение флага TLS. - /// - public static bool IsEnabled(string? rawValue) - => string.Equals(rawValue, "1", StringComparison.Ordinal) - || string.Equals(rawValue, "true", StringComparison.OrdinalIgnoreCase); -} +using Microsoft.Extensions.Configuration; + +namespace Deal.Storage.Options; + +/// +/// Настройки объектного хранилища сервиса данных. +/// +public sealed class StorageOptions +{ + /// + /// Значение бакета по умолчанию. + /// + public const string DefaultBucketName = "deal-attachments"; + + /// + /// Env-ключ адреса хранилища (host:port). + /// + public const string EndpointEnvKey = "DEAL_STORAGE_ENDPOINT"; + + /// + /// Env-ключ ключа доступа. + /// + public const string AccessKeyEnvKey = "DEAL_STORAGE_ACCESS_KEY"; + + /// + /// Env-ключ секрета доступа. + /// + public const string SecretKeyEnvKey = "DEAL_STORAGE_SECRET_KEY"; + + /// + /// Env-ключ имени бакета. + /// + public const string BucketEnvKey = "DEAL_STORAGE_BUCKET"; + + /// + /// Env-ключ флага TLS (1/true — https). + /// + public const string SecureEnvKey = "DEAL_STORAGE_SECURE"; + + public string Endpoint { get; init; } = string.Empty; + + public string AccessKey { get; init; } = string.Empty; + + public string SecretKey { get; init; } = string.Empty; + + public string Bucket { get; init; } = DefaultBucketName; + + public bool Secure { get; init; } + + /// + /// Читает настройки из конфигурации хоста. + /// + public static StorageOptions FromConfiguration(IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + string bucket = (configuration[BucketEnvKey] ?? string.Empty).Trim(); + return new StorageOptions + { + Endpoint = (configuration[EndpointEnvKey] ?? string.Empty).Trim(), + AccessKey = (configuration[AccessKeyEnvKey] ?? string.Empty).Trim(), + SecretKey = configuration[SecretKeyEnvKey] ?? string.Empty, + Bucket = bucket.Length == 0 ? DefaultBucketName : bucket, + Secure = IsEnabled(configuration[SecureEnvKey]), + }; + } + + /// + /// Разбирает значение флага TLS. + /// + public static bool IsEnabled(string? rawValue) + => string.Equals(rawValue, "1", StringComparison.Ordinal) + || string.Equals(rawValue, "true", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/storage-service/Deal.Storage/Program.cs b/src/storage-service/Deal.Storage/Program.cs index bb34656..dd40dc3 100644 --- a/src/storage-service/Deal.Storage/Program.cs +++ b/src/storage-service/Deal.Storage/Program.cs @@ -1,31 +1,31 @@ -using Deal.Grpc.Hosting.Interceptors; -using Deal.Grpc.Hosting.Models; -using Deal.Grpc.Hosting.Options; -using Deal.Grpc.Hosting.Services; -using Deal.Storage; - -const int defaultGrpcPort = 5104; -const string storageProcessName = "storage"; - -int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort); -int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); - -WebApplication app = StorageServiceHost.Create( - grpcPort, - configureBuilder: builder => - { - DealLogging.Configure(builder, storageProcessName); - DealMetricsHosting.AddDealMetrics(builder, metricsPort); - }); - -DealMetricsHosting.MapDealMetrics(app); - -MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration); -GrpcHostEnvironment.RequireMtlsInProduction(mtlsOptions); - -app.Logger.LogInformation( - "storage-service стартует: gRPC {Transport} 0.0.0.0:{Port} (health /grpc.health.v1.Health/Check)", - mtlsOptions.Enabled ? "mTLS (TLS + клиентский сертификат)" : "plaintext + service-token", - grpcPort); - -await app.RunAsync(); +using Deal.Grpc.Hosting.Interceptors; +using Deal.Grpc.Hosting.Models; +using Deal.Grpc.Hosting.Options; +using Deal.Grpc.Hosting.Services; +using Deal.Storage; + +const int defaultGrpcPort = 5104; +const string storageProcessName = "storage"; + +int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort); +int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); + +WebApplication app = StorageServiceHost.Create( + grpcPort, + configureBuilder: builder => + { + DealLogging.Configure(builder, storageProcessName); + DealMetricsHosting.AddDealMetrics(builder, metricsPort); + }); + +DealMetricsHosting.MapDealMetrics(app); + +MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration); +GrpcHostEnvironment.RequireMtlsInProduction(mtlsOptions); + +app.Logger.LogInformation( + "storage-service стартует: gRPC {Transport} 0.0.0.0:{Port} (health /grpc.health.v1.Health/Check)", + mtlsOptions.Enabled ? "mTLS (TLS + клиентский сертификат)" : "plaintext + service-token", + grpcPort); + +await app.RunAsync(); diff --git a/src/storage-service/Deal.Storage/Services/FileKindSniffer.cs b/src/storage-service/Deal.Storage/Services/FileKindSniffer.cs index c2be36d..c7557ba 100644 --- a/src/storage-service/Deal.Storage/Services/FileKindSniffer.cs +++ b/src/storage-service/Deal.Storage/Services/FileKindSniffer.cs @@ -1,138 +1,138 @@ -namespace Deal.Storage.Services; - -/// -/// Определяет тип и MIME объекта по первым байтам содержимого. -/// -public static class FileKindSniffer -{ - /// - /// Тип «прочее». - /// - public const string KindOther = "other"; - - private const string DefaultMime = "application/octet-stream"; - - /// - /// Определяет тип и MIME по заголовку файла. - /// - /// Первые байты содержимого. - /// MIME, заявленный загрузчиком (используется как подсказка). - /// Пара «тип, MIME». - public static (string Kind, string MimeType) Detect(ReadOnlySpan head, string? contentType) - { - if (StartsWith(head, 0x89, 0x50, 0x4E, 0x47)) - { - return ("image", "image/png"); - } - - if (StartsWith(head, 0xFF, 0xD8, 0xFF)) - { - return ("image", "image/jpeg"); - } - - if (StartsWith(head, 0x47, 0x49, 0x46, 0x38)) - { - return ("image", "image/gif"); - } - - if (StartsWith(head, 0x42, 0x4D)) - { - return ("image", "image/bmp"); - } - - if (StartsWith(head, 0x49, 0x49, 0x2A, 0x00) || StartsWith(head, 0x4D, 0x4D, 0x00, 0x2A)) - { - return ("image", "image/tiff"); - } - - if (StartsWith(head, 0x25, 0x50, 0x44, 0x46)) - { - return ("document", "application/pdf"); - } - - if (StartsWith(head, 0x50, 0x4B, 0x03, 0x04) || StartsWith(head, 0x50, 0x4B, 0x05, 0x06)) - { - return ("archive", "application/zip"); - } - - if (StartsWith(head, 0x52, 0x61, 0x72, 0x21)) - { - return ("archive", "application/vnd.rar"); - } - - if (StartsWith(head, 0x37, 0x7A, 0xBC, 0xAF)) - { - return ("archive", "application/x-7z-compressed"); - } - - if (StartsWith(head, 0x1F, 0x8B)) - { - return ("archive", "application/gzip"); - } - - if (StartsWith(head, 0x1A, 0x45, 0xDF, 0xA3)) - { - return ("video", "video/webm"); - } - - if (head.Length >= 12 && StartsWith(head, 0x52, 0x49, 0x46, 0x46)) - { - if (StartsWith(head[8..], 0x57, 0x45, 0x42, 0x50)) - { - return ("image", "image/webp"); - } - - if (StartsWith(head[8..], 0x57, 0x41, 0x56, 0x45)) - { - return ("audio", "audio/wav"); - } - - if (StartsWith(head[8..], 0x41, 0x56, 0x49, 0x20)) - { - return ("video", "video/x-msvideo"); - } - } - - if (head.Length >= 8 && StartsWith(head[4..], 0x66, 0x74, 0x79, 0x70)) - { - return ("video", "video/mp4"); - } - - if (StartsWith(head, 0x49, 0x44, 0x33) || StartsWith(head, 0xFF, 0xFB)) - { - return ("audio", "audio/mpeg"); - } - - if (StartsWith(head, 0x4F, 0x67, 0x67, 0x53)) - { - return ("audio", "audio/ogg"); - } - - if (StartsWith(head, 0x66, 0x4C, 0x61, 0x43)) - { - return ("audio", "audio/flac"); - } - - string declared = (contentType ?? string.Empty).Trim(); - if (declared.Length == 0) - { - return (KindOther, DefaultMime); - } - - return (KindFromMime(declared), declared); - } - - // Определяет общий тип по префиксу MIME-типа. - private static string KindFromMime(string mime) - { - if (mime.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) return "image"; - if (mime.StartsWith("video/", StringComparison.OrdinalIgnoreCase)) return "video"; - if (mime.StartsWith("audio/", StringComparison.OrdinalIgnoreCase)) return "audio"; - if (mime.StartsWith("text/", StringComparison.OrdinalIgnoreCase)) return "document"; - return KindOther; - } - - // Проверяет, начинается ли последовательность с заданных байтов. - private static bool StartsWith(ReadOnlySpan data, params byte[] prefix) - => data.Length >= prefix.Length && data[..prefix.Length].SequenceEqual(prefix); -} +namespace Deal.Storage.Services; + +/// +/// Определяет тип и MIME объекта по первым байтам содержимого. +/// +public static class FileKindSniffer +{ + /// + /// Тип «прочее». + /// + public const string KindOther = "other"; + + private const string DefaultMime = "application/octet-stream"; + + /// + /// Определяет тип и MIME по заголовку файла. + /// + /// Первые байты содержимого. + /// MIME, заявленный загрузчиком (используется как подсказка). + /// Пара «тип, MIME». + public static (string Kind, string MimeType) Detect(ReadOnlySpan head, string? contentType) + { + if (StartsWith(head, 0x89, 0x50, 0x4E, 0x47)) + { + return ("image", "image/png"); + } + + if (StartsWith(head, 0xFF, 0xD8, 0xFF)) + { + return ("image", "image/jpeg"); + } + + if (StartsWith(head, 0x47, 0x49, 0x46, 0x38)) + { + return ("image", "image/gif"); + } + + if (StartsWith(head, 0x42, 0x4D)) + { + return ("image", "image/bmp"); + } + + if (StartsWith(head, 0x49, 0x49, 0x2A, 0x00) || StartsWith(head, 0x4D, 0x4D, 0x00, 0x2A)) + { + return ("image", "image/tiff"); + } + + if (StartsWith(head, 0x25, 0x50, 0x44, 0x46)) + { + return ("document", "application/pdf"); + } + + if (StartsWith(head, 0x50, 0x4B, 0x03, 0x04) || StartsWith(head, 0x50, 0x4B, 0x05, 0x06)) + { + return ("archive", "application/zip"); + } + + if (StartsWith(head, 0x52, 0x61, 0x72, 0x21)) + { + return ("archive", "application/vnd.rar"); + } + + if (StartsWith(head, 0x37, 0x7A, 0xBC, 0xAF)) + { + return ("archive", "application/x-7z-compressed"); + } + + if (StartsWith(head, 0x1F, 0x8B)) + { + return ("archive", "application/gzip"); + } + + if (StartsWith(head, 0x1A, 0x45, 0xDF, 0xA3)) + { + return ("video", "video/webm"); + } + + if (head.Length >= 12 && StartsWith(head, 0x52, 0x49, 0x46, 0x46)) + { + if (StartsWith(head[8..], 0x57, 0x45, 0x42, 0x50)) + { + return ("image", "image/webp"); + } + + if (StartsWith(head[8..], 0x57, 0x41, 0x56, 0x45)) + { + return ("audio", "audio/wav"); + } + + if (StartsWith(head[8..], 0x41, 0x56, 0x49, 0x20)) + { + return ("video", "video/x-msvideo"); + } + } + + if (head.Length >= 8 && StartsWith(head[4..], 0x66, 0x74, 0x79, 0x70)) + { + return ("video", "video/mp4"); + } + + if (StartsWith(head, 0x49, 0x44, 0x33) || StartsWith(head, 0xFF, 0xFB)) + { + return ("audio", "audio/mpeg"); + } + + if (StartsWith(head, 0x4F, 0x67, 0x67, 0x53)) + { + return ("audio", "audio/ogg"); + } + + if (StartsWith(head, 0x66, 0x4C, 0x61, 0x43)) + { + return ("audio", "audio/flac"); + } + + string declared = (contentType ?? string.Empty).Trim(); + if (declared.Length == 0) + { + return (KindOther, DefaultMime); + } + + return (KindFromMime(declared), declared); + } + + // Определяет общий тип по префиксу MIME-типа. + private static string KindFromMime(string mime) + { + if (mime.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) return "image"; + if (mime.StartsWith("video/", StringComparison.OrdinalIgnoreCase)) return "video"; + if (mime.StartsWith("audio/", StringComparison.OrdinalIgnoreCase)) return "audio"; + if (mime.StartsWith("text/", StringComparison.OrdinalIgnoreCase)) return "document"; + return KindOther; + } + + // Проверяет, начинается ли последовательность с заданных байтов. + private static bool StartsWith(ReadOnlySpan data, params byte[] prefix) + => data.Length >= prefix.Length && data[..prefix.Length].SequenceEqual(prefix); +} diff --git a/src/storage-service/Deal.Storage/Services/MinioObjectStore.cs b/src/storage-service/Deal.Storage/Services/MinioObjectStore.cs index e665a3e..aa89953 100644 --- a/src/storage-service/Deal.Storage/Services/MinioObjectStore.cs +++ b/src/storage-service/Deal.Storage/Services/MinioObjectStore.cs @@ -1,195 +1,195 @@ -using Deal.Storage.Options; -using Microsoft.Extensions.Logging; -using Minio; -using Minio.DataModel; -using Minio.DataModel.Args; -using Minio.Exceptions; - -namespace Deal.Storage.Services; - -/// -/// Объектное хранилище сервиса данных поверх MinIO. -/// -public sealed class MinioObjectStore -{ - private const string DefaultContentType = "application/octet-stream"; - - private readonly IMinioClient _client; - private readonly string _endpoint; - private readonly string _bucket; - private readonly ILogger _logger; - - private readonly SemaphoreSlim _bucketCheckGate = new(1, 1); - - private bool _bucketChecked; - - /// - /// Создаёт хранилище поверх настроек. - /// - public MinioObjectStore(StorageOptions options, ILogger logger) - { - ArgumentNullException.ThrowIfNull(options); - ArgumentNullException.ThrowIfNull(logger); - - if (string.IsNullOrWhiteSpace(options.Endpoint) - || string.IsNullOrWhiteSpace(options.AccessKey) - || string.IsNullOrWhiteSpace(options.SecretKey)) - { - throw new InvalidOperationException( - "MinioObjectStore требует заполненные настройки хранилища (Endpoint и ключи доступа)."); - } - - _endpoint = options.Endpoint; - _bucket = options.Bucket; - _logger = logger; - _client = new MinioClient() - .WithEndpoint(options.Endpoint) - .WithCredentials(options.AccessKey, options.SecretKey) - .WithSSL(options.Secure) - .Build(); - } - - /// - /// Возвращает подпись режима для стартового лога. - /// - public override string ToString() => $"MinioObjectStore (endpoint: {_endpoint}; bucket: {_bucket})"; - - /// - /// Сохраняет объект, возвращает его размер. - /// - public async Task PutAsync(string key, Stream content, string contentType, CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(content); - await EnsureBucketAsync(ct); - - if (content.CanSeek && content.Position != 0) - { - content.Position = 0; - } - - using MemoryStream buffer = new(); - await content.CopyToAsync(buffer, ct); - buffer.Position = 0; - - await _client.PutObjectAsync( - new PutObjectArgs() - .WithBucket(_bucket) - .WithObject(key) - .WithStreamData(buffer) - .WithObjectSize(buffer.Length) - .WithContentType(string.IsNullOrWhiteSpace(contentType) ? DefaultContentType : contentType), - ct); - - return buffer.Length; - } - - /// - /// Возвращает содержимое объекта потоком либо null. - /// - public async Task GetAsync(string key, CancellationToken ct) - { - MemoryStream buffer = new(); - try - { - await _client.GetObjectAsync( - new GetObjectArgs() - .WithBucket(_bucket) - .WithObject(key) - .WithCallbackStream(async (stream, token) => await stream.CopyToAsync(buffer, token)), - ct); - } - catch (ObjectNotFoundException) - { - buffer.Dispose(); - return null; - } - catch (Exception) - { - buffer.Dispose(); - throw; - } - - buffer.Position = 0; - return buffer; - } - - /// - /// Возвращает размер и MIME объекта либо null. - /// - public async Task<(long Size, string ContentType)?> StatAsync(string key, CancellationToken ct) - { - try - { - ObjectStat stat = await _client.StatObjectAsync( - new StatObjectArgs().WithBucket(_bucket).WithObject(key), - ct); - return (stat.Size, stat.ContentType ?? string.Empty); - } - catch (ObjectNotFoundException) - { - return null; - } - } - - /// - /// Удаляет объект (отсутствующий — успех). - /// - public async Task DeleteAsync(string key, CancellationToken ct) - { - try - { - await _client.RemoveObjectAsync( - new RemoveObjectArgs().WithBucket(_bucket).WithObject(key), - ct); - } - catch (MinioException exception) - { - _logger.LogWarning( - exception, - "Не удалось удалить объект «{Key}» из бакета «{Bucket}»: {Message}", - key, - _bucket, - exception.Message); - } - } - - private async Task EnsureBucketAsync(CancellationToken ct) - { - if (_bucketChecked) - { - return; - } - - await _bucketCheckGate.WaitAsync(ct); - try - { - if (_bucketChecked) - { - return; - } - - try - { - bool exists = await _client.BucketExistsAsync(new BucketExistsArgs().WithBucket(_bucket), ct); - if (!exists) - { - await _client.MakeBucketAsync(new MakeBucketArgs().WithBucket(_bucket), ct); - } - } - catch (MinioException exception) - { - _logger.LogWarning( - exception, - "Не удалось проверить/создать бакет «{Bucket}»: {Message}", - _bucket, - exception.Message); - } - - _bucketChecked = true; - } - finally - { - _bucketCheckGate.Release(); - } - } -} +using Deal.Storage.Options; +using Microsoft.Extensions.Logging; +using Minio; +using Minio.DataModel; +using Minio.DataModel.Args; +using Minio.Exceptions; + +namespace Deal.Storage.Services; + +/// +/// Объектное хранилище сервиса данных поверх MinIO. +/// +public sealed class MinioObjectStore +{ + private const string DefaultContentType = "application/octet-stream"; + + private readonly IMinioClient _client; + private readonly string _endpoint; + private readonly string _bucket; + private readonly ILogger _logger; + + private readonly SemaphoreSlim _bucketCheckGate = new(1, 1); + + private bool _bucketChecked; + + /// + /// Создаёт хранилище поверх настроек. + /// + public MinioObjectStore(StorageOptions options, ILogger logger) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(logger); + + if (string.IsNullOrWhiteSpace(options.Endpoint) + || string.IsNullOrWhiteSpace(options.AccessKey) + || string.IsNullOrWhiteSpace(options.SecretKey)) + { + throw new InvalidOperationException( + "MinioObjectStore требует заполненные настройки хранилища (Endpoint и ключи доступа)."); + } + + _endpoint = options.Endpoint; + _bucket = options.Bucket; + _logger = logger; + _client = new MinioClient() + .WithEndpoint(options.Endpoint) + .WithCredentials(options.AccessKey, options.SecretKey) + .WithSSL(options.Secure) + .Build(); + } + + /// + /// Возвращает подпись режима для стартового лога. + /// + public override string ToString() => $"MinioObjectStore (endpoint: {_endpoint}; bucket: {_bucket})"; + + /// + /// Сохраняет объект, возвращает его размер. + /// + public async Task PutAsync(string key, Stream content, string contentType, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(content); + await EnsureBucketAsync(ct); + + if (content.CanSeek && content.Position != 0) + { + content.Position = 0; + } + + using MemoryStream buffer = new(); + await content.CopyToAsync(buffer, ct); + buffer.Position = 0; + + await _client.PutObjectAsync( + new PutObjectArgs() + .WithBucket(_bucket) + .WithObject(key) + .WithStreamData(buffer) + .WithObjectSize(buffer.Length) + .WithContentType(string.IsNullOrWhiteSpace(contentType) ? DefaultContentType : contentType), + ct); + + return buffer.Length; + } + + /// + /// Возвращает содержимое объекта потоком либо null. + /// + public async Task GetAsync(string key, CancellationToken ct) + { + MemoryStream buffer = new(); + try + { + await _client.GetObjectAsync( + new GetObjectArgs() + .WithBucket(_bucket) + .WithObject(key) + .WithCallbackStream(async (stream, token) => await stream.CopyToAsync(buffer, token)), + ct); + } + catch (ObjectNotFoundException) + { + buffer.Dispose(); + return null; + } + catch (Exception) + { + buffer.Dispose(); + throw; + } + + buffer.Position = 0; + return buffer; + } + + /// + /// Возвращает размер и MIME объекта либо null. + /// + public async Task<(long Size, string ContentType)?> StatAsync(string key, CancellationToken ct) + { + try + { + ObjectStat stat = await _client.StatObjectAsync( + new StatObjectArgs().WithBucket(_bucket).WithObject(key), + ct); + return (stat.Size, stat.ContentType ?? string.Empty); + } + catch (ObjectNotFoundException) + { + return null; + } + } + + /// + /// Удаляет объект (отсутствующий — успех). + /// + public async Task DeleteAsync(string key, CancellationToken ct) + { + try + { + await _client.RemoveObjectAsync( + new RemoveObjectArgs().WithBucket(_bucket).WithObject(key), + ct); + } + catch (MinioException exception) + { + _logger.LogWarning( + exception, + "Не удалось удалить объект «{Key}» из бакета «{Bucket}»: {Message}", + key, + _bucket, + exception.Message); + } + } + + private async Task EnsureBucketAsync(CancellationToken ct) + { + if (_bucketChecked) + { + return; + } + + await _bucketCheckGate.WaitAsync(ct); + try + { + if (_bucketChecked) + { + return; + } + + try + { + bool exists = await _client.BucketExistsAsync(new BucketExistsArgs().WithBucket(_bucket), ct); + if (!exists) + { + await _client.MakeBucketAsync(new MakeBucketArgs().WithBucket(_bucket), ct); + } + } + catch (MinioException exception) + { + _logger.LogWarning( + exception, + "Не удалось проверить/создать бакет «{Bucket}»: {Message}", + _bucket, + exception.Message); + } + + _bucketChecked = true; + } + finally + { + _bucketCheckGate.Release(); + } + } +} diff --git a/src/storage-service/Deal.Storage/StorageServiceHost.cs b/src/storage-service/Deal.Storage/StorageServiceHost.cs index 15bb8a8..c4c1673 100644 --- a/src/storage-service/Deal.Storage/StorageServiceHost.cs +++ b/src/storage-service/Deal.Storage/StorageServiceHost.cs @@ -1,47 +1,47 @@ -using Deal.Grpc.Hosting.Services; -using Deal.Storage.Options; -using Deal.Storage.Services; - -namespace Deal.Storage; - -/// -/// Собирает WebApplication gRPC-хоста сервиса данных. -/// -public static class StorageServiceHost -{ - /// - /// Создаёт (не запускает) хост. - /// - /// TCP-порт Kestrel. - /// Аргументы командной строки. - /// Опциональный хук DI для тестов. - /// Опциональный хук конфигурации билдера (логи/метрики). - /// Собранный хост. - public static WebApplication Create( - int grpcPort, - string[]? args = null, - Action? configureServices = null, - Action? configureBuilder = null) - { - WebApplicationBuilder builder = WebApplication.CreateBuilder(args ?? []); - - var mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder); - GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates); - builder.Services.AddDealGrpcServer(); - builder.Services.AddReadyHealthCheck("хост storage-service готов"); - - StorageOptions storageOptions = StorageOptions.FromConfiguration(builder.Configuration); - builder.Services.AddSingleton(storageOptions); - builder.Services.AddSingleton(); - - configureServices?.Invoke(builder.Services); - configureBuilder?.Invoke(builder); - - WebApplication app = builder.Build(); - - app.MapGrpcService(); - app.MapGrpcHealthChecksService(); - - return app; - } -} +using Deal.Grpc.Hosting.Services; +using Deal.Storage.Options; +using Deal.Storage.Services; + +namespace Deal.Storage; + +/// +/// Собирает WebApplication gRPC-хоста сервиса данных. +/// +public static class StorageServiceHost +{ + /// + /// Создаёт (не запускает) хост. + /// + /// TCP-порт Kestrel. + /// Аргументы командной строки. + /// Опциональный хук DI для тестов. + /// Опциональный хук конфигурации билдера (логи/метрики). + /// Собранный хост. + public static WebApplication Create( + int grpcPort, + string[]? args = null, + Action? configureServices = null, + Action? configureBuilder = null) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(args ?? []); + + var mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder); + GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates); + builder.Services.AddDealGrpcServer(); + builder.Services.AddReadyHealthCheck("хост storage-service готов"); + + StorageOptions storageOptions = StorageOptions.FromConfiguration(builder.Configuration); + builder.Services.AddSingleton(storageOptions); + builder.Services.AddSingleton(); + + configureServices?.Invoke(builder.Services); + configureBuilder?.Invoke(builder); + + WebApplication app = builder.Build(); + + app.MapGrpcService(); + app.MapGrpcHealthChecksService(); + + return app; + } +} diff --git a/src/storage-service/Deal.Storage/StorageServiceImpl.cs b/src/storage-service/Deal.Storage/StorageServiceImpl.cs index 99f422a..184f733 100644 --- a/src/storage-service/Deal.Storage/StorageServiceImpl.cs +++ b/src/storage-service/Deal.Storage/StorageServiceImpl.cs @@ -1,135 +1,135 @@ -using Deal.Grpc.Storage; -using Deal.Storage.Services; -using Grpc.Core; - -namespace Deal.Storage; - -/// -/// Реализация сервиса данных. -/// -public sealed class StorageServiceImpl : StorageService.StorageServiceBase -{ - private const int HeadSize = 512; - private const int DownloadChunkSize = 64 * 1024; - - private readonly MinioObjectStore _store; - private readonly ILogger _logger; - - /// - /// Создаёт реализацию поверх хранилища. - /// - public StorageServiceImpl(MinioObjectStore store, ILogger logger) - { - ArgumentNullException.ThrowIfNull(store); - ArgumentNullException.ThrowIfNull(logger); - _store = store; - _logger = logger; - } - - /// - public override async Task Upload( - IAsyncStreamReader requestStream, - ServerCallContext context) - { - CancellationToken ct = context.CancellationToken; - await using MemoryStream buffer = new(); - byte[] head = new byte[HeadSize]; - int headLength = 0; - UploadMeta? meta = null; - - while (await requestStream.MoveNext(ct)) - { - UploadRequest message = requestStream.Current; - if (meta is null && message.Meta is not null) - { - meta = message.Meta; - } - - if (message.Data.Length == 0) - { - continue; - } - - if (headLength < HeadSize) - { - int take = Math.Min(HeadSize - headLength, message.Data.Length); - message.Data.Span[..take].CopyTo(head.AsSpan(headLength)); - headLength += take; - } - - await buffer.WriteAsync(message.Data.Memory, ct); - } - - if (meta is null || string.IsNullOrWhiteSpace(meta.TenantId)) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, "tenant_id обязателен")); - } - - (string kind, string mimeType) = FileKindSniffer.Detect(head.AsSpan(0, headLength), meta.ContentType); - string key = $"{meta.TenantId.Trim()}/{Guid.NewGuid():N}"; - buffer.Position = 0; - long size = await _store.PutAsync(key, buffer, mimeType, ct); - - _logger.LogInformation("Объект сохранён: {Key} ({Kind}, {Size} байт)", key, kind, size); - - return new UploadReply - { - Id = key, - Ref = key, - Kind = kind, - MimeType = mimeType, - FileName = meta.FileName ?? string.Empty, - Size = size, - }; - } - - /// - public override async Task Download( - DownloadRequest request, - IServerStreamWriter responseStream, - ServerCallContext context) - { - CancellationToken ct = context.CancellationToken; - await using Stream? content = await _store.GetAsync(request.Id, ct); - if (content is null) - { - throw new RpcException(new Status(StatusCode.NotFound, "объект не найден")); - } - - byte[] chunk = new byte[DownloadChunkSize]; - int read; - while ((read = await content.ReadAsync(chunk, ct)) > 0) - { - await responseStream.WriteAsync(new DownloadChunk { Data = Google.Protobuf.ByteString.CopyFrom(chunk, 0, read) }, ct); - } - } - - /// - public override async Task Stat(StatRequest request, ServerCallContext context) - { - (long Size, string ContentType)? stat = await _store.StatAsync(request.Id, context.CancellationToken); - if (stat is null) - { - return new StatReply { Found = false }; - } - - return new StatReply - { - Found = true, - Info = new UploadReply - { - Id = request.Id, - Ref = request.Id, - MimeType = stat.Value.ContentType, - Size = stat.Value.Size, - }, - }; - } - - /// - public override async Task Delete(DeleteRequest request, ServerCallContext context) - { - await _store.DeleteAsync(request.Id, context.CancellationToken); - return new DeleteReply(); - } -} +using Deal.Grpc.Storage; +using Deal.Storage.Services; +using Grpc.Core; + +namespace Deal.Storage; + +/// +/// Реализация сервиса данных. +/// +public sealed class StorageServiceImpl : StorageService.StorageServiceBase +{ + private const int HeadSize = 512; + private const int DownloadChunkSize = 64 * 1024; + + private readonly MinioObjectStore _store; + private readonly ILogger _logger; + + /// + /// Создаёт реализацию поверх хранилища. + /// + public StorageServiceImpl(MinioObjectStore store, ILogger logger) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(logger); + _store = store; + _logger = logger; + } + + /// + public override async Task Upload( + IAsyncStreamReader requestStream, + ServerCallContext context) + { + CancellationToken ct = context.CancellationToken; + await using MemoryStream buffer = new(); + byte[] head = new byte[HeadSize]; + int headLength = 0; + UploadMeta? meta = null; + + while (await requestStream.MoveNext(ct)) + { + UploadRequest message = requestStream.Current; + if (meta is null && message.Meta is not null) + { + meta = message.Meta; + } + + if (message.Data.Length == 0) + { + continue; + } + + if (headLength < HeadSize) + { + int take = Math.Min(HeadSize - headLength, message.Data.Length); + message.Data.Span[..take].CopyTo(head.AsSpan(headLength)); + headLength += take; + } + + await buffer.WriteAsync(message.Data.Memory, ct); + } + + if (meta is null || string.IsNullOrWhiteSpace(meta.TenantId)) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, "tenant_id обязателен")); + } + + (string kind, string mimeType) = FileKindSniffer.Detect(head.AsSpan(0, headLength), meta.ContentType); + string key = $"{meta.TenantId.Trim()}/{Guid.NewGuid():N}"; + buffer.Position = 0; + long size = await _store.PutAsync(key, buffer, mimeType, ct); + + _logger.LogInformation("Объект сохранён: {Key} ({Kind}, {Size} байт)", key, kind, size); + + return new UploadReply + { + Id = key, + Ref = key, + Kind = kind, + MimeType = mimeType, + FileName = meta.FileName ?? string.Empty, + Size = size, + }; + } + + /// + public override async Task Download( + DownloadRequest request, + IServerStreamWriter responseStream, + ServerCallContext context) + { + CancellationToken ct = context.CancellationToken; + await using Stream? content = await _store.GetAsync(request.Id, ct); + if (content is null) + { + throw new RpcException(new Status(StatusCode.NotFound, "объект не найден")); + } + + byte[] chunk = new byte[DownloadChunkSize]; + int read; + while ((read = await content.ReadAsync(chunk, ct)) > 0) + { + await responseStream.WriteAsync(new DownloadChunk { Data = Google.Protobuf.ByteString.CopyFrom(chunk, 0, read) }, ct); + } + } + + /// + public override async Task Stat(StatRequest request, ServerCallContext context) + { + (long Size, string ContentType)? stat = await _store.StatAsync(request.Id, context.CancellationToken); + if (stat is null) + { + return new StatReply { Found = false }; + } + + return new StatReply + { + Found = true, + Info = new UploadReply + { + Id = request.Id, + Ref = request.Id, + MimeType = stat.Value.ContentType, + Size = stat.Value.Size, + }, + }; + } + + /// + public override async Task Delete(DeleteRequest request, ServerCallContext context) + { + await _store.DeleteAsync(request.Id, context.CancellationToken); + return new DeleteReply(); + } +} diff --git a/src/storage-service/Directory.Build.props b/src/storage-service/Directory.Build.props index b2a7175..0d969a1 100644 --- a/src/storage-service/Directory.Build.props +++ b/src/storage-service/Directory.Build.props @@ -1,11 +1,11 @@ - - - net10.0 - latest - enable - enable - true - latest - true - - + + + net10.0 + latest + enable + enable + true + latest + true + + diff --git a/src/telegram-service/Deal.Telegram.Tests/AssemblyInfo.cs b/src/telegram-service/Deal.Telegram.Tests/AssemblyInfo.cs index 2ad82d0..4afc528 100644 --- a/src/telegram-service/Deal.Telegram.Tests/AssemblyInfo.cs +++ b/src/telegram-service/Deal.Telegram.Tests/AssemblyInfo.cs @@ -1,4 +1,4 @@ -// Интеграционные тесты telegram-service поднимают реальные Kestrel-хосты и меняют процесс-глобальные -// env-переменные (DEAL_SERVICE_TOKEN/DEAL_TELEGRAM_SESSION_KEY/DEAL_TELEGRAM_SESSION_DIR) на время -// сценария (TelegramTestHost). Параллельный прогон классов дал бы гонки на env — тесты сериализованы. -[assembly: CollectionBehavior(DisableTestParallelization = true)] +// Интеграционные тесты telegram-service поднимают реальные Kestrel-хосты и меняют процесс-глобальные +// env-переменные (DEAL_SERVICE_TOKEN/DEAL_TELEGRAM_SESSION_KEY/DEAL_TELEGRAM_SESSION_DIR) на время +// сценария (TelegramTestHost). Параллельный прогон классов дал бы гонки на env — тесты сериализованы. +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/src/telegram-service/Deal.Telegram.Tests/Deal.Telegram.Tests.csproj b/src/telegram-service/Deal.Telegram.Tests/Deal.Telegram.Tests.csproj index 7dbee9b..ece5e57 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Deal.Telegram.Tests.csproj +++ b/src/telegram-service/Deal.Telegram.Tests/Deal.Telegram.Tests.csproj @@ -1,39 +1,39 @@ - - - - - false - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/CoreIngressClientTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/CoreIngressClientTests.cs index cca9d0d..eda5b81 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/CoreIngressClientTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/CoreIngressClientTests.cs @@ -1,114 +1,114 @@ -using Deal.Grpc.Sources; -using Deal.Grpc.Telegram; -using Deal.Telegram.Core; -using Deal.Telegram.Sessions; -using Grpc.Core; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Telegram.Tests.Grpc; - -/// -/// Тесты исходящего gRPC-канала в ядро. -/// -public sealed class CoreIngressClientTests -{ - private const string TenantId = "tenant-ingress"; - private const string ServiceToken = "deal-test-token"; - - /// - /// PushSource: тело уходит, metadata tenant-id/service-token на месте; ответ accepted. - /// - [Fact] - public async Task PushSource_SendsSourceWithMetadata_AndReturnsReply() - { - (string endpoint, RecordingIngressService server, WebApplication app) = await FakeIngressServer.StartAsync(); - try - { - CoreIngressClient client = CreateClient(endpoint); - - PushSourceReply reply = await client.PushSourceAsync( - TenantId, - new PushSourceRequest - { - Source = new SourceRefProto - { - Kind = "telegram", - ExternalId = "42", - OriginRef = "-1001234567890", - DisplayName = "IT Канал", - }, - Content = new SourceContentProto { Text = "сообщение" }, - }, - CancellationToken.None); - - Assert.True(reply.Accepted); - PushSourceRequest sent = Assert.Single(server.Pushes); - Assert.Equal("-1001234567890", sent.Source.OriginRef); - Assert.Equal("сообщение", sent.Content.Text); - Assert.Equal(TenantId, Assert.Single(server.Tenants)); - Assert.Equal(ServiceToken, Assert.Single(server.Tokens)); - } - finally - { - await app.DisposeAsync(); - } - } - - /// - /// SyncDialogs: entries уходят; ответ ядра - /// - [Fact] - public async Task SyncDialogs_SendsEntries_AndReturnsMonitoredIds() - { - (string endpoint, RecordingIngressService server, WebApplication app) = await FakeIngressServer.StartAsync(); - try - { - server.MonitoredIds.Add("-1001234567890"); - CoreIngressClient client = CreateClient(endpoint); - var entries = new List - { - new() { Id = "-1001234567890", Name = "IT Канал", Kind = "channel" }, - }; - - IReadOnlyList monitored = await client.SyncDialogsAsync(TenantId, entries, CancellationToken.None); - - Assert.Equal(["-1001234567890"], monitored); - SyncDialogsRequest sent = Assert.Single(server.Syncs); - Assert.Equal("-1001234567890", Assert.Single(sent.Entries).Id); - Assert.Equal(TenantId, Assert.Single(server.Tenants)); - } - finally - { - await app.DisposeAsync(); - } - } - - /// - /// Ядро недоступно → SessionException UNAVAILABLE «Ядро недоступно…» - /// - [Fact] - public async Task PushSource_UnreachableCore_ThrowsSessionException() - { - // Порт без сервера: соединение отклоняется — вызов падает до deadline (RpcTimeout 15 с). - CoreIngressClient client = CreateClient("http://127.0.0.1:1"); - - SessionException exception = await Assert.ThrowsAsync( - () => client.PushSourceAsync( - TenantId, - new PushSourceRequest - { - Source = new SourceRefProto { Kind = "telegram", OriginRef = "-1001" }, - Content = new SourceContentProto { Text = "x" }, - }, - CancellationToken.None)); - - Assert.Equal(StatusCode.Unavailable, exception.Code); - Assert.Equal(SessionErrorMessages.IngressUnavailable, exception.Message); - } - - // Клиент с endpoint из опций (чтение env/config проверяет FromConfiguration). - // endpoint: Адрес фейк-сервера. - private static CoreIngressClient CreateClient(string endpoint) - => new(CoreIngressOptions.Create(endpoint, ServiceToken), NullLogger.Instance); -} +using Deal.Grpc.Sources; +using Deal.Grpc.Telegram; +using Deal.Telegram.Core; +using Deal.Telegram.Sessions; +using Grpc.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Telegram.Tests.Grpc; + +/// +/// Тесты исходящего gRPC-канала в ядро. +/// +public sealed class CoreIngressClientTests +{ + private const string TenantId = "tenant-ingress"; + private const string ServiceToken = "deal-test-token"; + + /// + /// PushSource: тело уходит, metadata tenant-id/service-token на месте; ответ accepted. + /// + [Fact] + public async Task PushSource_SendsSourceWithMetadata_AndReturnsReply() + { + (string endpoint, RecordingIngressService server, WebApplication app) = await FakeIngressServer.StartAsync(); + try + { + CoreIngressClient client = CreateClient(endpoint); + + PushSourceReply reply = await client.PushSourceAsync( + TenantId, + new PushSourceRequest + { + Source = new SourceRefProto + { + Kind = "telegram", + ExternalId = "42", + OriginRef = "-1001234567890", + DisplayName = "IT Канал", + }, + Content = new SourceContentProto { Text = "сообщение" }, + }, + CancellationToken.None); + + Assert.True(reply.Accepted); + PushSourceRequest sent = Assert.Single(server.Pushes); + Assert.Equal("-1001234567890", sent.Source.OriginRef); + Assert.Equal("сообщение", sent.Content.Text); + Assert.Equal(TenantId, Assert.Single(server.Tenants)); + Assert.Equal(ServiceToken, Assert.Single(server.Tokens)); + } + finally + { + await app.DisposeAsync(); + } + } + + /// + /// SyncDialogs: entries уходят; ответ ядра + /// + [Fact] + public async Task SyncDialogs_SendsEntries_AndReturnsMonitoredIds() + { + (string endpoint, RecordingIngressService server, WebApplication app) = await FakeIngressServer.StartAsync(); + try + { + server.MonitoredIds.Add("-1001234567890"); + CoreIngressClient client = CreateClient(endpoint); + var entries = new List + { + new() { Id = "-1001234567890", Name = "IT Канал", Kind = "channel" }, + }; + + IReadOnlyList monitored = await client.SyncDialogsAsync(TenantId, entries, CancellationToken.None); + + Assert.Equal(["-1001234567890"], monitored); + SyncDialogsRequest sent = Assert.Single(server.Syncs); + Assert.Equal("-1001234567890", Assert.Single(sent.Entries).Id); + Assert.Equal(TenantId, Assert.Single(server.Tenants)); + } + finally + { + await app.DisposeAsync(); + } + } + + /// + /// Ядро недоступно → SessionException UNAVAILABLE «Ядро недоступно…» + /// + [Fact] + public async Task PushSource_UnreachableCore_ThrowsSessionException() + { + // Порт без сервера: соединение отклоняется — вызов падает до deadline (RpcTimeout 15 с). + CoreIngressClient client = CreateClient("http://127.0.0.1:1"); + + SessionException exception = await Assert.ThrowsAsync( + () => client.PushSourceAsync( + TenantId, + new PushSourceRequest + { + Source = new SourceRefProto { Kind = "telegram", OriginRef = "-1001" }, + Content = new SourceContentProto { Text = "x" }, + }, + CancellationToken.None)); + + Assert.Equal(StatusCode.Unavailable, exception.Code); + Assert.Equal(SessionErrorMessages.IngressUnavailable, exception.Message); + } + + // Клиент с endpoint из опций (чтение env/config проверяет FromConfiguration). + // endpoint: Адрес фейк-сервера. + private static CoreIngressClient CreateClient(string endpoint) + => new(CoreIngressOptions.Create(endpoint, ServiceToken), NullLogger.Instance); +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/DialogRpcTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/DialogRpcTests.cs index b342aea..89a1e8f 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/DialogRpcTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/DialogRpcTests.cs @@ -1,285 +1,285 @@ -using Deal.Grpc.Telegram; -using Deal.Telegram.Dialogs; -using Deal.Telegram.Telegram; -using Deal.Telegram.Tests.Telegram; -using Grpc.Core; -using Grpc.Net.Client; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace Deal.Telegram.Tests.Grpc; - -/// -/// RPC-тесты каталога/мониторинга поверх реального gRPC-хоста. -/// -public sealed class DialogRpcTests -{ - private const string TenantId = TelegramTestHost.DefaultTenantId; - private const string Phone = "+79990001122"; - private const string Code = "11111"; - private const string DialogA = "-100111"; - private const string DialogB = "-100222"; - private const int PollTimeoutMilliseconds = 5000; - - /// - /// RefreshDialogs ready-сессии - /// - [Fact] - public async Task RefreshDialogs_ReturnsEntries_AndSyncsToCore() - { - await RunScenarioAsync( - async (channel, factory, ingressServer) => - { - var client = new TelegramService.TelegramServiceClient(channel); - await LoginAsync(client); - factory.CreatedClients.Single().Dialogs.AddRange(TestDialogs()); - - RefreshDialogsReply reply = await client.RefreshDialogsAsync(new RefreshDialogsRequest(), Options()); - - Assert.Equal(2, reply.Entries.Count); - Assert.Equal(DialogA, reply.Entries[0].Id); - Assert.Equal("Канал А", reply.Entries[0].Name); - Assert.Equal("channel", reply.Entries[0].Kind); - SyncDialogsRequest sync = Assert.Single(ingressServer.Syncs); - Assert.Equal(2, sync.Entries.Count); - Assert.Equal(TenantId, Assert.Single(ingressServer.Tenants)); - Assert.Equal(TelegramTestHost.DefaultToken, Assert.Single(ingressServer.Tokens)); - }); - } - - /// - /// RefreshDialogs без сессии → FAILED_PRECONDITION «Telegram не подключён». - /// - [Fact] - public async Task RefreshDialogs_NoSession_FailedPrecondition() - { - await RunScenarioAsync( - async (channel, _, _) => - { - var client = new TelegramService.TelegramServiceClient(channel); - RpcException exception = await Assert.ThrowsAsync( - () => client.RefreshDialogsAsync(new RefreshDialogsRequest(), Options()).ResponseAsync); - - Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode); - Assert.Equal(Sessions.SessionErrorMessages.NotConnected, exception.Status.Detail); - }); - } - - /// - /// SetMonitor включает мониторинг диалога - /// - [Fact] - public async Task SetMonitor_EnablesAndDisables() - { - await RunScenarioAsync( - async (channel, factory, _) => - { - var client = new TelegramService.TelegramServiceClient(channel); - await LoginAsync(client); - factory.CreatedClients.Single().Dialogs.AddRange(TestDialogs()); - await client.RefreshDialogsAsync(new RefreshDialogsRequest(), Options()); - - SetMonitorReply enabled = await client.SetMonitorAsync( - new SetMonitorRequest { DialogId = DialogA, Enabled = true }, Options()); - SetMonitorReply disabled = await client.SetMonitorAsync( - new SetMonitorRequest { DialogId = DialogA, Enabled = false }, Options()); - - Assert.True(enabled.Ok); - Assert.True(enabled.Enabled); - Assert.True(disabled.Ok); - Assert.False(disabled.Enabled); - }); - } - - /// - /// SetMonitorAll: count = размер каталога; повторный off снимает мониторинг - /// - [Fact] - public async Task SetMonitorAll_ReturnsCatalogCount() - { - await RunScenarioAsync( - async (channel, factory, _) => - { - var client = new TelegramService.TelegramServiceClient(channel); - await LoginAsync(client); - factory.CreatedClients.Single().Dialogs.AddRange(TestDialogs()); - await client.RefreshDialogsAsync(new RefreshDialogsRequest(), Options()); - - SetMonitorAllReply reply = await client.SetMonitorAllAsync( - new SetMonitorAllRequest { Enabled = true }, Options()); - - Assert.True(reply.Ok); - Assert.Equal(2, reply.Count); - Assert.True(reply.Enabled); - - SetMonitorAllReply off = await client.SetMonitorAllAsync( - new SetMonitorAllRequest { Enabled = false }, Options()); - Assert.True(off.Ok); - Assert.Equal(2, off.Count); - Assert.False(off.Enabled); - }); - } - - /// - /// Backfill: последние сообщения уходят в ядро - /// - [Fact] - public async Task Backfill_PushesRecentMessages_AndMarksRead() - { - await RunScenarioAsync( - async (channel, factory, ingressServer) => - { - var client = new TelegramService.TelegramServiceClient(channel); - await LoginAsync(client); - factory.CreatedClients.Single().Messages[DialogA] = - [ - new TelegramMessage(DialogA, 10, "свежее", 1_700_000_000_010, "Канал А", "ch_a"), - new TelegramMessage(DialogA, 9, "старое", 1_700_000_000_009, "Канал А", "ch_a"), - ]; - - BackfillReply reply = await client.BackfillAsync( - new BackfillRequest { DialogId = DialogA, Force = true }, Options()); - - Assert.Equal(2, reply.Processed); - Assert.Equal(2, ingressServer.Pushes.Count); - Assert.Equal(["9", "10"], ingressServer.Pushes.Select(push => push.Source.ExternalId)); - Assert.All(ingressServer.Tenants, tenant => Assert.Equal(TenantId, tenant)); - Assert.Equal([DialogA], factory.CreatedClients.Single().MarkedReadDialogs); - }); - } - - /// - /// Backfill без готовой сессии → «Telegram не подключён» - /// - [Fact] - public async Task Backfill_NoSession_FailedPrecondition() - { - await RunScenarioAsync( - async (channel, _, _) => - { - var client = new TelegramService.TelegramServiceClient(channel); - RpcException exception = await Assert.ThrowsAsync( - () => client.BackfillAsync(new BackfillRequest { DialogId = DialogA }, Options()).ResponseAsync); - - Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode); - }); - } - - /// - /// ReadRecent: свежие сообщения превью - /// - [Fact] - public async Task ReadRecent_ReturnsFreshPreview_NewestFirst() - { - await RunScenarioAsync( - async (channel, factory, _) => - { - var client = new TelegramService.TelegramServiceClient(channel); - await LoginAsync(client); - factory.CreatedClients.Single().Messages[DialogA] = - [ - new TelegramMessage(DialogA, 12, "свежее", 1_700_000_000_012, "Канал А", "ch_a"), - new TelegramMessage(DialogA, 11, "среднее", 1_700_000_000_011, "Канал А", "ch_a"), - new TelegramMessage(DialogA, 10, "старое", 1_700_000_000_010, "Канал А", "ch_a"), - ]; - - ReadRecentReply reply = await client.ReadRecentAsync( - new ReadRecentRequest { DialogId = DialogA, Limit = 2 }, Options()); - - Assert.Equal(2, reply.Messages.Count); - Assert.Equal("12", reply.Messages[0].Id); - Assert.Equal("свежее", reply.Messages[0].Text); - Assert.Equal(1_700_000_000_012, reply.Messages[0].Time); - Assert.Equal("11", reply.Messages[1].Id); - Assert.Equal([DialogA], factory.CreatedClients.Single().MarkedReadDialogs); - }); - } - - /// - /// ReadRecent пустого диалога - /// - [Fact] - public async Task ReadRecent_EmptyDialog_ReturnsEmptyPreview() - { - await RunScenarioAsync( - async (channel, factory, _) => - { - var client = new TelegramService.TelegramServiceClient(channel); - await LoginAsync(client); - - ReadRecentReply reply = await client.ReadRecentAsync( - new ReadRecentRequest { DialogId = DialogB, Limit = 10 }, Options()); - - Assert.Empty(reply.Messages); - }); - } - - // Диалоги сценария (как get_dialogs фейк-клиента, от свежих к старым). - private static List TestDialogs() - => - [ - new TelegramDialog(DialogA, "Канал А", "ch_a", DialogKinds.Channel, unreadCount: 0, topMessageId: 1), - new TelegramDialog(DialogB, "Канал Б", "ch_b", DialogKinds.Group, unreadCount: 0, topMessageId: 1), - ]; - - // Вход по телефону на фейк-клиенте до фазы ready (StartPhone → SendCode). - // client: Клиент TelegramService. - private static async Task LoginAsync(TelegramService.TelegramServiceClient client) - { - await client.StartPhoneAsync( - new StartPhoneRequest { Phone = Phone, ApiId = SessionHarness.ApiId, ApiHash = SessionHarness.ApiHash }, - Options()); - await client.SendCodeAsync(new SendCodeRequest { Code = Code }, Options()); - await WaitForPhaseAsync(client, "ready"); - } - - // Поллинг GetStatus до ожидаемой фазы. - // client: Клиент TelegramService. - // phase: Ожидаемая фаза ("ready"). - private static async Task WaitForPhaseAsync(TelegramService.TelegramServiceClient client, string phase) - { - var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds); - while (DateTime.UtcNow < deadline) - { - GetStatusReply status = await client.GetStatusAsync(new GetStatusRequest(), Options()); - if (status.Phase == phase) - { - return; - } - - await Task.Delay(25); - } - } - - // Прогоняет сценарий на хосте с фейковой фабрикой клиентов, фейк-пейсером и in-proc ингрессом. - // scenario: Сценарий (канал, фабрика, фейк-сервер ингресса). - private static async Task RunScenarioAsync(Func scenario) - { - var factory = new FakeClientFactory(); - var pacer = new RecordingPacer(); - (string endpoint, RecordingIngressService server, WebApplication app) = await FakeIngressServer.StartAsync(); - try - { - var extraEnv = new Dictionary { [TelegramTestHost.IngressEndpointEnvKey] = endpoint }; - await TelegramTestHost.RunAsync( - TelegramTestHost.DefaultToken, - channel => scenario(channel, factory, server), - configureServices: services => - { - services.AddSingleton(factory); - services.RemoveAll(); - services.AddSingleton(pacer); - }, - extraEnv: extraEnv); - } - finally - { - await app.DisposeAsync(); - } - } - - // Опции вызова с токеном и tenant-id. - private static CallOptions Options() - => new(TelegramTestHost.CallMetadata(TelegramTestHost.DefaultToken, TenantId), deadline: DateTime.UtcNow.AddSeconds(TelegramTestHost.RpcDeadlineSeconds)); -} +using Deal.Grpc.Telegram; +using Deal.Telegram.Dialogs; +using Deal.Telegram.Telegram; +using Deal.Telegram.Tests.Telegram; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Deal.Telegram.Tests.Grpc; + +/// +/// RPC-тесты каталога/мониторинга поверх реального gRPC-хоста. +/// +public sealed class DialogRpcTests +{ + private const string TenantId = TelegramTestHost.DefaultTenantId; + private const string Phone = "+79990001122"; + private const string Code = "11111"; + private const string DialogA = "-100111"; + private const string DialogB = "-100222"; + private const int PollTimeoutMilliseconds = 5000; + + /// + /// RefreshDialogs ready-сессии + /// + [Fact] + public async Task RefreshDialogs_ReturnsEntries_AndSyncsToCore() + { + await RunScenarioAsync( + async (channel, factory, ingressServer) => + { + var client = new TelegramService.TelegramServiceClient(channel); + await LoginAsync(client); + factory.CreatedClients.Single().Dialogs.AddRange(TestDialogs()); + + RefreshDialogsReply reply = await client.RefreshDialogsAsync(new RefreshDialogsRequest(), Options()); + + Assert.Equal(2, reply.Entries.Count); + Assert.Equal(DialogA, reply.Entries[0].Id); + Assert.Equal("Канал А", reply.Entries[0].Name); + Assert.Equal("channel", reply.Entries[0].Kind); + SyncDialogsRequest sync = Assert.Single(ingressServer.Syncs); + Assert.Equal(2, sync.Entries.Count); + Assert.Equal(TenantId, Assert.Single(ingressServer.Tenants)); + Assert.Equal(TelegramTestHost.DefaultToken, Assert.Single(ingressServer.Tokens)); + }); + } + + /// + /// RefreshDialogs без сессии → FAILED_PRECONDITION «Telegram не подключён». + /// + [Fact] + public async Task RefreshDialogs_NoSession_FailedPrecondition() + { + await RunScenarioAsync( + async (channel, _, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + RpcException exception = await Assert.ThrowsAsync( + () => client.RefreshDialogsAsync(new RefreshDialogsRequest(), Options()).ResponseAsync); + + Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode); + Assert.Equal(Sessions.SessionErrorMessages.NotConnected, exception.Status.Detail); + }); + } + + /// + /// SetMonitor включает мониторинг диалога + /// + [Fact] + public async Task SetMonitor_EnablesAndDisables() + { + await RunScenarioAsync( + async (channel, factory, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + await LoginAsync(client); + factory.CreatedClients.Single().Dialogs.AddRange(TestDialogs()); + await client.RefreshDialogsAsync(new RefreshDialogsRequest(), Options()); + + SetMonitorReply enabled = await client.SetMonitorAsync( + new SetMonitorRequest { DialogId = DialogA, Enabled = true }, Options()); + SetMonitorReply disabled = await client.SetMonitorAsync( + new SetMonitorRequest { DialogId = DialogA, Enabled = false }, Options()); + + Assert.True(enabled.Ok); + Assert.True(enabled.Enabled); + Assert.True(disabled.Ok); + Assert.False(disabled.Enabled); + }); + } + + /// + /// SetMonitorAll: count = размер каталога; повторный off снимает мониторинг + /// + [Fact] + public async Task SetMonitorAll_ReturnsCatalogCount() + { + await RunScenarioAsync( + async (channel, factory, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + await LoginAsync(client); + factory.CreatedClients.Single().Dialogs.AddRange(TestDialogs()); + await client.RefreshDialogsAsync(new RefreshDialogsRequest(), Options()); + + SetMonitorAllReply reply = await client.SetMonitorAllAsync( + new SetMonitorAllRequest { Enabled = true }, Options()); + + Assert.True(reply.Ok); + Assert.Equal(2, reply.Count); + Assert.True(reply.Enabled); + + SetMonitorAllReply off = await client.SetMonitorAllAsync( + new SetMonitorAllRequest { Enabled = false }, Options()); + Assert.True(off.Ok); + Assert.Equal(2, off.Count); + Assert.False(off.Enabled); + }); + } + + /// + /// Backfill: последние сообщения уходят в ядро + /// + [Fact] + public async Task Backfill_PushesRecentMessages_AndMarksRead() + { + await RunScenarioAsync( + async (channel, factory, ingressServer) => + { + var client = new TelegramService.TelegramServiceClient(channel); + await LoginAsync(client); + factory.CreatedClients.Single().Messages[DialogA] = + [ + new TelegramMessage(DialogA, 10, "свежее", 1_700_000_000_010, "Канал А", "ch_a"), + new TelegramMessage(DialogA, 9, "старое", 1_700_000_000_009, "Канал А", "ch_a"), + ]; + + BackfillReply reply = await client.BackfillAsync( + new BackfillRequest { DialogId = DialogA, Force = true }, Options()); + + Assert.Equal(2, reply.Processed); + Assert.Equal(2, ingressServer.Pushes.Count); + Assert.Equal(["9", "10"], ingressServer.Pushes.Select(push => push.Source.ExternalId)); + Assert.All(ingressServer.Tenants, tenant => Assert.Equal(TenantId, tenant)); + Assert.Equal([DialogA], factory.CreatedClients.Single().MarkedReadDialogs); + }); + } + + /// + /// Backfill без готовой сессии → «Telegram не подключён» + /// + [Fact] + public async Task Backfill_NoSession_FailedPrecondition() + { + await RunScenarioAsync( + async (channel, _, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + RpcException exception = await Assert.ThrowsAsync( + () => client.BackfillAsync(new BackfillRequest { DialogId = DialogA }, Options()).ResponseAsync); + + Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode); + }); + } + + /// + /// ReadRecent: свежие сообщения превью + /// + [Fact] + public async Task ReadRecent_ReturnsFreshPreview_NewestFirst() + { + await RunScenarioAsync( + async (channel, factory, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + await LoginAsync(client); + factory.CreatedClients.Single().Messages[DialogA] = + [ + new TelegramMessage(DialogA, 12, "свежее", 1_700_000_000_012, "Канал А", "ch_a"), + new TelegramMessage(DialogA, 11, "среднее", 1_700_000_000_011, "Канал А", "ch_a"), + new TelegramMessage(DialogA, 10, "старое", 1_700_000_000_010, "Канал А", "ch_a"), + ]; + + ReadRecentReply reply = await client.ReadRecentAsync( + new ReadRecentRequest { DialogId = DialogA, Limit = 2 }, Options()); + + Assert.Equal(2, reply.Messages.Count); + Assert.Equal("12", reply.Messages[0].Id); + Assert.Equal("свежее", reply.Messages[0].Text); + Assert.Equal(1_700_000_000_012, reply.Messages[0].Time); + Assert.Equal("11", reply.Messages[1].Id); + Assert.Equal([DialogA], factory.CreatedClients.Single().MarkedReadDialogs); + }); + } + + /// + /// ReadRecent пустого диалога + /// + [Fact] + public async Task ReadRecent_EmptyDialog_ReturnsEmptyPreview() + { + await RunScenarioAsync( + async (channel, factory, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + await LoginAsync(client); + + ReadRecentReply reply = await client.ReadRecentAsync( + new ReadRecentRequest { DialogId = DialogB, Limit = 10 }, Options()); + + Assert.Empty(reply.Messages); + }); + } + + // Диалоги сценария (как get_dialogs фейк-клиента, от свежих к старым). + private static List TestDialogs() + => + [ + new TelegramDialog(DialogA, "Канал А", "ch_a", DialogKinds.Channel, unreadCount: 0, topMessageId: 1), + new TelegramDialog(DialogB, "Канал Б", "ch_b", DialogKinds.Group, unreadCount: 0, topMessageId: 1), + ]; + + // Вход по телефону на фейк-клиенте до фазы ready (StartPhone → SendCode). + // client: Клиент TelegramService. + private static async Task LoginAsync(TelegramService.TelegramServiceClient client) + { + await client.StartPhoneAsync( + new StartPhoneRequest { Phone = Phone, ApiId = SessionHarness.ApiId, ApiHash = SessionHarness.ApiHash }, + Options()); + await client.SendCodeAsync(new SendCodeRequest { Code = Code }, Options()); + await WaitForPhaseAsync(client, "ready"); + } + + // Поллинг GetStatus до ожидаемой фазы. + // client: Клиент TelegramService. + // phase: Ожидаемая фаза ("ready"). + private static async Task WaitForPhaseAsync(TelegramService.TelegramServiceClient client, string phase) + { + var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds); + while (DateTime.UtcNow < deadline) + { + GetStatusReply status = await client.GetStatusAsync(new GetStatusRequest(), Options()); + if (status.Phase == phase) + { + return; + } + + await Task.Delay(25); + } + } + + // Прогоняет сценарий на хосте с фейковой фабрикой клиентов, фейк-пейсером и in-proc ингрессом. + // scenario: Сценарий (канал, фабрика, фейк-сервер ингресса). + private static async Task RunScenarioAsync(Func scenario) + { + var factory = new FakeClientFactory(); + var pacer = new RecordingPacer(); + (string endpoint, RecordingIngressService server, WebApplication app) = await FakeIngressServer.StartAsync(); + try + { + var extraEnv = new Dictionary { [TelegramTestHost.IngressEndpointEnvKey] = endpoint }; + await TelegramTestHost.RunAsync( + TelegramTestHost.DefaultToken, + channel => scenario(channel, factory, server), + configureServices: services => + { + services.AddSingleton(factory); + services.RemoveAll(); + services.AddSingleton(pacer); + }, + extraEnv: extraEnv); + } + finally + { + await app.DisposeAsync(); + } + } + + // Опции вызова с токеном и tenant-id. + private static CallOptions Options() + => new(TelegramTestHost.CallMetadata(TelegramTestHost.DefaultToken, TenantId), deadline: DateTime.UtcNow.AddSeconds(TelegramTestHost.RpcDeadlineSeconds)); +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryProtoMapperTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryProtoMapperTests.cs index 61f1967..7d478cc 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryProtoMapperTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryProtoMapperTests.cs @@ -1,130 +1,130 @@ -using Deal.Grpc.Telegram; -using Deal.Telegram.Dialogs; -using Deal.Telegram.Discovery; -using Deal.Telegram.Telegram; - -namespace Deal.Telegram.Tests.Grpc; - -/// -/// Unit-тесты маппера ответов discovery -/// -public sealed class DiscoveryProtoMapperTests -{ - /// - /// Инфо источника → ChannelInfo - /// - [Fact] - public void ToChannelInfo_MapsAllFields() - { - var info = new TelegramSourceInfo("-1005", "IT Канал", "it_channel", DialogKinds.Channel, participants: 12_345, isForum: false); - - ChannelInfo result = DiscoveryProtoMapper.ToChannelInfo(info); - - Assert.Equal("-1005", result.Id); - Assert.Equal("IT Канал", result.Name); - Assert.Equal("it_channel", result.Username); - Assert.Equal(DialogKinds.Channel, result.Kind); - Assert.Equal(DialogHue.Compute("-1005", "IT Канал"), result.Hue); - Assert.Equal(12_345, result.Participants); - Assert.False(result.IsForum); - } - - /// - /// Инфо по умолчанию - /// - [Fact] - public void ToChannelInfo_UnknownInfo_LeavesParticipantsUnset() - { - var info = new TelegramSourceInfo("-1005", "-1005", string.Empty, string.Empty, participants: null, isForum: false); - - ChannelInfo result = DiscoveryProtoMapper.ToChannelInfo(info); - - Assert.Equal("-1005", result.Name); - Assert.Empty(result.Kind); - Assert.False(result.HasParticipants); - } - - /// - /// Форум: kind канона + is_forum=true. - /// - [Fact] - public void ToChannelInfo_Forum_SetsIsForum() - { - var info = new TelegramSourceInfo("-1009", "Форум", "forum_hub", DialogKinds.Group, participants: 900, isForum: true); - - ChannelInfo result = DiscoveryProtoMapper.ToChannelInfo(info); - - Assert.True(result.IsForum); - Assert.Equal(DialogKinds.Group, result.Kind); - } - - /// - /// Сообщение темы форума → EvalMessage - /// - [Fact] - public void ToEvalMessage_MapsTopicFields() - { - var message = new DiscoveryMessage(42, "Текст сообщения", 1_700_000_000_123, topicId: 7, topicTitle: "Вакансии"); - - EvalMessage result = DiscoveryProtoMapper.ToEvalMessage(message); - - Assert.Equal(42, result.Id); - Assert.Equal("Текст сообщения", result.Text); - Assert.Equal(1_700_000_000_123, result.DateMs); - Assert.Equal(7, result.TopicId); - Assert.Equal("Вакансии", result.TopicTitle); - } - - /// - /// Сообщение обычной ленты → EvalMessage - /// - [Fact] - public void ToEvalMessage_PlainFeed_LeavesTopicUnset() - { - var message = new DiscoveryMessage(1, "Просто текст", 1_700_000_000_000, topicId: null, topicTitle: null); - - EvalMessage result = DiscoveryProtoMapper.ToEvalMessage(message); - - Assert.False(result.HasTopicId); - Assert.False(result.HasTopicTitle); - } - - /// - /// Успешное чтение → ReadForEvalReply ok:true + сообщения в порядке выборки. - /// - [Fact] - public void ToReadForEvalReply_OkTrue_AddsMessages() - { - var result = new DiscoveryReadResult( - ok: true, - error: null, - messages: - [ - new DiscoveryMessage(10, "Первое", 1_700_000_000_010, null, null), - new DiscoveryMessage(11, "Второе", 1_700_000_000_011, 3, "Тема"), - ]); - - ReadForEvalReply reply = DiscoveryProtoMapper.ToReadForEvalReply(result); - - Assert.True(reply.Ok); - Assert.False(reply.HasError); - Assert.Equal(2, reply.Messages.Count); - Assert.Equal("Первое", reply.Messages[0].Text); - Assert.Equal("Второе", reply.Messages[1].Text); - Assert.Equal(3, reply.Messages[1].TopicId); - Assert.Equal("Тема", reply.Messages[1].TopicTitle); - } - - /// - /// История недоступна → ReadForEvalReply ok:false + error="no_history" - /// - [Fact] - public void ToReadForEvalReply_NoHistory_OkFalseWithError() - { - ReadForEvalReply reply = DiscoveryProtoMapper.ToReadForEvalReply(DiscoveryReadResult.NoHistory()); - - Assert.False(reply.Ok); - Assert.Equal(DiscoveryReadResult.NoHistoryError, reply.Error); - Assert.Empty(reply.Messages); - } -} +using Deal.Grpc.Telegram; +using Deal.Telegram.Dialogs; +using Deal.Telegram.Discovery; +using Deal.Telegram.Telegram; + +namespace Deal.Telegram.Tests.Grpc; + +/// +/// Unit-тесты маппера ответов discovery +/// +public sealed class DiscoveryProtoMapperTests +{ + /// + /// Инфо источника → ChannelInfo + /// + [Fact] + public void ToChannelInfo_MapsAllFields() + { + var info = new TelegramSourceInfo("-1005", "IT Канал", "it_channel", DialogKinds.Channel, participants: 12_345, isForum: false); + + ChannelInfo result = DiscoveryProtoMapper.ToChannelInfo(info); + + Assert.Equal("-1005", result.Id); + Assert.Equal("IT Канал", result.Name); + Assert.Equal("it_channel", result.Username); + Assert.Equal(DialogKinds.Channel, result.Kind); + Assert.Equal(DialogHue.Compute("-1005", "IT Канал"), result.Hue); + Assert.Equal(12_345, result.Participants); + Assert.False(result.IsForum); + } + + /// + /// Инфо по умолчанию + /// + [Fact] + public void ToChannelInfo_UnknownInfo_LeavesParticipantsUnset() + { + var info = new TelegramSourceInfo("-1005", "-1005", string.Empty, string.Empty, participants: null, isForum: false); + + ChannelInfo result = DiscoveryProtoMapper.ToChannelInfo(info); + + Assert.Equal("-1005", result.Name); + Assert.Empty(result.Kind); + Assert.False(result.HasParticipants); + } + + /// + /// Форум: kind канона + is_forum=true. + /// + [Fact] + public void ToChannelInfo_Forum_SetsIsForum() + { + var info = new TelegramSourceInfo("-1009", "Форум", "forum_hub", DialogKinds.Group, participants: 900, isForum: true); + + ChannelInfo result = DiscoveryProtoMapper.ToChannelInfo(info); + + Assert.True(result.IsForum); + Assert.Equal(DialogKinds.Group, result.Kind); + } + + /// + /// Сообщение темы форума → EvalMessage + /// + [Fact] + public void ToEvalMessage_MapsTopicFields() + { + var message = new DiscoveryMessage(42, "Текст сообщения", 1_700_000_000_123, topicId: 7, topicTitle: "Вакансии"); + + EvalMessage result = DiscoveryProtoMapper.ToEvalMessage(message); + + Assert.Equal(42, result.Id); + Assert.Equal("Текст сообщения", result.Text); + Assert.Equal(1_700_000_000_123, result.DateMs); + Assert.Equal(7, result.TopicId); + Assert.Equal("Вакансии", result.TopicTitle); + } + + /// + /// Сообщение обычной ленты → EvalMessage + /// + [Fact] + public void ToEvalMessage_PlainFeed_LeavesTopicUnset() + { + var message = new DiscoveryMessage(1, "Просто текст", 1_700_000_000_000, topicId: null, topicTitle: null); + + EvalMessage result = DiscoveryProtoMapper.ToEvalMessage(message); + + Assert.False(result.HasTopicId); + Assert.False(result.HasTopicTitle); + } + + /// + /// Успешное чтение → ReadForEvalReply ok:true + сообщения в порядке выборки. + /// + [Fact] + public void ToReadForEvalReply_OkTrue_AddsMessages() + { + var result = new DiscoveryReadResult( + ok: true, + error: null, + messages: + [ + new DiscoveryMessage(10, "Первое", 1_700_000_000_010, null, null), + new DiscoveryMessage(11, "Второе", 1_700_000_000_011, 3, "Тема"), + ]); + + ReadForEvalReply reply = DiscoveryProtoMapper.ToReadForEvalReply(result); + + Assert.True(reply.Ok); + Assert.False(reply.HasError); + Assert.Equal(2, reply.Messages.Count); + Assert.Equal("Первое", reply.Messages[0].Text); + Assert.Equal("Второе", reply.Messages[1].Text); + Assert.Equal(3, reply.Messages[1].TopicId); + Assert.Equal("Тема", reply.Messages[1].TopicTitle); + } + + /// + /// История недоступна → ReadForEvalReply ok:false + error="no_history" + /// + [Fact] + public void ToReadForEvalReply_NoHistory_OkFalseWithError() + { + ReadForEvalReply reply = DiscoveryProtoMapper.ToReadForEvalReply(DiscoveryReadResult.NoHistory()); + + Assert.False(reply.Ok); + Assert.Equal(DiscoveryReadResult.NoHistoryError, reply.Error); + Assert.Empty(reply.Messages); + } +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryRpcTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryRpcTests.cs index 8f46479..ce44b5b 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryRpcTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryRpcTests.cs @@ -1,331 +1,331 @@ -using Deal.Grpc.Telegram; -using Deal.Telegram.Dialogs; -using Deal.Telegram.Telegram; -using Deal.Telegram.Tests.Telegram; -using Grpc.Core; -using Grpc.Net.Client; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace Deal.Telegram.Tests.Grpc; - -/// -/// RPC-тесты discovery-операций поверх реального gRPC-хоста. -/// -public sealed class DiscoveryRpcTests -{ - private const string TenantId = TelegramTestHost.DefaultTenantId; - private const string TenantB = "tenant-discovery-b"; - private const string Phone = "+79990001122"; - private const string Code = "11111"; - private const string ChannelA = "-100111"; - private const string ChannelB = "-100222"; - private const int PollTimeoutMilliseconds = 5000; - - /// - /// Search ready-сессии - /// - [Fact] - public async Task Search_ReturnsFoundEntries_AndWaits() - { - await RunScenarioAsync( - async (channel, factory, pacer) => - { - var client = new TelegramService.TelegramServiceClient(channel); - await LoginAsync(client); - factory.CreatedClients.Single().SearchResults.AddRange( - [ - new TelegramDialog(ChannelA, "Канал А", "ch_a", DialogKinds.Channel, 0, 0), - new TelegramDialog(ChannelB, "Канал Б", "ch_b", DialogKinds.Group, 0, 0), - ]); - - SearchReply reply = await client.SearchAsync(new SearchRequest { Query = "python", Limit = 10 }, Options()); - - Assert.Equal(2, reply.Results.Count); - Assert.Equal(ChannelA, reply.Results[0].Id); - Assert.Equal("Канал А", reply.Results[0].Name); - Assert.Equal("ch_a", reply.Results[0].Username); - Assert.Equal(DialogKinds.Channel, reply.Results[0].Kind); - Assert.Equal(DialogHue.Compute(ChannelA, "Канал А"), reply.Results[0].Hue); - Assert.Equal((2.0, 4.0), Assert.Single(pacer.Waits)); - }); - } - - /// - /// Search без готовой сессии → FAILED_PRECONDITION «Telegram не подключён». - /// - [Fact] - public async Task Search_NoSession_FailedPrecondition() - { - await RunScenarioAsync( - async (channel, _, _) => - { - var client = new TelegramService.TelegramServiceClient(channel); - RpcException exception = await Assert.ThrowsAsync( - () => client.SearchAsync(new SearchRequest { Query = "python" }, Options()).ResponseAsync); - - Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode); - Assert.Equal(Sessions.SessionErrorMessages.NotConnected, exception.Status.Detail); - }); - } - - /// - /// GetInfo: имя/username/kind/participants/is_forum из фейка - /// - [Fact] - public async Task GetInfo_ReturnsChannelInfo() - { - await RunScenarioAsync( - async (channel, factory, _) => - { - var client = new TelegramService.TelegramServiceClient(channel); - await LoginAsync(client); - factory.CreatedClients.Single().SourceInfos[ChannelA] = new TelegramSourceInfo( - ChannelA, "IT Канал", "it_channel", DialogKinds.Channel, participants: 42_000, isForum: true); - - GetInfoReply reply = await client.GetInfoAsync(new GetInfoRequest { DialogId = ChannelA }, Options()); - - Assert.Equal(ChannelA, reply.Info.Id); - Assert.Equal("IT Канал", reply.Info.Name); - Assert.Equal("it_channel", reply.Info.Username); - Assert.Equal(DialogKinds.Channel, reply.Info.Kind); - Assert.Equal(42_000, reply.Info.Participants); - Assert.True(reply.Info.IsForum); - Assert.Equal(DialogHue.Compute(ChannelA, "IT Канал"), reply.Info.Hue); - }); - } - - /// - /// ReadForEval: выборка ok:true с сообщениями - /// - [Fact] - public async Task ReadForEval_ReturnsMessages_Ok() - { - await RunScenarioAsync( - async (channel, factory, _) => - { - var client = new TelegramService.TelegramServiceClient(channel); - await LoginAsync(client); - factory.CreatedClients.Single().ReadForEvalResults[ChannelA] = new DiscoveryReadResult( - ok: true, - error: null, - messages: - [ - new DiscoveryMessage(10, "Сообщение темы", 1_700_000_000_010, topicId: 3, topicTitle: "Вакансии"), - new DiscoveryMessage(9, "Обычное", 1_700_000_000_009, null, null), - ]); - - ReadForEvalReply reply = await client.ReadForEvalAsync( - new ReadForEvalRequest { DialogId = ChannelA, Limit = 30 }, Options()); - - Assert.True(reply.Ok); - Assert.False(reply.HasError); - Assert.Equal(2, reply.Messages.Count); - Assert.Equal(3, reply.Messages[0].TopicId); - Assert.Equal("Вакансии", reply.Messages[0].TopicTitle); - Assert.False(reply.Messages[1].HasTopicId); - Assert.Equal(9, reply.Messages[1].Id); - }); - } - - /// - /// ReadForEval недоступной истории - /// - [Fact] - public async Task ReadForEval_HistoryUnavailable_OkFalseNoHistory() - { - await RunScenarioAsync( - async (channel, factory, _) => - { - var client = new TelegramService.TelegramServiceClient(channel); - await LoginAsync(client); - factory.CreatedClients.Single().ReadForEvalResults[ChannelB] = DiscoveryReadResult.NoHistory(); - - ReadForEvalReply reply = await client.ReadForEvalAsync( - new ReadForEvalRequest { DialogId = ChannelB, Limit = 30 }, Options()); - - Assert.False(reply.Ok); - Assert.Equal(DiscoveryReadResult.NoHistoryError, reply.Error); - Assert.Empty(reply.Messages); - }); - } - - /// - /// Join: вступление по username - /// - [Fact] - public async Task Join_JoinsByNormalizedUsername_Ok() - { - await RunScenarioAsync( - async (channel, factory, pacer) => - { - var client = new TelegramService.TelegramServiceClient(channel); - await LoginAsync(client); - - JoinReply reply = await client.JoinAsync(new JoinRequest { Username = "@it_channel" }, Options()); - - Assert.True(reply.Ok); - Assert.Equal(["it_channel"], factory.CreatedClients.Single().JoinedUsernames); - Assert.Empty(pacer.Waits); - }); - } - - /// - /// Join при FloodWait → RESOURCE_EXHAUSTED с detail-префиксом "flood" - /// - [Fact] - public async Task Join_FloodWait_ResourceExhausted() - { - await RunScenarioAsync( - async (channel, factory, _) => - { - var client = new TelegramService.TelegramServiceClient(channel); - await LoginAsync(client); - factory.CreatedClients.Single().JoinError = new Sessions.SessionException( - StatusCode.ResourceExhausted, "flood: FLOOD_WAIT_120"); - - RpcException exception = await Assert.ThrowsAsync( - () => client.JoinAsync(new JoinRequest { Username = "it_channel" }, Options()).ResponseAsync); - - Assert.Equal(StatusCode.ResourceExhausted, exception.StatusCode); - Assert.StartsWith("flood", exception.Status.Detail, StringComparison.Ordinal); - }); - } - - /// - /// Join без username → INVALID_ARGUMENT. - /// - [Fact] - public async Task Join_EmptyUsername_InvalidArgument() - { - await RunScenarioAsync( - async (channel, _, _) => - { - var client = new TelegramService.TelegramServiceClient(channel); - RpcException exception = await Assert.ThrowsAsync( - () => client.JoinAsync(new JoinRequest { Username = " " }, Options()).ResponseAsync); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - Assert.Equal(Sessions.SessionErrorMessages.JoinUsernameMissing, exception.Status.Detail); - }); - } - - /// - /// Серверная граница длины Search.query - /// - [Fact] - public async Task Search_TooLongQuery_InvalidArgument() - { - await RunScenarioAsync( - async (channel, _, _) => - { - var client = new TelegramService.TelegramServiceClient(channel); - RpcException exception = await Assert.ThrowsAsync( - () => client.SearchAsync(new SearchRequest { Query = new string('a', 201) }, Options()).ResponseAsync); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - Assert.Equal(Sessions.SessionErrorMessages.SearchQueryTooLong, exception.Status.Detail); - }); - } - - /// - /// Серверная граница длины Join.username - /// - [Fact] - public async Task Join_TooLongUsername_InvalidArgument() - { - await RunScenarioAsync( - async (channel, _, _) => - { - var client = new TelegramService.TelegramServiceClient(channel); - RpcException exception = await Assert.ThrowsAsync( - () => client.JoinAsync(new JoinRequest { Username = new string('u', 33) }, Options()).ResponseAsync); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - Assert.Equal(Sessions.SessionErrorMessages.UsernameTooLong, exception.Status.Detail); - }); - } - - /// - /// Изоляция тенантов - /// - [Fact] - public async Task TenantIsolation_SearchOnOwnTenantSession() - { - await RunScenarioAsync( - async (channel, factory, _) => - { - var client = new TelegramService.TelegramServiceClient(channel); - await LoginAsync(client); - await LoginAsync(client, TenantB, "+79990001133"); - factory.CreatedClients[0].SearchResults.Add(new TelegramDialog(ChannelA, "Канал А", "ch_a", DialogKinds.Channel, 0, 0)); - factory.CreatedClients[1].SearchResults.Add(new TelegramDialog(ChannelB, "Канал Б", "ch_b", DialogKinds.Channel, 0, 0)); - - SearchReply forA = await client.SearchAsync(new SearchRequest { Query = "a" }, Options(TenantId)); - SearchReply forB = await client.SearchAsync(new SearchRequest { Query = "b" }, Options(TenantB)); - - Assert.Equal([ChannelA], forA.Results.Select(entry => entry.Id).ToArray()); - Assert.Equal([ChannelB], forB.Results.Select(entry => entry.Id).ToArray()); - }); - } - - // Вход по телефону на фейк-клиенте до фазы ready (StartPhone → SendCode). - // client: Клиент TelegramService. - // tenantId: Id тенанта сценария. - // phone: Номер телефона. - private static async Task LoginAsync( - TelegramService.TelegramServiceClient client, - string tenantId = TenantId, - string phone = Phone) - { - await client.StartPhoneAsync( - new StartPhoneRequest { Phone = phone, ApiId = SessionHarness.ApiId, ApiHash = SessionHarness.ApiHash }, - Options(tenantId)); - await client.SendCodeAsync(new SendCodeRequest { Code = Code }, Options(tenantId)); - await WaitForPhaseAsync(client, tenantId, "ready"); - } - - // Поллинг GetStatus до ожидаемой фазы. - // client: Клиент TelegramService. - // tenantId: Id тенанта. - // phase: Ожидаемая фаза ("ready"). - private static async Task WaitForPhaseAsync( - TelegramService.TelegramServiceClient client, - string tenantId, - string phase) - { - var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds); - while (DateTime.UtcNow < deadline) - { - GetStatusReply status = await client.GetStatusAsync(new GetStatusRequest(), Options(tenantId)); - if (status.Phase == phase) - { - return; - } - - await Task.Delay(25); - } - } - - // Прогоняет сценарий на хосте с фейковой фабрикой клиентов и фейк-пейсером. - // scenario: Сценарий (канал, фабрика, фейк-пейсер). - private static async Task RunScenarioAsync(Func scenario) - { - var factory = new FakeClientFactory(); - var pacer = new RecordingPacer(); - await TelegramTestHost.RunAsync( - TelegramTestHost.DefaultToken, - channel => scenario(channel, factory, pacer), - configureServices: services => - { - services.AddSingleton(factory); - services.RemoveAll(); - services.AddSingleton(pacer); - }); - } - - // Опции вызова с токеном и tenant-id. - // tenantId: Id тенанта. - private static CallOptions Options(string tenantId = TenantId) - => new(TelegramTestHost.CallMetadata(TelegramTestHost.DefaultToken, tenantId), deadline: DateTime.UtcNow.AddSeconds(TelegramTestHost.RpcDeadlineSeconds)); -} +using Deal.Grpc.Telegram; +using Deal.Telegram.Dialogs; +using Deal.Telegram.Telegram; +using Deal.Telegram.Tests.Telegram; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Deal.Telegram.Tests.Grpc; + +/// +/// RPC-тесты discovery-операций поверх реального gRPC-хоста. +/// +public sealed class DiscoveryRpcTests +{ + private const string TenantId = TelegramTestHost.DefaultTenantId; + private const string TenantB = "tenant-discovery-b"; + private const string Phone = "+79990001122"; + private const string Code = "11111"; + private const string ChannelA = "-100111"; + private const string ChannelB = "-100222"; + private const int PollTimeoutMilliseconds = 5000; + + /// + /// Search ready-сессии + /// + [Fact] + public async Task Search_ReturnsFoundEntries_AndWaits() + { + await RunScenarioAsync( + async (channel, factory, pacer) => + { + var client = new TelegramService.TelegramServiceClient(channel); + await LoginAsync(client); + factory.CreatedClients.Single().SearchResults.AddRange( + [ + new TelegramDialog(ChannelA, "Канал А", "ch_a", DialogKinds.Channel, 0, 0), + new TelegramDialog(ChannelB, "Канал Б", "ch_b", DialogKinds.Group, 0, 0), + ]); + + SearchReply reply = await client.SearchAsync(new SearchRequest { Query = "python", Limit = 10 }, Options()); + + Assert.Equal(2, reply.Results.Count); + Assert.Equal(ChannelA, reply.Results[0].Id); + Assert.Equal("Канал А", reply.Results[0].Name); + Assert.Equal("ch_a", reply.Results[0].Username); + Assert.Equal(DialogKinds.Channel, reply.Results[0].Kind); + Assert.Equal(DialogHue.Compute(ChannelA, "Канал А"), reply.Results[0].Hue); + Assert.Equal((2.0, 4.0), Assert.Single(pacer.Waits)); + }); + } + + /// + /// Search без готовой сессии → FAILED_PRECONDITION «Telegram не подключён». + /// + [Fact] + public async Task Search_NoSession_FailedPrecondition() + { + await RunScenarioAsync( + async (channel, _, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + RpcException exception = await Assert.ThrowsAsync( + () => client.SearchAsync(new SearchRequest { Query = "python" }, Options()).ResponseAsync); + + Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode); + Assert.Equal(Sessions.SessionErrorMessages.NotConnected, exception.Status.Detail); + }); + } + + /// + /// GetInfo: имя/username/kind/participants/is_forum из фейка + /// + [Fact] + public async Task GetInfo_ReturnsChannelInfo() + { + await RunScenarioAsync( + async (channel, factory, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + await LoginAsync(client); + factory.CreatedClients.Single().SourceInfos[ChannelA] = new TelegramSourceInfo( + ChannelA, "IT Канал", "it_channel", DialogKinds.Channel, participants: 42_000, isForum: true); + + GetInfoReply reply = await client.GetInfoAsync(new GetInfoRequest { DialogId = ChannelA }, Options()); + + Assert.Equal(ChannelA, reply.Info.Id); + Assert.Equal("IT Канал", reply.Info.Name); + Assert.Equal("it_channel", reply.Info.Username); + Assert.Equal(DialogKinds.Channel, reply.Info.Kind); + Assert.Equal(42_000, reply.Info.Participants); + Assert.True(reply.Info.IsForum); + Assert.Equal(DialogHue.Compute(ChannelA, "IT Канал"), reply.Info.Hue); + }); + } + + /// + /// ReadForEval: выборка ok:true с сообщениями + /// + [Fact] + public async Task ReadForEval_ReturnsMessages_Ok() + { + await RunScenarioAsync( + async (channel, factory, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + await LoginAsync(client); + factory.CreatedClients.Single().ReadForEvalResults[ChannelA] = new DiscoveryReadResult( + ok: true, + error: null, + messages: + [ + new DiscoveryMessage(10, "Сообщение темы", 1_700_000_000_010, topicId: 3, topicTitle: "Вакансии"), + new DiscoveryMessage(9, "Обычное", 1_700_000_000_009, null, null), + ]); + + ReadForEvalReply reply = await client.ReadForEvalAsync( + new ReadForEvalRequest { DialogId = ChannelA, Limit = 30 }, Options()); + + Assert.True(reply.Ok); + Assert.False(reply.HasError); + Assert.Equal(2, reply.Messages.Count); + Assert.Equal(3, reply.Messages[0].TopicId); + Assert.Equal("Вакансии", reply.Messages[0].TopicTitle); + Assert.False(reply.Messages[1].HasTopicId); + Assert.Equal(9, reply.Messages[1].Id); + }); + } + + /// + /// ReadForEval недоступной истории + /// + [Fact] + public async Task ReadForEval_HistoryUnavailable_OkFalseNoHistory() + { + await RunScenarioAsync( + async (channel, factory, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + await LoginAsync(client); + factory.CreatedClients.Single().ReadForEvalResults[ChannelB] = DiscoveryReadResult.NoHistory(); + + ReadForEvalReply reply = await client.ReadForEvalAsync( + new ReadForEvalRequest { DialogId = ChannelB, Limit = 30 }, Options()); + + Assert.False(reply.Ok); + Assert.Equal(DiscoveryReadResult.NoHistoryError, reply.Error); + Assert.Empty(reply.Messages); + }); + } + + /// + /// Join: вступление по username + /// + [Fact] + public async Task Join_JoinsByNormalizedUsername_Ok() + { + await RunScenarioAsync( + async (channel, factory, pacer) => + { + var client = new TelegramService.TelegramServiceClient(channel); + await LoginAsync(client); + + JoinReply reply = await client.JoinAsync(new JoinRequest { Username = "@it_channel" }, Options()); + + Assert.True(reply.Ok); + Assert.Equal(["it_channel"], factory.CreatedClients.Single().JoinedUsernames); + Assert.Empty(pacer.Waits); + }); + } + + /// + /// Join при FloodWait → RESOURCE_EXHAUSTED с detail-префиксом "flood" + /// + [Fact] + public async Task Join_FloodWait_ResourceExhausted() + { + await RunScenarioAsync( + async (channel, factory, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + await LoginAsync(client); + factory.CreatedClients.Single().JoinError = new Sessions.SessionException( + StatusCode.ResourceExhausted, "flood: FLOOD_WAIT_120"); + + RpcException exception = await Assert.ThrowsAsync( + () => client.JoinAsync(new JoinRequest { Username = "it_channel" }, Options()).ResponseAsync); + + Assert.Equal(StatusCode.ResourceExhausted, exception.StatusCode); + Assert.StartsWith("flood", exception.Status.Detail, StringComparison.Ordinal); + }); + } + + /// + /// Join без username → INVALID_ARGUMENT. + /// + [Fact] + public async Task Join_EmptyUsername_InvalidArgument() + { + await RunScenarioAsync( + async (channel, _, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + RpcException exception = await Assert.ThrowsAsync( + () => client.JoinAsync(new JoinRequest { Username = " " }, Options()).ResponseAsync); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + Assert.Equal(Sessions.SessionErrorMessages.JoinUsernameMissing, exception.Status.Detail); + }); + } + + /// + /// Серверная граница длины Search.query + /// + [Fact] + public async Task Search_TooLongQuery_InvalidArgument() + { + await RunScenarioAsync( + async (channel, _, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + RpcException exception = await Assert.ThrowsAsync( + () => client.SearchAsync(new SearchRequest { Query = new string('a', 201) }, Options()).ResponseAsync); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + Assert.Equal(Sessions.SessionErrorMessages.SearchQueryTooLong, exception.Status.Detail); + }); + } + + /// + /// Серверная граница длины Join.username + /// + [Fact] + public async Task Join_TooLongUsername_InvalidArgument() + { + await RunScenarioAsync( + async (channel, _, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + RpcException exception = await Assert.ThrowsAsync( + () => client.JoinAsync(new JoinRequest { Username = new string('u', 33) }, Options()).ResponseAsync); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + Assert.Equal(Sessions.SessionErrorMessages.UsernameTooLong, exception.Status.Detail); + }); + } + + /// + /// Изоляция тенантов + /// + [Fact] + public async Task TenantIsolation_SearchOnOwnTenantSession() + { + await RunScenarioAsync( + async (channel, factory, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + await LoginAsync(client); + await LoginAsync(client, TenantB, "+79990001133"); + factory.CreatedClients[0].SearchResults.Add(new TelegramDialog(ChannelA, "Канал А", "ch_a", DialogKinds.Channel, 0, 0)); + factory.CreatedClients[1].SearchResults.Add(new TelegramDialog(ChannelB, "Канал Б", "ch_b", DialogKinds.Channel, 0, 0)); + + SearchReply forA = await client.SearchAsync(new SearchRequest { Query = "a" }, Options(TenantId)); + SearchReply forB = await client.SearchAsync(new SearchRequest { Query = "b" }, Options(TenantB)); + + Assert.Equal([ChannelA], forA.Results.Select(entry => entry.Id).ToArray()); + Assert.Equal([ChannelB], forB.Results.Select(entry => entry.Id).ToArray()); + }); + } + + // Вход по телефону на фейк-клиенте до фазы ready (StartPhone → SendCode). + // client: Клиент TelegramService. + // tenantId: Id тенанта сценария. + // phone: Номер телефона. + private static async Task LoginAsync( + TelegramService.TelegramServiceClient client, + string tenantId = TenantId, + string phone = Phone) + { + await client.StartPhoneAsync( + new StartPhoneRequest { Phone = phone, ApiId = SessionHarness.ApiId, ApiHash = SessionHarness.ApiHash }, + Options(tenantId)); + await client.SendCodeAsync(new SendCodeRequest { Code = Code }, Options(tenantId)); + await WaitForPhaseAsync(client, tenantId, "ready"); + } + + // Поллинг GetStatus до ожидаемой фазы. + // client: Клиент TelegramService. + // tenantId: Id тенанта. + // phase: Ожидаемая фаза ("ready"). + private static async Task WaitForPhaseAsync( + TelegramService.TelegramServiceClient client, + string tenantId, + string phase) + { + var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds); + while (DateTime.UtcNow < deadline) + { + GetStatusReply status = await client.GetStatusAsync(new GetStatusRequest(), Options(tenantId)); + if (status.Phase == phase) + { + return; + } + + await Task.Delay(25); + } + } + + // Прогоняет сценарий на хосте с фейковой фабрикой клиентов и фейк-пейсером. + // scenario: Сценарий (канал, фабрика, фейк-пейсер). + private static async Task RunScenarioAsync(Func scenario) + { + var factory = new FakeClientFactory(); + var pacer = new RecordingPacer(); + await TelegramTestHost.RunAsync( + TelegramTestHost.DefaultToken, + channel => scenario(channel, factory, pacer), + configureServices: services => + { + services.AddSingleton(factory); + services.RemoveAll(); + services.AddSingleton(pacer); + }); + } + + // Опции вызова с токеном и tenant-id. + // tenantId: Id тенанта. + private static CallOptions Options(string tenantId = TenantId) + => new(TelegramTestHost.CallMetadata(TelegramTestHost.DefaultToken, tenantId), deadline: DateTime.UtcNow.AddSeconds(TelegramTestHost.RpcDeadlineSeconds)); +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/FakeIngressServer.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/FakeIngressServer.cs index 5ad0d71..43b9f93 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/FakeIngressServer.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/FakeIngressServer.cs @@ -1,157 +1,157 @@ -using System.Net; -using System.Net.Sockets; -using Deal.Grpc.Sources; -using Deal.Grpc.Telegram; -using Grpc.Core; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Server.Kestrel.Core; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Telegram.Tests.Grpc; - -// Общее состояние записанных вызовов: PushSource (SourceIngressService) и SyncDialogs (IngressService) -// хостятся на одном фейк-сервере, но лежат в разных protobuf-сервисах. -internal sealed class IngressRecorder -{ - /// - /// Полученные PushSource, в порядке вызовов. - /// - public List Pushes { get; } = []; - - /// - /// Полученные SyncDialogs, в порядке вызовов. - /// - public List Syncs { get; } = []; - - /// - /// Значения tenant-id полученных вызовов - /// - public List Tenants { get; } = []; - - /// - /// Значения service-token полученных вызовов - /// - public List Tokens { get; } = []; - - /// - /// Monitored-набор ответа SyncDialogs - /// - public List MonitoredIds { get; set; } = []; - - /// - /// Записывает metadata вызова - /// - /// Контекст серверного вызова. - public void Record(ServerCallContext context) - { - Tenants.Add(context.RequestHeaders.GetValue("tenant-id") ?? string.Empty); - Tokens.Add(context.RequestHeaders.GetValue("service-token") ?? string.Empty); - } -} - -internal sealed class RecordingIngressService : SourceIngressService.SourceIngressServiceBase -{ - private readonly IngressRecorder _recorder; - - public RecordingIngressService(IngressRecorder recorder) - { - _recorder = recorder; - } - - /// - /// Полученные PushSource, в порядке вызовов. - /// - public List Pushes => _recorder.Pushes; - - /// - /// Полученные SyncDialogs, в порядке вызовов. - /// - public List Syncs => _recorder.Syncs; - - /// - /// Значения tenant-id полученных вызовов - /// - public List Tenants => _recorder.Tenants; - - /// - /// Значения service-token полученных вызовов - /// - public List Tokens => _recorder.Tokens; - - /// - /// Monitored-набор ответа SyncDialogs - /// - public List MonitoredIds - { - get => _recorder.MonitoredIds; - set => _recorder.MonitoredIds = value; - } - - /// - public override Task PushSource(PushSourceRequest request, ServerCallContext context) - { - _recorder.Record(context); - _recorder.Pushes.Add(request); - return Task.FromResult(new PushSourceReply { Accepted = true, Duplicate = false }); - } -} - -internal sealed class RecordingSyncService : IngressService.IngressServiceBase -{ - private readonly IngressRecorder _recorder; - - public RecordingSyncService(IngressRecorder recorder) - { - _recorder = recorder; - } - - /// - public override Task SyncDialogs(SyncDialogsRequest request, ServerCallContext context) - { - _recorder.Record(context); - _recorder.Syncs.Add(request); - var reply = new SyncDialogsReply(); - reply.MonitoredIds.AddRange(_recorder.MonitoredIds); - return Task.FromResult(reply); - } -} - -// Поднимает in-proc gRPC-сервер исходящих контрактов (SourceIngressService/IngressService) на -// эфемерном порту (без service-token-интерцептора: тест проверяет, что клиент шлёт токен, -// а не что сервер его принимает). -internal static class FakeIngressServer -{ - /// - /// Создаёт сервер - /// - public static async Task<(string Endpoint, RecordingIngressService Server, WebApplication App)> StartAsync() - { - var recorder = new IngressRecorder(); - var server = new RecordingIngressService(recorder); - var syncServer = new RecordingSyncService(recorder); - int port = FreeTcpPort(); - - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.WebHost.ConfigureKestrel(kestrel => - kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2)); - builder.Services.AddSingleton(server); - builder.Services.AddSingleton(syncServer); - builder.Services.AddGrpc(); - - WebApplication app = builder.Build(); - app.MapGrpcService(); - app.MapGrpcService(); - await app.StartAsync(); - - return ($"http://127.0.0.1:{port}", server, app); - } - - // Свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом). - private static int FreeTcpPort() - { - using var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - return ((IPEndPoint)listener.LocalEndpoint).Port; - } -} +using System.Net; +using System.Net.Sockets; +using Deal.Grpc.Sources; +using Deal.Grpc.Telegram; +using Grpc.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Telegram.Tests.Grpc; + +// Общее состояние записанных вызовов: PushSource (SourceIngressService) и SyncDialogs (IngressService) +// хостятся на одном фейк-сервере, но лежат в разных protobuf-сервисах. +internal sealed class IngressRecorder +{ + /// + /// Полученные PushSource, в порядке вызовов. + /// + public List Pushes { get; } = []; + + /// + /// Полученные SyncDialogs, в порядке вызовов. + /// + public List Syncs { get; } = []; + + /// + /// Значения tenant-id полученных вызовов + /// + public List Tenants { get; } = []; + + /// + /// Значения service-token полученных вызовов + /// + public List Tokens { get; } = []; + + /// + /// Monitored-набор ответа SyncDialogs + /// + public List MonitoredIds { get; set; } = []; + + /// + /// Записывает metadata вызова + /// + /// Контекст серверного вызова. + public void Record(ServerCallContext context) + { + Tenants.Add(context.RequestHeaders.GetValue("tenant-id") ?? string.Empty); + Tokens.Add(context.RequestHeaders.GetValue("service-token") ?? string.Empty); + } +} + +internal sealed class RecordingIngressService : SourceIngressService.SourceIngressServiceBase +{ + private readonly IngressRecorder _recorder; + + public RecordingIngressService(IngressRecorder recorder) + { + _recorder = recorder; + } + + /// + /// Полученные PushSource, в порядке вызовов. + /// + public List Pushes => _recorder.Pushes; + + /// + /// Полученные SyncDialogs, в порядке вызовов. + /// + public List Syncs => _recorder.Syncs; + + /// + /// Значения tenant-id полученных вызовов + /// + public List Tenants => _recorder.Tenants; + + /// + /// Значения service-token полученных вызовов + /// + public List Tokens => _recorder.Tokens; + + /// + /// Monitored-набор ответа SyncDialogs + /// + public List MonitoredIds + { + get => _recorder.MonitoredIds; + set => _recorder.MonitoredIds = value; + } + + /// + public override Task PushSource(PushSourceRequest request, ServerCallContext context) + { + _recorder.Record(context); + _recorder.Pushes.Add(request); + return Task.FromResult(new PushSourceReply { Accepted = true, Duplicate = false }); + } +} + +internal sealed class RecordingSyncService : IngressService.IngressServiceBase +{ + private readonly IngressRecorder _recorder; + + public RecordingSyncService(IngressRecorder recorder) + { + _recorder = recorder; + } + + /// + public override Task SyncDialogs(SyncDialogsRequest request, ServerCallContext context) + { + _recorder.Record(context); + _recorder.Syncs.Add(request); + var reply = new SyncDialogsReply(); + reply.MonitoredIds.AddRange(_recorder.MonitoredIds); + return Task.FromResult(reply); + } +} + +// Поднимает in-proc gRPC-сервер исходящих контрактов (SourceIngressService/IngressService) на +// эфемерном порту (без service-token-интерцептора: тест проверяет, что клиент шлёт токен, +// а не что сервер его принимает). +internal static class FakeIngressServer +{ + /// + /// Создаёт сервер + /// + public static async Task<(string Endpoint, RecordingIngressService Server, WebApplication App)> StartAsync() + { + var recorder = new IngressRecorder(); + var server = new RecordingIngressService(recorder); + var syncServer = new RecordingSyncService(recorder); + int port = FreeTcpPort(); + + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(kestrel => + kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2)); + builder.Services.AddSingleton(server); + builder.Services.AddSingleton(syncServer); + builder.Services.AddGrpc(); + + WebApplication app = builder.Build(); + app.MapGrpcService(); + app.MapGrpcService(); + await app.StartAsync(); + + return ($"http://127.0.0.1:{port}", server, app); + } + + // Свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом). + private static int FreeTcpPort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/ReadSourceRpcTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/ReadSourceRpcTests.cs index 2c55a38..16673aa 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/ReadSourceRpcTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/ReadSourceRpcTests.cs @@ -1,155 +1,155 @@ -using Deal.Grpc.Telegram; -using Deal.Telegram.Telegram; -using Deal.Telegram.Tests.Telegram; -using Grpc.Core; -using Grpc.Net.Client; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Telegram.Tests.Grpc; - -/// -/// RPC-тесты remote-просмотра исходного сообщения (ReadSource). -/// -public sealed class ReadSourceRpcTests -{ - private const string TenantId = TelegramTestHost.DefaultTenantId; - private const string Phone = "+79990001122"; - private const string Code = "11111"; - private const string DialogA = "-100111"; - private const int PollTimeoutMilliseconds = 5000; - - /// - /// ReadSource найденного сообщения: текст и время - /// - [Fact] - public async Task ReadSource_Found_ReturnsTextAndTime() - { - await RunScenarioAsync( - async (channel, factory) => - { - var client = new TelegramService.TelegramServiceClient(channel); - await LoginAsync(client); - factory.CreatedClients.Single().Messages[DialogA] = - [ - new TelegramMessage(DialogA, 10, "исходный текст", 1_700_000_000_010, "Канал А", "ch_a"), - ]; - - ReadSourceReply reply = await client.ReadSourceAsync( - new ReadSourceRequest { DialogId = DialogA, MsgId = 10 }, Options()); - - Assert.True(reply.Found); - Assert.True(reply.HasText); - Assert.Equal("исходный текст", reply.Text); - Assert.True(reply.HasTime); - Assert.Equal(1_700_000_000_010, reply.Time); - Assert.Equal([(DialogA, 10L)], factory.CreatedClients.Single().MessageRequests); - }); - } - - /// - /// ReadSource ненайденного сообщения: found=false, поля пусты - /// - [Fact] - public async Task ReadSource_NotFound_FoundFalse() - { - await RunScenarioAsync( - async (channel, factory) => - { - var client = new TelegramService.TelegramServiceClient(channel); - await LoginAsync(client); - factory.CreatedClients.Single().Messages[DialogA] = - [ - new TelegramMessage(DialogA, 10, "исходный текст", 1_700_000_000_010, "Канал А", "ch_a"), - ]; - - ReadSourceReply reply = await client.ReadSourceAsync( - new ReadSourceRequest { DialogId = DialogA, MsgId = 99 }, Options()); - - Assert.False(reply.Found); - Assert.False(reply.HasText); - Assert.False(reply.HasTime); - }); - } - - /// - /// ReadSource неположительного msg_id → INVALID_ARGUMENT - /// - [Fact] - public async Task ReadSource_InvalidMsgId_InvalidArgument() - { - await RunScenarioAsync( - async (channel, _) => - { - var client = new TelegramService.TelegramServiceClient(channel); - RpcException exception = await Assert.ThrowsAsync( - () => client.ReadSourceAsync( - new ReadSourceRequest { DialogId = DialogA, MsgId = 0 }, Options()).ResponseAsync); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - Assert.Equal(Sessions.SessionErrorMessages.InvalidMessageId, exception.Status.Detail); - }); - } - - /// - /// ReadSource без готовой сессии → FAILED_PRECONDITION «Telegram не подключён» - /// - [Fact] - public async Task ReadSource_NoSession_FailedPrecondition() - { - await RunScenarioAsync( - async (channel, _) => - { - var client = new TelegramService.TelegramServiceClient(channel); - RpcException exception = await Assert.ThrowsAsync( - () => client.ReadSourceAsync( - new ReadSourceRequest { DialogId = DialogA, MsgId = 10 }, Options()).ResponseAsync); - - Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode); - Assert.Equal(Sessions.SessionErrorMessages.NotConnected, exception.Status.Detail); - }); - } - - // Вход по телефону на фейк-клиенте до фазы ready (StartPhone → SendCode). - // client: Клиент TelegramService. - private static async Task LoginAsync(TelegramService.TelegramServiceClient client) - { - await client.StartPhoneAsync( - new StartPhoneRequest { Phone = Phone, ApiId = SessionHarness.ApiId, ApiHash = SessionHarness.ApiHash }, - Options()); - await client.SendCodeAsync(new SendCodeRequest { Code = Code }, Options()); - await WaitForPhaseAsync(client, "ready"); - } - - // Поллинг GetStatus до ожидаемой фазы. - // client: Клиент TelegramService. - // phase: Ожидаемая фаза ("ready"). - private static async Task WaitForPhaseAsync(TelegramService.TelegramServiceClient client, string phase) - { - var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds); - while (DateTime.UtcNow < deadline) - { - GetStatusReply status = await client.GetStatusAsync(new GetStatusRequest(), Options()); - if (status.Phase == phase) - { - return; - } - - await Task.Delay(25); - } - } - - // Прогоняет сценарий на хосте с фейковой фабрикой клиентов. - // scenario: Сценарий (канал, фабрика). - private static async Task RunScenarioAsync(Func scenario) - { - var factory = new FakeClientFactory(); - await TelegramTestHost.RunAsync( - TelegramTestHost.DefaultToken, - channel => scenario(channel, factory), - configureServices: services => services.AddSingleton(factory)); - } - - // Опции вызова с токеном и tenant-id. - private static CallOptions Options() - => new(TelegramTestHost.CallMetadata(TelegramTestHost.DefaultToken, TenantId), deadline: DateTime.UtcNow.AddSeconds(TelegramTestHost.RpcDeadlineSeconds)); -} +using Deal.Grpc.Telegram; +using Deal.Telegram.Telegram; +using Deal.Telegram.Tests.Telegram; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Telegram.Tests.Grpc; + +/// +/// RPC-тесты remote-просмотра исходного сообщения (ReadSource). +/// +public sealed class ReadSourceRpcTests +{ + private const string TenantId = TelegramTestHost.DefaultTenantId; + private const string Phone = "+79990001122"; + private const string Code = "11111"; + private const string DialogA = "-100111"; + private const int PollTimeoutMilliseconds = 5000; + + /// + /// ReadSource найденного сообщения: текст и время + /// + [Fact] + public async Task ReadSource_Found_ReturnsTextAndTime() + { + await RunScenarioAsync( + async (channel, factory) => + { + var client = new TelegramService.TelegramServiceClient(channel); + await LoginAsync(client); + factory.CreatedClients.Single().Messages[DialogA] = + [ + new TelegramMessage(DialogA, 10, "исходный текст", 1_700_000_000_010, "Канал А", "ch_a"), + ]; + + ReadSourceReply reply = await client.ReadSourceAsync( + new ReadSourceRequest { DialogId = DialogA, MsgId = 10 }, Options()); + + Assert.True(reply.Found); + Assert.True(reply.HasText); + Assert.Equal("исходный текст", reply.Text); + Assert.True(reply.HasTime); + Assert.Equal(1_700_000_000_010, reply.Time); + Assert.Equal([(DialogA, 10L)], factory.CreatedClients.Single().MessageRequests); + }); + } + + /// + /// ReadSource ненайденного сообщения: found=false, поля пусты + /// + [Fact] + public async Task ReadSource_NotFound_FoundFalse() + { + await RunScenarioAsync( + async (channel, factory) => + { + var client = new TelegramService.TelegramServiceClient(channel); + await LoginAsync(client); + factory.CreatedClients.Single().Messages[DialogA] = + [ + new TelegramMessage(DialogA, 10, "исходный текст", 1_700_000_000_010, "Канал А", "ch_a"), + ]; + + ReadSourceReply reply = await client.ReadSourceAsync( + new ReadSourceRequest { DialogId = DialogA, MsgId = 99 }, Options()); + + Assert.False(reply.Found); + Assert.False(reply.HasText); + Assert.False(reply.HasTime); + }); + } + + /// + /// ReadSource неположительного msg_id → INVALID_ARGUMENT + /// + [Fact] + public async Task ReadSource_InvalidMsgId_InvalidArgument() + { + await RunScenarioAsync( + async (channel, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + RpcException exception = await Assert.ThrowsAsync( + () => client.ReadSourceAsync( + new ReadSourceRequest { DialogId = DialogA, MsgId = 0 }, Options()).ResponseAsync); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + Assert.Equal(Sessions.SessionErrorMessages.InvalidMessageId, exception.Status.Detail); + }); + } + + /// + /// ReadSource без готовой сессии → FAILED_PRECONDITION «Telegram не подключён» + /// + [Fact] + public async Task ReadSource_NoSession_FailedPrecondition() + { + await RunScenarioAsync( + async (channel, _) => + { + var client = new TelegramService.TelegramServiceClient(channel); + RpcException exception = await Assert.ThrowsAsync( + () => client.ReadSourceAsync( + new ReadSourceRequest { DialogId = DialogA, MsgId = 10 }, Options()).ResponseAsync); + + Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode); + Assert.Equal(Sessions.SessionErrorMessages.NotConnected, exception.Status.Detail); + }); + } + + // Вход по телефону на фейк-клиенте до фазы ready (StartPhone → SendCode). + // client: Клиент TelegramService. + private static async Task LoginAsync(TelegramService.TelegramServiceClient client) + { + await client.StartPhoneAsync( + new StartPhoneRequest { Phone = Phone, ApiId = SessionHarness.ApiId, ApiHash = SessionHarness.ApiHash }, + Options()); + await client.SendCodeAsync(new SendCodeRequest { Code = Code }, Options()); + await WaitForPhaseAsync(client, "ready"); + } + + // Поллинг GetStatus до ожидаемой фазы. + // client: Клиент TelegramService. + // phase: Ожидаемая фаза ("ready"). + private static async Task WaitForPhaseAsync(TelegramService.TelegramServiceClient client, string phase) + { + var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds); + while (DateTime.UtcNow < deadline) + { + GetStatusReply status = await client.GetStatusAsync(new GetStatusRequest(), Options()); + if (status.Phase == phase) + { + return; + } + + await Task.Delay(25); + } + } + + // Прогоняет сценарий на хосте с фейковой фабрикой клиентов. + // scenario: Сценарий (канал, фабрика). + private static async Task RunScenarioAsync(Func scenario) + { + var factory = new FakeClientFactory(); + await TelegramTestHost.RunAsync( + TelegramTestHost.DefaultToken, + channel => scenario(channel, factory), + configureServices: services => services.AddSingleton(factory)); + } + + // Опции вызова с токеном и tenant-id. + private static CallOptions Options() + => new(TelegramTestHost.CallMetadata(TelegramTestHost.DefaultToken, TenantId), deadline: DateTime.UtcNow.AddSeconds(TelegramTestHost.RpcDeadlineSeconds)); +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/RealtimeListenerTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/RealtimeListenerTests.cs index 47a92d7..a12cb54 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/RealtimeListenerTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/RealtimeListenerTests.cs @@ -1,138 +1,138 @@ -using Deal.Grpc.Sources; -using Deal.Telegram.Dialogs; -using Deal.Telegram.Sessions; -using Deal.Telegram.Telegram; -using Deal.Telegram.Tests.Telegram; -using Grpc.Core; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Telegram.Tests.Grpc; - -/// -/// Unit-тесты realtime-listener'а. -/// -public sealed class RealtimeListenerTests -{ - private const string TenantId = "tenant-listener"; - private const string DialogId = "-1001234567890"; - private const string OtherDialogId = "+79990001122"; - - /// - /// Мониторящееся сообщение - /// - [Fact] - public async Task Listener_MonitoredMessage_PushesAndMarksRead() - { - await using SessionHarness harness = await SessionHarness.CreateReadyAsync(TenantId); - try - { - var catalog = new DialogCatalog(); - catalog.SetMonitored(TenantId, DialogId, enabled: true); - var ingress = new FakeIngress(); - RealtimeListener listener = CreateListener(harness.Session, catalog, ingress); - listener.Start(); - - await harness.Client.RaiseMessageAsync( - new TelegramMessage(DialogId, 101, "свежее сообщение", 1_700_000_000_101, "IT Канал", "it_channel")); - - PushSourceRequest push = Assert.Single(ingress.Pushes).Request; - Assert.Equal(DialogId, push.Source.OriginRef); - Assert.Equal("свежее сообщение", push.Content.Text); - Assert.Equal(DialogId, Assert.Single(harness.Client.MarkedReadDialogs)); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// Немониторящийся диалог фильтруется - /// - [Fact] - public async Task Listener_UnmonitoredMessage_IsFilteredOut() - { - await using SessionHarness harness = await SessionHarness.CreateReadyAsync(TenantId); - try - { - var catalog = new DialogCatalog(); // зеркало пустое — мониторинга нет - var ingress = new FakeIngress(); - RealtimeListener listener = CreateListener(harness.Session, catalog, ingress); - listener.Start(); - - await harness.Client.RaiseMessageAsync( - new TelegramMessage(OtherDialogId, 7, "личное сообщение", 1_700_000_000_007, "Иван", "")); - - Assert.Empty(ingress.Pushes); - Assert.Empty(harness.Client.MarkedReadDialogs); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// Сбой PushSource не роняет realtime - /// - [Fact] - public async Task Listener_PushFails_NoMarkRead() - { - await using SessionHarness harness = await SessionHarness.CreateReadyAsync(TenantId); - try - { - var catalog = new DialogCatalog(); - catalog.SetMonitored(TenantId, DialogId, enabled: true); - var ingress = new FakeIngress { PushError = new SessionException(StatusCode.Unavailable, SessionErrorMessages.IngressUnavailable) }; - RealtimeListener listener = CreateListener(harness.Session, catalog, ingress); - listener.Start(); - - await harness.Client.RaiseMessageAsync( - new TelegramMessage(DialogId, 1, "не доедет", 1_700_000_000_001, "IT Канал", "")); - - Assert.Empty(harness.Client.MarkedReadDialogs); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// Stop отписывает listener - /// - [Fact] - public async Task Listener_Stop_Unsubscribes() - { - await using SessionHarness harness = await SessionHarness.CreateReadyAsync(TenantId); - try - { - var catalog = new DialogCatalog(); - catalog.SetMonitored(TenantId, DialogId, enabled: true); - var ingress = new FakeIngress(); - RealtimeListener listener = CreateListener(harness.Session, catalog, ingress); - listener.Start(); - listener.Stop(); - - await harness.Client.RaiseMessageAsync( - new TelegramMessage(DialogId, 2, "после отписки", 1_700_000_000_002, "IT Канал", "")); - - Assert.Empty(ingress.Pushes); - Assert.Empty(harness.Client.MarkedReadDialogs); - } - finally - { - await harness.DisposeAsync(); - } - } - - // Listener сессии. - // session: Ready-сессия тенанта. - // catalog: Зеркало мониторинга. - // ingress: Фейк ингресса. - private static RealtimeListener CreateListener( - TenantSession session, - DialogCatalog catalog, - FakeIngress ingress) - => new(session, catalog, ingress, NullLogger.Instance); -} +using Deal.Grpc.Sources; +using Deal.Telegram.Dialogs; +using Deal.Telegram.Sessions; +using Deal.Telegram.Telegram; +using Deal.Telegram.Tests.Telegram; +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Telegram.Tests.Grpc; + +/// +/// Unit-тесты realtime-listener'а. +/// +public sealed class RealtimeListenerTests +{ + private const string TenantId = "tenant-listener"; + private const string DialogId = "-1001234567890"; + private const string OtherDialogId = "+79990001122"; + + /// + /// Мониторящееся сообщение + /// + [Fact] + public async Task Listener_MonitoredMessage_PushesAndMarksRead() + { + await using SessionHarness harness = await SessionHarness.CreateReadyAsync(TenantId); + try + { + var catalog = new DialogCatalog(); + catalog.SetMonitored(TenantId, DialogId, enabled: true); + var ingress = new FakeIngress(); + RealtimeListener listener = CreateListener(harness.Session, catalog, ingress); + listener.Start(); + + await harness.Client.RaiseMessageAsync( + new TelegramMessage(DialogId, 101, "свежее сообщение", 1_700_000_000_101, "IT Канал", "it_channel")); + + PushSourceRequest push = Assert.Single(ingress.Pushes).Request; + Assert.Equal(DialogId, push.Source.OriginRef); + Assert.Equal("свежее сообщение", push.Content.Text); + Assert.Equal(DialogId, Assert.Single(harness.Client.MarkedReadDialogs)); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// Немониторящийся диалог фильтруется + /// + [Fact] + public async Task Listener_UnmonitoredMessage_IsFilteredOut() + { + await using SessionHarness harness = await SessionHarness.CreateReadyAsync(TenantId); + try + { + var catalog = new DialogCatalog(); // зеркало пустое — мониторинга нет + var ingress = new FakeIngress(); + RealtimeListener listener = CreateListener(harness.Session, catalog, ingress); + listener.Start(); + + await harness.Client.RaiseMessageAsync( + new TelegramMessage(OtherDialogId, 7, "личное сообщение", 1_700_000_000_007, "Иван", "")); + + Assert.Empty(ingress.Pushes); + Assert.Empty(harness.Client.MarkedReadDialogs); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// Сбой PushSource не роняет realtime + /// + [Fact] + public async Task Listener_PushFails_NoMarkRead() + { + await using SessionHarness harness = await SessionHarness.CreateReadyAsync(TenantId); + try + { + var catalog = new DialogCatalog(); + catalog.SetMonitored(TenantId, DialogId, enabled: true); + var ingress = new FakeIngress { PushError = new SessionException(StatusCode.Unavailable, SessionErrorMessages.IngressUnavailable) }; + RealtimeListener listener = CreateListener(harness.Session, catalog, ingress); + listener.Start(); + + await harness.Client.RaiseMessageAsync( + new TelegramMessage(DialogId, 1, "не доедет", 1_700_000_000_001, "IT Канал", "")); + + Assert.Empty(harness.Client.MarkedReadDialogs); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// Stop отписывает listener + /// + [Fact] + public async Task Listener_Stop_Unsubscribes() + { + await using SessionHarness harness = await SessionHarness.CreateReadyAsync(TenantId); + try + { + var catalog = new DialogCatalog(); + catalog.SetMonitored(TenantId, DialogId, enabled: true); + var ingress = new FakeIngress(); + RealtimeListener listener = CreateListener(harness.Session, catalog, ingress); + listener.Start(); + listener.Stop(); + + await harness.Client.RaiseMessageAsync( + new TelegramMessage(DialogId, 2, "после отписки", 1_700_000_000_002, "IT Канал", "")); + + Assert.Empty(ingress.Pushes); + Assert.Empty(harness.Client.MarkedReadDialogs); + } + finally + { + await harness.DisposeAsync(); + } + } + + // Listener сессии. + // session: Ready-сессия тенанта. + // catalog: Зеркало мониторинга. + // ingress: Фейк ингресса. + private static RealtimeListener CreateListener( + TenantSession session, + DialogCatalog catalog, + FakeIngress ingress) + => new(session, catalog, ingress, NullLogger.Instance); +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramServiceHostTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramServiceHostTests.cs index 7fb1e2d..dd8e4a6 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramServiceHostTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramServiceHostTests.cs @@ -1,154 +1,154 @@ -using Deal.Grpc.Telegram; -using Grpc.Core; -using Grpc.Health.V1; -using Grpc.Net.Client; - -namespace Deal.Telegram.Tests.Grpc; - -/// -/// Интеграционные тесты каркаса telegram-service. -/// -public sealed class TelegramServiceHostTests -{ - // Токен сценариев теста. - private const string ValidToken = TelegramTestHost.DefaultToken; - - /// - /// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура и health-сервис работают. - /// - [Fact] - public async Task HealthCheck_ReturnsServing() - { - await RunScenarioAsync( - ValidToken, - async channel => - { - var health = new Health.HealthClient(channel); - HealthCheckResponse response = await health.CheckAsync( - new HealthCheckRequest(), - deadline: Deadline()); - - Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, response.Status); - }); - } - - /// - /// Запрос без metadata «service-token» → UNAUTHENTICATED. - /// - [Fact] - public async Task GetStatus_WithoutToken_IsUnauthenticated() - { - await AssertDealRpcRejectedAsync(ValidToken, tokenHeader: null, expected: StatusCode.Unauthenticated); - } - - /// - /// Запрос с неверным токеном → UNAUTHENTICATED. - /// - [Fact] - public async Task GetStatus_WithWrongToken_IsUnauthenticated() - { - await AssertDealRpcRejectedAsync(ValidToken, tokenHeader: "wrong-token", expected: StatusCode.Unauthenticated); - } - - /// - /// Верный токен проходит интерцептор к методу - /// - [Fact] - public async Task GetStatus_WithValidToken_NoSession_IsFailedPrecondition() - { - await RunScenarioAsync( - ValidToken, - async channel => - { - var client = new TelegramService.TelegramServiceClient(channel); - AsyncUnaryCall call = client.GetStatusAsync( - new GetStatusRequest(), - new CallOptions(TelegramTestHost.CallMetadata(ValidToken, TelegramTestHost.DefaultTenantId), deadline: Deadline())); - - RpcException exception = await Assert.ThrowsAsync(() => call.ResponseAsync); - - Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode); - Assert.Equal(Sessions.SessionErrorMessages.NotConnected, exception.Status.Detail); - }); - } - - /// - /// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется даже с «каким-то» токеном; health при этом продолжает отвечать SERVING - /// - [Fact] - public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing() - { - await RunScenarioAsync( - serviceToken: null, - async channel => - { - var health = new Health.HealthClient(channel); - HealthCheckResponse healthResponse = await health.CheckAsync( - new HealthCheckRequest(), - deadline: Deadline()); - Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, healthResponse.Status); - - await AssertRejectedAsync(channel, ValidToken, StatusCode.Unauthenticated); - }); - } - - /// - /// Fail-closed-гард - /// - [Fact] - public async Task EmptyServiceToken_WithUnsetEnvToken_IsUnauthenticated_HealthServing() - { - await RunScenarioAsync( - serviceToken: null, - async channel => - { - var health = new Health.HealthClient(channel); - HealthCheckResponse healthResponse = await health.CheckAsync( - new HealthCheckRequest(), - deadline: Deadline()); - Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, healthResponse.Status); - - await AssertRejectedAsync(channel, string.Empty, StatusCode.Unauthenticated); - }); - } - - // Прогоняет GetStatus с заданным заголовком «service-token» (null — без заголовка). - // serviceToken: Ожидаемый токен хоста (env DEAL_SERVICE_TOKEN). - // tokenHeader: Значение metadata «service-token» запроса либо null (нет заголовка). - // expected: Ожидаемый StatusCode ответа. - private static async Task AssertDealRpcRejectedAsync( - string? serviceToken, - string? tokenHeader, - StatusCode expected) - { - await RunScenarioAsync(serviceToken, channel => AssertRejectedAsync(channel, tokenHeader, expected)); - } - - // Вызывает GetStatus и проверяет, что сервер ответил ожидаемым кодом статуса. - // channel: Канал к хосту telegram-service. - // tokenHeader: Значение metadata «service-token» либо null (нет заголовка). - // expected: Ожидаемый StatusCode. - private static async Task AssertRejectedAsync( - GrpcChannel channel, - string? tokenHeader, - StatusCode expected) - { - var client = new TelegramService.TelegramServiceClient(channel); - AsyncUnaryCall call = client.GetStatusAsync( - new GetStatusRequest(), - new CallOptions(TelegramTestHost.CallMetadata(tokenHeader), deadline: Deadline())); - - RpcException exception = await Assert.ThrowsAsync(() => call.ResponseAsync); - Assert.Equal(expected, exception.StatusCode); - } - - // Прогоняет сценарий через общий харнесс хоста (env токена/сессий, temp-каталог сессий). - // serviceToken: Значение env DEAL_SERVICE_TOKEN (null — убрать). - // scenario: Сценарий с каналом к поднятому хосту. - private static Task RunScenarioAsync(string? serviceToken, Func scenario) - => TelegramTestHost.RunAsync(serviceToken, scenario); - - // Deadline вызовов теста. - private static DateTime Deadline() - => DateTime.UtcNow.AddSeconds(TelegramTestHost.RpcDeadlineSeconds); -} +using Deal.Grpc.Telegram; +using Grpc.Core; +using Grpc.Health.V1; +using Grpc.Net.Client; + +namespace Deal.Telegram.Tests.Grpc; + +/// +/// Интеграционные тесты каркаса telegram-service. +/// +public sealed class TelegramServiceHostTests +{ + // Токен сценариев теста. + private const string ValidToken = TelegramTestHost.DefaultToken; + + /// + /// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура и health-сервис работают. + /// + [Fact] + public async Task HealthCheck_ReturnsServing() + { + await RunScenarioAsync( + ValidToken, + async channel => + { + var health = new Health.HealthClient(channel); + HealthCheckResponse response = await health.CheckAsync( + new HealthCheckRequest(), + deadline: Deadline()); + + Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, response.Status); + }); + } + + /// + /// Запрос без metadata «service-token» → UNAUTHENTICATED. + /// + [Fact] + public async Task GetStatus_WithoutToken_IsUnauthenticated() + { + await AssertDealRpcRejectedAsync(ValidToken, tokenHeader: null, expected: StatusCode.Unauthenticated); + } + + /// + /// Запрос с неверным токеном → UNAUTHENTICATED. + /// + [Fact] + public async Task GetStatus_WithWrongToken_IsUnauthenticated() + { + await AssertDealRpcRejectedAsync(ValidToken, tokenHeader: "wrong-token", expected: StatusCode.Unauthenticated); + } + + /// + /// Верный токен проходит интерцептор к методу + /// + [Fact] + public async Task GetStatus_WithValidToken_NoSession_IsFailedPrecondition() + { + await RunScenarioAsync( + ValidToken, + async channel => + { + var client = new TelegramService.TelegramServiceClient(channel); + AsyncUnaryCall call = client.GetStatusAsync( + new GetStatusRequest(), + new CallOptions(TelegramTestHost.CallMetadata(ValidToken, TelegramTestHost.DefaultTenantId), deadline: Deadline())); + + RpcException exception = await Assert.ThrowsAsync(() => call.ResponseAsync); + + Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode); + Assert.Equal(Sessions.SessionErrorMessages.NotConnected, exception.Status.Detail); + }); + } + + /// + /// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется даже с «каким-то» токеном; health при этом продолжает отвечать SERVING + /// + [Fact] + public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing() + { + await RunScenarioAsync( + serviceToken: null, + async channel => + { + var health = new Health.HealthClient(channel); + HealthCheckResponse healthResponse = await health.CheckAsync( + new HealthCheckRequest(), + deadline: Deadline()); + Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, healthResponse.Status); + + await AssertRejectedAsync(channel, ValidToken, StatusCode.Unauthenticated); + }); + } + + /// + /// Fail-closed-гард + /// + [Fact] + public async Task EmptyServiceToken_WithUnsetEnvToken_IsUnauthenticated_HealthServing() + { + await RunScenarioAsync( + serviceToken: null, + async channel => + { + var health = new Health.HealthClient(channel); + HealthCheckResponse healthResponse = await health.CheckAsync( + new HealthCheckRequest(), + deadline: Deadline()); + Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, healthResponse.Status); + + await AssertRejectedAsync(channel, string.Empty, StatusCode.Unauthenticated); + }); + } + + // Прогоняет GetStatus с заданным заголовком «service-token» (null — без заголовка). + // serviceToken: Ожидаемый токен хоста (env DEAL_SERVICE_TOKEN). + // tokenHeader: Значение metadata «service-token» запроса либо null (нет заголовка). + // expected: Ожидаемый StatusCode ответа. + private static async Task AssertDealRpcRejectedAsync( + string? serviceToken, + string? tokenHeader, + StatusCode expected) + { + await RunScenarioAsync(serviceToken, channel => AssertRejectedAsync(channel, tokenHeader, expected)); + } + + // Вызывает GetStatus и проверяет, что сервер ответил ожидаемым кодом статуса. + // channel: Канал к хосту telegram-service. + // tokenHeader: Значение metadata «service-token» либо null (нет заголовка). + // expected: Ожидаемый StatusCode. + private static async Task AssertRejectedAsync( + GrpcChannel channel, + string? tokenHeader, + StatusCode expected) + { + var client = new TelegramService.TelegramServiceClient(channel); + AsyncUnaryCall call = client.GetStatusAsync( + new GetStatusRequest(), + new CallOptions(TelegramTestHost.CallMetadata(tokenHeader), deadline: Deadline())); + + RpcException exception = await Assert.ThrowsAsync(() => call.ResponseAsync); + Assert.Equal(expected, exception.StatusCode); + } + + // Прогоняет сценарий через общий харнесс хоста (env токена/сессий, temp-каталог сессий). + // serviceToken: Значение env DEAL_SERVICE_TOKEN (null — убрать). + // scenario: Сценарий с каналом к поднятому хосту. + private static Task RunScenarioAsync(string? serviceToken, Func scenario) + => TelegramTestHost.RunAsync(serviceToken, scenario); + + // Deadline вызовов теста. + private static DateTime Deadline() + => DateTime.UtcNow.AddSeconds(TelegramTestHost.RpcDeadlineSeconds); +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramSessionRpcTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramSessionRpcTests.cs index e5cda7d..5690a93 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramSessionRpcTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramSessionRpcTests.cs @@ -1,226 +1,226 @@ -using Deal.Grpc.Telegram; -using Deal.Telegram.Telegram; -using Deal.Telegram.Tests.Telegram; -using Grpc.Core; -using Grpc.Net.Client; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Telegram.Tests.Grpc; - -/// -/// RPC-тесты подключения аккаунта поверх реального gRPC-хоста с фейковой фабрикой клиентов -/// -public sealed class TelegramSessionRpcTests -{ - private const string TenantId = TelegramTestHost.DefaultTenantId; - private const int ApiId = 12345; - private const string ApiHash = "0123456789abcdef0123456789abcdef"; - private const int PollTimeoutMilliseconds = 5000; - - /// - /// StartPhone без ключей приложения → INVALID_ARGUMENT «Сначала сохраните…». - /// - [Fact] - public async Task StartPhone_WithoutApiKeys_InvalidArgument() - { - await RunScenarioAsync( - async channel => - { - var client = new TelegramService.TelegramServiceClient(channel); - RpcException exception = await Assert.ThrowsAsync( - () => client.StartPhoneAsync( - new StartPhoneRequest { Phone = "+79990001122", ApiId = 0, ApiHash = string.Empty }, - Options()).ResponseAsync); - - Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); - Assert.Equal(Sessions.SessionErrorMessages.NoApiKeys, exception.Status.Detail); - }); - } - - /// - /// Отсутствующий tenant-id в metadata → UNAUTHENTICATED. - /// - [Fact] - public async Task GetStatus_WithoutTenantId_Unauthenticated() - { - await RunScenarioAsync( - async channel => - { - var client = new TelegramService.TelegramServiceClient(channel); - RpcException exception = await Assert.ThrowsAsync( - () => client.GetStatusAsync(new GetStatusRequest(), TokenOnlyOptions()).ResponseAsync); - - Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); - Assert.Equal(Sessions.SessionErrorMessages.TenantIdMissing, exception.Status.Detail); - }); - } - - /// - /// GetStatus без сессии тенанта → FAILED_PRECONDITION «Telegram не подключён». - /// - [Fact] - public async Task GetStatus_NoSession_FailedPrecondition() - { - await RunScenarioAsync( - async channel => - { - var client = new TelegramService.TelegramServiceClient(channel); - RpcException exception = await Assert.ThrowsAsync( - () => client.GetStatusAsync(new GetStatusRequest(), Options()).ResponseAsync); - - Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode); - Assert.Equal(Sessions.SessionErrorMessages.NotConnected, exception.Status.Detail); - }); - } - - /// - /// StartQr → фаза "qr" с URL; GetStatus показывает фазу/URL. - /// - [Fact] - public async Task StartQr_ReturnsQrPhase_AndStatusShowsQr() - { - await RunScenarioAsync( - async channel => - { - var client = new TelegramService.TelegramServiceClient(channel); - - StartQrReply qr = await client.StartQrAsync(new StartQrRequest { ApiId = ApiId, ApiHash = ApiHash }, Options()); - - Assert.Equal("qr", qr.Phase); - Assert.Equal(FakeSessionClient.DefaultQrUrl, qr.QrUrl); - - GetStatusReply status = await client.GetStatusAsync(new GetStatusRequest(), Options()); - Assert.Equal("qr", status.Phase); - Assert.True(status.HasQrUrl); - Assert.Equal(FakeSessionClient.DefaultQrUrl, status.QrUrl); - }); - } - - /// - /// QR-сканирование - /// - [Fact] - public async Task StartQr_ScanCompleted_ReadyWithAccount() - { - await RunScenarioAsync( - async channel => - { - var client = new TelegramService.TelegramServiceClient(channel); - - await client.StartQrAsync(new StartQrRequest { ApiId = ApiId, ApiHash = ApiHash }, Options()); - FakeSessionClient fake = SingleCreatedClient(); - fake.CompleteQrScan(); - - GetStatusReply status = await WaitForPhaseAsync(client, "ready"); - - Assert.True(status.Connected); - Assert.Equal("@fake_user", status.Account); - Assert.False(status.HasQrUrl); - }); - } - - /// - /// SendPassword вне фазы "password" - /// - [Fact] - public async Task SendPassword_WhileQrPhase_FailedPrecondition() - { - await RunScenarioAsync( - async channel => - { - var client = new TelegramService.TelegramServiceClient(channel); - await client.StartQrAsync(new StartQrRequest { ApiId = ApiId, ApiHash = ApiHash }, Options()); - - RpcException exception = await Assert.ThrowsAsync( - () => client.SendPasswordAsync(new SendPasswordRequest { Password = "x" }, Options()).ResponseAsync); - - Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode); - Assert.Equal(Sessions.SessionErrorMessages.PasswordNotRequested, exception.Status.Detail); - }); - } - - /// - /// Logout: ok=true, сессия удалена — GetStatus снова «Telegram не подключён». - /// - [Fact] - public async Task Logout_Ok_ThenGetStatusNotConnected() - { - await RunScenarioAsync( - async channel => - { - var client = new TelegramService.TelegramServiceClient(channel); - await client.StartQrAsync(new StartQrRequest { ApiId = ApiId, ApiHash = ApiHash }, Options()); - SingleCreatedClient().CompleteQrScan(); - await WaitForPhaseAsync(client, "ready"); - - LogoutReply logout = await client.LogoutAsync(new LogoutRequest(), Options()); - Assert.True(logout.Ok); - - RpcException exception = await Assert.ThrowsAsync( - () => client.GetStatusAsync(new GetStatusRequest(), Options()).ResponseAsync); - Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode); - }); - } - - // Прогоняет сценарий RPC на хосте с фейковой фабрикой клиентов. - // scenario: Сценарий с gRPC-каналом. - private static async Task RunScenarioAsync(Func scenario) - { - var factory = new FakeClientFactory(); - CurrentFactory = factory; - try - { - await TelegramTestHost.RunAsync( - TelegramTestHost.DefaultToken, - scenario, - configureServices: services => services.AddSingleton(factory)); - } - finally - { - CurrentFactory = null; - } - } - - // Хранилище активной фабрики сценария (для управления «сканированием»). - private static FakeClientFactory? CurrentFactory { get; set; } - - // Единственный созданный клиент сценария. - private static FakeSessionClient SingleCreatedClient() - { - List created = CurrentFactory?.CreatedClients ?? new List(); - Assert.Single(created); - return created[0]; - } - - // Опции вызова с токеном и tenant-id. - private static CallOptions Options() - => new(TelegramTestHost.CallMetadata(TelegramTestHost.DefaultToken, TenantId), deadline: Deadline()); - - // Опции вызова только с токеном (без tenant-id). - private static CallOptions TokenOnlyOptions() - => new(TelegramTestHost.CallMetadata(TelegramTestHost.DefaultToken), deadline: Deadline()); - - // Поллинг GetStatus до ожидаемой фазы. - // client: Клиент TelegramService. - // phase: Ожидаемая фаза ("ready"). - private static async Task WaitForPhaseAsync(TelegramService.TelegramServiceClient client, string phase) - { - var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds); - while (DateTime.UtcNow < deadline) - { - GetStatusReply status = await client.GetStatusAsync(new GetStatusRequest(), Options()); - if (status.Phase == phase) - { - return status; - } - - await Task.Delay(25); - } - - return await client.GetStatusAsync(new GetStatusRequest(), Options()); - } - - // Deadline вызовов теста. - private static DateTime Deadline() - => DateTime.UtcNow.AddSeconds(TelegramTestHost.RpcDeadlineSeconds); -} +using Deal.Grpc.Telegram; +using Deal.Telegram.Telegram; +using Deal.Telegram.Tests.Telegram; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Telegram.Tests.Grpc; + +/// +/// RPC-тесты подключения аккаунта поверх реального gRPC-хоста с фейковой фабрикой клиентов +/// +public sealed class TelegramSessionRpcTests +{ + private const string TenantId = TelegramTestHost.DefaultTenantId; + private const int ApiId = 12345; + private const string ApiHash = "0123456789abcdef0123456789abcdef"; + private const int PollTimeoutMilliseconds = 5000; + + /// + /// StartPhone без ключей приложения → INVALID_ARGUMENT «Сначала сохраните…». + /// + [Fact] + public async Task StartPhone_WithoutApiKeys_InvalidArgument() + { + await RunScenarioAsync( + async channel => + { + var client = new TelegramService.TelegramServiceClient(channel); + RpcException exception = await Assert.ThrowsAsync( + () => client.StartPhoneAsync( + new StartPhoneRequest { Phone = "+79990001122", ApiId = 0, ApiHash = string.Empty }, + Options()).ResponseAsync); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + Assert.Equal(Sessions.SessionErrorMessages.NoApiKeys, exception.Status.Detail); + }); + } + + /// + /// Отсутствующий tenant-id в metadata → UNAUTHENTICATED. + /// + [Fact] + public async Task GetStatus_WithoutTenantId_Unauthenticated() + { + await RunScenarioAsync( + async channel => + { + var client = new TelegramService.TelegramServiceClient(channel); + RpcException exception = await Assert.ThrowsAsync( + () => client.GetStatusAsync(new GetStatusRequest(), TokenOnlyOptions()).ResponseAsync); + + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + Assert.Equal(Sessions.SessionErrorMessages.TenantIdMissing, exception.Status.Detail); + }); + } + + /// + /// GetStatus без сессии тенанта → FAILED_PRECONDITION «Telegram не подключён». + /// + [Fact] + public async Task GetStatus_NoSession_FailedPrecondition() + { + await RunScenarioAsync( + async channel => + { + var client = new TelegramService.TelegramServiceClient(channel); + RpcException exception = await Assert.ThrowsAsync( + () => client.GetStatusAsync(new GetStatusRequest(), Options()).ResponseAsync); + + Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode); + Assert.Equal(Sessions.SessionErrorMessages.NotConnected, exception.Status.Detail); + }); + } + + /// + /// StartQr → фаза "qr" с URL; GetStatus показывает фазу/URL. + /// + [Fact] + public async Task StartQr_ReturnsQrPhase_AndStatusShowsQr() + { + await RunScenarioAsync( + async channel => + { + var client = new TelegramService.TelegramServiceClient(channel); + + StartQrReply qr = await client.StartQrAsync(new StartQrRequest { ApiId = ApiId, ApiHash = ApiHash }, Options()); + + Assert.Equal("qr", qr.Phase); + Assert.Equal(FakeSessionClient.DefaultQrUrl, qr.QrUrl); + + GetStatusReply status = await client.GetStatusAsync(new GetStatusRequest(), Options()); + Assert.Equal("qr", status.Phase); + Assert.True(status.HasQrUrl); + Assert.Equal(FakeSessionClient.DefaultQrUrl, status.QrUrl); + }); + } + + /// + /// QR-сканирование + /// + [Fact] + public async Task StartQr_ScanCompleted_ReadyWithAccount() + { + await RunScenarioAsync( + async channel => + { + var client = new TelegramService.TelegramServiceClient(channel); + + await client.StartQrAsync(new StartQrRequest { ApiId = ApiId, ApiHash = ApiHash }, Options()); + FakeSessionClient fake = SingleCreatedClient(); + fake.CompleteQrScan(); + + GetStatusReply status = await WaitForPhaseAsync(client, "ready"); + + Assert.True(status.Connected); + Assert.Equal("@fake_user", status.Account); + Assert.False(status.HasQrUrl); + }); + } + + /// + /// SendPassword вне фазы "password" + /// + [Fact] + public async Task SendPassword_WhileQrPhase_FailedPrecondition() + { + await RunScenarioAsync( + async channel => + { + var client = new TelegramService.TelegramServiceClient(channel); + await client.StartQrAsync(new StartQrRequest { ApiId = ApiId, ApiHash = ApiHash }, Options()); + + RpcException exception = await Assert.ThrowsAsync( + () => client.SendPasswordAsync(new SendPasswordRequest { Password = "x" }, Options()).ResponseAsync); + + Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode); + Assert.Equal(Sessions.SessionErrorMessages.PasswordNotRequested, exception.Status.Detail); + }); + } + + /// + /// Logout: ok=true, сессия удалена — GetStatus снова «Telegram не подключён». + /// + [Fact] + public async Task Logout_Ok_ThenGetStatusNotConnected() + { + await RunScenarioAsync( + async channel => + { + var client = new TelegramService.TelegramServiceClient(channel); + await client.StartQrAsync(new StartQrRequest { ApiId = ApiId, ApiHash = ApiHash }, Options()); + SingleCreatedClient().CompleteQrScan(); + await WaitForPhaseAsync(client, "ready"); + + LogoutReply logout = await client.LogoutAsync(new LogoutRequest(), Options()); + Assert.True(logout.Ok); + + RpcException exception = await Assert.ThrowsAsync( + () => client.GetStatusAsync(new GetStatusRequest(), Options()).ResponseAsync); + Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode); + }); + } + + // Прогоняет сценарий RPC на хосте с фейковой фабрикой клиентов. + // scenario: Сценарий с gRPC-каналом. + private static async Task RunScenarioAsync(Func scenario) + { + var factory = new FakeClientFactory(); + CurrentFactory = factory; + try + { + await TelegramTestHost.RunAsync( + TelegramTestHost.DefaultToken, + scenario, + configureServices: services => services.AddSingleton(factory)); + } + finally + { + CurrentFactory = null; + } + } + + // Хранилище активной фабрики сценария (для управления «сканированием»). + private static FakeClientFactory? CurrentFactory { get; set; } + + // Единственный созданный клиент сценария. + private static FakeSessionClient SingleCreatedClient() + { + List created = CurrentFactory?.CreatedClients ?? new List(); + Assert.Single(created); + return created[0]; + } + + // Опции вызова с токеном и tenant-id. + private static CallOptions Options() + => new(TelegramTestHost.CallMetadata(TelegramTestHost.DefaultToken, TenantId), deadline: Deadline()); + + // Опции вызова только с токеном (без tenant-id). + private static CallOptions TokenOnlyOptions() + => new(TelegramTestHost.CallMetadata(TelegramTestHost.DefaultToken), deadline: Deadline()); + + // Поллинг GetStatus до ожидаемой фазы. + // client: Клиент TelegramService. + // phase: Ожидаемая фаза ("ready"). + private static async Task WaitForPhaseAsync(TelegramService.TelegramServiceClient client, string phase) + { + var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds); + while (DateTime.UtcNow < deadline) + { + GetStatusReply status = await client.GetStatusAsync(new GetStatusRequest(), Options()); + if (status.Phase == phase) + { + return status; + } + + await Task.Delay(25); + } + + return await client.GetStatusAsync(new GetStatusRequest(), Options()); + } + + // Deadline вызовов теста. + private static DateTime Deadline() + => DateTime.UtcNow.AddSeconds(TelegramTestHost.RpcDeadlineSeconds); +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramTestHost.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramTestHost.cs index 545b8d4..43565a7 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramTestHost.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramTestHost.cs @@ -1,170 +1,170 @@ -using System.Net; -using System.Net.Sockets; -using Deal.Grpc.Hosting.Interceptors; -using Deal.Grpc.Hosting.Models; -using Deal.Grpc.Hosting.Options; -using Deal.Grpc.Hosting.Services; -using Deal.Telegram.Core; -using Deal.Telegram.Sessions; -using Grpc.Core; -using Grpc.Net.Client; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Telegram.Tests.Grpc; - -// Общий харнесс интеграционных тестов telegram-service: поднимает хост (TelegramServiceHost.Create) -// в процессе теста на эфемерном порту с управляемым окружением env и каталогом сессий в temp. -// TelegramServiceHost.Create требует DEAL_TELEGRAM_SESSION_KEY (fail-closed), поэтому харнесс всегда -// выставляет валидный тестовый ключ и указывает DEAL_TELEGRAM_SESSION_DIR во временный каталог -// (сессии тестов не попадают в data/sessions репозитория). Исходные значения env восстанавливаются. -internal static class TelegramTestHost -{ - /// - /// Env-ключ ожидаемого service-token - /// - public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - - /// - /// Env-ключ адреса ингресса ядра - /// - public const string IngressEndpointEnvKey = CoreIngressOptions.IngressEndpointEnvVarName; - - /// - /// Env-ключ ключа шифрования сессий - /// - public const string SessionKeyEnvKey = TgOptions.SessionKeyEnvVarName; - - /// - /// Env-ключ каталога сессий - /// - public const string SessionDirEnvKey = TgOptions.SessionDirEnvVarName; - - public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey; - - public const string TenantIdMetadataKey = TelegramServiceImpl.TenantIdMetadataKey; - - /// - /// Токен сценариев теста. - /// - public const string DefaultToken = "deal-test-token"; - - /// - /// Тестовый ключ шифрования сессий - /// - public const string SessionKeyBase64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="; - - /// - /// Id тенанта сценариев по умолчанию. - /// - public const string DefaultTenantId = "tenant-test"; - - /// - /// Deadline RPC-вызовов теста - /// - public const int RpcDeadlineSeconds = 10; - - /// - /// Прогоняет сценарий против поднятого хоста с заданным env-токеном и тестовым окружением сессий. - /// - /// Значение env DEAL_SERVICE_TOKEN (null — убрать переменную). - /// Сценарий с gRPC-каналом к хосту. - /// Хук DI (подмена зависимостей фейками). - /// Дополнительные env-переменные на время сценария (напр. адрес ингресса). - public static async Task RunAsync( - string? serviceToken, - Func scenario, - Action? configureServices = null, - IReadOnlyDictionary? extraEnv = null) - { - string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); - string? originalKey = Environment.GetEnvironmentVariable(SessionKeyEnvKey); - string? originalDir = Environment.GetEnvironmentVariable(SessionDirEnvKey); - Dictionary originalExtra = extraEnv?.ToDictionary(pair => pair.Key, pair => Environment.GetEnvironmentVariable(pair.Key)) ?? []; - - string sessionsDir = Path.Combine(Path.GetTempPath(), "deal-tg-tests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(sessionsDir); - - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); - Environment.SetEnvironmentVariable(SessionKeyEnvKey, SessionKeyBase64); - Environment.SetEnvironmentVariable(SessionDirEnvKey, sessionsDir); - if (extraEnv is not null) - { - foreach ((string key, string? value) in extraEnv) - { - Environment.SetEnvironmentVariable(key, value); - } - } - - WebApplication? app = null; - GrpcChannel? channel = null; - try - { - int port = FreeTcpPort(); - app = TelegramServiceHost.Create(port, configureServices: configureServices); - await app.StartAsync(); - - channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}"); - await scenario(channel); - } - finally - { - if (channel is not null) - { - channel.Dispose(); - } - - if (app is not null) - { - await app.StopAsync(); - await app.DisposeAsync(); - } - - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); - Environment.SetEnvironmentVariable(SessionKeyEnvKey, originalKey); - Environment.SetEnvironmentVariable(SessionDirEnvKey, originalDir); - foreach ((string key, string? originalValue) in originalExtra) - { - Environment.SetEnvironmentVariable(key, originalValue); - } - - try - { - Directory.Delete(sessionsDir, recursive: true); - } - catch (IOException) - { - // Каталог мог быть занят на момент удаления — тестовый мусор в temp допустим. - } - } - } - - /// - /// Строит metadata вызова - /// - /// Значение заголовка service-token. - /// Id тенанта (null — без заголовка tenant-id). - public static Metadata CallMetadata(string? serviceToken, string? tenantId = null) - { - var metadata = new Metadata(); - if (serviceToken is not null) - { - metadata.Add(ServiceTokenMetadataKey, serviceToken); - } - - if (tenantId is not null) - { - metadata.Add(TenantIdMetadataKey, tenantId); - } - - return metadata; - } - - // Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста). - private static int FreeTcpPort() - { - using var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - return ((IPEndPoint)listener.LocalEndpoint).Port; - } -} +using System.Net; +using System.Net.Sockets; +using Deal.Grpc.Hosting.Interceptors; +using Deal.Grpc.Hosting.Models; +using Deal.Grpc.Hosting.Options; +using Deal.Grpc.Hosting.Services; +using Deal.Telegram.Core; +using Deal.Telegram.Sessions; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Telegram.Tests.Grpc; + +// Общий харнесс интеграционных тестов telegram-service: поднимает хост (TelegramServiceHost.Create) +// в процессе теста на эфемерном порту с управляемым окружением env и каталогом сессий в temp. +// TelegramServiceHost.Create требует DEAL_TELEGRAM_SESSION_KEY (fail-closed), поэтому харнесс всегда +// выставляет валидный тестовый ключ и указывает DEAL_TELEGRAM_SESSION_DIR во временный каталог +// (сессии тестов не попадают в data/sessions репозитория). Исходные значения env восстанавливаются. +internal static class TelegramTestHost +{ + /// + /// Env-ключ ожидаемого service-token + /// + public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; + + /// + /// Env-ключ адреса ингресса ядра + /// + public const string IngressEndpointEnvKey = CoreIngressOptions.IngressEndpointEnvVarName; + + /// + /// Env-ключ ключа шифрования сессий + /// + public const string SessionKeyEnvKey = TgOptions.SessionKeyEnvVarName; + + /// + /// Env-ключ каталога сессий + /// + public const string SessionDirEnvKey = TgOptions.SessionDirEnvVarName; + + public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey; + + public const string TenantIdMetadataKey = TelegramServiceImpl.TenantIdMetadataKey; + + /// + /// Токен сценариев теста. + /// + public const string DefaultToken = "deal-test-token"; + + /// + /// Тестовый ключ шифрования сессий + /// + public const string SessionKeyBase64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="; + + /// + /// Id тенанта сценариев по умолчанию. + /// + public const string DefaultTenantId = "tenant-test"; + + /// + /// Deadline RPC-вызовов теста + /// + public const int RpcDeadlineSeconds = 10; + + /// + /// Прогоняет сценарий против поднятого хоста с заданным env-токеном и тестовым окружением сессий. + /// + /// Значение env DEAL_SERVICE_TOKEN (null — убрать переменную). + /// Сценарий с gRPC-каналом к хосту. + /// Хук DI (подмена зависимостей фейками). + /// Дополнительные env-переменные на время сценария (напр. адрес ингресса). + public static async Task RunAsync( + string? serviceToken, + Func scenario, + Action? configureServices = null, + IReadOnlyDictionary? extraEnv = null) + { + string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); + string? originalKey = Environment.GetEnvironmentVariable(SessionKeyEnvKey); + string? originalDir = Environment.GetEnvironmentVariable(SessionDirEnvKey); + Dictionary originalExtra = extraEnv?.ToDictionary(pair => pair.Key, pair => Environment.GetEnvironmentVariable(pair.Key)) ?? []; + + string sessionsDir = Path.Combine(Path.GetTempPath(), "deal-tg-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(sessionsDir); + + Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); + Environment.SetEnvironmentVariable(SessionKeyEnvKey, SessionKeyBase64); + Environment.SetEnvironmentVariable(SessionDirEnvKey, sessionsDir); + if (extraEnv is not null) + { + foreach ((string key, string? value) in extraEnv) + { + Environment.SetEnvironmentVariable(key, value); + } + } + + WebApplication? app = null; + GrpcChannel? channel = null; + try + { + int port = FreeTcpPort(); + app = TelegramServiceHost.Create(port, configureServices: configureServices); + await app.StartAsync(); + + channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}"); + await scenario(channel); + } + finally + { + if (channel is not null) + { + channel.Dispose(); + } + + if (app is not null) + { + await app.StopAsync(); + await app.DisposeAsync(); + } + + Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); + Environment.SetEnvironmentVariable(SessionKeyEnvKey, originalKey); + Environment.SetEnvironmentVariable(SessionDirEnvKey, originalDir); + foreach ((string key, string? originalValue) in originalExtra) + { + Environment.SetEnvironmentVariable(key, originalValue); + } + + try + { + Directory.Delete(sessionsDir, recursive: true); + } + catch (IOException) + { + // Каталог мог быть занят на момент удаления — тестовый мусор в temp допустим. + } + } + } + + /// + /// Строит metadata вызова + /// + /// Значение заголовка service-token. + /// Id тенанта (null — без заголовка tenant-id). + public static Metadata CallMetadata(string? serviceToken, string? tenantId = null) + { + var metadata = new Metadata(); + if (serviceToken is not null) + { + metadata.Add(ServiceTokenMetadataKey, serviceToken); + } + + if (tenantId is not null) + { + metadata.Add(TenantIdMetadataKey, tenantId); + } + + return metadata; + } + + // Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста). + private static int FreeTcpPort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/TestDoubles.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/TestDoubles.cs index 5b0b514..d1fe306 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/TestDoubles.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/TestDoubles.cs @@ -1,92 +1,92 @@ -using Deal.Grpc.Sources; -using Deal.Grpc.Telegram; -using Deal.Telegram.Core; -using Deal.Telegram.Dialogs; - -namespace Deal.Telegram.Tests.Grpc; - -internal sealed class FakeIngress : ICoreIngressClient -{ - /// - /// Отправленные PushSource - /// - public List<(string TenantId, PushSourceRequest Request)> Pushes { get; } = []; - - /// - /// Синхронизации каталога - /// - public List<(string TenantId, IReadOnlyList Entries)> Syncs { get; } = []; - - /// - /// Monitored-набор, который фейк возвращает ответом SyncDialogs - /// - public List MonitoredIdsToReturn { get; set; } = []; - - /// - /// Ошибка PushSourceAsync - /// - public Exception? PushError { get; set; } - - /// - /// Ошибка SyncDialogsAsync - /// - public Exception? SyncError { get; set; } - - /// - /// Текст последнего PushSource - /// - public string? LastPushText => Pushes.Count == 0 ? null : Pushes[^1].Request.Content.Text; - - /// - /// Id последнего отправленного диалога. - /// - public string? LastPushDialogId => Pushes.Count == 0 ? null : Pushes[^1].Request.Source.OriginRef; - - /// - public Task PushSourceAsync( - string tenantId, - PushSourceRequest request, - CancellationToken cancellationToken) - { - if (PushError is not null) - { - throw PushError; - } - - Pushes.Add((tenantId, request)); - return Task.FromResult(new PushSourceReply { Accepted = true, Duplicate = false }); - } - - /// - public Task> SyncDialogsAsync( - string tenantId, - IReadOnlyList entries, - CancellationToken cancellationToken) - { - if (SyncError is not null) - { - throw SyncError; - } - - Syncs.Add((tenantId, entries.ToArray())); - return Task.FromResult>(MonitoredIdsToReturn.ToArray()); - } -} - -internal sealed class RecordingPacer : IBackfillPacer -{ - /// - /// Запрошенные диапазоны пауз - /// - public List<(double MinSeconds, double MaxSeconds)> Waits { get; } = []; - - /// - public Task WaitAsync( - double minSeconds, - double maxSeconds, - CancellationToken cancellationToken) - { - Waits.Add((minSeconds, maxSeconds)); - return Task.CompletedTask; - } -} +using Deal.Grpc.Sources; +using Deal.Grpc.Telegram; +using Deal.Telegram.Core; +using Deal.Telegram.Dialogs; + +namespace Deal.Telegram.Tests.Grpc; + +internal sealed class FakeIngress : ICoreIngressClient +{ + /// + /// Отправленные PushSource + /// + public List<(string TenantId, PushSourceRequest Request)> Pushes { get; } = []; + + /// + /// Синхронизации каталога + /// + public List<(string TenantId, IReadOnlyList Entries)> Syncs { get; } = []; + + /// + /// Monitored-набор, который фейк возвращает ответом SyncDialogs + /// + public List MonitoredIdsToReturn { get; set; } = []; + + /// + /// Ошибка PushSourceAsync + /// + public Exception? PushError { get; set; } + + /// + /// Ошибка SyncDialogsAsync + /// + public Exception? SyncError { get; set; } + + /// + /// Текст последнего PushSource + /// + public string? LastPushText => Pushes.Count == 0 ? null : Pushes[^1].Request.Content.Text; + + /// + /// Id последнего отправленного диалога. + /// + public string? LastPushDialogId => Pushes.Count == 0 ? null : Pushes[^1].Request.Source.OriginRef; + + /// + public Task PushSourceAsync( + string tenantId, + PushSourceRequest request, + CancellationToken cancellationToken) + { + if (PushError is not null) + { + throw PushError; + } + + Pushes.Add((tenantId, request)); + return Task.FromResult(new PushSourceReply { Accepted = true, Duplicate = false }); + } + + /// + public Task> SyncDialogsAsync( + string tenantId, + IReadOnlyList entries, + CancellationToken cancellationToken) + { + if (SyncError is not null) + { + throw SyncError; + } + + Syncs.Add((tenantId, entries.ToArray())); + return Task.FromResult>(MonitoredIdsToReturn.ToArray()); + } +} + +internal sealed class RecordingPacer : IBackfillPacer +{ + /// + /// Запрошенные диапазоны пауз + /// + public List<(double MinSeconds, double MaxSeconds)> Waits { get; } = []; + + /// + public Task WaitAsync( + double minSeconds, + double maxSeconds, + CancellationToken cancellationToken) + { + Waits.Add((minSeconds, maxSeconds)); + return Task.CompletedTask; + } +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Support/SessionStorageTests.cs b/src/telegram-service/Deal.Telegram.Tests/Support/SessionStorageTests.cs index a571a91..d0c5042 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Support/SessionStorageTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Support/SessionStorageTests.cs @@ -1,354 +1,354 @@ -using System.Security.Cryptography; -using Deal.Telegram.Sessions; -using Deal.Telegram.Tests.Telegram; -using Grpc.Core; - -namespace Deal.Telegram.Tests.Support; - -/// -/// Тесты файлового хранилища сессий и AES-GCM-обёртки. -/// -public sealed class SessionStorageTests -{ - private const int ApiId = 123456; - private const string ApiHash = "0123456789abcdef0123456789abcdef"; - private static readonly byte[] SessionBytes = [9, 8, 7, 6, 5, 4, 3]; - - /// - /// Шифр: roundtrip произвольных байт сессии. - /// - [Fact] - public void Cipher_EncryptDecrypt_Roundtrip() - { - string dir = TestSessionFactory.NewSessionsDirectory(); - try - { - SessionFileCipher cipher = TestSessionFactory.Cipher(dir); - byte[] payload = new byte[2048]; - RandomNumberGenerator.Fill(payload); - - string encrypted = cipher.EncryptBytes(payload); - byte[]? decrypted = cipher.DecryptBytes(encrypted); - - Assert.NotNull(decrypted); - Assert.Equal(payload, decrypted); - } - finally - { - TestSessionFactory.Cleanup(dir); - } - } - - /// - /// Шифр: значение имеет префикс enc: и не содержит открытых байт сессии. - /// - [Fact] - public void Cipher_Encrypt_ProducesEncPrefix_AndHidesPlaintext() - { - string dir = TestSessionFactory.NewSessionsDirectory(); - try - { - SessionFileCipher cipher = TestSessionFactory.Cipher(dir); - - string encrypted = cipher.EncryptBytes(SessionBytes); - - Assert.StartsWith(SessionFileCipher.EncryptedPrefix, encrypted, StringComparison.Ordinal); - Assert.DoesNotContain(System.Text.Encoding.UTF8.GetString(SessionBytes), encrypted, StringComparison.Ordinal); - } - finally - { - TestSessionFactory.Cleanup(dir); - } - } - - /// - /// Шифр: значение не в формате enc: — Decrypt возвращает null - /// - [Theory] - [InlineData("")] - [InlineData("мусор")] - [InlineData("plain:AAAA")] - public void Cipher_Decrypt_NotOurFormat_ReturnsNull(string value) - { - string dir = TestSessionFactory.NewSessionsDirectory(); - try - { - SessionFileCipher cipher = TestSessionFactory.Cipher(dir); - Assert.Null(cipher.DecryptBytes(value)); - } - finally - { - TestSessionFactory.Cleanup(dir); - } - } - - /// - /// Шифр: чужой ключ не расшифровывает - /// - [Fact] - public void Cipher_Decrypt_WrongKey_ThrowsCryptographic() - { - string dir = TestSessionFactory.NewSessionsDirectory(); - try - { - SessionFileCipher encoder = TestSessionFactory.Cipher(dir, TestSessionFactory.TestKey); - SessionFileCipher decoder = TestSessionFactory.Cipher(dir, TestSessionFactory.TestKey.Select(b => (byte)(b ^ 0xFF)).ToArray()); - - string encrypted = encoder.EncryptBytes(SessionBytes); - - Assert.ThrowsAny(() => decoder.DecryptBytes(encrypted)); - } - finally - { - TestSessionFactory.Cleanup(dir); - } - } - - /// - /// Store: Save → Load возвращает тот же StoredSession - /// - [Fact] - public async Task Store_SaveLoad_Roundtrip() - { - string dir = TestSessionFactory.NewSessionsDirectory(); - try - { - SessionStore store = TestSessionFactory.Store(dir); - var stored = NewStoredSession(); - - await store.SaveAsync("tenant-a", stored); - - StoredSession? loaded = await store.LoadAsync("tenant-a"); - Assert.NotNull(loaded); - Assert.Equal(stored.ApiId, loaded.ApiId); - Assert.Equal(stored.ApiHash, loaded.ApiHash); - Assert.Equal(stored.SessionBytes, loaded.SessionBytes); - } - finally - { - TestSessionFactory.Cleanup(dir); - } - } - - /// - /// Store: файл создаётся как enc:-обёртка - /// - [Fact] - public async Task Store_Save_WritesEncryptedFileWithoutPlaintext() - { - string dir = TestSessionFactory.NewSessionsDirectory(); - try - { - SessionStore store = TestSessionFactory.Store(dir); - string filePath = Path.Combine(dir, "tenant-a.session"); - - await store.SaveAsync("tenant-a", NewStoredSession()); - - Assert.True(File.Exists(filePath)); - string content = await File.ReadAllTextAsync(filePath); - Assert.StartsWith(SessionFileCipher.EncryptedPrefix, content, StringComparison.Ordinal); - Assert.DoesNotContain(ApiHash, content, StringComparison.Ordinal); - Assert.DoesNotContain(Convert.ToBase64String(SessionBytes), content, StringComparison.Ordinal); - } - finally - { - TestSessionFactory.Cleanup(dir); - } - } - - /// - /// Store: повторное сохранение атомарно перезаписывает - /// - [Fact] - public async Task Store_SaveTwice_LastWriteWins() - { - string dir = TestSessionFactory.NewSessionsDirectory(); - try - { - SessionStore store = TestSessionFactory.Store(dir); - var first = NewStoredSession(apiId: 111, marker: [1, 1, 1]); - var second = NewStoredSession(apiId: 222, marker: [2, 2, 2]); - - await store.SaveAsync("tenant-a", first); - await store.SaveAsync("tenant-a", second); - - StoredSession? loaded = await store.LoadAsync("tenant-a"); - Assert.NotNull(loaded); - Assert.Equal(222, loaded.ApiId); - Assert.Equal([2, 2, 2], loaded.SessionBytes); - } - finally - { - TestSessionFactory.Cleanup(dir); - } - } - - /// - /// Store: файла нет → Load возвращает null - /// - [Fact] - public async Task Store_Load_MissingFile_ReturnsNull() - { - string dir = TestSessionFactory.NewSessionsDirectory(); - try - { - SessionStore store = TestSessionFactory.Store(dir); - Assert.Null(await store.LoadAsync("tenant-a")); - } - finally - { - TestSessionFactory.Cleanup(dir); - } - } - - /// - /// Store: чужой ключ → файл нечитаем → Load возвращает null - /// - [Fact] - public async Task Store_Load_WrongKey_ReturnsNull() - { - string dir = TestSessionFactory.NewSessionsDirectory(); - try - { - SessionStore storeA = TestSessionFactory.Store(dir, TestSessionFactory.TestKey); - await storeA.SaveAsync("tenant-a", NewStoredSession()); - - SessionStore storeB = TestSessionFactory.Store(dir, TestSessionFactory.TestKey.Select(b => (byte)(b ^ 0xFF)).ToArray()); - Assert.Null(await storeB.LoadAsync("tenant-a")); - } - finally - { - TestSessionFactory.Cleanup(dir); - } - } - - /// - /// Store: битый файл - /// - [Fact] - public async Task Store_Load_CorruptFile_ReturnsNull_FileKept() - { - string dir = TestSessionFactory.NewSessionsDirectory(); - try - { - Directory.CreateDirectory(dir); - string filePath = Path.Combine(dir, "tenant-a.session"); - await File.WriteAllTextAsync(filePath, "enc:не-base64-мусор"); - - SessionStore store = TestSessionFactory.Store(dir); - Assert.Null(await store.LoadAsync("tenant-a")); - Assert.True(File.Exists(filePath)); - } - finally - { - TestSessionFactory.Cleanup(dir); - } - } - - /// - /// Store: изоляция тенантов — файлы разных тенантов не пересекаются. - /// - [Fact] - public async Task Store_TenantIsolation_SeparateFiles() - { - string dir = TestSessionFactory.NewSessionsDirectory(); - try - { - SessionStore store = TestSessionFactory.Store(dir); - - await store.SaveAsync("tenant-a", NewStoredSession(apiId: 111, marker: [1])); - await store.SaveAsync("tenant-b", NewStoredSession(apiId: 222, marker: [2])); - - Assert.Equal(111, (await store.LoadAsync("tenant-a"))?.ApiId); - Assert.Equal(222, (await store.LoadAsync("tenant-b"))?.ApiId); - } - finally - { - TestSessionFactory.Cleanup(dir); - } - } - - /// - /// Store: Delete удаляет файл; повторный Delete не бросает. - /// - [Fact] - public async Task Store_Delete_RemovesFile() - { - string dir = TestSessionFactory.NewSessionsDirectory(); - try - { - SessionStore store = TestSessionFactory.Store(dir); - await store.SaveAsync("tenant-a", NewStoredSession()); - - await store.DeleteAsync("tenant-a"); - Assert.False(File.Exists(Path.Combine(dir, "tenant-a.session"))); - Assert.Null(await store.LoadAsync("tenant-a")); - - await store.DeleteAsync("tenant-a"); - } - finally - { - TestSessionFactory.Cleanup(dir); - } - } - - /// - /// Store: ListTenantIds перечисляет только *.session текущего каталога - /// - [Fact] - public async Task Store_ListTenantIds_ReturnsSessionFilesOnly() - { - string dir = TestSessionFactory.NewSessionsDirectory(); - try - { - Directory.CreateDirectory(dir); - SessionStore store = TestSessionFactory.Store(dir); - await store.SaveAsync("tenant-a", NewStoredSession()); - await store.SaveAsync("tenant-b", NewStoredSession()); - await File.WriteAllTextAsync(Path.Combine(dir, "notes.txt"), "не сессия"); - Directory.CreateDirectory(Path.Combine(dir, "sub")); - await File.WriteAllTextAsync(Path.Combine(dir, "sub", "tenant-c.session"), "не наш файл"); - - string[] tenants = store.ListTenantIds().OrderBy(id => id).ToArray(); - - Assert.Equal(["tenant-a", "tenant-b"], tenants); - } - finally - { - TestSessionFactory.Cleanup(dir); - } - } - - /// - /// Store: некорректный tenant-id - /// - [Fact] - public async Task Store_InvalidTenantId_ThrowsSessionException() - { - string dir = TestSessionFactory.NewSessionsDirectory(); - try - { - SessionStore store = TestSessionFactory.Store(dir); - - SessionException exception = await Assert.ThrowsAsync(() => store.SaveAsync("../escape", NewStoredSession())); - - Assert.Equal(StatusCode.InvalidArgument, exception.Code); - Assert.Equal(SessionErrorMessages.InvalidTenantId, exception.Message); - } - finally - { - TestSessionFactory.Cleanup(dir); - } - } - - // Образец StoredSession с настраиваемыми apiId/маркером сессии. - // apiId: api_id. - // marker: Байты сессии. - private static StoredSession NewStoredSession(int? apiId = null, byte[]? marker = null) - => new() - { - ApiId = apiId ?? ApiId, - ApiHash = ApiHash, - SessionBytes = marker ?? SessionBytes, - }; -} +using System.Security.Cryptography; +using Deal.Telegram.Sessions; +using Deal.Telegram.Tests.Telegram; +using Grpc.Core; + +namespace Deal.Telegram.Tests.Support; + +/// +/// Тесты файлового хранилища сессий и AES-GCM-обёртки. +/// +public sealed class SessionStorageTests +{ + private const int ApiId = 123456; + private const string ApiHash = "0123456789abcdef0123456789abcdef"; + private static readonly byte[] SessionBytes = [9, 8, 7, 6, 5, 4, 3]; + + /// + /// Шифр: roundtrip произвольных байт сессии. + /// + [Fact] + public void Cipher_EncryptDecrypt_Roundtrip() + { + string dir = TestSessionFactory.NewSessionsDirectory(); + try + { + SessionFileCipher cipher = TestSessionFactory.Cipher(dir); + byte[] payload = new byte[2048]; + RandomNumberGenerator.Fill(payload); + + string encrypted = cipher.EncryptBytes(payload); + byte[]? decrypted = cipher.DecryptBytes(encrypted); + + Assert.NotNull(decrypted); + Assert.Equal(payload, decrypted); + } + finally + { + TestSessionFactory.Cleanup(dir); + } + } + + /// + /// Шифр: значение имеет префикс enc: и не содержит открытых байт сессии. + /// + [Fact] + public void Cipher_Encrypt_ProducesEncPrefix_AndHidesPlaintext() + { + string dir = TestSessionFactory.NewSessionsDirectory(); + try + { + SessionFileCipher cipher = TestSessionFactory.Cipher(dir); + + string encrypted = cipher.EncryptBytes(SessionBytes); + + Assert.StartsWith(SessionFileCipher.EncryptedPrefix, encrypted, StringComparison.Ordinal); + Assert.DoesNotContain(System.Text.Encoding.UTF8.GetString(SessionBytes), encrypted, StringComparison.Ordinal); + } + finally + { + TestSessionFactory.Cleanup(dir); + } + } + + /// + /// Шифр: значение не в формате enc: — Decrypt возвращает null + /// + [Theory] + [InlineData("")] + [InlineData("мусор")] + [InlineData("plain:AAAA")] + public void Cipher_Decrypt_NotOurFormat_ReturnsNull(string value) + { + string dir = TestSessionFactory.NewSessionsDirectory(); + try + { + SessionFileCipher cipher = TestSessionFactory.Cipher(dir); + Assert.Null(cipher.DecryptBytes(value)); + } + finally + { + TestSessionFactory.Cleanup(dir); + } + } + + /// + /// Шифр: чужой ключ не расшифровывает + /// + [Fact] + public void Cipher_Decrypt_WrongKey_ThrowsCryptographic() + { + string dir = TestSessionFactory.NewSessionsDirectory(); + try + { + SessionFileCipher encoder = TestSessionFactory.Cipher(dir, TestSessionFactory.TestKey); + SessionFileCipher decoder = TestSessionFactory.Cipher(dir, TestSessionFactory.TestKey.Select(b => (byte)(b ^ 0xFF)).ToArray()); + + string encrypted = encoder.EncryptBytes(SessionBytes); + + Assert.ThrowsAny(() => decoder.DecryptBytes(encrypted)); + } + finally + { + TestSessionFactory.Cleanup(dir); + } + } + + /// + /// Store: Save → Load возвращает тот же StoredSession + /// + [Fact] + public async Task Store_SaveLoad_Roundtrip() + { + string dir = TestSessionFactory.NewSessionsDirectory(); + try + { + SessionStore store = TestSessionFactory.Store(dir); + var stored = NewStoredSession(); + + await store.SaveAsync("tenant-a", stored); + + StoredSession? loaded = await store.LoadAsync("tenant-a"); + Assert.NotNull(loaded); + Assert.Equal(stored.ApiId, loaded.ApiId); + Assert.Equal(stored.ApiHash, loaded.ApiHash); + Assert.Equal(stored.SessionBytes, loaded.SessionBytes); + } + finally + { + TestSessionFactory.Cleanup(dir); + } + } + + /// + /// Store: файл создаётся как enc:-обёртка + /// + [Fact] + public async Task Store_Save_WritesEncryptedFileWithoutPlaintext() + { + string dir = TestSessionFactory.NewSessionsDirectory(); + try + { + SessionStore store = TestSessionFactory.Store(dir); + string filePath = Path.Combine(dir, "tenant-a.session"); + + await store.SaveAsync("tenant-a", NewStoredSession()); + + Assert.True(File.Exists(filePath)); + string content = await File.ReadAllTextAsync(filePath); + Assert.StartsWith(SessionFileCipher.EncryptedPrefix, content, StringComparison.Ordinal); + Assert.DoesNotContain(ApiHash, content, StringComparison.Ordinal); + Assert.DoesNotContain(Convert.ToBase64String(SessionBytes), content, StringComparison.Ordinal); + } + finally + { + TestSessionFactory.Cleanup(dir); + } + } + + /// + /// Store: повторное сохранение атомарно перезаписывает + /// + [Fact] + public async Task Store_SaveTwice_LastWriteWins() + { + string dir = TestSessionFactory.NewSessionsDirectory(); + try + { + SessionStore store = TestSessionFactory.Store(dir); + var first = NewStoredSession(apiId: 111, marker: [1, 1, 1]); + var second = NewStoredSession(apiId: 222, marker: [2, 2, 2]); + + await store.SaveAsync("tenant-a", first); + await store.SaveAsync("tenant-a", second); + + StoredSession? loaded = await store.LoadAsync("tenant-a"); + Assert.NotNull(loaded); + Assert.Equal(222, loaded.ApiId); + Assert.Equal([2, 2, 2], loaded.SessionBytes); + } + finally + { + TestSessionFactory.Cleanup(dir); + } + } + + /// + /// Store: файла нет → Load возвращает null + /// + [Fact] + public async Task Store_Load_MissingFile_ReturnsNull() + { + string dir = TestSessionFactory.NewSessionsDirectory(); + try + { + SessionStore store = TestSessionFactory.Store(dir); + Assert.Null(await store.LoadAsync("tenant-a")); + } + finally + { + TestSessionFactory.Cleanup(dir); + } + } + + /// + /// Store: чужой ключ → файл нечитаем → Load возвращает null + /// + [Fact] + public async Task Store_Load_WrongKey_ReturnsNull() + { + string dir = TestSessionFactory.NewSessionsDirectory(); + try + { + SessionStore storeA = TestSessionFactory.Store(dir, TestSessionFactory.TestKey); + await storeA.SaveAsync("tenant-a", NewStoredSession()); + + SessionStore storeB = TestSessionFactory.Store(dir, TestSessionFactory.TestKey.Select(b => (byte)(b ^ 0xFF)).ToArray()); + Assert.Null(await storeB.LoadAsync("tenant-a")); + } + finally + { + TestSessionFactory.Cleanup(dir); + } + } + + /// + /// Store: битый файл + /// + [Fact] + public async Task Store_Load_CorruptFile_ReturnsNull_FileKept() + { + string dir = TestSessionFactory.NewSessionsDirectory(); + try + { + Directory.CreateDirectory(dir); + string filePath = Path.Combine(dir, "tenant-a.session"); + await File.WriteAllTextAsync(filePath, "enc:не-base64-мусор"); + + SessionStore store = TestSessionFactory.Store(dir); + Assert.Null(await store.LoadAsync("tenant-a")); + Assert.True(File.Exists(filePath)); + } + finally + { + TestSessionFactory.Cleanup(dir); + } + } + + /// + /// Store: изоляция тенантов — файлы разных тенантов не пересекаются. + /// + [Fact] + public async Task Store_TenantIsolation_SeparateFiles() + { + string dir = TestSessionFactory.NewSessionsDirectory(); + try + { + SessionStore store = TestSessionFactory.Store(dir); + + await store.SaveAsync("tenant-a", NewStoredSession(apiId: 111, marker: [1])); + await store.SaveAsync("tenant-b", NewStoredSession(apiId: 222, marker: [2])); + + Assert.Equal(111, (await store.LoadAsync("tenant-a"))?.ApiId); + Assert.Equal(222, (await store.LoadAsync("tenant-b"))?.ApiId); + } + finally + { + TestSessionFactory.Cleanup(dir); + } + } + + /// + /// Store: Delete удаляет файл; повторный Delete не бросает. + /// + [Fact] + public async Task Store_Delete_RemovesFile() + { + string dir = TestSessionFactory.NewSessionsDirectory(); + try + { + SessionStore store = TestSessionFactory.Store(dir); + await store.SaveAsync("tenant-a", NewStoredSession()); + + await store.DeleteAsync("tenant-a"); + Assert.False(File.Exists(Path.Combine(dir, "tenant-a.session"))); + Assert.Null(await store.LoadAsync("tenant-a")); + + await store.DeleteAsync("tenant-a"); + } + finally + { + TestSessionFactory.Cleanup(dir); + } + } + + /// + /// Store: ListTenantIds перечисляет только *.session текущего каталога + /// + [Fact] + public async Task Store_ListTenantIds_ReturnsSessionFilesOnly() + { + string dir = TestSessionFactory.NewSessionsDirectory(); + try + { + Directory.CreateDirectory(dir); + SessionStore store = TestSessionFactory.Store(dir); + await store.SaveAsync("tenant-a", NewStoredSession()); + await store.SaveAsync("tenant-b", NewStoredSession()); + await File.WriteAllTextAsync(Path.Combine(dir, "notes.txt"), "не сессия"); + Directory.CreateDirectory(Path.Combine(dir, "sub")); + await File.WriteAllTextAsync(Path.Combine(dir, "sub", "tenant-c.session"), "не наш файл"); + + string[] tenants = store.ListTenantIds().OrderBy(id => id).ToArray(); + + Assert.Equal(["tenant-a", "tenant-b"], tenants); + } + finally + { + TestSessionFactory.Cleanup(dir); + } + } + + /// + /// Store: некорректный tenant-id + /// + [Fact] + public async Task Store_InvalidTenantId_ThrowsSessionException() + { + string dir = TestSessionFactory.NewSessionsDirectory(); + try + { + SessionStore store = TestSessionFactory.Store(dir); + + SessionException exception = await Assert.ThrowsAsync(() => store.SaveAsync("../escape", NewStoredSession())); + + Assert.Equal(StatusCode.InvalidArgument, exception.Code); + Assert.Equal(SessionErrorMessages.InvalidTenantId, exception.Message); + } + finally + { + TestSessionFactory.Cleanup(dir); + } + } + + // Образец StoredSession с настраиваемыми apiId/маркером сессии. + // apiId: api_id. + // marker: Байты сессии. + private static StoredSession NewStoredSession(int? apiId = null, byte[]? marker = null) + => new() + { + ApiId = apiId ?? ApiId, + ApiHash = ApiHash, + SessionBytes = marker ?? SessionBytes, + }; +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Support/TenantSessionTests.cs b/src/telegram-service/Deal.Telegram.Tests/Support/TenantSessionTests.cs index d34aa4a..58bc025 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Support/TenantSessionTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Support/TenantSessionTests.cs @@ -1,533 +1,533 @@ -using System.Diagnostics; -using Deal.Telegram.Sessions; -using Deal.Telegram.Tests.Telegram; -using Grpc.Core; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Telegram.Tests.Support; - -/// -/// Тесты фазовой машины сессии тенанта. -/// -public sealed class TenantSessionTests -{ - private const string TenantId = "tenant-unit"; - private const int ApiId = 12345; - private const string ApiHash = "0123456789abcdef0123456789abcdef"; - private const string Phone = "+79990001122"; - private const string Code = "11111"; - private const int PollTimeoutMilliseconds = 5000; - - // Таймаут попытки переподключения в тестах (короткий, без реальных 10 с). - private static readonly TimeSpan ReconnectTestTimeout = TimeSpan.FromMilliseconds(200); - - /// - /// StartPhone без ключей приложения → INVALID_ARGUMENT «Сначала сохраните…». - /// - [Fact] - public async Task StartPhone_NoApiKeys_ThrowsNoApiKeys() - { - await using SessionContext ctx = await SessionContext.CreateAsync(); - try - { - SessionException exception = await Assert.ThrowsAsync( - () => ctx.Session.StartPhoneAsync(0, string.Empty, Phone, CancellationToken.None)); - - Assert.Equal(StatusCode.InvalidArgument, exception.Code); - Assert.Equal(SessionErrorMessages.NoApiKeys, exception.Message); - } - finally - { - ctx.Cleanup(); - } - } - - /// - /// StartPhone: успех → фаза "code", код запрошен у клиента; файл сессии ещё не создан. - /// - [Fact] - public async Task StartPhone_Success_PhaseCode_AndCodeRequested() - { - await using SessionContext ctx = await SessionContext.CreateAsync(); - try - { - TenantSessionSnapshot snapshot = await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None); - - Assert.Equal(AuthPhase.Code, snapshot.Phase); - Assert.Equal([Phone], ctx.Factory.CreatedClients.Single().RequestedPhones); - Assert.Null(await ctx.Store.LoadAsync(TenantId, CancellationToken.None)); - } - finally - { - ctx.Cleanup(); - } - } - - /// - /// SendCode без начатой сессии → FAILED_PRECONDITION «Telegram не подключён». - /// - [Fact] - public async Task SendCode_WithoutSession_ThrowsNotConnected() - { - await using SessionContext ctx = await SessionContext.CreateAsync(); - try - { - SessionException exception = await Assert.ThrowsAsync( - () => ctx.Session.SendCodeAsync("11111", CancellationToken.None)); - - Assert.Equal(StatusCode.FailedPrecondition, exception.Code); - Assert.Equal(SessionErrorMessages.NotConnected, exception.Message); - } - finally - { - ctx.Cleanup(); - } - } - - /// - /// SendPassword вне фазы "password" → FAILED_PRECONDITION - /// - [Fact] - public async Task SendPassword_WhenPhaseIsCode_ThrowsPasswordNotRequested() - { - await using SessionContext ctx = await SessionContext.CreateAsync(); - try - { - await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None); - - SessionException exception = await Assert.ThrowsAsync( - () => ctx.Session.SendPasswordAsync("secret", CancellationToken.None)); - - Assert.Equal(StatusCode.FailedPrecondition, exception.Code); - Assert.Equal(SessionErrorMessages.PasswordNotRequested, exception.Message); - } - finally - { - ctx.Cleanup(); - } - } - - /// - /// SendCode с неверным кодом → INVALID_ARGUMENT «Неверный код», фаза остаётся "code" - /// - [Fact] - public async Task SendCode_InvalidCode_KeepsCodePhase_WithError() - { - await using SessionContext ctx = await SessionContext.CreateAsync(); - ctx.ConfigureClient(client => client.CodeError = new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.WrongCode)); - try - { - await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None); - - SessionException exception = await Assert.ThrowsAsync( - () => ctx.Session.SendCodeAsync("00000", CancellationToken.None)); - - Assert.Equal(StatusCode.InvalidArgument, exception.Code); - Assert.Equal(SessionErrorMessages.WrongCode, exception.Message); - - TenantSessionSnapshot? snapshot = await ctx.Session.GetSnapshotAsync(CancellationToken.None); - Assert.Equal(AuthPhase.Code, snapshot?.Phase); - Assert.Equal(SessionErrorMessages.WrongCode, snapshot?.Error); - } - finally - { - ctx.Cleanup(); - } - } - - /// - /// SendCode с истёкшим кодом → INVALID_ARGUMENT «Код истёк — запросите новый». - /// - [Fact] - public async Task SendCode_ExpiredCode_ThrowsCodeExpired() - { - await using SessionContext ctx = await SessionContext.CreateAsync(); - ctx.ConfigureClient(client => client.CodeError = new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.CodeExpired)); - try - { - await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None); - - SessionException exception = await Assert.ThrowsAsync( - () => ctx.Session.SendCodeAsync("99999", CancellationToken.None)); - - Assert.Equal(SessionErrorMessages.CodeExpired, exception.Message); - } - finally - { - ctx.Cleanup(); - } - } - - /// - /// 2FA: верный код → фаза "password"; верный пароль → "ready" + файл сессии сохранён. - /// - [Fact] - public async Task SendCode_ThenPassword_Ready_AndSessionPersisted() - { - await using SessionContext ctx = await SessionContext.CreateAsync(); - ctx.ConfigureClient(client => client.CodeResult = "password"); - try - { - await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None); - TenantSessionSnapshot codeSnapshot = await ctx.Session.SendCodeAsync("33333", CancellationToken.None); - Assert.Equal(AuthPhase.Password, codeSnapshot.Phase); - - TenantSessionSnapshot readySnapshot = await ctx.Session.SendPasswordAsync("cloud-pass", CancellationToken.None); - - Assert.Equal(AuthPhase.Ready, readySnapshot.Phase); - Assert.Equal("@fake_user", readySnapshot.Account); - - StoredSession? stored = await ctx.Store.LoadAsync(TenantId, CancellationToken.None); - Assert.NotNull(stored); - Assert.Equal(ApiId, stored.ApiId); - Assert.Equal(ApiHash, stored.ApiHash); - Assert.Equal(FakeSessionClient.SessionMarker, stored.SessionBytes); - Assert.True(File.Exists(Path.Combine(ctx.Directory, TenantId + ".session"))); - } - finally - { - ctx.Cleanup(); - } - } - - /// - /// QR: StartQr → фаза "qr" + URL; после сканирования — "ready" и сессия сохранена. - /// - [Fact] - public async Task StartQr_ReturnsUrl_ThenScan_ReadyAndPersisted() - { - await using SessionContext ctx = await SessionContext.CreateAsync(); - try - { - TenantSessionSnapshot snapshot = await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None); - - Assert.Equal(AuthPhase.Qr, snapshot.Phase); - Assert.Equal(FakeSessionClient.DefaultQrUrl, snapshot.QrUrl); - Assert.True(ctx.Factory.CreatedClients.Single().QrStarted); - - ctx.Factory.CreatedClients.Single().CompleteQrScan(); - - TenantSessionSnapshot? ready = await WaitUntilReadyAsync(ctx); - Assert.Equal(AuthPhase.Ready, ready?.Phase); - Assert.Equal("@fake_user", ready?.Account); - Assert.NotNull(await ctx.Store.LoadAsync(TenantId, CancellationToken.None)); - } - finally - { - ctx.Cleanup(); - } - } - - /// - /// Повторный StartQr во время активного QR возвращает тот же URL без нового клиента. - /// - [Fact] - public async Task StartQr_TwiceWhileActive_ReturnsSameUrl_ReusesClient() - { - await using SessionContext ctx = await SessionContext.CreateAsync(); - try - { - await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None); - TenantSessionSnapshot second = await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None); - - Assert.Equal(AuthPhase.Qr, second.Phase); - Assert.Equal(FakeSessionClient.DefaultQrUrl, second.QrUrl); - Assert.Single(ctx.Factory.CreatedClients); - } - finally - { - ctx.Cleanup(); - } - } - - /// - /// TryResumeAsync - /// - [Fact] - public async Task Resume_StoredAuthorizedSession_BecomesReady() - { - await using SessionContext ctx = await SessionContext.CreateAsync(); - try - { - var stored = new StoredSession - { - ApiId = ApiId, - ApiHash = ApiHash, - SessionBytes = FakeSessionClient.SessionMarker, - }; - await ctx.Store.SaveAsync(TenantId, stored, CancellationToken.None); - - bool resumed = await ctx.Session.TryResumeAsync(stored, CancellationToken.None); - Assert.True(resumed); - - TenantSessionSnapshot? snapshot = await ctx.Session.GetSnapshotAsync(CancellationToken.None); - Assert.Equal(AuthPhase.Ready, snapshot?.Phase); - Assert.Equal("@fake_user", snapshot?.Account); - } - finally - { - ctx.Cleanup(); - } - } - - /// - /// StartQr на уже авторизованной - /// - [Fact] - public async Task StartQr_WhenAlreadyAuthorized_ReturnsReady_NoUrl() - { - await using SessionContext ctx = await SessionContext.CreateAsync(); - try - { - var stored = new StoredSession { ApiId = ApiId, ApiHash = ApiHash, SessionBytes = FakeSessionClient.SessionMarker }; - await ctx.Store.SaveAsync(TenantId, stored, CancellationToken.None); - await ctx.Session.TryResumeAsync(stored, CancellationToken.None); - - TenantSessionSnapshot snapshot = await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None); - - Assert.Equal(AuthPhase.Ready, snapshot.Phase); - Assert.Null(snapshot.QrUrl); - } - finally - { - ctx.Cleanup(); - } - } - - /// - /// Logout: фаза idle, сессия удалена, файл стёрт; последующие команды — «Telegram не подключён». - /// - [Fact] - public async Task Logout_RemovesSessionAndFile_ThenNotConnected() - { - await using SessionContext ctx = await SessionContext.CreateAsync(); - ctx.ConfigureClient(client => client.CodeResult = "password"); - try - { - await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None); - await ctx.Session.SendCodeAsync("33333", CancellationToken.None); - await ctx.Session.SendPasswordAsync("cloud-pass", CancellationToken.None); - Assert.NotNull(await ctx.Store.LoadAsync(TenantId, CancellationToken.None)); - FakeSessionClient client = ctx.Factory.CreatedClients.Single(); - - TenantSessionSnapshot? afterLogout = await ctx.Session.LogoutAsync(CancellationToken.None); - - Assert.Null(afterLogout); - Assert.True(client.LoggedOut); - Assert.Null(await ctx.Session.GetSnapshotAsync(CancellationToken.None)); - Assert.Null(await ctx.Store.LoadAsync(TenantId, CancellationToken.None)); - Assert.False(File.Exists(Path.Combine(ctx.Directory, TenantId + ".session"))); - - SessionException exception = await Assert.ThrowsAsync( - () => ctx.Session.SendCodeAsync("11111", CancellationToken.None)); - Assert.Equal(StatusCode.FailedPrecondition, exception.Code); - Assert.Equal(SessionErrorMessages.NotConnected, exception.Message); - } - finally - { - ctx.Cleanup(); - } - } - - /// - /// QR-отмены (переключение на phone-вход) не ломают новую фазу - /// - [Fact] - public async Task StartPhone_AfterActiveQr_SwitchesToCodePhase() - { - await using SessionContext ctx = await SessionContext.CreateAsync(); - try - { - await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None); - - TenantSessionSnapshot phoneSnapshot = await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None); - - Assert.Equal(AuthPhase.Code, phoneSnapshot.Phase); - } - finally - { - ctx.Cleanup(); - } - } - - /// - /// Отмена RPC StartQr до первого URL отменяет и фоновый QR-вход - /// - [Fact] - public async Task StartQr_CancelledBeforeFirstUrl_CancelsBackgroundQrLogin() - { - await using SessionContext ctx = await SessionContext.CreateAsync(); - try - { - // Первый URL не выдаётся, пока не случится сканирование/отмена (как в реальном QR-флоу). - ctx.ConfigureClient(client => client.DelayFirstQrUrl = true); - using var rpcCancellation = new CancellationTokenSource(); - - Task startQr = ctx.Session.StartQrAsync(ApiId, ApiHash, rpcCancellation.Token); - await WaitUntilQrStartedAsync(ctx); - - rpcCancellation.Cancel(); - await Assert.ThrowsAnyAsync(() => startQr); - - // Фоновая задача QR остановлена отменой, а не продолжает вход «скрыто». - Assert.True(await WaitUntilQrCancelledAsync(ctx), "фоновый QR-вход не отменён"); - TenantSessionSnapshot? snapshot = await ctx.Session.GetSnapshotAsync(CancellationToken.None); - Assert.Equal(AuthPhase.Idle, snapshot?.Phase); - } - finally - { - ctx.Cleanup(); - } - } - - /// - /// Heartbeat-переподключение ограничено собственным таймаутом - /// - [Fact] - public async Task TryReconnect_HangingConnect_TimesOutByAttemptTimeout() - { - await using SessionContext ctx = await SessionContext.CreateAsync(reconnectTimeout: ReconnectTestTimeout); - try - { - // Готовая сессия (StartPhone → SendCode), затем «обрыв соединения». - await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None); - await ctx.Session.SendCodeAsync(Code, CancellationToken.None); - FakeSessionClient client = ctx.Factory.CreatedClients.Single(); - await client.DisposeAsync(); - client.ConnectGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - var stopwatch = Stopwatch.StartNew(); - await ctx.Session.TryReconnectAsync(CancellationToken.None); - stopwatch.Stop(); - - Assert.True(client.ConnectAttempts >= 1, "connect не вызывался"); - Assert.False(client.ConnectGate.Task.IsCompleted, "connect завершился — ожидался «зависший» вызов"); - Assert.True( - stopwatch.Elapsed >= ReconnectTestTimeout - TimeSpan.FromMilliseconds(50), - $"попытка вернулась слишком рано: {stopwatch.Elapsed}"); - } - finally - { - ctx.Cleanup(); - } - } - - // Поллинг снимка до готовности (QR-сканирование обрабатывается фоновой задачей). - // ctx: Контекст сессии. - private static async Task WaitUntilReadyAsync(SessionContext ctx) - { - var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds); - while (DateTime.UtcNow < deadline) - { - TenantSessionSnapshot? snapshot = await ctx.Session.GetSnapshotAsync(CancellationToken.None); - if (snapshot?.Phase == AuthPhase.Ready) - { - return snapshot; - } - - await Task.Delay(25); - } - - return await ctx.Session.GetSnapshotAsync(CancellationToken.None); - } - - // Ждёт старта фонового QR-входа (клиент создан и ждёт первого URL/сканирования). - // ctx: Контекст сессии. - private static async Task WaitUntilQrStartedAsync(SessionContext ctx) - { - var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds); - while (DateTime.UtcNow < deadline) - { - if (ctx.Factory.CreatedClients.Count > 0 && ctx.Factory.CreatedClients[0].QrStarted) - { - return; - } - - await Task.Delay(10); - } - - Assert.Fail("QR-вход не стартовал за отведённое время"); - } - - // Ждёт остановки фонового QR-входа отменой (см. FakeSessionClient.QrLoginCancelled). - // ctx: Контекст сессии. - private static async Task WaitUntilQrCancelledAsync(SessionContext ctx) - { - var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds); - while (DateTime.UtcNow < deadline) - { - if (ctx.Factory.CreatedClients.Count > 0 && ctx.Factory.CreatedClients[0].QrLoginCancelled) - { - return true; - } - - await Task.Delay(10); - } - - return false; - } - - // Контекст одного теста: реальное хранилище в temp-каталоге + фейковая фабрика и сессия тенанта. - private sealed class SessionContext : IAsyncDisposable - { - private readonly FakeClientFactory _factory; - - private SessionContext( - string directory, - SessionStore store, - TenantSession session, - FakeClientFactory factory) - { - Directory = directory; - Store = store; - Session = session; - _factory = factory; - } - - public string Directory { get; } - - public SessionStore Store { get; } - - public TenantSession Session { get; } - - public FakeClientFactory Factory => _factory; - - /// - /// Создаёт контекст - /// - /// Таймаут попытки переподключения сессии (null — значение по умолчанию). - public static Task CreateAsync(TimeSpan? reconnectTimeout = null) - { - string dir = TestSessionFactory.NewSessionsDirectory(); - var factory = new FakeClientFactory(); - SessionStore store = TestSessionFactory.Store(dir); - var session = new TenantSession( - TenantId, - factory, - store, - NullLogger.Instance, - reconnectTimeout); - return Task.FromResult(new SessionContext(dir, store, session, factory)); - } - - /// - /// Настраивает клиента, который создаст фабрика при первом обращении сессии. - /// - /// Колбэк настройки FakeSessionClient. - public void ConfigureClient(Action configure) - => _factory.OnClientCreated = configure; - - /// - /// Удаляет temp-каталог. - /// - public void Cleanup() - => TestSessionFactory.Cleanup(Directory); - - /// - public ValueTask DisposeAsync() - { - Cleanup(); - return ValueTask.CompletedTask; - } - } -} +using System.Diagnostics; +using Deal.Telegram.Sessions; +using Deal.Telegram.Tests.Telegram; +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Telegram.Tests.Support; + +/// +/// Тесты фазовой машины сессии тенанта. +/// +public sealed class TenantSessionTests +{ + private const string TenantId = "tenant-unit"; + private const int ApiId = 12345; + private const string ApiHash = "0123456789abcdef0123456789abcdef"; + private const string Phone = "+79990001122"; + private const string Code = "11111"; + private const int PollTimeoutMilliseconds = 5000; + + // Таймаут попытки переподключения в тестах (короткий, без реальных 10 с). + private static readonly TimeSpan ReconnectTestTimeout = TimeSpan.FromMilliseconds(200); + + /// + /// StartPhone без ключей приложения → INVALID_ARGUMENT «Сначала сохраните…». + /// + [Fact] + public async Task StartPhone_NoApiKeys_ThrowsNoApiKeys() + { + await using SessionContext ctx = await SessionContext.CreateAsync(); + try + { + SessionException exception = await Assert.ThrowsAsync( + () => ctx.Session.StartPhoneAsync(0, string.Empty, Phone, CancellationToken.None)); + + Assert.Equal(StatusCode.InvalidArgument, exception.Code); + Assert.Equal(SessionErrorMessages.NoApiKeys, exception.Message); + } + finally + { + ctx.Cleanup(); + } + } + + /// + /// StartPhone: успех → фаза "code", код запрошен у клиента; файл сессии ещё не создан. + /// + [Fact] + public async Task StartPhone_Success_PhaseCode_AndCodeRequested() + { + await using SessionContext ctx = await SessionContext.CreateAsync(); + try + { + TenantSessionSnapshot snapshot = await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None); + + Assert.Equal(AuthPhase.Code, snapshot.Phase); + Assert.Equal([Phone], ctx.Factory.CreatedClients.Single().RequestedPhones); + Assert.Null(await ctx.Store.LoadAsync(TenantId, CancellationToken.None)); + } + finally + { + ctx.Cleanup(); + } + } + + /// + /// SendCode без начатой сессии → FAILED_PRECONDITION «Telegram не подключён». + /// + [Fact] + public async Task SendCode_WithoutSession_ThrowsNotConnected() + { + await using SessionContext ctx = await SessionContext.CreateAsync(); + try + { + SessionException exception = await Assert.ThrowsAsync( + () => ctx.Session.SendCodeAsync("11111", CancellationToken.None)); + + Assert.Equal(StatusCode.FailedPrecondition, exception.Code); + Assert.Equal(SessionErrorMessages.NotConnected, exception.Message); + } + finally + { + ctx.Cleanup(); + } + } + + /// + /// SendPassword вне фазы "password" → FAILED_PRECONDITION + /// + [Fact] + public async Task SendPassword_WhenPhaseIsCode_ThrowsPasswordNotRequested() + { + await using SessionContext ctx = await SessionContext.CreateAsync(); + try + { + await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None); + + SessionException exception = await Assert.ThrowsAsync( + () => ctx.Session.SendPasswordAsync("secret", CancellationToken.None)); + + Assert.Equal(StatusCode.FailedPrecondition, exception.Code); + Assert.Equal(SessionErrorMessages.PasswordNotRequested, exception.Message); + } + finally + { + ctx.Cleanup(); + } + } + + /// + /// SendCode с неверным кодом → INVALID_ARGUMENT «Неверный код», фаза остаётся "code" + /// + [Fact] + public async Task SendCode_InvalidCode_KeepsCodePhase_WithError() + { + await using SessionContext ctx = await SessionContext.CreateAsync(); + ctx.ConfigureClient(client => client.CodeError = new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.WrongCode)); + try + { + await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None); + + SessionException exception = await Assert.ThrowsAsync( + () => ctx.Session.SendCodeAsync("00000", CancellationToken.None)); + + Assert.Equal(StatusCode.InvalidArgument, exception.Code); + Assert.Equal(SessionErrorMessages.WrongCode, exception.Message); + + TenantSessionSnapshot? snapshot = await ctx.Session.GetSnapshotAsync(CancellationToken.None); + Assert.Equal(AuthPhase.Code, snapshot?.Phase); + Assert.Equal(SessionErrorMessages.WrongCode, snapshot?.Error); + } + finally + { + ctx.Cleanup(); + } + } + + /// + /// SendCode с истёкшим кодом → INVALID_ARGUMENT «Код истёк — запросите новый». + /// + [Fact] + public async Task SendCode_ExpiredCode_ThrowsCodeExpired() + { + await using SessionContext ctx = await SessionContext.CreateAsync(); + ctx.ConfigureClient(client => client.CodeError = new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.CodeExpired)); + try + { + await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None); + + SessionException exception = await Assert.ThrowsAsync( + () => ctx.Session.SendCodeAsync("99999", CancellationToken.None)); + + Assert.Equal(SessionErrorMessages.CodeExpired, exception.Message); + } + finally + { + ctx.Cleanup(); + } + } + + /// + /// 2FA: верный код → фаза "password"; верный пароль → "ready" + файл сессии сохранён. + /// + [Fact] + public async Task SendCode_ThenPassword_Ready_AndSessionPersisted() + { + await using SessionContext ctx = await SessionContext.CreateAsync(); + ctx.ConfigureClient(client => client.CodeResult = "password"); + try + { + await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None); + TenantSessionSnapshot codeSnapshot = await ctx.Session.SendCodeAsync("33333", CancellationToken.None); + Assert.Equal(AuthPhase.Password, codeSnapshot.Phase); + + TenantSessionSnapshot readySnapshot = await ctx.Session.SendPasswordAsync("cloud-pass", CancellationToken.None); + + Assert.Equal(AuthPhase.Ready, readySnapshot.Phase); + Assert.Equal("@fake_user", readySnapshot.Account); + + StoredSession? stored = await ctx.Store.LoadAsync(TenantId, CancellationToken.None); + Assert.NotNull(stored); + Assert.Equal(ApiId, stored.ApiId); + Assert.Equal(ApiHash, stored.ApiHash); + Assert.Equal(FakeSessionClient.SessionMarker, stored.SessionBytes); + Assert.True(File.Exists(Path.Combine(ctx.Directory, TenantId + ".session"))); + } + finally + { + ctx.Cleanup(); + } + } + + /// + /// QR: StartQr → фаза "qr" + URL; после сканирования — "ready" и сессия сохранена. + /// + [Fact] + public async Task StartQr_ReturnsUrl_ThenScan_ReadyAndPersisted() + { + await using SessionContext ctx = await SessionContext.CreateAsync(); + try + { + TenantSessionSnapshot snapshot = await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None); + + Assert.Equal(AuthPhase.Qr, snapshot.Phase); + Assert.Equal(FakeSessionClient.DefaultQrUrl, snapshot.QrUrl); + Assert.True(ctx.Factory.CreatedClients.Single().QrStarted); + + ctx.Factory.CreatedClients.Single().CompleteQrScan(); + + TenantSessionSnapshot? ready = await WaitUntilReadyAsync(ctx); + Assert.Equal(AuthPhase.Ready, ready?.Phase); + Assert.Equal("@fake_user", ready?.Account); + Assert.NotNull(await ctx.Store.LoadAsync(TenantId, CancellationToken.None)); + } + finally + { + ctx.Cleanup(); + } + } + + /// + /// Повторный StartQr во время активного QR возвращает тот же URL без нового клиента. + /// + [Fact] + public async Task StartQr_TwiceWhileActive_ReturnsSameUrl_ReusesClient() + { + await using SessionContext ctx = await SessionContext.CreateAsync(); + try + { + await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None); + TenantSessionSnapshot second = await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None); + + Assert.Equal(AuthPhase.Qr, second.Phase); + Assert.Equal(FakeSessionClient.DefaultQrUrl, second.QrUrl); + Assert.Single(ctx.Factory.CreatedClients); + } + finally + { + ctx.Cleanup(); + } + } + + /// + /// TryResumeAsync + /// + [Fact] + public async Task Resume_StoredAuthorizedSession_BecomesReady() + { + await using SessionContext ctx = await SessionContext.CreateAsync(); + try + { + var stored = new StoredSession + { + ApiId = ApiId, + ApiHash = ApiHash, + SessionBytes = FakeSessionClient.SessionMarker, + }; + await ctx.Store.SaveAsync(TenantId, stored, CancellationToken.None); + + bool resumed = await ctx.Session.TryResumeAsync(stored, CancellationToken.None); + Assert.True(resumed); + + TenantSessionSnapshot? snapshot = await ctx.Session.GetSnapshotAsync(CancellationToken.None); + Assert.Equal(AuthPhase.Ready, snapshot?.Phase); + Assert.Equal("@fake_user", snapshot?.Account); + } + finally + { + ctx.Cleanup(); + } + } + + /// + /// StartQr на уже авторизованной + /// + [Fact] + public async Task StartQr_WhenAlreadyAuthorized_ReturnsReady_NoUrl() + { + await using SessionContext ctx = await SessionContext.CreateAsync(); + try + { + var stored = new StoredSession { ApiId = ApiId, ApiHash = ApiHash, SessionBytes = FakeSessionClient.SessionMarker }; + await ctx.Store.SaveAsync(TenantId, stored, CancellationToken.None); + await ctx.Session.TryResumeAsync(stored, CancellationToken.None); + + TenantSessionSnapshot snapshot = await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None); + + Assert.Equal(AuthPhase.Ready, snapshot.Phase); + Assert.Null(snapshot.QrUrl); + } + finally + { + ctx.Cleanup(); + } + } + + /// + /// Logout: фаза idle, сессия удалена, файл стёрт; последующие команды — «Telegram не подключён». + /// + [Fact] + public async Task Logout_RemovesSessionAndFile_ThenNotConnected() + { + await using SessionContext ctx = await SessionContext.CreateAsync(); + ctx.ConfigureClient(client => client.CodeResult = "password"); + try + { + await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None); + await ctx.Session.SendCodeAsync("33333", CancellationToken.None); + await ctx.Session.SendPasswordAsync("cloud-pass", CancellationToken.None); + Assert.NotNull(await ctx.Store.LoadAsync(TenantId, CancellationToken.None)); + FakeSessionClient client = ctx.Factory.CreatedClients.Single(); + + TenantSessionSnapshot? afterLogout = await ctx.Session.LogoutAsync(CancellationToken.None); + + Assert.Null(afterLogout); + Assert.True(client.LoggedOut); + Assert.Null(await ctx.Session.GetSnapshotAsync(CancellationToken.None)); + Assert.Null(await ctx.Store.LoadAsync(TenantId, CancellationToken.None)); + Assert.False(File.Exists(Path.Combine(ctx.Directory, TenantId + ".session"))); + + SessionException exception = await Assert.ThrowsAsync( + () => ctx.Session.SendCodeAsync("11111", CancellationToken.None)); + Assert.Equal(StatusCode.FailedPrecondition, exception.Code); + Assert.Equal(SessionErrorMessages.NotConnected, exception.Message); + } + finally + { + ctx.Cleanup(); + } + } + + /// + /// QR-отмены (переключение на phone-вход) не ломают новую фазу + /// + [Fact] + public async Task StartPhone_AfterActiveQr_SwitchesToCodePhase() + { + await using SessionContext ctx = await SessionContext.CreateAsync(); + try + { + await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None); + + TenantSessionSnapshot phoneSnapshot = await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None); + + Assert.Equal(AuthPhase.Code, phoneSnapshot.Phase); + } + finally + { + ctx.Cleanup(); + } + } + + /// + /// Отмена RPC StartQr до первого URL отменяет и фоновый QR-вход + /// + [Fact] + public async Task StartQr_CancelledBeforeFirstUrl_CancelsBackgroundQrLogin() + { + await using SessionContext ctx = await SessionContext.CreateAsync(); + try + { + // Первый URL не выдаётся, пока не случится сканирование/отмена (как в реальном QR-флоу). + ctx.ConfigureClient(client => client.DelayFirstQrUrl = true); + using var rpcCancellation = new CancellationTokenSource(); + + Task startQr = ctx.Session.StartQrAsync(ApiId, ApiHash, rpcCancellation.Token); + await WaitUntilQrStartedAsync(ctx); + + rpcCancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => startQr); + + // Фоновая задача QR остановлена отменой, а не продолжает вход «скрыто». + Assert.True(await WaitUntilQrCancelledAsync(ctx), "фоновый QR-вход не отменён"); + TenantSessionSnapshot? snapshot = await ctx.Session.GetSnapshotAsync(CancellationToken.None); + Assert.Equal(AuthPhase.Idle, snapshot?.Phase); + } + finally + { + ctx.Cleanup(); + } + } + + /// + /// Heartbeat-переподключение ограничено собственным таймаутом + /// + [Fact] + public async Task TryReconnect_HangingConnect_TimesOutByAttemptTimeout() + { + await using SessionContext ctx = await SessionContext.CreateAsync(reconnectTimeout: ReconnectTestTimeout); + try + { + // Готовая сессия (StartPhone → SendCode), затем «обрыв соединения». + await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None); + await ctx.Session.SendCodeAsync(Code, CancellationToken.None); + FakeSessionClient client = ctx.Factory.CreatedClients.Single(); + await client.DisposeAsync(); + client.ConnectGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var stopwatch = Stopwatch.StartNew(); + await ctx.Session.TryReconnectAsync(CancellationToken.None); + stopwatch.Stop(); + + Assert.True(client.ConnectAttempts >= 1, "connect не вызывался"); + Assert.False(client.ConnectGate.Task.IsCompleted, "connect завершился — ожидался «зависший» вызов"); + Assert.True( + stopwatch.Elapsed >= ReconnectTestTimeout - TimeSpan.FromMilliseconds(50), + $"попытка вернулась слишком рано: {stopwatch.Elapsed}"); + } + finally + { + ctx.Cleanup(); + } + } + + // Поллинг снимка до готовности (QR-сканирование обрабатывается фоновой задачей). + // ctx: Контекст сессии. + private static async Task WaitUntilReadyAsync(SessionContext ctx) + { + var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds); + while (DateTime.UtcNow < deadline) + { + TenantSessionSnapshot? snapshot = await ctx.Session.GetSnapshotAsync(CancellationToken.None); + if (snapshot?.Phase == AuthPhase.Ready) + { + return snapshot; + } + + await Task.Delay(25); + } + + return await ctx.Session.GetSnapshotAsync(CancellationToken.None); + } + + // Ждёт старта фонового QR-входа (клиент создан и ждёт первого URL/сканирования). + // ctx: Контекст сессии. + private static async Task WaitUntilQrStartedAsync(SessionContext ctx) + { + var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds); + while (DateTime.UtcNow < deadline) + { + if (ctx.Factory.CreatedClients.Count > 0 && ctx.Factory.CreatedClients[0].QrStarted) + { + return; + } + + await Task.Delay(10); + } + + Assert.Fail("QR-вход не стартовал за отведённое время"); + } + + // Ждёт остановки фонового QR-входа отменой (см. FakeSessionClient.QrLoginCancelled). + // ctx: Контекст сессии. + private static async Task WaitUntilQrCancelledAsync(SessionContext ctx) + { + var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds); + while (DateTime.UtcNow < deadline) + { + if (ctx.Factory.CreatedClients.Count > 0 && ctx.Factory.CreatedClients[0].QrLoginCancelled) + { + return true; + } + + await Task.Delay(10); + } + + return false; + } + + // Контекст одного теста: реальное хранилище в temp-каталоге + фейковая фабрика и сессия тенанта. + private sealed class SessionContext : IAsyncDisposable + { + private readonly FakeClientFactory _factory; + + private SessionContext( + string directory, + SessionStore store, + TenantSession session, + FakeClientFactory factory) + { + Directory = directory; + Store = store; + Session = session; + _factory = factory; + } + + public string Directory { get; } + + public SessionStore Store { get; } + + public TenantSession Session { get; } + + public FakeClientFactory Factory => _factory; + + /// + /// Создаёт контекст + /// + /// Таймаут попытки переподключения сессии (null — значение по умолчанию). + public static Task CreateAsync(TimeSpan? reconnectTimeout = null) + { + string dir = TestSessionFactory.NewSessionsDirectory(); + var factory = new FakeClientFactory(); + SessionStore store = TestSessionFactory.Store(dir); + var session = new TenantSession( + TenantId, + factory, + store, + NullLogger.Instance, + reconnectTimeout); + return Task.FromResult(new SessionContext(dir, store, session, factory)); + } + + /// + /// Настраивает клиента, который создаст фабрика при первом обращении сессии. + /// + /// Колбэк настройки FakeSessionClient. + public void ConfigureClient(Action configure) + => _factory.OnClientCreated = configure; + + /// + /// Удаляет temp-каталог. + /// + public void Cleanup() + => TestSessionFactory.Cleanup(Directory); + + /// + public ValueTask DisposeAsync() + { + Cleanup(); + return ValueTask.CompletedTask; + } + } +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/BackfillServiceTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/BackfillServiceTests.cs index e602d4c..c8b18a7 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/BackfillServiceTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/BackfillServiceTests.cs @@ -1,190 +1,190 @@ -using Deal.Telegram.Dialogs; -using Deal.Telegram.Sessions; -using Deal.Telegram.Telegram; -using Deal.Telegram.Tests.Grpc; -using Grpc.Core; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Telegram.Tests.Telegram; - -/// -/// Unit-тесты backfill диалога. -/// -public sealed class BackfillServiceTests -{ - private const string TenantId = "tenant-backfill"; - private const string DialogId = "-1001234567890"; - private const string ChannelName = "IT Канал"; - private const string ChannelHandle = "it_channel"; - - /// - /// Backfill: сообщения уходят от старых к новым с паузами; processed; read-ack вызван. - /// - [Fact] - public async Task Backfill_PushesOldestFirst_WithPauses_AndMarksRead() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - harness.Client.Messages[DialogId] = NewestFirstMessages(); - var ingress = new FakeIngress(); - var pacer = new RecordingPacer(); - BackfillService backfill = CreateService(harness.Farm, ingress, pacer); - - int processed = await backfill.ExecuteAsync(TenantId, DialogId, force: false, CancellationToken.None); - - Assert.Equal(3, processed); - // От старых к новым (в fake список хранится от новых к старым, как get_messages). - Assert.Equal(["28", "29", "30"], ingress.Pushes.Select(push => push.Request.Source.ExternalId)); - Assert.Equal(TenantId, ingress.Pushes[0].TenantId); - Assert.Equal(DialogId, ingress.Pushes[0].Request.Source.OriginRef); - Assert.Equal(ChannelName, ingress.Pushes[0].Request.Source.DisplayName); - Assert.Equal(DialogHue.Compute(DialogId, ChannelName), ingress.Pushes[0].Request.Source.Extra["hue"]); - Assert.Equal("новое сообщение", ingress.Pushes[2].Request.Content.Text); - Assert.Equal(3, pacer.Waits.Count); - Assert.All(pacer.Waits, wait => - { - Assert.InRange(wait.MinSeconds, 1.5, 1.5 + 0.001); - Assert.InRange(wait.MaxSeconds, 3.0, 3.0 + 0.001); - }); - // «Перечитали» — сняли «новое» в Telegram. - Assert.Equal([DialogId], harness.Client.MarkedReadDialogs); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// Backfill пустого диалога - /// - [Fact] - public async Task Backfill_NoMessages_ProcessedZero_StillMarksRead() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - var ingress = new FakeIngress(); - var pacer = new RecordingPacer(); - BackfillService backfill = CreateService(harness.Farm, ingress, pacer); - - int processed = await backfill.ExecuteAsync(TenantId, DialogId, force: true, CancellationToken.None); - - Assert.Equal(0, processed); - Assert.Empty(ingress.Pushes); - Assert.Empty(pacer.Waits); - Assert.Equal([DialogId], harness.Client.MarkedReadDialogs); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// Второй backfill тенанта - /// - [Fact] - public async Task Backfill_SecondDialog_WaitsDialogSpacing() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - harness.Client.Messages[DialogId] = NewestFirstMessages(); - harness.Client.Messages["-10042"] = [Message("-10042", 7, "ещё одно")]; - var ingress = new FakeIngress(); - var pacer = new RecordingPacer(); - BackfillService backfill = CreateService(harness.Farm, ingress, pacer); - - await backfill.ExecuteAsync(TenantId, DialogId, force: false, CancellationToken.None); - pacer.Waits.Clear(); - await backfill.ExecuteAsync(TenantId, "-10042", force: false, CancellationToken.None); - - // Первая пауза второго backfill — меж-диалоговая (3–6 с), затем 1.5–3 с за сообщение. - Assert.Equal(2, pacer.Waits.Count); - Assert.InRange(pacer.Waits[0].MinSeconds, 2.9, 3.1); - Assert.InRange(pacer.Waits[0].MaxSeconds, 5.9, 6.1); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// Сбой PushSource прерывает backfill без read-ack - /// - [Fact] - public async Task Backfill_PushFails_Throws_WithoutMarkRead() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - harness.Client.Messages[DialogId] = NewestFirstMessages(); - var ingress = new FakeIngress { PushError = new SessionException(StatusCode.Unavailable, SessionErrorMessages.IngressUnavailable) }; - BackfillService backfill = CreateService(harness.Farm, ingress, new RecordingPacer()); - - await Assert.ThrowsAsync( - () => backfill.ExecuteAsync(TenantId, DialogId, force: false, CancellationToken.None)); - - Assert.Empty(harness.Client.MarkedReadDialogs); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// Backfill без готовой сессии тенанта → «Telegram не подключён» - /// - [Fact] - public async Task Backfill_NoReadySession_FailedPrecondition() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - BackfillService backfill = CreateService(harness.Farm, new FakeIngress(), new RecordingPacer()); - - SessionException exception = await Assert.ThrowsAsync( - () => backfill.ExecuteAsync("tenant-without-session", DialogId, force: false, CancellationToken.None)); - - Assert.Equal(StatusCode.FailedPrecondition, exception.Code); - Assert.Equal(SessionErrorMessages.NotConnected, exception.Message); - } - finally - { - await harness.DisposeAsync(); - } - } - - // Сообщения диалога от новых к старым (как get_messages) для сценариев backfill. - private static List NewestFirstMessages() - => - [ - Message(DialogId, id: 30, text: "новое сообщение"), - Message(DialogId, id: 29, text: "среднее сообщение"), - Message(DialogId, id: 28, text: "старое сообщение"), - ]; - - // Собирает нейтральное сообщение канала сценария. - // dialogId: Id диалога. - // id: Id сообщения. - // text: Текст. - private static TelegramMessage Message( - string dialogId, - int id, - string text) - => new(dialogId, id, text, dateMs: 1_700_000_000_000 + id, ChannelName, ChannelHandle); - - // Служба backfill на пуле, фейк-канале в ядро и фейк-пейсере. - // farm: Пул сессий. - // ingress: Фейк ингресса. - // pacer: Фейк-пейсер (без реальных задержек). - private static BackfillService CreateService( - SessionFarm farm, - FakeIngress ingress, - IBackfillPacer pacer) - => new(farm, ingress, pacer, NullLogger.Instance); -} +using Deal.Telegram.Dialogs; +using Deal.Telegram.Sessions; +using Deal.Telegram.Telegram; +using Deal.Telegram.Tests.Grpc; +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Telegram.Tests.Telegram; + +/// +/// Unit-тесты backfill диалога. +/// +public sealed class BackfillServiceTests +{ + private const string TenantId = "tenant-backfill"; + private const string DialogId = "-1001234567890"; + private const string ChannelName = "IT Канал"; + private const string ChannelHandle = "it_channel"; + + /// + /// Backfill: сообщения уходят от старых к новым с паузами; processed; read-ack вызван. + /// + [Fact] + public async Task Backfill_PushesOldestFirst_WithPauses_AndMarksRead() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + harness.Client.Messages[DialogId] = NewestFirstMessages(); + var ingress = new FakeIngress(); + var pacer = new RecordingPacer(); + BackfillService backfill = CreateService(harness.Farm, ingress, pacer); + + int processed = await backfill.ExecuteAsync(TenantId, DialogId, force: false, CancellationToken.None); + + Assert.Equal(3, processed); + // От старых к новым (в fake список хранится от новых к старым, как get_messages). + Assert.Equal(["28", "29", "30"], ingress.Pushes.Select(push => push.Request.Source.ExternalId)); + Assert.Equal(TenantId, ingress.Pushes[0].TenantId); + Assert.Equal(DialogId, ingress.Pushes[0].Request.Source.OriginRef); + Assert.Equal(ChannelName, ingress.Pushes[0].Request.Source.DisplayName); + Assert.Equal(DialogHue.Compute(DialogId, ChannelName), ingress.Pushes[0].Request.Source.Extra["hue"]); + Assert.Equal("новое сообщение", ingress.Pushes[2].Request.Content.Text); + Assert.Equal(3, pacer.Waits.Count); + Assert.All(pacer.Waits, wait => + { + Assert.InRange(wait.MinSeconds, 1.5, 1.5 + 0.001); + Assert.InRange(wait.MaxSeconds, 3.0, 3.0 + 0.001); + }); + // «Перечитали» — сняли «новое» в Telegram. + Assert.Equal([DialogId], harness.Client.MarkedReadDialogs); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// Backfill пустого диалога + /// + [Fact] + public async Task Backfill_NoMessages_ProcessedZero_StillMarksRead() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + var ingress = new FakeIngress(); + var pacer = new RecordingPacer(); + BackfillService backfill = CreateService(harness.Farm, ingress, pacer); + + int processed = await backfill.ExecuteAsync(TenantId, DialogId, force: true, CancellationToken.None); + + Assert.Equal(0, processed); + Assert.Empty(ingress.Pushes); + Assert.Empty(pacer.Waits); + Assert.Equal([DialogId], harness.Client.MarkedReadDialogs); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// Второй backfill тенанта + /// + [Fact] + public async Task Backfill_SecondDialog_WaitsDialogSpacing() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + harness.Client.Messages[DialogId] = NewestFirstMessages(); + harness.Client.Messages["-10042"] = [Message("-10042", 7, "ещё одно")]; + var ingress = new FakeIngress(); + var pacer = new RecordingPacer(); + BackfillService backfill = CreateService(harness.Farm, ingress, pacer); + + await backfill.ExecuteAsync(TenantId, DialogId, force: false, CancellationToken.None); + pacer.Waits.Clear(); + await backfill.ExecuteAsync(TenantId, "-10042", force: false, CancellationToken.None); + + // Первая пауза второго backfill — меж-диалоговая (3–6 с), затем 1.5–3 с за сообщение. + Assert.Equal(2, pacer.Waits.Count); + Assert.InRange(pacer.Waits[0].MinSeconds, 2.9, 3.1); + Assert.InRange(pacer.Waits[0].MaxSeconds, 5.9, 6.1); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// Сбой PushSource прерывает backfill без read-ack + /// + [Fact] + public async Task Backfill_PushFails_Throws_WithoutMarkRead() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + harness.Client.Messages[DialogId] = NewestFirstMessages(); + var ingress = new FakeIngress { PushError = new SessionException(StatusCode.Unavailable, SessionErrorMessages.IngressUnavailable) }; + BackfillService backfill = CreateService(harness.Farm, ingress, new RecordingPacer()); + + await Assert.ThrowsAsync( + () => backfill.ExecuteAsync(TenantId, DialogId, force: false, CancellationToken.None)); + + Assert.Empty(harness.Client.MarkedReadDialogs); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// Backfill без готовой сессии тенанта → «Telegram не подключён» + /// + [Fact] + public async Task Backfill_NoReadySession_FailedPrecondition() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + BackfillService backfill = CreateService(harness.Farm, new FakeIngress(), new RecordingPacer()); + + SessionException exception = await Assert.ThrowsAsync( + () => backfill.ExecuteAsync("tenant-without-session", DialogId, force: false, CancellationToken.None)); + + Assert.Equal(StatusCode.FailedPrecondition, exception.Code); + Assert.Equal(SessionErrorMessages.NotConnected, exception.Message); + } + finally + { + await harness.DisposeAsync(); + } + } + + // Сообщения диалога от новых к старым (как get_messages) для сценариев backfill. + private static List NewestFirstMessages() + => + [ + Message(DialogId, id: 30, text: "новое сообщение"), + Message(DialogId, id: 29, text: "среднее сообщение"), + Message(DialogId, id: 28, text: "старое сообщение"), + ]; + + // Собирает нейтральное сообщение канала сценария. + // dialogId: Id диалога. + // id: Id сообщения. + // text: Текст. + private static TelegramMessage Message( + string dialogId, + int id, + string text) + => new(dialogId, id, text, dateMs: 1_700_000_000_000 + id, ChannelName, ChannelHandle); + + // Служба backfill на пуле, фейк-канале в ядро и фейк-пейсере. + // farm: Пул сессий. + // ingress: Фейк ингресса. + // pacer: Фейк-пейсер (без реальных задержек). + private static BackfillService CreateService( + SessionFarm farm, + FakeIngress ingress, + IBackfillPacer pacer) + => new(farm, ingress, pacer, NullLogger.Instance); +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogCatalogTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogCatalogTests.cs index 843e088..8644269 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogCatalogTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogCatalogTests.cs @@ -1,128 +1,128 @@ -using Deal.Telegram.Dialogs; - -namespace Deal.Telegram.Tests.Telegram; - -/// -/// Unit-тесты зеркала каталога/мониторинга диалогов. -/// -public sealed class DialogCatalogTests -{ - private const string TenantA = "tenant-a"; - private const string TenantB = "tenant-b"; - private const string ChannelId = "-1001234567890"; - private const string GroupId = "-456789"; - private const string UserId = "+12345"; - - /// - /// SetMonitored включает/выключает диалог; IsMonitored повторяет решение. - /// - [Fact] - public void SetMonitored_TogglesDialog() - { - var catalog = new DialogCatalog(); - - Assert.False(catalog.IsMonitored(TenantA, ChannelId)); - - catalog.SetMonitored(TenantA, ChannelId, enabled: true); - Assert.True(catalog.IsMonitored(TenantA, ChannelId)); - - catalog.SetMonitored(TenantA, ChannelId, enabled: false); - Assert.False(catalog.IsMonitored(TenantA, ChannelId)); - } - - /// - /// Мониторинг изолирован по тенантам - /// - [Fact] - public void Monitored_IsIsolatedPerTenant() - { - var catalog = new DialogCatalog(); - catalog.SetMonitored(TenantA, ChannelId, enabled: true); - - Assert.True(catalog.IsMonitored(TenantA, ChannelId)); - Assert.False(catalog.IsMonitored(TenantB, ChannelId)); - } - - /// - /// SetMonitorAll(true) мониторит каталог и возвращает его размер; false — снимает всё. - /// - [Fact] - public void SetAllMonitored_EnablesKnownDialogs_AndReturnsCount() - { - var catalog = new DialogCatalog(); - catalog.ReplaceKnown(TenantA, [ChannelId, GroupId, UserId]); - - int count = catalog.SetAllMonitored(TenantA, enabled: true); - - Assert.Equal(3, count); - Assert.True(catalog.IsMonitored(TenantA, ChannelId)); - Assert.True(catalog.IsMonitored(TenantA, GroupId)); - Assert.True(catalog.IsMonitored(TenantA, UserId)); - - catalog.SetAllMonitored(TenantA, enabled: false); - Assert.False(catalog.IsMonitored(TenantA, ChannelId)); - Assert.False(catalog.IsMonitored(TenantA, GroupId)); - Assert.Equal(3, catalog.KnownCount(TenantA)); - } - - /// - /// SetMonitorAll(true) сохраняет мониторинг записей вне каталога - /// - [Fact] - public void SetAllMonitored_TrueKeepsExistingMonitoredOutsideKnown() - { - var catalog = new DialogCatalog(); - catalog.SetMonitored(TenantA, UserId, enabled: true); - - catalog.SetAllMonitored(TenantA, enabled: true); - - Assert.True(catalog.IsMonitored(TenantA, UserId)); - } - - /// - /// Актуализация ответом SyncDialogs - /// - [Fact] - public void ReplaceMonitored_OverridesMirror_WithCoreReply() - { - var catalog = new DialogCatalog(); - catalog.SetMonitored(TenantA, GroupId, enabled: true); - catalog.SetMonitored(TenantA, UserId, enabled: true); - - catalog.ReplaceKnown(TenantA, [ChannelId, GroupId]); - catalog.ReplaceMonitored(TenantA, [GroupId]); - - Assert.False(catalog.IsMonitored(TenantA, UserId)); // ядро сняло мониторинг (нет в каталоге) - Assert.True(catalog.IsMonitored(TenantA, GroupId)); - Assert.False(catalog.IsMonitored(TenantA, ChannelId)); - } - - /// - /// Reset (Logout) очищает каталог и мониторинг тенанта (`.clear`). - /// - [Fact] - public void Reset_ClearsTenantState() - { - var catalog = new DialogCatalog(); - catalog.ReplaceKnown(TenantA, [ChannelId]); - catalog.SetMonitored(TenantA, ChannelId, enabled: true); - - catalog.Reset(TenantA); - - Assert.Equal(0, catalog.KnownCount(TenantA)); - Assert.False(catalog.IsMonitored(TenantA, ChannelId)); - } - - /// - /// Каталог пустого тенанта - /// - [Fact] - public void UnknownTenant_ReportsEmptyState() - { - var catalog = new DialogCatalog(); - - Assert.Equal(0, catalog.KnownCount(TenantB)); - Assert.False(catalog.IsMonitored(TenantB, ChannelId)); - Assert.Empty(catalog.SnapshotMonitored(TenantB)); - } -} +using Deal.Telegram.Dialogs; + +namespace Deal.Telegram.Tests.Telegram; + +/// +/// Unit-тесты зеркала каталога/мониторинга диалогов. +/// +public sealed class DialogCatalogTests +{ + private const string TenantA = "tenant-a"; + private const string TenantB = "tenant-b"; + private const string ChannelId = "-1001234567890"; + private const string GroupId = "-456789"; + private const string UserId = "+12345"; + + /// + /// SetMonitored включает/выключает диалог; IsMonitored повторяет решение. + /// + [Fact] + public void SetMonitored_TogglesDialog() + { + var catalog = new DialogCatalog(); + + Assert.False(catalog.IsMonitored(TenantA, ChannelId)); + + catalog.SetMonitored(TenantA, ChannelId, enabled: true); + Assert.True(catalog.IsMonitored(TenantA, ChannelId)); + + catalog.SetMonitored(TenantA, ChannelId, enabled: false); + Assert.False(catalog.IsMonitored(TenantA, ChannelId)); + } + + /// + /// Мониторинг изолирован по тенантам + /// + [Fact] + public void Monitored_IsIsolatedPerTenant() + { + var catalog = new DialogCatalog(); + catalog.SetMonitored(TenantA, ChannelId, enabled: true); + + Assert.True(catalog.IsMonitored(TenantA, ChannelId)); + Assert.False(catalog.IsMonitored(TenantB, ChannelId)); + } + + /// + /// SetMonitorAll(true) мониторит каталог и возвращает его размер; false — снимает всё. + /// + [Fact] + public void SetAllMonitored_EnablesKnownDialogs_AndReturnsCount() + { + var catalog = new DialogCatalog(); + catalog.ReplaceKnown(TenantA, [ChannelId, GroupId, UserId]); + + int count = catalog.SetAllMonitored(TenantA, enabled: true); + + Assert.Equal(3, count); + Assert.True(catalog.IsMonitored(TenantA, ChannelId)); + Assert.True(catalog.IsMonitored(TenantA, GroupId)); + Assert.True(catalog.IsMonitored(TenantA, UserId)); + + catalog.SetAllMonitored(TenantA, enabled: false); + Assert.False(catalog.IsMonitored(TenantA, ChannelId)); + Assert.False(catalog.IsMonitored(TenantA, GroupId)); + Assert.Equal(3, catalog.KnownCount(TenantA)); + } + + /// + /// SetMonitorAll(true) сохраняет мониторинг записей вне каталога + /// + [Fact] + public void SetAllMonitored_TrueKeepsExistingMonitoredOutsideKnown() + { + var catalog = new DialogCatalog(); + catalog.SetMonitored(TenantA, UserId, enabled: true); + + catalog.SetAllMonitored(TenantA, enabled: true); + + Assert.True(catalog.IsMonitored(TenantA, UserId)); + } + + /// + /// Актуализация ответом SyncDialogs + /// + [Fact] + public void ReplaceMonitored_OverridesMirror_WithCoreReply() + { + var catalog = new DialogCatalog(); + catalog.SetMonitored(TenantA, GroupId, enabled: true); + catalog.SetMonitored(TenantA, UserId, enabled: true); + + catalog.ReplaceKnown(TenantA, [ChannelId, GroupId]); + catalog.ReplaceMonitored(TenantA, [GroupId]); + + Assert.False(catalog.IsMonitored(TenantA, UserId)); // ядро сняло мониторинг (нет в каталоге) + Assert.True(catalog.IsMonitored(TenantA, GroupId)); + Assert.False(catalog.IsMonitored(TenantA, ChannelId)); + } + + /// + /// Reset (Logout) очищает каталог и мониторинг тенанта (`.clear`). + /// + [Fact] + public void Reset_ClearsTenantState() + { + var catalog = new DialogCatalog(); + catalog.ReplaceKnown(TenantA, [ChannelId]); + catalog.SetMonitored(TenantA, ChannelId, enabled: true); + + catalog.Reset(TenantA); + + Assert.Equal(0, catalog.KnownCount(TenantA)); + Assert.False(catalog.IsMonitored(TenantA, ChannelId)); + } + + /// + /// Каталог пустого тенанта + /// + [Fact] + public void UnknownTenant_ReportsEmptyState() + { + var catalog = new DialogCatalog(); + + Assert.Equal(0, catalog.KnownCount(TenantB)); + Assert.False(catalog.IsMonitored(TenantB, ChannelId)); + Assert.Empty(catalog.SnapshotMonitored(TenantB)); + } +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/DiscoveryOpsTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/DiscoveryOpsTests.cs index 49443ce..5172bf9 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/DiscoveryOpsTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/DiscoveryOpsTests.cs @@ -1,291 +1,291 @@ -using Deal.Telegram.Discovery; -using Deal.Telegram.Sessions; -using Deal.Telegram.Telegram; -using Deal.Telegram.Tests.Grpc; -using Grpc.Core; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Telegram.Tests.Telegram; - -/// -/// Unit-тесты discovery-операций -/// -public sealed class DiscoveryOpsTests -{ - private const string TenantId = "tenant-discovery"; - private const string OtherTenantId = "tenant-other"; - private const string ChannelA = "-100111"; - private const string ChannelB = "-100222"; - private const string UserC = "+777333"; - - /// - /// Поиск: дедуп по id + обрезка до лимита - /// - [Fact] - public async Task Search_DedupesAndCapsResults_PausesTwoToFourSeconds() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - harness.Client.SearchResults.AddRange( - [ - Entry(ChannelA, "Канал А", "ch_a", DialogKinds.Channel), - Entry(ChannelB, "Канал Б", "ch_b", DialogKinds.Group), - Entry(ChannelA, "Канал А", "ch_a", DialogKinds.Channel), // дубль id - Entry(UserC, "Чат В", "user_c", DialogKinds.Chat), - ]); - var pacer = new RecordingPacer(); - DiscoveryOps ops = CreateOps(harness, pacer); - - IReadOnlyList result = await ops.SearchAsync(TenantId, "python", limit: 3, CancellationToken.None); - - Assert.Equal([ChannelA, ChannelB, UserC], result.Select(item => item.Id).ToArray()); - Assert.Equal(("python", 3), Assert.Single(harness.Client.SearchQueries)); - Assert.Equal((DiscoveryOps.SearchPauseMinSeconds, DiscoveryOps.SearchPauseMaxSeconds), Assert.Single(pacer.Waits)); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// Поиск без лимита - /// - [Fact] - public async Task Search_LimitZero_UsesDefaultThirty() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - harness.Client.SearchResults.AddRange([Entry(ChannelA, "Канал А", "ch_a", DialogKinds.Channel)]); - DiscoveryOps ops = CreateOps(harness, new RecordingPacer()); - - await ops.SearchAsync(TenantId, "jobs", limit: 0, CancellationToken.None); - - Assert.Equal(("jobs", DiscoveryOps.SearchDefaultLimit), Assert.Single(harness.Client.SearchQueries)); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// Поиск без готовой сессии → «Telegram не подключён» - /// - [Fact] - public async Task Search_NoSession_FailedPrecondition() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - DiscoveryOps ops = CreateOps(harness, new RecordingPacer()); - - SessionException exception = await Assert.ThrowsAsync( - () => ops.SearchAsync(OtherTenantId, "jobs", limit: 10, CancellationToken.None)); - - Assert.Equal(StatusCode.FailedPrecondition, exception.Code); - Assert.Equal(SessionErrorMessages.NotConnected, exception.Message); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// GetInfo: возвращает инфо сессии как есть - /// - [Fact] - public async Task GetInfo_ReturnsSourceInfo() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - var info = new TelegramSourceInfo(ChannelA, "Канал А", "ch_a", DialogKinds.Channel, participants: 1234, isForum: true); - harness.Client.SourceInfos[ChannelA] = info; - DiscoveryOps ops = CreateOps(harness, new RecordingPacer()); - - TelegramSourceInfo actual = await ops.GetInfoAsync(TenantId, ChannelA, CancellationToken.None); - - Assert.Equal(info, actual); - Assert.Equal([ChannelA], harness.Client.InfoRequests); - Assert.Empty(harness.Client.ReadRequests); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// ReadForEval: passthrough результата чтения сессии - /// - [Fact] - public async Task ReadForEval_ReturnsSessionResult() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - DiscoveryReadResult expected = DiscoveryReadResult.NoHistory(); - harness.Client.ReadForEvalResults[ChannelB] = expected; - DiscoveryOps ops = CreateOps(harness, new RecordingPacer()); - - DiscoveryReadResult actual = await ops.ReadForEvalAsync(TenantId, ChannelB, limit: 0, CancellationToken.None); - - Assert.False(actual.Ok); - Assert.Equal(DiscoveryReadResult.NoHistoryError, actual.Error); - Assert.Equal([ChannelB], harness.Client.ReadRequests); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// Join: нормализация username - /// - [Fact] - public async Task Join_NormalizesUsername_WithoutPause() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - var pacer = new RecordingPacer(); - DiscoveryOps ops = CreateOps(harness, pacer); - - await ops.JoinAsync(TenantId, " @it_channel ", CancellationToken.None); - - Assert.Equal(["it_channel"], harness.Client.JoinedUsernames); - Assert.Empty(pacer.Waits); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// Join пустого username → INVALID_ARGUMENT «Не указан username для вступления». - /// - [Fact] - public async Task Join_EmptyUsername_InvalidArgument() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - DiscoveryOps ops = CreateOps(harness, new RecordingPacer()); - - SessionException exception = await Assert.ThrowsAsync( - () => ops.JoinAsync(TenantId, "@", CancellationToken.None)); - - Assert.Equal(StatusCode.InvalidArgument, exception.Code); - Assert.Equal(SessionErrorMessages.JoinUsernameMissing, exception.Message); - Assert.Empty(harness.Client.JoinedUsernames); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// Join при FloodWait → RESOURCE_EXHAUSTED с detail-префиксом "flood" - /// - [Fact] - public async Task Join_FloodWait_ResourceExhaustedFlood() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - harness.Client.JoinError = new SessionException(StatusCode.ResourceExhausted, "flood: FLOOD_WAIT_120"); - DiscoveryOps ops = CreateOps(harness, new RecordingPacer()); - - SessionException exception = await Assert.ThrowsAsync( - () => ops.JoinAsync(TenantId, "it_channel", CancellationToken.None)); - - Assert.Equal(StatusCode.ResourceExhausted, exception.Code); - Assert.StartsWith("flood", exception.Message, StringComparison.Ordinal); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// Leave: passthrough id на сессию - /// - [Fact] - public async Task Leave_PassesDialogId() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - DiscoveryOps ops = CreateOps(harness, new RecordingPacer()); - - await ops.LeaveAsync(TenantId, ChannelA, CancellationToken.None); - - Assert.Equal([ChannelA], harness.Client.LeaveCalls); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// Изоляция тенантов - /// - [Fact] - public async Task TenantIsolation_SearchUsesOnlyOwnSession() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - await harness.Farm.StartPhoneAsync( - OtherTenantId, - SessionHarness.ApiId, - SessionHarness.ApiHash, - "+79990001133", - CancellationToken.None); - await harness.Farm.SendCodeAsync(OtherTenantId, "11111", CancellationToken.None); - - FakeSessionClient tenantA = harness.Factory.CreatedClients[0]; - FakeSessionClient tenantB = harness.Factory.CreatedClients[1]; - tenantA.SearchResults.Add(Entry(ChannelA, "Канал А", "ch_a", DialogKinds.Channel)); - tenantB.SearchResults.Add(Entry(ChannelB, "Канал Б", "ch_b", DialogKinds.Channel)); - DiscoveryOps ops = CreateOps(harness, new RecordingPacer()); - - IReadOnlyList forA = await ops.SearchAsync(TenantId, "a", limit: 10, CancellationToken.None); - IReadOnlyList forB = await ops.SearchAsync(OtherTenantId, "b", limit: 10, CancellationToken.None); - - Assert.Equal([ChannelA], forA.Select(item => item.Id).ToArray()); - Assert.Equal([ChannelB], forB.Select(item => item.Id).ToArray()); - } - finally - { - await harness.DisposeAsync(); - } - } - - // Запись поиска сценария (kind EN-канона). - // id: Подписанный id. - // name: Имя. - // username: Username. - // kind: Тип канона. - private static TelegramDialog Entry( - string id, - string name, - string username, - string kind) - => new(id, name, username, kind, unreadCount: 0, topMessageId: 0); - - // Служба discovery на пуле с фейк-клиентом и фейк-пейсером. - // harness: Харнесс ready-сессии. - // pacer: Фейк-пейсер (без реальных задержек). - private static DiscoveryOps CreateOps(FarmHarness harness, RecordingPacer pacer) - => new(harness.Farm, pacer, NullLogger.Instance); -} +using Deal.Telegram.Discovery; +using Deal.Telegram.Sessions; +using Deal.Telegram.Telegram; +using Deal.Telegram.Tests.Grpc; +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Telegram.Tests.Telegram; + +/// +/// Unit-тесты discovery-операций +/// +public sealed class DiscoveryOpsTests +{ + private const string TenantId = "tenant-discovery"; + private const string OtherTenantId = "tenant-other"; + private const string ChannelA = "-100111"; + private const string ChannelB = "-100222"; + private const string UserC = "+777333"; + + /// + /// Поиск: дедуп по id + обрезка до лимита + /// + [Fact] + public async Task Search_DedupesAndCapsResults_PausesTwoToFourSeconds() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + harness.Client.SearchResults.AddRange( + [ + Entry(ChannelA, "Канал А", "ch_a", DialogKinds.Channel), + Entry(ChannelB, "Канал Б", "ch_b", DialogKinds.Group), + Entry(ChannelA, "Канал А", "ch_a", DialogKinds.Channel), // дубль id + Entry(UserC, "Чат В", "user_c", DialogKinds.Chat), + ]); + var pacer = new RecordingPacer(); + DiscoveryOps ops = CreateOps(harness, pacer); + + IReadOnlyList result = await ops.SearchAsync(TenantId, "python", limit: 3, CancellationToken.None); + + Assert.Equal([ChannelA, ChannelB, UserC], result.Select(item => item.Id).ToArray()); + Assert.Equal(("python", 3), Assert.Single(harness.Client.SearchQueries)); + Assert.Equal((DiscoveryOps.SearchPauseMinSeconds, DiscoveryOps.SearchPauseMaxSeconds), Assert.Single(pacer.Waits)); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// Поиск без лимита + /// + [Fact] + public async Task Search_LimitZero_UsesDefaultThirty() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + harness.Client.SearchResults.AddRange([Entry(ChannelA, "Канал А", "ch_a", DialogKinds.Channel)]); + DiscoveryOps ops = CreateOps(harness, new RecordingPacer()); + + await ops.SearchAsync(TenantId, "jobs", limit: 0, CancellationToken.None); + + Assert.Equal(("jobs", DiscoveryOps.SearchDefaultLimit), Assert.Single(harness.Client.SearchQueries)); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// Поиск без готовой сессии → «Telegram не подключён» + /// + [Fact] + public async Task Search_NoSession_FailedPrecondition() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + DiscoveryOps ops = CreateOps(harness, new RecordingPacer()); + + SessionException exception = await Assert.ThrowsAsync( + () => ops.SearchAsync(OtherTenantId, "jobs", limit: 10, CancellationToken.None)); + + Assert.Equal(StatusCode.FailedPrecondition, exception.Code); + Assert.Equal(SessionErrorMessages.NotConnected, exception.Message); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// GetInfo: возвращает инфо сессии как есть + /// + [Fact] + public async Task GetInfo_ReturnsSourceInfo() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + var info = new TelegramSourceInfo(ChannelA, "Канал А", "ch_a", DialogKinds.Channel, participants: 1234, isForum: true); + harness.Client.SourceInfos[ChannelA] = info; + DiscoveryOps ops = CreateOps(harness, new RecordingPacer()); + + TelegramSourceInfo actual = await ops.GetInfoAsync(TenantId, ChannelA, CancellationToken.None); + + Assert.Equal(info, actual); + Assert.Equal([ChannelA], harness.Client.InfoRequests); + Assert.Empty(harness.Client.ReadRequests); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// ReadForEval: passthrough результата чтения сессии + /// + [Fact] + public async Task ReadForEval_ReturnsSessionResult() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + DiscoveryReadResult expected = DiscoveryReadResult.NoHistory(); + harness.Client.ReadForEvalResults[ChannelB] = expected; + DiscoveryOps ops = CreateOps(harness, new RecordingPacer()); + + DiscoveryReadResult actual = await ops.ReadForEvalAsync(TenantId, ChannelB, limit: 0, CancellationToken.None); + + Assert.False(actual.Ok); + Assert.Equal(DiscoveryReadResult.NoHistoryError, actual.Error); + Assert.Equal([ChannelB], harness.Client.ReadRequests); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// Join: нормализация username + /// + [Fact] + public async Task Join_NormalizesUsername_WithoutPause() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + var pacer = new RecordingPacer(); + DiscoveryOps ops = CreateOps(harness, pacer); + + await ops.JoinAsync(TenantId, " @it_channel ", CancellationToken.None); + + Assert.Equal(["it_channel"], harness.Client.JoinedUsernames); + Assert.Empty(pacer.Waits); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// Join пустого username → INVALID_ARGUMENT «Не указан username для вступления». + /// + [Fact] + public async Task Join_EmptyUsername_InvalidArgument() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + DiscoveryOps ops = CreateOps(harness, new RecordingPacer()); + + SessionException exception = await Assert.ThrowsAsync( + () => ops.JoinAsync(TenantId, "@", CancellationToken.None)); + + Assert.Equal(StatusCode.InvalidArgument, exception.Code); + Assert.Equal(SessionErrorMessages.JoinUsernameMissing, exception.Message); + Assert.Empty(harness.Client.JoinedUsernames); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// Join при FloodWait → RESOURCE_EXHAUSTED с detail-префиксом "flood" + /// + [Fact] + public async Task Join_FloodWait_ResourceExhaustedFlood() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + harness.Client.JoinError = new SessionException(StatusCode.ResourceExhausted, "flood: FLOOD_WAIT_120"); + DiscoveryOps ops = CreateOps(harness, new RecordingPacer()); + + SessionException exception = await Assert.ThrowsAsync( + () => ops.JoinAsync(TenantId, "it_channel", CancellationToken.None)); + + Assert.Equal(StatusCode.ResourceExhausted, exception.Code); + Assert.StartsWith("flood", exception.Message, StringComparison.Ordinal); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// Leave: passthrough id на сессию + /// + [Fact] + public async Task Leave_PassesDialogId() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + DiscoveryOps ops = CreateOps(harness, new RecordingPacer()); + + await ops.LeaveAsync(TenantId, ChannelA, CancellationToken.None); + + Assert.Equal([ChannelA], harness.Client.LeaveCalls); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// Изоляция тенантов + /// + [Fact] + public async Task TenantIsolation_SearchUsesOnlyOwnSession() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + await harness.Farm.StartPhoneAsync( + OtherTenantId, + SessionHarness.ApiId, + SessionHarness.ApiHash, + "+79990001133", + CancellationToken.None); + await harness.Farm.SendCodeAsync(OtherTenantId, "11111", CancellationToken.None); + + FakeSessionClient tenantA = harness.Factory.CreatedClients[0]; + FakeSessionClient tenantB = harness.Factory.CreatedClients[1]; + tenantA.SearchResults.Add(Entry(ChannelA, "Канал А", "ch_a", DialogKinds.Channel)); + tenantB.SearchResults.Add(Entry(ChannelB, "Канал Б", "ch_b", DialogKinds.Channel)); + DiscoveryOps ops = CreateOps(harness, new RecordingPacer()); + + IReadOnlyList forA = await ops.SearchAsync(TenantId, "a", limit: 10, CancellationToken.None); + IReadOnlyList forB = await ops.SearchAsync(OtherTenantId, "b", limit: 10, CancellationToken.None); + + Assert.Equal([ChannelA], forA.Select(item => item.Id).ToArray()); + Assert.Equal([ChannelB], forB.Select(item => item.Id).ToArray()); + } + finally + { + await harness.DisposeAsync(); + } + } + + // Запись поиска сценария (kind EN-канона). + // id: Подписанный id. + // name: Имя. + // username: Username. + // kind: Тип канона. + private static TelegramDialog Entry( + string id, + string name, + string username, + string kind) + => new(id, name, username, kind, unreadCount: 0, topMessageId: 0); + + // Служба discovery на пуле с фейк-клиентом и фейк-пейсером. + // harness: Харнесс ready-сессии. + // pacer: Фейк-пейсер (без реальных задержек). + private static DiscoveryOps CreateOps(FarmHarness harness, RecordingPacer pacer) + => new(harness.Farm, pacer, NullLogger.Instance); +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/FakeSessionClient.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/FakeSessionClient.cs index 503c15b..807ea2c 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/FakeSessionClient.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/FakeSessionClient.cs @@ -1,496 +1,496 @@ -using Deal.Telegram.Telegram; - -namespace Deal.Telegram.Tests.Telegram; - -internal sealed class FakeSessionClient : ISessionClient -{ - /// - /// URL, который фейк выдаёт первым колбэком QR-входа. - /// - public const string DefaultQrUrl = "tg://login?token=fake_token"; - - private readonly TaskCompletionSource _qrScanTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - private bool _authorized; - - /// - /// Создаёт фейк под ключи приложения - /// - /// api_id приложения. - /// api_hash приложения. - /// Байты «сохранённой сессии» (для проверки передачи в фабрику). - public FakeSessionClient( - int apiId, - string apiHash, - byte[]? storedSession) - { - ApiId = apiId; - ApiHash = apiHash; - SessionBytes = storedSession ?? SessionMarker; - _authorized = storedSession is not null; - } - - /// - /// Сколько раз вызван ConnectAsync - /// - public int ConnectAttempts { get; private set; } - - /// - /// Непустой — ConnectAsync ждёт завершения этой задачи - /// - public TaskCompletionSource? ConnectGate { get; set; } - - /// - /// True — первый URL QR не выдаётся сразу - /// - public bool DelayFirstQrUrl { get; set; } - - /// - /// True — фоновый QR-вход - /// - public bool QrLoginCancelled { get; private set; } - - /// - /// Маркерные байты сессии - /// - public static byte[] SessionMarker { get; } = [1, 3, 3, 7, 42]; - - /// - /// Исключение, которое бросит RequestCodeAsync - /// - public Exception? RequestCodeError { get; set; } - - /// - /// Исключение SubmitCodeAsync - /// - public Exception? CodeError { get; set; } - - /// - /// Исключение SubmitPasswordAsync - /// - public Exception? PasswordError { get; set; } - - /// - /// Результат SubmitCodeAsync - /// - public string? CodeResult { get; set; } - - /// - /// Аккаунт, возвращаемый GetAccountAsync. - /// - public string Account { get; set; } = "@fake_user"; - - /// - /// Был ли вызван LogOutAsync. - /// - public bool LoggedOut { get; private set; } - - /// - /// Запрошенные номера - /// - public List RequestedPhones { get; } = []; - - /// - /// Отправленные коды - /// - public List SubmittedCodes { get; } = []; - - /// - /// Отправленные пароли - /// - public List SubmittedPasswords { get; } = []; - - /// - /// Был ли вызван StartQrAsync. - /// - public bool QrStarted { get; private set; } - - /// - /// Завершает «сканирование» QR — авторизует фейк - /// - public void CompleteQrScan() - => _qrScanTcs.TrySetResult(true); - - /// - public bool IsAuthorized => _authorized; - - /// - public bool IsConnected { get; private set; } - - /// - public int ApiId { get; } - - /// - public string ApiHash { get; } - - /// - public byte[] SessionBytes { get; private set; } - - /// - public async Task ConnectAsync(CancellationToken cancellationToken) - { - ConnectAttempts++; - if (ConnectGate is not null) - { - // «Зависший» connect: ждём gate (или отмену токена — реальный клиент прерывается по ct). - await ConnectGate.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - } - - IsConnected = true; - } - - /// - public Task RequestCodeAsync(string phone, CancellationToken cancellationToken) - { - RequestedPhones.Add(phone); - if (RequestCodeError is not null) - { - throw RequestCodeError; - } - - return Task.CompletedTask; - } - - /// - public Task SubmitCodeAsync(string code, CancellationToken cancellationToken) - { - SubmittedCodes.Add(code); - if (CodeError is not null) - { - throw CodeError; - } - - if (CodeResult == "password") - { - return Task.FromResult(CodeResult); - } - - _authorized = true; - SessionBytes = SessionMarker; - return Task.FromResult(null); - } - - /// - public Task SubmitPasswordAsync(string password, CancellationToken cancellationToken) - { - SubmittedPasswords.Add(password); - if (PasswordError is not null) - { - throw PasswordError; - } - - _authorized = true; - SessionBytes = SessionMarker; - return Task.CompletedTask; - } - - /// - public async Task StartQrAsync(Action onQrUrl, CancellationToken cancellationToken) - { - QrStarted = true; - // Реальный WTelegramClient при QR-входе сам устанавливает соединение (LoginWithQRCode → ConnectAsync). - IsConnected = true; - if (!DelayFirstQrUrl) - { - onQrUrl(DefaultQrUrl); - } - - try - { - await _qrScanTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Отмена до сканирования: фоновый вход остановлен (не «скрытая» авторизация). - QrLoginCancelled = true; - throw; - } - - _authorized = true; - SessionBytes = SessionMarker; - } - - /// - public Task LogOutAsync(CancellationToken cancellationToken) - { - LoggedOut = true; - _authorized = false; - return Task.CompletedTask; - } - - /// - public Task GetAccountAsync(CancellationToken cancellationToken) - => Task.FromResult(Account); - - /// - public event Func? MessageReceived; - - /// - /// Список диалогов, который фейк возвращает из GetDialogsAsync - /// - public List Dialogs { get; } = []; - - /// - /// Сообщения диалогов - /// - public Dictionary> Messages { get; } = new(StringComparer.Ordinal); - - /// - /// Сколько раз вызван GetDialogsAsync. - /// - public int DialogListCallCount { get; private set; } - - /// - /// Диалоги, которые GetMessagesAsync/MarkReadAsync получали - /// - public List ReadDialogs { get; } = []; - - /// - /// Диалоги, помеченные прочитанными - /// - public List MarkedReadDialogs { get; } = []; - - /// - /// Исключение GetDialogsAsync - /// - public Exception? DialogsError { get; set; } - - /// - /// Исключение GetMessagesAsync - /// - public Exception? MessagesError { get; set; } - - /// - /// Id сообщений, запрошенных через GetMessageAsync. - /// - public List<(string DialogId, long MsgId)> MessageRequests { get; } = []; - - /// - /// Исключение GetMessageAsync - /// - public Exception? MessageError { get; set; } - - /// - public Task> GetDialogsAsync(int limit, CancellationToken cancellationToken) - { - DialogListCallCount++; - if (DialogsError is not null) - { - throw DialogsError; - } - - return Task.FromResult>(Dialogs.ToArray()); - } - - /// - public Task> GetMessagesAsync( - string dialogId, - int limit, - CancellationToken cancellationToken) - { - ReadDialogs.Add(dialogId); - if (MessagesError is not null) - { - throw MessagesError; - } - - return Task.FromResult>( - Messages.TryGetValue(dialogId, out List? messages) - ? messages.Take(limit).ToArray() - : []); - } - - /// - public Task GetMessageAsync( - string dialogId, - long msgId, - CancellationToken cancellationToken) - { - MessageRequests.Add((dialogId, msgId)); - if (MessageError is not null) - { - throw MessageError; - } - - TelegramMessage? message = Messages.TryGetValue(dialogId, out List? messages) - ? messages.FirstOrDefault(item => item.Id == msgId) - : null; - return Task.FromResult(message); - } - - /// - public Task MarkReadAsync(string dialogId, CancellationToken cancellationToken) - { - MarkedReadDialogs.Add(dialogId); - return Task.CompletedTask; - } - - - /// - /// Результат поиска, который фейк возвращает из SearchAsync - /// - public List SearchResults { get; } = []; - - /// - /// Поисковые запросы - /// - public List<(string Query, int Limit)> SearchQueries { get; } = []; - - /// - /// Исключение SearchAsync - /// - public Exception? SearchError { get; set; } - - /// - /// Инфо источников - /// - public Dictionary SourceInfos { get; } = new(StringComparer.Ordinal); - - /// - /// Id источников, по которым запрошено инфо, в порядке вызовов. - /// - public List InfoRequests { get; } = []; - - /// - /// Результаты чтения выборки - /// - public Dictionary ReadForEvalResults { get; } = new(StringComparer.Ordinal); - - /// - /// Id источников, по которым запрошено чтение, в порядке вызовов. - /// - public List ReadRequests { get; } = []; - - /// - /// Username вступлений - /// - public List JoinedUsernames { get; } = []; - - /// - /// Исключение JoinAsync - /// - public Exception? JoinError { get; set; } - - /// - /// Id диалогов выхода, в порядке вызовов. - /// - public List LeaveCalls { get; } = []; - - /// - /// Исключение LeaveAsync - /// - public Exception? LeaveError { get; set; } - - /// - public Task> SearchAsync( - string query, - int limit, - CancellationToken cancellationToken) - { - SearchQueries.Add((query, limit)); - if (SearchError is not null) - { - throw SearchError; - } - - return Task.FromResult>(SearchResults.ToArray()); - } - - /// - public Task GetInfoAsync(string dialogId, CancellationToken cancellationToken) - { - InfoRequests.Add(dialogId); - return Task.FromResult( - SourceInfos.TryGetValue(dialogId, out TelegramSourceInfo? info) - ? info - : new TelegramSourceInfo(dialogId, dialogId, string.Empty, string.Empty, participants: null, isForum: false)); - } - - /// - public Task ReadForEvalAsync( - string dialogId, - int limit, - CancellationToken cancellationToken) - { - ReadRequests.Add(dialogId); - return Task.FromResult( - ReadForEvalResults.TryGetValue(dialogId, out DiscoveryReadResult? result) - ? result - : DiscoveryReadResult.Empty); - } - - /// - public Task JoinAsync(string username, CancellationToken cancellationToken) - { - JoinedUsernames.Add(username); - if (JoinError is not null) - { - throw JoinError; - } - - return Task.CompletedTask; - } - - /// - public Task LeaveAsync(string dialogId, CancellationToken cancellationToken) - { - LeaveCalls.Add(dialogId); - if (LeaveError is not null) - { - throw LeaveError; - } - - return Task.CompletedTask; - } - - /// - /// Поднимает событие нового сообщения - /// - /// Входящее сообщение диалога. - public async Task RaiseMessageAsync(TelegramMessage message) - { - Func? handler = MessageReceived; - if (handler is null) - { - return; - } - - foreach (Delegate subscriber in handler.GetInvocationList()) - { - await ((Func)subscriber)(message).ConfigureAwait(false); - } - } - - /// - public ValueTask DisposeAsync() - { - IsConnected = false; - return ValueTask.CompletedTask; - } -} - -// Фейковая фабрика клиентов: создаёт FakeSessionClient и запоминает экземпляры -// (тест управляет «сканированием» QR и проверяет передачи ключей/байт сессии). -internal sealed class FakeClientFactory : ITelegramClientFactory -{ - /// - /// Все созданные фабрикой клиенты - /// - public List CreatedClients { get; } = []; - - /// - /// Колбэк настройки клиента перед возвратом - /// - public Action? OnClientCreated { get; set; } - - /// - public ISessionClient Create( - int apiId, - string apiHash, - byte[]? storedSession) - { - var client = new FakeSessionClient(apiId, apiHash, storedSession); - OnClientCreated?.Invoke(client); - CreatedClients.Add(client); - return client; - } -} +using Deal.Telegram.Telegram; + +namespace Deal.Telegram.Tests.Telegram; + +internal sealed class FakeSessionClient : ISessionClient +{ + /// + /// URL, который фейк выдаёт первым колбэком QR-входа. + /// + public const string DefaultQrUrl = "tg://login?token=fake_token"; + + private readonly TaskCompletionSource _qrScanTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private bool _authorized; + + /// + /// Создаёт фейк под ключи приложения + /// + /// api_id приложения. + /// api_hash приложения. + /// Байты «сохранённой сессии» (для проверки передачи в фабрику). + public FakeSessionClient( + int apiId, + string apiHash, + byte[]? storedSession) + { + ApiId = apiId; + ApiHash = apiHash; + SessionBytes = storedSession ?? SessionMarker; + _authorized = storedSession is not null; + } + + /// + /// Сколько раз вызван ConnectAsync + /// + public int ConnectAttempts { get; private set; } + + /// + /// Непустой — ConnectAsync ждёт завершения этой задачи + /// + public TaskCompletionSource? ConnectGate { get; set; } + + /// + /// True — первый URL QR не выдаётся сразу + /// + public bool DelayFirstQrUrl { get; set; } + + /// + /// True — фоновый QR-вход + /// + public bool QrLoginCancelled { get; private set; } + + /// + /// Маркерные байты сессии + /// + public static byte[] SessionMarker { get; } = [1, 3, 3, 7, 42]; + + /// + /// Исключение, которое бросит RequestCodeAsync + /// + public Exception? RequestCodeError { get; set; } + + /// + /// Исключение SubmitCodeAsync + /// + public Exception? CodeError { get; set; } + + /// + /// Исключение SubmitPasswordAsync + /// + public Exception? PasswordError { get; set; } + + /// + /// Результат SubmitCodeAsync + /// + public string? CodeResult { get; set; } + + /// + /// Аккаунт, возвращаемый GetAccountAsync. + /// + public string Account { get; set; } = "@fake_user"; + + /// + /// Был ли вызван LogOutAsync. + /// + public bool LoggedOut { get; private set; } + + /// + /// Запрошенные номера + /// + public List RequestedPhones { get; } = []; + + /// + /// Отправленные коды + /// + public List SubmittedCodes { get; } = []; + + /// + /// Отправленные пароли + /// + public List SubmittedPasswords { get; } = []; + + /// + /// Был ли вызван StartQrAsync. + /// + public bool QrStarted { get; private set; } + + /// + /// Завершает «сканирование» QR — авторизует фейк + /// + public void CompleteQrScan() + => _qrScanTcs.TrySetResult(true); + + /// + public bool IsAuthorized => _authorized; + + /// + public bool IsConnected { get; private set; } + + /// + public int ApiId { get; } + + /// + public string ApiHash { get; } + + /// + public byte[] SessionBytes { get; private set; } + + /// + public async Task ConnectAsync(CancellationToken cancellationToken) + { + ConnectAttempts++; + if (ConnectGate is not null) + { + // «Зависший» connect: ждём gate (или отмену токена — реальный клиент прерывается по ct). + await ConnectGate.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + IsConnected = true; + } + + /// + public Task RequestCodeAsync(string phone, CancellationToken cancellationToken) + { + RequestedPhones.Add(phone); + if (RequestCodeError is not null) + { + throw RequestCodeError; + } + + return Task.CompletedTask; + } + + /// + public Task SubmitCodeAsync(string code, CancellationToken cancellationToken) + { + SubmittedCodes.Add(code); + if (CodeError is not null) + { + throw CodeError; + } + + if (CodeResult == "password") + { + return Task.FromResult(CodeResult); + } + + _authorized = true; + SessionBytes = SessionMarker; + return Task.FromResult(null); + } + + /// + public Task SubmitPasswordAsync(string password, CancellationToken cancellationToken) + { + SubmittedPasswords.Add(password); + if (PasswordError is not null) + { + throw PasswordError; + } + + _authorized = true; + SessionBytes = SessionMarker; + return Task.CompletedTask; + } + + /// + public async Task StartQrAsync(Action onQrUrl, CancellationToken cancellationToken) + { + QrStarted = true; + // Реальный WTelegramClient при QR-входе сам устанавливает соединение (LoginWithQRCode → ConnectAsync). + IsConnected = true; + if (!DelayFirstQrUrl) + { + onQrUrl(DefaultQrUrl); + } + + try + { + await _qrScanTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Отмена до сканирования: фоновый вход остановлен (не «скрытая» авторизация). + QrLoginCancelled = true; + throw; + } + + _authorized = true; + SessionBytes = SessionMarker; + } + + /// + public Task LogOutAsync(CancellationToken cancellationToken) + { + LoggedOut = true; + _authorized = false; + return Task.CompletedTask; + } + + /// + public Task GetAccountAsync(CancellationToken cancellationToken) + => Task.FromResult(Account); + + /// + public event Func? MessageReceived; + + /// + /// Список диалогов, который фейк возвращает из GetDialogsAsync + /// + public List Dialogs { get; } = []; + + /// + /// Сообщения диалогов + /// + public Dictionary> Messages { get; } = new(StringComparer.Ordinal); + + /// + /// Сколько раз вызван GetDialogsAsync. + /// + public int DialogListCallCount { get; private set; } + + /// + /// Диалоги, которые GetMessagesAsync/MarkReadAsync получали + /// + public List ReadDialogs { get; } = []; + + /// + /// Диалоги, помеченные прочитанными + /// + public List MarkedReadDialogs { get; } = []; + + /// + /// Исключение GetDialogsAsync + /// + public Exception? DialogsError { get; set; } + + /// + /// Исключение GetMessagesAsync + /// + public Exception? MessagesError { get; set; } + + /// + /// Id сообщений, запрошенных через GetMessageAsync. + /// + public List<(string DialogId, long MsgId)> MessageRequests { get; } = []; + + /// + /// Исключение GetMessageAsync + /// + public Exception? MessageError { get; set; } + + /// + public Task> GetDialogsAsync(int limit, CancellationToken cancellationToken) + { + DialogListCallCount++; + if (DialogsError is not null) + { + throw DialogsError; + } + + return Task.FromResult>(Dialogs.ToArray()); + } + + /// + public Task> GetMessagesAsync( + string dialogId, + int limit, + CancellationToken cancellationToken) + { + ReadDialogs.Add(dialogId); + if (MessagesError is not null) + { + throw MessagesError; + } + + return Task.FromResult>( + Messages.TryGetValue(dialogId, out List? messages) + ? messages.Take(limit).ToArray() + : []); + } + + /// + public Task GetMessageAsync( + string dialogId, + long msgId, + CancellationToken cancellationToken) + { + MessageRequests.Add((dialogId, msgId)); + if (MessageError is not null) + { + throw MessageError; + } + + TelegramMessage? message = Messages.TryGetValue(dialogId, out List? messages) + ? messages.FirstOrDefault(item => item.Id == msgId) + : null; + return Task.FromResult(message); + } + + /// + public Task MarkReadAsync(string dialogId, CancellationToken cancellationToken) + { + MarkedReadDialogs.Add(dialogId); + return Task.CompletedTask; + } + + + /// + /// Результат поиска, который фейк возвращает из SearchAsync + /// + public List SearchResults { get; } = []; + + /// + /// Поисковые запросы + /// + public List<(string Query, int Limit)> SearchQueries { get; } = []; + + /// + /// Исключение SearchAsync + /// + public Exception? SearchError { get; set; } + + /// + /// Инфо источников + /// + public Dictionary SourceInfos { get; } = new(StringComparer.Ordinal); + + /// + /// Id источников, по которым запрошено инфо, в порядке вызовов. + /// + public List InfoRequests { get; } = []; + + /// + /// Результаты чтения выборки + /// + public Dictionary ReadForEvalResults { get; } = new(StringComparer.Ordinal); + + /// + /// Id источников, по которым запрошено чтение, в порядке вызовов. + /// + public List ReadRequests { get; } = []; + + /// + /// Username вступлений + /// + public List JoinedUsernames { get; } = []; + + /// + /// Исключение JoinAsync + /// + public Exception? JoinError { get; set; } + + /// + /// Id диалогов выхода, в порядке вызовов. + /// + public List LeaveCalls { get; } = []; + + /// + /// Исключение LeaveAsync + /// + public Exception? LeaveError { get; set; } + + /// + public Task> SearchAsync( + string query, + int limit, + CancellationToken cancellationToken) + { + SearchQueries.Add((query, limit)); + if (SearchError is not null) + { + throw SearchError; + } + + return Task.FromResult>(SearchResults.ToArray()); + } + + /// + public Task GetInfoAsync(string dialogId, CancellationToken cancellationToken) + { + InfoRequests.Add(dialogId); + return Task.FromResult( + SourceInfos.TryGetValue(dialogId, out TelegramSourceInfo? info) + ? info + : new TelegramSourceInfo(dialogId, dialogId, string.Empty, string.Empty, participants: null, isForum: false)); + } + + /// + public Task ReadForEvalAsync( + string dialogId, + int limit, + CancellationToken cancellationToken) + { + ReadRequests.Add(dialogId); + return Task.FromResult( + ReadForEvalResults.TryGetValue(dialogId, out DiscoveryReadResult? result) + ? result + : DiscoveryReadResult.Empty); + } + + /// + public Task JoinAsync(string username, CancellationToken cancellationToken) + { + JoinedUsernames.Add(username); + if (JoinError is not null) + { + throw JoinError; + } + + return Task.CompletedTask; + } + + /// + public Task LeaveAsync(string dialogId, CancellationToken cancellationToken) + { + LeaveCalls.Add(dialogId); + if (LeaveError is not null) + { + throw LeaveError; + } + + return Task.CompletedTask; + } + + /// + /// Поднимает событие нового сообщения + /// + /// Входящее сообщение диалога. + public async Task RaiseMessageAsync(TelegramMessage message) + { + Func? handler = MessageReceived; + if (handler is null) + { + return; + } + + foreach (Delegate subscriber in handler.GetInvocationList()) + { + await ((Func)subscriber)(message).ConfigureAwait(false); + } + } + + /// + public ValueTask DisposeAsync() + { + IsConnected = false; + return ValueTask.CompletedTask; + } +} + +// Фейковая фабрика клиентов: создаёт FakeSessionClient и запоминает экземпляры +// (тест управляет «сканированием» QR и проверяет передачи ключей/байт сессии). +internal sealed class FakeClientFactory : ITelegramClientFactory +{ + /// + /// Все созданные фабрикой клиенты + /// + public List CreatedClients { get; } = []; + + /// + /// Колбэк настройки клиента перед возвратом + /// + public Action? OnClientCreated { get; set; } + + /// + public ISessionClient Create( + int apiId, + string apiHash, + byte[]? storedSession) + { + var client = new FakeSessionClient(apiId, apiHash, storedSession); + OnClientCreated?.Invoke(client); + CreatedClients.Add(client); + return client; + } +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/FarmHarness.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/FarmHarness.cs index 464c9a7..94f1b16 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/FarmHarness.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/FarmHarness.cs @@ -1,78 +1,78 @@ -using Deal.Telegram.Sessions; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Telegram.Tests.Telegram; - -// Харнесс пула сессий (SessionFarm) с готовой ready-сессией тенанта для unit-тестов служб каталога -// (BackfillService/RealtimeSweep/RealtimeListener ходят в сессию через SessionFarm, как в проде). -// Вход — телефонный флоу на фейк-клиенте: StartPhone (код запрошен) → SendCode (код верный) → ready. -internal sealed class FarmHarness : IAsyncDisposable -{ - private const string Phone = "+79990001122"; - - private FarmHarness( - string directory, - SessionStore store, - FakeClientFactory factory, - SessionFarm farm) - { - Directory = directory; - Store = store; - Factory = factory; - Farm = farm; - } - - /// - /// Temp-каталог хранилища сессий. - /// - public string Directory { get; } - - /// - /// Файловое хранилище сессий. - /// - public SessionStore Store { get; } - - /// - /// Фейковая фабрика клиентов - /// - public FakeClientFactory Factory { get; } - - /// - /// Пул сессий с ready-сессией тенанта. - /// - public SessionFarm Farm { get; } - - /// - /// Ready-фейк-клиент тенанта - /// - public FakeSessionClient Client => Factory.CreatedClients.Single(); - - /// - /// Создаёт пул с ready-сессией тенанта - /// - /// Id тенанта. - public static async Task CreateWithReadySessionAsync(string tenantId) - { - string directory = TestSessionFactory.NewSessionsDirectory(); - var factory = new FakeClientFactory(); - SessionStore store = TestSessionFactory.Store(directory); - var farm = new SessionFarm( - factory, - store, - NullLoggerFactory.Instance, - NullLogger.Instance); - - await farm.StartPhoneAsync(tenantId, SessionHarness.ApiId, SessionHarness.ApiHash, Phone, CancellationToken.None).ConfigureAwait(false); - await farm.SendCodeAsync(tenantId, "11111", CancellationToken.None).ConfigureAwait(false); - return new FarmHarness(directory, store, factory, farm); - } - - /// - /// Останавливает пул - /// - public async ValueTask DisposeAsync() - { - await Farm.ShutdownAsync(CancellationToken.None).ConfigureAwait(false); - TestSessionFactory.Cleanup(Directory); - } -} +using Deal.Telegram.Sessions; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Telegram.Tests.Telegram; + +// Харнесс пула сессий (SessionFarm) с готовой ready-сессией тенанта для unit-тестов служб каталога +// (BackfillService/RealtimeSweep/RealtimeListener ходят в сессию через SessionFarm, как в проде). +// Вход — телефонный флоу на фейк-клиенте: StartPhone (код запрошен) → SendCode (код верный) → ready. +internal sealed class FarmHarness : IAsyncDisposable +{ + private const string Phone = "+79990001122"; + + private FarmHarness( + string directory, + SessionStore store, + FakeClientFactory factory, + SessionFarm farm) + { + Directory = directory; + Store = store; + Factory = factory; + Farm = farm; + } + + /// + /// Temp-каталог хранилища сессий. + /// + public string Directory { get; } + + /// + /// Файловое хранилище сессий. + /// + public SessionStore Store { get; } + + /// + /// Фейковая фабрика клиентов + /// + public FakeClientFactory Factory { get; } + + /// + /// Пул сессий с ready-сессией тенанта. + /// + public SessionFarm Farm { get; } + + /// + /// Ready-фейк-клиент тенанта + /// + public FakeSessionClient Client => Factory.CreatedClients.Single(); + + /// + /// Создаёт пул с ready-сессией тенанта + /// + /// Id тенанта. + public static async Task CreateWithReadySessionAsync(string tenantId) + { + string directory = TestSessionFactory.NewSessionsDirectory(); + var factory = new FakeClientFactory(); + SessionStore store = TestSessionFactory.Store(directory); + var farm = new SessionFarm( + factory, + store, + NullLoggerFactory.Instance, + NullLogger.Instance); + + await farm.StartPhoneAsync(tenantId, SessionHarness.ApiId, SessionHarness.ApiHash, Phone, CancellationToken.None).ConfigureAwait(false); + await farm.SendCodeAsync(tenantId, "11111", CancellationToken.None).ConfigureAwait(false); + return new FarmHarness(directory, store, factory, farm); + } + + /// + /// Останавливает пул + /// + public async ValueTask DisposeAsync() + { + await Farm.ShutdownAsync(CancellationToken.None).ConfigureAwait(false); + TestSessionFactory.Cleanup(Directory); + } +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/LruCacheTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/LruCacheTests.cs index d629f5a..1c11dcf 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/LruCacheTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/LruCacheTests.cs @@ -1,113 +1,113 @@ -using Deal.Telegram.Caching; - -namespace Deal.Telegram.Tests.Telegram; - -/// -/// Unit-тесты LRU-кэша -/// -public sealed class LruCacheTests -{ - // Ёмкость сценария (маленькая — вытеснение видно без тысяч элементов). - private const int Capacity = 3; - - /// - /// Переполнение вытесняет самый давний по обращению элемент, а не первый вставленный. - /// - [Fact] - public void Set_OverCapacity_EvictsLeastRecentlyUsed() - { - var cache = new LruCache(Capacity); - cache.Set("a", 1); - cache.Set("b", 2); - cache.Set("c", 3); - // Обращение к "a" делает его недавним: кандидат на вытеснение — "b". - Assert.True(cache.TryGetValue("a", out int _)); - - cache.Set("d", 4); - - Assert.True(cache.TryGetValue("a", out int a)); - Assert.True(cache.TryGetValue("c", out int c)); - Assert.True(cache.TryGetValue("d", out int d)); - Assert.False(cache.TryGetValue("b", out int _)); - Assert.Equal(1, a); - Assert.Equal(3, c); - Assert.Equal(4, d); - Assert.Equal(Capacity, cache.Count); - } - - /// - /// Обновление существующего ключа меняет значение и делает элемент самым недавним. - /// - [Fact] - public void Set_ExistingKey_UpdatesValueAndRecency() - { - var cache = new LruCache(Capacity); - cache.Set("a", 1); - cache.Set("b", 2); - cache.Set("c", 3); - cache.Set("a", 10); - - cache.Set("d", 4); - - Assert.True(cache.TryGetValue("a", out int a)); - Assert.Equal(10, a); - Assert.False(cache.TryGetValue("b", out int _)); - } - - /// - /// Промах возвращает false и значение по умолчанию - /// - [Fact] - public void TryGetValue_MissingKey_ReturnsFalseAndDefault() - { - var cache = new LruCache(Capacity); - - Assert.False(cache.TryGetValue("absent", out int value)); - Assert.Equal(0, value); - Assert.Equal(0, cache.Count); - } - - /// - /// ContainsKey не вытесняет - /// - [Fact] - public void ContainsKey_DoesNotRefreshRecency() - { - var cache = new LruCache(Capacity); - cache.Set("a", 1); - cache.Set("b", 2); - cache.Set("c", 3); - Assert.True(cache.ContainsKey("a")); - - cache.Set("d", 4); - - Assert.False(cache.ContainsKey("a")); - Assert.True(cache.ContainsKey("d")); - } - - /// - /// Нулевая/отрицательная ёмкость — ошибка конфигурации кэша - /// - [Theory] - [InlineData(0)] - [InlineData(-1)] - public void Constructor_NonPositiveCapacity_Throws(int capacity) - { - Assert.Throws(() => new LruCache(capacity)); - } - - /// - /// Ёмкость 1: каждая вставка вытесняет предыдущий элемент. - /// - [Fact] - public void Set_CapacityOne_KeepsOnlyLatest() - { - var cache = new LruCache(1); - cache.Set("a", 1); - cache.Set("b", 2); - - Assert.False(cache.TryGetValue("a", out int _)); - Assert.True(cache.TryGetValue("b", out int b)); - Assert.Equal(2, b); - } -} +using Deal.Telegram.Caching; + +namespace Deal.Telegram.Tests.Telegram; + +/// +/// Unit-тесты LRU-кэша +/// +public sealed class LruCacheTests +{ + // Ёмкость сценария (маленькая — вытеснение видно без тысяч элементов). + private const int Capacity = 3; + + /// + /// Переполнение вытесняет самый давний по обращению элемент, а не первый вставленный. + /// + [Fact] + public void Set_OverCapacity_EvictsLeastRecentlyUsed() + { + var cache = new LruCache(Capacity); + cache.Set("a", 1); + cache.Set("b", 2); + cache.Set("c", 3); + // Обращение к "a" делает его недавним: кандидат на вытеснение — "b". + Assert.True(cache.TryGetValue("a", out int _)); + + cache.Set("d", 4); + + Assert.True(cache.TryGetValue("a", out int a)); + Assert.True(cache.TryGetValue("c", out int c)); + Assert.True(cache.TryGetValue("d", out int d)); + Assert.False(cache.TryGetValue("b", out int _)); + Assert.Equal(1, a); + Assert.Equal(3, c); + Assert.Equal(4, d); + Assert.Equal(Capacity, cache.Count); + } + + /// + /// Обновление существующего ключа меняет значение и делает элемент самым недавним. + /// + [Fact] + public void Set_ExistingKey_UpdatesValueAndRecency() + { + var cache = new LruCache(Capacity); + cache.Set("a", 1); + cache.Set("b", 2); + cache.Set("c", 3); + cache.Set("a", 10); + + cache.Set("d", 4); + + Assert.True(cache.TryGetValue("a", out int a)); + Assert.Equal(10, a); + Assert.False(cache.TryGetValue("b", out int _)); + } + + /// + /// Промах возвращает false и значение по умолчанию + /// + [Fact] + public void TryGetValue_MissingKey_ReturnsFalseAndDefault() + { + var cache = new LruCache(Capacity); + + Assert.False(cache.TryGetValue("absent", out int value)); + Assert.Equal(0, value); + Assert.Equal(0, cache.Count); + } + + /// + /// ContainsKey не вытесняет + /// + [Fact] + public void ContainsKey_DoesNotRefreshRecency() + { + var cache = new LruCache(Capacity); + cache.Set("a", 1); + cache.Set("b", 2); + cache.Set("c", 3); + Assert.True(cache.ContainsKey("a")); + + cache.Set("d", 4); + + Assert.False(cache.ContainsKey("a")); + Assert.True(cache.ContainsKey("d")); + } + + /// + /// Нулевая/отрицательная ёмкость — ошибка конфигурации кэша + /// + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Constructor_NonPositiveCapacity_Throws(int capacity) + { + Assert.Throws(() => new LruCache(capacity)); + } + + /// + /// Ёмкость 1: каждая вставка вытесняет предыдущий элемент. + /// + [Fact] + public void Set_CapacityOne_KeepsOnlyLatest() + { + var cache = new LruCache(1); + cache.Set("a", 1); + cache.Set("b", 2); + + Assert.False(cache.TryGetValue("a", out int _)); + Assert.True(cache.TryGetValue("b", out int b)); + Assert.Equal(2, b); + } +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/RealtimeSweepTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/RealtimeSweepTests.cs index 4c5d299..0a9af97 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/RealtimeSweepTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/RealtimeSweepTests.cs @@ -1,149 +1,149 @@ -using Deal.Telegram.Dialogs; -using Deal.Telegram.Sessions; -using Deal.Telegram.Telegram; -using Deal.Telegram.Tests.Grpc; -using Grpc.Core; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Telegram.Tests.Telegram; - -/// -/// Unit-тесты догона realtime. -/// -public sealed class RealtimeSweepTests -{ - private const string TenantId = "tenant-sweep"; - private const string DialogId = "-1001234567890"; - private const string OtherDialogId = "-10042"; - private const string ChannelName = "IT Канал"; - - /// - /// Догон: синк каталога + только мониторящиеся непрочитанные; порядок от старых к новым. - /// - [Fact] - public async Task Sweep_PushesUnreadOfMonitoredDialogs_OldestFirst_AndMarksRead() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - harness.Client.Dialogs.Add(new TelegramDialog(DialogId, ChannelName, "it_channel", DialogKinds.Channel, unreadCount: 3, topMessageId: 30)); - harness.Client.Dialogs.Add(new TelegramDialog(OtherDialogId, "Другой", "other", DialogKinds.Channel, unreadCount: 2, topMessageId: 10)); - harness.Client.Messages[DialogId] = - [ - new TelegramMessage(DialogId, 30, "новое", 1_700_000_000_030, ChannelName, "it_channel"), - new TelegramMessage(DialogId, 29, "среднее", 1_700_000_000_029, ChannelName, "it_channel"), - new TelegramMessage(DialogId, 28, "старое", 1_700_000_000_028, ChannelName, "it_channel"), - ]; - - var catalog = new DialogCatalog(); - var ingress = new FakeIngress { MonitoredIdsToReturn = [DialogId] }; - RealtimeSweep sweep = CreateSweep(harness.Farm, catalog, ingress); - - await sweep.SweepAllAsync(CancellationToken.None); - - // Синк каталога выполнен (entries ушли в ядро), зеркало — ответом SyncDialogs. - Assert.Single(ingress.Syncs); - Assert.Equal([DialogId, OtherDialogId], ingress.Syncs[0].Entries.Select(entry => entry.Id)); - Assert.True(catalog.IsMonitored(TenantId, DialogId)); - Assert.False(catalog.IsMonitored(TenantId, OtherDialogId)); - // Дочитаны только сообщения мониторящегося диалога, от старых к новым. - Assert.Equal(["28", "29", "30"], ingress.Pushes.Select(push => push.Request.Source.ExternalId)); - Assert.Equal(DialogId, ingress.Pushes[0].Request.Source.OriginRef); - Assert.Equal([DialogId], harness.Client.MarkedReadDialogs); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// Диалог с unread=0 или не мониторящийся не трогается - /// - [Fact] - public async Task Sweep_SkipsReadAndUnmonitoredDialogs() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - harness.Client.Dialogs.Add(new TelegramDialog(DialogId, ChannelName, "", DialogKinds.Channel, unreadCount: 0, topMessageId: 5)); - harness.Client.Dialogs.Add(new TelegramDialog(OtherDialogId, "Другой", "", DialogKinds.Channel, unreadCount: 4, topMessageId: 9)); - var catalog = new DialogCatalog(); - catalog.ReplaceKnown(TenantId, [DialogId, OtherDialogId]); - catalog.ReplaceMonitored(TenantId, [DialogId]); - var ingress = new FakeIngress { MonitoredIdsToReturn = [] }; - - RealtimeSweep sweep = CreateSweep(harness.Farm, catalog, ingress); - await sweep.SweepAllAsync(CancellationToken.None); - - Assert.Empty(ingress.Pushes); - Assert.Empty(harness.Client.MarkedReadDialogs); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// Сбой SyncDialogs не роняет цикл - /// - [Fact] - public async Task Sweep_SyncFails_KeepsOldMirror_AndSkipsPushes() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - harness.Client.Dialogs.Add(new TelegramDialog(DialogId, ChannelName, "", DialogKinds.Channel, unreadCount: 1, topMessageId: 1)); - var catalog = new DialogCatalog(); - var ingress = new FakeIngress - { - SyncError = new SessionException(StatusCode.Unavailable, SessionErrorMessages.IngressUnavailable), - }; - - RealtimeSweep sweep = CreateSweep(harness.Farm, catalog, ingress); - await sweep.SweepAllAsync(CancellationToken.None); - - // Синк упал — зеркало мониторинга пустое, мониторящихся диалогов нет → без push и read. - Assert.Empty(ingress.Pushes); - Assert.False(catalog.IsMonitored(TenantId, DialogId)); - Assert.Empty(harness.Client.MarkedReadDialogs); - } - finally - { - await harness.DisposeAsync(); - } - } - - /// - /// Пустой список диалогов - /// - [Fact] - public async Task Sweep_NoDialogs_NoSyncCalls() - { - await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); - try - { - var ingress = new FakeIngress(); - RealtimeSweep sweep = CreateSweep(harness.Farm, new DialogCatalog(), ingress); - - await sweep.SweepAllAsync(CancellationToken.None); - - Assert.Empty(ingress.Syncs); - } - finally - { - await harness.DisposeAsync(); - } - } - - // Догонялка на пуле, каталоге и фейк-ингресса. - // farm: Пул сессий. - // catalog: Зеркало каталога. - // ingress: Фейк ингресса. - private static RealtimeSweep CreateSweep( - SessionFarm farm, - DialogCatalog catalog, - FakeIngress ingress) - => new(farm, catalog, ingress, NullLogger.Instance); -} +using Deal.Telegram.Dialogs; +using Deal.Telegram.Sessions; +using Deal.Telegram.Telegram; +using Deal.Telegram.Tests.Grpc; +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Telegram.Tests.Telegram; + +/// +/// Unit-тесты догона realtime. +/// +public sealed class RealtimeSweepTests +{ + private const string TenantId = "tenant-sweep"; + private const string DialogId = "-1001234567890"; + private const string OtherDialogId = "-10042"; + private const string ChannelName = "IT Канал"; + + /// + /// Догон: синк каталога + только мониторящиеся непрочитанные; порядок от старых к новым. + /// + [Fact] + public async Task Sweep_PushesUnreadOfMonitoredDialogs_OldestFirst_AndMarksRead() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + harness.Client.Dialogs.Add(new TelegramDialog(DialogId, ChannelName, "it_channel", DialogKinds.Channel, unreadCount: 3, topMessageId: 30)); + harness.Client.Dialogs.Add(new TelegramDialog(OtherDialogId, "Другой", "other", DialogKinds.Channel, unreadCount: 2, topMessageId: 10)); + harness.Client.Messages[DialogId] = + [ + new TelegramMessage(DialogId, 30, "новое", 1_700_000_000_030, ChannelName, "it_channel"), + new TelegramMessage(DialogId, 29, "среднее", 1_700_000_000_029, ChannelName, "it_channel"), + new TelegramMessage(DialogId, 28, "старое", 1_700_000_000_028, ChannelName, "it_channel"), + ]; + + var catalog = new DialogCatalog(); + var ingress = new FakeIngress { MonitoredIdsToReturn = [DialogId] }; + RealtimeSweep sweep = CreateSweep(harness.Farm, catalog, ingress); + + await sweep.SweepAllAsync(CancellationToken.None); + + // Синк каталога выполнен (entries ушли в ядро), зеркало — ответом SyncDialogs. + Assert.Single(ingress.Syncs); + Assert.Equal([DialogId, OtherDialogId], ingress.Syncs[0].Entries.Select(entry => entry.Id)); + Assert.True(catalog.IsMonitored(TenantId, DialogId)); + Assert.False(catalog.IsMonitored(TenantId, OtherDialogId)); + // Дочитаны только сообщения мониторящегося диалога, от старых к новым. + Assert.Equal(["28", "29", "30"], ingress.Pushes.Select(push => push.Request.Source.ExternalId)); + Assert.Equal(DialogId, ingress.Pushes[0].Request.Source.OriginRef); + Assert.Equal([DialogId], harness.Client.MarkedReadDialogs); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// Диалог с unread=0 или не мониторящийся не трогается + /// + [Fact] + public async Task Sweep_SkipsReadAndUnmonitoredDialogs() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + harness.Client.Dialogs.Add(new TelegramDialog(DialogId, ChannelName, "", DialogKinds.Channel, unreadCount: 0, topMessageId: 5)); + harness.Client.Dialogs.Add(new TelegramDialog(OtherDialogId, "Другой", "", DialogKinds.Channel, unreadCount: 4, topMessageId: 9)); + var catalog = new DialogCatalog(); + catalog.ReplaceKnown(TenantId, [DialogId, OtherDialogId]); + catalog.ReplaceMonitored(TenantId, [DialogId]); + var ingress = new FakeIngress { MonitoredIdsToReturn = [] }; + + RealtimeSweep sweep = CreateSweep(harness.Farm, catalog, ingress); + await sweep.SweepAllAsync(CancellationToken.None); + + Assert.Empty(ingress.Pushes); + Assert.Empty(harness.Client.MarkedReadDialogs); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// Сбой SyncDialogs не роняет цикл + /// + [Fact] + public async Task Sweep_SyncFails_KeepsOldMirror_AndSkipsPushes() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + harness.Client.Dialogs.Add(new TelegramDialog(DialogId, ChannelName, "", DialogKinds.Channel, unreadCount: 1, topMessageId: 1)); + var catalog = new DialogCatalog(); + var ingress = new FakeIngress + { + SyncError = new SessionException(StatusCode.Unavailable, SessionErrorMessages.IngressUnavailable), + }; + + RealtimeSweep sweep = CreateSweep(harness.Farm, catalog, ingress); + await sweep.SweepAllAsync(CancellationToken.None); + + // Синк упал — зеркало мониторинга пустое, мониторящихся диалогов нет → без push и read. + Assert.Empty(ingress.Pushes); + Assert.False(catalog.IsMonitored(TenantId, DialogId)); + Assert.Empty(harness.Client.MarkedReadDialogs); + } + finally + { + await harness.DisposeAsync(); + } + } + + /// + /// Пустой список диалогов + /// + [Fact] + public async Task Sweep_NoDialogs_NoSyncCalls() + { + await using FarmHarness harness = await FarmHarness.CreateWithReadySessionAsync(TenantId); + try + { + var ingress = new FakeIngress(); + RealtimeSweep sweep = CreateSweep(harness.Farm, new DialogCatalog(), ingress); + + await sweep.SweepAllAsync(CancellationToken.None); + + Assert.Empty(ingress.Syncs); + } + finally + { + await harness.DisposeAsync(); + } + } + + // Догонялка на пуле, каталоге и фейк-ингресса. + // farm: Пул сессий. + // catalog: Зеркало каталога. + // ingress: Фейк ингресса. + private static RealtimeSweep CreateSweep( + SessionFarm farm, + DialogCatalog catalog, + FakeIngress ingress) + => new(farm, catalog, ingress, NullLogger.Instance); +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/SessionHarness.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/SessionHarness.cs index c1ab0d4..55c7824 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/SessionHarness.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/SessionHarness.cs @@ -1,94 +1,94 @@ -using Deal.Telegram.Sessions; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Telegram.Tests.Telegram; - -internal sealed class SessionHarness : IAsyncDisposable -{ - /// - /// api_id приложения тестовых сессий. - /// - public const int ApiId = 12345; - - /// - /// api_hash приложения тестовых сессий. - /// - public const string ApiHash = "0123456789abcdef0123456789abcdef"; - - private SessionHarness( - string directory, - SessionStore store, - FakeClientFactory factory, - TenantSession session) - { - Directory = directory; - Store = store; - Factory = factory; - Session = session; - } - - /// - /// Temp-каталог хранилища сессий. - /// - public string Directory { get; } - - /// - /// Файловое хранилище сессий. - /// - public SessionStore Store { get; } - - /// - /// Фейковая фабрика клиентов - /// - public FakeClientFactory Factory { get; } - - /// - /// Ready-сессия тенанта. - /// - public TenantSession Session { get; } - - /// - /// Единственный фейк-клиент сессии - /// - public FakeSessionClient Client => Factory.CreatedClients.Single(); - - /// - /// Создаёт ready-сессию - /// - /// Id тенанта. - public static async Task CreateReadyAsync(string tenantId) - { - string directory = TestSessionFactory.NewSessionsDirectory(); - var factory = new FakeClientFactory(); - SessionStore store = TestSessionFactory.Store(directory); - var session = new TenantSession(tenantId, factory, store, NullLogger.Instance); - var stored = new StoredSession - { - ApiId = ApiId, - ApiHash = ApiHash, - SessionBytes = FakeSessionClient.SessionMarker, - }; - await store.SaveAsync(tenantId, stored, CancellationToken.None).ConfigureAwait(false); - bool resumed = await session.TryResumeAsync(stored, CancellationToken.None).ConfigureAwait(false); - if (!resumed) - { - TestSessionFactory.Cleanup(directory); - throw new InvalidOperationException("Фейковая сессия не возобновилась (нет ready)."); - } - - return new SessionHarness(directory, store, factory, session); - } - - /// - /// Удаляет temp-каталог сессий. - /// - public void Cleanup() - => TestSessionFactory.Cleanup(Directory); - - /// - public async ValueTask DisposeAsync() - { - await Session.DisposeAsync().ConfigureAwait(false); - Cleanup(); - } -} +using Deal.Telegram.Sessions; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Telegram.Tests.Telegram; + +internal sealed class SessionHarness : IAsyncDisposable +{ + /// + /// api_id приложения тестовых сессий. + /// + public const int ApiId = 12345; + + /// + /// api_hash приложения тестовых сессий. + /// + public const string ApiHash = "0123456789abcdef0123456789abcdef"; + + private SessionHarness( + string directory, + SessionStore store, + FakeClientFactory factory, + TenantSession session) + { + Directory = directory; + Store = store; + Factory = factory; + Session = session; + } + + /// + /// Temp-каталог хранилища сессий. + /// + public string Directory { get; } + + /// + /// Файловое хранилище сессий. + /// + public SessionStore Store { get; } + + /// + /// Фейковая фабрика клиентов + /// + public FakeClientFactory Factory { get; } + + /// + /// Ready-сессия тенанта. + /// + public TenantSession Session { get; } + + /// + /// Единственный фейк-клиент сессии + /// + public FakeSessionClient Client => Factory.CreatedClients.Single(); + + /// + /// Создаёт ready-сессию + /// + /// Id тенанта. + public static async Task CreateReadyAsync(string tenantId) + { + string directory = TestSessionFactory.NewSessionsDirectory(); + var factory = new FakeClientFactory(); + SessionStore store = TestSessionFactory.Store(directory); + var session = new TenantSession(tenantId, factory, store, NullLogger.Instance); + var stored = new StoredSession + { + ApiId = ApiId, + ApiHash = ApiHash, + SessionBytes = FakeSessionClient.SessionMarker, + }; + await store.SaveAsync(tenantId, stored, CancellationToken.None).ConfigureAwait(false); + bool resumed = await session.TryResumeAsync(stored, CancellationToken.None).ConfigureAwait(false); + if (!resumed) + { + TestSessionFactory.Cleanup(directory); + throw new InvalidOperationException("Фейковая сессия не возобновилась (нет ready)."); + } + + return new SessionHarness(directory, store, factory, session); + } + + /// + /// Удаляет temp-каталог сессий. + /// + public void Cleanup() + => TestSessionFactory.Cleanup(Directory); + + /// + public async ValueTask DisposeAsync() + { + await Session.DisposeAsync().ConfigureAwait(false); + Cleanup(); + } +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/TestSessionFactory.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/TestSessionFactory.cs index c420aeb..7aff8e1 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/TestSessionFactory.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/TestSessionFactory.cs @@ -1,60 +1,60 @@ -using Deal.Telegram.Sessions; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Telegram.Tests.Telegram; - -// Фабрика компонентов хранилища сессий для unit-тестов: уникальный temp-каталог на сценарий, -// тестовый ключ AES-256 и реальные SessionFileCipher/SessionStore (без сети). -internal static class TestSessionFactory -{ - /// - /// Тестовый ключ AES-256 - /// - public static byte[] TestKey { get; } = Enumerable.Range(1, 32).Select(index => (byte)index).ToArray(); - - /// - /// Создаёт уникальный temp-каталог для сценария теста. - /// - public static string NewSessionsDirectory() - => Path.Combine(Path.GetTempPath(), "deal-tg-sessions", Guid.NewGuid().ToString("N")); - - /// - /// Опции хранилища над temp-каталогом. - /// - /// Каталог сессий (NewSessionsDirectory). - /// Ключ шифрования (по умолчанию ). - public static TgOptions Options(string sessionsDirectory, byte[]? key = null) - => TgOptions.Create(key ?? TestKey, sessionsDirectory); - - /// - /// Шифр сессий над каталогом. - /// - /// Каталог сессий. - /// Ключ шифрования. - public static SessionFileCipher Cipher(string sessionsDirectory, byte[]? key = null) - => new(Options(sessionsDirectory, key)); - - /// - /// Реальное файловое хранилище над temp-каталогом - /// - /// Каталог сессий. - /// Ключ шифрования. - public static SessionStore Store(string sessionsDirectory, byte[]? key = null) - => new(Options(sessionsDirectory, key), Cipher(sessionsDirectory, key), NullLogger.Instance); - - /// - /// Удаляет temp-каталог после сценария - /// - /// Каталог сессий. - public static void Cleanup(string sessionsDirectory) - { - try - { - Directory.Delete(sessionsDirectory, recursive: true); - } - catch (IOException) - { - // Занятый файл — тестовый мусор в temp допустим. - } - } -} +using Deal.Telegram.Sessions; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Telegram.Tests.Telegram; + +// Фабрика компонентов хранилища сессий для unit-тестов: уникальный temp-каталог на сценарий, +// тестовый ключ AES-256 и реальные SessionFileCipher/SessionStore (без сети). +internal static class TestSessionFactory +{ + /// + /// Тестовый ключ AES-256 + /// + public static byte[] TestKey { get; } = Enumerable.Range(1, 32).Select(index => (byte)index).ToArray(); + + /// + /// Создаёт уникальный temp-каталог для сценария теста. + /// + public static string NewSessionsDirectory() + => Path.Combine(Path.GetTempPath(), "deal-tg-sessions", Guid.NewGuid().ToString("N")); + + /// + /// Опции хранилища над temp-каталогом. + /// + /// Каталог сессий (NewSessionsDirectory). + /// Ключ шифрования (по умолчанию ). + public static TgOptions Options(string sessionsDirectory, byte[]? key = null) + => TgOptions.Create(key ?? TestKey, sessionsDirectory); + + /// + /// Шифр сессий над каталогом. + /// + /// Каталог сессий. + /// Ключ шифрования. + public static SessionFileCipher Cipher(string sessionsDirectory, byte[]? key = null) + => new(Options(sessionsDirectory, key)); + + /// + /// Реальное файловое хранилище над temp-каталогом + /// + /// Каталог сессий. + /// Ключ шифрования. + public static SessionStore Store(string sessionsDirectory, byte[]? key = null) + => new(Options(sessionsDirectory, key), Cipher(sessionsDirectory, key), NullLogger.Instance); + + /// + /// Удаляет temp-каталог после сценария + /// + /// Каталог сессий. + public static void Cleanup(string sessionsDirectory) + { + try + { + Directory.Delete(sessionsDirectory, recursive: true); + } + catch (IOException) + { + // Занятый файл — тестовый мусор в temp допустим. + } + } +} diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/TlMessageMapperTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/TlMessageMapperTests.cs index c626836..c1ca094 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/TlMessageMapperTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/TlMessageMapperTests.cs @@ -1,235 +1,235 @@ -using Deal.Telegram.Telegram; -using TL; - -namespace Deal.Telegram.Tests.Telegram; - -/// -/// Unit-тесты веток realtime-разбора на фейковых TL-объектах. -/// -public sealed class TlMessageMapperTests -{ - private const long ChannelId = 111; - private const long ChatId = 222; - private const long UserId = 333; - - /// - /// UpdateNewMessage - /// - [Fact] - public void NewMessageFrom_UpdateNewMessage_ReturnsMessage() - { - var message = PrivateMessage(id: 5, text: "привет"); - var update = new UpdateNewMessage { message = message, pts = 1, pts_count = 1 }; - - Assert.Same(message, TlMessageMapper.NewMessageFrom(update)); - } - - /// - /// UpdateNewChannelMessage - /// - [Fact] - public void NewMessageFrom_UpdateNewChannelMessage_ReturnsMessage() - { - var message = ChannelMessage(id: 42, text: "пост канала"); - var update = new UpdateNewChannelMessage { message = message, pts = 1, pts_count = 1 }; - - Assert.Same(message, TlMessageMapper.NewMessageFrom(update)); - } - - /// - /// Обновления «не нового сообщения» - /// - [Fact] - public void NewMessageFrom_OtherUpdates_ReturnsNull() - { - var message = ChannelMessage(id: 7, text: "текст"); - - Assert.Null(TlMessageMapper.NewMessageFrom(new UpdateEditMessage { message = message })); - Assert.Null(TlMessageMapper.NewMessageFrom(new UpdateDeleteMessages { messages = [7], pts = 1, pts_count = 1 })); - Assert.Null(TlMessageMapper.NewMessageFrom(new UpdateMessageID { id = 7, random_id = 1 })); - } - - /// - /// Короткий личный UpdateShortMessage — библиотека синтезирует UpdateNewMessage → сообщение. - /// - [Fact] - public void NewMessageFrom_UpdateShortMessage_SynthesizesIncomingMessage() - { - var shortUpdate = new UpdateShortMessage - { - id = 9, - message = "короткое личное", - date = UtcDate(), - user_id = UserId, - pts = 1, - pts_count = 1, - }; - - var users = new Dictionary { [UserId] = PrivateUser() }; - MessageBase? synthesized = Assert.Single(shortUpdate.UpdateList) is UpdateNewMessage { message: MessageBase m } ? m : null; - - Assert.NotNull(synthesized); - Assert.NotNull(TlMessageMapper.NewMessageFrom(new UpdateNewMessage { message = synthesized! })); - TelegramMessage? mapped = TlMessageMapper.ToMessage(synthesized!, new Dictionary(), users); - Assert.NotNull(mapped); - Assert.Equal("+" + UserId, mapped.DialogId); - Assert.Equal("короткое личное", mapped.Text); - } - - /// - /// Короткий групповой UpdateShortChatMessage — синтез UpdateNewMessage в чат - /// - [Fact] - public void NewMessageFrom_UpdateShortChatMessage_SynthesizesIncomingMessage() - { - var shortUpdate = new UpdateShortChatMessage - { - id = 11, - message = "короткое в группе", - date = UtcDate(), - chat_id = ChatId, - from_id = UserId, - pts = 1, - pts_count = 1, - }; - - var chats = new Dictionary { [ChatId] = new Chat { id = ChatId, title = "Базовая группа" } }; - MessageBase? synthesized = Assert.Single(shortUpdate.UpdateList) is UpdateNewMessage { message: MessageBase m } ? m : null; - - Assert.NotNull(synthesized); - TelegramMessage? mapped = TlMessageMapper.ToMessage(synthesized!, chats, new Dictionary()); - Assert.NotNull(mapped); - Assert.Equal("-" + ChatId, mapped.DialogId); - Assert.Equal("Базовая группа", mapped.DialogName); - } - - /// - /// Маппинг сообщения канала - /// - [Fact] - public void ToMessage_ChannelMessage_MapsSignedIdAndChannelFields() - { - var message = ChannelMessage(id: 42, text: "пост канала"); - var chats = new Dictionary { [ChannelId] = ChannelEntity() }; - - TelegramMessage? mapped = TlMessageMapper.ToMessage(message, chats, new Dictionary()); - - Assert.NotNull(mapped); - Assert.Equal("-100" + ChannelId, mapped.DialogId); - Assert.Equal("пост канала", mapped.Text); - Assert.Equal("IT Канал", mapped.DialogName); - Assert.Equal("it_ch", mapped.DialogHandle); - Assert.Equal(42, mapped.Id); - Assert.Equal(new DateTimeOffset(UtcDate()).ToUnixTimeMilliseconds(), mapped.DateMs); - } - - /// - /// Исходящее (out_) сообщение не поднимается как входящее. - /// - [Fact] - public void ToMessage_OutgoingMessage_ReturnsNull() - { - var message = ChannelMessage(id: 43, text: "своё исходящее", flags: Message.Flags.out_); - - Assert.Null(TlMessageMapper.ToMessage(message, new Dictionary(), new Dictionary())); - } - - /// - /// Пустой/служебный текст не отдаётся в downstream. - /// - [Fact] - public void ToMessage_EmptyOrServiceMessage_ReturnsNull() - { - Assert.Null(TlMessageMapper.ToMessage(ChannelMessage(id: 44, text: " "), new Dictionary(), new Dictionary())); - Assert.Null(TlMessageMapper.ToMessage(new MessageService { id = 45, peer_id = new PeerChannel { channel_id = ChannelId } }, new Dictionary(), new Dictionary())); - } - - /// - /// Медиа-сообщение без текста (подпись пуста) не отдаётся. - /// - [Fact] - public void ToMessage_MediaMessageWithoutText_ReturnsNull() - { - var message = new Message - { - id = 46, - message = string.Empty, - date = UtcDate(), - peer_id = new PeerChannel { channel_id = ChannelId }, - media = new MessageMediaPhoto { photo = new PhotoEmpty { id = 1 } }, - }; - - Assert.Null(TlMessageMapper.ToMessage(message, new Dictionary(), new Dictionary())); - } - - /// - /// Сообщение личного чата - /// - [Fact] - public void ToMessage_PrivateMessage_MapsUserDialog() - { - var message = PrivateMessage(id: 3, text: "личное"); - var users = new Dictionary { [UserId] = PrivateUser() }; - - TelegramMessage? mapped = TlMessageMapper.ToMessage(message, new Dictionary(), users); - - Assert.NotNull(mapped); - Assert.Equal("+" + UserId, mapped.DialogId); - Assert.Equal("Иван Петров", mapped.DialogName); - Assert.Equal("ivan", mapped.DialogHandle); - } - - // Дата сообщения канала сценария (UTC). - private static DateTime UtcDate() => new(2023, 11, 15, 12, 0, 0, DateTimeKind.Utc); - - // Сообщение канала (peer_id — PeerChannel). - // id: Id сообщения. - // text: Текст. - // flags: Флаги сообщения (по умолчанию входящее). - private static Message ChannelMessage( - int id, - string text, - Message.Flags flags = 0) - => new() - { - id = id, - message = text, - flags = flags, - date = UtcDate(), - peer_id = new PeerChannel { channel_id = ChannelId }, - }; - - // Сообщение личного чата (peer_id — PeerUser). - // id: Id сообщения. - // text: Текст. - private static Message PrivateMessage(int id, string text) - => new() - { - id = id, - message = text, - date = UtcDate(), - peer_id = new PeerUser { user_id = UserId }, - }; - - // Канал сценария (broadcast, title/username). - private static Channel ChannelEntity() - => new() - { - id = ChannelId, - access_hash = 987654321, - title = "IT Канал", - username = "it_ch", - flags = Channel.Flags.broadcast, - }; - - // Пользователь сценария (имя/username). - private static User PrivateUser() - => new() - { - id = UserId, - access_hash = 123456, - first_name = "Иван", - last_name = "Петров", - username = "ivan", - }; -} +using Deal.Telegram.Telegram; +using TL; + +namespace Deal.Telegram.Tests.Telegram; + +/// +/// Unit-тесты веток realtime-разбора на фейковых TL-объектах. +/// +public sealed class TlMessageMapperTests +{ + private const long ChannelId = 111; + private const long ChatId = 222; + private const long UserId = 333; + + /// + /// UpdateNewMessage + /// + [Fact] + public void NewMessageFrom_UpdateNewMessage_ReturnsMessage() + { + var message = PrivateMessage(id: 5, text: "привет"); + var update = new UpdateNewMessage { message = message, pts = 1, pts_count = 1 }; + + Assert.Same(message, TlMessageMapper.NewMessageFrom(update)); + } + + /// + /// UpdateNewChannelMessage + /// + [Fact] + public void NewMessageFrom_UpdateNewChannelMessage_ReturnsMessage() + { + var message = ChannelMessage(id: 42, text: "пост канала"); + var update = new UpdateNewChannelMessage { message = message, pts = 1, pts_count = 1 }; + + Assert.Same(message, TlMessageMapper.NewMessageFrom(update)); + } + + /// + /// Обновления «не нового сообщения» + /// + [Fact] + public void NewMessageFrom_OtherUpdates_ReturnsNull() + { + var message = ChannelMessage(id: 7, text: "текст"); + + Assert.Null(TlMessageMapper.NewMessageFrom(new UpdateEditMessage { message = message })); + Assert.Null(TlMessageMapper.NewMessageFrom(new UpdateDeleteMessages { messages = [7], pts = 1, pts_count = 1 })); + Assert.Null(TlMessageMapper.NewMessageFrom(new UpdateMessageID { id = 7, random_id = 1 })); + } + + /// + /// Короткий личный UpdateShortMessage — библиотека синтезирует UpdateNewMessage → сообщение. + /// + [Fact] + public void NewMessageFrom_UpdateShortMessage_SynthesizesIncomingMessage() + { + var shortUpdate = new UpdateShortMessage + { + id = 9, + message = "короткое личное", + date = UtcDate(), + user_id = UserId, + pts = 1, + pts_count = 1, + }; + + var users = new Dictionary { [UserId] = PrivateUser() }; + MessageBase? synthesized = Assert.Single(shortUpdate.UpdateList) is UpdateNewMessage { message: MessageBase m } ? m : null; + + Assert.NotNull(synthesized); + Assert.NotNull(TlMessageMapper.NewMessageFrom(new UpdateNewMessage { message = synthesized! })); + TelegramMessage? mapped = TlMessageMapper.ToMessage(synthesized!, new Dictionary(), users); + Assert.NotNull(mapped); + Assert.Equal("+" + UserId, mapped.DialogId); + Assert.Equal("короткое личное", mapped.Text); + } + + /// + /// Короткий групповой UpdateShortChatMessage — синтез UpdateNewMessage в чат + /// + [Fact] + public void NewMessageFrom_UpdateShortChatMessage_SynthesizesIncomingMessage() + { + var shortUpdate = new UpdateShortChatMessage + { + id = 11, + message = "короткое в группе", + date = UtcDate(), + chat_id = ChatId, + from_id = UserId, + pts = 1, + pts_count = 1, + }; + + var chats = new Dictionary { [ChatId] = new Chat { id = ChatId, title = "Базовая группа" } }; + MessageBase? synthesized = Assert.Single(shortUpdate.UpdateList) is UpdateNewMessage { message: MessageBase m } ? m : null; + + Assert.NotNull(synthesized); + TelegramMessage? mapped = TlMessageMapper.ToMessage(synthesized!, chats, new Dictionary()); + Assert.NotNull(mapped); + Assert.Equal("-" + ChatId, mapped.DialogId); + Assert.Equal("Базовая группа", mapped.DialogName); + } + + /// + /// Маппинг сообщения канала + /// + [Fact] + public void ToMessage_ChannelMessage_MapsSignedIdAndChannelFields() + { + var message = ChannelMessage(id: 42, text: "пост канала"); + var chats = new Dictionary { [ChannelId] = ChannelEntity() }; + + TelegramMessage? mapped = TlMessageMapper.ToMessage(message, chats, new Dictionary()); + + Assert.NotNull(mapped); + Assert.Equal("-100" + ChannelId, mapped.DialogId); + Assert.Equal("пост канала", mapped.Text); + Assert.Equal("IT Канал", mapped.DialogName); + Assert.Equal("it_ch", mapped.DialogHandle); + Assert.Equal(42, mapped.Id); + Assert.Equal(new DateTimeOffset(UtcDate()).ToUnixTimeMilliseconds(), mapped.DateMs); + } + + /// + /// Исходящее (out_) сообщение не поднимается как входящее. + /// + [Fact] + public void ToMessage_OutgoingMessage_ReturnsNull() + { + var message = ChannelMessage(id: 43, text: "своё исходящее", flags: Message.Flags.out_); + + Assert.Null(TlMessageMapper.ToMessage(message, new Dictionary(), new Dictionary())); + } + + /// + /// Пустой/служебный текст не отдаётся в downstream. + /// + [Fact] + public void ToMessage_EmptyOrServiceMessage_ReturnsNull() + { + Assert.Null(TlMessageMapper.ToMessage(ChannelMessage(id: 44, text: " "), new Dictionary(), new Dictionary())); + Assert.Null(TlMessageMapper.ToMessage(new MessageService { id = 45, peer_id = new PeerChannel { channel_id = ChannelId } }, new Dictionary(), new Dictionary())); + } + + /// + /// Медиа-сообщение без текста (подпись пуста) не отдаётся. + /// + [Fact] + public void ToMessage_MediaMessageWithoutText_ReturnsNull() + { + var message = new Message + { + id = 46, + message = string.Empty, + date = UtcDate(), + peer_id = new PeerChannel { channel_id = ChannelId }, + media = new MessageMediaPhoto { photo = new PhotoEmpty { id = 1 } }, + }; + + Assert.Null(TlMessageMapper.ToMessage(message, new Dictionary(), new Dictionary())); + } + + /// + /// Сообщение личного чата + /// + [Fact] + public void ToMessage_PrivateMessage_MapsUserDialog() + { + var message = PrivateMessage(id: 3, text: "личное"); + var users = new Dictionary { [UserId] = PrivateUser() }; + + TelegramMessage? mapped = TlMessageMapper.ToMessage(message, new Dictionary(), users); + + Assert.NotNull(mapped); + Assert.Equal("+" + UserId, mapped.DialogId); + Assert.Equal("Иван Петров", mapped.DialogName); + Assert.Equal("ivan", mapped.DialogHandle); + } + + // Дата сообщения канала сценария (UTC). + private static DateTime UtcDate() => new(2023, 11, 15, 12, 0, 0, DateTimeKind.Utc); + + // Сообщение канала (peer_id — PeerChannel). + // id: Id сообщения. + // text: Текст. + // flags: Флаги сообщения (по умолчанию входящее). + private static Message ChannelMessage( + int id, + string text, + Message.Flags flags = 0) + => new() + { + id = id, + message = text, + flags = flags, + date = UtcDate(), + peer_id = new PeerChannel { channel_id = ChannelId }, + }; + + // Сообщение личного чата (peer_id — PeerUser). + // id: Id сообщения. + // text: Текст. + private static Message PrivateMessage(int id, string text) + => new() + { + id = id, + message = text, + date = UtcDate(), + peer_id = new PeerUser { user_id = UserId }, + }; + + // Канал сценария (broadcast, title/username). + private static Channel ChannelEntity() + => new() + { + id = ChannelId, + access_hash = 987654321, + title = "IT Канал", + username = "it_ch", + flags = Channel.Flags.broadcast, + }; + + // Пользователь сценария (имя/username). + private static User PrivateUser() + => new() + { + id = UserId, + access_hash = 123456, + first_name = "Иван", + last_name = "Петров", + username = "ivan", + }; +} diff --git a/src/telegram-service/Deal.Telegram.sln b/src/telegram-service/Deal.Telegram.sln index 5c43cdd..75b0119 100644 --- a/src/telegram-service/Deal.Telegram.sln +++ b/src/telegram-service/Deal.Telegram.sln @@ -1,76 +1,76 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Telegram", "Deal.Telegram\Deal.Telegram.csproj", "{34AB858D-C167-4A54-A45D-B3D8551842CF}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Proto", "..\contracts\Deal.Proto.csproj", "{B4B43971-AFA3-4266-8F18-63BBB52B5F9A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Telegram.Tests", "Deal.Telegram.Tests\Deal.Telegram.Tests.csproj", "{4C13F922-2C4F-4B79-A5C6-07C58C166F2E}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Grpc.Hosting", "..\grpc-hosting\Deal.Grpc.Hosting\Deal.Grpc.Hosting.csproj", "{03D1A800-5206-467E-92F1-0EED932AF0E9}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {34AB858D-C167-4A54-A45D-B3D8551842CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {34AB858D-C167-4A54-A45D-B3D8551842CF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {34AB858D-C167-4A54-A45D-B3D8551842CF}.Debug|x64.ActiveCfg = Debug|Any CPU - {34AB858D-C167-4A54-A45D-B3D8551842CF}.Debug|x64.Build.0 = Debug|Any CPU - {34AB858D-C167-4A54-A45D-B3D8551842CF}.Debug|x86.ActiveCfg = Debug|Any CPU - {34AB858D-C167-4A54-A45D-B3D8551842CF}.Debug|x86.Build.0 = Debug|Any CPU - {34AB858D-C167-4A54-A45D-B3D8551842CF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {34AB858D-C167-4A54-A45D-B3D8551842CF}.Release|Any CPU.Build.0 = Release|Any CPU - {34AB858D-C167-4A54-A45D-B3D8551842CF}.Release|x64.ActiveCfg = Release|Any CPU - {34AB858D-C167-4A54-A45D-B3D8551842CF}.Release|x64.Build.0 = Release|Any CPU - {34AB858D-C167-4A54-A45D-B3D8551842CF}.Release|x86.ActiveCfg = Release|Any CPU - {34AB858D-C167-4A54-A45D-B3D8551842CF}.Release|x86.Build.0 = Release|Any CPU - {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Debug|x64.ActiveCfg = Debug|Any CPU - {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Debug|x64.Build.0 = Debug|Any CPU - {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Debug|x86.ActiveCfg = Debug|Any CPU - {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Debug|x86.Build.0 = Debug|Any CPU - {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Release|Any CPU.Build.0 = Release|Any CPU - {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Release|x64.ActiveCfg = Release|Any CPU - {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Release|x64.Build.0 = Release|Any CPU - {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Release|x86.ActiveCfg = Release|Any CPU - {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Release|x86.Build.0 = Release|Any CPU - {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Debug|x64.ActiveCfg = Debug|Any CPU - {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Debug|x64.Build.0 = Debug|Any CPU - {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Debug|x86.ActiveCfg = Debug|Any CPU - {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Debug|x86.Build.0 = Debug|Any CPU - {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Release|Any CPU.Build.0 = Release|Any CPU - {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Release|x64.ActiveCfg = Release|Any CPU - {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Release|x64.Build.0 = Release|Any CPU - {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Release|x86.ActiveCfg = Release|Any CPU - {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Release|x86.Build.0 = Release|Any CPU - {03D1A800-5206-467E-92F1-0EED932AF0E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {03D1A800-5206-467E-92F1-0EED932AF0E9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {03D1A800-5206-467E-92F1-0EED932AF0E9}.Debug|x64.ActiveCfg = Debug|Any CPU - {03D1A800-5206-467E-92F1-0EED932AF0E9}.Debug|x64.Build.0 = Debug|Any CPU - {03D1A800-5206-467E-92F1-0EED932AF0E9}.Debug|x86.ActiveCfg = Debug|Any CPU - {03D1A800-5206-467E-92F1-0EED932AF0E9}.Debug|x86.Build.0 = Debug|Any CPU - {03D1A800-5206-467E-92F1-0EED932AF0E9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {03D1A800-5206-467E-92F1-0EED932AF0E9}.Release|Any CPU.Build.0 = Release|Any CPU - {03D1A800-5206-467E-92F1-0EED932AF0E9}.Release|x64.ActiveCfg = Release|Any CPU - {03D1A800-5206-467E-92F1-0EED932AF0E9}.Release|x64.Build.0 = Release|Any CPU - {03D1A800-5206-467E-92F1-0EED932AF0E9}.Release|x86.ActiveCfg = Release|Any CPU - {03D1A800-5206-467E-92F1-0EED932AF0E9}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Telegram", "Deal.Telegram\Deal.Telegram.csproj", "{34AB858D-C167-4A54-A45D-B3D8551842CF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Proto", "..\contracts\Deal.Proto.csproj", "{B4B43971-AFA3-4266-8F18-63BBB52B5F9A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Telegram.Tests", "Deal.Telegram.Tests\Deal.Telegram.Tests.csproj", "{4C13F922-2C4F-4B79-A5C6-07C58C166F2E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deal.Grpc.Hosting", "..\grpc-hosting\Deal.Grpc.Hosting\Deal.Grpc.Hosting.csproj", "{03D1A800-5206-467E-92F1-0EED932AF0E9}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {34AB858D-C167-4A54-A45D-B3D8551842CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {34AB858D-C167-4A54-A45D-B3D8551842CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {34AB858D-C167-4A54-A45D-B3D8551842CF}.Debug|x64.ActiveCfg = Debug|Any CPU + {34AB858D-C167-4A54-A45D-B3D8551842CF}.Debug|x64.Build.0 = Debug|Any CPU + {34AB858D-C167-4A54-A45D-B3D8551842CF}.Debug|x86.ActiveCfg = Debug|Any CPU + {34AB858D-C167-4A54-A45D-B3D8551842CF}.Debug|x86.Build.0 = Debug|Any CPU + {34AB858D-C167-4A54-A45D-B3D8551842CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {34AB858D-C167-4A54-A45D-B3D8551842CF}.Release|Any CPU.Build.0 = Release|Any CPU + {34AB858D-C167-4A54-A45D-B3D8551842CF}.Release|x64.ActiveCfg = Release|Any CPU + {34AB858D-C167-4A54-A45D-B3D8551842CF}.Release|x64.Build.0 = Release|Any CPU + {34AB858D-C167-4A54-A45D-B3D8551842CF}.Release|x86.ActiveCfg = Release|Any CPU + {34AB858D-C167-4A54-A45D-B3D8551842CF}.Release|x86.Build.0 = Release|Any CPU + {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Debug|x64.Build.0 = Debug|Any CPU + {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Debug|x86.Build.0 = Debug|Any CPU + {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Release|Any CPU.Build.0 = Release|Any CPU + {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Release|x64.ActiveCfg = Release|Any CPU + {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Release|x64.Build.0 = Release|Any CPU + {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Release|x86.ActiveCfg = Release|Any CPU + {B4B43971-AFA3-4266-8F18-63BBB52B5F9A}.Release|x86.Build.0 = Release|Any CPU + {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Debug|x64.ActiveCfg = Debug|Any CPU + {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Debug|x64.Build.0 = Debug|Any CPU + {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Debug|x86.ActiveCfg = Debug|Any CPU + {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Debug|x86.Build.0 = Debug|Any CPU + {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Release|Any CPU.Build.0 = Release|Any CPU + {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Release|x64.ActiveCfg = Release|Any CPU + {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Release|x64.Build.0 = Release|Any CPU + {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Release|x86.ActiveCfg = Release|Any CPU + {4C13F922-2C4F-4B79-A5C6-07C58C166F2E}.Release|x86.Build.0 = Release|Any CPU + {03D1A800-5206-467E-92F1-0EED932AF0E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {03D1A800-5206-467E-92F1-0EED932AF0E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {03D1A800-5206-467E-92F1-0EED932AF0E9}.Debug|x64.ActiveCfg = Debug|Any CPU + {03D1A800-5206-467E-92F1-0EED932AF0E9}.Debug|x64.Build.0 = Debug|Any CPU + {03D1A800-5206-467E-92F1-0EED932AF0E9}.Debug|x86.ActiveCfg = Debug|Any CPU + {03D1A800-5206-467E-92F1-0EED932AF0E9}.Debug|x86.Build.0 = Debug|Any CPU + {03D1A800-5206-467E-92F1-0EED932AF0E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {03D1A800-5206-467E-92F1-0EED932AF0E9}.Release|Any CPU.Build.0 = Release|Any CPU + {03D1A800-5206-467E-92F1-0EED932AF0E9}.Release|x64.ActiveCfg = Release|Any CPU + {03D1A800-5206-467E-92F1-0EED932AF0E9}.Release|x64.Build.0 = Release|Any CPU + {03D1A800-5206-467E-92F1-0EED932AF0E9}.Release|x86.ActiveCfg = Release|Any CPU + {03D1A800-5206-467E-92F1-0EED932AF0E9}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/src/telegram-service/Deal.Telegram/Caching/LruCache.cs b/src/telegram-service/Deal.Telegram/Caching/LruCache.cs index bd48544..4dbab01 100644 --- a/src/telegram-service/Deal.Telegram/Caching/LruCache.cs +++ b/src/telegram-service/Deal.Telegram/Caching/LruCache.cs @@ -1,109 +1,109 @@ -using System.Diagnostics.CodeAnalysis; - -namespace Deal.Telegram.Caching; - -/// -/// Кэш с ограниченной ёмкостью и вытеснением least-recently-used -/// -/// Тип ключа (ссылочный или значимый). -/// Тип значения. -public sealed class LruCache - where TKey : notnull -{ - private readonly Dictionary> _map; - private readonly LinkedList _recency = new(); - - /// - /// Создаёт кэш заданной ёмкости. - /// - /// Максимальное число элементов (должно быть больше нуля). - /// Ёмкость не положительна. - public LruCache(int capacity) - { - if (capacity <= 0) - { - throw new ArgumentOutOfRangeException(nameof(capacity), capacity, "Ёмкость LRU-кэша должна быть положительной"); - } - - Capacity = capacity; - _map = new Dictionary>(capacity); - } - - /// - /// Максимальное число элементов кэша. - /// - public int Capacity { get; } - - /// - /// Текущее число элементов кэша. - /// - public int Count => _map.Count; - - /// - /// Пробует получить значение по ключу, отмечая его как недавно использованный. - /// - /// Ключ. - /// Найденное значение (иначе значение по умолчанию). - /// True — ключ найден. - public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) - { - if (!_map.TryGetValue(key, out LinkedListNode? node)) - { - value = default; - return false; - } - - Touch(node); - value = node.Value.Value; - return true; - } - - /// - /// Проверяет наличие ключа, не меняя недавность - /// - /// Ключ. - /// True — ключ есть в кэше. - public bool ContainsKey(TKey key) => _map.ContainsKey(key); - - /// - /// Записывает значение по ключу - /// - /// Ключ. - /// Значение. - public void Set(TKey key, TValue value) - { - if (_map.TryGetValue(key, out LinkedListNode? existing)) - { - existing.Value = new Entry(key, value); - Touch(existing); - return; - } - - var node = new LinkedListNode(new Entry(key, value)); - _recency.AddFirst(node); - _map[key] = node; - - if (_map.Count > Capacity) - { - LinkedListNode oldest = _recency.Last!; - _recency.RemoveLast(); - _map.Remove(oldest.Value.Key); - } - } - - // Перемещает узел в начало списка недавности (последний использованный). - // node: Узел кэша. - private void Touch(LinkedListNode node) - { - if (!ReferenceEquals(node, _recency.First)) - { - _recency.Remove(node); - _recency.AddFirst(node); - } - } - - // Элемент кэша (ключ хранится для удаления при вытеснении из списка недавности). - // Key: Ключ. - // Value: Значение. - private readonly record struct Entry(TKey Key, TValue Value); -} +using System.Diagnostics.CodeAnalysis; + +namespace Deal.Telegram.Caching; + +/// +/// Кэш с ограниченной ёмкостью и вытеснением least-recently-used +/// +/// Тип ключа (ссылочный или значимый). +/// Тип значения. +public sealed class LruCache + where TKey : notnull +{ + private readonly Dictionary> _map; + private readonly LinkedList _recency = new(); + + /// + /// Создаёт кэш заданной ёмкости. + /// + /// Максимальное число элементов (должно быть больше нуля). + /// Ёмкость не положительна. + public LruCache(int capacity) + { + if (capacity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(capacity), capacity, "Ёмкость LRU-кэша должна быть положительной"); + } + + Capacity = capacity; + _map = new Dictionary>(capacity); + } + + /// + /// Максимальное число элементов кэша. + /// + public int Capacity { get; } + + /// + /// Текущее число элементов кэша. + /// + public int Count => _map.Count; + + /// + /// Пробует получить значение по ключу, отмечая его как недавно использованный. + /// + /// Ключ. + /// Найденное значение (иначе значение по умолчанию). + /// True — ключ найден. + public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) + { + if (!_map.TryGetValue(key, out LinkedListNode? node)) + { + value = default; + return false; + } + + Touch(node); + value = node.Value.Value; + return true; + } + + /// + /// Проверяет наличие ключа, не меняя недавность + /// + /// Ключ. + /// True — ключ есть в кэше. + public bool ContainsKey(TKey key) => _map.ContainsKey(key); + + /// + /// Записывает значение по ключу + /// + /// Ключ. + /// Значение. + public void Set(TKey key, TValue value) + { + if (_map.TryGetValue(key, out LinkedListNode? existing)) + { + existing.Value = new Entry(key, value); + Touch(existing); + return; + } + + var node = new LinkedListNode(new Entry(key, value)); + _recency.AddFirst(node); + _map[key] = node; + + if (_map.Count > Capacity) + { + LinkedListNode oldest = _recency.Last!; + _recency.RemoveLast(); + _map.Remove(oldest.Value.Key); + } + } + + // Перемещает узел в начало списка недавности (последний использованный). + // node: Узел кэша. + private void Touch(LinkedListNode node) + { + if (!ReferenceEquals(node, _recency.First)) + { + _recency.Remove(node); + _recency.AddFirst(node); + } + } + + // Элемент кэша (ключ хранится для удаления при вытеснении из списка недавности). + // Key: Ключ. + // Value: Значение. + private readonly record struct Entry(TKey Key, TValue Value); +} diff --git a/src/telegram-service/Deal.Telegram/Core/CoreIngressClient.cs b/src/telegram-service/Deal.Telegram/Core/CoreIngressClient.cs index 6b5f350..1e9298f 100644 --- a/src/telegram-service/Deal.Telegram/Core/CoreIngressClient.cs +++ b/src/telegram-service/Deal.Telegram/Core/CoreIngressClient.cs @@ -1,136 +1,136 @@ -using Deal.Grpc.Hosting.Interceptors; -using Deal.Grpc.Hosting.Models; -using Deal.Grpc.Hosting.Options; -using Deal.Grpc.Hosting.Services; -using Deal.Grpc.Sources; -using Deal.Grpc.Telegram; -using Deal.Telegram.Sessions; -using Grpc.Core; -using Grpc.Net.Client; - -namespace Deal.Telegram.Core; - -/// -/// Исходящий gRPC-канал в ядро -/// -public sealed class CoreIngressClient : ICoreIngressClient -{ - public const string TenantIdMetadataKey = "tenant-id"; - - public const string ServiceTokenMetadataKey = "service-token"; - - private readonly CoreIngressOptions _options; - private readonly ILogger _logger; - private readonly MtlsCertificates? _mtlsCertificates; - private readonly object _channelGate = new(); - private IngressService.IngressServiceClient? _ingressClient; - private SourceIngressService.SourceIngressServiceClient? _sourceClient; - private GrpcChannel? _channel; - - /// - /// Создаёт клиент ингресса ядра. - /// - /// Конфигурация (адрес из env, service-token). - /// Логгер. - /// Сертификаты mTLS: null — plaintext-канал (dev). - public CoreIngressClient( - CoreIngressOptions options, - ILogger logger, - MtlsCertificates? mtlsCertificates = null) - { - _options = options; - _logger = logger; - _mtlsCertificates = mtlsCertificates; - } - - /// - public async Task PushSourceAsync( - string tenantId, - PushSourceRequest request, - CancellationToken cancellationToken) - { - SourceIngressService.SourceIngressServiceClient client = GetSourceClient(); - try - { - return await client.PushSourceAsync(request, CallOptions(tenantId)).ResponseAsync.WaitAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - throw Fail("PushSource", exception); - } - } - - /// - public async Task> SyncDialogsAsync( - string tenantId, - IReadOnlyList entries, - CancellationToken cancellationToken) - { - IngressService.IngressServiceClient client = GetIngressClient(); - var request = new SyncDialogsRequest(); - request.Entries.AddRange(entries); - try - { - SyncDialogsReply reply = await client.SyncDialogsAsync(request, CallOptions(tenantId)).ResponseAsync.WaitAsync(cancellationToken).ConfigureAwait(false); - return reply.MonitoredIds; - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - throw Fail("SyncDialogs", exception); - } - } - - // Клиент SourceIngress (PushSource); канал к ядру создаётся лениво. - private SourceIngressService.SourceIngressServiceClient GetSourceClient() - { - lock (_channelGate) - { - EnsureChannelLocked(); - return _sourceClient!; - } - } - - // Клиент Ingress (SyncDialogs); канал к ядру создаётся лениво. - private IngressService.IngressServiceClient GetIngressClient() - { - lock (_channelGate) - { - EnsureChannelLocked(); - return _ingressClient!; - } - } - - // Лениво создаёт gRPC-канал к ядру (адрес неизменен на время жизни процесса). - private void EnsureChannelLocked() - { - if (_channel is not null) - { - return; - } - - _channel = _mtlsCertificates is not null - ? GrpcChannel.ForAddress( - _options.IngressEndpoint, - new GrpcChannelOptions { HttpHandler = _mtlsCertificates.CreateClientHttpHandler() }) - : GrpcChannel.ForAddress(_options.IngressEndpoint); - - _ingressClient = new IngressService.IngressServiceClient(_channel); - _sourceClient = new SourceIngressService.SourceIngressServiceClient(_channel); - } - - private CallOptions CallOptions(string tenantId) - { - var metadata = new Metadata - { - { TenantIdMetadataKey, tenantId }, - { ServiceTokenMetadataKey, _options.ServiceToken }, - }; - return new CallOptions(metadata, deadline: DateTime.UtcNow.AddSeconds(CoreIngressOptions.RpcTimeoutSeconds)); - } - - private SessionException Fail(string action, Exception exception) - { - _logger.LogWarning(exception, "Аудит: ингресс ядра {Action} → сбой ({Endpoint})", action, _options.IngressEndpoint); - return new SessionException(StatusCode.Unavailable, SessionErrorMessages.IngressUnavailable, exception); - } -} +using Deal.Grpc.Hosting.Interceptors; +using Deal.Grpc.Hosting.Models; +using Deal.Grpc.Hosting.Options; +using Deal.Grpc.Hosting.Services; +using Deal.Grpc.Sources; +using Deal.Grpc.Telegram; +using Deal.Telegram.Sessions; +using Grpc.Core; +using Grpc.Net.Client; + +namespace Deal.Telegram.Core; + +/// +/// Исходящий gRPC-канал в ядро +/// +public sealed class CoreIngressClient : ICoreIngressClient +{ + public const string TenantIdMetadataKey = "tenant-id"; + + public const string ServiceTokenMetadataKey = "service-token"; + + private readonly CoreIngressOptions _options; + private readonly ILogger _logger; + private readonly MtlsCertificates? _mtlsCertificates; + private readonly object _channelGate = new(); + private IngressService.IngressServiceClient? _ingressClient; + private SourceIngressService.SourceIngressServiceClient? _sourceClient; + private GrpcChannel? _channel; + + /// + /// Создаёт клиент ингресса ядра. + /// + /// Конфигурация (адрес из env, service-token). + /// Логгер. + /// Сертификаты mTLS: null — plaintext-канал (dev). + public CoreIngressClient( + CoreIngressOptions options, + ILogger logger, + MtlsCertificates? mtlsCertificates = null) + { + _options = options; + _logger = logger; + _mtlsCertificates = mtlsCertificates; + } + + /// + public async Task PushSourceAsync( + string tenantId, + PushSourceRequest request, + CancellationToken cancellationToken) + { + SourceIngressService.SourceIngressServiceClient client = GetSourceClient(); + try + { + return await client.PushSourceAsync(request, CallOptions(tenantId)).ResponseAsync.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + throw Fail("PushSource", exception); + } + } + + /// + public async Task> SyncDialogsAsync( + string tenantId, + IReadOnlyList entries, + CancellationToken cancellationToken) + { + IngressService.IngressServiceClient client = GetIngressClient(); + var request = new SyncDialogsRequest(); + request.Entries.AddRange(entries); + try + { + SyncDialogsReply reply = await client.SyncDialogsAsync(request, CallOptions(tenantId)).ResponseAsync.WaitAsync(cancellationToken).ConfigureAwait(false); + return reply.MonitoredIds; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + throw Fail("SyncDialogs", exception); + } + } + + // Клиент SourceIngress (PushSource); канал к ядру создаётся лениво. + private SourceIngressService.SourceIngressServiceClient GetSourceClient() + { + lock (_channelGate) + { + EnsureChannelLocked(); + return _sourceClient!; + } + } + + // Клиент Ingress (SyncDialogs); канал к ядру создаётся лениво. + private IngressService.IngressServiceClient GetIngressClient() + { + lock (_channelGate) + { + EnsureChannelLocked(); + return _ingressClient!; + } + } + + // Лениво создаёт gRPC-канал к ядру (адрес неизменен на время жизни процесса). + private void EnsureChannelLocked() + { + if (_channel is not null) + { + return; + } + + _channel = _mtlsCertificates is not null + ? GrpcChannel.ForAddress( + _options.IngressEndpoint, + new GrpcChannelOptions { HttpHandler = _mtlsCertificates.CreateClientHttpHandler() }) + : GrpcChannel.ForAddress(_options.IngressEndpoint); + + _ingressClient = new IngressService.IngressServiceClient(_channel); + _sourceClient = new SourceIngressService.SourceIngressServiceClient(_channel); + } + + private CallOptions CallOptions(string tenantId) + { + var metadata = new Metadata + { + { TenantIdMetadataKey, tenantId }, + { ServiceTokenMetadataKey, _options.ServiceToken }, + }; + return new CallOptions(metadata, deadline: DateTime.UtcNow.AddSeconds(CoreIngressOptions.RpcTimeoutSeconds)); + } + + private SessionException Fail(string action, Exception exception) + { + _logger.LogWarning(exception, "Аудит: ингресс ядра {Action} → сбой ({Endpoint})", action, _options.IngressEndpoint); + return new SessionException(StatusCode.Unavailable, SessionErrorMessages.IngressUnavailable, exception); + } +} diff --git a/src/telegram-service/Deal.Telegram/Core/CoreIngressOptions.cs b/src/telegram-service/Deal.Telegram/Core/CoreIngressOptions.cs index 222c2ac..6a25d5c 100644 --- a/src/telegram-service/Deal.Telegram/Core/CoreIngressOptions.cs +++ b/src/telegram-service/Deal.Telegram/Core/CoreIngressOptions.cs @@ -1,77 +1,77 @@ -namespace Deal.Telegram.Core; - -/// -/// Конфигурация исходящего канала в ядро. -/// -public sealed class CoreIngressOptions -{ - /// - /// Env-ключ адреса gRPC-ингресса ядра. - /// - public const string IngressEndpointEnvVarName = "SERVICES__CORE__INGRESS"; - - public const string IngressEndpointConfigKey = "Services:Core:Ingress"; - - /// - /// Env-ключ service-token. - /// - public const string ServiceTokenEnvVarName = "DEAL_SERVICE_TOKEN"; - - /// - /// Адрес ингресса ядра по умолчанию. - /// - public const string DefaultIngressEndpoint = "http://localhost:5082"; - - /// - /// Таймаут одного RPC в ядро - /// - public const int RpcTimeoutSeconds = 15; - - private CoreIngressOptions(string ingressEndpoint, string serviceToken) - { - IngressEndpoint = ingressEndpoint; - ServiceToken = serviceToken; - } - - /// - /// Адрес gRPC-ингресса ядра - /// - public string IngressEndpoint { get; } - - /// - /// Service-token для metadata каждого RPC - /// - public string ServiceToken { get; } - - /// - /// Создаёт опции с явным адресом и токеном - /// - /// Адрес ингресса ядра. - /// Service-token (пустой — вызовы будут отвергнуты ядром). - public static CoreIngressOptions Create(string ingressEndpoint, string serviceToken) - { - if (string.IsNullOrWhiteSpace(ingressEndpoint)) - { - throw new ArgumentException("Адрес ингресса ядра не задан.", nameof(ingressEndpoint)); - } - - return new CoreIngressOptions(ingressEndpoint, serviceToken); - } - - /// - /// Читает конфигурацию из env/конфигурации хоста. - /// - /// Конфигурация хоста (env-провайдер WebApplicationBuilder). - /// Опции исходящего канала в ядро. - public static CoreIngressOptions FromConfiguration(IConfiguration configuration) - { - string? endpoint = configuration[IngressEndpointConfigKey]; - if (string.IsNullOrWhiteSpace(endpoint)) - { - endpoint = DefaultIngressEndpoint; - } - - string token = configuration[ServiceTokenEnvVarName] ?? string.Empty; - return new CoreIngressOptions(endpoint.Trim(), token); - } -} +namespace Deal.Telegram.Core; + +/// +/// Конфигурация исходящего канала в ядро. +/// +public sealed class CoreIngressOptions +{ + /// + /// Env-ключ адреса gRPC-ингресса ядра. + /// + public const string IngressEndpointEnvVarName = "SERVICES__CORE__INGRESS"; + + public const string IngressEndpointConfigKey = "Services:Core:Ingress"; + + /// + /// Env-ключ service-token. + /// + public const string ServiceTokenEnvVarName = "DEAL_SERVICE_TOKEN"; + + /// + /// Адрес ингресса ядра по умолчанию. + /// + public const string DefaultIngressEndpoint = "http://localhost:5082"; + + /// + /// Таймаут одного RPC в ядро + /// + public const int RpcTimeoutSeconds = 15; + + private CoreIngressOptions(string ingressEndpoint, string serviceToken) + { + IngressEndpoint = ingressEndpoint; + ServiceToken = serviceToken; + } + + /// + /// Адрес gRPC-ингресса ядра + /// + public string IngressEndpoint { get; } + + /// + /// Service-token для metadata каждого RPC + /// + public string ServiceToken { get; } + + /// + /// Создаёт опции с явным адресом и токеном + /// + /// Адрес ингресса ядра. + /// Service-token (пустой — вызовы будут отвергнуты ядром). + public static CoreIngressOptions Create(string ingressEndpoint, string serviceToken) + { + if (string.IsNullOrWhiteSpace(ingressEndpoint)) + { + throw new ArgumentException("Адрес ингресса ядра не задан.", nameof(ingressEndpoint)); + } + + return new CoreIngressOptions(ingressEndpoint, serviceToken); + } + + /// + /// Читает конфигурацию из env/конфигурации хоста. + /// + /// Конфигурация хоста (env-провайдер WebApplicationBuilder). + /// Опции исходящего канала в ядро. + public static CoreIngressOptions FromConfiguration(IConfiguration configuration) + { + string? endpoint = configuration[IngressEndpointConfigKey]; + if (string.IsNullOrWhiteSpace(endpoint)) + { + endpoint = DefaultIngressEndpoint; + } + + string token = configuration[ServiceTokenEnvVarName] ?? string.Empty; + return new CoreIngressOptions(endpoint.Trim(), token); + } +} diff --git a/src/telegram-service/Deal.Telegram/Core/ICoreIngressClient.cs b/src/telegram-service/Deal.Telegram/Core/ICoreIngressClient.cs index 8d586db..22d56ad 100644 --- a/src/telegram-service/Deal.Telegram/Core/ICoreIngressClient.cs +++ b/src/telegram-service/Deal.Telegram/Core/ICoreIngressClient.cs @@ -1,34 +1,34 @@ -using Deal.Grpc.Sources; -using Deal.Grpc.Telegram; - -namespace Deal.Telegram.Core; - -/// -/// Исходящий канал в ядро. -/// -public interface ICoreIngressClient -{ - /// - /// Отправляет запись источника в ядро. - /// - /// Id тенанта. - /// Запись в контракте PushSourceRequest. - /// Отмена вызова. - /// Ответ ядра (accepted/duplicate — дубль источника в очереди не растёт). - public Task PushSourceAsync( - string tenantId, - PushSourceRequest request, - CancellationToken cancellationToken); - - /// - /// Синхронизирует каталог с ядром. - /// - /// Id тенанта. - /// Актуальный каталог диалогов (как refresh_dialogs). - /// Отмена вызова. - /// Список id диалогов с включённым мониторингом (по версии ядра). - public Task> SyncDialogsAsync( - string tenantId, - IReadOnlyList entries, - CancellationToken cancellationToken); -} +using Deal.Grpc.Sources; +using Deal.Grpc.Telegram; + +namespace Deal.Telegram.Core; + +/// +/// Исходящий канал в ядро. +/// +public interface ICoreIngressClient +{ + /// + /// Отправляет запись источника в ядро. + /// + /// Id тенанта. + /// Запись в контракте PushSourceRequest. + /// Отмена вызова. + /// Ответ ядра (accepted/duplicate — дубль источника в очереди не растёт). + public Task PushSourceAsync( + string tenantId, + PushSourceRequest request, + CancellationToken cancellationToken); + + /// + /// Синхронизирует каталог с ядром. + /// + /// Id тенанта. + /// Актуальный каталог диалогов (как refresh_dialogs). + /// Отмена вызова. + /// Список id диалогов с включённым мониторингом (по версии ядра). + public Task> SyncDialogsAsync( + string tenantId, + IReadOnlyList entries, + CancellationToken cancellationToken); +} diff --git a/src/telegram-service/Deal.Telegram/Deal.Telegram.csproj b/src/telegram-service/Deal.Telegram/Deal.Telegram.csproj index 4cbdd6f..6f91c32 100644 --- a/src/telegram-service/Deal.Telegram/Deal.Telegram.csproj +++ b/src/telegram-service/Deal.Telegram/Deal.Telegram.csproj @@ -1,40 +1,40 @@ - - - - - Deal.Telegram - Deal.Telegram - - - - - - - - - - - - - - - - - - - - - + + + + + Deal.Telegram + Deal.Telegram + + + + + + + + + + + + + + + + + + + + + diff --git a/src/telegram-service/Deal.Telegram/Dialogs/BackfillService.cs b/src/telegram-service/Deal.Telegram/Dialogs/BackfillService.cs index 1fe0758..2db273a 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/BackfillService.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/BackfillService.cs @@ -1,185 +1,185 @@ -using System.Collections.Concurrent; -using Deal.Grpc.Sources; -using Deal.Telegram.Core; -using Deal.Telegram.Sessions; -using Deal.Telegram.Telegram; - -namespace Deal.Telegram.Dialogs; - -/// -/// Backfill диалога -/// -public sealed class BackfillService -{ - /// - /// Сколько последних сообщений читает backfill. - /// - public const int MessagesLimit = 10; - - /// - /// Нижняя граница анти-бан-паузы между сообщениями. - /// - public const double MinPerMessageDelaySeconds = 1.5; - - /// - /// Верхняя граница анти-бан-паузы между сообщениями. - /// - public const double MaxPerMessageDelaySeconds = 3.0; - - /// - /// Нижняя граница паузы между диалогами одного тенанта. - /// - public const double MinPerDialogDelaySeconds = 3.0; - - /// - /// Верхняя граница паузы между диалогами одного тенанта. - /// - public const double MaxPerDialogDelaySeconds = 6.0; - - private readonly SessionFarm _sessionFarm; - private readonly ICoreIngressClient _ingress; - private readonly IBackfillPacer _pacer; - private readonly ILogger _logger; - - private readonly ConcurrentDictionary<(string TenantId, string DialogId), byte> _running = new(); - - private readonly ConcurrentDictionary _tenantGates = new(StringComparer.Ordinal); - - // Момент завершения последнего backfill тенанта (пауза 3–6 с между диалогами). - private readonly Dictionary _tenantLastFinishUtc = new(StringComparer.Ordinal); - - private readonly object _lastFinishGate = new(); - - /// - /// Создаёт службу backfill'а. - /// - /// Пул сессий тенантов (read-операции диалогов). - /// Канал в ядро (PushSource). - /// Анти-бан-паузы (реальный — случайные, тесты — фейк). - /// Логгер. - public BackfillService( - SessionFarm sessionFarm, - ICoreIngressClient ingress, - IBackfillPacer pacer, - ILogger logger) - { - _sessionFarm = sessionFarm; - _ingress = ingress; - _pacer = pacer; - _logger = logger; - } - - /// - /// Перечитывает последние сообщения диалога в ядро - /// - /// Id тенанта. - /// Подписанный id диалога. - /// True — «Перечитать» по кнопке (семантика флага — в ядре, см. класс). - /// Отмена операции. - /// Сколько сообщений отправлено в ядро (0 — диалог уже перечитывается/нет текстов). - public async Task ExecuteAsync( - string tenantId, - string dialogId, - bool force, - CancellationToken cancellationToken) - { - var key = (tenantId, dialogId); - if (!_running.TryAdd(key, 0)) - { - _logger.LogInformation("Аудит: backfill {TenantId} {DialogId} → пропущен (уже перечитывается)", tenantId, dialogId); - return 0; - } - - try - { - SemaphoreSlim gate = _tenantGates.GetOrAdd(tenantId, static _ => new SemaphoreSlim(1, 1)); - await gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - await EnsureDialogSpacingAsync(tenantId, cancellationToken).ConfigureAwait(false); - int processed = await BackfillDialogAsync(tenantId, dialogId, cancellationToken).ConfigureAwait(false); - lock (_lastFinishGate) - { - _tenantLastFinishUtc[tenantId] = DateTime.UtcNow; - } - - _logger.LogInformation( - "Аудит: backfill {TenantId} {DialogId} → {Processed} сообщений в ядро (force {Force})", - tenantId, - dialogId, - processed, - force); - return processed; - } - finally - { - gate.Release(); - } - } - finally - { - _running.TryRemove(key, out _); - } - } - - private async Task EnsureDialogSpacingAsync(string tenantId, CancellationToken cancellationToken) - { - DateTime? lastFinishUtc; - lock (_lastFinishGate) - { - lastFinishUtc = _tenantLastFinishUtc.TryGetValue(tenantId, out DateTime value) ? value : null; - } - - if (lastFinishUtc is null) - { - return; - } - - double elapsedSeconds = (DateTime.UtcNow - lastFinishUtc.Value).TotalSeconds; - if (elapsedSeconds >= MaxPerDialogDelaySeconds) - { - return; - } - - double minSeconds = Math.Max(0, MinPerDialogDelaySeconds - elapsedSeconds); - double maxSeconds = MaxPerDialogDelaySeconds - elapsedSeconds; - if (maxSeconds < minSeconds) - { - maxSeconds = minSeconds; - } - - await _pacer.WaitAsync(minSeconds, maxSeconds, cancellationToken).ConfigureAwait(false); - } - - // Читает и отправляет сообщения одного диалога (паузы между сообщениями, read-ack). - // tenantId: Id тенанта. - // dialogId: Подписанный id диалога. - // cancellationToken: Отмена операции. - // Возвращает: Сколько сообщений отправлено в ядро. - private async Task BackfillDialogAsync( - string tenantId, - string dialogId, - CancellationToken cancellationToken) - { - IReadOnlyList messages = await _sessionFarm - .GetMessagesAsync(tenantId, dialogId, MessagesLimit, cancellationToken) - .ConfigureAwait(false); - - int processed = 0; - foreach (TelegramMessage message in messages.Reverse()) - { - PushSourceReply reply = await _ingress - .PushSourceAsync(tenantId, DialogProtoMapper.ToSourceRequest(message), cancellationToken) - .ConfigureAwait(false); - if (reply.Accepted && !reply.Duplicate) - { - processed++; - } - - await _pacer.WaitAsync(MinPerMessageDelaySeconds, MaxPerMessageDelaySeconds, cancellationToken).ConfigureAwait(false); - } - - await _sessionFarm.MarkReadAsync(tenantId, dialogId, cancellationToken).ConfigureAwait(false); - return processed; - } -} +using System.Collections.Concurrent; +using Deal.Grpc.Sources; +using Deal.Telegram.Core; +using Deal.Telegram.Sessions; +using Deal.Telegram.Telegram; + +namespace Deal.Telegram.Dialogs; + +/// +/// Backfill диалога +/// +public sealed class BackfillService +{ + /// + /// Сколько последних сообщений читает backfill. + /// + public const int MessagesLimit = 10; + + /// + /// Нижняя граница анти-бан-паузы между сообщениями. + /// + public const double MinPerMessageDelaySeconds = 1.5; + + /// + /// Верхняя граница анти-бан-паузы между сообщениями. + /// + public const double MaxPerMessageDelaySeconds = 3.0; + + /// + /// Нижняя граница паузы между диалогами одного тенанта. + /// + public const double MinPerDialogDelaySeconds = 3.0; + + /// + /// Верхняя граница паузы между диалогами одного тенанта. + /// + public const double MaxPerDialogDelaySeconds = 6.0; + + private readonly SessionFarm _sessionFarm; + private readonly ICoreIngressClient _ingress; + private readonly IBackfillPacer _pacer; + private readonly ILogger _logger; + + private readonly ConcurrentDictionary<(string TenantId, string DialogId), byte> _running = new(); + + private readonly ConcurrentDictionary _tenantGates = new(StringComparer.Ordinal); + + // Момент завершения последнего backfill тенанта (пауза 3–6 с между диалогами). + private readonly Dictionary _tenantLastFinishUtc = new(StringComparer.Ordinal); + + private readonly object _lastFinishGate = new(); + + /// + /// Создаёт службу backfill'а. + /// + /// Пул сессий тенантов (read-операции диалогов). + /// Канал в ядро (PushSource). + /// Анти-бан-паузы (реальный — случайные, тесты — фейк). + /// Логгер. + public BackfillService( + SessionFarm sessionFarm, + ICoreIngressClient ingress, + IBackfillPacer pacer, + ILogger logger) + { + _sessionFarm = sessionFarm; + _ingress = ingress; + _pacer = pacer; + _logger = logger; + } + + /// + /// Перечитывает последние сообщения диалога в ядро + /// + /// Id тенанта. + /// Подписанный id диалога. + /// True — «Перечитать» по кнопке (семантика флага — в ядре, см. класс). + /// Отмена операции. + /// Сколько сообщений отправлено в ядро (0 — диалог уже перечитывается/нет текстов). + public async Task ExecuteAsync( + string tenantId, + string dialogId, + bool force, + CancellationToken cancellationToken) + { + var key = (tenantId, dialogId); + if (!_running.TryAdd(key, 0)) + { + _logger.LogInformation("Аудит: backfill {TenantId} {DialogId} → пропущен (уже перечитывается)", tenantId, dialogId); + return 0; + } + + try + { + SemaphoreSlim gate = _tenantGates.GetOrAdd(tenantId, static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await EnsureDialogSpacingAsync(tenantId, cancellationToken).ConfigureAwait(false); + int processed = await BackfillDialogAsync(tenantId, dialogId, cancellationToken).ConfigureAwait(false); + lock (_lastFinishGate) + { + _tenantLastFinishUtc[tenantId] = DateTime.UtcNow; + } + + _logger.LogInformation( + "Аудит: backfill {TenantId} {DialogId} → {Processed} сообщений в ядро (force {Force})", + tenantId, + dialogId, + processed, + force); + return processed; + } + finally + { + gate.Release(); + } + } + finally + { + _running.TryRemove(key, out _); + } + } + + private async Task EnsureDialogSpacingAsync(string tenantId, CancellationToken cancellationToken) + { + DateTime? lastFinishUtc; + lock (_lastFinishGate) + { + lastFinishUtc = _tenantLastFinishUtc.TryGetValue(tenantId, out DateTime value) ? value : null; + } + + if (lastFinishUtc is null) + { + return; + } + + double elapsedSeconds = (DateTime.UtcNow - lastFinishUtc.Value).TotalSeconds; + if (elapsedSeconds >= MaxPerDialogDelaySeconds) + { + return; + } + + double minSeconds = Math.Max(0, MinPerDialogDelaySeconds - elapsedSeconds); + double maxSeconds = MaxPerDialogDelaySeconds - elapsedSeconds; + if (maxSeconds < minSeconds) + { + maxSeconds = minSeconds; + } + + await _pacer.WaitAsync(minSeconds, maxSeconds, cancellationToken).ConfigureAwait(false); + } + + // Читает и отправляет сообщения одного диалога (паузы между сообщениями, read-ack). + // tenantId: Id тенанта. + // dialogId: Подписанный id диалога. + // cancellationToken: Отмена операции. + // Возвращает: Сколько сообщений отправлено в ядро. + private async Task BackfillDialogAsync( + string tenantId, + string dialogId, + CancellationToken cancellationToken) + { + IReadOnlyList messages = await _sessionFarm + .GetMessagesAsync(tenantId, dialogId, MessagesLimit, cancellationToken) + .ConfigureAwait(false); + + int processed = 0; + foreach (TelegramMessage message in messages.Reverse()) + { + PushSourceReply reply = await _ingress + .PushSourceAsync(tenantId, DialogProtoMapper.ToSourceRequest(message), cancellationToken) + .ConfigureAwait(false); + if (reply.Accepted && !reply.Duplicate) + { + processed++; + } + + await _pacer.WaitAsync(MinPerMessageDelaySeconds, MaxPerMessageDelaySeconds, cancellationToken).ConfigureAwait(false); + } + + await _sessionFarm.MarkReadAsync(tenantId, dialogId, cancellationToken).ConfigureAwait(false); + return processed; + } +} diff --git a/src/telegram-service/Deal.Telegram/Dialogs/DialogCatalog.cs b/src/telegram-service/Deal.Telegram/Dialogs/DialogCatalog.cs index fa12baa..a50666c 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/DialogCatalog.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/DialogCatalog.cs @@ -1,195 +1,195 @@ -using System.Collections.ObjectModel; - -namespace Deal.Telegram.Dialogs; - -/// -/// Зеркало каталога диалогов тенанта в памяти telegram-service. -/// -public sealed class DialogCatalog -{ - private readonly object _gate = new(); - private readonly Dictionary> _knownByTenant = new(StringComparer.Ordinal); - private readonly Dictionary> _monitoredByTenant = new(StringComparer.Ordinal); - - /// - /// Обновляет полный каталог диалогов тенанта - /// - /// Id тенанта. - /// Актуальные id каталога (entries списка диалогов). - public void ReplaceKnown(string tenantId, IEnumerable dialogIds) - { - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); - - lock (_gate) - { - _knownByTenant[tenantId] = new HashSet(dialogIds, StringComparer.Ordinal); - } - } - - /// - /// Включает/выключает мониторинг одного диалога. - /// - /// Id тенанта. - /// Id диалога. - /// Мониторить (true) или выключить (false). - public void SetMonitored( - string tenantId, - string dialogId, - bool enabled) - { - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); - ArgumentException.ThrowIfNullOrWhiteSpace(dialogId); - - lock (_gate) - { - HashSet monitored = MonitoredSet(tenantId); - if (enabled) - { - monitored.Add(dialogId); - } - else - { - monitored.Remove(dialogId); - } - } - } - - /// - /// Заменяет monitored-набор ответом SyncDialogs. - /// - /// Id тенанта. - /// Id диалогов с включённым мониторингом. - public void ReplaceMonitored(string tenantId, IEnumerable monitoredIds) - { - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); - - lock (_gate) - { - _monitoredByTenant[tenantId] = new HashSet(monitoredIds, StringComparer.Ordinal); - } - } - - /// - /// Включает/выключает мониторинг всех диалогов каталога. - /// - /// Id тенанта. - /// Мониторить все (true) или снять мониторинг со всех (false). - /// Сколько диалогов в каталоге тенанта (SetMonitorAllReply.count). - public int SetAllMonitored(string tenantId, bool enabled) - { - ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); - - lock (_gate) - { - HashSet known = KnownSet(tenantId); - if (enabled) - { - HashSet monitored = MonitoredSet(tenantId); - monitored.UnionWith(known); - } - else - { - _monitoredByTenant[tenantId] = new HashSet(StringComparer.Ordinal); - } - - return known.Count; - } - } - - /// - /// Проверяет, мониторится ли диалог. - /// - /// Id тенанта. - /// Id диалога. - /// True — диалог в monitored-наборе тенанта. - public bool IsMonitored(string tenantId, string dialogId) - { - if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(dialogId)) - { - return false; - } - - lock (_gate) - { - return _monitoredByTenant.TryGetValue(tenantId, out HashSet? monitored) && monitored.Contains(dialogId); - } - } - - /// - /// Сколько диалогов знает каталог тенанта - /// - /// Id тенанта. - /// Размер каталога; 0 — каталог ещё не синхронизирован (нет refresh/sweep). - public int KnownCount(string tenantId) - { - if (string.IsNullOrWhiteSpace(tenantId)) - { - return 0; - } - - lock (_gate) - { - return _knownByTenant.TryGetValue(tenantId, out HashSet? known) ? known.Count : 0; - } - } - - /// - /// Возвращает копию мониторящихся id тенанта - /// - /// Id тенанта. - /// Набор мониторящихся id (пустой, если тенанта нет). - public IReadOnlyCollection SnapshotMonitored(string tenantId) - { - if (string.IsNullOrWhiteSpace(tenantId)) - { - return Array.Empty(); - } - - lock (_gate) - { - return _monitoredByTenant.TryGetValue(tenantId, out HashSet? monitored) - ? new ReadOnlyCollection(monitored.ToArray()) - : Array.Empty(); - } - } - - /// - /// Сбрасывает состояние тенанта. - /// - /// Id тенанта. - public void Reset(string tenantId) - { - if (string.IsNullOrWhiteSpace(tenantId)) - { - return; - } - - lock (_gate) - { - _knownByTenant.Remove(tenantId); - _monitoredByTenant.Remove(tenantId); - } - } - - private HashSet KnownSet(string tenantId) - { - if (!_knownByTenant.TryGetValue(tenantId, out HashSet? known)) - { - known = new HashSet(StringComparer.Ordinal); - _knownByTenant[tenantId] = known; - } - - return known; - } - - private HashSet MonitoredSet(string tenantId) - { - if (!_monitoredByTenant.TryGetValue(tenantId, out HashSet? monitored)) - { - monitored = new HashSet(StringComparer.Ordinal); - _monitoredByTenant[tenantId] = monitored; - } - - return monitored; - } -} +using System.Collections.ObjectModel; + +namespace Deal.Telegram.Dialogs; + +/// +/// Зеркало каталога диалогов тенанта в памяти telegram-service. +/// +public sealed class DialogCatalog +{ + private readonly object _gate = new(); + private readonly Dictionary> _knownByTenant = new(StringComparer.Ordinal); + private readonly Dictionary> _monitoredByTenant = new(StringComparer.Ordinal); + + /// + /// Обновляет полный каталог диалогов тенанта + /// + /// Id тенанта. + /// Актуальные id каталога (entries списка диалогов). + public void ReplaceKnown(string tenantId, IEnumerable dialogIds) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + + lock (_gate) + { + _knownByTenant[tenantId] = new HashSet(dialogIds, StringComparer.Ordinal); + } + } + + /// + /// Включает/выключает мониторинг одного диалога. + /// + /// Id тенанта. + /// Id диалога. + /// Мониторить (true) или выключить (false). + public void SetMonitored( + string tenantId, + string dialogId, + bool enabled) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(dialogId); + + lock (_gate) + { + HashSet monitored = MonitoredSet(tenantId); + if (enabled) + { + monitored.Add(dialogId); + } + else + { + monitored.Remove(dialogId); + } + } + } + + /// + /// Заменяет monitored-набор ответом SyncDialogs. + /// + /// Id тенанта. + /// Id диалогов с включённым мониторингом. + public void ReplaceMonitored(string tenantId, IEnumerable monitoredIds) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + + lock (_gate) + { + _monitoredByTenant[tenantId] = new HashSet(monitoredIds, StringComparer.Ordinal); + } + } + + /// + /// Включает/выключает мониторинг всех диалогов каталога. + /// + /// Id тенанта. + /// Мониторить все (true) или снять мониторинг со всех (false). + /// Сколько диалогов в каталоге тенанта (SetMonitorAllReply.count). + public int SetAllMonitored(string tenantId, bool enabled) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + + lock (_gate) + { + HashSet known = KnownSet(tenantId); + if (enabled) + { + HashSet monitored = MonitoredSet(tenantId); + monitored.UnionWith(known); + } + else + { + _monitoredByTenant[tenantId] = new HashSet(StringComparer.Ordinal); + } + + return known.Count; + } + } + + /// + /// Проверяет, мониторится ли диалог. + /// + /// Id тенанта. + /// Id диалога. + /// True — диалог в monitored-наборе тенанта. + public bool IsMonitored(string tenantId, string dialogId) + { + if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(dialogId)) + { + return false; + } + + lock (_gate) + { + return _monitoredByTenant.TryGetValue(tenantId, out HashSet? monitored) && monitored.Contains(dialogId); + } + } + + /// + /// Сколько диалогов знает каталог тенанта + /// + /// Id тенанта. + /// Размер каталога; 0 — каталог ещё не синхронизирован (нет refresh/sweep). + public int KnownCount(string tenantId) + { + if (string.IsNullOrWhiteSpace(tenantId)) + { + return 0; + } + + lock (_gate) + { + return _knownByTenant.TryGetValue(tenantId, out HashSet? known) ? known.Count : 0; + } + } + + /// + /// Возвращает копию мониторящихся id тенанта + /// + /// Id тенанта. + /// Набор мониторящихся id (пустой, если тенанта нет). + public IReadOnlyCollection SnapshotMonitored(string tenantId) + { + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Array.Empty(); + } + + lock (_gate) + { + return _monitoredByTenant.TryGetValue(tenantId, out HashSet? monitored) + ? new ReadOnlyCollection(monitored.ToArray()) + : Array.Empty(); + } + } + + /// + /// Сбрасывает состояние тенанта. + /// + /// Id тенанта. + public void Reset(string tenantId) + { + if (string.IsNullOrWhiteSpace(tenantId)) + { + return; + } + + lock (_gate) + { + _knownByTenant.Remove(tenantId); + _monitoredByTenant.Remove(tenantId); + } + } + + private HashSet KnownSet(string tenantId) + { + if (!_knownByTenant.TryGetValue(tenantId, out HashSet? known)) + { + known = new HashSet(StringComparer.Ordinal); + _knownByTenant[tenantId] = known; + } + + return known; + } + + private HashSet MonitoredSet(string tenantId) + { + if (!_monitoredByTenant.TryGetValue(tenantId, out HashSet? monitored)) + { + monitored = new HashSet(StringComparer.Ordinal); + _monitoredByTenant[tenantId] = monitored; + } + + return monitored; + } +} diff --git a/src/telegram-service/Deal.Telegram/Dialogs/DialogHue.cs b/src/telegram-service/Deal.Telegram/Dialogs/DialogHue.cs index 15a8219..8716b0b 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/DialogHue.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/DialogHue.cs @@ -1,38 +1,38 @@ -using System.Text; - -namespace Deal.Telegram.Dialogs; - -/// -/// Детерминированный цвет диалога из палитры DIALOG_HUES. -/// -public static class DialogHue -{ - private static readonly string[] Palette = - [ - "#3b82f6", - "#38bdf8", - "#f472b6", - "#f59e0b", - "#a78bfa", - "#34d399", - "#fb7185", - "#22c55e", - ]; - - /// - /// Цвет источника по id диалога и имени - /// - /// Подписанный id диалога. - /// Отображаемое имя (title/first_name); пустое — как в. - /// Цвет палитры, hex "#rrggbb". - public static string Compute(string dialogId, string name) - { - int hash = 0; - foreach (Rune rune in (dialogId + name).EnumerateRunes()) - { - hash = (hash * 31 + rune.Value) % Palette.Length; - } - - return Palette[hash]; - } -} +using System.Text; + +namespace Deal.Telegram.Dialogs; + +/// +/// Детерминированный цвет диалога из палитры DIALOG_HUES. +/// +public static class DialogHue +{ + private static readonly string[] Palette = + [ + "#3b82f6", + "#38bdf8", + "#f472b6", + "#f59e0b", + "#a78bfa", + "#34d399", + "#fb7185", + "#22c55e", + ]; + + /// + /// Цвет источника по id диалога и имени + /// + /// Подписанный id диалога. + /// Отображаемое имя (title/first_name); пустое — как в. + /// Цвет палитры, hex "#rrggbb". + public static string Compute(string dialogId, string name) + { + int hash = 0; + foreach (Rune rune in (dialogId + name).EnumerateRunes()) + { + hash = (hash * 31 + rune.Value) % Palette.Length; + } + + return Palette[hash]; + } +} diff --git a/src/telegram-service/Deal.Telegram/Dialogs/DialogProtoMapper.cs b/src/telegram-service/Deal.Telegram/Dialogs/DialogProtoMapper.cs index a99a971..dd16aee 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/DialogProtoMapper.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/DialogProtoMapper.cs @@ -1,69 +1,69 @@ -using System.Globalization; -using Deal.Grpc.Sources; -using Deal.Grpc.Telegram; -using Deal.Telegram.Telegram; - -namespace Deal.Telegram.Dialogs; - -/// -/// Маппер нейтральных результатов сессии в protobuf-контракт. -/// -public static class DialogProtoMapper -{ - private const string TelegramKind = "telegram"; - - private const string HueKey = "hue"; - - /// - /// Нейтральный диалог → DialogEntry контракта - /// - /// Диалог из списка сессии. - /// Запись каталога контракта с цветом палитры. - public static DialogEntry ToEntry(TelegramDialog dialog) - => new() - { - Id = dialog.Id, - Name = dialog.Name, - Username = dialog.Username, - Kind = dialog.Kind, - Hue = DialogHue.Compute(dialog.Id, dialog.Name), - }; - - /// - /// Нейтральное сообщение → PreviewMessage превью - /// - /// Сообщение диалога (непустой текст). - /// Сообщение превью ReadRecentReply (от новых к старым). - public static PreviewMessage ToPreview(TelegramMessage message) - => new() - { - Id = message.Id.ToString(CultureInfo.InvariantCulture), - Text = message.Text, - Time = message.DateMs, - }; - - /// - /// Нейтральное сообщение → PushSourceRequest ингресса. - /// - /// Сообщение диалога (непустой текст). - /// Запрос SourceIngress.PushSource с источником telegram и содержимым. - public static PushSourceRequest ToSourceRequest(TelegramMessage message) - => new() - { - Source = new SourceRefProto - { - Kind = TelegramKind, - ExternalId = message.Id.ToString(CultureInfo.InvariantCulture), - OriginRef = message.DialogId, - DisplayName = message.DialogName, - Author = message.DialogName, - ReceivedAt = message.DateMs, - Extra = { [HueKey] = DialogHue.Compute(message.DialogId, message.DialogName) }, - }, - Content = new SourceContentProto - { - Text = message.Text, - Author = message.DialogName, - }, - }; -} +using System.Globalization; +using Deal.Grpc.Sources; +using Deal.Grpc.Telegram; +using Deal.Telegram.Telegram; + +namespace Deal.Telegram.Dialogs; + +/// +/// Маппер нейтральных результатов сессии в protobuf-контракт. +/// +public static class DialogProtoMapper +{ + private const string TelegramKind = "telegram"; + + private const string HueKey = "hue"; + + /// + /// Нейтральный диалог → DialogEntry контракта + /// + /// Диалог из списка сессии. + /// Запись каталога контракта с цветом палитры. + public static DialogEntry ToEntry(TelegramDialog dialog) + => new() + { + Id = dialog.Id, + Name = dialog.Name, + Username = dialog.Username, + Kind = dialog.Kind, + Hue = DialogHue.Compute(dialog.Id, dialog.Name), + }; + + /// + /// Нейтральное сообщение → PreviewMessage превью + /// + /// Сообщение диалога (непустой текст). + /// Сообщение превью ReadRecentReply (от новых к старым). + public static PreviewMessage ToPreview(TelegramMessage message) + => new() + { + Id = message.Id.ToString(CultureInfo.InvariantCulture), + Text = message.Text, + Time = message.DateMs, + }; + + /// + /// Нейтральное сообщение → PushSourceRequest ингресса. + /// + /// Сообщение диалога (непустой текст). + /// Запрос SourceIngress.PushSource с источником telegram и содержимым. + public static PushSourceRequest ToSourceRequest(TelegramMessage message) + => new() + { + Source = new SourceRefProto + { + Kind = TelegramKind, + ExternalId = message.Id.ToString(CultureInfo.InvariantCulture), + OriginRef = message.DialogId, + DisplayName = message.DialogName, + Author = message.DialogName, + ReceivedAt = message.DateMs, + Extra = { [HueKey] = DialogHue.Compute(message.DialogId, message.DialogName) }, + }, + Content = new SourceContentProto + { + Text = message.Text, + Author = message.DialogName, + }, + }; +} diff --git a/src/telegram-service/Deal.Telegram/Dialogs/IBackfillPacer.cs b/src/telegram-service/Deal.Telegram/Dialogs/IBackfillPacer.cs index 8dcb404..f6a3f1e 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/IBackfillPacer.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/IBackfillPacer.cs @@ -1,19 +1,19 @@ -namespace Deal.Telegram.Dialogs; - -/// -/// Seam анти-бан-пауз между сетевыми операциями каталога -/// -public interface IBackfillPacer -{ - /// - /// Ждёт случайное время в диапазоне [minSeconds, maxSeconds] - /// - /// Нижняя граница паузы, секунды (≥ 0). - /// Верхняя граница паузы, секунды (≥ minSeconds). - /// Отмена ожидания. - /// Задача ожидания. - public Task WaitAsync( - double minSeconds, - double maxSeconds, - CancellationToken cancellationToken); -} +namespace Deal.Telegram.Dialogs; + +/// +/// Seam анти-бан-пауз между сетевыми операциями каталога +/// +public interface IBackfillPacer +{ + /// + /// Ждёт случайное время в диапазоне [minSeconds, maxSeconds] + /// + /// Нижняя граница паузы, секунды (≥ 0). + /// Верхняя граница паузы, секунды (≥ minSeconds). + /// Отмена ожидания. + /// Задача ожидания. + public Task WaitAsync( + double minSeconds, + double maxSeconds, + CancellationToken cancellationToken); +} diff --git a/src/telegram-service/Deal.Telegram/Dialogs/RandomBackfillPacer.cs b/src/telegram-service/Deal.Telegram/Dialogs/RandomBackfillPacer.cs index 614fc77..8ca4289 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/RandomBackfillPacer.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/RandomBackfillPacer.cs @@ -1,29 +1,29 @@ -namespace Deal.Telegram.Dialogs; - -/// -/// Реальная реализация -/// -public sealed class RandomBackfillPacer : IBackfillPacer -{ - /// - public async Task WaitAsync( - double minSeconds, - double maxSeconds, - CancellationToken cancellationToken) - { - if (maxSeconds < 0 || minSeconds < 0 || minSeconds > maxSeconds) - { - throw new ArgumentOutOfRangeException(nameof(maxSeconds), $"Некорректный диапазон паузы: [{minSeconds}, {maxSeconds}]."); - } - - if (maxSeconds == 0) - { - return; - } - - double seconds = minSeconds == maxSeconds - ? minSeconds - : minSeconds + Random.Shared.NextDouble() * (maxSeconds - minSeconds); - await Task.Delay(TimeSpan.FromSeconds(seconds), cancellationToken).ConfigureAwait(false); - } -} +namespace Deal.Telegram.Dialogs; + +/// +/// Реальная реализация +/// +public sealed class RandomBackfillPacer : IBackfillPacer +{ + /// + public async Task WaitAsync( + double minSeconds, + double maxSeconds, + CancellationToken cancellationToken) + { + if (maxSeconds < 0 || minSeconds < 0 || minSeconds > maxSeconds) + { + throw new ArgumentOutOfRangeException(nameof(maxSeconds), $"Некорректный диапазон паузы: [{minSeconds}, {maxSeconds}]."); + } + + if (maxSeconds == 0) + { + return; + } + + double seconds = minSeconds == maxSeconds + ? minSeconds + : minSeconds + Random.Shared.NextDouble() * (maxSeconds - minSeconds); + await Task.Delay(TimeSpan.FromSeconds(seconds), cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/telegram-service/Deal.Telegram/Dialogs/RealtimeListener.cs b/src/telegram-service/Deal.Telegram/Dialogs/RealtimeListener.cs index cbf252e..4245fb6 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/RealtimeListener.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/RealtimeListener.cs @@ -1,92 +1,92 @@ -using Deal.Grpc.Sources; -using Deal.Telegram.Core; -using Deal.Telegram.Sessions; -using Deal.Telegram.Telegram; - -namespace Deal.Telegram.Dialogs; - -/// -/// Realtime-listener мониторящихся диалогов одного тенанта. -/// -public sealed class RealtimeListener -{ - private readonly TenantSession _session; - private readonly DialogCatalog _catalog; - private readonly ICoreIngressClient _ingress; - private readonly ILogger _logger; - - /// - /// Создаёт listener сессии. - /// - /// Ready-сессия тенанта (события сообщений её клиента). - /// Зеркало мониторинга. - /// Канал в ядро (PushSource). - /// Логгер. - public RealtimeListener( - TenantSession session, - DialogCatalog catalog, - ICoreIngressClient ingress, - ILogger logger) - { - _session = session; - _catalog = catalog; - _ingress = ingress; - _logger = logger; - } - - /// - /// Подписывает listener на события сессии - /// - public void Start() - { - _session.MessageReceived += OnMessageReceivedAsync; - _session.SetListenerActive(true); - } - - /// - /// Отписывает listener - /// - public void Stop() - { - _session.MessageReceived -= OnMessageReceivedAsync; - _session.SetListenerActive(false); - } - - // Обработчик входящего сообщения: фильтр по зеркалу → PushSource в ядро → mark-as-read. - // Сбой отправки не роняет realtime: сообщение остаётся непрочитанным и догоняется realtime_sweep. - // message: Входящее сообщение аккаунта. - private async Task OnMessageReceivedAsync(TelegramMessage message) - { - try - { - if (!_catalog.IsMonitored(_session.TenantId, message.DialogId)) - { - return; - } - - PushSourceReply reply = await _ingress - .PushSourceAsync(_session.TenantId, DialogProtoMapper.ToSourceRequest(message), CancellationToken.None) - .ConfigureAwait(false); - if (reply.Accepted) - { - await _session.MarkReadAsync(message.DialogId, CancellationToken.None).ConfigureAwait(false); - } - - _logger.LogInformation( - "Realtime {TenantId} {DialogId} msg {MessageId} → ядро (duplicate {Duplicate})", - _session.TenantId, - message.DialogId, - message.Id, - reply.Duplicate); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogWarning( - exception, - "Realtime {TenantId} {DialogId} msg {MessageId}: не доставлено в ядро — догонит sweep", - _session.TenantId, - message.DialogId, - message.Id); - } - } -} +using Deal.Grpc.Sources; +using Deal.Telegram.Core; +using Deal.Telegram.Sessions; +using Deal.Telegram.Telegram; + +namespace Deal.Telegram.Dialogs; + +/// +/// Realtime-listener мониторящихся диалогов одного тенанта. +/// +public sealed class RealtimeListener +{ + private readonly TenantSession _session; + private readonly DialogCatalog _catalog; + private readonly ICoreIngressClient _ingress; + private readonly ILogger _logger; + + /// + /// Создаёт listener сессии. + /// + /// Ready-сессия тенанта (события сообщений её клиента). + /// Зеркало мониторинга. + /// Канал в ядро (PushSource). + /// Логгер. + public RealtimeListener( + TenantSession session, + DialogCatalog catalog, + ICoreIngressClient ingress, + ILogger logger) + { + _session = session; + _catalog = catalog; + _ingress = ingress; + _logger = logger; + } + + /// + /// Подписывает listener на события сессии + /// + public void Start() + { + _session.MessageReceived += OnMessageReceivedAsync; + _session.SetListenerActive(true); + } + + /// + /// Отписывает listener + /// + public void Stop() + { + _session.MessageReceived -= OnMessageReceivedAsync; + _session.SetListenerActive(false); + } + + // Обработчик входящего сообщения: фильтр по зеркалу → PushSource в ядро → mark-as-read. + // Сбой отправки не роняет realtime: сообщение остаётся непрочитанным и догоняется realtime_sweep. + // message: Входящее сообщение аккаунта. + private async Task OnMessageReceivedAsync(TelegramMessage message) + { + try + { + if (!_catalog.IsMonitored(_session.TenantId, message.DialogId)) + { + return; + } + + PushSourceReply reply = await _ingress + .PushSourceAsync(_session.TenantId, DialogProtoMapper.ToSourceRequest(message), CancellationToken.None) + .ConfigureAwait(false); + if (reply.Accepted) + { + await _session.MarkReadAsync(message.DialogId, CancellationToken.None).ConfigureAwait(false); + } + + _logger.LogInformation( + "Realtime {TenantId} {DialogId} msg {MessageId} → ядро (duplicate {Duplicate})", + _session.TenantId, + message.DialogId, + message.Id, + reply.Duplicate); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning( + exception, + "Realtime {TenantId} {DialogId} msg {MessageId}: не доставлено в ядро — догонит sweep", + _session.TenantId, + message.DialogId, + message.Id); + } + } +} diff --git a/src/telegram-service/Deal.Telegram/Dialogs/RealtimeSweep.cs b/src/telegram-service/Deal.Telegram/Dialogs/RealtimeSweep.cs index 616a974..7e30e98 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/RealtimeSweep.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/RealtimeSweep.cs @@ -1,178 +1,178 @@ -using Deal.Grpc.Sources; -using Deal.Grpc.Telegram; -using Deal.Telegram.Core; -using Deal.Telegram.Sessions; -using Deal.Telegram.Telegram; - -namespace Deal.Telegram.Dialogs; - -/// -/// Страховочная догонялка realtime. -/// -public sealed class RealtimeSweep -{ - /// - /// Период цикла догона. - /// - public const int SweepPeriodSeconds = 30; - - /// - /// Верхняя граница списка диалогов за цикл. - /// - public const int DialogsLimit = 500; - - /// - /// Запас сообщений сверх unread_count при чтении - /// - public const int UnreadSlackMessages = 2; - - /// - /// Потолок сообщений одного диалога за цикл. - /// - public const int MaxMessagesPerDialog = 10; - - private readonly SessionFarm _sessionFarm; - private readonly DialogCatalog _catalog; - private readonly ICoreIngressClient _ingress; - private readonly ILogger _logger; - - /// - /// Создаёт догонялку realtime. - /// - /// Пул сессий тенантов. - /// Зеркало каталога/мониторинга (актуализируется ответом SyncDialogs). - /// Канал в ядро (PushSource/SyncDialogs). - /// Логгер. - public RealtimeSweep( - SessionFarm sessionFarm, - DialogCatalog catalog, - ICoreIngressClient ingress, - ILogger logger) - { - _sessionFarm = sessionFarm; - _catalog = catalog; - _ingress = ingress; - _logger = logger; - } - - /// - /// Один цикл догона - /// - /// Отмена цикла. - public async Task SweepAllAsync(CancellationToken cancellationToken) - { - foreach (TenantSession session in _sessionFarm.Sessions) - { - if (cancellationToken.IsCancellationRequested) - { - return; - } - - if (session.Phase != AuthPhase.Ready) - { - continue; - } - - try - { - await SweepTenantAsync(session.TenantId, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - _logger.LogWarning(exception, "realtime sweep {TenantId}: цикл завершился с ошибкой", session.TenantId); - } - } - } - - // Догон одного тенанта: синк каталога + непрочитанные мониторящихся диалогов. - // tenantId: Id тенанта. - // cancellationToken: Отмена операции. - private async Task SweepTenantAsync(string tenantId, CancellationToken cancellationToken) - { - IReadOnlyList dialogs = await _sessionFarm - .ListDialogsAsync(tenantId, DialogsLimit, cancellationToken) - .ConfigureAwait(false); - if (dialogs.Count == 0) - { - return; - } - - List dialogIds = dialogs.Select(dialog => dialog.Id).ToList(); - _catalog.ReplaceKnown(tenantId, dialogIds); - List entries = dialogs.Select(DialogProtoMapper.ToEntry).ToList(); - try - { - IReadOnlyList monitored = await _ingress.SyncDialogsAsync(tenantId, entries, cancellationToken).ConfigureAwait(false); - _catalog.ReplaceMonitored(tenantId, monitored); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - // Ядро недоступно: упущенное догонит следующий цикл, зеркало остаётся прежним. - _logger.LogWarning(exception, "realtime sweep {TenantId}: синхронизация каталога не выполнена", tenantId); - } - - foreach (TelegramDialog dialog in dialogs) - { - if (cancellationToken.IsCancellationRequested) - { - return; - } - - if (!_catalog.IsMonitored(tenantId, dialog.Id) || dialog.UnreadCount <= 0) - { - continue; - } - - await SweepDialogAsync(tenantId, dialog, cancellationToken).ConfigureAwait(false); - } - } - - private async Task SweepDialogAsync( - string tenantId, - TelegramDialog dialog, - CancellationToken cancellationToken) - { - try - { - int limit = Math.Min(dialog.UnreadCount + UnreadSlackMessages, MaxMessagesPerDialog); - IReadOnlyList messages = await _sessionFarm - .GetMessagesAsync(tenantId, dialog.Id, limit, cancellationToken) - .ConfigureAwait(false); - - int added = 0; - foreach (TelegramMessage message in messages.Reverse()) - { - PushSourceReply reply = await _ingress - .PushSourceAsync(tenantId, DialogProtoMapper.ToSourceRequest(message), cancellationToken) - .ConfigureAwait(false); - if (reply.Accepted && !reply.Duplicate) - { - added++; - } - } - - if (added > 0) - { - _logger.LogInformation( - "realtime sweep {TenantId} {DialogId}: +{Added} в ядро (потерянные события)", - tenantId, - dialog.Id, - added); - } - - await _sessionFarm.MarkReadAsync(tenantId, dialog.Id, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - _logger.LogWarning(exception, "realtime sweep {TenantId} {DialogId}: диалог не догнан", tenantId, dialog.Id); - } - } -} +using Deal.Grpc.Sources; +using Deal.Grpc.Telegram; +using Deal.Telegram.Core; +using Deal.Telegram.Sessions; +using Deal.Telegram.Telegram; + +namespace Deal.Telegram.Dialogs; + +/// +/// Страховочная догонялка realtime. +/// +public sealed class RealtimeSweep +{ + /// + /// Период цикла догона. + /// + public const int SweepPeriodSeconds = 30; + + /// + /// Верхняя граница списка диалогов за цикл. + /// + public const int DialogsLimit = 500; + + /// + /// Запас сообщений сверх unread_count при чтении + /// + public const int UnreadSlackMessages = 2; + + /// + /// Потолок сообщений одного диалога за цикл. + /// + public const int MaxMessagesPerDialog = 10; + + private readonly SessionFarm _sessionFarm; + private readonly DialogCatalog _catalog; + private readonly ICoreIngressClient _ingress; + private readonly ILogger _logger; + + /// + /// Создаёт догонялку realtime. + /// + /// Пул сессий тенантов. + /// Зеркало каталога/мониторинга (актуализируется ответом SyncDialogs). + /// Канал в ядро (PushSource/SyncDialogs). + /// Логгер. + public RealtimeSweep( + SessionFarm sessionFarm, + DialogCatalog catalog, + ICoreIngressClient ingress, + ILogger logger) + { + _sessionFarm = sessionFarm; + _catalog = catalog; + _ingress = ingress; + _logger = logger; + } + + /// + /// Один цикл догона + /// + /// Отмена цикла. + public async Task SweepAllAsync(CancellationToken cancellationToken) + { + foreach (TenantSession session in _sessionFarm.Sessions) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + if (session.Phase != AuthPhase.Ready) + { + continue; + } + + try + { + await SweepTenantAsync(session.TenantId, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "realtime sweep {TenantId}: цикл завершился с ошибкой", session.TenantId); + } + } + } + + // Догон одного тенанта: синк каталога + непрочитанные мониторящихся диалогов. + // tenantId: Id тенанта. + // cancellationToken: Отмена операции. + private async Task SweepTenantAsync(string tenantId, CancellationToken cancellationToken) + { + IReadOnlyList dialogs = await _sessionFarm + .ListDialogsAsync(tenantId, DialogsLimit, cancellationToken) + .ConfigureAwait(false); + if (dialogs.Count == 0) + { + return; + } + + List dialogIds = dialogs.Select(dialog => dialog.Id).ToList(); + _catalog.ReplaceKnown(tenantId, dialogIds); + List entries = dialogs.Select(DialogProtoMapper.ToEntry).ToList(); + try + { + IReadOnlyList monitored = await _ingress.SyncDialogsAsync(tenantId, entries, cancellationToken).ConfigureAwait(false); + _catalog.ReplaceMonitored(tenantId, monitored); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + // Ядро недоступно: упущенное догонит следующий цикл, зеркало остаётся прежним. + _logger.LogWarning(exception, "realtime sweep {TenantId}: синхронизация каталога не выполнена", tenantId); + } + + foreach (TelegramDialog dialog in dialogs) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + if (!_catalog.IsMonitored(tenantId, dialog.Id) || dialog.UnreadCount <= 0) + { + continue; + } + + await SweepDialogAsync(tenantId, dialog, cancellationToken).ConfigureAwait(false); + } + } + + private async Task SweepDialogAsync( + string tenantId, + TelegramDialog dialog, + CancellationToken cancellationToken) + { + try + { + int limit = Math.Min(dialog.UnreadCount + UnreadSlackMessages, MaxMessagesPerDialog); + IReadOnlyList messages = await _sessionFarm + .GetMessagesAsync(tenantId, dialog.Id, limit, cancellationToken) + .ConfigureAwait(false); + + int added = 0; + foreach (TelegramMessage message in messages.Reverse()) + { + PushSourceReply reply = await _ingress + .PushSourceAsync(tenantId, DialogProtoMapper.ToSourceRequest(message), cancellationToken) + .ConfigureAwait(false); + if (reply.Accepted && !reply.Duplicate) + { + added++; + } + } + + if (added > 0) + { + _logger.LogInformation( + "realtime sweep {TenantId} {DialogId}: +{Added} в ядро (потерянные события)", + tenantId, + dialog.Id, + added); + } + + await _sessionFarm.MarkReadAsync(tenantId, dialog.Id, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "realtime sweep {TenantId} {DialogId}: диалог не догнан", tenantId, dialog.Id); + } + } +} diff --git a/src/telegram-service/Deal.Telegram/Discovery/DiscoveryOps.cs b/src/telegram-service/Deal.Telegram/Discovery/DiscoveryOps.cs index d7e3c36..6ffdeac 100644 --- a/src/telegram-service/Deal.Telegram/Discovery/DiscoveryOps.cs +++ b/src/telegram-service/Deal.Telegram/Discovery/DiscoveryOps.cs @@ -1,165 +1,165 @@ -using Deal.Telegram.Dialogs; -using Deal.Telegram.Sessions; -using Deal.Telegram.Telegram; -using Grpc.Core; - -namespace Deal.Telegram.Discovery; - -/// -/// Discovery-операции telegram-service поверх пула сессий тенантов. -/// -public sealed class DiscoveryOps -{ - /// - /// Верхняя граница поиска по умолчанию, если core не передал. - /// - public const int SearchDefaultLimit = 30; - - /// - /// Нижняя граница анти-бан-паузы после поиска - /// - public const double SearchPauseMinSeconds = 2.0; - - /// - /// Верхняя граница анти-бан-паузы после поиска - /// - public const double SearchPauseMaxSeconds = 4.0; - - /// - /// Размер выборки чтения по умолчанию, если core не передал - /// - public const int ReadForEvalDefaultLimit = 30; - - private readonly SessionFarm _sessionFarm; - private readonly IBackfillPacer _pacer; - private readonly ILogger _logger; - - /// - /// Создаёт службу discovery-операций. - /// - /// Пул сессий тенантов (операции только на сессии своего тенанта). - /// Анти-бан-паузы (реальный — случайные 2–4 с, тесты — фейк). - /// Логгер. - public DiscoveryOps( - SessionFarm sessionFarm, - IBackfillPacer pacer, - ILogger logger) - { - _sessionFarm = sessionFarm; - _pacer = pacer; - _logger = logger; - } - - /// - /// Глобальный поиск источников по ключу. - /// - /// Id тенанта. - /// Поисковый запрос (ключ задачи discovery). - /// Верхняя граница результата. - /// Отмена операции. - /// Найденные источники (каналы/группы/личные) без дублей, не длиннее limit. - public async Task> SearchAsync( - string tenantId, - string query, - int limit, - CancellationToken cancellationToken) - { - int effectiveLimit = limit > 0 ? limit : SearchDefaultLimit; - IReadOnlyList found = await _sessionFarm - .SearchAsync(tenantId, query, effectiveLimit, cancellationToken) - .ConfigureAwait(false); - - await _pacer.WaitAsync(SearchPauseMinSeconds, SearchPauseMaxSeconds, cancellationToken).ConfigureAwait(false); - return DedupeAndCap(found, effectiveLimit); - } - - /// - /// Инфо об источнике для оценки кандидата. - /// - /// Id тенанта. - /// Подписанный id источника («-100…»/«-…»/«+…»). - /// Отмена операции. - /// Инфо об источнике (сбои определения не бросаются — см. ISessionClient.GetInfoAsync). - public Task GetInfoAsync( - string tenantId, - string dialogId, - CancellationToken cancellationToken) - => _sessionFarm.GetInfoAsync(tenantId, dialogId, cancellationToken); - - /// - /// Выборка последних сообщений источника для оценки. - /// - /// Id тенанта. - /// Подписанный id источника. - /// Размер выборки (≤ 0 — дефолт; форумы читаются по активным темам). - /// Отмена операции. - /// ok/сообщения либо ok=false/error="no_history" (не ошибка RPC). - public Task ReadForEvalAsync( - string tenantId, - string dialogId, - int limit, - CancellationToken cancellationToken) - => _sessionFarm.ReadForEvalAsync(tenantId, dialogId, limit > 0 ? limit : ReadForEvalDefaultLimit, cancellationToken); - - /// - /// Вступить в канал/группу по username. - /// - /// Id тенанта. - /// Username источника («@»/пробелы нормализуются). - /// Отмена операции. - public async Task JoinAsync( - string tenantId, - string username, - CancellationToken cancellationToken) - { - string normalized = NormalizeUsername(username); - if (normalized.Length == 0) - { - throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.JoinUsernameMissing); - } - - await _sessionFarm.JoinAsync(tenantId, normalized, cancellationToken).ConfigureAwait(false); - _logger.LogInformation("Discovery: вступили в @{Username} от tenant {TenantId}", normalized, tenantId); - } - - /// - /// Выйти из канала/группы. - /// - /// Id тенанта. - /// Подписанный id диалога. - /// Отмена операции. - public Task LeaveAsync( - string tenantId, - string dialogId, - CancellationToken cancellationToken) - => _sessionFarm.LeaveAsync(tenantId, dialogId, cancellationToken); - - /// - /// Нормализует username вступления - /// - /// Username из запроса (может быть пуст/null). - /// Нормализованный username (пустой — вступать не по чему). - public static string NormalizeUsername(string? username) - => (username ?? string.Empty).Trim().TrimStart('@'); - - private static IReadOnlyList DedupeAndCap(IReadOnlyList found, int limit) - { - var seen = new HashSet(StringComparer.Ordinal); - var items = new List(Math.Min(found.Count, limit)); - foreach (TelegramDialog item in found) - { - if (!seen.Add(item.Id)) - { - continue; - } - - items.Add(item); - if (items.Count >= limit) - { - break; - } - } - - return items; - } -} +using Deal.Telegram.Dialogs; +using Deal.Telegram.Sessions; +using Deal.Telegram.Telegram; +using Grpc.Core; + +namespace Deal.Telegram.Discovery; + +/// +/// Discovery-операции telegram-service поверх пула сессий тенантов. +/// +public sealed class DiscoveryOps +{ + /// + /// Верхняя граница поиска по умолчанию, если core не передал. + /// + public const int SearchDefaultLimit = 30; + + /// + /// Нижняя граница анти-бан-паузы после поиска + /// + public const double SearchPauseMinSeconds = 2.0; + + /// + /// Верхняя граница анти-бан-паузы после поиска + /// + public const double SearchPauseMaxSeconds = 4.0; + + /// + /// Размер выборки чтения по умолчанию, если core не передал + /// + public const int ReadForEvalDefaultLimit = 30; + + private readonly SessionFarm _sessionFarm; + private readonly IBackfillPacer _pacer; + private readonly ILogger _logger; + + /// + /// Создаёт службу discovery-операций. + /// + /// Пул сессий тенантов (операции только на сессии своего тенанта). + /// Анти-бан-паузы (реальный — случайные 2–4 с, тесты — фейк). + /// Логгер. + public DiscoveryOps( + SessionFarm sessionFarm, + IBackfillPacer pacer, + ILogger logger) + { + _sessionFarm = sessionFarm; + _pacer = pacer; + _logger = logger; + } + + /// + /// Глобальный поиск источников по ключу. + /// + /// Id тенанта. + /// Поисковый запрос (ключ задачи discovery). + /// Верхняя граница результата. + /// Отмена операции. + /// Найденные источники (каналы/группы/личные) без дублей, не длиннее limit. + public async Task> SearchAsync( + string tenantId, + string query, + int limit, + CancellationToken cancellationToken) + { + int effectiveLimit = limit > 0 ? limit : SearchDefaultLimit; + IReadOnlyList found = await _sessionFarm + .SearchAsync(tenantId, query, effectiveLimit, cancellationToken) + .ConfigureAwait(false); + + await _pacer.WaitAsync(SearchPauseMinSeconds, SearchPauseMaxSeconds, cancellationToken).ConfigureAwait(false); + return DedupeAndCap(found, effectiveLimit); + } + + /// + /// Инфо об источнике для оценки кандидата. + /// + /// Id тенанта. + /// Подписанный id источника («-100…»/«-…»/«+…»). + /// Отмена операции. + /// Инфо об источнике (сбои определения не бросаются — см. ISessionClient.GetInfoAsync). + public Task GetInfoAsync( + string tenantId, + string dialogId, + CancellationToken cancellationToken) + => _sessionFarm.GetInfoAsync(tenantId, dialogId, cancellationToken); + + /// + /// Выборка последних сообщений источника для оценки. + /// + /// Id тенанта. + /// Подписанный id источника. + /// Размер выборки (≤ 0 — дефолт; форумы читаются по активным темам). + /// Отмена операции. + /// ok/сообщения либо ok=false/error="no_history" (не ошибка RPC). + public Task ReadForEvalAsync( + string tenantId, + string dialogId, + int limit, + CancellationToken cancellationToken) + => _sessionFarm.ReadForEvalAsync(tenantId, dialogId, limit > 0 ? limit : ReadForEvalDefaultLimit, cancellationToken); + + /// + /// Вступить в канал/группу по username. + /// + /// Id тенанта. + /// Username источника («@»/пробелы нормализуются). + /// Отмена операции. + public async Task JoinAsync( + string tenantId, + string username, + CancellationToken cancellationToken) + { + string normalized = NormalizeUsername(username); + if (normalized.Length == 0) + { + throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.JoinUsernameMissing); + } + + await _sessionFarm.JoinAsync(tenantId, normalized, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Discovery: вступили в @{Username} от tenant {TenantId}", normalized, tenantId); + } + + /// + /// Выйти из канала/группы. + /// + /// Id тенанта. + /// Подписанный id диалога. + /// Отмена операции. + public Task LeaveAsync( + string tenantId, + string dialogId, + CancellationToken cancellationToken) + => _sessionFarm.LeaveAsync(tenantId, dialogId, cancellationToken); + + /// + /// Нормализует username вступления + /// + /// Username из запроса (может быть пуст/null). + /// Нормализованный username (пустой — вступать не по чему). + public static string NormalizeUsername(string? username) + => (username ?? string.Empty).Trim().TrimStart('@'); + + private static IReadOnlyList DedupeAndCap(IReadOnlyList found, int limit) + { + var seen = new HashSet(StringComparer.Ordinal); + var items = new List(Math.Min(found.Count, limit)); + foreach (TelegramDialog item in found) + { + if (!seen.Add(item.Id)) + { + continue; + } + + items.Add(item); + if (items.Count >= limit) + { + break; + } + } + + return items; + } +} diff --git a/src/telegram-service/Deal.Telegram/Discovery/DiscoveryProtoMapper.cs b/src/telegram-service/Deal.Telegram/Discovery/DiscoveryProtoMapper.cs index ee8831c..c626363 100644 --- a/src/telegram-service/Deal.Telegram/Discovery/DiscoveryProtoMapper.cs +++ b/src/telegram-service/Deal.Telegram/Discovery/DiscoveryProtoMapper.cs @@ -1,82 +1,82 @@ -using Deal.Grpc.Telegram; -using Deal.Telegram.Dialogs; -using Deal.Telegram.Telegram; - -namespace Deal.Telegram.Discovery; - -/// -/// Маппер нейтральных результатов discovery в protobuf-контракт. -/// -public static class DiscoveryProtoMapper -{ - /// - /// Нейтральное инфо источника → ChannelInfo контракта - /// - /// Инфо из сессии (по умолчанию — только id/name). - public static ChannelInfo ToChannelInfo(TelegramSourceInfo info) - { - var result = new ChannelInfo - { - Id = info.Id, - Name = info.Name, - Username = info.Username, - Kind = info.Kind, - Hue = DialogHue.Compute(info.Id, info.Name), - IsForum = info.IsForum, - }; - - if (info.Participants is int participants) - { - result.Participants = participants; - } - - return result; - } - - /// - /// Сообщение выборки → EvalMessage контракта - /// - /// Сообщение discovery-read (непустой текст). - public static EvalMessage ToEvalMessage(DiscoveryMessage message) - { - var result = new EvalMessage - { - Id = message.Id, - Text = message.Text, - DateMs = message.DateMs, - }; - - if (message.TopicId is long topicId) - { - result.TopicId = topicId; - } - - if (!string.IsNullOrEmpty(message.TopicTitle)) - { - result.TopicTitle = message.TopicTitle; - } - - return result; - } - - /// - /// Результат чтения выборки → ReadForEvalReply контракта - /// - /// Результат из сессии (ok=false → error="no_history"). - public static ReadForEvalReply ToReadForEvalReply(DiscoveryReadResult result) - { - var reply = new ReadForEvalReply { Ok = result.Ok }; - if (!result.Ok) - { - reply.Error = result.Error ?? DiscoveryReadResult.NoHistoryError; - return reply; - } - - foreach (DiscoveryMessage message in result.Messages) - { - reply.Messages.Add(ToEvalMessage(message)); - } - - return reply; - } -} +using Deal.Grpc.Telegram; +using Deal.Telegram.Dialogs; +using Deal.Telegram.Telegram; + +namespace Deal.Telegram.Discovery; + +/// +/// Маппер нейтральных результатов discovery в protobuf-контракт. +/// +public static class DiscoveryProtoMapper +{ + /// + /// Нейтральное инфо источника → ChannelInfo контракта + /// + /// Инфо из сессии (по умолчанию — только id/name). + public static ChannelInfo ToChannelInfo(TelegramSourceInfo info) + { + var result = new ChannelInfo + { + Id = info.Id, + Name = info.Name, + Username = info.Username, + Kind = info.Kind, + Hue = DialogHue.Compute(info.Id, info.Name), + IsForum = info.IsForum, + }; + + if (info.Participants is int participants) + { + result.Participants = participants; + } + + return result; + } + + /// + /// Сообщение выборки → EvalMessage контракта + /// + /// Сообщение discovery-read (непустой текст). + public static EvalMessage ToEvalMessage(DiscoveryMessage message) + { + var result = new EvalMessage + { + Id = message.Id, + Text = message.Text, + DateMs = message.DateMs, + }; + + if (message.TopicId is long topicId) + { + result.TopicId = topicId; + } + + if (!string.IsNullOrEmpty(message.TopicTitle)) + { + result.TopicTitle = message.TopicTitle; + } + + return result; + } + + /// + /// Результат чтения выборки → ReadForEvalReply контракта + /// + /// Результат из сессии (ok=false → error="no_history"). + public static ReadForEvalReply ToReadForEvalReply(DiscoveryReadResult result) + { + var reply = new ReadForEvalReply { Ok = result.Ok }; + if (!result.Ok) + { + reply.Error = result.Error ?? DiscoveryReadResult.NoHistoryError; + return reply; + } + + foreach (DiscoveryMessage message in result.Messages) + { + reply.Messages.Add(ToEvalMessage(message)); + } + + return reply; + } +} diff --git a/src/telegram-service/Deal.Telegram/Dockerfile b/src/telegram-service/Deal.Telegram/Dockerfile index 4e5cdc5..7d140a3 100644 --- a/src/telegram-service/Deal.Telegram/Dockerfile +++ b/src/telegram-service/Deal.Telegram/Dockerfile @@ -1,45 +1,45 @@ -# telegram-service: gRPC-хост Telegram (план Task 2; Ruling 12 — запись в deploy/compose.dev.yml). -# -# КОНТЕКСТ СБОРКИ — корень репозитория: Deal.Telegram.csproj ссылается на src/contracts/Deal.Proto.csproj -# (общий проект кодогенерации, Task 1) вне каталога сервиса, поэтому нельзя собирать из -# src/telegram-service. Запуск из корня: docker build -f src/telegram-service/Deal.Telegram/Dockerfile . -# Порт — env GRPC_PORT (Program.cs), в compose.dev.yml задан 5101. - -# --- Этап сборки: restore + publish --- -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /repo - -# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников). -COPY src/contracts/Deal.Proto.csproj src/contracts/ -COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/ -COPY src/telegram-service/Directory.Build.props src/telegram-service/ -COPY src/telegram-service/Deal.Telegram/Deal.Telegram.csproj src/telegram-service/Deal.Telegram/ -RUN dotnet restore src/telegram-service/Deal.Telegram/Deal.Telegram.csproj - -# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса. -COPY src/contracts/ src/contracts/ -COPY src/grpc-hosting/ src/grpc-hosting/ -COPY src/telegram-service/Deal.Telegram/ src/telegram-service/Deal.Telegram/ -RUN dotnet publish src/telegram-service/Deal.Telegram/Deal.Telegram.csproj -c Release -o /app/publish - -# --- Runtime-этап --- -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final -WORKDIR /app -EXPOSE 5101 -COPY --from=build /app/publish . - -# grpc_health_probe — healthcheck контейнера (Ruling 12): gRPC-health освобождён от service-token -# (см. ServiceTokenInterceptor), поэтому проба идёт без metadata. -COPY --from=ghcr.io/grpc-ecosystem/grpc-health-probe:v0.4.35 /ko-app/grpc-health-probe /bin/grpc_health_probe - -# Сессии тенантов — файлы /data/sessions (Ruling 3): каталог монтируется volume-ом deal_tg_sessions -# из compose.dev.yml; создаётся при первом сохранении сессии (задачи 9–11). - -# Non-root: процесс работает пользователем deal (UID 10001); /data/sessions — каталог сессий (volume). -RUN useradd --uid 10001 --shell /usr/sbin/nologin --no-create-home deal \ - && mkdir -p /data/sessions \ - && chown -R deal:deal /data/sessions -ENV HOME=/tmp -USER deal - -ENTRYPOINT ["dotnet", "Deal.Telegram.dll"] +# telegram-service: gRPC-хост Telegram (план Task 2; Ruling 12 — запись в deploy/compose.dev.yml). +# +# КОНТЕКСТ СБОРКИ — корень репозитория: Deal.Telegram.csproj ссылается на src/contracts/Deal.Proto.csproj +# (общий проект кодогенерации, Task 1) вне каталога сервиса, поэтому нельзя собирать из +# src/telegram-service. Запуск из корня: docker build -f src/telegram-service/Deal.Telegram/Dockerfile . +# Порт — env GRPC_PORT (Program.cs), в compose.dev.yml задан 5101. + +# --- Этап сборки: restore + publish --- +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /repo + +# Restore-слой: только csproj/props (кэш слоёв Docker — restore не повторяется при правке исходников). +COPY src/contracts/Deal.Proto.csproj src/contracts/ +COPY src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj src/grpc-hosting/Deal.Grpc.Hosting/ +COPY src/telegram-service/Directory.Build.props src/telegram-service/ +COPY src/telegram-service/Deal.Telegram/Deal.Telegram.csproj src/telegram-service/Deal.Telegram/ +RUN dotnet restore src/telegram-service/Deal.Telegram/Deal.Telegram.csproj + +# Исходники: контракты (.proto) + общая gRPC-обвязка + проект сервиса. +COPY src/contracts/ src/contracts/ +COPY src/grpc-hosting/ src/grpc-hosting/ +COPY src/telegram-service/Deal.Telegram/ src/telegram-service/Deal.Telegram/ +RUN dotnet publish src/telegram-service/Deal.Telegram/Deal.Telegram.csproj -c Release -o /app/publish + +# --- Runtime-этап --- +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +EXPOSE 5101 +COPY --from=build /app/publish . + +# grpc_health_probe — healthcheck контейнера (Ruling 12): gRPC-health освобождён от service-token +# (см. ServiceTokenInterceptor), поэтому проба идёт без metadata. +COPY --from=ghcr.io/grpc-ecosystem/grpc-health-probe:v0.4.35 /ko-app/grpc-health-probe /bin/grpc_health_probe + +# Сессии тенантов — файлы /data/sessions (Ruling 3): каталог монтируется volume-ом deal_tg_sessions +# из compose.dev.yml; создаётся при первом сохранении сессии (задачи 9–11). + +# Non-root: процесс работает пользователем deal (UID 10001); /data/sessions — каталог сессий (volume). +RUN useradd --uid 10001 --shell /usr/sbin/nologin --no-create-home deal \ + && mkdir -p /data/sessions \ + && chown -R deal:deal /data/sessions +ENV HOME=/tmp +USER deal + +ENTRYPOINT ["dotnet", "Deal.Telegram.dll"] diff --git a/src/telegram-service/Deal.Telegram/Hosting/RealtimeMonitorService.cs b/src/telegram-service/Deal.Telegram/Hosting/RealtimeMonitorService.cs index bbfbf09..82349f8 100644 --- a/src/telegram-service/Deal.Telegram/Hosting/RealtimeMonitorService.cs +++ b/src/telegram-service/Deal.Telegram/Hosting/RealtimeMonitorService.cs @@ -1,127 +1,127 @@ -using Deal.Telegram.Core; -using Deal.Telegram.Dialogs; -using Deal.Telegram.Sessions; - -namespace Deal.Telegram.Hosting; - -/// -/// Фоновый reconcile realtime-listener'ов. -/// -public sealed class RealtimeMonitorService : BackgroundService -{ - /// - /// Период reconcile подписок - /// - public const int ReconcileIntervalSeconds = 2; - - private readonly SessionFarm _sessionFarm; - private readonly DialogCatalog _catalog; - private readonly ICoreIngressClient _ingress; - private readonly ILoggerFactory _loggerFactory; - private readonly ILogger _logger; - private readonly Dictionary _listeners = new(); - private readonly object _listenersGate = new(); - - /// - /// Создаёт фоновый reconcile listener'ов. - /// - /// Пул сессий тенантов. - /// Зеркало мониторинга (фильтр сообщений). - /// Канал в ядро (PushSource). - /// Фабрика логгеров (логгеры listener'ов). - public RealtimeMonitorService( - SessionFarm sessionFarm, - DialogCatalog catalog, - ICoreIngressClient ingress, - ILoggerFactory loggerFactory) - { - _sessionFarm = sessionFarm; - _catalog = catalog; - _ingress = ingress; - _loggerFactory = loggerFactory; - _logger = loggerFactory.CreateLogger(); - } - - /// - /// Периодический reconcile подписок ready-сессий. - /// - /// Токен остановки хоста. - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - using PeriodicTimer timer = new(TimeSpan.FromSeconds(ReconcileIntervalSeconds)); - while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) - { - try - { - Reconcile(); - } - catch (OperationCanceledException) - { - break; - } - catch (Exception exception) - { - _logger.LogError(exception, "Reconcile realtime-listener'ов завершился с ошибкой — следующий цикл"); - } - } - } - - /// - /// При остановке хоста отписывает все listener'ы. - /// - /// Токен остановки. - public override async Task StopAsync(CancellationToken cancellationToken) - { - await base.StopAsync(cancellationToken).ConfigureAwait(false); - DetachAll(); - } - - // Сверяет подписки с ready-сессиями пула (подписка/отписка по факту фазы). - private void Reconcile() - { - HashSet readySessions = _sessionFarm.Sessions - .Where(session => session.Phase == AuthPhase.Ready) - .ToHashSet(); - - lock (_listenersGate) - { - foreach (TenantSession session in _listeners.Keys.ToArray()) - { - if (!readySessions.Contains(session)) - { - if (_listeners.Remove(session, out RealtimeListener? listener)) - { - listener.Stop(); - } - } - } - - foreach (TenantSession session in readySessions) - { - if (_listeners.ContainsKey(session)) - { - continue; - } - - var listener = new RealtimeListener(session, _catalog, _ingress, _loggerFactory.CreateLogger()); - listener.Start(); - _listeners[session] = listener; - _logger.LogInformation("Realtime-listener {TenantId} подписан", session.TenantId); - } - } - } - - // Отписывает все listener'ы (остановка хоста). - private void DetachAll() - { - lock (_listenersGate) - { - foreach (RealtimeListener listener in _listeners.Values) - { - listener.Stop(); - } - - _listeners.Clear(); - } - } -} +using Deal.Telegram.Core; +using Deal.Telegram.Dialogs; +using Deal.Telegram.Sessions; + +namespace Deal.Telegram.Hosting; + +/// +/// Фоновый reconcile realtime-listener'ов. +/// +public sealed class RealtimeMonitorService : BackgroundService +{ + /// + /// Период reconcile подписок + /// + public const int ReconcileIntervalSeconds = 2; + + private readonly SessionFarm _sessionFarm; + private readonly DialogCatalog _catalog; + private readonly ICoreIngressClient _ingress; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + private readonly Dictionary _listeners = new(); + private readonly object _listenersGate = new(); + + /// + /// Создаёт фоновый reconcile listener'ов. + /// + /// Пул сессий тенантов. + /// Зеркало мониторинга (фильтр сообщений). + /// Канал в ядро (PushSource). + /// Фабрика логгеров (логгеры listener'ов). + public RealtimeMonitorService( + SessionFarm sessionFarm, + DialogCatalog catalog, + ICoreIngressClient ingress, + ILoggerFactory loggerFactory) + { + _sessionFarm = sessionFarm; + _catalog = catalog; + _ingress = ingress; + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger(); + } + + /// + /// Периодический reconcile подписок ready-сессий. + /// + /// Токен остановки хоста. + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using PeriodicTimer timer = new(TimeSpan.FromSeconds(ReconcileIntervalSeconds)); + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + try + { + Reconcile(); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception exception) + { + _logger.LogError(exception, "Reconcile realtime-listener'ов завершился с ошибкой — следующий цикл"); + } + } + } + + /// + /// При остановке хоста отписывает все listener'ы. + /// + /// Токен остановки. + public override async Task StopAsync(CancellationToken cancellationToken) + { + await base.StopAsync(cancellationToken).ConfigureAwait(false); + DetachAll(); + } + + // Сверяет подписки с ready-сессиями пула (подписка/отписка по факту фазы). + private void Reconcile() + { + HashSet readySessions = _sessionFarm.Sessions + .Where(session => session.Phase == AuthPhase.Ready) + .ToHashSet(); + + lock (_listenersGate) + { + foreach (TenantSession session in _listeners.Keys.ToArray()) + { + if (!readySessions.Contains(session)) + { + if (_listeners.Remove(session, out RealtimeListener? listener)) + { + listener.Stop(); + } + } + } + + foreach (TenantSession session in readySessions) + { + if (_listeners.ContainsKey(session)) + { + continue; + } + + var listener = new RealtimeListener(session, _catalog, _ingress, _loggerFactory.CreateLogger()); + listener.Start(); + _listeners[session] = listener; + _logger.LogInformation("Realtime-listener {TenantId} подписан", session.TenantId); + } + } + } + + // Отписывает все listener'ы (остановка хоста). + private void DetachAll() + { + lock (_listenersGate) + { + foreach (RealtimeListener listener in _listeners.Values) + { + listener.Stop(); + } + + _listeners.Clear(); + } + } +} diff --git a/src/telegram-service/Deal.Telegram/Hosting/RealtimeSweepService.cs b/src/telegram-service/Deal.Telegram/Hosting/RealtimeSweepService.cs index d932c9f..93251bb 100644 --- a/src/telegram-service/Deal.Telegram/Hosting/RealtimeSweepService.cs +++ b/src/telegram-service/Deal.Telegram/Hosting/RealtimeSweepService.cs @@ -1,52 +1,52 @@ -using Deal.Telegram.Dialogs; - -namespace Deal.Telegram.Hosting; - -/// -/// Фоновый цикл догона realtime. -/// -public sealed class RealtimeSweepService : BackgroundService -{ - private readonly RealtimeSweep _sweep; - private readonly ILogger _logger; - - /// - /// Создаёт фоновый цикл догона. - /// - /// Логика догона realtime. - /// Логгер. - public RealtimeSweepService(RealtimeSweep sweep, ILogger logger) - { - _sweep = sweep; - _logger = logger; - } - - /// - /// Первый проход сразу, затем цикл 30 с. - /// - /// Токен остановки хоста. - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - using PeriodicTimer timer = new(TimeSpan.FromSeconds(RealtimeSweep.SweepPeriodSeconds)); - while (true) - { - try - { - await _sweep.SweepAllAsync(stoppingToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - break; - } - catch (Exception exception) - { - _logger.LogError(exception, "Цикл realtime sweep завершился с ошибкой — следующий цикл"); - } - - if (!await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) - { - break; - } - } - } -} +using Deal.Telegram.Dialogs; + +namespace Deal.Telegram.Hosting; + +/// +/// Фоновый цикл догона realtime. +/// +public sealed class RealtimeSweepService : BackgroundService +{ + private readonly RealtimeSweep _sweep; + private readonly ILogger _logger; + + /// + /// Создаёт фоновый цикл догона. + /// + /// Логика догона realtime. + /// Логгер. + public RealtimeSweepService(RealtimeSweep sweep, ILogger logger) + { + _sweep = sweep; + _logger = logger; + } + + /// + /// Первый проход сразу, затем цикл 30 с. + /// + /// Токен остановки хоста. + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using PeriodicTimer timer = new(TimeSpan.FromSeconds(RealtimeSweep.SweepPeriodSeconds)); + while (true) + { + try + { + await _sweep.SweepAllAsync(stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception exception) + { + _logger.LogError(exception, "Цикл realtime sweep завершился с ошибкой — следующий цикл"); + } + + if (!await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + break; + } + } + } +} diff --git a/src/telegram-service/Deal.Telegram/Hosting/SessionHeartbeatService.cs b/src/telegram-service/Deal.Telegram/Hosting/SessionHeartbeatService.cs index 094e3e6..55eebba 100644 --- a/src/telegram-service/Deal.Telegram/Hosting/SessionHeartbeatService.cs +++ b/src/telegram-service/Deal.Telegram/Hosting/SessionHeartbeatService.cs @@ -1,71 +1,71 @@ -using Deal.Telegram.Sessions; - -namespace Deal.Telegram.Hosting; - -/// -/// Фоновый цикл сессий telegram-service. -/// -public sealed class SessionHeartbeatService : BackgroundService -{ - /// - /// Период цикла сердцебиения. - /// - public const int HeartbeatIntervalSeconds = 30; - - private readonly SessionFarm _sessionFarm; - private readonly ILogger _logger; - - /// - /// Создаёт фоновый цикл сессий. - /// - /// Пул сессий тенантов. - /// Логгер. - public SessionHeartbeatService(SessionFarm sessionFarm, ILogger logger) - { - _sessionFarm = sessionFarm; - _logger = logger; - } - - /// - /// Первый проход — auto_resume файлов сессий - /// - /// Токен остановки хоста. - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - try - { - await _sessionFarm.ResumeAllAsync(stoppingToken).ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogError(exception, "auto_resume сессий не выполнен — продолжаем без возобновления"); - } - - using PeriodicTimer timer = new(TimeSpan.FromSeconds(HeartbeatIntervalSeconds)); - while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) - { - try - { - await _sessionFarm.HeartbeatTickAsync(stoppingToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - break; - } - catch (Exception exception) - { - _logger.LogError(exception, "Heartbeat сессий завершился с ошибкой — следующий цикл"); - } - } - } - - /// - /// При остановке хоста сохраняет живые сессии. - /// - /// Токен остановки. - public override async Task StopAsync(CancellationToken cancellationToken) - { - await base.StopAsync(cancellationToken).ConfigureAwait(false); - await _sessionFarm.ShutdownAsync(cancellationToken).ConfigureAwait(false); - } -} +using Deal.Telegram.Sessions; + +namespace Deal.Telegram.Hosting; + +/// +/// Фоновый цикл сессий telegram-service. +/// +public sealed class SessionHeartbeatService : BackgroundService +{ + /// + /// Период цикла сердцебиения. + /// + public const int HeartbeatIntervalSeconds = 30; + + private readonly SessionFarm _sessionFarm; + private readonly ILogger _logger; + + /// + /// Создаёт фоновый цикл сессий. + /// + /// Пул сессий тенантов. + /// Логгер. + public SessionHeartbeatService(SessionFarm sessionFarm, ILogger logger) + { + _sessionFarm = sessionFarm; + _logger = logger; + } + + /// + /// Первый проход — auto_resume файлов сессий + /// + /// Токен остановки хоста. + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await _sessionFarm.ResumeAllAsync(stoppingToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogError(exception, "auto_resume сессий не выполнен — продолжаем без возобновления"); + } + + using PeriodicTimer timer = new(TimeSpan.FromSeconds(HeartbeatIntervalSeconds)); + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + try + { + await _sessionFarm.HeartbeatTickAsync(stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception exception) + { + _logger.LogError(exception, "Heartbeat сессий завершился с ошибкой — следующий цикл"); + } + } + } + + /// + /// При остановке хоста сохраняет живые сессии. + /// + /// Токен остановки. + public override async Task StopAsync(CancellationToken cancellationToken) + { + await base.StopAsync(cancellationToken).ConfigureAwait(false); + await _sessionFarm.ShutdownAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/telegram-service/Deal.Telegram/Program.cs b/src/telegram-service/Deal.Telegram/Program.cs index aa430b7..ec4dcdd 100644 --- a/src/telegram-service/Deal.Telegram/Program.cs +++ b/src/telegram-service/Deal.Telegram/Program.cs @@ -1,36 +1,36 @@ -// - -using Deal.Grpc.Hosting.Interceptors; -using Deal.Grpc.Hosting.Models; -using Deal.Grpc.Hosting.Options; -using Deal.Grpc.Hosting.Services; -using Deal.Telegram; - -const int defaultGrpcPort = 5101; -const string telegramProcessName = "telegram"; - -int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort); -int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); - -WebApplication app = TelegramServiceHost.Create( - grpcPort, - configureBuilder: builder => - { - DealLogging.Configure(builder, telegramProcessName); - DealMetricsHosting.AddDealMetrics(builder, metricsPort); - }); - -DealMetricsHosting.MapDealMetrics(app); - -MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration); - -// Fail-closed (замечание code-review): отсутствие/опечатка DEAL_MTLS_ENABLED не должны давать -// «тихого» plaintext в Production; Development (и прочие не-prod окружения) — как раньше. -GrpcHostEnvironment.RequireMtlsInProduction(mtlsOptions); - -app.Logger.LogInformation( - "telegram-service стартует: gRPC {Transport} 0.0.0.0:{Port} (health /grpc.health.v1.Health/Check)", - mtlsOptions.Enabled ? "mTLS (TLS + клиентский сертификат)" : "plaintext + service-token", - grpcPort); - -await app.RunAsync(); +// + +using Deal.Grpc.Hosting.Interceptors; +using Deal.Grpc.Hosting.Models; +using Deal.Grpc.Hosting.Options; +using Deal.Grpc.Hosting.Services; +using Deal.Telegram; + +const int defaultGrpcPort = 5101; +const string telegramProcessName = "telegram"; + +int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort); +int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); + +WebApplication app = TelegramServiceHost.Create( + grpcPort, + configureBuilder: builder => + { + DealLogging.Configure(builder, telegramProcessName); + DealMetricsHosting.AddDealMetrics(builder, metricsPort); + }); + +DealMetricsHosting.MapDealMetrics(app); + +MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration); + +// Fail-closed (замечание code-review): отсутствие/опечатка DEAL_MTLS_ENABLED не должны давать +// «тихого» plaintext в Production; Development (и прочие не-prod окружения) — как раньше. +GrpcHostEnvironment.RequireMtlsInProduction(mtlsOptions); + +app.Logger.LogInformation( + "telegram-service стартует: gRPC {Transport} 0.0.0.0:{Port} (health /grpc.health.v1.Health/Check)", + mtlsOptions.Enabled ? "mTLS (TLS + клиентский сертификат)" : "plaintext + service-token", + grpcPort); + +await app.RunAsync(); diff --git a/src/telegram-service/Deal.Telegram/Sessions/AuthPhase.cs b/src/telegram-service/Deal.Telegram/Sessions/AuthPhase.cs index 7171989..b4f2546 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/AuthPhase.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/AuthPhase.cs @@ -1,37 +1,37 @@ -namespace Deal.Telegram.Sessions; - -/// -/// Фаза входа/подключения аккаунта тенанта -/// -public enum AuthPhase -{ - /// - /// Активного входа нет - /// - Idle, - - /// - /// Фаза ввода номера телефона. - /// - Phone, - - /// - /// SMS-код отправлен, ожидается код - /// - Code, - - /// - /// Включена двухфакторная аутентификация — нужен облачный пароль - /// - Password, - - /// - /// QR-вход запущен - /// - Qr, - - /// - /// Аккаунт авторизован, сессия сохранена - /// - Ready, -} +namespace Deal.Telegram.Sessions; + +/// +/// Фаза входа/подключения аккаунта тенанта +/// +public enum AuthPhase +{ + /// + /// Активного входа нет + /// + Idle, + + /// + /// Фаза ввода номера телефона. + /// + Phone, + + /// + /// SMS-код отправлен, ожидается код + /// + Code, + + /// + /// Включена двухфакторная аутентификация — нужен облачный пароль + /// + Password, + + /// + /// QR-вход запущен + /// + Qr, + + /// + /// Аккаунт авторизован, сессия сохранена + /// + Ready, +} diff --git a/src/telegram-service/Deal.Telegram/Sessions/SessionErrorMessages.cs b/src/telegram-service/Deal.Telegram/Sessions/SessionErrorMessages.cs index 45a1fc7..92e653b 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/SessionErrorMessages.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/SessionErrorMessages.cs @@ -1,106 +1,106 @@ -namespace Deal.Telegram.Sessions; - -/// -/// Канонические тексты причин сессионных ошибок -/// -public static class SessionErrorMessages -{ - public const string NoApiKeys = "Сначала сохраните Telegram api_id и api_hash в настройках"; - - /// - /// Аккаунт тенанта не подключён — нет сессии - /// - public const string NotConnected = "Telegram не подключён"; - - /// - /// Неверный SMS-код входа - /// - public const string WrongCode = "Неверный код"; - - /// - /// SMS-код истёк, нужен новый - /// - public const string CodeExpired = "Код истёк — запросите новый"; - - /// - /// Неверный облачный пароль 2FA - /// - public const string WrongPassword = "Неверный облачный пароль"; - - /// - /// SendCode вызван вне фазы "code" - /// - public const string CodeNotRequested = "Код не запрашивался — начните вход по номеру телефона"; - - /// - /// SendPassword вызван вне фазы "password" - /// - public const string PasswordNotRequested = "2FA-пароль не запрашивался — сначала отправьте код"; - - /// - /// Metadata tenant-id отсутствует или пуст - /// - public const string TenantIdMissing = "tenant-id отсутствует в metadata"; - - /// - /// Некорректный tenant-id - /// - public const string InvalidTenantId = "Некорректный tenant-id в metadata"; - - /// - /// Telegram/сеть недоступны - /// - public const string TelegramUnavailable = "Telegram недоступен — повторите попытку позже"; - - /// - /// Ядро (gRPC-ингресс) недоступно — PushSource/SyncDialogs не доставлены (UNAVAILABLE). - /// - public const string IngressUnavailable = "Ядро недоступно — повторите попытку позже"; - - /// - /// Диалог не найден в аккаунте/кэше сущностей сессии - /// - public const string UnknownDialog = "Источник не найден в аккаунте — обновите список каналов"; - - /// - /// Пустой username вступления. - /// - public const string JoinUsernameMissing = "Не указан username для вступления"; - - /// - /// Поисковый запрос длиннее верхней границы - /// - public const string SearchQueryTooLong = "Слишком длинный поисковый запрос"; - - /// - /// Username вступления длиннее лимита Telegram - /// - public const string UsernameTooLong = "Слишком длинный username"; - - /// - /// Внутренняя ошибка сервиса - /// - public const string InternalError = "Внутренняя ошибка сервиса — повторите попытку позже"; - - /// - /// По username найден не канал/группа - /// - public const string JoinTargetNotChannel = "По этому username найден не канал/группа — вступить нельзя"; - - /// - /// Некорректный - /// - public const string InvalidDialogId = "Некорректный id источника"; - - /// - /// Неположительный id сообщения в запросе - /// - public const string InvalidMessageId = "Некорректный id сообщения"; - - /// - /// Телефон не зарегистрирован в Telegram - /// - public const string SignUpRequired = "Номер не зарегистрирован в Telegram — зарегистрируйте его в приложении Telegram"; - - public const string SessionKeyNotConfigured = "Ключ шифрования сессий не задан (DEAL_TELEGRAM_SESSION_KEY)"; -} +namespace Deal.Telegram.Sessions; + +/// +/// Канонические тексты причин сессионных ошибок +/// +public static class SessionErrorMessages +{ + public const string NoApiKeys = "Сначала сохраните Telegram api_id и api_hash в настройках"; + + /// + /// Аккаунт тенанта не подключён — нет сессии + /// + public const string NotConnected = "Telegram не подключён"; + + /// + /// Неверный SMS-код входа + /// + public const string WrongCode = "Неверный код"; + + /// + /// SMS-код истёк, нужен новый + /// + public const string CodeExpired = "Код истёк — запросите новый"; + + /// + /// Неверный облачный пароль 2FA + /// + public const string WrongPassword = "Неверный облачный пароль"; + + /// + /// SendCode вызван вне фазы "code" + /// + public const string CodeNotRequested = "Код не запрашивался — начните вход по номеру телефона"; + + /// + /// SendPassword вызван вне фазы "password" + /// + public const string PasswordNotRequested = "2FA-пароль не запрашивался — сначала отправьте код"; + + /// + /// Metadata tenant-id отсутствует или пуст + /// + public const string TenantIdMissing = "tenant-id отсутствует в metadata"; + + /// + /// Некорректный tenant-id + /// + public const string InvalidTenantId = "Некорректный tenant-id в metadata"; + + /// + /// Telegram/сеть недоступны + /// + public const string TelegramUnavailable = "Telegram недоступен — повторите попытку позже"; + + /// + /// Ядро (gRPC-ингресс) недоступно — PushSource/SyncDialogs не доставлены (UNAVAILABLE). + /// + public const string IngressUnavailable = "Ядро недоступно — повторите попытку позже"; + + /// + /// Диалог не найден в аккаунте/кэше сущностей сессии + /// + public const string UnknownDialog = "Источник не найден в аккаунте — обновите список каналов"; + + /// + /// Пустой username вступления. + /// + public const string JoinUsernameMissing = "Не указан username для вступления"; + + /// + /// Поисковый запрос длиннее верхней границы + /// + public const string SearchQueryTooLong = "Слишком длинный поисковый запрос"; + + /// + /// Username вступления длиннее лимита Telegram + /// + public const string UsernameTooLong = "Слишком длинный username"; + + /// + /// Внутренняя ошибка сервиса + /// + public const string InternalError = "Внутренняя ошибка сервиса — повторите попытку позже"; + + /// + /// По username найден не канал/группа + /// + public const string JoinTargetNotChannel = "По этому username найден не канал/группа — вступить нельзя"; + + /// + /// Некорректный + /// + public const string InvalidDialogId = "Некорректный id источника"; + + /// + /// Неположительный id сообщения в запросе + /// + public const string InvalidMessageId = "Некорректный id сообщения"; + + /// + /// Телефон не зарегистрирован в Telegram + /// + public const string SignUpRequired = "Номер не зарегистрирован в Telegram — зарегистрируйте его в приложении Telegram"; + + public const string SessionKeyNotConfigured = "Ключ шифрования сессий не задан (DEAL_TELEGRAM_SESSION_KEY)"; +} diff --git a/src/telegram-service/Deal.Telegram/Sessions/SessionException.cs b/src/telegram-service/Deal.Telegram/Sessions/SessionException.cs index 20ce87d..9e07bba 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/SessionException.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/SessionException.cs @@ -1,29 +1,29 @@ -using Grpc.Core; - -namespace Deal.Telegram.Sessions; - -/// -/// Доменная ошибка сессий telegram-service. -/// -public sealed class SessionException : Exception -{ - /// - /// Создаёт ошибку сессии с gRPC-кодом, в который она должна превратиться на границе. - /// - /// gRPC-статус ошибки (контракт telegram.proto, шапка файла). - /// Текст причины — detail RPC. - /// Внутренняя причина (исключение Telegram/адаптера), если есть. - public SessionException( - StatusCode code, - string message, - Exception? innerException = null) - : base(message, innerException) - { - Code = code; - } - - /// - /// gRPC-статус, в который ошибка превращается на границе сервиса. - /// - public StatusCode Code { get; } -} +using Grpc.Core; + +namespace Deal.Telegram.Sessions; + +/// +/// Доменная ошибка сессий telegram-service. +/// +public sealed class SessionException : Exception +{ + /// + /// Создаёт ошибку сессии с gRPC-кодом, в который она должна превратиться на границе. + /// + /// gRPC-статус ошибки (контракт telegram.proto, шапка файла). + /// Текст причины — detail RPC. + /// Внутренняя причина (исключение Telegram/адаптера), если есть. + public SessionException( + StatusCode code, + string message, + Exception? innerException = null) + : base(message, innerException) + { + Code = code; + } + + /// + /// gRPC-статус, в который ошибка превращается на границе сервиса. + /// + public StatusCode Code { get; } +} diff --git a/src/telegram-service/Deal.Telegram/Sessions/SessionFarm.cs b/src/telegram-service/Deal.Telegram/Sessions/SessionFarm.cs index 45fa0da..2fbd2a1 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/SessionFarm.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/SessionFarm.cs @@ -1,349 +1,349 @@ -using System.Collections.Concurrent; -using Deal.Telegram.Telegram; -using Grpc.Core; - -namespace Deal.Telegram.Sessions; - -/// -/// Пул сессий тенантов «1 аккаунт на тенанта». -/// -public sealed class SessionFarm -{ - private readonly ITelegramClientFactory _clientFactory; - private readonly SessionStore _sessionStore; - private readonly ILoggerFactory _loggerFactory; - private readonly ILogger _logger; - private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); - - /// - /// Создаёт пул сессий. - /// - /// Фабрика клиентов Telegram (реальная или фейк в тестах). - /// Файловое хранилище сессий. - /// Фабрика логгеров (логгеры TenantSession). - /// Логгер. - public SessionFarm( - ITelegramClientFactory clientFactory, - SessionStore sessionStore, - ILoggerFactory loggerFactory, - ILogger logger) - { - _clientFactory = clientFactory; - _sessionStore = sessionStore; - _loggerFactory = loggerFactory; - _logger = logger; - } - - /// - /// Возвращает сессию тенанта, если она уже создана - /// - /// Id тенанта. - public TenantSession? FindSession(string tenantId) - => _sessions.TryGetValue(tenantId, out TenantSession? session) ? session : null; - - /// - /// Все сессии пула - /// - public IReadOnlyCollection Sessions - => _sessions.Values.ToArray(); - - /// - /// Список диалогов аккаунта тенанта. - /// - /// Id тенанта. - /// Верхняя граница числа диалогов. - /// Отмена операции. - public Task> ListDialogsAsync( - string tenantId, - int limit, - CancellationToken cancellationToken) - => RequireSession(tenantId).ListDialogsAsync(limit, cancellationToken); - - /// - /// Последние сообщения диалога тенанта. - /// - /// Id тенанта. - /// Подписанный id диалога. - /// Сколько последних сообщений запросить. - /// Отмена операции. - public Task> GetMessagesAsync( - string tenantId, - string dialogId, - int limit, - CancellationToken cancellationToken) - => RequireSession(tenantId).GetMessagesAsync(dialogId, limit, cancellationToken); - - /// - /// Исходное сообщение диалога тенанта по id. - /// - /// Id тенанта. - /// Подписанный id диалога. - /// Id сообщения в Telegram. - /// Отмена операции. - public Task GetMessageAsync( - string tenantId, - string dialogId, - long msgId, - CancellationToken cancellationToken) - => RequireSession(tenantId).GetMessageAsync(dialogId, msgId, cancellationToken); - - /// - /// Помечает диалог тенанта прочитанным. - /// - /// Id тенанта. - /// Подписанный id диалога. - /// Отмена операции. - public Task MarkReadAsync( - string tenantId, - string dialogId, - CancellationToken cancellationToken) - => RequireSession(tenantId).MarkReadAsync(dialogId, cancellationToken); - - /// - /// Глобальный поиск каналов/групп по ключу. - /// - /// Id тенанта. - /// Поисковый запрос (ключ задачи discovery). - /// Верхняя граница результата. - /// Отмена операции. - public Task> SearchAsync( - string tenantId, - string query, - int limit, - CancellationToken cancellationToken) - => RequireSession(tenantId).SearchAsync(query, limit, cancellationToken); - - /// - /// Инфо об источнике для оценки кандидата. - /// - /// Id тенанта. - /// Подписанный id источника. - /// Отмена операции. - public Task GetInfoAsync( - string tenantId, - string dialogId, - CancellationToken cancellationToken) - => RequireSession(tenantId).GetInfoAsync(dialogId, cancellationToken); - - /// - /// Выборка сообщений источника для оценки. - /// - /// Id тенанта. - /// Подписанный id источника. - /// Размер выборки. - /// Отмена операции. - public Task ReadForEvalAsync( - string tenantId, - string dialogId, - int limit, - CancellationToken cancellationToken) - => RequireSession(tenantId).ReadForEvalAsync(dialogId, limit, cancellationToken); - - /// - /// Вступить в канал/группу по username. - /// - /// Id тенанта. - /// Username (без «@»; нормализует DiscoveryOps). - /// Отмена операции. - public Task JoinAsync( - string tenantId, - string username, - CancellationToken cancellationToken) - => RequireSession(tenantId).JoinAsync(username, cancellationToken); - - /// - /// Выйти из канала/группы. - /// - /// Id тенанта. - /// Подписанный id диалога. - /// Отмена операции. - public Task LeaveAsync( - string tenantId, - string dialogId, - CancellationToken cancellationToken) - => RequireSession(tenantId).LeaveAsync(dialogId, cancellationToken); - - /// - /// Вход по телефону - /// - /// Id тенанта. - /// api_id приложения. - /// api_hash приложения. - /// Номер телефона. - /// Отмена операции. - public Task StartPhoneAsync( - string tenantId, - int apiId, - string apiHash, - string phone, - CancellationToken cancellationToken) - => GetOrCreate(tenantId).StartPhoneAsync(apiId, apiHash, phone, cancellationToken); - - /// - /// QR-вход: сессия создаётся при первом обращении. - /// - /// Id тенанта. - /// api_id приложения. - /// api_hash приложения. - /// Отмена операции. - public Task StartQrAsync( - string tenantId, - int apiId, - string apiHash, - CancellationToken cancellationToken) - => GetOrCreate(tenantId).StartQrAsync(apiId, apiHash, cancellationToken); - - /// - /// Отправка SMS-кода на сессии тенанта. - /// - /// Id тенанта. - /// Код. - /// Отмена операции. - public Task SendCodeAsync( - string tenantId, - string code, - CancellationToken cancellationToken) - => RequireSession(tenantId).SendCodeAsync(code, cancellationToken); - - /// - /// Отправка облачного пароля 2FA на сессии тенанта. - /// - /// Id тенанта. - /// Пароль. - /// Отмена операции. - public Task SendPasswordAsync( - string tenantId, - string password, - CancellationToken cancellationToken) - => RequireSession(tenantId).SendPasswordAsync(password, cancellationToken); - - /// - /// Отключение аккаунта - /// - /// Id тенанта. - /// Отмена операции. - public Task LogoutAsync(string tenantId, CancellationToken cancellationToken) - { - TenantSession? session = FindSession(tenantId); - if (session is null) - { - // Сессии нет — нечего отключать; файла на диске тоже быть не должно (чистим на всякий случай). - return DeleteStrayFileAsync(tenantId, cancellationToken); - } - - return session.LogoutAsync(cancellationToken); - } - - /// - /// Статус сессии тенанта; null — аккаунт не подключён - /// - /// Id тенанта. - /// Отмена операции. - public Task GetStatusAsync(string tenantId, CancellationToken cancellationToken) - { - TenantSession? session = FindSession(tenantId); - return session is null ? Task.FromResult(null) : session.GetSnapshotAsync(cancellationToken); - } - - /// - /// Авто-возобновление на старте - /// - /// Отмена операции. - public async Task ResumeAllAsync(CancellationToken cancellationToken) - { - foreach (string tenantId in _sessionStore.ListTenantIds()) - { - if (cancellationToken.IsCancellationRequested) - { - return; - } - - StoredSession? stored; - try - { - stored = await _sessionStore.LoadAsync(tenantId, cancellationToken).ConfigureAwait(false); - } - catch (SessionException exception) - { - _logger.LogWarning(exception, "auto_resume: сессия {TenantId} не прочитана", tenantId); - continue; - } - - if (stored is null) - { - continue; - } - - TenantSession session = GetOrCreate(tenantId); - await session.TryResumeAsync(stored, cancellationToken).ConfigureAwait(false); - } - } - - /// - /// Сердцебиение - /// - /// Отмена операции. - public async Task HeartbeatTickAsync(CancellationToken cancellationToken) - { - foreach (TenantSession session in _sessions.Values) - { - if (cancellationToken.IsCancellationRequested) - { - return; - } - - await session.TryReconnectAsync(cancellationToken).ConfigureAwait(false); - } - } - - /// - /// Остановка хоста - /// - /// Отмена операции. - public async Task ShutdownAsync(CancellationToken cancellationToken) - { - foreach (TenantSession session in _sessions.Values) - { - try - { - await session.FlushAndDisposeAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogWarning(exception, "Остановка: сессия {TenantId} не освобождена", session.TenantId); - } - } - } - - // Создаёт (или возвращает существующую) сессию тенанта. - // tenantId: Id тенанта. - private TenantSession GetOrCreate(string tenantId) - => _sessions.GetOrAdd(tenantId, id => new TenantSession( - id, - _clientFactory, - _sessionStore, - _loggerFactory.CreateLogger())); - - // Сессия обязана существовать (иначе FAILED_PRECONDITION «Telegram не подключён»). - // tenantId: Id тенанта. - private TenantSession RequireSession(string tenantId) - => FindSession(tenantId) - ?? throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.NotConnected); - - // Logout без сессии в памяти: удаляет возможный осиротевший файл сессии. - // tenantId: Id тенанта. - // cancellationToken: Отмена операции. - private async Task DeleteStrayFileAsync(string tenantId, CancellationToken cancellationToken) - { - try - { - await _sessionStore.DeleteAsync(tenantId, cancellationToken).ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogWarning(exception, "Logout {TenantId}: осиротевший файл сессии не удалён", tenantId); - } - - return null; - } -} +using System.Collections.Concurrent; +using Deal.Telegram.Telegram; +using Grpc.Core; + +namespace Deal.Telegram.Sessions; + +/// +/// Пул сессий тенантов «1 аккаунт на тенанта». +/// +public sealed class SessionFarm +{ + private readonly ITelegramClientFactory _clientFactory; + private readonly SessionStore _sessionStore; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); + + /// + /// Создаёт пул сессий. + /// + /// Фабрика клиентов Telegram (реальная или фейк в тестах). + /// Файловое хранилище сессий. + /// Фабрика логгеров (логгеры TenantSession). + /// Логгер. + public SessionFarm( + ITelegramClientFactory clientFactory, + SessionStore sessionStore, + ILoggerFactory loggerFactory, + ILogger logger) + { + _clientFactory = clientFactory; + _sessionStore = sessionStore; + _loggerFactory = loggerFactory; + _logger = logger; + } + + /// + /// Возвращает сессию тенанта, если она уже создана + /// + /// Id тенанта. + public TenantSession? FindSession(string tenantId) + => _sessions.TryGetValue(tenantId, out TenantSession? session) ? session : null; + + /// + /// Все сессии пула + /// + public IReadOnlyCollection Sessions + => _sessions.Values.ToArray(); + + /// + /// Список диалогов аккаунта тенанта. + /// + /// Id тенанта. + /// Верхняя граница числа диалогов. + /// Отмена операции. + public Task> ListDialogsAsync( + string tenantId, + int limit, + CancellationToken cancellationToken) + => RequireSession(tenantId).ListDialogsAsync(limit, cancellationToken); + + /// + /// Последние сообщения диалога тенанта. + /// + /// Id тенанта. + /// Подписанный id диалога. + /// Сколько последних сообщений запросить. + /// Отмена операции. + public Task> GetMessagesAsync( + string tenantId, + string dialogId, + int limit, + CancellationToken cancellationToken) + => RequireSession(tenantId).GetMessagesAsync(dialogId, limit, cancellationToken); + + /// + /// Исходное сообщение диалога тенанта по id. + /// + /// Id тенанта. + /// Подписанный id диалога. + /// Id сообщения в Telegram. + /// Отмена операции. + public Task GetMessageAsync( + string tenantId, + string dialogId, + long msgId, + CancellationToken cancellationToken) + => RequireSession(tenantId).GetMessageAsync(dialogId, msgId, cancellationToken); + + /// + /// Помечает диалог тенанта прочитанным. + /// + /// Id тенанта. + /// Подписанный id диалога. + /// Отмена операции. + public Task MarkReadAsync( + string tenantId, + string dialogId, + CancellationToken cancellationToken) + => RequireSession(tenantId).MarkReadAsync(dialogId, cancellationToken); + + /// + /// Глобальный поиск каналов/групп по ключу. + /// + /// Id тенанта. + /// Поисковый запрос (ключ задачи discovery). + /// Верхняя граница результата. + /// Отмена операции. + public Task> SearchAsync( + string tenantId, + string query, + int limit, + CancellationToken cancellationToken) + => RequireSession(tenantId).SearchAsync(query, limit, cancellationToken); + + /// + /// Инфо об источнике для оценки кандидата. + /// + /// Id тенанта. + /// Подписанный id источника. + /// Отмена операции. + public Task GetInfoAsync( + string tenantId, + string dialogId, + CancellationToken cancellationToken) + => RequireSession(tenantId).GetInfoAsync(dialogId, cancellationToken); + + /// + /// Выборка сообщений источника для оценки. + /// + /// Id тенанта. + /// Подписанный id источника. + /// Размер выборки. + /// Отмена операции. + public Task ReadForEvalAsync( + string tenantId, + string dialogId, + int limit, + CancellationToken cancellationToken) + => RequireSession(tenantId).ReadForEvalAsync(dialogId, limit, cancellationToken); + + /// + /// Вступить в канал/группу по username. + /// + /// Id тенанта. + /// Username (без «@»; нормализует DiscoveryOps). + /// Отмена операции. + public Task JoinAsync( + string tenantId, + string username, + CancellationToken cancellationToken) + => RequireSession(tenantId).JoinAsync(username, cancellationToken); + + /// + /// Выйти из канала/группы. + /// + /// Id тенанта. + /// Подписанный id диалога. + /// Отмена операции. + public Task LeaveAsync( + string tenantId, + string dialogId, + CancellationToken cancellationToken) + => RequireSession(tenantId).LeaveAsync(dialogId, cancellationToken); + + /// + /// Вход по телефону + /// + /// Id тенанта. + /// api_id приложения. + /// api_hash приложения. + /// Номер телефона. + /// Отмена операции. + public Task StartPhoneAsync( + string tenantId, + int apiId, + string apiHash, + string phone, + CancellationToken cancellationToken) + => GetOrCreate(tenantId).StartPhoneAsync(apiId, apiHash, phone, cancellationToken); + + /// + /// QR-вход: сессия создаётся при первом обращении. + /// + /// Id тенанта. + /// api_id приложения. + /// api_hash приложения. + /// Отмена операции. + public Task StartQrAsync( + string tenantId, + int apiId, + string apiHash, + CancellationToken cancellationToken) + => GetOrCreate(tenantId).StartQrAsync(apiId, apiHash, cancellationToken); + + /// + /// Отправка SMS-кода на сессии тенанта. + /// + /// Id тенанта. + /// Код. + /// Отмена операции. + public Task SendCodeAsync( + string tenantId, + string code, + CancellationToken cancellationToken) + => RequireSession(tenantId).SendCodeAsync(code, cancellationToken); + + /// + /// Отправка облачного пароля 2FA на сессии тенанта. + /// + /// Id тенанта. + /// Пароль. + /// Отмена операции. + public Task SendPasswordAsync( + string tenantId, + string password, + CancellationToken cancellationToken) + => RequireSession(tenantId).SendPasswordAsync(password, cancellationToken); + + /// + /// Отключение аккаунта + /// + /// Id тенанта. + /// Отмена операции. + public Task LogoutAsync(string tenantId, CancellationToken cancellationToken) + { + TenantSession? session = FindSession(tenantId); + if (session is null) + { + // Сессии нет — нечего отключать; файла на диске тоже быть не должно (чистим на всякий случай). + return DeleteStrayFileAsync(tenantId, cancellationToken); + } + + return session.LogoutAsync(cancellationToken); + } + + /// + /// Статус сессии тенанта; null — аккаунт не подключён + /// + /// Id тенанта. + /// Отмена операции. + public Task GetStatusAsync(string tenantId, CancellationToken cancellationToken) + { + TenantSession? session = FindSession(tenantId); + return session is null ? Task.FromResult(null) : session.GetSnapshotAsync(cancellationToken); + } + + /// + /// Авто-возобновление на старте + /// + /// Отмена операции. + public async Task ResumeAllAsync(CancellationToken cancellationToken) + { + foreach (string tenantId in _sessionStore.ListTenantIds()) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + StoredSession? stored; + try + { + stored = await _sessionStore.LoadAsync(tenantId, cancellationToken).ConfigureAwait(false); + } + catch (SessionException exception) + { + _logger.LogWarning(exception, "auto_resume: сессия {TenantId} не прочитана", tenantId); + continue; + } + + if (stored is null) + { + continue; + } + + TenantSession session = GetOrCreate(tenantId); + await session.TryResumeAsync(stored, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Сердцебиение + /// + /// Отмена операции. + public async Task HeartbeatTickAsync(CancellationToken cancellationToken) + { + foreach (TenantSession session in _sessions.Values) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + await session.TryReconnectAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Остановка хоста + /// + /// Отмена операции. + public async Task ShutdownAsync(CancellationToken cancellationToken) + { + foreach (TenantSession session in _sessions.Values) + { + try + { + await session.FlushAndDisposeAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "Остановка: сессия {TenantId} не освобождена", session.TenantId); + } + } + } + + // Создаёт (или возвращает существующую) сессию тенанта. + // tenantId: Id тенанта. + private TenantSession GetOrCreate(string tenantId) + => _sessions.GetOrAdd(tenantId, id => new TenantSession( + id, + _clientFactory, + _sessionStore, + _loggerFactory.CreateLogger())); + + // Сессия обязана существовать (иначе FAILED_PRECONDITION «Telegram не подключён»). + // tenantId: Id тенанта. + private TenantSession RequireSession(string tenantId) + => FindSession(tenantId) + ?? throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.NotConnected); + + // Logout без сессии в памяти: удаляет возможный осиротевший файл сессии. + // tenantId: Id тенанта. + // cancellationToken: Отмена операции. + private async Task DeleteStrayFileAsync(string tenantId, CancellationToken cancellationToken) + { + try + { + await _sessionStore.DeleteAsync(tenantId, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "Logout {TenantId}: осиротевший файл сессии не удалён", tenantId); + } + + return null; + } +} diff --git a/src/telegram-service/Deal.Telegram/Sessions/SessionFileCipher.cs b/src/telegram-service/Deal.Telegram/Sessions/SessionFileCipher.cs index 1463e17..b3af5b0 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/SessionFileCipher.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/SessionFileCipher.cs @@ -1,101 +1,101 @@ -using System.Security.Cryptography; - -namespace Deal.Telegram.Sessions; - -/// -/// AES-256-GCM-обёртка файла сессии тенанта. -/// -public sealed class SessionFileCipher -{ - /// - /// Префикс зашифрованного значения - /// - public const string EncryptedPrefix = "enc:"; - - // Размер nonce AES-GCM (рекомендованный NIST — 96 бит). - private const int NonceSizeBytes = 12; - - // Размер тега аутентичности. - private const int TagSizeBytes = 16; - - private readonly byte[] _key; - - /// - /// Создаёт шифр с ключом из опций - /// - /// Опции хранения сессий. - public SessionFileCipher(TgOptions options) - { - _key = options.SessionKey; - } - - /// - /// Шифрует содержимое файла сессии - /// - /// Открытое содержимое сессии (расшифрованная копия в памяти процесса). - /// Зашифрованное значение для записи в файл. - public string EncryptBytes(byte[] plainBytes) - { - ArgumentNullException.ThrowIfNull(plainBytes); - - byte[] nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes); - byte[] cipherBytes = new byte[plainBytes.Length]; - byte[] tag = new byte[TagSizeBytes]; - - using (AesGcm aesGcm = new AesGcm(_key, TagSizeBytes)) - { - aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag); - } - - byte[] payload = new byte[nonce.Length + cipherBytes.Length + tag.Length]; - nonce.CopyTo(payload, 0); - cipherBytes.CopyTo(payload, nonce.Length); - tag.CopyTo(payload, nonce.Length + cipherBytes.Length); - - return EncryptedPrefix + Convert.ToBase64String(payload); - } - - /// - /// Расшифровывает значение файла сессии. - /// - /// Содержимое файла сессии (enc: + base64). - /// Открытые байты сессии либо null (не наш формат). - /// Тег аутентичности не совпал (битый файл/чужой ключ). - public byte[]? DecryptBytes(string encryptedValue) - { - if (string.IsNullOrEmpty(encryptedValue) || !encryptedValue.StartsWith(EncryptedPrefix, StringComparison.Ordinal)) - { - return null; - } - - byte[] payload; - try - { - payload = Convert.FromBase64String(encryptedValue[EncryptedPrefix.Length..]); - } - catch (FormatException) - { - return null; - } - - if (payload.Length < NonceSizeBytes + TagSizeBytes) - { - // Слишком короткий payload: nonce и tag в нём не помещаются. - return null; - } - - int cipherTextLength = payload.Length - NonceSizeBytes - TagSizeBytes; - byte[] plainBytes = new byte[cipherTextLength]; - - using (AesGcm aesGcm = new AesGcm(_key, TagSizeBytes)) - { - aesGcm.Decrypt( - new ReadOnlySpan(payload, 0, NonceSizeBytes), - new ReadOnlySpan(payload, NonceSizeBytes, cipherTextLength), - new ReadOnlySpan(payload, NonceSizeBytes + cipherTextLength, TagSizeBytes), - plainBytes); - } - - return plainBytes; - } -} +using System.Security.Cryptography; + +namespace Deal.Telegram.Sessions; + +/// +/// AES-256-GCM-обёртка файла сессии тенанта. +/// +public sealed class SessionFileCipher +{ + /// + /// Префикс зашифрованного значения + /// + public const string EncryptedPrefix = "enc:"; + + // Размер nonce AES-GCM (рекомендованный NIST — 96 бит). + private const int NonceSizeBytes = 12; + + // Размер тега аутентичности. + private const int TagSizeBytes = 16; + + private readonly byte[] _key; + + /// + /// Создаёт шифр с ключом из опций + /// + /// Опции хранения сессий. + public SessionFileCipher(TgOptions options) + { + _key = options.SessionKey; + } + + /// + /// Шифрует содержимое файла сессии + /// + /// Открытое содержимое сессии (расшифрованная копия в памяти процесса). + /// Зашифрованное значение для записи в файл. + public string EncryptBytes(byte[] plainBytes) + { + ArgumentNullException.ThrowIfNull(plainBytes); + + byte[] nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes); + byte[] cipherBytes = new byte[plainBytes.Length]; + byte[] tag = new byte[TagSizeBytes]; + + using (AesGcm aesGcm = new AesGcm(_key, TagSizeBytes)) + { + aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag); + } + + byte[] payload = new byte[nonce.Length + cipherBytes.Length + tag.Length]; + nonce.CopyTo(payload, 0); + cipherBytes.CopyTo(payload, nonce.Length); + tag.CopyTo(payload, nonce.Length + cipherBytes.Length); + + return EncryptedPrefix + Convert.ToBase64String(payload); + } + + /// + /// Расшифровывает значение файла сессии. + /// + /// Содержимое файла сессии (enc: + base64). + /// Открытые байты сессии либо null (не наш формат). + /// Тег аутентичности не совпал (битый файл/чужой ключ). + public byte[]? DecryptBytes(string encryptedValue) + { + if (string.IsNullOrEmpty(encryptedValue) || !encryptedValue.StartsWith(EncryptedPrefix, StringComparison.Ordinal)) + { + return null; + } + + byte[] payload; + try + { + payload = Convert.FromBase64String(encryptedValue[EncryptedPrefix.Length..]); + } + catch (FormatException) + { + return null; + } + + if (payload.Length < NonceSizeBytes + TagSizeBytes) + { + // Слишком короткий payload: nonce и tag в нём не помещаются. + return null; + } + + int cipherTextLength = payload.Length - NonceSizeBytes - TagSizeBytes; + byte[] plainBytes = new byte[cipherTextLength]; + + using (AesGcm aesGcm = new AesGcm(_key, TagSizeBytes)) + { + aesGcm.Decrypt( + new ReadOnlySpan(payload, 0, NonceSizeBytes), + new ReadOnlySpan(payload, NonceSizeBytes, cipherTextLength), + new ReadOnlySpan(payload, NonceSizeBytes + cipherTextLength, TagSizeBytes), + plainBytes); + } + + return plainBytes; + } +} diff --git a/src/telegram-service/Deal.Telegram/Sessions/SessionStore.cs b/src/telegram-service/Deal.Telegram/Sessions/SessionStore.cs index 2ce0e0e..1a8af66 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/SessionStore.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/SessionStore.cs @@ -1,169 +1,169 @@ -using System.Security.Cryptography; -using System.Text.Json; -using Grpc.Core; - -namespace Deal.Telegram.Sessions; - -/// -/// Файловое хранилище сессий тенантов — SessionManager/SessionStore задачи -/// -public sealed class SessionStore -{ - /// - /// Расширение файла сессии - /// - public const string SessionFileExtension = ".session"; - - private readonly string _sessionsDirectory; - private readonly SessionFileCipher _cipher; - private readonly ILogger _logger; - private readonly SemaphoreSlim _writeLock = new(1, 1); - - /// - /// Создаёт хранилище над каталогом из опций. - /// - /// Опции хранения сессий (каталог, ключ шифрования). - /// AES-GCM-обёртка файлов сессий. - /// Логгер. - public SessionStore( - TgOptions options, - SessionFileCipher cipher, - ILogger logger) - { - _sessionsDirectory = options.SessionsDirectory; - _cipher = cipher; - _logger = logger; - } - - /// - /// Читает и расшифровывает сессию тенанта. - /// - /// Id тенанта. - /// Отмена операции. - public async Task LoadAsync(string tenantId, CancellationToken cancellationToken = default) - { - string filePath = PathFor(tenantId); - - if (!File.Exists(filePath)) - { - return null; - } - - string encryptedValue; - try - { - encryptedValue = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false); - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) - { - _logger.LogWarning(exception, "Сессия {TenantId} не читается — трактуется как отсутствующая", tenantId); - return null; - } - - try - { - byte[]? plainBytes = _cipher.DecryptBytes(encryptedValue); - if (plainBytes is null) - { - _logger.LogWarning("Файл сессии {TenantId} не в формате сервиса — трактуется как отсутствующий", tenantId); - return null; - } - - StoredSession? stored = JsonSerializer.Deserialize(plainBytes); - if (stored is null || stored.FormatVersion != StoredSession.CurrentFormatVersion) - { - _logger.LogWarning("Файл сессии {TenantId} имеет неизвестную версию формата — трактуется как отсутствующий", tenantId); - return null; - } - - return stored; - } - catch (CryptographicException exception) - { - _logger.LogWarning(exception, "Сессия {TenantId} не расшифрована (повреждён файл или сменился ключ)", tenantId); - return null; - } - catch (JsonException exception) - { - _logger.LogWarning(exception, "Содержимое сессии {TenantId} не является корректным JSON", tenantId); - return null; - } - } - - /// - /// Шифрует и атомарно сохраняет сессию тенанта - /// - /// Id тенанта. - /// Открытое содержимое сессии для шифрования at-rest. - /// Отмена операции. - public async Task SaveAsync( - string tenantId, - StoredSession session, - CancellationToken cancellationToken = default) - { - string filePath = PathFor(tenantId); - byte[] plainBytes = JsonSerializer.SerializeToUtf8Bytes(session); - string encryptedValue = _cipher.EncryptBytes(plainBytes); - - await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - Directory.CreateDirectory(_sessionsDirectory); - - string tempPath = filePath + ".tmp"; - await File.WriteAllTextAsync(tempPath, encryptedValue, cancellationToken).ConfigureAwait(false); - File.Move(tempPath, filePath, overwrite: true); - } - finally - { - _writeLock.Release(); - } - } - - /// - /// Удаляет файл сессии тенанта - /// - /// Id тенанта. - /// Отмена операции. - public Task DeleteAsync(string tenantId, CancellationToken cancellationToken = default) - { - string filePath = PathFor(tenantId); - if (File.Exists(filePath)) - { - File.Delete(filePath); - } - - return Task.CompletedTask; - } - - /// - /// Перечисляет id тенантов, для которых на диске есть файл сессии - /// - public IEnumerable ListTenantIds() - { - if (!Directory.Exists(_sessionsDirectory)) - { - return []; - } - - return Directory - .EnumerateFiles(_sessionsDirectory, "*" + SessionFileExtension) - .Select(Path.GetFileNameWithoutExtension) - .Where(fileName => fileName is not null) - .Cast() - .ToArray(); - } - - // Путь файла сессии тенанта (валидация tenant-id: только безопасное имя файла). - // tenantId: Id тенанта. - // Исключение SessionException: Tenant-id пуст или содержит недопустимые для имени файла символы. - private string PathFor(string tenantId) - { - if (string.IsNullOrWhiteSpace(tenantId) || tenantId.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) - { - throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidTenantId); - } - - return Path.Combine(_sessionsDirectory, tenantId + SessionFileExtension); - } -} +using System.Security.Cryptography; +using System.Text.Json; +using Grpc.Core; + +namespace Deal.Telegram.Sessions; + +/// +/// Файловое хранилище сессий тенантов — SessionManager/SessionStore задачи +/// +public sealed class SessionStore +{ + /// + /// Расширение файла сессии + /// + public const string SessionFileExtension = ".session"; + + private readonly string _sessionsDirectory; + private readonly SessionFileCipher _cipher; + private readonly ILogger _logger; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + /// + /// Создаёт хранилище над каталогом из опций. + /// + /// Опции хранения сессий (каталог, ключ шифрования). + /// AES-GCM-обёртка файлов сессий. + /// Логгер. + public SessionStore( + TgOptions options, + SessionFileCipher cipher, + ILogger logger) + { + _sessionsDirectory = options.SessionsDirectory; + _cipher = cipher; + _logger = logger; + } + + /// + /// Читает и расшифровывает сессию тенанта. + /// + /// Id тенанта. + /// Отмена операции. + public async Task LoadAsync(string tenantId, CancellationToken cancellationToken = default) + { + string filePath = PathFor(tenantId); + + if (!File.Exists(filePath)) + { + return null; + } + + string encryptedValue; + try + { + encryptedValue = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(exception, "Сессия {TenantId} не читается — трактуется как отсутствующая", tenantId); + return null; + } + + try + { + byte[]? plainBytes = _cipher.DecryptBytes(encryptedValue); + if (plainBytes is null) + { + _logger.LogWarning("Файл сессии {TenantId} не в формате сервиса — трактуется как отсутствующий", tenantId); + return null; + } + + StoredSession? stored = JsonSerializer.Deserialize(plainBytes); + if (stored is null || stored.FormatVersion != StoredSession.CurrentFormatVersion) + { + _logger.LogWarning("Файл сессии {TenantId} имеет неизвестную версию формата — трактуется как отсутствующий", tenantId); + return null; + } + + return stored; + } + catch (CryptographicException exception) + { + _logger.LogWarning(exception, "Сессия {TenantId} не расшифрована (повреждён файл или сменился ключ)", tenantId); + return null; + } + catch (JsonException exception) + { + _logger.LogWarning(exception, "Содержимое сессии {TenantId} не является корректным JSON", tenantId); + return null; + } + } + + /// + /// Шифрует и атомарно сохраняет сессию тенанта + /// + /// Id тенанта. + /// Открытое содержимое сессии для шифрования at-rest. + /// Отмена операции. + public async Task SaveAsync( + string tenantId, + StoredSession session, + CancellationToken cancellationToken = default) + { + string filePath = PathFor(tenantId); + byte[] plainBytes = JsonSerializer.SerializeToUtf8Bytes(session); + string encryptedValue = _cipher.EncryptBytes(plainBytes); + + await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + Directory.CreateDirectory(_sessionsDirectory); + + string tempPath = filePath + ".tmp"; + await File.WriteAllTextAsync(tempPath, encryptedValue, cancellationToken).ConfigureAwait(false); + File.Move(tempPath, filePath, overwrite: true); + } + finally + { + _writeLock.Release(); + } + } + + /// + /// Удаляет файл сессии тенанта + /// + /// Id тенанта. + /// Отмена операции. + public Task DeleteAsync(string tenantId, CancellationToken cancellationToken = default) + { + string filePath = PathFor(tenantId); + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + + return Task.CompletedTask; + } + + /// + /// Перечисляет id тенантов, для которых на диске есть файл сессии + /// + public IEnumerable ListTenantIds() + { + if (!Directory.Exists(_sessionsDirectory)) + { + return []; + } + + return Directory + .EnumerateFiles(_sessionsDirectory, "*" + SessionFileExtension) + .Select(Path.GetFileNameWithoutExtension) + .Where(fileName => fileName is not null) + .Cast() + .ToArray(); + } + + // Путь файла сессии тенанта (валидация tenant-id: только безопасное имя файла). + // tenantId: Id тенанта. + // Исключение SessionException: Tenant-id пуст или содержит недопустимые для имени файла символы. + private string PathFor(string tenantId) + { + if (string.IsNullOrWhiteSpace(tenantId) || tenantId.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidTenantId); + } + + return Path.Combine(_sessionsDirectory, tenantId + SessionFileExtension); + } +} diff --git a/src/telegram-service/Deal.Telegram/Sessions/StoredSession.cs b/src/telegram-service/Deal.Telegram/Sessions/StoredSession.cs index 4d055c9..7d20e6f 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/StoredSession.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/StoredSession.cs @@ -1,32 +1,32 @@ -namespace Deal.Telegram.Sessions; - -/// -/// Открытое содержимое файла сессии тенанта. -/// -public sealed record StoredSession -{ - /// - /// Версия формата файла - /// - public const int CurrentFormatVersion = 1; - - /// - /// Версия формата; при несовпадении файл считается нечитаемым. - /// - public int FormatVersion { get; init; } = CurrentFormatVersion; - - /// - /// api_id приложения Telegram, под которым авторизована сессия. - /// - public int ApiId { get; init; } - - /// - /// api_hash приложения Telegram, под которым авторизована сессия. - /// - public string ApiHash { get; init; } = string.Empty; - - /// - /// Байты файла сессии WTelegramClient - /// - public byte[] SessionBytes { get; init; } = []; -} +namespace Deal.Telegram.Sessions; + +/// +/// Открытое содержимое файла сессии тенанта. +/// +public sealed record StoredSession +{ + /// + /// Версия формата файла + /// + public const int CurrentFormatVersion = 1; + + /// + /// Версия формата; при несовпадении файл считается нечитаемым. + /// + public int FormatVersion { get; init; } = CurrentFormatVersion; + + /// + /// api_id приложения Telegram, под которым авторизована сессия. + /// + public int ApiId { get; init; } + + /// + /// api_hash приложения Telegram, под которым авторизована сессия. + /// + public string ApiHash { get; init; } = string.Empty; + + /// + /// Байты файла сессии WTelegramClient + /// + public byte[] SessionBytes { get; init; } = []; +} diff --git a/src/telegram-service/Deal.Telegram/Sessions/TenantSession.cs b/src/telegram-service/Deal.Telegram/Sessions/TenantSession.cs index 8790fd3..3341a38 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/TenantSession.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/TenantSession.cs @@ -1,1064 +1,1064 @@ -using Deal.Telegram.Telegram; -using Grpc.Core; - -namespace Deal.Telegram.Sessions; - -/// -/// Сессия тенанта: id тенанта, клиент Telegram и состояние входа. -/// -public sealed class TenantSession : IAsyncDisposable -{ - private readonly ITelegramClientFactory _clientFactory; - private readonly SessionStore _sessionStore; - private readonly ILogger _logger; - private readonly SemaphoreSlim _gate = new(1, 1); - - // Таймаут одной попытки переподключения heartbeat по умолчанию (зависший connect не должен блокировать - // heartbeat остальных тенантов и остановку хоста — замечание code-review). - private static readonly TimeSpan DefaultReconnectAttemptTimeout = TimeSpan.FromSeconds(10); - - private readonly TimeSpan _reconnectAttemptTimeout; - - private ISessionClient? _client; - private int _clientApiId; - private string? _clientApiHash; - - private bool _registered; - private bool _loggedOut; - private AuthPhase _phase = AuthPhase.Idle; - private string? _error; - private string? _account; - private string? _qrUrl; - private string? _phone; - - private CancellationTokenSource? _qrCts; - private Task? _qrWaitTask; - private volatile bool _listenerActive; - - /// - /// Realtime-listener сессии жив - /// - public AuthPhase Phase => _phase; - - /// - /// Событие входящего сообщения аккаунта. - /// - public event Func? MessageReceived; - - /// - /// Создаёт сессию тенанта - /// - /// Id тенанта (принадлежность сессии). - /// Фабрика клиентов Telegram (реальная или фейк в тестах). - /// Файловое хранилище сессий (шифрование at-rest). - /// Логгер. - /// Таймаут попытки переподключения (null — 10 с по умолчанию; тесты). - public TenantSession( - string tenantId, - ITelegramClientFactory clientFactory, - SessionStore sessionStore, - ILogger logger, - TimeSpan? reconnectAttemptTimeout = null) - { - TenantId = tenantId; - _clientFactory = clientFactory; - _sessionStore = sessionStore; - _logger = logger; - _reconnectAttemptTimeout = reconnectAttemptTimeout ?? DefaultReconnectAttemptTimeout; - } - - /// - /// Id тенанта, которому принадлежит сессия - /// - public string TenantId { get; } - - /// - /// Вход по номеру телефона - /// - /// api_id приложения (из тела запроса ядра). - /// api_hash приложения. - /// Номер телефона (международный формат). - /// Отмена операции. - /// Снимок состояния после операции (фаза "code"). - /// Нет ключей (INVALID_ARGUMENT) / ошибки Telegram. - public async Task StartPhoneAsync( - int apiId, - string apiHash, - string phone, - CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - ValidateApiKeys(apiId, apiHash); - _registered = true; - _loggedOut = false; - _error = null; - _phone = phone; - - CancelQrFlow(); - - await EnsureClientAsync(apiId, apiHash, cancellationToken).ConfigureAwait(false); - if (_client!.IsAuthorized) - { - // Сессия уже авторизована (например, возобновлена на старте) — код не нужен. - await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); - return Snapshot(); - } - - try - { - await _client.ConnectAsync(cancellationToken).ConfigureAwait(false); - await _client.RequestCodeAsync(phone, cancellationToken).ConfigureAwait(false); - } - catch (SessionException exception) - { - _phase = AuthPhase.Idle; - _error = exception.Message; - throw; - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - SessionException wrapped = new(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception); - _phase = AuthPhase.Idle; - _error = wrapped.Message; - throw wrapped; - } - - _phase = AuthPhase.Code; - return Snapshot(); - } - finally - { - _gate.Release(); - } - } - - /// - /// Начать QR-вход: фаза "qr" + первый URL либо "ready", если уже вошли. - /// - /// api_id приложения. - /// api_hash приложения. - /// Отмена операции. - /// Снимок состояния: Qr с qrUrl или Ready (авторизация уже была). - /// Нет ключей (INVALID_ARGUMENT) / ошибки Telegram до первого URL. - public async Task StartQrAsync( - int apiId, - string apiHash, - CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - ValidateApiKeys(apiId, apiHash); - _registered = true; - _loggedOut = false; - _error = null; - - await EnsureClientAsync(apiId, apiHash, cancellationToken).ConfigureAwait(false); - if (_client!.IsAuthorized) - { - await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); - return Snapshot(); - } - - if (_phase == AuthPhase.Qr && _qrWaitTask is { IsCompleted: false }) - { - return Snapshot(); - } - - CancelQrFlow(); - - _phase = AuthPhase.Qr; - _qrUrl = null; - - var qrCts = new CancellationTokenSource(); - _qrCts = qrCts; - var firstUrlTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _qrWaitTask = RunQrFlowAsync(qrCts.Token, firstUrlTcs); - - string? firstUrl; - try - { - firstUrl = await WaitForFirstQrUrlAsync(_qrWaitTask, firstUrlTcs, cancellationToken).ConfigureAwait(false); - } - catch (SessionException exception) - { - _phase = AuthPhase.Idle; - _qrUrl = null; - _error = exception.Message; - throw; - } - catch (OperationCanceledException) - { - if (_phase == AuthPhase.Qr) - { - _phase = AuthPhase.Idle; - _qrUrl = null; - } - - CancelQrFlow(); - - throw; - } - - if (firstUrl is not null) - { - _qrUrl = firstUrl; - } - - // Задача завершилась без URL — авторизация произошла мгновенно (фаза Ready выставлена задачей) - // либо URL пришёл (фаза Qr). Возвращаем актуальный снимок. - return Snapshot(); - } - finally - { - _gate.Release(); - } - } - - /// - /// Отправить SMS-код. Фазы вне "code" — FAILED_PRECONDITION. - /// - /// Код из SMS/Telegram-сообщения. - /// Отмена операции. - /// Снимок состояния: "password" при 2FA либо "ready" после авторизации. - public async Task SendCodeAsync(string code, CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - EnsureLoginStarted(); - if (_phase != AuthPhase.Code || _client is null) - { - throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.CodeNotRequested); - } - - _error = null; - - string? nextStep; - try - { - nextStep = await _client.SubmitCodeAsync(code, cancellationToken).ConfigureAwait(false); - } - catch (SessionException exception) - { - _error = exception.Message; - throw; - } - - if (nextStep == "password") - { - _phase = AuthPhase.Password; - return Snapshot(); - } - - await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); - return Snapshot(); - } - finally - { - _gate.Release(); - } - } - - /// - /// Отправить облачный пароль 2FA. - /// - /// Облачный пароль. - /// Отмена операции. - /// Снимок состояния после операции (фаза "ready"). - public async Task SendPasswordAsync(string password, CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - EnsureLoginStarted(); - if (_phase != AuthPhase.Password || _client is null) - { - throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.PasswordNotRequested); - } - - _error = null; - - try - { - await _client.SubmitPasswordAsync(password, cancellationToken).ConfigureAwait(false); - } - catch (SessionException exception) - { - _error = exception.Message; - throw; - } - - await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); - return Snapshot(); - } - finally - { - _gate.Release(); - } - } - - /// - /// Отключить аккаунт и удалить сессию тенанта. - /// - /// Отмена операции. - public async Task LogoutAsync(CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - CancelQrFlow(); - - if (_client is not null) - { - try - { - await _client.LogOutAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - // Auth_LogOut недоступен (сеть) — локальный выход и удаление файла всё равно выполняются. - _logger.LogWarning(exception, "Logout {TenantId}: Auth_LogOut не выполнен — продолжаем локальный выход", TenantId); - } - - try - { - await _sessionStore.DeleteAsync(TenantId, cancellationToken).ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogWarning(exception, "Logout {TenantId}: файл сессии не удалён", TenantId); - } - - DetachClientMessages(_client); - await _client.DisposeAsync().ConfigureAwait(false); - _client = null; - } - - _clientApiId = 0; - _clientApiHash = null; - _phase = AuthPhase.Idle; - _error = null; - _account = null; - _qrUrl = null; - _phone = null; - _loggedOut = true; - _registered = false; - return null; - } - finally - { - _gate.Release(); - } - } - - /// - /// Снимок состояния для GetStatus; null — сессии тенанта нет - /// - /// Отмена операции. - public async Task GetSnapshotAsync(CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - return _loggedOut || !_registered ? null : Snapshot(); - } - finally - { - _gate.Release(); - } - } - - - /// - /// Список диалогов аккаунта; фаза обязана быть ready. - /// - /// Верхняя граница числа диалогов. - /// Отмена операции. - /// Диалоги аккаунта (нейтральный вид). - public async Task> ListDialogsAsync(int limit, CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); - return await client.GetDialogsAsync(limit, cancellationToken).ConfigureAwait(false); - } - finally - { - _gate.Release(); - } - } - - /// - /// Последние сообщения диалога - /// - /// Подписанный id диалога. - /// Сколько последних сообщений запросить. - /// Отмена операции. - /// Сообщения диалога (от новых к старым, непустые тексты). - public async Task> GetMessagesAsync( - string dialogId, - int limit, - CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); - return await client.GetMessagesAsync(dialogId, limit, cancellationToken).ConfigureAwait(false); - } - finally - { - _gate.Release(); - } - } - - /// - /// Исходное сообщение диалога по id (remote-просмотр). - /// - /// Подписанный id диалога. - /// Id сообщения в Telegram. - /// Отмена операции. - /// Сообщение с текстом либо null (медиа без текста/не найдено). - public async Task GetMessageAsync( - string dialogId, - long msgId, - CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); - return await client.GetMessageAsync(dialogId, msgId, cancellationToken).ConfigureAwait(false); - } - finally - { - _gate.Release(); - } - } - - /// - /// Помечает диалог прочитанным - /// - /// Подписанный id диалога. - /// Отмена операции. - public async Task MarkReadAsync(string dialogId, CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); - await client.MarkReadAsync(dialogId, cancellationToken).ConfigureAwait(false); - } - finally - { - _gate.Release(); - } - } - - - /// - /// Глобальный поиск каналов/групп по ключу; фаза ready. - /// - /// Поисковый запрос (ключ задачи discovery). - /// Верхняя граница результата. - /// Отмена операции. - /// Найденные источники. - public async Task> SearchAsync( - string query, - int limit, - CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); - return await client.SearchAsync(query, limit, cancellationToken).ConfigureAwait(false); - } - finally - { - _gate.Release(); - } - } - - /// - /// Инфо об источнике для оценки кандидата; фаза ready. - /// - /// Подписанный id источника. - /// Отмена операции. - /// Инфо (по умолчанию — только id; сбои определения не бросаются, см. ISessionClient). - public async Task GetInfoAsync(string dialogId, CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); - return await client.GetInfoAsync(dialogId, cancellationToken).ConfigureAwait(false); - } - finally - { - _gate.Release(); - } - } - - /// - /// Выборка сообщений источника для оценки; фаза ready. - /// - /// Подписанный id источника. - /// Размер выборки (limit ≤ 0 — пусто без сети). - /// Отмена операции. - /// Результат чтения (ok/messages либо no_history). - public async Task ReadForEvalAsync( - string dialogId, - int limit, - CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); - return await client.ReadForEvalAsync(dialogId, limit, cancellationToken).ConfigureAwait(false); - } - finally - { - _gate.Release(); - } - } - - /// - /// Вступить в канал/группу по username; фаза ready. - /// - /// Username (без «@»; нормализует DiscoveryOps). - /// Отмена операции. - public async Task JoinAsync(string username, CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); - await client.JoinAsync(username, cancellationToken).ConfigureAwait(false); - } - finally - { - _gate.Release(); - } - } - - /// - /// Выйти из канала/группы; фаза ready. - /// - /// Подписанный id диалога. - /// Отмена операции. - public async Task LeaveAsync(string dialogId, CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); - await client.LeaveAsync(dialogId, cancellationToken).ConfigureAwait(false); - } - finally - { - _gate.Release(); - } - } - - /// - /// Ставит признак живого realtime-listener. - /// - /// True — listener сессии подписан на события сообщений. - public void SetListenerActive(bool active) - => _listenerActive = active; - - // Проверяет готовность сессии и соединения (фаза ready + клиент). - // cancellationToken: Отмена операции. - private async Task EnsureReadyConnectedAsync(CancellationToken cancellationToken) - { - ISessionClient client = RequireReadyClient(); - if (client.IsConnected) - { - return client; - } - - try - { - await client.ConnectAsync(cancellationToken).ConfigureAwait(false); - } - catch (SessionException) - { - throw; - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - throw new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception); - } - - return client; - } - - // Ready-клиент сессии (иначе «Telegram не подключён», FAILED_PRECONDITION). - private ISessionClient RequireReadyClient() - { - if (_loggedOut || !_registered || _client is null || _phase != AuthPhase.Ready) - { - throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.NotConnected); - } - - return _client; - } - - // --- Проброс realtime-сообщений клиента на уровень службы --- - - // Передаёт сообщение клиента подписчикам сессии (каждый в своей ошибко-изоляции). - // message: Входящее сообщение аккаунта. - private async Task ForwardClientMessageAsync(TelegramMessage message) - { - Func? subscribers = MessageReceived; - if (subscribers is null) - { - return; - } - - foreach (Delegate subscriber in subscribers.GetInvocationList()) - { - try - { - await ((Func)subscriber)(message).ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogWarning(exception, "Обработчик сообщения {TenantId} завершился с ошибкой", TenantId); - } - } - } - - // Подписывает проброс сообщений нового клиента (вызывается после создания клиента). - // client: Новый клиент сессии. - private void AttachClientMessages(ISessionClient client) - => client.MessageReceived += ForwardClientMessageAsync; - - // Отписывает проброс сообщений клиента (перед Dispose клиента). - // client: Уходящий клиент сессии. - private void DetachClientMessages(ISessionClient client) - => client.MessageReceived -= ForwardClientMessageAsync; - - /// - /// Авто-возобновление на старте... - /// - /// Содержимое файла сессии тенанта. - /// Отмена операции. - /// True — сессия возобновлена (ready); false — не авторизована/сбой. - public async Task TryResumeAsync(StoredSession stored, CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - ValidateApiKeys(stored.ApiId, stored.ApiHash); - _registered = true; - _loggedOut = false; - _error = null; - - if (_client is not null && _client.ApiId == stored.ApiId && _client.ApiHash == stored.ApiHash) - { - // Клиент уже создан под те же ключи (например, жив после входа в этой сессии). - } - else - { - if (_client is not null) - { - DetachClientMessages(_client); - await _client.DisposeAsync().ConfigureAwait(false); - } - - _client = _clientFactory.Create(stored.ApiId, stored.ApiHash, stored.SessionBytes); - _clientApiId = stored.ApiId; - _clientApiHash = stored.ApiHash; - AttachClientMessages(_client); - } - - try - { - await _client!.ConnectAsync(cancellationToken).ConfigureAwait(false); - if (!_client.IsAuthorized) - { - _phase = AuthPhase.Idle; - return false; - } - - await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); - return true; - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogWarning(exception, "auto_resume {TenantId} пропущен", TenantId); - _phase = AuthPhase.Idle; - _error = exception is SessionException sessionException ? sessionException.Message : null; - return false; - } - } - finally - { - _gate.Release(); - } - } - - /// - /// Сердцебиение: для фазы "ready" при обрыве соединения — повторный connect. - /// - /// Отмена операции. - public async Task TryReconnectAsync(CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - if (_loggedOut || _client is null || _phase != AuthPhase.Ready || _client.IsConnected) - { - return; - } - - try - { - using CancellationTokenSource attemptTimeout = - CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - attemptTimeout.CancelAfter(_reconnectAttemptTimeout); - await _client.ConnectAsync(attemptTimeout.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - // Таймаут собственной попытки — не отмена хоста: предупреждение, повтор следующим циклом. - _logger.LogWarning( - "Heartbeat {TenantId}: таймаут переподключения ({TimeoutSeconds:0} с) — повторим следующим циклом", - TenantId, - _reconnectAttemptTimeout.TotalSeconds); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogWarning(exception, "Heartbeat {TenantId}: переподключение не удалось — повторим следующим циклом", TenantId); - } - } - finally - { - _gate.Release(); - } - } - - /// - /// Остановка (хост гасится) - /// - /// Отмена операции. - public async Task FlushAndDisposeAsync(CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - CancelQrFlow(); - if (_client is not null) - { - try - { - await PersistCurrentSessionAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogWarning(exception, "Остановка {TenantId}: сессия не сохранена", TenantId); - } - - DetachClientMessages(_client); - await _client.DisposeAsync().ConfigureAwait(false); - _client = null; - } - } - finally - { - _gate.Release(); - } - } - - /// - public async ValueTask DisposeAsync() - { - // Сброс без сетевых операций: авторизованные сессии уже сохранены на ключевых событиях - // (FlushAndDisposeAsync зовёт хост при остановке; здесь — финальная очистка клиента). - await _gate.WaitAsync().ConfigureAwait(false); - try - { - CancelQrFlow(); - if (_client is not null) - { - DetachClientMessages(_client); - await _client.DisposeAsync().ConfigureAwait(false); - _client = null; - } - } - finally - { - _gate.Release(); - } - } - - - // Проверяет, что сессия тенанта существует и не закрыта (иначе «Telegram не подключён»). - private void EnsureLoginStarted() - { - if (_loggedOut || !_registered) - { - throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.NotConnected); - } - } - - private static void ValidateApiKeys(int apiId, string apiHash) - { - if (apiId <= 0 || string.IsNullOrWhiteSpace(apiHash)) - { - throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.NoApiKeys); - } - } - - // Гарантирует клиент под запрошенные ключи: существующий клиент с теми же ключами переиспользуется; - // при смене ключей живая сессия сначала сохраняется (свои ключи), затем клиент пересоздаётся; - // сохранённая сессия с диска подсевается только при совпадении ключей приложения. - // apiId: api_id приложения. - // apiHash: api_hash приложения. - // cancellationToken: Отмена операции. - private async Task EnsureClientAsync( - int apiId, - string apiHash, - CancellationToken cancellationToken) - { - if (_client is not null && _clientApiId == apiId && _clientApiHash == apiHash) - { - return; - } - - if (_client is not null) - { - DetachClientMessages(_client); - try - { - await PersistCurrentSessionAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogWarning(exception, "Сессия {TenantId}: предыдущий клиент не сохранён при смене ключей", TenantId); - } - - await _client.DisposeAsync().ConfigureAwait(false); - _client = null; - } - - StoredSession? stored = await _sessionStore.LoadAsync(TenantId, cancellationToken).ConfigureAwait(false); - byte[]? seed = stored is not null && stored.ApiId == apiId && stored.ApiHash == apiHash - ? stored.SessionBytes - : null; - - _client = _clientFactory.Create(apiId, apiHash, seed); - _clientApiId = apiId; - _clientApiHash = apiHash; - AttachClientMessages(_client); - } - - private async Task CompleteAuthorizationAsync(CancellationToken cancellationToken) - { - if (_client is null) - { - return; - } - - try - { - _account = await _client.GetAccountAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogWarning(exception, "Финализация {TenantId}: account не получен — готовность сохраняется", TenantId); - } - - _phase = AuthPhase.Ready; - _qrUrl = null; - _error = null; - - await PersistCurrentSessionAsync(cancellationToken).ConfigureAwait(false); - } - - // Шифрует и сохраняет текущие байты сессии в файл (если клиент их уже сформировал). - // cancellationToken: Отмена операции. - private async Task PersistCurrentSessionAsync(CancellationToken cancellationToken) - { - byte[]? sessionBytes = _client?.SessionBytes; - if (_client is null || sessionBytes is not { Length: > 0 }) - { - return; - } - - await _sessionStore.SaveAsync( - TenantId, - new StoredSession - { - ApiId = _client.ApiId, - ApiHash = _client.ApiHash, - SessionBytes = sessionBytes, - }, - cancellationToken).ConfigureAwait(false); - } - - // Отменяет активный QR-вход (без ожидания фоновой задачи: её guard увидит смену фазы). - private void CancelQrFlow() - { - if (_qrCts is not null) - { - _qrCts.Cancel(); - _qrCts.Dispose(); - _qrCts = null; - } - - _qrWaitTask = null; - } - - // Фоновая задача QR-входа: ждёт сканирования; URL обновляет колбэк, после авторизации под замком - // финализирует сессию. Ошибка до первого URL пробрасывается (StartQrAsync держит замок и сам - // сбросит фазу); после выдачи URL состояние обновляется здесь под замком. - // cancellationToken: Токен отмены QR (StartPhone/Logout/остановка). - // firstUrlTcs: Завершается первым URL (StartQrAsync ждёт его под замком). - private async Task RunQrFlowAsync(CancellationToken cancellationToken, TaskCompletionSource firstUrlTcs) - { - ISessionClient client = _client!; - bool urlAlreadyDelivered; - try - { - await client.StartQrAsync( - url => - { - if (_phase == AuthPhase.Qr) - { - _qrUrl = url; - } - - firstUrlTcs.TrySetResult(url); - }, - cancellationToken).ConfigureAwait(false); - - // Сканирование принято — авторизация завершена: финализировать под замком. - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - if (!_loggedOut && ReferenceEquals(_client, client) && _phase == AuthPhase.Qr) - { - await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); - } - } - finally - { - _gate.Release(); - } - } - catch (OperationCanceledException) - { - urlAlreadyDelivered = firstUrlTcs.Task.IsCompletedSuccessfully; - if (urlAlreadyDelivered) - { - await ResetQrUnderGateAsync().ConfigureAwait(false); - return; - } - - firstUrlTcs.TrySetCanceled(); - throw; - } - catch (SessionException exception) - { - urlAlreadyDelivered = firstUrlTcs.Task.IsCompletedSuccessfully; - if (urlAlreadyDelivered) - { - await FailQrUnderGateAsync(exception).ConfigureAwait(false); - return; - } - - firstUrlTcs.TrySetException(exception); - throw; - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - SessionException wrapped = new(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception); - urlAlreadyDelivered = firstUrlTcs.Task.IsCompletedSuccessfully; - if (urlAlreadyDelivered) - { - await FailQrUnderGateAsync(wrapped).ConfigureAwait(false); - return; - } - - firstUrlTcs.TrySetException(wrapped); - throw wrapped; - } - } - - // Ждёт первый URL QR (или завершение/ошибку фоновой задачи). Вызывается из StartQrAsync под замком: - // при ошибке/отмене до первого URL задача уже завершена — замок освобождается при unwind. - // qrTask: Фоновая задача QR. - // firstUrlTcs: TCS первого URL. - // cancellationToken: Отмена операции. - // Возвращает: Первый URL либо null (задача завершилась без URL). - private static async Task WaitForFirstQrUrlAsync( - Task qrTask, - TaskCompletionSource firstUrlTcs, - CancellationToken cancellationToken) - { - Task urlTask = firstUrlTcs.Task; - Task completed = await Task.WhenAny(urlTask, qrTask).WaitAsync(cancellationToken).ConfigureAwait(false); - if (completed == urlTask) - { - return await urlTask.ConfigureAwait(false); - } - - // Задача завершилась без URL (ошибка/отмена/авторизация без URL) — проброс результата задачи. - await qrTask.ConfigureAwait(false); - return null; - } - - // Сброс QR-состояния после отмены (URL уже был выдан, RPC вернулся): фаза idle, если QR всё ещё - // владеет сессией (StartPhone/Logout уже сменили фазу/клиента — не трогаем). - private async Task ResetQrUnderGateAsync() - { - await _gate.WaitAsync().ConfigureAwait(false); - try - { - if (!_loggedOut && _phase == AuthPhase.Qr) - { - _phase = AuthPhase.Idle; - _qrUrl = null; - } - } - finally - { - _gate.Release(); - } - } - - private async Task FailQrUnderGateAsync(SessionException exception) - { - await _gate.WaitAsync().ConfigureAwait(false); - try - { - if (!_loggedOut && _phase == AuthPhase.Qr) - { - _phase = AuthPhase.Idle; - _qrUrl = null; - _error = exception.Message; - } - } - finally - { - _gate.Release(); - } - } - - private TenantSessionSnapshot Snapshot() - => new( - _phase, - connected: _client?.IsConnected ?? false, - listener: _listenerActive, - account: _phase == AuthPhase.Ready ? _account : null, - error: _error, - qrUrl: _phase == AuthPhase.Qr ? _qrUrl : null); -} +using Deal.Telegram.Telegram; +using Grpc.Core; + +namespace Deal.Telegram.Sessions; + +/// +/// Сессия тенанта: id тенанта, клиент Telegram и состояние входа. +/// +public sealed class TenantSession : IAsyncDisposable +{ + private readonly ITelegramClientFactory _clientFactory; + private readonly SessionStore _sessionStore; + private readonly ILogger _logger; + private readonly SemaphoreSlim _gate = new(1, 1); + + // Таймаут одной попытки переподключения heartbeat по умолчанию (зависший connect не должен блокировать + // heartbeat остальных тенантов и остановку хоста — замечание code-review). + private static readonly TimeSpan DefaultReconnectAttemptTimeout = TimeSpan.FromSeconds(10); + + private readonly TimeSpan _reconnectAttemptTimeout; + + private ISessionClient? _client; + private int _clientApiId; + private string? _clientApiHash; + + private bool _registered; + private bool _loggedOut; + private AuthPhase _phase = AuthPhase.Idle; + private string? _error; + private string? _account; + private string? _qrUrl; + private string? _phone; + + private CancellationTokenSource? _qrCts; + private Task? _qrWaitTask; + private volatile bool _listenerActive; + + /// + /// Realtime-listener сессии жив + /// + public AuthPhase Phase => _phase; + + /// + /// Событие входящего сообщения аккаунта. + /// + public event Func? MessageReceived; + + /// + /// Создаёт сессию тенанта + /// + /// Id тенанта (принадлежность сессии). + /// Фабрика клиентов Telegram (реальная или фейк в тестах). + /// Файловое хранилище сессий (шифрование at-rest). + /// Логгер. + /// Таймаут попытки переподключения (null — 10 с по умолчанию; тесты). + public TenantSession( + string tenantId, + ITelegramClientFactory clientFactory, + SessionStore sessionStore, + ILogger logger, + TimeSpan? reconnectAttemptTimeout = null) + { + TenantId = tenantId; + _clientFactory = clientFactory; + _sessionStore = sessionStore; + _logger = logger; + _reconnectAttemptTimeout = reconnectAttemptTimeout ?? DefaultReconnectAttemptTimeout; + } + + /// + /// Id тенанта, которому принадлежит сессия + /// + public string TenantId { get; } + + /// + /// Вход по номеру телефона + /// + /// api_id приложения (из тела запроса ядра). + /// api_hash приложения. + /// Номер телефона (международный формат). + /// Отмена операции. + /// Снимок состояния после операции (фаза "code"). + /// Нет ключей (INVALID_ARGUMENT) / ошибки Telegram. + public async Task StartPhoneAsync( + int apiId, + string apiHash, + string phone, + CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ValidateApiKeys(apiId, apiHash); + _registered = true; + _loggedOut = false; + _error = null; + _phone = phone; + + CancelQrFlow(); + + await EnsureClientAsync(apiId, apiHash, cancellationToken).ConfigureAwait(false); + if (_client!.IsAuthorized) + { + // Сессия уже авторизована (например, возобновлена на старте) — код не нужен. + await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); + return Snapshot(); + } + + try + { + await _client.ConnectAsync(cancellationToken).ConfigureAwait(false); + await _client.RequestCodeAsync(phone, cancellationToken).ConfigureAwait(false); + } + catch (SessionException exception) + { + _phase = AuthPhase.Idle; + _error = exception.Message; + throw; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + SessionException wrapped = new(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception); + _phase = AuthPhase.Idle; + _error = wrapped.Message; + throw wrapped; + } + + _phase = AuthPhase.Code; + return Snapshot(); + } + finally + { + _gate.Release(); + } + } + + /// + /// Начать QR-вход: фаза "qr" + первый URL либо "ready", если уже вошли. + /// + /// api_id приложения. + /// api_hash приложения. + /// Отмена операции. + /// Снимок состояния: Qr с qrUrl или Ready (авторизация уже была). + /// Нет ключей (INVALID_ARGUMENT) / ошибки Telegram до первого URL. + public async Task StartQrAsync( + int apiId, + string apiHash, + CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ValidateApiKeys(apiId, apiHash); + _registered = true; + _loggedOut = false; + _error = null; + + await EnsureClientAsync(apiId, apiHash, cancellationToken).ConfigureAwait(false); + if (_client!.IsAuthorized) + { + await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); + return Snapshot(); + } + + if (_phase == AuthPhase.Qr && _qrWaitTask is { IsCompleted: false }) + { + return Snapshot(); + } + + CancelQrFlow(); + + _phase = AuthPhase.Qr; + _qrUrl = null; + + var qrCts = new CancellationTokenSource(); + _qrCts = qrCts; + var firstUrlTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _qrWaitTask = RunQrFlowAsync(qrCts.Token, firstUrlTcs); + + string? firstUrl; + try + { + firstUrl = await WaitForFirstQrUrlAsync(_qrWaitTask, firstUrlTcs, cancellationToken).ConfigureAwait(false); + } + catch (SessionException exception) + { + _phase = AuthPhase.Idle; + _qrUrl = null; + _error = exception.Message; + throw; + } + catch (OperationCanceledException) + { + if (_phase == AuthPhase.Qr) + { + _phase = AuthPhase.Idle; + _qrUrl = null; + } + + CancelQrFlow(); + + throw; + } + + if (firstUrl is not null) + { + _qrUrl = firstUrl; + } + + // Задача завершилась без URL — авторизация произошла мгновенно (фаза Ready выставлена задачей) + // либо URL пришёл (фаза Qr). Возвращаем актуальный снимок. + return Snapshot(); + } + finally + { + _gate.Release(); + } + } + + /// + /// Отправить SMS-код. Фазы вне "code" — FAILED_PRECONDITION. + /// + /// Код из SMS/Telegram-сообщения. + /// Отмена операции. + /// Снимок состояния: "password" при 2FA либо "ready" после авторизации. + public async Task SendCodeAsync(string code, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + EnsureLoginStarted(); + if (_phase != AuthPhase.Code || _client is null) + { + throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.CodeNotRequested); + } + + _error = null; + + string? nextStep; + try + { + nextStep = await _client.SubmitCodeAsync(code, cancellationToken).ConfigureAwait(false); + } + catch (SessionException exception) + { + _error = exception.Message; + throw; + } + + if (nextStep == "password") + { + _phase = AuthPhase.Password; + return Snapshot(); + } + + await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); + return Snapshot(); + } + finally + { + _gate.Release(); + } + } + + /// + /// Отправить облачный пароль 2FA. + /// + /// Облачный пароль. + /// Отмена операции. + /// Снимок состояния после операции (фаза "ready"). + public async Task SendPasswordAsync(string password, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + EnsureLoginStarted(); + if (_phase != AuthPhase.Password || _client is null) + { + throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.PasswordNotRequested); + } + + _error = null; + + try + { + await _client.SubmitPasswordAsync(password, cancellationToken).ConfigureAwait(false); + } + catch (SessionException exception) + { + _error = exception.Message; + throw; + } + + await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); + return Snapshot(); + } + finally + { + _gate.Release(); + } + } + + /// + /// Отключить аккаунт и удалить сессию тенанта. + /// + /// Отмена операции. + public async Task LogoutAsync(CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + CancelQrFlow(); + + if (_client is not null) + { + try + { + await _client.LogOutAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + // Auth_LogOut недоступен (сеть) — локальный выход и удаление файла всё равно выполняются. + _logger.LogWarning(exception, "Logout {TenantId}: Auth_LogOut не выполнен — продолжаем локальный выход", TenantId); + } + + try + { + await _sessionStore.DeleteAsync(TenantId, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "Logout {TenantId}: файл сессии не удалён", TenantId); + } + + DetachClientMessages(_client); + await _client.DisposeAsync().ConfigureAwait(false); + _client = null; + } + + _clientApiId = 0; + _clientApiHash = null; + _phase = AuthPhase.Idle; + _error = null; + _account = null; + _qrUrl = null; + _phone = null; + _loggedOut = true; + _registered = false; + return null; + } + finally + { + _gate.Release(); + } + } + + /// + /// Снимок состояния для GetStatus; null — сессии тенанта нет + /// + /// Отмена операции. + public async Task GetSnapshotAsync(CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return _loggedOut || !_registered ? null : Snapshot(); + } + finally + { + _gate.Release(); + } + } + + + /// + /// Список диалогов аккаунта; фаза обязана быть ready. + /// + /// Верхняя граница числа диалогов. + /// Отмена операции. + /// Диалоги аккаунта (нейтральный вид). + public async Task> ListDialogsAsync(int limit, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); + return await client.GetDialogsAsync(limit, cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + /// + /// Последние сообщения диалога + /// + /// Подписанный id диалога. + /// Сколько последних сообщений запросить. + /// Отмена операции. + /// Сообщения диалога (от новых к старым, непустые тексты). + public async Task> GetMessagesAsync( + string dialogId, + int limit, + CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); + return await client.GetMessagesAsync(dialogId, limit, cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + /// + /// Исходное сообщение диалога по id (remote-просмотр). + /// + /// Подписанный id диалога. + /// Id сообщения в Telegram. + /// Отмена операции. + /// Сообщение с текстом либо null (медиа без текста/не найдено). + public async Task GetMessageAsync( + string dialogId, + long msgId, + CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); + return await client.GetMessageAsync(dialogId, msgId, cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + /// + /// Помечает диалог прочитанным + /// + /// Подписанный id диалога. + /// Отмена операции. + public async Task MarkReadAsync(string dialogId, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); + await client.MarkReadAsync(dialogId, cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + + /// + /// Глобальный поиск каналов/групп по ключу; фаза ready. + /// + /// Поисковый запрос (ключ задачи discovery). + /// Верхняя граница результата. + /// Отмена операции. + /// Найденные источники. + public async Task> SearchAsync( + string query, + int limit, + CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); + return await client.SearchAsync(query, limit, cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + /// + /// Инфо об источнике для оценки кандидата; фаза ready. + /// + /// Подписанный id источника. + /// Отмена операции. + /// Инфо (по умолчанию — только id; сбои определения не бросаются, см. ISessionClient). + public async Task GetInfoAsync(string dialogId, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); + return await client.GetInfoAsync(dialogId, cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + /// + /// Выборка сообщений источника для оценки; фаза ready. + /// + /// Подписанный id источника. + /// Размер выборки (limit ≤ 0 — пусто без сети). + /// Отмена операции. + /// Результат чтения (ok/messages либо no_history). + public async Task ReadForEvalAsync( + string dialogId, + int limit, + CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); + return await client.ReadForEvalAsync(dialogId, limit, cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + /// + /// Вступить в канал/группу по username; фаза ready. + /// + /// Username (без «@»; нормализует DiscoveryOps). + /// Отмена операции. + public async Task JoinAsync(string username, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); + await client.JoinAsync(username, cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + /// + /// Выйти из канала/группы; фаза ready. + /// + /// Подписанный id диалога. + /// Отмена операции. + public async Task LeaveAsync(string dialogId, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ISessionClient client = await EnsureReadyConnectedAsync(cancellationToken).ConfigureAwait(false); + await client.LeaveAsync(dialogId, cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + /// + /// Ставит признак живого realtime-listener. + /// + /// True — listener сессии подписан на события сообщений. + public void SetListenerActive(bool active) + => _listenerActive = active; + + // Проверяет готовность сессии и соединения (фаза ready + клиент). + // cancellationToken: Отмена операции. + private async Task EnsureReadyConnectedAsync(CancellationToken cancellationToken) + { + ISessionClient client = RequireReadyClient(); + if (client.IsConnected) + { + return client; + } + + try + { + await client.ConnectAsync(cancellationToken).ConfigureAwait(false); + } + catch (SessionException) + { + throw; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + throw new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception); + } + + return client; + } + + // Ready-клиент сессии (иначе «Telegram не подключён», FAILED_PRECONDITION). + private ISessionClient RequireReadyClient() + { + if (_loggedOut || !_registered || _client is null || _phase != AuthPhase.Ready) + { + throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.NotConnected); + } + + return _client; + } + + // --- Проброс realtime-сообщений клиента на уровень службы --- + + // Передаёт сообщение клиента подписчикам сессии (каждый в своей ошибко-изоляции). + // message: Входящее сообщение аккаунта. + private async Task ForwardClientMessageAsync(TelegramMessage message) + { + Func? subscribers = MessageReceived; + if (subscribers is null) + { + return; + } + + foreach (Delegate subscriber in subscribers.GetInvocationList()) + { + try + { + await ((Func)subscriber)(message).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "Обработчик сообщения {TenantId} завершился с ошибкой", TenantId); + } + } + } + + // Подписывает проброс сообщений нового клиента (вызывается после создания клиента). + // client: Новый клиент сессии. + private void AttachClientMessages(ISessionClient client) + => client.MessageReceived += ForwardClientMessageAsync; + + // Отписывает проброс сообщений клиента (перед Dispose клиента). + // client: Уходящий клиент сессии. + private void DetachClientMessages(ISessionClient client) + => client.MessageReceived -= ForwardClientMessageAsync; + + /// + /// Авто-возобновление на старте... + /// + /// Содержимое файла сессии тенанта. + /// Отмена операции. + /// True — сессия возобновлена (ready); false — не авторизована/сбой. + public async Task TryResumeAsync(StoredSession stored, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ValidateApiKeys(stored.ApiId, stored.ApiHash); + _registered = true; + _loggedOut = false; + _error = null; + + if (_client is not null && _client.ApiId == stored.ApiId && _client.ApiHash == stored.ApiHash) + { + // Клиент уже создан под те же ключи (например, жив после входа в этой сессии). + } + else + { + if (_client is not null) + { + DetachClientMessages(_client); + await _client.DisposeAsync().ConfigureAwait(false); + } + + _client = _clientFactory.Create(stored.ApiId, stored.ApiHash, stored.SessionBytes); + _clientApiId = stored.ApiId; + _clientApiHash = stored.ApiHash; + AttachClientMessages(_client); + } + + try + { + await _client!.ConnectAsync(cancellationToken).ConfigureAwait(false); + if (!_client.IsAuthorized) + { + _phase = AuthPhase.Idle; + return false; + } + + await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); + return true; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "auto_resume {TenantId} пропущен", TenantId); + _phase = AuthPhase.Idle; + _error = exception is SessionException sessionException ? sessionException.Message : null; + return false; + } + } + finally + { + _gate.Release(); + } + } + + /// + /// Сердцебиение: для фазы "ready" при обрыве соединения — повторный connect. + /// + /// Отмена операции. + public async Task TryReconnectAsync(CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_loggedOut || _client is null || _phase != AuthPhase.Ready || _client.IsConnected) + { + return; + } + + try + { + using CancellationTokenSource attemptTimeout = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + attemptTimeout.CancelAfter(_reconnectAttemptTimeout); + await _client.ConnectAsync(attemptTimeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // Таймаут собственной попытки — не отмена хоста: предупреждение, повтор следующим циклом. + _logger.LogWarning( + "Heartbeat {TenantId}: таймаут переподключения ({TimeoutSeconds:0} с) — повторим следующим циклом", + TenantId, + _reconnectAttemptTimeout.TotalSeconds); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "Heartbeat {TenantId}: переподключение не удалось — повторим следующим циклом", TenantId); + } + } + finally + { + _gate.Release(); + } + } + + /// + /// Остановка (хост гасится) + /// + /// Отмена операции. + public async Task FlushAndDisposeAsync(CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + CancelQrFlow(); + if (_client is not null) + { + try + { + await PersistCurrentSessionAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "Остановка {TenantId}: сессия не сохранена", TenantId); + } + + DetachClientMessages(_client); + await _client.DisposeAsync().ConfigureAwait(false); + _client = null; + } + } + finally + { + _gate.Release(); + } + } + + /// + public async ValueTask DisposeAsync() + { + // Сброс без сетевых операций: авторизованные сессии уже сохранены на ключевых событиях + // (FlushAndDisposeAsync зовёт хост при остановке; здесь — финальная очистка клиента). + await _gate.WaitAsync().ConfigureAwait(false); + try + { + CancelQrFlow(); + if (_client is not null) + { + DetachClientMessages(_client); + await _client.DisposeAsync().ConfigureAwait(false); + _client = null; + } + } + finally + { + _gate.Release(); + } + } + + + // Проверяет, что сессия тенанта существует и не закрыта (иначе «Telegram не подключён»). + private void EnsureLoginStarted() + { + if (_loggedOut || !_registered) + { + throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.NotConnected); + } + } + + private static void ValidateApiKeys(int apiId, string apiHash) + { + if (apiId <= 0 || string.IsNullOrWhiteSpace(apiHash)) + { + throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.NoApiKeys); + } + } + + // Гарантирует клиент под запрошенные ключи: существующий клиент с теми же ключами переиспользуется; + // при смене ключей живая сессия сначала сохраняется (свои ключи), затем клиент пересоздаётся; + // сохранённая сессия с диска подсевается только при совпадении ключей приложения. + // apiId: api_id приложения. + // apiHash: api_hash приложения. + // cancellationToken: Отмена операции. + private async Task EnsureClientAsync( + int apiId, + string apiHash, + CancellationToken cancellationToken) + { + if (_client is not null && _clientApiId == apiId && _clientApiHash == apiHash) + { + return; + } + + if (_client is not null) + { + DetachClientMessages(_client); + try + { + await PersistCurrentSessionAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "Сессия {TenantId}: предыдущий клиент не сохранён при смене ключей", TenantId); + } + + await _client.DisposeAsync().ConfigureAwait(false); + _client = null; + } + + StoredSession? stored = await _sessionStore.LoadAsync(TenantId, cancellationToken).ConfigureAwait(false); + byte[]? seed = stored is not null && stored.ApiId == apiId && stored.ApiHash == apiHash + ? stored.SessionBytes + : null; + + _client = _clientFactory.Create(apiId, apiHash, seed); + _clientApiId = apiId; + _clientApiHash = apiHash; + AttachClientMessages(_client); + } + + private async Task CompleteAuthorizationAsync(CancellationToken cancellationToken) + { + if (_client is null) + { + return; + } + + try + { + _account = await _client.GetAccountAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "Финализация {TenantId}: account не получен — готовность сохраняется", TenantId); + } + + _phase = AuthPhase.Ready; + _qrUrl = null; + _error = null; + + await PersistCurrentSessionAsync(cancellationToken).ConfigureAwait(false); + } + + // Шифрует и сохраняет текущие байты сессии в файл (если клиент их уже сформировал). + // cancellationToken: Отмена операции. + private async Task PersistCurrentSessionAsync(CancellationToken cancellationToken) + { + byte[]? sessionBytes = _client?.SessionBytes; + if (_client is null || sessionBytes is not { Length: > 0 }) + { + return; + } + + await _sessionStore.SaveAsync( + TenantId, + new StoredSession + { + ApiId = _client.ApiId, + ApiHash = _client.ApiHash, + SessionBytes = sessionBytes, + }, + cancellationToken).ConfigureAwait(false); + } + + // Отменяет активный QR-вход (без ожидания фоновой задачи: её guard увидит смену фазы). + private void CancelQrFlow() + { + if (_qrCts is not null) + { + _qrCts.Cancel(); + _qrCts.Dispose(); + _qrCts = null; + } + + _qrWaitTask = null; + } + + // Фоновая задача QR-входа: ждёт сканирования; URL обновляет колбэк, после авторизации под замком + // финализирует сессию. Ошибка до первого URL пробрасывается (StartQrAsync держит замок и сам + // сбросит фазу); после выдачи URL состояние обновляется здесь под замком. + // cancellationToken: Токен отмены QR (StartPhone/Logout/остановка). + // firstUrlTcs: Завершается первым URL (StartQrAsync ждёт его под замком). + private async Task RunQrFlowAsync(CancellationToken cancellationToken, TaskCompletionSource firstUrlTcs) + { + ISessionClient client = _client!; + bool urlAlreadyDelivered; + try + { + await client.StartQrAsync( + url => + { + if (_phase == AuthPhase.Qr) + { + _qrUrl = url; + } + + firstUrlTcs.TrySetResult(url); + }, + cancellationToken).ConfigureAwait(false); + + // Сканирование принято — авторизация завершена: финализировать под замком. + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!_loggedOut && ReferenceEquals(_client, client) && _phase == AuthPhase.Qr) + { + await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); + } + } + finally + { + _gate.Release(); + } + } + catch (OperationCanceledException) + { + urlAlreadyDelivered = firstUrlTcs.Task.IsCompletedSuccessfully; + if (urlAlreadyDelivered) + { + await ResetQrUnderGateAsync().ConfigureAwait(false); + return; + } + + firstUrlTcs.TrySetCanceled(); + throw; + } + catch (SessionException exception) + { + urlAlreadyDelivered = firstUrlTcs.Task.IsCompletedSuccessfully; + if (urlAlreadyDelivered) + { + await FailQrUnderGateAsync(exception).ConfigureAwait(false); + return; + } + + firstUrlTcs.TrySetException(exception); + throw; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + SessionException wrapped = new(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception); + urlAlreadyDelivered = firstUrlTcs.Task.IsCompletedSuccessfully; + if (urlAlreadyDelivered) + { + await FailQrUnderGateAsync(wrapped).ConfigureAwait(false); + return; + } + + firstUrlTcs.TrySetException(wrapped); + throw wrapped; + } + } + + // Ждёт первый URL QR (или завершение/ошибку фоновой задачи). Вызывается из StartQrAsync под замком: + // при ошибке/отмене до первого URL задача уже завершена — замок освобождается при unwind. + // qrTask: Фоновая задача QR. + // firstUrlTcs: TCS первого URL. + // cancellationToken: Отмена операции. + // Возвращает: Первый URL либо null (задача завершилась без URL). + private static async Task WaitForFirstQrUrlAsync( + Task qrTask, + TaskCompletionSource firstUrlTcs, + CancellationToken cancellationToken) + { + Task urlTask = firstUrlTcs.Task; + Task completed = await Task.WhenAny(urlTask, qrTask).WaitAsync(cancellationToken).ConfigureAwait(false); + if (completed == urlTask) + { + return await urlTask.ConfigureAwait(false); + } + + // Задача завершилась без URL (ошибка/отмена/авторизация без URL) — проброс результата задачи. + await qrTask.ConfigureAwait(false); + return null; + } + + // Сброс QR-состояния после отмены (URL уже был выдан, RPC вернулся): фаза idle, если QR всё ещё + // владеет сессией (StartPhone/Logout уже сменили фазу/клиента — не трогаем). + private async Task ResetQrUnderGateAsync() + { + await _gate.WaitAsync().ConfigureAwait(false); + try + { + if (!_loggedOut && _phase == AuthPhase.Qr) + { + _phase = AuthPhase.Idle; + _qrUrl = null; + } + } + finally + { + _gate.Release(); + } + } + + private async Task FailQrUnderGateAsync(SessionException exception) + { + await _gate.WaitAsync().ConfigureAwait(false); + try + { + if (!_loggedOut && _phase == AuthPhase.Qr) + { + _phase = AuthPhase.Idle; + _qrUrl = null; + _error = exception.Message; + } + } + finally + { + _gate.Release(); + } + } + + private TenantSessionSnapshot Snapshot() + => new( + _phase, + connected: _client?.IsConnected ?? false, + listener: _listenerActive, + account: _phase == AuthPhase.Ready ? _account : null, + error: _error, + qrUrl: _phase == AuthPhase.Qr ? _qrUrl : null); +} diff --git a/src/telegram-service/Deal.Telegram/Sessions/TenantSessionSnapshot.cs b/src/telegram-service/Deal.Telegram/Sessions/TenantSessionSnapshot.cs index ab471bd..ae9f208 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/TenantSessionSnapshot.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/TenantSessionSnapshot.cs @@ -1,62 +1,62 @@ -namespace Deal.Telegram.Sessions; - -/// -/// Снимок состояния сессии тенанта для GetStatus/ответов RPC подключения. -/// -public sealed record TenantSessionSnapshot -{ - /// - /// Создаёт снимок состояния сессии. - /// - /// Текущая фаза входа. - /// Клиент Telegram соединён. - /// Жив ли realtime-listener. - /// Аккаунт "@username" авторизованного пользователя (иначе null). - /// Текст последней ошибки (иначе null). - /// URL QR-входа (только при phase == Qr; иначе null). - public TenantSessionSnapshot( - AuthPhase phase, - bool connected, - bool listener, - string? account, - string? error, - string? qrUrl) - { - Phase = phase; - Connected = connected; - Listener = listener; - Account = account; - Error = error; - QrUrl = qrUrl; - } - - /// - /// Фаза входа (idle|phone|code|password|qr|ready). - /// - public AuthPhase Phase { get; } - - /// - /// Клиент Telegram соединён. - /// - public bool Connected { get; } - - /// - /// Realtime-listener жив. - /// - public bool Listener { get; } - - /// - /// Аккаунт "@username" - /// - public string? Account { get; } - - /// - /// Текст последней ошибки входа/соединения - /// - public string? Error { get; } - - /// - /// URL QR-входа - /// - public string? QrUrl { get; } -} +namespace Deal.Telegram.Sessions; + +/// +/// Снимок состояния сессии тенанта для GetStatus/ответов RPC подключения. +/// +public sealed record TenantSessionSnapshot +{ + /// + /// Создаёт снимок состояния сессии. + /// + /// Текущая фаза входа. + /// Клиент Telegram соединён. + /// Жив ли realtime-listener. + /// Аккаунт "@username" авторизованного пользователя (иначе null). + /// Текст последней ошибки (иначе null). + /// URL QR-входа (только при phase == Qr; иначе null). + public TenantSessionSnapshot( + AuthPhase phase, + bool connected, + bool listener, + string? account, + string? error, + string? qrUrl) + { + Phase = phase; + Connected = connected; + Listener = listener; + Account = account; + Error = error; + QrUrl = qrUrl; + } + + /// + /// Фаза входа (idle|phone|code|password|qr|ready). + /// + public AuthPhase Phase { get; } + + /// + /// Клиент Telegram соединён. + /// + public bool Connected { get; } + + /// + /// Realtime-listener жив. + /// + public bool Listener { get; } + + /// + /// Аккаунт "@username" + /// + public string? Account { get; } + + /// + /// Текст последней ошибки входа/соединения + /// + public string? Error { get; } + + /// + /// URL QR-входа + /// + public string? QrUrl { get; } +} diff --git a/src/telegram-service/Deal.Telegram/Sessions/TgOptions.cs b/src/telegram-service/Deal.Telegram/Sessions/TgOptions.cs index 19d8783..c36f027 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/TgOptions.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/TgOptions.cs @@ -1,112 +1,112 @@ -namespace Deal.Telegram.Sessions; - -/// -/// Конфигурация хранения сессий telegram-service. -/// -public sealed class TgOptions -{ - /// - /// Env-ключ ключа шифрования сессий. - /// - public const string SessionKeyEnvVarName = "DEAL_TELEGRAM_SESSION_KEY"; - - /// - /// Env-ключ каталога сессий - /// - public const string SessionDirEnvVarName = "DEAL_TELEGRAM_SESSION_DIR"; - - /// - /// Относительный каталог сессий по умолчанию - /// - public const string DefaultSessionDirRelative = "data/sessions"; - - /// - /// Размер ключа AES-256 - /// - public const int KeySizeBytes = 32; - - private TgOptions(byte[] sessionKey, string sessionsDirectory) - { - SessionKey = sessionKey; - SessionsDirectory = sessionsDirectory; - } - - public byte[] SessionKey { get; } - - /// - /// Абсолютный путь к каталогу файлов сессий data/sessions/<tenant>.session. - /// - public string SessionsDirectory { get; } - - /// - /// Создаёт опции с уже известными ключом и каталогом - /// - /// Ключ AES-256-GCM обёртки файлов сессий. - /// Каталог файлов сессий. - public static TgOptions Create(byte[] sessionKey, string sessionsDirectory) - { - if (sessionKey is null || sessionKey.Length != KeySizeBytes) - { - throw new ArgumentException($"Ключ AES-256 должен быть длиной {KeySizeBytes} байта; получено {sessionKey?.Length ?? 0}.", nameof(sessionKey)); - } - - if (string.IsNullOrWhiteSpace(sessionsDirectory)) - { - throw new ArgumentException("Каталог сессий не задан.", nameof(sessionsDirectory)); - } - - return new TgOptions(sessionKey, sessionsDirectory); - } - - /// - /// Читает конфигурацию из env. - /// - /// Конфигурация хоста (env-провайдер WebApplicationBuilder). - /// Окружение хоста (ContentRootPath для каталога по умолчанию). - /// Опции хранения сессий. - /// Ключ отсутствует или не является base64-представлением 32 байт. - public static TgOptions FromConfiguration(IConfiguration configuration, IHostEnvironment environment) - { - string? keyBase64 = configuration[SessionKeyEnvVarName]; - if (string.IsNullOrWhiteSpace(keyBase64)) - { - throw new InvalidOperationException($"{SessionKeyEnvVarName} не задан — без ключа сессии нельзя шифровать (fail-closed)."); - } - - byte[] sessionKey; - try - { - sessionKey = Convert.FromBase64String(keyBase64); - } - catch (FormatException exception) - { - throw new InvalidOperationException($"{SessionKeyEnvVarName} не является корректным base64.", exception); - } - - if (sessionKey.Length != KeySizeBytes) - { - throw new InvalidOperationException( - $"{SessionKeyEnvVarName} должен быть base64-представлением {KeySizeBytes} байт (AES-256); получено {sessionKey.Length}."); - } - - string? configuredDir = configuration[SessionDirEnvVarName]; - string sessionsDirectory = ResolveSessionsDirectory(configuredDir, environment.ContentRootPath); - return new TgOptions(sessionKey, sessionsDirectory); - } - - // Разрешает каталог сессий: абсолютный env-путь как есть, иначе — под ContentRoot. - // configuredDir: Значение DEAL_TELEGRAM_SESSION_DIR (может быть пустым). - // contentRootPath: ContentRoot хоста. - private static string ResolveSessionsDirectory(string? configuredDir, string contentRootPath) - { - if (string.IsNullOrWhiteSpace(configuredDir)) - { - return Path.Combine(contentRootPath, DefaultSessionDirRelative); - } - - string trimmed = configuredDir.Trim(); - return Path.IsPathRooted(trimmed) - ? trimmed - : Path.Combine(contentRootPath, trimmed); - } -} +namespace Deal.Telegram.Sessions; + +/// +/// Конфигурация хранения сессий telegram-service. +/// +public sealed class TgOptions +{ + /// + /// Env-ключ ключа шифрования сессий. + /// + public const string SessionKeyEnvVarName = "DEAL_TELEGRAM_SESSION_KEY"; + + /// + /// Env-ключ каталога сессий + /// + public const string SessionDirEnvVarName = "DEAL_TELEGRAM_SESSION_DIR"; + + /// + /// Относительный каталог сессий по умолчанию + /// + public const string DefaultSessionDirRelative = "data/sessions"; + + /// + /// Размер ключа AES-256 + /// + public const int KeySizeBytes = 32; + + private TgOptions(byte[] sessionKey, string sessionsDirectory) + { + SessionKey = sessionKey; + SessionsDirectory = sessionsDirectory; + } + + public byte[] SessionKey { get; } + + /// + /// Абсолютный путь к каталогу файлов сессий data/sessions/<tenant>.session. + /// + public string SessionsDirectory { get; } + + /// + /// Создаёт опции с уже известными ключом и каталогом + /// + /// Ключ AES-256-GCM обёртки файлов сессий. + /// Каталог файлов сессий. + public static TgOptions Create(byte[] sessionKey, string sessionsDirectory) + { + if (sessionKey is null || sessionKey.Length != KeySizeBytes) + { + throw new ArgumentException($"Ключ AES-256 должен быть длиной {KeySizeBytes} байта; получено {sessionKey?.Length ?? 0}.", nameof(sessionKey)); + } + + if (string.IsNullOrWhiteSpace(sessionsDirectory)) + { + throw new ArgumentException("Каталог сессий не задан.", nameof(sessionsDirectory)); + } + + return new TgOptions(sessionKey, sessionsDirectory); + } + + /// + /// Читает конфигурацию из env. + /// + /// Конфигурация хоста (env-провайдер WebApplicationBuilder). + /// Окружение хоста (ContentRootPath для каталога по умолчанию). + /// Опции хранения сессий. + /// Ключ отсутствует или не является base64-представлением 32 байт. + public static TgOptions FromConfiguration(IConfiguration configuration, IHostEnvironment environment) + { + string? keyBase64 = configuration[SessionKeyEnvVarName]; + if (string.IsNullOrWhiteSpace(keyBase64)) + { + throw new InvalidOperationException($"{SessionKeyEnvVarName} не задан — без ключа сессии нельзя шифровать (fail-closed)."); + } + + byte[] sessionKey; + try + { + sessionKey = Convert.FromBase64String(keyBase64); + } + catch (FormatException exception) + { + throw new InvalidOperationException($"{SessionKeyEnvVarName} не является корректным base64.", exception); + } + + if (sessionKey.Length != KeySizeBytes) + { + throw new InvalidOperationException( + $"{SessionKeyEnvVarName} должен быть base64-представлением {KeySizeBytes} байт (AES-256); получено {sessionKey.Length}."); + } + + string? configuredDir = configuration[SessionDirEnvVarName]; + string sessionsDirectory = ResolveSessionsDirectory(configuredDir, environment.ContentRootPath); + return new TgOptions(sessionKey, sessionsDirectory); + } + + // Разрешает каталог сессий: абсолютный env-путь как есть, иначе — под ContentRoot. + // configuredDir: Значение DEAL_TELEGRAM_SESSION_DIR (может быть пустым). + // contentRootPath: ContentRoot хоста. + private static string ResolveSessionsDirectory(string? configuredDir, string contentRootPath) + { + if (string.IsNullOrWhiteSpace(configuredDir)) + { + return Path.Combine(contentRootPath, DefaultSessionDirRelative); + } + + string trimmed = configuredDir.Trim(); + return Path.IsPathRooted(trimmed) + ? trimmed + : Path.Combine(contentRootPath, trimmed); + } +} diff --git a/src/telegram-service/Deal.Telegram/Telegram/ClientFactory.cs b/src/telegram-service/Deal.Telegram/Telegram/ClientFactory.cs index 87b548e..7470b04 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/ClientFactory.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/ClientFactory.cs @@ -1,14 +1,14 @@ -namespace Deal.Telegram.Telegram; - -/// -/// Фабрика реальных клиентов WTelegramClient. -/// -public sealed class ClientFactory : ITelegramClientFactory -{ - /// - public ISessionClient Create( - int apiId, - string apiHash, - byte[]? storedSession) - => new WTelegramSessionClient(apiId, apiHash, storedSession); -} +namespace Deal.Telegram.Telegram; + +/// +/// Фабрика реальных клиентов WTelegramClient. +/// +public sealed class ClientFactory : ITelegramClientFactory +{ + /// + public ISessionClient Create( + int apiId, + string apiHash, + byte[]? storedSession) + => new WTelegramSessionClient(apiId, apiHash, storedSession); +} diff --git a/src/telegram-service/Deal.Telegram/Telegram/DialogKinds.cs b/src/telegram-service/Deal.Telegram/Telegram/DialogKinds.cs index f7d58ef..234d314 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/DialogKinds.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/DialogKinds.cs @@ -1,27 +1,27 @@ -namespace Deal.Telegram.Telegram; - -/// -/// Канон типов диалогов/источников контракта -/// -public static class DialogKinds -{ - /// - /// Канал (broadcast) - /// - public const string Channel = "channel"; - - /// - /// Группа (базовая или супергруппа без тем) - /// - public const string Group = "group"; - - /// - /// Супергруппа с темами - /// - public const string Forum = "forum"; - - /// - /// Личный чат (пользователь) - /// - public const string Chat = "chat"; -} +namespace Deal.Telegram.Telegram; + +/// +/// Канон типов диалогов/источников контракта +/// +public static class DialogKinds +{ + /// + /// Канал (broadcast) + /// + public const string Channel = "channel"; + + /// + /// Группа (базовая или супергруппа без тем) + /// + public const string Group = "group"; + + /// + /// Супергруппа с темами + /// + public const string Forum = "forum"; + + /// + /// Личный чат (пользователь) + /// + public const string Chat = "chat"; +} diff --git a/src/telegram-service/Deal.Telegram/Telegram/DiscoveryMessage.cs b/src/telegram-service/Deal.Telegram/Telegram/DiscoveryMessage.cs index cacf934..28c2ba4 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/DiscoveryMessage.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/DiscoveryMessage.cs @@ -1,54 +1,54 @@ -namespace Deal.Telegram.Telegram; - -/// -/// Сообщение выборки discovery-read для оценки кандидата в нейтральном для TL-слоя виде. -/// -public sealed record DiscoveryMessage -{ - /// - /// Создаёт сообщение выборки discovery-read. - /// - /// Id сообщения в Telegram. - /// Текст сообщения (непустой). - /// Время сообщения, epoch-ms. - /// Id темы форума (для обычных источников пуст). - /// Название темы форума (для обычных источников пусто). - public DiscoveryMessage( - long id, - string text, - long dateMs, - long? topicId, - string? topicTitle) - { - Id = id; - Text = text; - DateMs = dateMs; - TopicId = topicId; - TopicTitle = topicTitle; - } - - /// - /// Id сообщения в Telegram. - /// - public long Id { get; } - - /// - /// Текст сообщения - /// - public string Text { get; } - - /// - /// Время сообщения, epoch-ms. - /// - public long DateMs { get; } - - /// - /// Id темы форума - /// - public long? TopicId { get; } - - /// - /// Название темы форума - /// - public string? TopicTitle { get; } -} +namespace Deal.Telegram.Telegram; + +/// +/// Сообщение выборки discovery-read для оценки кандидата в нейтральном для TL-слоя виде. +/// +public sealed record DiscoveryMessage +{ + /// + /// Создаёт сообщение выборки discovery-read. + /// + /// Id сообщения в Telegram. + /// Текст сообщения (непустой). + /// Время сообщения, epoch-ms. + /// Id темы форума (для обычных источников пуст). + /// Название темы форума (для обычных источников пусто). + public DiscoveryMessage( + long id, + string text, + long dateMs, + long? topicId, + string? topicTitle) + { + Id = id; + Text = text; + DateMs = dateMs; + TopicId = topicId; + TopicTitle = topicTitle; + } + + /// + /// Id сообщения в Telegram. + /// + public long Id { get; } + + /// + /// Текст сообщения + /// + public string Text { get; } + + /// + /// Время сообщения, epoch-ms. + /// + public long DateMs { get; } + + /// + /// Id темы форума + /// + public long? TopicId { get; } + + /// + /// Название темы форума + /// + public string? TopicTitle { get; } +} diff --git a/src/telegram-service/Deal.Telegram/Telegram/DiscoveryReadResult.cs b/src/telegram-service/Deal.Telegram/Telegram/DiscoveryReadResult.cs index 46af5f4..be0e4ed 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/DiscoveryReadResult.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/DiscoveryReadResult.cs @@ -1,54 +1,54 @@ -namespace Deal.Telegram.Telegram; - -/// -/// Результат чтения выборки источника для оценки кандидата. -/// -public sealed record DiscoveryReadResult -{ - /// - /// Код причины ok=false - /// - public const string NoHistoryError = "no_history"; - - /// - /// Пустой успешный результат. - /// - public static DiscoveryReadResult Empty { get; } = new(true, null, []); - - /// - /// Создаёт результат чтения выборки. - /// - /// True — выборка получена; false — история недоступна. - /// Код причины при ok=false ("no_history"); иначе null. - /// Сообщения выборки (форумы — по темам, topic_id/topic_title заполнены). - public DiscoveryReadResult( - bool ok, - string? error, - IReadOnlyList messages) - { - Ok = ok; - Error = error; - Messages = messages; - } - - /// - /// Создаёт результат «история недоступна» - /// - public static DiscoveryReadResult NoHistory() - => new(false, NoHistoryError, []); - - /// - /// True — выборка получена; false — история недоступна без членства. - /// - public bool Ok { get; } - - /// - /// Код причины при ok=false - /// - public string? Error { get; } - - /// - /// Сообщения выборки - /// - public IReadOnlyList Messages { get; } -} +namespace Deal.Telegram.Telegram; + +/// +/// Результат чтения выборки источника для оценки кандидата. +/// +public sealed record DiscoveryReadResult +{ + /// + /// Код причины ok=false + /// + public const string NoHistoryError = "no_history"; + + /// + /// Пустой успешный результат. + /// + public static DiscoveryReadResult Empty { get; } = new(true, null, []); + + /// + /// Создаёт результат чтения выборки. + /// + /// True — выборка получена; false — история недоступна. + /// Код причины при ok=false ("no_history"); иначе null. + /// Сообщения выборки (форумы — по темам, topic_id/topic_title заполнены). + public DiscoveryReadResult( + bool ok, + string? error, + IReadOnlyList messages) + { + Ok = ok; + Error = error; + Messages = messages; + } + + /// + /// Создаёт результат «история недоступна» + /// + public static DiscoveryReadResult NoHistory() + => new(false, NoHistoryError, []); + + /// + /// True — выборка получена; false — история недоступна без членства. + /// + public bool Ok { get; } + + /// + /// Код причины при ok=false + /// + public string? Error { get; } + + /// + /// Сообщения выборки + /// + public IReadOnlyList Messages { get; } +} diff --git a/src/telegram-service/Deal.Telegram/Telegram/ISessionClient.cs b/src/telegram-service/Deal.Telegram/Telegram/ISessionClient.cs index abf9f44..5ec76b5 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/ISessionClient.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/ISessionClient.cs @@ -1,174 +1,174 @@ -namespace Deal.Telegram.Telegram; - -/// -/// Абстракция клиента Telegram для сессии тенанта. -/// -public interface ISessionClient : IAsyncDisposable -{ - /// - /// Авторизован ли клиент - /// - public bool IsAuthorized { get; } - - /// - /// Есть ли активное соединение с Telegram. - /// - public bool IsConnected { get; } - - /// - /// api_id приложения, под которым создан клиент - /// - public int ApiId { get; } - - /// - /// api_hash приложения, под которым создан клиент. - /// - public string ApiHash { get; } - - /// - /// Последние байты сессии WTelegramClient - /// - public byte[]? SessionBytes { get; } - - /// - /// Устанавливает соединение с Telegram - /// - /// Отмена операции. - public Task ConnectAsync(CancellationToken cancellationToken); - - /// - /// Запрашивает SMS-код для номера. - /// - /// Номер в международном формате (как ввёл пользователь). - /// Отмена операции. - public Task RequestCodeAsync(string phone, CancellationToken cancellationToken); - - /// - /// Отправляет SMS-код. Возвращает следующий запрашиваемый шаг - /// - /// Код из SMS/Telegram-сообщения. - /// Отмена операции. - /// "password" при необходимости 2FA, иначе null. - public Task SubmitCodeAsync(string code, CancellationToken cancellationToken); - - /// - /// Отправляет облачный пароль 2FA. - /// - /// Облачный пароль. - /// Отмена операции. - public Task SubmitPasswordAsync(string password, CancellationToken cancellationToken); - - /// - /// Выполняет QR-вход - /// - /// Колбэк нового URL QR-входа (tg://login?token=...). - /// Отмена операции (прерывает ожидание сканирования). - public Task StartQrAsync(Action onQrUrl, CancellationToken cancellationToken); - - /// - /// Полный выход: отзывает авторизацию на стороне Telegram - /// - /// Отмена операции. - public Task LogOutAsync(CancellationToken cancellationToken); - - /// - /// Возвращает строку аккаунта для статуса - /// - /// Отмена операции. - public Task GetAccountAsync(CancellationToken cancellationToken); - - - /// - /// Список диалогов аккаунта. - /// - /// Верхняя граница числа диалогов. - /// Отмена операции. - /// Диалоги аккаунта в нейтральном виде. - public Task> GetDialogsAsync(int limit, CancellationToken cancellationToken); - - /// - /// Последние сообщения диалога - /// - /// Подписанный id диалога (каналы "-100…", группы "-…", личные "+…"). - /// Сколько последних сообщений запросить. - /// Отмена операции. - /// Сообщения диалога (с канальными полями для PushSource). - public Task> GetMessagesAsync( - string dialogId, - int limit, - CancellationToken cancellationToken); - - /// - /// Возвращает исходное сообщение диалога по id (remote-просмотр карточки). - /// - /// Подписанный id диалога (каналы "-100…", группы "-…", личные "+…"). - /// Id сообщения в Telegram. - /// Отмена операции. - /// Сообщение с текстом либо null (медиа без текста/не найдено). - public Task GetMessageAsync( - string dialogId, - long msgId, - CancellationToken cancellationToken); - - /// - /// Помечает весь диалог прочитанным. - /// - /// Подписанный id диалога. - /// Отмена операции. - /// Задача завершения. - public Task MarkReadAsync(string dialogId, CancellationToken cancellationToken); - - /// - /// Событие входящего текстового сообщения аккаунта. - /// - public event Func? MessageReceived; - - - /// - /// Глобальный поиск каналов/групп по ключу. - /// - /// Поисковый запрос (ключ задачи discovery). - /// Верхняя граница результата. - /// Отмена операции. - /// Найденные источники в нейтральном виде (каналы/группы/личные). - public Task> SearchAsync( - string query, - int limit, - CancellationToken cancellationToken); - - /// - /// Инфо об источнике для оценки кандидата - /// - /// Подписанный id источника («-100…»/«-…»/«+…»). - /// Отмена операции. - /// Инфо об источнике (по умолчанию — только id и name=id). - public Task GetInfoAsync(string dialogId, CancellationToken cancellationToken); - - /// - /// Последние сообщения источника для оценки кандидата - /// - /// Подписанный id источника. - /// Размер выборки. - /// Отмена операции. - /// Результат чтения выборки (ok + сообщения либо no_history). - public Task ReadForEvalAsync( - string dialogId, - int limit, - CancellationToken cancellationToken); - - /// - /// Вступить в канал/группу по username. - /// - /// Username канала/группы (без «@»). - /// Отмена операции. - /// Задача завершения (ok=true при успехе). - public Task JoinAsync(string username, CancellationToken cancellationToken); - - /// - /// Выйти из канала/группы. - /// - /// Подписанный id диалога. - /// Отмена операции. - /// Задача завершения (ok=true при успехе). - public Task LeaveAsync(string dialogId, CancellationToken cancellationToken); -} +namespace Deal.Telegram.Telegram; + +/// +/// Абстракция клиента Telegram для сессии тенанта. +/// +public interface ISessionClient : IAsyncDisposable +{ + /// + /// Авторизован ли клиент + /// + public bool IsAuthorized { get; } + + /// + /// Есть ли активное соединение с Telegram. + /// + public bool IsConnected { get; } + + /// + /// api_id приложения, под которым создан клиент + /// + public int ApiId { get; } + + /// + /// api_hash приложения, под которым создан клиент. + /// + public string ApiHash { get; } + + /// + /// Последние байты сессии WTelegramClient + /// + public byte[]? SessionBytes { get; } + + /// + /// Устанавливает соединение с Telegram + /// + /// Отмена операции. + public Task ConnectAsync(CancellationToken cancellationToken); + + /// + /// Запрашивает SMS-код для номера. + /// + /// Номер в международном формате (как ввёл пользователь). + /// Отмена операции. + public Task RequestCodeAsync(string phone, CancellationToken cancellationToken); + + /// + /// Отправляет SMS-код. Возвращает следующий запрашиваемый шаг + /// + /// Код из SMS/Telegram-сообщения. + /// Отмена операции. + /// "password" при необходимости 2FA, иначе null. + public Task SubmitCodeAsync(string code, CancellationToken cancellationToken); + + /// + /// Отправляет облачный пароль 2FA. + /// + /// Облачный пароль. + /// Отмена операции. + public Task SubmitPasswordAsync(string password, CancellationToken cancellationToken); + + /// + /// Выполняет QR-вход + /// + /// Колбэк нового URL QR-входа (tg://login?token=...). + /// Отмена операции (прерывает ожидание сканирования). + public Task StartQrAsync(Action onQrUrl, CancellationToken cancellationToken); + + /// + /// Полный выход: отзывает авторизацию на стороне Telegram + /// + /// Отмена операции. + public Task LogOutAsync(CancellationToken cancellationToken); + + /// + /// Возвращает строку аккаунта для статуса + /// + /// Отмена операции. + public Task GetAccountAsync(CancellationToken cancellationToken); + + + /// + /// Список диалогов аккаунта. + /// + /// Верхняя граница числа диалогов. + /// Отмена операции. + /// Диалоги аккаунта в нейтральном виде. + public Task> GetDialogsAsync(int limit, CancellationToken cancellationToken); + + /// + /// Последние сообщения диалога + /// + /// Подписанный id диалога (каналы "-100…", группы "-…", личные "+…"). + /// Сколько последних сообщений запросить. + /// Отмена операции. + /// Сообщения диалога (с канальными полями для PushSource). + public Task> GetMessagesAsync( + string dialogId, + int limit, + CancellationToken cancellationToken); + + /// + /// Возвращает исходное сообщение диалога по id (remote-просмотр карточки). + /// + /// Подписанный id диалога (каналы "-100…", группы "-…", личные "+…"). + /// Id сообщения в Telegram. + /// Отмена операции. + /// Сообщение с текстом либо null (медиа без текста/не найдено). + public Task GetMessageAsync( + string dialogId, + long msgId, + CancellationToken cancellationToken); + + /// + /// Помечает весь диалог прочитанным. + /// + /// Подписанный id диалога. + /// Отмена операции. + /// Задача завершения. + public Task MarkReadAsync(string dialogId, CancellationToken cancellationToken); + + /// + /// Событие входящего текстового сообщения аккаунта. + /// + public event Func? MessageReceived; + + + /// + /// Глобальный поиск каналов/групп по ключу. + /// + /// Поисковый запрос (ключ задачи discovery). + /// Верхняя граница результата. + /// Отмена операции. + /// Найденные источники в нейтральном виде (каналы/группы/личные). + public Task> SearchAsync( + string query, + int limit, + CancellationToken cancellationToken); + + /// + /// Инфо об источнике для оценки кандидата + /// + /// Подписанный id источника («-100…»/«-…»/«+…»). + /// Отмена операции. + /// Инфо об источнике (по умолчанию — только id и name=id). + public Task GetInfoAsync(string dialogId, CancellationToken cancellationToken); + + /// + /// Последние сообщения источника для оценки кандидата + /// + /// Подписанный id источника. + /// Размер выборки. + /// Отмена операции. + /// Результат чтения выборки (ok + сообщения либо no_history). + public Task ReadForEvalAsync( + string dialogId, + int limit, + CancellationToken cancellationToken); + + /// + /// Вступить в канал/группу по username. + /// + /// Username канала/группы (без «@»). + /// Отмена операции. + /// Задача завершения (ok=true при успехе). + public Task JoinAsync(string username, CancellationToken cancellationToken); + + /// + /// Выйти из канала/группы. + /// + /// Подписанный id диалога. + /// Отмена операции. + /// Задача завершения (ok=true при успехе). + public Task LeaveAsync(string dialogId, CancellationToken cancellationToken); +} diff --git a/src/telegram-service/Deal.Telegram/Telegram/ITelegramClientFactory.cs b/src/telegram-service/Deal.Telegram/Telegram/ITelegramClientFactory.cs index 36e2907..1531546 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/ITelegramClientFactory.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/ITelegramClientFactory.cs @@ -1,19 +1,19 @@ -namespace Deal.Telegram.Telegram; - -/// -/// Фабрика клиентов Telegram для сессий тенантов. -/// -public interface ITelegramClientFactory -{ - /// - /// Создаёт клиент Telegram для сессии тенанта. - /// - /// api_id приложения Telegram (настройка tgKeys тенанта, из тела запроса). - /// api_hash приложения Telegram. - /// Байты сохранённой сессии WTelegramClient (расшифрованная копия файла data/sessions/<tenant>.session) либо null — новая сессия (нет файла или ключи приложения сменились). - /// Клиент, готовый к ConnectAsync/логину. - public ISessionClient Create( - int apiId, - string apiHash, - byte[]? storedSession); -} +namespace Deal.Telegram.Telegram; + +/// +/// Фабрика клиентов Telegram для сессий тенантов. +/// +public interface ITelegramClientFactory +{ + /// + /// Создаёт клиент Telegram для сессии тенанта. + /// + /// api_id приложения Telegram (настройка tgKeys тенанта, из тела запроса). + /// api_hash приложения Telegram. + /// Байты сохранённой сессии WTelegramClient (расшифрованная копия файла data/sessions/<tenant>.session) либо null — новая сессия (нет файла или ключи приложения сменились). + /// Клиент, готовый к ConnectAsync/логину. + public ISessionClient Create( + int apiId, + string apiHash, + byte[]? storedSession); +} diff --git a/src/telegram-service/Deal.Telegram/Telegram/TelegramDialog.cs b/src/telegram-service/Deal.Telegram/Telegram/TelegramDialog.cs index de7b290..9b742e7 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/TelegramDialog.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/TelegramDialog.cs @@ -1,62 +1,62 @@ -namespace Deal.Telegram.Telegram; - -/// -/// Диалог (источник) аккаунта в нейтральном для TL-слоя виде. -/// -public sealed record TelegramDialog -{ - /// - /// Создаёт описание диалога. - /// - /// Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»). - /// Отображаемое имя (title/first_name) или id, если имени нет. - /// Username (handle); пуст, если публичного username нет. - /// Тип канона: channel|group|forum|chat (шапка telegram.proto). - /// Число непрочитанных сообщений (для realtime_sweep). - /// Id самого свежего сообщения диалога (для read-ack). - public TelegramDialog( - string id, - string name, - string username, - string kind, - int unreadCount, - int topMessageId) - { - Id = id; - Name = name; - Username = username; - Kind = kind; - UnreadCount = unreadCount; - TopMessageId = topMessageId; - } - - /// - /// Подписанный id диалога - /// - public string Id { get; } - - /// - /// Отображаемое имя - /// - public string Name { get; } - - /// - /// Username (handle); пуст, если публичного username нет. - /// - public string Username { get; } - - /// - /// Тип канона контракта - /// - public string Kind { get; } - - /// - /// Число непрочитанных сообщений диалога - /// - public int UnreadCount { get; } - - /// - /// Id самого свежего сообщения диалога. - /// - public int TopMessageId { get; } -} +namespace Deal.Telegram.Telegram; + +/// +/// Диалог (источник) аккаунта в нейтральном для TL-слоя виде. +/// +public sealed record TelegramDialog +{ + /// + /// Создаёт описание диалога. + /// + /// Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»). + /// Отображаемое имя (title/first_name) или id, если имени нет. + /// Username (handle); пуст, если публичного username нет. + /// Тип канона: channel|group|forum|chat (шапка telegram.proto). + /// Число непрочитанных сообщений (для realtime_sweep). + /// Id самого свежего сообщения диалога (для read-ack). + public TelegramDialog( + string id, + string name, + string username, + string kind, + int unreadCount, + int topMessageId) + { + Id = id; + Name = name; + Username = username; + Kind = kind; + UnreadCount = unreadCount; + TopMessageId = topMessageId; + } + + /// + /// Подписанный id диалога + /// + public string Id { get; } + + /// + /// Отображаемое имя + /// + public string Name { get; } + + /// + /// Username (handle); пуст, если публичного username нет. + /// + public string Username { get; } + + /// + /// Тип канона контракта + /// + public string Kind { get; } + + /// + /// Число непрочитанных сообщений диалога + /// + public int UnreadCount { get; } + + /// + /// Id самого свежего сообщения диалога. + /// + public int TopMessageId { get; } +} diff --git a/src/telegram-service/Deal.Telegram/Telegram/TelegramMessage.cs b/src/telegram-service/Deal.Telegram/Telegram/TelegramMessage.cs index 33e6880..a994b2f 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/TelegramMessage.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/TelegramMessage.cs @@ -1,62 +1,62 @@ -namespace Deal.Telegram.Telegram; - -/// -/// Текстовое сообщение диалога в нейтральном для TL-слоя виде. -/// -public sealed record TelegramMessage -{ - /// - /// Создаёт сообщение диалога. - /// - /// Подписанный id диалога-источника («-100…»/«-…»/«+…»). - /// Id сообщения в Telegram (дубль-гвард dialog+msgId ядра). - /// Текст сообщения (непустой). - /// Время сообщения, epoch-ms. - /// Имя диалога (title/first_name) для source.display_name. - /// Username диалога. - public TelegramMessage( - string dialogId, - int id, - string text, - long dateMs, - string dialogName, - string dialogHandle) - { - DialogId = dialogId; - Id = id; - Text = text; - DateMs = dateMs; - DialogName = dialogName; - DialogHandle = dialogHandle; - } - - /// - /// Подписанный id диалога-источника. - /// - public string DialogId { get; } - - /// - /// Id сообщения в Telegram - /// - public int Id { get; } - - /// - /// Текст сообщения - /// - public string Text { get; } - - /// - /// Время сообщения, epoch-ms. - /// - public long DateMs { get; } - - /// - /// Имя диалога (title/first_name) — для source.display_name. - /// - public string DialogName { get; } - - /// - /// Username диалога - /// - public string DialogHandle { get; } -} +namespace Deal.Telegram.Telegram; + +/// +/// Текстовое сообщение диалога в нейтральном для TL-слоя виде. +/// +public sealed record TelegramMessage +{ + /// + /// Создаёт сообщение диалога. + /// + /// Подписанный id диалога-источника («-100…»/«-…»/«+…»). + /// Id сообщения в Telegram (дубль-гвард dialog+msgId ядра). + /// Текст сообщения (непустой). + /// Время сообщения, epoch-ms. + /// Имя диалога (title/first_name) для source.display_name. + /// Username диалога. + public TelegramMessage( + string dialogId, + int id, + string text, + long dateMs, + string dialogName, + string dialogHandle) + { + DialogId = dialogId; + Id = id; + Text = text; + DateMs = dateMs; + DialogName = dialogName; + DialogHandle = dialogHandle; + } + + /// + /// Подписанный id диалога-источника. + /// + public string DialogId { get; } + + /// + /// Id сообщения в Telegram + /// + public int Id { get; } + + /// + /// Текст сообщения + /// + public string Text { get; } + + /// + /// Время сообщения, epoch-ms. + /// + public long DateMs { get; } + + /// + /// Имя диалога (title/first_name) — для source.display_name. + /// + public string DialogName { get; } + + /// + /// Username диалога + /// + public string DialogHandle { get; } +} diff --git a/src/telegram-service/Deal.Telegram/Telegram/TelegramSourceInfo.cs b/src/telegram-service/Deal.Telegram/Telegram/TelegramSourceInfo.cs index 23651de..ec1c068 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/TelegramSourceInfo.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/TelegramSourceInfo.cs @@ -1,62 +1,62 @@ -namespace Deal.Telegram.Telegram; - -/// -/// Инфо об источнике для оценки кандидата discovery в нейтральном для TL-слоя виде. -/// -public sealed record TelegramSourceInfo -{ - /// - /// Создаёт инфо об источнике. - /// - /// Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»). - /// Отображаемое имя (title/first_name) или id, если имени нет. - /// Username (handle); пуст, если публичного username нет. - /// Тип канона контракта: channel|group|forum|chat (пусто — не определён). - /// Число участников (full_chat); null — определить не удалось. - /// True — мегагруппа с темами (форум; core трактует kind как forum). - public TelegramSourceInfo( - string id, - string name, - string username, - string kind, - int? participants, - bool isForum) - { - Id = id; - Name = name; - Username = username; - Kind = kind; - Participants = participants; - IsForum = isForum; - } - - /// - /// Подписанный id диалога - /// - public string Id { get; } - - /// - /// Отображаемое имя - /// - public string Name { get; } - - /// - /// Username (handle); пуст, если публичного username нет. - /// - public string Username { get; } - - /// - /// Тип канона контракта - /// - public string Kind { get; } - - /// - /// Число участников - /// - public int? Participants { get; } - - /// - /// True — мегагруппа с темами. - /// - public bool IsForum { get; } -} +namespace Deal.Telegram.Telegram; + +/// +/// Инфо об источнике для оценки кандидата discovery в нейтральном для TL-слоя виде. +/// +public sealed record TelegramSourceInfo +{ + /// + /// Создаёт инфо об источнике. + /// + /// Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»). + /// Отображаемое имя (title/first_name) или id, если имени нет. + /// Username (handle); пуст, если публичного username нет. + /// Тип канона контракта: channel|group|forum|chat (пусто — не определён). + /// Число участников (full_chat); null — определить не удалось. + /// True — мегагруппа с темами (форум; core трактует kind как forum). + public TelegramSourceInfo( + string id, + string name, + string username, + string kind, + int? participants, + bool isForum) + { + Id = id; + Name = name; + Username = username; + Kind = kind; + Participants = participants; + IsForum = isForum; + } + + /// + /// Подписанный id диалога + /// + public string Id { get; } + + /// + /// Отображаемое имя + /// + public string Name { get; } + + /// + /// Username (handle); пуст, если публичного username нет. + /// + public string Username { get; } + + /// + /// Тип канона контракта + /// + public string Kind { get; } + + /// + /// Число участников + /// + public int? Participants { get; } + + /// + /// True — мегагруппа с темами. + /// + public bool IsForum { get; } +} diff --git a/src/telegram-service/Deal.Telegram/Telegram/TlMessageMapper.cs b/src/telegram-service/Deal.Telegram/Telegram/TlMessageMapper.cs index a653a26..9b2f780 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/TlMessageMapper.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/TlMessageMapper.cs @@ -1,205 +1,205 @@ -using System.Globalization; -using Deal.Telegram.Sessions; -using Grpc.Core; -using TL; - -namespace Deal.Telegram.Telegram; - -/// -/// Чистый маппер TL-объектов каталога/сообщений в нейтральные типы сервиса. -/// -public static class TlMessageMapper -{ - /// - /// Извлекает сообщение из «нового сообщения» обновления. - /// - /// Одно нормализованное обновление. - /// Сообщение нового входящего события или null. - public static MessageBase? NewMessageFrom(Update update) - => update is UpdateNewMessage { message: MessageBase message } ? message : null; - - /// - /// Превращает диалог списка в нейтральный - /// - /// Диалог из ответа getDialogs. - /// Сущности чатов контейнера (по raw id). - /// Сущности пользователей контейнера (по raw id). - public static TelegramDialog? ToDialog( - DialogBase dialog, - IReadOnlyDictionary chats, - IReadOnlyDictionary users) - { - if (dialog.Peer is null) - { - return null; - } - - string signedId = SignedIdOf(dialog.Peer); - (string name, string handle, string kind) = DescribePeer(signedId, FindChat(dialog.Peer, chats), FindUser(dialog.Peer, users)); - int unreadCount = dialog is Dialog fullDialog ? fullDialog.unread_count : 0; - return new TelegramDialog(signedId, name, handle, kind, unreadCount, dialog.TopMessage); - } - - /// - /// Превращает сообщение истории/обновления в нейтральное - /// - /// Сообщение (MessageBase). - /// Сущности чатов контейнера. - /// Сущности пользователей контейнера. - public static TelegramMessage? ToMessage( - MessageBase message, - IReadOnlyDictionary chats, - IReadOnlyDictionary users) - { - if (message is not Message textMessage - || string.IsNullOrWhiteSpace(textMessage.message) - || textMessage.media is not null - || (textMessage.flags & Message.Flags.out_) != 0 - || textMessage.Peer is null) - { - return null; - } - - string signedId = SignedIdOf(textMessage.Peer); - (string name, string handle, _) = DescribePeer(signedId, FindChat(textMessage.Peer, chats), FindUser(textMessage.Peer, users)); - return new TelegramMessage(signedId, textMessage.id, textMessage.message, ToEpochMs(textMessage.Date), name, handle); - } - - - /// - /// Сущность чата/канала результата contacts.SearchRequest → запись поиска. - /// - /// Сущность канала/группы из chats результата поиска. - public static TelegramDialog ToFoundChat(ChatBase chat) - { - string signedId = SignedChatId(chat); - (string name, string handle, string kind) = DescribePeer(signedId, chat, null); - return new TelegramDialog(signedId, name, handle, kind, unreadCount: 0, topMessageId: 0); - } - - /// - /// Сущность пользователя результата contacts.SearchRequest → запись поиска. - /// - /// Сущность пользователя из users результата поиска. - public static TelegramDialog ToFoundUser(User user) - { - string signedId = "+" + user.id.ToString(CultureInfo.InvariantCulture); - (string name, string handle, string kind) = DescribePeer(signedId, null, user); - return new TelegramDialog(signedId, name, handle, kind, unreadCount: 0, topMessageId: 0); - } - - /// - /// Сообщение выборки discovery-read → нейтральное - /// - /// Сообщение ленты/темы форума (MessageBase). - /// Id темы форума (для обычных источников null). - /// Название темы форума (для обычных источников null). - /// Текущее время. - public static DiscoveryMessage? ToEvalMessage( - MessageBase message, - long? topicId, - string? topicTitle, - DateTimeOffset now) - { - if (message is not Message textMessage - || string.IsNullOrWhiteSpace(textMessage.message)) - { - return null; - } - - return new DiscoveryMessage( - textMessage.id, - textMessage.message, - ToEpochMsOrNow(textMessage.Date, now), - topicId, - topicTitle); - } - - /// - /// Подписанный id канона контракта по peer диалога/сообщения. - /// - /// Peer (PeerChannel/PeerChat/PeerUser). - public static string SignedIdOf(Peer peer) - => peer switch - { - PeerChannel channel => "-100" + channel.channel_id.ToString(CultureInfo.InvariantCulture), - PeerChat chat => "-" + chat.chat_id.ToString(CultureInfo.InvariantCulture), - PeerUser user => "+" + user.user_id.ToString(CultureInfo.InvariantCulture), - _ => throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId), - }; - - // Имя/username/тип диалога по его сущности (пустой сущности — имя и тип по умолчанию). - // signedId: Подписанный id диалога (фолбэк имени). - // chat: Сущность чата/канала (или null). - // user: Сущность пользователя (или null). - private static (string Name, string Handle, string Kind) DescribePeer( - string signedId, - ChatBase? chat, - UserBase? user) - { - if (chat is not null) - { - string title = chat.Title ?? string.Empty; - return (title.Length > 0 ? title : signedId, chat.MainUsername ?? string.Empty, KindOf(chat)); - } - - if (user is User regularUser) - { - string name = (regularUser.first_name + " " + regularUser.last_name).Trim(); - string username = regularUser.MainUsername ?? string.Empty; - if (name.Length == 0) - { - name = username.Length > 0 ? username : signedId; - } - - return (name, username, DialogKinds.Chat); - } - - return (signedId, string.Empty, DialogKinds.Chat); - } - - /// - /// Тип диалога канона контракта по сущности чата/канала. - /// - /// Сущность канала/группы. - public static string KindOf(ChatBase chat) - => chat switch - { - Channel channel when (channel.flags & Channel.Flags.forum) != 0 => DialogKinds.Forum, - Channel channel when (channel.flags & Channel.Flags.broadcast) != 0 => DialogKinds.Channel, - _ => DialogKinds.Group, - }; - - // Подписанный id чата/канала по самой сущности (каналы «-100…», группы «-…»). - // chat: Сущность канала/группы. - private static string SignedChatId(ChatBase chat) - => chat switch - { - Channel channel => "-100" + channel.id.ToString(CultureInfo.InvariantCulture), - Chat group => "-" + group.id.ToString(CultureInfo.InvariantCulture), - _ => throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId), - }; - - private static long ToEpochMsOrNow(DateTime date, DateTimeOffset now) - => date == default ? now.ToUnixTimeMilliseconds() : ToEpochMs(date); - - // Ищет сущность чата/канала диалога в словаре ответа. - // peer: Peer сообщения/диалога. - // chats: Сущности чатов контейнера. - private static ChatBase? FindChat(Peer peer, IReadOnlyDictionary chats) - => peer switch - { - PeerChannel channel when chats.TryGetValue(channel.channel_id, out ChatBase? chat) => chat, - PeerChat chat when chats.TryGetValue(chat.chat_id, out ChatBase? group) => group, - _ => null, - }; - - // Ищет сущность пользователя диалога в словаре ответа. - // peer: Peer сообщения/диалога. - // users: Сущности пользователей контейнера. - private static UserBase? FindUser(Peer peer, IReadOnlyDictionary users) - => peer is PeerUser user && users.TryGetValue(user.user_id, out User? regularUser) ? regularUser : null; - - private static long ToEpochMs(DateTime date) - => new DateTimeOffset(DateTime.SpecifyKind(date, DateTimeKind.Utc)).ToUnixTimeMilliseconds(); -} +using System.Globalization; +using Deal.Telegram.Sessions; +using Grpc.Core; +using TL; + +namespace Deal.Telegram.Telegram; + +/// +/// Чистый маппер TL-объектов каталога/сообщений в нейтральные типы сервиса. +/// +public static class TlMessageMapper +{ + /// + /// Извлекает сообщение из «нового сообщения» обновления. + /// + /// Одно нормализованное обновление. + /// Сообщение нового входящего события или null. + public static MessageBase? NewMessageFrom(Update update) + => update is UpdateNewMessage { message: MessageBase message } ? message : null; + + /// + /// Превращает диалог списка в нейтральный + /// + /// Диалог из ответа getDialogs. + /// Сущности чатов контейнера (по raw id). + /// Сущности пользователей контейнера (по raw id). + public static TelegramDialog? ToDialog( + DialogBase dialog, + IReadOnlyDictionary chats, + IReadOnlyDictionary users) + { + if (dialog.Peer is null) + { + return null; + } + + string signedId = SignedIdOf(dialog.Peer); + (string name, string handle, string kind) = DescribePeer(signedId, FindChat(dialog.Peer, chats), FindUser(dialog.Peer, users)); + int unreadCount = dialog is Dialog fullDialog ? fullDialog.unread_count : 0; + return new TelegramDialog(signedId, name, handle, kind, unreadCount, dialog.TopMessage); + } + + /// + /// Превращает сообщение истории/обновления в нейтральное + /// + /// Сообщение (MessageBase). + /// Сущности чатов контейнера. + /// Сущности пользователей контейнера. + public static TelegramMessage? ToMessage( + MessageBase message, + IReadOnlyDictionary chats, + IReadOnlyDictionary users) + { + if (message is not Message textMessage + || string.IsNullOrWhiteSpace(textMessage.message) + || textMessage.media is not null + || (textMessage.flags & Message.Flags.out_) != 0 + || textMessage.Peer is null) + { + return null; + } + + string signedId = SignedIdOf(textMessage.Peer); + (string name, string handle, _) = DescribePeer(signedId, FindChat(textMessage.Peer, chats), FindUser(textMessage.Peer, users)); + return new TelegramMessage(signedId, textMessage.id, textMessage.message, ToEpochMs(textMessage.Date), name, handle); + } + + + /// + /// Сущность чата/канала результата contacts.SearchRequest → запись поиска. + /// + /// Сущность канала/группы из chats результата поиска. + public static TelegramDialog ToFoundChat(ChatBase chat) + { + string signedId = SignedChatId(chat); + (string name, string handle, string kind) = DescribePeer(signedId, chat, null); + return new TelegramDialog(signedId, name, handle, kind, unreadCount: 0, topMessageId: 0); + } + + /// + /// Сущность пользователя результата contacts.SearchRequest → запись поиска. + /// + /// Сущность пользователя из users результата поиска. + public static TelegramDialog ToFoundUser(User user) + { + string signedId = "+" + user.id.ToString(CultureInfo.InvariantCulture); + (string name, string handle, string kind) = DescribePeer(signedId, null, user); + return new TelegramDialog(signedId, name, handle, kind, unreadCount: 0, topMessageId: 0); + } + + /// + /// Сообщение выборки discovery-read → нейтральное + /// + /// Сообщение ленты/темы форума (MessageBase). + /// Id темы форума (для обычных источников null). + /// Название темы форума (для обычных источников null). + /// Текущее время. + public static DiscoveryMessage? ToEvalMessage( + MessageBase message, + long? topicId, + string? topicTitle, + DateTimeOffset now) + { + if (message is not Message textMessage + || string.IsNullOrWhiteSpace(textMessage.message)) + { + return null; + } + + return new DiscoveryMessage( + textMessage.id, + textMessage.message, + ToEpochMsOrNow(textMessage.Date, now), + topicId, + topicTitle); + } + + /// + /// Подписанный id канона контракта по peer диалога/сообщения. + /// + /// Peer (PeerChannel/PeerChat/PeerUser). + public static string SignedIdOf(Peer peer) + => peer switch + { + PeerChannel channel => "-100" + channel.channel_id.ToString(CultureInfo.InvariantCulture), + PeerChat chat => "-" + chat.chat_id.ToString(CultureInfo.InvariantCulture), + PeerUser user => "+" + user.user_id.ToString(CultureInfo.InvariantCulture), + _ => throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId), + }; + + // Имя/username/тип диалога по его сущности (пустой сущности — имя и тип по умолчанию). + // signedId: Подписанный id диалога (фолбэк имени). + // chat: Сущность чата/канала (или null). + // user: Сущность пользователя (или null). + private static (string Name, string Handle, string Kind) DescribePeer( + string signedId, + ChatBase? chat, + UserBase? user) + { + if (chat is not null) + { + string title = chat.Title ?? string.Empty; + return (title.Length > 0 ? title : signedId, chat.MainUsername ?? string.Empty, KindOf(chat)); + } + + if (user is User regularUser) + { + string name = (regularUser.first_name + " " + regularUser.last_name).Trim(); + string username = regularUser.MainUsername ?? string.Empty; + if (name.Length == 0) + { + name = username.Length > 0 ? username : signedId; + } + + return (name, username, DialogKinds.Chat); + } + + return (signedId, string.Empty, DialogKinds.Chat); + } + + /// + /// Тип диалога канона контракта по сущности чата/канала. + /// + /// Сущность канала/группы. + public static string KindOf(ChatBase chat) + => chat switch + { + Channel channel when (channel.flags & Channel.Flags.forum) != 0 => DialogKinds.Forum, + Channel channel when (channel.flags & Channel.Flags.broadcast) != 0 => DialogKinds.Channel, + _ => DialogKinds.Group, + }; + + // Подписанный id чата/канала по самой сущности (каналы «-100…», группы «-…»). + // chat: Сущность канала/группы. + private static string SignedChatId(ChatBase chat) + => chat switch + { + Channel channel => "-100" + channel.id.ToString(CultureInfo.InvariantCulture), + Chat group => "-" + group.id.ToString(CultureInfo.InvariantCulture), + _ => throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId), + }; + + private static long ToEpochMsOrNow(DateTime date, DateTimeOffset now) + => date == default ? now.ToUnixTimeMilliseconds() : ToEpochMs(date); + + // Ищет сущность чата/канала диалога в словаре ответа. + // peer: Peer сообщения/диалога. + // chats: Сущности чатов контейнера. + private static ChatBase? FindChat(Peer peer, IReadOnlyDictionary chats) + => peer switch + { + PeerChannel channel when chats.TryGetValue(channel.channel_id, out ChatBase? chat) => chat, + PeerChat chat when chats.TryGetValue(chat.chat_id, out ChatBase? group) => group, + _ => null, + }; + + // Ищет сущность пользователя диалога в словаре ответа. + // peer: Peer сообщения/диалога. + // users: Сущности пользователей контейнера. + private static UserBase? FindUser(Peer peer, IReadOnlyDictionary users) + => peer is PeerUser user && users.TryGetValue(user.user_id, out User? regularUser) ? regularUser : null; + + private static long ToEpochMs(DateTime date) + => new DateTimeOffset(DateTime.SpecifyKind(date, DateTimeKind.Utc)).ToUnixTimeMilliseconds(); +} diff --git a/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs b/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs index ad23af1..99b49b3 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs @@ -1,1082 +1,1082 @@ -using System.Globalization; -using Deal.Telegram.Caching; -using Deal.Telegram.Sessions; -using Grpc.Core; -using TL; -using WTelegram; -using RpcException = TL.RpcException; - -namespace Deal.Telegram.Telegram; - -#pragma warning disable CS0618 // Auth_SendCode/Auth_SignIn используются осознанно: ручной веб-вход 1:1 с прототипом - -/// -/// Реальная реализация поверх WTelegramClient. -/// -public sealed class WTelegramSessionClient : ISessionClient -{ - private readonly Client _client; - private readonly int _apiId; - private readonly string _apiHash; - private byte[]? _latestSessionBytes; - private bool _connected; - private string? _phone; - private string? _phoneCodeHash; - private bool _phoneAlreadyAuthorized; - - // Ёмкость LRU-кэша access_hash сущностей (ограничивает память на длинной истории диалогов). - private const int AccessHashCacheCapacity = 2000; - - // Ёмкость LRU-кэша сущностей чатов/каналов (последние использованные имена/forum). - private const int ChatEntityCacheCapacity = 1000; - - // Ёмкость LRU-кэша сущностей пользователей (последние использованные имена). - private const int UserEntityCacheCapacity = 2000; - - // LRU-кэш access_hash сущностей (ключ — подписанный id диалога; заполняется из ответов). - private readonly LruCache _entityAccessHashes = new(AccessHashCacheCapacity); - - private readonly LruCache _chatsById = new(ChatEntityCacheCapacity); - - private readonly LruCache _usersById = new(UserEntityCacheCapacity); - - // Защита кэшей сущностей (обновляются из потоков reactor/вызовов). - private readonly object _entityCacheGate = new(); - - // Штатный UpdateManager библиотеки: единственный нормализованный колбэк на каждое обновление - // (порядок/pts, восстановление пропусков через getDifference, дедупликация устаревших). - // Новые сообщения всех типов приходят как TL.UpdateNewMessage (каналы/супергруппы — - // подкласс UpdateNewChannelMessage; короткие UpdateShort* — синтез в UpdateList). - private readonly UpdateManager _updateManager; - - /// - /// Создаёт клиент WTelegramClient с сессией в памяти. - /// - /// api_id приложения Telegram. - /// api_hash приложения Telegram (же ключ внутреннего шифрования сессии). - /// Сохранённые байты сессии (null — новая сессия). - public WTelegramSessionClient( - int apiId, - string apiHash, - byte[]? storedSession) - { - _apiId = apiId; - _apiHash = apiHash; - _client = new Client(ConfigProvider, storedSession ?? [], OnSessionSaved); - _updateManager = new UpdateManager(_client, OnSingleUpdateAsync); - } - - /// - public bool IsAuthorized => _client.UserId != 0; - - /// - public bool IsConnected => _connected && !_client.Disconnected; - - /// - public int ApiId => _apiId; - - /// - public string ApiHash => _apiHash; - - /// - public byte[]? SessionBytes => Volatile.Read(ref _latestSessionBytes); - - /// - public async Task ConnectAsync(CancellationToken cancellationToken) - { - if (IsConnected) - { - return; - } - - await _client.ConnectAsync().WaitAsync(cancellationToken).ConfigureAwait(false); - _connected = true; - } - - /// - public async Task RequestCodeAsync(string phone, CancellationToken cancellationToken) - { - _phone = phone; - _phoneCodeHash = null; - _phoneAlreadyAuthorized = false; - - Auth_SentCodeBase sentCode = await SendCodeOnceAsync(phone, cancellationToken).ConfigureAwait(false); - switch (sentCode) - { - case Auth_SentCode { phone_code_hash: not null } code: - _phoneCodeHash = code.phone_code_hash; - return; - case Auth_SentCodeSuccess: - // Номер уже авторизован в этой сессии: код не нужен (SubmitCodeAsync вернёт готово). - _phoneAlreadyAuthorized = true; - return; - default: - throw new SessionException( - StatusCode.InvalidArgument, - "Telegram не запросил код для этого номера — повторите вход по номеру телефона"); - } - } - - /// - public async Task SubmitCodeAsync(string code, CancellationToken cancellationToken) - { - if (_phoneAlreadyAuthorized) - { - return null; - } - - if (_phone is null || _phoneCodeHash is null) - { - throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.CodeNotRequested); - } - - Auth_AuthorizationBase authorization; - try - { - authorization = await _client.Auth_SignIn(_phone, _phoneCodeHash, code).WaitAsync(cancellationToken).ConfigureAwait(false); - } - catch (RpcException exception) when (exception.Code == 401 && exception.Message == "SESSION_PASSWORD_NEEDED") - { - // Включён 2FA — следующий шаг: облачный пароль (submit_password). - return "password"; - } - catch (RpcException exception) when (exception.Code == 400 && exception.Message == "PHONE_CODE_INVALID") - { - throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.WrongCode, exception); - } - catch (RpcException exception) when (exception.Code == 400 && exception.Message == "PHONE_CODE_EXPIRED") - { - throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.CodeExpired, exception); - } - catch (RpcException exception) - { - throw MapRpcException(exception); - } - - CompleteAuthorization(authorization); - return null; - } - - /// - public async Task SubmitPasswordAsync(string password, CancellationToken cancellationToken) - { - try - { - Account_Password accountPassword = await _client.Account_GetPassword().WaitAsync(cancellationToken).ConfigureAwait(false); - InputCheckPasswordSRP checkPassword = await Client.InputCheckPassword(accountPassword, password).WaitAsync(cancellationToken).ConfigureAwait(false); - Auth_AuthorizationBase authorization = await _client.Auth_CheckPassword(checkPassword).WaitAsync(cancellationToken).ConfigureAwait(false); - CompleteAuthorization(authorization); - } - catch (RpcException exception) when (exception.Code == 400 && exception.Message == "PASSWORD_HASH_INVALID") - { - throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.WrongPassword, exception); - } - catch (RpcException exception) - { - throw MapRpcException(exception); - } - } - - /// - public async Task StartQrAsync(Action onQrUrl, CancellationToken cancellationToken) - { - try - { - // logoutFirst=false: сессия без пользователя (проверено TenantSession до запуска QR); - // завершившись, библиотека сама сохраняет авторизованную сессию в память (LoginAlreadyDone). - await _client - .LoginWithQRCode(url => onQrUrl(url), except_ids: null, logoutFirst: false, ct: cancellationToken) - .WaitAsync(cancellationToken) - .ConfigureAwait(false); - } - catch (RpcException exception) - { - throw MapRpcException(exception); - } - } - - /// - public async Task LogOutAsync(CancellationToken cancellationToken) - { - await _client.Auth_LogOut().WaitAsync(cancellationToken).ConfigureAwait(false); - } - - /// - public async Task GetAccountAsync(CancellationToken cancellationToken) - { - UserBase[] users = await _client.Users_GetUsers(InputUser.Self).WaitAsync(cancellationToken).ConfigureAwait(false); - string username = users.OfType().FirstOrDefault()?.username ?? string.Empty; - return "@" + (string.IsNullOrEmpty(username) ? "user" : username); - } - - /// - public ValueTask DisposeAsync() - { - _connected = false; - return _client.DisposeAsync(); - } - - - /// - public event Func? MessageReceived; - - /// - public async Task> GetDialogsAsync(int limit, CancellationToken cancellationToken) - { - Messages_DialogsBase result = await RunTlCallAsync(() => _client.Messages_GetDialogs(limit: limit), cancellationToken).ConfigureAwait(false); - (DialogBase[] dialogs, Dictionary chats, Dictionary users) = UnpackDialogs(result); - CacheEntities(chats.Values, users.Values); - - var items = new List(dialogs.Length); - foreach (DialogBase dialog in dialogs) - { - TelegramDialog? item = TlMessageMapper.ToDialog(dialog, chats, users); - if (item is not null) - { - items.Add(item); - } - } - - return items; - } - - /// - public async Task> GetMessagesAsync( - string dialogId, - int limit, - CancellationToken cancellationToken) - { - InputPeer peer = await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false); - Messages_MessagesBase result = await RunTlCallAsync(() => _client.Messages_GetHistory(peer, limit: limit), cancellationToken).ConfigureAwait(false); - (MessageBase[] messages, Dictionary chats, Dictionary users) = UnpackMessages(result); - CacheEntities(chats.Values, users.Values); - - var items = new List(messages.Length); - foreach (MessageBase message in messages) - { - TelegramMessage? item = TlMessageMapper.ToMessage(message, chats, users); - if (item is not null) - { - items.Add(item); - } - } - - return items; - } - - /// - public async Task GetMessageAsync( - string dialogId, - long msgId, - CancellationToken cancellationToken) - { - // Диалог обязан быть доступен аккаунту (иначе INVALID_ARGUMENT, как в истории). - await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false); - - // messages.getMessages по голому id (InputMessageID) — без привязки к peer. - InputMessage[] ids = [new InputMessageID { id = (int)msgId }]; - Messages_MessagesBase result = await RunTlCallAsync( - () => _client.Messages_GetMessages(ids), - cancellationToken).ConfigureAwait(false); - (MessageBase[] messages, Dictionary chats, Dictionary users) = UnpackMessages(result); - CacheEntities(chats.Values, users.Values); - - foreach (MessageBase message in messages) - { - TelegramMessage? mapped = TlMessageMapper.ToMessage(message, chats, users); - if (mapped is not null && mapped.Id == msgId && mapped.DialogId == dialogId) - { - return mapped; - } - } - - // Медиа без текста или удалённое сообщение — не найдено (found=false). - return null; - } - - /// - public async Task MarkReadAsync(string dialogId, CancellationToken cancellationToken) - { - InputPeer peer = await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false); - await RunTlCallAsync(() => _client.ReadHistory(peer), cancellationToken).ConfigureAwait(false); - } - - - /// - public async Task> SearchAsync( - string query, - int limit, - CancellationToken cancellationToken) - { - // contacts.SearchRequest(q, limit): результат — сущности chats/users (поиск каналов/групп/людей). - Contacts_Found found = await RunTlCallAsync(() => _client.Contacts_Search(query, limit), cancellationToken).ConfigureAwait(false); - CacheEntities(found.chats.Values, found.users.Values); - - var items = new List(found.chats.Count + found.users.Count); - foreach (ChatBase chat in found.chats.Values) - { - items.Add(TlMessageMapper.ToFoundChat(chat)); - } - - foreach (User user in found.users.Values) - { - items.Add(TlMessageMapper.ToFoundUser(user)); - } - - return items; - } - - /// - public async Task GetInfoAsync(string dialogId, CancellationToken cancellationToken) - { - TelegramSourceInfo unknown = DefaultSourceInfo(dialogId); - if (!TryParseSignedId(dialogId, out bool isChannel, out bool isChat, out bool isUser, out long rawId)) - { - return unknown; - } - - try - { - if (isChannel) - { - return await GetChannelInfoAsync(dialogId, rawId, unknown, cancellationToken).ConfigureAwait(false); - } - - if (isChat) - { - return await GetChatInfoAsync(dialogId, rawId, unknown, cancellationToken).ConfigureAwait(false); - } - - if (isUser) - { - return GetUserInfo(dialogId, rawId, unknown); - } - } - catch (SessionException) - { - return unknown; - } - - return unknown; - } - - /// - public async Task ReadForEvalAsync( - string dialogId, - int limit, - CancellationToken cancellationToken) - { - if (limit <= 0) - { - return DiscoveryReadResult.Empty; - } - - if (!TryParseSignedId(dialogId, out bool isChannel, out bool isChat, out bool isUser, out long rawId)) - { - return DiscoveryReadResult.NoHistory(); - } - - InputPeer peer; - try - { - peer = await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false); - } - catch (SessionException) - { - return DiscoveryReadResult.NoHistory(); - } - - if (isChannel && IsForumChannel(rawId)) - { - IReadOnlyList forumMessages = await ReadForumTopicsAsync(peer, limit, cancellationToken).ConfigureAwait(false); - if (forumMessages.Count > 0) - { - return new DiscoveryReadResult(true, null, forumMessages); - } - } - - // Обычная лента (каналы/группы; для форума — General-тема; форум без читаемых тем → тоже лента). - try - { - Messages_MessagesBase result = await RunTlCallAsync(() => _client.Messages_GetHistory(peer, limit: limit), cancellationToken).ConfigureAwait(false); - (MessageBase[] messages, Dictionary chats, Dictionary users) = UnpackMessages(result); - CacheEntities(chats.Values, users.Values); - return new DiscoveryReadResult(true, null, MapEvalMessages(messages)); - } - catch (SessionException) - { - return DiscoveryReadResult.NoHistory(); - } - } - - /// - public async Task JoinAsync(string username, CancellationToken cancellationToken) - { - Contacts_ResolvedPeer resolved = await RunTlCallAsync(() => _client.Contacts_ResolveUsername(username), cancellationToken).ConfigureAwait(false); - CacheEntities(resolved.chats.Values, resolved.users.Values); - - Channel? channel = resolved.chats.Values.OfType().FirstOrDefault(); - if (channel is null) - { - throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.JoinTargetNotChannel); - } - - await RunTlCallAsync(() => _client.Channels_JoinChannel(new InputChannel(channel.id, channel.access_hash)), cancellationToken).ConfigureAwait(false); - } - - /// - public async Task LeaveAsync(string dialogId, CancellationToken cancellationToken) - { - if (!TryParseSignedId(dialogId, out bool isChannel, out _, out _, out long rawId) || !isChannel) - { - throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId); - } - - InputChannel channel = await ResolveInputChannelAsync(dialogId, rawId, cancellationToken).ConfigureAwait(false); - await RunTlCallAsync(() => _client.Channels_LeaveChannel(channel), cancellationToken).ConfigureAwait(false); - } - - private async Task GetChannelInfoAsync( - string dialogId, - long rawId, - TelegramSourceInfo unknown, - CancellationToken cancellationToken) - { - Channel? cached; - lock (_entityCacheGate) - { - cached = _chatsById.TryGetValue(rawId, out ChatBase? chat) ? chat as Channel : null; - } - - if (cached is not null) - { - int? participants = await TryFetchChannelParticipantsAsync(dialogId, rawId, cancellationToken).ConfigureAwait(false); - return DescribeChannel(dialogId, cached, participants); - } - - InputChannel input = await ResolveInputChannelAsync(dialogId, rawId, cancellationToken).ConfigureAwait(false); - Messages_ChatFull full = await RunTlCallAsync(() => _client.Channels_GetFullChannel(input), cancellationToken).ConfigureAwait(false); - CacheEntities(full.chats.Values, full.users.Values); - - Channel? channel = full.chats.TryGetValue(rawId, out ChatBase? found) ? found as Channel : null; - if (channel is null) - { - return unknown; - } - - int? fullParticipants = (full.full_chat as ChannelFull)?.participants_count is int count && count > 0 ? count : null; - return DescribeChannel(dialogId, channel, fullParticipants); - } - - private async Task TryFetchChannelParticipantsAsync( - string dialogId, - long rawId, - CancellationToken cancellationToken) - { - try - { - InputChannel input = await ResolveInputChannelAsync(dialogId, rawId, cancellationToken).ConfigureAwait(false); - Messages_ChatFull full = await RunTlCallAsync(() => _client.Channels_GetFullChannel(input), cancellationToken).ConfigureAwait(false); - CacheEntities(full.chats.Values, full.users.Values); - return (full.full_chat as ChannelFull)?.participants_count is int count && count > 0 ? count : null; - } - catch (SessionException) - { - return null; - } - } - - // Инфо о базовой группе: entity из кэша/полного чата, участники — GetFullChat (только при членстве). - // dialogId: Подписанный id. - // rawId: Raw id группы (положительный). - // unknown: Инфо по умолчанию. - // cancellationToken: Отмена операции. - private async Task GetChatInfoAsync( - string dialogId, - long rawId, - TelegramSourceInfo unknown, - CancellationToken cancellationToken) - { - Chat? cached; - lock (_entityCacheGate) - { - cached = _chatsById.TryGetValue(rawId, out ChatBase? chat) ? chat as Chat : null; - } - - if (cached is not null) - { - int? participants = await TryFetchBasicParticipantsAsync(rawId, cancellationToken).ConfigureAwait(false); - return DescribeGroup(dialogId, cached, participants); - } - - Messages_ChatFull full = await RunTlCallAsync(() => _client.Messages_GetFullChat(rawId), cancellationToken).ConfigureAwait(false); - CacheEntities(full.chats.Values, full.users.Values); - - Chat? group = full.chats.TryGetValue(rawId, out ChatBase? found) ? found as Chat : null; - if (group is null) - { - return unknown; - } - - int? fullParticipants = full.full_chat is ChatFull fullChat && fullChat.participants is ChatParticipants members - ? members.participants.Length - : null; - return DescribeGroup(dialogId, group, fullParticipants); - } - - private async Task TryFetchBasicParticipantsAsync(long rawId, CancellationToken cancellationToken) - { - try - { - Messages_ChatFull full = await RunTlCallAsync(() => _client.Messages_GetFullChat(rawId), cancellationToken).ConfigureAwait(false); - CacheEntities(full.chats.Values, full.users.Values); - return full.full_chat is ChatFull fullChat && fullChat.participants is ChatParticipants members - ? members.participants.Length - : null; - } - catch (SessionException) - { - return null; - } - } - - // Собирает инфо базовой группы из entity (имя/username/kind group, без форума). - // dialogId: Подписанный id. - // group: Entity группы. - // participants: Число участников (null — определить не удалось). - private static TelegramSourceInfo DescribeGroup( - string dialogId, - Chat group, - int? participants) - => new( - dialogId, - DisplayName(dialogId, group), - group.MainUsername ?? string.Empty, - DialogKinds.Group, - participants, - isForum: false); - - private TelegramSourceInfo GetUserInfo( - string dialogId, - long rawId, - TelegramSourceInfo unknown) - { - User? user; - lock (_entityCacheGate) - { - _usersById.TryGetValue(rawId, out user); - } - - if (user is null) - { - return unknown; - } - - string name = (user.first_name + " " + user.last_name).Trim(); - string username = user.username ?? string.Empty; - if (name.Length == 0) - { - name = username.Length > 0 ? username : dialogId; - } - - return new TelegramSourceInfo(dialogId, name, username, DialogKinds.Chat, participants: null, isForum: false); - } - - private async Task> ReadForumTopicsAsync( - InputPeer peer, - int limit, - CancellationToken cancellationToken) - { - var outMessages = new List(); - try - { - Messages_ForumTopics forum = await RunTlCallAsync( - () => _client.Messages_GetForumTopics(peer, offset_date: default, offset_id: 0, offset_topic: 0, limit: ForumTopicsLimit), - cancellationToken).ConfigureAwait(false); - CacheEntities(forum.chats.Values, forum.users.Values); - - ForumTopic[] topics = forum.topics.OfType().ToArray(); - if (topics.Length == 0) - { - return outMessages; - } - - int perTopic = Math.Min(Math.Max(3, (int)Math.Ceiling(limit / (double)topics.Length)), ForumMessagesPerTopicCap); - DateTimeOffset now = DateTimeOffset.UtcNow; - foreach (ForumTopic topic in topics) - { - try - { - Messages_MessagesBase result = await RunTlCallAsync( - () => _client.Messages_GetReplies(peer, topic.id, limit: perTopic), - cancellationToken).ConfigureAwait(false); - (MessageBase[] messages, Dictionary chats, Dictionary users) = UnpackMessages(result); - CacheEntities(chats.Values, users.Values); - foreach (MessageBase message in messages) - { - DiscoveryMessage? item = TlMessageMapper.ToEvalMessage(message, topic.id, topic.title, now); - if (item is not null) - { - outMessages.Add(item); - } - } - } - catch (SessionException) - { - } - } - } - catch (SessionException) - { - outMessages.Clear(); - } - - return outMessages; - } - - // Маппит сообщения ленты/тем в нейтральные (только непустые тексты, темы пусты). - // messages: Сообщения выборки (MessageBase). - private static IReadOnlyList MapEvalMessages(MessageBase[] messages) - { - var items = new List(messages.Length); - DateTimeOffset now = DateTimeOffset.UtcNow; - foreach (MessageBase message in messages) - { - DiscoveryMessage? item = TlMessageMapper.ToEvalMessage(message, null, null, now); - if (item is not null) - { - items.Add(item); - } - } - - return items; - } - - // Инфо по умолчанию (сущность недоступна): name=id, username/kind пусты, без участников. - // dialogId: Подписанный id источника. - private static TelegramSourceInfo DefaultSourceInfo(string dialogId) - => new(dialogId, dialogId, string.Empty, string.Empty, participants: null, isForum: false); - - // Собирает инфо канала из entity полного чата (имя/username/kind/forum + участники). - // dialogId: Подписанный id. - // channel: Entity канала из ответа полного чата. - // participants: Число участников (full_chat) либо null. - private static TelegramSourceInfo DescribeChannel( - string dialogId, - Channel channel, - int? participants) - => new( - dialogId, - DisplayName(dialogId, channel), - channel.MainUsername ?? string.Empty, - TlMessageMapper.KindOf(channel), - participants, - isForum: (channel.flags & Channel.Flags.forum) != 0); - - private static string DisplayName(string dialogId, ChatBase chat) - { - string title = chat.Title ?? string.Empty; - return title.Length > 0 ? title : dialogId; - } - - private bool IsForumChannel(long rawId) - { - lock (_entityCacheGate) - { - return _chatsById.TryGetValue(rawId, out ChatBase? chat) - && chat is Channel channel - && (channel.flags & Channel.Flags.forum) != 0; - } - } - - private const int ForumTopicsLimit = 5; - - private const int ForumMessagesPerTopicCap = 10; - - - private async Task OnSingleUpdateAsync(Update update) - { - try - { - // TlMessageMapper.NewMessageFrom: UpdateNewMessage/UpdateNewChannelMessage (каналы — - // подкласс) и синтезированные из коротких вариантов; прочие обновления игнорируются. - MessageBase? message = TlMessageMapper.NewMessageFrom(update); - if (message is null) - { - return; - } - - // UpdateManager собрал сущности сообщения в коллектор (Users/Chats) до вызова колбэка — - // имя/username диалога и access_hash (для read-ack) доступны без сетевых запросов. - Dictionary chats = _updateManager.Chats; - Dictionary users = _updateManager.Users; - TelegramMessage? item = TlMessageMapper.ToMessage(message, chats, users); - if (item is not null) - { - await DispatchMessageAsync(item).ConfigureAwait(false); - } - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - // Сбой обработки обновления не должен останавливать realtime; ошибки подписчиков - // обрабатывает TenantSession (логгер есть на уровне сессии). - } - } - - // Последовательно вызывает подписчиков события нового сообщения. - // message: Входящее сообщение диалога. - private async Task DispatchMessageAsync(TelegramMessage message) - { - Func? handler = MessageReceived; - if (handler is null) - { - return; - } - - foreach (Delegate subscriber in handler.GetInvocationList()) - { - try - { - await ((Func)subscriber)(message).ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - // Исключение подписчика (например, ядро недоступно) логирует сам подписчик - // (TenantSession) и не влияет на остальных. - } - } - } - - // Возвращает значения конфигурации WTelegramClient (api_id/api_hash; остальное — по умолчанию). - // what: Запрашиваемый библиотекой ключ конфигурации. - private string? ConfigProvider(string what) - => what switch - { - "api_id" => _apiId.ToString(System.Globalization.CultureInfo.InvariantCulture), - "api_hash" => _apiHash, - _ => null, - }; - - // Колбэк сохранения сессии библиотекой: держим последние байты в памяти процесса. - // sessionBytes: Новые байты сессии (готовый самодостаточный блок). - private void OnSessionSaved(byte[] sessionBytes) - => Volatile.Write(ref _latestSessionBytes, sessionBytes); - - private async Task SendCodeOnceAsync(string phone, CancellationToken cancellationToken) - { - try - { - return await _client.Auth_SendCode(phone, _apiId, _apiHash, new CodeSettings()).WaitAsync(cancellationToken).ConfigureAwait(false); - } - catch (RpcException exception) when (exception.Code == 500 && exception.Message == "AUTH_RESTART") - { - return await _client.Auth_SendCode(phone, _apiId, _apiHash, new CodeSettings()).WaitAsync(cancellationToken).ConfigureAwait(false); - } - catch (RpcException exception) - { - throw MapRpcException(exception); - } - } - - // Завершает авторизацию по объекту Auth_AuthorizationBase: регистрирует пользователя в сессии - // (LoginAlreadyDone сохраняет сессию — колбэк OnSessionSaved обновит байты для файла). - // authorization: Ответ Auth_SignIn/Auth_CheckPassword. - private void CompleteAuthorization(Auth_AuthorizationBase authorization) - { - if (authorization is Auth_AuthorizationSignUpRequired) - { - throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.SignUpRequired); - } - - try - { - _client.LoginAlreadyDone(authorization); - } - catch (WTException exception) - { - throw new SessionException(StatusCode.Internal, exception.Message, exception); - } - } - - private static SessionException MapRpcException(RpcException exception) - { - if (exception.Message.StartsWith("FLOOD_WAIT_", StringComparison.Ordinal)) - { - return new SessionException(StatusCode.ResourceExhausted, "flood: " + exception.Message, exception); - } - - if (exception.Code == 400) - { - return new SessionException(StatusCode.InvalidArgument, exception.Message, exception); - } - - return new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception); - } - - - // Исполняет TL-вызов с единым переводом ошибок (RpcException Telegram → SessionException; - // прочие сбои — UNAVAILABLE «Telegram недоступен…»). - // TResult: Тип результата TL-вызова. - // call: Фабрика задачи TL-вызова (методы библиотеки токенов не принимают). - // cancellationToken: Отмена операции. - private async Task RunTlCallAsync(Func> call, CancellationToken cancellationToken) - { - try - { - return await call().WaitAsync(cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (RpcException exception) - { - throw MapRpcException(exception); - } - catch (Exception exception) - { - throw new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception); - } - } - - // Разворачивает результат messages.getDialogs в диалоги и словари сущностей. - // result: Результат TL-вызова списка диалогов. - private static (DialogBase[] Dialogs, Dictionary Chats, Dictionary Users) UnpackDialogs(Messages_DialogsBase result) - => result switch - { - // Slice наследует Messages_Dialogs — сначала конкретный тип (иначе ветка недостижима). - Messages_DialogsSlice slice => (slice.dialogs, slice.chats, slice.users), - Messages_Dialogs dialogs => (dialogs.dialogs, dialogs.chats, dialogs.users), - _ => throw new SessionException(StatusCode.Internal, "Telegram вернул неподдерживаемый список диалогов"), - }; - - // Разворачивает результат messages.getHistory в сообщения и словари сущностей. - // result: Результат TL-вызова истории. - private static (MessageBase[] Messages, Dictionary Chats, Dictionary Users) UnpackMessages(Messages_MessagesBase result) - => result switch - { - Messages_Messages messages => (messages.messages, messages.chats, messages.users), - Messages_ChannelMessages channelMessages => (channelMessages.messages, channelMessages.chats, channelMessages.users), - // Прочие варианты (rare/новые слои) — сообщения отдаёт базовый доступор, сущностей нет: - // имена диалогов в таких сообщениях будут подменены id (кадр редкий, реальный поток не страдает). - _ => (result.Messages, new Dictionary(), new Dictionary()), - }; - - // Кэширует access_hash и сущности из ответа (для InputPeer по id и имён/forum discovery). - // chats: Каналы/группы ответа (по raw id). - // users: Пользователи ответа (по raw id). - private void CacheEntities(IEnumerable chats, IEnumerable users) - { - lock (_entityCacheGate) - { - foreach (ChatBase chat in chats) - { - switch (chat) - { - case Channel channel: - _entityAccessHashes.Set("-100" + channel.id.ToString(CultureInfo.InvariantCulture), channel.access_hash); - _chatsById.Set(channel.id, channel); - break; - case Chat group: - _chatsById.Set(group.id, group); - break; - } - } - - foreach (UserBase user in users) - { - if (user is User regularUser) - { - _entityAccessHashes.Set("+" + regularUser.id.ToString(CultureInfo.InvariantCulture), regularUser.access_hash); - _usersById.Set(regularUser.id, regularUser); - } - } - } - } - - // Строит InputPeer диалога; при пустом кэше — доливка списком диалогов (первая страница). - // dialogId: Подписанный id диалога. - // cancellationToken: Отмена операции. - private async Task ResolvePeerAsync(string dialogId, CancellationToken cancellationToken) - { - if (!TryParseSignedId(dialogId, out bool isChannel, out bool isChat, out bool isUser, out long rawId)) - { - throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId); - } - - InputPeer? peer = BuildPeer(isChannel, isChat, isUser, rawId); - if (peer is not null) - { - return peer; - } - - // Сущность может быть уже собрана коллектором UpdateManager (realtime-сообщение пришло до - // списка диалогов) — access_hash берётся оттуда без сетевых запросов. - if (TryImportPeerFromCollector(isChannel, isUser, rawId)) - { - InputPeer? imported = BuildPeer(isChannel, isChat, isUser, rawId); - if (imported is not null) - { - return imported; - } - } - - // Канал/пользователь без access_hash (рестарт/новый источник): доливаем сущности - // первой страницей списка диалогов — каталог ядра строится из неё же (лимит 500, как refresh). - await GetDialogsAsync(DialogResolvePageSize, cancellationToken).ConfigureAwait(false); - return BuildPeer(isChannel, isChat, isUser, rawId) - ?? throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.UnknownDialog); - } - - // InputChannel по подписанному id канала (join/leave/полный чат); как ResolvePeerAsync. - // dialogId: Подписанный id канала («-100…»). - // rawId: Raw id канала. - // cancellationToken: Отмена операции. - private async Task ResolveInputChannelAsync( - string dialogId, - long rawId, - CancellationToken cancellationToken) - { - lock (_entityCacheGate) - { - if (_entityAccessHashes.TryGetValue(dialogId, out long cached)) - { - return new InputChannel(rawId, cached); - } - } - - await GetDialogsAsync(DialogResolvePageSize, cancellationToken).ConfigureAwait(false); - lock (_entityCacheGate) - { - return _entityAccessHashes.TryGetValue(dialogId, out long accessHash) - ? new InputChannel(rawId, accessHash) - : throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.UnknownDialog); - } - } - - // Импортирует access_hash сущности из коллектора UpdateManager в кэш (базовая группа хеша не - // требует — возвращает true сразу). False — сущности в коллекторе нет или хеш нулевой (min-entity). - // isChannel: Признак канала («-100…»). - // isUser: Признак личного чата («+…»). - // rawId: Raw id (канала/пользователя). - private bool TryImportPeerFromCollector( - bool isChannel, - bool isUser, - long rawId) - { - if (!isChannel && !isUser) - { - return true; - } - - string signedId = (isChannel ? "-100" : "+") + rawId.ToString(CultureInfo.InvariantCulture); - lock (_entityCacheGate) - { - if (_entityAccessHashes.ContainsKey(signedId)) - { - return true; - } - } - - long accessHash = 0; - if (isChannel - && _updateManager.Chats.TryGetValue(rawId, out ChatBase? chat) - && chat is Channel channel) - { - accessHash = channel.access_hash; - } - else if (isUser && _updateManager.Users.TryGetValue(rawId, out User? user)) - { - accessHash = user.access_hash; - } - - if (accessHash == 0) - { - return false; - } - - lock (_entityCacheGate) - { - _entityAccessHashes.Set(signedId, accessHash); - } - - return true; - } - - // Строит InputPeer по разобранному id и access_hash из кэша (null — сущность неизвестна). - // isChannel: Признак канала («-100…»). - // isChat: Признак базовой группы («-…»). - // isUser: Признак личного чата («+…»). - // rawId: Raw id (канала/группы/пользователя). - private InputPeer? BuildPeer( - bool isChannel, - bool isChat, - bool isUser, - long rawId) - { - if (isChat) - { - return new InputPeerChat(rawId); - } - - string signedId = (isChannel ? "-100" : "+") + rawId.ToString(CultureInfo.InvariantCulture); - long accessHash; - lock (_entityCacheGate) - { - if (!_entityAccessHashes.TryGetValue(signedId, out accessHash)) - { - return null; - } - } - - return isChannel - ? new InputPeerChannel(rawId, accessHash) - : new InputPeerUser(rawId, accessHash); - } - - // Разбирает подписанный id канона контракта («-100…» канал, «-…» группа, «+…» личный). - // dialogId: Подписанный id. - // isChannel: True — канал. - // isChat: True — базовая группа. - // isUser: True — личный чат. - // rawId: Raw id без префикса. - private static bool TryParseSignedId( - string dialogId, - out bool isChannel, - out bool isChat, - out bool isUser, - out long rawId) - { - isChannel = false; - isChat = false; - isUser = false; - rawId = 0; - - string digits; - if (dialogId.StartsWith("-100", StringComparison.Ordinal)) - { - digits = dialogId[4..]; - isChannel = true; - } - else if (dialogId.StartsWith('-')) - { - digits = dialogId[1..]; - isChat = true; - } - else if (dialogId.StartsWith('+')) - { - digits = dialogId[1..]; - isUser = true; - } - else - { - return false; - } - - return digits.Length > 0 && long.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out rawId); - } - - // Размер страницы списка диалогов для доливки кэша сущностей (как refresh_dialogs limit). - private const int DialogResolvePageSize = 500; -} -#pragma warning restore CS0618 +using System.Globalization; +using Deal.Telegram.Caching; +using Deal.Telegram.Sessions; +using Grpc.Core; +using TL; +using WTelegram; +using RpcException = TL.RpcException; + +namespace Deal.Telegram.Telegram; + +#pragma warning disable CS0618 // Auth_SendCode/Auth_SignIn используются осознанно: ручной веб-вход 1:1 с прототипом + +/// +/// Реальная реализация поверх WTelegramClient. +/// +public sealed class WTelegramSessionClient : ISessionClient +{ + private readonly Client _client; + private readonly int _apiId; + private readonly string _apiHash; + private byte[]? _latestSessionBytes; + private bool _connected; + private string? _phone; + private string? _phoneCodeHash; + private bool _phoneAlreadyAuthorized; + + // Ёмкость LRU-кэша access_hash сущностей (ограничивает память на длинной истории диалогов). + private const int AccessHashCacheCapacity = 2000; + + // Ёмкость LRU-кэша сущностей чатов/каналов (последние использованные имена/forum). + private const int ChatEntityCacheCapacity = 1000; + + // Ёмкость LRU-кэша сущностей пользователей (последние использованные имена). + private const int UserEntityCacheCapacity = 2000; + + // LRU-кэш access_hash сущностей (ключ — подписанный id диалога; заполняется из ответов). + private readonly LruCache _entityAccessHashes = new(AccessHashCacheCapacity); + + private readonly LruCache _chatsById = new(ChatEntityCacheCapacity); + + private readonly LruCache _usersById = new(UserEntityCacheCapacity); + + // Защита кэшей сущностей (обновляются из потоков reactor/вызовов). + private readonly object _entityCacheGate = new(); + + // Штатный UpdateManager библиотеки: единственный нормализованный колбэк на каждое обновление + // (порядок/pts, восстановление пропусков через getDifference, дедупликация устаревших). + // Новые сообщения всех типов приходят как TL.UpdateNewMessage (каналы/супергруппы — + // подкласс UpdateNewChannelMessage; короткие UpdateShort* — синтез в UpdateList). + private readonly UpdateManager _updateManager; + + /// + /// Создаёт клиент WTelegramClient с сессией в памяти. + /// + /// api_id приложения Telegram. + /// api_hash приложения Telegram (же ключ внутреннего шифрования сессии). + /// Сохранённые байты сессии (null — новая сессия). + public WTelegramSessionClient( + int apiId, + string apiHash, + byte[]? storedSession) + { + _apiId = apiId; + _apiHash = apiHash; + _client = new Client(ConfigProvider, storedSession ?? [], OnSessionSaved); + _updateManager = new UpdateManager(_client, OnSingleUpdateAsync); + } + + /// + public bool IsAuthorized => _client.UserId != 0; + + /// + public bool IsConnected => _connected && !_client.Disconnected; + + /// + public int ApiId => _apiId; + + /// + public string ApiHash => _apiHash; + + /// + public byte[]? SessionBytes => Volatile.Read(ref _latestSessionBytes); + + /// + public async Task ConnectAsync(CancellationToken cancellationToken) + { + if (IsConnected) + { + return; + } + + await _client.ConnectAsync().WaitAsync(cancellationToken).ConfigureAwait(false); + _connected = true; + } + + /// + public async Task RequestCodeAsync(string phone, CancellationToken cancellationToken) + { + _phone = phone; + _phoneCodeHash = null; + _phoneAlreadyAuthorized = false; + + Auth_SentCodeBase sentCode = await SendCodeOnceAsync(phone, cancellationToken).ConfigureAwait(false); + switch (sentCode) + { + case Auth_SentCode { phone_code_hash: not null } code: + _phoneCodeHash = code.phone_code_hash; + return; + case Auth_SentCodeSuccess: + // Номер уже авторизован в этой сессии: код не нужен (SubmitCodeAsync вернёт готово). + _phoneAlreadyAuthorized = true; + return; + default: + throw new SessionException( + StatusCode.InvalidArgument, + "Telegram не запросил код для этого номера — повторите вход по номеру телефона"); + } + } + + /// + public async Task SubmitCodeAsync(string code, CancellationToken cancellationToken) + { + if (_phoneAlreadyAuthorized) + { + return null; + } + + if (_phone is null || _phoneCodeHash is null) + { + throw new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.CodeNotRequested); + } + + Auth_AuthorizationBase authorization; + try + { + authorization = await _client.Auth_SignIn(_phone, _phoneCodeHash, code).WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (RpcException exception) when (exception.Code == 401 && exception.Message == "SESSION_PASSWORD_NEEDED") + { + // Включён 2FA — следующий шаг: облачный пароль (submit_password). + return "password"; + } + catch (RpcException exception) when (exception.Code == 400 && exception.Message == "PHONE_CODE_INVALID") + { + throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.WrongCode, exception); + } + catch (RpcException exception) when (exception.Code == 400 && exception.Message == "PHONE_CODE_EXPIRED") + { + throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.CodeExpired, exception); + } + catch (RpcException exception) + { + throw MapRpcException(exception); + } + + CompleteAuthorization(authorization); + return null; + } + + /// + public async Task SubmitPasswordAsync(string password, CancellationToken cancellationToken) + { + try + { + Account_Password accountPassword = await _client.Account_GetPassword().WaitAsync(cancellationToken).ConfigureAwait(false); + InputCheckPasswordSRP checkPassword = await Client.InputCheckPassword(accountPassword, password).WaitAsync(cancellationToken).ConfigureAwait(false); + Auth_AuthorizationBase authorization = await _client.Auth_CheckPassword(checkPassword).WaitAsync(cancellationToken).ConfigureAwait(false); + CompleteAuthorization(authorization); + } + catch (RpcException exception) when (exception.Code == 400 && exception.Message == "PASSWORD_HASH_INVALID") + { + throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.WrongPassword, exception); + } + catch (RpcException exception) + { + throw MapRpcException(exception); + } + } + + /// + public async Task StartQrAsync(Action onQrUrl, CancellationToken cancellationToken) + { + try + { + // logoutFirst=false: сессия без пользователя (проверено TenantSession до запуска QR); + // завершившись, библиотека сама сохраняет авторизованную сессию в память (LoginAlreadyDone). + await _client + .LoginWithQRCode(url => onQrUrl(url), except_ids: null, logoutFirst: false, ct: cancellationToken) + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + } + catch (RpcException exception) + { + throw MapRpcException(exception); + } + } + + /// + public async Task LogOutAsync(CancellationToken cancellationToken) + { + await _client.Auth_LogOut().WaitAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task GetAccountAsync(CancellationToken cancellationToken) + { + UserBase[] users = await _client.Users_GetUsers(InputUser.Self).WaitAsync(cancellationToken).ConfigureAwait(false); + string username = users.OfType().FirstOrDefault()?.username ?? string.Empty; + return "@" + (string.IsNullOrEmpty(username) ? "user" : username); + } + + /// + public ValueTask DisposeAsync() + { + _connected = false; + return _client.DisposeAsync(); + } + + + /// + public event Func? MessageReceived; + + /// + public async Task> GetDialogsAsync(int limit, CancellationToken cancellationToken) + { + Messages_DialogsBase result = await RunTlCallAsync(() => _client.Messages_GetDialogs(limit: limit), cancellationToken).ConfigureAwait(false); + (DialogBase[] dialogs, Dictionary chats, Dictionary users) = UnpackDialogs(result); + CacheEntities(chats.Values, users.Values); + + var items = new List(dialogs.Length); + foreach (DialogBase dialog in dialogs) + { + TelegramDialog? item = TlMessageMapper.ToDialog(dialog, chats, users); + if (item is not null) + { + items.Add(item); + } + } + + return items; + } + + /// + public async Task> GetMessagesAsync( + string dialogId, + int limit, + CancellationToken cancellationToken) + { + InputPeer peer = await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false); + Messages_MessagesBase result = await RunTlCallAsync(() => _client.Messages_GetHistory(peer, limit: limit), cancellationToken).ConfigureAwait(false); + (MessageBase[] messages, Dictionary chats, Dictionary users) = UnpackMessages(result); + CacheEntities(chats.Values, users.Values); + + var items = new List(messages.Length); + foreach (MessageBase message in messages) + { + TelegramMessage? item = TlMessageMapper.ToMessage(message, chats, users); + if (item is not null) + { + items.Add(item); + } + } + + return items; + } + + /// + public async Task GetMessageAsync( + string dialogId, + long msgId, + CancellationToken cancellationToken) + { + // Диалог обязан быть доступен аккаунту (иначе INVALID_ARGUMENT, как в истории). + await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false); + + // messages.getMessages по голому id (InputMessageID) — без привязки к peer. + InputMessage[] ids = [new InputMessageID { id = (int)msgId }]; + Messages_MessagesBase result = await RunTlCallAsync( + () => _client.Messages_GetMessages(ids), + cancellationToken).ConfigureAwait(false); + (MessageBase[] messages, Dictionary chats, Dictionary users) = UnpackMessages(result); + CacheEntities(chats.Values, users.Values); + + foreach (MessageBase message in messages) + { + TelegramMessage? mapped = TlMessageMapper.ToMessage(message, chats, users); + if (mapped is not null && mapped.Id == msgId && mapped.DialogId == dialogId) + { + return mapped; + } + } + + // Медиа без текста или удалённое сообщение — не найдено (found=false). + return null; + } + + /// + public async Task MarkReadAsync(string dialogId, CancellationToken cancellationToken) + { + InputPeer peer = await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false); + await RunTlCallAsync(() => _client.ReadHistory(peer), cancellationToken).ConfigureAwait(false); + } + + + /// + public async Task> SearchAsync( + string query, + int limit, + CancellationToken cancellationToken) + { + // contacts.SearchRequest(q, limit): результат — сущности chats/users (поиск каналов/групп/людей). + Contacts_Found found = await RunTlCallAsync(() => _client.Contacts_Search(query, limit), cancellationToken).ConfigureAwait(false); + CacheEntities(found.chats.Values, found.users.Values); + + var items = new List(found.chats.Count + found.users.Count); + foreach (ChatBase chat in found.chats.Values) + { + items.Add(TlMessageMapper.ToFoundChat(chat)); + } + + foreach (User user in found.users.Values) + { + items.Add(TlMessageMapper.ToFoundUser(user)); + } + + return items; + } + + /// + public async Task GetInfoAsync(string dialogId, CancellationToken cancellationToken) + { + TelegramSourceInfo unknown = DefaultSourceInfo(dialogId); + if (!TryParseSignedId(dialogId, out bool isChannel, out bool isChat, out bool isUser, out long rawId)) + { + return unknown; + } + + try + { + if (isChannel) + { + return await GetChannelInfoAsync(dialogId, rawId, unknown, cancellationToken).ConfigureAwait(false); + } + + if (isChat) + { + return await GetChatInfoAsync(dialogId, rawId, unknown, cancellationToken).ConfigureAwait(false); + } + + if (isUser) + { + return GetUserInfo(dialogId, rawId, unknown); + } + } + catch (SessionException) + { + return unknown; + } + + return unknown; + } + + /// + public async Task ReadForEvalAsync( + string dialogId, + int limit, + CancellationToken cancellationToken) + { + if (limit <= 0) + { + return DiscoveryReadResult.Empty; + } + + if (!TryParseSignedId(dialogId, out bool isChannel, out bool isChat, out bool isUser, out long rawId)) + { + return DiscoveryReadResult.NoHistory(); + } + + InputPeer peer; + try + { + peer = await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false); + } + catch (SessionException) + { + return DiscoveryReadResult.NoHistory(); + } + + if (isChannel && IsForumChannel(rawId)) + { + IReadOnlyList forumMessages = await ReadForumTopicsAsync(peer, limit, cancellationToken).ConfigureAwait(false); + if (forumMessages.Count > 0) + { + return new DiscoveryReadResult(true, null, forumMessages); + } + } + + // Обычная лента (каналы/группы; для форума — General-тема; форум без читаемых тем → тоже лента). + try + { + Messages_MessagesBase result = await RunTlCallAsync(() => _client.Messages_GetHistory(peer, limit: limit), cancellationToken).ConfigureAwait(false); + (MessageBase[] messages, Dictionary chats, Dictionary users) = UnpackMessages(result); + CacheEntities(chats.Values, users.Values); + return new DiscoveryReadResult(true, null, MapEvalMessages(messages)); + } + catch (SessionException) + { + return DiscoveryReadResult.NoHistory(); + } + } + + /// + public async Task JoinAsync(string username, CancellationToken cancellationToken) + { + Contacts_ResolvedPeer resolved = await RunTlCallAsync(() => _client.Contacts_ResolveUsername(username), cancellationToken).ConfigureAwait(false); + CacheEntities(resolved.chats.Values, resolved.users.Values); + + Channel? channel = resolved.chats.Values.OfType().FirstOrDefault(); + if (channel is null) + { + throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.JoinTargetNotChannel); + } + + await RunTlCallAsync(() => _client.Channels_JoinChannel(new InputChannel(channel.id, channel.access_hash)), cancellationToken).ConfigureAwait(false); + } + + /// + public async Task LeaveAsync(string dialogId, CancellationToken cancellationToken) + { + if (!TryParseSignedId(dialogId, out bool isChannel, out _, out _, out long rawId) || !isChannel) + { + throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId); + } + + InputChannel channel = await ResolveInputChannelAsync(dialogId, rawId, cancellationToken).ConfigureAwait(false); + await RunTlCallAsync(() => _client.Channels_LeaveChannel(channel), cancellationToken).ConfigureAwait(false); + } + + private async Task GetChannelInfoAsync( + string dialogId, + long rawId, + TelegramSourceInfo unknown, + CancellationToken cancellationToken) + { + Channel? cached; + lock (_entityCacheGate) + { + cached = _chatsById.TryGetValue(rawId, out ChatBase? chat) ? chat as Channel : null; + } + + if (cached is not null) + { + int? participants = await TryFetchChannelParticipantsAsync(dialogId, rawId, cancellationToken).ConfigureAwait(false); + return DescribeChannel(dialogId, cached, participants); + } + + InputChannel input = await ResolveInputChannelAsync(dialogId, rawId, cancellationToken).ConfigureAwait(false); + Messages_ChatFull full = await RunTlCallAsync(() => _client.Channels_GetFullChannel(input), cancellationToken).ConfigureAwait(false); + CacheEntities(full.chats.Values, full.users.Values); + + Channel? channel = full.chats.TryGetValue(rawId, out ChatBase? found) ? found as Channel : null; + if (channel is null) + { + return unknown; + } + + int? fullParticipants = (full.full_chat as ChannelFull)?.participants_count is int count && count > 0 ? count : null; + return DescribeChannel(dialogId, channel, fullParticipants); + } + + private async Task TryFetchChannelParticipantsAsync( + string dialogId, + long rawId, + CancellationToken cancellationToken) + { + try + { + InputChannel input = await ResolveInputChannelAsync(dialogId, rawId, cancellationToken).ConfigureAwait(false); + Messages_ChatFull full = await RunTlCallAsync(() => _client.Channels_GetFullChannel(input), cancellationToken).ConfigureAwait(false); + CacheEntities(full.chats.Values, full.users.Values); + return (full.full_chat as ChannelFull)?.participants_count is int count && count > 0 ? count : null; + } + catch (SessionException) + { + return null; + } + } + + // Инфо о базовой группе: entity из кэша/полного чата, участники — GetFullChat (только при членстве). + // dialogId: Подписанный id. + // rawId: Raw id группы (положительный). + // unknown: Инфо по умолчанию. + // cancellationToken: Отмена операции. + private async Task GetChatInfoAsync( + string dialogId, + long rawId, + TelegramSourceInfo unknown, + CancellationToken cancellationToken) + { + Chat? cached; + lock (_entityCacheGate) + { + cached = _chatsById.TryGetValue(rawId, out ChatBase? chat) ? chat as Chat : null; + } + + if (cached is not null) + { + int? participants = await TryFetchBasicParticipantsAsync(rawId, cancellationToken).ConfigureAwait(false); + return DescribeGroup(dialogId, cached, participants); + } + + Messages_ChatFull full = await RunTlCallAsync(() => _client.Messages_GetFullChat(rawId), cancellationToken).ConfigureAwait(false); + CacheEntities(full.chats.Values, full.users.Values); + + Chat? group = full.chats.TryGetValue(rawId, out ChatBase? found) ? found as Chat : null; + if (group is null) + { + return unknown; + } + + int? fullParticipants = full.full_chat is ChatFull fullChat && fullChat.participants is ChatParticipants members + ? members.participants.Length + : null; + return DescribeGroup(dialogId, group, fullParticipants); + } + + private async Task TryFetchBasicParticipantsAsync(long rawId, CancellationToken cancellationToken) + { + try + { + Messages_ChatFull full = await RunTlCallAsync(() => _client.Messages_GetFullChat(rawId), cancellationToken).ConfigureAwait(false); + CacheEntities(full.chats.Values, full.users.Values); + return full.full_chat is ChatFull fullChat && fullChat.participants is ChatParticipants members + ? members.participants.Length + : null; + } + catch (SessionException) + { + return null; + } + } + + // Собирает инфо базовой группы из entity (имя/username/kind group, без форума). + // dialogId: Подписанный id. + // group: Entity группы. + // participants: Число участников (null — определить не удалось). + private static TelegramSourceInfo DescribeGroup( + string dialogId, + Chat group, + int? participants) + => new( + dialogId, + DisplayName(dialogId, group), + group.MainUsername ?? string.Empty, + DialogKinds.Group, + participants, + isForum: false); + + private TelegramSourceInfo GetUserInfo( + string dialogId, + long rawId, + TelegramSourceInfo unknown) + { + User? user; + lock (_entityCacheGate) + { + _usersById.TryGetValue(rawId, out user); + } + + if (user is null) + { + return unknown; + } + + string name = (user.first_name + " " + user.last_name).Trim(); + string username = user.username ?? string.Empty; + if (name.Length == 0) + { + name = username.Length > 0 ? username : dialogId; + } + + return new TelegramSourceInfo(dialogId, name, username, DialogKinds.Chat, participants: null, isForum: false); + } + + private async Task> ReadForumTopicsAsync( + InputPeer peer, + int limit, + CancellationToken cancellationToken) + { + var outMessages = new List(); + try + { + Messages_ForumTopics forum = await RunTlCallAsync( + () => _client.Messages_GetForumTopics(peer, offset_date: default, offset_id: 0, offset_topic: 0, limit: ForumTopicsLimit), + cancellationToken).ConfigureAwait(false); + CacheEntities(forum.chats.Values, forum.users.Values); + + ForumTopic[] topics = forum.topics.OfType().ToArray(); + if (topics.Length == 0) + { + return outMessages; + } + + int perTopic = Math.Min(Math.Max(3, (int)Math.Ceiling(limit / (double)topics.Length)), ForumMessagesPerTopicCap); + DateTimeOffset now = DateTimeOffset.UtcNow; + foreach (ForumTopic topic in topics) + { + try + { + Messages_MessagesBase result = await RunTlCallAsync( + () => _client.Messages_GetReplies(peer, topic.id, limit: perTopic), + cancellationToken).ConfigureAwait(false); + (MessageBase[] messages, Dictionary chats, Dictionary users) = UnpackMessages(result); + CacheEntities(chats.Values, users.Values); + foreach (MessageBase message in messages) + { + DiscoveryMessage? item = TlMessageMapper.ToEvalMessage(message, topic.id, topic.title, now); + if (item is not null) + { + outMessages.Add(item); + } + } + } + catch (SessionException) + { + } + } + } + catch (SessionException) + { + outMessages.Clear(); + } + + return outMessages; + } + + // Маппит сообщения ленты/тем в нейтральные (только непустые тексты, темы пусты). + // messages: Сообщения выборки (MessageBase). + private static IReadOnlyList MapEvalMessages(MessageBase[] messages) + { + var items = new List(messages.Length); + DateTimeOffset now = DateTimeOffset.UtcNow; + foreach (MessageBase message in messages) + { + DiscoveryMessage? item = TlMessageMapper.ToEvalMessage(message, null, null, now); + if (item is not null) + { + items.Add(item); + } + } + + return items; + } + + // Инфо по умолчанию (сущность недоступна): name=id, username/kind пусты, без участников. + // dialogId: Подписанный id источника. + private static TelegramSourceInfo DefaultSourceInfo(string dialogId) + => new(dialogId, dialogId, string.Empty, string.Empty, participants: null, isForum: false); + + // Собирает инфо канала из entity полного чата (имя/username/kind/forum + участники). + // dialogId: Подписанный id. + // channel: Entity канала из ответа полного чата. + // participants: Число участников (full_chat) либо null. + private static TelegramSourceInfo DescribeChannel( + string dialogId, + Channel channel, + int? participants) + => new( + dialogId, + DisplayName(dialogId, channel), + channel.MainUsername ?? string.Empty, + TlMessageMapper.KindOf(channel), + participants, + isForum: (channel.flags & Channel.Flags.forum) != 0); + + private static string DisplayName(string dialogId, ChatBase chat) + { + string title = chat.Title ?? string.Empty; + return title.Length > 0 ? title : dialogId; + } + + private bool IsForumChannel(long rawId) + { + lock (_entityCacheGate) + { + return _chatsById.TryGetValue(rawId, out ChatBase? chat) + && chat is Channel channel + && (channel.flags & Channel.Flags.forum) != 0; + } + } + + private const int ForumTopicsLimit = 5; + + private const int ForumMessagesPerTopicCap = 10; + + + private async Task OnSingleUpdateAsync(Update update) + { + try + { + // TlMessageMapper.NewMessageFrom: UpdateNewMessage/UpdateNewChannelMessage (каналы — + // подкласс) и синтезированные из коротких вариантов; прочие обновления игнорируются. + MessageBase? message = TlMessageMapper.NewMessageFrom(update); + if (message is null) + { + return; + } + + // UpdateManager собрал сущности сообщения в коллектор (Users/Chats) до вызова колбэка — + // имя/username диалога и access_hash (для read-ack) доступны без сетевых запросов. + Dictionary chats = _updateManager.Chats; + Dictionary users = _updateManager.Users; + TelegramMessage? item = TlMessageMapper.ToMessage(message, chats, users); + if (item is not null) + { + await DispatchMessageAsync(item).ConfigureAwait(false); + } + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + // Сбой обработки обновления не должен останавливать realtime; ошибки подписчиков + // обрабатывает TenantSession (логгер есть на уровне сессии). + } + } + + // Последовательно вызывает подписчиков события нового сообщения. + // message: Входящее сообщение диалога. + private async Task DispatchMessageAsync(TelegramMessage message) + { + Func? handler = MessageReceived; + if (handler is null) + { + return; + } + + foreach (Delegate subscriber in handler.GetInvocationList()) + { + try + { + await ((Func)subscriber)(message).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + // Исключение подписчика (например, ядро недоступно) логирует сам подписчик + // (TenantSession) и не влияет на остальных. + } + } + } + + // Возвращает значения конфигурации WTelegramClient (api_id/api_hash; остальное — по умолчанию). + // what: Запрашиваемый библиотекой ключ конфигурации. + private string? ConfigProvider(string what) + => what switch + { + "api_id" => _apiId.ToString(System.Globalization.CultureInfo.InvariantCulture), + "api_hash" => _apiHash, + _ => null, + }; + + // Колбэк сохранения сессии библиотекой: держим последние байты в памяти процесса. + // sessionBytes: Новые байты сессии (готовый самодостаточный блок). + private void OnSessionSaved(byte[] sessionBytes) + => Volatile.Write(ref _latestSessionBytes, sessionBytes); + + private async Task SendCodeOnceAsync(string phone, CancellationToken cancellationToken) + { + try + { + return await _client.Auth_SendCode(phone, _apiId, _apiHash, new CodeSettings()).WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (RpcException exception) when (exception.Code == 500 && exception.Message == "AUTH_RESTART") + { + return await _client.Auth_SendCode(phone, _apiId, _apiHash, new CodeSettings()).WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (RpcException exception) + { + throw MapRpcException(exception); + } + } + + // Завершает авторизацию по объекту Auth_AuthorizationBase: регистрирует пользователя в сессии + // (LoginAlreadyDone сохраняет сессию — колбэк OnSessionSaved обновит байты для файла). + // authorization: Ответ Auth_SignIn/Auth_CheckPassword. + private void CompleteAuthorization(Auth_AuthorizationBase authorization) + { + if (authorization is Auth_AuthorizationSignUpRequired) + { + throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.SignUpRequired); + } + + try + { + _client.LoginAlreadyDone(authorization); + } + catch (WTException exception) + { + throw new SessionException(StatusCode.Internal, exception.Message, exception); + } + } + + private static SessionException MapRpcException(RpcException exception) + { + if (exception.Message.StartsWith("FLOOD_WAIT_", StringComparison.Ordinal)) + { + return new SessionException(StatusCode.ResourceExhausted, "flood: " + exception.Message, exception); + } + + if (exception.Code == 400) + { + return new SessionException(StatusCode.InvalidArgument, exception.Message, exception); + } + + return new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception); + } + + + // Исполняет TL-вызов с единым переводом ошибок (RpcException Telegram → SessionException; + // прочие сбои — UNAVAILABLE «Telegram недоступен…»). + // TResult: Тип результата TL-вызова. + // call: Фабрика задачи TL-вызова (методы библиотеки токенов не принимают). + // cancellationToken: Отмена операции. + private async Task RunTlCallAsync(Func> call, CancellationToken cancellationToken) + { + try + { + return await call().WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (RpcException exception) + { + throw MapRpcException(exception); + } + catch (Exception exception) + { + throw new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception); + } + } + + // Разворачивает результат messages.getDialogs в диалоги и словари сущностей. + // result: Результат TL-вызова списка диалогов. + private static (DialogBase[] Dialogs, Dictionary Chats, Dictionary Users) UnpackDialogs(Messages_DialogsBase result) + => result switch + { + // Slice наследует Messages_Dialogs — сначала конкретный тип (иначе ветка недостижима). + Messages_DialogsSlice slice => (slice.dialogs, slice.chats, slice.users), + Messages_Dialogs dialogs => (dialogs.dialogs, dialogs.chats, dialogs.users), + _ => throw new SessionException(StatusCode.Internal, "Telegram вернул неподдерживаемый список диалогов"), + }; + + // Разворачивает результат messages.getHistory в сообщения и словари сущностей. + // result: Результат TL-вызова истории. + private static (MessageBase[] Messages, Dictionary Chats, Dictionary Users) UnpackMessages(Messages_MessagesBase result) + => result switch + { + Messages_Messages messages => (messages.messages, messages.chats, messages.users), + Messages_ChannelMessages channelMessages => (channelMessages.messages, channelMessages.chats, channelMessages.users), + // Прочие варианты (rare/новые слои) — сообщения отдаёт базовый доступор, сущностей нет: + // имена диалогов в таких сообщениях будут подменены id (кадр редкий, реальный поток не страдает). + _ => (result.Messages, new Dictionary(), new Dictionary()), + }; + + // Кэширует access_hash и сущности из ответа (для InputPeer по id и имён/forum discovery). + // chats: Каналы/группы ответа (по raw id). + // users: Пользователи ответа (по raw id). + private void CacheEntities(IEnumerable chats, IEnumerable users) + { + lock (_entityCacheGate) + { + foreach (ChatBase chat in chats) + { + switch (chat) + { + case Channel channel: + _entityAccessHashes.Set("-100" + channel.id.ToString(CultureInfo.InvariantCulture), channel.access_hash); + _chatsById.Set(channel.id, channel); + break; + case Chat group: + _chatsById.Set(group.id, group); + break; + } + } + + foreach (UserBase user in users) + { + if (user is User regularUser) + { + _entityAccessHashes.Set("+" + regularUser.id.ToString(CultureInfo.InvariantCulture), regularUser.access_hash); + _usersById.Set(regularUser.id, regularUser); + } + } + } + } + + // Строит InputPeer диалога; при пустом кэше — доливка списком диалогов (первая страница). + // dialogId: Подписанный id диалога. + // cancellationToken: Отмена операции. + private async Task ResolvePeerAsync(string dialogId, CancellationToken cancellationToken) + { + if (!TryParseSignedId(dialogId, out bool isChannel, out bool isChat, out bool isUser, out long rawId)) + { + throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId); + } + + InputPeer? peer = BuildPeer(isChannel, isChat, isUser, rawId); + if (peer is not null) + { + return peer; + } + + // Сущность может быть уже собрана коллектором UpdateManager (realtime-сообщение пришло до + // списка диалогов) — access_hash берётся оттуда без сетевых запросов. + if (TryImportPeerFromCollector(isChannel, isUser, rawId)) + { + InputPeer? imported = BuildPeer(isChannel, isChat, isUser, rawId); + if (imported is not null) + { + return imported; + } + } + + // Канал/пользователь без access_hash (рестарт/новый источник): доливаем сущности + // первой страницей списка диалогов — каталог ядра строится из неё же (лимит 500, как refresh). + await GetDialogsAsync(DialogResolvePageSize, cancellationToken).ConfigureAwait(false); + return BuildPeer(isChannel, isChat, isUser, rawId) + ?? throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.UnknownDialog); + } + + // InputChannel по подписанному id канала (join/leave/полный чат); как ResolvePeerAsync. + // dialogId: Подписанный id канала («-100…»). + // rawId: Raw id канала. + // cancellationToken: Отмена операции. + private async Task ResolveInputChannelAsync( + string dialogId, + long rawId, + CancellationToken cancellationToken) + { + lock (_entityCacheGate) + { + if (_entityAccessHashes.TryGetValue(dialogId, out long cached)) + { + return new InputChannel(rawId, cached); + } + } + + await GetDialogsAsync(DialogResolvePageSize, cancellationToken).ConfigureAwait(false); + lock (_entityCacheGate) + { + return _entityAccessHashes.TryGetValue(dialogId, out long accessHash) + ? new InputChannel(rawId, accessHash) + : throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.UnknownDialog); + } + } + + // Импортирует access_hash сущности из коллектора UpdateManager в кэш (базовая группа хеша не + // требует — возвращает true сразу). False — сущности в коллекторе нет или хеш нулевой (min-entity). + // isChannel: Признак канала («-100…»). + // isUser: Признак личного чата («+…»). + // rawId: Raw id (канала/пользователя). + private bool TryImportPeerFromCollector( + bool isChannel, + bool isUser, + long rawId) + { + if (!isChannel && !isUser) + { + return true; + } + + string signedId = (isChannel ? "-100" : "+") + rawId.ToString(CultureInfo.InvariantCulture); + lock (_entityCacheGate) + { + if (_entityAccessHashes.ContainsKey(signedId)) + { + return true; + } + } + + long accessHash = 0; + if (isChannel + && _updateManager.Chats.TryGetValue(rawId, out ChatBase? chat) + && chat is Channel channel) + { + accessHash = channel.access_hash; + } + else if (isUser && _updateManager.Users.TryGetValue(rawId, out User? user)) + { + accessHash = user.access_hash; + } + + if (accessHash == 0) + { + return false; + } + + lock (_entityCacheGate) + { + _entityAccessHashes.Set(signedId, accessHash); + } + + return true; + } + + // Строит InputPeer по разобранному id и access_hash из кэша (null — сущность неизвестна). + // isChannel: Признак канала («-100…»). + // isChat: Признак базовой группы («-…»). + // isUser: Признак личного чата («+…»). + // rawId: Raw id (канала/группы/пользователя). + private InputPeer? BuildPeer( + bool isChannel, + bool isChat, + bool isUser, + long rawId) + { + if (isChat) + { + return new InputPeerChat(rawId); + } + + string signedId = (isChannel ? "-100" : "+") + rawId.ToString(CultureInfo.InvariantCulture); + long accessHash; + lock (_entityCacheGate) + { + if (!_entityAccessHashes.TryGetValue(signedId, out accessHash)) + { + return null; + } + } + + return isChannel + ? new InputPeerChannel(rawId, accessHash) + : new InputPeerUser(rawId, accessHash); + } + + // Разбирает подписанный id канона контракта («-100…» канал, «-…» группа, «+…» личный). + // dialogId: Подписанный id. + // isChannel: True — канал. + // isChat: True — базовая группа. + // isUser: True — личный чат. + // rawId: Raw id без префикса. + private static bool TryParseSignedId( + string dialogId, + out bool isChannel, + out bool isChat, + out bool isUser, + out long rawId) + { + isChannel = false; + isChat = false; + isUser = false; + rawId = 0; + + string digits; + if (dialogId.StartsWith("-100", StringComparison.Ordinal)) + { + digits = dialogId[4..]; + isChannel = true; + } + else if (dialogId.StartsWith('-')) + { + digits = dialogId[1..]; + isChat = true; + } + else if (dialogId.StartsWith('+')) + { + digits = dialogId[1..]; + isUser = true; + } + else + { + return false; + } + + return digits.Length > 0 && long.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out rawId); + } + + // Размер страницы списка диалогов для доливки кэша сущностей (как refresh_dialogs limit). + private const int DialogResolvePageSize = 500; +} +#pragma warning restore CS0618 diff --git a/src/telegram-service/Deal.Telegram/TelegramServiceHost.cs b/src/telegram-service/Deal.Telegram/TelegramServiceHost.cs index c4a59c6..69a8447 100644 --- a/src/telegram-service/Deal.Telegram/TelegramServiceHost.cs +++ b/src/telegram-service/Deal.Telegram/TelegramServiceHost.cs @@ -1,72 +1,72 @@ -using Deal.Grpc.Hosting.Interceptors; -using Deal.Grpc.Hosting.Models; -using Deal.Grpc.Hosting.Options; -using Deal.Grpc.Hosting.Services; -using Deal.Telegram.Core; -using Deal.Telegram.Dialogs; -using Deal.Telegram.Discovery; -using Deal.Telegram.Hosting; -using Deal.Telegram.Sessions; -using Deal.Telegram.Telegram; - -namespace Deal.Telegram; - -/// -/// Собирает WebApplication gRPC-хоста telegram-service. -/// -public static class TelegramServiceHost -{ - /// - /// Создаёт (не запускает) хост - /// - /// TCP-порт Kestrel. - /// Аргументы командной строки (Program.cs); в тестах не нужны. - /// Опциональный хук DI для тестов (подмена зависимостей фейками, напр. ITelegramClientFactory). - /// Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog. Тесты хост поднимают БЕЗ этого хука — логирование файлов/консоли тестам не нужно. - /// Собранный хост; запуск — StartAsync/RunAsync у вызывающего. - public static WebApplication Create( - int grpcPort, - string[]? args = null, - Action? configureServices = null, - Action? configureBuilder = null) - { - WebApplicationBuilder builder = WebApplication.CreateBuilder(args ?? []); - - MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder); - GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates); - builder.Services.AddDealGrpcServer(); - builder.Services.AddReadyHealthCheck("хост telegram-service готов"); - - TgOptions sessionOptions = TgOptions.FromConfiguration(builder.Configuration, builder.Environment); - builder.Services.AddSingleton(sessionOptions); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddHostedService(); - - CoreIngressOptions ingressOptions = CoreIngressOptions.FromConfiguration(builder.Configuration); - builder.Services.AddSingleton(ingressOptions); - builder.Services.AddSingleton(provider => new CoreIngressClient( - ingressOptions, - provider.GetRequiredService>(), - mtlsCertificates)); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - - configureServices?.Invoke(builder.Services); - configureBuilder?.Invoke(builder); - - WebApplication app = builder.Build(); - - app.MapGrpcService(); - app.MapGrpcHealthChecksService(); - - return app; - } -} +using Deal.Grpc.Hosting.Interceptors; +using Deal.Grpc.Hosting.Models; +using Deal.Grpc.Hosting.Options; +using Deal.Grpc.Hosting.Services; +using Deal.Telegram.Core; +using Deal.Telegram.Dialogs; +using Deal.Telegram.Discovery; +using Deal.Telegram.Hosting; +using Deal.Telegram.Sessions; +using Deal.Telegram.Telegram; + +namespace Deal.Telegram; + +/// +/// Собирает WebApplication gRPC-хоста telegram-service. +/// +public static class TelegramServiceHost +{ + /// + /// Создаёт (не запускает) хост + /// + /// TCP-порт Kestrel. + /// Аргументы командной строки (Program.cs); в тестах не нужны. + /// Опциональный хук DI для тестов (подмена зависимостей фейками, напр. ITelegramClientFactory). + /// Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog. Тесты хост поднимают БЕЗ этого хука — логирование файлов/консоли тестам не нужно. + /// Собранный хост; запуск — StartAsync/RunAsync у вызывающего. + public static WebApplication Create( + int grpcPort, + string[]? args = null, + Action? configureServices = null, + Action? configureBuilder = null) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(args ?? []); + + MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder); + GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates); + builder.Services.AddDealGrpcServer(); + builder.Services.AddReadyHealthCheck("хост telegram-service готов"); + + TgOptions sessionOptions = TgOptions.FromConfiguration(builder.Configuration, builder.Environment); + builder.Services.AddSingleton(sessionOptions); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(); + + CoreIngressOptions ingressOptions = CoreIngressOptions.FromConfiguration(builder.Configuration); + builder.Services.AddSingleton(ingressOptions); + builder.Services.AddSingleton(provider => new CoreIngressClient( + ingressOptions, + provider.GetRequiredService>(), + mtlsCertificates)); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + + configureServices?.Invoke(builder.Services); + configureBuilder?.Invoke(builder); + + WebApplication app = builder.Build(); + + app.MapGrpcService(); + app.MapGrpcHealthChecksService(); + + return app; + } +} diff --git a/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs b/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs index 7dc2eab..7e7c24b 100644 --- a/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs +++ b/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs @@ -1,549 +1,549 @@ -using Deal.Grpc.Telegram; -using Deal.Telegram.Core; -using Deal.Telegram.Dialogs; -using Deal.Telegram.Discovery; -using Deal.Telegram.Extensions; -using Deal.Telegram.Sessions; -using Deal.Telegram.Telegram; -using Grpc.Core; - -namespace Deal.Telegram; - -/// -/// Реализация серверной стороны Deal.Grpc.Telegram.TelegramService — команды core → telegram-service. -/// -public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase -{ - public const string TenantIdMetadataKey = "tenant-id"; - - private const int DialogListLimit = 500; - - private const int PreviewDefaultLimit = 24; - - // Максимальный лимит превью (контракт ReadRecentRequest: 1..50). - private const int PreviewMaxLimit = 50; - - // Верхняя граница поискового запроса (ключ discovery; защита границы сервиса). - private const int MaxSearchQueryLength = 200; - - // Верхняя граница username вступления (лимит Telegram: 5..32 символа). - private const int MaxUsernameLength = 32; - - private readonly SessionFarm _sessionFarm; - private readonly DialogCatalog _catalog; - private readonly ICoreIngressClient _ingress; - private readonly BackfillService _backfill; - private readonly DiscoveryOps _discovery; - private readonly ILogger _logger; - - /// - /// Создаёт сервис команд core. - /// - /// Пул сессий тенантов. - /// Зеркало каталога/мониторинга диалогов. - /// Исходящий канал в ядро (SyncDialogs — актуализация зеркала). - /// Backfill последних сообщений диалога. - /// Discovery-операции (поиск/инфо/чтение/join/leave). - /// Логгер. - public TelegramServiceImpl( - SessionFarm sessionFarm, - DialogCatalog catalog, - ICoreIngressClient ingress, - BackfillService backfill, - DiscoveryOps discovery, - ILogger logger) - { - _sessionFarm = sessionFarm; - _catalog = catalog; - _ingress = ingress; - _backfill = backfill; - _discovery = discovery; - _logger = logger; - } - - - /// - /// GetStatus — статус и фаза входа аккаунта тенанта. - /// - public override async Task GetStatus(GetStatusRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - - TenantSessionSnapshot? snapshot = await _sessionFarm.GetStatusAsync(tenantId, context.CancellationToken).ConfigureAwait(false); - if (snapshot is null) - { - throw ToRpc(new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.NotConnected)); - } - - var reply = new GetStatusReply - { - Phase = PhaseToString(snapshot.Phase), - Connected = snapshot.Connected, - Listener = snapshot.Listener, - Account = snapshot.Account ?? string.Empty, - }; - - if (snapshot.Error is not null) - { - reply.Error = snapshot.Error; - } - - if (snapshot.QrUrl is not null) - { - reply.QrUrl = snapshot.QrUrl; - } - - return reply; - } - - /// - /// StartPhone — запросить код по номеру телефона. - /// - public override async Task StartPhone(StartPhoneRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - - TenantSessionSnapshot snapshot = await ExecuteAsync( - tenantId, - ct => _sessionFarm.StartPhoneAsync(tenantId, request.ApiId, request.ApiHash, request.Phone, ct), - "start_phone", - context).ConfigureAwait(false); - - return new StartPhoneReply { Phase = PhaseToString(snapshot.Phase) }; - } - - /// - /// StartQr — начать вход по QR - /// - public override async Task StartQr(StartQrRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - - TenantSessionSnapshot snapshot = await ExecuteAsync( - tenantId, - ct => _sessionFarm.StartQrAsync(tenantId, request.ApiId, request.ApiHash, ct), - "start_qr", - context).ConfigureAwait(false); - - var reply = new StartQrReply { Phase = PhaseToString(snapshot.Phase) }; - if (snapshot.QrUrl is not null) - { - reply.QrUrl = snapshot.QrUrl; - } - - return reply; - } - - /// - /// SendCode — отправить SMS-код входа. - /// - public override async Task SendCode(SendCodeRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - - TenantSessionSnapshot snapshot = await ExecuteAsync( - tenantId, - ct => _sessionFarm.SendCodeAsync(tenantId, request.Code, ct), - "send_code", - context).ConfigureAwait(false); - - return new SendCodeReply { Phase = PhaseToString(snapshot.Phase) }; - } - - /// - /// SendPassword — облачный пароль 2FA. - /// - public override async Task SendPassword(SendPasswordRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - - TenantSessionSnapshot snapshot = await ExecuteAsync( - tenantId, - ct => _sessionFarm.SendPasswordAsync(tenantId, request.Password, ct), - "send_password", - context).ConfigureAwait(false); - - return new SendPasswordReply { Phase = PhaseToString(snapshot.Phase) }; - } - - /// - /// Logout — отключить аккаунт, удалить сессию тенанта. - /// - public override async Task Logout(LogoutRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - - await ExecuteAsync( - tenantId, - ct => _sessionFarm.LogoutAsync(tenantId, ct), - "logout", - context).ConfigureAwait(false); - - _catalog.Reset(tenantId); - return new LogoutReply { Ok = true }; - } - - - /// - /// RefreshDialogs — актуальный каталог диалогов аккаунта. - /// - public override async Task RefreshDialogs(RefreshDialogsRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - - RefreshDialogsReply reply = await ExecuteAsync( - tenantId, - async ct => - { - IReadOnlyList dialogs = await _sessionFarm - .ListDialogsAsync(tenantId, DialogListLimit, ct) - .ConfigureAwait(false); - - List entries = dialogs.Select(DialogProtoMapper.ToEntry).ToList(); - _catalog.ReplaceKnown(tenantId, dialogs.Select(dialog => dialog.Id).ToList()); - - await SyncCatalogSilentlyAsync(tenantId, entries, ct).ConfigureAwait(false); - - var result = new RefreshDialogsReply(); - result.Entries.AddRange(entries); - return result; - }, - "refresh_dialogs", - context).ConfigureAwait(false); - return reply; - } - - /// - /// SetMonitor — включить/выключить мониторинг диалога. - /// - public override Task SetMonitor(SetMonitorRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - RequireDialogId(request.DialogId); - - _catalog.SetMonitored(tenantId, request.DialogId, request.Enabled); - _logger.LogInformation( - "Аудит: tenant {TenantId} set_monitor {DialogId} → {Enabled}", - tenantId, - request.DialogId, - request.Enabled); - return Task.FromResult(new SetMonitorReply { Ok = true, Enabled = request.Enabled }); - } - - /// - /// SetMonitorAll — мониторинг всех диалогов каталога. - /// - public override Task SetMonitorAll(SetMonitorAllRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - - int count = _catalog.SetAllMonitored(tenantId, request.Enabled); - _logger.LogInformation( - "Аудит: tenant {TenantId} set_monitor_all → {Enabled} (каталог {Count})", - tenantId, - request.Enabled, - count); - return Task.FromResult(new SetMonitorAllReply { Ok = true, Count = count, Enabled = request.Enabled }); - } - - /// - /// Backfill — перечитать последние сообщения диалога в ядро. - /// - public override async Task Backfill(BackfillRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - RequireDialogId(request.DialogId); - - int processed = await ExecuteAsync( - tenantId, - ct => _backfill.ExecuteAsync(tenantId, request.DialogId, request.Force, ct), - "backfill", - context).ConfigureAwait(false); - return new BackfillReply { Processed = processed }; - } - - /// - /// ReadRecent — последние сообщения диалога для превью. - /// - public override async Task ReadRecent(ReadRecentRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - RequireDialogId(request.DialogId); - int limit = request.Limit <= 0 ? PreviewDefaultLimit : Math.Min(request.Limit, PreviewMaxLimit); - - ReadRecentReply reply = await ExecuteAsync( - tenantId, - async ct => - { - IReadOnlyList messages = await _sessionFarm - .GetMessagesAsync(tenantId, request.DialogId, limit, ct) - .ConfigureAwait(false); - - var result = new ReadRecentReply(); - result.Messages.AddRange(messages.Select(DialogProtoMapper.ToPreview)); - - await _sessionFarm.MarkReadAsync(tenantId, request.DialogId, ct).ConfigureAwait(false); - return result; - }, - "read_recent", - context).ConfigureAwait(false); - return reply; - } - - - /// - /// ReadSource — исходное сообщение источника по id (remote-просмотр). - /// - public override async Task ReadSource(ReadSourceRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - RequireDialogId(request.DialogId); - if (request.MsgId <= 0) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, SessionErrorMessages.InvalidMessageId)); - } - - ReadSourceReply reply = await ExecuteAsync( - tenantId, - async ct => - { - Telegram.TelegramMessage? message = await _sessionFarm - .GetMessageAsync(tenantId, request.DialogId, request.MsgId, ct) - .ConfigureAwait(false); - - if (message is null) - { - return new ReadSourceReply { Found = false }; - } - - return new ReadSourceReply - { - Found = true, - Text = message.Text, - Time = message.DateMs, - }; - }, - "read_source", - context).ConfigureAwait(false); - return reply; - } - - - /// - /// Search — глобальный поиск каналов/групп по ключу. - /// - public override async Task Search(SearchRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - RequireSearchQueryWithinBounds(request.Query); - - SearchReply reply = await ExecuteAsync( - tenantId, - async ct => - { - IReadOnlyList found = await _discovery - .SearchAsync(tenantId, request.Query, request.Limit, ct) - .ConfigureAwait(false); - - var result = new SearchReply(); - result.Results.AddRange(found.Select(DialogProtoMapper.ToEntry)); - return result; - }, - "search", - context).ConfigureAwait(false); - return reply; - } - - /// - /// GetInfo — инфо об источнике для оценки кандидата. - /// - public override async Task GetInfo(GetInfoRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - RequireDialogId(request.DialogId); - - GetInfoReply reply = await ExecuteAsync( - tenantId, - async ct => - { - TelegramSourceInfo info = await _discovery - .GetInfoAsync(tenantId, request.DialogId, ct) - .ConfigureAwait(false); - return new GetInfoReply { Info = DiscoveryProtoMapper.ToChannelInfo(info) }; - }, - "discovery_info", - context).ConfigureAwait(false); - return reply; - } - - /// - /// ReadForEval — выборка сообщений источника для оценки. - /// - public override async Task ReadForEval(ReadForEvalRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - RequireDialogId(request.DialogId); - - ReadForEvalReply reply = await ExecuteAsync( - tenantId, - async ct => - { - DiscoveryReadResult result = await _discovery - .ReadForEvalAsync(tenantId, request.DialogId, request.Limit, ct) - .ConfigureAwait(false); - return DiscoveryProtoMapper.ToReadForEvalReply(result); - }, - "discovery_read", - context).ConfigureAwait(false); - return reply; - } - - /// - /// Join — вступить в канал/группу по @username. - /// - public override async Task Join(JoinRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - string normalizedUsername = DiscoveryOps.NormalizeUsername(request.Username); - RequireUsernameWithinBounds(normalizedUsername); - - await ExecuteAsync( - tenantId, - async ct => - { - await _discovery.JoinAsync(tenantId, normalizedUsername, ct).ConfigureAwait(false); - return true; - }, - "join", - context).ConfigureAwait(false); - return new JoinReply { Ok = true }; - } - - /// - /// Leave — выйти из канала/группы. - /// - public override async Task Leave(LeaveRequest request, ServerCallContext context) - { - string tenantId = RequireTenantId(context); - RequireDialogId(request.DialogId); - - await ExecuteAsync( - tenantId, - async ct => - { - await _discovery.LeaveAsync(tenantId, request.DialogId, ct).ConfigureAwait(false); - return true; - }, - "leave", - context).ConfigureAwait(false); - return new LeaveReply { Ok = true }; - } - - private static string RequireTenantId(ServerCallContext context) - { - string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey); - if (string.IsNullOrWhiteSpace(tenantId)) - { - throw new RpcException(new Status(StatusCode.Unauthenticated, SessionErrorMessages.TenantIdMissing)); - } - - return tenantId; - } - - // Id диалога обязателен в командах каталога (пустое — INVALID_ARGUMENT). - // dialogId: Id диалога из запроса. - private static void RequireDialogId(string dialogId) - { - if (string.IsNullOrWhiteSpace(dialogId)) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId)); - } - } - - // Поисковый запрос ограничен сверху (INVALID_ARGUMENT; защита границы сервиса). - // query: Поисковый запрос. - private static void RequireSearchQueryWithinBounds(string query) - { - if (query.Trim().Length > MaxSearchQueryLength) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, SessionErrorMessages.SearchQueryTooLong)); - } - } - - // Username вступления ограничен сверху (INVALID_ARGUMENT; лимит Telegram 5..32). - // username: Нормализованный username (без «@»/пробелов). - private static void RequireUsernameWithinBounds(string username) - { - if (username.Length > MaxUsernameLength) - { - throw new RpcException(new Status(StatusCode.InvalidArgument, SessionErrorMessages.UsernameTooLong)); - } - } - - private async Task SyncCatalogSilentlyAsync( - string tenantId, - IReadOnlyList entries, - CancellationToken cancellationToken) - { - try - { - IReadOnlyList monitored = await _ingress.SyncDialogsAsync(tenantId, entries, cancellationToken).ConfigureAwait(false); - _catalog.ReplaceMonitored(tenantId, monitored); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogWarning(exception, "Аудит: tenant {TenantId} sync_dialogs → сбой (зеркало прежнее; догонит sweep)", tenantId); - } - } - - private async Task ExecuteAsync( - string tenantId, - Func> operation, - string action, - ServerCallContext context) - { - try - { - TResult result = await operation(context.CancellationToken).ConfigureAwait(false); - _logger.LogInformation("Аудит: tenant {TenantId} {Action} → ok", tenantId, action); - return result; - } - catch (SessionException exception) - { - _logger.LogInformation("Аудит: tenant {TenantId} {Action} → ошибка {Error}", tenantId, action, exception.Message); - throw ToRpc(exception); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogError(exception, "Аудит: tenant {TenantId} {Action} → сбой", tenantId, action); - throw ToRpc(ToSessionFailure(exception)); - } - } - - // Переводит не-SessionException в доменную ошибку: транспорт/сеть — UNAVAILABLE («Telegram недоступен…»), - // внутренние сбои реализации (NRE/InvalidOperation и пр.) — INTERNAL. Раньше любые ошибки маскировались - // UNAVAILABLE (замечание code-review): внутренние дефекты должны быть видны как Internal. - // exception: Необработанное исключение операции. - private static SessionException ToSessionFailure(Exception exception) - => exception.IsTransportFailure() - ? new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception) - : new SessionException(StatusCode.Internal, SessionErrorMessages.InternalError, exception); - - // Фаза AuthPhase → строка канона контракта (idle|phone|code|password|qr|ready). - // phase: Фаза сессии. - private static string PhaseToString(AuthPhase phase) - => phase switch - { - AuthPhase.Phone => "phone", - AuthPhase.Code => "code", - AuthPhase.Password => "password", - AuthPhase.Qr => "qr", - AuthPhase.Ready => "ready", - _ => "idle", - }; - - // Доменная ошибка сессии → RpcException контракта (код + detail = текст причины). - // exception: Ошибка сессии. - private static RpcException ToRpc(SessionException exception) - => new(new Status(exception.Code, exception.Message)); -} +using Deal.Grpc.Telegram; +using Deal.Telegram.Core; +using Deal.Telegram.Dialogs; +using Deal.Telegram.Discovery; +using Deal.Telegram.Extensions; +using Deal.Telegram.Sessions; +using Deal.Telegram.Telegram; +using Grpc.Core; + +namespace Deal.Telegram; + +/// +/// Реализация серверной стороны Deal.Grpc.Telegram.TelegramService — команды core → telegram-service. +/// +public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase +{ + public const string TenantIdMetadataKey = "tenant-id"; + + private const int DialogListLimit = 500; + + private const int PreviewDefaultLimit = 24; + + // Максимальный лимит превью (контракт ReadRecentRequest: 1..50). + private const int PreviewMaxLimit = 50; + + // Верхняя граница поискового запроса (ключ discovery; защита границы сервиса). + private const int MaxSearchQueryLength = 200; + + // Верхняя граница username вступления (лимит Telegram: 5..32 символа). + private const int MaxUsernameLength = 32; + + private readonly SessionFarm _sessionFarm; + private readonly DialogCatalog _catalog; + private readonly ICoreIngressClient _ingress; + private readonly BackfillService _backfill; + private readonly DiscoveryOps _discovery; + private readonly ILogger _logger; + + /// + /// Создаёт сервис команд core. + /// + /// Пул сессий тенантов. + /// Зеркало каталога/мониторинга диалогов. + /// Исходящий канал в ядро (SyncDialogs — актуализация зеркала). + /// Backfill последних сообщений диалога. + /// Discovery-операции (поиск/инфо/чтение/join/leave). + /// Логгер. + public TelegramServiceImpl( + SessionFarm sessionFarm, + DialogCatalog catalog, + ICoreIngressClient ingress, + BackfillService backfill, + DiscoveryOps discovery, + ILogger logger) + { + _sessionFarm = sessionFarm; + _catalog = catalog; + _ingress = ingress; + _backfill = backfill; + _discovery = discovery; + _logger = logger; + } + + + /// + /// GetStatus — статус и фаза входа аккаунта тенанта. + /// + public override async Task GetStatus(GetStatusRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + + TenantSessionSnapshot? snapshot = await _sessionFarm.GetStatusAsync(tenantId, context.CancellationToken).ConfigureAwait(false); + if (snapshot is null) + { + throw ToRpc(new SessionException(StatusCode.FailedPrecondition, SessionErrorMessages.NotConnected)); + } + + var reply = new GetStatusReply + { + Phase = PhaseToString(snapshot.Phase), + Connected = snapshot.Connected, + Listener = snapshot.Listener, + Account = snapshot.Account ?? string.Empty, + }; + + if (snapshot.Error is not null) + { + reply.Error = snapshot.Error; + } + + if (snapshot.QrUrl is not null) + { + reply.QrUrl = snapshot.QrUrl; + } + + return reply; + } + + /// + /// StartPhone — запросить код по номеру телефона. + /// + public override async Task StartPhone(StartPhoneRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + + TenantSessionSnapshot snapshot = await ExecuteAsync( + tenantId, + ct => _sessionFarm.StartPhoneAsync(tenantId, request.ApiId, request.ApiHash, request.Phone, ct), + "start_phone", + context).ConfigureAwait(false); + + return new StartPhoneReply { Phase = PhaseToString(snapshot.Phase) }; + } + + /// + /// StartQr — начать вход по QR + /// + public override async Task StartQr(StartQrRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + + TenantSessionSnapshot snapshot = await ExecuteAsync( + tenantId, + ct => _sessionFarm.StartQrAsync(tenantId, request.ApiId, request.ApiHash, ct), + "start_qr", + context).ConfigureAwait(false); + + var reply = new StartQrReply { Phase = PhaseToString(snapshot.Phase) }; + if (snapshot.QrUrl is not null) + { + reply.QrUrl = snapshot.QrUrl; + } + + return reply; + } + + /// + /// SendCode — отправить SMS-код входа. + /// + public override async Task SendCode(SendCodeRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + + TenantSessionSnapshot snapshot = await ExecuteAsync( + tenantId, + ct => _sessionFarm.SendCodeAsync(tenantId, request.Code, ct), + "send_code", + context).ConfigureAwait(false); + + return new SendCodeReply { Phase = PhaseToString(snapshot.Phase) }; + } + + /// + /// SendPassword — облачный пароль 2FA. + /// + public override async Task SendPassword(SendPasswordRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + + TenantSessionSnapshot snapshot = await ExecuteAsync( + tenantId, + ct => _sessionFarm.SendPasswordAsync(tenantId, request.Password, ct), + "send_password", + context).ConfigureAwait(false); + + return new SendPasswordReply { Phase = PhaseToString(snapshot.Phase) }; + } + + /// + /// Logout — отключить аккаунт, удалить сессию тенанта. + /// + public override async Task Logout(LogoutRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + + await ExecuteAsync( + tenantId, + ct => _sessionFarm.LogoutAsync(tenantId, ct), + "logout", + context).ConfigureAwait(false); + + _catalog.Reset(tenantId); + return new LogoutReply { Ok = true }; + } + + + /// + /// RefreshDialogs — актуальный каталог диалогов аккаунта. + /// + public override async Task RefreshDialogs(RefreshDialogsRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + + RefreshDialogsReply reply = await ExecuteAsync( + tenantId, + async ct => + { + IReadOnlyList dialogs = await _sessionFarm + .ListDialogsAsync(tenantId, DialogListLimit, ct) + .ConfigureAwait(false); + + List entries = dialogs.Select(DialogProtoMapper.ToEntry).ToList(); + _catalog.ReplaceKnown(tenantId, dialogs.Select(dialog => dialog.Id).ToList()); + + await SyncCatalogSilentlyAsync(tenantId, entries, ct).ConfigureAwait(false); + + var result = new RefreshDialogsReply(); + result.Entries.AddRange(entries); + return result; + }, + "refresh_dialogs", + context).ConfigureAwait(false); + return reply; + } + + /// + /// SetMonitor — включить/выключить мониторинг диалога. + /// + public override Task SetMonitor(SetMonitorRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + RequireDialogId(request.DialogId); + + _catalog.SetMonitored(tenantId, request.DialogId, request.Enabled); + _logger.LogInformation( + "Аудит: tenant {TenantId} set_monitor {DialogId} → {Enabled}", + tenantId, + request.DialogId, + request.Enabled); + return Task.FromResult(new SetMonitorReply { Ok = true, Enabled = request.Enabled }); + } + + /// + /// SetMonitorAll — мониторинг всех диалогов каталога. + /// + public override Task SetMonitorAll(SetMonitorAllRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + + int count = _catalog.SetAllMonitored(tenantId, request.Enabled); + _logger.LogInformation( + "Аудит: tenant {TenantId} set_monitor_all → {Enabled} (каталог {Count})", + tenantId, + request.Enabled, + count); + return Task.FromResult(new SetMonitorAllReply { Ok = true, Count = count, Enabled = request.Enabled }); + } + + /// + /// Backfill — перечитать последние сообщения диалога в ядро. + /// + public override async Task Backfill(BackfillRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + RequireDialogId(request.DialogId); + + int processed = await ExecuteAsync( + tenantId, + ct => _backfill.ExecuteAsync(tenantId, request.DialogId, request.Force, ct), + "backfill", + context).ConfigureAwait(false); + return new BackfillReply { Processed = processed }; + } + + /// + /// ReadRecent — последние сообщения диалога для превью. + /// + public override async Task ReadRecent(ReadRecentRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + RequireDialogId(request.DialogId); + int limit = request.Limit <= 0 ? PreviewDefaultLimit : Math.Min(request.Limit, PreviewMaxLimit); + + ReadRecentReply reply = await ExecuteAsync( + tenantId, + async ct => + { + IReadOnlyList messages = await _sessionFarm + .GetMessagesAsync(tenantId, request.DialogId, limit, ct) + .ConfigureAwait(false); + + var result = new ReadRecentReply(); + result.Messages.AddRange(messages.Select(DialogProtoMapper.ToPreview)); + + await _sessionFarm.MarkReadAsync(tenantId, request.DialogId, ct).ConfigureAwait(false); + return result; + }, + "read_recent", + context).ConfigureAwait(false); + return reply; + } + + + /// + /// ReadSource — исходное сообщение источника по id (remote-просмотр). + /// + public override async Task ReadSource(ReadSourceRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + RequireDialogId(request.DialogId); + if (request.MsgId <= 0) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, SessionErrorMessages.InvalidMessageId)); + } + + ReadSourceReply reply = await ExecuteAsync( + tenantId, + async ct => + { + Telegram.TelegramMessage? message = await _sessionFarm + .GetMessageAsync(tenantId, request.DialogId, request.MsgId, ct) + .ConfigureAwait(false); + + if (message is null) + { + return new ReadSourceReply { Found = false }; + } + + return new ReadSourceReply + { + Found = true, + Text = message.Text, + Time = message.DateMs, + }; + }, + "read_source", + context).ConfigureAwait(false); + return reply; + } + + + /// + /// Search — глобальный поиск каналов/групп по ключу. + /// + public override async Task Search(SearchRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + RequireSearchQueryWithinBounds(request.Query); + + SearchReply reply = await ExecuteAsync( + tenantId, + async ct => + { + IReadOnlyList found = await _discovery + .SearchAsync(tenantId, request.Query, request.Limit, ct) + .ConfigureAwait(false); + + var result = new SearchReply(); + result.Results.AddRange(found.Select(DialogProtoMapper.ToEntry)); + return result; + }, + "search", + context).ConfigureAwait(false); + return reply; + } + + /// + /// GetInfo — инфо об источнике для оценки кандидата. + /// + public override async Task GetInfo(GetInfoRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + RequireDialogId(request.DialogId); + + GetInfoReply reply = await ExecuteAsync( + tenantId, + async ct => + { + TelegramSourceInfo info = await _discovery + .GetInfoAsync(tenantId, request.DialogId, ct) + .ConfigureAwait(false); + return new GetInfoReply { Info = DiscoveryProtoMapper.ToChannelInfo(info) }; + }, + "discovery_info", + context).ConfigureAwait(false); + return reply; + } + + /// + /// ReadForEval — выборка сообщений источника для оценки. + /// + public override async Task ReadForEval(ReadForEvalRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + RequireDialogId(request.DialogId); + + ReadForEvalReply reply = await ExecuteAsync( + tenantId, + async ct => + { + DiscoveryReadResult result = await _discovery + .ReadForEvalAsync(tenantId, request.DialogId, request.Limit, ct) + .ConfigureAwait(false); + return DiscoveryProtoMapper.ToReadForEvalReply(result); + }, + "discovery_read", + context).ConfigureAwait(false); + return reply; + } + + /// + /// Join — вступить в канал/группу по @username. + /// + public override async Task Join(JoinRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + string normalizedUsername = DiscoveryOps.NormalizeUsername(request.Username); + RequireUsernameWithinBounds(normalizedUsername); + + await ExecuteAsync( + tenantId, + async ct => + { + await _discovery.JoinAsync(tenantId, normalizedUsername, ct).ConfigureAwait(false); + return true; + }, + "join", + context).ConfigureAwait(false); + return new JoinReply { Ok = true }; + } + + /// + /// Leave — выйти из канала/группы. + /// + public override async Task Leave(LeaveRequest request, ServerCallContext context) + { + string tenantId = RequireTenantId(context); + RequireDialogId(request.DialogId); + + await ExecuteAsync( + tenantId, + async ct => + { + await _discovery.LeaveAsync(tenantId, request.DialogId, ct).ConfigureAwait(false); + return true; + }, + "leave", + context).ConfigureAwait(false); + return new LeaveReply { Ok = true }; + } + + private static string RequireTenantId(ServerCallContext context) + { + string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey); + if (string.IsNullOrWhiteSpace(tenantId)) + { + throw new RpcException(new Status(StatusCode.Unauthenticated, SessionErrorMessages.TenantIdMissing)); + } + + return tenantId; + } + + // Id диалога обязателен в командах каталога (пустое — INVALID_ARGUMENT). + // dialogId: Id диалога из запроса. + private static void RequireDialogId(string dialogId) + { + if (string.IsNullOrWhiteSpace(dialogId)) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId)); + } + } + + // Поисковый запрос ограничен сверху (INVALID_ARGUMENT; защита границы сервиса). + // query: Поисковый запрос. + private static void RequireSearchQueryWithinBounds(string query) + { + if (query.Trim().Length > MaxSearchQueryLength) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, SessionErrorMessages.SearchQueryTooLong)); + } + } + + // Username вступления ограничен сверху (INVALID_ARGUMENT; лимит Telegram 5..32). + // username: Нормализованный username (без «@»/пробелов). + private static void RequireUsernameWithinBounds(string username) + { + if (username.Length > MaxUsernameLength) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, SessionErrorMessages.UsernameTooLong)); + } + } + + private async Task SyncCatalogSilentlyAsync( + string tenantId, + IReadOnlyList entries, + CancellationToken cancellationToken) + { + try + { + IReadOnlyList monitored = await _ingress.SyncDialogsAsync(tenantId, entries, cancellationToken).ConfigureAwait(false); + _catalog.ReplaceMonitored(tenantId, monitored); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "Аудит: tenant {TenantId} sync_dialogs → сбой (зеркало прежнее; догонит sweep)", tenantId); + } + } + + private async Task ExecuteAsync( + string tenantId, + Func> operation, + string action, + ServerCallContext context) + { + try + { + TResult result = await operation(context.CancellationToken).ConfigureAwait(false); + _logger.LogInformation("Аудит: tenant {TenantId} {Action} → ok", tenantId, action); + return result; + } + catch (SessionException exception) + { + _logger.LogInformation("Аудит: tenant {TenantId} {Action} → ошибка {Error}", tenantId, action, exception.Message); + throw ToRpc(exception); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogError(exception, "Аудит: tenant {TenantId} {Action} → сбой", tenantId, action); + throw ToRpc(ToSessionFailure(exception)); + } + } + + // Переводит не-SessionException в доменную ошибку: транспорт/сеть — UNAVAILABLE («Telegram недоступен…»), + // внутренние сбои реализации (NRE/InvalidOperation и пр.) — INTERNAL. Раньше любые ошибки маскировались + // UNAVAILABLE (замечание code-review): внутренние дефекты должны быть видны как Internal. + // exception: Необработанное исключение операции. + private static SessionException ToSessionFailure(Exception exception) + => exception.IsTransportFailure() + ? new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception) + : new SessionException(StatusCode.Internal, SessionErrorMessages.InternalError, exception); + + // Фаза AuthPhase → строка канона контракта (idle|phone|code|password|qr|ready). + // phase: Фаза сессии. + private static string PhaseToString(AuthPhase phase) + => phase switch + { + AuthPhase.Phone => "phone", + AuthPhase.Code => "code", + AuthPhase.Password => "password", + AuthPhase.Qr => "qr", + AuthPhase.Ready => "ready", + _ => "idle", + }; + + // Доменная ошибка сессии → RpcException контракта (код + detail = текст причины). + // exception: Ошибка сессии. + private static RpcException ToRpc(SessionException exception) + => new(new Status(exception.Code, exception.Message)); +} diff --git a/src/telegram-service/Directory.Build.props b/src/telegram-service/Directory.Build.props index b2a7175..0d969a1 100644 --- a/src/telegram-service/Directory.Build.props +++ b/src/telegram-service/Directory.Build.props @@ -1,11 +1,11 @@ - - - net10.0 - latest - enable - enable - true - latest - true - - + + + net10.0 + latest + enable + enable + true + latest + true + +