From e8b9fab860f3da0ac5c331ccd53ad81721a62db0 Mon Sep 17 00:00:00 2001 From: Rustam Khalimov Date: Fri, 11 Sep 2026 13:30:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D1=83=D0=B5?= =?UTF-8?q?=D0=BC=D1=8B=D0=B5=20using=20=D0=BF=D0=BE=20=D0=BA=D0=BE=D0=B4-?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=B9=D0=BB=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прогон dotnet format (IDE0005) по 4 решениям: удалены лишние using, оставшиеся после миграции namespace (676 файлов). --- src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs | 4 +- src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs | 6 +- .../Deal.Ai.Tests/Ai/FakeProviderClient.cs | 1 - .../Deal.Ai.Tests/Ai/ProviderCallerTests.cs | 1 - .../Deal.Ai.Tests/Ai/TokenEstimatorTests.cs | 1 - src/ai-service/Deal.Ai.Tests/AssemblyInfo.cs | 2 - .../Deal.Ai.Tests/Grpc/AiServiceHostTests.cs | 9 +- .../Support/StubHttpMessageHandler.cs | 1 - .../Deal.Api/Configuration/CookieOptions.cs | 72 +- .../Configuration/DataRetentionOptions.cs | 3 - .../Configuration/ForwardedHeadersConfig.cs | 81 +- .../Configuration/OperatorCookieOptions.cs | 76 +- .../Configuration/RateLimitOptions.cs | 89 +- .../Deal.Api/Configuration/SecurityOptions.cs | 53 +- src/core/Deal.Api/Dtos/AdminTickResultDto.cs | 55 +- .../Deal.Api/Endpoints/AiCheckEndpoint.cs | 348 ++-- .../Deal.Api/Endpoints/AiSuggestEndpoints.cs | 196 +- src/core/Deal.Api/Endpoints/AuthEndpoints.cs | 430 +++-- .../Endpoints/CardDetailsEndpoints.cs | 947 +++++----- src/core/Deal.Api/Endpoints/CardsEndpoints.cs | 993 +++++----- .../Endpoints/ChangePasswordRequest.cs | 19 +- .../Deal.Api/Endpoints/CheckMessageRequest.cs | 17 +- .../Deal.Api/Endpoints/ContainersEndpoints.cs | 655 ++++--- .../Deal.Api/Endpoints/DiscoveryEndpoints.cs | 1153 ++++++------ src/core/Deal.Api/Endpoints/EventsEndpoint.cs | 243 ++- .../Endpoints/FilterTesterEndpoints.cs | 161 +- src/core/Deal.Api/Endpoints/JoinEndpoint.cs | 230 ++- src/core/Deal.Api/Endpoints/JoinRequest.cs | 23 +- src/core/Deal.Api/Endpoints/LoginRequest.cs | 19 +- src/core/Deal.Api/Endpoints/MlApplyRequest.cs | 21 +- .../Deal.Api/Endpoints/MlCandidatesRequest.cs | 19 +- src/core/Deal.Api/Endpoints/MlEndpoints.cs | 380 ++-- .../Deal.Api/Endpoints/MlPredictRequest.cs | 17 +- .../Endpoints/OperatorAnalyticsEndpoints.cs | 343 ++-- .../Endpoints/OperatorAuditEndpoints.cs | 195 +- .../Endpoints/OperatorAuthEndpoints.cs | 356 ++-- .../Endpoints/OperatorHealthEndpoints.cs | 402 ++-- .../Endpoints/OperatorInviteCreateRequest.cs | 19 +- .../Endpoints/OperatorInvitesEndpoints.cs | 299 ++- .../Endpoints/OperatorLimitUpdateRequest.cs | 23 +- .../Endpoints/OperatorLimitsEndpoints.cs | 498 +++-- .../Endpoints/OperatorMaintenanceEndpoints.cs | 132 +- .../Endpoints/OperatorSettingsEndpoints.cs | 315 ++-- .../Endpoints/OperatorTenantCreateRequest.cs | 21 +- .../OperatorTenantImpersonateRequest.cs | 19 +- .../Endpoints/OperatorTenantsEndpoints.cs | 587 +++--- .../Deal.Api/Endpoints/PipelineEndpoints.cs | 376 ++-- src/core/Deal.Api/Endpoints/RatesEndpoints.cs | 176 +- .../RequestModels/CardLinkRequest.cs | 3 - .../Endpoints/RequestModels/ClearColBody.cs | 23 +- .../Endpoints/RequestModels/ColStateBody.cs | 25 +- .../Endpoints/RequestModels/CommentBody.cs | 23 +- .../RequestModels/ContainerCreateRequest.cs | 62 +- .../RequestModels/ContainerPatchRequest.cs | 60 +- .../RequestModels/CreateCardRequest.cs | 2 - .../RequestModels/DiscoveryTaskCreateBody.cs | 119 +- .../RequestModels/DiscoveryTaskPatchBody.cs | 117 +- .../Endpoints/RequestModels/MarkColBody.cs | 27 +- .../Endpoints/RequestModels/MoveBody.cs | 25 +- .../OperatorTelegramKeysRequest.cs | 3 - .../Endpoints/RequestModels/OrderBody.cs | 27 +- .../Endpoints/RequestModels/ReclassifyBody.cs | 25 +- .../RequestModels/ReminderSetRequest.cs | 37 +- .../RequestModels/ReturnReasonRequest.cs | 25 +- .../RequestModels/TakeCardRequest.cs | 3 - .../Endpoints/RequestModels/TgMonitorBody.cs | 17 +- .../Endpoints/RequestModels/TgPreviewBody.cs | 19 +- .../RequestModels/TgSendCodeRequest.cs | 17 +- .../RequestModels/TgSendPasswordRequest.cs | 17 +- .../RequestModels/TgStartPhoneRequest.cs | 17 +- .../Deal.Api/Endpoints/SettingsEndpoints.cs | 266 ++- .../Deal.Api/Endpoints/StorageEndpoints.cs | 178 +- .../Deal.Api/Endpoints/TelegramEndpoints.cs | 1114 ++++++----- .../Endpoints/TelegramQrImageEndpoint.cs | 172 +- src/core/Deal.Api/Events/SseBroker.cs | 256 ++- src/core/Deal.Api/Events/SseEvent.cs | 35 +- src/core/Deal.Api/Events/SseSubscription.cs | 26 +- .../Deal.Api/Events/StorageToastPublisher.cs | 166 +- src/core/Deal.Api/Extensions/AuthHelpers.cs | 138 +- .../Deal.Api/Hosting/BudgetAlertScheduler.cs | 435 +++-- .../Hosting/DataRetentionScheduler.cs | 6 - .../Hosting/DiscoveryWorkerScheduler.cs | 383 ++-- .../Hosting/MlOutboxFlushScheduler.cs | 477 +++-- .../Hosting/OperatorBootstrapHostedService.cs | 198 +- .../Deal.Api/Hosting/StorageTickScheduler.cs | 493 +++-- .../Hosting/TenantBootstrapService.cs | 194 +- src/core/Deal.Api/Logging/DealLogging.cs | 249 ++- .../Middleware/HttpAccessLogMiddleware.cs | 198 +- .../Middleware/OperatorSessionMiddleware.cs | 117 +- .../Middleware/OriginGuardMiddleware.cs | 196 +- .../Deal.Api/Middleware/RateLimitPolicies.cs | 308 ++- .../Deal.Api/Middleware/SessionMiddleware.cs | 155 +- src/core/Deal.Api/Models/CurrentOperator.cs | 22 +- src/core/Deal.Api/Models/CurrentUser.cs | 24 +- .../Observability/DealMetricsCollector.cs | 2 - .../Observability/DealMetricsHosting.cs | 6 - .../Observability/RuntimeDepthsCollector.cs | 308 ++- .../Observability/RuntimeDepthsDto.cs | 3 - src/core/Deal.Api/Program.cs | 1517 ++++++++------- .../Services/AdminTickOrchestrator.cs | 379 ++-- src/core/Deal.Api/Services/AuditAppender.cs | 190 +- src/core/Deal.Api/Services/EndpointResults.cs | 127 +- .../Deal.Api/Services/LoginAttemptGuard.cs | 310 ++-- .../Deal.Api/Services/PipelinePumpGate.cs | 73 +- .../Services/PipelineWorkerScheduler.cs | 450 +++-- .../Services/RatesRefreshScheduler.cs | 148 +- .../Deal.Api/Services/SessionCookieWriter.cs | 5 - .../StoreBackedFixedWindowRateLimiter.cs | 9 - .../Services/TelegramBackfillScheduler.cs | 275 ++- .../Telegram/IngressRateLimitInterceptor.cs | 218 ++- .../IngressServiceTokenInterceptor.cs | 178 +- .../Telegram/RpcCallLoggingInterceptor.cs | 186 +- .../Telegram/TelegramIngressService.cs | 863 +++++---- .../Telegram/TelegramKeysMaskedDto.cs | 3 - .../Deal.Api/Telegram/TelegramKeysService.cs | 330 ++-- .../Deal.Api/Telegram/TelegramKeysValue.cs | 3 - src/core/Deal.Api/Telegram/TgKeysSnapshot.cs | 43 +- .../Deal.Api/Telegram/TgReportedStatus.cs | 79 +- src/core/Deal.Api/Telegram/TgStatusService.cs | 220 ++- .../Integrations/Models/SourceDefaults.cs | 2 - .../Data/ConnectionStringProvider.cs | 1 - .../Abstractions/IMlTrainClient.cs | 54 +- .../Exceptions/AiUnavailableException.cs | 6 - .../Extensions/RpcExceptionExtensions.cs | 5 - .../Integrations/Extensions/UriExtensions.cs | 5 - .../Integrations/Models/AiGrpcConnection.cs | 194 +- .../Integrations/Models/MlGrpcConnection.cs | 192 +- .../Integrations/Models/MtlsCertificates.cs | 4 - .../Models/ServiceHealthResult.cs | 4 - .../Models/TelegramGrpcConnection.cs | 192 +- .../Integrations/Options/AiServiceOptions.cs | 6 - .../Integrations/Options/MlServiceOptions.cs | 6 - .../Integrations/Options/MtlsOptions.cs | 225 ++- .../Options/TelegramServiceOptions.cs | 6 - .../Services/AiConnectionChecker.cs | 548 +++--- .../Services/AiProviderConfigBuilder.cs | 339 ++-- .../Services/BudgetedAiClassifier.cs | 267 ++- .../Integrations/Services/BudgetedAiTools.cs | 299 ++- .../Integrations/Services/CbrRateSource.cs | 364 ++-- .../Integrations/Services/GrpcAiClassifier.cs | 506 +++-- .../Integrations/Services/GrpcAiTools.cs | 467 +++-- .../Integrations/Services/GrpcMlClient.cs | 848 +++++---- .../Services/GrpcTelegramClient.cs | 976 +++++----- .../Services/LocalAiClassifier.cs | 95 +- .../Integrations/Services/LocalAiTools.cs | 5 - .../Services/LocalColumnSuggester.cs | 503 +++-- .../Integrations/Services/LocalMlClient.cs | 335 ++-- .../Services/LocalTelegramGateway.cs | 5 - .../Integrations/Services/MlOutboxQueue.cs | 152 +- .../Integrations/Services/MlStatusCache.cs | 5 - .../Services/ServiceHealthProbe.cs | 159 +- .../Services/TokenUsageRecorder.cs | 527 +++--- .../MinioStorageOptionsExtensions.cs | 1 - .../Storage/Extensions/StringExtensions.cs | 3 - .../Storage/Options/LocalStorageOptions.cs | 1 - .../Storage/Options/MinioStorageOptions.cs | 1 - .../Storage/Options/StorageOptions.cs | 1 - .../Storage/Services/FileStorageRegistrar.cs | 339 ++-- .../Storage/Services/LocalFileStorage.cs | 2 - .../Storage/Services/MinioFileStorage.cs | 471 +++-- .../20260905192825_InitialSystem.cs | 3 +- .../Migrations/20260907181413_SystemSaaS.cs | 3 +- ...0260907192419_SessionsImpersonationMark.cs | 3 +- .../20260910152246_AddTokenUsageEvents.cs | 3 +- .../20260910172545_RateLimitCounters.cs | 3 +- .../20260910194443_GlobalSettings.cs | 3 +- .../TenantDb/20260905193010_InitialTenant.cs | 3 +- .../TenantDb/20260906125058_TenantKanban.cs | 3 +- .../TenantDb/20260906165058_TenantPipeline.cs | 3 +- .../TenantDb/20260906200342_TenantProjects.cs | 3 +- .../TenantDb/20260907141242_TenantTelegram.cs | 3 +- .../20260907155333_TenantDiscovery.cs | 3 +- .../20260909222322_TenantContainers.cs | 3 +- .../20260910132805_TenantUnifiedCard.cs | 3 +- .../20260910134104_TenantContainerRegistry.cs | 3 +- .../Configurations/AuditLogConfiguration.cs | 54 +- .../Configurations/CardConfiguration.cs | 96 +- .../Configurations/CardMoveConfiguration.cs | 40 +- .../Configurations/ContainerConfiguration.cs | 86 +- .../Configurations/DedupEntryConfiguration.cs | 46 +- .../Configurations/DialogConfiguration.cs | 46 +- .../DiscBlacklistConfiguration.cs | 48 +- .../DiscCandidateConfiguration.cs | 58 +- .../Configurations/DiscLogConfiguration.cs | 50 +- .../Configurations/DiscTaskConfiguration.cs | 50 +- .../GlobalSettingConfiguration.cs | 2 - .../Configurations/InviteConfiguration.cs | 74 +- .../LeadCommentConfiguration.cs | 54 +- .../Configurations/MlOutboxConfiguration.cs | 46 +- .../Configurations/OperatorConfiguration.cs | 56 +- .../OperatorSessionConfiguration.cs | 70 +- .../Configurations/QueueItemConfiguration.cs | 52 +- .../RateLimitCounterConfiguration.cs | 2 - .../RejectedItemConfiguration.cs | 74 +- .../Configurations/SessionConfiguration.cs | 70 +- .../Configurations/TenantConfiguration.cs | 46 +- .../TenantLimitConfiguration.cs | 64 +- .../TenantSettingConfiguration.cs | 50 +- .../Configurations/TgMessageConfiguration.cs | 44 +- .../TokenUsageEventConfiguration.cs | 2 - .../Configurations/UserConfiguration.cs | 68 +- .../Persistence/DealDbContext.cs | 116 +- .../Persistence/DealDbDesignTimeFactory.cs | 41 +- .../Persistence/Entities/AuditLogEntity.cs | 2 - .../Persistence/Entities/CardEntity.cs | 381 ++-- .../Persistence/Entities/CardMoveEntity.cs | 2 - .../Persistence/Entities/ContainerEntity.cs | 169 +- .../Persistence/Entities/DedupEntryEntity.cs | 2 - .../Persistence/Entities/DialogEntity.cs | 2 - .../Entities/DiscBlacklistEntity.cs | 2 - .../Entities/DiscCandidateEntity.cs | 2 - .../Persistence/Entities/DiscLogEntity.cs | 2 - .../Persistence/Entities/DiscTaskEntity.cs | 2 - .../Entities/GlobalSettingEntity.cs | 2 - .../Persistence/Entities/InviteEntity.cs | 2 - .../Persistence/Entities/LeadCommentEntity.cs | 2 - .../Persistence/Entities/MlOutboxEntity.cs | 2 - .../Persistence/Entities/OperatorEntity.cs | 2 - .../Entities/OperatorSessionEntity.cs | 2 - .../Persistence/Entities/QueueItemEntity.cs | 2 - .../Entities/RateLimitCounterEntity.cs | 2 - .../Entities/RejectedItemEntity.cs | 203 +- .../Persistence/Entities/SessionEntity.cs | 2 - .../Persistence/Entities/TenantEntity.cs | 2 - .../Persistence/Entities/TenantLimitEntity.cs | 2 - .../Entities/TenantSettingEntity.cs | 2 - .../Persistence/Entities/TgMessageEntity.cs | 2 - .../Entities/TokenUsageEventEntity.cs | 2 - .../Persistence/Entities/UserEntity.cs | 2 - .../Persistence/Repositories/AuditLogStore.cs | 265 ++- .../Persistence/Repositories/AuthStore.cs | 264 ++- .../Repositories/DiscoveryStore.Blacklist.cs | 141 +- .../Repositories/DiscoveryStore.Candidates.cs | 343 ++-- .../Repositories/DiscoveryStore.Logs.cs | 119 +- .../Repositories/DiscoveryStore.Tasks.cs | 465 +++-- .../Repositories/DiscoveryStore.cs | 442 +++-- .../Repositories/GlobalSettingsStore.cs | 111 +- .../Persistence/Repositories/InviteStore.cs | 229 ++- .../Repositories/KanbanStore.Cards.cs | 544 +++--- .../Repositories/KanbanStore.Comments.cs | 190 +- .../Repositories/KanbanStore.Containers.cs | 221 ++- .../Repositories/KanbanStore.Selected.cs | 1 - .../Repositories/KanbanStore.StorageRules.cs | 304 ++- .../Persistence/Repositories/KanbanStore.cs | 643 ++++--- .../Repositories/MlLearningStore.cs | 152 +- .../Repositories/OperatorAuthStore.cs | 170 +- .../Persistence/Repositories/PipelineStore.cs | 757 ++++---- .../Repositories/RateLimitCounterStore.cs | 5 - .../Persistence/Repositories/SettingsStore.cs | 159 +- .../Persistence/Repositories/TelegramStore.cs | 525 +++--- .../Repositories/TenantLimitStore.cs | 609 +++--- .../Repositories/TenantRepository.cs | 140 +- .../Repositories/TokenUsageEventStore.cs | 366 ++-- .../Persistence/TenantDbContext.cs | 210 +-- .../Persistence/TenantDbDesignTimeFactory.cs | 41 +- .../Security/AesGcmSecretCipher.cs | 3 - .../ServiceCollectionExtensions.cs | 25 - .../Deal.Infrastructure/Services/CardMover.cs | 3 - .../Services/FtsMaintenance.cs | 1 - .../Tenancy/DefaultContainerProvisioner.cs | 7 - .../Tenancy/TenantProvisioningService.cs | 6 - .../Tenancy/TenantSchemaMigrationService.cs | 4 - .../Application/Abstractions/IAiSource.cs | 3 - .../Application/Abstractions/IApiSource.cs | 3 - .../Abstractions/IAttributedCard.cs | 1 - .../Application/Abstractions/IBudgetedCard.cs | 1 - .../Application/Abstractions/ICard.cs | 3 - .../Abstractions/ICommentableCard.cs | 1 - .../Abstractions/ICompositeSource.cs | 3 - .../Application/Abstractions/IContactCard.cs | 1 - .../Application/Abstractions/IContainer.cs | 3 - .../Abstractions/IContainerPolicy.cs | 3 - .../Abstractions/IContainerRules.cs | 1 - .../Application/Abstractions/IContentCard.cs | 3 - .../Application/Abstractions/IFileCard.cs | 1 - .../Application/Abstractions/IFileSource.cs | 3 - .../Application/Abstractions/ILinkCard.cs | 1 - .../Application/Abstractions/ILocalSource.cs | 3 - .../Application/Abstractions/ILocatedCard.cs | 3 - .../Abstractions/IRemindableCard.cs | 1 - .../Application/Abstractions/IRowSource.cs | 3 - .../Application/Abstractions/ISource.cs | 3 - .../Abstractions/ITelegramSource.cs | 3 - .../Abstractions/ITraceableCard.cs | 1 - .../Application/Abstractions/ITzCard.cs | 3 - .../Application/Abstractions/IWebSource.cs | 3 - .../Application/Dtos/CardMoveResultDto.cs | 1 - .../Application/Models/Card.cs | 1 - .../Application/Models/CardAttribute.cs | 3 - .../Application/Models/CardBudget.cs | 3 - .../Application/Models/CardComment.cs | 3 - .../Application/Models/CardContact.cs | 3 - .../Application/Models/CardFile.cs | 3 - .../Application/Models/CardHistoryEntry.cs | 3 - .../Application/Models/CardIds.cs | 3 - .../Application/Models/CardLink.cs | 3 - .../Application/Models/CardReminder.cs | 3 - .../Models/CardsDefaultContainers.cs | 3 - .../Application/Models/DefaultContainer.cs | 3 - .../Application/Models/TransitionContext.cs | 1 - .../Abstractions/IDiscoveryPacer.cs | 4 - .../IDiscoverySearchErrorCounter.cs | 6 - .../Abstractions/IDiscoveryStore.cs | 628 ++++--- .../DiscoveryValidationException.cs | 6 - .../DiscoveryTaskPatchExtensions.cs | 4 - .../Models/DiscoveryCandidateKinds.cs | 6 - .../Models/DiscoveryCandidateStatuses.cs | 6 - .../Models/DiscoveryCounterField.cs | 6 - .../Application/Models/DiscoveryEvalSample.cs | 6 - .../Application/Models/DiscoveryIdPrefixes.cs | 5 - .../Application/Models/DiscoveryLogEvents.cs | 6 - .../Application/Models/DiscoveryMessageFit.cs | 6 - .../Models/DiscoveryTaskStatuses.cs | 6 - .../Application/Models/DiscoveryTopicGroup.cs | 5 - .../Models/DiscoveryWorkerOutcome.cs | 4 - .../Registrars/DiscoveryModuleRegistrar.cs | 10 +- .../Application/Services/DiscoveryBanGuard.cs | 255 ++- .../Services/DiscoveryBlacklistService.cs | 129 +- .../Services/DiscoveryCandidatesService.cs | 707 ++++--- .../Services/DiscoveryEvaluator.cs | 668 ++++--- .../Services/DiscoveryLangDetector.cs | 6 - .../Services/DiscoveryLogService.cs | 141 +- .../Application/Services/DiscoveryPacer.cs | 78 +- .../Services/DiscoveryPlanGuard.cs | 179 +- .../Services/DiscoverySearchErrorCounter.cs | 4 - .../Services/DiscoveryTasksService.cs | 603 +++--- .../DiscoveryWorkerService.Constants.cs | 6 - .../DiscoveryWorkerService.Evaluate.cs | 475 +++-- .../DiscoveryWorkerService.Helpers.cs | 101 +- .../Services/DiscoveryWorkerService.Join.cs | 305 ++- .../Services/DiscoveryWorkerService.Search.cs | 207 +-- .../Services/DiscoveryWorkerService.cs | 360 ++-- .../Application/Abstractions/ICardStore.cs | 903 +++++---- .../Abstractions/IMlLearningStore.cs | 145 +- .../Application/ColumnRules/BudgetInRange.cs | 3 - .../Application/Extensions/CharExtensions.cs | 5 - .../Application/Models/CardFileKind.cs | 3 - .../Application/Models/ContainerKinds.cs | 5 - .../Application/Models/ContainerSpaces.cs | 5 - .../Application/Models/KanbanColumns.cs | 5 - .../Application/Models/KanbanIdPrefixes.cs | 6 - .../Application/Models/PrefixId.cs | 4 - .../Registrars/KanbanModuleRegistrar.cs | 90 +- .../Application/Services/BudgetNormalizer.cs | 365 ++-- .../Services/CardsService.Files.cs | 4 - .../Services/CardsService.Helpers.cs | 250 ++- .../Services/CardsService.Operations.cs | 712 ++++--- .../Services/CardsService.Reminders.cs | 6 - .../Services/CardsService.Selected.cs | 6 +- .../Application/Services/CardsService.cs | 247 ++- .../Application/Services/ContainersService.cs | 592 +++--- .../Services/ConversionRecomputer.cs | 193 +- .../Application/Services/FileKindDetector.cs | 3 - .../Services/StorageTickService.cs | 160 +- .../Application/Services/SuggestHeuristics.cs | 6 - .../Abstractions/IPipelineStore.cs | 2 - .../Application/Models/PipelineIdPrefixes.cs | 4 - .../Models/PipelineQueueStatuses.cs | 4 - .../Models/PipelineRejectConstants.cs | 4 - .../Application/Parse/LocalFieldsParser.cs | 2 - .../Registrars/PipelineModuleRegistrar.cs | 148 +- .../Application/Services/AiCardLearning.cs | 130 +- .../Application/Services/AiCardMapper.cs | 135 +- .../Services/AiClassifyContextBuilder.cs | 395 ++-- .../Application/Services/AiRawCardMapper.cs | 702 ++++--- .../Application/Services/CardComposer.cs | 416 ++--- .../Application/Services/CardReclassifier.cs | 788 ++++---- .../Services/GlobalExclusionRules.cs | 3 - .../Application/Services/MlReviewService.cs | 889 +++++---- .../Services/PipelineCardWriter.cs | 104 +- .../Services/PipelineIngestService.cs | 135 +- .../Services/PipelineProcessingService.cs | 703 ++++--- .../Services/PipelineWorkerService.Checks.cs | 316 ++-- .../PipelineWorkerService.Decisions.cs | 146 +- .../PipelineWorkerService.Learning.cs | 145 +- .../Services/PipelineWorkerService.Pump.cs | 687 ++++--- .../PipelineWorkerService.Rejections.cs | 172 +- .../PipelineWorkerService.Settings.cs | 191 +- .../Services/PipelineWorkerService.State.cs | 174 +- .../Services/PipelineWorkerService.cs | 396 ++-- .../Application/Services/ReclassifyGate.cs | 4 - .../Abstractions/IAiConnectionChecker.cs | 2 - .../Abstractions/IGlobalSettingsStore.cs | 2 - .../Abstractions/IRatesChangedListener.cs | 2 - .../Application/Abstractions/IRatesSource.cs | 2 - .../Application/Abstractions/ISecretCipher.cs | 4 - .../Abstractions/ISettingsStore.cs | 2 - .../Models/AiProviderDefinition.cs | 4 - .../Application/Models/AiProviders.cs | 4 - .../Application/Models/DefaultPrompts.cs | 4 - .../Application/Models/GlobalSettingsKeys.cs | 4 - .../Application/Models/IncomingRules.cs | 567 +++--- .../Application/Models/SettingKind.cs | 4 - .../Application/Models/SettingsDefaults.cs | 565 +++--- .../Application/Models/SettingsKeys.cs | 4 - .../Models/TenantSettingsSnapshot.cs | 503 +++-- .../Registrars/SettingsModuleRegistrar.cs | 71 +- .../Application/Services/MockRates.cs | 4 - .../Application/Services/PromptFiller.cs | 4 - .../Application/Services/RatesService.cs | 419 +++-- .../SettingsService.PatchMyPrompts.cs | 166 +- .../SettingsService.PatchScalarKeys.cs | 296 ++- .../Services/SettingsService.PatchSecrets.cs | 218 ++- .../Services/SettingsService.PublicForms.cs | 71 +- .../Services/SettingsService.ReadMerge.cs | 538 +++--- .../Application/Services/SettingsService.cs | 645 ++++--- .../Application/DialogsService.cs | 2 - .../Application/Models/TelegramDialogDto.cs | 2 - .../Application/TelegramModuleRegistrar.cs | 6 - .../Abstractions/IAuditLogStore.cs | 102 +- .../Application/Abstractions/IAuthStore.cs | 177 +- .../Application/Abstractions/IInviteStore.cs | 150 +- .../Abstractions/IOperatorAuthStore.cs | 117 +- .../Abstractions/IPasswordHasher.cs | 5 - .../Abstractions/IRateLimitCounterStore.cs | 5 - .../Abstractions/ITenantLimitStore.cs | 206 +-- .../Abstractions/ITenantProvisioner.cs | 39 +- .../Abstractions/ITenantRepository.cs | 89 +- .../Abstractions/ITokenUsageEventStore.cs | 2 - .../Extensions/AuditRecordDtoExtensions.cs | 5 - .../Extensions/InviteDtoExtensions.cs | 3 - .../Application/Models/AuditActorTypes.cs | 5 - .../Application/Models/AuditEvents.cs | 5 - .../Application/Models/InviteStatuses.cs | 3 - .../Application/Models/TenantLimitPeriods.cs | 5 - .../Application/Models/TenantStatuses.cs | 3 - .../Application/Models/TokenBudgetDefaults.cs | 64 +- .../Models/TokenUsageEventKinds.cs | 5 - .../Application/Models/TokenUsageGroupBys.cs | 5 - .../Application/Models/TokenUsageSources.cs | 5 - .../Registrars/TenantModuleRegistrar.cs | 94 +- .../Application/Services/AnalyticsService.cs | 4 +- .../Application/Services/AuditService.cs | 172 +- .../Application/Services/AuthService.cs | 632 ++++--- .../Services/DefaultPasswordHasher.cs | 47 +- .../Services/InviteCodeGenerator.cs | 62 +- .../Application/Services/InvitesService.cs | 383 ++-- .../Application/Services/JoinService.cs | 304 ++- .../Services/OperatorAuthService.cs | 256 ++- .../Services/OperatorBootstrapService.cs | 184 +- .../Application/Services/SessionTokens.cs | 4 - .../Services/SuspiciousActivityService.cs | 4 +- .../Services/TenantAdminService.cs | 342 ++-- .../Application/Services/TenantService.cs | 111 +- .../Services/TokenBudgetService.cs | 3 - .../Services/TokenUsageEventService.cs | 4 +- .../Tenants/Models/TenantId.cs | 2 - .../Api/DataRetentionSchedulerTests.cs | 230 ++- .../Api/DiscoveryEndpointsHelpersTests.cs | 1 - .../Api/DiscoveryWorkerSchedulerTests.cs | 443 +++-- .../Api/IngressRateLimitInterceptorTests.cs | 332 ++-- .../Api/LoginAttemptGuardTests.cs | 427 +++-- .../Api/MlOutboxFlushSchedulerTests.cs | 464 +++-- .../Api/OperatorAuditEndpointsHelpersTests.cs | 5 - .../OperatorBootstrapHostedServiceTests.cs | 414 ++--- .../Api/OperatorCookieOptionsTests.cs | 5 - .../OperatorLimitsEndpointsHelpersTests.cs | 1 - .../Api/PipelinePumpGateTests.cs | 3 - .../Api/RuntimeDepthsCollectorTests.cs | 243 ++- .../Api/TelegramEndpointsMappingTests.cs | 123 +- .../Api/TelegramKeysServiceTests.cs | 5 +- .../Api/TgStatusServiceTests.cs | 297 ++- .../Contracts/BudgetedAiClassifierTests.cs | 392 ++-- .../Contracts/BudgetedAiToolsTests.cs | 339 ++-- .../Contracts/CardComposerTests.cs | 691 ++++--- .../Contracts/CardsServiceTests.cs | 1643 ++++++++--------- .../Contracts/DialogsServiceTests.cs | 1085 ++++++----- .../Contracts/DiscoveryEvaluatorTests.cs | 430 +++-- .../Contracts/DiscoveryWorkerServiceTests.cs | 1118 ++++++----- .../Contracts/FakeAiClassifier.cs | 1 - .../Deal.Tests.Unit/Contracts/FakeAiTools.cs | 1 - .../Contracts/FakeDiscoveryGateway.cs | 1 - .../Contracts/FakeFileStorage.cs | 1 - .../Deal.Tests.Unit/Contracts/FakeMlClient.cs | 1 - .../Contracts/FakeTelegramGateway.cs | 357 ++-- .../Contracts/GrpcAiToolsTests.cs | 309 ++-- .../Contracts/GrpcMlClientTests.cs | 700 ++++--- .../Contracts/GrpcTelegramClientTests.cs | 442 +++-- .../Contracts/IntegrationsDiTests.cs | 24 +- .../Contracts/LocalAiClassifierTests.cs | 336 ++-- .../Contracts/LocalAiToolsTests.cs | 8 - .../Contracts/PipelineCardWriterTests.cs | 275 ++- .../Contracts/PipelineWorkerGrpcAiTests.cs | 526 +++--- .../Contracts/TelegramGatewayPortTests.cs | 2 - .../Grpc/RecordingAiService.cs | 357 ++-- .../Grpc/RecordingMlService.cs | 309 ++-- .../Grpc/RecordingTelegramService.cs | 329 ++-- .../Infrastructure/AuditLogStoreTests.cs | 305 ++- .../Infrastructure/AuthStoreTests.cs | 176 +- .../Infrastructure/CardMoverTests.cs | 191 +- .../GlobalSettingsStoreTests.cs | 125 +- .../Infrastructure/InviteStoreTests.cs | 257 ++- .../Infrastructure/MtlsOptionsTests.cs | 230 ++- .../Infrastructure/OperatorAuthStoreTests.cs | 188 +- .../RateLimitCounterStoreTests.cs | 2 - .../Infrastructure/SecretCipherTests.cs | 4 - .../Infrastructure/TenantEntityTests.cs | 1 - .../Infrastructure/TenantLimitStoreTests.cs | 637 ++++--- .../Infrastructure/TenantRepositoryTests.cs | 110 +- .../TenantSchemaMigrationServiceTests.cs | 190 +- .../TenantSchemaMigratorTests.cs | 1 - .../TenantSettingEntityTests.cs | 1 - .../Modules/Cards/CardsDomainTests.cs | 2 - .../Modules/Cards/FakeKanjStore.cs | 6 - .../Modules/Cards/LocalSourceStub.cs | 3 - .../Modules/Cards/TelegramSourceStub.cs | 3 - .../Discovery/DiscoveryBanGuardTests.cs | 258 ++- .../DiscoveryBlacklistServiceTests.cs | 5 - .../DiscoveryCandidatesServiceTests.cs | 4 - .../Discovery/DiscoveryLangDetectorTests.cs | 6 - .../Discovery/DiscoveryLogServiceTests.cs | 5 - .../DiscoverySearchErrorCounterTests.cs | 6 - .../Discovery/DiscoveryTasksServiceTests.cs | 824 ++++----- .../Modules/Discovery/FakeDiscoveryPacer.cs | 6 - .../Modules/Discovery/FakeDiscoveryStore.cs | 5 - .../Kanban/AiClassifyContextBuilderTests.cs | 388 ++-- .../Modules/Kanban/AmountParserTests.cs | 1 - .../Modules/Kanban/BudgetNormalizerTests.cs | 4 - .../Kanban/CardsServiceRemindersTests.cs | 481 +++-- .../Modules/Kanban/ColumnRulesTests.cs | 1 - .../Modules/Kanban/FakeMlLearningStore.cs | 4 - .../Modules/Kanban/FakePipelineStore.cs | 7 - .../Modules/Kanban/FileKindDetectorTests.cs | 4 - .../Modules/Kanban/MarkerTests.cs | 1 - .../Modules/Kanban/MlReviewServiceTests.cs | 514 +++--- .../Modules/Kanban/StorageTickServiceTests.cs | 485 +++-- .../Modules/Kanban/SuggestHeuristicsTests.cs | 4 - .../Pipeline/GlobalExclusionRulesTests.cs | 3 - .../Pipeline/PipelineIngestServiceTests.cs | 381 ++-- .../PipelineProcessingServiceTests.cs | 927 +++++----- .../Settings/FakeGlobalSettingsStore.cs | 3 - .../Modules/Settings/FakeRatesListener.cs | 4 - .../Modules/Settings/FakeRatesSource.cs | 4 - .../Modules/Settings/FakeSettingsStore.cs | 3 - .../Modules/Settings/PromptDefaultsTests.cs | 3 - .../Modules/Settings/SettingsCatalogTests.cs | 3 - .../Modules/Tenants/AuditEventsTests.cs | 5 - .../Modules/Tenants/AuditServiceTests.cs | 3 - .../Modules/Tenants/AuthServiceTests.cs | 4 - .../Tenants/FailingTenantProvisioner.cs | 6 - .../Modules/Tenants/FakeAuditLogStore.cs | 3 - .../Modules/Tenants/FakeAuthStore.cs | 4 - .../Modules/Tenants/FakeInviteStore.cs | 4 - .../Modules/Tenants/FakeOperatorAuthStore.cs | 4 - .../Modules/Tenants/FakePasswordHasher.cs | 5 - .../Tenants/FakeRateLimitCounterStore.cs | 5 - .../Modules/Tenants/FakeTenantLimitStore.cs | 3 - .../Modules/Tenants/FakeTenantProvisioner.cs | 6 - .../Modules/Tenants/FakeTenantRegistry.cs | 4 - .../Modules/Tenants/FakeTenantRepository.cs | 4 - .../Modules/Tenants/FakeTenantStore.cs | 4 - .../Tenants/FakeTokenUsageEventStore.cs | 4 - .../Tenants/InviteCodeGeneratorTests.cs | 5 - .../Modules/Tenants/InvitesServiceTests.cs | 4 - .../Modules/Tenants/JoinFlowTests.cs | 4 - .../Tenants/OperatorAuthServiceTests.cs | 4 - .../Tenants/OperatorBootstrapServiceTests.cs | 4 - .../Modules/Tenants/PasswordHasherTests.cs | 4 - .../Modules/Tenants/SessionTokensTests.cs | 5 - .../Tenants/SuspiciousActivityServiceTests.cs | 4 - .../Tenants/TenantAdminServiceTests.cs | 4 - .../Tenants/TokenBudgetServiceTests.cs | 4 - .../Tenants/TokenUsageEventServiceTests.cs | 4 - .../SharedKernel/TenantIdTests.cs | 2 - .../Support/AdminTickOrchestratorTests.cs | 741 ++++---- .../Support/AiConnectionCheckerTests.cs | 5 - .../Deal.Tests.Unit/Support/AiGrpcTestHost.cs | 8 +- .../Support/AiRawCardMapperTests.cs | 3 - .../Support/BudgetAlertSchedulerTests.cs | 387 ++-- .../Support/CardReclassifierTests.cs | 758 ++++---- .../Support/CardsServiceFilesTests.cs | 547 +++--- .../Support/CardsServiceSelectedTests.cs | 1210 ++++++------ .../Support/CbrRateSourceTests.cs | 5 - .../Support/ContainersServiceTests.cs | 3 - .../Support/ConversionRecomputerTests.cs | 4 - .../Support/FakeSecretCipher.cs | 3 - .../Support/GrpcAiClassifierTests.cs | 706 ++++--- .../Support/IncomingRulesTests.cs | 3 - .../Support/JoinEndpointHttpTests.cs | 701 ++++--- .../Support/LocalColumnSuggesterTests.cs | 12 - .../Support/LocalFileStorageTests.cs | 2 - .../Support/LocalMlClientTests.cs | 734 ++++---- .../Support/LoginAttemptEndpointHttpTests.cs | 2 - .../Support/MessageParseCoreTests.cs | 3 - .../Deal.Tests.Unit/Support/MlGrpcTestHost.cs | 3 +- .../Support/MtlsCertificatesTests.cs | 4 - .../OperatorAnalyticsEndpointsHttpTests.cs | 3 - .../OperatorAuditEndpointsHttpTests.cs | 4 - .../Support/OperatorAuthEndpointsHttpTests.cs | 6 - .../Support/OperatorAuthHttpHost.cs | 20 +- .../OperatorHealthEndpointsHttpTests.cs | 4 - .../OperatorInvitesEndpointsHttpTests.cs | 3 - .../OperatorLimitsEndpointsHttpTests.cs | 4 - .../OperatorMaintenanceEndpointsHttpTests.cs | 4 - .../OperatorSettingsEndpointsHttpTests.cs | 667 ++++--- .../OperatorTenantsEndpointsHttpTests.cs | 3 - .../Support/PipelineWorkerSchedulerTests.cs | 665 ++++--- .../Support/PipelineWorkerServiceTests.cs | 1433 +++++++------- .../Support/RateLimitHttpTests.cs | 9 +- .../Support/RatesServiceTests.cs | 1 - .../Support/ServiceHealthProbeTests.cs | 4 - .../Support/SettingsServiceTests.cs | 1 - .../Support/StorageTickSchedulerTests.cs | 1079 ++++++----- .../Support/TelegramGrpcTestHost.cs | 3 +- .../Support/TelegramIngressServiceTests.cs | 995 +++++----- .../Support/TelegramIngressTestHost.cs | 22 +- .../Support/TokenUsageRecorderTests.cs | 12 - .../Deal.Grpc.Hosting/DealMetricsHosting.cs | 1 - src/ml-service/Deal.Ml.Tests/AssemblyInfo.cs | 2 - .../Deal.Ml.Tests/Grpc/MlRpcTests.cs | 567 +++--- .../Deal.Ml.Tests/Grpc/MlServiceHostTests.cs | 492 +++-- .../Deal.Ml.Tests/Grpc/MlTestHost.cs | 285 ++- .../Deal.Ml.Tests/Ml/LearningData.cs | 209 ++- .../Deal.Ml.Tests/Ml/MlTokenizerTests.cs | 155 +- .../Deal.Ml.Tests/Ml/ModelPersistenceTests.cs | 319 ++-- .../Deal.Ml.Tests/Ml/ModelPoolTests.cs | 171 +- .../Ml/TenantModelLearningTests.cs | 757 ++++---- .../Deal.Ml/Extensions/LabelExtensions.cs | 1 - src/ml-service/Deal.Ml/MlServiceHost.cs | 1 - src/ml-service/Deal.Ml/MlServiceImpl.cs | 1 - src/ml-service/Deal.Ml/Model/EvalEntry.cs | 2 - src/ml-service/Deal.Ml/Model/LearnItem.cs | 2 - src/ml-service/Deal.Ml/Model/MlEvalInfo.cs | 2 - src/ml-service/Deal.Ml/Model/MlOptions.cs | 2 - .../Deal.Ml/Model/MlPredictResult.cs | 2 - .../Deal.Ml/Model/MlStatusResult.cs | 2 - .../Deal.Ml/Model/MlTypeDecision.cs | 2 - .../Deal.Ml/Model/ModelConstants.cs | 2 - src/ml-service/Deal.Ml/Model/ModelState.cs | 2 - src/ml-service/Deal.Ml/Storage/MlDb.cs | 1 - .../Deal.Telegram.Tests/AssemblyInfo.cs | 2 - .../Grpc/CoreIngressClientTests.cs | 2 - .../Grpc/DialogRpcTests.cs | 5 +- .../Grpc/DiscoveryProtoMapperTests.cs | 5 +- .../Grpc/DiscoveryRpcTests.cs | 6 +- .../Grpc/FakeIngressServer.cs | 5 +- .../Grpc/RealtimeListenerTests.cs | 5 +- .../Grpc/TelegramServiceHostTests.cs | 2 - .../Grpc/TelegramSessionRpcTests.cs | 5 +- .../Grpc/TelegramTestHost.cs | 6 +- .../Deal.Telegram.Tests/Grpc/TestDoubles.cs | 2 - .../Support/SessionStorageTests.cs | 3 +- .../Support/TenantSessionTests.cs | 3 +- .../Telegram/BackfillServiceTests.cs | 5 +- .../Telegram/DialogCatalogTests.cs | 2 - .../Telegram/DialogHueTests.cs | 2 - .../Telegram/DiscoveryOpsTests.cs | 5 +- .../Telegram/FakeSessionClient.cs | 3 - .../Telegram/FarmHarness.cs | 2 - .../Telegram/LruCacheTests.cs | 2 - .../Telegram/RealtimeSweepTests.cs | 5 +- .../Telegram/SessionHarness.cs | 1 - .../Telegram/TestSessionFactory.cs | 1 - .../Telegram/TlMessageMapperTests.cs | 3 - .../Deal.Telegram/Dialogs/BackfillService.cs | 1 - .../Dialogs/DialogProtoMapper.cs | 1 - .../Deal.Telegram/Dialogs/RealtimeListener.cs | 1 - .../Deal.Telegram/Dialogs/RealtimeSweep.cs | 1 - .../Deal.Telegram/Discovery/DiscoveryOps.cs | 1 - .../Discovery/DiscoveryProtoMapper.cs | 1 - .../Extensions/ExceptionExtensions.cs | 4 - .../Deal.Telegram/Sessions/SessionFarm.cs | 3 +- .../Deal.Telegram/Sessions/TenantSession.cs | 3 +- .../Deal.Telegram/Telegram/ClientFactory.cs | 2 - .../Deal.Telegram/Telegram/DialogKinds.cs | 2 - .../Telegram/DiscoveryMessage.cs | 2 - .../Telegram/DiscoveryReadResult.cs | 2 - .../Deal.Telegram/Telegram/ISessionClient.cs | 2 - .../Telegram/ITelegramClientFactory.cs | 2 - .../Deal.Telegram/Telegram/TelegramDialog.cs | 2 - .../Deal.Telegram/Telegram/TelegramMessage.cs | 2 - .../Telegram/TelegramSourceInfo.cs | 2 - .../Telegram/WTelegramSessionClient.cs | 2 +- .../Deal.Telegram/TelegramServiceHost.cs | 1 - .../Deal.Telegram/TelegramServiceImpl.cs | 3 +- 675 files changed, 47248 insertions(+), 49911 deletions(-) diff --git a/src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs b/src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs index 0e34a66..094cf06 100644 --- a/src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs +++ b/src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs @@ -1,10 +1,8 @@ +using System.Text.Json.Nodes; using Deal.Ai.Llm; using Deal.Grpc.Ai; using Grpc.Core; -using Grpc.Net.Client; using Microsoft.Extensions.DependencyInjection; -using System.Text.Json.Nodes; -using Deal.Ai.Tests.Support; namespace Deal.Ai.Tests.Ai; diff --git a/src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs b/src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs index dff36d3..d342cbd 100644 --- a/src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs +++ b/src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs @@ -1,13 +1,11 @@ -using Deal.Ai; +using System.Net; +using System.Net.Sockets; using Deal.Ai.Llm; using Deal.Grpc.Hosting; using Grpc.Core; using Grpc.Net.Client; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; -using System.Net; -using System.Net.Sockets; -using Deal.Ai.Tests.Support; namespace Deal.Ai.Tests.Ai; diff --git a/src/ai-service/Deal.Ai.Tests/Ai/FakeProviderClient.cs b/src/ai-service/Deal.Ai.Tests/Ai/FakeProviderClient.cs index 5b8dee3..e5d7f75 100644 --- a/src/ai-service/Deal.Ai.Tests/Ai/FakeProviderClient.cs +++ b/src/ai-service/Deal.Ai.Tests/Ai/FakeProviderClient.cs @@ -1,5 +1,4 @@ using Deal.Ai.Llm; -using Deal.Ai.Tests.Support; namespace Deal.Ai.Tests.Ai; diff --git a/src/ai-service/Deal.Ai.Tests/Ai/ProviderCallerTests.cs b/src/ai-service/Deal.Ai.Tests/Ai/ProviderCallerTests.cs index 718379b..df2749c 100644 --- a/src/ai-service/Deal.Ai.Tests/Ai/ProviderCallerTests.cs +++ b/src/ai-service/Deal.Ai.Tests/Ai/ProviderCallerTests.cs @@ -1,6 +1,5 @@ using Deal.Ai.Llm; using Microsoft.Extensions.Logging.Abstractions; -using Deal.Ai.Tests.Support; namespace Deal.Ai.Tests.Ai; diff --git a/src/ai-service/Deal.Ai.Tests/Ai/TokenEstimatorTests.cs b/src/ai-service/Deal.Ai.Tests/Ai/TokenEstimatorTests.cs index 7be3743..c8d3f25 100644 --- a/src/ai-service/Deal.Ai.Tests/Ai/TokenEstimatorTests.cs +++ b/src/ai-service/Deal.Ai.Tests/Ai/TokenEstimatorTests.cs @@ -1,5 +1,4 @@ using Deal.Ai.Llm; -using Deal.Ai.Tests.Support; namespace Deal.Ai.Tests.Ai; diff --git a/src/ai-service/Deal.Ai.Tests/AssemblyInfo.cs b/src/ai-service/Deal.Ai.Tests/AssemblyInfo.cs index cb37830..83c835f 100644 --- a/src/ai-service/Deal.Ai.Tests/AssemblyInfo.cs +++ b/src/ai-service/Deal.Ai.Tests/AssemblyInfo.cs @@ -1,5 +1,3 @@ -using Xunit; - // In-proc gRPC-тесты ai-service поднимают реальные Kestrel-хосты и меняют процесс-глобальную // env-переменную DEAL_SERVICE_TOKEN на время сценария (AiTestHost и AiServiceHostTests). // Параллельный прогон классов дал бы гонки на env — тесты сериализованы (тот же шаблон, что diff --git a/src/ai-service/Deal.Ai.Tests/Grpc/AiServiceHostTests.cs b/src/ai-service/Deal.Ai.Tests/Grpc/AiServiceHostTests.cs index c628090..d7b1fdc 100644 --- a/src/ai-service/Deal.Ai.Tests/Grpc/AiServiceHostTests.cs +++ b/src/ai-service/Deal.Ai.Tests/Grpc/AiServiceHostTests.cs @@ -1,13 +1,10 @@ +using System.Net; +using System.Net.Sockets; using Deal.Grpc.Ai; -using Deal.Ai; -using Microsoft.AspNetCore.Builder; using Grpc.Core; using Grpc.Health.V1; using Grpc.Net.Client; -using System.Net; -using System.Net.Sockets; -using Xunit; -using Deal.Ai.Tests.Support; +using Microsoft.AspNetCore.Builder; namespace Deal.Ai.Tests.Grpc; diff --git a/src/ai-service/Deal.Ai.Tests/Support/StubHttpMessageHandler.cs b/src/ai-service/Deal.Ai.Tests/Support/StubHttpMessageHandler.cs index 8e7c20b..9fc1499 100644 --- a/src/ai-service/Deal.Ai.Tests/Support/StubHttpMessageHandler.cs +++ b/src/ai-service/Deal.Ai.Tests/Support/StubHttpMessageHandler.cs @@ -1,7 +1,6 @@ using System.Net; using System.Text; using System.Text.Json.Nodes; -using Deal.Ai.Llm; namespace Deal.Ai.Tests.Support; diff --git a/src/core/Deal.Api/Configuration/CookieOptions.cs b/src/core/Deal.Api/Configuration/CookieOptions.cs index d8d954a..1531337 100644 --- a/src/core/Deal.Api/Configuration/CookieOptions.cs +++ b/src/core/Deal.Api/Configuration/CookieOptions.cs @@ -1,39 +1,33 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Configuration; - -/// -/// Настройки httpOnly-куки сессии. Привязываются из секции "Cookies" конфигурации (IOptions). -/// -/// -/// Источник значений — конфигурация: секция Cookies в appsettings.json / -/// appsettings.Development.json и переменные окружения Cookies__* (имя, срок, Secure). -/// -/// Срок жизни по умолчанию — единый источник числа «30 дней»: константа модуля -/// , на которую ссылается код-дефолт свойства -/// . Значение из конфигурации (Cookies__Days) при необходимости перекрывает его. -/// -/// -public sealed class CookieOptions -{ - /// - /// Имя куки (Ruling 6: deal_session). - /// - public string Name { get; set; } = "deal_session"; - - /// - /// Срок жизни куки в днях; совпадает со сроком жизни сессии (Ruling 6). - /// - public int Days { get; set; } = AuthService.SessionLifetimeDays; - - /// - /// Флаг Secure куки (dev=false; включается при HTTPS-проксировании, Ruling 6). - /// - public bool Secure { get; set; } -} +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Api.Configuration; + +/// +/// Настройки httpOnly-куки сессии. Привязываются из секции "Cookies" конфигурации (IOptions). +/// +/// +/// Источник значений — конфигурация: секция Cookies в appsettings.json / +/// appsettings.Development.json и переменные окружения Cookies__* (имя, срок, Secure). +/// +/// Срок жизни по умолчанию — единый источник числа «30 дней»: константа модуля +/// , на которую ссылается код-дефолт свойства +/// . Значение из конфигурации (Cookies__Days) при необходимости перекрывает его. +/// +/// +public sealed class CookieOptions +{ + /// + /// Имя куки (Ruling 6: deal_session). + /// + public string Name { get; set; } = "deal_session"; + + /// + /// Срок жизни куки в днях; совпадает со сроком жизни сессии (Ruling 6). + /// + public int Days { get; set; } = AuthService.SessionLifetimeDays; + + /// + /// Флаг Secure куки (dev=false; включается при HTTPS-проксировании, Ruling 6). + /// + public bool Secure { get; set; } +} diff --git a/src/core/Deal.Api/Configuration/DataRetentionOptions.cs b/src/core/Deal.Api/Configuration/DataRetentionOptions.cs index a0b849a..66faf83 100644 --- a/src/core/Deal.Api/Configuration/DataRetentionOptions.cs +++ b/src/core/Deal.Api/Configuration/DataRetentionOptions.cs @@ -1,6 +1,3 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - namespace Deal.Api.Configuration; /// diff --git a/src/core/Deal.Api/Configuration/ForwardedHeadersConfig.cs b/src/core/Deal.Api/Configuration/ForwardedHeadersConfig.cs index 1a4ea7a..deeb96e 100644 --- a/src/core/Deal.Api/Configuration/ForwardedHeadersConfig.cs +++ b/src/core/Deal.Api/Configuration/ForwardedHeadersConfig.cs @@ -1,42 +1,39 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Configuration; - -/// -/// Настройки доверия прокси-заголовкам (план Task 12; замечание ревью T4/T11 к Ruling 5/10): секция -/// ForwardedHeaders конфигурации (appsettings.json + переменные окружения ForwardedHeaders__*). -/// -/// -/// В PROD наружу смотрит только Caddy (compose-prod, Task 14), core принимает соединения от него: -/// без обработки X-Forwarded-For/X-Forwarded-Proto RemoteIpAddress (HttpContext.Connection) -/// всех запросов — адрес Caddy, и audit-IP (ClientIp эндпоинтов, Ruling 4) и rate-limit-по-IP -/// (политики Ruling 5, LoginAttemptGuard) схлопываются в один бакет прокси. UseForwardedHeaders -/// доверяет заголовкам только клиентов из (IP-адреса) и -/// (подсети CIDR). -/// -/// Enabled=false — код-дефолт и значение dev/тестов: прокси в dev-стеке нет (compose.dev — -/// core наружу напрямую :5080), curl-приёмки от заголовков не зависят. PROD включает env -/// (ForwardedHeaders__Enabled=true) и перечисляет Caddy: KnownProxies (его IP) либо KnownNetworks -/// (узкий CIDR compose-сети). Пустые KnownProxies/KnownNetworks у ForwardedHeadersMiddleware означают -/// «доверять любому клиенту» — Program.BuildForwardedHeadersOptions не допускает пустоту и добавляет -/// loopback-фолбэк (dev-прокси на хосте: vite/локальный Caddy); явное перечисление в конфиге замещает его. -/// -/// -public sealed class ForwardedHeadersConfig -{ - /// - /// Включена ли обработка прокси-заголовков (dev/тесты — false; PROD за Caddy — true). - /// - public bool Enabled { get; set; } - - /// - /// Доверенные прокси-адреса: IP клиентов, которым можно верить в X-Forwarded-For/Proto. - /// - public string[] KnownProxies { get; set; } = []; - - /// - /// Доверенные подсети прокси в CIDR (например "172.16.0.0/12" — compose-сеть PROD). - /// - public string[] KnownNetworks { get; set; } = []; -} +namespace Deal.Api.Configuration; + +/// +/// Настройки доверия прокси-заголовкам (план Task 12; замечание ревью T4/T11 к Ruling 5/10): секция +/// ForwardedHeaders конфигурации (appsettings.json + переменные окружения ForwardedHeaders__*). +/// +/// +/// В PROD наружу смотрит только Caddy (compose-prod, Task 14), core принимает соединения от него: +/// без обработки X-Forwarded-For/X-Forwarded-Proto RemoteIpAddress (HttpContext.Connection) +/// всех запросов — адрес Caddy, и audit-IP (ClientIp эндпоинтов, Ruling 4) и rate-limit-по-IP +/// (политики Ruling 5, LoginAttemptGuard) схлопываются в один бакет прокси. UseForwardedHeaders +/// доверяет заголовкам только клиентов из (IP-адреса) и +/// (подсети CIDR). +/// +/// Enabled=false — код-дефолт и значение dev/тестов: прокси в dev-стеке нет (compose.dev — +/// core наружу напрямую :5080), curl-приёмки от заголовков не зависят. PROD включает env +/// (ForwardedHeaders__Enabled=true) и перечисляет Caddy: KnownProxies (его IP) либо KnownNetworks +/// (узкий CIDR compose-сети). Пустые KnownProxies/KnownNetworks у ForwardedHeadersMiddleware означают +/// «доверять любому клиенту» — Program.BuildForwardedHeadersOptions не допускает пустоту и добавляет +/// loopback-фолбэк (dev-прокси на хосте: vite/локальный Caddy); явное перечисление в конфиге замещает его. +/// +/// +public sealed class ForwardedHeadersConfig +{ + /// + /// Включена ли обработка прокси-заголовков (dev/тесты — false; PROD за Caddy — true). + /// + public bool Enabled { get; set; } + + /// + /// Доверенные прокси-адреса: IP клиентов, которым можно верить в X-Forwarded-For/Proto. + /// + public string[] KnownProxies { get; set; } = []; + + /// + /// Доверенные подсети прокси в CIDR (например "172.16.0.0/12" — compose-сеть PROD). + /// + public string[] KnownNetworks { get; set; } = []; +} diff --git a/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs b/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs index 0828b95..0524791 100644 --- a/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs +++ b/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs @@ -1,41 +1,35 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Configuration; - -/// -/// Настройки httpOnly-куки сессии оператора. Привязываются из секции "OperatorCookies" конфигурации (IOptions). -/// -/// -/// Источник значений — конфигурация: секция OperatorCookies в appsettings.json и переменные -/// окружения OperatorCookies__* (имя, срок, Secure). Имя по умолчанию — deal_operator_session: -/// отдельная от тенантной deal_session кука (Ruling 1 этапа 7) — операторская сессия не может быть -/// подменена тенантной и наоборот (сессии разрешаются разными middleware). -/// -/// Срок жизни по умолчанию — единый источник числа «12 часов»: константа модуля -/// , на которую ссылается код-дефолт свойства -/// . Значение из конфигурации (OperatorCookies__Hours) при необходимости перекрывает его. -/// -/// -public sealed class OperatorCookieOptions -{ - /// - /// Имя куки (Ruling 1: deal_operator_session). - /// - public string Name { get; set; } = "deal_operator_session"; - - /// - /// Срок жизни куки в часах; совпадает со сроком жизни сессии оператора (Ruling 1: 12). - /// - public int Hours { get; set; } = OperatorAuthService.SessionLifetimeHours; - - /// - /// Флаг Secure куки (dev=false; включается при HTTPS-проксировании, Ruling 1). - /// - public bool Secure { get; set; } -} +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Api.Configuration; + +/// +/// Настройки httpOnly-куки сессии оператора. Привязываются из секции "OperatorCookies" конфигурации (IOptions). +/// +/// +/// Источник значений — конфигурация: секция OperatorCookies в appsettings.json и переменные +/// окружения OperatorCookies__* (имя, срок, Secure). Имя по умолчанию — deal_operator_session: +/// отдельная от тенантной deal_session кука (Ruling 1 этапа 7) — операторская сессия не может быть +/// подменена тенантной и наоборот (сессии разрешаются разными middleware). +/// +/// Срок жизни по умолчанию — единый источник числа «12 часов»: константа модуля +/// , на которую ссылается код-дефолт свойства +/// . Значение из конфигурации (OperatorCookies__Hours) при необходимости перекрывает его. +/// +/// +public sealed class OperatorCookieOptions +{ + /// + /// Имя куки (Ruling 1: deal_operator_session). + /// + public string Name { get; set; } = "deal_operator_session"; + + /// + /// Срок жизни куки в часах; совпадает со сроком жизни сессии оператора (Ruling 1: 12). + /// + public int Hours { get; set; } = OperatorAuthService.SessionLifetimeHours; + + /// + /// Флаг Secure куки (dev=false; включается при HTTPS-проксировании, Ruling 1). + /// + public bool Secure { get; set; } +} diff --git a/src/core/Deal.Api/Configuration/RateLimitOptions.cs b/src/core/Deal.Api/Configuration/RateLimitOptions.cs index 9ca2b77..0970c26 100644 --- a/src/core/Deal.Api/Configuration/RateLimitOptions.cs +++ b/src/core/Deal.Api/Configuration/RateLimitOptions.cs @@ -1,46 +1,43 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Configuration; - -/// -/// Настройки rate limiting и защиты входа (план Task 11, Ruling 5): секция RateLimit конфигурации -/// (appsettings.json + переменные окружения RateLimit__*). -/// -/// -/// Enabled=false — код-дефолт и значение dev/тестов: политики и middleware не регистрируются вовсе, -/// curl-приёмки и unit-хосты не режутся. PROD включает env-переопределением (RateLimit__Enabled=true -/// в compose-prod, Task 14). Все окна политик — фиксированные, 1 минута (имена свойств — «PerMinute»). -/// -public sealed class RateLimitOptions -{ - /// - /// Включены ли rate limiting и LoginAttemptGuard (dev/тесты — false, Ruling 5). - /// - public bool Enabled { get; set; } - - /// - /// Лимит политики "auth" (фиксированное окно в минуту на IP) для /api/auth/login и /api/operator/auth/login. - /// - public int AuthPerMinute { get; set; } = 10; - - /// - /// Лимит политики "api" (в минуту на тенанта либо IP анонима) для остальных /api-эндпоинтов. - /// - public int ApiPerMinute { get; set; } = 600; - - /// - /// Лимит gRPC-ингресса (в минуту на tenant-id из metadata; интерцептор IngressRateLimitInterceptor). - /// - public int GrpcIngressPerMinute { get; set; } = 600; - - /// - /// Порог неудачных попыток входа ключа ip|login до блокировки (LoginAttemptGuard). - /// - public int LoginAttemptsMax { get; set; } = 5; - - /// - /// Окно учёта неудачных попыток входа в минутах (LoginAttemptGuard; текст 429 — фиксированный «15 минут»). - /// - public int LoginAttemptWindowMin { get; set; } = 15; -} +namespace Deal.Api.Configuration; + +/// +/// Настройки rate limiting и защиты входа (план Task 11, Ruling 5): секция RateLimit конфигурации +/// (appsettings.json + переменные окружения RateLimit__*). +/// +/// +/// Enabled=false — код-дефолт и значение dev/тестов: политики и middleware не регистрируются вовсе, +/// curl-приёмки и unit-хосты не режутся. PROD включает env-переопределением (RateLimit__Enabled=true +/// в compose-prod, Task 14). Все окна политик — фиксированные, 1 минута (имена свойств — «PerMinute»). +/// +public sealed class RateLimitOptions +{ + /// + /// Включены ли rate limiting и LoginAttemptGuard (dev/тесты — false, Ruling 5). + /// + public bool Enabled { get; set; } + + /// + /// Лимит политики "auth" (фиксированное окно в минуту на IP) для /api/auth/login и /api/operator/auth/login. + /// + public int AuthPerMinute { get; set; } = 10; + + /// + /// Лимит политики "api" (в минуту на тенанта либо IP анонима) для остальных /api-эндпоинтов. + /// + public int ApiPerMinute { get; set; } = 600; + + /// + /// Лимит gRPC-ингресса (в минуту на tenant-id из metadata; интерцептор IngressRateLimitInterceptor). + /// + public int GrpcIngressPerMinute { get; set; } = 600; + + /// + /// Порог неудачных попыток входа ключа ip|login до блокировки (LoginAttemptGuard). + /// + public int LoginAttemptsMax { get; set; } = 5; + + /// + /// Окно учёта неудачных попыток входа в минутах (LoginAttemptGuard; текст 429 — фиксированный «15 минут»). + /// + 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 a391004..7f20ba6 100644 --- a/src/core/Deal.Api/Configuration/SecurityOptions.cs +++ b/src/core/Deal.Api/Configuration/SecurityOptions.cs @@ -1,28 +1,25 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Configuration; - -/// -/// Настройки безопасности HTTP (план Task 12, Ruling 10(2)/9): секция Security конфигурации -/// (appsettings.json + переменные окружения Security__*). -/// -/// -/// — единый явный allowlist для Origin-проверки мутаций -/// () и CORS-политики. Пустой список — dev-режим: -/// OriginGuard принимает только «свой» origin запроса (схема + Host, для не-GET запросов /api), -/// CORS разрешает любой origin (текущее поведение «как в прототипе»). Непустой список (PROD, -/// compose-prod, Ruling 9) — CORS становится строгим allowlist + credentials; OriginGuard дополнительно -/// принимает перечисленные origin'ы (в т.ч. когда запрос идёт не от «своего» Host — фронт за прокси). -/// -/// Записи — полные origin'ы в том виде, в каком их шлёт браузер: схема://хост[:порт], без завершающего -/// слэша (например https://deal.example, http://localhost:5173). Сравнение регистронезависимо. -/// -/// -public sealed class SecurityOptions -{ - /// - /// Явный allowlist Origin/CORS (схема://хост[:порт]); пусто — dev-режим «любой origin + свой Host». - /// - public string[] AllowedOrigins { get; set; } = []; -} +namespace Deal.Api.Configuration; + +/// +/// Настройки безопасности HTTP (план Task 12, Ruling 10(2)/9): секция Security конфигурации +/// (appsettings.json + переменные окружения Security__*). +/// +/// +/// — единый явный allowlist для Origin-проверки мутаций +/// () и CORS-политики. Пустой список — dev-режим: +/// OriginGuard принимает только «свой» origin запроса (схема + Host, для не-GET запросов /api), +/// CORS разрешает любой origin (текущее поведение «как в прототипе»). Непустой список (PROD, +/// compose-prod, Ruling 9) — CORS становится строгим allowlist + credentials; OriginGuard дополнительно +/// принимает перечисленные origin'ы (в т.ч. когда запрос идёт не от «своего» Host — фронт за прокси). +/// +/// Записи — полные origin'ы в том виде, в каком их шлёт браузер: схема://хост[:порт], без завершающего +/// слэша (например https://deal.example, http://localhost:5173). Сравнение регистронезависимо. +/// +/// +public sealed class SecurityOptions +{ + /// + /// Явный allowlist Origin/CORS (схема://хост[:порт]); пусто — dev-режим «любой origin + свой Host». + /// + public string[] AllowedOrigins { get; set; } = []; +} diff --git a/src/core/Deal.Api/Dtos/AdminTickResultDto.cs b/src/core/Deal.Api/Dtos/AdminTickResultDto.cs index 962b492..09db2e5 100644 --- a/src/core/Deal.Api/Dtos/AdminTickResultDto.cs +++ b/src/core/Deal.Api/Dtos/AdminTickResultDto.cs @@ -1,29 +1,26 @@ -using Deal.Modules.Kanban.Application.Models; -using Deal.Api.Services; -using Deal.Api; -using Deal.Api.Dtos; - -namespace Deal.Api.Dtos; - -/// -/// Ответ POST /api/admin/tick — форма {storage, reminders, pipeline, queue} (dashboard_routes.py admin_tick L327–337, план Task 10). -/// -/// -/// Поля 1:1 с прототипом: storage — статистика тика правил хранения с очисткой отсева -/// (, purgedRejected объединяет purge пайплайна — Ruling 9, tick_storage -/// L485–493); reminders — «выстрелившие» напоминания «Отложено» этапа 5 (план Task 11, Ruling 3/8): те же -/// записи {id,title,containerId}, что ушли SSE-событиями reminder_due (check_reminders admin_tick L334/L337), -/// пусто — сработавших нет либо проверка недоступна; pipeline — счётчики одного прохода pump (ключи словаря -/// python L921: staged/rulesStored/mlStored/mlDrop/typeDrop/aiStored/aiDrop/aiFail/noBudget; пусто — pump не -/// выполнялся/сбой, как {} при занятом локе прототипа); queue — число строк очереди входящих после pump -/// (queue_len L337). Наружу сериализуется в camelCase (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} (dashboard_routes.py admin_tick L327–337, план Task 10). +/// +/// +/// Поля 1:1 с прототипом: storage — статистика тика правил хранения с очисткой отсева +/// (, purgedRejected объединяет purge пайплайна — Ruling 9, tick_storage +/// L485–493); reminders — «выстрелившие» напоминания «Отложено» этапа 5 (план Task 11, Ruling 3/8): те же +/// записи {id,title,containerId}, что ушли SSE-событиями reminder_due (check_reminders admin_tick L334/L337), +/// пусто — сработавших нет либо проверка недоступна; pipeline — счётчики одного прохода pump (ключи словаря +/// python L921: staged/rulesStored/mlStored/mlDrop/typeDrop/aiStored/aiDrop/aiFail/noBudget; пусто — pump не +/// выполнялся/сбой, как {} при занятом локе прототипа); queue — число строк очереди входящих после pump +/// (queue_len L337). Наружу сериализуется в camelCase (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 72646f2..b2ead6f 100644 --- a/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs +++ b/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs @@ -1,176 +1,172 @@ -using System.Text.Json; -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Эндпоинт проверки подключения AI-провайдера: POST /api/ai/check (Ruling 7/8, api-map §4.10). -/// -/// -/// «Только для Settings-экрана» (Ruling 8): фронт жмёт «Проверить подключение» (store.js -/// checkAiConnection) БЕЗ тела — сервер читает АКТИВНУЮ конфигурацию провайдера тенанта -/// (настройки aiProvider + aiConfigs с расшифровкой ключа через ; -/// 1:1 с ai_svc._cfg(), ai.py L25–33), вызывает порт и отдаёт -/// {ok, message} + статус провайдера. Требует сессию: 401 {detail} (формат прототипа). -/// Резолв scoped-зависимостей — через RequestServices ПОСЛЕ проверки сессии (как SettingsEndpoints: -/// DI-биндинг параметров выполняется до тела, а ISettingsStore требует tenant-контекст запроса). -/// -public static class AiCheckEndpoint -{ - // Префикс группы API (общий для эндпоинтов этапа, Ruling 8). - private const string ApiGroupPrefix = "/api"; - - // Путь проверки подключения AI-провайдера. - private const string AiCheckPath = "/ai/check"; - - // OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py). - 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); - } - - // Собирает запрос проверки из активной конфигурации провайдера (1:1 с ai_svc._cfg()). - // store: KV-хранилище настроек тенанта. - // secretCipher: Шифр секретов (расшифровка apiKey). - // ct: Токен отмены. - // Возвращает: Запрос проверки: id провайдера + эффективные base/model + расшифрованный ключ. - // Эффективные значения = дефолты SettingsDefaults, перекрытые сохранёнными - // переопределениями (Ruling 1); пустое переопределение base/model → дефолт каталога - // (семантика «cfg.get(...) or meta[...]» прототипа). - 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-провайдера: POST /api/ai/check (Ruling 7/8, api-map §4.10). +/// +/// +/// «Только для Settings-экрана» (Ruling 8): фронт жмёт «Проверить подключение» (store.js +/// checkAiConnection) БЕЗ тела — сервер читает АКТИВНУЮ конфигурацию провайдера тенанта +/// (настройки aiProvider + aiConfigs с расшифровкой ключа через ; +/// 1:1 с ai_svc._cfg(), ai.py L25–33), вызывает порт и отдаёт +/// {ok, message} + статус провайдера. Требует сессию: 401 {detail} (формат прототипа). +/// Резолв scoped-зависимостей — через RequestServices ПОСЛЕ проверки сессии (как SettingsEndpoints: +/// DI-биндинг параметров выполняется до тела, а ISettingsStore требует tenant-контекст запроса). +/// +public static class AiCheckEndpoint +{ + // Префикс группы API (общий для эндпоинтов этапа, Ruling 8). + private const string ApiGroupPrefix = "/api"; + + // Путь проверки подключения AI-провайдера. + private const string AiCheckPath = "/ai/check"; + + // OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py). + 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); + } + + // Собирает запрос проверки из активной конфигурации провайдера (1:1 с ai_svc._cfg()). + // store: KV-хранилище настроек тенанта. + // secretCipher: Шифр секретов (расшифровка apiKey). + // ct: Токен отмены. + // Возвращает: Запрос проверки: id провайдера + эффективные base/model + расшифрованный ключ. + // Эффективные значения = дефолты SettingsDefaults, перекрытые сохранёнными + // переопределениями (Ruling 1); пустое переопределение base/model → дефолт каталога + // (семантика «cfg.get(...) or meta[...]» прототипа). + 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 85e6b21..b172654 100644 --- a/src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs @@ -1,99 +1,97 @@ -using Deal.Api.Events; -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Эндпоинты ИИ-предложений: POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords -/// (план Task 14 L476–479; прототип dashboard_routes.py L395–409). -/// -/// -/// Контракт 1:1 с прототипом и api-map §3.2 L120–121: suggest-columns → {ok:true, created:N} -/// либо {ok:false, reason, cooldown?}; suggest-keywords → {ok:true, keywords:[…]} либо -/// {ok:false, reason}. Причины — мягкие ошибки (HTTP 200 с ok:false + reason), статусы 4xx/5xx -/// не мапятся. Оба требуют сессию: 401 {detail} без куки (как остальные эндпоинты контейнеров); порт -/// IColumnSuggester резолвится из RequestServices ПОСЛЕ проверки сессии (scoped на tenant-контекст -/// запроса). При успехе suggest-columns публикуется SSE-toast «ИИ предложил колонок: N — откройте и -/// решите» (sparkles, 1:1 с suggest.py L162); boards_changed НЕ шлём (Ruling 5: фронт перечитывает -/// доски сам после ok). Публикации — из эндпоинта (Ruling 5): без подписчиков — no-op. -/// -public static class AiSuggestEndpoints -{ - // Префикс группы AI-эндпоинтов этапа (роутер dashboard, prefix="/api"; пути L395/L406). - private const string AiGroupPrefix = "/api/ai"; - - // Путь предложения колонок (dashboard_routes.py L395). - private const string SuggestColumnsPath = "/suggest-columns"; - - // Путь предложения ключевых слов (dashboard_routes.py L404). - private const string SuggestKeywordsPath = "/suggest-keywords"; - - // OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py). - private const string OpenApiTag = "dashboard"; - - // SSE-тип события тоста (Ruling 5; api.js слушает 'toast'). - private const string ToastEventType = "toast"; - - // Текст тоста после успешных предложений колонок (suggest.py L162, 1:1). - private const string SuggestToastTextFormat = "ИИ предложил колонок: {0} — откройте и решите"; - - // Иконка тоста предложений колонок (sparkles, 1:1 с прототипом). - 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; - } - - // POST /api/ai/suggest-columns: анализ «Неразобранного» и создание колонок-предложений. - // Ответ — результат порта 1:1: {ok:true, created:N} — доски suggested=true созданы (эндпоинт шлёт - // SSE-toast), {ok:false, reason} (+ cooldown) — мягкая причина (HTTP 200). Кулдаун/«мало карточек»/ - // «похожие колонки уже есть» — за адаптером LocalColumnSuggester (Ruling 3). - 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); - } - - // POST /api/ai/suggest-keywords: слова-маркеры сферы по карточкам (настройки «Сфера и ключи»). - // Ответ — результат порта 1:1: {ok:true, keywords:[…]} (≤60) либо {ok:false, reason} (HTTP 200). - 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; + +/// +/// Эндпоинты ИИ-предложений: POST /api/ai/suggest-columns и POST /api/ai/suggest-keywords +/// (план Task 14 L476–479; прототип dashboard_routes.py L395–409). +/// +/// +/// Контракт 1:1 с прототипом и api-map §3.2 L120–121: suggest-columns → {ok:true, created:N} +/// либо {ok:false, reason, cooldown?}; suggest-keywords → {ok:true, keywords:[…]} либо +/// {ok:false, reason}. Причины — мягкие ошибки (HTTP 200 с ok:false + reason), статусы 4xx/5xx +/// не мапятся. Оба требуют сессию: 401 {detail} без куки (как остальные эндпоинты контейнеров); порт +/// IColumnSuggester резолвится из RequestServices ПОСЛЕ проверки сессии (scoped на tenant-контекст +/// запроса). При успехе suggest-columns публикуется SSE-toast «ИИ предложил колонок: N — откройте и +/// решите» (sparkles, 1:1 с suggest.py L162); boards_changed НЕ шлём (Ruling 5: фронт перечитывает +/// доски сам после ok). Публикации — из эндпоинта (Ruling 5): без подписчиков — no-op. +/// +public static class AiSuggestEndpoints +{ + // Префикс группы AI-эндпоинтов этапа (роутер dashboard, prefix="/api"; пути L395/L406). + private const string AiGroupPrefix = "/api/ai"; + + // Путь предложения колонок (dashboard_routes.py L395). + private const string SuggestColumnsPath = "/suggest-columns"; + + // Путь предложения ключевых слов (dashboard_routes.py L404). + private const string SuggestKeywordsPath = "/suggest-keywords"; + + // OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py). + private const string OpenApiTag = "dashboard"; + + // SSE-тип события тоста (Ruling 5; api.js слушает 'toast'). + private const string ToastEventType = "toast"; + + // Текст тоста после успешных предложений колонок (suggest.py L162, 1:1). + private const string SuggestToastTextFormat = "ИИ предложил колонок: {0} — откройте и решите"; + + // Иконка тоста предложений колонок (sparkles, 1:1 с прототипом). + 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; + } + + // POST /api/ai/suggest-columns: анализ «Неразобранного» и создание колонок-предложений. + // Ответ — результат порта 1:1: {ok:true, created:N} — доски suggested=true созданы (эндпоинт шлёт + // SSE-toast), {ok:false, reason} (+ cooldown) — мягкая причина (HTTP 200). Кулдаун/«мало карточек»/ + // «похожие колонки уже есть» — за адаптером LocalColumnSuggester (Ruling 3). + 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); + } + + // POST /api/ai/suggest-keywords: слова-маркеры сферы по карточкам (настройки «Сфера и ключи»). + // Ответ — результат порта 1:1: {ok:true, keywords:[…]} (≤60) либо {ok:false, reason} (HTTP 200). + 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/AuthEndpoints.cs b/src/core/Deal.Api/Endpoints/AuthEndpoints.cs index 2dcfa90..0497ed8 100644 --- a/src/core/Deal.Api/Endpoints/AuthEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/AuthEndpoints.cs @@ -1,217 +1,213 @@ -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Api.Middleware; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.Extensions.Options; -using Deal.Api.Dtos; -// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. -using CookieOptions = Deal.Api.Configuration.CookieOptions; - -namespace Deal.Api.Endpoints; - -/// -/// HTTP-эндпоинты аутентификации (группа /api/auth). Контракт 1:1 с прототипом auth_routes.py. -/// -/// -/// Успех-ответы — {ok:true,...}, ошибки — HTTP-код + {"detail":"..."} (Ruling 10). -/// Кука сессии выставляется на login и change-password (свежий токен). Сообщения об ошибках — -/// фиксированные строки прототипа. -/// -public static class AuthEndpoints -{ - private const string InvalidCredentialsDetail = "Неверный логин или пароль"; - private const string WrongOldPasswordDetail = "Текущий пароль неверен"; - private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)"; - private const string TenantSuspendedDetail = "Учётная запись приостановлена. Обратитесь к оператору"; - private const string AuthGroupPrefix = "/api/auth"; - private const string AuthOpenApiTag = "auth"; - - /// - /// Регистрирует группу /api/auth: login, logout, me, change-password. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(AuthGroupPrefix).WithTags(AuthOpenApiTag); - - // Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP - // ручки входа; остальные ручки группы — под глобальной API-политикой (по тенанту/IP). - group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy); - group.MapPost("/logout", LogoutAsync); - group.MapGet("/me", MeAsync); - group.MapPost("/change-password", ChangePasswordAsync); - - return app; - } - - // POST /api/auth/login: проверка учётных данных, выдача куки сессии; результат пишется в аудит (Task 4/7). - // До AuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5). - private static async Task LoginAsync( - LoginRequest body, - AuthService authService, - AuditService auditService, - IOptions cookieOptions, - HttpContext context, - CancellationToken ct, - LoginAttemptGuard loginAttemptGuard) - { - string? attemptedLogin = NormalizeLogin(body.Login); - - // Защита входа (план Task 11, Ruling 5): блокировка ключа ip|login до проверки учётных данных — - // 429 «Слишком много попыток входа…» (в dev при RateLimit:Enabled=false гвард выключен). - if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct)) - { - return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail); - } - - var result = await authService.LoginAsync(body.Login, body.Password, ct); - - // Приостановленный тенант: вход заблокирован (Ruling 10(5)). Отдельный текст от «неверных учётных - // данных»; tenant_login_failed пишется с tenantId и ActorId (ревью Task 4: failed-логины suspended-тенанта). - // Решение Task 7: HTTP-код 403 (а не 401) — учётка существует, доступ запрещён; приёмочный текст плана - // Task 16 формулирует «login 401» — отклонение зафиксировано для api-map/техдок в task-7-report.md. - if (result.Error == LoginResultDto.ErrorTenantSuspended) - { - await auditService.AppendAsync(new AuditRecordDto( - AuditEvents.TenantLoginFailed, - AuditActorTypes.Tenant, - ActorId: result.UserId, - TenantId: result.TenantId, - Ip: ClientIp(context), - DetailJson: AuditService.ToDetailJson(new { login = NormalizeLogin(body.Login) })), ct); - - return EndpointResults.Forbidden(TenantSuspendedDetail); - } - - if (result.Login is null || result.Token is null) - { - // Пустой/пробельный login и неверные учётные данные — одно сообщение (семантика прототипа). - // Аудит tenant_login_failed пишем только для реальной попытки (непустой логин), без пароля (Ruling 4); - // счётчик неудач гварда растёт там же — пустые логины ключа не имеют (блокирует только auth-политика). - if (attemptedLogin is not null) - { - await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct); - await auditService.AppendAsync(new AuditRecordDto( - AuditEvents.TenantLoginFailed, - AuditActorTypes.Tenant, - ActorId: null, - TenantId: null, - Ip: ClientIp(context), - DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct); - } - - return EndpointResults.Unauthorized(InvalidCredentialsDetail); - } - - // Успешный вход сбрасывает счётчик неудач ключа ip|login (Ruling 5). - await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct); - - await auditService.AppendAsync(new AuditRecordDto( - AuditEvents.TenantLoginOk, - AuditActorTypes.Tenant, - ActorId: result.UserId, - TenantId: result.TenantId, - Ip: ClientIp(context), - DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct); - - SessionCookieWriter.Append(context, cookieOptions.Value, result.Token); - return Results.Ok(new { ok = true, login = result.Login }); - } - - // POST /api/auth/logout: удаление сессии по токену из куки и очистка куки (всегда ok). - // Если удалённая сессия была impersonation — пишется аудит impersonation_stopped (Task 7, ревью: полный аудит). - private static async Task LogoutAsync( - AuthService authService, - AuditService auditService, - IOptions cookieOptions, - HttpContext context, - CancellationToken ct) - { - var cookieName = cookieOptions.Value.Name; - var rawToken = context.Request.Cookies[cookieName]; - // Пользователь разрешённой сессии — до её удаления (SessionMiddleware наполнил Items на старте запроса). - CurrentUser? user = context.GetCurrentUser(); - var logout = await authService.LogoutAsync(rawToken, ct); - if (logout is not null) - { - // Актор — оператор, начавший impersonation (маркер сессии); тенант — для фильтра TenantId. - await auditService.AppendAsync(new AuditRecordDto( - AuditEvents.ImpersonationStopped, - AuditActorTypes.Operator, - ActorId: logout.OperatorId, - TenantId: logout.TenantId, - Ip: ClientIp(context), - DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct); - } - - // Выход пользователя тенанта (этап 10, T1): событие пишется при живой разрешённой сессии. - if (user is not null) - { - await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct); - } - - context.Response.Cookies.Delete(cookieName); - return Results.Ok(new { ok = true }); - } - - // GET /api/auth/me: проверка живой сессии. - private static IResult MeAsync(HttpContext context) - { - var user = context.GetCurrentUser(); - if (user is null) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - return Results.Ok(new { login = user.Login, ok = true }); - } - - // POST /api/auth/change-password: смена пароля и перевыпуск куки (свежая сессия). - private static async Task ChangePasswordAsync( - ChangePasswordRequest body, - AuthService authService, - IOptions cookieOptions, - HttpContext context, - CancellationToken ct) - { - var user = context.GetCurrentUser(); - if (user is null) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - var result = await authService.ChangePasswordAsync(user.Login, body.OldPassword, body.NewPassword, ct); - if (!result.Ok || result.NewToken is null) - { - // Семантика прототипа: код ошибки различает «старый пароль неверен» и «слишком короткий». - var detail = result.Error == ChangePasswordResultDto.ErrorTooShort - ? PasswordTooShortDetail - : WrongOldPasswordDetail; - return EndpointResults.BadRequest(detail); - } - - // Старые сессии удалены внутри сервиса; выдаём клиенту свежую куку. - SessionCookieWriter.Append(context, cookieOptions.Value, result.NewToken); - return Results.Ok(new { ok = true }); - } - - // Нормализованная попытка логина для аудита (нижний регистр/обрезка, как AuthService); null — писать нечего. - // login: Логин из тела запроса. - // Возвращает: Нормализованный логин или null при пустом/пробельном входе. - private static string? NormalizeLogin(string? login) - { - string? normalized = login?.Trim().ToLowerInvariant(); - return string.IsNullOrEmpty(normalized) ? null : normalized; - } - - // IP-адрес клиента для аудита (без порта; null, если недоступен). - // context: Контекст запроса. - // Возвращает: Строковое представление IP или null. - private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); -} +using Deal.Api.Extensions; +using Deal.Api.Middleware; +using Deal.Api.Models; +using Deal.Api.Services; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; +using Microsoft.Extensions.Options; +// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. +using CookieOptions = Deal.Api.Configuration.CookieOptions; + +namespace Deal.Api.Endpoints; + +/// +/// HTTP-эндпоинты аутентификации (группа /api/auth). Контракт 1:1 с прототипом auth_routes.py. +/// +/// +/// Успех-ответы — {ok:true,...}, ошибки — HTTP-код + {"detail":"..."} (Ruling 10). +/// Кука сессии выставляется на login и change-password (свежий токен). Сообщения об ошибках — +/// фиксированные строки прототипа. +/// +public static class AuthEndpoints +{ + private const string InvalidCredentialsDetail = "Неверный логин или пароль"; + private const string WrongOldPasswordDetail = "Текущий пароль неверен"; + private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)"; + private const string TenantSuspendedDetail = "Учётная запись приостановлена. Обратитесь к оператору"; + private const string AuthGroupPrefix = "/api/auth"; + private const string AuthOpenApiTag = "auth"; + + /// + /// Регистрирует группу /api/auth: login, logout, me, change-password. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup(AuthGroupPrefix).WithTags(AuthOpenApiTag); + + // Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP + // ручки входа; остальные ручки группы — под глобальной API-политикой (по тенанту/IP). + group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy); + group.MapPost("/logout", LogoutAsync); + group.MapGet("/me", MeAsync); + group.MapPost("/change-password", ChangePasswordAsync); + + return app; + } + + // POST /api/auth/login: проверка учётных данных, выдача куки сессии; результат пишется в аудит (Task 4/7). + // До AuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5). + private static async Task LoginAsync( + LoginRequest body, + AuthService authService, + AuditService auditService, + IOptions cookieOptions, + HttpContext context, + CancellationToken ct, + LoginAttemptGuard loginAttemptGuard) + { + string? attemptedLogin = NormalizeLogin(body.Login); + + // Защита входа (план Task 11, Ruling 5): блокировка ключа ip|login до проверки учётных данных — + // 429 «Слишком много попыток входа…» (в dev при RateLimit:Enabled=false гвард выключен). + if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct)) + { + return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail); + } + + var result = await authService.LoginAsync(body.Login, body.Password, ct); + + // Приостановленный тенант: вход заблокирован (Ruling 10(5)). Отдельный текст от «неверных учётных + // данных»; tenant_login_failed пишется с tenantId и ActorId (ревью Task 4: failed-логины suspended-тенанта). + // Решение Task 7: HTTP-код 403 (а не 401) — учётка существует, доступ запрещён; приёмочный текст плана + // Task 16 формулирует «login 401» — отклонение зафиксировано для api-map/техдок в task-7-report.md. + if (result.Error == LoginResultDto.ErrorTenantSuspended) + { + await auditService.AppendAsync(new AuditRecordDto( + AuditEvents.TenantLoginFailed, + AuditActorTypes.Tenant, + ActorId: result.UserId, + TenantId: result.TenantId, + Ip: ClientIp(context), + DetailJson: AuditService.ToDetailJson(new { login = NormalizeLogin(body.Login) })), ct); + + return EndpointResults.Forbidden(TenantSuspendedDetail); + } + + if (result.Login is null || result.Token is null) + { + // Пустой/пробельный login и неверные учётные данные — одно сообщение (семантика прототипа). + // Аудит tenant_login_failed пишем только для реальной попытки (непустой логин), без пароля (Ruling 4); + // счётчик неудач гварда растёт там же — пустые логины ключа не имеют (блокирует только auth-политика). + if (attemptedLogin is not null) + { + await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct); + await auditService.AppendAsync(new AuditRecordDto( + AuditEvents.TenantLoginFailed, + AuditActorTypes.Tenant, + ActorId: null, + TenantId: null, + Ip: ClientIp(context), + DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct); + } + + return EndpointResults.Unauthorized(InvalidCredentialsDetail); + } + + // Успешный вход сбрасывает счётчик неудач ключа ip|login (Ruling 5). + await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct); + + await auditService.AppendAsync(new AuditRecordDto( + AuditEvents.TenantLoginOk, + AuditActorTypes.Tenant, + ActorId: result.UserId, + TenantId: result.TenantId, + Ip: ClientIp(context), + DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct); + + SessionCookieWriter.Append(context, cookieOptions.Value, result.Token); + return Results.Ok(new { ok = true, login = result.Login }); + } + + // POST /api/auth/logout: удаление сессии по токену из куки и очистка куки (всегда ok). + // Если удалённая сессия была impersonation — пишется аудит impersonation_stopped (Task 7, ревью: полный аудит). + private static async Task LogoutAsync( + AuthService authService, + AuditService auditService, + IOptions cookieOptions, + HttpContext context, + CancellationToken ct) + { + var cookieName = cookieOptions.Value.Name; + var rawToken = context.Request.Cookies[cookieName]; + // Пользователь разрешённой сессии — до её удаления (SessionMiddleware наполнил Items на старте запроса). + CurrentUser? user = context.GetCurrentUser(); + var logout = await authService.LogoutAsync(rawToken, ct); + if (logout is not null) + { + // Актор — оператор, начавший impersonation (маркер сессии); тенант — для фильтра TenantId. + await auditService.AppendAsync(new AuditRecordDto( + AuditEvents.ImpersonationStopped, + AuditActorTypes.Operator, + ActorId: logout.OperatorId, + TenantId: logout.TenantId, + Ip: ClientIp(context), + DetailJson: AuditService.ToDetailJson(new { login = logout.Login })), ct); + } + + // Выход пользователя тенанта (этап 10, T1): событие пишется при живой разрешённой сессии. + if (user is not null) + { + await AuditAppender.AppendTenantAsync(context, AuditEvents.TenantLogout, new { login = user.Login }, ct); + } + + context.Response.Cookies.Delete(cookieName); + return Results.Ok(new { ok = true }); + } + + // GET /api/auth/me: проверка живой сессии. + private static IResult MeAsync(HttpContext context) + { + var user = context.GetCurrentUser(); + if (user is null) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + return Results.Ok(new { login = user.Login, ok = true }); + } + + // POST /api/auth/change-password: смена пароля и перевыпуск куки (свежая сессия). + private static async Task ChangePasswordAsync( + ChangePasswordRequest body, + AuthService authService, + IOptions cookieOptions, + HttpContext context, + CancellationToken ct) + { + var user = context.GetCurrentUser(); + if (user is null) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + var result = await authService.ChangePasswordAsync(user.Login, body.OldPassword, body.NewPassword, ct); + if (!result.Ok || result.NewToken is null) + { + // Семантика прототипа: код ошибки различает «старый пароль неверен» и «слишком короткий». + var detail = result.Error == ChangePasswordResultDto.ErrorTooShort + ? PasswordTooShortDetail + : WrongOldPasswordDetail; + return EndpointResults.BadRequest(detail); + } + + // Старые сессии удалены внутри сервиса; выдаём клиенту свежую куку. + SessionCookieWriter.Append(context, cookieOptions.Value, result.NewToken); + return Results.Ok(new { ok = true }); + } + + // Нормализованная попытка логина для аудита (нижний регистр/обрезка, как AuthService); null — писать нечего. + // login: Логин из тела запроса. + // Возвращает: Нормализованный логин или null при пустом/пробельном входе. + private static string? NormalizeLogin(string? login) + { + string? normalized = login?.Trim().ToLowerInvariant(); + return string.IsNullOrEmpty(normalized) ? null : normalized; + } + + // IP-адрес клиента для аудита (без порта; null, если недоступен). + // context: Контекст запроса. + // Возвращает: Строковое представление IP или null. + private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); +} diff --git a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs index 2a6d8bf..c8316fc 100644 --- a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs @@ -1,478 +1,469 @@ -using System.Text.Json; -using Deal.Api.Endpoints.RequestModels; -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Детальные операции карточки: создание локальной, «взять в работу», патч, ссылки, файлы, -/// напоминания, очистка «Отклонено» — продолжение группы /api/cards (этап 9, T6). -/// -/// -/// Единый контракт /api/cards (R5): операции проектной карточки (патч полей, ссылки, файлы, напоминания) -/// теперь живут на том же ресурсе карточки. Список/чтение/перенос/комментарии/корзина — в -/// ; здесь — уникальные подпути. Все мутации возвращают обновлённую -/// единую карточку (чтение после записи через ). Все эндпоинты требуют сессию. -/// -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 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 (создание, take, патч, ссылки, файлы, напоминания). - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - 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.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); - - // Аудит создания карточки (этап 10, T1). - 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. - 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); - - // Переводит тело POST /api/cards в начальные поля сервиса (поля 1:1 с CardLocalCreateDto). - // body: Тело запроса. - // Возвращает: DTO модуля для CardsService.CreateLocalCardAsync. - 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.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Api.Endpoints; + +/// +/// Детальные операции карточки: создание локальной, «взять в работу», патч, ссылки, файлы, +/// напоминания, очистка «Отклонено» — продолжение группы /api/cards (этап 9, T6). +/// +/// +/// Единый контракт /api/cards (R5): операции проектной карточки (патч полей, ссылки, файлы, напоминания) +/// теперь живут на том же ресурсе карточки. Список/чтение/перенос/комментарии/корзина — в +/// ; здесь — уникальные подпути. Все мутации возвращают обновлённую +/// единую карточку (чтение после записи через ). Все эндпоинты требуют сессию. +/// +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 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 (создание, take, патч, ссылки, файлы, напоминания). + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + 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.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); + + // Аудит создания карточки (этап 10, T1). + 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. + 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); + + // Переводит тело POST /api/cards в начальные поля сервиса (поля 1:1 с CardLocalCreateDto). + // body: Тело запроса. + // Возвращает: DTO модуля для CardsService.CreateLocalCardAsync. + 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 f8f3635..b96e0d1 100644 --- a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs @@ -1,502 +1,491 @@ -using Deal.Api.Endpoints.RequestModels; -using Deal.Api.Events; -using Deal.Api.Extensions; -using Deal.Api.Models; -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.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Эндпоинты карточек и поиска: GET /api/cards[?containerId=], /cards/counts, /cards/{id}, -/// mark-all-seen/mark-col-seen, move/trash/restore/DELETE, clear-col, comments, reclassify (batch + {id}), -/// GET /api/search (этап 9, T6). -/// -/// -/// Единый контракт /api/cards (R5): старые ручки /api/leads, /api/projects, /api/boards и /api/columns -/// упразднены. GET /cards → -/// {items}; counts — плоская wire-форма {new, <col>: {count, new}, learning, ml, ai}; move → обновлённая -/// карточка; restore → {ok, col}; clear-col → {ok, cleared}; comments → {comments}; search → -/// {cards, messages: []}. 400 «Неизвестный контейнер» при несуществующем containerId; 404 «Карточка не -/// найдена» — null-результаты сервисов, 400-тексты — константы CardsService. -/// ⚠ Статические сегменты (counts, mark-all-seen, mark-col-seen, clear-col, reclassify) регистрируются ДО -/// /cards/{cardId}. Все эндпоинты требуют сессию: 401 {detail}; сервисы резолвятся из RequestServices -/// ПОСЛЕ проверки сессии. -/// -public static class CardsEndpoints -{ - // Префикс группы карточек. - private const string CardsGroupPrefix = "/api/cards"; - - // Префикс группы поиска (единственный эндпоинт группы — /api/search). - private const string ApiGroupPrefix = "/api"; - - // Путь поиска (GET). - private const string SearchPath = "/search"; - - // OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py). - private const string OpenApiTag = "dashboard"; - - // 404: карточка не найдена (dashboard_routes.py _lead_or_404 L92–96). - private const string CardNotFoundDetail = "Карточка не найдена"; - - // 400 GET /cards: containerId не существует. - private const string UnknownColumnDetail = "Неизвестный контейнер"; - - // Инициатор перехода при ручном переносе — действие пользователя (R4 этапа 9). - private const string UserActor = "user"; - - // SSE-тип события завершения переклассификации (этап 12, остаток 2; api.js слушает 'cards_reclassified'). - private const string ReclassifiedEventType = "cards_reclassified"; - - // Контекст ручного перехода карточки: пользователь, обучение ML по цели переноса. - private static readonly TransitionContext UserMoveContext = new() { Actor = UserActor, Learn = true }; - - /// - /// Регистрирует группы /api/cards и /api (карточки + поиск). Статические сегменты — до /cards/{cardId}. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - 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 }); - } - - // GET /api/cards/counts: плоская wire-форма счётчиков {new, <col>:{count,new}, learning, ml, ai} (L161–163, §4.1 L257). - 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); - - // Разворачивание CardCountsDto: колонки — корневые ключи (counts L268–279), служебные — фиксированные. - 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); - } - - // GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168). - 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); - } - - // POST /api/cards/mark-all-seen: снять «новое» со всех карточек (L177–180); ответ {ok:true}. - 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 }); - } - - // POST /api/cards/mark-col-seen: снять «новое» с колонки (L187–191); ответ {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) - { - // Пустая/отсутствующая col попала бы в mark_seen как «не задана» и сняла бы «новое» со ВСЕХ - // карточек (truthiness python, L250–256) — эндпоинт защищает от вызова с null (прототип: 422). - return EndpointResults.BadRequest(UnknownColumnDetail); - } - - CardsService cardsService = context.RequestServices.GetRequiredService(); - await cardsService.MarkSeenAsync(cardId: null, col: body.Col, ct); - return Results.Ok(new { ok = true }); - } - - // POST /api/cards/{cardId}/move {to}: перенос карточки между контейнерами (этап 9, R4). - // Маршрутизацию цели (стадия «Выбранных» vs дашборд-контейнер) и побочные эффекты выполняет единый - // доменный механизм перехода ICardMover: стадия — запись истории и сброс напоминания - // (move_stage), дашборд-контейнер — журнал/обучение ML. Ответ — обновлённая карточка; 400 при - // несуществующем контейнере, 404 — карточки нет. - 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); - } - - // Аудит переноса карточки (этап 10, T1): цель — минимальный безопасный идентификатор. - 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); - } - - // POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203–207); ответ {ok:true}; 404. - 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); - } - - // Аудит отправки карточки в корзину (этап 10, T1). - await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct); - return Results.Ok(new { ok = true }); - } - - // POST /api/cards/{cardId}/restore: возврат из архив/корзины на канбан (L210–214); ответ {ok, col}; 404. - 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); - } - - // Аудит возврата карточки из корзины/архива (этап 10, T1). - await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct); - return Results.Ok(new { ok = true, col }); - } - - // DELETE /api/cards/{cardId}: удалить навсегда (Cards + комментарии; L217–221); ответ {ok:true}; 404. - 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); - } - - // Аудит удаления карточки навсегда (этап 10, T1). - await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct); - return Results.Ok(new { ok = true }); - } - - // POST /api/cards/clear-col {col}: очистить корзину/архив (L228–235); ответ {ok, cleared}; 400. - 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 }); - } - - // POST /api/cards/{cardId}/comments {text}: добавить комментарий (L238–242); ответ {comments}; 400 «Пустой комментарий»; 404. - 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); - } - - // Аудит добавления комментария (этап 10, T1): текст комментария в детали не пишется. - 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(); - ReclassifyResultDto result = await reclassifier.ReclassifyInboxAsync(body?.Ids, ct); - 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, - }; - - // Публикует SSE cards_reclassified после успешного прохода (Ruling 5: публикации — из Api). - // Публикуется только когда проход реально выполнен и что-то изменил (started и - // reclassified > 0): пустой inbox/всё пропущено не меняют доску — событие не шлём. Нагрузка - // минимальная: сколько обработано и перемещено (фронт перечитывает доску). Без подписчиков — no-op. - // context: Контекст запроса (тенант-канал сессии). - // result: Итог прохода. - 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 }); - } - - // Аудит переклассификации: пишется только когда проход реально что-то изменил. - // 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); - } - - // GET /api/search?q=: поиск карточек (FTS + LIKE, Ruling 6/Task 12; dashboard_routes L254–256). Ответ {leads, messages: []}. - 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 всегда []: telegram-сообщений здесь нет, фронт их не читает. - 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; + +/// +/// Эндпоинты карточек и поиска: GET /api/cards[?containerId=], /cards/counts, /cards/{id}, +/// mark-all-seen/mark-col-seen, move/trash/restore/DELETE, clear-col, comments, reclassify (batch + {id}), +/// GET /api/search (этап 9, T6). +/// +/// +/// Единый контракт /api/cards (R5): старые ручки /api/leads, /api/projects, /api/boards и /api/columns +/// упразднены. GET /cards → +/// {items}; counts — плоская wire-форма {new, <col>: {count, new}, learning, ml, ai}; move → обновлённая +/// карточка; restore → {ok, col}; clear-col → {ok, cleared}; comments → {comments}; search → +/// {cards, messages: []}. 400 «Неизвестный контейнер» при несуществующем containerId; 404 «Карточка не +/// найдена» — null-результаты сервисов, 400-тексты — константы CardsService. +/// ⚠ Статические сегменты (counts, mark-all-seen, mark-col-seen, clear-col, reclassify) регистрируются ДО +/// /cards/{cardId}. Все эндпоинты требуют сессию: 401 {detail}; сервисы резолвятся из RequestServices +/// ПОСЛЕ проверки сессии. +/// +public static class CardsEndpoints +{ + // Префикс группы карточек. + private const string CardsGroupPrefix = "/api/cards"; + + // Префикс группы поиска (единственный эндпоинт группы — /api/search). + private const string ApiGroupPrefix = "/api"; + + // Путь поиска (GET). + private const string SearchPath = "/search"; + + // OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py). + private const string OpenApiTag = "dashboard"; + + // 404: карточка не найдена (dashboard_routes.py _lead_or_404 L92–96). + private const string CardNotFoundDetail = "Карточка не найдена"; + + // 400 GET /cards: containerId не существует. + private const string UnknownColumnDetail = "Неизвестный контейнер"; + + // Инициатор перехода при ручном переносе — действие пользователя (R4 этапа 9). + private const string UserActor = "user"; + + // SSE-тип события завершения переклассификации (этап 12, остаток 2; api.js слушает 'cards_reclassified'). + private const string ReclassifiedEventType = "cards_reclassified"; + + // Контекст ручного перехода карточки: пользователь, обучение ML по цели переноса. + private static readonly TransitionContext UserMoveContext = new() { Actor = UserActor, Learn = true }; + + /// + /// Регистрирует группы /api/cards и /api (карточки + поиск). Статические сегменты — до /cards/{cardId}. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + 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 }); + } + + // GET /api/cards/counts: плоская wire-форма счётчиков {new, <col>:{count,new}, learning, ml, ai} (L161–163, §4.1 L257). + 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); + + // Разворачивание CardCountsDto: колонки — корневые ключи (counts L268–279), служебные — фиксированные. + 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); + } + + // GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168). + 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); + } + + // POST /api/cards/mark-all-seen: снять «новое» со всех карточек (L177–180); ответ {ok:true}. + 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 }); + } + + // POST /api/cards/mark-col-seen: снять «новое» с колонки (L187–191); ответ {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) + { + // Пустая/отсутствующая col попала бы в mark_seen как «не задана» и сняла бы «новое» со ВСЕХ + // карточек (truthiness python, L250–256) — эндпоинт защищает от вызова с null (прототип: 422). + return EndpointResults.BadRequest(UnknownColumnDetail); + } + + CardsService cardsService = context.RequestServices.GetRequiredService(); + await cardsService.MarkSeenAsync(cardId: null, col: body.Col, ct); + return Results.Ok(new { ok = true }); + } + + // POST /api/cards/{cardId}/move {to}: перенос карточки между контейнерами (этап 9, R4). + // Маршрутизацию цели (стадия «Выбранных» vs дашборд-контейнер) и побочные эффекты выполняет единый + // доменный механизм перехода ICardMover: стадия — запись истории и сброс напоминания + // (move_stage), дашборд-контейнер — журнал/обучение ML. Ответ — обновлённая карточка; 400 при + // несуществующем контейнере, 404 — карточки нет. + 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); + } + + // Аудит переноса карточки (этап 10, T1): цель — минимальный безопасный идентификатор. + 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); + } + + // POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203–207); ответ {ok:true}; 404. + 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); + } + + // Аудит отправки карточки в корзину (этап 10, T1). + await AuditAppender.AppendTenantAsync(context, AuditEvents.CardTrashed, new { cardId }, ct); + return Results.Ok(new { ok = true }); + } + + // POST /api/cards/{cardId}/restore: возврат из архив/корзины на канбан (L210–214); ответ {ok, col}; 404. + 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); + } + + // Аудит возврата карточки из корзины/архива (этап 10, T1). + await AuditAppender.AppendTenantAsync(context, AuditEvents.CardRestored, new { cardId, col }, ct); + return Results.Ok(new { ok = true, col }); + } + + // DELETE /api/cards/{cardId}: удалить навсегда (Cards + комментарии; L217–221); ответ {ok:true}; 404. + 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); + } + + // Аудит удаления карточки навсегда (этап 10, T1). + await AuditAppender.AppendTenantAsync(context, AuditEvents.CardDeleted, new { cardId }, ct); + return Results.Ok(new { ok = true }); + } + + // POST /api/cards/clear-col {col}: очистить корзину/архив (L228–235); ответ {ok, cleared}; 400. + 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 }); + } + + // POST /api/cards/{cardId}/comments {text}: добавить комментарий (L238–242); ответ {comments}; 400 «Пустой комментарий»; 404. + 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); + } + + // Аудит добавления комментария (этап 10, T1): текст комментария в детали не пишется. + 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(); + ReclassifyResultDto result = await reclassifier.ReclassifyInboxAsync(body?.Ids, ct); + 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, + }; + + // Публикует SSE cards_reclassified после успешного прохода (Ruling 5: публикации — из Api). + // Публикуется только когда проход реально выполнен и что-то изменил (started и + // reclassified > 0): пустой inbox/всё пропущено не меняют доску — событие не шлём. Нагрузка + // минимальная: сколько обработано и перемещено (фронт перечитывает доску). Без подписчиков — no-op. + // context: Контекст запроса (тенант-канал сессии). + // result: Итог прохода. + 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 }); + } + + // Аудит переклассификации: пишется только когда проход реально что-то изменил. + // 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); + } + + // GET /api/search?q=: поиск карточек (FTS + LIKE, Ruling 6/Task 12; dashboard_routes L254–256). Ответ {leads, messages: []}. + 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 всегда []: telegram-сообщений здесь нет, фронт их не читает. + 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 2cde3fe..3a4db92 100644 --- a/src/core/Deal.Api/Endpoints/ChangePasswordRequest.cs +++ b/src/core/Deal.Api/Endpoints/ChangePasswordRequest.cs @@ -1,11 +1,8 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/auth/change-password. Входящий JSON — camelCase (oldPassword, newPassword). -/// -/// Текущий пароль. -/// Новый пароль (минимум 8 символов). -public sealed record ChangePasswordRequest(string OldPassword, string NewPassword); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/auth/change-password. Входящий JSON — camelCase (oldPassword, newPassword). +/// +/// Текущий пароль. +/// Новый пароль (минимум 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 b780455..f838792 100644 --- a/src/core/Deal.Api/Endpoints/CheckMessageRequest.cs +++ b/src/core/Deal.Api/Endpoints/CheckMessageRequest.cs @@ -1,10 +1,7 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/admin/check-message (1:1 с CheckMessageBody, dashboard_routes.py L72–73). -/// -/// Текст сообщения для проверки фильтром (этап 1 + этап 2 тестера). -public sealed record CheckMessageRequest(string Text); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/admin/check-message (1:1 с CheckMessageBody, dashboard_routes.py L72–73). +/// +/// Текст сообщения для проверки фильтром (этап 1 + этап 2 тестера). +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 3fe2f57..966c2d7 100644 --- a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs @@ -1,332 +1,323 @@ -using System.Text.Json; -using Deal.Api.Endpoints.RequestModels; -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Эндпоинты контейнеров (колонок/стадий/зон) и состояния колонок: GET/POST /api/containers, -/// PATCH /{id}/accept, PATCH/DELETE /{id}, POST /reorder, GET/PATCH state (этап 9, T4/T6). -/// -/// -/// Единый реестр контейнеров приходит на смену /api/boards + /api/columns (R5): список, создание, -/// частичное обновление, принятие ИИ-предложения, удаление с переносом карточек в inbox, reorder и -/// состояние колонок (colState). Все эндпоинты требуют сессию: 401 {detail}. ContainersService -/// резолвится из RequestServices ПОСЛЕ проверки сессии. -/// -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); - - // Аудит создания контейнера (этап 10, T1). - 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); - } - - // Аудит изменения контейнера (этап 10, T1). - 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); - } - - // Аудит изменения контейнера (принятие ИИ-предложения) — этап 10, T1. - 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); - - // Аудит удаления контейнера (этап 10, T1). - 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; + +/// +/// Эндпоинты контейнеров (колонок/стадий/зон) и состояния колонок: GET/POST /api/containers, +/// PATCH /{id}/accept, PATCH/DELETE /{id}, POST /reorder, GET/PATCH state (этап 9, T4/T6). +/// +/// +/// Единый реестр контейнеров приходит на смену /api/boards + /api/columns (R5): список, создание, +/// частичное обновление, принятие ИИ-предложения, удаление с переносом карточек в inbox, reorder и +/// состояние колонок (colState). Все эндпоинты требуют сессию: 401 {detail}. ContainersService +/// резолвится из RequestServices ПОСЛЕ проверки сессии. +/// +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); + + // Аудит создания контейнера (этап 10, T1). + 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); + } + + // Аудит изменения контейнера (этап 10, T1). + 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); + } + + // Аудит изменения контейнера (принятие ИИ-предложения) — этап 10, T1. + 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); + + // Аудит удаления контейнера (этап 10, T1). + 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 2b0af03..9b71b74 100644 --- a/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs @@ -1,580 +1,573 @@ -using System.Text.Json; -using Deal.Api.Endpoints.RequestModels; -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -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.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Telegram.Application; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Эндпоинты /api/discovery: задачи поиска, кандидаты, чёрный список, лог, генерация ключей (Ruling 11, api-map §3.8). -/// -/// -/// 13 эндпоинтов 1:1 с backend/app/routers/discovery_routes.py (prefix /api/discovery): tasks -/// (list/create/patch/delete/start/pause), generate-keywords (мягкая ошибка HTTP 200 {keywords: [], error}, -/// Ruling 11), candidates (статус-фильтр), join/reject (ручные, вне квот воркера), blacklist, log. Тела ответов — -/// DTO модуля Discovery (camelCase, §4.8 L353–355) и {items: [...]} для списков (конвенция api-map §1); -/// 404-семантика сервисов (null/KeyError python) — «Задача не найдена»/«Кандидат не найден», -/// 400-семантика — (ValueError python) с текстом причины 1:1. -/// Ручной join — как worker-авто-join (Task 18): RPC Join через → строка каталога -/// Dialogs (монитор on) + зеркало через → фоновый первый -/// разбор (, python-_spawn) → снятие чёрного списка → mark_joined(auto:false); -/// ошибка Telegram → 400 с текстом причины. Все эндпоинты требуют сессию: 401 {detail} (Ruling 10); сервисы -/// резолвятся из RequestServices ПОСЛЕ проверки сессии (scoped — TenantDbContext схемы тенанта, паттерн SettingsEndpoints). -/// -public static class DiscoveryEndpoints -{ - // Префикс группы /api/discovery (python: router prefix, discovery_routes.py L26). - private const string DiscoveryGroupPrefix = "/api/discovery"; - - // OpenAPI-тег группы (в прототипе роутер discovery — discovery_routes.py). - 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"; - - // 404 create/start/patch/candidates/log: задачи нет (python _task_or_404 L83–87). - private const string TaskNotFoundDetail = "Задача не найдена"; - - // 404 join/reject: кандидата нет (python _candidate_or_404 L90–94). - private const string CandidateNotFoundDetail = "Кандидат не найден"; - - // 400 join: уже вступили (python L235–237). - private const string AlreadyJoinedDetail = "Уже вступили в этот источник"; - - // 400 reject: источник уже вступили (python L256–258). - private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов"; - - // 400 join: ошибка Telegram при вступлении (python L240–242, текст с @username). - private const string JoinFailedFormat = "Не удалось вступить в @{0}: {1}"; - - // Причина отклонения вручную для чёрного списка/лога (python L260: reason="отклонено вручную"). - private const string ManualRejectReason = "отклонено вручную"; - - // Мягкая ошибка generate-keywords: ИИ выключен (python _ai_unavailable_reason L99–100). - private const string AiDisabledDetail = "ИИ выключен в настройках (aiEnabled)"; - - // Мягкая ошибка generate-keywords: описания нет (python L201–202). - private const string NoDescriptionDetail = "У задачи нет описания — по нему генерируются ключи"; - - // Страховочный потолок числа сгенерированных ключей (python _KEYWORDS_LIMIT L31: промпт просит 10–16). - private const int KeywordsLimit = 30; - - // Потолок длины одного ключа (python _KEYWORD_LENGTH_LIMIT L33: короткие фразы для поиска Telegram). - private const int KeywordLengthLimit = 60; - - /// - /// Регистрирует группу /api/discovery: 13 эндпоинтов (tasks + generate-keywords + candidates + join/reject + blacklist + log). - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - 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; - } - - // GET /api/discovery/tasks: список задач, старые первыми (list_tasks L141–143). - 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 }); - } - - // POST /api/discovery/tasks: создать задачу поиска (create_task L146–151; дефолты — в сервисе). - 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); - } - } - - // PATCH /api/discovery/tasks/{task_id}: обновить задачу (patch_task L154–161; 404/400). - 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); - } - } - - // DELETE /api/discovery/tasks/{task_id}: удалить задачу с кандидатами и логом (delete_task L164–168). - 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); - } - - // POST /api/discovery/tasks/{task_id}/start: запуск поиска (start_task L171–179; пустые ключи → 400). - 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); - } - } - - // POST /api/discovery/tasks/{task_id}/pause: пауза поиска (pause_task L181–187). - 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); - } - - // POST /api/discovery/tasks/{task_id}/generate-keywords: ИИ-ключи по описанию задачи - // (generate_keywords L189–211). ИИ выключен/недоступен/нет описания → HTTP 200 {keywords: [], error}. - // Очистка ключей ответа — CleanKeywords (python _clean_keywords L111–128: ≤30, ≤60 - // символов, дедуп casefold); описание режет до 4000 сам адаптер (GrpcAiTools.MaxDescriptionCodePoints). - // Локальный режим (LocalAiTools, UseLocal=true) — NotSupportedException → та же мягкая ветка с текстом причины. - 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) }); - } - - // Недоступность провайдера/сервиса — мягкая ошибка для UI (Ruling 11), HTTP 200. - return Results.Ok(new { keywords = Array.Empty(), error = result.Error ?? ServiceUnavailableText }); - } - catch (NotSupportedException exception) - { - // Локальный режим: ai-service не подключён — инструменты недоступны (LocalAiTools, Task 15). - return Results.Ok(new { keywords = Array.Empty(), error = exception.Message }); - } - } - - // GET /api/discovery/tasks/{task_id}/candidates?status=: кандидаты задачи с фильтром - // new|review|joined|rejected (list_candidates L216–224; невалидный статус — пустой список). - 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 }); - } - - // POST /api/discovery/candidates/{dialog_id}/join: ручное вступление вне квот (join_candidate L226–251). - // RPC Join → строка каталога Dialogs (монитор on) + зеркало → фоновый первый разбор → снятие чёрного списка → - // mark_joined(auto:false). Ошибка Telegram → 400 с текстом причины. - 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)); - } - - // Источник в каталоге (монитор on, backfilled=false) + монитор-зеркало telegram-service - // (python add_dialog_monitored L242; решение T18: локальную строку пишет Api-слой). - DialogsService dialogs = context.RequestServices.GetRequiredService(); - await dialogs.AddDiscoveredMonitoredAsync(dialog_id, row.Name, username, row.Kind, row.Hue, ct); - - // Догон последних сообщений — в фоне: join из UI не должен висеть на паузах backfill - // (python _spawn(_backfill_quiet) L244–245; источник уже в каталоге и мониторится). - 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); - } - } - - // POST /api/discovery/candidates/{dialog_id}/reject: отклонить кандидата — в чёрный список - // (reject_candidate L253–264; уже вступившего — нельзя, 400). - 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); - } - } - - // GET /api/discovery/blacklist: чёрный список источников (list_blacklist L269–271). - 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 }); - } - - // DELETE /api/discovery/blacklist/{dialog_id}: снять источник с чёрного списка (remove_blacklist L274–277). - 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 }); - } - - // GET /api/discovery/tasks/{task_id}/log: лог задачи (task_log L282–285), события от новых к старым. - 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 }); - } - - /// - /// Ключи из ответа ИИ: строки без пустых/длинных и повторов (python _clean_keywords L111–128). - /// - /// Повтор считается по casefold python: здесь — регистронезависимое сравнение - /// (RU/EN-ключи; StringComparer.OrdinalIgnoreCase). Потолок списка — . - /// Сырые ключи ответа модели (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; - } - - // Фолбэк-текст недоступного ИИ, если адаптер причину не вернул (мягкая ошибка, Ruling 11). - private const string ServiceUnavailableText = "ИИ недоступен — повторите попытку через несколько секунд"; - - // Читает настройку aiEnabled (KV; отсутствие строки — дефолт SettingsDefaults). - // settings: KV-хранилище настроек тенанта. - // ct: Токен отмены. - // Возвращает: True — ИИ включён (ветки выключателя отрабатывает вызывающий, Ruling 10/11). - 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, - }; - } - - // Маппит тело патча в сервисный патч (не-null значения; как python model_dump(exclude_none=True)). - // body: Тело запроса (wire-поля camelCase). - // Возвращает: Патч задачи (DiscoveryTaskPatch). - 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: задачи поиска, кандидаты, чёрный список, лог, генерация ключей (Ruling 11, api-map §3.8). +/// +/// +/// 13 эндпоинтов 1:1 с backend/app/routers/discovery_routes.py (prefix /api/discovery): tasks +/// (list/create/patch/delete/start/pause), generate-keywords (мягкая ошибка HTTP 200 {keywords: [], error}, +/// Ruling 11), candidates (статус-фильтр), join/reject (ручные, вне квот воркера), blacklist, log. Тела ответов — +/// DTO модуля Discovery (camelCase, §4.8 L353–355) и {items: [...]} для списков (конвенция api-map §1); +/// 404-семантика сервисов (null/KeyError python) — «Задача не найдена»/«Кандидат не найден», +/// 400-семантика — (ValueError python) с текстом причины 1:1. +/// Ручной join — как worker-авто-join (Task 18): RPC Join через → строка каталога +/// Dialogs (монитор on) + зеркало через → фоновый первый +/// разбор (, python-_spawn) → снятие чёрного списка → mark_joined(auto:false); +/// ошибка Telegram → 400 с текстом причины. Все эндпоинты требуют сессию: 401 {detail} (Ruling 10); сервисы +/// резолвятся из RequestServices ПОСЛЕ проверки сессии (scoped — TenantDbContext схемы тенанта, паттерн SettingsEndpoints). +/// +public static class DiscoveryEndpoints +{ + // Префикс группы /api/discovery (python: router prefix, discovery_routes.py L26). + private const string DiscoveryGroupPrefix = "/api/discovery"; + + // OpenAPI-тег группы (в прототипе роутер discovery — discovery_routes.py). + 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"; + + // 404 create/start/patch/candidates/log: задачи нет (python _task_or_404 L83–87). + private const string TaskNotFoundDetail = "Задача не найдена"; + + // 404 join/reject: кандидата нет (python _candidate_or_404 L90–94). + private const string CandidateNotFoundDetail = "Кандидат не найден"; + + // 400 join: уже вступили (python L235–237). + private const string AlreadyJoinedDetail = "Уже вступили в этот источник"; + + // 400 reject: источник уже вступили (python L256–258). + private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов"; + + // 400 join: ошибка Telegram при вступлении (python L240–242, текст с @username). + private const string JoinFailedFormat = "Не удалось вступить в @{0}: {1}"; + + // Причина отклонения вручную для чёрного списка/лога (python L260: reason="отклонено вручную"). + private const string ManualRejectReason = "отклонено вручную"; + + // Мягкая ошибка generate-keywords: ИИ выключен (python _ai_unavailable_reason L99–100). + private const string AiDisabledDetail = "ИИ выключен в настройках (aiEnabled)"; + + // Мягкая ошибка generate-keywords: описания нет (python L201–202). + private const string NoDescriptionDetail = "У задачи нет описания — по нему генерируются ключи"; + + // Страховочный потолок числа сгенерированных ключей (python _KEYWORDS_LIMIT L31: промпт просит 10–16). + private const int KeywordsLimit = 30; + + // Потолок длины одного ключа (python _KEYWORD_LENGTH_LIMIT L33: короткие фразы для поиска Telegram). + private const int KeywordLengthLimit = 60; + + /// + /// Регистрирует группу /api/discovery: 13 эндпоинтов (tasks + generate-keywords + candidates + join/reject + blacklist + log). + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + 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; + } + + // GET /api/discovery/tasks: список задач, старые первыми (list_tasks L141–143). + 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 }); + } + + // POST /api/discovery/tasks: создать задачу поиска (create_task L146–151; дефолты — в сервисе). + 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); + } + } + + // PATCH /api/discovery/tasks/{task_id}: обновить задачу (patch_task L154–161; 404/400). + 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); + } + } + + // DELETE /api/discovery/tasks/{task_id}: удалить задачу с кандидатами и логом (delete_task L164–168). + 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); + } + + // POST /api/discovery/tasks/{task_id}/start: запуск поиска (start_task L171–179; пустые ключи → 400). + 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); + } + } + + // POST /api/discovery/tasks/{task_id}/pause: пауза поиска (pause_task L181–187). + 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); + } + + // POST /api/discovery/tasks/{task_id}/generate-keywords: ИИ-ключи по описанию задачи + // (generate_keywords L189–211). ИИ выключен/недоступен/нет описания → HTTP 200 {keywords: [], error}. + // Очистка ключей ответа — CleanKeywords (python _clean_keywords L111–128: ≤30, ≤60 + // символов, дедуп casefold); описание режет до 4000 сам адаптер (GrpcAiTools.MaxDescriptionCodePoints). + // Локальный режим (LocalAiTools, UseLocal=true) — NotSupportedException → та же мягкая ветка с текстом причины. + 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) }); + } + + // Недоступность провайдера/сервиса — мягкая ошибка для UI (Ruling 11), HTTP 200. + return Results.Ok(new { keywords = Array.Empty(), error = result.Error ?? ServiceUnavailableText }); + } + catch (NotSupportedException exception) + { + // Локальный режим: ai-service не подключён — инструменты недоступны (LocalAiTools, Task 15). + return Results.Ok(new { keywords = Array.Empty(), error = exception.Message }); + } + } + + // GET /api/discovery/tasks/{task_id}/candidates?status=: кандидаты задачи с фильтром + // new|review|joined|rejected (list_candidates L216–224; невалидный статус — пустой список). + 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 }); + } + + // POST /api/discovery/candidates/{dialog_id}/join: ручное вступление вне квот (join_candidate L226–251). + // RPC Join → строка каталога Dialogs (монитор on) + зеркало → фоновый первый разбор → снятие чёрного списка → + // mark_joined(auto:false). Ошибка Telegram → 400 с текстом причины. + 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)); + } + + // Источник в каталоге (монитор on, backfilled=false) + монитор-зеркало telegram-service + // (python add_dialog_monitored L242; решение T18: локальную строку пишет Api-слой). + DialogsService dialogs = context.RequestServices.GetRequiredService(); + await dialogs.AddDiscoveredMonitoredAsync(dialog_id, row.Name, username, row.Kind, row.Hue, ct); + + // Догон последних сообщений — в фоне: join из UI не должен висеть на паузах backfill + // (python _spawn(_backfill_quiet) L244–245; источник уже в каталоге и мониторится). + 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); + } + } + + // POST /api/discovery/candidates/{dialog_id}/reject: отклонить кандидата — в чёрный список + // (reject_candidate L253–264; уже вступившего — нельзя, 400). + 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); + } + } + + // GET /api/discovery/blacklist: чёрный список источников (list_blacklist L269–271). + 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 }); + } + + // DELETE /api/discovery/blacklist/{dialog_id}: снять источник с чёрного списка (remove_blacklist L274–277). + 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 }); + } + + // GET /api/discovery/tasks/{task_id}/log: лог задачи (task_log L282–285), события от новых к старым. + 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 }); + } + + /// + /// Ключи из ответа ИИ: строки без пустых/длинных и повторов (python _clean_keywords L111–128). + /// + /// Повтор считается по casefold python: здесь — регистронезависимое сравнение + /// (RU/EN-ключи; StringComparer.OrdinalIgnoreCase). Потолок списка — . + /// Сырые ключи ответа модели (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; + } + + // Фолбэк-текст недоступного ИИ, если адаптер причину не вернул (мягкая ошибка, Ruling 11). + private const string ServiceUnavailableText = "ИИ недоступен — повторите попытку через несколько секунд"; + + // Читает настройку aiEnabled (KV; отсутствие строки — дефолт SettingsDefaults). + // settings: KV-хранилище настроек тенанта. + // ct: Токен отмены. + // Возвращает: True — ИИ включён (ветки выключателя отрабатывает вызывающий, Ruling 10/11). + 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, + }; + } + + // Маппит тело патча в сервисный патч (не-null значения; как python model_dump(exclude_none=True)). + // body: Тело запроса (wire-поля camelCase). + // Возвращает: Патч задачи (DiscoveryTaskPatch). + 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 ab93445..cf036fe 100644 --- a/src/core/Deal.Api/Endpoints/EventsEndpoint.cs +++ b/src/core/Deal.Api/Endpoints/EventsEndpoint.cs @@ -1,122 +1,121 @@ -using Deal.Api.Events; -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// SSE-поток событий канбана: GET /api/events (Ruling 5; прототип events_routes.py L15–38). -/// -/// -/// Открывает text/event-stream с подпиской на канал тенанта сессии (singleton SseBroker). -/// События пишутся по мере поступления; при тишине 15 с отправляется ping-комментарий ": ping" — -/// соединение держится (переподключение EventSource, api.js L62–104). Завершение — по отвалу клиента -/// (CancellationToken = RequestAborted); отписка — в finally. Без сессии — 401 {detail} (Ruling 10, -/// паттерн остальных эндпоинтов). Заголовки: Content-Type text/event-stream, Cache-Control: no-cache, -/// X-Accel-Buffering: no (запрет буферизации прокси, иначе ping/события задерживаются). -/// -public static class EventsEndpoint -{ - // Путь потока (роутер events, events_routes.py L12: prefix="/api"). - private const string EventsPath = "/api/events"; - - // OpenAPI-тег группы (в прототипе — роутер events_routes.py). - private const string OpenApiTag = "events"; - - // Тип контента потока (events_routes.py L32). - private const string EventStreamContentType = "text/event-stream"; - - // Директива кеширования: поток не кешируется (events_routes.py L34). - private const string NoCacheHeaderValue = "no-cache"; - - // Отключение буферизации ответа nginx-прокси (events_routes.py L35). - private const string NoBufferingHeaderValue = "no"; - - // Ping-комментарий: строки протокола SSE, начинающиеся с ':', клиент игнорирует. - private const string PingComment = ": ping\n\n"; - - // Интервал ping при тишине: держим соединение (events_routes.py L24: timeout=15). - 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-поток событий канбана: GET /api/events (Ruling 5; прототип events_routes.py L15–38). +/// +/// +/// Открывает text/event-stream с подпиской на канал тенанта сессии (singleton SseBroker). +/// События пишутся по мере поступления; при тишине 15 с отправляется ping-комментарий ": ping" — +/// соединение держится (переподключение EventSource, api.js L62–104). Завершение — по отвалу клиента +/// (CancellationToken = RequestAborted); отписка — в finally. Без сессии — 401 {detail} (Ruling 10, +/// паттерн остальных эндпоинтов). Заголовки: Content-Type text/event-stream, Cache-Control: no-cache, +/// X-Accel-Buffering: no (запрет буферизации прокси, иначе ping/события задерживаются). +/// +public static class EventsEndpoint +{ + // Путь потока (роутер events, events_routes.py L12: prefix="/api"). + private const string EventsPath = "/api/events"; + + // OpenAPI-тег группы (в прототипе — роутер events_routes.py). + private const string OpenApiTag = "events"; + + // Тип контента потока (events_routes.py L32). + private const string EventStreamContentType = "text/event-stream"; + + // Директива кеширования: поток не кешируется (events_routes.py L34). + private const string NoCacheHeaderValue = "no-cache"; + + // Отключение буферизации ответа nginx-прокси (events_routes.py L35). + private const string NoBufferingHeaderValue = "no"; + + // Ping-комментарий: строки протокола SSE, начинающиеся с ':', клиент игнорирует. + private const string PingComment = ": ping\n\n"; + + // Интервал ping при тишине: держим соединение (events_routes.py L24: timeout=15). + 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 33a4427..dd5e368 100644 --- a/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs @@ -1,83 +1,78 @@ -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Тестер фильтра входящих: POST /api/admin/check-message (Ruling 8, api-map §3.2 L109, §4.10 L364). -/// -/// -/// Имитация этапов пайплайна для тестера в настройках — 1:1 с dashboard_routes.py L267–284: -/// этап 1 считают детерминированные правила (stage1_plain, pipeline.py -/// L94–124) по настройкам тенанта; ответ — {stage1:{pass,reason}, stage2:{pass,reason,skipped}, passed}. -/// ИИ-фильтр этапа 2 на этапе 2 ВСЕГДА skipped (Ruling 4/8, план Task 10 L377–380): если этап-1 не прошёл — -/// stage2={pass:false,reason:null,skipped:true}, passed:false; иначе — stage2={pass:true,reason:null, -/// skipped:true}, passed:true (реальный ИИ-фильтр — этап 6, ветка ошибки ИИ прототипа L281 к skipped -/// не относится — там ИИ реально зовётся). kind/kw результата правил наружу НЕ отдаются (в ответе только -/// pass/reason — как в прототипе); они нужны мониторингу отсева этапа 4. -/// Эндпоинт требует сессию: 401 {detail} (Ruling 10). IncomingRules резолвится из RequestServices ПОСЛЕ -/// проверки сессии (scoped на TenantDbContext — паттерн SettingsEndpoints/MlEndpoints). -/// -public static class FilterTesterEndpoints -{ - // Префикс группы API (общий для эндпоинтов этапа, Ruling 8). - private const string ApiGroupPrefix = "/api"; - - // Путь тестера фильтра входящих (dashboard_routes.py L267). - private const string CheckMessagePath = "/admin/check-message"; - - // OpenAPI-тег группы (эндпоинт Settings-экрана, Ruling 8). - 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: этап-1 правила + этап-2 (skipped) для тестера (dashboard_routes.py L267–284). - private static async Task CheckAsync( - CheckMessageRequest body, - HttpContext context, - CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - // Резолв после 401-гейта: IncomingRules — scoped на TenantDbContext (tenant-контекст запроса). - IncomingRules incomingRules = context.RequestServices.GetRequiredService(); - IncomingRulesResult stage1 = await incomingRules.CheckAsync(body.Text, ct); - - // Ответ 1:1 с прототипом: stage2 на этапе 2 всегда skipped (Ruling 4/8, план L377–380). - if (!stage1.Pass) - { - return Results.Ok(new - { - stage1 = new { pass = stage1.Pass, reason = stage1.Reason }, - stage2 = new { pass = false, reason = (string?)null, skipped = true }, - passed = false, - }); - } - - return Results.Ok(new - { - stage1 = new { pass = stage1.Pass, reason = stage1.Reason }, - stage2 = new { pass = true, reason = (string?)null, skipped = true }, - passed = true, - }); - } -} +using Deal.Api.Extensions; +using Deal.Api.Services; +using Deal.Modules.Settings.Application.Models; + +namespace Deal.Api.Endpoints; + +/// +/// Тестер фильтра входящих: POST /api/admin/check-message (Ruling 8, api-map §3.2 L109, §4.10 L364). +/// +/// +/// Имитация этапов пайплайна для тестера в настройках — 1:1 с dashboard_routes.py L267–284: +/// этап 1 считают детерминированные правила (stage1_plain, pipeline.py +/// L94–124) по настройкам тенанта; ответ — {stage1:{pass,reason}, stage2:{pass,reason,skipped}, passed}. +/// ИИ-фильтр этапа 2 на этапе 2 ВСЕГДА skipped (Ruling 4/8, план Task 10 L377–380): если этап-1 не прошёл — +/// stage2={pass:false,reason:null,skipped:true}, passed:false; иначе — stage2={pass:true,reason:null, +/// skipped:true}, passed:true (реальный ИИ-фильтр — этап 6, ветка ошибки ИИ прототипа L281 к skipped +/// не относится — там ИИ реально зовётся). kind/kw результата правил наружу НЕ отдаются (в ответе только +/// pass/reason — как в прототипе); они нужны мониторингу отсева этапа 4. +/// Эндпоинт требует сессию: 401 {detail} (Ruling 10). IncomingRules резолвится из RequestServices ПОСЛЕ +/// проверки сессии (scoped на TenantDbContext — паттерн SettingsEndpoints/MlEndpoints). +/// +public static class FilterTesterEndpoints +{ + // Префикс группы API (общий для эндпоинтов этапа, Ruling 8). + private const string ApiGroupPrefix = "/api"; + + // Путь тестера фильтра входящих (dashboard_routes.py L267). + private const string CheckMessagePath = "/admin/check-message"; + + // OpenAPI-тег группы (эндпоинт Settings-экрана, Ruling 8). + 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: этап-1 правила + этап-2 (skipped) для тестера (dashboard_routes.py L267–284). + private static async Task CheckAsync( + CheckMessageRequest body, + HttpContext context, + CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + // Резолв после 401-гейта: IncomingRules — scoped на TenantDbContext (tenant-контекст запроса). + IncomingRules incomingRules = context.RequestServices.GetRequiredService(); + IncomingRulesResult stage1 = await incomingRules.CheckAsync(body.Text, ct); + + // Ответ 1:1 с прототипом: stage2 на этапе 2 всегда skipped (Ruling 4/8, план L377–380). + if (!stage1.Pass) + { + return Results.Ok(new + { + stage1 = new { pass = stage1.Pass, reason = stage1.Reason }, + stage2 = new { pass = false, reason = (string?)null, skipped = true }, + passed = false, + }); + } + + return Results.Ok(new + { + stage1 = new { pass = stage1.Pass, reason = stage1.Reason }, + stage2 = new { pass = true, reason = (string?)null, skipped = true }, + passed = true, + }); + } +} diff --git a/src/core/Deal.Api/Endpoints/JoinEndpoint.cs b/src/core/Deal.Api/Endpoints/JoinEndpoint.cs index c39877d..2d4802c 100644 --- a/src/core/Deal.Api/Endpoints/JoinEndpoint.cs +++ b/src/core/Deal.Api/Endpoints/JoinEndpoint.cs @@ -1,118 +1,112 @@ -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Публичный эндпоинт активации инвайта: POST /api/join (Ruling 2/11 этапа 7). -/// -/// -/// Ручка не требует сессии (публичная; фронт её не вызывает — API-only, curl/будущий UI). Тело -/// {code, email, name?, password} → JoinService: валидация кода/email/пароля, CAS-резервирование инвайта, -/// создание тенанта (при пустом TenantId — с провижинингом схемы) и пользователя. Кука НЕ ставится: после -/// активации клиент входит обычным /api/auth/login (план Task 6). Успех — {ok:true, login}; ошибки — 400 -/// {detail} с фиксированным текстом причины (все отказы активации — 400, включая истёкший инвайт: слой -/// эндпоинта, см. Task 6; Ruling 2 называет это «410-семантикой» — ресурс больше недоступен). Результат -/// пишется в аудит — invite_joined (актор — новый пользователь тенанта, детали email+codeHash). -/// -public static class JoinEndpoint -{ - // Путь ручки (вне группы /api/operator — публичная). - private const string JoinPath = "/api/join"; - - // OpenAPI-тег. - private const string JoinOpenApiTag = "join"; - - // Текст 400: приглашение с таким кодом не найдено. - private const string InviteNotFoundDetail = "Приглашение не найдено"; - - // Текст 400: срок действия приглашения истёк (план Task 6, Ruling 2). - private const string InviteExpiredDetail = "Срок действия приглашения истёк"; - - // Текст 400: приглашение уже активировано (повторная активация тем же кодом). - private const string InviteUsedDetail = "Приглашение уже использовано"; - - // Текст 400: приглашение отозвано оператором. - private const string InviteRevokedDetail = "Приглашение отозвано"; - - // Текст 400: email запроса не совпадает с email приглашения (Ruling 2). - private const string EmailMismatchDetail = "Email не совпадает с приглашением"; - - // Текст 400: пользователь с таким email уже зарегистрирован (users.login unique, Ruling 2). - private const string EmailTakenDetail = "Этот email уже зарегистрирован"; - - // Текст 400: пароль короче минимума (текст как в AuthEndpoints, план Task 6). - 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; - } - - // POST /api/join: активация инвайта; успех пишется в аудит (invite_activated, Task 6/Ruling 4). - 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; + +/// +/// Публичный эндпоинт активации инвайта: POST /api/join (Ruling 2/11 этапа 7). +/// +/// +/// Ручка не требует сессии (публичная; фронт её не вызывает — API-only, curl/будущий UI). Тело +/// {code, email, name?, password} → JoinService: валидация кода/email/пароля, CAS-резервирование инвайта, +/// создание тенанта (при пустом TenantId — с провижинингом схемы) и пользователя. Кука НЕ ставится: после +/// активации клиент входит обычным /api/auth/login (план Task 6). Успех — {ok:true, login}; ошибки — 400 +/// {detail} с фиксированным текстом причины (все отказы активации — 400, включая истёкший инвайт: слой +/// эндпоинта, см. Task 6; Ruling 2 называет это «410-семантикой» — ресурс больше недоступен). Результат +/// пишется в аудит — invite_joined (актор — новый пользователь тенанта, детали email+codeHash). +/// +public static class JoinEndpoint +{ + // Путь ручки (вне группы /api/operator — публичная). + private const string JoinPath = "/api/join"; + + // OpenAPI-тег. + private const string JoinOpenApiTag = "join"; + + // Текст 400: приглашение с таким кодом не найдено. + private const string InviteNotFoundDetail = "Приглашение не найдено"; + + // Текст 400: срок действия приглашения истёк (план Task 6, Ruling 2). + private const string InviteExpiredDetail = "Срок действия приглашения истёк"; + + // Текст 400: приглашение уже активировано (повторная активация тем же кодом). + private const string InviteUsedDetail = "Приглашение уже использовано"; + + // Текст 400: приглашение отозвано оператором. + private const string InviteRevokedDetail = "Приглашение отозвано"; + + // Текст 400: email запроса не совпадает с email приглашения (Ruling 2). + private const string EmailMismatchDetail = "Email не совпадает с приглашением"; + + // Текст 400: пользователь с таким email уже зарегистрирован (users.login unique, Ruling 2). + private const string EmailTakenDetail = "Этот email уже зарегистрирован"; + + // Текст 400: пароль короче минимума (текст как в AuthEndpoints, план Task 6). + 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; + } + + // POST /api/join: активация инвайта; успех пишется в аудит (invite_activated, Task 6/Ruling 4). + 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 ae8b848..15af596 100644 --- a/src/core/Deal.Api/Endpoints/JoinRequest.cs +++ b/src/core/Deal.Api/Endpoints/JoinRequest.cs @@ -1,13 +1,10 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/join — активация инвайта (Ruling 2, Task 6 этапа 7). Входящий JSON — camelCase (code, email, name?, password). -/// -/// Код приглашения (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 — активация инвайта (Ruling 2, Task 6 этапа 7). Входящий JSON — camelCase (code, email, name?, password). +/// +/// Код приглашения (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 556fa90..3a8f6f6 100644 --- a/src/core/Deal.Api/Endpoints/LoginRequest.cs +++ b/src/core/Deal.Api/Endpoints/LoginRequest.cs @@ -1,11 +1,8 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/auth/login. Входящий JSON — camelCase (login, password). -/// -/// Логин пользователя. -/// Пароль в открытом виде. -public sealed record LoginRequest(string Login, string Password); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/auth/login. Входящий JSON — camelCase (login, password). +/// +/// Логин пользователя. +/// Пароль в открытом виде. +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 0e005a2..14c7fad 100644 --- a/src/core/Deal.Api/Endpoints/MlApplyRequest.cs +++ b/src/core/Deal.Api/Endpoints/MlApplyRequest.cs @@ -1,12 +1,9 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/ml/apply. Входящий JSON — camelCase (dialogId, msgId, action). -/// -/// Id диалога/канала Telegram, где лежит исходное сообщение. -/// Id сообщения внутри диалога. -/// Ручное решение: spam | board:<id> | skip (api-map §3.7 L197). -public sealed record MlApplyRequest(string DialogId, int MsgId, string Action); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/ml/apply. Входящий JSON — camelCase (dialogId, msgId, action). +/// +/// Id диалога/канала Telegram, где лежит исходное сообщение. +/// Id сообщения внутри диалога. +/// Ручное решение: spam | board:<id> | skip (api-map §3.7 L197). +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 7166536..56d01d7 100644 --- a/src/core/Deal.Api/Endpoints/MlCandidatesRequest.cs +++ b/src/core/Deal.Api/Endpoints/MlCandidatesRequest.cs @@ -1,11 +1,8 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/ml/candidates. Входящий JSON — camelCase (dialogId, limit). -/// -/// Id диалога/канала Telegram; пусто — выборка по всем источникам тенанта (§8). -/// Сколько последних сообщений вернуть (кламп 1..60, дефолт 10). -public sealed record MlCandidatesRequest(string DialogId, int Limit); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/ml/candidates. Входящий JSON — camelCase (dialogId, limit). +/// +/// Id диалога/канала Telegram; пусто — выборка по всем источникам тенанта (§8). +/// Сколько последних сообщений вернуть (кламп 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 02d8d4d..b596650 100644 --- a/src/core/Deal.Api/Endpoints/MlEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/MlEndpoints.cs @@ -1,192 +1,188 @@ -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Эндпоинты ML-панели: GET /api/ml/status, POST /api/ml/reset, /predict, /candidates, /apply (Ruling 8, api-map §3.7). -/// -/// -/// Тела ответов 1:1 с прототипом backend/app/routers/ml_routes.py (L66–91, L112–171): -/// status — MlStatusResponseDto (enabled/service/reachable/stats, §4.10 L363); reset — -/// {ok:true} (мягкая ошибка {ok:false,error} зарезервирована — заглушка всегда успешна); -/// predict{text: первые 200, take, label, scores, hits, ready, margin, terms, type} -/// (текст короче 2 символов после trim → 400 «Введите текст»); candidates{items} реальных -/// сообщений-кандидатов канала/выборки (очередь/отсев/карточки + мнение ML, §8; MlReviewService); -/// apply — 404 «Исходное сообщение не найдено» либо результат ручной разметки -/// {ok, learned, moved, leadId} (обучение ML + перенос/корзина/отсев). ml/learn и ml/flush -/// НЕ реализуются (фронт не вызывает, api-map п.9 L399). Все эндпоинты требуют сессию: 401 {detail} -/// (Ruling 10). IMlClient/MlReviewService резолвятся из RequestServices ПОСЛЕ проверки сессии (scoped -/// на tenant-запрос — вне него не разрешимы, паттерн SettingsEndpoints/AiCheckEndpoint). -/// -public static class MlEndpoints -{ - // Префикс группы /api/ml (Ruling 8: MapMlEndpoints). - private const string MlGroupPrefix = "/api/ml"; - - // OpenAPI-тег группы (в прототипе роутер ml — ml_routes.py). - 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"; - - // Минимальная длина текста для проверки (ml_routes.py L87: len(text) < 2 → 400). - private const int MinPredictTextLength = 2; - - // Длина текста в ответе predict: первые 200 символов (ml_routes.py L90 text[:200]). - private const int PredictTextPreviewLength = 200; - - // Сообщение 400 для слишком короткого текста (ml_routes.py L88, план Task 9 L348). - private const string EnterTextDetail = "Введите текст"; - - // Сообщение 404 apply: исходное сообщение не найдено (ml_routes.py L142, план Task 9 L352). - private const string MessageNotFoundDetail = "Исходное сообщение не найдено"; - - /// - /// Регистрирует группу /api/ml: status/reset/predict/candidates/apply. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - 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; - } - - // GET /api/ml/status: статус ML-сервиса + локальная статистика (ml_routes.py L66–75). - 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)); - } - - // POST /api/ml/reset: полный сброс модели + очистка очереди обучения (ml_routes.py L78–81). - 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)); - } - - // POST /api/ml/predict: проверка ML на тексте (ml_routes.py L84–90). - 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); - - // Ответ 1:1 с ml_routes.py L90: {"text": <первые 200>, **результат предсказания}. - 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, - }); - } - - // POST /api/ml/candidates: последние сообщения канала + мнение ML (ml_routes.py L112–134). - 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 }); - } - - // POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137–171). - 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-панели: GET /api/ml/status, POST /api/ml/reset, /predict, /candidates, /apply (Ruling 8, api-map §3.7). +/// +/// +/// Тела ответов 1:1 с прототипом backend/app/routers/ml_routes.py (L66–91, L112–171): +/// status — MlStatusResponseDto (enabled/service/reachable/stats, §4.10 L363); reset — +/// {ok:true} (мягкая ошибка {ok:false,error} зарезервирована — заглушка всегда успешна); +/// predict{text: первые 200, take, label, scores, hits, ready, margin, terms, type} +/// (текст короче 2 символов после trim → 400 «Введите текст»); candidates{items} реальных +/// сообщений-кандидатов канала/выборки (очередь/отсев/карточки + мнение ML, §8; MlReviewService); +/// apply — 404 «Исходное сообщение не найдено» либо результат ручной разметки +/// {ok, learned, moved, leadId} (обучение ML + перенос/корзина/отсев). ml/learn и ml/flush +/// НЕ реализуются (фронт не вызывает, api-map п.9 L399). Все эндпоинты требуют сессию: 401 {detail} +/// (Ruling 10). IMlClient/MlReviewService резолвятся из RequestServices ПОСЛЕ проверки сессии (scoped +/// на tenant-запрос — вне него не разрешимы, паттерн SettingsEndpoints/AiCheckEndpoint). +/// +public static class MlEndpoints +{ + // Префикс группы /api/ml (Ruling 8: MapMlEndpoints). + private const string MlGroupPrefix = "/api/ml"; + + // OpenAPI-тег группы (в прототипе роутер ml — ml_routes.py). + 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"; + + // Минимальная длина текста для проверки (ml_routes.py L87: len(text) < 2 → 400). + private const int MinPredictTextLength = 2; + + // Длина текста в ответе predict: первые 200 символов (ml_routes.py L90 text[:200]). + private const int PredictTextPreviewLength = 200; + + // Сообщение 400 для слишком короткого текста (ml_routes.py L88, план Task 9 L348). + private const string EnterTextDetail = "Введите текст"; + + // Сообщение 404 apply: исходное сообщение не найдено (ml_routes.py L142, план Task 9 L352). + private const string MessageNotFoundDetail = "Исходное сообщение не найдено"; + + /// + /// Регистрирует группу /api/ml: status/reset/predict/candidates/apply. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + 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; + } + + // GET /api/ml/status: статус ML-сервиса + локальная статистика (ml_routes.py L66–75). + 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)); + } + + // POST /api/ml/reset: полный сброс модели + очистка очереди обучения (ml_routes.py L78–81). + 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)); + } + + // POST /api/ml/predict: проверка ML на тексте (ml_routes.py L84–90). + 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); + + // Ответ 1:1 с ml_routes.py L90: {"text": <первые 200>, **результат предсказания}. + 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, + }); + } + + // POST /api/ml/candidates: последние сообщения канала + мнение ML (ml_routes.py L112–134). + 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 }); + } + + // POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137–171). + 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 f61bae3..be27fd9 100644 --- a/src/core/Deal.Api/Endpoints/MlPredictRequest.cs +++ b/src/core/Deal.Api/Endpoints/MlPredictRequest.cs @@ -1,10 +1,7 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/ml/predict. Входящий JSON — camelCase (text). -/// -/// Текст сообщения для проверки ML (обрезается/тримится обработчиком, как ml_routes.py L86). -public sealed record MlPredictRequest(string Text); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/ml/predict. Входящий JSON — camelCase (text). +/// +/// Текст сообщения для проверки ML (обрезается/тримится обработчиком, как ml_routes.py L86). +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 ec01601..dc5d899 100644 --- a/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs @@ -1,174 +1,169 @@ -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Операторские read-only эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3). -/// -/// -/// Только под операторской сессией: без неё 401 «Требуется вход оператора» (как прочие /api/operator/*). -/// Ничего не меняет (read-only). groupBy — day|tenant|provider|model (неизвестное — 400 {detail}); from/to — -/// ISO-8601 (включительно), как у аудита; activity поддерживает фильтры eventType/actorType/actorId/tenantId, -/// limit (1..500) и offset. Все ответы — camelCase (контракт: docs/architecture/2026-09-10-operator-analytics-contract.md). -/// -public static class OperatorAnalyticsEndpoints -{ - // Префикс группы аналитики (Ruling 4 этапа 10). - 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: overview/tokens/activity. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - 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; - } - - // GET /api/operator/analytics/suspicious?from=&to=: находки детектора подозрительной активности (§10.5). - // from: Начало окна анализа (включительно; ISO-8601); null — последние 24 часа. - // to: Конец окна анализа (включительно; ISO-8601); null — «сейчас». - // context: Контекст запроса. - // suspiciousService: Детектор подозрительной активности (scoped). - // ct: Токен отмены. - // Возвращает: 200 сводка находок или 401 без операторской сессии. - 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); - } - - /// - /// Известна ли группировка расхода токенов (day|tenant|provider|model). - /// - /// Значение группировки. - /// 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 эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3). +/// +/// +/// Только под операторской сессией: без неё 401 «Требуется вход оператора» (как прочие /api/operator/*). +/// Ничего не меняет (read-only). groupBy — day|tenant|provider|model (неизвестное — 400 {detail}); from/to — +/// ISO-8601 (включительно), как у аудита; activity поддерживает фильтры eventType/actorType/actorId/tenantId, +/// limit (1..500) и offset. Все ответы — camelCase (контракт: docs/architecture/2026-09-10-operator-analytics-contract.md). +/// +public static class OperatorAnalyticsEndpoints +{ + // Префикс группы аналитики (Ruling 4 этапа 10). + 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: overview/tokens/activity. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + 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; + } + + // GET /api/operator/analytics/suspicious?from=&to=: находки детектора подозрительной активности (§10.5). + // from: Начало окна анализа (включительно; ISO-8601); null — последние 24 часа. + // to: Конец окна анализа (включительно; ISO-8601); null — «сейчас». + // context: Контекст запроса. + // suspiciousService: Детектор подозрительной активности (scoped). + // ct: Токен отмены. + // Возвращает: 200 сводка находок или 401 без операторской сессии. + 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); + } + + /// + /// Известна ли группировка расхода токенов (day|tenant|provider|model). + /// + /// Значение группировки. + /// 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 4a909e6..d5904de 100644 --- a/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs @@ -1,100 +1,95 @@ -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Операторский эндпоинт чтения аудита: GET /api/operator/audit (Ruling 4 этапа 7). -/// -/// -/// Чтение — только оператору: без операторской сессии 401 «Требуется вход оператора» (как /api/operator/auth/me). -/// Фильтры-query: eventType, actorType, tenantId, from, to, limit (At DESC, limit клампится в -/// 1.., дефолт — ). -/// Ответ — {items: [...], total}: total — полное число записей по фильтру (без учёта limit). Запись событий — -/// только через (append-only); этот эндпоинт лишь читает. -/// -public static class OperatorAuditEndpoints -{ - // Префикс группы операторских ручек /api/operator (Ruling 11). - private const string OperatorGroupPrefix = "/api/operator"; - - // Путь ленты аудита относительно группы. - private const string AuditPath = "/audit"; - - // OpenAPI-тег группы (Ruling 11: операторская админка — API-only). - private const string OperatorOpenApiTag = "operator"; - - /// - /// Регистрирует группу /api/operator: GET /audit (лента аудита; другие ручки — задачи 5/7/10). - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapOperatorAuditEndpoints(this IEndpointRouteBuilder app) - { - app.MapGroup(OperatorGroupPrefix).WithTags(OperatorOpenApiTag).MapGet(AuditPath, ListAsync); - return app; - } - - // GET /api/operator/audit?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; этап 10, T3). - // context: Контекст запроса. - // auditService: Сервис аудита (scoped). - // ct: Токен отмены. - // Возвращает: 200 {items:[...], total} или 401 без операторской сессии. - 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 запроса: дефолт , кламп 1..500 (Ruling 4). - /// - /// Запрошенный размер выборки (null — не задан). - /// Значение для фильтра. - public static int NormalizeLimit(int? limit) => - limit is null - ? AuditService.DefaultQueryLimit - : Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit.Value)); - - /// - /// Нормализует offset запроса: отрицательное/отсутствующее — 0 (этап 10, T3). - /// - /// Запрошенное смещение (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; + +/// +/// Операторский эндпоинт чтения аудита: GET /api/operator/audit (Ruling 4 этапа 7). +/// +/// +/// Чтение — только оператору: без операторской сессии 401 «Требуется вход оператора» (как /api/operator/auth/me). +/// Фильтры-query: eventType, actorType, tenantId, from, to, limit (At DESC, limit клампится в +/// 1.., дефолт — ). +/// Ответ — {items: [...], total}: total — полное число записей по фильтру (без учёта limit). Запись событий — +/// только через (append-only); этот эндпоинт лишь читает. +/// +public static class OperatorAuditEndpoints +{ + // Префикс группы операторских ручек /api/operator (Ruling 11). + private const string OperatorGroupPrefix = "/api/operator"; + + // Путь ленты аудита относительно группы. + private const string AuditPath = "/audit"; + + // OpenAPI-тег группы (Ruling 11: операторская админка — API-only). + private const string OperatorOpenApiTag = "operator"; + + /// + /// Регистрирует группу /api/operator: GET /audit (лента аудита; другие ручки — задачи 5/7/10). + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapOperatorAuditEndpoints(this IEndpointRouteBuilder app) + { + app.MapGroup(OperatorGroupPrefix).WithTags(OperatorOpenApiTag).MapGet(AuditPath, ListAsync); + return app; + } + + // GET /api/operator/audit?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; этап 10, T3). + // context: Контекст запроса. + // auditService: Сервис аудита (scoped). + // ct: Токен отмены. + // Возвращает: 200 {items:[...], total} или 401 без операторской сессии. + 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 запроса: дефолт , кламп 1..500 (Ruling 4). + /// + /// Запрошенный размер выборки (null — не задан). + /// Значение для фильтра. + public static int NormalizeLimit(int? limit) => + limit is null + ? AuditService.DefaultQueryLimit + : Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit.Value)); + + /// + /// Нормализует offset запроса: отрицательное/отсутствующее — 0 (этап 10, T3). + /// + /// Запрошенное смещение (null — не задано). + /// Неотрицательное смещение. + public static int NormalizeOffset(int? offset) => Math.Max(0, offset ?? 0); +} diff --git a/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs index 854ba92..365199b 100644 --- a/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs @@ -1,180 +1,176 @@ -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Api.Middleware; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.Extensions.Options; -using Deal.Api.Dtos; -using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions; -// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасами. -using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions; - -namespace Deal.Api.Endpoints; - -/// -/// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1). -/// -/// -/// Оператор ≠ пользователь тенанта: вход по отдельным public-таблицам (OperatorAuthService/IOperatorAuthStore), -/// сессия — в куке deal_operator_session (отдельная от deal_session; 12 ч, httpOnly, SameSite=Lax). -/// Успех-ответы — {ok:true,...}, ошибки — HTTP-код + {"detail":"..."} (Ruling 10). Защищённые -/// ручки (me) требуют операторскую сессию (401 «Требуется вход оператора») — тенантная кука не проходит. -/// Результаты входа пишутся в аудит (operator_login_ok/failed, Task 4/Ruling 4). -/// -public static class OperatorAuthEndpoints -{ - private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора"; - private const string OperatorAuthGroupPrefix = "/api/operator/auth"; - private const string OperatorAuthOpenApiTag = "operator-auth"; - - /// - /// Регистрирует группу /api/operator/auth: login, logout, me. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapOperatorAuthEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag); - - // Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP ручки - // входа оператора; остальные ручки группы — под глобальной API-политикой (по тенанту/IP). - group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy); - group.MapPost("/logout", LogoutAsync); - group.MapGet("/me", MeAsync); - - return app; - } - - // POST /api/operator/auth/login: проверка учётных данных оператора, выдача куки сессии; результат пишется в аудит (Task 4). - // До OperatorAuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5). - private static async Task LoginAsync( - LoginRequest body, - OperatorAuthService operatorAuthService, - AuditService auditService, - IOptions cookieOptions, - HttpContext context, - CancellationToken ct, - LoginAttemptGuard loginAttemptGuard) - { - string? attemptedLogin = NormalizeLogin(body.Login); - - // Защита входа оператора (план Task 11, Ruling 5): зеркало AuthEndpoints — блокировка ключа - // ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен). - if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct)) - { - return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail); - } - - var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct); - if (result.Login is null || result.Token is null) - { - // Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints). - // Аудит operator_login_failed — только для реальной попытки (непустой логин), без пароля (Ruling 4); - // счётчик неудач гварда растёт там же (пустые логины ключа не имеют). - if (attemptedLogin is not null) - { - await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct); - await auditService.AppendAsync(new AuditRecordDto( - AuditEvents.OperatorLoginFailed, - AuditActorTypes.Operator, - ActorId: null, - TenantId: null, - Ip: ClientIp(context), - DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct); - } - - return EndpointResults.Unauthorized(InvalidCredentialsDetail); - } - - // Успешный вход оператора сбрасывает счётчик неудач ключа ip|login (Ruling 5). - await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct); - - await auditService.AppendAsync(new AuditRecordDto( - AuditEvents.OperatorLoginOk, - AuditActorTypes.Operator, - ActorId: result.OperatorId, - TenantId: null, - Ip: ClientIp(context), - DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct); - - SetOperatorSessionCookie(context, cookieOptions.Value, result.Token); - return Results.Ok(new { ok = true, login = result.Login }); - } - - // POST /api/operator/auth/logout: удаление операторской сессии по токену из куки и очистка куки (всегда ok). - private static async Task LogoutAsync( - OperatorAuthService operatorAuthService, - IOptions cookieOptions, - HttpContext context, - CancellationToken ct) - { - var cookieName = cookieOptions.Value.Name; - var rawToken = context.Request.Cookies[cookieName]; - // Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items). - CurrentOperator? operatorIdentity = context.GetCurrentOperator(); - await operatorAuthService.LogoutAsync(rawToken, ct); - context.Response.Cookies.Delete(cookieName); - - // Выход оператора (этап 10, T1): событие пишется при живой разрешённой сессии. - if (operatorIdentity is not null) - { - await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct); - } - - return Results.Ok(new { ok = true }); - } - - // GET /api/operator/auth/me: проверка живой операторской сессии (401 без неё, Ruling 1). - private static IResult MeAsync(HttpContext context) - { - var operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - return Results.Ok(new { login = operatorIdentity.Login, ok = true }); - } - - // Выставляет httpOnly-куку сессии оператора: SameSite=Lax, Path=/, MaxAge=Hours, Secure — из конфига. - // context: Контекст запроса. - // options: Настройки куки из конфигурации (секция OperatorCookies). - // rawToken: Raw-токен операторской сессии. - private static void SetOperatorSessionCookie( - HttpContext context, - OperatorCookieOptions options, - string rawToken) - { - // MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на - // OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions). - context.Response.Cookies.Append( - options.Name, - rawToken, - new AspNetCoreCookieOptions - { - HttpOnly = true, - SameSite = SameSiteMode.Lax, - Path = "/", - MaxAge = TimeSpan.FromHours(options.Hours), - Secure = options.Secure, - }); - } - - // Нормализованная попытка логина для аудита (нижний регистр/обрезка); null — писать нечего. - // login: Логин из тела запроса. - // Возвращает: Нормализованный логин или null при пустом/пробельном входе. - private static string? NormalizeLogin(string? login) - { - string? normalized = login?.Trim().ToLowerInvariant(); - return string.IsNullOrEmpty(normalized) ? null : normalized; - } - - // IP-адрес клиента для аудита (без порта; null, если недоступен). - // context: Контекст запроса. - // Возвращает: Строковое представление IP или null. - private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); -} +using Deal.Api.Extensions; +using Deal.Api.Middleware; +using Deal.Api.Models; +using Deal.Api.Services; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; +using Microsoft.Extensions.Options; +using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions; +// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасами. +using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions; + +namespace Deal.Api.Endpoints; + +/// +/// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1). +/// +/// +/// Оператор ≠ пользователь тенанта: вход по отдельным public-таблицам (OperatorAuthService/IOperatorAuthStore), +/// сессия — в куке deal_operator_session (отдельная от deal_session; 12 ч, httpOnly, SameSite=Lax). +/// Успех-ответы — {ok:true,...}, ошибки — HTTP-код + {"detail":"..."} (Ruling 10). Защищённые +/// ручки (me) требуют операторскую сессию (401 «Требуется вход оператора») — тенантная кука не проходит. +/// Результаты входа пишутся в аудит (operator_login_ok/failed, Task 4/Ruling 4). +/// +public static class OperatorAuthEndpoints +{ + private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора"; + private const string OperatorAuthGroupPrefix = "/api/operator/auth"; + private const string OperatorAuthOpenApiTag = "operator-auth"; + + /// + /// Регистрирует группу /api/operator/auth: login, logout, me. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapOperatorAuthEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag); + + // Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP ручки + // входа оператора; остальные ручки группы — под глобальной API-политикой (по тенанту/IP). + group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy); + group.MapPost("/logout", LogoutAsync); + group.MapGet("/me", MeAsync); + + return app; + } + + // POST /api/operator/auth/login: проверка учётных данных оператора, выдача куки сессии; результат пишется в аудит (Task 4). + // До OperatorAuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5). + private static async Task LoginAsync( + LoginRequest body, + OperatorAuthService operatorAuthService, + AuditService auditService, + IOptions cookieOptions, + HttpContext context, + CancellationToken ct, + LoginAttemptGuard loginAttemptGuard) + { + string? attemptedLogin = NormalizeLogin(body.Login); + + // Защита входа оператора (план Task 11, Ruling 5): зеркало AuthEndpoints — блокировка ключа + // ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен). + if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct)) + { + return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail); + } + + var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct); + if (result.Login is null || result.Token is null) + { + // Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints). + // Аудит operator_login_failed — только для реальной попытки (непустой логин), без пароля (Ruling 4); + // счётчик неудач гварда растёт там же (пустые логины ключа не имеют). + if (attemptedLogin is not null) + { + await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct); + await auditService.AppendAsync(new AuditRecordDto( + AuditEvents.OperatorLoginFailed, + AuditActorTypes.Operator, + ActorId: null, + TenantId: null, + Ip: ClientIp(context), + DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct); + } + + return EndpointResults.Unauthorized(InvalidCredentialsDetail); + } + + // Успешный вход оператора сбрасывает счётчик неудач ключа ip|login (Ruling 5). + await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct); + + await auditService.AppendAsync(new AuditRecordDto( + AuditEvents.OperatorLoginOk, + AuditActorTypes.Operator, + ActorId: result.OperatorId, + TenantId: null, + Ip: ClientIp(context), + DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct); + + SetOperatorSessionCookie(context, cookieOptions.Value, result.Token); + return Results.Ok(new { ok = true, login = result.Login }); + } + + // POST /api/operator/auth/logout: удаление операторской сессии по токену из куки и очистка куки (всегда ok). + private static async Task LogoutAsync( + OperatorAuthService operatorAuthService, + IOptions cookieOptions, + HttpContext context, + CancellationToken ct) + { + var cookieName = cookieOptions.Value.Name; + var rawToken = context.Request.Cookies[cookieName]; + // Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items). + CurrentOperator? operatorIdentity = context.GetCurrentOperator(); + await operatorAuthService.LogoutAsync(rawToken, ct); + context.Response.Cookies.Delete(cookieName); + + // Выход оператора (этап 10, T1): событие пишется при живой разрешённой сессии. + if (operatorIdentity is not null) + { + await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct); + } + + return Results.Ok(new { ok = true }); + } + + // GET /api/operator/auth/me: проверка живой операторской сессии (401 без неё, Ruling 1). + private static IResult MeAsync(HttpContext context) + { + var operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + return Results.Ok(new { login = operatorIdentity.Login, ok = true }); + } + + // Выставляет httpOnly-куку сессии оператора: SameSite=Lax, Path=/, MaxAge=Hours, Secure — из конфига. + // context: Контекст запроса. + // options: Настройки куки из конфигурации (секция OperatorCookies). + // rawToken: Raw-токен операторской сессии. + private static void SetOperatorSessionCookie( + HttpContext context, + OperatorCookieOptions options, + string rawToken) + { + // MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на + // OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions). + context.Response.Cookies.Append( + options.Name, + rawToken, + new AspNetCoreCookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Lax, + Path = "/", + MaxAge = TimeSpan.FromHours(options.Hours), + Secure = options.Secure, + }); + } + + // Нормализованная попытка логина для аудита (нижний регистр/обрезка); null — писать нечего. + // login: Логин из тела запроса. + // Возвращает: Нормализованный логин или null при пустом/пробельном входе. + private static string? NormalizeLogin(string? login) + { + string? normalized = login?.Trim().ToLowerInvariant(); + return string.IsNullOrEmpty(normalized) ? null : normalized; + } + + // IP-адрес клиента для аудита (без порта; null, если недоступен). + // context: Контекст запроса. + // Возвращает: Строковое представление IP или null. + private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); +} diff --git a/src/core/Deal.Api/Endpoints/OperatorHealthEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorHealthEndpoints.cs index 25ab469..d761491 100644 --- a/src/core/Deal.Api/Endpoints/OperatorHealthEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorHealthEndpoints.cs @@ -1,204 +1,198 @@ -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Api.Observability; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; -using Microsoft.EntityFrameworkCore; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Операторский health: GET /api/operator/health (план Task 10, Ruling 3/6/9/11) — ядро/БД и -/// автономные сервисы ml/ai/telegram. -/// -/// -/// Ручка — только оператору (401 «Требуется вход оператора» без операторской сессии). Ответ всегда 200 -/// (информационный операторский обзор, как /api/health) с полями состояния: -/// {ok, core:{db:"ok"|"down"}, services:[{name, mode:"grpc"|"local", status, reachable}], -/// queues:{pipeline, mlOutbox}, sessions:{active}}. Глубины очередей обработки/ML-outbox и число -/// активных сессий (§10.2) собирает общий (тот же путь, что метрики). -/// Проверка БД — SELECT 1 через DealDbContext (public-схема) с таймаутом 5 с; сбой (контейнер не поднят/ -/// сеть) → core.db=down без падения ручки. Сервисы: при Services:*:UseLocal=true{mode:"local", -/// reachable:false, status:"local"} (Local-адаптеры, реальный сервис не поднят — Ruling 6; dev-приёмка); -/// в gRPC-режиме — к Services:*:Endpoint (grpc.health.v1, таймаут 3 с): -/// SERVING → status=ok, иной статус → unhealthy, недоступен → down. ok сводки — БД доступна и все -/// сервисы в порядке (Local-режим не считается сбоем). -/// -public static class OperatorHealthEndpoints -{ - // Префикс группы операторских ручек health (Ruling 11). - 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"; - - // Статус сервиса в Local-режиме: реальный сервис не подключён (UseLocal=true, Ruling 6). - 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 (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: GET /api/operator/health (план Task 10, Ruling 3/6/9/11) — ядро/БД и +/// автономные сервисы ml/ai/telegram. +/// +/// +/// Ручка — только оператору (401 «Требуется вход оператора» без операторской сессии). Ответ всегда 200 +/// (информационный операторский обзор, как /api/health) с полями состояния: +/// {ok, core:{db:"ok"|"down"}, services:[{name, mode:"grpc"|"local", status, reachable}], +/// queues:{pipeline, mlOutbox}, sessions:{active}}. Глубины очередей обработки/ML-outbox и число +/// активных сессий (§10.2) собирает общий (тот же путь, что метрики). +/// Проверка БД — SELECT 1 через DealDbContext (public-схема) с таймаутом 5 с; сбой (контейнер не поднят/ +/// сеть) → core.db=down без падения ручки. Сервисы: при Services:*:UseLocal=true{mode:"local", +/// reachable:false, status:"local"} (Local-адаптеры, реальный сервис не поднят — Ruling 6; dev-приёмка); +/// в gRPC-режиме — к Services:*:Endpoint (grpc.health.v1, таймаут 3 с): +/// SERVING → status=ok, иной статус → unhealthy, недоступен → down. ok сводки — БД доступна и все +/// сервисы в порядке (Local-режим не считается сбоем). +/// +public static class OperatorHealthEndpoints +{ + // Префикс группы операторских ручек health (Ruling 11). + 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"; + + // Статус сервиса в Local-режиме: реальный сервис не подключён (UseLocal=true, Ruling 6). + 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 (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 ab870f2..f422e75 100644 --- a/src/core/Deal.Api/Endpoints/OperatorInviteCreateRequest.cs +++ b/src/core/Deal.Api/Endpoints/OperatorInviteCreateRequest.cs @@ -1,11 +1,8 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/operator/invites: email приглашённого и опциональный целевой тенант (Ruling 2 этапа 7). -/// -/// Email приглашённого (регистр/пробелы не важны — нормализует InvitesService). -/// Целевой тенант; null — при активации будет создан новый тенант (Task 6). -public sealed record OperatorInviteCreateRequest(string? Email, Guid? TenantId); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/operator/invites: email приглашённого и опциональный целевой тенант (Ruling 2 этапа 7). +/// +/// Email приглашённого (регистр/пробелы не важны — нормализует InvitesService). +/// Целевой тенант; null — при активации будет создан новый тенант (Task 6). +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 c627794..38658f8 100644 --- a/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs @@ -1,152 +1,147 @@ -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Операторские эндпоинты приглашений: GET /api/operator/invites, POST (создание), POST {code}/revoke (Ruling 2/11 этапа 7). -/// -/// -/// Создание/отзыв/чтение — только оператор: без операторской сессии 401 «Требуется вход оператора» -/// (как /api/operator/auth/me). Создание возвращает {code, email, tenantId, expiresAt, status} (план Task 5), -/// список — {items:[...]} (полные строки; форма как у GET /api/operator/audit), отзыв — {ok:true}. Результаты -/// пишутся в аудит — invite_created/invite_revoked с email и codeHash в DetailJson (Ruling 4; операторские события, -/// TenantId null; хэш кода — Security review). Тексты ошибок — фиксированные строки HTTP-слоя (паттерн AuthEndpoints). -/// -public static class OperatorInvitesEndpoints -{ - // Текст 400: email пустой/некорректного формата. - private const string InvalidEmailDetail = "Некорректный email"; - - // Текст 400: на email уже есть активное приглашение (план Task 5, Ruling 2). - private const string DuplicateActiveDetail = "Для этого email уже есть активное приглашение"; - - // Текст 404: приглашение с таким кодом не найдено. - private const string InviteNotFoundDetail = "Приглашение не найдено"; - - // Текст 400: отзыв приглашения не в статусе pending (уже отозвано/использовано/истекло). - private const string InviteNotPendingDetail = "Отозвать можно только ожидающее активации приглашение"; - - // Префикс группы операторских ручек приглашений (Ruling 11). - private const string InvitesGroupPrefix = "/api/operator/invites"; - - // Относительный путь отзыва приглашения. - private const string RevokePath = "/{code}/revoke"; - - // OpenAPI-тег группы. - private const string InvitesOpenApiTag = "operator-invites"; - - /// - /// Регистрирует группу /api/operator/invites: GET (список), POST (создание), POST {code}/revoke (отзыв). - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - 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; + +/// +/// Операторские эндпоинты приглашений: GET /api/operator/invites, POST (создание), POST {code}/revoke (Ruling 2/11 этапа 7). +/// +/// +/// Создание/отзыв/чтение — только оператор: без операторской сессии 401 «Требуется вход оператора» +/// (как /api/operator/auth/me). Создание возвращает {code, email, tenantId, expiresAt, status} (план Task 5), +/// список — {items:[...]} (полные строки; форма как у GET /api/operator/audit), отзыв — {ok:true}. Результаты +/// пишутся в аудит — invite_created/invite_revoked с email и codeHash в DetailJson (Ruling 4; операторские события, +/// TenantId null; хэш кода — Security review). Тексты ошибок — фиксированные строки HTTP-слоя (паттерн AuthEndpoints). +/// +public static class OperatorInvitesEndpoints +{ + // Текст 400: email пустой/некорректного формата. + private const string InvalidEmailDetail = "Некорректный email"; + + // Текст 400: на email уже есть активное приглашение (план Task 5, Ruling 2). + private const string DuplicateActiveDetail = "Для этого email уже есть активное приглашение"; + + // Текст 404: приглашение с таким кодом не найдено. + private const string InviteNotFoundDetail = "Приглашение не найдено"; + + // Текст 400: отзыв приглашения не в статусе pending (уже отозвано/использовано/истекло). + private const string InviteNotPendingDetail = "Отозвать можно только ожидающее активации приглашение"; + + // Префикс группы операторских ручек приглашений (Ruling 11). + private const string InvitesGroupPrefix = "/api/operator/invites"; + + // Относительный путь отзыва приглашения. + private const string RevokePath = "/{code}/revoke"; + + // OpenAPI-тег группы. + private const string InvitesOpenApiTag = "operator-invites"; + + /// + /// Регистрирует группу /api/operator/invites: GET (список), POST (создание), POST {code}/revoke (отзыв). + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + 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 feb1473..bf78810 100644 --- a/src/core/Deal.Api/Endpoints/OperatorLimitUpdateRequest.cs +++ b/src/core/Deal.Api/Endpoints/OperatorLimitUpdateRequest.cs @@ -1,13 +1,10 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Тело PATCH /api/operator/tenants/{id}/limit: смена лимитов ИИ-бюджета тенанта (план Task 10, Ruling 3). -/// Оба поля опциональны — меняется только заданное; смена бюджета/периода сбрасывает флаги Warned80/ -/// NotifiedExhausted (новый период открывает пороги тостов, один тост на период на порог, Ruling 3). -/// -/// Новый бюджет периода в токенах (≥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: смена лимитов ИИ-бюджета тенанта (план Task 10, Ruling 3). +/// Оба поля опциональны — меняется только заданное; смена бюджета/периода сбрасывает флаги Warned80/ +/// NotifiedExhausted (новый период открывает пороги тостов, один тост на период на порог, Ruling 3). +/// +/// Новый бюджет периода в токенах (≥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 e627b29..047deb0 100644 --- a/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs @@ -1,251 +1,247 @@ -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Операторские эндпоинты лимитов ИИ-бюджета: сводка по всем тенантам и просмотр/смена лимита тенанта -/// (план Task 10, Ruling 3/11). -/// -/// -/// Все ручки — только оператору: без операторской сессии 401 «Требуется вход оператора» (как остальные -/// /api/operator/*). GET /api/operator/limits — сводка {items:[{tenantId, name, budget, period, used, percent, -/// status}]} по реестру тенантов (Ruling 3: строка лимита на путь чтения заводится лениво с дефолт-бюджетом — -/// тенант без расхода виден как «дефолт, 0»). GET/PATCH /api/operator/tenants/{id}/limit — детали/смена лимита: -/// PATCH принимает {budget?, period?} (оба опциональны — меняется только заданное; null-тело/без полей → 400), -/// сбрасывает Warned80/NotifiedExhausted через UpdateBudgetAsync (Ruling 3: смена бюджета открывает пороги -/// тостов заново) и пишет аудит tenant_limit_changed (только при реальном изменении — повторный PATCH с теми же -/// значениями идемпотентен, аудит не дублируется). Отрицательный бюджет/чужой период отсекаются 400 до вызова -/// хранилища; тенант проверяется по реестру (404 «Тенант не найден»). Ответы деталей — единая форма -/// (см. ) — статус тенанта, флаги порогов и процент расхода. -/// -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; - - // Префикс сводки лимитов (Ruling 11: /api/operator/*). - private const string OperatorGroupPrefix = "/api/operator"; - - // Префикс группы операторских ручек тенантов (общий с Task 7). - 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"; - - // Без состояния, поэтому безопасен как статический экземпляр (период-математика Task 8). - private static readonly TokenBudgetService BudgetService = new(); - - /// - /// Регистрирует ручки лимитов: GET /api/operator/limits (сводка) и GET/PATCH - /// /api/operator/tenants/{id}/limit (детали/смена). - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - 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; - } - - // GET /api/operator/limits: сводка бюджета/расхода по всем тенантам (план Task 10). - 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) - { - // Ленивый reset периода внутри GetStateAsync (Ruling 3): сводка всегда про текущий период. - 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, floor). - /// - /// - /// Бюджет ≤0 трактуется как исчерпанный (лимит 0 запрещает ИИ, Ruling 3) → 100%; расход ≥ бюджета также - /// показывается как 100 (потолок индикатора). Расчёт — в double: диапазон long (до ~9.2·10¹⁸ токенов) - /// не переполняет double, floor-ошибка возможна только на границе целого при масштабах, нереальных для - /// бюджета токенов (целочисленный used·100/budget переполнялся бы при used > ~9.2·10¹⁶). - /// - /// Использовано токенов с начала периода. - /// Бюджет периода. - /// Процент в диапазоне 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; + +/// +/// Операторские эндпоинты лимитов ИИ-бюджета: сводка по всем тенантам и просмотр/смена лимита тенанта +/// (план Task 10, Ruling 3/11). +/// +/// +/// Все ручки — только оператору: без операторской сессии 401 «Требуется вход оператора» (как остальные +/// /api/operator/*). GET /api/operator/limits — сводка {items:[{tenantId, name, budget, period, used, percent, +/// status}]} по реестру тенантов (Ruling 3: строка лимита на путь чтения заводится лениво с дефолт-бюджетом — +/// тенант без расхода виден как «дефолт, 0»). GET/PATCH /api/operator/tenants/{id}/limit — детали/смена лимита: +/// PATCH принимает {budget?, period?} (оба опциональны — меняется только заданное; null-тело/без полей → 400), +/// сбрасывает Warned80/NotifiedExhausted через UpdateBudgetAsync (Ruling 3: смена бюджета открывает пороги +/// тостов заново) и пишет аудит tenant_limit_changed (только при реальном изменении — повторный PATCH с теми же +/// значениями идемпотентен, аудит не дублируется). Отрицательный бюджет/чужой период отсекаются 400 до вызова +/// хранилища; тенант проверяется по реестру (404 «Тенант не найден»). Ответы деталей — единая форма +/// (см. ) — статус тенанта, флаги порогов и процент расхода. +/// +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; + + // Префикс сводки лимитов (Ruling 11: /api/operator/*). + private const string OperatorGroupPrefix = "/api/operator"; + + // Префикс группы операторских ручек тенантов (общий с Task 7). + 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"; + + // Без состояния, поэтому безопасен как статический экземпляр (период-математика Task 8). + private static readonly TokenBudgetService BudgetService = new(); + + /// + /// Регистрирует ручки лимитов: GET /api/operator/limits (сводка) и GET/PATCH + /// /api/operator/tenants/{id}/limit (детали/смена). + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + 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; + } + + // GET /api/operator/limits: сводка бюджета/расхода по всем тенантам (план Task 10). + 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) + { + // Ленивый reset периода внутри GetStateAsync (Ruling 3): сводка всегда про текущий период. + 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, floor). + /// + /// + /// Бюджет ≤0 трактуется как исчерпанный (лимит 0 запрещает ИИ, Ruling 3) → 100%; расход ≥ бюджета также + /// показывается как 100 (потолок индикатора). Расчёт — в double: диапазон long (до ~9.2·10¹⁸ токенов) + /// не переполняет double, floor-ошибка возможна только на границе целого при масштабах, нереальных для + /// бюджета токенов (целочисленный used·100/budget переполнялся бы при used > ~9.2·10¹⁶). + /// + /// Использовано токенов с начала периода. + /// Бюджет периода. + /// Процент в диапазоне 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 b1dc9e1..053f8aa 100644 --- a/src/core/Deal.Api/Endpoints/OperatorMaintenanceEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorMaintenanceEndpoints.cs @@ -1,67 +1,65 @@ -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Infrastructure.Tenancy; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Операторские maintenance-ручки (этап 12, пакет C): пакетная миграция схем всех тенантов. -/// -/// -/// Ручка — только оператору (401 «Требуется вход оператора» без операторской сессии). -/// POST /api/operator/maintenance/tenants/migrate — идемпотентно проводит провижининг/миграции схем ВСЕХ -/// тенантов реестра (CREATE SCHEMA IF NOT EXISTS + EF Migrate, применяющий только неприменённые миграции) -/// с ограниченным параллелизмом и логированием прогресса (). -/// Ответ {ok, total, migrated, failed, failedSchemas, durationMs}; ok=false, если хотя бы одна схема -/// не мигрирована (сбой одной не прерывает остальные — оператор видит список проблемных схем). -/// -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-ручки (этап 12, пакет C): пакетная миграция схем всех тенантов. +/// +/// +/// Ручка — только оператору (401 «Требуется вход оператора» без операторской сессии). +/// POST /api/operator/maintenance/tenants/migrate — идемпотентно проводит провижининг/миграции схем ВСЕХ +/// тенантов реестра (CREATE SCHEMA IF NOT EXISTS + EF Migrate, применяющий только неприменённые миграции) +/// с ограниченным параллелизмом и логированием прогресса (). +/// Ответ {ok, total, migrated, failed, failedSchemas, durationMs}; ok=false, если хотя бы одна схема +/// не мигрирована (сбой одной не прерывает остальные — оператор видит список проблемных схем). +/// +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 7aa7886..635c1ae 100644 --- a/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs @@ -1,160 +1,155 @@ -using Deal.Api.Endpoints.RequestModels; -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Api.Telegram; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Операторские ручки глобальных (системных) настроек: ключи приложения Telegram -/// (ТЗ §4.1/§8.1). -/// -/// -/// Все ручки — только под операторской сессией: без неё 401 «Требуется вход оператора». Ключи Telegram -/// задаёт оператор глобально (едины для всех тенантов), тенант их не видит и не задаёт. -/// -/// GET /api/operator/settings/telegram-keys — маскированный снимок: apiId (не секрет, открыт), -/// apiHash (маска) и keysSet; -/// PUT /api/operator/settings/telegram-keys {apiId?, apiHash?} — частичное сохранение (можно -/// передать только одно поле, второе сохраняется); валидация (api_id 5..9 цифр, api_hash непустой), -/// шифрование секрета и аудит telegram_keys_changed (без секретов в деталях). -/// -/// Ошибки — 400/401 {detail} (формат прототипа, Ruling 10). -/// -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: telegram-keys (GET/PUT). - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - 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); - - // Аудит смены глобальных ключей: apiId — не секрет, apiHash в детали не пишется (Ruling 4). - 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; + +/// +/// Операторские ручки глобальных (системных) настроек: ключи приложения Telegram +/// (ТЗ §4.1/§8.1). +/// +/// +/// Все ручки — только под операторской сессией: без неё 401 «Требуется вход оператора». Ключи Telegram +/// задаёт оператор глобально (едины для всех тенантов), тенант их не видит и не задаёт. +/// +/// GET /api/operator/settings/telegram-keys — маскированный снимок: apiId (не секрет, открыт), +/// apiHash (маска) и keysSet; +/// PUT /api/operator/settings/telegram-keys {apiId?, apiHash?} — частичное сохранение (можно +/// передать только одно поле, второе сохраняется); валидация (api_id 5..9 цифр, api_hash непустой), +/// шифрование секрета и аудит telegram_keys_changed (без секретов в деталях). +/// +/// Ошибки — 400/401 {detail} (формат прототипа, Ruling 10). +/// +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: telegram-keys (GET/PUT). + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + 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); + + // Аудит смены глобальных ключей: apiId — не секрет, apiHash в детали не пишется (Ruling 4). + 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 f78cc71..bcf628f 100644 --- a/src/core/Deal.Api/Endpoints/OperatorTenantCreateRequest.cs +++ b/src/core/Deal.Api/Endpoints/OperatorTenantCreateRequest.cs @@ -1,12 +1,9 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/operator/tenants: создание тенанта оператором (план Task 7, Ruling 11). -/// -/// Имя тенанта (обязательно; пробелы по краям обрезаются). -/// Email владельца (опционально): создаёт сразу пользователя-владельца с одноразовым -/// паролем (иначе владелец заводится инвайтом, Ruling 2). -public sealed record OperatorTenantCreateRequest(string? Name, string? Email); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/operator/tenants: создание тенанта оператором (план Task 7, Ruling 11). +/// +/// Имя тенанта (обязательно; пробелы по краям обрезаются). +/// Email владельца (опционально): создаёт сразу пользователя-владельца с одноразовым +/// паролем (иначе владелец заводится инвайтом, Ruling 2). +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 853ed95..9f15c8e 100644 --- a/src/core/Deal.Api/Endpoints/OperatorTenantImpersonateRequest.cs +++ b/src/core/Deal.Api/Endpoints/OperatorTenantImpersonateRequest.cs @@ -1,11 +1,8 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Тело POST /api/operator/tenants/{id}/impersonate: опциональный логин пользователя тенанта (план Task 7). -/// -/// Логин пользователя, под которым оператор входит (impersonation); null/пустой — -/// берётся первый пользователь тенанта (по времени создания). -public sealed record OperatorTenantImpersonateRequest(string? Login); +namespace Deal.Api.Endpoints; + +/// +/// Тело POST /api/operator/tenants/{id}/impersonate: опциональный логин пользователя тенанта (план Task 7). +/// +/// Логин пользователя, под которым оператор входит (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 7e70f48..c60b113 100644 --- a/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs @@ -1,296 +1,291 @@ -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.Extensions.Options; -using Deal.Api.Dtos; -using CookieOptions = Deal.Api.Configuration.CookieOptions; - -namespace Deal.Api.Endpoints; - -/// -/// Операторские эндпоинты тенантов: create/список/детали, suspend/unsuspend, impersonation (план Task 7, Ruling 1/4/10/11). -/// -/// -/// Все ручки — только оператору: без операторской сессии 401 «Требуется вход оператора» (как остальные -/// /api/operator/*). POST "" (create {name, email?}) — тенант (Status active) + провижининг схемы + аудит -/// tenant_created; список — {items:[...]} (реестр + счётчик пользователей; поля лимитов добавит Task 8), -/// детали — {id,name,status,createdAt,users:[...]}. Suspend/unsuspend меняют Status тенанта -/// (TenantAdminService) и пишут аудит tenant_status_changed (только при реальном изменении — повторный -/// suspend идемпотентен). Impersonation выпускает tenant-сессию целевого пользователя -/// (AuthService, механизм обычного входа; пароль не меняется) и возвращает {sessionToken, expiresAt, -/// tenantId, login} — токен используется как значение куки deal_session; аудит impersonation_started -/// (DetailJson: targetLogin, tenantId), завершение — logout'ом пользователя (impersonation_stopped в -/// AuthEndpoints). Зафиксированные решения Task 7: suspend-гейт отвечает 403 (не 401; см. AuthEndpoints), -/// impersonation suspended-тенанта разрешён (аудируется; ИИ заморожен гейтом Task 9), PATCH {status} плана -/// заменён на явные POST /suspend|/unsuspend, budget? при create не принимается до Task 8/10 — отклонения -/// для api-map/техдок Task 16 зафиксированы в task-7-report.md. Тексты ошибок — фиксированные строки -/// HTTP-слоя (паттерн OperatorInvitesEndpoints). -/// -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 = "В тенанте нет пользователей для входа"; - - // Префикс группы операторских ручек тенантов (Ruling 11). - 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: список, create, детали, suspend/unsuspend, impersonate. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - 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; - } - - // GET /api/operator/tenants: список тенантов со счётчиками пользователей (план Task 7). - 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 }); - } - - // POST /api/operator/tenants: создание тенанта (Status active + провижининг схемы); аудит tenant_created. - // Решение Task 7: PATCH {status} заменён на явные POST /suspend и /unsuspend — create принимает только - // {name, email?}; budget?/лимиты — зона Task 8/10 (прецедент: join-строка лимитов отложена в Task 6). - 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; + +/// +/// Операторские эндпоинты тенантов: create/список/детали, suspend/unsuspend, impersonation (план Task 7, Ruling 1/4/10/11). +/// +/// +/// Все ручки — только оператору: без операторской сессии 401 «Требуется вход оператора» (как остальные +/// /api/operator/*). POST "" (create {name, email?}) — тенант (Status active) + провижининг схемы + аудит +/// tenant_created; список — {items:[...]} (реестр + счётчик пользователей; поля лимитов добавит Task 8), +/// детали — {id,name,status,createdAt,users:[...]}. Suspend/unsuspend меняют Status тенанта +/// (TenantAdminService) и пишут аудит tenant_status_changed (только при реальном изменении — повторный +/// suspend идемпотентен). Impersonation выпускает tenant-сессию целевого пользователя +/// (AuthService, механизм обычного входа; пароль не меняется) и возвращает {sessionToken, expiresAt, +/// tenantId, login} — токен используется как значение куки deal_session; аудит impersonation_started +/// (DetailJson: targetLogin, tenantId), завершение — logout'ом пользователя (impersonation_stopped в +/// AuthEndpoints). Зафиксированные решения Task 7: suspend-гейт отвечает 403 (не 401; см. AuthEndpoints), +/// impersonation suspended-тенанта разрешён (аудируется; ИИ заморожен гейтом Task 9), PATCH {status} плана +/// заменён на явные POST /suspend|/unsuspend, budget? при create не принимается до Task 8/10 — отклонения +/// для api-map/техдок Task 16 зафиксированы в task-7-report.md. Тексты ошибок — фиксированные строки +/// HTTP-слоя (паттерн OperatorInvitesEndpoints). +/// +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 = "В тенанте нет пользователей для входа"; + + // Префикс группы операторских ручек тенантов (Ruling 11). + 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: список, create, детали, suspend/unsuspend, impersonate. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + 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; + } + + // GET /api/operator/tenants: список тенантов со счётчиками пользователей (план Task 7). + 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 }); + } + + // POST /api/operator/tenants: создание тенанта (Status active + провижининг схемы); аудит tenant_created. + // Решение Task 7: PATCH {status} заменён на явные POST /suspend и /unsuspend — create принимает только + // {name, email?}; budget?/лимиты — зона Task 8/10 (прецедент: join-строка лимитов отложена в Task 6). + 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 9840e56..a61a47e 100644 --- a/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs @@ -1,190 +1,186 @@ -using Deal.Api.Endpoints.RequestModels; -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Эндпоинты вкладки «Обработка»: GET /api/pipeline/stats|queue|rejected, POST /api/pipeline/rejected/clear, -/// DELETE /api/pipeline/rejected/{rejId}, POST /api/pipeline/rejected/{rejId}/return (план Task 9 L437–460, -/// Rulings 6/10; прототип processing_routes.py L17–74). -/// -/// -/// Контракт 1:1 с прототипом и api-map §3.6 L178–186, §4.5: /stats → {queue:{new,ai,total}, rejected}; -/// /queue?limit= → {items, counts:{new,ai,total}, rejected} (limit ≤500, дефолт 100, фронт шлёт 120); -/// /rejected?q=&offset=&limit= → {items, total, offset, limit} (q — FTS ∪ LIKE-поиск, Ruling 6); -/// /rejected/clear → {ok, cleared}; DELETE /rejected/{rejId} → {ok:true} всегда (delete_one L196–198, 404 не -/// шлём — Ruling 10); /rejected/{rejId}/return {reason=""} → {id, returned:true, returnedAt} | 400 (строки -/// Ruling 10) | 404 «Запись не найдена» (текст 404 — слой эндпоинтов, паттерн CardsService → LeadsEndpoints). -/// Все эндпоинты требуют сессию: 401 {detail} без куки (Ruling 10); сервисы модуля резолвятся из -/// RequestServices ПОСЛЕ проверки сессии (scoped на tenant-контекст запроса, паттерн SettingsEndpoints). -/// Статические сегменты (/stats, /queue, /rejected/clear) до параметризованного /rejected/{rejId} — порядок -/// как в прототипе (api-map L19), хотя литералы имеют приоритет в ASP.NET Core. -/// -public static class PipelineEndpoints -{ - // Префикс группы (роутер processing, prefix="/api/pipeline" — processing_routes.py L10). - private const string PipelineGroupPrefix = "/api/pipeline"; - - // OpenAPI-тег группы (в прототипе роутер processing — processing_routes.py L10). - 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"; - - // 404 return: записи отсева нет (processing_routes.py L71: KeyError → 404, Ruling 10). - private const string RejectedNotFoundDetail = "Запись не найдена"; - - // Размер страницы по умолчанию списков очереди/отсева (processing.DEFAULT_LIMIT L48; фронт шлёт 120/80). - private const int DefaultPageSize = 100; - - /// - /// Регистрирует группу /api/pipeline: stats/queue/rejected/clear/{rejId}/return. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapPipelineEndpoints(this IEndpointRouteBuilder app) - { - var pipeline = app.MapGroup(PipelineGroupPrefix).WithTags(OpenApiTag); - - // Статические сегменты до /rejected/{rejId} (Ruling 10, api-map L19; порядок 1:1 с прототипом). - 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; - } - - // GET /api/pipeline/stats: сводка вкладки {queue:{new,ai,total}, rejected} (processing_routes.py L17–20, stats L315–320). - 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)); - } - - // GET /api/pipeline/queue?limit=: сырые сообщения очереди + счётчики + число отсева (processing_routes.py L23–31). - // Ответ {items, counts:{new,ai,total}, rejected} 1:1 с list_queue L218–241 + queue_counts L207–215 + - // rejected_count L201–202. limit — дефолт 100, clamp 1..500 делает сервис (ListQueueAsync). - 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 }); - } - - // GET /api/pipeline/rejected?q=&offset=&limit=: страница отсева (processing_routes.py L34–42, list_rejected L246–312). - // q — поиск по тексту/причине/фразе/имени канала (FTS ∪ LIKE, Ruling 6), пустой q — весь отсев свежими - // первыми; offset ≥ 0, limit 1..500 (clamp в сервисе), значения эхом в ответе {items,total,offset,limit}. - 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); - } - - // POST /api/pipeline/rejected/clear: полная безвозвратная очистка отсева (processing_routes.py L45–49, clear_all L120–125). - 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 }); - } - - // DELETE /api/pipeline/rejected/{rejId}: удалить запись отсева; ответ {ok:true} всегда (delete_one L196–198, Ruling 10). - // Прототип не проверяет наличие записи — 404 не шлём (план Task 9 L444; Ruling 10 «always ok»). - 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 }); - } - - // POST /api/pipeline/rejected/{rejId}/return {reason=""}: вернуть отсеянное в обработку (return_to_queue L128–193). - // Успех — {id, returned:true, returnedAt} (запись помечается returned, НЕ удаляется — аудит Ruling 10); - // причины 400 (уже возвращено/повтор-dup/нет текста) — константы PipelineProcessingService (строки 1:1 с - // прототипом); записи нет — 404 «Запись не найдена» (текст 404 — слой эндпоинтов). - 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; + +/// +/// Эндпоинты вкладки «Обработка»: GET /api/pipeline/stats|queue|rejected, POST /api/pipeline/rejected/clear, +/// DELETE /api/pipeline/rejected/{rejId}, POST /api/pipeline/rejected/{rejId}/return (план Task 9 L437–460, +/// Rulings 6/10; прототип processing_routes.py L17–74). +/// +/// +/// Контракт 1:1 с прототипом и api-map §3.6 L178–186, §4.5: /stats → {queue:{new,ai,total}, rejected}; +/// /queue?limit= → {items, counts:{new,ai,total}, rejected} (limit ≤500, дефолт 100, фронт шлёт 120); +/// /rejected?q=&offset=&limit= → {items, total, offset, limit} (q — FTS ∪ LIKE-поиск, Ruling 6); +/// /rejected/clear → {ok, cleared}; DELETE /rejected/{rejId} → {ok:true} всегда (delete_one L196–198, 404 не +/// шлём — Ruling 10); /rejected/{rejId}/return {reason=""} → {id, returned:true, returnedAt} | 400 (строки +/// Ruling 10) | 404 «Запись не найдена» (текст 404 — слой эндпоинтов, паттерн CardsService → LeadsEndpoints). +/// Все эндпоинты требуют сессию: 401 {detail} без куки (Ruling 10); сервисы модуля резолвятся из +/// RequestServices ПОСЛЕ проверки сессии (scoped на tenant-контекст запроса, паттерн SettingsEndpoints). +/// Статические сегменты (/stats, /queue, /rejected/clear) до параметризованного /rejected/{rejId} — порядок +/// как в прототипе (api-map L19), хотя литералы имеют приоритет в ASP.NET Core. +/// +public static class PipelineEndpoints +{ + // Префикс группы (роутер processing, prefix="/api/pipeline" — processing_routes.py L10). + private const string PipelineGroupPrefix = "/api/pipeline"; + + // OpenAPI-тег группы (в прототипе роутер processing — processing_routes.py L10). + 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"; + + // 404 return: записи отсева нет (processing_routes.py L71: KeyError → 404, Ruling 10). + private const string RejectedNotFoundDetail = "Запись не найдена"; + + // Размер страницы по умолчанию списков очереди/отсева (processing.DEFAULT_LIMIT L48; фронт шлёт 120/80). + private const int DefaultPageSize = 100; + + /// + /// Регистрирует группу /api/pipeline: stats/queue/rejected/clear/{rejId}/return. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapPipelineEndpoints(this IEndpointRouteBuilder app) + { + var pipeline = app.MapGroup(PipelineGroupPrefix).WithTags(OpenApiTag); + + // Статические сегменты до /rejected/{rejId} (Ruling 10, api-map L19; порядок 1:1 с прототипом). + 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; + } + + // GET /api/pipeline/stats: сводка вкладки {queue:{new,ai,total}, rejected} (processing_routes.py L17–20, stats L315–320). + 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)); + } + + // GET /api/pipeline/queue?limit=: сырые сообщения очереди + счётчики + число отсева (processing_routes.py L23–31). + // Ответ {items, counts:{new,ai,total}, rejected} 1:1 с list_queue L218–241 + queue_counts L207–215 + + // rejected_count L201–202. limit — дефолт 100, clamp 1..500 делает сервис (ListQueueAsync). + 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 }); + } + + // GET /api/pipeline/rejected?q=&offset=&limit=: страница отсева (processing_routes.py L34–42, list_rejected L246–312). + // q — поиск по тексту/причине/фразе/имени канала (FTS ∪ LIKE, Ruling 6), пустой q — весь отсев свежими + // первыми; offset ≥ 0, limit 1..500 (clamp в сервисе), значения эхом в ответе {items,total,offset,limit}. + 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); + } + + // POST /api/pipeline/rejected/clear: полная безвозвратная очистка отсева (processing_routes.py L45–49, clear_all L120–125). + 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 }); + } + + // DELETE /api/pipeline/rejected/{rejId}: удалить запись отсева; ответ {ok:true} всегда (delete_one L196–198, Ruling 10). + // Прототип не проверяет наличие записи — 404 не шлём (план Task 9 L444; Ruling 10 «always ok»). + 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 }); + } + + // POST /api/pipeline/rejected/{rejId}/return {reason=""}: вернуть отсеянное в обработку (return_to_queue L128–193). + // Успех — {id, returned:true, returnedAt} (запись помечается returned, НЕ удаляется — аудит Ruling 10); + // причины 400 (уже возвращено/повтор-dup/нет текста) — константы PipelineProcessingService (строки 1:1 с + // прототипом); записи нет — 404 «Запись не найдена» (текст 404 — слой эндпоинтов). + 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 16c242c..0250ca2 100644 --- a/src/core/Deal.Api/Endpoints/RatesEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/RatesEndpoints.cs @@ -1,90 +1,86 @@ -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// HTTP-эндпоинты курсов валют: GET /api/rates, POST /api/rates/refresh (Ruling 8, api-map §3.4 L149–150). -/// -/// -/// «Только для Settings-экрана» (Ruling 8): фронт читает курсы на boot (store.js L571–581) и обновляет -/// по кнопке (refreshRates L1843–1848). GET — текущий кэш (ratesCache) или дефолт-мок; при протухании/ -/// смене источника/отсутствии кэша (Ruling 6) фоново запускает RefreshAsync через -/// и отвечает текущим кэшем (план Task 8 L317–318). POST — синхронный -/// refresh 1:1 с прототипом: {ok: bool, rates: {base, rates, source, updatedAt}} (ok=false при сбое -/// ЦБ, кэш не тронут). Оба требуют сессию: 401 {detail} (Ruling 10). Резолв scoped-зависимостей — через -/// RequestServices ПОСЛЕ проверки сессии (как SettingsEndpoints/AiCheckEndpoint: ISettingsStore требует -/// tenant-контекст запроса). -/// -public static class RatesEndpoints -{ - // Префикс группы API (общий для эндпоинтов этапа, Ruling 8). - private const string ApiGroupPrefix = "/api"; - - // Путь текущих курсов (GET). - private const string RatesPath = "/rates"; - - // Путь принудительного обновления (POST). - private const string RatesRefreshPath = "/rates/refresh"; - - // OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py). - 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); - - // Ленивое обновление (Ruling 6, план Task 8): протухший кэш / смена источника / нет кэша — - // фоновый RefreshAsync в отдельном scope; ответ — текущий кэш. - if (await ratesService.ShouldFetchAsync(ct)) - { - context.RequestServices.GetRequiredService().Schedule(); - } - - return Results.Ok(current); - } - - // POST /api/rates/refresh: принудительное обновление; ответ {ok, rates} (1:1 settings_routes.py L229–232). - 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-эндпоинты курсов валют: GET /api/rates, POST /api/rates/refresh (Ruling 8, api-map §3.4 L149–150). +/// +/// +/// «Только для Settings-экрана» (Ruling 8): фронт читает курсы на boot (store.js L571–581) и обновляет +/// по кнопке (refreshRates L1843–1848). GET — текущий кэш (ratesCache) или дефолт-мок; при протухании/ +/// смене источника/отсутствии кэша (Ruling 6) фоново запускает RefreshAsync через +/// и отвечает текущим кэшем (план Task 8 L317–318). POST — синхронный +/// refresh 1:1 с прототипом: {ok: bool, rates: {base, rates, source, updatedAt}} (ok=false при сбое +/// ЦБ, кэш не тронут). Оба требуют сессию: 401 {detail} (Ruling 10). Резолв scoped-зависимостей — через +/// RequestServices ПОСЛЕ проверки сессии (как SettingsEndpoints/AiCheckEndpoint: ISettingsStore требует +/// tenant-контекст запроса). +/// +public static class RatesEndpoints +{ + // Префикс группы API (общий для эндпоинтов этапа, Ruling 8). + private const string ApiGroupPrefix = "/api"; + + // Путь текущих курсов (GET). + private const string RatesPath = "/rates"; + + // Путь принудительного обновления (POST). + private const string RatesRefreshPath = "/rates/refresh"; + + // OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py). + 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); + + // Ленивое обновление (Ruling 6, план Task 8): протухший кэш / смена источника / нет кэша — + // фоновый RefreshAsync в отдельном scope; ответ — текущий кэш. + if (await ratesService.ShouldFetchAsync(ct)) + { + context.RequestServices.GetRequiredService().Schedule(); + } + + return Results.Ok(current); + } + + // POST /api/rates/refresh: принудительное обновление; ответ {ok, rates} (1:1 settings_routes.py L229–232). + 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 7e1db98..06a74f5 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/CardLinkRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/CardLinkRequest.cs @@ -1,6 +1,3 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - namespace Deal.Api.Endpoints.RequestModels; /// diff --git a/src/core/Deal.Api/Endpoints/RequestModels/ClearColBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/ClearColBody.cs index f55cf84..3f6c85b 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ClearColBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ClearColBody.cs @@ -1,13 +1,10 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/cards/clear-col — полная очистка служебной колонки (dashboard_routes.py ClearColBody L224–225, api-map §3.2 L93). -/// -/// -/// Wire-имя — camelCase: col — "trash" | "archive" (другие колонки/отсутствие значения → 400 -/// «Очищать можно только корзину или архив», валидация CardsService.ClearColAsync L237–247). -/// -public sealed record ClearColBody(string? Col); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/cards/clear-col — полная очистка служебной колонки (dashboard_routes.py ClearColBody L224–225, api-map §3.2 L93). +/// +/// +/// Wire-имя — camelCase: col — "trash" | "archive" (другие колонки/отсутствие значения → 400 +/// «Очищать можно только корзину или архив», валидация CardsService.ClearColAsync L237–247). +/// +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 1baf875..e2ed3b5 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ColStateBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ColStateBody.cs @@ -1,14 +1,11 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело PATCH /api/containers/{containerId}/state — смена состояния колонки (этап 9, T4). -/// -/// -/// Wire-имена — camelCase: collapsed (bool) / width ("sm"|"md"|"lg"). Поле со значением null (либо -/// отсутствующее) текущее значение не меняет (прототип model_dump(exclude_none=True) + merge в текущее -/// состояние колонки, L146–149). Пустой патч {} сохраняет текущее состояние (для новой колонки — {}). -/// -public sealed record ColStateBody(bool? Collapsed, string? Width); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело PATCH /api/containers/{containerId}/state — смена состояния колонки (этап 9, T4). +/// +/// +/// Wire-имена — camelCase: collapsed (bool) / width ("sm"|"md"|"lg"). Поле со значением null (либо +/// отсутствующее) текущее значение не меняет (прототип model_dump(exclude_none=True) + merge в текущее +/// состояние колонки, L146–149). Пустой патч {} сохраняет текущее состояние (для новой колонки — {}). +/// +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 54dfcda..939c16a 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/CommentBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/CommentBody.cs @@ -1,13 +1,10 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/cards/{cardId}/comments — добавление комментария (dashboard_routes.py CommentBody L60–61). -/// -/// -/// Wire-имя — camelCase: text. Пустой/пробельный текст либо явный null → 400 «Пустой комментарий» -/// (валидация CardsService.AddCommentAsync, 1:1 с dashboard_routes L240–241). -/// -public sealed record CommentBody(string? Text); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/cards/{cardId}/comments — добавление комментария (dashboard_routes.py CommentBody L60–61). +/// +/// +/// Wire-имя — camelCase: text. Пустой/пробельный текст либо явный null → 400 «Пустой комментарий» +/// (валидация CardsService.AddCommentAsync, 1:1 с dashboard_routes L240–241). +/// +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 ddd8206..4f08c52 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ContainerCreateRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ContainerCreateRequest.cs @@ -1,32 +1,30 @@ -using Deal.Modules.Kanban.Application.Models; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/containers — создание контейнера (этап 9, T4). -/// -/// -/// Wire-имена — camelCase: name/description/color/space/kind/suggested/note/rules. Name — обязательное: -/// отсутствие либо явный null → 400 «Укажите название колонки»; пустая строка допустима — сервис -/// подставит «Новая колонка». Description/Note имеют дефолт "". Отсутствующие группы правил -/// трактуются как пустые (null-устойчивость). -/// -/// Имя контейнера (обязательно). -/// Описание (опционально). -/// Цвет (опционально; 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 — создание контейнера (этап 9, T4). +/// +/// +/// Wire-имена — camelCase: name/description/color/space/kind/suggested/note/rules. Name — обязательное: +/// отсутствие либо явный null → 400 «Укажите название колонки»; пустая строка допустима — сервис +/// подставит «Новая колонка». Description/Note имеют дефолт "". Отсутствующие группы правил +/// трактуются как пустые (null-устойчивость). +/// +/// Имя контейнера (обязательно). +/// Описание (опционально). +/// Цвет (опционально; 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 6e22e0a..4f46bc7 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ContainerPatchRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ContainerPatchRequest.cs @@ -1,31 +1,29 @@ -using Deal.Modules.Kanban.Application.Models; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело PATCH /api/containers/{id} — частичное обновление контейнера (этап 9, T4). -/// -/// -/// Wire-имена — camelCase: name/description/color/collapsed/suggested/note/rules/policy. Поле со -/// значением null означает «не менять»; исключение — ЯВНЫЙ null у name → 400 «Укажите название колонки». -/// JSON-объекты rules/policy заменяются целиком. -/// -/// Новое имя (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} — частичное обновление контейнера (этап 9, T4). +/// +/// +/// Wire-имена — camelCase: name/description/color/collapsed/suggested/note/rules/policy. Поле со +/// значением null означает «не менять»; исключение — ЯВНЫЙ null у name → 400 «Укажите название колонки». +/// JSON-объекты rules/policy заменяются целиком. +/// +/// Новое имя (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 062fe42..79da3a7 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/CreateCardRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/CreateCardRequest.cs @@ -1,6 +1,4 @@ using Deal.Modules.Kanban.Application.Models; -using Deal.Api.Services; -using Deal.Api.Dtos; namespace Deal.Api.Endpoints.RequestModels; diff --git a/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskCreateBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskCreateBody.cs index 14bee41..e74568d 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskCreateBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskCreateBody.cs @@ -1,61 +1,58 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/discovery/tasks (TaskCreate discovery_routes.py L50–60; api-map §3.8 L204). -/// -/// -/// Wire-имена — camelCase: name/description/keywords/minSubscribers/lang/threshold/sampleSize/planJoins/autoJoin. -/// Значения-дефолты pydantic повторяет сервис DiscoveryTasksService: description/keywords — пустые, lang — «ru», -/// minSubscribers — 0, planJoins — 1, autoJoin — false; threshold/sampleSize — из настроек (null → дефолт). -/// name — единственное поле без дефолта: пустое/пробельное значение → 400 «Укажите название задачи». -/// -public sealed record DiscoveryTaskCreateBody -{ - /// - /// Название задачи (обязательное; Trim, пустое → 400). - /// - public string Name { get; init; } = string.Empty; - - /// - /// Описание ниши/цели (источник для generate-keywords); null → пустая строка. - /// - public string? Description { get; init; } - - /// - /// Ключевые слова поиска; null/пусто — список пуст (start до добавления ключей → 400). - /// - public IReadOnlyList? Keywords { get; init; } - - /// - /// Минимальное число участников источника; null → 0. - /// - public int? MinSubscribers { get; init; } - - /// - /// Язык источников: «ru»|«any»; null/иное → «ru» (нормализует сервис). - /// - public string? Lang { get; init; } - - /// - /// Порог подходящих сообщений оценки, % (кламп 1..100); null → discEvalThreshold. - /// - public int? Threshold { get; init; } - - /// - /// Размер выборки сообщений оценки (кламп ≥1); null → discEvalSample. - /// - public int? SampleSize { get; init; } - - /// - /// План авто-вступлений (1..discJoinLimit + бюджет); null → 1. - /// - public int? PlanJoins { get; init; } - - /// - /// Авто-вступления воркером; null → false. - /// - public bool? AutoJoin { get; init; } -} +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/discovery/tasks (TaskCreate discovery_routes.py L50–60; api-map §3.8 L204). +/// +/// +/// Wire-имена — camelCase: name/description/keywords/minSubscribers/lang/threshold/sampleSize/planJoins/autoJoin. +/// Значения-дефолты pydantic повторяет сервис DiscoveryTasksService: description/keywords — пустые, lang — «ru», +/// minSubscribers — 0, planJoins — 1, autoJoin — false; threshold/sampleSize — из настроек (null → дефолт). +/// name — единственное поле без дефолта: пустое/пробельное значение → 400 «Укажите название задачи». +/// +public sealed record DiscoveryTaskCreateBody +{ + /// + /// Название задачи (обязательное; Trim, пустое → 400). + /// + public string Name { get; init; } = string.Empty; + + /// + /// Описание ниши/цели (источник для generate-keywords); null → пустая строка. + /// + public string? Description { get; init; } + + /// + /// Ключевые слова поиска; null/пусто — список пуст (start до добавления ключей → 400). + /// + public IReadOnlyList? Keywords { get; init; } + + /// + /// Минимальное число участников источника; null → 0. + /// + public int? MinSubscribers { get; init; } + + /// + /// Язык источников: «ru»|«any»; null/иное → «ru» (нормализует сервис). + /// + public string? Lang { get; init; } + + /// + /// Порог подходящих сообщений оценки, % (кламп 1..100); null → discEvalThreshold. + /// + public int? Threshold { get; init; } + + /// + /// Размер выборки сообщений оценки (кламп ≥1); null → discEvalSample. + /// + public int? SampleSize { get; init; } + + /// + /// План авто-вступлений (1..discJoinLimit + бюджет); null → 1. + /// + 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 ebbf808..969d4b8 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskPatchBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskPatchBody.cs @@ -1,60 +1,57 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело PATCH /api/discovery/tasks/{task_id} (TaskPatch discovery_routes.py L62–71; api-map §3.8 L205). -/// -/// -/// Wire-имена — camelCase; все поля optional: null/отсутствующее поле не меняется (в DiscoveryTaskPatch -/// пробрасываются только не-null значения, как python model_dump(exclude_none=True)). keywords — полная замена -/// списка (пустой список очищает ключи); увеличение planJoins проверяется план-бюджетом. -/// -public sealed record DiscoveryTaskPatchBody -{ - /// - /// Новое название (после Trim; пустое допустимо на patch — 1:1 прототип). - /// - public string? Name { get; init; } - - /// - /// Новое описание (пустая строка очищает). - /// - public string? Description { get; init; } - - /// - /// Новые ключевые слова (полная замена; null — не менять). - /// - public IReadOnlyList? Keywords { get; init; } - - /// - /// Новый минимум участников (кламп ≥0). - /// - public int? MinSubscribers { get; init; } - - /// - /// Новый язык: «ru»|«any» (иное → «ru»). - /// - public string? Lang { get; init; } - - /// - /// Новый порог оценки, % (кламп 1..100). - /// - public int? Threshold { get; init; } - - /// - /// Новый размер выборки (кламп ≥1). - /// - public int? SampleSize { get; init; } - - /// - /// Новый план авто-вступлений (рост — с проверкой бюджета). - /// - public int? PlanJoins { get; init; } - - /// - /// Новый флаг авто-вступлений (false — выключить). - /// - public bool? AutoJoin { get; init; } -} +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело PATCH /api/discovery/tasks/{task_id} (TaskPatch discovery_routes.py L62–71; api-map §3.8 L205). +/// +/// +/// Wire-имена — camelCase; все поля optional: null/отсутствующее поле не меняется (в DiscoveryTaskPatch +/// пробрасываются только не-null значения, как python model_dump(exclude_none=True)). keywords — полная замена +/// списка (пустой список очищает ключи); увеличение planJoins проверяется план-бюджетом. +/// +public sealed record DiscoveryTaskPatchBody +{ + /// + /// Новое название (после Trim; пустое допустимо на patch — 1:1 прототип). + /// + public string? Name { get; init; } + + /// + /// Новое описание (пустая строка очищает). + /// + public string? Description { get; init; } + + /// + /// Новые ключевые слова (полная замена; null — не менять). + /// + public IReadOnlyList? Keywords { get; init; } + + /// + /// Новый минимум участников (кламп ≥0). + /// + public int? MinSubscribers { get; init; } + + /// + /// Новый язык: «ru»|«any» (иное → «ru»). + /// + public string? Lang { get; init; } + + /// + /// Новый порог оценки, % (кламп 1..100). + /// + public int? Threshold { get; init; } + + /// + /// Новый размер выборки (кламп ≥1). + /// + public int? SampleSize { get; init; } + + /// + /// Новый план авто-вступлений (рост — с проверкой бюджета). + /// + public int? PlanJoins { get; init; } + + /// + /// Новый флаг авто-вступлений (false — выключить). + /// + 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 224ec18..df843a9 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/MarkColBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/MarkColBody.cs @@ -1,15 +1,12 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/cards/mark-col-seen — снять «новое» с колонки (dashboard_routes.py MarkColBody L183–184, api-map §3.2 L88). -/// -/// -/// Wire-имя — camelCase: col — колонка (inbox/archive/trash/доска). Отсутствие/null col → 400 -/// «Неизвестная колонка»: иначе пустой col попал бы в CardsService.MarkSeenAsync и снял «новое» со ВСЕХ -/// карточек (truthiness-семантика прототипа: пустая строка = параметр не задан, mark_seen L250–256) — -/// эндпоинт защищает от такого вызова (прототип: pydantic required 422). -/// -public sealed record MarkColBody(string? Col); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/cards/mark-col-seen — снять «новое» с колонки (dashboard_routes.py MarkColBody L183–184, api-map §3.2 L88). +/// +/// +/// Wire-имя — camelCase: col — колонка (inbox/archive/trash/доска). Отсутствие/null col → 400 +/// «Неизвестная колонка»: иначе пустой col попал бы в CardsService.MarkSeenAsync и снял «новое» со ВСЕХ +/// карточек (truthiness-семантика прототипа: пустая строка = параметр не задан, mark_seen L250–256) — +/// эндпоинт защищает от такого вызова (прототип: pydantic required 422). +/// +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 e943549..f360c82 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/MoveBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/MoveBody.cs @@ -1,14 +1,11 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/cards/{lead_id}/move — перенос карточки (dashboard_routes.py MoveBody L56–57, api-map §3.2 L89). -/// -/// -/// Wire-имя — camelCase: to — колонка назначения: "inbox" либо id доски (b_...). Цель валидирует -/// CardsService (400 «Переносить можно только на доски или в «Неразобранное»»); отсутствующий/null to -/// трактуются той же валидацией (прототип — pydantic required 422). -/// -public sealed record MoveBody(string? To); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/cards/{lead_id}/move — перенос карточки (dashboard_routes.py MoveBody L56–57, api-map §3.2 L89). +/// +/// +/// Wire-имя — camelCase: to — колонка назначения: "inbox" либо id доски (b_...). Цель валидирует +/// CardsService (400 «Переносить можно только на доски или в «Неразобранное»»); отсутствующий/null to +/// трактуются той же валидацией (прототип — pydantic required 422). +/// +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 1e9defd..8d56427 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/OperatorTelegramKeysRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/OperatorTelegramKeysRequest.cs @@ -1,6 +1,3 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - namespace Deal.Api.Endpoints.RequestModels; /// diff --git a/src/core/Deal.Api/Endpoints/RequestModels/OrderBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/OrderBody.cs index 7671d9c..a93ebef 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/OrderBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/OrderBody.cs @@ -1,15 +1,12 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/containers/reorder — новый порядок контейнеров пространства (этап 9, T4). -/// -/// -/// Wire-имена — camelCase: space (пространство dashboard/selected; отсутствие — dashboard) и order -/// (список id контейнеров в новом порядке). Отсутствие/явный null у order — 400 «Не указан порядок колонок». -/// -/// Пространство (dashboard/selected); null — dashboard. -/// Id контейнеров в новом порядке. -public sealed record OrderBody(string? Space, IReadOnlyList? Order); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/containers/reorder — новый порядок контейнеров пространства (этап 9, T4). +/// +/// +/// Wire-имена — camelCase: space (пространство dashboard/selected; отсутствие — dashboard) и order +/// (список id контейнеров в новом порядке). Отсутствие/явный null у order — 400 «Не указан порядок колонок». +/// +/// Пространство (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 d82093c..6e45996 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ReclassifyBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ReclassifyBody.cs @@ -1,14 +1,11 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/cards/reclassify — ИИ-переклассификация «Неразобранного» (dashboard_routes.py ReclassifyBody L64–65, api-map §3.2 L95). -/// -/// -/// Wire-имя — camelCase: ids (опциональный список id карточек). Тело опционально (фронт вызывает без -/// тела — reclassifyInbox, store.js L1115–1133; параметр эндпоинта nullable). В этапе 3 — заглушка -/// Ruling 11: тело не используется, ответ всегда {started:false, busy:false, attempted:0, reason}. -/// -public sealed record ReclassifyBody(IReadOnlyList? Ids); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/cards/reclassify — ИИ-переклассификация «Неразобранного» (dashboard_routes.py ReclassifyBody L64–65, api-map §3.2 L95). +/// +/// +/// Wire-имя — camelCase: ids (опциональный список id карточек). Тело опционально (фронт вызывает без +/// тела — reclassifyInbox, store.js L1115–1133; параметр эндпоинта nullable). В этапе 3 — заглушка +/// Ruling 11: тело не используется, ответ всегда {started:false, busy:false, attempted:0, reason}. +/// +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 490d126..ceda572 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ReminderSetRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ReminderSetRequest.cs @@ -1,20 +1,17 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/cards/{cardId}/reminder — установка напоминания hold-карточке -/// (projects_routes.py ReminderBody L52–53, api-map §3.5 L172; Ruling 3). -/// -/// -/// Wire-имя — camelCase: at — время напоминания в epoch-мс (рассчитывает фронт HoldReminderDialog: -/// «через N дней (1–30)» или «дата+время» локального времени; store.js setHoldReminder L2060–2086). -/// Стадия карточки/будущность at сервисом НЕ проверяются (1:1 прототип: фронт шлёт только для hold; -/// прошлое at допустимо — приёмка Tasks 11/13 «выстреливает» его ручным тиком). Ответ — полная карточка -/// с напоминанием {at}; 400 «Напоминания об отложенных выключены в настройках» при выключенном -/// remindersEnabled; 404 «Карточка не найдена». Отсутствующий/JSON-null at (клиентский баг; pydantic на -/// такое — 422) эндпоинт отвергает 400 — у напоминания без времени нет осмысленной семантики. -/// -/// Время напоминания, epoch-ms. -public sealed record ReminderSetRequest(long? At); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/cards/{cardId}/reminder — установка напоминания hold-карточке +/// (projects_routes.py ReminderBody L52–53, api-map §3.5 L172; Ruling 3). +/// +/// +/// Wire-имя — camelCase: at — время напоминания в epoch-мс (рассчитывает фронт HoldReminderDialog: +/// «через N дней (1–30)» или «дата+время» локального времени; store.js setHoldReminder L2060–2086). +/// Стадия карточки/будущность at сервисом НЕ проверяются (1:1 прототип: фронт шлёт только для hold; +/// прошлое at допустимо — приёмка Tasks 11/13 «выстреливает» его ручным тиком). Ответ — полная карточка +/// с напоминанием {at}; 400 «Напоминания об отложенных выключены в настройках» при выключенном +/// remindersEnabled; 404 «Карточка не найдена». Отсутствующий/JSON-null at (клиентский баг; pydantic на +/// такое — 422) эндпоинт отвергает 400 — у напоминания без времени нет осмысленной семантики. +/// +/// Время напоминания, 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 0e2feb2..d8afa70 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ReturnReasonRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ReturnReasonRequest.cs @@ -1,14 +1,11 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/pipeline/rejected/{rejId}/return — причина возврата в обработку (processing_routes.py ReturnBody L13–15, api-map §3.6 L185). -/// -/// -/// Wire-имя — camelCase: reason (опциональна, дефолт "" — как pydantic reason: str = ""; фронт шлёт -/// {reason} всегда). Пустая причина допустима: сервис тримит и кладёт на запись для аудита -/// (return_to_queue L158, Ruling 10). -/// -public sealed record ReturnReasonRequest(string? Reason); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/pipeline/rejected/{rejId}/return — причина возврата в обработку (processing_routes.py ReturnBody L13–15, api-map §3.6 L185). +/// +/// +/// Wire-имя — camelCase: reason (опциональна, дефолт "" — как pydantic reason: str = ""; фронт шлёт +/// {reason} всегда). Пустая причина допустима: сервис тримит и кладёт на запись для аудита +/// (return_to_queue L158, Ruling 10). +/// +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 fe247fe..da23b13 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/TakeCardRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/TakeCardRequest.cs @@ -1,6 +1,3 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - namespace Deal.Api.Endpoints.RequestModels; /// diff --git a/src/core/Deal.Api/Endpoints/RequestModels/TgMonitorBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/TgMonitorBody.cs index db8e6b0..69334b7 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/TgMonitorBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/TgMonitorBody.cs @@ -1,10 +1,7 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/tg/dialogs/{dialog_id}/monitor и /monitor-all (tg_routes.py MonitorBody L54–56). -/// -/// True — мониторить (сообщения → PushMessage в ядро), false — выключить. -public sealed record TgMonitorBody(bool Enabled); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/tg/dialogs/{dialog_id}/monitor и /monitor-all (tg_routes.py MonitorBody L54–56). +/// +/// 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 5a94ca0..3f24b87 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/TgPreviewBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/TgPreviewBody.cs @@ -1,11 +1,8 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/tg/dialogs/preview — последние сообщения диалога (tg_routes.py PreviewBody L58–60). -/// -/// Id диалога (подписанный). -/// Сколько последних сообщений; дефолт 24, кламп 1..50 (python L153). -public sealed record TgPreviewBody(string DialogId, int? Limit); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/tg/dialogs/preview — последние сообщения диалога (tg_routes.py PreviewBody L58–60). +/// +/// Id диалога (подписанный). +/// Сколько последних сообщений; дефолт 24, кламп 1..50 (python L153). +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 3253986..4391952 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/TgSendCodeRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/TgSendCodeRequest.cs @@ -1,10 +1,7 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/tg/send-code — SMS-код входа (tg_routes.py CodeBody L46–48). -/// -/// Код из SMS/Telegram-сообщения (trim перед отправкой, python L89). -public sealed record TgSendCodeRequest(string Code); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/tg/send-code — SMS-код входа (tg_routes.py CodeBody L46–48). +/// +/// Код из SMS/Telegram-сообщения (trim перед отправкой, python L89). +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 b4ea886..be3fb1c 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/TgSendPasswordRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/TgSendPasswordRequest.cs @@ -1,10 +1,7 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/tg/send-password — облачный пароль 2FA (tg_routes.py PasswordBody L50–52). -/// -/// Пароль облачной защиты (как ввёл пользователь, без trim — python L100). -public sealed record TgSendPasswordRequest(string Password); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/tg/send-password — облачный пароль 2FA (tg_routes.py PasswordBody L50–52). +/// +/// Пароль облачной защиты (как ввёл пользователь, без trim — python L100). +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 22f1060..ba0408c 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/TgStartPhoneRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/TgStartPhoneRequest.cs @@ -1,10 +1,7 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints.RequestModels; - -/// -/// Тело POST /api/tg/start-phone — вход по номеру телефона (tg_routes.py PhoneBody L42–44). -/// -/// Номер в международном формате (как ввёл пользователь; обрезается обработчиком, python L71). -public sealed record TgStartPhoneRequest(string Phone); +namespace Deal.Api.Endpoints.RequestModels; + +/// +/// Тело POST /api/tg/start-phone — вход по номеру телефона (tg_routes.py PhoneBody L42–44). +/// +/// Номер в международном формате (как ввёл пользователь; обрезается обработчиком, python L71). +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 c2d35b1..16d9e56 100644 --- a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs @@ -1,137 +1,129 @@ -using System.Text.Json; -using Deal.Api; -using Deal.Api.Dtos; -using Deal.Api.Services; -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Api.Endpoints; - -/// -/// HTTP-эндпоинты настроек тенанта: GET/PATCH /api/settings (api-map §3.4 L146–147, §4.6). -/// -/// -/// GET — публичный снимок настроек (дефолты + переопределения, маски секретов, providers — Ruling 3); -/// PATCH — произвольный JSON-объект публичных полей §4.6, ответ — полный снимок после применения -/// (фронт затирает локальный state ответом — store.js). Оба эндпоинта требуют сессию: -/// 401 {"detail":"Требуется авторизация"} (Ruling 10). Мягкая семантика: невалидное поле PATCH -/// просто не применяется; жёсткая ошибка — только тело не JSON-объект (400). -/// Побочные эффекты прототипа L186–192: PATCH с полем rateSource запускает фоновое -/// обновление кэша курсов (, Ruling 6); пересчёт карточек при смене -/// targetCurrency/conversionOn выполняет сам SettingsService через порт -/// (реализация — ConversionRecomputer модуля Kanban, Ruling 7, Task 12). -/// -/// SettingsService резолвится из RequestServices ВНУТРИ обработчика после проверки сессии, а не -/// параметром эндпоинта: DI-биндинг параметров выполняется до тела обработчика, а зависимость -/// сервиса — scoped TenantDbContext, опции которого строятся по tenant-контексту запроса -/// (без сессии контекст не разрешим — ошибка конфигурации). Так запрос без сессии получает 401, -/// а не 500 при резолве. -/// -/// -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); - } - - // Произвольный JSON-объект: ключи тела — публичные ключи §4.6 (как их шлёт фронт). - Dictionary? body; - try - { - body = await JsonSerializer.DeserializeAsync>( - context.Request.Body, - options: null, - cancellationToken: ct); - } - catch (JsonException) - { - // Не-JSON или не-объект целиком — ошибка запроса: 400 + detail - // (в прототипе FastAPI на такое тело — 422). - return EndpointResults.BadRequest(InvalidBodyDetail); - } - - if (body is null) - { - return EndpointResults.BadRequest(InvalidBodyDetail); - } - - SettingsService settingsService = context.RequestServices.GetRequiredService(); - PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct); - - // Аудит сохранения настроек (этап 10, T1): только имена полей — значения (в т.ч. секреты) не пишутся. - await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct); - - // Смена источника курсов в PATCH (settings_routes.py L188–189) — фоновое обновление кэша - // курсов (Ruling 6, Task 8). RefreshAsync читает уже сохранённую настройку rateSource. - if (ShouldScheduleRatesRefresh(body)) - { - context.RequestServices.GetRequiredService().Schedule(); - } - - return Results.Ok(result); - } - - // Запускать ли фоновый refresh курсов после PATCH (семантика if body.get("rateSource") L188). - // body: Тело PATCH — публичные ключи §4.6. - // Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка). - private static bool ShouldScheduleRatesRefresh(Dictionary body) - { - if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element)) - { - return false; - } - - // JSON-булево/число в python «правдивы» и запускают refresh; пустая строка/null — нет. - 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-эндпоинты настроек тенанта: GET/PATCH /api/settings (api-map §3.4 L146–147, §4.6). +/// +/// +/// GET — публичный снимок настроек (дефолты + переопределения, маски секретов, providers — Ruling 3); +/// PATCH — произвольный JSON-объект публичных полей §4.6, ответ — полный снимок после применения +/// (фронт затирает локальный state ответом — store.js). Оба эндпоинта требуют сессию: +/// 401 {"detail":"Требуется авторизация"} (Ruling 10). Мягкая семантика: невалидное поле PATCH +/// просто не применяется; жёсткая ошибка — только тело не JSON-объект (400). +/// Побочные эффекты прототипа L186–192: PATCH с полем rateSource запускает фоновое +/// обновление кэша курсов (, Ruling 6); пересчёт карточек при смене +/// targetCurrency/conversionOn выполняет сам SettingsService через порт +/// (реализация — ConversionRecomputer модуля Kanban, Ruling 7, Task 12). +/// +/// SettingsService резолвится из RequestServices ВНУТРИ обработчика после проверки сессии, а не +/// параметром эндпоинта: DI-биндинг параметров выполняется до тела обработчика, а зависимость +/// сервиса — scoped TenantDbContext, опции которого строятся по tenant-контексту запроса +/// (без сессии контекст не разрешим — ошибка конфигурации). Так запрос без сессии получает 401, +/// а не 500 при резолве. +/// +/// +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); + } + + // Произвольный JSON-объект: ключи тела — публичные ключи §4.6 (как их шлёт фронт). + Dictionary? body; + try + { + body = await JsonSerializer.DeserializeAsync>( + context.Request.Body, + options: null, + cancellationToken: ct); + } + catch (JsonException) + { + // Не-JSON или не-объект целиком — ошибка запроса: 400 + detail + // (в прототипе FastAPI на такое тело — 422). + return EndpointResults.BadRequest(InvalidBodyDetail); + } + + if (body is null) + { + return EndpointResults.BadRequest(InvalidBodyDetail); + } + + SettingsService settingsService = context.RequestServices.GetRequiredService(); + PublicSettingsDto result = await settingsService.ApplyPatchAsync(body, ct); + + // Аудит сохранения настроек (этап 10, T1): только имена полей — значения (в т.ч. секреты) не пишутся. + await AuditAppender.AppendTenantAsync(context, AuditEvents.SettingsUpdated, new { fields = body.Keys }, ct); + + // Смена источника курсов в PATCH (settings_routes.py L188–189) — фоновое обновление кэша + // курсов (Ruling 6, Task 8). RefreshAsync читает уже сохранённую настройку rateSource. + if (ShouldScheduleRatesRefresh(body)) + { + context.RequestServices.GetRequiredService().Schedule(); + } + + return Results.Ok(result); + } + + // Запускать ли фоновый refresh курсов после PATCH (семантика if body.get("rateSource") L188). + // body: Тело PATCH — публичные ключи §4.6. + // Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка). + private static bool ShouldScheduleRatesRefresh(Dictionary body) + { + if (!body.TryGetValue(SettingsKeys.RateSource, out JsonElement element)) + { + return false; + } + + // JSON-булево/число в python «правдивы» и запускают refresh; пустая строка/null — нет. + 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 de79cb2..0a1abe3 100644 --- a/src/core/Deal.Api/Endpoints/StorageEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/StorageEndpoints.cs @@ -1,90 +1,88 @@ -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Infrastructure.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Служебные storage-эндпоинты: POST /api/admin/tick и POST /api/admin/fts/rebuild (план Tasks 10–11, -/// Rulings 6/8/11; прототип dashboard_routes.py L261–264, L327–337). -/// -/// -/// Контракт 1:1 с прототипом и api-map §3.2 L103–112: POST /admin/tick = тик правил хранения текущего -/// тенанта + очистка отсева пайплайна (3 суток) + проверка напоминаний «Отложено» (план Task 11, Ruling 3/8) -/// + один проход pump очереди входящих (этап 4, Ruling 8/9); -/// ответ {storage, reminders, pipeline: {…}, queue: N} (dashboard_routes.py L327–337; storage.purgedRejected -/// объединяет очистку отсева — Ruling 9; reminders — «выстрелившие» напоминания {id,title,stage}, пусто — -/// сработавших нет). SSE-публикации (тосты статистики notify_tick_stats L496–504, new_card по созданным -/// карточкам и reminder_due по «выстрелившим» напоминаниям) выполняет из -/// Api-слоя — модули остаются чистыми (Ruling 5/8); без подписчиков публикация — no-op. Сбой проверки -/// напоминаний/pump не роняет тик: reminders/pipeline ответа пусты, очередь ждёт следующего тика/фонового -/// цикла (Task 11). POST /admin/fts/rebuild — -/// реальная идемпотентная пересборка FTS-индексов (CREATE INDEX IF NOT EXISTS + -/// ANALYZE, Ruling 6), ответ {ok:true, ready:true} (при сбое {ok:false, ready:false} — 1:1 с fts_rebuild L261–264, -/// кнопка Settings «Пересобрать индекс» store.js L1883–1889). Оба эндпоинта требуют сессию: 401 {detail} без -/// куки (Ruling 10); сервисы резолвятся из RequestServices ПОСЛЕ проверки сессии (паттерн BoardsEndpoints). -/// -public static class StorageEndpoints -{ - // Префикс группы (роутер dashboard, prefix="/api"; admin-пути прототипа L261/L327). - private const string AdminGroupPrefix = "/api"; - - // Путь ручного тика правил хранения (dashboard_routes.py L327). - private const string TickPath = "/admin/tick"; - - // Путь пересборки поискового индекса (dashboard_routes.py L261; Ruling 6). - private const string FtsRebuildPath = "/admin/fts/rebuild"; - - // OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py). - 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; - } - - // POST /api/admin/tick: правила хранения + очистка отсева + напоминания + pump + SSE-тосты/new_card/reminder_due (admin_tick L327–337). - // Весь состав тика — AdminTickOrchestrator (вынесен из эндпоинта для unit-тестов логики и - // переиспользования): тик StorageTickService (Kanban) → PurgeExpiredAsync (отсев 3 суток, merge в - // storage.purgedRejected) → тосты статистики (включая «Отсев очищен: N записей (3 дн.)») → CheckDueAsync - // (напоминания «Отложено»: reminders ответа + SSE reminder_due, план Task 11; сбой не роняет тик) → - // PumpOnceAsync (сбой не роняет тик) → SSE new_card по созданным карточкам → queue. Формы — 1:1 с прототипом. - 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)); - } - - // POST /api/admin/fts/rebuild: пересборка FTS-индексов тенанта; ответ {ok:true, ready:true} (Ruling 6). - // SearchTsv — генерируемые STORED-колонки Cards/RejectedItems: авто-актуальны, «пересборка» = создание - // отсутствующих GIN-индексов (CREATE INDEX IF NOT EXISTS) + ANALYZE таблиц (FtsMaintenance.RebuildAsync). - // Сбой обслуживания возвращает {ok:false, ready:false} (прототип fts_rebuild L261–264: rebuild() → ok, - // is_ready() → ready) — кнопка Settings фронта показывает ошибку по ready (store.js L1883–1889). - 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-эндпоинты: POST /api/admin/tick и POST /api/admin/fts/rebuild (план Tasks 10–11, +/// Rulings 6/8/11; прототип dashboard_routes.py L261–264, L327–337). +/// +/// +/// Контракт 1:1 с прототипом и api-map §3.2 L103–112: POST /admin/tick = тик правил хранения текущего +/// тенанта + очистка отсева пайплайна (3 суток) + проверка напоминаний «Отложено» (план Task 11, Ruling 3/8) +/// + один проход pump очереди входящих (этап 4, Ruling 8/9); +/// ответ {storage, reminders, pipeline: {…}, queue: N} (dashboard_routes.py L327–337; storage.purgedRejected +/// объединяет очистку отсева — Ruling 9; reminders — «выстрелившие» напоминания {id,title,stage}, пусто — +/// сработавших нет). SSE-публикации (тосты статистики notify_tick_stats L496–504, new_card по созданным +/// карточкам и reminder_due по «выстрелившим» напоминаниям) выполняет из +/// Api-слоя — модули остаются чистыми (Ruling 5/8); без подписчиков публикация — no-op. Сбой проверки +/// напоминаний/pump не роняет тик: reminders/pipeline ответа пусты, очередь ждёт следующего тика/фонового +/// цикла (Task 11). POST /admin/fts/rebuild — +/// реальная идемпотентная пересборка FTS-индексов (CREATE INDEX IF NOT EXISTS + +/// ANALYZE, Ruling 6), ответ {ok:true, ready:true} (при сбое {ok:false, ready:false} — 1:1 с fts_rebuild L261–264, +/// кнопка Settings «Пересобрать индекс» store.js L1883–1889). Оба эндпоинта требуют сессию: 401 {detail} без +/// куки (Ruling 10); сервисы резолвятся из RequestServices ПОСЛЕ проверки сессии (паттерн BoardsEndpoints). +/// +public static class StorageEndpoints +{ + // Префикс группы (роутер dashboard, prefix="/api"; admin-пути прототипа L261/L327). + private const string AdminGroupPrefix = "/api"; + + // Путь ручного тика правил хранения (dashboard_routes.py L327). + private const string TickPath = "/admin/tick"; + + // Путь пересборки поискового индекса (dashboard_routes.py L261; Ruling 6). + private const string FtsRebuildPath = "/admin/fts/rebuild"; + + // OpenAPI-тег группы (в прототипе роутер dashboard — dashboard_routes.py). + 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; + } + + // POST /api/admin/tick: правила хранения + очистка отсева + напоминания + pump + SSE-тосты/new_card/reminder_due (admin_tick L327–337). + // Весь состав тика — AdminTickOrchestrator (вынесен из эндпоинта для unit-тестов логики и + // переиспользования): тик StorageTickService (Kanban) → PurgeExpiredAsync (отсев 3 суток, merge в + // storage.purgedRejected) → тосты статистики (включая «Отсев очищен: N записей (3 дн.)») → CheckDueAsync + // (напоминания «Отложено»: reminders ответа + SSE reminder_due, план Task 11; сбой не роняет тик) → + // PumpOnceAsync (сбой не роняет тик) → SSE new_card по созданным карточкам → queue. Формы — 1:1 с прототипом. + 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)); + } + + // POST /api/admin/fts/rebuild: пересборка FTS-индексов тенанта; ответ {ok:true, ready:true} (Ruling 6). + // SearchTsv — генерируемые STORED-колонки Cards/RejectedItems: авто-актуальны, «пересборка» = создание + // отсутствующих GIN-индексов (CREATE INDEX IF NOT EXISTS) + ANALYZE таблиц (FtsMaintenance.RebuildAsync). + // Сбой обслуживания возвращает {ok:false, ready:false} (прототип fts_rebuild L261–264: rebuild() → ok, + // is_ready() → ready) — кнопка Settings фронта показывает ошибку по ready (store.js L1883–1889). + 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 e27196f..5811086 100644 --- a/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs @@ -1,560 +1,554 @@ -using Deal.Api.Endpoints.RequestModels; -using Deal.Api.Extensions; -using Deal.Api.Models; -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.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// Эндпоинты /api/tg: статус, веб-авторизация (phone/QR/код/2FA/logout), диалоги и мониторинг (Ruling 8, api-map §3.3). -/// -/// -/// Тела ответов 1:1 с прототипом backend/app/routers/tg_routes.py: -/// status — §4.9 (собирает ); start-phone/send-code/send-password — {phase}; -/// start-qr — {phase, qrUrl}; logout — {ok:true}; dialogs — {items:[диалог §4.8]} (type — русская -/// форма на границе: канал/группа/чат, заметка Task 1); refresh — {ok, count} либо {ok:false, -/// reason:"not-connected", count:0} (мягкая ветка L119–120); monitor/monitor-all/backfill-all/preview — как в -/// §3.3. Ошибки гейта (недоступный сервис/доменный отказ RPC) — 400 {detail} с канонической причиной -/// (Ruling 7/8). Фоновый первый разбор при включении мониторинга и «Перечитать» — -/// (python-_spawn L546/L566/L580). Все эндпоинты требуют сессию: 401 {detail} (Ruling 10). Сервисы резолвятся -/// из RequestServices ПОСЛЕ проверки сессии (scoped — TenantDbContext схемы тенанта, паттерн SettingsEndpoints). -/// -public static class TelegramEndpoints -{ - // Префикс группы /api/tg (python: router prefix, tg_routes.py L12). - private const string TgGroupPrefix = "/api/tg"; - - // OpenAPI-тег группы (в прототипе роутер tg — tg_routes.py). - 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"; - - // Путь разбора одного диалога (POST, параметр dialog_id; сервер-only, api-map п.9). - private const string DialogBackfillPath = "/dialogs/{dialog_id}/backfill"; - - // Деталь 400: сохранённый api_id не является корректным числом (Security review: int.TryParse). - private const string InvalidApiIdDetail = "Некорректный api_id — проверьте глобальные ключи Telegram"; - - // Деталь 400 без ключей приложения: оператор не задал глобальные ключи Telegram (ТЗ §4.1/§8.1). - private const string NoKeysDetail = "Ключи Telegram не заданы оператором"; - - // Деталь недоступного telegram-service/неподключённого аккаунта (глобальная строка контракта). - private const string NotConnectedDetail = "Telegram не подключён"; - - // Reason мягкой ветки refresh: аккаунт не подключён (tg_routes.py L120). - private const string NotConnectedReason = "not-connected"; - - // Фаза успешной привязки аккаунта Telegram (telegram_linked — этап 10, T1). - private const string ReadyPhase = "ready"; - - // Дефолт limit превью (PreviewBody L60: limit = 24). - private const int PreviewDefaultLimit = 24; - - // Нижняя граница limit превью (python L153: min(…, 1)). - private const int PreviewLimitMin = 1; - - // Верхняя граница limit превью (python L153: max(…, 50); api-map /dialogs/preview). - private const int PreviewLimitMax = 50; - - /// - /// Регистрирует группу /api/tg: status/start-phone/start-qr/send-code/send-password/logout/dialogs/refresh/ - /// monitor-all/backfill-all/preview/{dialog_id}/monitor/{dialog_id}/backfill (qr-image — TelegramQrImageEndpoint). - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - 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; - } - - // GET /api/tg/status: статус аккаунта/фазы входа (tg_routes.py L63–65; форма §4.9). - 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)); - } - - // POST /api/tg/start-phone: запросить код по номеру (tg_routes.py L68–74; python L134–147). - 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); - } - } - - // POST /api/tg/start-qr: начать QR-вход (tg_routes.py L77–83; python qr_start L286–300). - 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) - { - // Аудит привязки Telegram (этап 10, T1): аккаунт уже авторизован — фаза ready. - 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); - } - } - - // POST /api/tg/send-code: отправить SMS-код (tg_routes.py L86–94; python submit_code L149–166). - 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) - { - // Аудит привязки Telegram (этап 10, T1): вход завершён без 2FA — фаза ready. - 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); - } - } - - // POST /api/tg/send-password: облачный пароль 2FA (tg_routes.py L97–103; python submit_password L168–176). - 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) - { - // Аудит привязки Telegram (этап 10, T1): 2FA пройдена — фаза ready. - 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); - } - } - - // POST /api/tg/logout: отключить аккаунт, удалить сессию (tg_routes.py L106–109; python disconnect L189–207). - 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); - } - } - - // GET /api/tg/dialogs: список диалогов из БД (tg_routes.py L112–114; list_dialogs L521–534). - 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); - - // Форма §4.8 L349: {id, name, handle, type, hue, on, last:{text,time}}; type — русская форма на границе - // (EN-канон каталога channel/group/forum/chat → «канал»/«группа»/«чат», заметка Task 1/«кривое место» п.4). - 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 }); - } - - // POST /api/tg/dialogs/refresh: синхронизировать каталог диалогов из Telegram (tg_routes.py L117–122). - 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(); - - // 1:1 tg_routes.py L119–120: аккаунт не подключён (или сервис недоступен — Ruling 7) → мягкая ветка - // {ok:false, reason:"not-connected", count:0} HTTP 200 — refresh не ошибка запроса. - 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 }); - } - } - - // POST /api/tg/dialogs/monitor-all: мониторинг всех каналов (tg_routes.py L125–129; L548–567). - 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) - { - // Первое включение неразобранных: фоновый разбор списком (python L566: _spawn(_backfill_dialogs)). - context.RequestServices.GetRequiredService().ScheduleFirstBackfills(result.BackfillNeededIds); - } - - if (body.Enabled) - { - // Аудит включения каналов (этап 10, T1): без имён/содержимого. - 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); - } - } - - // POST /api/tg/dialogs/backfill-all: «Перечитать» включённые каналы в фоне (tg_routes.py L132–136). - 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) - { - // Ответ — сразу {ok, count}, разбор идёт в фоне (python L580: _spawn(backfill_monitored)). - context.RequestServices.GetRequiredService().ScheduleReadRecent(); - } - - return Results.Ok(new { ok = true, count }); - } - - // POST /api/tg/dialogs/{dialog_id}/monitor: вкл/выкл мониторинг канала (tg_routes.py L139–142; L536–546). - 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) - { - // Первое включение неразобранного канала: фоновый разбор (python L546: _spawn(backfill_dialog)). - context.RequestServices.GetRequiredService().ScheduleFirstBackfill(dialog_id); - } - - if (result.Enabled) - { - // Аудит включения канала (этап 10, T1). - 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); - } - } - - // POST /api/tg/dialogs/{dialog_id}/backfill: разбор одного диалога (tg_routes.py L145–148; L349–390). - // Сервер-only эндпоинт (фронт не вызывает, api-map §3.3 L139/п.9): первый разбор/догон одного канала. - 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); - } - } - - // POST /api/tg/dialogs/preview: последние сообщения диалога (tg_routes.py L151–153; dialog_messages L583–620). - 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; - } - - // 400 {detail} по ошибке гейта: канонический detail RPC либо «Telegram не подключён» (Ruling 7/8). - // exception: Исключение вызова гейта (RpcException домена/транспорта, прочее). - // Возвращает: 400-ответ с текстом причины. - private static IResult GatewayError(Exception exception) - { - return EndpointResults.BadRequest(GatewayErrorText(exception)); - } - - /// - /// Текст причины ошибки гейта для {detail} (канонические тексты telegram-service 1:1, Ruling 7). - /// - /// Исключение вызова гейта. - /// Текст причины. - 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, - // Недоступность/прочий транспорт — «не подключён» (GrpcTelegramClient нормализует, Ruling 7). - _ => NotConnectedDetail, - }; - } - - /// - /// Русская форма типа источника на границе эндпоинта (заметка Task 1, api-map §4.8 L349). - /// - /// Каталог ядра хранит EN-канон (channel/group/forum/chat); наружу (вкладка «Каналы», фильтр по типу) - /// — русские подписи python (_kind_of L461–466: «канал»/«группа»/«чат»; форум отображается как группа). - /// Тип источника (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: статус, веб-авторизация (phone/QR/код/2FA/logout), диалоги и мониторинг (Ruling 8, api-map §3.3). +/// +/// +/// Тела ответов 1:1 с прототипом backend/app/routers/tg_routes.py: +/// status — §4.9 (собирает ); start-phone/send-code/send-password — {phase}; +/// start-qr — {phase, qrUrl}; logout — {ok:true}; dialogs — {items:[диалог §4.8]} (type — русская +/// форма на границе: канал/группа/чат, заметка Task 1); refresh — {ok, count} либо {ok:false, +/// reason:"not-connected", count:0} (мягкая ветка L119–120); monitor/monitor-all/backfill-all/preview — как в +/// §3.3. Ошибки гейта (недоступный сервис/доменный отказ RPC) — 400 {detail} с канонической причиной +/// (Ruling 7/8). Фоновый первый разбор при включении мониторинга и «Перечитать» — +/// (python-_spawn L546/L566/L580). Все эндпоинты требуют сессию: 401 {detail} (Ruling 10). Сервисы резолвятся +/// из RequestServices ПОСЛЕ проверки сессии (scoped — TenantDbContext схемы тенанта, паттерн SettingsEndpoints). +/// +public static class TelegramEndpoints +{ + // Префикс группы /api/tg (python: router prefix, tg_routes.py L12). + private const string TgGroupPrefix = "/api/tg"; + + // OpenAPI-тег группы (в прототипе роутер tg — tg_routes.py). + 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"; + + // Путь разбора одного диалога (POST, параметр dialog_id; сервер-only, api-map п.9). + private const string DialogBackfillPath = "/dialogs/{dialog_id}/backfill"; + + // Деталь 400: сохранённый api_id не является корректным числом (Security review: int.TryParse). + private const string InvalidApiIdDetail = "Некорректный api_id — проверьте глобальные ключи Telegram"; + + // Деталь 400 без ключей приложения: оператор не задал глобальные ключи Telegram (ТЗ §4.1/§8.1). + private const string NoKeysDetail = "Ключи Telegram не заданы оператором"; + + // Деталь недоступного telegram-service/неподключённого аккаунта (глобальная строка контракта). + private const string NotConnectedDetail = "Telegram не подключён"; + + // Reason мягкой ветки refresh: аккаунт не подключён (tg_routes.py L120). + private const string NotConnectedReason = "not-connected"; + + // Фаза успешной привязки аккаунта Telegram (telegram_linked — этап 10, T1). + private const string ReadyPhase = "ready"; + + // Дефолт limit превью (PreviewBody L60: limit = 24). + private const int PreviewDefaultLimit = 24; + + // Нижняя граница limit превью (python L153: min(…, 1)). + private const int PreviewLimitMin = 1; + + // Верхняя граница limit превью (python L153: max(…, 50); api-map /dialogs/preview). + private const int PreviewLimitMax = 50; + + /// + /// Регистрирует группу /api/tg: status/start-phone/start-qr/send-code/send-password/logout/dialogs/refresh/ + /// monitor-all/backfill-all/preview/{dialog_id}/monitor/{dialog_id}/backfill (qr-image — TelegramQrImageEndpoint). + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + 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; + } + + // GET /api/tg/status: статус аккаунта/фазы входа (tg_routes.py L63–65; форма §4.9). + 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)); + } + + // POST /api/tg/start-phone: запросить код по номеру (tg_routes.py L68–74; python L134–147). + 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); + } + } + + // POST /api/tg/start-qr: начать QR-вход (tg_routes.py L77–83; python qr_start L286–300). + 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) + { + // Аудит привязки Telegram (этап 10, T1): аккаунт уже авторизован — фаза ready. + 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); + } + } + + // POST /api/tg/send-code: отправить SMS-код (tg_routes.py L86–94; python submit_code L149–166). + 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) + { + // Аудит привязки Telegram (этап 10, T1): вход завершён без 2FA — фаза ready. + 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); + } + } + + // POST /api/tg/send-password: облачный пароль 2FA (tg_routes.py L97–103; python submit_password L168–176). + 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) + { + // Аудит привязки Telegram (этап 10, T1): 2FA пройдена — фаза ready. + 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); + } + } + + // POST /api/tg/logout: отключить аккаунт, удалить сессию (tg_routes.py L106–109; python disconnect L189–207). + 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); + } + } + + // GET /api/tg/dialogs: список диалогов из БД (tg_routes.py L112–114; list_dialogs L521–534). + 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); + + // Форма §4.8 L349: {id, name, handle, type, hue, on, last:{text,time}}; type — русская форма на границе + // (EN-канон каталога channel/group/forum/chat → «канал»/«группа»/«чат», заметка Task 1/«кривое место» п.4). + 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 }); + } + + // POST /api/tg/dialogs/refresh: синхронизировать каталог диалогов из Telegram (tg_routes.py L117–122). + 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(); + + // 1:1 tg_routes.py L119–120: аккаунт не подключён (или сервис недоступен — Ruling 7) → мягкая ветка + // {ok:false, reason:"not-connected", count:0} HTTP 200 — refresh не ошибка запроса. + 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 }); + } + } + + // POST /api/tg/dialogs/monitor-all: мониторинг всех каналов (tg_routes.py L125–129; L548–567). + 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) + { + // Первое включение неразобранных: фоновый разбор списком (python L566: _spawn(_backfill_dialogs)). + context.RequestServices.GetRequiredService().ScheduleFirstBackfills(result.BackfillNeededIds); + } + + if (body.Enabled) + { + // Аудит включения каналов (этап 10, T1): без имён/содержимого. + 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); + } + } + + // POST /api/tg/dialogs/backfill-all: «Перечитать» включённые каналы в фоне (tg_routes.py L132–136). + 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) + { + // Ответ — сразу {ok, count}, разбор идёт в фоне (python L580: _spawn(backfill_monitored)). + context.RequestServices.GetRequiredService().ScheduleReadRecent(); + } + + return Results.Ok(new { ok = true, count }); + } + + // POST /api/tg/dialogs/{dialog_id}/monitor: вкл/выкл мониторинг канала (tg_routes.py L139–142; L536–546). + 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) + { + // Первое включение неразобранного канала: фоновый разбор (python L546: _spawn(backfill_dialog)). + context.RequestServices.GetRequiredService().ScheduleFirstBackfill(dialog_id); + } + + if (result.Enabled) + { + // Аудит включения канала (этап 10, T1). + 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); + } + } + + // POST /api/tg/dialogs/{dialog_id}/backfill: разбор одного диалога (tg_routes.py L145–148; L349–390). + // Сервер-only эндпоинт (фронт не вызывает, api-map §3.3 L139/п.9): первый разбор/догон одного канала. + 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); + } + } + + // POST /api/tg/dialogs/preview: последние сообщения диалога (tg_routes.py L151–153; dialog_messages L583–620). + 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; + } + + // 400 {detail} по ошибке гейта: канонический detail RPC либо «Telegram не подключён» (Ruling 7/8). + // exception: Исключение вызова гейта (RpcException домена/транспорта, прочее). + // Возвращает: 400-ответ с текстом причины. + private static IResult GatewayError(Exception exception) + { + return EndpointResults.BadRequest(GatewayErrorText(exception)); + } + + /// + /// Текст причины ошибки гейта для {detail} (канонические тексты telegram-service 1:1, Ruling 7). + /// + /// Исключение вызова гейта. + /// Текст причины. + 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, + // Недоступность/прочий транспорт — «не подключён» (GrpcTelegramClient нормализует, Ruling 7). + _ => NotConnectedDetail, + }; + } + + /// + /// Русская форма типа источника на границе эндпоинта (заметка Task 1, api-map §4.8 L349). + /// + /// Каталог ядра хранит EN-канон (channel/group/forum/chat); наружу (вкладка «Каналы», фильтр по типу) + /// — русские подписи python (_kind_of L461–466: «канал»/«группа»/«чат»; форум отображается как группа). + /// Тип источника (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 354b5d0..12b3ace 100644 --- a/src/core/Deal.Api/Endpoints/TelegramQrImageEndpoint.cs +++ b/src/core/Deal.Api/Endpoints/TelegramQrImageEndpoint.cs @@ -1,87 +1,85 @@ -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Net.Codecrete.QrCodeGenerator; -using Deal.Api.Dtos; - -namespace Deal.Api.Endpoints; - -/// -/// GET /api/tg/qr-image: SVG QR-кода входа (tg_routes.py L30–39; Ruling 8). -/// -/// -/// Фронт рисует QR картинкой: <img src="/api/tg/qr-image?t=N"> (store.js tg-флоу; api-map §3.3 L133). -/// Активен только в фазе входа «qr» (живой статус гейта); иначе — 404 «QR не активен — начните вход по QR» -/// (глобальная строка контракта, python L34). SVG генерирует Net.Codecrete.QrCodeGenerator (SVG-first, без -/// внешних растровых зависимостей — план Task 14/Tech Stack); border=1 как python (border=1, L22). Заголовки — -/// no-store + Content-Disposition: inline (python L37–38: свежий QR на каждый запрос, не кэшировать). Сессия -/// обязательна: 401 {detail} (Ruling 10). -/// -public static class TelegramQrImageEndpoint -{ - // Префикс группы /api/tg (общий с TelegramEndpoints). - private const string QrGroupPrefix = "/api/tg"; - - // Путь SVG QR-кода (GET; фронт добавляет ?t=N от кэша). - private const string QrImagePath = "/qr-image"; - - // OpenAPI-тег группы (в прототипе роутер tg — tg_routes.py). - private const string QrImageOpenApiTag = "telegram"; - - // Деталь 404: QR не активен (python L34, глобальная строка контракта). - private const string QrNotActiveDetail = "QR не активен — начните вход по QR"; - - // Media-type SVG-ответа (python L37: image/svg+xml). - private const string SvgMediaType = "image/svg+xml"; - - // Ширина рамки (quiet zone) QR в модулях (python L22: qrcode border=1). - 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; - } - - // GET /api/tg/qr-image: SVG QR-кода фазы входа «qr» (tg_routes.py L30–39). - private static async Task QrImageAsync(HttpContext context, CancellationToken ct) - { - if (!context.HasUser()) - { - return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); - } - - // Активен только в фазе «qr» живого статуса telegram-service; сервис недоступен/фаза иная — 404 (python L33–34). - 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); - - // Свежий QR на каждый запрос (не кэшировать); inline — как python L37–38. - 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: SVG QR-кода входа (tg_routes.py L30–39; Ruling 8). +/// +/// +/// Фронт рисует QR картинкой: <img src="/api/tg/qr-image?t=N"> (store.js tg-флоу; api-map §3.3 L133). +/// Активен только в фазе входа «qr» (живой статус гейта); иначе — 404 «QR не активен — начните вход по QR» +/// (глобальная строка контракта, python L34). SVG генерирует Net.Codecrete.QrCodeGenerator (SVG-first, без +/// внешних растровых зависимостей — план Task 14/Tech Stack); border=1 как python (border=1, L22). Заголовки — +/// no-store + Content-Disposition: inline (python L37–38: свежий QR на каждый запрос, не кэшировать). Сессия +/// обязательна: 401 {detail} (Ruling 10). +/// +public static class TelegramQrImageEndpoint +{ + // Префикс группы /api/tg (общий с TelegramEndpoints). + private const string QrGroupPrefix = "/api/tg"; + + // Путь SVG QR-кода (GET; фронт добавляет ?t=N от кэша). + private const string QrImagePath = "/qr-image"; + + // OpenAPI-тег группы (в прототипе роутер tg — tg_routes.py). + private const string QrImageOpenApiTag = "telegram"; + + // Деталь 404: QR не активен (python L34, глобальная строка контракта). + private const string QrNotActiveDetail = "QR не активен — начните вход по QR"; + + // Media-type SVG-ответа (python L37: image/svg+xml). + private const string SvgMediaType = "image/svg+xml"; + + // Ширина рамки (quiet zone) QR в модулях (python L22: qrcode border=1). + 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; + } + + // GET /api/tg/qr-image: SVG QR-кода фазы входа «qr» (tg_routes.py L30–39). + private static async Task QrImageAsync(HttpContext context, CancellationToken ct) + { + if (!context.HasUser()) + { + return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); + } + + // Активен только в фазе «qr» живого статуса telegram-service; сервис недоступен/фаза иная — 404 (python L33–34). + 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); + + // Свежий QR на каждый запрос (не кэшировать); inline — как python L37–38. + 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 91d3033..20c1c1f 100644 --- a/src/core/Deal.Api/Events/SseBroker.cs +++ b/src/core/Deal.Api/Events/SseBroker.cs @@ -1,129 +1,127 @@ -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Threading.Channels; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Events; - -/// -/// Singleton SSE-брокер этапа: per-tenant каналы событий (Ruling 5, план Task 9). -/// -/// -/// Канал заводится на тенанта при подписке (тенант сессии — CurrentUser.TenantId), публикация идёт -/// в канал тенанта по явному идентификатору — её делают ТОЛЬКО эндпоинты Api после вызова сервисов -/// модулей (Task 10/13/14); фоновые задачи вне tenant-запроса публикуют со своим scope + ITenantContext -/// (Task 11). Публикация без подписчиков канала — no-op, не падает (Ruling 5). Очередь подписчика — -/// bounded ≤200 с вытеснением старых, как прототип sse.py (maxsize=200, при переполнении get_nowait → -/// put_nowait текущего события). Потокобезопасен: словарь защищён гейтом; запись в каналы — -/// неблокирующий TryWrite (DropOldest) вне гейта, подписки/отписки конкурентны публикациям. -/// -public sealed class SseBroker -{ - /// - /// Ёмкость очереди подписчика (sse.py L20: asyncio.Queue(maxsize=200)). - /// - 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(); - - /// - /// Подписывает клиента на канал тенанта: новая bounded-очередь (≤200, DropOldest). - /// - /// Тенант сессии запроса (Ruling 5: канал по TenantId при подписке). - /// Подписка: идентификатор для отписки и читатель канала событий. - public SseSubscription Subscribe(Guid tenantId) - { - var channel = Channel.CreateBounded(new BoundedChannelOptions(SubscriberQueueCapacity) - { - // Вытеснение старых при переполнении (sse.py L36–44): TryWrite не блокирует и не падает. - 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-соединения (events_routes.py L27–28). - /// - /// Тенант канала подписки. - /// Идентификатор подписки из . - 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); - } - } - } - - /// - /// Публикует событие в канал тенанта (Ruling 5: публикации — из эндпоинтов Api). - /// - /// Тенант-получатель; без подписчиков — no-op, не падает. - /// Тип события (new_card/toast этапа 3; api.js L78–79). - /// Полезная нагрузка — сериализуется в JSON (camelCase, без \u). - public void Publish( - Guid tenantId, - string eventType, - object payload) => - Publish(tenantId, new SseEvent(eventType, JsonSerializer.Serialize(payload, PublishJsonOptions))); - - /// - /// Публикует готовое событие (тип + JSON) в канал тенанта. - /// - /// Тенант-получатель; без подписчиков — 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-брокер этапа: per-tenant каналы событий (Ruling 5, план Task 9). +/// +/// +/// Канал заводится на тенанта при подписке (тенант сессии — CurrentUser.TenantId), публикация идёт +/// в канал тенанта по явному идентификатору — её делают ТОЛЬКО эндпоинты Api после вызова сервисов +/// модулей (Task 10/13/14); фоновые задачи вне tenant-запроса публикуют со своим scope + ITenantContext +/// (Task 11). Публикация без подписчиков канала — no-op, не падает (Ruling 5). Очередь подписчика — +/// bounded ≤200 с вытеснением старых, как прототип sse.py (maxsize=200, при переполнении get_nowait → +/// put_nowait текущего события). Потокобезопасен: словарь защищён гейтом; запись в каналы — +/// неблокирующий TryWrite (DropOldest) вне гейта, подписки/отписки конкурентны публикациям. +/// +public sealed class SseBroker +{ + /// + /// Ёмкость очереди подписчика (sse.py L20: asyncio.Queue(maxsize=200)). + /// + 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(); + + /// + /// Подписывает клиента на канал тенанта: новая bounded-очередь (≤200, DropOldest). + /// + /// Тенант сессии запроса (Ruling 5: канал по TenantId при подписке). + /// Подписка: идентификатор для отписки и читатель канала событий. + public SseSubscription Subscribe(Guid tenantId) + { + var channel = Channel.CreateBounded(new BoundedChannelOptions(SubscriberQueueCapacity) + { + // Вытеснение старых при переполнении (sse.py L36–44): TryWrite не блокирует и не падает. + 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-соединения (events_routes.py L27–28). + /// + /// Тенант канала подписки. + /// Идентификатор подписки из . + 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); + } + } + } + + /// + /// Публикует событие в канал тенанта (Ruling 5: публикации — из эндпоинтов Api). + /// + /// Тенант-получатель; без подписчиков — no-op, не падает. + /// Тип события (new_card/toast этапа 3; api.js L78–79). + /// Полезная нагрузка — сериализуется в JSON (camelCase, без \u). + public void Publish( + Guid tenantId, + string eventType, + object payload) => + Publish(tenantId, new SseEvent(eventType, JsonSerializer.Serialize(payload, PublishJsonOptions))); + + /// + /// Публикует готовое событие (тип + JSON) в канал тенанта. + /// + /// Тенант-получатель; без подписчиков — 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 da21d2a..4af8411 100644 --- a/src/core/Deal.Api/Events/SseEvent.cs +++ b/src/core/Deal.Api/Events/SseEvent.cs @@ -1,19 +1,16 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Events; - -/// -/// Событие SSE-потока: тип + JSON-полезная нагрузка (Ruling 5; прототип sse.py L29–30). -/// -/// Тип события — фронт слушает addEventListener по имени -/// (api.js L78–81): на этапе 3 — new_card (полный объект карточки) и toast {text, icon}. -/// Полезная нагрузка, сериализованная в JSON (camelCase, без \u-экранирования). -public sealed record SseEvent(string Type, string Json) -{ - /// - /// Отрисовывает frame протокола SSE: event: <type>\ndata: <json>\n\n (sse.py L30). - /// - /// Готовый frame для отправки в поток ответа. - public string RenderFrame() => $"event: {Type}\ndata: {Json}\n\n"; -} +namespace Deal.Api.Events; + +/// +/// Событие SSE-потока: тип + JSON-полезная нагрузка (Ruling 5; прототип sse.py L29–30). +/// +/// Тип события — фронт слушает addEventListener по имени +/// (api.js L78–81): на этапе 3 — new_card (полный объект карточки) и toast {text, icon}. +/// Полезная нагрузка, сериализованная в JSON (camelCase, без \u-экранирования). +public sealed record SseEvent(string Type, string Json) +{ + /// + /// Отрисовывает frame протокола SSE: event: <type>\ndata: <json>\n\n (sse.py L30). + /// + /// Готовый 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 889967f..1bee684 100644 --- a/src/core/Deal.Api/Events/SseSubscription.cs +++ b/src/core/Deal.Api/Events/SseSubscription.cs @@ -1,14 +1,12 @@ -using System.Threading.Channels; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Events; - -/// -/// Активная подписка на канал SSE тенанта (прототип sse.py — очередь подписчика L19–20). -/// -/// Идентификатор подписки — передаётся в . -/// Тенант канала: тенант сессии при подписке (Ruling 5). -/// Канал событий подписчика: bounded-очередь ≤200 с вытеснением старых -/// (DropOldest, как get_nowait+put_nowait прототипа L36–44). -public sealed record SseSubscription(Guid Id, Guid TenantId, ChannelReader Events); +using System.Threading.Channels; + +namespace Deal.Api.Events; + +/// +/// Активная подписка на канал SSE тенанта (прототип sse.py — очередь подписчика L19–20). +/// +/// Идентификатор подписки — передаётся в . +/// Тенант канала: тенант сессии при подписке (Ruling 5). +/// Канал событий подписчика: bounded-очередь ≤200 с вытеснением старых +/// (DropOldest, как get_nowait+put_nowait прототипа L36–44). +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 b71ee0e..af8a839 100644 --- a/src/core/Deal.Api/Events/StorageToastPublisher.cs +++ b/src/core/Deal.Api/Events/StorageToastPublisher.cs @@ -1,84 +1,82 @@ -using Deal.Modules.Kanban.Application.Models; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Events; - -/// -/// Публикация SSE-тостов статистики тика правил хранения в канал тенанта (Ruling 8; notify_tick_stats L496–504). -/// -/// -/// Единый хелпер Api-слоя для POST /api/admin/tick (StorageEndpoints/AdminTickOrchestrator, Task 10) и -/// фонового StorageTickScheduler (Task 11): публикует тосты только по ненулевым счётчикам, тексты и иконки -/// 1:1 с прототипом; без подписчиков канала публикация — no-op (Ruling 5). Вынесен из StorageEndpoints, -/// чтобы ручной и фоновый тики не дублировали логику. Модуль Kanban тосты не публикует (Ruling 5: -/// публикации SSE — обязанность Api-слоя). -/// -public sealed class StorageToastPublisher -{ - // Тип SSE-события тоста (Ruling 5; api.js L79 слушает 'toast'). - private const string ToastEventType = "toast"; - - // Текст тоста автоархива: N карточек ушло в архив (notify_tick_stats L498). - private const string AutoArchiveToastText = "Автоархив: {0} карточек"; - - // Текст тоста очистки архива: N карточек удалено из архива (notify_tick_stats L500). - private const string ArchiveClearedToastText = "Архив очищен: {0} (90 дн.)"; - - // Текст тоста очистки корзины: N карточек удалено из корзины (notify_tick_stats L502). - private const string TrashClearedToastText = "Корзина очищена: {0} (7 дн.)"; - - // Текст тоста автоочистки отсева пайплайна: N записей старше 3 суток (notify_tick_stats L503–504). - private const string RejectedPurgedToastText = "Отсев очищен: {0} записей (3 дн.)"; - - // Иконка тоста автоархива (Ruling 8, 1:1 с прототипом). - private const string ClockIcon = "clock"; - - // Иконка тостов очисток архива/корзины (Ruling 8, 1:1 с прототипом). - private const string TrashIcon = "trash"; - - private readonly SseBroker _broker; - - /// - /// Создаёт публикатора тостов тика. - /// - /// SSE-брокер каналов тенантов (singleton). - public StorageToastPublisher(SseBroker broker) - { - ArgumentNullException.ThrowIfNull(broker); - _broker = broker; - } - - /// - /// Публикует тосты статистики тика по ненулевым счётчикам (notify_tick_stats L496–504). - /// - /// Тексты и иконки 1:1 с прототипом; дни в скобках («90 дн.»/«7 дн.»/«3 дн.») — фиксированные - /// строки прототипа (не пересчитываются от настроек). Ветка purgedRejected («Отсев очищен: N записей - /// (3 дн.)») — план Task 10: счётчик наполняет оркестратор тика (AdminTickOrchestrator) очисткой отсева - /// PipelineProcessingService.PurgeExpiredAsync; фоновый цикл Task 11 публикует ту же ветку по своему тику. - /// Публикация в канал тенанта; без подписчиков — no-op (Ruling 5). - /// Тенант-получатель тостов (сессия запроса / канал тенанта цикла). - /// Статистика только что выполненного тика. - 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-тостов статистики тика правил хранения в канал тенанта (Ruling 8; notify_tick_stats L496–504). +/// +/// +/// Единый хелпер Api-слоя для POST /api/admin/tick (StorageEndpoints/AdminTickOrchestrator, Task 10) и +/// фонового StorageTickScheduler (Task 11): публикует тосты только по ненулевым счётчикам, тексты и иконки +/// 1:1 с прототипом; без подписчиков канала публикация — no-op (Ruling 5). Вынесен из StorageEndpoints, +/// чтобы ручной и фоновый тики не дублировали логику. Модуль Kanban тосты не публикует (Ruling 5: +/// публикации SSE — обязанность Api-слоя). +/// +public sealed class StorageToastPublisher +{ + // Тип SSE-события тоста (Ruling 5; api.js L79 слушает 'toast'). + private const string ToastEventType = "toast"; + + // Текст тоста автоархива: N карточек ушло в архив (notify_tick_stats L498). + private const string AutoArchiveToastText = "Автоархив: {0} карточек"; + + // Текст тоста очистки архива: N карточек удалено из архива (notify_tick_stats L500). + private const string ArchiveClearedToastText = "Архив очищен: {0} (90 дн.)"; + + // Текст тоста очистки корзины: N карточек удалено из корзины (notify_tick_stats L502). + private const string TrashClearedToastText = "Корзина очищена: {0} (7 дн.)"; + + // Текст тоста автоочистки отсева пайплайна: N записей старше 3 суток (notify_tick_stats L503–504). + private const string RejectedPurgedToastText = "Отсев очищен: {0} записей (3 дн.)"; + + // Иконка тоста автоархива (Ruling 8, 1:1 с прототипом). + private const string ClockIcon = "clock"; + + // Иконка тостов очисток архива/корзины (Ruling 8, 1:1 с прототипом). + private const string TrashIcon = "trash"; + + private readonly SseBroker _broker; + + /// + /// Создаёт публикатора тостов тика. + /// + /// SSE-брокер каналов тенантов (singleton). + public StorageToastPublisher(SseBroker broker) + { + ArgumentNullException.ThrowIfNull(broker); + _broker = broker; + } + + /// + /// Публикует тосты статистики тика по ненулевым счётчикам (notify_tick_stats L496–504). + /// + /// Тексты и иконки 1:1 с прототипом; дни в скобках («90 дн.»/«7 дн.»/«3 дн.») — фиксированные + /// строки прототипа (не пересчитываются от настроек). Ветка purgedRejected («Отсев очищен: N записей + /// (3 дн.)») — план Task 10: счётчик наполняет оркестратор тика (AdminTickOrchestrator) очисткой отсева + /// PipelineProcessingService.PurgeExpiredAsync; фоновый цикл Task 11 публикует ту же ветку по своему тику. + /// Публикация в канал тенанта; без подписчиков — no-op (Ruling 5). + /// Тенант-получатель тостов (сессия запроса / канал тенанта цикла). + /// Статистика только что выполненного тика. + 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 f32d05b..4de1d17 100644 --- a/src/core/Deal.Api/Extensions/AuthHelpers.cs +++ b/src/core/Deal.Api/Extensions/AuthHelpers.cs @@ -1,70 +1,68 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; -using Deal.Api.Models; - -namespace Deal.Api.Extensions; - -/// -/// Хелперы доступа к текущему пользователю запроса (минимальные API). -/// -public static class AuthHelpers -{ - /// - /// Ключ в HttpContext.Items, по которому SessionMiddleware хранит CurrentUser. - /// - public const string CurrentUserItemKey = "CurrentUser"; - - /// - /// Ключ в HttpContext.Items, по которому OperatorSessionMiddleware хранит CurrentOperator. - /// - public const string CurrentOperatorItemKey = "CurrentOperator"; - - /// - /// Сообщение 401 для эндпоинтов, требующих авторизации (семантика прототипа, Ruling 10). - /// - public const string UnauthorizedDetail = "Требуется авторизация"; - - /// - /// Сообщение 401 для ручек /api/operator/* без разрешённой операторской сессии (Ruling 1). - /// - 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; + +/// +/// Хелперы доступа к текущему пользователю запроса (минимальные API). +/// +public static class AuthHelpers +{ + /// + /// Ключ в HttpContext.Items, по которому SessionMiddleware хранит CurrentUser. + /// + public const string CurrentUserItemKey = "CurrentUser"; + + /// + /// Ключ в HttpContext.Items, по которому OperatorSessionMiddleware хранит CurrentOperator. + /// + public const string CurrentOperatorItemKey = "CurrentOperator"; + + /// + /// Сообщение 401 для эндпоинтов, требующих авторизации (семантика прототипа, Ruling 10). + /// + public const string UnauthorizedDetail = "Требуется авторизация"; + + /// + /// Сообщение 401 для ручек /api/operator/* без разрешённой операторской сессии (Ruling 1). + /// + 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 65adfc7..0556a2d 100644 --- a/src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs +++ b/src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs @@ -1,220 +1,215 @@ -using Deal.Api.Events; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Hosting; - -/// -/// Фоновый цикл SSE-алертов ИИ-бюджета (Ruling 3, Task 9; эталон StorageTickScheduler): каждые 60 с -/// обходит ВСЕ тенанты реестра и публикует в канал тенанта тост при пересечении порогов бюджета 80%/100%. -/// -/// -/// Пересечение порога детектируется CAS-установкой флагов Warned80/NotifiedExhausted -/// ( / , Task 8): метод возвращает -/// true только в момент «флаг ещё не стоял и порог достигнут» — публикация выполняется ровно один раз на порог -/// за период (флаги сбрасываются ленивым reset периода и сменой бюджета оператором, Task 8/10). Флаги выставляет -/// ТОЛЬКО TryMark* (review-fix Task 9): AddUsage только инкрементирует UsedTokens, поэтому естественный расход, -/// пересекший порог, планировщик видит на ближайшем проходе как непомеченный переход — TryMark* возвращает true -/// ровно один раз, и тост не теряется и не задваивается. -/// Тексты и иконка -/// 1:1 с Ruling 3 («ИИ-бюджет израсходован на 80%» / «ИИ-бюджет исчерпан — обработка в локальном режиме», icon -/// bell); SSE-тип — существующий 'toast' (Ruling 11: новых SSE-типов нет). Списывание usage выполняется в -/// gRPC-адаптерах (TokenUsageRecorder) и флаги при пересечении уже могут стоять — TryMark*-CAS не даёт -/// задвоить тост. Публикация в канал тенанта; без подписчиков — no-op, не падает (Ruling 5). -/// -/// Лимиты живут в публичной схеме (ITenantLimitStore → DealDbContext) — в отличие от StorageTickScheduler -/// tenant-контекст проходу не нужен (SetTenant не выполняется); проверка каждого тенанта — в собственном scope -/// (scoped-хранилище лимитов). Первый проход — сразу после старта, далее по таймеру; перекрывающиеся проходы -/// исключены in-flight guard (Interlocked, как RatesRefreshScheduler). Ошибки логируются и наружу не выбрасываются -/// (проверка одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий проход -/// отменяется (graceful). -/// -/// -public sealed class BudgetAlertScheduler : IHostedService -{ - /// - /// Период проходов цикла — 60 с (план Task 9: фоновая проверка порогов бюджета). - /// - public const int AlertPeriodSeconds = 60; - - // Тип SSE-события тоста (Ruling 5/11; api.js слушает 'toast', новых типов не вводим). - private const string ToastEventType = "toast"; - - // Текст тоста пересечения порога 80% (Ruling 3). - private const string Warned80ToastText = "ИИ-бюджет израсходован на 80%"; - - // Текст тоста исчерпания бюджета (Ruling 3). - private const string ExhaustedToastText = "ИИ-бюджет исчерпан — обработка в локальном режиме"; - - // Иконка тостов бюджета (Ruling 3: bell). - 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, незавершённый проход безопасно завершится на отменённом токене. - } - } - } - - /// - /// Один проход цикла: список тенантов реестра и проверка порогов каждого (no-op, если проход идёт). - /// - /// Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются); таймер - /// вызывает этот же метод. Ошибки и отмена токена наружу не выбрасываются: сбои логируются (цикл живёт), - /// отмена по токену останова завершает проход штатно. - /// Токен отмены прохода (в проде — токен остановки хоста). - /// Задача прохода (завершается без исключений). - 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(); - - // TryMark* — атомарная установка флага: true только в момент первого наблюдения порога за период - // (Task 8/9). Тост публикуется ровно один раз на порог; повторные проходы — false → no-op. - 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-алертов ИИ-бюджета (Ruling 3, Task 9; эталон StorageTickScheduler): каждые 60 с +/// обходит ВСЕ тенанты реестра и публикует в канал тенанта тост при пересечении порогов бюджета 80%/100%. +/// +/// +/// Пересечение порога детектируется CAS-установкой флагов Warned80/NotifiedExhausted +/// ( / , Task 8): метод возвращает +/// true только в момент «флаг ещё не стоял и порог достигнут» — публикация выполняется ровно один раз на порог +/// за период (флаги сбрасываются ленивым reset периода и сменой бюджета оператором, Task 8/10). Флаги выставляет +/// ТОЛЬКО TryMark* (review-fix Task 9): AddUsage только инкрементирует UsedTokens, поэтому естественный расход, +/// пересекший порог, планировщик видит на ближайшем проходе как непомеченный переход — TryMark* возвращает true +/// ровно один раз, и тост не теряется и не задваивается. +/// Тексты и иконка +/// 1:1 с Ruling 3 («ИИ-бюджет израсходован на 80%» / «ИИ-бюджет исчерпан — обработка в локальном режиме», icon +/// bell); SSE-тип — существующий 'toast' (Ruling 11: новых SSE-типов нет). Списывание usage выполняется в +/// gRPC-адаптерах (TokenUsageRecorder) и флаги при пересечении уже могут стоять — TryMark*-CAS не даёт +/// задвоить тост. Публикация в канал тенанта; без подписчиков — no-op, не падает (Ruling 5). +/// +/// Лимиты живут в публичной схеме (ITenantLimitStore → DealDbContext) — в отличие от StorageTickScheduler +/// tenant-контекст проходу не нужен (SetTenant не выполняется); проверка каждого тенанта — в собственном scope +/// (scoped-хранилище лимитов). Первый проход — сразу после старта, далее по таймеру; перекрывающиеся проходы +/// исключены in-flight guard (Interlocked, как RatesRefreshScheduler). Ошибки логируются и наружу не выбрасываются +/// (проверка одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий проход +/// отменяется (graceful). +/// +/// +public sealed class BudgetAlertScheduler : IHostedService +{ + /// + /// Период проходов цикла — 60 с (план Task 9: фоновая проверка порогов бюджета). + /// + public const int AlertPeriodSeconds = 60; + + // Тип SSE-события тоста (Ruling 5/11; api.js слушает 'toast', новых типов не вводим). + private const string ToastEventType = "toast"; + + // Текст тоста пересечения порога 80% (Ruling 3). + private const string Warned80ToastText = "ИИ-бюджет израсходован на 80%"; + + // Текст тоста исчерпания бюджета (Ruling 3). + private const string ExhaustedToastText = "ИИ-бюджет исчерпан — обработка в локальном режиме"; + + // Иконка тостов бюджета (Ruling 3: bell). + 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, незавершённый проход безопасно завершится на отменённом токене. + } + } + } + + /// + /// Один проход цикла: список тенантов реестра и проверка порогов каждого (no-op, если проход идёт). + /// + /// Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются); таймер + /// вызывает этот же метод. Ошибки и отмена токена наружу не выбрасываются: сбои логируются (цикл живёт), + /// отмена по токену останова завершает проход штатно. + /// Токен отмены прохода (в проде — токен остановки хоста). + /// Задача прохода (завершается без исключений). + 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(); + + // TryMark* — атомарная установка флага: true только в момент первого наблюдения порога за период + // (Task 8/9). Тост публикуется ровно один раз на порог; повторные проходы — false → no-op. + 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 a2d3a3d..ca6cbde 100644 --- a/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs +++ b/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs @@ -1,11 +1,5 @@ using Deal.Api.Configuration; using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Api.Services; -using Deal.Api.Dtos; namespace Deal.Api.Hosting; diff --git a/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs b/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs index 1da90c2..0eec3f0 100644 --- a/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs +++ b/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs @@ -1,196 +1,187 @@ -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.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Hosting; - -/// -/// Фоновый цикл Discovery-воркера по всем тенантам (план Task 18, Ruling 10; эталон PipelineWorkerScheduler/StorageTickScheduler). -/// -/// -/// Каждые 5 с (в прототипе — _discovery_loop main.py: tick раз в ~5 секунд) обходит ВСЕ тенанты -/// системного реестра и для каждого выполняет один тик в -/// собственном scope с ITenantContext.SetTenant (эталон StorageTickScheduler). Тик делает ОДНО действие -/// (поиск/оценка/вступление/done) для самой старой running-задачи тенанта — 1:1 с discovery_worker.tick L444–484. -/// Как StorageTickScheduler: первый проход — сразу после старта, далее по таймеру; перекрывающиеся проходы -/// исключены in-flight guard (Interlocked) — следующее срабатывание пропускается, если проход длится дольше -/// периода (в частности, при паузах авто-вступлений 50–70 с внутри тика). Ошибки логируются и наружу не -/// выбрасываются (тик одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий -/// проход отменяется (graceful). Пустой проход (нет running-задач/пауза/flood) — тихий no-op (action none). -/// -public sealed class DiscoveryWorkerScheduler : IHostedService -{ - // Период тиков цикла — 5 с (в прототипе _discovery_loop: tick каждые ~5 секунд). - 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, незавершённый проход безопасно завершится на отменённом токене. - } - } - } - - /// - /// Один проход цикла: список тенантов реестра и тик каждого (no-op, если проход уже идёт). - /// - /// Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются) и - /// ручного вызова при отладке; таймер вызывает этот же метод. Ошибки и отмена токена наружу не - /// выбрасываются: сбои логируются (цикл живёт), отмена по токену останова завершает проход штатно. - /// Токен отмены прохода (в проде — токен остановки хоста). - /// Задача прохода (завершается без исключений). - 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-воркера по всем тенантам (план Task 18, Ruling 10; эталон PipelineWorkerScheduler/StorageTickScheduler). +/// +/// +/// Каждые 5 с (в прототипе — _discovery_loop main.py: tick раз в ~5 секунд) обходит ВСЕ тенанты +/// системного реестра и для каждого выполняет один тик в +/// собственном scope с ITenantContext.SetTenant (эталон StorageTickScheduler). Тик делает ОДНО действие +/// (поиск/оценка/вступление/done) для самой старой running-задачи тенанта — 1:1 с discovery_worker.tick L444–484. +/// Как StorageTickScheduler: первый проход — сразу после старта, далее по таймеру; перекрывающиеся проходы +/// исключены in-flight guard (Interlocked) — следующее срабатывание пропускается, если проход длится дольше +/// периода (в частности, при паузах авто-вступлений 50–70 с внутри тика). Ошибки логируются и наружу не +/// выбрасываются (тик одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий +/// проход отменяется (graceful). Пустой проход (нет running-задач/пауза/flood) — тихий no-op (action none). +/// +public sealed class DiscoveryWorkerScheduler : IHostedService +{ + // Период тиков цикла — 5 с (в прототипе _discovery_loop: tick каждые ~5 секунд). + 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, незавершённый проход безопасно завершится на отменённом токене. + } + } + } + + /// + /// Один проход цикла: список тенантов реестра и тик каждого (no-op, если проход уже идёт). + /// + /// Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются) и + /// ручного вызова при отладке; таймер вызывает этот же метод. Ошибки и отмена токена наружу не + /// выбрасываются: сбои логируются (цикл живёт), отмена по токену останова завершает проход штатно. + /// Токен отмены прохода (в проде — токен остановки хоста). + /// Задача прохода (завершается без исключений). + 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 95bc71f..11e749c 100644 --- a/src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs +++ b/src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs @@ -1,245 +1,232 @@ -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Hosting; - -/// -/// Фоновый флашер очереди обучения ML — выгрузка MlOutbox в ml-service батчами (план Task 16, Ruling 6). -/// -/// -/// Аналог _ml_sync_loop python-прототипа и flush_outbox (ml_client.py L56–82): каждые 10 с -/// обходит ВСЕ тенанты реестра и в собственном scope с ITenantContext.SetTenant (эталон -/// PipelineWorkerScheduler/StorageTickScheduler) отправляет накопленное обучение RPC TrainBatch порциями по -/// 10 строк, ≤100 за цикл. Строки удаляются ТОЛЬКО после успешного батча (python L78–79); при недоступности -/// ml-service порция остаётся и уходит в следующий цикл (ретрай на каждом тике, «строки остаются» — Ruling 6). -/// -/// Регистрируется в Deal.Api только при Services:Ml:UseLocal=false (gRPC-режим): Local-режиму -/// ml-service не нужен — очередь копится (этап 3), а при «поднятом сервисе» флашер выгружает её сразу. -/// Первый проход — сразу после старта (как PipelineWorkerScheduler); перекрывающиеся проходы исключены -/// in-flight guard (Interlocked). Ошибки логируются и наружу не выбрасываются (флаш одного тенанта не -/// валит цикл — остальные тенанты обрабатываются); при остановке хоста таймер останавливается и текущий -/// проход отменяется (graceful). Пустая очередь — тихий no-op. -/// -/// -public sealed class MlOutboxFlushScheduler : IHostedService -{ - /// - /// Период циклов выгрузки — 10 с (Ruling 6). - /// - public const int FlushPeriodSeconds = 10; - - /// - /// Размер порции за один TrainBatch — 10 строк (ml_client.flush_outbox L63: chunk=10). - /// - public const int BatchSize = 10; - - /// - /// Потолок выгрузки за один цикл тенанта — 100 строк (ml_client.flush_outbox L56: batch=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, незавершённый проход безопасно завершится на отменённом токене. - } - } - } - - /// - /// Один проход цикла: список тенантов реестра и выгрузка каждого (no-op, если проход уже идёт). - /// - /// Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются); - /// таймер вызывает этот же метод. Ошибки и отмена токена наружу не выбрасываются: сбои логируются - /// (цикл живёт), отмена по токену останова завершает проход штатно. - /// Токен отмены прохода (в проде — токен остановки хоста). - /// Задача прохода (завершается без исключений). - 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); - } - } - - // Выгрузка очереди одного тенанта в собственном scope: SetTenant → порции по 10 до ≤100/цикл. - // Строки удаляются только после успешного TrainBatch (Ruling 6); сбой батча — порция остаётся, - // цикл тенанта завершается (следующая попытка — следующий тик). Сбой хранилища тенанта не валит проход: - // ошибка логируется, остальные тенанты обрабатываются; отмена (OCE) пробрасывается наверх. - // tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>). - // ct: Токен отмены прохода. - 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) - { - // Недоступность/сбой ml-service: порция остаётся в очереди (python L75–77), следующая - // попытка — на следующем тике; флашер не роняет проход цикла. - _logger.LogWarning( - exception, - "Флашер ML-outbox: отправка {RowCount} строк тенанта {TenantId} не удалась — строки остались", - rows.Count, - tenant.Id); - break; - } - - // Удаление только после успеха (python L78–79): отправленные строки больше не нужны. - 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 батчами (план Task 16, Ruling 6). +/// +/// +/// Аналог _ml_sync_loop python-прототипа и flush_outbox (ml_client.py L56–82): каждые 10 с +/// обходит ВСЕ тенанты реестра и в собственном scope с ITenantContext.SetTenant (эталон +/// PipelineWorkerScheduler/StorageTickScheduler) отправляет накопленное обучение RPC TrainBatch порциями по +/// 10 строк, ≤100 за цикл. Строки удаляются ТОЛЬКО после успешного батча (python L78–79); при недоступности +/// ml-service порция остаётся и уходит в следующий цикл (ретрай на каждом тике, «строки остаются» — Ruling 6). +/// +/// Регистрируется в Deal.Api только при Services:Ml:UseLocal=false (gRPC-режим): Local-режиму +/// ml-service не нужен — очередь копится (этап 3), а при «поднятом сервисе» флашер выгружает её сразу. +/// Первый проход — сразу после старта (как PipelineWorkerScheduler); перекрывающиеся проходы исключены +/// in-flight guard (Interlocked). Ошибки логируются и наружу не выбрасываются (флаш одного тенанта не +/// валит цикл — остальные тенанты обрабатываются); при остановке хоста таймер останавливается и текущий +/// проход отменяется (graceful). Пустая очередь — тихий no-op. +/// +/// +public sealed class MlOutboxFlushScheduler : IHostedService +{ + /// + /// Период циклов выгрузки — 10 с (Ruling 6). + /// + public const int FlushPeriodSeconds = 10; + + /// + /// Размер порции за один TrainBatch — 10 строк (ml_client.flush_outbox L63: chunk=10). + /// + public const int BatchSize = 10; + + /// + /// Потолок выгрузки за один цикл тенанта — 100 строк (ml_client.flush_outbox L56: batch=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, незавершённый проход безопасно завершится на отменённом токене. + } + } + } + + /// + /// Один проход цикла: список тенантов реестра и выгрузка каждого (no-op, если проход уже идёт). + /// + /// Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются); + /// таймер вызывает этот же метод. Ошибки и отмена токена наружу не выбрасываются: сбои логируются + /// (цикл живёт), отмена по токену останова завершает проход штатно. + /// Токен отмены прохода (в проде — токен остановки хоста). + /// Задача прохода (завершается без исключений). + 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); + } + } + + // Выгрузка очереди одного тенанта в собственном scope: SetTenant → порции по 10 до ≤100/цикл. + // Строки удаляются только после успешного TrainBatch (Ruling 6); сбой батча — порция остаётся, + // цикл тенанта завершается (следующая попытка — следующий тик). Сбой хранилища тенанта не валит проход: + // ошибка логируется, остальные тенанты обрабатываются; отмена (OCE) пробрасывается наверх. + // tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>). + // ct: Токен отмены прохода. + 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) + { + // Недоступность/сбой ml-service: порция остаётся в очереди (python L75–77), следующая + // попытка — на следующем тике; флашер не роняет проход цикла. + _logger.LogWarning( + exception, + "Флашер ML-outbox: отправка {RowCount} строк тенанта {TenantId} не удалась — строки остались", + rows.Count, + tenant.Id); + break; + } + + // Удаление только после успеха (python L78–79): отправленные строки больше не нужны. + 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/OperatorBootstrapHostedService.cs b/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs index 5f0c77d..120c196 100644 --- a/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs +++ b/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs @@ -1,102 +1,96 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Hosting; - -/// -/// Hosted-шаг bootstrap оператора при старте (Ruling 1 этапа 7): env DEAL_OPERATOR_* → OperatorBootstrapService.EnsureOperatorAsync. -/// -/// -/// Регистрируется после и работает тем же паттерном: OperatorBootstrapService — -/// scoped (его IOperatorAuthStore живёт на scoped DealDbContext), поэтому резолвится в собственном scope из -/// IServiceScopeFactory (как TenantService в TenantBootstrapService). Идемпотентен: существующего оператора -/// не пересоздаёт и пароль не перезаписывает. В Development без env-кред используется dev-дефолт -/// operator/operator (зеркало dev-seed admin/admin, логируется как dev-режим); в Production без env-кред -/// шаг пропускается с warning — оператора заводит админ позже через env и рестарт хоста (кода регистрации -/// оператора нет). Частичная конфигурация (задана ровно одна из DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD) -/// логируется warning — не молчаливый дефолт (решение ревью Task 2): в Development используются dev-дефолты, -/// в Production шаг пропускается. Секреты (пароли) в логи не пишутся (правило этапов 1–6). -/// -public sealed class OperatorBootstrapHostedService( - IServiceScopeFactory scopeFactory, - IConfiguration configuration, - IHostEnvironment environment, - ILogger logger) : IHostedService -{ - /// - public async Task StartAsync(CancellationToken ct) - { - await using var scope = scopeFactory.CreateAsyncScope(); - var operatorBootstrapService = scope.ServiceProvider.GetRequiredService(); - - var login = configuration[OperatorBootstrapService.LoginEnvKey]; - var password = configuration[OperatorBootstrapService.PasswordEnvKey]; - bool hasLogin = !string.IsNullOrWhiteSpace(login); - bool hasPassword = !string.IsNullOrWhiteSpace(password); - bool allowDevelopmentDefaults = environment.IsDevelopment(); - - // Предупреждения о пропуске/частичной конфигурации логируются ДО вызова шага — сам шаг - // в этих случаях ничего не создаёт (EnsureOperatorAsync возвращает null, хранилище не тронуто). - if (!hasLogin || !hasPassword) - { - if (hasLogin != hasPassword) - { - LogPartialConfigurationWarning(hasLogin, allowDevelopmentDefaults); - } - else if (!allowDevelopmentDefaults) - { - // Production без кред: пропуск с warning (Ruling 1) — кода регистрации оператора нет. - logger.LogWarning( - "DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Production) — bootstrap оператора " - + "пропущен. Оператор заводится позже: задайте env DEAL_OPERATOR_* и перезапустите хост."); - } - else - { - // Development без кред: штатный dev-дефолт (зеркало dev-seed admin/admin). - logger.LogInformation( - "DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Development) — используется " - + "dev-дефолт оператора {DefaultLogin}.", - OperatorBootstrapService.DefaultOperatorLogin); - } - } - - var ensuredLogin = await operatorBootstrapService.EnsureOperatorAsync( - login, password, allowDevelopmentDefaults, ct); - if (ensuredLogin is not null) - { - logger.LogInformation("Bootstrap оператора: оператор {Login} присутствует.", ensuredLogin); - } - } - - /// - public Task StopAsync(CancellationToken ct) => Task.CompletedTask; - - // Логирует warning о неполной env-конфигурации (задана одна из двух переменных). - // hasLogin: Задан ли логин (пароль при этом пуст). - // allowDevelopmentDefaults: Разрешены ли dev-дефолты (Development). - private void LogPartialConfigurationWarning(bool hasLogin, bool allowDevelopmentDefaults) - { - string missingEnv = hasLogin - ? OperatorBootstrapService.PasswordEnvKey - : OperatorBootstrapService.LoginEnvKey; - if (allowDevelopmentDefaults) - { - logger.LogWarning( - "Env-конфигурация оператора неполна: не задан {MissingEnv}. В Development используются " - + "dev-дефолты оператора {DefaultLogin}.", - missingEnv, OperatorBootstrapService.DefaultOperatorLogin); - } - else - { - logger.LogWarning( - "Env-конфигурация оператора неполна: не задан {MissingEnv}. Bootstrap оператора в Production " - + "пропущен — оператор заводится позже через env DEAL_OPERATOR_* и рестарт хоста.", - missingEnv); - } - } -} +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Api.Hosting; + +/// +/// Hosted-шаг bootstrap оператора при старте (Ruling 1 этапа 7): env DEAL_OPERATOR_* → OperatorBootstrapService.EnsureOperatorAsync. +/// +/// +/// Регистрируется после и работает тем же паттерном: OperatorBootstrapService — +/// scoped (его IOperatorAuthStore живёт на scoped DealDbContext), поэтому резолвится в собственном scope из +/// IServiceScopeFactory (как TenantService в TenantBootstrapService). Идемпотентен: существующего оператора +/// не пересоздаёт и пароль не перезаписывает. В Development без env-кред используется dev-дефолт +/// operator/operator (зеркало dev-seed admin/admin, логируется как dev-режим); в Production без env-кред +/// шаг пропускается с warning — оператора заводит админ позже через env и рестарт хоста (кода регистрации +/// оператора нет). Частичная конфигурация (задана ровно одна из DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD) +/// логируется warning — не молчаливый дефолт (решение ревью Task 2): в Development используются dev-дефолты, +/// в Production шаг пропускается. Секреты (пароли) в логи не пишутся (правило этапов 1–6). +/// +public sealed class OperatorBootstrapHostedService( + IServiceScopeFactory scopeFactory, + IConfiguration configuration, + IHostEnvironment environment, + ILogger logger) : IHostedService +{ + /// + public async Task StartAsync(CancellationToken ct) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var operatorBootstrapService = scope.ServiceProvider.GetRequiredService(); + + var login = configuration[OperatorBootstrapService.LoginEnvKey]; + var password = configuration[OperatorBootstrapService.PasswordEnvKey]; + bool hasLogin = !string.IsNullOrWhiteSpace(login); + bool hasPassword = !string.IsNullOrWhiteSpace(password); + bool allowDevelopmentDefaults = environment.IsDevelopment(); + + // Предупреждения о пропуске/частичной конфигурации логируются ДО вызова шага — сам шаг + // в этих случаях ничего не создаёт (EnsureOperatorAsync возвращает null, хранилище не тронуто). + if (!hasLogin || !hasPassword) + { + if (hasLogin != hasPassword) + { + LogPartialConfigurationWarning(hasLogin, allowDevelopmentDefaults); + } + else if (!allowDevelopmentDefaults) + { + // Production без кред: пропуск с warning (Ruling 1) — кода регистрации оператора нет. + logger.LogWarning( + "DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Production) — bootstrap оператора " + + "пропущен. Оператор заводится позже: задайте env DEAL_OPERATOR_* и перезапустите хост."); + } + else + { + // Development без кред: штатный dev-дефолт (зеркало dev-seed admin/admin). + logger.LogInformation( + "DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Development) — используется " + + "dev-дефолт оператора {DefaultLogin}.", + OperatorBootstrapService.DefaultOperatorLogin); + } + } + + var ensuredLogin = await operatorBootstrapService.EnsureOperatorAsync( + login, password, allowDevelopmentDefaults, ct); + if (ensuredLogin is not null) + { + logger.LogInformation("Bootstrap оператора: оператор {Login} присутствует.", ensuredLogin); + } + } + + /// + public Task StopAsync(CancellationToken ct) => Task.CompletedTask; + + // Логирует warning о неполной env-конфигурации (задана одна из двух переменных). + // hasLogin: Задан ли логин (пароль при этом пуст). + // allowDevelopmentDefaults: Разрешены ли dev-дефолты (Development). + private void LogPartialConfigurationWarning(bool hasLogin, bool allowDevelopmentDefaults) + { + string missingEnv = hasLogin + ? OperatorBootstrapService.PasswordEnvKey + : OperatorBootstrapService.LoginEnvKey; + if (allowDevelopmentDefaults) + { + logger.LogWarning( + "Env-конфигурация оператора неполна: не задан {MissingEnv}. В Development используются " + + "dev-дефолты оператора {DefaultLogin}.", + missingEnv, OperatorBootstrapService.DefaultOperatorLogin); + } + else + { + logger.LogWarning( + "Env-конфигурация оператора неполна: не задан {MissingEnv}. Bootstrap оператора в Production " + + "пропущен — оператор заводится позже через env DEAL_OPERATOR_* и рестарт хоста.", + missingEnv); + } + } +} diff --git a/src/core/Deal.Api/Hosting/StorageTickScheduler.cs b/src/core/Deal.Api/Hosting/StorageTickScheduler.cs index e732e7e..0f65fe8 100644 --- a/src/core/Deal.Api/Hosting/StorageTickScheduler.cs +++ b/src/core/Deal.Api/Hosting/StorageTickScheduler.cs @@ -1,252 +1,241 @@ -using Deal.Api.Events; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Hosting; - -/// -/// Фоновый цикл правил хранения по всем тенантам (план Tasks 11–12, Rulings 3/8; аналог _storage_loop main.py L43–53). -/// -/// -/// Каждые 30 с (в прототипе — asyncio.sleep(30), main.py L53) обходит ВСЕ тенанты системного -/// реестра. Проход открывает собственный scope (реестр живёт в публичной схеме — ITenantRepository вне -/// tenant-контекста, паттерн TenantBootstrapService), на каждый тенант — вложенный scope с -/// ITenantContext.SetTenant (эталон SessionMiddleware/TenantBootstrapService) и StorageTickService.TickAsync, -/// после чего — автоочистка отсева пайплайна (, 3 суток; -/// Task 11, Ruling 8/9: фоновый аналог тика AdminTickOrchestrator, tick_storage L485–493), SSE-тосты -/// статистики в канал тенанта (StorageToastPublisher; без подписчиков — no-op, Ruling 5) и проверка -/// наступивших напоминаний «Отложено» (CardsService.CheckDueRemindersAsync + SSE reminder_due, Task 12, -/// Ruling 3/8 — фоновый аналог ветки AdminTickOrchestrator, check_reminders из _storage_loop main.py L49). -/// Порядок тика тенанта 1:1 с _storage_loop main.py L47–53: тик → тосты → напоминания. Как в прототипе, -/// первый проход выполняется сразу после старта (тик до первого sleep), далее — по таймеру. Параллельные -/// проходы исключены in-flight guard (Interlocked, как RatesRefreshScheduler): если проход длится дольше -/// периода, следующее срабатывание таймера пропускается. Ошибки логируются и наружу не выбрасываются -/// (тик одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий проход -/// отменяется (graceful). -/// -public sealed class StorageTickScheduler : IHostedService -{ - // Период проходов цикла — 30 с, 1:1 с _storage_loop main.py L53 (asyncio.sleep(30)). - private const int TickPeriodSeconds = 30; - - // SSE-тип события «выстрелившего» напоминания «Отложено» (Ruling 8, api-map §2: {id,title,containerId}). - 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 «выстреливших» напоминаний в канал тенанта - /// (как StorageToastPublisher; без подписчиков публикация — no-op, Ruling 5). - /// Логгер ошибок цикла. - 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) - { - // Первый проход — сразу после старта (в прототипе тик выполняется до первого sleep); далее каждые 30 с. - _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, незавершённый проход безопасно завершится на отменённом токене. - } - } - } - - /// - /// Один проход цикла: список тенантов реестра и тик каждого (no-op, если проход уже идёт). - /// - /// Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются) и - /// ручного вызова при отладке; таймер вызывает этот же метод. Ошибки и отмена токена наружу не - /// выбрасываются: сбои логируются (цикл живёт), отмена по токену останова завершает проход штатно. - /// Токен отмены прохода (в проде — токен остановки хоста). - /// Задача прохода (завершается без исключений). - 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); - } - } - - // Тик одного тенанта в собственном scope: SetTenant → Kanban-тик → purge отсева → SSE-тосты → - // проверка напоминаний + SSE reminder_due; Reset в finally (1:1 с _storage_loop main.py L47–53: тик → - // тосты → напоминания). - // Контекст AsyncLocal сбрасывается в finally, чтобы не переживать scope тенанта (как - // SessionMiddleware). Тик одного тенанта не валит проход: ошибка ветки/тенанта логируется, остальные - // тенанты обрабатываются; отмена (OCE) пробрасывается наверх — проход завершается. - // 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 (и его адаптеры) строятся от схемы текущего тенанта. - StorageTickService tickService = tenantScope.ServiceProvider.GetRequiredService(); - StorageTickStatsDto stats = await tickService.TickAsync(ct); - - // Автоочистка отсева пайплайна: записи старше 3 суток — безвозвратно (Task 11, Ruling 8/9; - // tick_storage L485–493). Счётчик вливается в storage.purgedRejected — как ручной тик - // (AdminTickOrchestrator), тост «Отсев очищен: N записей (3 дн.)» публикуется этой же веткой. - PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService(); - int purgedRejected = await processing.PurgeExpiredAsync(ct); - StorageTickStatsDto mergedStats = stats with { PurgedRejected = purgedRejected }; - - // Тосты — в канал тенанта (публикация из Api-слоя, Ruling 5; без подписчиков — no-op). - _toastPublisher.PublishTickToasts(tenant.Id, mergedStats); - - // Проверка наступивших напоминаний «Отложено» (Task 12, Ruling 3/8; proj_svc.check_reminders из - // _storage_loop main.py L49): CheckDueRemindersAsync помечает due-строки hold-карточек fired и - // возвращает их {id,title,containerId}. Сбой проверки НЕ роняет тик тенанта/проход: лог-предупреждение, - // остальные тенанты обрабатываются (паттерн ветки AdminTickOrchestrator). SSE reminder_due по каждой - // записи — в канал тенанта (Ruling 8: публикации только из Api; toast НЕ шлём, без подписчиков — no-op). - 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; + +/// +/// Фоновый цикл правил хранения по всем тенантам (план Tasks 11–12, Rulings 3/8; аналог _storage_loop main.py L43–53). +/// +/// +/// Каждые 30 с (в прототипе — asyncio.sleep(30), main.py L53) обходит ВСЕ тенанты системного +/// реестра. Проход открывает собственный scope (реестр живёт в публичной схеме — ITenantRepository вне +/// tenant-контекста, паттерн TenantBootstrapService), на каждый тенант — вложенный scope с +/// ITenantContext.SetTenant (эталон SessionMiddleware/TenantBootstrapService) и StorageTickService.TickAsync, +/// после чего — автоочистка отсева пайплайна (, 3 суток; +/// Task 11, Ruling 8/9: фоновый аналог тика AdminTickOrchestrator, tick_storage L485–493), SSE-тосты +/// статистики в канал тенанта (StorageToastPublisher; без подписчиков — no-op, Ruling 5) и проверка +/// наступивших напоминаний «Отложено» (CardsService.CheckDueRemindersAsync + SSE reminder_due, Task 12, +/// Ruling 3/8 — фоновый аналог ветки AdminTickOrchestrator, check_reminders из _storage_loop main.py L49). +/// Порядок тика тенанта 1:1 с _storage_loop main.py L47–53: тик → тосты → напоминания. Как в прототипе, +/// первый проход выполняется сразу после старта (тик до первого sleep), далее — по таймеру. Параллельные +/// проходы исключены in-flight guard (Interlocked, как RatesRefreshScheduler): если проход длится дольше +/// периода, следующее срабатывание таймера пропускается. Ошибки логируются и наружу не выбрасываются +/// (тик одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий проход +/// отменяется (graceful). +/// +public sealed class StorageTickScheduler : IHostedService +{ + // Период проходов цикла — 30 с, 1:1 с _storage_loop main.py L53 (asyncio.sleep(30)). + private const int TickPeriodSeconds = 30; + + // SSE-тип события «выстрелившего» напоминания «Отложено» (Ruling 8, api-map §2: {id,title,containerId}). + 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 «выстреливших» напоминаний в канал тенанта + /// (как StorageToastPublisher; без подписчиков публикация — no-op, Ruling 5). + /// Логгер ошибок цикла. + 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) + { + // Первый проход — сразу после старта (в прототипе тик выполняется до первого sleep); далее каждые 30 с. + _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, незавершённый проход безопасно завершится на отменённом токене. + } + } + } + + /// + /// Один проход цикла: список тенантов реестра и тик каждого (no-op, если проход уже идёт). + /// + /// Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются) и + /// ручного вызова при отладке; таймер вызывает этот же метод. Ошибки и отмена токена наружу не + /// выбрасываются: сбои логируются (цикл живёт), отмена по токену останова завершает проход штатно. + /// Токен отмены прохода (в проде — токен остановки хоста). + /// Задача прохода (завершается без исключений). + 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); + } + } + + // Тик одного тенанта в собственном scope: SetTenant → Kanban-тик → purge отсева → SSE-тосты → + // проверка напоминаний + SSE reminder_due; Reset в finally (1:1 с _storage_loop main.py L47–53: тик → + // тосты → напоминания). + // Контекст AsyncLocal сбрасывается в finally, чтобы не переживать scope тенанта (как + // SessionMiddleware). Тик одного тенанта не валит проход: ошибка ветки/тенанта логируется, остальные + // тенанты обрабатываются; отмена (OCE) пробрасывается наверх — проход завершается. + // 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 (и его адаптеры) строятся от схемы текущего тенанта. + StorageTickService tickService = tenantScope.ServiceProvider.GetRequiredService(); + StorageTickStatsDto stats = await tickService.TickAsync(ct); + + // Автоочистка отсева пайплайна: записи старше 3 суток — безвозвратно (Task 11, Ruling 8/9; + // tick_storage L485–493). Счётчик вливается в storage.purgedRejected — как ручной тик + // (AdminTickOrchestrator), тост «Отсев очищен: N записей (3 дн.)» публикуется этой же веткой. + PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService(); + int purgedRejected = await processing.PurgeExpiredAsync(ct); + StorageTickStatsDto mergedStats = stats with { PurgedRejected = purgedRejected }; + + // Тосты — в канал тенанта (публикация из Api-слоя, Ruling 5; без подписчиков — no-op). + _toastPublisher.PublishTickToasts(tenant.Id, mergedStats); + + // Проверка наступивших напоминаний «Отложено» (Task 12, Ruling 3/8; proj_svc.check_reminders из + // _storage_loop main.py L49): CheckDueRemindersAsync помечает due-строки hold-карточек fired и + // возвращает их {id,title,containerId}. Сбой проверки НЕ роняет тик тенанта/проход: лог-предупреждение, + // остальные тенанты обрабатываются (паттерн ветки AdminTickOrchestrator). SSE reminder_due по каждой + // записи — в канал тенанта (Ruling 8: публикации только из Api; toast НЕ шлём, без подписчиков — no-op). + 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/Hosting/TenantBootstrapService.cs b/src/core/Deal.Api/Hosting/TenantBootstrapService.cs index 2f67f6f..3f0e330 100644 --- a/src/core/Deal.Api/Hosting/TenantBootstrapService.cs +++ b/src/core/Deal.Api/Hosting/TenantBootstrapService.cs @@ -1,99 +1,95 @@ -using Deal.Infrastructure.Tenancy; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Hosting; - -/// -/// Bootstrap при старте (Ruling 8): дефолтный тенант + admin (dev-only), провижининг схем всех тенантов. -/// -/// -/// Идемпотентен. Dev-seed дефолтного тенанта (фиксированный id) и пользователя admin из env -/// DEAL_BOOTSTRAP_LOGIN/DEAL_BOOTSTRAP_PASSWORD (по умолчанию admin/admin) выполняется только в Development -/// или при DEAL_BOOTSTRAP_DEFAULT_TENANT=1 (Ruling 1 этапа 7); в Production тенантов заводит оператор. -/// Провижининг схем ВСЕХ тенантов реестра выполняется всегда. Работает только через порты модулей и -/// IPasswordHasher — без DealDbContext. -/// -public sealed class TenantBootstrapService(IServiceScopeFactory scopeFactory) : IHostedService -{ - // Имя дефолтного тенанта, если реестр пуст (Ruling 8). - private const string DefaultTenantName = "Default"; - - // Фиксированный id дефолтного тенанта: схема tenant_000...0001 детерминирована (Ruling 8). - private static readonly Guid DefaultTenantId = Guid.Parse("00000000-0000-0000-0000-000000000001"); - - private const string ActiveStatus = "active"; - private const string BootstrapLoginEnvKey = "DEAL_BOOTSTRAP_LOGIN"; - private const string BootstrapPasswordEnvKey = "DEAL_BOOTSTRAP_PASSWORD"; - private const string DefaultAdminLogin = "admin"; - private const string DefaultAdminPassword = "admin"; - - // Ключ env-флага принудительного dev-seed в не-Development окружениях (Ruling 1 этапа 7). - private const string DefaultTenantBootstrapEnvKey = "DEAL_BOOTSTRAP_DEFAULT_TENANT"; - - // Значение «включено» env-флага (Ruling 1: =1). - private const string DefaultTenantBootstrapEnabledValue = "1"; - - /// - public async Task StartAsync(CancellationToken ct) - { - await using var scope = scopeFactory.CreateAsyncScope(); - var configuration = scope.ServiceProvider.GetRequiredService(); - var tenantService = scope.ServiceProvider.GetRequiredService(); - var tenantRepository = scope.ServiceProvider.GetRequiredService(); - var authStore = scope.ServiceProvider.GetRequiredService(); - var passwordHasher = scope.ServiceProvider.GetRequiredService(); - - var login = NormalizeLogin(configuration[BootstrapLoginEnvKey] ?? DefaultAdminLogin); - var password = configuration[BootstrapPasswordEnvKey] ?? DefaultAdminPassword; - var environment = scope.ServiceProvider.GetRequiredService(); - - // Dev-seed дефолтного тенанта + admin становится dev-only (Ruling 1 этапа 7): создаётся только - // в Development или при DEAL_BOOTSTRAP_DEFAULT_TENANT=1. В Production тенантов заводит оператор. - var seedDefaultTenant = environment.IsDevelopment() - || configuration[DefaultTenantBootstrapEnvKey] == DefaultTenantBootstrapEnabledValue; - if (seedDefaultTenant) - { - var tenants = await tenantRepository.ListAsync(ct); - if (tenants.Count == 0) - { - // CreateTenantAsync сам провижинит схему дефолтного тенанта (TenantService → ITenantProvisioner). - await tenantService.CreateTenantAsync(DefaultTenantName, DefaultTenantId, ct); - } - - // Идемпотентность: пользователь с таким логином уже есть — ничего не делаем. - var existingUser = await authStore.FindUserByLoginAsync(login, ct); - if (existingUser is null) - { - await authStore.CreateUserAsync( - new StoredUserDto( - Id: Guid.NewGuid(), - Login: login, - TenantId: DefaultTenantId, - Status: ActiveStatus, - PasswordHash: passwordHasher.Hash(password)), - ct); - } - } - - // Провижининг схем ВСЕХ тенантов реестра выполняется всегда (Ruling 1 этапа 7), включая только - // что созданного в dev: пакетная идемпотентная миграция (CREATE SCHEMA IF NOT EXISTS + Migrate, - // применяющий только неприменённые миграции) с ограниченным параллелизмом и логированием прогресса — - // масштабируется на сотни/тысячи схем (этап 12, пакет C). Повторный старт безопасен. - var tenantSchemaMigrationService = scope.ServiceProvider.GetRequiredService(); - await tenantSchemaMigrationService.MigrateAllAsync(ct); - } - - /// - public Task StopAsync(CancellationToken ct) => Task.CompletedTask; - - // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения). - // login: Входной логин. - // Возвращает: Нормализованный логин. - private static string NormalizeLogin(string login) => login.Trim().ToLowerInvariant(); -} +using Deal.Infrastructure.Tenancy; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Api.Hosting; + +/// +/// Bootstrap при старте (Ruling 8): дефолтный тенант + admin (dev-only), провижининг схем всех тенантов. +/// +/// +/// Идемпотентен. Dev-seed дефолтного тенанта (фиксированный id) и пользователя admin из env +/// DEAL_BOOTSTRAP_LOGIN/DEAL_BOOTSTRAP_PASSWORD (по умолчанию admin/admin) выполняется только в Development +/// или при DEAL_BOOTSTRAP_DEFAULT_TENANT=1 (Ruling 1 этапа 7); в Production тенантов заводит оператор. +/// Провижининг схем ВСЕХ тенантов реестра выполняется всегда. Работает только через порты модулей и +/// IPasswordHasher — без DealDbContext. +/// +public sealed class TenantBootstrapService(IServiceScopeFactory scopeFactory) : IHostedService +{ + // Имя дефолтного тенанта, если реестр пуст (Ruling 8). + private const string DefaultTenantName = "Default"; + + // Фиксированный id дефолтного тенанта: схема tenant_000...0001 детерминирована (Ruling 8). + private static readonly Guid DefaultTenantId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + + private const string ActiveStatus = "active"; + private const string BootstrapLoginEnvKey = "DEAL_BOOTSTRAP_LOGIN"; + private const string BootstrapPasswordEnvKey = "DEAL_BOOTSTRAP_PASSWORD"; + private const string DefaultAdminLogin = "admin"; + private const string DefaultAdminPassword = "admin"; + + // Ключ env-флага принудительного dev-seed в не-Development окружениях (Ruling 1 этапа 7). + private const string DefaultTenantBootstrapEnvKey = "DEAL_BOOTSTRAP_DEFAULT_TENANT"; + + // Значение «включено» env-флага (Ruling 1: =1). + private const string DefaultTenantBootstrapEnabledValue = "1"; + + /// + public async Task StartAsync(CancellationToken ct) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var configuration = scope.ServiceProvider.GetRequiredService(); + var tenantService = scope.ServiceProvider.GetRequiredService(); + var tenantRepository = scope.ServiceProvider.GetRequiredService(); + var authStore = scope.ServiceProvider.GetRequiredService(); + var passwordHasher = scope.ServiceProvider.GetRequiredService(); + + var login = NormalizeLogin(configuration[BootstrapLoginEnvKey] ?? DefaultAdminLogin); + var password = configuration[BootstrapPasswordEnvKey] ?? DefaultAdminPassword; + var environment = scope.ServiceProvider.GetRequiredService(); + + // Dev-seed дефолтного тенанта + admin становится dev-only (Ruling 1 этапа 7): создаётся только + // в Development или при DEAL_BOOTSTRAP_DEFAULT_TENANT=1. В Production тенантов заводит оператор. + var seedDefaultTenant = environment.IsDevelopment() + || configuration[DefaultTenantBootstrapEnvKey] == DefaultTenantBootstrapEnabledValue; + if (seedDefaultTenant) + { + var tenants = await tenantRepository.ListAsync(ct); + if (tenants.Count == 0) + { + // CreateTenantAsync сам провижинит схему дефолтного тенанта (TenantService → ITenantProvisioner). + await tenantService.CreateTenantAsync(DefaultTenantName, DefaultTenantId, ct); + } + + // Идемпотентность: пользователь с таким логином уже есть — ничего не делаем. + var existingUser = await authStore.FindUserByLoginAsync(login, ct); + if (existingUser is null) + { + await authStore.CreateUserAsync( + new StoredUserDto( + Id: Guid.NewGuid(), + Login: login, + TenantId: DefaultTenantId, + Status: ActiveStatus, + PasswordHash: passwordHasher.Hash(password)), + ct); + } + } + + // Провижининг схем ВСЕХ тенантов реестра выполняется всегда (Ruling 1 этапа 7), включая только + // что созданного в dev: пакетная идемпотентная миграция (CREATE SCHEMA IF NOT EXISTS + Migrate, + // применяющий только неприменённые миграции) с ограниченным параллелизмом и логированием прогресса — + // масштабируется на сотни/тысячи схем (этап 12, пакет C). Повторный старт безопасен. + var tenantSchemaMigrationService = scope.ServiceProvider.GetRequiredService(); + await tenantSchemaMigrationService.MigrateAllAsync(ct); + } + + /// + public Task StopAsync(CancellationToken ct) => Task.CompletedTask; + + // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения). + // login: Входной логин. + // Возвращает: Нормализованный логин. + private static string NormalizeLogin(string login) => login.Trim().ToLowerInvariant(); +} diff --git a/src/core/Deal.Api/Logging/DealLogging.cs b/src/core/Deal.Api/Logging/DealLogging.cs index 2d406b4..e594149 100644 --- a/src/core/Deal.Api/Logging/DealLogging.cs +++ b/src/core/Deal.Api/Logging/DealLogging.cs @@ -1,126 +1,123 @@ -using Microsoft.Extensions.Configuration; -using Serilog; -using Serilog.Events; -using Serilog.Formatting.Compact; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Logging; - -// Serilog-конфигурация процесса core (Ruling 7/9, план Task 14). -// Все четыре процесса (core + telegram/ai/ml-сервисы) логируют через Serilog: консоль — JSON в -// prod-стиле (CompactJsonFormatter: одна JSON-строка на событие, поля @t/@mt/@l — парсинг -// Loki/Promtail) либо текст в Development; плюс rolling-файл data/logs/deal-<процесс>.json -// (под ContentRoot; у core каталог data смонтирован volume-ом, compose.prod). Уровень/каталог -// переопределяются env: DEAL_LOG_LEVEL, DEAL_LOGS_DIR. -// Конфигурация кодом, а не секцией appsettings: у трёх сервисов appsettings.json нет (весь конфиг — -// env, Ruling 13), поэтому единый для четырёх хостов код-набор с env-переопределениями дешевле и не -// расходится между процессами. Правило «секреты не логируются» (Ruling 13) соблюдается на уровне -// сообщений (в лог-конфигурации секретов нет; запрос-логи Task 14 логируют метод/путь/статус без -// query/заголовков/тел). OTel-метрики/трейсы и Prometheus в этапе 7 не добавляются (Ruling 7) — -// стек: Serilog-логи → docker-логи → Promtail → Loki → Grafana. -// Вызов — из Program.cs процесса (entry point): DealLogging.Configure(builder, "…") ДО -// builder.Build(). Интеграционные тесты сервисов поднимают хост через *ServiceHost.Create -// БЕЗ этого вызова (конфигурация логирования — забота production-точки входа), поэтому тесты не пишут -// файлы-логи и не меняют своё логирование. -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 к хосту (builder.Host.UseSerilog). Регистрация отложенная: конфигурация - /// логгера применяется при builder.Build(), когда среда/конфигурация (env) уже собраны. - /// - /// Билдер 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()) - { - // Dev: читаемый текст в консоли (Ruling 7: «dev можно текст»); файл — всегда JSON. - loggerConfiguration.WriteTo.Console(outputTemplate: DevelopmentConsoleTemplate); - } - else - { - // Prod-стиль: одна JSON-строка на событие — docker-логи собирает Promtail (Ruling 7/9). - 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; + +// Serilog-конфигурация процесса core (Ruling 7/9, план Task 14). +// Все четыре процесса (core + telegram/ai/ml-сервисы) логируют через Serilog: консоль — JSON в +// prod-стиле (CompactJsonFormatter: одна JSON-строка на событие, поля @t/@mt/@l — парсинг +// Loki/Promtail) либо текст в Development; плюс rolling-файл data/logs/deal-<процесс>.json +// (под ContentRoot; у core каталог data смонтирован volume-ом, compose.prod). Уровень/каталог +// переопределяются env: DEAL_LOG_LEVEL, DEAL_LOGS_DIR. +// Конфигурация кодом, а не секцией appsettings: у трёх сервисов appsettings.json нет (весь конфиг — +// env, Ruling 13), поэтому единый для четырёх хостов код-набор с env-переопределениями дешевле и не +// расходится между процессами. Правило «секреты не логируются» (Ruling 13) соблюдается на уровне +// сообщений (в лог-конфигурации секретов нет; запрос-логи Task 14 логируют метод/путь/статус без +// query/заголовков/тел). OTel-метрики/трейсы и Prometheus в этапе 7 не добавляются (Ruling 7) — +// стек: Serilog-логи → docker-логи → Promtail → Loki → Grafana. +// Вызов — из Program.cs процесса (entry point): DealLogging.Configure(builder, "…") ДО +// builder.Build(). Интеграционные тесты сервисов поднимают хост через *ServiceHost.Create +// БЕЗ этого вызова (конфигурация логирования — забота production-точки входа), поэтому тесты не пишут +// файлы-логи и не меняют своё логирование. +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 к хосту (builder.Host.UseSerilog). Регистрация отложенная: конфигурация + /// логгера применяется при builder.Build(), когда среда/конфигурация (env) уже собраны. + /// + /// Билдер 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()) + { + // Dev: читаемый текст в консоли (Ruling 7: «dev можно текст»); файл — всегда JSON. + loggerConfiguration.WriteTo.Console(outputTemplate: DevelopmentConsoleTemplate); + } + else + { + // Prod-стиль: одна JSON-строка на событие — docker-логи собирает Promtail (Ruling 7/9). + 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 c387696..919453a 100644 --- a/src/core/Deal.Api/Middleware/HttpAccessLogMiddleware.cs +++ b/src/core/Deal.Api/Middleware/HttpAccessLogMiddleware.cs @@ -1,100 +1,98 @@ -using System.Diagnostics; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Middleware; - -/// -/// Access-лог HTTP-запросов core (Ruling 7, план Task 14): каждый запрос — одна структурированная -/// строка «метод путь → статус за N мс» (Serilog JSON). Логируются метод и путь БЕЗ query-строки, -/// заголовков и тела — секреты/токены в query не попадают в логи (Ruling 13); клиентский IP не -/// логируется (аудит-IP живёт в AuditLog, операторский контур). -/// -/// -/// Регистрируется самым первым в HTTP-конвейере (после UseForwardedHeaders): видит результат всех -/// слоёв ниже (CORS/session/rate-limiter/OriginGuard/эндпоинты) и полную длительность запроса. -/// Запросы gRPC-ингресса (Content-Type application/grpc) пропускаются — их содержательный access-лог -/// пишет интерцептор RpcCallLoggingInterceptor (HTTP-статус gRPC-вызовов всегда 200, полезен только -/// gRPC-статус). SSE-подписка /api/events логируется по завершении потока (длительность = время жизни -/// соединения). Отмена запроса (клиент закрыл SSE/дисконнект) отдельной строкой не пишется. -/// -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-запросов. - /// - /// Следующий обработчик конвейера. - /// Логгер (Serilog, Ruling 7). - public HttpAccessLogMiddleware(RequestDelegate next, ILogger logger) - { - ArgumentNullException.ThrowIfNull(next); - ArgumentNullException.ThrowIfNull(logger); - _next = next; - _logger = logger; - } - - /// - /// Обрабатывает запрос: пропускает gRPC-ингресс, остальные логирует по завершении. - /// - /// Контекст запроса. - 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: Контекст запроса (метод/путь/статус ответа). - // 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 ?? "/"; - if (exception is null) - { - _logger.LogInformation( - "HTTP {Method} {Path} -> {StatusCode} за {DurationMs} мс", - method, - path, - context.Response.StatusCode, - elapsedMs); - } - else - { - _logger.LogError( - exception, - "HTTP {Method} {Path} -> исключение за {DurationMs} мс", - method, - path, - elapsedMs); - } - } -} +using System.Diagnostics; + +namespace Deal.Api.Middleware; + +/// +/// Access-лог HTTP-запросов core (Ruling 7, план Task 14): каждый запрос — одна структурированная +/// строка «метод путь → статус за N мс» (Serilog JSON). Логируются метод и путь БЕЗ query-строки, +/// заголовков и тела — секреты/токены в query не попадают в логи (Ruling 13); клиентский IP не +/// логируется (аудит-IP живёт в AuditLog, операторский контур). +/// +/// +/// Регистрируется самым первым в HTTP-конвейере (после UseForwardedHeaders): видит результат всех +/// слоёв ниже (CORS/session/rate-limiter/OriginGuard/эндпоинты) и полную длительность запроса. +/// Запросы gRPC-ингресса (Content-Type application/grpc) пропускаются — их содержательный access-лог +/// пишет интерцептор RpcCallLoggingInterceptor (HTTP-статус gRPC-вызовов всегда 200, полезен только +/// gRPC-статус). SSE-подписка /api/events логируется по завершении потока (длительность = время жизни +/// соединения). Отмена запроса (клиент закрыл SSE/дисконнект) отдельной строкой не пишется. +/// +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-запросов. + /// + /// Следующий обработчик конвейера. + /// Логгер (Serilog, Ruling 7). + public HttpAccessLogMiddleware(RequestDelegate next, ILogger logger) + { + ArgumentNullException.ThrowIfNull(next); + ArgumentNullException.ThrowIfNull(logger); + _next = next; + _logger = logger; + } + + /// + /// Обрабатывает запрос: пропускает gRPC-ингресс, остальные логирует по завершении. + /// + /// Контекст запроса. + 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: Контекст запроса (метод/путь/статус ответа). + // 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 ?? "/"; + if (exception is null) + { + _logger.LogInformation( + "HTTP {Method} {Path} -> {StatusCode} за {DurationMs} мс", + method, + path, + context.Response.StatusCode, + elapsedMs); + } + else + { + _logger.LogError( + exception, + "HTTP {Method} {Path} -> исключение за {DurationMs} мс", + method, + path, + elapsedMs); + } + } +} diff --git a/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs b/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs index 4881601..74d6aaa 100644 --- a/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs +++ b/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs @@ -1,62 +1,55 @@ -using Deal.Api.Configuration; -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.Extensions.Options; -using Deal.Api.Dtos; -// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. -using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions; - -namespace Deal.Api.Middleware; - -/// -/// Middleware операторской сессии: читает httpOnly-куку deal_operator_session, разрешает сессию через -/// OperatorAuthService и наполняет HttpContext.Items["CurrentOperator"] (Ruling 1 этапа 7). -/// -/// -/// Зеркало для операторов: отдельная кука и отдельный ключ Items — -/// операторская сессия не может подменить тенантную и наоборот (разные имена куки, разные middleware). -/// Tenant-контекст (ITenantContext/CurrentUser) middleware не трогает — оператор не принадлежит тенанту. -/// Middleware НЕ отвечает 401 сама (pass-through): ручки /api/operator/*, требующие оператора, проверяют -/// GetCurrentOperator() и выставляют 401. OperatorAuthService — scoped, поэтому на запрос -/// создаётся собственный scope через RequestServices (как в SessionMiddleware). -/// -public sealed class OperatorSessionMiddleware -{ - private readonly RequestDelegate _next; - private readonly IOptionsMonitor _cookieOptions; - - public OperatorSessionMiddleware(RequestDelegate next, IOptionsMonitor cookieOptions) - { - _next = next; - _cookieOptions = cookieOptions; - } - - /// - /// Обрабатывает запрос: разрешает операторскую сессию по куке и наполняет контекст. - /// - /// Контекст запроса. - public async Task InvokeAsync(HttpContext context) - { - var cookieName = _cookieOptions.CurrentValue.Name; - if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken) - && !string.IsNullOrWhiteSpace(rawToken)) - { - // OperatorAuthService scoped: создаём scope на запрос через RequestServices. - await using var scope = context.RequestServices.CreateAsyncScope(); - var operatorAuthService = scope.ServiceProvider.GetRequiredService(); - var identity = await operatorAuthService.ResolveSessionAsync(rawToken, context.RequestAborted); - if (identity is not null) - { - context.SetCurrentOperator(new CurrentOperator(identity.Id, identity.Login, identity.Status)); - } - } - - await _next(context); - } -} +using Deal.Api.Extensions; +using Deal.Api.Models; +using Deal.Modules.Tenants.Application.Services; +using Microsoft.Extensions.Options; +// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. +using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions; + +namespace Deal.Api.Middleware; + +/// +/// Middleware операторской сессии: читает httpOnly-куку deal_operator_session, разрешает сессию через +/// OperatorAuthService и наполняет HttpContext.Items["CurrentOperator"] (Ruling 1 этапа 7). +/// +/// +/// Зеркало для операторов: отдельная кука и отдельный ключ Items — +/// операторская сессия не может подменить тенантную и наоборот (разные имена куки, разные middleware). +/// Tenant-контекст (ITenantContext/CurrentUser) middleware не трогает — оператор не принадлежит тенанту. +/// Middleware НЕ отвечает 401 сама (pass-through): ручки /api/operator/*, требующие оператора, проверяют +/// GetCurrentOperator() и выставляют 401. OperatorAuthService — scoped, поэтому на запрос +/// создаётся собственный scope через RequestServices (как в SessionMiddleware). +/// +public sealed class OperatorSessionMiddleware +{ + private readonly RequestDelegate _next; + private readonly IOptionsMonitor _cookieOptions; + + public OperatorSessionMiddleware(RequestDelegate next, IOptionsMonitor cookieOptions) + { + _next = next; + _cookieOptions = cookieOptions; + } + + /// + /// Обрабатывает запрос: разрешает операторскую сессию по куке и наполняет контекст. + /// + /// Контекст запроса. + public async Task InvokeAsync(HttpContext context) + { + var cookieName = _cookieOptions.CurrentValue.Name; + if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken) + && !string.IsNullOrWhiteSpace(rawToken)) + { + // OperatorAuthService scoped: создаём scope на запрос через RequestServices. + await using var scope = context.RequestServices.CreateAsyncScope(); + var operatorAuthService = scope.ServiceProvider.GetRequiredService(); + var identity = await operatorAuthService.ResolveSessionAsync(rawToken, context.RequestAborted); + if (identity is not null) + { + context.SetCurrentOperator(new CurrentOperator(identity.Id, identity.Login, identity.Status)); + } + } + + await _next(context); + } +} diff --git a/src/core/Deal.Api/Middleware/OriginGuardMiddleware.cs b/src/core/Deal.Api/Middleware/OriginGuardMiddleware.cs index 010e477..716c4d5 100644 --- a/src/core/Deal.Api/Middleware/OriginGuardMiddleware.cs +++ b/src/core/Deal.Api/Middleware/OriginGuardMiddleware.cs @@ -1,99 +1,97 @@ -using Deal.Api.Configuration; -using Microsoft.Net.Http.Headers; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Middleware; - -/// -/// Origin-проверка мутаций /api (план Task 12, Ruling 10(2)): запросы не-GET/HEAD/OPTIONS к /api, -/// у которых есть заголовок Origin, обязаны иметь Origin, равный «своему» origin запроса -/// (схема + Host; за Caddy схема — https из X-Forwarded-Proto, см. UseForwardedHeaders) либо входящий -/// в явный allowlist ; несовпадение — HTTP 403 -/// {"detail":"…"}. -/// -/// -/// Дополнительный слой CSRF поверх SameSite=Lax кук (первый рубеж, документируется в техдок §10): -/// браузер всегда шлёт Origin на мутирующих запросах, а подделать его из чужого сайта нельзя, поэтому -/// «чужой» Origin — надёжный признак cross-site запроса. Запросы без Origin (curl, сервер-сервер, -/// gRPC) не проверяются и пропускаются — Origin обязателен только у браузерных вызовов. GET/HEAD — -/// не мутации, OPTIONS — CORS-preflight: не проверяются. Пустой allowlist (dev-режим, Ruling 10(2)) — -/// правило «Origin == свой origin запроса»; непустой список из конфига расширяет его (фронт за -/// прокси, меняющим Host, и/или явные домены PROD, Ruling 9). -/// -/// Регистрируется последним из security-слоёв (Ruling 5: Session → Operator → RateLimiter → OriginGuard): -/// сессии уже разрешены, rate-limiter ответил 429 раньше, чем проверяется Origin. -/// -/// -public sealed class OriginGuardMiddleware -{ - /// - /// Текст 403 Origin-проверки (Ruling 10(2)): единая формулировка для всех отказов. - /// - public const string OriginRejectedDetail = "Запрос отклонён: недопустимый Origin"; - - // Префикс пути, под которым живут все HTTP-эндпоинты приложения (Ruling 11). - 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); - } - - /// - /// Обрабатывает запрос: отклоняет мутации /api с чужим Origin (403 {detail}). - /// - /// Контекст запроса. - 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 (план Task 12, Ruling 10(2)): запросы не-GET/HEAD/OPTIONS к /api, +/// у которых есть заголовок Origin, обязаны иметь Origin, равный «своему» origin запроса +/// (схема + Host; за Caddy схема — https из X-Forwarded-Proto, см. UseForwardedHeaders) либо входящий +/// в явный allowlist ; несовпадение — HTTP 403 +/// {"detail":"…"}. +/// +/// +/// Дополнительный слой CSRF поверх SameSite=Lax кук (первый рубеж, документируется в техдок §10): +/// браузер всегда шлёт Origin на мутирующих запросах, а подделать его из чужого сайта нельзя, поэтому +/// «чужой» Origin — надёжный признак cross-site запроса. Запросы без Origin (curl, сервер-сервер, +/// gRPC) не проверяются и пропускаются — Origin обязателен только у браузерных вызовов. GET/HEAD — +/// не мутации, OPTIONS — CORS-preflight: не проверяются. Пустой allowlist (dev-режим, Ruling 10(2)) — +/// правило «Origin == свой origin запроса»; непустой список из конфига расширяет его (фронт за +/// прокси, меняющим Host, и/или явные домены PROD, Ruling 9). +/// +/// Регистрируется последним из security-слоёв (Ruling 5: Session → Operator → RateLimiter → OriginGuard): +/// сессии уже разрешены, rate-limiter ответил 429 раньше, чем проверяется Origin. +/// +/// +public sealed class OriginGuardMiddleware +{ + /// + /// Текст 403 Origin-проверки (Ruling 10(2)): единая формулировка для всех отказов. + /// + public const string OriginRejectedDetail = "Запрос отклонён: недопустимый Origin"; + + // Префикс пути, под которым живут все HTTP-эндпоинты приложения (Ruling 11). + 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); + } + + /// + /// Обрабатывает запрос: отклоняет мутации /api с чужим Origin (403 {detail}). + /// + /// Контекст запроса. + 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 40d086f..5ef9f22 100644 --- a/src/core/Deal.Api/Middleware/RateLimitPolicies.cs +++ b/src/core/Deal.Api/Middleware/RateLimitPolicies.cs @@ -1,155 +1,153 @@ -using System.Threading.RateLimiting; -using Deal.Api.Configuration; -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Microsoft.AspNetCore.RateLimiting; -using Microsoft.Extensions.DependencyInjection; -using Deal.Api.Dtos; - -namespace Deal.Api.Middleware; - -/// -/// Регистрация встроенного rate limiter ASP.NET Core (план Task 11, Ruling 5; этап 12, пакет B — хранилище -/// на Postgres) и его политики: "auth" — фиксированное окно на IP клиента (ручки входа /api/auth/login и -/// /api/operator/auth/login), "api" — на тенанта из либо IP анонима -/// (Session/OperatorSessionMiddleware отрабатывают раньше — порядок Session → Operator → RateLimiter). -/// API-партиция выставляется и глобальным лимитером: все /api-эндпоинты без собственной политики -/// ограничены 600/мин на тенанта/IP. Отказ любого лимитера — HTTP 429 с телом {"detail": "…"}. -/// -/// -/// Регистрируется только при (в dev/тестах middleware и политики -/// не создаются — Ruling 5). gRPC-ингресс (:5082) HTTP-лимитером освобождён (DisableRateLimiting на -/// MapGrpcService): лимит по tenant-id там считает IngressRateLimitInterceptor — иначе входящий поток -/// telegram-service резался бы общим окном на IP. -/// -/// Счётчики окон живут в Postgres ( через scoped -/// ) — лимиты общие для всех инстансов core. Пороги/окна -/// не менялись: фиксированные 1 минута, PermitLimit из . -/// -/// -public static class RateLimitPolicies -{ - /// - /// Имя политики входа: фиксированное окно по IP (RateLimit:AuthPerMinute). - /// - public const string AuthPolicy = "auth"; - - /// - /// Имя API-политики: фиксированное окно по CurrentUser.TenantId либо IP анонима (RateLimit:ApiPerMinute). - /// - public const string ApiPolicy = "api"; - - /// - /// Текст 429 rate limiter (Ruling 5): все отказы лимитов запросов — единый detail. - /// - 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; - } - - // Пишет ответ 429 формата прототипа: {"detail": "…"} (Ruling 10/5). - // context: Контекст отклонённого запроса. - // cancellationToken: Токен отмены ответа. - private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken) - { - context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; - await context.HttpContext.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 (план Task 11, Ruling 5; этап 12, пакет B — хранилище +/// на Postgres) и его политики: "auth" — фиксированное окно на IP клиента (ручки входа /api/auth/login и +/// /api/operator/auth/login), "api" — на тенанта из либо IP анонима +/// (Session/OperatorSessionMiddleware отрабатывают раньше — порядок Session → Operator → RateLimiter). +/// API-партиция выставляется и глобальным лимитером: все /api-эндпоинты без собственной политики +/// ограничены 600/мин на тенанта/IP. Отказ любого лимитера — HTTP 429 с телом {"detail": "…"}. +/// +/// +/// Регистрируется только при (в dev/тестах middleware и политики +/// не создаются — Ruling 5). gRPC-ингресс (:5082) HTTP-лимитером освобождён (DisableRateLimiting на +/// MapGrpcService): лимит по tenant-id там считает IngressRateLimitInterceptor — иначе входящий поток +/// telegram-service резался бы общим окном на IP. +/// +/// Счётчики окон живут в Postgres ( через scoped +/// ) — лимиты общие для всех инстансов core. Пороги/окна +/// не менялись: фиксированные 1 минута, PermitLimit из . +/// +/// +public static class RateLimitPolicies +{ + /// + /// Имя политики входа: фиксированное окно по IP (RateLimit:AuthPerMinute). + /// + public const string AuthPolicy = "auth"; + + /// + /// Имя API-политики: фиксированное окно по CurrentUser.TenantId либо IP анонима (RateLimit:ApiPerMinute). + /// + public const string ApiPolicy = "api"; + + /// + /// Текст 429 rate limiter (Ruling 5): все отказы лимитов запросов — единый detail. + /// + 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; + } + + // Пишет ответ 429 формата прототипа: {"detail": "…"} (Ruling 10/5). + // context: Контекст отклонённого запроса. + // cancellationToken: Токен отмены ответа. + private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken) + { + context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await context.HttpContext.Response.WriteAsJsonAsync(new { detail = RejectedDetail }, cancellationToken); + } +} diff --git a/src/core/Deal.Api/Middleware/SessionMiddleware.cs b/src/core/Deal.Api/Middleware/SessionMiddleware.cs index ca5b847..5beb12c 100644 --- a/src/core/Deal.Api/Middleware/SessionMiddleware.cs +++ b/src/core/Deal.Api/Middleware/SessionMiddleware.cs @@ -1,81 +1,74 @@ -using Deal.Api.Configuration; -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.Extensions.Options; -using Deal.Api.Dtos; -// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. -using CookieOptions = Deal.Api.Configuration.CookieOptions; - -namespace Deal.Api.Middleware; - -/// -/// Middleware сессии: читает httpOnly-куку, разрешает сессию через AuthService и наполняет -/// HttpContext.Items["CurrentUser"] + tenant-контекст запроса (ITenantContext). -/// -/// -/// Middleware НЕ отвечает 401 сама (pass-through): эндпоинты, требующие авторизации, проверяют -/// пользователя и выставляют 401. Нет куки или сессия невалидна — запрос идёт дальше без -/// пользователя. Tenant-контекст сбрасывается в finally после обработки запроса. -/// -/// AuthService — scoped, поэтому на запрос создаётся собственный scope через RequestServices. -/// Экземпляр middleware — singleton (стандартный паттерн UseMiddleware), опции читаются через -/// IOptionsMonitor, чтобы подхватывать изменения конфигурации. -/// -/// -public sealed class SessionMiddleware -{ - private readonly RequestDelegate _next; - private readonly IOptionsMonitor _cookieOptions; - private readonly ITenantContext _tenantContext; - - public SessionMiddleware( - RequestDelegate next, - IOptionsMonitor cookieOptions, - ITenantContext tenantContext) - { - _next = next; - _cookieOptions = cookieOptions; - _tenantContext = tenantContext; - } - - /// - /// Обрабатывает запрос: разрешает сессию по куке и наполняет контекст. - /// - /// Контекст запроса. - public async Task InvokeAsync(HttpContext context) - { - try - { - var cookieName = _cookieOptions.CurrentValue.Name; - if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken) - && !string.IsNullOrWhiteSpace(rawToken)) - { - // AuthService scoped: создаём scope на запрос через RequestServices. - await using var scope = context.RequestServices.CreateAsyncScope(); - var authService = scope.ServiceProvider.GetRequiredService(); - var user = await authService.ResolveSessionAsync(rawToken, context.RequestAborted); - if (user is not null) - { - context.SetCurrentUser(new CurrentUser(user.Id, user.Login, user.TenantId, user.Status)); - // Схема тенанта именуется tenant_<id>, где id — Guid в формате "N" (см. TenantService). - _tenantContext.SetTenant(new TenantId(user.TenantId.ToString("N"))); - } - } - - await _next(context); - } - finally - { - // Контекст AsyncLocal не должен переживать запрос. - _tenantContext.Reset(); - } - } -} +using Deal.Api.Extensions; +using Deal.Api.Models; +using Deal.Modules.Tenants.Application.Services; +using Deal.SharedKernel.Tenants.Abstractions; +using Deal.SharedKernel.Tenants.Models; +using Microsoft.Extensions.Options; +// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. +using CookieOptions = Deal.Api.Configuration.CookieOptions; + +namespace Deal.Api.Middleware; + +/// +/// Middleware сессии: читает httpOnly-куку, разрешает сессию через AuthService и наполняет +/// HttpContext.Items["CurrentUser"] + tenant-контекст запроса (ITenantContext). +/// +/// +/// Middleware НЕ отвечает 401 сама (pass-through): эндпоинты, требующие авторизации, проверяют +/// пользователя и выставляют 401. Нет куки или сессия невалидна — запрос идёт дальше без +/// пользователя. Tenant-контекст сбрасывается в finally после обработки запроса. +/// +/// AuthService — scoped, поэтому на запрос создаётся собственный scope через RequestServices. +/// Экземпляр middleware — singleton (стандартный паттерн UseMiddleware), опции читаются через +/// IOptionsMonitor, чтобы подхватывать изменения конфигурации. +/// +/// +public sealed class SessionMiddleware +{ + private readonly RequestDelegate _next; + private readonly IOptionsMonitor _cookieOptions; + private readonly ITenantContext _tenantContext; + + public SessionMiddleware( + RequestDelegate next, + IOptionsMonitor cookieOptions, + ITenantContext tenantContext) + { + _next = next; + _cookieOptions = cookieOptions; + _tenantContext = tenantContext; + } + + /// + /// Обрабатывает запрос: разрешает сессию по куке и наполняет контекст. + /// + /// Контекст запроса. + public async Task InvokeAsync(HttpContext context) + { + try + { + var cookieName = _cookieOptions.CurrentValue.Name; + if (context.Request.Cookies.TryGetValue(cookieName, out var rawToken) + && !string.IsNullOrWhiteSpace(rawToken)) + { + // AuthService scoped: создаём scope на запрос через RequestServices. + await using var scope = context.RequestServices.CreateAsyncScope(); + var authService = scope.ServiceProvider.GetRequiredService(); + var user = await authService.ResolveSessionAsync(rawToken, context.RequestAborted); + if (user is not null) + { + context.SetCurrentUser(new CurrentUser(user.Id, user.Login, user.TenantId, user.Status)); + // Схема тенанта именуется tenant_<id>, где id — Guid в формате "N" (см. TenantService). + _tenantContext.SetTenant(new TenantId(user.TenantId.ToString("N"))); + } + } + + await _next(context); + } + finally + { + // Контекст AsyncLocal не должен переживать запрос. + _tenantContext.Reset(); + } + } +} diff --git a/src/core/Deal.Api/Models/CurrentOperator.cs b/src/core/Deal.Api/Models/CurrentOperator.cs index 91f6cfa..d06af7a 100644 --- a/src/core/Deal.Api/Models/CurrentOperator.cs +++ b/src/core/Deal.Api/Models/CurrentOperator.cs @@ -1,13 +1,9 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; -using Deal.Api.Extensions; - -namespace Deal.Api.Models; - -/// -/// Текущий оператор запроса — кладёт OperatorSessionMiddleware в HttpContext.Items (Ruling 1). -/// -/// Идентификатор оператора. -/// Логин в нижнем регистре. -/// Статус учётной записи (всегда "active" — разрешаются только активные). -public sealed record CurrentOperator(Guid OperatorId, string Login, string Status); +namespace Deal.Api.Models; + +/// +/// Текущий оператор запроса — кладёт OperatorSessionMiddleware в HttpContext.Items (Ruling 1). +/// +/// Идентификатор оператора. +/// Логин в нижнем регистре. +/// Статус учётной записи (всегда "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 86ce773..0b3f5a6 100644 --- a/src/core/Deal.Api/Models/CurrentUser.cs +++ b/src/core/Deal.Api/Models/CurrentUser.cs @@ -1,14 +1,10 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; -using Deal.Api.Extensions; - -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 5855d76..ed1e49a 100644 --- a/src/core/Deal.Api/Observability/DealMetricsCollector.cs +++ b/src/core/Deal.Api/Observability/DealMetricsCollector.cs @@ -1,6 +1,4 @@ using Deal.SharedKernel.Observability; -using Deal.Api.Services; -using Deal.Api.Dtos; namespace Deal.Api.Observability; diff --git a/src/core/Deal.Api/Observability/DealMetricsHosting.cs b/src/core/Deal.Api/Observability/DealMetricsHosting.cs index 1a81b1d..0c440ac 100644 --- a/src/core/Deal.Api/Observability/DealMetricsHosting.cs +++ b/src/core/Deal.Api/Observability/DealMetricsHosting.cs @@ -1,12 +1,6 @@ using Deal.SharedKernel.Observability; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; -using Microsoft.Extensions.DependencyInjection; -using OpenTelemetry; using OpenTelemetry.Metrics; -using Deal.Api.Services; -using Deal.Api.Dtos; namespace Deal.Api.Observability; diff --git a/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs b/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs index 1d5ae9d..df648da 100644 --- a/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs +++ b/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs @@ -1,160 +1,148 @@ -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.EntityFrameworkCore; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Observability; - -/// -/// Сборщик глубин очередей и активных сессий (этап 12, §10.2): единый источник для метрик и health. -/// -/// -/// -/// Обходит реестр тенантов (на тенант — вложенный scope с ITenantContext.SetTenant) и считает через -/// существующие сервисы/порты, без дублирования SQL: -/// -/// глубина очереди пайплайна — (new+filtered); -/// глубина очереди обучения ML — (count(MlOutbox)); -/// активные сессии — count(public.sessions) + count(public.operator_sessions) с непросроченным ExpiresAt. -/// -/// Переиспользуется фоновым (публикация в DealMetrics) и операторским -/// health (глубины прямо в JSON). Ошибки каждой секции логируются и не выбрасываются наружу (сбой тенанта не -/// валит проход; наружу летит только отмена); значения агрегируются по всем тенантам. -/// -/// -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; - } - - /// - /// Собирает снимок глубин очередей и числа активных сессий (агрегат по всем тенантам). - /// - /// Токен отмены (пробрасывается в EF-запросы; отмена — единственное исключение наружу). - /// Снимок: суммарные глубины pipeline/ML-outbox и число активных сессий. - public async Task CollectAsync(CancellationToken ct) - { - await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope(); - int sessions = await CountActiveSessionsAsync(cycleScope, ct); - (long queue, long outbox) = await SumTenantDepthsAsync(cycleScope, ct); - return new RuntimeDepthsDto(queue, outbox, sessions); - } - - // Считает активные непросроченные сессии пользователей и операторов (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; - } - } - - // Суммирует глубины очередей по всем тенантам реестра. - // cycleScope: Scope прохода (реестр тенантов читается без tenant-контекста). - // ct: Токен отмены. - // Возвращает: Пара (сумма очереди пайплайна, сумма MlOutbox); недоступность реестра — (0, 0). - private async Task<(long Queue, long Outbox)> SumTenantDepthsAsync(AsyncServiceScope cycleScope, CancellationToken ct) - { - long queueDepth = 0; - long outboxDepth = 0; - try - { - ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService(); - IReadOnlyList tenants = await tenantRepository.ListAsync(ct); - foreach (TenantRecordDto tenant in tenants) - { - (int queue, int outbox) = await CollectTenantAsync(tenant, ct); - queueDepth += queue; - outboxDepth += outbox; - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - _logger.LogWarning(exception, "Сборщик глубин: обход реестра тенантов не удался"); - } - - return (queueDepth, outboxDepth); - } - - // Считает глубины очередей одного тенанта в собственном 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; + +/// +/// Сборщик глубин очередей и активных сессий (этап 12, §10.2): единый источник для метрик и health. +/// +/// +/// +/// Обходит реестр тенантов (на тенант — вложенный scope с ITenantContext.SetTenant) и считает через +/// существующие сервисы/порты, без дублирования SQL: +/// +/// глубина очереди пайплайна — (new+filtered); +/// глубина очереди обучения ML — (count(MlOutbox)); +/// активные сессии — count(public.sessions) + count(public.operator_sessions) с непросроченным ExpiresAt. +/// +/// Переиспользуется фоновым (публикация в DealMetrics) и операторским +/// health (глубины прямо в JSON). Ошибки каждой секции логируются и не выбрасываются наружу (сбой тенанта не +/// валит проход; наружу летит только отмена); значения агрегируются по всем тенантам. +/// +/// +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; + } + + /// + /// Собирает снимок глубин очередей и числа активных сессий (агрегат по всем тенантам). + /// + /// Токен отмены (пробрасывается в EF-запросы; отмена — единственное исключение наружу). + /// Снимок: суммарные глубины pipeline/ML-outbox и число активных сессий. + public async Task CollectAsync(CancellationToken ct) + { + await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope(); + int sessions = await CountActiveSessionsAsync(cycleScope, ct); + (long queue, long outbox) = await SumTenantDepthsAsync(cycleScope, ct); + return new RuntimeDepthsDto(queue, outbox, sessions); + } + + // Считает активные непросроченные сессии пользователей и операторов (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; + } + } + + // Суммирует глубины очередей по всем тенантам реестра. + // cycleScope: Scope прохода (реестр тенантов читается без tenant-контекста). + // ct: Токен отмены. + // Возвращает: Пара (сумма очереди пайплайна, сумма MlOutbox); недоступность реестра — (0, 0). + private async Task<(long Queue, long Outbox)> SumTenantDepthsAsync(AsyncServiceScope cycleScope, CancellationToken ct) + { + long queueDepth = 0; + long outboxDepth = 0; + try + { + ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService(); + IReadOnlyList tenants = await tenantRepository.ListAsync(ct); + foreach (TenantRecordDto tenant in tenants) + { + (int queue, int outbox) = await CollectTenantAsync(tenant, ct); + queueDepth += queue; + outboxDepth += outbox; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Сборщик глубин: обход реестра тенантов не удался"); + } + + return (queueDepth, outboxDepth); + } + + // Считает глубины очередей одного тенанта в собственном 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 7a1c16a..885217f 100644 --- a/src/core/Deal.Api/Observability/RuntimeDepthsDto.cs +++ b/src/core/Deal.Api/Observability/RuntimeDepthsDto.cs @@ -1,6 +1,3 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - namespace Deal.Api.Observability; /// diff --git a/src/core/Deal.Api/Program.cs b/src/core/Deal.Api/Program.cs index 30c25de..de1a5d3 100644 --- a/src/core/Deal.Api/Program.cs +++ b/src/core/Deal.Api/Program.cs @@ -1,773 +1,744 @@ -using System.Net; -using System.Net.Sockets; -using System.Text.Encodings.Web; -using Deal.Api; -using Deal.Api.Dtos; -using Deal.Api.Services; -using Deal.Api.Configuration; -using Deal.Api.Endpoints; -using Deal.Api.Events; -using Deal.Api.Hosting; -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Logging; -using Deal.Api.Middleware; -using Deal.Api.Observability; -using Deal.Api.Telegram; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.Infrastructure.Integrations.Storage.Extensions; -using Deal.Infrastructure.Integrations.Storage.Options; -using Deal.Infrastructure.Integrations.Storage.Services; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; -using Deal.Infrastructure.Services; -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.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.AspNetCore.Server.Kestrel.Core; -using Microsoft.AspNetCore.Server.Kestrel.Https; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Diagnostics.HealthChecks; -// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. -using CookieOptions = Deal.Api.Configuration.CookieOptions; - -const string cookiesSectionName = "Cookies"; -// Секция настроек куки оператора (Ruling 1): имя deal_operator_session, срок 12 ч, Secure из конфига. -const string operatorCookiesSectionName = "OperatorCookies"; -// Имя CORS-политики (план Task 12, Ruling 10): одна политика, режим зависит от Security:AllowedOrigins. -const string corsPolicyName = "cors"; -// Секции конфигурации интеграций (Ruling 6): Services:Ml / Services:Ai / Services:Telegram → {UseLocal, Endpoint}. -const string servicesSectionName = "Services:Ml"; -const string aiServicesSectionName = "Services:Ai"; -const string telegramServicesSectionName = "Services:Telegram"; -// Имя истории tenant-миграций без схемы (схема — через search_path; миграции применяет -// TenantProvisioningService на старте, runtime-контекст их не выполняет). -const string tenantMigrationsHistoryTable = "__TenantMigrationsHistory"; -// Порт gRPC-ингресса по умолчанию (Ruling 7): :5082; переопределяется env GRPC_INGRESS_PORT. -const int defaultIngressPort = 5082; -const string ingressPortEnvKey = "GRPC_INGRESS_PORT"; -// Ключ конфигурации адресов основного HTTP-эндпоинта (--urls/ASPNETCORE_URLS/launchSettings). -const string serverUrlsKey = "urls"; -// Фолбэк основного HTTP-адреса при отсутствии явных URL (дефолт ASP.NET Core http://localhost:5000). -const string defaultHttpUrl = "http://localhost:5000"; -// Ключ env-переопределения дефолт-бюджета нового тенанта (Ruling 3; токенов в месяц, период — всегда month). -const string defaultAiBudgetEnvKey = "DEAL_DEFAULT_AI_BUDGET"; -// Секция настроек rate limiting (план Task 11, Ruling 5): RateLimit:Enabled=false в dev/тестах по -// умолчанию (curl-приёмки не режутся); PROD включает env-переопределением RateLimit__Enabled=true -// (compose-prod, Task 14). -const string rateLimitSectionName = "RateLimit"; -// Секция авто-очистки данных (этап 12, пакет B): DataRetention:AuditRetentionDays (дефолт 180) + Enabled — -// фоновый цикл DataRetentionScheduler чистит audit_log по retention, лимиты/счётчики прошедших окон. -const string dataRetentionSectionName = "DataRetention"; -// Секция настроек безопасности HTTP (план Task 12, Ruling 10): Security:AllowedOrigins — явный allowlist -// Origin-проверки/CORS (пусто — dev-режим «свой origin» + CORS-любой; PROD — домены фронта, Ruling 9). -const string securitySectionName = "Security"; -// Секция доверия прокси-заголовкам (план Task 12; замечание ревью T4/T11): ForwardedHeaders:KnownProxies/ -// KnownNetworks — доверенные прокси (Caddy в PROD); dev-дефолт в appsettings — loopback. -const string forwardedHeadersSectionName = "ForwardedHeaders"; - -// Имя процесса для rolling-файла логов (Ruling 7, Task 14): data/logs/deal-core-<дата>.json. -const string coreProcessName = "core"; - -var builder = WebApplication.CreateBuilder(args); - -// Структурированные логи Serilog (Ruling 7, план Task 14): консоль JSON в prod / текст в dev + -// rolling-файл data/logs/deal-core-*.json (data — volume контейнера). Уровень/каталог — env -// DEAL_LOG_LEVEL/DEAL_LOGS_DIR (см. DealLogging). Регистрируется до остальных сервисов: логирование -// заменяет провайдеры Microsoft при builder.Build(). -DealLogging.Configure(builder, coreProcessName); - -// Метрики OpenTelemetry → Prometheus (этап 12, пакет A): эндпоинт /metrics в формате Prometheus на -// отдельном HTTP/1.1 Kestrel-эндпоинте (порт 9464/env METRICS_PORT) + инструментация входящих HTTP- -// запросов и исходящих HTTP-клиентов; прикладные метрики — SharedKernel/Observability/DealMetrics. -int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); -DealMetricsHosting.AddDealMetrics(builder, metricsPort); - -// Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует -// (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД. -var connectionString = builder.Configuration.GetConnectionString("DealPostgres") - ?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан"); -builder.Services.AddDbContext(options => options.UseNpgsql(connectionString)); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -// mTLS внутреннего gRPC-транспорта (Ruling 6, план Task 13): env DEAL_MTLS_* — флаг и пути/пароли -// сертификатов (только env, Ruling 13). Dev-дефолт — выключено: сервисы и ингресс остаются на plaintext + -// service-token (Ruling 2 этапа 6); compose-prod (Task 14) передаёт env и монтирует deploy/certs -// (генерация — scripts/mtls-certs.sh). При DEAL_MTLS_ENABLED=1 сертификаты грузятся сразу (fail-fast на -// битые пути/пароли) — один экземпляр используют и Kestrel-ингресс ниже, и gRPC-клиенты -// (Ml/Ai/Telegram-каналы + ServiceHealthProbe). -MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(builder.Configuration); -MtlsCertificates? mtlsCertificates = MtlsCertificates.Load(mtlsOptions); -if (mtlsCertificates is not null) -{ - builder.Services.AddSingleton(mtlsCertificates); -} - -// Kestrel: основной HTTP/1.1-эндпоинт из URL-конфигурации (как раньше — --urls/ASPNETCORE_URLS/ -// launchSettings) + второй endpoint gRPC-ингресса telegram-service (:5082, HTTP/2, env GRPC_INGRESS_PORT; -// план Task 12, Ruling 7). Явные Listen заменяют URL-биндинг Kestrel, поэтому основной эндпоинт -// пере-биндим адресами конфигурации "urls" явно (см. BindMainHttpEndpoints ниже). Ingress слушает все -// интерфейсы (AnyIP): в dev к нему ходит telegram-service из compose-сети через host.docker.internal (Ruling 12). -builder.WebHost.ConfigureKestrel(kestrel => -{ - BindMainHttpEndpoints(kestrel, builder.Configuration[serverUrlsKey]); - int ingressPort = ParsePort(builder.Configuration[ingressPortEnvKey]) ?? defaultIngressPort; - kestrel.ListenAnyIP(ingressPort, listen => - { - listen.Protocols = HttpProtocols.Http2; - if (mtlsCertificates is not null) - { - // mTLS-ингресс (Ruling 6, Task 13): HTTPS с серверным сертификатом core + обязательный клиентский - // сертификат (цепочка до CA из DEAL_MTLS_CA_PEM). Основной HTTP :5080 остаётся http — TLS наружу - // терминирует Caddy (Ruling 9, compose-prod Task 14). - listen.UseHttps(https => - { - https.ServerCertificate = mtlsCertificates.ServerCertificate; - https.ClientCertificateMode = ClientCertificateMode.RequireCertificate; - https.ClientCertificateValidation = mtlsCertificates.ValidateClientCertificate; - }); - } - }); -}); - -// TenantDbContext — scoped-контекст бессхемной модели тенанта (таблица settings и др.): строка -// подключения на каждый scope строится по текущему ITenantContext (заполняет SessionMiddleware) -// с Search Path на схему тенанта (ConnectionStringProvider.ForTenant). Опции живут в scope запроса -// (optionsLifetime: Scoped) — иначе опции с первым тенантом закешировались бы в singleton. -// Безопасность: вне tenant-запроса (нет сессии) контекст не имеет смысла — ошибка конфигурации. -builder.Services.AddDbContext( - (serviceProvider, options) => - { - var tenantContext = serviceProvider.GetRequiredService(); - if (!tenantContext.HasTenant) - { - throw new InvalidOperationException( - "TenantDbContext запрошен вне tenant-запроса: на запрос не разрешена сессия " - + "(ITenantContext.HasTenant == false)."); - } - - var connectionStringProvider = serviceProvider.GetRequiredService(); - options.UseNpgsql( - connectionStringProvider.ForTenant(tenantContext.TenantId), - npgsql => npgsql.MigrationsHistoryTable(tenantMigrationsHistoryTable)); - }, - contextLifetime: ServiceLifetime.Scoped, - optionsLifetime: ServiceLifetime.Scoped); - -// Модуль Tenants и его EF-адаптеры («port & adapter», Ruling 1). -builder.Services.AddTenantsModule(); - -// Лимиты ИИ-бюджета (Ruling 3, Task 8): дефолт-бюджет лениво создаваемой строки public.tenant_limits — -// env DEAL_DEFAULT_AI_BUDGET (токенов в месяц) с фолбэком на константу модуля TokenBudgetDefaults (10 000 000); -// период нового тенанта — month (константа). Значение читается один раз на старте и передаётся адаптеру -// TenantLimitStore (GetOrCreateAsync при первом чтении/списании, задачи 7/10 list-путь тоже закрыт). -TokenLimitDefaults tenantLimitDefaults = new( - ResolveDefaultAiBudget(builder.Configuration), TokenBudgetDefaults.DefaultPeriod); -builder.Services.AddDealPersistence(tenantLimitDefaults); - -// Шифрование секретов (Ruling 2): ISecretCipher — AES-256-GCM; ключ из DEAL_ENCRYPTION_KEY -// либо файла data/encryption.key под ContentRoot (dev). Ключ разрешается на старте — -// невалидный env-ключ останавливает запуск. -builder.Services.AddDealSecurity(builder.Environment.ContentRootPath); - -// Внешние интеграции (Tasks 9/16/15, Rulings 4/5/6/9): IMlClient — детерминированная заглушка LocalMlClient -// (Services:Ml:UseLocal=true, default; обучение — этап 3) либо gRPC-клиент GrpcMlClient (UseLocal=false, -// ml-service :5103). Local-адаптеры читают KV-настройки тенанта через ISettingsStore — scoped (вне -// tenant-запроса не разрешимы). IColumnSuggester — LocalColumnSuggester (эвристика, Self-Review L525–527). -MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get() ?? new MlServiceOptions(); -builder.Services.AddSingleton(mlOptions); -// AI-интеграция (план Task 15, Ruling 6/9): IAiClassifier/IAiTools — Local-адаптеры (Services:Ai:UseLocal=true, -// default; локальный разбор ядра / инструменты не поддерживаются) либо декораторы бюджетного гейта поверх -// gRPC-клиентов ai-service (UseLocal=false, ai-service :5102; AddDealIntegrations регистрирует GrpcAiClassifier/ -// GrpcAiTools + транспорт AiGrpcConnection — fail-fast, как MlGrpcConnection — и оборачивает их в -// BudgetedAiClassifier/BudgetedAiTools, Ruling 3/Task 9). -AiServiceOptions aiOptions = builder.Configuration.GetSection(aiServicesSectionName).Get() ?? new AiServiceOptions(); -builder.Services.AddSingleton(aiOptions); -// Telegram-гейт (план Task 14, Ruling 6/7): LocalTelegramGateway (Services:Telegram:UseLocal=true, default — -// нейтральный no-op/idle) либо gRPC-клиент GrpcTelegramClient (UseLocal=false, telegram-service :5101). -TelegramServiceOptions telegramOptions = builder.Configuration.GetSection(telegramServicesSectionName).Get() ?? new TelegramServiceOptions(); -builder.Services.AddSingleton(telegramOptions); -builder.Services.AddDealIntegrations(mlOptions, aiOptions, telegramOptions, mtlsCertificates); - -// Health-проба автономных сервисов для операторского health (план Task 10, Ruling 3/6/9): grpc.health.v1 -// к Services:*:Endpoint с дедлайном 3 с (ServiceHealthProbe). Stateless, singleton — пробы строят -// короткоживущие каналы на каждый вызов (mTLS-каналы — при включённом флаге, Task 13/Ruling 6). -builder.Services.AddSingleton(new ServiceHealthProbe(mtlsCertificates)); - -// Файловое хранилище вложений проектных карточек (Ruling 4, Task 6): LocalFileStorage (data/attachments -// под ContentRoot) — dev/curl/unit по умолчанию; MinioFileStorage регистрируется, только когда сконфигурирован -// MinIO (секция Storage:Minio либо env-алиасы DEAL_MINIO_*; compose-сервис deal-minio, порты 9000/9001). -// Singleton: хранилище не привязано к схеме тенанта (объекты — в едином бакете/каталоге, мульти-аренда -// объектного хранилища — этап 7 SaaS). Режим логируется на старте (см. ниже) — приёмка Task 6. -builder.Services.AddDealFileStorage(builder.Configuration, builder.Environment.ContentRootPath); - -// Модуль Settings (сервис настроек тенанта); адаптеры ISettingsStore/ISecretCipher уже -// зарегистрированы AddDealPersistence/AddDealSecurity выше (см. Task 4). -builder.Services.AddSettingsModule(); - -// Модуль Kanban — единый домен карточки (Ruling 12): регистратор сервисов карточек/контейнеров/тиков; -// порт-адаптер ICardStore → KanbanStore уже зарегистрирован AddDealPersistence. -builder.Services.AddKanbanModule(); - -// Модуль Pipeline (Ruling 10, Task 9): приём/обработка/воркер и ядра разбора этапа 4. Порт-адаптер -// IPipelineStore → PipelineStore и внешние порты (IMlClient/IAiClassifier) уже зарегистрированы -// AddDealPersistence/AddDealIntegrations выше; сервисы модуля вызывают из эндпоинтов /api/pipeline/* -// и gRPC-ингресса telegram-service, pump — admin/tick (Task 10) и фоновый цикл (Task 11). -builder.Services.AddPipelineModule(); - -// Модуль Telegram (план Task 13, Ruling 7): сервис каталога диалогов (DialogsService) — владелец таблиц -// Dialogs/TgMessages схемы тенанта (миграция TenantTelegram). Порт-адаптеры ITelegramStore → TelegramStore и -// ITelegramGateway → LocalTelegramGateway/GrpcTelegramClient зарегистрированы AddDealPersistence/AddDealIntegrations -// выше; сервис зовут gRPC-ингресс (SyncDialogs/PushMessage) и эндпоинты /api/tg (Task 14). -builder.Services.AddTelegramModule(); - -// Модуль Discovery (план Task 17/18, Ruling 9/10): сервисы задач/кандидатов/чёрного списка/лога, план-бюджет -// и воркер (оценка/бан-гард/паузы). Порт-адаптер IDiscoveryStore → DiscoveryStore зарегистрирован -// AddDealPersistence; внешние порты (ITelegramGateway/IAiTools/IMlClient) — AddDealIntegrations выше. Эндпоинты -// /api/discovery добавляет Task 19; фоновый цикл воркера — DiscoveryWorkerScheduler ниже. -builder.Services.AddDiscoveryModule(); - -// Статус/ключи вкладки Telegram (план Task 14, Ruling 8): сборка GET /api/tg/status (гейт + KV tgAccount + -// счётчик мониторящихся + keysSet) и чтение глобальных ключей приложения (telegramKeys в public.global_settings, -// расшифровка apiHash — задаёт оператор, ТЗ §4.1/§8.1). Scoped: зависимости — ISettingsStore/ITelegramStore -// на TenantDbContext схемы тенанта запроса, IGlobalSettingsStore — на системном DealDbContext. -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// Фоновые спуски перечитывания каналов (план Task 14, Ruling 8; аналог python-_spawn роутеров): эндпоинты -// мониторинга/«Перечитать» отвечают сразу, тяжёлый разбор идёт в отдельном scope с захваченным tenant-контекстом. -builder.Services.AddSingleton(); - -// Оркестратор ручного тика (план Task 10, Rulings 8/9): тик правил хранения (Kanban) + очистка отсева -// Pipeline + один проход pump + SSE-публикации (тосты/new_card) для POST /api/admin/tick (StorageEndpoints). -// Scoped: зависимости живут в рамках tenant-запроса (scoped-сервисы модулей на TenantDbContext схемы). -builder.Services.AddScoped(); - -// Обслуживание FTS-индексов схемы тенанта (Ruling 6, план Task 10): POST /api/admin/fts/rebuild — -// CREATE INDEX IF NOT EXISTS + ANALYZE (FtsMaintenance) на TenantDbContext запроса (scoped, как адаптеры). -builder.Services.AddScoped(); - -// SSE-брокер этапа (Ruling 5): singleton per-tenant каналов событий; подписка — GET /api/events, -// публикации — из эндпоинтов Api после вызова сервисов (Tasks 10/13/14). -builder.Services.AddSingleton(); - -// SSE-тосты статистики тика правил хранения (Ruling 8): единый хелпер для POST /api/admin/tick -// (StorageEndpoints) и фонового StorageTickScheduler (Task 11) — без дублирования текстов/иконок. -builder.Services.AddSingleton(); - -// Общий воркер-гейт pump тенанта (план Task 11, Ruling 8; аналог asyncio.Lock pipeline.py L38–42): -// POST /api/admin/tick (AdminTickOrchestrator) и фоновый цикл разбора очереди (PipelineWorkerScheduler) -// не разбирают очередь одного тенанта одновременно (singleton per-tenant флагов, Interlocked). -builder.Services.AddSingleton(); - -// Rate limiting и защита входа (план Task 11, Ruling 5; этап 12, пакет B — хранилище на Postgres): секция -// "RateLimit" (appsettings.json + env RateLimit__*). Enabled=false в dev/тестах — политики/middleware/ -// интерцептор не регистрируются вовсе (Ruling 5: «в dev выключено — curl-приёмки не режутся»); -// LoginAttemptGuard (окно ip|login 5 неудач/15 мин в public.rate_limit_counters) регистрируется всегда, -// но активен только при Enabled. -RateLimitOptions rateLimitOptions = builder.Configuration - .GetSection(rateLimitSectionName) - .Get() ?? new RateLimitOptions(); -builder.Services.AddSingleton(rateLimitOptions); -// Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер -// (public.rate_limit_counters). Активен только при Enabled (no-op иначе). -builder.Services.AddScoped(); -if (rateLimitOptions.Enabled) -{ - builder.Services.AddDealRateLimiter(rateLimitOptions); -} - -// gRPC-ингресс telegram-service (план Task 12, Ruling 1/7): сервер Deal.Grpc.Telegram.IngressService -// на отдельном Kestrel-endpoint (:5082, HTTP/2, см. ConfigureKestrel выше) в том же процессе. Token -// из metadata «service-token» проверяет интерцептор (fail-closed, DEAL_SERVICE_TOKEN); AddAuthentication -// не нужен — пользовательская сессия HTTP ингрессом не используется (tenant-id из metadata → SetTenant). -builder.Services.AddGrpc(grpc => -{ - // Access-лог RPC ингресса (Ruling 7, Task 14): ПЕРВЫМ в цепочке — логируются и отклонённые - // вызовы (401/429); gRPC-health не логируется (см. RpcCallLoggingInterceptor). - grpc.Interceptors.Add(); - grpc.Interceptors.Add(); - if (rateLimitOptions.Enabled) - { - // Лимит входящего потока по tenant-id (план Task 11, Ruling 5): окно считает общий - // singleton-лимитер (CreateLimiter) — экземпляры интерцептора общий PartitionedRateLimiter - // разделяют; health-методы освобождены (см. IngressRateLimitInterceptor). - grpc.Interceptors.Add(); - } -}); -if (rateLimitOptions.Enabled) -{ - builder.Services.AddSingleton(provider => - IngressRateLimitInterceptor.CreateLimiter( - provider.GetRequiredService(), - rateLimitOptions.GrpcIngressPerMinute)); -} - -builder.Services.AddScoped(); - -// gRPC-health ингресса (план Task 20, Ruling 12): healthcheck контейнера core в docker compose. -// grpc.health.v1.Health интерцептор токеном не проверяет (инфраструктурный liveness, как в сервисах -// этапа T2–T4); регистрируется явная проверка "ready" — без неё health-сервис отвечает UNKNOWN. -// Живучесть интеграций (ml/ai/telegram) health не проверяет — недоступность сервиса это UNAVAILABLE -// на RPC и фолбэк Local-адаптеров, а не падение хоста (Ruling 6). -builder.Services - .AddGrpcHealthChecks() - .AddCheck("ready", () => HealthCheckResult.Healthy("хост Deal.Api готов")); - -// Проверка подключения AI-провайдера (Task 6, Ruling 7): порт модуля IAiConnectionChecker → -// HTTP-адаптер Infrastructure с собственным HttpClient (фабрика AddHttpClient, таймаут 12 с). -// HTTP наружу ходит только по действию Settings-экрана (POST /api/ai/check) — GET {base}/models. -builder.Services.AddHttpClient( - client => client.Timeout = TimeSpan.FromSeconds(AiConnectionChecker.RequestTimeoutSeconds)); - -// Курсы валют (Task 8, Ruling 6): порт модуля IRatesSource → HTTP-адаптер ЦБ с собственным -// HttpClient (таймаут 15 с, как httpx timeout=15 в rates.py). URL — фиксированная константа -// адаптера (SSRF-allowlist), источник тенантом не настраивается. Типизированный клиент -// регистрируется transient и живёт в рамках scope запроса (как IAiConnectionChecker, Task 6). -builder.Services.AddHttpClient( - client => client.Timeout = TimeSpan.FromSeconds(CbrRateSource.RequestTimeoutSeconds)); - -// Фоновое обновление кэша курсов вне запроса (Ruling 6): PATCH rateSource и лениво на GET — -// собственный scope + in-flight guard (см. RatesRefreshScheduler). -builder.Services.AddSingleton(); - -// Bootstrap при старте (Ruling 8): дефолтный тенант + admin, провижининг схем всех тенантов. -builder.Services.AddHostedService(); - -// Bootstrap оператора при старте (Ruling 1 этапа 7): env DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD, -// dev-дефолт operator/operator в Development; в Production без env — warning и пропуск. Идёт после -// TenantBootstrapService: операторские public-таблицы не зависят от провижининга схем тенантов. -builder.Services.AddHostedService(); - -// Фоновый цикл правил хранения (план Task 11, Ruling 8; аналог _storage_loop main.py L43–53): каждые -// 30 с тикает ВСЕ тенанты (StorageTickService + автоочистка отсева пайплайна 3 суток) и публикует -// SSE-тосты. Регистрируется после Bootstrap — первый проход стартует уже после провижининга схем. -builder.Services.AddHostedService(); - -// Фоновый цикл SSE-алертов ИИ-бюджета (план Task 9, Ruling 3; эталон StorageTickScheduler): каждые 60 с -// проверяет ВСЕ тенанты и публикует в канал тенанта тост при пересечении порогов 80/100% (TryMark*-CAS — -// один тост на порог за период). Идёт после Bootstrap: реестр тенантов провижинен до первого прохода. -builder.Services.AddHostedService(); - -// Фоновый цикл разбора очереди входящих (план Task 11, Ruling 8/11; аналог _pipeline_loop main.py L79–88): -// каждые 2 с pump'ит ВСЕ тенанты (PipelineWorkerService.PumpOnceAsync под общим PipelinePumpGate) и -// публикует SSE new_card по созданным карточкам — ingest разбирается без ручного tick (приёмка Task 11). -// После StorageTickScheduler: очередь цикла — 2 с, первый проход сразу после старта. -builder.Services.AddHostedService(); - -// Фоновый флашер очереди обучения ML (план Task 16, Ruling 6; аналог _ml_sync_loop main.py): каждые 10 с -// выгружает MlOutbox тенантов в ml-service (TrainBatch, порции по 10, ≤100/цикл; удаление после успеха). -// Регистрируется только в gRPC-режиме (UseLocal=false) — Local-режиму (этапы 2–5) сервис не нужен, очередь -// копится до подключения ml-service (python L56–82); MlGrpcConnection создан в AddDealIntegrations (fail-fast). -if (!mlOptions.UseLocal) -{ - builder.Services.AddHostedService(); -} - -// Фоновый цикл Discovery-воркера (план Task 18, Ruling 10; аналог _discovery_loop main.py): каждые 5 с -// делает ОДИН шаг (поиск/оценка/авто-вступление/done) для каждой running-задачи всех тенантов. Работает -// всегда: в Local-режиме гейт нейтрален (поиск пуст/история недоступна), реальные действия — при -// подключённом telegram-service (UseLocal=false). После Bootstrap: первый проход стартует после провижининга. -builder.Services.AddHostedService(); - -// Сборщик глубин очередей/сессий (этап 12, §10.2): общий источник для метрик и операторского health. -builder.Services.AddSingleton(); - -// Фоновый сборщик gauge-метрик (этап 12, пакет A): каждые 15 с публикует глубины очередей (пайплайн, -// MlOutbox) по всем тенантам и число активных сессий в meter Deal (callback /metrics отдаёт их Prometheus). -// Регистрируется последним из фоновых: после Bootstrap (реестр тенантов провижинен до первого прохода). -builder.Services.AddHostedService(); - -// Фоновый цикл авто-очистки данных (этап 12, пакет B; эталон DealMetricsCollector): раз в сутки удаляет -// записи audit_log старше DataRetention:AuditRetentionDays (дефолт 180 дней), сбрасывает накопительные -// поля лимитов прошедших периодов и убирает завершившиеся окна распределённых счётчиков. Регистрируется -// последним из фоновых: после Bootstrap (реестр тенантов провижинен до первого прохода). -DataRetentionOptions dataRetentionOptions = builder.Configuration - .GetSection(dataRetentionSectionName) - .Get() ?? new DataRetentionOptions(); -builder.Services.AddSingleton(dataRetentionOptions); -builder.Services.AddHostedService(); - -// Кука сессии: имя/срок/Secure из секции "Cookies" (appsettings.json + env Cookies__*). -builder.Services.Configure(builder.Configuration.GetSection(cookiesSectionName)); - -// Кука операторской сессии (Ruling 1 этапа 7): имя deal_operator_session/срок/Secure из секции -// "OperatorCookies" (appsettings.json + env OperatorCookies__*) — отдельная от тенантной deal_session. -builder.Services.Configure(builder.Configuration.GetSection(operatorCookiesSectionName)); - -// Ответы JSON — как в прототипе FastAPI: без \u-экранирования не-ASCII символов. -builder.Services.ConfigureHttpJsonOptions(options => - options.SerializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping); - -// Безопасность HTTP (план Task 12, Ruling 10(2)/9): Security:AllowedOrigins — явный allowlist Origin- -// проверки мутаций и CORS (пусто — dev-режим «свой origin», см. AddCors ниже; PROD — домен фронта в -// compose-prod). Инстанс регистрируется в DI: значение читается один раз на старте (политики формируются -// при старте хоста), OriginGuardMiddleware получает его конструктором. -SecurityOptions securityOptions = builder.Configuration - .GetSection(securitySectionName) - .Get() ?? new SecurityOptions(); -builder.Services.AddSingleton(securityOptions); - -// Доверие прокси-заголовкам (план Task 12; замечание ревью T4/T11): ForwardedHeaders — UseForwardedHeaders -// включается env-переопределением (ForwardedHeaders__Enabled=true в PROD за Caddy, compose-prod Task 14); -// dev-дефолт — false (прокси в dev-стеке нет, compose.dev публикует core напрямую). -ForwardedHeadersConfig forwardedHeadersConfig = builder.Configuration - .GetSection(forwardedHeadersSectionName) - .Get() ?? new ForwardedHeadersConfig(); -builder.Services.AddSingleton(forwardedHeadersConfig); - -// CORS (план Task 12, Ruling 10(2)/9): пустой Security:AllowedOrigins — dev-режим «как в прототипе» -// (любой origin/method/header, credentials=true; AllowAnyOrigin + AllowCredentials несовместимы — любой -// origin разрешается предикатом). Непустой список (PROD, Ruling 9) — строгий allowlist + credentials. -// Security-заголовки ответов (nosniff/X-Frame-Options/Referrer-Policy; CSP/HSTS) — на edge (Caddyfile, -// Task 14): наружу статику и /api отдаёт Caddy, core отвечает JSON — на core не дублируются -// (пересмотр Ruling 10(3), см. task-12-report). -builder.Services.AddCors(options => - options.AddPolicy(corsPolicyName, cors => - { - cors.AllowAnyHeader() - .AllowAnyMethod() - .AllowCredentials(); - if (securityOptions.AllowedOrigins.Length == 0) - { - cors.SetIsOriginAllowed(_ => true); - } - else - { - cors.WithOrigins(securityOptions.AllowedOrigins); - } - })); - -var app = builder.Build(); - -// Эндпоинт метрик /metrics (HTTP/1.1 на отдельном порту): формат Prometheus (этап 12, пакет A). -DealMetricsHosting.MapDealMetrics(app); - -// Fail-closed для Production (Security review): дефолты кода рассчитаны на dev/тесты (rate limit выключен, -// CORS — «любой origin»). Прод-окружение обязано задать защиту ЯВНО — иначе старт отказывается, а не -// молча работает без лимитов/с открытым CORS. -if (app.Environment.IsProduction()) -{ - if (!rateLimitOptions.Enabled) - { - throw new InvalidOperationException( - "Production требует RateLimit__Enabled=true (анти-брутфорс и лимиты выключены код-дефолтом)."); - } - - if (securityOptions.AllowedOrigins.Length == 0) - { - throw new InvalidOperationException( - "Production требует непустой Security__AllowedOrigins (CORS fail-open при пустом списке)."); - } -} - -// Стартовый лог выбранного режима файлового хранилища (приёмка Task 6: запуск Api — LocalFileStorage -// с путём data/attachments; при сконфигурированном MinIO — MinioFileStorage с endpoint/бакетом). -app.Logger.LogInformation("Файловое хранилище: {FileStorage}", app.Services.GetRequiredService()); - -// Стартовый лог режима ML-интеграции (приёмка Task 16): Local-заглушка либо gRPC-клиент ml-service. -app.Logger.LogInformation( - "ML-интеграция: {Mode} ({Endpoint})", - mlOptions.UseLocal ? "Local-заглушка (MlOutbox накапливается)" : "gRPC-клиент ml-service", - mlOptions.Endpoint); - -// Стартовый лог режима AI-интеграции (приёмка Task 15): локальный разбор ядра либо gRPC-клиент ai-service -// под декоратором бюджетного гейта (Task 9: исчерпано/приостановлено → локальный разбор, приём не блокируется). -app.Logger.LogInformation( - "AI-интеграция: {Mode} ({Endpoint})", - aiOptions.UseLocal ? "Local-адаптеры (разбор ядра/инструменты выключены)" : "gRPC-клиент ai-service", - aiOptions.Endpoint); - -// Стартовый лог режима Telegram-гейта (приёмка Task 14): Local-заглушка либо gRPC-клиент telegram-service. -app.Logger.LogInformation( - "Telegram-гейт: {Mode} ({Endpoint})", - telegramOptions.UseLocal ? "Local-заглушка (idle/не подключён)" : "gRPC-клиент telegram-service", - telegramOptions.Endpoint); - -// Стартовый лог транспорта внутреннего gRPC (приёмка Task 13, Ruling 6): dev — plaintext + service-token, -// PROD (DEAL_MTLS_ENABLED=1) — mTLS. Пути/пароли не логируются (Ruling 13). -app.Logger.LogInformation( - "Транспорт внутреннего gRPC: {Transport}", - mtlsOptions.Enabled ? "mTLS (DEAL_MTLS_ENABLED=1, сертификаты из DEAL_MTLS_*)" : "plaintext + service-token (dev)"); - -if (forwardedHeadersConfig.Enabled) -{ - // Прокси-заголовки (план Task 12; замечание ревью T4/T11): X-Forwarded-For/X-Forwarded-Proto доверяются - // только клиентам из ForwardedHeaders:KnownProxies/KnownNetworks (конфиг; appsettings — loopback для dev). - // Middleware — ПЕРВЫЙ в конвейере: RemoteIpAddress/Scheme читают слои ниже (CORS, Session/OperatorSession — - // audit-IP эндпоинтов, RateLimiter — ключи по IP, LoginAttemptGuard). Без него за Caddy (compose-prod, - // Task 14) RemoteIpAddress всех запросов = IP Caddy, и audit-IP + rate-limit-по-IP схлопываются в один бакет. - app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig)); -} - -// Access-лог HTTP (Ruling 7, Task 14): первый в конвейере (после ForwardedHeaders) — длительность -// и статус всего пути обработки. gRPC-ингресс (Content-Type application/grpc) middleware пропускает — -// его логирует интерцептор RpcCallLoggingInterceptor (см. HttpAccessLogMiddleware). -app.UseMiddleware(); - -app.UseCors(corsPolicyName); -app.UseMiddleware(); -app.UseMiddleware(); -if (rateLimitOptions.Enabled) -{ - // Порядок middleware — Ruling 5: Session → Operator → RateLimiter (политика "api" ключует по - // CurrentUser.TenantId либо IP анонима; сессии уже разрешены). При Enabled=false лимитер не - // регистрируется (dev-прогон не режет curl-приёмки; OriginGuard Task 12 встанет после). - app.UseRateLimiter(); -} - -// Origin-проверка мутаций /api (план Task 12, Ruling 10(2)): порядок Ruling 5 — Session → Operator → -// RateLimiter → OriginGuard (сессии разрешены, 429 важнее 403). Проверяются не-GET/HEAD/OPTIONS запросы -// с заголовком Origin: Origin == «свой» origin (схема + Host; за Caddy — https из X-Forwarded-Proto) -// либо входит в Security:AllowedOrigins; иначе 403 {detail}. Без Origin (curl/сервер-сервер) пропускаются; -// SameSite=Lax куки остаётся первым рубежом CSRF (фиксируется в техдок §10, Task 16). -app.UseMiddleware(); - -app.MapGet("/api/health", () => Results.Ok(new { ok = true, service = "deal" })); -app.MapAuthEndpoints(); -app.MapOperatorAuthEndpoints(); -app.MapOperatorAuditEndpoints(); -// Операторская аналитика (план этапа 10, T3): read-only сводка/расход токенов/лента действий. -app.MapOperatorAnalyticsEndpoints(); -app.MapOperatorInvitesEndpoints(); -app.MapOperatorTenantsEndpoints(); -// Операторские лимиты/health (план Task 10, Ruling 3/11): сводка и смена бюджета по тенанту (GET/PATCH -// .../limit, аудит tenant_limit_changed) + health ядра/БД и сервисов ml/ai/telegram. -app.MapOperatorLimitsEndpoints(); -app.MapOperatorHealthEndpoints(); -// Глобальные настройки оператора (ТЗ §4.1/§8.1): ключи приложения Telegram — чтение (маска) и смена. -app.MapOperatorSettingsEndpoints(); -// Обслуживание (этап 12, пакет C): идемпотентная пакетная миграция схем всех тенантов реестра -// (ограниченный параллелизм + логирование прогресса) — для SaaS с сотнями/тысячами схем. -app.MapOperatorMaintenanceEndpoints(); -app.MapJoinEndpoint(); -app.MapSettingsEndpoints(); -app.MapAiCheckEndpoint(); -app.MapRatesEndpoints(); -app.MapMlEndpoints(); -app.MapFilterTesterEndpoints(); -app.MapContainersEndpoints(); -app.MapCardsEndpoints(); -app.MapCardDetailsEndpoints(); -app.MapStorageEndpoints(); -app.MapEventsEndpoint(); -app.MapAiSuggestEndpoints(); -app.MapPipelineEndpoints(); -// Эндпоинты /api/tg (план Task 14, Ruling 8): реальный контракт вкладки «Каналы» вместо boot-заглушки -// (BootStubEndpoints удалён); qr-image — отдельным файлом. -app.MapTelegramEndpoints(); -app.MapTelegramQrImageEndpoint(); -// Эндпоинты /api/discovery (план Task 19, Ruling 11): задачи поиска/кандидаты/чёрный список/лог/generate-keywords -// (DiscoveryEndpoints, 1:1 api-map §3.8) — модуль Discovery зарегистрирован AddDiscoveryModule выше. -app.MapDiscoveryEndpoints(); -// gRPC-ингресс и его health освобождены от HTTP-политик rate limiter (план Task 11, Ruling 5): лимит -// входящего потока считает IngressRateLimitInterceptor по tenant-id из metadata (иначе общее окно на IP -// telegram-service резало бы весь ингресс раньше интерцептора); health — инфраструктурный liveness. -app.MapGrpcService().DisableRateLimiting(); -app.MapGrpcHealthChecksService().DisableRateLimiting(); - -app.Run(); - -// ── Kestrel: основной HTTP/1.1-эндпоинт из URL-конфигурации (явные Listen заменяют URL-биндинг) ── - -// Биндит основной HTTP-эндпоинт(ы) из ключа "urls" (--urls/ASPNETCORE_URLS/launchSettings): без явных -// адресов используется фолбэк defaultHttpUrl (как дефолт ASP.NET Core). Схема https — сертификат из -// стандартной конфигурации Kestrel (UseHttps без аргументов), как при URL-биндинге. -static void BindMainHttpEndpoints(KestrelServerOptions kestrel, string? urlsConfig) -{ - List addresses = ParseHttpAddresses(urlsConfig); - if (addresses.Count == 0) - { - addresses.Add(new Uri(defaultHttpUrl)); - } - - foreach (Uri address in addresses) - { - // Dev-запуски используют loopback («localhost»/127.0.0.1) — ListenLocalhost; «0.0.0.0»/«*»/«+»/«::» - // (compose/host) — все интерфейсы. Иных хостов в конфигурации нет (URL-хосты не биндятся по имени). - bool isHttps = string.Equals(address.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); - string host = address.Host.Trim('[', ']'); - if (host is "0.0.0.0" or "*" or "+" or "::") - { - kestrel.Listen(IPAddress.Any, address.Port, listen => - { - if (isHttps) - { - listen.UseHttps(); - } - }); - } - else if (isHttps) - { - kestrel.ListenLocalhost(address.Port, listen => listen.UseHttps()); - } - else - { - kestrel.ListenLocalhost(address.Port); - } - } -} - -// Разбирает адреса конфигурации "urls" (http/https, разделитель «;»); невалидные/чужие схемы пропускаются. -static List ParseHttpAddresses(string? urlsConfig) -{ - var addresses = new List(); - if (string.IsNullOrWhiteSpace(urlsConfig)) - { - return addresses; - } - - foreach (string part in urlsConfig.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - if (Uri.TryCreate(part, UriKind.Absolute, out Uri? uri) - && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) - { - addresses.Add(uri); - } - } - - return addresses; -} - -// Парсит порт из env-строки; пустое/нечисловое значение — null (фолбэк дефолта). -static int? ParsePort(string? rawValue) - => int.TryParse(rawValue, out int parsedPort) ? parsedPort : null; - -// Дефолт-бюджет нового тенанта из env DEAL_DEFAULT_AI_BUDGET (Ruling 3, Task 8): нечисловое/неположительное -// значение (пустая переменная, опечатка) — константа модуля TokenBudgetDefaults.DefaultBudgetTokens. Период -// всегда month — оператор меняет бюджет/период позже через PATCH лимита (Task 10). -long ResolveDefaultAiBudget(IConfiguration configuration) -{ - string? rawValue = configuration[defaultAiBudgetEnvKey]; - return long.TryParse(rawValue, out long parsedBudget) && parsedBudget > 0 - ? parsedBudget - : TokenBudgetDefaults.DefaultBudgetTokens; -} - -public partial class Program -{ - /// - /// Строит опции UseForwardedHeaders из ForwardedHeadersConfig (план Task 12; замечание ревью - /// T4/T11): обрабатываются X-Forwarded-For/X-Forwarded-Proto ровно одного доверенного hop'а. Списки - /// доверия — строго из конфига KnownProxies/KnownNetworks (appsettings-дефолт — loopback); невалидный - /// IP/CIDR — InvalidOperationException (fail-fast: опечатка в настройке доверия не должна молча - /// отключать обработку). Публичный: unit-тесты опций (ForwardedHeadersHttpTests). - /// - /// Секция ForwardedHeaders конфигурации. - /// Опции для app.UseForwardedHeaders. - public static ForwardedHeadersOptions BuildForwardedHeadersOptions(ForwardedHeadersConfig config) - { - ArgumentNullException.ThrowIfNull(config); - var options = new ForwardedHeadersOptions - { - // Адрес клиента и схема (Origin-проверка «своего» origin за Caddy требует https из - // X-Forwarded-Proto). X-Forwarded-Host не нужен: Caddy транслирует Host без перезаписи. - ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, - // Между клиентом и core ровно один прокси (Caddy): читаем ближайший к нам элемент цепочки. - ForwardLimit = 1, - }; - // Доверие — только явный конфиг + loopback-фолбэк ниже: очищаем дефолты конструктора, чтобы - // перечисление в KnownProxies/KnownIPNetworks было единственным источником истины. - options.KnownProxies.Clear(); - options.KnownIPNetworks.Clear(); - foreach (string proxy in config.KnownProxies) - { - if (!IPAddress.TryParse(proxy, out IPAddress? address)) - { - throw new InvalidOperationException( - $"Невалидный IP доверенного прокси в ForwardedHeaders:KnownProxies: \"{proxy}\"."); - } - - options.KnownProxies.Add(address); - } - - foreach (string network in config.KnownNetworks) - { - if (!TryParseCidr(network, out System.Net.IPNetwork parsedNetwork)) - { - throw new InvalidOperationException( - $"Невалидная подсеть доверенного прокси в ForwardedHeaders:KnownNetworks: \"{network}\" " - + "(ожидается CIDR, напр. 172.16.0.0/12)."); - } - - options.KnownIPNetworks.Add(parsedNetwork); - } - - if (options.KnownProxies.Count == 0 && options.KnownIPNetworks.Count == 0) - { - // Пустые списки KnownProxies/KnownIPNetworks у ForwardedHeadersMiddleware означают «доверять - // ЛЮБОМУ клиенту» (спуфинг XFF) — пустоту до middleware не допускаем. Дефолт без явного - // конфига — loopback (dev-прокси на хосте: vite/локальный Caddy, см. appsettings); PROD - // перечисляет Caddy адресами/подсетями в конфиге. - options.KnownProxies.Add(IPAddress.Loopback); - options.KnownProxies.Add(IPAddress.IPv6Loopback); - } - - return options; - } - - // Разбирает CIDR-запись подсети ("127.0.0.0/8", "2001:db8::/32"); без префикса — вся - // подсеть одного адреса (IPv4 — /32, IPv6 — /128). - // value: Строка конфига (ForwardedHeaders:KnownNetworks). - // network: Разобранная подсеть при успехе. - // Возвращает: true — запись валидна. - private static bool TryParseCidr(string value, out System.Net.IPNetwork network) - { - network = default; - int slashIndex = value.IndexOf('/'); - string addressPart = slashIndex >= 0 ? value[..slashIndex] : value; - if (!IPAddress.TryParse(addressPart, out IPAddress? address)) - { - return false; - } - - int maxPrefixLength = address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128; - int prefixLength = maxPrefixLength; - if (slashIndex >= 0) - { - string lengthPart = value[(slashIndex + 1)..]; - if (!int.TryParse(lengthPart, out prefixLength) || prefixLength < 0 || prefixLength > maxPrefixLength) - { - return false; - } - } - - network = new System.Net.IPNetwork(address, prefixLength); - return true; - } -} +using System.Net; +using System.Net.Sockets; +using System.Text.Encodings.Web; +using Deal.Api.Configuration; +using Deal.Api.Endpoints; +using Deal.Api.Events; +using Deal.Api.Hosting; +using Deal.Api.Logging; +using Deal.Api.Middleware; +using Deal.Api.Observability; +using Deal.Api.Services; +using Deal.Api.Telegram; +using Deal.Contracts.Integrations.Abstractions; +using Deal.Infrastructure; +using Deal.Infrastructure.Data; +using Deal.Infrastructure.Integrations.Models; +using Deal.Infrastructure.Integrations.Options; +using Deal.Infrastructure.Integrations.Services; +using Deal.Infrastructure.Integrations.Storage.Services; +using Deal.Infrastructure.Persistence; +using Deal.Infrastructure.Services; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Telegram.Application; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.SharedKernel.Tenants.Abstractions; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Https; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; +// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. +using CookieOptions = Deal.Api.Configuration.CookieOptions; + +const string cookiesSectionName = "Cookies"; +// Секция настроек куки оператора (Ruling 1): имя deal_operator_session, срок 12 ч, Secure из конфига. +const string operatorCookiesSectionName = "OperatorCookies"; +// Имя CORS-политики (план Task 12, Ruling 10): одна политика, режим зависит от Security:AllowedOrigins. +const string corsPolicyName = "cors"; +// Секции конфигурации интеграций (Ruling 6): Services:Ml / Services:Ai / Services:Telegram → {UseLocal, Endpoint}. +const string servicesSectionName = "Services:Ml"; +const string aiServicesSectionName = "Services:Ai"; +const string telegramServicesSectionName = "Services:Telegram"; +// Имя истории tenant-миграций без схемы (схема — через search_path; миграции применяет +// TenantProvisioningService на старте, runtime-контекст их не выполняет). +const string tenantMigrationsHistoryTable = "__TenantMigrationsHistory"; +// Порт gRPC-ингресса по умолчанию (Ruling 7): :5082; переопределяется env GRPC_INGRESS_PORT. +const int defaultIngressPort = 5082; +const string ingressPortEnvKey = "GRPC_INGRESS_PORT"; +// Ключ конфигурации адресов основного HTTP-эндпоинта (--urls/ASPNETCORE_URLS/launchSettings). +const string serverUrlsKey = "urls"; +// Фолбэк основного HTTP-адреса при отсутствии явных URL (дефолт ASP.NET Core http://localhost:5000). +const string defaultHttpUrl = "http://localhost:5000"; +// Ключ env-переопределения дефолт-бюджета нового тенанта (Ruling 3; токенов в месяц, период — всегда month). +const string defaultAiBudgetEnvKey = "DEAL_DEFAULT_AI_BUDGET"; +// Секция настроек rate limiting (план Task 11, Ruling 5): RateLimit:Enabled=false в dev/тестах по +// умолчанию (curl-приёмки не режутся); PROD включает env-переопределением RateLimit__Enabled=true +// (compose-prod, Task 14). +const string rateLimitSectionName = "RateLimit"; +// Секция авто-очистки данных (этап 12, пакет B): DataRetention:AuditRetentionDays (дефолт 180) + Enabled — +// фоновый цикл DataRetentionScheduler чистит audit_log по retention, лимиты/счётчики прошедших окон. +const string dataRetentionSectionName = "DataRetention"; +// Секция настроек безопасности HTTP (план Task 12, Ruling 10): Security:AllowedOrigins — явный allowlist +// Origin-проверки/CORS (пусто — dev-режим «свой origin» + CORS-любой; PROD — домены фронта, Ruling 9). +const string securitySectionName = "Security"; +// Секция доверия прокси-заголовкам (план Task 12; замечание ревью T4/T11): ForwardedHeaders:KnownProxies/ +// KnownNetworks — доверенные прокси (Caddy в PROD); dev-дефолт в appsettings — loopback. +const string forwardedHeadersSectionName = "ForwardedHeaders"; + +// Имя процесса для rolling-файла логов (Ruling 7, Task 14): data/logs/deal-core-<дата>.json. +const string coreProcessName = "core"; + +var builder = WebApplication.CreateBuilder(args); + +// Структурированные логи Serilog (Ruling 7, план Task 14): консоль JSON в prod / текст в dev + +// rolling-файл data/logs/deal-core-*.json (data — volume контейнера). Уровень/каталог — env +// DEAL_LOG_LEVEL/DEAL_LOGS_DIR (см. DealLogging). Регистрируется до остальных сервисов: логирование +// заменяет провайдеры Microsoft при builder.Build(). +DealLogging.Configure(builder, coreProcessName); + +// Метрики OpenTelemetry → Prometheus (этап 12, пакет A): эндпоинт /metrics в формате Prometheus на +// отдельном HTTP/1.1 Kestrel-эндпоинте (порт 9464/env METRICS_PORT) + инструментация входящих HTTP- +// запросов и исходящих HTTP-клиентов; прикладные метрики — SharedKernel/Observability/DealMetrics. +int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); +DealMetricsHosting.AddDealMetrics(builder, metricsPort); + +// Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует +// (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД. +var connectionString = builder.Configuration.GetConnectionString("DealPostgres") + ?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан"); +builder.Services.AddDbContext(options => options.UseNpgsql(connectionString)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// mTLS внутреннего gRPC-транспорта (Ruling 6, план Task 13): env DEAL_MTLS_* — флаг и пути/пароли +// сертификатов (только env, Ruling 13). Dev-дефолт — выключено: сервисы и ингресс остаются на plaintext + +// service-token (Ruling 2 этапа 6); compose-prod (Task 14) передаёт env и монтирует deploy/certs +// (генерация — scripts/mtls-certs.sh). При DEAL_MTLS_ENABLED=1 сертификаты грузятся сразу (fail-fast на +// битые пути/пароли) — один экземпляр используют и Kestrel-ингресс ниже, и gRPC-клиенты +// (Ml/Ai/Telegram-каналы + ServiceHealthProbe). +MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(builder.Configuration); +MtlsCertificates? mtlsCertificates = MtlsCertificates.Load(mtlsOptions); +if (mtlsCertificates is not null) +{ + builder.Services.AddSingleton(mtlsCertificates); +} + +// Kestrel: основной HTTP/1.1-эндпоинт из URL-конфигурации (как раньше — --urls/ASPNETCORE_URLS/ +// launchSettings) + второй endpoint gRPC-ингресса telegram-service (:5082, HTTP/2, env GRPC_INGRESS_PORT; +// план Task 12, Ruling 7). Явные Listen заменяют URL-биндинг Kestrel, поэтому основной эндпоинт +// пере-биндим адресами конфигурации "urls" явно (см. BindMainHttpEndpoints ниже). Ingress слушает все +// интерфейсы (AnyIP): в dev к нему ходит telegram-service из compose-сети через host.docker.internal (Ruling 12). +builder.WebHost.ConfigureKestrel(kestrel => +{ + BindMainHttpEndpoints(kestrel, builder.Configuration[serverUrlsKey]); + int ingressPort = ParsePort(builder.Configuration[ingressPortEnvKey]) ?? defaultIngressPort; + kestrel.ListenAnyIP(ingressPort, listen => + { + listen.Protocols = HttpProtocols.Http2; + if (mtlsCertificates is not null) + { + // mTLS-ингресс (Ruling 6, Task 13): HTTPS с серверным сертификатом core + обязательный клиентский + // сертификат (цепочка до CA из DEAL_MTLS_CA_PEM). Основной HTTP :5080 остаётся http — TLS наружу + // терминирует Caddy (Ruling 9, compose-prod Task 14). + listen.UseHttps(https => + { + https.ServerCertificate = mtlsCertificates.ServerCertificate; + https.ClientCertificateMode = ClientCertificateMode.RequireCertificate; + https.ClientCertificateValidation = mtlsCertificates.ValidateClientCertificate; + }); + } + }); +}); + +// TenantDbContext — scoped-контекст бессхемной модели тенанта (таблица settings и др.): строка +// подключения на каждый scope строится по текущему ITenantContext (заполняет SessionMiddleware) +// с Search Path на схему тенанта (ConnectionStringProvider.ForTenant). Опции живут в scope запроса +// (optionsLifetime: Scoped) — иначе опции с первым тенантом закешировались бы в singleton. +// Безопасность: вне tenant-запроса (нет сессии) контекст не имеет смысла — ошибка конфигурации. +builder.Services.AddDbContext( + (serviceProvider, options) => + { + var tenantContext = serviceProvider.GetRequiredService(); + if (!tenantContext.HasTenant) + { + throw new InvalidOperationException( + "TenantDbContext запрошен вне tenant-запроса: на запрос не разрешена сессия " + + "(ITenantContext.HasTenant == false)."); + } + + var connectionStringProvider = serviceProvider.GetRequiredService(); + options.UseNpgsql( + connectionStringProvider.ForTenant(tenantContext.TenantId), + npgsql => npgsql.MigrationsHistoryTable(tenantMigrationsHistoryTable)); + }, + contextLifetime: ServiceLifetime.Scoped, + optionsLifetime: ServiceLifetime.Scoped); + +// Модуль Tenants и его EF-адаптеры («port & adapter», Ruling 1). +builder.Services.AddTenantsModule(); + +// Лимиты ИИ-бюджета (Ruling 3, Task 8): дефолт-бюджет лениво создаваемой строки public.tenant_limits — +// env DEAL_DEFAULT_AI_BUDGET (токенов в месяц) с фолбэком на константу модуля TokenBudgetDefaults (10 000 000); +// период нового тенанта — month (константа). Значение читается один раз на старте и передаётся адаптеру +// TenantLimitStore (GetOrCreateAsync при первом чтении/списании, задачи 7/10 list-путь тоже закрыт). +TokenLimitDefaults tenantLimitDefaults = new( + ResolveDefaultAiBudget(builder.Configuration), TokenBudgetDefaults.DefaultPeriod); +builder.Services.AddDealPersistence(tenantLimitDefaults); + +// Шифрование секретов (Ruling 2): ISecretCipher — AES-256-GCM; ключ из DEAL_ENCRYPTION_KEY +// либо файла data/encryption.key под ContentRoot (dev). Ключ разрешается на старте — +// невалидный env-ключ останавливает запуск. +builder.Services.AddDealSecurity(builder.Environment.ContentRootPath); + +// Внешние интеграции (Tasks 9/16/15, Rulings 4/5/6/9): IMlClient — детерминированная заглушка LocalMlClient +// (Services:Ml:UseLocal=true, default; обучение — этап 3) либо gRPC-клиент GrpcMlClient (UseLocal=false, +// ml-service :5103). Local-адаптеры читают KV-настройки тенанта через ISettingsStore — scoped (вне +// tenant-запроса не разрешимы). IColumnSuggester — LocalColumnSuggester (эвристика, Self-Review L525–527). +MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get() ?? new MlServiceOptions(); +builder.Services.AddSingleton(mlOptions); +// AI-интеграция (план Task 15, Ruling 6/9): IAiClassifier/IAiTools — Local-адаптеры (Services:Ai:UseLocal=true, +// default; локальный разбор ядра / инструменты не поддерживаются) либо декораторы бюджетного гейта поверх +// gRPC-клиентов ai-service (UseLocal=false, ai-service :5102; AddDealIntegrations регистрирует GrpcAiClassifier/ +// GrpcAiTools + транспорт AiGrpcConnection — fail-fast, как MlGrpcConnection — и оборачивает их в +// BudgetedAiClassifier/BudgetedAiTools, Ruling 3/Task 9). +AiServiceOptions aiOptions = builder.Configuration.GetSection(aiServicesSectionName).Get() ?? new AiServiceOptions(); +builder.Services.AddSingleton(aiOptions); +// Telegram-гейт (план Task 14, Ruling 6/7): LocalTelegramGateway (Services:Telegram:UseLocal=true, default — +// нейтральный no-op/idle) либо gRPC-клиент GrpcTelegramClient (UseLocal=false, telegram-service :5101). +TelegramServiceOptions telegramOptions = builder.Configuration.GetSection(telegramServicesSectionName).Get() ?? new TelegramServiceOptions(); +builder.Services.AddSingleton(telegramOptions); +builder.Services.AddDealIntegrations(mlOptions, aiOptions, telegramOptions, mtlsCertificates); + +// Health-проба автономных сервисов для операторского health (план Task 10, Ruling 3/6/9): grpc.health.v1 +// к Services:*:Endpoint с дедлайном 3 с (ServiceHealthProbe). Stateless, singleton — пробы строят +// короткоживущие каналы на каждый вызов (mTLS-каналы — при включённом флаге, Task 13/Ruling 6). +builder.Services.AddSingleton(new ServiceHealthProbe(mtlsCertificates)); + +// Файловое хранилище вложений проектных карточек (Ruling 4, Task 6): LocalFileStorage (data/attachments +// под ContentRoot) — dev/curl/unit по умолчанию; MinioFileStorage регистрируется, только когда сконфигурирован +// MinIO (секция Storage:Minio либо env-алиасы DEAL_MINIO_*; compose-сервис deal-minio, порты 9000/9001). +// Singleton: хранилище не привязано к схеме тенанта (объекты — в едином бакете/каталоге, мульти-аренда +// объектного хранилища — этап 7 SaaS). Режим логируется на старте (см. ниже) — приёмка Task 6. +builder.Services.AddDealFileStorage(builder.Configuration, builder.Environment.ContentRootPath); + +// Модуль Settings (сервис настроек тенанта); адаптеры ISettingsStore/ISecretCipher уже +// зарегистрированы AddDealPersistence/AddDealSecurity выше (см. Task 4). +builder.Services.AddSettingsModule(); + +// Модуль Kanban — единый домен карточки (Ruling 12): регистратор сервисов карточек/контейнеров/тиков; +// порт-адаптер ICardStore → KanbanStore уже зарегистрирован AddDealPersistence. +builder.Services.AddKanbanModule(); + +// Модуль Pipeline (Ruling 10, Task 9): приём/обработка/воркер и ядра разбора этапа 4. Порт-адаптер +// IPipelineStore → PipelineStore и внешние порты (IMlClient/IAiClassifier) уже зарегистрированы +// AddDealPersistence/AddDealIntegrations выше; сервисы модуля вызывают из эндпоинтов /api/pipeline/* +// и gRPC-ингресса telegram-service, pump — admin/tick (Task 10) и фоновый цикл (Task 11). +builder.Services.AddPipelineModule(); + +// Модуль Telegram (план Task 13, Ruling 7): сервис каталога диалогов (DialogsService) — владелец таблиц +// Dialogs/TgMessages схемы тенанта (миграция TenantTelegram). Порт-адаптеры ITelegramStore → TelegramStore и +// ITelegramGateway → LocalTelegramGateway/GrpcTelegramClient зарегистрированы AddDealPersistence/AddDealIntegrations +// выше; сервис зовут gRPC-ингресс (SyncDialogs/PushMessage) и эндпоинты /api/tg (Task 14). +builder.Services.AddTelegramModule(); + +// Модуль Discovery (план Task 17/18, Ruling 9/10): сервисы задач/кандидатов/чёрного списка/лога, план-бюджет +// и воркер (оценка/бан-гард/паузы). Порт-адаптер IDiscoveryStore → DiscoveryStore зарегистрирован +// AddDealPersistence; внешние порты (ITelegramGateway/IAiTools/IMlClient) — AddDealIntegrations выше. Эндпоинты +// /api/discovery добавляет Task 19; фоновый цикл воркера — DiscoveryWorkerScheduler ниже. +builder.Services.AddDiscoveryModule(); + +// Статус/ключи вкладки Telegram (план Task 14, Ruling 8): сборка GET /api/tg/status (гейт + KV tgAccount + +// счётчик мониторящихся + keysSet) и чтение глобальных ключей приложения (telegramKeys в public.global_settings, +// расшифровка apiHash — задаёт оператор, ТЗ §4.1/§8.1). Scoped: зависимости — ISettingsStore/ITelegramStore +// на TenantDbContext схемы тенанта запроса, IGlobalSettingsStore — на системном DealDbContext. +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Фоновые спуски перечитывания каналов (план Task 14, Ruling 8; аналог python-_spawn роутеров): эндпоинты +// мониторинга/«Перечитать» отвечают сразу, тяжёлый разбор идёт в отдельном scope с захваченным tenant-контекстом. +builder.Services.AddSingleton(); + +// Оркестратор ручного тика (план Task 10, Rulings 8/9): тик правил хранения (Kanban) + очистка отсева +// Pipeline + один проход pump + SSE-публикации (тосты/new_card) для POST /api/admin/tick (StorageEndpoints). +// Scoped: зависимости живут в рамках tenant-запроса (scoped-сервисы модулей на TenantDbContext схемы). +builder.Services.AddScoped(); + +// Обслуживание FTS-индексов схемы тенанта (Ruling 6, план Task 10): POST /api/admin/fts/rebuild — +// CREATE INDEX IF NOT EXISTS + ANALYZE (FtsMaintenance) на TenantDbContext запроса (scoped, как адаптеры). +builder.Services.AddScoped(); + +// SSE-брокер этапа (Ruling 5): singleton per-tenant каналов событий; подписка — GET /api/events, +// публикации — из эндпоинтов Api после вызова сервисов (Tasks 10/13/14). +builder.Services.AddSingleton(); + +// SSE-тосты статистики тика правил хранения (Ruling 8): единый хелпер для POST /api/admin/tick +// (StorageEndpoints) и фонового StorageTickScheduler (Task 11) — без дублирования текстов/иконок. +builder.Services.AddSingleton(); + +// Общий воркер-гейт pump тенанта (план Task 11, Ruling 8; аналог asyncio.Lock pipeline.py L38–42): +// POST /api/admin/tick (AdminTickOrchestrator) и фоновый цикл разбора очереди (PipelineWorkerScheduler) +// не разбирают очередь одного тенанта одновременно (singleton per-tenant флагов, Interlocked). +builder.Services.AddSingleton(); + +// Rate limiting и защита входа (план Task 11, Ruling 5; этап 12, пакет B — хранилище на Postgres): секция +// "RateLimit" (appsettings.json + env RateLimit__*). Enabled=false в dev/тестах — политики/middleware/ +// интерцептор не регистрируются вовсе (Ruling 5: «в dev выключено — curl-приёмки не режутся»); +// LoginAttemptGuard (окно ip|login 5 неудач/15 мин в public.rate_limit_counters) регистрируется всегда, +// но активен только при Enabled. +RateLimitOptions rateLimitOptions = builder.Configuration + .GetSection(rateLimitSectionName) + .Get() ?? new RateLimitOptions(); +builder.Services.AddSingleton(rateLimitOptions); +// Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер +// (public.rate_limit_counters). Активен только при Enabled (no-op иначе). +builder.Services.AddScoped(); +if (rateLimitOptions.Enabled) +{ + builder.Services.AddDealRateLimiter(rateLimitOptions); +} + +// gRPC-ингресс telegram-service (план Task 12, Ruling 1/7): сервер Deal.Grpc.Telegram.IngressService +// на отдельном Kestrel-endpoint (:5082, HTTP/2, см. ConfigureKestrel выше) в том же процессе. Token +// из metadata «service-token» проверяет интерцептор (fail-closed, DEAL_SERVICE_TOKEN); AddAuthentication +// не нужен — пользовательская сессия HTTP ингрессом не используется (tenant-id из metadata → SetTenant). +builder.Services.AddGrpc(grpc => +{ + // Access-лог RPC ингресса (Ruling 7, Task 14): ПЕРВЫМ в цепочке — логируются и отклонённые + // вызовы (401/429); gRPC-health не логируется (см. RpcCallLoggingInterceptor). + grpc.Interceptors.Add(); + grpc.Interceptors.Add(); + if (rateLimitOptions.Enabled) + { + // Лимит входящего потока по tenant-id (план Task 11, Ruling 5): окно считает общий + // singleton-лимитер (CreateLimiter) — экземпляры интерцептора общий PartitionedRateLimiter + // разделяют; health-методы освобождены (см. IngressRateLimitInterceptor). + grpc.Interceptors.Add(); + } +}); +if (rateLimitOptions.Enabled) +{ + builder.Services.AddSingleton(provider => + IngressRateLimitInterceptor.CreateLimiter( + provider.GetRequiredService(), + rateLimitOptions.GrpcIngressPerMinute)); +} + +builder.Services.AddScoped(); + +// gRPC-health ингресса (план Task 20, Ruling 12): healthcheck контейнера core в docker compose. +// grpc.health.v1.Health интерцептор токеном не проверяет (инфраструктурный liveness, как в сервисах +// этапа T2–T4); регистрируется явная проверка "ready" — без неё health-сервис отвечает UNKNOWN. +// Живучесть интеграций (ml/ai/telegram) health не проверяет — недоступность сервиса это UNAVAILABLE +// на RPC и фолбэк Local-адаптеров, а не падение хоста (Ruling 6). +builder.Services + .AddGrpcHealthChecks() + .AddCheck("ready", () => HealthCheckResult.Healthy("хост Deal.Api готов")); + +// Проверка подключения AI-провайдера (Task 6, Ruling 7): порт модуля IAiConnectionChecker → +// HTTP-адаптер Infrastructure с собственным HttpClient (фабрика AddHttpClient, таймаут 12 с). +// HTTP наружу ходит только по действию Settings-экрана (POST /api/ai/check) — GET {base}/models. +builder.Services.AddHttpClient( + client => client.Timeout = TimeSpan.FromSeconds(AiConnectionChecker.RequestTimeoutSeconds)); + +// Курсы валют (Task 8, Ruling 6): порт модуля IRatesSource → HTTP-адаптер ЦБ с собственным +// HttpClient (таймаут 15 с, как httpx timeout=15 в rates.py). URL — фиксированная константа +// адаптера (SSRF-allowlist), источник тенантом не настраивается. Типизированный клиент +// регистрируется transient и живёт в рамках scope запроса (как IAiConnectionChecker, Task 6). +builder.Services.AddHttpClient( + client => client.Timeout = TimeSpan.FromSeconds(CbrRateSource.RequestTimeoutSeconds)); + +// Фоновое обновление кэша курсов вне запроса (Ruling 6): PATCH rateSource и лениво на GET — +// собственный scope + in-flight guard (см. RatesRefreshScheduler). +builder.Services.AddSingleton(); + +// Bootstrap при старте (Ruling 8): дефолтный тенант + admin, провижининг схем всех тенантов. +builder.Services.AddHostedService(); + +// Bootstrap оператора при старте (Ruling 1 этапа 7): env DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD, +// dev-дефолт operator/operator в Development; в Production без env — warning и пропуск. Идёт после +// TenantBootstrapService: операторские public-таблицы не зависят от провижининга схем тенантов. +builder.Services.AddHostedService(); + +// Фоновый цикл правил хранения (план Task 11, Ruling 8; аналог _storage_loop main.py L43–53): каждые +// 30 с тикает ВСЕ тенанты (StorageTickService + автоочистка отсева пайплайна 3 суток) и публикует +// SSE-тосты. Регистрируется после Bootstrap — первый проход стартует уже после провижининга схем. +builder.Services.AddHostedService(); + +// Фоновый цикл SSE-алертов ИИ-бюджета (план Task 9, Ruling 3; эталон StorageTickScheduler): каждые 60 с +// проверяет ВСЕ тенанты и публикует в канал тенанта тост при пересечении порогов 80/100% (TryMark*-CAS — +// один тост на порог за период). Идёт после Bootstrap: реестр тенантов провижинен до первого прохода. +builder.Services.AddHostedService(); + +// Фоновый цикл разбора очереди входящих (план Task 11, Ruling 8/11; аналог _pipeline_loop main.py L79–88): +// каждые 2 с pump'ит ВСЕ тенанты (PipelineWorkerService.PumpOnceAsync под общим PipelinePumpGate) и +// публикует SSE new_card по созданным карточкам — ingest разбирается без ручного tick (приёмка Task 11). +// После StorageTickScheduler: очередь цикла — 2 с, первый проход сразу после старта. +builder.Services.AddHostedService(); + +// Фоновый флашер очереди обучения ML (план Task 16, Ruling 6; аналог _ml_sync_loop main.py): каждые 10 с +// выгружает MlOutbox тенантов в ml-service (TrainBatch, порции по 10, ≤100/цикл; удаление после успеха). +// Регистрируется только в gRPC-режиме (UseLocal=false) — Local-режиму (этапы 2–5) сервис не нужен, очередь +// копится до подключения ml-service (python L56–82); MlGrpcConnection создан в AddDealIntegrations (fail-fast). +if (!mlOptions.UseLocal) +{ + builder.Services.AddHostedService(); +} + +// Фоновый цикл Discovery-воркера (план Task 18, Ruling 10; аналог _discovery_loop main.py): каждые 5 с +// делает ОДИН шаг (поиск/оценка/авто-вступление/done) для каждой running-задачи всех тенантов. Работает +// всегда: в Local-режиме гейт нейтрален (поиск пуст/история недоступна), реальные действия — при +// подключённом telegram-service (UseLocal=false). После Bootstrap: первый проход стартует после провижининга. +builder.Services.AddHostedService(); + +// Сборщик глубин очередей/сессий (этап 12, §10.2): общий источник для метрик и операторского health. +builder.Services.AddSingleton(); + +// Фоновый сборщик gauge-метрик (этап 12, пакет A): каждые 15 с публикует глубины очередей (пайплайн, +// MlOutbox) по всем тенантам и число активных сессий в meter Deal (callback /metrics отдаёт их Prometheus). +// Регистрируется последним из фоновых: после Bootstrap (реестр тенантов провижинен до первого прохода). +builder.Services.AddHostedService(); + +// Фоновый цикл авто-очистки данных (этап 12, пакет B; эталон DealMetricsCollector): раз в сутки удаляет +// записи audit_log старше DataRetention:AuditRetentionDays (дефолт 180 дней), сбрасывает накопительные +// поля лимитов прошедших периодов и убирает завершившиеся окна распределённых счётчиков. Регистрируется +// последним из фоновых: после Bootstrap (реестр тенантов провижинен до первого прохода). +DataRetentionOptions dataRetentionOptions = builder.Configuration + .GetSection(dataRetentionSectionName) + .Get() ?? new DataRetentionOptions(); +builder.Services.AddSingleton(dataRetentionOptions); +builder.Services.AddHostedService(); + +// Кука сессии: имя/срок/Secure из секции "Cookies" (appsettings.json + env Cookies__*). +builder.Services.Configure(builder.Configuration.GetSection(cookiesSectionName)); + +// Кука операторской сессии (Ruling 1 этапа 7): имя deal_operator_session/срок/Secure из секции +// "OperatorCookies" (appsettings.json + env OperatorCookies__*) — отдельная от тенантной deal_session. +builder.Services.Configure(builder.Configuration.GetSection(operatorCookiesSectionName)); + +// Ответы JSON — как в прототипе FastAPI: без \u-экранирования не-ASCII символов. +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping); + +// Безопасность HTTP (план Task 12, Ruling 10(2)/9): Security:AllowedOrigins — явный allowlist Origin- +// проверки мутаций и CORS (пусто — dev-режим «свой origin», см. AddCors ниже; PROD — домен фронта в +// compose-prod). Инстанс регистрируется в DI: значение читается один раз на старте (политики формируются +// при старте хоста), OriginGuardMiddleware получает его конструктором. +SecurityOptions securityOptions = builder.Configuration + .GetSection(securitySectionName) + .Get() ?? new SecurityOptions(); +builder.Services.AddSingleton(securityOptions); + +// Доверие прокси-заголовкам (план Task 12; замечание ревью T4/T11): ForwardedHeaders — UseForwardedHeaders +// включается env-переопределением (ForwardedHeaders__Enabled=true в PROD за Caddy, compose-prod Task 14); +// dev-дефолт — false (прокси в dev-стеке нет, compose.dev публикует core напрямую). +ForwardedHeadersConfig forwardedHeadersConfig = builder.Configuration + .GetSection(forwardedHeadersSectionName) + .Get() ?? new ForwardedHeadersConfig(); +builder.Services.AddSingleton(forwardedHeadersConfig); + +// CORS (план Task 12, Ruling 10(2)/9): пустой Security:AllowedOrigins — dev-режим «как в прототипе» +// (любой origin/method/header, credentials=true; AllowAnyOrigin + AllowCredentials несовместимы — любой +// origin разрешается предикатом). Непустой список (PROD, Ruling 9) — строгий allowlist + credentials. +// Security-заголовки ответов (nosniff/X-Frame-Options/Referrer-Policy; CSP/HSTS) — на edge (Caddyfile, +// Task 14): наружу статику и /api отдаёт Caddy, core отвечает JSON — на core не дублируются +// (пересмотр Ruling 10(3), см. task-12-report). +builder.Services.AddCors(options => + options.AddPolicy(corsPolicyName, cors => + { + cors.AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + if (securityOptions.AllowedOrigins.Length == 0) + { + cors.SetIsOriginAllowed(_ => true); + } + else + { + cors.WithOrigins(securityOptions.AllowedOrigins); + } + })); + +var app = builder.Build(); + +// Эндпоинт метрик /metrics (HTTP/1.1 на отдельном порту): формат Prometheus (этап 12, пакет A). +DealMetricsHosting.MapDealMetrics(app); + +// Fail-closed для Production (Security review): дефолты кода рассчитаны на dev/тесты (rate limit выключен, +// CORS — «любой origin»). Прод-окружение обязано задать защиту ЯВНО — иначе старт отказывается, а не +// молча работает без лимитов/с открытым CORS. +if (app.Environment.IsProduction()) +{ + if (!rateLimitOptions.Enabled) + { + throw new InvalidOperationException( + "Production требует RateLimit__Enabled=true (анти-брутфорс и лимиты выключены код-дефолтом)."); + } + + if (securityOptions.AllowedOrigins.Length == 0) + { + throw new InvalidOperationException( + "Production требует непустой Security__AllowedOrigins (CORS fail-open при пустом списке)."); + } +} + +// Стартовый лог выбранного режима файлового хранилища (приёмка Task 6: запуск Api — LocalFileStorage +// с путём data/attachments; при сконфигурированном MinIO — MinioFileStorage с endpoint/бакетом). +app.Logger.LogInformation("Файловое хранилище: {FileStorage}", app.Services.GetRequiredService()); + +// Стартовый лог режима ML-интеграции (приёмка Task 16): Local-заглушка либо gRPC-клиент ml-service. +app.Logger.LogInformation( + "ML-интеграция: {Mode} ({Endpoint})", + mlOptions.UseLocal ? "Local-заглушка (MlOutbox накапливается)" : "gRPC-клиент ml-service", + mlOptions.Endpoint); + +// Стартовый лог режима AI-интеграции (приёмка Task 15): локальный разбор ядра либо gRPC-клиент ai-service +// под декоратором бюджетного гейта (Task 9: исчерпано/приостановлено → локальный разбор, приём не блокируется). +app.Logger.LogInformation( + "AI-интеграция: {Mode} ({Endpoint})", + aiOptions.UseLocal ? "Local-адаптеры (разбор ядра/инструменты выключены)" : "gRPC-клиент ai-service", + aiOptions.Endpoint); + +// Стартовый лог режима Telegram-гейта (приёмка Task 14): Local-заглушка либо gRPC-клиент telegram-service. +app.Logger.LogInformation( + "Telegram-гейт: {Mode} ({Endpoint})", + telegramOptions.UseLocal ? "Local-заглушка (idle/не подключён)" : "gRPC-клиент telegram-service", + telegramOptions.Endpoint); + +// Стартовый лог транспорта внутреннего gRPC (приёмка Task 13, Ruling 6): dev — plaintext + service-token, +// PROD (DEAL_MTLS_ENABLED=1) — mTLS. Пути/пароли не логируются (Ruling 13). +app.Logger.LogInformation( + "Транспорт внутреннего gRPC: {Transport}", + mtlsOptions.Enabled ? "mTLS (DEAL_MTLS_ENABLED=1, сертификаты из DEAL_MTLS_*)" : "plaintext + service-token (dev)"); + +if (forwardedHeadersConfig.Enabled) +{ + // Прокси-заголовки (план Task 12; замечание ревью T4/T11): X-Forwarded-For/X-Forwarded-Proto доверяются + // только клиентам из ForwardedHeaders:KnownProxies/KnownNetworks (конфиг; appsettings — loopback для dev). + // Middleware — ПЕРВЫЙ в конвейере: RemoteIpAddress/Scheme читают слои ниже (CORS, Session/OperatorSession — + // audit-IP эндпоинтов, RateLimiter — ключи по IP, LoginAttemptGuard). Без него за Caddy (compose-prod, + // Task 14) RemoteIpAddress всех запросов = IP Caddy, и audit-IP + rate-limit-по-IP схлопываются в один бакет. + app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig)); +} + +// Access-лог HTTP (Ruling 7, Task 14): первый в конвейере (после ForwardedHeaders) — длительность +// и статус всего пути обработки. gRPC-ингресс (Content-Type application/grpc) middleware пропускает — +// его логирует интерцептор RpcCallLoggingInterceptor (см. HttpAccessLogMiddleware). +app.UseMiddleware(); + +app.UseCors(corsPolicyName); +app.UseMiddleware(); +app.UseMiddleware(); +if (rateLimitOptions.Enabled) +{ + // Порядок middleware — Ruling 5: Session → Operator → RateLimiter (политика "api" ключует по + // CurrentUser.TenantId либо IP анонима; сессии уже разрешены). При Enabled=false лимитер не + // регистрируется (dev-прогон не режет curl-приёмки; OriginGuard Task 12 встанет после). + app.UseRateLimiter(); +} + +// Origin-проверка мутаций /api (план Task 12, Ruling 10(2)): порядок Ruling 5 — Session → Operator → +// RateLimiter → OriginGuard (сессии разрешены, 429 важнее 403). Проверяются не-GET/HEAD/OPTIONS запросы +// с заголовком Origin: Origin == «свой» origin (схема + Host; за Caddy — https из X-Forwarded-Proto) +// либо входит в Security:AllowedOrigins; иначе 403 {detail}. Без Origin (curl/сервер-сервер) пропускаются; +// SameSite=Lax куки остаётся первым рубежом CSRF (фиксируется в техдок §10, Task 16). +app.UseMiddleware(); + +app.MapGet("/api/health", () => Results.Ok(new { ok = true, service = "deal" })); +app.MapAuthEndpoints(); +app.MapOperatorAuthEndpoints(); +app.MapOperatorAuditEndpoints(); +// Операторская аналитика (план этапа 10, T3): read-only сводка/расход токенов/лента действий. +app.MapOperatorAnalyticsEndpoints(); +app.MapOperatorInvitesEndpoints(); +app.MapOperatorTenantsEndpoints(); +// Операторские лимиты/health (план Task 10, Ruling 3/11): сводка и смена бюджета по тенанту (GET/PATCH +// .../limit, аудит tenant_limit_changed) + health ядра/БД и сервисов ml/ai/telegram. +app.MapOperatorLimitsEndpoints(); +app.MapOperatorHealthEndpoints(); +// Глобальные настройки оператора (ТЗ §4.1/§8.1): ключи приложения Telegram — чтение (маска) и смена. +app.MapOperatorSettingsEndpoints(); +// Обслуживание (этап 12, пакет C): идемпотентная пакетная миграция схем всех тенантов реестра +// (ограниченный параллелизм + логирование прогресса) — для SaaS с сотнями/тысячами схем. +app.MapOperatorMaintenanceEndpoints(); +app.MapJoinEndpoint(); +app.MapSettingsEndpoints(); +app.MapAiCheckEndpoint(); +app.MapRatesEndpoints(); +app.MapMlEndpoints(); +app.MapFilterTesterEndpoints(); +app.MapContainersEndpoints(); +app.MapCardsEndpoints(); +app.MapCardDetailsEndpoints(); +app.MapStorageEndpoints(); +app.MapEventsEndpoint(); +app.MapAiSuggestEndpoints(); +app.MapPipelineEndpoints(); +// Эндпоинты /api/tg (план Task 14, Ruling 8): реальный контракт вкладки «Каналы» вместо boot-заглушки +// (BootStubEndpoints удалён); qr-image — отдельным файлом. +app.MapTelegramEndpoints(); +app.MapTelegramQrImageEndpoint(); +// Эндпоинты /api/discovery (план Task 19, Ruling 11): задачи поиска/кандидаты/чёрный список/лог/generate-keywords +// (DiscoveryEndpoints, 1:1 api-map §3.8) — модуль Discovery зарегистрирован AddDiscoveryModule выше. +app.MapDiscoveryEndpoints(); +// gRPC-ингресс и его health освобождены от HTTP-политик rate limiter (план Task 11, Ruling 5): лимит +// входящего потока считает IngressRateLimitInterceptor по tenant-id из metadata (иначе общее окно на IP +// telegram-service резало бы весь ингресс раньше интерцептора); health — инфраструктурный liveness. +app.MapGrpcService().DisableRateLimiting(); +app.MapGrpcHealthChecksService().DisableRateLimiting(); + +app.Run(); + +// ── Kestrel: основной HTTP/1.1-эндпоинт из URL-конфигурации (явные Listen заменяют URL-биндинг) ── + +// Биндит основной HTTP-эндпоинт(ы) из ключа "urls" (--urls/ASPNETCORE_URLS/launchSettings): без явных +// адресов используется фолбэк defaultHttpUrl (как дефолт ASP.NET Core). Схема https — сертификат из +// стандартной конфигурации Kestrel (UseHttps без аргументов), как при URL-биндинге. +static void BindMainHttpEndpoints(KestrelServerOptions kestrel, string? urlsConfig) +{ + List addresses = ParseHttpAddresses(urlsConfig); + if (addresses.Count == 0) + { + addresses.Add(new Uri(defaultHttpUrl)); + } + + foreach (Uri address in addresses) + { + // Dev-запуски используют loopback («localhost»/127.0.0.1) — ListenLocalhost; «0.0.0.0»/«*»/«+»/«::» + // (compose/host) — все интерфейсы. Иных хостов в конфигурации нет (URL-хосты не биндятся по имени). + bool isHttps = string.Equals(address.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); + string host = address.Host.Trim('[', ']'); + if (host is "0.0.0.0" or "*" or "+" or "::") + { + kestrel.Listen(IPAddress.Any, address.Port, listen => + { + if (isHttps) + { + listen.UseHttps(); + } + }); + } + else if (isHttps) + { + kestrel.ListenLocalhost(address.Port, listen => listen.UseHttps()); + } + else + { + kestrel.ListenLocalhost(address.Port); + } + } +} + +// Разбирает адреса конфигурации "urls" (http/https, разделитель «;»); невалидные/чужие схемы пропускаются. +static List ParseHttpAddresses(string? urlsConfig) +{ + var addresses = new List(); + if (string.IsNullOrWhiteSpace(urlsConfig)) + { + return addresses; + } + + foreach (string part in urlsConfig.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (Uri.TryCreate(part, UriKind.Absolute, out Uri? uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + { + addresses.Add(uri); + } + } + + return addresses; +} + +// Парсит порт из env-строки; пустое/нечисловое значение — null (фолбэк дефолта). +static int? ParsePort(string? rawValue) + => int.TryParse(rawValue, out int parsedPort) ? parsedPort : null; + +// Дефолт-бюджет нового тенанта из env DEAL_DEFAULT_AI_BUDGET (Ruling 3, Task 8): нечисловое/неположительное +// значение (пустая переменная, опечатка) — константа модуля TokenBudgetDefaults.DefaultBudgetTokens. Период +// всегда month — оператор меняет бюджет/период позже через PATCH лимита (Task 10). +long ResolveDefaultAiBudget(IConfiguration configuration) +{ + string? rawValue = configuration[defaultAiBudgetEnvKey]; + return long.TryParse(rawValue, out long parsedBudget) && parsedBudget > 0 + ? parsedBudget + : TokenBudgetDefaults.DefaultBudgetTokens; +} + +public partial class Program +{ + /// + /// Строит опции UseForwardedHeaders из ForwardedHeadersConfig (план Task 12; замечание ревью + /// T4/T11): обрабатываются X-Forwarded-For/X-Forwarded-Proto ровно одного доверенного hop'а. Списки + /// доверия — строго из конфига KnownProxies/KnownNetworks (appsettings-дефолт — loopback); невалидный + /// IP/CIDR — InvalidOperationException (fail-fast: опечатка в настройке доверия не должна молча + /// отключать обработку). Публичный: unit-тесты опций (ForwardedHeadersHttpTests). + /// + /// Секция ForwardedHeaders конфигурации. + /// Опции для app.UseForwardedHeaders. + public static ForwardedHeadersOptions BuildForwardedHeadersOptions(ForwardedHeadersConfig config) + { + ArgumentNullException.ThrowIfNull(config); + var options = new ForwardedHeadersOptions + { + // Адрес клиента и схема (Origin-проверка «своего» origin за Caddy требует https из + // X-Forwarded-Proto). X-Forwarded-Host не нужен: Caddy транслирует Host без перезаписи. + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, + // Между клиентом и core ровно один прокси (Caddy): читаем ближайший к нам элемент цепочки. + ForwardLimit = 1, + }; + // Доверие — только явный конфиг + loopback-фолбэк ниже: очищаем дефолты конструктора, чтобы + // перечисление в KnownProxies/KnownIPNetworks было единственным источником истины. + options.KnownProxies.Clear(); + options.KnownIPNetworks.Clear(); + foreach (string proxy in config.KnownProxies) + { + if (!IPAddress.TryParse(proxy, out IPAddress? address)) + { + throw new InvalidOperationException( + $"Невалидный IP доверенного прокси в ForwardedHeaders:KnownProxies: \"{proxy}\"."); + } + + options.KnownProxies.Add(address); + } + + foreach (string network in config.KnownNetworks) + { + if (!TryParseCidr(network, out System.Net.IPNetwork parsedNetwork)) + { + throw new InvalidOperationException( + $"Невалидная подсеть доверенного прокси в ForwardedHeaders:KnownNetworks: \"{network}\" " + + "(ожидается CIDR, напр. 172.16.0.0/12)."); + } + + options.KnownIPNetworks.Add(parsedNetwork); + } + + if (options.KnownProxies.Count == 0 && options.KnownIPNetworks.Count == 0) + { + // Пустые списки KnownProxies/KnownIPNetworks у ForwardedHeadersMiddleware означают «доверять + // ЛЮБОМУ клиенту» (спуфинг XFF) — пустоту до middleware не допускаем. Дефолт без явного + // конфига — loopback (dev-прокси на хосте: vite/локальный Caddy, см. appsettings); PROD + // перечисляет Caddy адресами/подсетями в конфиге. + options.KnownProxies.Add(IPAddress.Loopback); + options.KnownProxies.Add(IPAddress.IPv6Loopback); + } + + return options; + } + + // Разбирает CIDR-запись подсети ("127.0.0.0/8", "2001:db8::/32"); без префикса — вся + // подсеть одного адреса (IPv4 — /32, IPv6 — /128). + // value: Строка конфига (ForwardedHeaders:KnownNetworks). + // network: Разобранная подсеть при успехе. + // Возвращает: true — запись валидна. + private static bool TryParseCidr(string value, out System.Net.IPNetwork network) + { + network = default; + int slashIndex = value.IndexOf('/'); + string addressPart = slashIndex >= 0 ? value[..slashIndex] : value; + if (!IPAddress.TryParse(addressPart, out IPAddress? address)) + { + return false; + } + + int maxPrefixLength = address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128; + int prefixLength = maxPrefixLength; + if (slashIndex >= 0) + { + string lengthPart = value[(slashIndex + 1)..]; + if (!int.TryParse(lengthPart, out prefixLength) || prefixLength < 0 || prefixLength > maxPrefixLength) + { + return false; + } + } + + network = new System.Net.IPNetwork(address, prefixLength); + return true; + } +} diff --git a/src/core/Deal.Api/Services/AdminTickOrchestrator.cs b/src/core/Deal.Api/Services/AdminTickOrchestrator.cs index 215e5ff..9ea02df 100644 --- a/src/core/Deal.Api/Services/AdminTickOrchestrator.cs +++ b/src/core/Deal.Api/Services/AdminTickOrchestrator.cs @@ -1,193 +1,186 @@ -using Deal.Api.Events; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Api.Dtos; -using Deal.Api; -using Deal.Api.Services; - -namespace Deal.Api.Services; - -/// -/// Оркестратор ручного тика POST /api/admin/tick (план Tasks 10–11, Ruling 8/9; dashboard_routes.py admin_tick L327–337). -/// -/// -/// Api-слой объединяет сервисы модулей (Kanban тик правил хранения + Pipeline очистка отсева и pump + -/// Projects проверка напоминаний «Отложено») и публикует SSE (Ruling 5/8/9 — публикации только из Api; -/// модули остаются чистыми). Порядок 1:1 с прототипом: -/// (1) — автоархив и очистки архива/корзины; -/// (2) — отсев старше 3 суток (tick_storage L485–493), -/// результат вливается в storage.purgedRejected (Ruling 9); -/// (3) SSE-тосты статистики (, notify_tick_stats L496–504) — до pump, как в -/// прототипе (L333); -/// (4) проверка наступивших напоминаний (admin_tick L334, -/// check_reminders L264–282; план Task 11, Ruling 3): «выстрелившие» {id,title,containerId} помечены fired и -/// публикуются SSE reminder_due (Ruling 8 — toast НЕ шлём, у фронта модалка ReminderNotice); сбой -/// проверки НЕ роняет тик: лог + reminders ответа пуст; -/// (5) под общим воркер-гейтом тенанта (Task 10/11): pump -/// одного тенанта выполняет либо ручной тик, либо фоновый цикл — при занятом гейте проход пропускается; -/// сбой pump НЕ роняет тик: исключение логируется, pipeline ответа пуст ({} как при занятом локе прототипа -/// L901–902), очередь остаётся до следующего тика/фонового цикла. Операция отмены (OCE) пробрасывается — запрос прерван; -/// (6) SSE new_card по каждой созданной карточке (Ruling 8/9; полный CardDto, как publish из Api); -/// (7) queue = строк очереди после pump (queue_len L337). Ответ — . -/// -/// Тик правил хранения канбана (StorageTickService модуля Kanban). -/// Очистка отсева и счётчики очереди (модуль Pipeline). -/// Один проход pump по очереди входящих (модуль Pipeline). -/// Проверка наступивших напоминаний «Отложено» (CardsService, Ruling 3). -/// Публикатор SSE-тостов статистики тика (общий с фоновым циклом Task 11). -/// SSE-брокер канала тенанта (публикация reminder_due/new_card). -/// Общий воркер-гейт pump тенанта (singleton; общий с фоновым циклом Task 11). -/// Логгер сбоя проверки напоминаний/pump (тик продолжается без этих веток). -public sealed class AdminTickOrchestrator( - StorageTickService storageTick, - PipelineProcessingService processing, - PipelineWorkerService worker, - CardsService reminders, - StorageToastPublisher toastPublisher, - SseBroker broker, - PipelinePumpGate pumpGate, - ILogger logger) -{ - // SSE-тип события новой карточки (Ruling 5; api.js слушает 'new_card'). - private const string NewCardEventType = "new_card"; - - // SSE-тип события «выстрелившего» напоминания «Отложено» (Ruling 8, api-map §2: {id,title,containerId}). - private const string ReminderDueEventType = "reminder_due"; - - // Ключи счётчиков pump в pipeline-словаре ответа (1:1 со словарём _pump_unlocked python L921). - private static readonly string[] PipelineCounterKeys = - [ - "staged", "rulesStored", "mlStored", "mlDrop", "typeDrop", "aiStored", "aiDrop", "aiFail", "noBudget", - ]; - - /// - /// Выполняет один ручной тик тенанта: правила хранения + очистка отсева + напоминания + pump + SSE-публикации. - /// - /// Тенант-получатель (сессия запроса; канал SSE-публикаций). - /// Токен отмены запроса. - /// Ответ {storage, reminders, pipeline, queue}; сбой проверки напоминаний/pump не выбрасывается наружу. - public async Task TickAsync(Guid tenantId, CancellationToken ct) - { - // (1) Тик правил хранения канбана (как этап 3; leads.py tick_storage L454–484). - StorageTickStatsDto storage = await storageTick.TickAsync(ct); - - // (2) Очистка отсева пайплайна: записи старше 3 суток — безвозвратно (tick_storage L485–486); счётчик - // вливается в storage.purgedRejected (Ruling 9: ответ тика объединяет статистику, L488–493). - int purgedRejected = await processing.PurgeExpiredAsync(ct); - StorageTickStatsDto mergedStorage = storage with { PurgedRejected = purgedRejected }; - - // (3) Тосты статистики — до pump, как в прототипе (L333): очистка отсева видна, даже если pump упадёт. - toastPublisher.PublishTickToasts(tenantId, mergedStorage); - - // (4) Проверка наступивших напоминаний «Отложено» (admin_tick L334 → check_reminders L264–282; план - // Task 11, Ruling 3): CheckDueAsync помечает due-строки fired и возвращает их {id,title,stage}. Сбой - // проверки НЕ роняет тик: лог + reminders ответа пуст (очередь/хранение продолжают работать). - 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(); - } - - // SSE reminder_due по каждому «выстрелившему» напоминанию (Ruling 8: событие {id,title,stage}, toast НЕ - // шлём — у фронта модалка ReminderNotice; без подписчиков канала публикация — no-op). После MarkFired - // (внутри CheckDueAsync), как прототип L277–281 — публикуются уже «сработавшие» записи. - foreach (CardReminderDueDto due in dueReminders) - { - broker.Publish(tenantId, ReminderDueEventType, due); - } - - // (5) Один проход pump под гейтом тенанта; сбой не роняет тик: pipeline={}, очередь дождётся - // следующего тика/фонового цикла (Ruling 10; Task 11 — гейт общий с фоновым циклом). - PipelinePumpResult? pump = await PumpOnceSafelyAsync(tenantId, ct); - - // (6) SSE new_card по карточкам, созданным проходом (Ruling 8/9; без подписчиков — no-op). - if (pump is not null) - { - foreach (CardDto card in pump.CreatedCards) - { - broker.Publish(tenantId, NewCardEventType, card); - } - } - - // (7) Строк очереди после pump (queue_len L337: total = new + ai). - QueueCountsDto queueCounts = await processing.QueueCountsAsync(ct); - - return new AdminTickResultDto( - mergedStorage, - dueReminders, - pump is null ? new Dictionary() : ToPipelineWireDict(pump), - queueCounts.Total); - } - - // Один проход воркера под гейтом тенанта с изоляцией сбоя: исключения pump не роняют тик (Task 10). - // tenantId: Тенант тика (ключ гейта, общего с фоновым циклом Task 11). - // ct: Токен отмены запроса. - // Возвращает: Результат прохода либо null — гейт занят другим воркером/pump упал (pipeline ответа пуст). - private async Task PumpOnceSafelyAsync(Guid tenantId, CancellationToken ct) - { - // Общий воркер-гейт (Ruling 8, аналог asyncio.Lock pipeline.py L40): admin/tick и фоновый цикл не - // разбирают очередь тенанта одновременно. Гейт занят (фоновый цикл уже pump'ит) — проход пропускаем, - // как прототип при занятом локе (L901–902): pipeline={}, очередь дождётся следующего срабатывания. - 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); - } - } - - // Счётчики результата pump → pipeline-словарь ответа (9 ключей словаря python L921; карточки в - // wire не выходят — они ушли отдельными SSE new_card). - // pump: Результат успешного прохода pump. - // Возвращает: Словарь счётчиков в wire-порядке прототипа. - 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 (план Tasks 10–11, Ruling 8/9; dashboard_routes.py admin_tick L327–337). +/// +/// +/// Api-слой объединяет сервисы модулей (Kanban тик правил хранения + Pipeline очистка отсева и pump + +/// Projects проверка напоминаний «Отложено») и публикует SSE (Ruling 5/8/9 — публикации только из Api; +/// модули остаются чистыми). Порядок 1:1 с прототипом: +/// (1) — автоархив и очистки архива/корзины; +/// (2) — отсев старше 3 суток (tick_storage L485–493), +/// результат вливается в storage.purgedRejected (Ruling 9); +/// (3) SSE-тосты статистики (, notify_tick_stats L496–504) — до pump, как в +/// прототипе (L333); +/// (4) проверка наступивших напоминаний (admin_tick L334, +/// check_reminders L264–282; план Task 11, Ruling 3): «выстрелившие» {id,title,containerId} помечены fired и +/// публикуются SSE reminder_due (Ruling 8 — toast НЕ шлём, у фронта модалка ReminderNotice); сбой +/// проверки НЕ роняет тик: лог + reminders ответа пуст; +/// (5) под общим воркер-гейтом тенанта (Task 10/11): pump +/// одного тенанта выполняет либо ручной тик, либо фоновый цикл — при занятом гейте проход пропускается; +/// сбой pump НЕ роняет тик: исключение логируется, pipeline ответа пуст ({} как при занятом локе прототипа +/// L901–902), очередь остаётся до следующего тика/фонового цикла. Операция отмены (OCE) пробрасывается — запрос прерван; +/// (6) SSE new_card по каждой созданной карточке (Ruling 8/9; полный CardDto, как publish из Api); +/// (7) queue = строк очереди после pump (queue_len L337). Ответ — . +/// +/// Тик правил хранения канбана (StorageTickService модуля Kanban). +/// Очистка отсева и счётчики очереди (модуль Pipeline). +/// Один проход pump по очереди входящих (модуль Pipeline). +/// Проверка наступивших напоминаний «Отложено» (CardsService, Ruling 3). +/// Публикатор SSE-тостов статистики тика (общий с фоновым циклом Task 11). +/// SSE-брокер канала тенанта (публикация reminder_due/new_card). +/// Общий воркер-гейт pump тенанта (singleton; общий с фоновым циклом Task 11). +/// Логгер сбоя проверки напоминаний/pump (тик продолжается без этих веток). +public sealed class AdminTickOrchestrator( + StorageTickService storageTick, + PipelineProcessingService processing, + PipelineWorkerService worker, + CardsService reminders, + StorageToastPublisher toastPublisher, + SseBroker broker, + PipelinePumpGate pumpGate, + ILogger logger) +{ + // SSE-тип события новой карточки (Ruling 5; api.js слушает 'new_card'). + private const string NewCardEventType = "new_card"; + + // SSE-тип события «выстрелившего» напоминания «Отложено» (Ruling 8, api-map §2: {id,title,containerId}). + private const string ReminderDueEventType = "reminder_due"; + + // Ключи счётчиков pump в pipeline-словаре ответа (1:1 со словарём _pump_unlocked python L921). + private static readonly string[] PipelineCounterKeys = + [ + "staged", "rulesStored", "mlStored", "mlDrop", "typeDrop", "aiStored", "aiDrop", "aiFail", "noBudget", + ]; + + /// + /// Выполняет один ручной тик тенанта: правила хранения + очистка отсева + напоминания + pump + SSE-публикации. + /// + /// Тенант-получатель (сессия запроса; канал SSE-публикаций). + /// Токен отмены запроса. + /// Ответ {storage, reminders, pipeline, queue}; сбой проверки напоминаний/pump не выбрасывается наружу. + public async Task TickAsync(Guid tenantId, CancellationToken ct) + { + // (1) Тик правил хранения канбана (как этап 3; leads.py tick_storage L454–484). + StorageTickStatsDto storage = await storageTick.TickAsync(ct); + + // (2) Очистка отсева пайплайна: записи старше 3 суток — безвозвратно (tick_storage L485–486); счётчик + // вливается в storage.purgedRejected (Ruling 9: ответ тика объединяет статистику, L488–493). + int purgedRejected = await processing.PurgeExpiredAsync(ct); + StorageTickStatsDto mergedStorage = storage with { PurgedRejected = purgedRejected }; + + // (3) Тосты статистики — до pump, как в прототипе (L333): очистка отсева видна, даже если pump упадёт. + toastPublisher.PublishTickToasts(tenantId, mergedStorage); + + // (4) Проверка наступивших напоминаний «Отложено» (admin_tick L334 → check_reminders L264–282; план + // Task 11, Ruling 3): CheckDueAsync помечает due-строки fired и возвращает их {id,title,stage}. Сбой + // проверки НЕ роняет тик: лог + reminders ответа пуст (очередь/хранение продолжают работать). + 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(); + } + + // SSE reminder_due по каждому «выстрелившему» напоминанию (Ruling 8: событие {id,title,stage}, toast НЕ + // шлём — у фронта модалка ReminderNotice; без подписчиков канала публикация — no-op). После MarkFired + // (внутри CheckDueAsync), как прототип L277–281 — публикуются уже «сработавшие» записи. + foreach (CardReminderDueDto due in dueReminders) + { + broker.Publish(tenantId, ReminderDueEventType, due); + } + + // (5) Один проход pump под гейтом тенанта; сбой не роняет тик: pipeline={}, очередь дождётся + // следующего тика/фонового цикла (Ruling 10; Task 11 — гейт общий с фоновым циклом). + PipelinePumpResult? pump = await PumpOnceSafelyAsync(tenantId, ct); + + // (6) SSE new_card по карточкам, созданным проходом (Ruling 8/9; без подписчиков — no-op). + if (pump is not null) + { + foreach (CardDto card in pump.CreatedCards) + { + broker.Publish(tenantId, NewCardEventType, card); + } + } + + // (7) Строк очереди после pump (queue_len L337: total = new + ai). + QueueCountsDto queueCounts = await processing.QueueCountsAsync(ct); + + return new AdminTickResultDto( + mergedStorage, + dueReminders, + pump is null ? new Dictionary() : ToPipelineWireDict(pump), + queueCounts.Total); + } + + // Один проход воркера под гейтом тенанта с изоляцией сбоя: исключения pump не роняют тик (Task 10). + // tenantId: Тенант тика (ключ гейта, общего с фоновым циклом Task 11). + // ct: Токен отмены запроса. + // Возвращает: Результат прохода либо null — гейт занят другим воркером/pump упал (pipeline ответа пуст). + private async Task PumpOnceSafelyAsync(Guid tenantId, CancellationToken ct) + { + // Общий воркер-гейт (Ruling 8, аналог asyncio.Lock pipeline.py L40): admin/tick и фоновый цикл не + // разбирают очередь тенанта одновременно. Гейт занят (фоновый цикл уже pump'ит) — проход пропускаем, + // как прототип при занятом локе (L901–902): pipeline={}, очередь дождётся следующего срабатывания. + 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); + } + } + + // Счётчики результата pump → pipeline-словарь ответа (9 ключей словаря python L921; карточки в + // wire не выходят — они ушли отдельными SSE new_card). + // pump: Результат успешного прохода pump. + // Возвращает: Словарь счётчиков в wire-порядке прототипа. + 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 b6a6e2d..5dc4e62 100644 --- a/src/core/Deal.Api/Services/AuditAppender.cs +++ b/src/core/Deal.Api/Services/AuditAppender.cs @@ -1,98 +1,92 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.Extensions.DependencyInjection; -using Deal.Api.Services; -using Deal.Api.Dtos; -using Deal.Api.Extensions; -using Deal.Api.Models; - -namespace Deal.Api.Services; - -/// -/// Хелпер записи аудита действий пользователей тенанта и операторов (единая точка — ). -/// -/// -/// Актор берётся из разрешённой сессии (HttpContext.Items, наполняют SessionMiddleware/ -/// OperatorSessionMiddleware): для тенанта — с userId/tenantId, для -/// оператора — без tenantId. IP — адрес клиента без порта. -/// Детали — минимальные, без секретов (пароли/токены/api-ключи). Нет сессии — no-op (вызывать после -/// проверки HasUser, но безопасно и без неё). Append-only, как весь аудит. -/// -public static class AuditAppender -{ - /// - /// Пишет событие действия пользователя тенанта (актор tenant). - /// - /// Контекст запроса (источник актора и IP). - /// Тип события — константа . - /// Минимальные детали события (обычно анонимный объект) или 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); - } - - /// - /// Пишет событие действия оператора (актор operator, без tenantId). - /// - /// Контекст запроса (источник актора и IP). - /// Тип события — константа . - /// Минимальные детали события (обычно анонимный объект) или 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; + +/// +/// Хелпер записи аудита действий пользователей тенанта и операторов (единая точка — ). +/// +/// +/// Актор берётся из разрешённой сессии (HttpContext.Items, наполняют SessionMiddleware/ +/// OperatorSessionMiddleware): для тенанта — с userId/tenantId, для +/// оператора — без tenantId. IP — адрес клиента без порта. +/// Детали — минимальные, без секретов (пароли/токены/api-ключи). Нет сессии — no-op (вызывать после +/// проверки HasUser, но безопасно и без неё). Append-only, как весь аудит. +/// +public static class AuditAppender +{ + /// + /// Пишет событие действия пользователя тенанта (актор tenant). + /// + /// Контекст запроса (источник актора и IP). + /// Тип события — константа . + /// Минимальные детали события (обычно анонимный объект) или 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); + } + + /// + /// Пишет событие действия оператора (актор operator, без tenantId). + /// + /// Контекст запроса (источник актора и IP). + /// Тип события — константа . + /// Минимальные детали события (обычно анонимный объект) или 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 2d1678d..08740be 100644 --- a/src/core/Deal.Api/Services/EndpointResults.cs +++ b/src/core/Deal.Api/Services/EndpointResults.cs @@ -1,65 +1,62 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; -using Deal.Api.Extensions; -using Deal.Api.Models; - -namespace Deal.Api.Services; - -/// -/// Общие ответы ошибок минимальных API: HTTP-код + {"detail":"…"} (формат прототипа, Ruling 10). -/// -/// -/// Единая точка для всех Endpoints-файлов (Auth/Settings/Ai-проверка): сообщения деталей — -/// фиксированные строки прототипа (FastAPI HTTPException), задаются вызывающим. -/// -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-ответ: ресурс не найден (формат прототипа, Ruling 10). - /// - /// Текст ошибки. - /// JSON-ответ {detail} со статусом 404. - public static IResult NotFound(string detail) => - Results.Json(new { detail }, statusCode: StatusCodes.Status404NotFound); - - /// - /// 403-ответ: запрос понятен, но доступ запрещён (приостановленный тенант, план Task 7/Ruling 10(5)). - /// - /// Текст ошибки. - /// JSON-ответ {detail} со статусом 403. - public static IResult Forbidden(string detail) => - Results.Json(new { detail }, statusCode: StatusCodes.Status403Forbidden); - - /// - /// 429-ответ: превышен лимит запросов/попыток входа (план Task 11, Ruling 5: LoginAttemptGuard). - /// - /// Текст ошибки. - /// JSON-ответ {detail} со статусом 429. - public static IResult TooManyRequests(string detail) => - Results.Json(new { detail }, statusCode: StatusCodes.Status429TooManyRequests); - - /// - /// 410-ответ: ресурс больше недоступен (Ruling 4: файл без objectKey — «Файл не сохранён - /// в объектном хранилище»; api-map §1: 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: HTTP-код + {"detail":"…"} (формат прототипа, Ruling 10). +/// +/// +/// Единая точка для всех Endpoints-файлов (Auth/Settings/Ai-проверка): сообщения деталей — +/// фиксированные строки прототипа (FastAPI HTTPException), задаются вызывающим. +/// +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-ответ: ресурс не найден (формат прототипа, Ruling 10). + /// + /// Текст ошибки. + /// JSON-ответ {detail} со статусом 404. + public static IResult NotFound(string detail) => + Results.Json(new { detail }, statusCode: StatusCodes.Status404NotFound); + + /// + /// 403-ответ: запрос понятен, но доступ запрещён (приостановленный тенант, план Task 7/Ruling 10(5)). + /// + /// Текст ошибки. + /// JSON-ответ {detail} со статусом 403. + public static IResult Forbidden(string detail) => + Results.Json(new { detail }, statusCode: StatusCodes.Status403Forbidden); + + /// + /// 429-ответ: превышен лимит запросов/попыток входа (план Task 11, Ruling 5: LoginAttemptGuard). + /// + /// Текст ошибки. + /// JSON-ответ {detail} со статусом 429. + public static IResult TooManyRequests(string detail) => + Results.Json(new { detail }, statusCode: StatusCodes.Status429TooManyRequests); + + /// + /// 410-ответ: ресурс больше недоступен (Ruling 4: файл без objectKey — «Файл не сохранён + /// в объектном хранилище»; api-map §1: 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/LoginAttemptGuard.cs b/src/core/Deal.Api/Services/LoginAttemptGuard.cs index d30ba42..749f5ac 100644 --- a/src/core/Deal.Api/Services/LoginAttemptGuard.cs +++ b/src/core/Deal.Api/Services/LoginAttemptGuard.cs @@ -1,159 +1,151 @@ -using Deal.Api.Configuration; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Api.Services; -using Deal.Api.Dtos; -using Deal.Api.Extensions; -using Deal.Api.Models; - -namespace Deal.Api.Services; - -/// -/// Прикладной guard неудачных попыток входа (план Task 11, Ruling 5; этап 12, пакет B — хранилище на -/// Postgres): фиксированное окно по ключу ip|login — после -/// неудач в окне минут последующие попытки ключа -/// отклоняются (429 «Слишком много попыток входа…»), успешный вход сбрасывает счётчик ключа. -/// -/// -/// Счётчики живут в общем хранилище (public.rate_limit_counters) — -/// блокировка брутфорса действует на всех инстансах core, а не только на принявшем неудачу (ранее — -/// память одного процесса). Часы — инъекцией Func<DateTimeOffset> (эталон TenantLimitStore): -/// unit-тесты окна идут на фиксированном «сейчас». Гвард включается только при -/// (dev/тесты — false: curl-приёмки не режутся, Ruling 5); -/// вызовы эндпоинтов не проверяют флаг — no-op внутри guard'а. -/// -public sealed class LoginAttemptGuard -{ - /// - /// Текст 429 при блокировке ключа (Ruling 5, фиксированная формулировка «15 минут»). - /// - 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 (неудач в текущем окне ≥ LoginAttemptsMax). - /// - /// 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); - } - - /// - /// Сбрасывает счётчик ключа (успешный вход, Ruling 5). - /// - /// 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 неудачных попыток входа (план Task 11, Ruling 5; этап 12, пакет B — хранилище на +/// Postgres): фиксированное окно по ключу ip|login — после +/// неудач в окне минут последующие попытки ключа +/// отклоняются (429 «Слишком много попыток входа…»), успешный вход сбрасывает счётчик ключа. +/// +/// +/// Счётчики живут в общем хранилище (public.rate_limit_counters) — +/// блокировка брутфорса действует на всех инстансах core, а не только на принявшем неудачу (ранее — +/// память одного процесса). Часы — инъекцией Func<DateTimeOffset> (эталон TenantLimitStore): +/// unit-тесты окна идут на фиксированном «сейчас». Гвард включается только при +/// (dev/тесты — false: curl-приёмки не режутся, Ruling 5); +/// вызовы эндпоинтов не проверяют флаг — no-op внутри guard'а. +/// +public sealed class LoginAttemptGuard +{ + /// + /// Текст 429 при блокировке ключа (Ruling 5, фиксированная формулировка «15 минут»). + /// + 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 (неудач в текущем окне ≥ LoginAttemptsMax). + /// + /// 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); + } + + /// + /// Сбрасывает счётчик ключа (успешный вход, Ruling 5). + /// + /// 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 e137c40..e890be7 100644 --- a/src/core/Deal.Api/Services/PipelinePumpGate.cs +++ b/src/core/Deal.Api/Services/PipelinePumpGate.cs @@ -1,38 +1,35 @@ -using System.Collections.Concurrent; -using Deal.Api.Dtos; -using Deal.Api; -using Deal.Api.Services; - -namespace Deal.Api.Services; - -/// -/// Общий воркер-гейт pump одного тенанта: admin/tick и фоновый цикл не разбирают очередь одновременно (Ruling 8, план Task 11; аналог asyncio.Lock pipeline.py L38–42). -/// -/// -/// Прототип держит один глобальный asyncio.Lock на процесс (pipeline.py L40) и в pump_once при занятом -/// локе сразу возвращает {} (L901–902). Здесь очередь каждого тенанта живёт в своей tenant-схеме, поэтому -/// гейт — per-tenant: вход получает один «воркер» (ручной тик POST /api/admin/tick — AdminTickOrchestrator, -/// либо фоновый цикл — PipelineWorkerScheduler), второй для того же тенанта пропускает проход -/// ( = false) и не трогает очередь — она дождётся следующего срабатывания. Разные тенанты -/// гейтом не связаны (их pump не конкурируют: отдельные схемы/соединения). -/// Потокобезопасен: занятость тенанта — атомарная вставка флага в ConcurrentDictionary (TryAdd/TryRemove), -/// как Interlocked-гварды StorageTickScheduler/RatesRefreshScheduler. -/// -public sealed class PipelinePumpGate -{ - // Тенанты с выполняющимся в данный момент pump (ключ — TenantId; значение — заглушка, важен факт наличия ключа). - private readonly ConcurrentDictionary _activePumps = new(); - - /// - /// Пытается захватить pump тенанта: true — гейт свободен и вызывающий начинает разбор очереди. - /// - /// Тенант (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 одного тенанта: admin/tick и фоновый цикл не разбирают очередь одновременно (Ruling 8, план Task 11; аналог asyncio.Lock pipeline.py L38–42). +/// +/// +/// Прототип держит один глобальный asyncio.Lock на процесс (pipeline.py L40) и в pump_once при занятом +/// локе сразу возвращает {} (L901–902). Здесь очередь каждого тенанта живёт в своей tenant-схеме, поэтому +/// гейт — per-tenant: вход получает один «воркер» (ручной тик POST /api/admin/tick — AdminTickOrchestrator, +/// либо фоновый цикл — PipelineWorkerScheduler), второй для того же тенанта пропускает проход +/// ( = false) и не трогает очередь — она дождётся следующего срабатывания. Разные тенанты +/// гейтом не связаны (их pump не конкурируют: отдельные схемы/соединения). +/// Потокобезопасен: занятость тенанта — атомарная вставка флага в ConcurrentDictionary (TryAdd/TryRemove), +/// как Interlocked-гварды StorageTickScheduler/RatesRefreshScheduler. +/// +public sealed class PipelinePumpGate +{ + // Тенанты с выполняющимся в данный момент pump (ключ — TenantId; значение — заглушка, важен факт наличия ключа). + private readonly ConcurrentDictionary _activePumps = new(); + + /// + /// Пытается захватить pump тенанта: true — гейт свободен и вызывающий начинает разбор очереди. + /// + /// Тенант (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 267b87b..6cbc599 100644 --- a/src/core/Deal.Api/Services/PipelineWorkerScheduler.cs +++ b/src/core/Deal.Api/Services/PipelineWorkerScheduler.cs @@ -1,229 +1,221 @@ -using Deal.Api.Events; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Deal.Api.Dtos; -using Deal.Api; -using Deal.Api.Services; - -namespace Deal.Api.Services; - -/// -/// Фоновый цикл разбора очереди входящих по всем тенантам (план Task 11, Ruling 8; аналог _pipeline_loop main.py L79–88). -/// -/// -/// Каждые 2 с (в прототипе — asyncio.sleep(2), main.py L87) обходит ВСЕ тенанты системного реестра и для -/// каждого выполняет один проход в собственном scope с -/// ITenantContext.SetTenant (эталон StorageTickScheduler/TenantBootstrapService). Проходы одного тенанта -/// не параллелятся — общий с ручным тиком (AdminTickOrchestrator): если -/// pump тенанта уже выполняет другой воркер (ручной тик или не успевшее завершиться срабатывание цикла), -/// цикл пропускает тенанта, а очередь ждёт следующего срабатывания (как прототип L901–902 — занятый lock → {}). -/// Карточки, созданные проходом, публикуются SSE new_card в канал тенанта (Ruling 8/9; без подписчиков — no-op). -/// -/// Как и StorageTickScheduler: первый проход — сразу после старта (в прототипе pump до первого sleep), далее по -/// таймеру; перекрывающиеся проходы исключены in-flight guard (Interlocked) — следующее срабатывание таймера -/// пропускается, если проход длится дольше периода. Ошибки логируются и наружу не выбрасываются (pump одного -/// тенанта не валит проход цикла — остальные тенанты обрабатываются); при остановке хоста таймер -/// останавливается и текущий проход отменяется (graceful). Пустая очередь — тихий no-op (без логов/публикаций). -/// -/// -public sealed class PipelineWorkerScheduler : IHostedService -{ - // Период проходов цикла — 2 с, 1:1 с _pipeline_loop main.py L87 (asyncio.sleep(2)). - private const int PipelinePeriodSeconds = 2; - - private static readonly TimeSpan PipelinePeriod = TimeSpan.FromSeconds(PipelinePeriodSeconds); - - // SSE-тип события новой карточки (Ruling 5; api.js слушает 'new_card'). - 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) - { - // Первый проход — сразу после старта (в прототипе pump выполняется до первого sleep); далее каждые 2 с. - _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, незавершённый проход безопасно завершится на отменённом токене. - } - } - } - - /// - /// Один проход цикла: список тенантов реестра и pump каждого (no-op, если проход уже идёт). - /// - /// Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются) и - /// ручного вызова при отладке; таймер вызывает этот же метод. Ошибки и отмена токена наружу не - /// выбрасываются: сбои логируются (цикл живёт), отмена по токену останова завершает проход штатно. - /// Токен отмены прохода (в проде — токен остановки хоста). - /// Задача прохода (завершается без исключений). - 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"))); - - // Общий гейт pump тенанта (Ruling 8): занят другим воркером (ручной тик/прошлый проход) — - // пропускаем тенанта, очередь остаётся до следующего срабатывания (прототип L901–902). - if (!_pumpGate.TryEnter(tenant.Id)) - { - return; - } - - try - { - // Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта. - PipelineWorkerService worker = tenantScope.ServiceProvider.GetRequiredService(); - PipelinePumpResult pump = await worker.PumpOnceAsync(ct); - - // new_card по карточкам прохода — в канал тенанта (Ruling 8/9; без подписчиков — no-op). - 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; + +/// +/// Фоновый цикл разбора очереди входящих по всем тенантам (план Task 11, Ruling 8; аналог _pipeline_loop main.py L79–88). +/// +/// +/// Каждые 2 с (в прототипе — asyncio.sleep(2), main.py L87) обходит ВСЕ тенанты системного реестра и для +/// каждого выполняет один проход в собственном scope с +/// ITenantContext.SetTenant (эталон StorageTickScheduler/TenantBootstrapService). Проходы одного тенанта +/// не параллелятся — общий с ручным тиком (AdminTickOrchestrator): если +/// pump тенанта уже выполняет другой воркер (ручной тик или не успевшее завершиться срабатывание цикла), +/// цикл пропускает тенанта, а очередь ждёт следующего срабатывания (как прототип L901–902 — занятый lock → {}). +/// Карточки, созданные проходом, публикуются SSE new_card в канал тенанта (Ruling 8/9; без подписчиков — no-op). +/// +/// Как и StorageTickScheduler: первый проход — сразу после старта (в прототипе pump до первого sleep), далее по +/// таймеру; перекрывающиеся проходы исключены in-flight guard (Interlocked) — следующее срабатывание таймера +/// пропускается, если проход длится дольше периода. Ошибки логируются и наружу не выбрасываются (pump одного +/// тенанта не валит проход цикла — остальные тенанты обрабатываются); при остановке хоста таймер +/// останавливается и текущий проход отменяется (graceful). Пустая очередь — тихий no-op (без логов/публикаций). +/// +/// +public sealed class PipelineWorkerScheduler : IHostedService +{ + // Период проходов цикла — 2 с, 1:1 с _pipeline_loop main.py L87 (asyncio.sleep(2)). + private const int PipelinePeriodSeconds = 2; + + private static readonly TimeSpan PipelinePeriod = TimeSpan.FromSeconds(PipelinePeriodSeconds); + + // SSE-тип события новой карточки (Ruling 5; api.js слушает 'new_card'). + 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) + { + // Первый проход — сразу после старта (в прототипе pump выполняется до первого sleep); далее каждые 2 с. + _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, незавершённый проход безопасно завершится на отменённом токене. + } + } + } + + /// + /// Один проход цикла: список тенантов реестра и pump каждого (no-op, если проход уже идёт). + /// + /// Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются) и + /// ручного вызова при отладке; таймер вызывает этот же метод. Ошибки и отмена токена наружу не + /// выбрасываются: сбои логируются (цикл живёт), отмена по токену останова завершает проход штатно. + /// Токен отмены прохода (в проде — токен остановки хоста). + /// Задача прохода (завершается без исключений). + 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"))); + + // Общий гейт pump тенанта (Ruling 8): занят другим воркером (ручной тик/прошлый проход) — + // пропускаем тенанта, очередь остаётся до следующего срабатывания (прототип L901–902). + if (!_pumpGate.TryEnter(tenant.Id)) + { + return; + } + + try + { + // Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта. + PipelineWorkerService worker = tenantScope.ServiceProvider.GetRequiredService(); + PipelinePumpResult pump = await worker.PumpOnceAsync(ct); + + // new_card по карточкам прохода — в канал тенанта (Ruling 8/9; без подписчиков — no-op). + 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 e1d9f29..bf1c58e 100644 --- a/src/core/Deal.Api/Services/RatesRefreshScheduler.cs +++ b/src/core/Deal.Api/Services/RatesRefreshScheduler.cs @@ -1,77 +1,71 @@ -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Api.Dtos; -using Deal.Api; -using Deal.Api.Services; - -namespace Deal.Api.Services; - -/// -/// Фоновое обновление кэша курсов вне жизненного цикла запроса (Ruling 6, Task 8). -/// -/// -/// Применяется там, где прототип запускает rates_svc.refresh_rates() как fire-and-forget -/// (settings_routes.py L186–192 — PATCH rateSource) и где этап требует ленивого обновления на GET -/// (план Task 8: «фоновый запуск RefreshAsync, ответ — текущий кэш»). RefreshAsync выполняется в -/// ОТДЕЛЬНОМ scope: scoped-зависимости (ISettingsStore → TenantDbContext) должны жить столько, сколько -/// нужна фоновая работа (HTTP к ЦБ — до 15 с), а не до конца запроса. Тенант пробрасывается через -/// ExecutionContext: Schedule вызывается внутри tenant-запроса, Task.Run захватывает AsyncLocal -/// ITenantContext с установленным тенантом. In-flight guard на процесс: пока фоновое обновление -/// выполняется, повторные Schedule игнорируются — частые GET не спамят ЦБ (интервал Ruling 6) и -/// не плодят гонки записи ratesCache. Ошибки фоновой работы логируются, наружу не выбрасываются. -/// -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 (no-op, если обновление уже в процессе). - /// - 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; + +/// +/// Фоновое обновление кэша курсов вне жизненного цикла запроса (Ruling 6, Task 8). +/// +/// +/// Применяется там, где прототип запускает rates_svc.refresh_rates() как fire-and-forget +/// (settings_routes.py L186–192 — PATCH rateSource) и где этап требует ленивого обновления на GET +/// (план Task 8: «фоновый запуск RefreshAsync, ответ — текущий кэш»). RefreshAsync выполняется в +/// ОТДЕЛЬНОМ scope: scoped-зависимости (ISettingsStore → TenantDbContext) должны жить столько, сколько +/// нужна фоновая работа (HTTP к ЦБ — до 15 с), а не до конца запроса. Тенант пробрасывается через +/// ExecutionContext: Schedule вызывается внутри tenant-запроса, Task.Run захватывает AsyncLocal +/// ITenantContext с установленным тенантом. In-flight guard на процесс: пока фоновое обновление +/// выполняется, повторные Schedule игнорируются — частые GET не спамят ЦБ (интервал Ruling 6) и +/// не плодят гонки записи ratesCache. Ошибки фоновой работы логируются, наружу не выбрасываются. +/// +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 (no-op, если обновление уже в процессе). + /// + 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 0a2ebc2..9146a8c 100644 --- a/src/core/Deal.Api/Services/SessionCookieWriter.cs +++ b/src/core/Deal.Api/Services/SessionCookieWriter.cs @@ -1,10 +1,5 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions; using CookieOptions = Deal.Api.Configuration.CookieOptions; -using Deal.Api.Extensions; -using Deal.Api.Models; namespace Deal.Api.Services; diff --git a/src/core/Deal.Api/Services/StoreBackedFixedWindowRateLimiter.cs b/src/core/Deal.Api/Services/StoreBackedFixedWindowRateLimiter.cs index f03932b..cde6cb3 100644 --- a/src/core/Deal.Api/Services/StoreBackedFixedWindowRateLimiter.cs +++ b/src/core/Deal.Api/Services/StoreBackedFixedWindowRateLimiter.cs @@ -1,14 +1,5 @@ using System.Threading.RateLimiting; using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.Extensions.DependencyInjection; -using Deal.Api.Services; -using Deal.Api.Dtos; -using Deal.Api.Extensions; -using Deal.Api.Models; namespace Deal.Api.Services; diff --git a/src/core/Deal.Api/Services/TelegramBackfillScheduler.cs b/src/core/Deal.Api/Services/TelegramBackfillScheduler.cs index 419c975..db32b1d 100644 --- a/src/core/Deal.Api/Services/TelegramBackfillScheduler.cs +++ b/src/core/Deal.Api/Services/TelegramBackfillScheduler.cs @@ -1,139 +1,136 @@ -using Deal.Modules.Telegram.Application; -using Deal.Api.Dtos; -using Deal.Api; -using Deal.Api.Services; - -namespace Deal.Api.Services; - -/// -/// Фоновые спуски перечитывания каналов вне жизненного цикла запроса (Ruling 8, план Task 14). -/// -/// -/// Эквивалент python-_spawn из роутеров (tg_routes.py L546/L566/L580): эндпоинты мониторинга/«Перечитать» -/// отвечают сразу, тяжёлый разбор (паузы анти-бана внутри telegram-service) выполняется в фоне. Работа идёт -/// в ОТДЕЛЬНОМ scope: scoped-зависимости (DialogsService → ITelegramStore/TenantDbContext, ITelegramGateway) -/// должны жить столько, сколько нужен фоновый разбор, а не до конца запроса. Тенант пробрасывается через -/// ExecutionContext: Schedule вызывается внутри tenant-запроса, Task.Run захватывает AsyncLocal ITenantContext -/// с установленным тенантом. Падение одного канала не отменяет остальные (per-dialog try/continue — замечание -/// ревью T13); ошибки фоновой работы логируются, наружу не выбрасываются. -/// -/// Фабрика scope для фоновой работы (root-провайдер). -/// Логгер ошибок/аудита фоновых спусков. -public sealed class TelegramBackfillScheduler( - IServiceScopeFactory scopeFactory, - IHostApplicationLifetime applicationLifetime, - ILogger logger) -{ - // Флаг in-flight «Перечитать» всех каналов (Interlocked): повторный вызов не плодит - // параллельные полные перечитывания (Security review, как RatesRefreshScheduler). - private int _readRecentInProgress; - - /// - /// Запускает фоновое «Перечитать» всех включённых каналов (backfill_monitored L569–581). - /// Повторный вызов, пока разбор идёт, игнорируется (in-flight guard). - /// - /// Эндпоинт backfill-all сначала отвечает {ok, count} (число включённых), разбор — здесь. - public void ScheduleReadRecent() - { - if (Interlocked.CompareExchange(ref _readRecentInProgress, 1, 0) != 0) - { - logger.LogInformation("Фоновое «Перечитать» уже выполняется — повторный запуск пропущен"); - return; - } - - RunBackground("backfill_monitored", RunReadRecentAsync); - } - - /// - /// Запускает фоновый первый разбор одного канала (первое включение мониторинга, python L546). - /// - /// Id диалога, включённого впервые (Backfilled=false). - public void ScheduleFirstBackfill(string dialogId) - { - ScheduleFirstBackfills([dialogId]); - } - - /// - /// Запускает фоновый первый разбор списка каналов (set_monitor_all L566: неразобранные при включении). - /// - /// 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) - { - // Канал остаётся неразобранным — следующее включение/«Перечитать» попробует снова (ревью T13). - runLogger.LogWarning(exception, "Первый разбор {DialogId} не удался", dialogId); - } - } - } -} +using Deal.Modules.Telegram.Application; + +namespace Deal.Api.Services; + +/// +/// Фоновые спуски перечитывания каналов вне жизненного цикла запроса (Ruling 8, план Task 14). +/// +/// +/// Эквивалент python-_spawn из роутеров (tg_routes.py L546/L566/L580): эндпоинты мониторинга/«Перечитать» +/// отвечают сразу, тяжёлый разбор (паузы анти-бана внутри telegram-service) выполняется в фоне. Работа идёт +/// в ОТДЕЛЬНОМ scope: scoped-зависимости (DialogsService → ITelegramStore/TenantDbContext, ITelegramGateway) +/// должны жить столько, сколько нужен фоновый разбор, а не до конца запроса. Тенант пробрасывается через +/// ExecutionContext: Schedule вызывается внутри tenant-запроса, Task.Run захватывает AsyncLocal ITenantContext +/// с установленным тенантом. Падение одного канала не отменяет остальные (per-dialog try/continue — замечание +/// ревью T13); ошибки фоновой работы логируются, наружу не выбрасываются. +/// +/// Фабрика scope для фоновой работы (root-провайдер). +/// Логгер ошибок/аудита фоновых спусков. +public sealed class TelegramBackfillScheduler( + IServiceScopeFactory scopeFactory, + IHostApplicationLifetime applicationLifetime, + ILogger logger) +{ + // Флаг in-flight «Перечитать» всех каналов (Interlocked): повторный вызов не плодит + // параллельные полные перечитывания (Security review, как RatesRefreshScheduler). + private int _readRecentInProgress; + + /// + /// Запускает фоновое «Перечитать» всех включённых каналов (backfill_monitored L569–581). + /// Повторный вызов, пока разбор идёт, игнорируется (in-flight guard). + /// + /// Эндпоинт backfill-all сначала отвечает {ok, count} (число включённых), разбор — здесь. + public void ScheduleReadRecent() + { + if (Interlocked.CompareExchange(ref _readRecentInProgress, 1, 0) != 0) + { + logger.LogInformation("Фоновое «Перечитать» уже выполняется — повторный запуск пропущен"); + return; + } + + RunBackground("backfill_monitored", RunReadRecentAsync); + } + + /// + /// Запускает фоновый первый разбор одного канала (первое включение мониторинга, python L546). + /// + /// Id диалога, включённого впервые (Backfilled=false). + public void ScheduleFirstBackfill(string dialogId) + { + ScheduleFirstBackfills([dialogId]); + } + + /// + /// Запускает фоновый первый разбор списка каналов (set_monitor_all L566: неразобранные при включении). + /// + /// 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) + { + // Канал остаётся неразобранным — следующее включение/«Перечитать» попробует снова (ревью T13). + runLogger.LogWarning(exception, "Первый разбор {DialogId} не удался", dialogId); + } + } + } +} diff --git a/src/core/Deal.Api/Telegram/IngressRateLimitInterceptor.cs b/src/core/Deal.Api/Telegram/IngressRateLimitInterceptor.cs index 263f4a7..f4726a8 100644 --- a/src/core/Deal.Api/Telegram/IngressRateLimitInterceptor.cs +++ b/src/core/Deal.Api/Telegram/IngressRateLimitInterceptor.cs @@ -1,111 +1,107 @@ -using System.Threading.RateLimiting; -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Api.Middleware; -using Grpc.Core; -using Grpc.Core.Interceptors; -using Microsoft.Extensions.DependencyInjection; -using Deal.Api.Dtos; - -namespace Deal.Api.Telegram; - -/// -/// Серверный интерцептор rate limit gRPC-ингресса (план Task 11, Ruling 5; этап 12, пакет B — хранилище -/// на Postgres): фиксированное окно (GrpcIngressPerMinute в минуту) по gRPC-metadata «tenant-id» — -/// каждый тенант имеет собственный счётчик входящего потока telegram-service, общий для всех инстансов -/// core. Стандартный grpc.health.v1.Health не ограничивается (инфраструктурный liveness, как у -/// IngressServiceTokenInterceptor). Превышение лимита — RPC-отказ RESOURCE_EXHAUSTED (gRPC-аналог -/// HTTP 429) до вызова метода сервиса. -/// -/// -/// Окно считает общий (регистрируется в DI singleton'ом -/// рядом с AddGrpc): экземпляры интерцептора создаются фреймворком, но партиции окон живут в одном -/// лимитере — иначе каждая регистрация/вызов получил бы собственное окно и лимит не работал бы. -/// Счётчики партиций — в Postgres через . Лимитер -/// регистрируется и интерцептор добавляется только при RateLimit:Enabled=true (dev — plaintext-поток -/// без лимита, как HTTP-политики, Ruling 5). -/// -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; - - /// - /// Создаёт интерцептор с общим лимитером ингресса (см. CreateLimiter). - /// - /// Singleton-лимитер, зарегистрированный хостом. - public IngressRateLimitInterceptor(PartitionedRateLimiter limiter) - { - _limiter = limiter ?? throw new ArgumentNullException(nameof(limiter)); - } - - /// - /// Создаёт лимитер ингресса: фиксированное окно в минуту, партиция на каждый tenant-id, - /// счётчики — в общем хранилище Postgres. - /// - /// Фабрика 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: health-методы пропускаются, остальные получают разрешение - /// партиции tenant-id; исчерпание окна — RESOURCE_EXHAUSTED. - /// - /// Тип запроса 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-ингресса (план Task 11, Ruling 5; этап 12, пакет B — хранилище +/// на Postgres): фиксированное окно (GrpcIngressPerMinute в минуту) по gRPC-metadata «tenant-id» — +/// каждый тенант имеет собственный счётчик входящего потока telegram-service, общий для всех инстансов +/// core. Стандартный grpc.health.v1.Health не ограничивается (инфраструктурный liveness, как у +/// IngressServiceTokenInterceptor). Превышение лимита — RPC-отказ RESOURCE_EXHAUSTED (gRPC-аналог +/// HTTP 429) до вызова метода сервиса. +/// +/// +/// Окно считает общий (регистрируется в DI singleton'ом +/// рядом с AddGrpc): экземпляры интерцептора создаются фреймворком, но партиции окон живут в одном +/// лимитере — иначе каждая регистрация/вызов получил бы собственное окно и лимит не работал бы. +/// Счётчики партиций — в Postgres через . Лимитер +/// регистрируется и интерцептор добавляется только при RateLimit:Enabled=true (dev — plaintext-поток +/// без лимита, как HTTP-политики, Ruling 5). +/// +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; + + /// + /// Создаёт интерцептор с общим лимитером ингресса (см. CreateLimiter). + /// + /// Singleton-лимитер, зарегистрированный хостом. + public IngressRateLimitInterceptor(PartitionedRateLimiter limiter) + { + _limiter = limiter ?? throw new ArgumentNullException(nameof(limiter)); + } + + /// + /// Создаёт лимитер ингресса: фиксированное окно в минуту, партиция на каждый tenant-id, + /// счётчики — в общем хранилище Postgres. + /// + /// Фабрика 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: health-методы пропускаются, остальные получают разрешение + /// партиции tenant-id; исчерпание окна — RESOURCE_EXHAUSTED. + /// + /// Тип запроса 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 e47b30f..afcc50b 100644 --- a/src/core/Deal.Api/Telegram/IngressServiceTokenInterceptor.cs +++ b/src/core/Deal.Api/Telegram/IngressServiceTokenInterceptor.cs @@ -1,90 +1,88 @@ -using Grpc.Core; -using Grpc.Core.Interceptors; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Telegram; - -/// -/// Серверный интерцептор service-token gRPC-ингресса (план Task 12; Ruling 1/2). -/// -/// Каждый RPC ингресса обязан нести gRPC-metadata «service-token», равный ожидаемому значению -/// из env DEAL_SERVICE_TOKEN (общий токен сервисов в compose, Ruling 12). Отсутствие или -/// несовпадение токена — отказ UNAUTHENTICATED до вызова метода сервиса. Стандартный -/// grpc.health.v1.Health токеном НЕ проверяется: это liveness инфраструктуры (Ruling 12), -/// данных тенантов он не отдаёт (в Deal.Api health-сервис появится при включении healthcheck -/// ингресса — исключение оставлено по общему шаблону T2–T4). Fail-closed: если DEAL_SERVICE_TOKEN -/// не задан — любой RPC ингресса отклоняется (UNAUTHENTICATED). -/// -public sealed class IngressServiceTokenInterceptor : Interceptor -{ - /// - /// Ключ gRPC-metadata с токеном сервиса (контракт — README src/contracts). - /// - public const string ServiceTokenMetadataKey = "service-token"; - - /// - /// Префикс методов стандартного gRPC-health, освобождённых от проверки токена. - /// Общий для интерцепторов ингресса: IngressRateLimitInterceptor тоже пропускает health (Ruling 5). - /// - public const string HealthMethodPrefix = "/grpc.health.v1.Health/"; - - // Env-ключ ожидаемого токена (только env; ключи/секреты не логируются — Ruling 13). - private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - - // Деталь отказа — общий текст для процессов этапа (шаблон T2–T4). - private const string RejectionDetail = "service-token отсутствует или неверен"; - - private readonly string _expectedToken; - - /// - /// Создаёт интерцептор. Ожидаемый токен читается из конфигурации (env DEAL_SERVICE_TOKEN) - /// в момент старта хоста; смена токена требует рестарта (как остальной env-конфиг). - /// - /// Конфигурация хоста (env-провайдер WebApplicationBuilder). - public IngressServiceTokenInterceptor(IConfiguration configuration) - { - _expectedToken = configuration[ServiceTokenEnvKey] ?? string.Empty; - } - - /// - /// Проверка токена для unary-RPC: сначала пропускаются методы gRPC-health (безопасны), затем - /// сверяется metadata «service-token» с ожидаемым значением; несовпадение — UNAUTHENTICATED. - /// - /// Тип запроса 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-ингресса (план Task 12; Ruling 1/2). +/// +/// Каждый RPC ингресса обязан нести gRPC-metadata «service-token», равный ожидаемому значению +/// из env DEAL_SERVICE_TOKEN (общий токен сервисов в compose, Ruling 12). Отсутствие или +/// несовпадение токена — отказ UNAUTHENTICATED до вызова метода сервиса. Стандартный +/// grpc.health.v1.Health токеном НЕ проверяется: это liveness инфраструктуры (Ruling 12), +/// данных тенантов он не отдаёт (в Deal.Api health-сервис появится при включении healthcheck +/// ингресса — исключение оставлено по общему шаблону T2–T4). Fail-closed: если DEAL_SERVICE_TOKEN +/// не задан — любой RPC ингресса отклоняется (UNAUTHENTICATED). +/// +public sealed class IngressServiceTokenInterceptor : Interceptor +{ + /// + /// Ключ gRPC-metadata с токеном сервиса (контракт — README src/contracts). + /// + public const string ServiceTokenMetadataKey = "service-token"; + + /// + /// Префикс методов стандартного gRPC-health, освобождённых от проверки токена. + /// Общий для интерцепторов ингресса: IngressRateLimitInterceptor тоже пропускает health (Ruling 5). + /// + public const string HealthMethodPrefix = "/grpc.health.v1.Health/"; + + // Env-ключ ожидаемого токена (только env; ключи/секреты не логируются — Ruling 13). + private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; + + // Деталь отказа — общий текст для процессов этапа (шаблон T2–T4). + private const string RejectionDetail = "service-token отсутствует или неверен"; + + private readonly string _expectedToken; + + /// + /// Создаёт интерцептор. Ожидаемый токен читается из конфигурации (env DEAL_SERVICE_TOKEN) + /// в момент старта хоста; смена токена требует рестарта (как остальной env-конфиг). + /// + /// Конфигурация хоста (env-провайдер WebApplicationBuilder). + public IngressServiceTokenInterceptor(IConfiguration configuration) + { + _expectedToken = configuration[ServiceTokenEnvKey] ?? string.Empty; + } + + /// + /// Проверка токена для unary-RPC: сначала пропускаются методы gRPC-health (безопасны), затем + /// сверяется metadata «service-token» с ожидаемым значением; несовпадение — UNAUTHENTICATED. + /// + /// Тип запроса 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 b21c2c7..9ff15fd 100644 --- a/src/core/Deal.Api/Telegram/RpcCallLoggingInterceptor.cs +++ b/src/core/Deal.Api/Telegram/RpcCallLoggingInterceptor.cs @@ -1,94 +1,92 @@ -using System.Diagnostics; -using Grpc.Core; -using Grpc.Core.Interceptors; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Telegram; - -/// -/// Access-лог RPC gRPC-ингресса (Ruling 7, план Task 14): каждый вызов (кроме gRPC-health) — одна -/// структурированная строка «метод → статус за N мс». HTTP-уровень ингресса (POST-запросы HTTP/2) -/// логируется этим интерцептором содержательно: gRPC-статус в HTTP-коде не отражается (всегда 200), -/// поэтому HTTP access-лог (Deal.Api.Middleware.HttpAccessLogMiddleware) пропускает запросы -/// application/grpc. -/// -/// -/// Регистрируется ПЕРВЫМ в цепочке AddGrpc (до ServiceToken/rate-limit интерцепторов): логируются и -/// отклонённые вызовы (401/429) — access-лог должен видеть отказы. Значения не логируются (в теле RPC — -/// тексты/промпты; metadata — только токен/tenant-id), секреты не пишутся (Ruling 13). gRPC-health -/// (docker healthcheck каждые ~5 с) пропускается — иначе лог был бы зашумлён инфраструктурными пробами. -/// -public sealed class RpcCallLoggingInterceptor : Interceptor -{ - // Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness). - private const string HealthMethodPrefix = "/grpc.health.v1.Health/"; - - private readonly ILogger _logger; - - /// - /// Создаёт интерцептор access-лога gRPC-вызовов. - /// - /// Логгер (Serilog, Ruling 7). - public RpcCallLoggingInterceptor(ILogger logger) - { - ArgumentNullException.ThrowIfNull(logger); - _logger = logger; - } - - /// - /// Логирует unary-RPC: время вызова и итоговый gRPC-статус (успех либо статус исключения). - /// - /// Тип запроса 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-ингресса (Ruling 7, план Task 14): каждый вызов (кроме gRPC-health) — одна +/// структурированная строка «метод → статус за N мс». HTTP-уровень ингресса (POST-запросы HTTP/2) +/// логируется этим интерцептором содержательно: gRPC-статус в HTTP-коде не отражается (всегда 200), +/// поэтому HTTP access-лог (Deal.Api.Middleware.HttpAccessLogMiddleware) пропускает запросы +/// application/grpc. +/// +/// +/// Регистрируется ПЕРВЫМ в цепочке AddGrpc (до ServiceToken/rate-limit интерцепторов): логируются и +/// отклонённые вызовы (401/429) — access-лог должен видеть отказы. Значения не логируются (в теле RPC — +/// тексты/промпты; metadata — только токен/tenant-id), секреты не пишутся (Ruling 13). gRPC-health +/// (docker healthcheck каждые ~5 с) пропускается — иначе лог был бы зашумлён инфраструктурными пробами. +/// +public sealed class RpcCallLoggingInterceptor : Interceptor +{ + // Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness). + private const string HealthMethodPrefix = "/grpc.health.v1.Health/"; + + private readonly ILogger _logger; + + /// + /// Создаёт интерцептор access-лога gRPC-вызовов. + /// + /// Логгер (Serilog, Ruling 7). + public RpcCallLoggingInterceptor(ILogger logger) + { + ArgumentNullException.ThrowIfNull(logger); + _logger = logger; + } + + /// + /// Логирует unary-RPC: время вызова и итоговый gRPC-статус (успех либо статус исключения). + /// + /// Тип запроса 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 2705d1c..d1a72af 100644 --- a/src/core/Deal.Api/Telegram/TelegramIngressService.cs +++ b/src/core/Deal.Api/Telegram/TelegramIngressService.cs @@ -1,436 +1,427 @@ -using System.Text.Json; -using Deal.Api.Events; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Telegram; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Grpc.Core; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Telegram; - -/// -/// gRPC-сервер входящего потока telegram-service → ядро (план Task 12, L361–377; Ruling 1/7). -/// -/// Реализация серверной стороны Deal.Grpc.Telegram.IngressService (telegram.proto, L380–395): -/// PushMessage — новое/догоняющее сообщение мониторящегося диалога в очередь пайплайна -/// (, тот же контракт, что приём сообщений пайплайна) в схеме тенанта -/// + превью (DialogsService.SavePreview: TgMessages + «последнее сообщение» каталога, Ruling 7); -/// SyncDialogs — применение каталога диалогов (DialogsService.SyncFromTelegram) и ответ со списком -/// monitored id (зеркало сервиса); ReportStatus — статус аккаунта в KV (tgStatus/tgAccount) + SSE -/// system_status/тосты на переходах фаз. -/// -/// -/// Tenant-id берётся ТОЛЬКО из gRPC-metadata (полю в теле не доверяем — Ruling 1), принадлежность -/// подтверждается реестром тенантов (public.tenants), затем для работы открывается собственный scope -/// с ITenantContext.SetTenant (эталон PipelineWorkerScheduler, L169–213): tenant-scoped адаптеры -/// (PipelineStore/SettingsStore) строятся от схемы тенанта. Неизвестный тенант/сбой схемы — RPC не падает: -/// ответ не-принято (accepted=false / ok=false, план Task 12) + лог аудита (Ruling 13); недоступный сервис -/// догоняет упущенное realtime-sweep (контракт README). -/// -/// Полная синхронизация каталога (применение entries к таблице Dialogs, ответ = список monitored id) — -/// модуль Deal.Modules.Telegram (план Task 13): DialogsService.SyncFromTelegram (upsert/удаление, авто- -/// мониторинг новых по autoMonitorNew), превью сообщений — DialogsService.SavePreview (PushMessage). -/// -/// -public sealed class TelegramIngressService( - IServiceScopeFactory scopeFactory, - SseBroker broker, - ILogger logger) : IngressService.IngressServiceBase -{ - /// - /// Ключ gRPC-metadata с id тенанта (единственный источник принадлежности — Ruling 1). - /// - public const string TenantIdMetadataKey = "tenant-id"; - - // Тип SSE-события статуса Telegram (фронт по нему перечитывает GET /api/tg/status, Ruling 7). - private const string SystemStatusEventType = "system_status"; - - // Тип SSE-события тоста (Ruling 5; api.js L79 слушает 'toast'). - private const string ToastEventType = "toast"; - - // Текст тоста подключения (Ruling 7, 1:1 с прототипом). - private const string ConnectedToastText = "Telegram подключён, сессия сохранена"; - - // Текст тоста отключения (Ruling 7, 1:1 с прототипом). - private const string DisconnectedToastText = "Telegram отключён"; - - // Иконка тоста подключения (из набора Icon.vue фронта). - private const string ConnectedToastIcon = "send"; - - // Иконка тоста отключения (из набора Icon.vue фронта). - private const string DisconnectedToastIcon = "logout"; - - // Деталь отказа: metadata tenant-id отсутствует (UNAUTHENTICATED, README src/contracts). - private const string MissingTenantIdDetail = "tenant-id отсутствует в metadata"; - - // Опции JSON KV-статуса: camelCase (1:1 с wire-именами) + терпимость регистра при чтении. - private static readonly JsonSerializerOptions StatusJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - /// - /// PushMessage — сообщение диалога в очередь пайплайна тенанта + превью (PushMessageRequest, Ruling 7). - /// - /// Дубль dialog_id+msg_id уже в очереди — duplicate=true, очередь не растёт (гвард - /// PipelineIngestService). Пустой текст/диалог — no-op приёма (accepted=false, контракт proto). - /// После постановки в очередь пишется превью (DialogsService.SavePreview: строка TgMessages - /// «m_<dialog>_<msg>» + «последнее сообщение» каталога — 1:1 _on_message python L270–274); - /// сбой превью не влияет на приём (accepted определён очередью, лог дебага). - /// Неизвестный тенант или сбой схемы/БД — не-принято (accepted=false) без исключения RPC. - /// Сообщение из потока telegram-service. - /// Контекст вызова (metadata tenant-id + service-token). - /// accepted — сообщение принято (либо дубль), duplicate — уже было в очереди. - public override async Task PushMessage(PushMessageRequest request, ServerCallContext context) - { - TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false); - if (tenant is null) - { - return new PushMessageReply(); - } - - await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope(); - ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); - try - { - // Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта. - tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); - PipelineIngestService ingest = tenantScope.ServiceProvider.GetRequiredService(); - - PipelineIngestResultDto result = await ingest.EnqueueAsync( - new QueuedMessage - { - DialogId = request.DialogId, - ChannelName = request.ChannelName, - ChannelHandle = request.ChannelHandle, - ChannelHue = request.ChannelHue, - MsgId = request.HasMsgId ? request.MsgId : null, - Text = request.Text, - MsgAtMs = request.HasMsgAt ? request.MsgAt : null, - }, - context.CancellationToken).ConfigureAwait(false); - - await SavePreviewSafelyAsync(tenantScope, tenant, request, context.CancellationToken).ConfigureAwait(false); - - logger.LogInformation( - "Аудит: PushMessage {TenantId} диалог {DialogId} msg {MsgId} → {Outcome}", - tenant.Id, - request.DialogId, - request.HasMsgId ? request.MsgId.ToString() : "-", - result.Duplicate ? "duplicate" : result.Id is null ? "no-op" : "queued"); - - return new PushMessageReply - { - Accepted = result.Id is not null || result.Duplicate, - Duplicate = result.Duplicate, - }; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - // Сбой схемы/БД тенанта (напр. схема ещё не провижинена): RPC не падает — reply not-accepted - // (план Task 12), упущенное сообщение при необходимости догонит realtime-sweep сервиса. - logger.LogWarning(exception, "Аудит: PushMessage {TenantId} → не принято (сбой схемы/БД)", tenant.Id); - return new PushMessageReply(); - } - finally - { - tenantContext.Reset(); - } - } - - /// - /// SyncDialogs — синхронизация каталога диалогов аккаунта (Ruling 7, L386–390). - /// - /// - /// Модуль Deal.Modules.Telegram (план Task 13) применяет entries к таблице Dialogs - /// (DialogsService.SyncFromTelegram: upsert + удаление отсутствующих; авто-мониторинг новых — по - /// настройке autoMonitorNew). Ответ несёт актуальный список monitored id — по нему telegram-service - /// держит своё зеркало мониторинга в памяти (обновляется ответом SyncDialogs и командой SetMonitor, - /// Ruling 7) и фильтрует события realtime. - /// - /// Актуальный каталог диалогов (entries). - /// Контекст вызова. - /// monitored_ids — диалоги с включённым мониторингом после применения каталога. - public override async Task SyncDialogs(SyncDialogsRequest request, ServerCallContext context) - { - TenantRecordDto? tenant = await ResolveTenantAsync(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/тосты на переходах фаз (Ruling 7). - /// - /// KV tgStatus (снимок без account) и tgAccount (JSON-строка) пишутся в схему тенанта; - /// system_status публикуется на каждый репорт (фронт перечитывает /api/tg/status), тосты — только на - /// переходы connected: false→true «Telegram подключён, сессия сохранена», true→false «Telegram отключён» - /// (сервис шлёт статус по событию и heartbeat'ом — без гарда переходов тосты дублировались бы). - /// Неизвестный тенант/сбой схемы — ok=false без исключения RPC (план Task 12). - /// Статус аккаунта из _publish_status прототипа. - /// Контекст вызова. - /// ok — статус принят и сохранён. - public override async Task ReportStatus(ReportStatusRequest request, ServerCallContext context) - { - TenantRecordDto? tenant = await ResolveTenantAsync(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(); - } - } - - // Разрешает тенанта запроса: metadata tenant-id → реестр public.tenants. - // Отсутствующий/пустой tenant-id — RPC-отказ UNAUTHENTICATED (README: tenant-id обязателен). - // Id не Guid либо записи нет в реестре — неизвестный тенант: лог аудита и null (RPC отвечает не-принято, - // план Task 12: «для несуществующего тенанта не падает»). - // context: Контекст вызова. - // Возвращает: Запись тенанта реестра либо null (тенант неизвестен). - private async Task ResolveTenantAsync(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; - } - - // Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, README). - // context: Контекст вызова. - // Возвращает: Значение tenant-id. - private 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; - } - - // Пишет превью принятого сообщения (TgMessages + «последнее сообщение» каталога) без влияния на приём. - // Ruling 7: PushMessage → EnqueueAsync + превью. Сбой превью (нет таблиц/строки каталога и т.п.) - // не роняет RPC и не меняет accepted — очередь уже записана, упущенное догонит realtime-sweep (как - // python: обновление last_text после enqueue в том же обработчике, ошибка не отменяет приём). - // tenantScope: Scope тенанта (TenantDbContext построен на схеме тенанта). - // tenant: Тенант канала (для лога аудита). - // request: Сообщение PushMessage. - // ct: Токен отмены. - private async Task SavePreviewSafelyAsync( - AsyncServiceScope tenantScope, - TenantRecordDto tenant, - PushMessageRequest request, - CancellationToken ct) - { - try - { - DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService(); - DateTimeOffset? msgAt = request.HasMsgAt ? DateTimeOffset.FromUnixTimeMilliseconds(request.MsgAt) : null; - await dialogs.SavePreviewAsync( - request.DialogId, - request.HasMsgId ? request.MsgId : null, - request.Text, - msgAt, - ct).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - // Превью — вторичная запись: приём не затронут (лог дебага, не ошибка RPC). - logger.LogDebug(exception, "PushMessage {TenantId}: превью не сохранено (приём не затронут)", tenant.Id); - } - } - - // Публикует 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); - } - } - - // Публикует SSE-тост в канал тенанта (без подписчиков — no-op, Ruling 5). - // tenantId: Тенант-получатель. - // text: Текст тоста. - // icon: Иконка тоста (набор Icon.vue фронта). - 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.Contracts.Integrations.Models; +using Deal.Grpc.Telegram; +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.Telegram.Application; +using Deal.Modules.Tenants.Application.Abstractions; +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 → ядро (план Task 12, L361–377; Ruling 1/7). +/// +/// Реализация серверной стороны Deal.Grpc.Telegram.IngressService (telegram.proto, L380–395): +/// PushMessage — новое/догоняющее сообщение мониторящегося диалога в очередь пайплайна +/// (, тот же контракт, что приём сообщений пайплайна) в схеме тенанта +/// + превью (DialogsService.SavePreview: TgMessages + «последнее сообщение» каталога, Ruling 7); +/// SyncDialogs — применение каталога диалогов (DialogsService.SyncFromTelegram) и ответ со списком +/// monitored id (зеркало сервиса); ReportStatus — статус аккаунта в KV (tgStatus/tgAccount) + SSE +/// system_status/тосты на переходах фаз. +/// +/// +/// Tenant-id берётся ТОЛЬКО из gRPC-metadata (полю в теле не доверяем — Ruling 1), принадлежность +/// подтверждается реестром тенантов (public.tenants), затем для работы открывается собственный scope +/// с ITenantContext.SetTenant (эталон PipelineWorkerScheduler, L169–213): tenant-scoped адаптеры +/// (PipelineStore/SettingsStore) строятся от схемы тенанта. Неизвестный тенант/сбой схемы — RPC не падает: +/// ответ не-принято (accepted=false / ok=false, план Task 12) + лог аудита (Ruling 13); недоступный сервис +/// догоняет упущенное realtime-sweep (контракт README). +/// +/// Полная синхронизация каталога (применение entries к таблице Dialogs, ответ = список monitored id) — +/// модуль Deal.Modules.Telegram (план Task 13): DialogsService.SyncFromTelegram (upsert/удаление, авто- +/// мониторинг новых по autoMonitorNew), превью сообщений — DialogsService.SavePreview (PushMessage). +/// +/// +public sealed class TelegramIngressService( + IServiceScopeFactory scopeFactory, + SseBroker broker, + ILogger logger) : IngressService.IngressServiceBase +{ + /// + /// Ключ gRPC-metadata с id тенанта (единственный источник принадлежности — Ruling 1). + /// + public const string TenantIdMetadataKey = "tenant-id"; + + // Тип SSE-события статуса Telegram (фронт по нему перечитывает GET /api/tg/status, Ruling 7). + private const string SystemStatusEventType = "system_status"; + + // Тип SSE-события тоста (Ruling 5; api.js L79 слушает 'toast'). + private const string ToastEventType = "toast"; + + // Текст тоста подключения (Ruling 7, 1:1 с прототипом). + private const string ConnectedToastText = "Telegram подключён, сессия сохранена"; + + // Текст тоста отключения (Ruling 7, 1:1 с прототипом). + private const string DisconnectedToastText = "Telegram отключён"; + + // Иконка тоста подключения (из набора Icon.vue фронта). + private const string ConnectedToastIcon = "send"; + + // Иконка тоста отключения (из набора Icon.vue фронта). + private const string DisconnectedToastIcon = "logout"; + + // Деталь отказа: metadata tenant-id отсутствует (UNAUTHENTICATED, README src/contracts). + private const string MissingTenantIdDetail = "tenant-id отсутствует в metadata"; + + // Опции JSON KV-статуса: camelCase (1:1 с wire-именами) + терпимость регистра при чтении. + private static readonly JsonSerializerOptions StatusJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + /// + /// PushMessage — сообщение диалога в очередь пайплайна тенанта + превью (PushMessageRequest, Ruling 7). + /// + /// Дубль dialog_id+msg_id уже в очереди — duplicate=true, очередь не растёт (гвард + /// PipelineIngestService). Пустой текст/диалог — no-op приёма (accepted=false, контракт proto). + /// После постановки в очередь пишется превью (DialogsService.SavePreview: строка TgMessages + /// «m_<dialog>_<msg>» + «последнее сообщение» каталога — 1:1 _on_message python L270–274); + /// сбой превью не влияет на приём (accepted определён очередью, лог дебага). + /// Неизвестный тенант или сбой схемы/БД — не-принято (accepted=false) без исключения RPC. + /// Сообщение из потока telegram-service. + /// Контекст вызова (metadata tenant-id + service-token). + /// accepted — сообщение принято (либо дубль), duplicate — уже было в очереди. + public override async Task PushMessage(PushMessageRequest request, ServerCallContext context) + { + TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false); + if (tenant is null) + { + return new PushMessageReply(); + } + + await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope(); + ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); + try + { + // Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта. + tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); + PipelineIngestService ingest = tenantScope.ServiceProvider.GetRequiredService(); + + PipelineIngestResultDto result = await ingest.EnqueueAsync( + new QueuedMessage + { + DialogId = request.DialogId, + ChannelName = request.ChannelName, + ChannelHandle = request.ChannelHandle, + ChannelHue = request.ChannelHue, + MsgId = request.HasMsgId ? request.MsgId : null, + Text = request.Text, + MsgAtMs = request.HasMsgAt ? request.MsgAt : null, + }, + context.CancellationToken).ConfigureAwait(false); + + await SavePreviewSafelyAsync(tenantScope, tenant, request, context.CancellationToken).ConfigureAwait(false); + + logger.LogInformation( + "Аудит: PushMessage {TenantId} диалог {DialogId} msg {MsgId} → {Outcome}", + tenant.Id, + request.DialogId, + request.HasMsgId ? request.MsgId.ToString() : "-", + result.Duplicate ? "duplicate" : result.Id is null ? "no-op" : "queued"); + + return new PushMessageReply + { + Accepted = result.Id is not null || result.Duplicate, + Duplicate = result.Duplicate, + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + // Сбой схемы/БД тенанта (напр. схема ещё не провижинена): RPC не падает — reply not-accepted + // (план Task 12), упущенное сообщение при необходимости догонит realtime-sweep сервиса. + logger.LogWarning(exception, "Аудит: PushMessage {TenantId} → не принято (сбой схемы/БД)", tenant.Id); + return new PushMessageReply(); + } + finally + { + tenantContext.Reset(); + } + } + + /// + /// SyncDialogs — синхронизация каталога диалогов аккаунта (Ruling 7, L386–390). + /// + /// + /// Модуль Deal.Modules.Telegram (план Task 13) применяет entries к таблице Dialogs + /// (DialogsService.SyncFromTelegram: upsert + удаление отсутствующих; авто-мониторинг новых — по + /// настройке autoMonitorNew). Ответ несёт актуальный список monitored id — по нему telegram-service + /// держит своё зеркало мониторинга в памяти (обновляется ответом SyncDialogs и командой SetMonitor, + /// Ruling 7) и фильтрует события realtime. + /// + /// Актуальный каталог диалогов (entries). + /// Контекст вызова. + /// monitored_ids — диалоги с включённым мониторингом после применения каталога. + public override async Task SyncDialogs(SyncDialogsRequest request, ServerCallContext context) + { + TenantRecordDto? tenant = await ResolveTenantAsync(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/тосты на переходах фаз (Ruling 7). + /// + /// KV tgStatus (снимок без account) и tgAccount (JSON-строка) пишутся в схему тенанта; + /// system_status публикуется на каждый репорт (фронт перечитывает /api/tg/status), тосты — только на + /// переходы connected: false→true «Telegram подключён, сессия сохранена», true→false «Telegram отключён» + /// (сервис шлёт статус по событию и heartbeat'ом — без гарда переходов тосты дублировались бы). + /// Неизвестный тенант/сбой схемы — ok=false без исключения RPC (план Task 12). + /// Статус аккаунта из _publish_status прототипа. + /// Контекст вызова. + /// ok — статус принят и сохранён. + public override async Task ReportStatus(ReportStatusRequest request, ServerCallContext context) + { + TenantRecordDto? tenant = await ResolveTenantAsync(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(); + } + } + + // Разрешает тенанта запроса: metadata tenant-id → реестр public.tenants. + // Отсутствующий/пустой tenant-id — RPC-отказ UNAUTHENTICATED (README: tenant-id обязателен). + // Id не Guid либо записи нет в реестре — неизвестный тенант: лог аудита и null (RPC отвечает не-принято, + // план Task 12: «для несуществующего тенанта не падает»). + // context: Контекст вызова. + // Возвращает: Запись тенанта реестра либо null (тенант неизвестен). + private async Task ResolveTenantAsync(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; + } + + // Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, README). + // context: Контекст вызова. + // Возвращает: Значение tenant-id. + private 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; + } + + // Пишет превью принятого сообщения (TgMessages + «последнее сообщение» каталога) без влияния на приём. + // Ruling 7: PushMessage → EnqueueAsync + превью. Сбой превью (нет таблиц/строки каталога и т.п.) + // не роняет RPC и не меняет accepted — очередь уже записана, упущенное догонит realtime-sweep (как + // python: обновление last_text после enqueue в том же обработчике, ошибка не отменяет приём). + // tenantScope: Scope тенанта (TenantDbContext построен на схеме тенанта). + // tenant: Тенант канала (для лога аудита). + // request: Сообщение PushMessage. + // ct: Токен отмены. + private async Task SavePreviewSafelyAsync( + AsyncServiceScope tenantScope, + TenantRecordDto tenant, + PushMessageRequest request, + CancellationToken ct) + { + try + { + DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService(); + DateTimeOffset? msgAt = request.HasMsgAt ? DateTimeOffset.FromUnixTimeMilliseconds(request.MsgAt) : null; + await dialogs.SavePreviewAsync( + request.DialogId, + request.HasMsgId ? request.MsgId : null, + request.Text, + msgAt, + ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + // Превью — вторичная запись: приём не затронут (лог дебага, не ошибка RPC). + logger.LogDebug(exception, "PushMessage {TenantId}: превью не сохранено (приём не затронут)", tenant.Id); + } + } + + // Публикует 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); + } + } + + // Публикует SSE-тост в канал тенанта (без подписчиков — no-op, Ruling 5). + // tenantId: Тенант-получатель. + // text: Текст тоста. + // icon: Иконка тоста (набор Icon.vue фронта). + 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 0466f6c..9eb78cc 100644 --- a/src/core/Deal.Api/Telegram/TelegramKeysMaskedDto.cs +++ b/src/core/Deal.Api/Telegram/TelegramKeysMaskedDto.cs @@ -1,6 +1,3 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - namespace Deal.Api.Telegram; /// diff --git a/src/core/Deal.Api/Telegram/TelegramKeysService.cs b/src/core/Deal.Api/Telegram/TelegramKeysService.cs index 5f7d5ec..d5cb6dd 100644 --- a/src/core/Deal.Api/Telegram/TelegramKeysService.cs +++ b/src/core/Deal.Api/Telegram/TelegramKeysService.cs @@ -1,167 +1,163 @@ -using System.Text.Json; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Telegram; - -/// -/// Читает, расшифровывает и сохраняет ключи приложения Telegram из глобальной (системной) -/// настройки оператора (ТЗ §4.1/§8.1). -/// -/// -/// Источник ключей — операторский уровень: public.global_settings (ключ -/// ), единый для всех тенантов; тенант ключи не задаёт. -/// apiHash хранится зашифрованным (enc: — Ruling 2), apiId — открытым (не секрет). Наружу -/// (командам входа/статусу) отдаётся расшифрованный снимок, операторским ручкам — маскированный -/// (apiHash не раскрывается). Повреждённая строка/сбой расшифровки — пустые ключи (мягкая семантика). -/// Scoped: IGlobalSettingsStore живёт на системном DealDbContext запроса. -/// -/// 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 = "…"; - - // Префикс зашифрованного значения (маркер формата в хранилище, Ruling 2). - private const string EncryptedPrefix = "enc:"; - - // Опции JSON значения telegramKeys: camelCase (как пишет SaveAsync) + терпимость регистра. - private static readonly JsonSerializerOptions KeysJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - /// - /// Читает текущие ключи приложения: apiId (открыт в БД) + расшифрованный apiHash. - /// - /// Токен отмены. - /// Снимок ключей (пустые — настройка не задана/повреждена). - 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); - } - } - - /// - /// Маскированная форма ключей для операторской ручки: apiId открыт, apiHash — маска. - /// - /// Токен отмены. - /// 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); - } - - /// - /// Сохраняет глобальные ключи приложения: apiId открытым, apiHash — зашифрованным. - /// - /// 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: только ASCII-цифры, длина 5..9 (ТЗ §4.1/§8.1). - /// - /// Проверяемое значение (уже без пробелов). - /// True — значение допустимо. - public static bool IsValidApiId(string apiId) => - apiId.Length is >= ApiIdMinDigits and <= ApiIdMaxDigits && apiId.All(char.IsAsciiDigit); - - /// - /// Валиден ли api_hash: непустой, без маски (…), без префикса enc:. - /// - /// Проверяемое значение (уже без пробелов). - /// 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 из глобальной (системной) +/// настройки оператора (ТЗ §4.1/§8.1). +/// +/// +/// Источник ключей — операторский уровень: public.global_settings (ключ +/// ), единый для всех тенантов; тенант ключи не задаёт. +/// apiHash хранится зашифрованным (enc: — Ruling 2), apiId — открытым (не секрет). Наружу +/// (командам входа/статусу) отдаётся расшифрованный снимок, операторским ручкам — маскированный +/// (apiHash не раскрывается). Повреждённая строка/сбой расшифровки — пустые ключи (мягкая семантика). +/// Scoped: IGlobalSettingsStore живёт на системном DealDbContext запроса. +/// +/// 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 = "…"; + + // Префикс зашифрованного значения (маркер формата в хранилище, Ruling 2). + private const string EncryptedPrefix = "enc:"; + + // Опции JSON значения telegramKeys: camelCase (как пишет SaveAsync) + терпимость регистра. + private static readonly JsonSerializerOptions KeysJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + /// + /// Читает текущие ключи приложения: apiId (открыт в БД) + расшифрованный apiHash. + /// + /// Токен отмены. + /// Снимок ключей (пустые — настройка не задана/повреждена). + 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); + } + } + + /// + /// Маскированная форма ключей для операторской ручки: apiId открыт, apiHash — маска. + /// + /// Токен отмены. + /// 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); + } + + /// + /// Сохраняет глобальные ключи приложения: apiId открытым, apiHash — зашифрованным. + /// + /// 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: только ASCII-цифры, длина 5..9 (ТЗ §4.1/§8.1). + /// + /// Проверяемое значение (уже без пробелов). + /// True — значение допустимо. + public static bool IsValidApiId(string apiId) => + apiId.Length is >= ApiIdMinDigits and <= ApiIdMaxDigits && apiId.All(char.IsAsciiDigit); + + /// + /// Валиден ли api_hash: непустой, без маски (…), без префикса enc:. + /// + /// Проверяемое значение (уже без пробелов). + /// 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 c58cced..0e0edfc 100644 --- a/src/core/Deal.Api/Telegram/TelegramKeysValue.cs +++ b/src/core/Deal.Api/Telegram/TelegramKeysValue.cs @@ -1,6 +1,3 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - namespace Deal.Api.Telegram; /// diff --git a/src/core/Deal.Api/Telegram/TgKeysSnapshot.cs b/src/core/Deal.Api/Telegram/TgKeysSnapshot.cs index d88b911..12ac3b0 100644 --- a/src/core/Deal.Api/Telegram/TgKeysSnapshot.cs +++ b/src/core/Deal.Api/Telegram/TgKeysSnapshot.cs @@ -1,23 +1,20 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Telegram; - -/// -/// Снимок глобальных ключей приложения Telegram для команд входа (ТЗ §4.1/§8.1, Ruling 3). -/// -/// -/// — уже расшифрованный секрет (глобальная настройка telegramKeys хранит apiHash -/// в enc:-форме, Ruling 2); наружу/в логи не отдаётся. = оба ключа непустые — -/// без них команды start-phone/start-qr отвечают «Ключи Telegram не заданы оператором», поле -/// status.keysSet (api-map §4.9). -/// -/// Идентификатор приложения Telegram (только цифры, длина 5..9; задаёт оператор). -/// Расшифрованный api_hash приложения Telegram (пуст — не задан). -public sealed record TgKeysSnapshot(string ApiId, string ApiHash) -{ - /// - /// Заданы ли оба ключа приложения (python L116: bool(_keys().get("apiId") and …)). - /// - public bool KeysSet => ApiId.Length > 0 && ApiHash.Length > 0; -} +namespace Deal.Api.Telegram; + +/// +/// Снимок глобальных ключей приложения Telegram для команд входа (ТЗ §4.1/§8.1, Ruling 3). +/// +/// +/// — уже расшифрованный секрет (глобальная настройка telegramKeys хранит apiHash +/// в enc:-форме, Ruling 2); наружу/в логи не отдаётся. = оба ключа непустые — +/// без них команды start-phone/start-qr отвечают «Ключи Telegram не заданы оператором», поле +/// status.keysSet (api-map §4.9). +/// +/// Идентификатор приложения Telegram (только цифры, длина 5..9; задаёт оператор). +/// Расшифрованный api_hash приложения Telegram (пуст — не задан). +public sealed record TgKeysSnapshot(string ApiId, string ApiHash) +{ + /// + /// Заданы ли оба ключа приложения (python L116: bool(_keys().get("apiId") and …)). + /// + 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 7b7bdfd..b6c70ec 100644 --- a/src/core/Deal.Api/Telegram/TgReportedStatus.cs +++ b/src/core/Deal.Api/Telegram/TgReportedStatus.cs @@ -1,41 +1,38 @@ -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Telegram; - -/// -/// Снимок статуса Telegram-аккаунта из RPC ReportStatus (план Task 12; Ruling 7). -/// -/// -/// Форма 1:1 с полями ReportStatusRequest (telegram.proto, L434–448), кроме account — аккаунт живёт -/// отдельным KV-ключом tgAccount (SettingsKeys, Ruling 7). Снимок пишется в KV tgStatus -/// (JSON, camelCase — конвенция value_json) и публикуется SSE-событием system_status: фронт по -/// этому событию перечитывает GET /api/tg/status (store.js startRealtime), сам payload не разбирает. -/// -public sealed record TgReportedStatus -{ - /// - /// Фаза входа: idle|phone|code|password|qr|ready (канон контракта, шапка telegram.proto). - /// - public string Phase { get; init; } = string.Empty; - - /// - /// Клиент подключён и авторизован. - /// - public bool Connected { get; init; } - - /// - /// Realtime-listener жив. - /// - public bool Listener { get; init; } - - /// - /// Текст ошибки (null — ошибки нет). - /// - public string? Error { get; init; } - - /// - /// URL QR-входа при phase == "qr" (иначе null). - /// - public string? QrUrl { get; init; } -} +namespace Deal.Api.Telegram; + +/// +/// Снимок статуса Telegram-аккаунта из RPC ReportStatus (план Task 12; Ruling 7). +/// +/// +/// Форма 1:1 с полями ReportStatusRequest (telegram.proto, L434–448), кроме account — аккаунт живёт +/// отдельным KV-ключом tgAccount (SettingsKeys, Ruling 7). Снимок пишется в KV tgStatus +/// (JSON, camelCase — конвенция value_json) и публикуется SSE-событием system_status: фронт по +/// этому событию перечитывает GET /api/tg/status (store.js startRealtime), сам payload не разбирает. +/// +public sealed record TgReportedStatus +{ + /// + /// Фаза входа: idle|phone|code|password|qr|ready (канон контракта, шапка telegram.proto). + /// + public string Phase { get; init; } = string.Empty; + + /// + /// Клиент подключён и авторизован. + /// + public bool Connected { get; init; } + + /// + /// Realtime-listener жив. + /// + public bool Listener { get; init; } + + /// + /// Текст ошибки (null — ошибки нет). + /// + public string? Error { get; init; } + + /// + /// URL QR-входа при phase == "qr" (иначе null). + /// + public string? QrUrl { get; init; } +} diff --git a/src/core/Deal.Api/Telegram/TgStatusService.cs b/src/core/Deal.Api/Telegram/TgStatusService.cs index 1380afb..742bb80 100644 --- a/src/core/Deal.Api/Telegram/TgStatusService.cs +++ b/src/core/Deal.Api/Telegram/TgStatusService.cs @@ -1,112 +1,108 @@ -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.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Telegram.Application.Models; -using Deal.Api.Services; -using Deal.Api.Dtos; - -namespace Deal.Api.Telegram; - -/// -/// Сборка статуса вкладки Telegram — GET /api/tg/status (Ruling 8, api-map §4.9 L357–359). -/// -/// -/// Форма 1:1 с status() python L103–119 в терминах этапа 6: -/// -/// live-поля (phase/connected/listener/error/qrUrl) — из гейта -/// (живой telegram-service); сервис недоступен/сессии нет (RPC-отказ) → idle-форма (Ruling 8); -/// account — из KV tgAccount (источник истины — ReportStatus ингресса, Ruling 7); -/// monitored — count(Dialogs WHERE Monitor) ядра (DialogsService.ListMonitoredIds); -/// keysSet — оба глобальных ключа приложения заданы оператором (TelegramKeysService, Ruling 3, ТЗ §4.1/§8.1). -/// -/// Scoped: зависимости живут на контекстах запроса (ISettingsStore — схема тенанта, IGlobalSettingsStore — public). -/// -/// Порт-гейт telegram-service (живой статус аккаунта). -/// Сервис каталога диалогов ядра (счётчик мониторящихся). -/// KV-хранилище настроек тенанта (tgAccount). -/// Глобальные ключи приложения Telegram (keysSet). -public sealed class TgStatusService( - ITelegramGateway gateway, - DialogsService dialogs, - ISettingsStore settings, - TelegramKeysService keys) -{ - // Фаза idle-формы (аккаунт не подключён/сервис недоступен — Ruling 8). - private const string IdlePhase = "idle"; - - // Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра. - private static readonly JsonSerializerOptions KvJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - /// - /// Форма GET /api/tg/status текущего тенанта (поля §4.9). - /// - /// Токен отмены. - /// Полный статус вкладки 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); - } - - // Живой статус из гейта; сбой (сервис недоступен/нет сессии) → idle-форма (Ruling 8). - // ct: Токен отмены. - // Возвращает: Статус гейта либо idle-поля. - private async Task ReadLiveAsync(CancellationToken ct) - { - try - { - return await gateway.StatusAsync(ct).ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException || !ct.IsCancellationRequested) - { - // «Сервис недоступен → idle-форма» (Ruling 8): connected=false, live-поля пусты. Аккаунт/счётчики - // ядро всё равно докладывает из своего KV/БД (ниже) — как python при отключённом клиенте. - return new TelegramAccountStatusDto(IdlePhase, Connected: false, Listener: false, string.Empty, null, null); - } - } - - // Аккаунт «@username» из KV tgAccount (JSON-строка, пишет ReportStatus ингресса, Ruling 7). - // ct: Токен отмены. - // Возвращает: Аккаунт или пустая строка. - 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 (Ruling 8, api-map §4.9 L357–359). +/// +/// +/// Форма 1:1 с status() python L103–119 в терминах этапа 6: +/// +/// live-поля (phase/connected/listener/error/qrUrl) — из гейта +/// (живой telegram-service); сервис недоступен/сессии нет (RPC-отказ) → idle-форма (Ruling 8); +/// account — из KV tgAccount (источник истины — ReportStatus ингресса, Ruling 7); +/// monitored — count(Dialogs WHERE Monitor) ядра (DialogsService.ListMonitoredIds); +/// keysSet — оба глобальных ключа приложения заданы оператором (TelegramKeysService, Ruling 3, ТЗ §4.1/§8.1). +/// +/// Scoped: зависимости живут на контекстах запроса (ISettingsStore — схема тенанта, IGlobalSettingsStore — public). +/// +/// Порт-гейт telegram-service (живой статус аккаунта). +/// Сервис каталога диалогов ядра (счётчик мониторящихся). +/// KV-хранилище настроек тенанта (tgAccount). +/// Глобальные ключи приложения Telegram (keysSet). +public sealed class TgStatusService( + ITelegramGateway gateway, + DialogsService dialogs, + ISettingsStore settings, + TelegramKeysService keys) +{ + // Фаза idle-формы (аккаунт не подключён/сервис недоступен — Ruling 8). + private const string IdlePhase = "idle"; + + // Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра. + private static readonly JsonSerializerOptions KvJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + /// + /// Форма GET /api/tg/status текущего тенанта (поля §4.9). + /// + /// Токен отмены. + /// Полный статус вкладки 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); + } + + // Живой статус из гейта; сбой (сервис недоступен/нет сессии) → idle-форма (Ruling 8). + // ct: Токен отмены. + // Возвращает: Статус гейта либо idle-поля. + private async Task ReadLiveAsync(CancellationToken ct) + { + try + { + return await gateway.StatusAsync(ct).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException || !ct.IsCancellationRequested) + { + // «Сервис недоступен → idle-форма» (Ruling 8): connected=false, live-поля пусты. Аккаунт/счётчики + // ядро всё равно докладывает из своего KV/БД (ниже) — как python при отключённом клиенте. + return new TelegramAccountStatusDto(IdlePhase, Connected: false, Listener: false, string.Empty, null, null); + } + } + + // Аккаунт «@username» из KV tgAccount (JSON-строка, пишет ReportStatus ингресса, Ruling 7). + // ct: Токен отмены. + // Возвращает: Аккаунт или пустая строка. + 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.Contracts/Integrations/Models/SourceDefaults.cs b/src/core/Deal.Contracts/Integrations/Models/SourceDefaults.cs index b01da82..ef51083 100644 --- a/src/core/Deal.Contracts/Integrations/Models/SourceDefaults.cs +++ b/src/core/Deal.Contracts/Integrations/Models/SourceDefaults.cs @@ -1,5 +1,3 @@ -using Deal.Contracts.Integrations.Abstractions; - namespace Deal.Contracts.Integrations.Models; /// diff --git a/src/core/Deal.Infrastructure/Data/ConnectionStringProvider.cs b/src/core/Deal.Infrastructure/Data/ConnectionStringProvider.cs index e263477..5f62151 100644 --- a/src/core/Deal.Infrastructure/Data/ConnectionStringProvider.cs +++ b/src/core/Deal.Infrastructure/Data/ConnectionStringProvider.cs @@ -1,4 +1,3 @@ -using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Models; using Microsoft.Extensions.Configuration; diff --git a/src/core/Deal.Infrastructure/Integrations/Abstractions/IMlTrainClient.cs b/src/core/Deal.Infrastructure/Integrations/Abstractions/IMlTrainClient.cs index 9482755..d10c215 100644 --- a/src/core/Deal.Infrastructure/Integrations/Abstractions/IMlTrainClient.cs +++ b/src/core/Deal.Infrastructure/Integrations/Abstractions/IMlTrainClient.cs @@ -1,29 +1,25 @@ -using Deal.Modules.Kanban.Application.Models; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; - -namespace Deal.Infrastructure.Integrations.Abstractions; - -/// -/// Порт отправки батча обучения в ml-service (RPC TrainBatch, ml.proto L52–55) — для MlOutboxFlushScheduler. -/// -/// -/// Отдельный от порт: сигнатура IMlClient не меняется -/// (Self-Review плана L530), а выгрузку очереди делает фоновый флашер (Ruling 6), которому нужен только -/// TrainBatch. Реализуется gRPC-адаптером и регистрируется только при -/// Services:Ml:UseLocal=false (Local-режиму ml-service не нужен — очередь копится, как в этапе 3). -/// -public interface IMlTrainClient -{ - /// - /// Отправляет порцию очереди обучения в ml-service (одна транзакция learn_batch, ml.proto L52–55). - /// - /// Строки outbox (text/label/delta; id в запрос не уходит — нужен вызывающему для удаления). - /// Токен отмены. - /// Число применённых примеров (= len(items) при успехе; no-op-пропуски сервис не считает). - /// Сервис недоступен/отклонил батч — строки НЕ удаляются (Ruling 6). - 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 (RPC TrainBatch, ml.proto L52–55) — для MlOutboxFlushScheduler. +/// +/// +/// Отдельный от порт: сигнатура IMlClient не меняется +/// (Self-Review плана L530), а выгрузку очереди делает фоновый флашер (Ruling 6), которому нужен только +/// TrainBatch. Реализуется gRPC-адаптером и регистрируется только при +/// Services:Ml:UseLocal=false (Local-режиму ml-service не нужен — очередь копится, как в этапе 3). +/// +public interface IMlTrainClient +{ + /// + /// Отправляет порцию очереди обучения в ml-service (одна транзакция learn_batch, ml.proto L52–55). + /// + /// Строки outbox (text/label/delta; id в запрос не уходит — нужен вызывающему для удаления). + /// Токен отмены. + /// Число применённых примеров (= len(items) при успехе; no-op-пропуски сервис не считает). + /// Сервис недоступен/отклонил батч — строки НЕ удаляются (Ruling 6). + public Task TrainBatchAsync(IReadOnlyList items, CancellationToken ct); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Exceptions/AiUnavailableException.cs b/src/core/Deal.Infrastructure/Integrations/Exceptions/AiUnavailableException.cs index 6a8fa40..d5304e6 100644 --- a/src/core/Deal.Infrastructure/Integrations/Exceptions/AiUnavailableException.cs +++ b/src/core/Deal.Infrastructure/Integrations/Exceptions/AiUnavailableException.cs @@ -1,9 +1,3 @@ -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; - namespace Deal.Infrastructure.Integrations.Exceptions; /// diff --git a/src/core/Deal.Infrastructure/Integrations/Extensions/RpcExceptionExtensions.cs b/src/core/Deal.Infrastructure/Integrations/Extensions/RpcExceptionExtensions.cs index c75799d..882cb6e 100644 --- a/src/core/Deal.Infrastructure/Integrations/Extensions/RpcExceptionExtensions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Extensions/RpcExceptionExtensions.cs @@ -1,9 +1,4 @@ using Grpc.Core; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Extensions; diff --git a/src/core/Deal.Infrastructure/Integrations/Extensions/UriExtensions.cs b/src/core/Deal.Infrastructure/Integrations/Extensions/UriExtensions.cs index be622bb..e3fb647 100644 --- a/src/core/Deal.Infrastructure/Integrations/Extensions/UriExtensions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Extensions/UriExtensions.cs @@ -1,10 +1,5 @@ using System.Net; using System.Net.Sockets; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Extensions; diff --git a/src/core/Deal.Infrastructure/Integrations/Models/AiGrpcConnection.cs b/src/core/Deal.Infrastructure/Integrations/Models/AiGrpcConnection.cs index aea6960..26c1fb3 100644 --- a/src/core/Deal.Infrastructure/Integrations/Models/AiGrpcConnection.cs +++ b/src/core/Deal.Infrastructure/Integrations/Models/AiGrpcConnection.cs @@ -1,99 +1,95 @@ -using Deal.Grpc.Ai; -using Grpc.Core; -using Grpc.Net.Client; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; - -namespace Deal.Infrastructure.Integrations.Models; - -/// -/// Транспорт gRPC-клиентов ai-service: общий канал + обязательные metadata (Ruling 1, эталон -/// MlGrpcConnection). -/// -/// -/// Singleton (канал живёт долго и переиспользуется всеми вызовами): endpoint из , -/// service-token — из env DEAL_SERVICE_TOKEN (Ruling 13: секреты только в env). Dev-транспорт без TLS -/// (Ruling 2); mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским сертификатом -/// и проверяет CA сервера (сертификаты передаются ). Пустой endpoint либо -/// пустой токен при создании — ошибка конфигурации (fail-closed: без токена сервис отвергнет каждый вызов -/// UNAUTHENTICATED, Ruling 1). Автоповторы Grpc.Net.Client отключены (MaxRetryAttempts=0): стратегию повторов -/// держит ai-service (retry 2 с паузами 0.8/2 с, Ruling 5) — ядро повторно не ждёт. -/// -public sealed class AiGrpcConnection : IDisposable -{ - /// - /// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor сервисов, Ruling 1). - /// - public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - - /// - /// Ключ gRPC-metadata с tenant-id (зеркало AiServiceImpl, Ruling 1). - /// - public const string TenantIdMetadataKey = "tenant-id"; - - /// - /// Ключ gRPC-metadata с service-token (зеркало AiServiceImpl, Ruling 1). - /// - public const string ServiceTokenMetadataKey = "service-token"; - - private readonly GrpcChannel _channel; - private readonly string _serviceToken; - - /// - /// Создаёт транспорт ai-service по конфигурации и env-токену (валидация fail-closed). - /// - /// Конфигурация секции Services:Ai (endpoint). - /// Сертификаты mTLS (Ruling 6): 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 вызова: tenant-id + service-token (Ruling 1). - /// - /// 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: общий канал + обязательные metadata (Ruling 1, эталон +/// MlGrpcConnection). +/// +/// +/// Singleton (канал живёт долго и переиспользуется всеми вызовами): endpoint из , +/// service-token — из env DEAL_SERVICE_TOKEN (Ruling 13: секреты только в env). Dev-транспорт без TLS +/// (Ruling 2); mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским сертификатом +/// и проверяет CA сервера (сертификаты передаются ). Пустой endpoint либо +/// пустой токен при создании — ошибка конфигурации (fail-closed: без токена сервис отвергнет каждый вызов +/// UNAUTHENTICATED, Ruling 1). Автоповторы Grpc.Net.Client отключены (MaxRetryAttempts=0): стратегию повторов +/// держит ai-service (retry 2 с паузами 0.8/2 с, Ruling 5) — ядро повторно не ждёт. +/// +public sealed class AiGrpcConnection : IDisposable +{ + /// + /// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor сервисов, Ruling 1). + /// + public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; + + /// + /// Ключ gRPC-metadata с tenant-id (зеркало AiServiceImpl, Ruling 1). + /// + public const string TenantIdMetadataKey = "tenant-id"; + + /// + /// Ключ gRPC-metadata с service-token (зеркало AiServiceImpl, Ruling 1). + /// + public const string ServiceTokenMetadataKey = "service-token"; + + private readonly GrpcChannel _channel; + private readonly string _serviceToken; + + /// + /// Создаёт транспорт ai-service по конфигурации и env-токену (валидация fail-closed). + /// + /// Конфигурация секции Services:Ai (endpoint). + /// Сертификаты mTLS (Ruling 6): 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 вызова: tenant-id + service-token (Ruling 1). + /// + /// 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 c694b9d..df475f7 100644 --- a/src/core/Deal.Infrastructure/Integrations/Models/MlGrpcConnection.cs +++ b/src/core/Deal.Infrastructure/Integrations/Models/MlGrpcConnection.cs @@ -1,98 +1,94 @@ -using Deal.Grpc.Ml; -using Grpc.Core; -using Grpc.Net.Client; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; - -namespace Deal.Infrastructure.Integrations.Models; - -/// -/// Транспорт gRPC-клиента ml-service: общий канал + обязательные metadata (Ruling 1). -/// -/// -/// Singleton (канал живёт долго и переиспользуется всеми вызовами): endpoint из , -/// service-token — из env DEAL_SERVICE_TOKEN (Ruling 13: секреты только в env). Dev-транспорт без TLS -/// (Ruling 2); mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским сертификатом -/// и проверяет CA сервера (сертификаты передаются ). Пустой endpoint либо -/// пустой токен при создании — ошибка конфигурации (fail-closed: без токена сервис отвергнет каждый вызов -/// UNAUTHENTICATED, Ruling 1). Автоповторы Grpc.Net.Client отключены (MaxRetryAttempts=0): стратегию повторов -/// реализует вызывающий (флашер MlOutboxFlushScheduler оставляет строки и пробует в следующем цикле). -/// -public sealed class MlGrpcConnection : IDisposable -{ - /// - /// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor сервисов, Ruling 1). - /// - public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - - /// - /// Ключ gRPC-metadata с tenant-id (зеркало MlServiceImpl, Ruling 1). - /// - public const string TenantIdMetadataKey = "tenant-id"; - - /// - /// Ключ gRPC-metadata с service-token (зеркало MlServiceImpl, Ruling 1). - /// - public const string ServiceTokenMetadataKey = "service-token"; - - private readonly GrpcChannel _channel; - private readonly string _serviceToken; - - /// - /// Создаёт транспорт ml-service по конфигурации и env-токену (валидация fail-closed). - /// - /// Конфигурация секции Services:Ml (endpoint). - /// Сертификаты mTLS (Ruling 6): 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 вызова: tenant-id + service-token (Ruling 1). - /// - /// 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: общий канал + обязательные metadata (Ruling 1). +/// +/// +/// Singleton (канал живёт долго и переиспользуется всеми вызовами): endpoint из , +/// service-token — из env DEAL_SERVICE_TOKEN (Ruling 13: секреты только в env). Dev-транспорт без TLS +/// (Ruling 2); mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским сертификатом +/// и проверяет CA сервера (сертификаты передаются ). Пустой endpoint либо +/// пустой токен при создании — ошибка конфигурации (fail-closed: без токена сервис отвергнет каждый вызов +/// UNAUTHENTICATED, Ruling 1). Автоповторы Grpc.Net.Client отключены (MaxRetryAttempts=0): стратегию повторов +/// реализует вызывающий (флашер MlOutboxFlushScheduler оставляет строки и пробует в следующем цикле). +/// +public sealed class MlGrpcConnection : IDisposable +{ + /// + /// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor сервисов, Ruling 1). + /// + public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; + + /// + /// Ключ gRPC-metadata с tenant-id (зеркало MlServiceImpl, Ruling 1). + /// + public const string TenantIdMetadataKey = "tenant-id"; + + /// + /// Ключ gRPC-metadata с service-token (зеркало MlServiceImpl, Ruling 1). + /// + public const string ServiceTokenMetadataKey = "service-token"; + + private readonly GrpcChannel _channel; + private readonly string _serviceToken; + + /// + /// Создаёт транспорт ml-service по конфигурации и env-токену (валидация fail-closed). + /// + /// Конфигурация секции Services:Ml (endpoint). + /// Сертификаты mTLS (Ruling 6): 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 вызова: tenant-id + service-token (Ruling 1). + /// + /// 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/MtlsCertificates.cs b/src/core/Deal.Infrastructure/Integrations/Models/MtlsCertificates.cs index 8459aeb..c6354c5 100644 --- a/src/core/Deal.Infrastructure/Integrations/Models/MtlsCertificates.cs +++ b/src/core/Deal.Infrastructure/Integrations/Models/MtlsCertificates.cs @@ -1,11 +1,7 @@ using System.Net.Security; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Models; diff --git a/src/core/Deal.Infrastructure/Integrations/Models/ServiceHealthResult.cs b/src/core/Deal.Infrastructure/Integrations/Models/ServiceHealthResult.cs index 32f4508..48eb3e6 100644 --- a/src/core/Deal.Infrastructure/Integrations/Models/ServiceHealthResult.cs +++ b/src/core/Deal.Infrastructure/Integrations/Models/ServiceHealthResult.cs @@ -1,7 +1,3 @@ -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Models; diff --git a/src/core/Deal.Infrastructure/Integrations/Models/TelegramGrpcConnection.cs b/src/core/Deal.Infrastructure/Integrations/Models/TelegramGrpcConnection.cs index 5c9c460..066a8b7 100644 --- a/src/core/Deal.Infrastructure/Integrations/Models/TelegramGrpcConnection.cs +++ b/src/core/Deal.Infrastructure/Integrations/Models/TelegramGrpcConnection.cs @@ -1,98 +1,94 @@ -using Deal.Grpc.Telegram; -using Grpc.Core; -using Grpc.Net.Client; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; - -namespace Deal.Infrastructure.Integrations.Models; - -/// -/// Транспорт gRPC-клиента telegram-service: общий канал + обязательные metadata (Ruling 1, план Task 14). -/// -/// -/// Singleton (канал живёт долго и переиспользуется всеми вызовами): endpoint из -/// , service-token — из env DEAL_SERVICE_TOKEN (Ruling 13: секреты -/// только в env). Dev-транспорт без TLS (Ruling 2); mTLS (Ruling 6, Task 13): при включённом флаге канал -/// подписывает запрос клиентским сертификатом и проверяет CA сервера (сертификаты передаются -/// ). Пустой endpoint либо пустой токен при создании — ошибка конфигурации (fail-closed: -/// без токена сервис отвергнет каждый вызов UNAUTHENTICATED, Ruling 1). Автоповторы Grpc.Net.Client отключены -/// (MaxRetryAttempts=0): стратегию повторов реализует вызывающий (фоновые циклы Api пробуют в следующем тике). -/// -public sealed class TelegramGrpcConnection : IDisposable -{ - /// - /// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor сервисов, Ruling 1). - /// - public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - - /// - /// Ключ gRPC-metadata с tenant-id (зеркало TelegramServiceImpl, Ruling 1). - /// - public const string TenantIdMetadataKey = "tenant-id"; - - /// - /// Ключ gRPC-metadata с service-token (зеркало TelegramServiceImpl, Ruling 1). - /// - public const string ServiceTokenMetadataKey = "service-token"; - - private readonly GrpcChannel _channel; - private readonly string _serviceToken; - - /// - /// Создаёт транспорт telegram-service по конфигурации и env-токену (валидация fail-closed). - /// - /// Конфигурация секции Services:Telegram (endpoint). - /// Сертификаты mTLS (Ruling 6): 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 вызова: tenant-id + service-token (Ruling 1). - /// - /// 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: общий канал + обязательные metadata (Ruling 1, план Task 14). +/// +/// +/// Singleton (канал живёт долго и переиспользуется всеми вызовами): endpoint из +/// , service-token — из env DEAL_SERVICE_TOKEN (Ruling 13: секреты +/// только в env). Dev-транспорт без TLS (Ruling 2); mTLS (Ruling 6, Task 13): при включённом флаге канал +/// подписывает запрос клиентским сертификатом и проверяет CA сервера (сертификаты передаются +/// ). Пустой endpoint либо пустой токен при создании — ошибка конфигурации (fail-closed: +/// без токена сервис отвергнет каждый вызов UNAUTHENTICATED, Ruling 1). Автоповторы Grpc.Net.Client отключены +/// (MaxRetryAttempts=0): стратегию повторов реализует вызывающий (фоновые циклы Api пробуют в следующем тике). +/// +public sealed class TelegramGrpcConnection : IDisposable +{ + /// + /// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor сервисов, Ruling 1). + /// + public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; + + /// + /// Ключ gRPC-metadata с tenant-id (зеркало TelegramServiceImpl, Ruling 1). + /// + public const string TenantIdMetadataKey = "tenant-id"; + + /// + /// Ключ gRPC-metadata с service-token (зеркало TelegramServiceImpl, Ruling 1). + /// + public const string ServiceTokenMetadataKey = "service-token"; + + private readonly GrpcChannel _channel; + private readonly string _serviceToken; + + /// + /// Создаёт транспорт telegram-service по конфигурации и env-токену (валидация fail-closed). + /// + /// Конфигурация секции Services:Telegram (endpoint). + /// Сертификаты mTLS (Ruling 6): 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 вызова: tenant-id + service-token (Ruling 1). + /// + /// 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/AiServiceOptions.cs b/src/core/Deal.Infrastructure/Integrations/Options/AiServiceOptions.cs index 3366305..e26f184 100644 --- a/src/core/Deal.Infrastructure/Integrations/Options/AiServiceOptions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Options/AiServiceOptions.cs @@ -1,9 +1,3 @@ -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Services; - namespace Deal.Infrastructure.Integrations.Options; /// diff --git a/src/core/Deal.Infrastructure/Integrations/Options/MlServiceOptions.cs b/src/core/Deal.Infrastructure/Integrations/Options/MlServiceOptions.cs index afc1382..9b81c3b 100644 --- a/src/core/Deal.Infrastructure/Integrations/Options/MlServiceOptions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Options/MlServiceOptions.cs @@ -1,9 +1,3 @@ -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Services; - namespace Deal.Infrastructure.Integrations.Options; /// diff --git a/src/core/Deal.Infrastructure/Integrations/Options/MtlsOptions.cs b/src/core/Deal.Infrastructure/Integrations/Options/MtlsOptions.cs index f10710e..41498b9 100644 --- a/src/core/Deal.Infrastructure/Integrations/Options/MtlsOptions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Options/MtlsOptions.cs @@ -1,115 +1,110 @@ -using Microsoft.Extensions.Configuration; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Services; - -namespace Deal.Infrastructure.Integrations.Options; - -/// -/// Конфигурация mTLS-транспорта внутреннего gRPC (Ruling 6, план Task 13). -/// -/// -/// Только env (Ruling 13: секреты/пути сертификатов не читаются из appsettings): флаг -/// DEAL_MTLS_ENABLED и пути/пароли DEAL_MTLS_* из Ruling 6. Dev-дефолт — выключено -/// ( = false): процессы остаются на plaintext + service-token (Ruling 2 этапа 6); -/// PROD включает флаг env из compose-prod (Task 14; файлы монтируются из deploy/certs/, генерация — -/// scripts/mtls-certs.sh). Каждый процесс несёт и серверную, и клиентскую роль (Ruling 6): серверный PFX — -/// для своего Kestrel-gRPC (у сервисов свой, у core — ингресс :5082), клиентский — для исходящих каналов -/// (общий deal-client), CA — для проверки второй стороны. -/// -public sealed class MtlsOptions -{ - /// - /// Env-ключ флага: 1/true включает mTLS (как прочие env-флаги сервиса). - /// - public const string EnabledEnvKey = "DEAL_MTLS_ENABLED"; - - /// - /// Env-ключ пути к PFX серверного сертификата процесса (Kestrel-gRPC). - /// - public const string ServerCertPfxEnvKey = "DEAL_MTLS_SERVER_CERT_PFX"; - - /// - /// Env-ключ пароля серверного PFX. - /// - public const string ServerCertPasswordEnvKey = "DEAL_MTLS_SERVER_CERT_PASSWORD"; - - /// - /// Env-ключ пути к PFX клиентского сертификата (общий deal-client исходящих каналов). - /// - 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-ключи DEAL_MTLS_*, только env — Ruling 13). - /// - /// Конфигурация хоста (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: «1»/«true» (без учёта регистра) — включено. - /// - /// Сырое значение 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 (Ruling 6, план Task 13). +/// +/// +/// Только env (Ruling 13: секреты/пути сертификатов не читаются из appsettings): флаг +/// DEAL_MTLS_ENABLED и пути/пароли DEAL_MTLS_* из Ruling 6. Dev-дефолт — выключено +/// ( = false): процессы остаются на plaintext + service-token (Ruling 2 этапа 6); +/// PROD включает флаг env из compose-prod (Task 14; файлы монтируются из deploy/certs/, генерация — +/// scripts/mtls-certs.sh). Каждый процесс несёт и серверную, и клиентскую роль (Ruling 6): серверный PFX — +/// для своего Kestrel-gRPC (у сервисов свой, у core — ингресс :5082), клиентский — для исходящих каналов +/// (общий deal-client), CA — для проверки второй стороны. +/// +public sealed class MtlsOptions +{ + /// + /// Env-ключ флага: 1/true включает mTLS (как прочие env-флаги сервиса). + /// + public const string EnabledEnvKey = "DEAL_MTLS_ENABLED"; + + /// + /// Env-ключ пути к PFX серверного сертификата процесса (Kestrel-gRPC). + /// + public const string ServerCertPfxEnvKey = "DEAL_MTLS_SERVER_CERT_PFX"; + + /// + /// Env-ключ пароля серверного PFX. + /// + public const string ServerCertPasswordEnvKey = "DEAL_MTLS_SERVER_CERT_PASSWORD"; + + /// + /// Env-ключ пути к PFX клиентского сертификата (общий deal-client исходящих каналов). + /// + 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-ключи DEAL_MTLS_*, только env — Ruling 13). + /// + /// Конфигурация хоста (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: «1»/«true» (без учёта регистра) — включено. + /// + /// Сырое значение 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/Options/TelegramServiceOptions.cs b/src/core/Deal.Infrastructure/Integrations/Options/TelegramServiceOptions.cs index 635a2f2..61fc8fe 100644 --- a/src/core/Deal.Infrastructure/Integrations/Options/TelegramServiceOptions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Options/TelegramServiceOptions.cs @@ -1,9 +1,3 @@ -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Services; - namespace Deal.Infrastructure.Integrations.Options; /// diff --git a/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs b/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs index c9bae00..5f8ff4a 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs @@ -1,277 +1,271 @@ -using System.Diagnostics.CodeAnalysis; -using System.Net.Http.Headers; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// HTTP-реализация проверки подключения к AI-провайдеру (Ruling 7; 1:1 settings_routes.py L195–219). -/// -/// -/// Лёгкая проверка БЕЗ LLM-вызовов: для OpenAI-совместимых — GET {base}/models, для Anthropic -/// (api_style "anthropic") — GET {base}/v1/models c заголовком x-api-key. Без ключа и для -/// локальных провайдеров (Ollama/LM Studio) HTTP не выполняется — короткие ветки ответа. -/// Таймаут клиента — 12 с (HttpClient настраивается DI-регистрацией AddHttpClient в Deal.Api, -/// см. ). Ключ в ответ не попадает: только keySet/keyMasked -/// (маска — ai.py mask_key L53–58). -/// SSRF-контур dev-режима (см. отчёт Task 6): провайдер обязан быть из фиксированного каталога -/// (allowlist), base URL — только абсолютный http(s)-адрес; host-level -/// рестрикции нет (локальные серверы на LAN + ветка «недоступный хост» приёмки плана). -/// -public sealed class AiConnectionChecker : IAiConnectionChecker -{ - /// - /// Таймаут HTTP-запроса проверки в секундах (Ruling 7 — 12 с); применяется DI-регистрацией клиента. - /// - public const int RequestTimeoutSeconds = 12; - - // ── Фиксированные сообщения веток (Ruling 7, 1:1 с прототипом) ── - - // Сообщение ветки «локальный провайдер» (вместо HTTP — ping на этапе 6). - 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)"; - - // ── Константы протокола (референс settings_routes.py L206–209) ── - - // Значение 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); - } - - // Локальный провайдер (Ollama/LM Studio): HTTP наружу не ходим (Ruling 7 — ветка до ключа). - 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) - { - // Сработал HttpClient.Timeout (12 с) — ветка сетевого сбоя (прототип ловит все исключения). - return BuildResult(request, name, ok: false, message: TimeoutMessage); - } - catch (HttpRequestException exception) - { - return BuildResult(request, name, ok: false, - message: string.Format(ConnectionErrorMessageTemplate, ConnectionErrorDetail(exception))); - } - } - - // Собирает ответ ветки: {ok, message} + статус провайдера (Ruling 7). - // request: Запрос проверки (поля статуса провайдера). - // name: Имя провайдера из каталога AiProviders. - // ok: Результат подключения. - // message: Сообщение ветки. - // Возвращает: DTO ответа (наружу — camelCase). - 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)); - } - - // Маска ключа: пусто → "", len ≤ 8 → «x…», иначе «1234…5678» (ai.py mask_key L53–58). - // key: Ключ открытым текстом. - // Возвращает: Маскированная строка. - 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)); - } - - // Строит URL проверки: {base}/models или {base}/v1/models (Anthropic), как в ai_check L206–207. - // baseUrl: Эффективный базовый URL из конфигурации провайдера. - // apiStyle: Стиль API провайдера (null — OpenAI-совместимый). - // modelsUri: URL списка моделей (валиден только при возврате true). - // Возвращает: True — URL построен; False — base URL не абсолютный http(s) (SSRF-гейт). - 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; - } - - // rstrip("/") как в прототипе: baseUrl из настроек может заканчиваться слэшем. - 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-провайдеру (Ruling 7; 1:1 settings_routes.py L195–219). +/// +/// +/// Лёгкая проверка БЕЗ LLM-вызовов: для OpenAI-совместимых — GET {base}/models, для Anthropic +/// (api_style "anthropic") — GET {base}/v1/models c заголовком x-api-key. Без ключа и для +/// локальных провайдеров (Ollama/LM Studio) HTTP не выполняется — короткие ветки ответа. +/// Таймаут клиента — 12 с (HttpClient настраивается DI-регистрацией AddHttpClient в Deal.Api, +/// см. ). Ключ в ответ не попадает: только keySet/keyMasked +/// (маска — ai.py mask_key L53–58). +/// SSRF-контур dev-режима (см. отчёт Task 6): провайдер обязан быть из фиксированного каталога +/// (allowlist), base URL — только абсолютный http(s)-адрес; host-level +/// рестрикции нет (локальные серверы на LAN + ветка «недоступный хост» приёмки плана). +/// +public sealed class AiConnectionChecker : IAiConnectionChecker +{ + /// + /// Таймаут HTTP-запроса проверки в секундах (Ruling 7 — 12 с); применяется DI-регистрацией клиента. + /// + public const int RequestTimeoutSeconds = 12; + + // ── Фиксированные сообщения веток (Ruling 7, 1:1 с прототипом) ── + + // Сообщение ветки «локальный провайдер» (вместо HTTP — ping на этапе 6). + 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)"; + + // ── Константы протокола (референс settings_routes.py L206–209) ── + + // Значение 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); + } + + // Локальный провайдер (Ollama/LM Studio): HTTP наружу не ходим (Ruling 7 — ветка до ключа). + 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) + { + // Сработал HttpClient.Timeout (12 с) — ветка сетевого сбоя (прототип ловит все исключения). + return BuildResult(request, name, ok: false, message: TimeoutMessage); + } + catch (HttpRequestException exception) + { + return BuildResult(request, name, ok: false, + message: string.Format(ConnectionErrorMessageTemplate, ConnectionErrorDetail(exception))); + } + } + + // Собирает ответ ветки: {ok, message} + статус провайдера (Ruling 7). + // request: Запрос проверки (поля статуса провайдера). + // name: Имя провайдера из каталога AiProviders. + // ok: Результат подключения. + // message: Сообщение ветки. + // Возвращает: DTO ответа (наружу — camelCase). + 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)); + } + + // Маска ключа: пусто → "", len ≤ 8 → «x…», иначе «1234…5678» (ai.py mask_key L53–58). + // key: Ключ открытым текстом. + // Возвращает: Маскированная строка. + 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)); + } + + // Строит URL проверки: {base}/models или {base}/v1/models (Anthropic), как в ai_check L206–207. + // baseUrl: Эффективный базовый URL из конфигурации провайдера. + // apiStyle: Стиль API провайдера (null — OpenAI-совместимый). + // modelsUri: URL списка моделей (валиден только при возврате true). + // Возвращает: True — URL построен; False — base URL не абсолютный http(s) (SSRF-гейт). + 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; + } + + // rstrip("/") как в прототипе: baseUrl из настроек может заканчиваться слэшем. + 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 b5c0480..4086005 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/AiProviderConfigBuilder.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/AiProviderConfigBuilder.cs @@ -1,173 +1,166 @@ -using System.Text.Json.Nodes; -using Deal.Grpc.Ai; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// Собирает конфиг активного ИИ-провайдера для запросов ai-service (Ruling 5: ядро расшифровывает -/// aiConfigs и передаёт ProviderConfig в теле каждого запроса; сервис настроек тенанта не знает). -/// -/// -/// Эффективный конфиг 1:1 с python ai.py _cfg() L25–33 и формой ProviderConfig (ai.proto L70–85): -/// активный провайдер — настройка aiProvider (дефолт «deepseek»), каталог — -/// (fallback на первый — deepseek, как python L28); из переопределения aiConfigs берутся -/// apiKey/baseUrl/model, отсутствующие поля дополняются дефолтами каталога (base провайдера, первая модель); -/// apiKey расшифровывается (значения enc:+… через ; незашифрованные ранних -/// версий — как есть, python crypto.decrypt_text L52–61); api_style провайдера — из каталога (Anthropic — -/// «anthropic», остальные — пусто = OpenAI-совместимый). Scoped: читает KV-настройки тенанта (ISettingsStore → -/// scoped TenantDbContext запроса), как LocalAiClassifier/GrpcMlClient. -/// -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"; - - // Префикс зашифрованного значения apiKey (crypto.py L49: enc: + Base64(nonce‖ct‖tag)). - private const string EncryptedPrefix = "enc:"; - - private readonly ISettingsStore _store; - private readonly ISecretCipher _secretCipher; - - /// - /// Создаёт сборщик конфига провайдера. - /// - /// KV-хранилище настроек тенанта (aiProvider/aiConfigs). - /// Расшифровка секрета aiConfigs.apiKey (AES-GCM, Ruling 2). - 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; - } - - // Активный провайдер из настройки aiProvider (дефолт «deepseek», python L26). - // ct: Токен отмены. - // Возвращает: Id провайдера (каталога AiProviders). - 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 (Ruling 5: ядро расшифровывает +/// aiConfigs и передаёт ProviderConfig в теле каждого запроса; сервис настроек тенанта не знает). +/// +/// +/// Эффективный конфиг 1:1 с python ai.py _cfg() L25–33 и формой ProviderConfig (ai.proto L70–85): +/// активный провайдер — настройка aiProvider (дефолт «deepseek»), каталог — +/// (fallback на первый — deepseek, как python L28); из переопределения aiConfigs берутся +/// apiKey/baseUrl/model, отсутствующие поля дополняются дефолтами каталога (base провайдера, первая модель); +/// apiKey расшифровывается (значения enc:+… через ; незашифрованные ранних +/// версий — как есть, python crypto.decrypt_text L52–61); api_style провайдера — из каталога (Anthropic — +/// «anthropic», остальные — пусто = OpenAI-совместимый). Scoped: читает KV-настройки тенанта (ISettingsStore → +/// scoped TenantDbContext запроса), как LocalAiClassifier/GrpcMlClient. +/// +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"; + + // Префикс зашифрованного значения apiKey (crypto.py L49: enc: + Base64(nonce‖ct‖tag)). + private const string EncryptedPrefix = "enc:"; + + private readonly ISettingsStore _store; + private readonly ISecretCipher _secretCipher; + + /// + /// Создаёт сборщик конфига провайдера. + /// + /// KV-хранилище настроек тенанта (aiProvider/aiConfigs). + /// Расшифровка секрета aiConfigs.apiKey (AES-GCM, Ruling 2). + 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; + } + + // Активный провайдер из настройки aiProvider (дефолт «deepseek», python L26). + // ct: Токен отмены. + // Возвращает: Id провайдера (каталога AiProviders). + 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 e5dc35e..c918c54 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiClassifier.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiClassifier.cs @@ -1,137 +1,130 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.Extensions.Logging; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// Декоратор бюджетного гейта порта (Ruling 3, Task 9): поверх «платного» -/// исполнителя (gRPC-адаптер ) перед каждым вызовом спрашивает гейт и при запрете -/// ИИ уводит вызов на бесплатную локальную реализацию . -/// -/// -/// Гейт — (public.tenant_limits + статус тенанта, Task 8): -/// вызов разрешён, когда — тенант active и бюджет периода не исчерпан -/// (UsedTokens ≥ BudgetTokens; лимит 0 запрещает ИИ уже с нулевого расхода). Запрещено — исчерпание бюджета -/// либо приостановка тенанта (suspended замораживает ИИ, Ruling 3/10(5)). При запрете фильтр/классификация -/// выполняются Local-реализацией — семантика aiEnabled=false/aiFail этапов 4–5: фильтр {pass:true, skipped:true}, -/// разбор ядра (детерминированный, бесплатный) — приём и обработка сообщений не -/// блокируются, платный ИИ не зовётся и бюджет не расходуется. Списание usage остаётся внутри gRPC-адаптера -/// (, Task 8) и выполняется только по реальным платным ответам. Регистрируется -/// в AddDealIntegrations только при Services:Ai:UseLocal=false (порядок Grpc → Budgeted → наружу); -/// в Local-режиме адаптер и так бесплатен — декоратор не нужен. Ошибки платного исполнителя -/// () пробрасываются как раньше — ветки фолбэка воркера не меняются. -/// SSE-уведомления о пересечении порогов 80/100% бюджета публикует BudgetAlertScheduler (Api-слой): здесь -/// запрет только логируется (Ruling 13: стабильные строки, без секретов). -/// -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); - } - - // Запрет гейта — фильтр через Local-реализацию {pass:true, skipped:true} (семантика «фильтр недоступен», - // Ruling 3): сообщение не блокируется, платный фильтр не зовётся. - _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); - } - - // Запрет гейта — локальный разбор ядра (семантика aiEnabled=false/aiFail, Ruling 3): карточка строится - // без платного ИИ, приём не блокируется. - _logger.LogDebug( - "ИИ-классификация: {Reason} — Local-разбор (тенант {TenantId})", GateDeniedLogText, TenantIdForLog()); - return await _localClassifier.ClassifyAsync(text, ct); - } - - // Бюджетный гейт вызова (Ruling 3): true — платный ИИ разрешён (тенант active и бюджет не исчерпан). - // ct: Токен отмены. - // Возвращает: True — можно звать платного исполнителя. - 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; + +/// +/// Декоратор бюджетного гейта порта (Ruling 3, Task 9): поверх «платного» +/// исполнителя (gRPC-адаптер ) перед каждым вызовом спрашивает гейт и при запрете +/// ИИ уводит вызов на бесплатную локальную реализацию . +/// +/// +/// Гейт — (public.tenant_limits + статус тенанта, Task 8): +/// вызов разрешён, когда — тенант active и бюджет периода не исчерпан +/// (UsedTokens ≥ BudgetTokens; лимит 0 запрещает ИИ уже с нулевого расхода). Запрещено — исчерпание бюджета +/// либо приостановка тенанта (suspended замораживает ИИ, Ruling 3/10(5)). При запрете фильтр/классификация +/// выполняются Local-реализацией — семантика aiEnabled=false/aiFail этапов 4–5: фильтр {pass:true, skipped:true}, +/// разбор ядра (детерминированный, бесплатный) — приём и обработка сообщений не +/// блокируются, платный ИИ не зовётся и бюджет не расходуется. Списание usage остаётся внутри gRPC-адаптера +/// (, Task 8) и выполняется только по реальным платным ответам. Регистрируется +/// в AddDealIntegrations только при Services:Ai:UseLocal=false (порядок Grpc → Budgeted → наружу); +/// в Local-режиме адаптер и так бесплатен — декоратор не нужен. Ошибки платного исполнителя +/// () пробрасываются как раньше — ветки фолбэка воркера не меняются. +/// SSE-уведомления о пересечении порогов 80/100% бюджета публикует BudgetAlertScheduler (Api-слой): здесь +/// запрет только логируется (Ruling 13: стабильные строки, без секретов). +/// +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); + } + + // Запрет гейта — фильтр через Local-реализацию {pass:true, skipped:true} (семантика «фильтр недоступен», + // Ruling 3): сообщение не блокируется, платный фильтр не зовётся. + _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); + } + + // Запрет гейта — локальный разбор ядра (семантика aiEnabled=false/aiFail, Ruling 3): карточка строится + // без платного ИИ, приём не блокируется. + _logger.LogDebug( + "ИИ-классификация: {Reason} — Local-разбор (тенант {TenantId})", GateDeniedLogText, TenantIdForLog()); + return await _localClassifier.ClassifyAsync(text, ct); + } + + // Бюджетный гейт вызова (Ruling 3): true — платный ИИ разрешён (тенант active и бюджет не исчерпан). + // ct: Токен отмены. + // Возвращает: True — можно звать платного исполнителя. + 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 c7c1057..5025563 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiTools.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiTools.cs @@ -1,153 +1,146 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.Extensions.Logging; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// Декоратор бюджетного гейта порта (Ruling 3, Task 9): поверх «платного» -/// исполнителя (gRPC-адаптер ) перед каждым вызовом спрашивает гейт и при запрете ИИ -/// не зовёт платный инструмент: бросает -/// (вызывающий — воркер Discovery — сам уходит в эвристику, код не меняется, Ruling 10), -/// отдаёт мягкую ошибку {ok:false, keywords:[], error} (Ruling 11: эндпоинт -/// отвечает HTTP 200 {keywords: [], error}). -/// -/// -/// Гейт — (public.tenant_limits + статус тенанта, Task 8): вызов -/// разрешён, когда — тенант active и бюджет периода не исчерпан; запрещено — -/// исчерпание либо приостановка тенанта (suspended замораживает ИИ, Ruling 3/10(5)). Тексты запрета различают -/// приостановку и исчерпание по (стабильные строки без секретов, Ruling 13). -/// Ошибки платного исполнителя () пробрасываются как раньше — ветки фолбэка -/// Discovery не меняются. Списание usage остаётся внутри gRPC-адаптера (, Task 8) -/// и выполняется только по реальным платным ответам. Регистрируется в AddDealIntegrations только при -/// Services:Ai:UseLocal=false (порядок Grpc → Budgeted → наружу). SSE-уведомления о пересечении порогов -/// 80/100% бюджета публикует BudgetAlertScheduler (Api-слой): здесь запрет только логируется. -/// -public sealed class BudgetedAiTools : IAiTools -{ - // Текст мягкой ошибки generate-keywords при исчерпанном бюджете (Ruling 3). - private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна"; - - // Текст мягкой ошибки generate-keywords при приостановке тенанта (Ruling 3/10(5)). - private const string SuspendedKeywordsError = "Тенант приостановлен — генерация ключевых слов недоступна"; - - // Текст исключения EvaluateFit при исчерпанном бюджете (семантика локальной обработки, Ruling 3). - private const string ExhaustedFitError = "ИИ-бюджет исчерпан — обработка в локальном режиме"; - - // Текст исключения EvaluateFit при приостановке тенанта (Ruling 3/10(5)). - 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); - } - - // Мягкая ошибка для UI (Ruling 3/11): {ok:false, keywords:[], error} — эндпоинт отвечает HTTP 200. - _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); - } - - // Сбой ИИ-оценки не роняет оценку кандидата: воркер Discovery падает в эвристику (Ruling 3/10, - // python L186–194 — код вызывающего не меняется). - _logger.LogDebug( - "evaluate-fit: {Reason} — эвристика (тенант {TenantId})", - DenyLogText(state), - TenantIdForLog()); - throw new AiUnavailableException( - state.Status == TenantStatuses.Suspended ? SuspendedFitError : ExhaustedFitError); - } - - // Текущее состояние бюджета тенанта (ленивый reset периода + Allowed/Status для гейта, Task 9). - // ct: Токен отмены. - // Возвращает: Состояние бюджета тенанта на сейчас. - 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; + +/// +/// Декоратор бюджетного гейта порта (Ruling 3, Task 9): поверх «платного» +/// исполнителя (gRPC-адаптер ) перед каждым вызовом спрашивает гейт и при запрете ИИ +/// не зовёт платный инструмент: бросает +/// (вызывающий — воркер Discovery — сам уходит в эвристику, код не меняется, Ruling 10), +/// отдаёт мягкую ошибку {ok:false, keywords:[], error} (Ruling 11: эндпоинт +/// отвечает HTTP 200 {keywords: [], error}). +/// +/// +/// Гейт — (public.tenant_limits + статус тенанта, Task 8): вызов +/// разрешён, когда — тенант active и бюджет периода не исчерпан; запрещено — +/// исчерпание либо приостановка тенанта (suspended замораживает ИИ, Ruling 3/10(5)). Тексты запрета различают +/// приостановку и исчерпание по (стабильные строки без секретов, Ruling 13). +/// Ошибки платного исполнителя () пробрасываются как раньше — ветки фолбэка +/// Discovery не меняются. Списание usage остаётся внутри gRPC-адаптера (, Task 8) +/// и выполняется только по реальным платным ответам. Регистрируется в AddDealIntegrations только при +/// Services:Ai:UseLocal=false (порядок Grpc → Budgeted → наружу). SSE-уведомления о пересечении порогов +/// 80/100% бюджета публикует BudgetAlertScheduler (Api-слой): здесь запрет только логируется. +/// +public sealed class BudgetedAiTools : IAiTools +{ + // Текст мягкой ошибки generate-keywords при исчерпанном бюджете (Ruling 3). + private const string ExhaustedKeywordsError = "ИИ-бюджет исчерпан — генерация ключевых слов недоступна"; + + // Текст мягкой ошибки generate-keywords при приостановке тенанта (Ruling 3/10(5)). + private const string SuspendedKeywordsError = "Тенант приостановлен — генерация ключевых слов недоступна"; + + // Текст исключения EvaluateFit при исчерпанном бюджете (семантика локальной обработки, Ruling 3). + private const string ExhaustedFitError = "ИИ-бюджет исчерпан — обработка в локальном режиме"; + + // Текст исключения EvaluateFit при приостановке тенанта (Ruling 3/10(5)). + 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); + } + + // Мягкая ошибка для UI (Ruling 3/11): {ok:false, keywords:[], error} — эндпоинт отвечает HTTP 200. + _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); + } + + // Сбой ИИ-оценки не роняет оценку кандидата: воркер Discovery падает в эвристику (Ruling 3/10, + // python L186–194 — код вызывающего не меняется). + _logger.LogDebug( + "evaluate-fit: {Reason} — эвристика (тенант {TenantId})", + DenyLogText(state), + TenantIdForLog()); + throw new AiUnavailableException( + state.Status == TenantStatuses.Suspended ? SuspendedFitError : ExhaustedFitError); + } + + // Текущее состояние бюджета тенанта (ленивый reset периода + Allowed/Status для гейта, Task 9). + // ct: Токен отмены. + // Возвращает: Состояние бюджета тенанта на сейчас. + 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 2a0249d..4f05f20 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/CbrRateSource.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/CbrRateSource.cs @@ -1,186 +1,178 @@ -using System.Globalization; -using System.Text.Json; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Microsoft.Extensions.Logging; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// HTTP-источник курсов ЦБ РФ: GET daily_json.js (Ruling 6, Task 8; 1:1 rates.py L43–59). -/// -/// -/// Запрос — GET https://www.cbr-xml-daily.ru/daily_json.js (JSON-зеркало ЦБ). SSRF-контур: URL — -/// фиксированная константа (allowlist), тенант не управляет адресом источника (в отличие от baseUrl -/// AI-провайдеров, Task 6). Таймаут клиента — 15 с (python: httpx timeout=15), задаётся -/// DI-регистрацией AddHttpClient в Deal.Api. Парсинг: Valute[code].Value / Nominal (1 единица -/// валюты в рублях; Nominal может быть > 1, напр. 100 KZT), к курсам добавляется RUB:1. -/// Любой сбой (HTTP-код ≠ 2xx, нераспознанное тело/запись, сетевая ошибка) → null + warning — кэш -/// RatesService при этом не трогает (Ruling 6). Отмена вызывающего пробрасывается (не «сбой»). -/// -public sealed class CbrRateSource : IRatesSource -{ - /// - /// Таймаут HTTP-запроса в секундах (python rates.py L46: timeout=15). - /// - public const int RequestTimeoutSeconds = 15; - - // URL JSON-зеркала курсов ЦБ (constants.py L52). Фиксированный — SSRF-allowlist. - 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"; - - // Курс рубля к рублю (всегда 1.0, rates.py L50). - 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) - { - // Любой сбой HTTP/парсинга = неуспех источника (python ловит все исключения, rates.py L57–59). - _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)) - { - // Нераспознанная запись валюты: как и исключение python внутри цикла, роняет весь fetch. - return null; - } - - rates[currency.Name] = rate; - } - - return rates; - } - - // Разбирает одну запись валюты: курс = Value / Nominal, округлён до 6 знаков (rates.py L51–55). - // currency: Пара «код валюты → объект {Value, Nominal}». - // rate: Курс единицы валюты к рублю (валиден при возврате true). - // Возвращает: True — запись распознана; False — повреждённая запись (весь fetch — сбой). - private static bool TryParseCurrency(JsonProperty currency, out double rate) - { - rate = 0; - if (currency.Value.ValueKind != JsonValueKind.Object) - { - return false; - } - - JsonElement item = currency.Value; - - // Значение по умолчанию, как в python: отсутствующий Value → 0, Nominal → 1 (иначе — сбой). - 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-источник курсов ЦБ РФ: GET daily_json.js (Ruling 6, Task 8; 1:1 rates.py L43–59). +/// +/// +/// Запрос — GET https://www.cbr-xml-daily.ru/daily_json.js (JSON-зеркало ЦБ). SSRF-контур: URL — +/// фиксированная константа (allowlist), тенант не управляет адресом источника (в отличие от baseUrl +/// AI-провайдеров, Task 6). Таймаут клиента — 15 с (python: httpx timeout=15), задаётся +/// DI-регистрацией AddHttpClient в Deal.Api. Парсинг: Valute[code].Value / Nominal (1 единица +/// валюты в рублях; Nominal может быть > 1, напр. 100 KZT), к курсам добавляется RUB:1. +/// Любой сбой (HTTP-код ≠ 2xx, нераспознанное тело/запись, сетевая ошибка) → null + warning — кэш +/// RatesService при этом не трогает (Ruling 6). Отмена вызывающего пробрасывается (не «сбой»). +/// +public sealed class CbrRateSource : IRatesSource +{ + /// + /// Таймаут HTTP-запроса в секундах (python rates.py L46: timeout=15). + /// + public const int RequestTimeoutSeconds = 15; + + // URL JSON-зеркала курсов ЦБ (constants.py L52). Фиксированный — SSRF-allowlist. + 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"; + + // Курс рубля к рублю (всегда 1.0, rates.py L50). + 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) + { + // Любой сбой HTTP/парсинга = неуспех источника (python ловит все исключения, rates.py L57–59). + _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)) + { + // Нераспознанная запись валюты: как и исключение python внутри цикла, роняет весь fetch. + return null; + } + + rates[currency.Name] = rate; + } + + return rates; + } + + // Разбирает одну запись валюты: курс = Value / Nominal, округлён до 6 знаков (rates.py L51–55). + // currency: Пара «код валюты → объект {Value, Nominal}». + // rate: Курс единицы валюты к рублю (валиден при возврате true). + // Возвращает: True — запись распознана; False — повреждённая запись (весь fetch — сбой). + private static bool TryParseCurrency(JsonProperty currency, out double rate) + { + rate = 0; + if (currency.Value.ValueKind != JsonValueKind.Object) + { + return false; + } + + JsonElement item = currency.Value; + + // Значение по умолчанию, как в python: отсутствующий Value → 0, Nominal → 1 (иначе — сбой). + 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 e8ef5da..7106cee 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiClassifier.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiClassifier.cs @@ -1,256 +1,250 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Ai; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Grpc.Core; -using Microsoft.Extensions.Logging; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// gRPC-адаптер порта к автономному ai-service (Ruling 5/6, план Task 15 -/// L412–434). -/// -/// -/// Регистрируется вместо Local-реализации при Services:Ai:UseLocal=false (выбор на старте, Ruling 6). -/// Поведение 1:1 с backend/app/services/ai.py filter_incoming L188–198 / classify L218–258 и ai.proto: -/// -/// — RPC Filter (deadline 120 с, README контрактов): заполненный aiFilterPrompt -/// из настроек () + текст ≤4000; недоступность провайдера → RPC-ошибка → -/// (воркер отвечает «пропустить», python L1102–1106); -/// — RPC Classify: system_prompt = aiPrompt+cardPrompt, user-контекст «Доски + -/// примеры разметки + Сообщение» (собирает билдер по данным тенанта, python L226–251); ok=false/сбой → -/// (воркер собирает локальный разбор, aiFail, python L1108–1114); -/// ok=true → строгий маппинг JSON в (, 1:1 -/// normalize_stack/clean_budget/build_contacts); -/// конфиг провайдера на каждый запрос — (Ruling 5: core читает -/// aiConfigs тенанта, расшифровывает apiKey); usage ответов списывается с бюджета тенанта в tenant_limits и -/// копится в lifetime-KV aiTokenUsage (, Ruling 3 этапа 7). -/// -/// Каждый вызов несёт metadata tenant-id + service-token (, Ruling 1). Scoped: -/// настройки/доски/журнал тенанта читаются через scoped-хранилища (ISettingsStore/ICardStore), как -/// LocalAiClassifier/GrpcMlClient. Ветки выключателей aiEnabled/aiFilterEnabled порт не читает — их -/// отрабатывает воркер (Ruling 5 этапа 4). -/// -public sealed class GrpcAiClassifier : IAiClassifier -{ - /// - /// Deadline RPC ai-service — 120 с (README контрактов: провайдер 90/60 с + ретраи 0.8/2 с). - /// - public const int RpcDeadlineSeconds = 120; - - /// - /// Лимит текста сообщения фильтра (ai.py filter_incoming L193: text[:4000]). - /// - 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); - - // Фильтр применён (воркер звал его только при aiFilterEnabled и не force) — skipped=false - // (python filter_incoming L194–198: {pass, reason, skipped:false}). - return new AiFilterResultDto( - Pass: reply.Pass, - Reason: reply.HasReason ? reply.Reason : null, - Skipped: false); - } - catch (RpcException exception) - { - // Провайдер/сервис недоступен — воркер отвечает «пропустить» (python L1102–1106: r2=pass+skipped). - _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) - { - // Классификатор недоступен — как raw={} в прототипе (L1112–1114): локальный разбор, aiFail. - _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) - { - // Модель не вернула разбираемый JSON после ретраев — контрактная ok=false (README ai.proto): - // ядро трактует как «разбора нет» и падает в локальный путь (python: RuntimeError → raw={}). - _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)."); - - // CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1). - // tenantId: Id тенанта (формат N). - // ct: Токен отмены вызова. - // Возвращает: Опции вызова с заголовками, deadline и отменой. - private CallOptions CallOptions(string tenantId, CancellationToken ct) - => new( - headers: _connection.CreateMetadata(tenantId), - deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)), - cancellationToken: ct); - - // Краткий текст ошибки: detail gRPC-ошибки (дружелюбный текст ai-service) либо фолбэк (Ruling 13: - // секреты/тела ответов не логируются и в текст не попадают). - // exception: Исключение RPC-вызова. - // Возвращает: Текст ошибки. - private static string ErrorText(RpcException exception) - { - string detail = exception.Status.Detail?.Trim() ?? string.Empty; - return detail.Length > 0 ? detail : ServiceUnavailableText; - } - - // Первые max кодовых точек строки (python-срез без разрыва суррогатных пар). - // text: Строка. - // max: Лимит. - // Возвращает: Усечённая строка. - 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 (Ruling 5/6, план Task 15 +/// L412–434). +/// +/// +/// Регистрируется вместо Local-реализации при Services:Ai:UseLocal=false (выбор на старте, Ruling 6). +/// Поведение 1:1 с backend/app/services/ai.py filter_incoming L188–198 / classify L218–258 и ai.proto: +/// +/// — RPC Filter (deadline 120 с, README контрактов): заполненный aiFilterPrompt +/// из настроек () + текст ≤4000; недоступность провайдера → RPC-ошибка → +/// (воркер отвечает «пропустить», python L1102–1106); +/// — RPC Classify: system_prompt = aiPrompt+cardPrompt, user-контекст «Доски + +/// примеры разметки + Сообщение» (собирает билдер по данным тенанта, python L226–251); ok=false/сбой → +/// (воркер собирает локальный разбор, aiFail, python L1108–1114); +/// ok=true → строгий маппинг JSON в (, 1:1 +/// normalize_stack/clean_budget/build_contacts); +/// конфиг провайдера на каждый запрос — (Ruling 5: core читает +/// aiConfigs тенанта, расшифровывает apiKey); usage ответов списывается с бюджета тенанта в tenant_limits и +/// копится в lifetime-KV aiTokenUsage (, Ruling 3 этапа 7). +/// +/// Каждый вызов несёт metadata tenant-id + service-token (, Ruling 1). Scoped: +/// настройки/доски/журнал тенанта читаются через scoped-хранилища (ISettingsStore/ICardStore), как +/// LocalAiClassifier/GrpcMlClient. Ветки выключателей aiEnabled/aiFilterEnabled порт не читает — их +/// отрабатывает воркер (Ruling 5 этапа 4). +/// +public sealed class GrpcAiClassifier : IAiClassifier +{ + /// + /// Deadline RPC ai-service — 120 с (README контрактов: провайдер 90/60 с + ретраи 0.8/2 с). + /// + public const int RpcDeadlineSeconds = 120; + + /// + /// Лимит текста сообщения фильтра (ai.py filter_incoming L193: text[:4000]). + /// + 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); + + // Фильтр применён (воркер звал его только при aiFilterEnabled и не force) — skipped=false + // (python filter_incoming L194–198: {pass, reason, skipped:false}). + return new AiFilterResultDto( + Pass: reply.Pass, + Reason: reply.HasReason ? reply.Reason : null, + Skipped: false); + } + catch (RpcException exception) + { + // Провайдер/сервис недоступен — воркер отвечает «пропустить» (python L1102–1106: r2=pass+skipped). + _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) + { + // Классификатор недоступен — как raw={} в прототипе (L1112–1114): локальный разбор, aiFail. + _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) + { + // Модель не вернула разбираемый JSON после ретраев — контрактная ok=false (README ai.proto): + // ядро трактует как «разбора нет» и падает в локальный путь (python: RuntimeError → raw={}). + _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)."); + + // CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1). + // tenantId: Id тенанта (формат N). + // ct: Токен отмены вызова. + // Возвращает: Опции вызова с заголовками, deadline и отменой. + private CallOptions CallOptions(string tenantId, CancellationToken ct) + => new( + headers: _connection.CreateMetadata(tenantId), + deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)), + cancellationToken: ct); + + // Краткий текст ошибки: detail gRPC-ошибки (дружелюбный текст ai-service) либо фолбэк (Ruling 13: + // секреты/тела ответов не логируются и в текст не попадают). + // exception: Исключение RPC-вызова. + // Возвращает: Текст ошибки. + private static string ErrorText(RpcException exception) + { + string detail = exception.Status.Detail?.Trim() ?? string.Empty; + return detail.Length > 0 ? detail : ServiceUnavailableText; + } + + // Первые max кодовых точек строки (python-срез без разрыва суррогатных пар). + // text: Строка. + // max: Лимит. + // Возвращает: Усечённая строка. + 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 f0aa4b4..ab36341 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiTools.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiTools.cs @@ -1,235 +1,232 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Ai; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Grpc.Core; -using Microsoft.Extensions.Logging; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// gRPC-адаптер порта к автономному ai-service (Ruling 9, план Task 15/18/19). -/// -/// -/// Регистрируется вместо Local-реализации при Services:Ai:UseLocal=false (Ruling 6). Поведение 1:1 с -/// ai.proto GenerateKeywords/EvaluateFit и прототипом: -/// -/// — RPC GenerateKeywords (deadline 120 с): конфиг провайдера из -/// настроек, описание ≤4000 (discovery_routes L29); недоступность — мягкий {ok:false, keywords:[], error} -/// (Ruling 11: generate-keywords-эндпоинт отдаёт HTTP 200 {keywords: [], error}); -/// — RPC EvaluateFit: текст ≤4000 (discovery_eval L41) + описание и ключи -/// задачи; сбой — (воркер Discovery падает в эвристику, Ruling 10); -/// успех — {fit, reason} (потолок причины 200 задаёт сервис, _AI_REASON_LIMIT L43); -/// usage ответов списывается с бюджета тенанта в tenant_limits и копится в lifetime-KV aiTokenUsage -/// (, Ruling 3 этапа 7), как у классификатора. -/// -/// Каждый вызов несёт metadata tenant-id + service-token (, Ruling 1). Scoped: -/// настройки провайдера читаются через scoped-хранилище тенанта (ISettingsStore), как GrpcAiClassifier. -/// Выключатель aiEnabled порт не читает — его отрабатывает вызывающий (воркер/эндпоинт Discovery, Ruling 10/11). -/// -public sealed class GrpcAiTools : IAiTools -{ - /// - /// Deadline RPC ai-service — 120 с (README контрактов: провайдер 90/60 с + ретраи 0.8/2 с). - /// - public const int RpcDeadlineSeconds = 120; - - /// - /// Лимит описания ниши generate-keywords (discovery_routes L29: обрезает до 4000). - /// - public const int MaxDescriptionCodePoints = 4000; - - /// - /// Лимит текста сообщения evaluate-fit (discovery_eval L41: _AI_TEXT_LIMIT=4000). - /// - public const int MaxEvalTextCodePoints = 4000; - - // Текст фолбэк-ошибки, когда RPC-ошибка не несёт detail (сервис недоступен). - private const string ServiceUnavailableText = "ai-service недоступен — повторите попытку через несколько секунд"; - - // Причина по умолчанию при fit=true, если сервис причину не вернул (1:1 _AI_REASON_LIMIT L170). - private const string FitReasonDefault = "подходит"; - - // Причина по умолчанию при fit=false, если сервис причину не вернул (1:1 L170). - 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) - { - // Мягкая ошибка для UI (Ruling 11): {ok:false, keywords:[], error} — эндпоинт отвечает HTTP 200. - _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) - { - // Сбой ИИ-оценки не роняет оценку кандидата — воркер падает в эвристику (Ruling 10, python L191–192). - _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)."); - - // CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1). - // tenantId: Id тенанта (формат N). - // ct: Токен отмены вызова. - // Возвращает: Опции вызова с заголовками, deadline и отменой. - private CallOptions CallOptions(string tenantId, CancellationToken ct) - => new( - headers: _connection.CreateMetadata(tenantId), - deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)), - cancellationToken: ct); - - // Краткий текст ошибки: detail gRPC-ошибки (дружелюбный текст ai-service) либо фолбэк (Ruling 13: - // секреты/тела ответов не логируются и в текст не попадают). - // exception: Исключение RPC-вызова. - // Возвращает: Текст ошибки. - private static string ErrorText(RpcException exception) - { - string detail = exception.Status.Detail?.Trim() ?? string.Empty; - return detail.Length > 0 ? detail : ServiceUnavailableText; - } - - // Первые max кодовых точек строки (python-срез без разрыва суррогатных пар). - // text: Строка. - // max: Лимит. - // Возвращает: Усечённая строка. - 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 (Ruling 9, план Task 15/18/19). +/// +/// +/// Регистрируется вместо Local-реализации при Services:Ai:UseLocal=false (Ruling 6). Поведение 1:1 с +/// ai.proto GenerateKeywords/EvaluateFit и прототипом: +/// +/// — RPC GenerateKeywords (deadline 120 с): конфиг провайдера из +/// настроек, описание ≤4000 (discovery_routes L29); недоступность — мягкий {ok:false, keywords:[], error} +/// (Ruling 11: generate-keywords-эндпоинт отдаёт HTTP 200 {keywords: [], error}); +/// — RPC EvaluateFit: текст ≤4000 (discovery_eval L41) + описание и ключи +/// задачи; сбой — (воркер Discovery падает в эвристику, Ruling 10); +/// успех — {fit, reason} (потолок причины 200 задаёт сервис, _AI_REASON_LIMIT L43); +/// usage ответов списывается с бюджета тенанта в tenant_limits и копится в lifetime-KV aiTokenUsage +/// (, Ruling 3 этапа 7), как у классификатора. +/// +/// Каждый вызов несёт metadata tenant-id + service-token (, Ruling 1). Scoped: +/// настройки провайдера читаются через scoped-хранилище тенанта (ISettingsStore), как GrpcAiClassifier. +/// Выключатель aiEnabled порт не читает — его отрабатывает вызывающий (воркер/эндпоинт Discovery, Ruling 10/11). +/// +public sealed class GrpcAiTools : IAiTools +{ + /// + /// Deadline RPC ai-service — 120 с (README контрактов: провайдер 90/60 с + ретраи 0.8/2 с). + /// + public const int RpcDeadlineSeconds = 120; + + /// + /// Лимит описания ниши generate-keywords (discovery_routes L29: обрезает до 4000). + /// + public const int MaxDescriptionCodePoints = 4000; + + /// + /// Лимит текста сообщения evaluate-fit (discovery_eval L41: _AI_TEXT_LIMIT=4000). + /// + public const int MaxEvalTextCodePoints = 4000; + + // Текст фолбэк-ошибки, когда RPC-ошибка не несёт detail (сервис недоступен). + private const string ServiceUnavailableText = "ai-service недоступен — повторите попытку через несколько секунд"; + + // Причина по умолчанию при fit=true, если сервис причину не вернул (1:1 _AI_REASON_LIMIT L170). + private const string FitReasonDefault = "подходит"; + + // Причина по умолчанию при fit=false, если сервис причину не вернул (1:1 L170). + 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) + { + // Мягкая ошибка для UI (Ruling 11): {ok:false, keywords:[], error} — эндпоинт отвечает HTTP 200. + _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) + { + // Сбой ИИ-оценки не роняет оценку кандидата — воркер падает в эвристику (Ruling 10, python L191–192). + _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)."); + + // CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1). + // tenantId: Id тенанта (формат N). + // ct: Токен отмены вызова. + // Возвращает: Опции вызова с заголовками, deadline и отменой. + private CallOptions CallOptions(string tenantId, CancellationToken ct) + => new( + headers: _connection.CreateMetadata(tenantId), + deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)), + cancellationToken: ct); + + // Краткий текст ошибки: detail gRPC-ошибки (дружелюбный текст ai-service) либо фолбэк (Ruling 13: + // секреты/тела ответов не логируются и в текст не попадают). + // exception: Исключение RPC-вызова. + // Возвращает: Текст ошибки. + private static string ErrorText(RpcException exception) + { + string detail = exception.Status.Detail?.Trim() ?? string.Empty; + return detail.Length > 0 ? detail : ServiceUnavailableText; + } + + // Первые max кодовых точек строки (python-срез без разрыва суррогатных пар). + // text: Строка. + // max: Лимит. + // Возвращает: Усечённая строка. + 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 a30e78f..59920aa 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/GrpcMlClient.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/GrpcMlClient.cs @@ -1,430 +1,418 @@ -using System.Text.Json; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Ml; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Grpc.Core; -using Microsoft.Extensions.Logging; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// gRPC-адаптер порта IMlClient к автономному ml-service (Ruling 4/6, план Task 16 L438–445). -/// -/// -/// Регистрируется вместо Local-заглушки при Services:Ml:UseLocal=false (выбор на старте, Ruling 6). -/// Поведение 1:1 с backend/app/services/ml_client.py и ml.proto: -/// -/// — статус модели из ml-service (RPC Status, deadline 10 с) с кэшем 15 с -/// (, python L30–31/127–135) + локальная статистика тенанта из KV/таблиц -/// (счётчики ml/ai, learning = count(CardMoves), outbox = count(MlOutbox)); сервис недоступен — старые -/// данные кэша (или «не готова») и reachable=false; -/// — RPC Predict (deadline 5 с); сбой/недоступность → фиксированный «не -/// уверен» (python L101–107: решит ИИ/локальный путь воркера); -/// — RPC Reset (deadline 10 с); при успехе — очистка своей очереди -/// MlOutbox (reset_model L110–124) и инвалидация кэша статуса; сбой — мягкий {ok:false,error}, -/// очередь не трогается; -/// — ВСЕГДА запись в MlOutbox через (Ruling 6: -/// обучение гарантированно и локально; отправку батчами делает MlOutboxFlushScheduler); -/// (IMlTrainClient) — RPC TrainBatch (deadline 30 с) для фонового флашера. -/// -/// Каждый вызов несёт metadata tenant-id + service-token (, Ruling 1). Scoped: -/// локальная статистика читает KV-настройки и таблицы тенанта (ISettingsStore/IMlLearningStore → scoped -/// TenantDbContext), как LocalMlClient. -/// -public sealed class GrpcMlClient : IMlClient, IMlTrainClient -{ - /// - /// Deadline Predict — 5 с (README контрактов: локальная модель). - /// - public const int PredictDeadlineSeconds = 5; - - /// - /// Deadline Status/Reset — 10 с (README контрактов). - /// - public const int StatusDeadlineSeconds = 10; - - /// - /// Deadline TrainBatch — 30 с (README контрактов: батч ≤100, 1 транзакция). - /// - 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; - - // Recorder истории расхода (ML-событие расхода, этап 10, T2): оценка токенов входного текста. - private readonly TokenUsageRecorder _usageRecorder; - - // Логгер сбоев вызовов ml-service. - private readonly ILogger _logger; - - /// - /// Создаёт gRPC-адаптер клиента ML-сервиса. - /// - /// Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами). - /// KV-хранилище настроек тенанта. - /// Хранилище обучения ML (очередь MlOutbox + журнал). - /// Транспорт ml-service (singleton-канал + service-token). - /// Кэш статуса сервиса на тенанта (singleton). - /// Recorder истории расхода (ML-событие predict, этап 10, T2). - /// Логгер сбоев. - 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)); - - // История расхода (этап 10, T2): ML-ответ токенов не несёт — оценка входного текста (≈chars/4), - // бюджет/lifetime AI-счётчик не затрагиваются (локальная модель бесплатна). - await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct); - return MapPredict(reply); - } - catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException) - { - // Сервис недоступен/таймаут/отмена — «не уверен» (python predict L101–107): решит ИИ/локальный путь. - _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) - { - // Мягкая ошибка реального сервиса (python reset_model L117–121): ok:false + текст; outbox не трогаем. - _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); - } - - // 1:1 reset_model L122–123: после успешного сброса сервиса — очистка своей очереди + свежий статус. - 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) - { - // Обучение гарантированно и локально (Ruling 6): сигнал всегда пишется в MlOutbox, отправку батчами - // делает MlOutboxFlushScheduler — и в Local-, и в gRPC-режиме (ml_client.py L6–7). - 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; - } - - // Последние известные данные (при сбое refresh останутся они — python refresh_status L132–135). - _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()); - } - } - - // Маппит ответ Status в контрактный статус модели (поля 1:1 с MlServiceStatusDto). - // reply: Ответ ml-service. - // Возвращает: DTO статуса модели. - 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)); - } - - // Маппит ответ Predict в контрактный результат (поля 1:1 с MlPredictResultDto). - // reply: Ответ ml-service. - // Возвращает: DTO предсказания. - 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); - } - - // CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1). - // tenantId: Id тенанта (формат N). - // deadline: Лимит времени вызова. - // ct: Токен отмены вызова. - // Возвращает: Опции вызова с заголовками, deadline и отменой. - 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-сервис не ответил — повторите попытку через несколько секунд"; - - // Фиксированный ответ неготовой/недоступной модели: «не уверен» (Ruling 5, ml.proto L21–23). - 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)); - - // Читает выключатель mlEnabled: «не false» (ml_routes.py L71) — false только при сохранённом JSON-false. - // ct: Токен отмены. - // Возвращает: True, если ключ отсутствует, повреждён или хранит JSON-true. - 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 (Ruling 4/6, план Task 16 L438–445). +/// +/// +/// Регистрируется вместо Local-заглушки при Services:Ml:UseLocal=false (выбор на старте, Ruling 6). +/// Поведение 1:1 с backend/app/services/ml_client.py и ml.proto: +/// +/// — статус модели из ml-service (RPC Status, deadline 10 с) с кэшем 15 с +/// (, python L30–31/127–135) + локальная статистика тенанта из KV/таблиц +/// (счётчики ml/ai, learning = count(CardMoves), outbox = count(MlOutbox)); сервис недоступен — старые +/// данные кэша (или «не готова») и reachable=false; +/// — RPC Predict (deadline 5 с); сбой/недоступность → фиксированный «не +/// уверен» (python L101–107: решит ИИ/локальный путь воркера); +/// — RPC Reset (deadline 10 с); при успехе — очистка своей очереди +/// MlOutbox (reset_model L110–124) и инвалидация кэша статуса; сбой — мягкий {ok:false,error}, +/// очередь не трогается; +/// — ВСЕГДА запись в MlOutbox через (Ruling 6: +/// обучение гарантированно и локально; отправку батчами делает MlOutboxFlushScheduler); +/// (IMlTrainClient) — RPC TrainBatch (deadline 30 с) для фонового флашера. +/// +/// Каждый вызов несёт metadata tenant-id + service-token (, Ruling 1). Scoped: +/// локальная статистика читает KV-настройки и таблицы тенанта (ISettingsStore/IMlLearningStore → scoped +/// TenantDbContext), как LocalMlClient. +/// +public sealed class GrpcMlClient : IMlClient, IMlTrainClient +{ + /// + /// Deadline Predict — 5 с (README контрактов: локальная модель). + /// + public const int PredictDeadlineSeconds = 5; + + /// + /// Deadline Status/Reset — 10 с (README контрактов). + /// + public const int StatusDeadlineSeconds = 10; + + /// + /// Deadline TrainBatch — 30 с (README контрактов: батч ≤100, 1 транзакция). + /// + 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; + + // Recorder истории расхода (ML-событие расхода, этап 10, T2): оценка токенов входного текста. + private readonly TokenUsageRecorder _usageRecorder; + + // Логгер сбоев вызовов ml-service. + private readonly ILogger _logger; + + /// + /// Создаёт gRPC-адаптер клиента ML-сервиса. + /// + /// Контекст текущего тенанта (AsyncLocal, заполняется middleware/циклами). + /// KV-хранилище настроек тенанта. + /// Хранилище обучения ML (очередь MlOutbox + журнал). + /// Транспорт ml-service (singleton-канал + service-token). + /// Кэш статуса сервиса на тенанта (singleton). + /// Recorder истории расхода (ML-событие predict, этап 10, T2). + /// Логгер сбоев. + 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)); + + // История расхода (этап 10, T2): ML-ответ токенов не несёт — оценка входного текста (≈chars/4), + // бюджет/lifetime AI-счётчик не затрагиваются (локальная модель бесплатна). + await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct); + return MapPredict(reply); + } + catch (Exception exception) when (exception is RpcException or OperationCanceledException or HttpRequestException) + { + // Сервис недоступен/таймаут/отмена — «не уверен» (python predict L101–107): решит ИИ/локальный путь. + _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) + { + // Мягкая ошибка реального сервиса (python reset_model L117–121): ok:false + текст; outbox не трогаем. + _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); + } + + // 1:1 reset_model L122–123: после успешного сброса сервиса — очистка своей очереди + свежий статус. + 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) + { + // Обучение гарантированно и локально (Ruling 6): сигнал всегда пишется в MlOutbox, отправку батчами + // делает MlOutboxFlushScheduler — и в Local-, и в gRPC-режиме (ml_client.py L6–7). + 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; + } + + // Последние известные данные (при сбое refresh останутся они — python refresh_status L132–135). + _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()); + } + } + + // Маппит ответ Status в контрактный статус модели (поля 1:1 с MlServiceStatusDto). + // reply: Ответ ml-service. + // Возвращает: DTO статуса модели. + 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)); + } + + // Маппит ответ Predict в контрактный результат (поля 1:1 с MlPredictResultDto). + // reply: Ответ ml-service. + // Возвращает: DTO предсказания. + 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); + } + + // CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1). + // tenantId: Id тенанта (формат N). + // deadline: Лимит времени вызова. + // ct: Токен отмены вызова. + // Возвращает: Опции вызова с заголовками, deadline и отменой. + 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-сервис не ответил — повторите попытку через несколько секунд"; + + // Фиксированный ответ неготовой/недоступной модели: «не уверен» (Ruling 5, ml.proto L21–23). + 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)); + + // Читает выключатель mlEnabled: «не false» (ml_routes.py L71) — false только при сохранённом JSON-false. + // ct: Токен отмены. + // Возвращает: True, если ключ отсутствует, повреждён или хранит JSON-true. + 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 10b5bee..f9c13e4 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/GrpcTelegramClient.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/GrpcTelegramClient.cs @@ -1,490 +1,486 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Telegram; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Grpc.Core; -using Microsoft.Extensions.Logging; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// gRPC-адаптер порта к автономному telegram-service (Ruling 6/7, план Task 14). -/// -/// -/// Регистрируется вместо Local-заглушки при Services:Telegram:UseLocal=false (выбор на старте, Ruling 6). -/// Каждый RPC telegram.proto (TelegramService) маппится 1:1 в метод порта: подключение/отключение аккаунта -/// (StartPhone/StartQr/SendCode/SendPassword/Logout), каталог диалогов (RefreshDialogs), мониторинг -/// (SetMonitor/SetMonitorAll), backfill (Backfill), превью (ReadRecent) и discovery-операции (Search/GetInfo/ -/// ReadForEval/Join/Leave). Каждый вызов несёт metadata tenant-id + service-token -/// (, Ruling 1) и deadline по README контрактов (src/contracts L62–74). -/// -/// Ошибки домена telegram-service приходят RPC-статусами с каноническими detail («Telegram не подключён», -/// «Сначала сохраните Telegram api_id и api_hash в настройках», «Неверный код», …) — RpcException -/// пробрасывается наружу без изменений, текст причины решает HTTP-слой эндпоинтов (Ruling 7/8). Транспортные -/// сбои (сервис недоступен/таймаут) нормализуются в RpcException Unavailable с detail «Telegram не подключён» -/// — ветки эндпоинтов отвечают «не подключён», как при недоступном сервисе. -/// -/// -public sealed class GrpcTelegramClient : ITelegramGateway -{ - /// - /// Deadline локальных команд статуса/зеркала — 10 с (README L66). - /// - public const int ShortDeadlineSeconds = 10; - - /// - /// Deadline сетевых команд Telegram — 60 с (README L67: паузы анти-бана внутри сервиса). - /// - public const int CommandDeadlineSeconds = 60; - - /// - /// Deadline тяжёлых команд каталога/backfill — 120 с (README L68: iter_dialogs 500, backfill). - /// - public const int LongDeadlineSeconds = 120; - - // Detail недоступного telegram-service (Ruling 7: «недоступность сервиса → не подключён»). - private const string NotConnectedDetail = "Telegram не подключён"; - - // Контекст текущего тенанта (id — в metadata вызовов, Ruling 1). - 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> 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)."); - - // Выполняет unary RPC с metadata tenant-id/service-token, deadline и токеном отмены (Ruling 1). - // TReply: Тип ответа RPC. - // tenantId: Id тенанта (формат N). - // deadline: Лимит времени вызова (README контрактов L62–74). - // ct: Токен отмены вызова. - // call: Вызов клиента (принимает клиент и CallOptions). - // Возвращает: Ответ RPC. - 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); - } - - // Нормализует транспортные сбои в RpcException «Telegram не подключён»; RpcException домена — как есть. - // Отмена по токену вызывающего пробрасывается без нормализации (не сбой сервиса). Доменные - // RPC-ошибки (INVALID_ARGUMENT/FAILED_PRECONDITION/…) несут канонический detail — их трогать нельзя: - // текст причины 1:1 уходит в {detail} эндпоинтов (Ruling 7/8). - // exception: Исключение вызова. - // tenantId: Id тенанта (лог). - // operation: Имя RPC (лог-аудит). - // Возвращает: Исключение для проброса: транспортный сбой — нормализованный RpcException. - private Exception TranslateTransportFailure( - Exception exception, - TenantId tenantId, - string operation) - { - // Отмена по токену вызывающего — не ошибка сервиса (пробрасываем как обычно). - if (exception is OperationCanceledException) - { - return exception; - } - - // Доменная RPC-ошибка сервиса (INVALID_ARGUMENT/FAILED_PRECONDITION/NOT_FOUND…) несёт канонический - // detail (Ruling 1) — пробрасываем без изменений, текст причины 1:1 уходит в {detail} эндпоинтов. - // Unavailable с detail (сервис сам ответил причиной) — тоже как есть. - 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)); - } - - // Маппит записи каталога proto (DialogEntry) в контрактный DTO каталога/поиска (Ruling 7). - // entries: Записи каталога telegram-service. - // Возвращает: Записи в форме контракта (username → handle). - 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 (Ruling 6/7, план Task 14). +/// +/// +/// Регистрируется вместо Local-заглушки при Services:Telegram:UseLocal=false (выбор на старте, Ruling 6). +/// Каждый RPC telegram.proto (TelegramService) маппится 1:1 в метод порта: подключение/отключение аккаунта +/// (StartPhone/StartQr/SendCode/SendPassword/Logout), каталог диалогов (RefreshDialogs), мониторинг +/// (SetMonitor/SetMonitorAll), backfill (Backfill), превью (ReadRecent) и discovery-операции (Search/GetInfo/ +/// ReadForEval/Join/Leave). Каждый вызов несёт metadata tenant-id + service-token +/// (, Ruling 1) и deadline по README контрактов (src/contracts L62–74). +/// +/// Ошибки домена telegram-service приходят RPC-статусами с каноническими detail («Telegram не подключён», +/// «Сначала сохраните Telegram api_id и api_hash в настройках», «Неверный код», …) — RpcException +/// пробрасывается наружу без изменений, текст причины решает HTTP-слой эндпоинтов (Ruling 7/8). Транспортные +/// сбои (сервис недоступен/таймаут) нормализуются в RpcException Unavailable с detail «Telegram не подключён» +/// — ветки эндпоинтов отвечают «не подключён», как при недоступном сервисе. +/// +/// +public sealed class GrpcTelegramClient : ITelegramGateway +{ + /// + /// Deadline локальных команд статуса/зеркала — 10 с (README L66). + /// + public const int ShortDeadlineSeconds = 10; + + /// + /// Deadline сетевых команд Telegram — 60 с (README L67: паузы анти-бана внутри сервиса). + /// + public const int CommandDeadlineSeconds = 60; + + /// + /// Deadline тяжёлых команд каталога/backfill — 120 с (README L68: iter_dialogs 500, backfill). + /// + public const int LongDeadlineSeconds = 120; + + // Detail недоступного telegram-service (Ruling 7: «недоступность сервиса → не подключён»). + private const string NotConnectedDetail = "Telegram не подключён"; + + // Контекст текущего тенанта (id — в metadata вызовов, Ruling 1). + 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> 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)."); + + // Выполняет unary RPC с metadata tenant-id/service-token, deadline и токеном отмены (Ruling 1). + // TReply: Тип ответа RPC. + // tenantId: Id тенанта (формат N). + // deadline: Лимит времени вызова (README контрактов L62–74). + // ct: Токен отмены вызова. + // call: Вызов клиента (принимает клиент и CallOptions). + // Возвращает: Ответ RPC. + 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); + } + + // Нормализует транспортные сбои в RpcException «Telegram не подключён»; RpcException домена — как есть. + // Отмена по токену вызывающего пробрасывается без нормализации (не сбой сервиса). Доменные + // RPC-ошибки (INVALID_ARGUMENT/FAILED_PRECONDITION/…) несут канонический detail — их трогать нельзя: + // текст причины 1:1 уходит в {detail} эндпоинтов (Ruling 7/8). + // exception: Исключение вызова. + // tenantId: Id тенанта (лог). + // operation: Имя RPC (лог-аудит). + // Возвращает: Исключение для проброса: транспортный сбой — нормализованный RpcException. + private Exception TranslateTransportFailure( + Exception exception, + TenantId tenantId, + string operation) + { + // Отмена по токену вызывающего — не ошибка сервиса (пробрасываем как обычно). + if (exception is OperationCanceledException) + { + return exception; + } + + // Доменная RPC-ошибка сервиса (INVALID_ARGUMENT/FAILED_PRECONDITION/NOT_FOUND…) несёт канонический + // detail (Ruling 1) — пробрасываем без изменений, текст причины 1:1 уходит в {detail} эндпоинтов. + // Unavailable с detail (сервис сам ответил причиной) — тоже как есть. + 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)); + } + + // Маппит записи каталога proto (DialogEntry) в контрактный DTO каталога/поиска (Ruling 7). + // entries: Записи каталога telegram-service. + // Возвращает: Записи в форме контракта (username → handle). + 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 f8b4bd7..7c82496 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/LocalAiClassifier.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/LocalAiClassifier.cs @@ -1,51 +1,44 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// Локальная реализация без внешнего ИИ-сервиса (Ruling 5, план Task 6 L370–388). -/// -/// -/// Адаптер поверх чистого ядра разбора модуля Pipeline (эталон — : ядро -/// владельца + тонкий адаптер): разбирает сообщение -/// (pipeline.py _local_fields L718–798 — заголовок, суть, стек/грейд/бюджет/контакты по меткам и fallback, -/// is_vacancy по hire-маркерам) и маппит в контрактный через -/// (Ruling 7 — модульный маппинг используют и локальные пути воркера): -/// бюджет нормализуется, контакты квалифицируются, блок «О заявке» заполняет только -/// legacy-суть, тип — маркерная гипотеза: is_vacancy_known=false, board=null («смысловые колонки до ИИ не -/// назначаем», python L954–958; карточку в колонку кладёт воркер после ContainerAccepts). Фильтр всегда -/// {pass:true, skipped:true} — реального ИИ-фильтра нет, а выключатель aiFilterEnabled порт не читает -/// (ветки выключателя отрабатывает воркер, как filter_incoming L190–192 и L1103–1106). На этапе 6 адаптер -/// заменяется gRPC-клиентом ai-service с тем же контрактом. Scoped: LocalFieldsParser читает KV-настройки -/// тенанта (ISettingsStore → scoped TenantDbContext запроса). -/// -/// Локальный структуратор модуля Pipeline (маркеры hireMarkers/levelTerms — из настроек). -public sealed class LocalAiClassifier(LocalFieldsParser fieldsParser) : IAiClassifier -{ - /// - public Task FilterAsync(string text, CancellationToken ct) - { - // Реального ИИ-фильтра нет (Ruling 5): локальная реализация всегда пропускает. Семантика ответа 1:1 - // с ветками прототипа, где фильтр недоступен/выключен: {pass:true, reason:null, skipped:true} - // (filter_incoming L190–198, сбой L1103–1106). Отсевы spam_ai/filter_ai станут достижимы этапом 6. - 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; + +/// +/// Локальная реализация без внешнего ИИ-сервиса (Ruling 5, план Task 6 L370–388). +/// +/// +/// Адаптер поверх чистого ядра разбора модуля Pipeline (эталон — : ядро +/// владельца + тонкий адаптер): разбирает сообщение +/// (pipeline.py _local_fields L718–798 — заголовок, суть, стек/грейд/бюджет/контакты по меткам и fallback, +/// is_vacancy по hire-маркерам) и маппит в контрактный через +/// (Ruling 7 — модульный маппинг используют и локальные пути воркера): +/// бюджет нормализуется, контакты квалифицируются, блок «О заявке» заполняет только +/// legacy-суть, тип — маркерная гипотеза: is_vacancy_known=false, board=null («смысловые колонки до ИИ не +/// назначаем», python L954–958; карточку в колонку кладёт воркер после ContainerAccepts). Фильтр всегда +/// {pass:true, skipped:true} — реального ИИ-фильтра нет, а выключатель aiFilterEnabled порт не читает +/// (ветки выключателя отрабатывает воркер, как filter_incoming L190–192 и L1103–1106). На этапе 6 адаптер +/// заменяется gRPC-клиентом ai-service с тем же контрактом. Scoped: LocalFieldsParser читает KV-настройки +/// тенанта (ISettingsStore → scoped TenantDbContext запроса). +/// +/// Локальный структуратор модуля Pipeline (маркеры hireMarkers/levelTerms — из настроек). +public sealed class LocalAiClassifier(LocalFieldsParser fieldsParser) : IAiClassifier +{ + /// + public Task FilterAsync(string text, CancellationToken ct) + { + // Реального ИИ-фильтра нет (Ruling 5): локальная реализация всегда пропускает. Семантика ответа 1:1 + // с ветками прототипа, где фильтр недоступен/выключен: {pass:true, reason:null, skipped:true} + // (filter_incoming L190–198, сбой L1103–1106). Отсевы spam_ai/filter_ai станут достижимы этапом 6. + 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/LocalAiTools.cs b/src/core/Deal.Infrastructure/Integrations/Services/LocalAiTools.cs index 2dde240..3866794 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/LocalAiTools.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/LocalAiTools.cs @@ -1,10 +1,5 @@ using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; namespace Deal.Infrastructure.Integrations.Services; diff --git a/src/core/Deal.Infrastructure/Integrations/Services/LocalColumnSuggester.cs b/src/core/Deal.Infrastructure/Integrations/Services/LocalColumnSuggester.cs index 6f4d045..0fb9ec3 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/LocalColumnSuggester.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/LocalColumnSuggester.cs @@ -1,257 +1,246 @@ -using System.Globalization; -using System.Text.Json; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён (см. CardsService) — -// внутри Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство. -using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика этапа 3 (Ruling 3, план Task 14). -/// -/// -/// Реализует порт поверх порта и чистого ядра -/// (модуль Kanban): читает «Неразобранное» (ListInboxWithSourceAsync), -/// считает группы слов-тем и создаёт доски suggested=true (RulesJson {mode:"any", keywords:[…]}, -/// note-обоснование, цвет/позицию даёт ContainersService) и раскладывает карточки (is_new=TRUE, -/// prev_col='inbox', matchHits по правилам доски — Ruling 2). Причины отказов — детерминированные -/// строки прототипа/Ruling 3: «мало карточек в «Неразобранном» (нужно от 6)», «похожие колонки уже -/// есть или нечего сгруппировать»; кулдаун повторов — KV-ключ -/// (прототип COOLDOWN_S L51 + «недавно предлагали — подождите» L95). Журнал CardMoves/ML-сигналы при -/// раскладке НЕ пишутся (suggest.py _assign_ids L220–239 — это не действие пользователя, а предложение). -/// Suggest-keywords читает карточки вне trash/archive (suggest_domain_keywords L172–178). -/// -/// Порт хранилища (карточки «Неразобранного», переносы в колонки-доски). -/// KV-хранилище настроек тенанта (кулдаун lastSuggestAt, как KEY suggest.py L52). -/// Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами. -public sealed class LocalColumnSuggester( -ICardStore store, -ISettingsStore settings, -ContainersService containersService) : IColumnSuggester -{ - // ── Кулдаун повторов (suggest.py COOLDOWN_S L51; KEY lastSuggestAt L52) ── - - // Как часто можно переспрашивать ИИ-предложения: 20 минут (COOLDOWN_S = 20 * 60, L51). - private const long CooldownSeconds = 20 * 60; - - // ── Детерминированные причины (Ruling 3; строки прототипа suggest.py) ── - - // Кулдаун: повторный вызов слишком рано (suggest.py L95 «недавно предлагали — подождите»). - private const string CooldownReason = "недавно предлагали — подождите"; - - // Мало карточек в «Неразобранном»: {0} — порог MIN_INBOX (suggest.py L102). - private const string TooFewCardsReasonFormat = "мало карточек в «Неразобранном» (нужно от {0})"; - - // Групп не вышло: темы похожи на существующие доски или карточкам нечего разделить (L159). - private const string NothingGroupedReason = "похожие колонки уже есть или нечего сгруппировать"; - - // Мало карточек для ключей: нужно хотя бы 3 (suggest_domain_keywords L178). - private const string KeywordsTooFewReason = "мало карточек — сначала накопите заявки (нужно хотя бы 3)"; - - // Повторяющихся слов-маркеров не нашлось (suggest_domain_keywords L187, текст прототипа). - private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз"; - - // Режим правил колонки-предложения: «любое из условий» (suggest.py _rules_for L68 mode: any). - 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); - } - - // Существующие (suggested=false) колонки: похожие темы не предлагаем (suggest.py L105, L138–139). - 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) - { - // Все колонки откатаны: карточки групп разобраны между чтением и раскладкой (suggest.py L153–156). - 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) - { - // Выборка ключей — как suggest_domain_keywords L172–176: карточки вне trash/archive с текстом, - // свежие 40 (ListCardsAsync(null) = «все, кроме taken», ORDER BY received_at DESC). - IReadOnlyList cards = await store.ListCardsAsync(new CardsQuery(null), ct); - List texts = cards - .Where(card => card.Col != CardIds.Trash - && card.Col != CardIds.Archive - && card.SourceMsg.Trim().Length > 0) - .Take(SuggestHeuristics.KeywordsSampleLimit) - .Select(card => card.SourceMsg.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); - } - - // Создаёт доски-предложения по планам и раскладывает карточки (suggest.py L129–156). - // inbox: Снимок «Неразобранного» (карточки планов берутся из него). - // plans: Планы колонок (SuggestHeuristics.PlanColumns, ≤4). - // ct: Токен отмены. - // Возвращает: Сколько досок реально создано (0 — все откатаны из-за разобранных карточек). - // Каждая доска — suggested=true c правилами {mode:"any", keywords:[тема]} и note-обоснованием. - // Перед раскладкой перечитывается «Неразобранное»: карточки, ушедшие из inbox между снимком и - // раскладкой (пользователь/тик), пропускаются — 1:1 со страховкой _assign_ids L231–233. Если в - // колонку не легло ни одной карточки, пустая доска-предложение откатывается (_rollback_suggested - // L242–248). matchHits считаются по правилам созданной доски (Ruling 2); журнал/ML не пишутся. - private async Task StoreSuggestedColumnsAsync( - IReadOnlyList inbox, - IReadOnlyList plans, - CancellationToken ct) - { - // Свежий снимок inbox — страховка «карточку уже разобрали» (suggest.py _assign_ids L231–233). - HashSet inboxIds = (await store.ListInboxWithSourceAsync(ct)) - .Select(card => card.Id) - .ToHashSet(StringComparer.Ordinal); - Dictionary textByCardId = inbox - .ToDictionary(card => card.Id, card => card.SourceMsg, 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) - { - // Ничего не легло — пустое предложение не нужно (suggest.py L152–156). - await containersService.DeleteAsync(container.Id, ct); - continue; - } - - created++; - } - - return created; - } - - // Сработал ли кулдаун: с последнего успешного предложения прошло меньше 20 минут. - // Повреждённое/отсутствующее значение lastSuggestAt — кулдауна нет (как прототип: значение - // пишется только после успеха, L160–161; битый KV — дефолт «никогда»). - // ct: Токен отмены. - // Возвращает: True — повторный вызов слишком рано (ответ {ok:false, reason, cooldown:true}). - 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; // повреждённое значение не должно блокировать предложения - } - } - - // Записывает метку успешного предложения (suggest.py L160: set_setting(KEY, time.time())). - // ct: Токен отмены. - 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; + +/// +/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика этапа 3 (Ruling 3, план Task 14). +/// +/// +/// Реализует порт поверх порта и чистого ядра +/// (модуль Kanban): читает «Неразобранное» (ListInboxWithSourceAsync), +/// считает группы слов-тем и создаёт доски suggested=true (RulesJson {mode:"any", keywords:[…]}, +/// note-обоснование, цвет/позицию даёт ContainersService) и раскладывает карточки (is_new=TRUE, +/// prev_col='inbox', matchHits по правилам доски — Ruling 2). Причины отказов — детерминированные +/// строки прототипа/Ruling 3: «мало карточек в «Неразобранном» (нужно от 6)», «похожие колонки уже +/// есть или нечего сгруппировать»; кулдаун повторов — KV-ключ +/// (прототип COOLDOWN_S L51 + «недавно предлагали — подождите» L95). Журнал CardMoves/ML-сигналы при +/// раскладке НЕ пишутся (suggest.py _assign_ids L220–239 — это не действие пользователя, а предложение). +/// Suggest-keywords читает карточки вне trash/archive (suggest_domain_keywords L172–178). +/// +/// Порт хранилища (карточки «Неразобранного», переносы в колонки-доски). +/// KV-хранилище настроек тенанта (кулдаун lastSuggestAt, как KEY suggest.py L52). +/// Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами. +public sealed class LocalColumnSuggester( +ICardStore store, +ISettingsStore settings, +ContainersService containersService) : IColumnSuggester +{ + // ── Кулдаун повторов (suggest.py COOLDOWN_S L51; KEY lastSuggestAt L52) ── + + // Как часто можно переспрашивать ИИ-предложения: 20 минут (COOLDOWN_S = 20 * 60, L51). + private const long CooldownSeconds = 20 * 60; + + // ── Детерминированные причины (Ruling 3; строки прототипа suggest.py) ── + + // Кулдаун: повторный вызов слишком рано (suggest.py L95 «недавно предлагали — подождите»). + private const string CooldownReason = "недавно предлагали — подождите"; + + // Мало карточек в «Неразобранном»: {0} — порог MIN_INBOX (suggest.py L102). + private const string TooFewCardsReasonFormat = "мало карточек в «Неразобранном» (нужно от {0})"; + + // Групп не вышло: темы похожи на существующие доски или карточкам нечего разделить (L159). + private const string NothingGroupedReason = "похожие колонки уже есть или нечего сгруппировать"; + + // Мало карточек для ключей: нужно хотя бы 3 (suggest_domain_keywords L178). + private const string KeywordsTooFewReason = "мало карточек — сначала накопите заявки (нужно хотя бы 3)"; + + // Повторяющихся слов-маркеров не нашлось (suggest_domain_keywords L187, текст прототипа). + private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз"; + + // Режим правил колонки-предложения: «любое из условий» (suggest.py _rules_for L68 mode: any). + 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); + } + + // Существующие (suggested=false) колонки: похожие темы не предлагаем (suggest.py L105, L138–139). + 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) + { + // Все колонки откатаны: карточки групп разобраны между чтением и раскладкой (suggest.py L153–156). + 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) + { + // Выборка ключей — как suggest_domain_keywords L172–176: карточки вне trash/archive с текстом, + // свежие 40 (ListCardsAsync(null) = «все, кроме taken», ORDER BY received_at DESC). + IReadOnlyList cards = await store.ListCardsAsync(new CardsQuery(null), ct); + List texts = cards + .Where(card => card.Col != CardIds.Trash + && card.Col != CardIds.Archive + && card.SourceMsg.Trim().Length > 0) + .Take(SuggestHeuristics.KeywordsSampleLimit) + .Select(card => card.SourceMsg.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); + } + + // Создаёт доски-предложения по планам и раскладывает карточки (suggest.py L129–156). + // inbox: Снимок «Неразобранного» (карточки планов берутся из него). + // plans: Планы колонок (SuggestHeuristics.PlanColumns, ≤4). + // ct: Токен отмены. + // Возвращает: Сколько досок реально создано (0 — все откатаны из-за разобранных карточек). + // Каждая доска — suggested=true c правилами {mode:"any", keywords:[тема]} и note-обоснованием. + // Перед раскладкой перечитывается «Неразобранное»: карточки, ушедшие из inbox между снимком и + // раскладкой (пользователь/тик), пропускаются — 1:1 со страховкой _assign_ids L231–233. Если в + // колонку не легло ни одной карточки, пустая доска-предложение откатывается (_rollback_suggested + // L242–248). matchHits считаются по правилам созданной доски (Ruling 2); журнал/ML не пишутся. + private async Task StoreSuggestedColumnsAsync( + IReadOnlyList inbox, + IReadOnlyList plans, + CancellationToken ct) + { + // Свежий снимок inbox — страховка «карточку уже разобрали» (suggest.py _assign_ids L231–233). + HashSet inboxIds = (await store.ListInboxWithSourceAsync(ct)) + .Select(card => card.Id) + .ToHashSet(StringComparer.Ordinal); + Dictionary textByCardId = inbox + .ToDictionary(card => card.Id, card => card.SourceMsg, 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) + { + // Ничего не легло — пустое предложение не нужно (suggest.py L152–156). + await containersService.DeleteAsync(container.Id, ct); + continue; + } + + created++; + } + + return created; + } + + // Сработал ли кулдаун: с последнего успешного предложения прошло меньше 20 минут. + // Повреждённое/отсутствующее значение lastSuggestAt — кулдауна нет (как прототип: значение + // пишется только после успеха, L160–161; битый KV — дефолт «никогда»). + // ct: Токен отмены. + // Возвращает: True — повторный вызов слишком рано (ответ {ok:false, reason, cooldown:true}). + 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; // повреждённое значение не должно блокировать предложения + } + } + + // Записывает метку успешного предложения (suggest.py L160: set_setting(KEY, time.time())). + // ct: Токен отмены. + 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 ff04a28..8f16bf1 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/LocalMlClient.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/LocalMlClient.cs @@ -1,173 +1,162 @@ -using System.Text.Json; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// Локальная реализация без внешнего ML-сервиса (Ruling 4, план Task 5 L266–286). -/// -/// -/// Этап 3: обучение копится локально в очередь MlOutbox (отправка в ML-сервис — фоновый воркер -/// этапа 6), счётчики learning/outbox читаются из таблиц схемы тенанта (Ruling 4). Поведение 1:1 -/// с backend/app/services/ml_client.py: PushAsync = push L40–49 (trim text/label, -/// пустые — no-op, text[:6000], id mle_+12 hex); StatusAsync = snapshot L138–150 -/// (learning = count(CardMoves), outbox = count(MlOutbox), ml/ai — KV-счётчики решений, на этапе 3 -/// всегда 0 — не инкрементируются); ResetAsync = reset_model L110–124 (чистится только -/// MlOutbox, журнал и KV не трогаются). Модель «не готова» до этапа 4 (ready=false, classes пусты, -/// learned=0, eval обнулён), предсказание — фиксированный «не уверен» (Ruling 5 L79–80); заглушка -/// «жива»: reachable=true. Зависимости — порты (ISettingsStore, IMlLearningStore), а не EF: -/// LocalMlClient остаётся unit-чистым (план Task 5). На этапе 6 адаптер заменяется gRPC-клиентом -/// с тем же контрактом (Ruling 4 L73–74). -/// -/// KV-хранилище настроек тенанта (таблица settings). -/// Хранилище обучения ML: очередь MlOutbox + счётчик журнала CardMoves. -public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learningStore) : IMlClient -{ - // Пустой словарь классов модели (неготовая модель, Ruling 5). - private static readonly IReadOnlyDictionary EmptyClasses = new Dictionary(); - - // Пустой словарь весов предсказания (неготовая модель, Ruling 5). - private static readonly IReadOnlyDictionary EmptyScores = new Dictionary(); - - /// - public async Task StatusAsync(CancellationToken ct) - { - // Статус самой модели: обучение копится в outbox, реальная модель появится этапом 4 — - // сейчас модель всегда не готова (Ruling 5). - 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); - - // Локальная статистика (ml_client.snapshot L138–150): learning = count(CardMoves), - // outbox = count(MlOutbox) (Ruling 4); ml/ai — KV-счётчики РЕШЕНИЙ пайплайна (этап 4): - // на этапе 3 не инкрементируются и всегда 0. - 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) - { - // Неготовая модель ничего не решает (Ruling 5 L79–80) — текст не влияет на ответ. - 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) - { - // Сброс 1:1 с reset_model (L110–124): чистится только очередь обучения MlOutbox; журнал - // CardMoves и KV-счётчики не трогаются (Ruling 4, план L275–276). Реального сервиса нет — ok. - await learningStore.ClearOutboxAsync(ct); - return new MlResetResultDto(Ok: true, Error: null); - } - - /// - public async Task PushAsync( - string text, - string label, - double delta, - CancellationToken ct) - { - // Обучение гарантированно и локально (ml_client.push L40–49): действие пользователя — строка - // очереди MlOutbox (отправку в ML-сервис делает воркер этапа 6). Общая логика (trim text/label, - // пустые — тихий no-op, text[:6000], id mle_+hex) — в MlOutboxQueue, общем для Local/Grpc-адаптеров. - await MlOutboxQueue.PushAsync(learningStore, text, label, delta, ct); - } - - // Читает выключатель mlEnabled: «не false» (ml_routes.py L71) — false только при сохранённом JSON-false. - // ct: Токен отмены. - // Возвращает: True, если ключ отсутствует, повреждён или хранит JSON-true. - 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-сервиса (Ruling 4, план Task 5 L266–286). +/// +/// +/// Этап 3: обучение копится локально в очередь MlOutbox (отправка в ML-сервис — фоновый воркер +/// этапа 6), счётчики learning/outbox читаются из таблиц схемы тенанта (Ruling 4). Поведение 1:1 +/// с backend/app/services/ml_client.py: PushAsync = push L40–49 (trim text/label, +/// пустые — no-op, text[:6000], id mle_+12 hex); StatusAsync = snapshot L138–150 +/// (learning = count(CardMoves), outbox = count(MlOutbox), ml/ai — KV-счётчики решений, на этапе 3 +/// всегда 0 — не инкрементируются); ResetAsync = reset_model L110–124 (чистится только +/// MlOutbox, журнал и KV не трогаются). Модель «не готова» до этапа 4 (ready=false, classes пусты, +/// learned=0, eval обнулён), предсказание — фиксированный «не уверен» (Ruling 5 L79–80); заглушка +/// «жива»: reachable=true. Зависимости — порты (ISettingsStore, IMlLearningStore), а не EF: +/// LocalMlClient остаётся unit-чистым (план Task 5). На этапе 6 адаптер заменяется gRPC-клиентом +/// с тем же контрактом (Ruling 4 L73–74). +/// +/// KV-хранилище настроек тенанта (таблица settings). +/// Хранилище обучения ML: очередь MlOutbox + счётчик журнала CardMoves. +public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learningStore) : IMlClient +{ + // Пустой словарь классов модели (неготовая модель, Ruling 5). + private static readonly IReadOnlyDictionary EmptyClasses = new Dictionary(); + + // Пустой словарь весов предсказания (неготовая модель, Ruling 5). + private static readonly IReadOnlyDictionary EmptyScores = new Dictionary(); + + /// + public async Task StatusAsync(CancellationToken ct) + { + // Статус самой модели: обучение копится в outbox, реальная модель появится этапом 4 — + // сейчас модель всегда не готова (Ruling 5). + 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); + + // Локальная статистика (ml_client.snapshot L138–150): learning = count(CardMoves), + // outbox = count(MlOutbox) (Ruling 4); ml/ai — KV-счётчики РЕШЕНИЙ пайплайна (этап 4): + // на этапе 3 не инкрементируются и всегда 0. + 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) + { + // Неготовая модель ничего не решает (Ruling 5 L79–80) — текст не влияет на ответ. + 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) + { + // Сброс 1:1 с reset_model (L110–124): чистится только очередь обучения MlOutbox; журнал + // CardMoves и KV-счётчики не трогаются (Ruling 4, план L275–276). Реального сервиса нет — ok. + await learningStore.ClearOutboxAsync(ct); + return new MlResetResultDto(Ok: true, Error: null); + } + + /// + public async Task PushAsync( + string text, + string label, + double delta, + CancellationToken ct) + { + // Обучение гарантированно и локально (ml_client.push L40–49): действие пользователя — строка + // очереди MlOutbox (отправку в ML-сервис делает воркер этапа 6). Общая логика (trim text/label, + // пустые — тихий no-op, text[:6000], id mle_+hex) — в MlOutboxQueue, общем для Local/Grpc-адаптеров. + await MlOutboxQueue.PushAsync(learningStore, text, label, delta, ct); + } + + // Читает выключатель mlEnabled: «не false» (ml_routes.py L71) — false только при сохранённом JSON-false. + // ct: Токен отмены. + // Возвращает: True, если ключ отсутствует, повреждён или хранит JSON-true. + 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/LocalTelegramGateway.cs b/src/core/Deal.Infrastructure/Integrations/Services/LocalTelegramGateway.cs index 0230bce..602e792 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/LocalTelegramGateway.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/LocalTelegramGateway.cs @@ -1,10 +1,5 @@ using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; namespace Deal.Infrastructure.Integrations.Services; diff --git a/src/core/Deal.Infrastructure/Integrations/Services/MlOutboxQueue.cs b/src/core/Deal.Infrastructure/Integrations/Services/MlOutboxQueue.cs index 71cc198..7fcb151 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/MlOutboxQueue.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/MlOutboxQueue.cs @@ -1,80 +1,72 @@ -using System.Security.Cryptography; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; - -namespace Deal.Infrastructure.Integrations.Services; - -// Общая запись обучающего сигнала в очередь MlOutbox (ml_client.push L40–49) для адаптеров IMlClient. -// Поведение 1:1 с прототипом и с LocalMlClient.PushAsync этапа 3: пустые после trim text/label — -// тихий no-op, text обрезается до 6000 символов (без разрыва суррогатной пары), id — mle_ + -// 12 случайных hex (store.uid L48). Обучение идёт ВСЕГДА (выключатель mlEnabled его не трогает) — -// и в Local-, и в gRPC-режиме сигнал сначала пишется в outbox, отправку в ml-service делает фоновый -// MlOutboxFlushScheduler (Ruling 6: PushAsync ВСЕГДА пишет MlOutbox). -internal static class MlOutboxQueue -{ - // Максимальная длина текста обучающего примера (ml_client.push L48: text[:6000]). - internal const int MaxLearningTextLength = 6000; - - // Случайный хвост id outbox: 6 байт → 12 hex-символов (прототип store.uid — uuid4().hex[:12]). - private const int OutboxIdRandomBytes = 6; - - /// - /// Пишет строку очереди обучения: trim text/label (пустые — no-op), text[:6000], id mle_+hex. - /// - /// Хранилище обучения (таблица 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); - } - - // Генерирует id строки outbox: префикс mle_ + 12 случайных hex-символов (прототип store.uid). - // Возвращает: Короткий id записи очереди. - private static string NewOutboxId() - => KanbanIdPrefixes.MlOutbox + Convert.ToHexString(RandomNumberGenerator.GetBytes(OutboxIdRandomBytes)).ToLowerInvariant(); - - // Обрезает текст до MaxLearningTextLength символов, не разбивая суррогатную пару на конце. - // text: Текст (уже trim-нут). - // Возвращает: Первые 6000 символов (или весь текст, если короче). - // .NET-срез идёт по UTF-16-единицам и может разбить суррогатную пару; Python-срез прототипа - // (text[:6000]) режет по code points — хвостовой high-surrogate убираем, чтобы в БД не ушла «битая» пара. - 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; + +// Общая запись обучающего сигнала в очередь MlOutbox (ml_client.push L40–49) для адаптеров IMlClient. +// Поведение 1:1 с прототипом и с LocalMlClient.PushAsync этапа 3: пустые после trim text/label — +// тихий no-op, text обрезается до 6000 символов (без разрыва суррогатной пары), id — mle_ + +// 12 случайных hex (store.uid L48). Обучение идёт ВСЕГДА (выключатель mlEnabled его не трогает) — +// и в Local-, и в gRPC-режиме сигнал сначала пишется в outbox, отправку в ml-service делает фоновый +// MlOutboxFlushScheduler (Ruling 6: PushAsync ВСЕГДА пишет MlOutbox). +internal static class MlOutboxQueue +{ + // Максимальная длина текста обучающего примера (ml_client.push L48: text[:6000]). + internal const int MaxLearningTextLength = 6000; + + // Случайный хвост id outbox: 6 байт → 12 hex-символов (прототип store.uid — uuid4().hex[:12]). + private const int OutboxIdRandomBytes = 6; + + /// + /// Пишет строку очереди обучения: trim text/label (пустые — no-op), text[:6000], id mle_+hex. + /// + /// Хранилище обучения (таблица 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); + } + + // Генерирует id строки outbox: префикс mle_ + 12 случайных hex-символов (прототип store.uid). + // Возвращает: Короткий id записи очереди. + private static string NewOutboxId() + => KanbanIdPrefixes.MlOutbox + Convert.ToHexString(RandomNumberGenerator.GetBytes(OutboxIdRandomBytes)).ToLowerInvariant(); + + // Обрезает текст до MaxLearningTextLength символов, не разбивая суррогатную пару на конце. + // text: Текст (уже trim-нут). + // Возвращает: Первые 6000 символов (или весь текст, если короче). + // .NET-срез идёт по UTF-16-единицам и может разбить суррогатную пару; Python-срез прототипа + // (text[:6000]) режет по code points — хвостовой high-surrogate убираем, чтобы в БД не ушла «битая» пара. + 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/MlStatusCache.cs b/src/core/Deal.Infrastructure/Integrations/Services/MlStatusCache.cs index 84efd8b..c9ab671 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/MlStatusCache.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/MlStatusCache.cs @@ -1,10 +1,5 @@ using System.Collections.Concurrent; using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; namespace Deal.Infrastructure.Integrations.Services; diff --git a/src/core/Deal.Infrastructure/Integrations/Services/ServiceHealthProbe.cs b/src/core/Deal.Infrastructure/Integrations/Services/ServiceHealthProbe.cs index ae3817a..ce8a162 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/ServiceHealthProbe.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/ServiceHealthProbe.cs @@ -1,81 +1,78 @@ -using Grpc.Core; -using Grpc.Health.V1; -using Grpc.Net.Client; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// Health-проба grpc.health.v1 автономных сервисов (ml/ai/telegram) для операторского health -/// (план Task 10: GET /api/operator/health, Ruling 3/6/9). -/// -/// -/// Каждый вызов строит свой короткоживущий канал к endpoint сервиса (dev — без TLS, Ruling 2; -/// mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским сертификатом и -/// проверяет CA сервера — сертификаты передаются в конструктор) и спрашивает -/// Health.Check("") с дедлайном 3 с — health не должен висеть дольше таймаута. Классификация: ответ -/// SERVING → Reachable+Serving; ответ с иным статусом → Reachable без -/// Serving; таймаут/нет соединения (Unavailable/DeadlineExceeded, HTTP-транспорт) и сервис без health-контракта -/// (Unimplemented) → . -/// -public sealed class ServiceHealthProbe -{ - /// - /// Дедлайн health-RPC, секунд (Ruling 3/9: операторский health отвечает за ~3 с на сервис). - /// - public const int HealthTimeoutSeconds = 3; - - private readonly MtlsCertificates? _mtlsCertificates; - - /// - /// Создаёт пробу; mTLS-каналы — при переданных сертификатах (иначе plaintext, dev). - /// - /// Сертификаты mTLS (Ruling 6, Task 13): null — plaintext-канал. - public ServiceHealthProbe(MtlsCertificates? mtlsCertificates = null) - { - _mtlsCertificates = mtlsCertificates; - } - - /// - /// Проверяет health-контракт gRPC-сервиса по базовому адресу (grpc.health.v1, сервис ""). - /// - /// Базовый адрес сервиса (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 автономных сервисов (ml/ai/telegram) для операторского health +/// (план Task 10: GET /api/operator/health, Ruling 3/6/9). +/// +/// +/// Каждый вызов строит свой короткоживущий канал к endpoint сервиса (dev — без TLS, Ruling 2; +/// mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским сертификатом и +/// проверяет CA сервера — сертификаты передаются в конструктор) и спрашивает +/// Health.Check("") с дедлайном 3 с — health не должен висеть дольше таймаута. Классификация: ответ +/// SERVING → Reachable+Serving; ответ с иным статусом → Reachable без +/// Serving; таймаут/нет соединения (Unavailable/DeadlineExceeded, HTTP-транспорт) и сервис без health-контракта +/// (Unimplemented) → . +/// +public sealed class ServiceHealthProbe +{ + /// + /// Дедлайн health-RPC, секунд (Ruling 3/9: операторский health отвечает за ~3 с на сервис). + /// + public const int HealthTimeoutSeconds = 3; + + private readonly MtlsCertificates? _mtlsCertificates; + + /// + /// Создаёт пробу; mTLS-каналы — при переданных сертификатах (иначе plaintext, dev). + /// + /// Сертификаты mTLS (Ruling 6, Task 13): null — plaintext-канал. + public ServiceHealthProbe(MtlsCertificates? mtlsCertificates = null) + { + _mtlsCertificates = mtlsCertificates; + } + + /// + /// Проверяет health-контракт gRPC-сервиса по базовому адресу (grpc.health.v1, сервис ""). + /// + /// Базовый адрес сервиса (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 d28113d..ce7d531 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/TokenUsageRecorder.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/TokenUsageRecorder.cs @@ -1,268 +1,259 @@ -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.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Observability; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; - -namespace Deal.Infrastructure.Integrations.Services; - -/// -/// Recorder расхода токенов (Ruling 3 этапа 7; история — этап 10, T2): успешный RPC ai-service -/// (Filter/Classify/GenerateKeywords/EvaluateFit) списывает usage с бюджета тенанта, копит lifetime-сумму -/// в tenant-KV и пишет событие в public.token_usage_events; локальный ML-вызов пишет событие (kind=ml). -/// -/// -/// Точка вызова — та же, что у этапа 6 (GrpcAiClassifier/GrpcAiTools после успешного RPC; ML — GrpcMlClient/ -/// LocalMlClient.Predict). Три учёта: -/// -/// Бюджет периодаITenantLimitStore.AddUsageAsync: инкремент UsedTokens в public.tenant_limits -/// (тот же scoped DealDbContext запроса) с ленивым reset периода; источник истины бюджетного гейта Task 9. -/// Только для платных AI-вызовов (ML бюджет не расходует). -/// Lifetime-счётчик — tenant-KV ключ aiTokenUsage ({prompt, completion, total}, существующий формат -/// этапа 6): «всего» за всё время. Только для AI (ML — локальный, aiTokenUsage не засоряет). -/// История событийTokenUsageEventService.AppendAsync (public.token_usage_events): провайдер, -/// модель, вид (ai|ml), токены; основа time-series аналитики оператора (этап 10, T3). -/// -/// Списание в tenant_limits выполняется только при Total>0 (нулевой usage ответа моделью не заводит строку -/// лимита); lifetime-KV пишется всегда, как раньше. Scoped: пишет в KV-хранилище тенанта запроса (ISettingsStore -/// → scoped TenantDbContext), в public.tenant_limits/токен-историю — через scoped DealDbContext. -/// -public sealed class TokenUsageRecorder -{ - // Имена полей значения aiTokenUsage (1:1 с Usage ai.proto: prompt/completion/total). - private const string PromptField = "prompt"; - - private const string CompletionField = "completion"; - - private const string TotalField = "total"; - - // Оценка токенов по символам, символов на токен (конвенция проекта ai.proto Ruling 5: ≈chars/4). - 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.token_usage_events, этап 10). - 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: (1) инкремент бюджета периода в tenant_limits, (2) lifetime-сумму - /// в KV aiTokenUsage, (3) событие истории (kind=ai). usage null — no-op (успешный RPC без оценки токенов). - /// - /// Оценка токенов ответа (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); - // Прикладная метрика (этап 12, пакет A): счётчик вызовов/токенов ИИ — та же точка, что и событие - // token_usage_events (без tenantId в метках). - DealMetrics.RecordAiUsage(usage.Prompt, usage.Completion); - await RecordEventAsync( - provider, - model, - TokenUsageEventKinds.Ai, - promptTokens: usage.Prompt, - completionTokens: usage.Completion, - totalTokens: usage.Total, - ct); - } - - /// - /// Записывает событие локального ML-вызова (kind=ml) с оценкой токенов по длине входного текста - /// (≈chars/4, конвенция ai.proto): бюджет/lifetime aiTokenUsage ML не затрагивает. - /// - /// Входной текст предсказания (оценка токенов запроса; null — 0). - /// Провайдер/источник события (для локальной ML-модели — "local"). - /// Модель/вид локального ML-вызова (событие истории). - /// Токен отмены. - /// Оценка токенов (для тестов/наблюдаемости). - public async Task AddEstimatedAsync( - string? text, - string provider, - string model, - CancellationToken ct) - { - long promptTokens = EstimateTokens(text); - // Прикладная метрика (этап 12, пакет A): вызов локального ML + оценка токенов (та же точка, - // что и событие token_usage_events, kind=ml). - DealMetrics.RecordMlUsage(promptTokens); - await RecordEventAsync( - provider, - model, - TokenUsageEventKinds.Ml, - promptTokens: promptTokens, - completionTokens: 0, - totalTokens: promptTokens, - ct); - return promptTokens; - } - - /// - /// Оценка токенов по символам (≈chars/4; конвенция проекта, ai.proto Ruling 5). - /// - /// Текст (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; - } - - // Прибавляет usage к накопленному значению aiTokenUsage (lifetime-счётчик, формат этапа 6). - // usage: Оценка токенов ответа. - // ct: Токен отмены. - 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 расхода токенов (Ruling 3 этапа 7; история — этап 10, T2): успешный RPC ai-service +/// (Filter/Classify/GenerateKeywords/EvaluateFit) списывает usage с бюджета тенанта, копит lifetime-сумму +/// в tenant-KV и пишет событие в public.token_usage_events; локальный ML-вызов пишет событие (kind=ml). +/// +/// +/// Точка вызова — та же, что у этапа 6 (GrpcAiClassifier/GrpcAiTools после успешного RPC; ML — GrpcMlClient/ +/// LocalMlClient.Predict). Три учёта: +/// +/// Бюджет периодаITenantLimitStore.AddUsageAsync: инкремент UsedTokens в public.tenant_limits +/// (тот же scoped DealDbContext запроса) с ленивым reset периода; источник истины бюджетного гейта Task 9. +/// Только для платных AI-вызовов (ML бюджет не расходует). +/// Lifetime-счётчик — tenant-KV ключ aiTokenUsage ({prompt, completion, total}, существующий формат +/// этапа 6): «всего» за всё время. Только для AI (ML — локальный, aiTokenUsage не засоряет). +/// История событийTokenUsageEventService.AppendAsync (public.token_usage_events): провайдер, +/// модель, вид (ai|ml), токены; основа time-series аналитики оператора (этап 10, T3). +/// +/// Списание в tenant_limits выполняется только при Total>0 (нулевой usage ответа моделью не заводит строку +/// лимита); lifetime-KV пишется всегда, как раньше. Scoped: пишет в KV-хранилище тенанта запроса (ISettingsStore +/// → scoped TenantDbContext), в public.tenant_limits/токен-историю — через scoped DealDbContext. +/// +public sealed class TokenUsageRecorder +{ + // Имена полей значения aiTokenUsage (1:1 с Usage ai.proto: prompt/completion/total). + private const string PromptField = "prompt"; + + private const string CompletionField = "completion"; + + private const string TotalField = "total"; + + // Оценка токенов по символам, символов на токен (конвенция проекта ai.proto Ruling 5: ≈chars/4). + 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.token_usage_events, этап 10). + 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: (1) инкремент бюджета периода в tenant_limits, (2) lifetime-сумму + /// в KV aiTokenUsage, (3) событие истории (kind=ai). usage null — no-op (успешный RPC без оценки токенов). + /// + /// Оценка токенов ответа (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); + // Прикладная метрика (этап 12, пакет A): счётчик вызовов/токенов ИИ — та же точка, что и событие + // token_usage_events (без tenantId в метках). + DealMetrics.RecordAiUsage(usage.Prompt, usage.Completion); + await RecordEventAsync( + provider, + model, + TokenUsageEventKinds.Ai, + promptTokens: usage.Prompt, + completionTokens: usage.Completion, + totalTokens: usage.Total, + ct); + } + + /// + /// Записывает событие локального ML-вызова (kind=ml) с оценкой токенов по длине входного текста + /// (≈chars/4, конвенция ai.proto): бюджет/lifetime aiTokenUsage ML не затрагивает. + /// + /// Входной текст предсказания (оценка токенов запроса; null — 0). + /// Провайдер/источник события (для локальной ML-модели — "local"). + /// Модель/вид локального ML-вызова (событие истории). + /// Токен отмены. + /// Оценка токенов (для тестов/наблюдаемости). + public async Task AddEstimatedAsync( + string? text, + string provider, + string model, + CancellationToken ct) + { + long promptTokens = EstimateTokens(text); + // Прикладная метрика (этап 12, пакет A): вызов локального ML + оценка токенов (та же точка, + // что и событие token_usage_events, kind=ml). + DealMetrics.RecordMlUsage(promptTokens); + await RecordEventAsync( + provider, + model, + TokenUsageEventKinds.Ml, + promptTokens: promptTokens, + completionTokens: 0, + totalTokens: promptTokens, + ct); + return promptTokens; + } + + /// + /// Оценка токенов по символам (≈chars/4; конвенция проекта, ai.proto Ruling 5). + /// + /// Текст (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; + } + + // Прибавляет usage к накопленному значению aiTokenUsage (lifetime-счётчик, формат этапа 6). + // usage: Оценка токенов ответа. + // ct: Токен отмены. + 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/Storage/Extensions/MinioStorageOptionsExtensions.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/MinioStorageOptionsExtensions.cs index a417ece..c25da36 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/MinioStorageOptionsExtensions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/MinioStorageOptionsExtensions.cs @@ -1,5 +1,4 @@ using Deal.Infrastructure.Integrations.Storage.Options; -using Deal.Infrastructure.Integrations.Storage.Services; namespace Deal.Infrastructure.Integrations.Storage.Extensions; diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/StringExtensions.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/StringExtensions.cs index 93382dd..52b773f 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/StringExtensions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/StringExtensions.cs @@ -1,6 +1,3 @@ -using Deal.Infrastructure.Integrations.Storage.Options; -using Deal.Infrastructure.Integrations.Storage.Services; - namespace Deal.Infrastructure.Integrations.Storage.Extensions; /// diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Options/LocalStorageOptions.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Options/LocalStorageOptions.cs index 610ac54..f860495 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Options/LocalStorageOptions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Options/LocalStorageOptions.cs @@ -1,4 +1,3 @@ -using Deal.Infrastructure.Integrations.Storage.Extensions; using Deal.Infrastructure.Integrations.Storage.Services; namespace Deal.Infrastructure.Integrations.Storage.Options; diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Options/MinioStorageOptions.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Options/MinioStorageOptions.cs index 8854a32..61e212f 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Options/MinioStorageOptions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Options/MinioStorageOptions.cs @@ -1,4 +1,3 @@ -using Deal.Infrastructure.Integrations.Storage.Extensions; using Deal.Infrastructure.Integrations.Storage.Services; namespace Deal.Infrastructure.Integrations.Storage.Options; diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Options/StorageOptions.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Options/StorageOptions.cs index 94ea31e..60545dd 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Options/StorageOptions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Options/StorageOptions.cs @@ -1,4 +1,3 @@ -using Deal.Infrastructure.Integrations.Storage.Extensions; using Deal.Infrastructure.Integrations.Storage.Services; namespace Deal.Infrastructure.Integrations.Storage.Options; diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs index 75845e3..571d1b3 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs @@ -1,170 +1,169 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Deal.Infrastructure.Integrations.Storage.Extensions; -using Deal.Infrastructure.Integrations.Storage.Options; - -namespace Deal.Infrastructure.Integrations.Storage.Services; - -/// -/// DI-регистрация файлового хранилища: выбор Local/MinIO по конфигурации (Ruling 4, Task 6). -/// -/// -/// читает секцию Storage (см. ) и выбирает -/// адаптер по правилу Ruling 4: секция Minio заполнена (Endpoint и AccessKey/SecretKey) → регистрируется -/// ; иначе — (root из Storage:Local:Root либо -/// дефолт data/attachments под ContentRoot) — dev/curl/unit идут БЕЗ MinIO (требование «заглушка- -/// адаптер, если MinIO недоступен»). Значения секции Storage:Minio дублируются env-алиасами -/// DEAL_MINIO_ENDPOINT/DEAL_MINIO_ACCESS_KEY/DEAL_MINIO_SECRET_KEY/DEAL_MINIO_BUCKET/ -/// DEAL_MINIO_SECURE (аналог LEADRADAR_MINIO_* прототипа config.py): секция (appsettings/env -/// Storage__Minio__*) имеет приоритет, алиасы заполняют незаданные поля. Оба адаптера — singleton: -/// хранилище не привязано к схеме тенанта (объекты — в едином бакете/каталоге; мульти-аренда объектного -/// хранилища — этап 7 SaaS), реализации потокобезопасны. Вызывается из Program.cs -/// (после AddDealIntegrations; contentRoot — IWebHostEnvironment.ContentRootPath). -/// -public static class FileStorageRegistrar -{ - /// - /// Имя секции конфигурации файлового хранилища (Storage). - /// - public const string ConfigurationSectionName = "Storage"; - - /// - /// Дефолтный каталог вложений локального режима относительно ContentRoot (fallback прототипа: FILES_DIR = DATA_DIR/attachments). - /// - 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 по конфигурации (Ruling 4). - /// - /// Коллекция сервисов. - /// Конфигурация приложения (секция 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); - - // MinIO-режим: только когда секция/алиасы заполнены (Ruling 4: «заглушка-адаптер, если MinIO - // недоступен» — dev/curl/unit по умолчанию работают на LocalFileStorage без MinIO). - 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; - } - - // Env-алиас DEAL_MINIO_* для ключа секции Storage:Minio (аналог LEADRADAR_MINIO_*); Local-ключи алиасов не имеют. - 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-регистрация файлового хранилища: выбор Local/MinIO по конфигурации (Ruling 4, Task 6). +/// +/// +/// читает секцию Storage (см. ) и выбирает +/// адаптер по правилу Ruling 4: секция Minio заполнена (Endpoint и AccessKey/SecretKey) → регистрируется +/// ; иначе — (root из Storage:Local:Root либо +/// дефолт data/attachments под ContentRoot) — dev/curl/unit идут БЕЗ MinIO (требование «заглушка- +/// адаптер, если MinIO недоступен»). Значения секции Storage:Minio дублируются env-алиасами +/// DEAL_MINIO_ENDPOINT/DEAL_MINIO_ACCESS_KEY/DEAL_MINIO_SECRET_KEY/DEAL_MINIO_BUCKET/ +/// DEAL_MINIO_SECURE (аналог LEADRADAR_MINIO_* прототипа config.py): секция (appsettings/env +/// Storage__Minio__*) имеет приоритет, алиасы заполняют незаданные поля. Оба адаптера — singleton: +/// хранилище не привязано к схеме тенанта (объекты — в едином бакете/каталоге; мульти-аренда объектного +/// хранилища — этап 7 SaaS), реализации потокобезопасны. Вызывается из Program.cs +/// (после AddDealIntegrations; contentRoot — IWebHostEnvironment.ContentRootPath). +/// +public static class FileStorageRegistrar +{ + /// + /// Имя секции конфигурации файлового хранилища (Storage). + /// + public const string ConfigurationSectionName = "Storage"; + + /// + /// Дефолтный каталог вложений локального режима относительно ContentRoot (fallback прототипа: FILES_DIR = DATA_DIR/attachments). + /// + 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 по конфигурации (Ruling 4). + /// + /// Коллекция сервисов. + /// Конфигурация приложения (секция 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); + + // MinIO-режим: только когда секция/алиасы заполнены (Ruling 4: «заглушка-адаптер, если MinIO + // недоступен» — dev/curl/unit по умолчанию работают на LocalFileStorage без MinIO). + 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; + } + + // Env-алиас DEAL_MINIO_* для ключа секции Storage:Minio (аналог LEADRADAR_MINIO_*); Local-ключи алиасов не имеют. + 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/LocalFileStorage.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Services/LocalFileStorage.cs index 2a86a5f..abb1bcf 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Services/LocalFileStorage.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Services/LocalFileStorage.cs @@ -1,7 +1,5 @@ using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Integrations.Storage.Extensions; -using Deal.Infrastructure.Integrations.Storage.Options; namespace Deal.Infrastructure.Integrations.Storage.Services; diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Services/MinioFileStorage.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Services/MinioFileStorage.cs index 45e425b..25a7936 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Services/MinioFileStorage.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Services/MinioFileStorage.cs @@ -1,236 +1,235 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Microsoft.Extensions.Logging; -using Minio; -using Minio.DataModel; -using Minio.DataModel.Args; -using Minio.Exceptions; -using Deal.Infrastructure.Integrations.Storage.Extensions; -using Deal.Infrastructure.Integrations.Storage.Options; - -namespace Deal.Infrastructure.Integrations.Storage.Services; - -/// -/// Хранилище вложений на MinIO (S3-совместимое) — Minio .NET SDK (Ruling 4, Task 6; 1:1 object_store.py L26–107). -/// -/// -/// Регистрируется, только когда MinIO сконфигурирован (секция Storage:Minio / env DEAL_MINIO_* заполнена — -/// см. ); иначе действует LocalFileStorage. Клиент строится в конструкторе -/// (без сети), бакет проверяется/создаётся ЛЕНИВО при первом put (object_store.py L26–51: bucket_exists/ -/// make_bucket один раз; сбой проверки — warning-лог, put продолжит и упадёт — 1:1 с python L47–51). Put — -/// буферизация потока в память: MinIO-пути нужна известная длина (Content-Length), а прототип и так держит -/// байты файла в памяти (put L67–73); Get — GetObjectAsync с callback-потоком (буфер MemoryStream); -/// Stat — StatObjectAsync → FileMeta (размер + contentType, сохранённый при put); -/// отсутствие объекта (ObjectNotFoundException) → null (как GetAsync порта). Delete гасит MinioException -/// warning-логом (remove L96–108: метаданные карточки чистит сервис в любом случае). Единственный бакет, -/// tenant-префикса в ключах нет — мульти-аренда объектного хранилища этапом 7 SaaS. Потокобезопасен -/// (клиент SDK thread-safe, проверка бакета под gate); регистрируется singleton. -/// -public sealed class MinioFileStorage : IFileStorage -{ - // ContentType по умолчанию, когда загрузка не указала MIME (object_store.py L72). - private const string DefaultContentType = "application/octet-stream"; - - private readonly IMinioClient _client; - private readonly string _endpoint; - private readonly string _bucket; - private readonly ILogger _logger; - - // Семафор ленивой проверки/создания бакета (гонка первых put, object_store.py L44–51). - 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 (endpoint/бакет, без секретов). - /// - /// Строка вида 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); - - // Прототип держит байты файла в памяти (put L67–73); MinIO-пути нужна известная длина объекта - // (Content-Length), поэтому поток буферизуется — Local-адаптер буферизации не требует. - 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) - { - // Любая иная ошибка (сеть/MinIO недоступен и т.п.): частично заполненный буфер не течёт (Ruling T6), - // ошибка уходит вызывающему (эндпоинт Task 9 мапит её в 404 «Файл не найден в MinIO»). - buffer.Dispose(); - throw; - } - - buffer.Position = 0; - return buffer; - } - - /// - public async Task StatAsync(string objectKey, CancellationToken ct) - { - try - { - // Стат объекта: размер и contentType (кладётся при put, см. PutAsync) — download-эндпоинт (Task 9) - // отвечает Content-Length/Content-Type из дескриптора (Ruling T6; объекта нет → ObjectNotFoundException - // → null-семантика порта). Иные ошибки (MinIO недоступен) уходят вызывающему — он мапит их в 404. - 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) - { - // 1:1 object_store.py remove L96–108: сбой MinIO (недоступен, бакет не создан) гасим warning-логом — - // метаданные карточки (FilesJson) чистит сервис в любом случае (Task 7). - _logger.LogWarning( - exception, - "Не удалось удалить объект MinIO «{ObjectKey}» из бакета «{Bucket}»: {Message}", - objectKey, - _bucket, - exception.Message); - } - } - - // Ленивая проверка/создание бакета при первом put (object_store.py L26–51). - // ct: Токен отмены. - 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) - { - // Бакет не проверить/создать (MinIO недоступен и т.п.): put продолжит и упадёт с понятной - // ошибкой; 1:1 object_store.py L47–51 (python логирует warning и не бросает на проверке). - _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 (S3-совместимое) — Minio .NET SDK (Ruling 4, Task 6; 1:1 object_store.py L26–107). +/// +/// +/// Регистрируется, только когда MinIO сконфигурирован (секция Storage:Minio / env DEAL_MINIO_* заполнена — +/// см. ); иначе действует LocalFileStorage. Клиент строится в конструкторе +/// (без сети), бакет проверяется/создаётся ЛЕНИВО при первом put (object_store.py L26–51: bucket_exists/ +/// make_bucket один раз; сбой проверки — warning-лог, put продолжит и упадёт — 1:1 с python L47–51). Put — +/// буферизация потока в память: MinIO-пути нужна известная длина (Content-Length), а прототип и так держит +/// байты файла в памяти (put L67–73); Get — GetObjectAsync с callback-потоком (буфер MemoryStream); +/// Stat — StatObjectAsync → FileMeta (размер + contentType, сохранённый при put); +/// отсутствие объекта (ObjectNotFoundException) → null (как GetAsync порта). Delete гасит MinioException +/// warning-логом (remove L96–108: метаданные карточки чистит сервис в любом случае). Единственный бакет, +/// tenant-префикса в ключах нет — мульти-аренда объектного хранилища этапом 7 SaaS. Потокобезопасен +/// (клиент SDK thread-safe, проверка бакета под gate); регистрируется singleton. +/// +public sealed class MinioFileStorage : IFileStorage +{ + // ContentType по умолчанию, когда загрузка не указала MIME (object_store.py L72). + private const string DefaultContentType = "application/octet-stream"; + + private readonly IMinioClient _client; + private readonly string _endpoint; + private readonly string _bucket; + private readonly ILogger _logger; + + // Семафор ленивой проверки/создания бакета (гонка первых put, object_store.py L44–51). + 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 (endpoint/бакет, без секретов). + /// + /// Строка вида 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); + + // Прототип держит байты файла в памяти (put L67–73); MinIO-пути нужна известная длина объекта + // (Content-Length), поэтому поток буферизуется — Local-адаптер буферизации не требует. + 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) + { + // Любая иная ошибка (сеть/MinIO недоступен и т.п.): частично заполненный буфер не течёт (Ruling T6), + // ошибка уходит вызывающему (эндпоинт Task 9 мапит её в 404 «Файл не найден в MinIO»). + buffer.Dispose(); + throw; + } + + buffer.Position = 0; + return buffer; + } + + /// + public async Task StatAsync(string objectKey, CancellationToken ct) + { + try + { + // Стат объекта: размер и contentType (кладётся при put, см. PutAsync) — download-эндпоинт (Task 9) + // отвечает Content-Length/Content-Type из дескриптора (Ruling T6; объекта нет → ObjectNotFoundException + // → null-семантика порта). Иные ошибки (MinIO недоступен) уходят вызывающему — он мапит их в 404. + 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) + { + // 1:1 object_store.py remove L96–108: сбой MinIO (недоступен, бакет не создан) гасим warning-логом — + // метаданные карточки (FilesJson) чистит сервис в любом случае (Task 7). + _logger.LogWarning( + exception, + "Не удалось удалить объект MinIO «{ObjectKey}» из бакета «{Bucket}»: {Message}", + objectKey, + _bucket, + exception.Message); + } + } + + // Ленивая проверка/создание бакета при первом put (object_store.py L26–51). + // ct: Токен отмены. + 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) + { + // Бакет не проверить/создать (MinIO недоступен и т.п.): put продолжит и упадёт с понятной + // ошибкой; 1:1 object_store.py L47–51 (python логирует warning и не бросает на проверке). + _logger.LogWarning( + exception, + "Не удалось проверить/создать бакет MinIO «{Bucket}»: {Message}", + _bucket, + exception.Message); + } + + _bucketChecked = true; + } + finally + { + _bucketCheckGate.Release(); + } + } +} diff --git a/src/core/Deal.Infrastructure/Migrations/20260905192825_InitialSystem.cs b/src/core/Deal.Infrastructure/Migrations/20260905192825_InitialSystem.cs index 3aa85db..0375fcb 100644 --- a/src/core/Deal.Infrastructure/Migrations/20260905192825_InitialSystem.cs +++ b/src/core/Deal.Infrastructure/Migrations/20260905192825_InitialSystem.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/core/Deal.Infrastructure/Migrations/20260907181413_SystemSaaS.cs b/src/core/Deal.Infrastructure/Migrations/20260907181413_SystemSaaS.cs index 1ac596a..dcf7a26 100644 --- a/src/core/Deal.Infrastructure/Migrations/20260907181413_SystemSaaS.cs +++ b/src/core/Deal.Infrastructure/Migrations/20260907181413_SystemSaaS.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable diff --git a/src/core/Deal.Infrastructure/Migrations/20260907192419_SessionsImpersonationMark.cs b/src/core/Deal.Infrastructure/Migrations/20260907192419_SessionsImpersonationMark.cs index bfcbf9f..c178d6a 100644 --- a/src/core/Deal.Infrastructure/Migrations/20260907192419_SessionsImpersonationMark.cs +++ b/src/core/Deal.Infrastructure/Migrations/20260907192419_SessionsImpersonationMark.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/core/Deal.Infrastructure/Migrations/20260910152246_AddTokenUsageEvents.cs b/src/core/Deal.Infrastructure/Migrations/20260910152246_AddTokenUsageEvents.cs index 264ea9b..6e8f53c 100644 --- a/src/core/Deal.Infrastructure/Migrations/20260910152246_AddTokenUsageEvents.cs +++ b/src/core/Deal.Infrastructure/Migrations/20260910152246_AddTokenUsageEvents.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable diff --git a/src/core/Deal.Infrastructure/Migrations/20260910172545_RateLimitCounters.cs b/src/core/Deal.Infrastructure/Migrations/20260910172545_RateLimitCounters.cs index 2158cf1..334caa7 100644 --- a/src/core/Deal.Infrastructure/Migrations/20260910172545_RateLimitCounters.cs +++ b/src/core/Deal.Infrastructure/Migrations/20260910172545_RateLimitCounters.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/core/Deal.Infrastructure/Migrations/20260910194443_GlobalSettings.cs b/src/core/Deal.Infrastructure/Migrations/20260910194443_GlobalSettings.cs index d47e350..e38288c 100644 --- a/src/core/Deal.Infrastructure/Migrations/20260910194443_GlobalSettings.cs +++ b/src/core/Deal.Infrastructure/Migrations/20260910194443_GlobalSettings.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260905193010_InitialTenant.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260905193010_InitialTenant.cs index be8f037..2820a5b 100644 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260905193010_InitialTenant.cs +++ b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260905193010_InitialTenant.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906125058_TenantKanban.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906125058_TenantKanban.cs index 4463150..80b4d98 100644 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906125058_TenantKanban.cs +++ b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906125058_TenantKanban.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906165058_TenantPipeline.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906165058_TenantPipeline.cs index cf82e4b..03e7e0e 100644 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906165058_TenantPipeline.cs +++ b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906165058_TenantPipeline.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using NpgsqlTypes; #nullable disable diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906200342_TenantProjects.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906200342_TenantProjects.cs index 4671c94..2d07a7a 100644 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906200342_TenantProjects.cs +++ b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260906200342_TenantProjects.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907141242_TenantTelegram.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907141242_TenantTelegram.cs index 80e1439..88f4771 100644 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907141242_TenantTelegram.cs +++ b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907141242_TenantTelegram.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907155333_TenantDiscovery.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907155333_TenantDiscovery.cs index d0c6bdc..d1dd887 100644 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907155333_TenantDiscovery.cs +++ b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260907155333_TenantDiscovery.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260909222322_TenantContainers.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260909222322_TenantContainers.cs index 41d6003..3315724 100644 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260909222322_TenantContainers.cs +++ b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260909222322_TenantContainers.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using NpgsqlTypes; #nullable disable diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910132805_TenantUnifiedCard.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910132805_TenantUnifiedCard.cs index c53498f..607ee90 100644 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910132805_TenantUnifiedCard.cs +++ b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910132805_TenantUnifiedCard.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134104_TenantContainerRegistry.cs b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134104_TenantContainerRegistry.cs index b42728c..ffa2e85 100644 --- a/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134104_TenantContainerRegistry.cs +++ b/src/core/Deal.Infrastructure/Migrations/TenantDb/20260910134104_TenantContainerRegistry.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs index 6b6b7d9..5ea7276 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs @@ -1,28 +1,26 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация записи аудита: таблица audit_log в схеме public (append-only). -/// -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-конфигурация записи аудита: таблица audit_log в схеме public (append-only). +/// +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 98e7083..5429ebb 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/CardConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/CardConfiguration.cs @@ -1,49 +1,47 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация карточки канбана: таблица Cards (модель без схемы). -/// -public sealed class CardConfiguration : IEntityTypeConfiguration -{ - // Максимальная длина Col: служебные значения + id доски (короткие строки b_...). - private const int ColMaxLength = 200; - - 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.SourceMsg).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(); - - // Полнотекстовый вектор карточки (russian): Title+Summary+SourceMsg+Contact — вычисляемая STORED- - // колонка (Ruling 6). Поиск /api/search идёт по SearchTsv @@ plainto_tsquery с LIKE-дополнением. - builder.Property(x => x.SearchTsv) - .HasComputedColumnSql( - "to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||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-конфигурация карточки канбана: таблица Cards (модель без схемы). +/// +public sealed class CardConfiguration : IEntityTypeConfiguration +{ + // Максимальная длина Col: служебные значения + id доски (короткие строки b_...). + private const int ColMaxLength = 200; + + 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.SourceMsg).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(); + + // Полнотекстовый вектор карточки (russian): Title+Summary+SourceMsg+Contact — вычисляемая STORED- + // колонка (Ruling 6). Поиск /api/search идёт по SearchTsv @@ plainto_tsquery с LIKE-дополнением. + builder.Property(x => x.SearchTsv) + .HasComputedColumnSql( + "to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceMsg\",'')||' '||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 70d3e75..55df9e7 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/CardMoveConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/CardMoveConfiguration.cs @@ -1,21 +1,19 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация журнала действий над карточками: таблица CardMoves (модель без схемы). -/// -/// Без внешних ключей: журнал живёт дольше карточки (прототип _hard_delete его не чистит). -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-конфигурация журнала действий над карточками: таблица CardMoves (модель без схемы). +/// +/// Без внешних ключей: журнал живёт дольше карточки (прототип _hard_delete его не чистит). +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 0d2cc82..4a4d6dc 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/ContainerConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/ContainerConfiguration.cs @@ -1,44 +1,42 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация единого контейнера: таблица Containers (модель без схемы). -/// -/// -/// Аддитивный слой этапа 9: единый реестр контейнеров (колонки/стадии/зоны) вместо прежних board-строк. -/// переключение хранилища/сервисов — следующими задачами (T3/T4). SearchTsv — полнотекстовый индекс -/// для поиска контейнеров (title/description), как Cards.SearchTsv. -/// -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-конфигурация единого контейнера: таблица Containers (модель без схемы). +/// +/// +/// Аддитивный слой этапа 9: единый реестр контейнеров (колонки/стадии/зоны) вместо прежних board-строк. +/// переключение хранилища/сервисов — следующими задачами (T3/T4). SearchTsv — полнотекстовый индекс +/// для поиска контейнеров (title/description), как Cards.SearchTsv. +/// +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 f07b928..2265263 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/DedupEntryConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/DedupEntryConfiguration.cs @@ -1,24 +1,22 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация дедуп-хэшей пайплайна: таблица DedupEntries (модель без схемы). -/// -/// -/// Без внешних ключей: LeadId — «мягкая» ссылка на Cards; при жёстком удалении карточки строки чистит -/// приложение (Ruling 3), чтобы «сирота» не блокировала повторное создание карточки. -/// -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-конфигурация дедуп-хэшей пайплайна: таблица DedupEntries (модель без схемы). +/// +/// +/// Без внешних ключей: LeadId — «мягкая» ссылка на Cards; при жёстком удалении карточки строки чистит +/// приложение (Ruling 3), чтобы «сирота» не блокировала повторное создание карточки. +/// +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 0406152..ee90660 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/DialogConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/DialogConfiguration.cs @@ -1,24 +1,22 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация каталога диалогов: таблица Dialogs (модель без схемы; Ruling 7). -/// -/// Индексов нет — каталог читается целиком (список вкладки ≤500 диалогов) и по PK. -public sealed class DialogConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("Dialogs"); - - builder.HasKey(x => x.Id); - - // Дефолт цвета каталога (1:1 db.py L81 — hue VARCHAR NOT NULL DEFAULT '#666'). - 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-конфигурация каталога диалогов: таблица Dialogs (модель без схемы; Ruling 7). +/// +/// Индексов нет — каталог читается целиком (список вкладки ≤500 диалогов) и по PK. +public sealed class DialogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Dialogs"); + + builder.HasKey(x => x.Id); + + // Дефолт цвета каталога (1:1 db.py L81 — hue VARCHAR NOT NULL DEFAULT '#666'). + 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 c7c0795..4c2d7bf 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscBlacklistConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscBlacklistConfiguration.cs @@ -1,25 +1,23 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация чёрного списка Discovery: таблица DiscBlacklist (модель без схемы; Ruling 9). -/// -/// -/// 1:1 db.py L180–185. Список читается целиком (вкладка) и по PK (проверки add_candidate/воркера) — индексов -/// не требуется. Повторная вставка того же диалога — ON CONFLICT DO UPDATE name/reason (python L572–575): -/// адаптер реализует upsert кодом (CreatedAt сохраняется). -/// -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: таблица DiscBlacklist (модель без схемы; Ruling 9). +/// +/// +/// 1:1 db.py L180–185. Список читается целиком (вкладка) и по PK (проверки add_candidate/воркера) — индексов +/// не требуется. Повторная вставка того же диалога — ON CONFLICT DO UPDATE name/reason (python L572–575): +/// адаптер реализует upsert кодом (CreatedAt сохраняется). +/// +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 1e5dc09..21ab964 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscCandidateConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscCandidateConfiguration.cs @@ -1,30 +1,28 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация кандидата Discovery: таблица DiscCandidates (модель без схемы; Ruling 9). -/// -/// -/// 1:1 db.py L159–177. Marks/Topics — JSON-массивы в text. Составной индекс (TaskId, Status) — python -/// idx_disc_cand_task L177: выборка кандидатов задачи по статусу (списки воркера/вкладки). Без FK на DiscTasks: -/// удаление задачи чистит кандидатов каскадом в коде сервиса (delete_task L314–318). -/// -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: таблица DiscCandidates (модель без схемы; Ruling 9). +/// +/// +/// 1:1 db.py L159–177. Marks/Topics — JSON-массивы в text. Составной индекс (TaskId, Status) — python +/// idx_disc_cand_task L177: выборка кандидатов задачи по статусу (списки воркера/вкладки). Без FK на DiscTasks: +/// удаление задачи чистит кандидатов каскадом в коде сервиса (delete_task L314–318). +/// +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 2ce5f28..633ff30 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscLogConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscLogConfiguration.cs @@ -1,26 +1,24 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация лога Discovery: таблица DiscLog (модель без схемы; Ruling 9). -/// -/// -/// 1:1 db.py L189–196. Составной индекс (TaskId, CreatedAt) — python idx_disc_log_task L196: последние события -/// задачи (ORDER BY created_at DESC). Без FK на DiscTasks: лог чистится каскадом delete_task в коде сервиса. -/// -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: таблица DiscLog (модель без схемы; Ruling 9). +/// +/// +/// 1:1 db.py L189–196. Составной индекс (TaskId, CreatedAt) — python idx_disc_log_task L196: последние события +/// задачи (ORDER BY created_at DESC). Без FK на DiscTasks: лог чистится каскадом delete_task в коде сервиса. +/// +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 e95e352..caec09b 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscTaskConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscTaskConfiguration.cs @@ -1,26 +1,24 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация задачи поиска Discovery: таблица DiscTasks (модель без схемы; Ruling 9). -/// -/// -/// 1:1 db.py L136–156. Keywords — JSON-массив в text (конвенция JSON-колонок этапов 1–5). Индексов нет — -/// python idx для disc_tasks не создаёт: задачи читаются списком целиком (воркер/список вкладки) и по PK. -/// -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: таблица DiscTasks (модель без схемы; Ruling 9). +/// +/// +/// 1:1 db.py L136–156. Keywords — JSON-массив в text (конвенция JSON-колонок этапов 1–5). Индексов нет — +/// python idx для disc_tasks не создаёт: задачи читаются списком целиком (воркер/список вкладки) и по PK. +/// +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 29237b7..1439a2e 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/GlobalSettingConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/GlobalSettingConfiguration.cs @@ -1,8 +1,6 @@ using Deal.Infrastructure.Persistence.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; namespace Deal.Infrastructure.Persistence.Configurations; diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/InviteConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/InviteConfiguration.cs index 938c03d..ce175a4 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/InviteConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/InviteConfiguration.cs @@ -1,38 +1,36 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация приглашения: таблица invites в схеме public. -/// -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-конфигурация приглашения: таблица invites в схеме public. +/// +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 18e9cfc..8be71ce 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/LeadCommentConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/LeadCommentConfiguration.cs @@ -1,28 +1,26 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация комментария карточки: таблица LeadComments (модель без схемы). -/// -public sealed class LeadCommentConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("LeadComments"); - - builder.HasKey(x => x.Id); - - // Комментарии карточки читаются вместе с ней; удаление карточки каскадно чистит комментарии (Ruling 10). - 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-конфигурация комментария карточки: таблица LeadComments (модель без схемы). +/// +public sealed class LeadCommentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("LeadComments"); + + builder.HasKey(x => x.Id); + + // Комментарии карточки читаются вместе с ней; удаление карточки каскадно чистит комментарии (Ruling 10). + 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 552b072..b1d35a2 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/MlOutboxConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/MlOutboxConfiguration.cs @@ -1,24 +1,22 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация очереди обучающих сигналов ML: таблица MlOutbox (модель без схемы). -/// -/// Без внешних ключей: очередь не зависит от карточек и чистится отдельно (ResetAsync). -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: таблица MlOutbox (модель без схемы). +/// +/// Без внешних ключей: очередь не зависит от карточек и чистится отдельно (ResetAsync). +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 e633fd3..9e01703 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorConfiguration.cs @@ -1,29 +1,27 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация оператора: таблица operators в схеме public. -/// -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-конфигурация оператора: таблица operators в схеме public. +/// +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 ab1422c..1e54805 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorSessionConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorSessionConfiguration.cs @@ -1,36 +1,34 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация сессии оператора: таблица operator_sessions в схеме public. -/// -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-конфигурация сессии оператора: таблица operator_sessions в схеме public. +/// +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 aaed102..910e8e0 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/QueueItemConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/QueueItemConfiguration.cs @@ -1,27 +1,25 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация очереди входящих пайплайна: таблица QueueItems (модель без схемы). -/// -/// Без внешних ключей: очередь не зависит от карточек и чистится воркером/очистками. -public sealed class QueueItemConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("QueueItems"); - - builder.HasKey(x => x.Id); - - // Текст сообщения — text; лимит 6000 символов применяет сервис при приёме (Ruling 2), не БД. - builder.Property(x => x.Text).HasColumnType("text"); - - // Выборка pump'а идёт по статусу и времени постановки (status='new', лимит 12). - builder.HasIndex(x => new { x.Status, x.CreatedAt }); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Deal.Infrastructure.Persistence.Configurations; + +/// +/// EF-конфигурация очереди входящих пайплайна: таблица QueueItems (модель без схемы). +/// +/// Без внешних ключей: очередь не зависит от карточек и чистится воркером/очистками. +public sealed class QueueItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("QueueItems"); + + builder.HasKey(x => x.Id); + + // Текст сообщения — text; лимит 6000 символов применяет сервис при приёме (Ruling 2), не БД. + builder.Property(x => x.Text).HasColumnType("text"); + + // Выборка pump'а идёт по статусу и времени постановки (status='new', лимит 12). + builder.HasIndex(x => new { x.Status, x.CreatedAt }); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/RateLimitCounterConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/RateLimitCounterConfiguration.cs index ebdf75a..887840a 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/RateLimitCounterConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/RateLimitCounterConfiguration.cs @@ -1,8 +1,6 @@ using Deal.Infrastructure.Persistence.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; namespace Deal.Infrastructure.Persistence.Configurations; diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/RejectedItemConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/RejectedItemConfiguration.cs index 36cc876..f13425e 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/RejectedItemConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/RejectedItemConfiguration.cs @@ -1,38 +1,36 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация отсева пайплайна: таблица RejectedItems (модель без схемы). -/// -/// -/// Без внешних ключей — отсев живёт дольше карточки. Тексты Text/Reason/Kw — text (лимиты 6000/500/200 -/// символов применяет сервис). SearchTsv — вычисляемая STORED-колонка tsvector (Ruling 6). -/// -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"); - - // Полнотекстовый вектор отсева: только Text (fts.py _FTS_TARGETS) — поиск GET /pipeline/rejected?q=. - builder.Property(x => x.SearchTsv) - .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", stored: true); - - // Автоочистка старше 3 суток и сортировка списка идут по времени отсева. - builder.HasIndex(x => x.RejectedAt); - - // Полнотекстовый поиск отсева — GIN-индекс по tsvector (Ruling 6). - 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-конфигурация отсева пайплайна: таблица RejectedItems (модель без схемы). +/// +/// +/// Без внешних ключей — отсев живёт дольше карточки. Тексты Text/Reason/Kw — text (лимиты 6000/500/200 +/// символов применяет сервис). SearchTsv — вычисляемая STORED-колонка tsvector (Ruling 6). +/// +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"); + + // Полнотекстовый вектор отсева: только Text (fts.py _FTS_TARGETS) — поиск GET /pipeline/rejected?q=. + builder.Property(x => x.SearchTsv) + .HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", stored: true); + + // Автоочистка старше 3 суток и сортировка списка идут по времени отсева. + builder.HasIndex(x => x.RejectedAt); + + // Полнотекстовый поиск отсева — GIN-индекс по tsvector (Ruling 6). + 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 18bf052..8bbfa21 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/SessionConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/SessionConfiguration.cs @@ -1,36 +1,34 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация сессии: таблица sessions в схеме public. -/// -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-конфигурация сессии: таблица sessions в схеме public. +/// +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 89c2b01..75c176f 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/TenantConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/TenantConfiguration.cs @@ -1,24 +1,22 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация тенанта: таблица tenants в схеме public. -/// -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-конфигурация тенанта: таблица tenants в схеме public. +/// +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 a923d56..974d465 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/TenantLimitConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/TenantLimitConfiguration.cs @@ -1,33 +1,31 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация лимита тенанта: таблица tenant_limits в схеме public. -/// -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-конфигурация лимита тенанта: таблица tenant_limits в схеме public. +/// +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 8a3de73..276f8a5 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/TenantSettingConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/TenantSettingConfiguration.cs @@ -1,26 +1,24 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация настройки тенанта: таблица settings в схеме тенанта (модель без схемы). -/// -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-конфигурация настройки тенанта: таблица settings в схеме тенанта (модель без схемы). +/// +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 25ff241..a436bd9 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/TgMessageConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/TgMessageConfiguration.cs @@ -1,23 +1,21 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация превью-сообщений: таблица TgMessages (модель без схемы; Ruling 7, db.py L67–74). -/// -public sealed class TgMessageConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("TgMessages"); - - builder.HasKey(x => x.Id); - - // Фолбэк превью диалога читается по диалогу в порядке времени (idx_messages_dialog db.py L74). - 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-конфигурация превью-сообщений: таблица TgMessages (модель без схемы; Ruling 7, db.py L67–74). +/// +public sealed class TgMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("TgMessages"); + + builder.HasKey(x => x.Id); + + // Фолбэк превью диалога читается по диалогу в порядке времени (idx_messages_dialog db.py L74). + 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 67fa294..96d1d5e 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/TokenUsageEventConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/TokenUsageEventConfiguration.cs @@ -1,8 +1,6 @@ using Deal.Infrastructure.Persistence.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; namespace Deal.Infrastructure.Persistence.Configurations; diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/UserConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/UserConfiguration.cs index 3ede495..cb0c20c 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/UserConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/UserConfiguration.cs @@ -1,35 +1,33 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Configurations; - -/// -/// EF-конфигурация пользователя: таблица users в схеме public. -/// -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-конфигурация пользователя: таблица users в схеме public. +/// +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 61a1131..575a8b5 100644 --- a/src/core/Deal.Infrastructure/Persistence/DealDbContext.cs +++ b/src/core/Deal.Infrastructure/Persistence/DealDbContext.cs @@ -1,58 +1,58 @@ -using Deal.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence; - -/// -/// Системный DbContext: схема public (тенанты, пользователи, сессии, операторы, инвайты, лимиты, аудит). -/// -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(); - - /// - /// История расхода токенов (time-series аналитики, этап 10, T2). - /// - public DbSet TokenUsageEvents => Set(); - - /// - /// Счётчики фиксированного окна (этап 12, пакет B): распределённый rate-limit и учёт - /// попыток входа (public.rate_limit_counters). - /// - public DbSet RateLimitCounters => Set(); - - /// - /// Глобальные (системные) настройки оператора: ключи Telegram и др. (public.global_settings). - /// - 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 (тенанты, пользователи, сессии, операторы, инвайты, лимиты, аудит). +/// +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(); + + /// + /// История расхода токенов (time-series аналитики, этап 10, T2). + /// + public DbSet TokenUsageEvents => Set(); + + /// + /// Счётчики фиксированного окна (этап 12, пакет B): распределённый rate-limit и учёт + /// попыток входа (public.rate_limit_counters). + /// + public DbSet RateLimitCounters => Set(); + + /// + /// Глобальные (системные) настройки оператора: ключи Telegram и др. (public.global_settings). + /// + 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/DealDbDesignTimeFactory.cs b/src/core/Deal.Infrastructure/Persistence/DealDbDesignTimeFactory.cs index 64b6944..6cb40ab 100644 --- a/src/core/Deal.Infrastructure/Persistence/DealDbDesignTimeFactory.cs +++ b/src/core/Deal.Infrastructure/Persistence/DealDbDesignTimeFactory.cs @@ -1,21 +1,20 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence; - -/// -/// Фабрика для dotnet-ef (миграции). Читает строку подключения из env. -/// -public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory -{ - public DealDbContext CreateDbContext(string[] args) - { - var connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION") - ?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password"; - var options = new DbContextOptionsBuilder() - .UseNpgsql(connectionString) - .Options; - return new DealDbContext(options); - } -} +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Deal.Infrastructure.Persistence; + +/// +/// Фабрика для dotnet-ef (миграции). Читает строку подключения из env. +/// +public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory +{ + public DealDbContext CreateDbContext(string[] args) + { + var connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION") + ?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password"; + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .Options; + return new DealDbContext(options); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/AuditLogEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/AuditLogEntity.cs index 864e7c9..3a73192 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/AuditLogEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/AuditLogEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/CardEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/CardEntity.cs index 5146cc2..d6859b0 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/CardEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/CardEntity.cs @@ -1,191 +1,190 @@ -using NpgsqlTypes; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Entities; - -/// -/// Карточка канбана: таблица Cards в схеме тенанта. Соответствует таблице leads прототипа. -/// -public sealed class CardEntity -{ - /// - /// Короткий id карточки (префикс c_), первичный ключ. - /// - public string Id { get; set; } = string.Empty; - - /// - /// Колонка карточки: служебные inbox|archive|trash|taken либо id доски (b_...). - /// Ссылочной целостности нет — существование доски валидирует приложение. - /// - 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; } - - /// - /// Заголовок карточки (очищенный, до 140 символов — режет сервис). - /// - public string Title { get; set; } = string.Empty; - - /// - /// Краткое содержание карточки (очищенное, до 2000 символов — режет сервис). - /// - public string Summary { get; set; } = string.Empty; - - /// - /// Стек/направления, сериализованные в JSON (text). - /// - public string StackJson { get; set; } = "[]"; - - /// - /// Нижняя граница бюджета (валюта — BudgetCur), либо null. - /// - public double? BudgetFrom { get; set; } - - /// - /// Верхняя граница бюджета (валюта — BudgetCur), либо null. - /// - 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; - - /// - /// Контактная строка «как в сообщении» (fallback, если ContactsJson пуст). - /// - public string Contact { get; set; } = string.Empty; - - /// - /// Квалифицированные контакты, сериализованные в JSON (text). - /// - public string ContactsJson { get; set; } = "[]"; - - /// - /// Имя канала/диалога-источника. - /// - public string ChannelName { get; set; } = string.Empty; - - /// - /// Handle канала-источника. - /// - public string ChannelHandle { get; set; } = string.Empty; - - /// - /// Цвет канала-источника (hex). - /// - public string ChannelHue { get; set; } = "#666"; - - /// - /// Время получения исходного сообщения (сортировка карточек, автоархив). - /// - public DateTimeOffset ReceivedAt { get; set; } - - /// - /// Текст исходного сообщения (для переобучения ML и поиска). - /// - public string SourceMsg { get; set; } = string.Empty; - - /// - /// Id диалога исходного сообщения (для «открыть исходник»). - /// - public string SourceDialogId { get; set; } = string.Empty; - - /// - /// Id исходного сообщения в Telegram, либо null. - /// - public long? SourceMsgId { get; set; } - - /// - /// Предыдущая колонка (для возврата из архива/корзины). - /// - public string PrevCol { get; set; } = "inbox"; - - /// - /// Время помещения в архив (для правила «архив очищается через N дней»), либо null. - /// - public DateTimeOffset? ArchivedAt { get; set; } - - /// - /// Совпавшие критерии правил при попадании в колонку, сериализованные в JSON (text). - /// - public string MatchHitsJson { get; set; } = "[]"; - - /// - /// Ссылки карточки, сериализованные в JSON (text; элементы {id,name,url}). - /// - public string LinksJson { get; set; } = "[]"; - - /// - /// Файлы карточки, сериализованные в JSON (text; элементы {id,name,size,kind,label,objectKey}). - /// - public string FilesJson { get; set; } = "[]"; - - /// - /// История движения карточки, сериализованная в JSON (text; элементы {id,at,type|stage}). - /// - public string HistoryJson { get; set; } = "[]"; - - /// - /// Текст технического задания по карточке (заметка-задание). - /// - public string TzText { get; set; } = string.Empty; - - /// - /// Время напоминания об отложенной карточке, либо null (напоминание не задано/сброшено). - /// - public DateTimeOffset? ReminderAt { get; set; } - - /// - /// Признак «напоминание уже выстрелило» (повторно не срабатывает до переноса/переустановки). - /// - public bool ReminderFired { get; set; } - - /// - /// Полнотекстовый вектор (tsvector, конфигурация russian) для поиска карточек — вычисляемая STORED-колонка БД. - /// - public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty; - - /// - /// Время создания карточки. - /// - public DateTimeOffset CreatedAt { get; set; } - - /// - /// Время последнего изменения карточки (сортировка пространства «Выбранные» — UpdatedAt DESC). - /// - public DateTimeOffset UpdatedAt { get; set; } -} +using NpgsqlTypes; + +namespace Deal.Infrastructure.Persistence.Entities; + +/// +/// Карточка канбана: таблица Cards в схеме тенанта. Соответствует таблице leads прототипа. +/// +public sealed class CardEntity +{ + /// + /// Короткий id карточки (префикс c_), первичный ключ. + /// + public string Id { get; set; } = string.Empty; + + /// + /// Колонка карточки: служебные inbox|archive|trash|taken либо id доски (b_...). + /// Ссылочной целостности нет — существование доски валидирует приложение. + /// + 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; } + + /// + /// Заголовок карточки (очищенный, до 140 символов — режет сервис). + /// + public string Title { get; set; } = string.Empty; + + /// + /// Краткое содержание карточки (очищенное, до 2000 символов — режет сервис). + /// + public string Summary { get; set; } = string.Empty; + + /// + /// Стек/направления, сериализованные в JSON (text). + /// + public string StackJson { get; set; } = "[]"; + + /// + /// Нижняя граница бюджета (валюта — BudgetCur), либо null. + /// + public double? BudgetFrom { get; set; } + + /// + /// Верхняя граница бюджета (валюта — BudgetCur), либо null. + /// + 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; + + /// + /// Контактная строка «как в сообщении» (fallback, если ContactsJson пуст). + /// + public string Contact { get; set; } = string.Empty; + + /// + /// Квалифицированные контакты, сериализованные в JSON (text). + /// + public string ContactsJson { get; set; } = "[]"; + + /// + /// Имя канала/диалога-источника. + /// + public string ChannelName { get; set; } = string.Empty; + + /// + /// Handle канала-источника. + /// + public string ChannelHandle { get; set; } = string.Empty; + + /// + /// Цвет канала-источника (hex). + /// + public string ChannelHue { get; set; } = "#666"; + + /// + /// Время получения исходного сообщения (сортировка карточек, автоархив). + /// + public DateTimeOffset ReceivedAt { get; set; } + + /// + /// Текст исходного сообщения (для переобучения ML и поиска). + /// + public string SourceMsg { get; set; } = string.Empty; + + /// + /// Id диалога исходного сообщения (для «открыть исходник»). + /// + public string SourceDialogId { get; set; } = string.Empty; + + /// + /// Id исходного сообщения в Telegram, либо null. + /// + public long? SourceMsgId { get; set; } + + /// + /// Предыдущая колонка (для возврата из архива/корзины). + /// + public string PrevCol { get; set; } = "inbox"; + + /// + /// Время помещения в архив (для правила «архив очищается через N дней»), либо null. + /// + public DateTimeOffset? ArchivedAt { get; set; } + + /// + /// Совпавшие критерии правил при попадании в колонку, сериализованные в JSON (text). + /// + public string MatchHitsJson { get; set; } = "[]"; + + /// + /// Ссылки карточки, сериализованные в JSON (text; элементы {id,name,url}). + /// + public string LinksJson { get; set; } = "[]"; + + /// + /// Файлы карточки, сериализованные в JSON (text; элементы {id,name,size,kind,label,objectKey}). + /// + public string FilesJson { get; set; } = "[]"; + + /// + /// История движения карточки, сериализованная в JSON (text; элементы {id,at,type|stage}). + /// + public string HistoryJson { get; set; } = "[]"; + + /// + /// Текст технического задания по карточке (заметка-задание). + /// + public string TzText { get; set; } = string.Empty; + + /// + /// Время напоминания об отложенной карточке, либо null (напоминание не задано/сброшено). + /// + public DateTimeOffset? ReminderAt { get; set; } + + /// + /// Признак «напоминание уже выстрелило» (повторно не срабатывает до переноса/переустановки). + /// + public bool ReminderFired { get; set; } + + /// + /// Полнотекстовый вектор (tsvector, конфигурация russian) для поиска карточек — вычисляемая STORED-колонка БД. + /// + public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty; + + /// + /// Время создания карточки. + /// + public DateTimeOffset CreatedAt { get; set; } + + /// + /// Время последнего изменения карточки (сортировка пространства «Выбранные» — UpdatedAt DESC). + /// + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/CardMoveEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/CardMoveEntity.cs index a32c622..2dcbf84 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/CardMoveEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/CardMoveEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/ContainerEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/ContainerEntity.cs index e1fb7e3..8f7e082 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/ContainerEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/ContainerEntity.cs @@ -1,85 +1,84 @@ -using NpgsqlTypes; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Entities; - -/// -/// Единый контейнер карточек: колонка дашборда, стадия «Выбранных» или служебная зона (таблица Containers). -/// -/// -/// Приходит на смену разрозненным сущностям: Boards (колонки дашборда) + предзаданный каталог стадий -/// (planned…rejected, жил константой модуля) + строковые служебные зоны (inbox/archive/trash — жили -/// значениями Cards.Col). Одна таблица: kind (board|stage|service|terminal), space (dashboard|selected), -/// правила фильтрации (RulesJson), политика (PolicyJson: CanRestore/IsTerminal/RetentionDays). -/// Служебные и стадии провижининг сидирует из реестров модуля Cards (CardsDefaultContainers/CardIds); -/// доски создаёт пользователь/ИИ (kind=board, как Boards раньше). -/// -public sealed class ContainerEntity -{ - /// - /// Короткий id контейнера (доски b_…, стадии planned…, служебные inbox/archive/trash). - /// - public string Id { get; set; } = string.Empty; - - /// - /// Имя для отображения («WPF», «В работе», «Архив»). - /// - public string Name { get; set; } = string.Empty; - - /// - /// Описание контейнера (для пользователя и подсказки ИИ/ML). - /// - public string Description { get; set; } = string.Empty; - - /// - /// Цвет (hex). - /// - public string Color { get; set; } = "#818cf8"; - - /// - /// Позиция в пространстве (ORDER BY space, position). - /// - public int Position { get; set; } - - /// - /// Вид контейнера: board (колонка-фильтр) | stage (стадия) | service (inbox/archive/trash) | terminal (finished/rejected). - /// - public string Kind { get; set; } = "board"; - - /// - /// Пространство: dashboard | selected (вид дашборда, к которому принадлежит контейнер). - /// - public string Space { get; set; } = "dashboard"; - - /// - /// Свёрнутость колонки на дашборде (состояние UI). - /// - public bool Collapsed { get; set; } - - /// - /// Признак ИИ-предложения: контейнер ждёт решения пользователя. - /// - public bool Suggested { get; set; } - - /// - /// Правила маршрутизации (IContainerRules), сериализованные в JSON (text). - /// - public string RulesJson { get; set; } = "{}"; - - /// - /// Заметка контейнера (например, сгенерированное описание правил / обоснование ИИ). - /// - public string Note { get; set; } = string.Empty; - - /// - /// Политика контейнера (CanRestore/IsTerminal/RetentionDays), сериализованная в JSON (text). - /// - public string PolicyJson { get; set; } = "{}"; - - /// - /// Полнотекстовый вектор поиска по контейнерам (title/description) — вычисляемая STORED-колонка. - /// - public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty; - - public DateTimeOffset CreatedAt { get; set; } -} +using NpgsqlTypes; + +namespace Deal.Infrastructure.Persistence.Entities; + +/// +/// Единый контейнер карточек: колонка дашборда, стадия «Выбранных» или служебная зона (таблица Containers). +/// +/// +/// Приходит на смену разрозненным сущностям: Boards (колонки дашборда) + предзаданный каталог стадий +/// (planned…rejected, жил константой модуля) + строковые служебные зоны (inbox/archive/trash — жили +/// значениями Cards.Col). Одна таблица: kind (board|stage|service|terminal), space (dashboard|selected), +/// правила фильтрации (RulesJson), политика (PolicyJson: CanRestore/IsTerminal/RetentionDays). +/// Служебные и стадии провижининг сидирует из реестров модуля Cards (CardsDefaultContainers/CardIds); +/// доски создаёт пользователь/ИИ (kind=board, как Boards раньше). +/// +public sealed class ContainerEntity +{ + /// + /// Короткий id контейнера (доски b_…, стадии planned…, служебные inbox/archive/trash). + /// + public string Id { get; set; } = string.Empty; + + /// + /// Имя для отображения («WPF», «В работе», «Архив»). + /// + public string Name { get; set; } = string.Empty; + + /// + /// Описание контейнера (для пользователя и подсказки ИИ/ML). + /// + public string Description { get; set; } = string.Empty; + + /// + /// Цвет (hex). + /// + public string Color { get; set; } = "#818cf8"; + + /// + /// Позиция в пространстве (ORDER BY space, position). + /// + public int Position { get; set; } + + /// + /// Вид контейнера: board (колонка-фильтр) | stage (стадия) | service (inbox/archive/trash) | terminal (finished/rejected). + /// + public string Kind { get; set; } = "board"; + + /// + /// Пространство: dashboard | selected (вид дашборда, к которому принадлежит контейнер). + /// + public string Space { get; set; } = "dashboard"; + + /// + /// Свёрнутость колонки на дашборде (состояние UI). + /// + public bool Collapsed { get; set; } + + /// + /// Признак ИИ-предложения: контейнер ждёт решения пользователя. + /// + public bool Suggested { get; set; } + + /// + /// Правила маршрутизации (IContainerRules), сериализованные в JSON (text). + /// + public string RulesJson { get; set; } = "{}"; + + /// + /// Заметка контейнера (например, сгенерированное описание правил / обоснование ИИ). + /// + public string Note { get; set; } = string.Empty; + + /// + /// Политика контейнера (CanRestore/IsTerminal/RetentionDays), сериализованная в JSON (text). + /// + public string PolicyJson { get; set; } = "{}"; + + /// + /// Полнотекстовый вектор поиска по контейнерам (title/description) — вычисляемая STORED-колонка. + /// + public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty; + + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/DedupEntryEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/DedupEntryEntity.cs index c1dafab..e637bb2 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/DedupEntryEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/DedupEntryEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/DialogEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/DialogEntity.cs index ec042b2..b5626bd 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/DialogEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/DialogEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/DiscBlacklistEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/DiscBlacklistEntity.cs index 6b9f768..fcdb158 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/DiscBlacklistEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/DiscBlacklistEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/DiscCandidateEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/DiscCandidateEntity.cs index eb4220b..6146596 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/DiscCandidateEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/DiscCandidateEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/DiscLogEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/DiscLogEntity.cs index 195bdb9..a66dfae 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/DiscLogEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/DiscLogEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/DiscTaskEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/DiscTaskEntity.cs index 8e933d8..347d412 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/DiscTaskEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/DiscTaskEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs index c63e384..2ae60f0 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/InviteEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/InviteEntity.cs index 414bdc2..ed7fe6b 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/InviteEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/InviteEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/LeadCommentEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/LeadCommentEntity.cs index f2dd179..e62b71c 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/LeadCommentEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/LeadCommentEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/MlOutboxEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/MlOutboxEntity.cs index 49a55ff..1f29f02 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/MlOutboxEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/MlOutboxEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/OperatorEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/OperatorEntity.cs index f616fc5..e5383dd 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/OperatorEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/OperatorEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/OperatorSessionEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/OperatorSessionEntity.cs index c1cc8ed..77b2b5c 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/OperatorSessionEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/OperatorSessionEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/QueueItemEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/QueueItemEntity.cs index 53e9706..9e01af0 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/QueueItemEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/QueueItemEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/RateLimitCounterEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/RateLimitCounterEntity.cs index 528ea67..44b2fa0 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/RateLimitCounterEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/RateLimitCounterEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/RejectedItemEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/RejectedItemEntity.cs index 365af2f..925188b 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/RejectedItemEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/RejectedItemEntity.cs @@ -1,102 +1,101 @@ -using NpgsqlTypes; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Entities; - -/// -/// Запись отсева пайплайна: таблица RejectedItems в схеме тенанта. Соответствует таблице rejected_msgs прототипа. -/// -/// -/// Сообщения, не прошедшие этапы обработки (стоп-лист/резюме/тип/без суммы/устарело/ML/ИИ): хранится причина -/// и «чьё» решение (Source). Автоочистка раз в 3 суток + ручная очистка из UI. При возврате в обработку запись -/// не удаляется — помечается returned/returnedAt/returnReason. Без FK — отсев живёт дольше карточки (конвенция -/// Ruling 1 этапа 3). -/// -public sealed class RejectedItemEntity -{ - /// - /// Короткий id записи (префикс r_), первичный ключ. - /// - /// Детерминированный r_<dialog>_<msgId> при наличии dialog+msgId, иначе r_+hex (upsert по id). - public string Id { get; set; } = string.Empty; - - /// - /// Id диалога-источника (для повторного возврата в очередь); пусто — диалог неизвестен. - /// - public string DialogId { get; set; } = string.Empty; - - /// - /// Id исходного сообщения в Telegram, либо null. - /// - public long? MsgId { get; set; } - - /// - /// Имя канала/диалога-источника. - /// - public string ChannelName { get; set; } = string.Empty; - - /// - /// Handle канала-источника. - /// - public string ChannelHandle { get; set; } = string.Empty; - - /// - /// Цвет канала-источника (hex). - /// - public string ChannelHue { get; set; } = "#666"; - - /// - /// Текст сообщения (обрезается до 6000 символов при записи — режет сервис). - /// - 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; - - /// - /// Человекочитаемая причина отсева (до 500 символов — режет сервис). - /// - public string Reason { get; set; } = string.Empty; - - /// - /// Совпавшее ключевое слово/фраза правила (до 200 символов), либо пусто. - /// - public string Kw { get; set; } = string.Empty; - - /// - /// Кто вынес решение: stop|ml|ai|stale|dup (stale/dup — «система»). - /// - public string Source { get; set; } = "stop"; - - /// - /// Время получения исходного сообщения (для повторного возврата в очередь). - /// - public DateTimeOffset MsgAt { get; set; } - - /// - /// Время записи в отсев (автоочистка старше 3 суток). - /// - public DateTimeOffset RejectedAt { get; set; } - - /// - /// Признак возврата записи в обработку пользователем. - /// - public bool Returned { get; set; } - - /// - /// Время возврата в обработку, либо null. - /// - public DateTimeOffset? ReturnedAt { get; set; } - - /// - /// Причина возврата пользователем (до 500 символов — режет сервис), либо пусто. - /// - public string ReturnReason { get; set; } = string.Empty; - - /// - /// Полнотекстовый вектор (tsvector, конфигурация russian) для поиска отсева — вычисляемая STORED-колонка БД. - /// - public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty; -} +using NpgsqlTypes; + +namespace Deal.Infrastructure.Persistence.Entities; + +/// +/// Запись отсева пайплайна: таблица RejectedItems в схеме тенанта. Соответствует таблице rejected_msgs прототипа. +/// +/// +/// Сообщения, не прошедшие этапы обработки (стоп-лист/резюме/тип/без суммы/устарело/ML/ИИ): хранится причина +/// и «чьё» решение (Source). Автоочистка раз в 3 суток + ручная очистка из UI. При возврате в обработку запись +/// не удаляется — помечается returned/returnedAt/returnReason. Без FK — отсев живёт дольше карточки (конвенция +/// Ruling 1 этапа 3). +/// +public sealed class RejectedItemEntity +{ + /// + /// Короткий id записи (префикс r_), первичный ключ. + /// + /// Детерминированный r_<dialog>_<msgId> при наличии dialog+msgId, иначе r_+hex (upsert по id). + public string Id { get; set; } = string.Empty; + + /// + /// Id диалога-источника (для повторного возврата в очередь); пусто — диалог неизвестен. + /// + public string DialogId { get; set; } = string.Empty; + + /// + /// Id исходного сообщения в Telegram, либо null. + /// + public long? MsgId { get; set; } + + /// + /// Имя канала/диалога-источника. + /// + public string ChannelName { get; set; } = string.Empty; + + /// + /// Handle канала-источника. + /// + public string ChannelHandle { get; set; } = string.Empty; + + /// + /// Цвет канала-источника (hex). + /// + public string ChannelHue { get; set; } = "#666"; + + /// + /// Текст сообщения (обрезается до 6000 символов при записи — режет сервис). + /// + 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; + + /// + /// Человекочитаемая причина отсева (до 500 символов — режет сервис). + /// + public string Reason { get; set; } = string.Empty; + + /// + /// Совпавшее ключевое слово/фраза правила (до 200 символов), либо пусто. + /// + public string Kw { get; set; } = string.Empty; + + /// + /// Кто вынес решение: stop|ml|ai|stale|dup (stale/dup — «система»). + /// + public string Source { get; set; } = "stop"; + + /// + /// Время получения исходного сообщения (для повторного возврата в очередь). + /// + public DateTimeOffset MsgAt { get; set; } + + /// + /// Время записи в отсев (автоочистка старше 3 суток). + /// + public DateTimeOffset RejectedAt { get; set; } + + /// + /// Признак возврата записи в обработку пользователем. + /// + public bool Returned { get; set; } + + /// + /// Время возврата в обработку, либо null. + /// + public DateTimeOffset? ReturnedAt { get; set; } + + /// + /// Причина возврата пользователем (до 500 символов — режет сервис), либо пусто. + /// + public string ReturnReason { get; set; } = string.Empty; + + /// + /// Полнотекстовый вектор (tsvector, конфигурация russian) для поиска отсева — вычисляемая STORED-колонка БД. + /// + public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty; +} diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/SessionEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/SessionEntity.cs index 76fb01c..9f91dbf 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/SessionEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/SessionEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/TenantEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/TenantEntity.cs index 15e128a..e58c8d8 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/TenantEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/TenantEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/TenantLimitEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/TenantLimitEntity.cs index 25f779b..14f903a 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/TenantLimitEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/TenantLimitEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/TenantSettingEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/TenantSettingEntity.cs index 9b61d5c..6f4e7d5 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/TenantSettingEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/TenantSettingEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/TgMessageEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/TgMessageEntity.cs index 235605a..c3438eb 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/TgMessageEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/TgMessageEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/TokenUsageEventEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/TokenUsageEventEntity.cs index bc06080..e2c91e0 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/TokenUsageEventEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/TokenUsageEventEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/UserEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/UserEntity.cs index 1ae7cf8..3099f26 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/UserEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/UserEntity.cs @@ -1,5 +1,3 @@ -using Deal.Infrastructure.Persistence.Configurations; - namespace Deal.Infrastructure.Persistence.Entities; /// diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs index edb0549..ee3c403 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs @@ -1,134 +1,131 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища аудита: таблица public.audit_log (append-only, Ruling 4). -/// -/// -/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). Запись — только -/// Add+SaveChanges; выборка — фильтры At-range/EventType/ActorType/TenantId/ActorId, сортировка At DESC, offset/limit -/// (limit клампится 1.., offset ≥0). Update/Delete в приложении отсутствуют (append-only на уровне -/// кода и конвенции; DB-триггеры не добавляем, Ruling 4). -/// -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; - } - - // Клампит размер выборки в 1..MaxQueryLimit (Ruling 4: limit ≤500). - // limit: Запрошенный размер. - // Возвращает: Клампированное значение. - 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.audit_log (append-only, Ruling 4). +/// +/// +/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). Запись — только +/// Add+SaveChanges; выборка — фильтры At-range/EventType/ActorType/TenantId/ActorId, сортировка At DESC, offset/limit +/// (limit клампится 1.., offset ≥0). Update/Delete в приложении отсутствуют (append-only на уровне +/// кода и конвенции; DB-триггеры не добавляем, Ruling 4). +/// +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; + } + + // Клампит размер выборки в 1..MaxQueryLimit (Ruling 4: limit ≤500). + // limit: Запрошенный размер. + // Возвращает: Клампированное значение. + 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 929d1b0..44be182 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs @@ -1,134 +1,130 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища аутентификации: таблицы public.users и public.sessions. -/// -/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). -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) - { - // Порядок по CreatedAt — «первый пользователь тенанта» для impersonation без login (Task 7) детерминирован. - 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.users и public.sessions. +/// +/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). +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) + { + // Порядок по CreatedAt — «первый пользователь тенанта» для impersonation без login (Task 7) детерминирован. + 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 ecdd4f1..b7b5568 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Blacklist.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Blacklist.cs @@ -1,74 +1,67 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -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.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Чёрный список Discovery — partial-часть (C32: выделено из общего -/// файла, поведение не менялось): upsert/удаление/чтение/список DiscBlacklist (ON CONFLICT DO UPDATE). -/// -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 - { - // add_blacklist L572–575 (ON CONFLICT DO UPDATE): name/reason обновляются, CreatedAt сохраняется. - 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-часть (C32: выделено из общего +/// файла, поведение не менялось): upsert/удаление/чтение/список DiscBlacklist (ON CONFLICT DO UPDATE). +/// +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 + { + // add_blacklist L572–575 (ON CONFLICT DO UPDATE): name/reason обновляются, CreatedAt сохраняется. + 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 a13d151..fd3f799 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Candidates.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Candidates.cs @@ -1,175 +1,168 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -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.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Кандидаты Discovery — partial-часть (C32: выделено из общего файла, -/// поведение не менялось): список/чтение кандидатов (DiscCandidates), мониторинг диалога и чёрный список, -/// создание/патч/статусы joined/rejected и счётчик сбоев вступлений. -/// -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; - } - - // mark_joined L531–534: status=joined + auto_joined. - 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") - { - // воркер L404–416: счётчик и удаление трогаем только у живой записи в статусе 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-часть (C32: выделено из общего файла, +/// поведение не менялось): список/чтение кандидатов (DiscCandidates), мониторинг диалога и чёрный список, +/// создание/патч/статусы joined/rejected и счётчик сбоев вступлений. +/// +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; + } + + // mark_joined L531–534: status=joined + auto_joined. + 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") + { + // воркер L404–416: счётчик и удаление трогаем только у живой записи в статусе 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 5523a35..fe8cf35 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Logs.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Logs.cs @@ -1,63 +1,56 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -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.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Лог Discovery — partial-часть (C32: выделено из общего файла, -/// поведение не менялось): запись события, счётчик событий с начала суток (квота вступлений) и лог задачи. -/// -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) - { - // ban_guard.joins_today_auto L29–35: число событий лога по типу с начала UTC-суток (счётчик квоты). - 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-часть (C32: выделено из общего файла, +/// поведение не менялось): запись события, счётчик событий с начала суток (квота вступлений) и лог задачи. +/// +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) + { + // ban_guard.joins_today_auto L29–35: число событий лога по типу с начала UTC-суток (счётчик квоты). + 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 d23f8f8..52a9479 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Tasks.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Tasks.cs @@ -1,236 +1,229 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -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.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Задачи Discovery — partial-часть (C32: выделено из общего файла, -/// поведение не менялось): CRUD задач (DiscTasks), состояние running/paused/done, атомарные инкременты -/// счётчиков и сдвиг поиска, сумма плана (discovery.py db.py; Ruling 9, Task 17). -/// -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; - } - - // delete_task L314–318: задача удаляется вместе с кандидатами и логом; чёрный список общий — не трогаем. - 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) - { - // start_task L333–339: повторный прогон завершённой/упавшей — свежий проход по ключам. - 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; - } - - // воркер _finish_done L126–136: план вступлений выполнен — status=done, бюджет планов освобождается. - 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; - } - - // bump_counter L359–368: приращение счётчика + bump UpdatedAt (python читает и пишет абсолютное значение). - 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; - } - - // advance_search L371–380: новый индекс и флаг завершения прохода (значения считает сервис). - 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-часть (C32: выделено из общего файла, +/// поведение не менялось): CRUD задач (DiscTasks), состояние running/paused/done, атомарные инкременты +/// счётчиков и сдвиг поиска, сумма плана (discovery.py db.py; Ruling 9, Task 17). +/// +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; + } + + // delete_task L314–318: задача удаляется вместе с кандидатами и логом; чёрный список общий — не трогаем. + 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) + { + // start_task L333–339: повторный прогон завершённой/упавшей — свежий проход по ключам. + 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; + } + + // воркер _finish_done L126–136: план вступлений выполнен — status=done, бюджет планов освобождается. + 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; + } + + // bump_counter L359–368: приращение счётчика + bump UpdatedAt (python читает и пишет абсолютное значение). + 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; + } + + // advance_search L371–380: новый индекс и флаг завершения прохода (значения считает сервис). + 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 eb0f341..2861f6c 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs @@ -1,224 +1,218 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -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.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища Discovery: таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog схемы тенанта (Ruling 9, Task 17). -/// -/// -/// Реализация порта на (эталон TelegramStore/ -/// KanbanStore). Семантика 1:1 с python discovery.py/db.py: INSERT со служебными дефолтами (draft/new/счётчики 0), -/// UPDATE присутствующих полей патча с bump UpdatedAt, атомарные инкременты счётчиков (bump_counter), upsert -/// чёрного списка (ON CONFLICT DO UPDATE name/reason при сохранённом CreatedAt), каскад delete_task -/// (задача + кандидаты + лог). JSON-колонки (keywords/marks/topics) — text с сериализованным JSON camelCase; -/// времена — timestamptz (DateTimeOffset), наружу epoch-ms. Чтение Dialogs (проверка «уже мониторится») — тот же -/// TenantDbContext (владелец каталога — модуль Telegram; доступ по БД, реверс-зависимостей нет). -/// C32: класс разделён на partial-файлы по агрегатам (DiscoveryStore.Tasks/Candidates/Blacklist/Logs.cs); -/// маппинг DTO ↔ строк остаётся в этом файле. Поведение и сигнатуры не менялись. -/// -public sealed partial class DiscoveryStore : IDiscoveryStore -{ - private readonly TenantDbContext _dbContext; - - /// - /// Создаёт EF-адаптер хранилища Discovery (зависимости — tenant-контекст БД). - /// - /// Scoped-контекст тенанта запроса (search_path). - public DiscoveryStore(TenantDbContext dbContext) - { - _dbContext = dbContext; - } - - // Опции JSON: camelCase для wire-форм Discovery (1:1 с §4.8; эталон KanbanStore JsonOptions). - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - // Маппит строку задачи в DTO (task_view L116–137; keywords — JSON-список, времена — epoch-ms). - 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()); - } - - // Маппит строку кандидата в DTO (candidate_view L140–158; marks/topics — JSON-списки). - 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()); - } - - // Маппит строку чёрного списка в DTO (blacklist_view L161–167). - private static DiscoveryBlacklistDto ToBlacklistDto(DiscBlacklistEntity row) - { - return new DiscoveryBlacklistDto(row.DialogId, row.Name, row.Reason, row.CreatedAt.ToUnixTimeMilliseconds()); - } - - // Маппит строку лога в DTO (log_view L170–177). - private static DiscoveryLogDto ToLogDto(DiscLogEntity row) - { - return new DiscoveryLogDto(row.Id, row.TaskId, row.Event, row.Text, row.CreatedAt.ToUnixTimeMilliseconds()); - } - - // Применяет патч задачи к строке (patch_task L292–310: только присутствующие поля; keywords — JSON). - 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; - } - } - - // Применяет патч кандидата к строке (set_candidate L468–492; marks/topics — JSON-замена). - 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; - } - } - - // Разбирает JSON-массив колонки (повреждённая строка/не-массив → пустой список, python _loads L73–77). - 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: таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog схемы тенанта (Ruling 9, Task 17). +/// +/// +/// Реализация порта на (эталон TelegramStore/ +/// KanbanStore). Семантика 1:1 с python discovery.py/db.py: INSERT со служебными дефолтами (draft/new/счётчики 0), +/// UPDATE присутствующих полей патча с bump UpdatedAt, атомарные инкременты счётчиков (bump_counter), upsert +/// чёрного списка (ON CONFLICT DO UPDATE name/reason при сохранённом CreatedAt), каскад delete_task +/// (задача + кандидаты + лог). JSON-колонки (keywords/marks/topics) — text с сериализованным JSON camelCase; +/// времена — timestamptz (DateTimeOffset), наружу epoch-ms. Чтение Dialogs (проверка «уже мониторится») — тот же +/// TenantDbContext (владелец каталога — модуль Telegram; доступ по БД, реверс-зависимостей нет). +/// C32: класс разделён на partial-файлы по агрегатам (DiscoveryStore.Tasks/Candidates/Blacklist/Logs.cs); +/// маппинг DTO ↔ строк остаётся в этом файле. Поведение и сигнатуры не менялись. +/// +public sealed partial class DiscoveryStore : IDiscoveryStore +{ + private readonly TenantDbContext _dbContext; + + /// + /// Создаёт EF-адаптер хранилища Discovery (зависимости — tenant-контекст БД). + /// + /// Scoped-контекст тенанта запроса (search_path). + public DiscoveryStore(TenantDbContext dbContext) + { + _dbContext = dbContext; + } + + // Опции JSON: camelCase для wire-форм Discovery (1:1 с §4.8; эталон KanbanStore JsonOptions). + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + // Маппит строку задачи в DTO (task_view L116–137; keywords — JSON-список, времена — epoch-ms). + 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()); + } + + // Маппит строку кандидата в DTO (candidate_view L140–158; marks/topics — JSON-списки). + 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()); + } + + // Маппит строку чёрного списка в DTO (blacklist_view L161–167). + private static DiscoveryBlacklistDto ToBlacklistDto(DiscBlacklistEntity row) + { + return new DiscoveryBlacklistDto(row.DialogId, row.Name, row.Reason, row.CreatedAt.ToUnixTimeMilliseconds()); + } + + // Маппит строку лога в DTO (log_view L170–177). + private static DiscoveryLogDto ToLogDto(DiscLogEntity row) + { + return new DiscoveryLogDto(row.Id, row.TaskId, row.Event, row.Text, row.CreatedAt.ToUnixTimeMilliseconds()); + } + + // Применяет патч задачи к строке (patch_task L292–310: только присутствующие поля; keywords — JSON). + 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; + } + } + + // Применяет патч кандидата к строке (set_candidate L468–492; marks/topics — JSON-замена). + 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; + } + } + + // Разбирает JSON-массив колонки (повреждённая строка/не-массив → пустой список, python _loads L73–77). + 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 526f184..f60750a 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/GlobalSettingsStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/GlobalSettingsStore.cs @@ -1,57 +1,54 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер KV-хранилища глобальных (системных) настроек оператора: таблица public.global_settings. -/// -/// -/// Маппинг 1:1 со строкой таблицы: key/value/updated_at (сущность GlobalSettingEntity, системный -/// DealDbContext схемы public). Порт оперирует готовыми JSON-строками (сериализацию выполняет -/// владелец ключа), поэтому адаптер хранит значение как текст без интерпретации. -/// пишет updated_at = UTC-now. -/// -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.global_settings. +/// +/// +/// Маппинг 1:1 со строкой таблицы: key/value/updated_at (сущность GlobalSettingEntity, системный +/// DealDbContext схемы public). Порт оперирует готовыми JSON-строками (сериализацию выполняет +/// владелец ключа), поэтому адаптер хранит значение как текст без интерпретации. +/// пишет updated_at = UTC-now. +/// +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 107e63f..df5734e 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/InviteStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/InviteStore.cs @@ -1,116 +1,113 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища приглашений: таблица public.invites (Ruling 2 этапа 7). -/// -/// -/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). «expired» здесь -/// не вычисляется: ленивое проставление статуса выполняет при чтении/проверке, -/// адаптер лишь меняет статус по коду (). List упорядочен CreatedAt DESC — -/// свежие приглашения сверху в операторском списке. -/// -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) - { - // CAS (Task 6): атомарный условный UPDATE — pending → activated только если статус всё ещё pending, - // иначе параллельный отзыв/активация не перезаписываются (ExecuteUpdate выполняется одним оператором SQL). - 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.invites (Ruling 2 этапа 7). +/// +/// +/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). «expired» здесь +/// не вычисляется: ленивое проставление статуса выполняет при чтении/проверке, +/// адаптер лишь меняет статус по коду (). List упорядочен CreatedAt DESC — +/// свежие приглашения сверху в операторском списке. +/// +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) + { + // CAS (Task 6): атомарный условный UPDATE — pending → activated только если статус всё ещё pending, + // иначе параллельный отзыв/активация не перезаписываются (ExecuteUpdate выполняется одним оператором SQL). + 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 cd613a5..e27f07a 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs @@ -1,275 +1,269 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Карточки канбана — partial-часть (C32: выделено из общего файла, -/// поведение не менялось): чтение списка/поиск/одна карточка, создание из снимка, -/// смена колонки/seen, жёсткое удаление, очистка колонки, счётчики. -/// -public sealed partial class KanbanStore -{ - /// - public async Task> ListCardsAsync(CardsQuery query, CancellationToken ct) - { - IQueryable queryable = _dbContext.Cards.AsNoTracking(); - if (query.Col is null) - { - // Все колонки дашборда, кроме контейнеров-стадий «Выбранных» (этап 9): карточка, взятая - // в работу, живёт в том же пространстве «Выбранных», а не на дашборде. - 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(); - } - - // FTS + LIKE-дополнение одним запросом (Ruling 6): SearchTsv @@ plainto_tsquery('russian') ∪ lower - // LIKE по title/summary/source_msg/contact (search L527–529), контейнеры-стадии «Выбранных» - // исключены, порядок — ts_rank DESC, ReceivedAt DESC, limit 12. plainto_tsquery со стоп-словами даёт - // пустой tsquery — совпадений нет (не ошибка), LIKE ниже всё равно отработает. Всё параметризовано - // (никакой конкатенации пользовательского ввода); LIKE-паттерн — как в отсев-поиске PipelineStore. - 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("SourceMsg") 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( - string dialogId, - long msgId, - CancellationToken ct) - { - if (string.IsNullOrEmpty(dialogId)) - { - return null; - } - - CardEntity? entity = await _dbContext.Cards - .AsNoTracking() - .Where(card => card.SourceDialogId == dialogId && card.SourceMsgId == msgId) - .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) - { - // PrevCol=null — не менять (автоархив тика prev_col не трогает, Ruling 8): условное обновление - // через ExecuteUpdate потребовало бы двух запросов, чтение со слежением проще и точнее. - CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct); - if (entity is null) - { - // Карточки нет — переносить нечего (сервис валидирует существование до вызова, Task 7). - 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) - { - // Полная замена полей классификации одним обновлением (leads.py reclassify_lead L346–367): бюджет без - // валюты и отсутствие конверсии очищают поля (BudgetCur/ConvCur = ""), PrevCol/ArchivedAt не трогаются. - 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) - { - // Три режима leads.py mark_seen L250–256: одна карточка | колонка | все карточки. - 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) - { - // Жёсткое удаление карточки (leads.py _hard_delete L225–234): комментарии чистит каскад БД - // (FK LeadComments → Cards, Ruling 1); журнал CardMoves и MlOutbox не трогаем. Строки дедупа - // карточки (DELETE DedupEntries WHERE LeadId=?) удаляем в той же транзакции (Ruling 3) — иначе - // «сирота» заблокирует повторное создание карточки при перечитывании канала. - 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) - { - // Полная очистка служебной колонки (только trash|archive — валидирует сервис), leads.py clear_col L237–247. - // Дедуп-строки карточек колонки удаляем в той же транзакции (Ruling 3, _hard_delete L229) — очистка - // корзины/архива тоже должна снимать «сирот», иначе текст из удалённой карточки не пройдёт дедуп. - 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) - { - // GROUP BY Cards (col, is_new): ключи — только колонки с карточками (leads.py counts L268–279). - 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.Kanban.Application.Models; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// Карточки канбана — partial-часть (C32: выделено из общего файла, +/// поведение не менялось): чтение списка/поиск/одна карточка, создание из снимка, +/// смена колонки/seen, жёсткое удаление, очистка колонки, счётчики. +/// +public sealed partial class KanbanStore +{ + /// + public async Task> ListCardsAsync(CardsQuery query, CancellationToken ct) + { + IQueryable queryable = _dbContext.Cards.AsNoTracking(); + if (query.Col is null) + { + // Все колонки дашборда, кроме контейнеров-стадий «Выбранных» (этап 9): карточка, взятая + // в работу, живёт в том же пространстве «Выбранных», а не на дашборде. + 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(); + } + + // FTS + LIKE-дополнение одним запросом (Ruling 6): SearchTsv @@ plainto_tsquery('russian') ∪ lower + // LIKE по title/summary/source_msg/contact (search L527–529), контейнеры-стадии «Выбранных» + // исключены, порядок — ts_rank DESC, ReceivedAt DESC, limit 12. plainto_tsquery со стоп-словами даёт + // пустой tsquery — совпадений нет (не ошибка), LIKE ниже всё равно отработает. Всё параметризовано + // (никакой конкатенации пользовательского ввода); LIKE-паттерн — как в отсев-поиске PipelineStore. + 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("SourceMsg") 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( + string dialogId, + long msgId, + CancellationToken ct) + { + if (string.IsNullOrEmpty(dialogId)) + { + return null; + } + + CardEntity? entity = await _dbContext.Cards + .AsNoTracking() + .Where(card => card.SourceDialogId == dialogId && card.SourceMsgId == msgId) + .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) + { + // PrevCol=null — не менять (автоархив тика prev_col не трогает, Ruling 8): условное обновление + // через ExecuteUpdate потребовало бы двух запросов, чтение со слежением проще и точнее. + CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct); + if (entity is null) + { + // Карточки нет — переносить нечего (сервис валидирует существование до вызова, Task 7). + 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) + { + // Полная замена полей классификации одним обновлением (leads.py reclassify_lead L346–367): бюджет без + // валюты и отсутствие конверсии очищают поля (BudgetCur/ConvCur = ""), PrevCol/ArchivedAt не трогаются. + 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) + { + // Три режима leads.py mark_seen L250–256: одна карточка | колонка | все карточки. + 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) + { + // Жёсткое удаление карточки (leads.py _hard_delete L225–234): комментарии чистит каскад БД + // (FK LeadComments → Cards, Ruling 1); журнал CardMoves и MlOutbox не трогаем. Строки дедупа + // карточки (DELETE DedupEntries WHERE LeadId=?) удаляем в той же транзакции (Ruling 3) — иначе + // «сирота» заблокирует повторное создание карточки при перечитывании канала. + 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) + { + // Полная очистка служебной колонки (только trash|archive — валидирует сервис), leads.py clear_col L237–247. + // Дедуп-строки карточек колонки удаляем в той же транзакции (Ruling 3, _hard_delete L229) — очистка + // корзины/архива тоже должна снимать «сирот», иначе текст из удалённой карточки не пройдёт дедуп. + 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) + { + // GROUP BY Cards (col, is_new): ключи — только колонки с карточками (leads.py counts L268–279). + 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 11c9f70..1dc6af4 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Comments.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Comments.cs @@ -1,99 +1,91 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Комментарии и журнал действий — partial-часть (C32: выделено из общего -/// файла, поведение не менялось): LeadComments (список/добавление), CardMoves (запись/счётчик learning) -/// и few-shot-примеры разметки для ИИ (leads.py add_comment/_log_learning/_learning_examples). -/// -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) - { - // CreatedAt — UTC-now (leads.py add_comment L259–265). Целостность ссылки на карточку держит FK: - // комментарий без карточки не запишется (404-семантику несуществующей карточки отдаёт сервис, - // прочитав карточку перед добавлением, — адаптеру возвращать нечего: порт void). - _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) - { - // Каждое действие пользователя пишет строку журнала (leads.py _log_learning L40–44); счётчик - // learning = число записей CardMoves (Ruling 4). CreatedAt — UTC-now. - _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) - { - // Few-shot-примеры ИИ-классификации (pipeline.py _learning_examples L201–215): join журнала с карточками, - // действия move/restore, цель не служебная (trash/archive), source_msg непустой, свежие первыми. - 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.SourceMsg.Length > 0) - .OrderByDescending(joined => joined.move.CreatedAt) - .Take(limit) - .Select(joined => new AiMarkupExampleDto(joined.card.SourceMsg, 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-часть (C32: выделено из общего +/// файла, поведение не менялось): LeadComments (список/добавление), CardMoves (запись/счётчик learning) +/// и few-shot-примеры разметки для ИИ (leads.py add_comment/_log_learning/_learning_examples). +/// +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) + { + // CreatedAt — UTC-now (leads.py add_comment L259–265). Целостность ссылки на карточку держит FK: + // комментарий без карточки не запишется (404-семантику несуществующей карточки отдаёт сервис, + // прочитав карточку перед добавлением, — адаптеру возвращать нечего: порт void). + _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) + { + // Каждое действие пользователя пишет строку журнала (leads.py _log_learning L40–44); счётчик + // learning = число записей CardMoves (Ruling 4). CreatedAt — UTC-now. + _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) + { + // Few-shot-примеры ИИ-классификации (pipeline.py _learning_examples L201–215): join журнала с карточками, + // действия move/restore, цель не служебная (trash/archive), source_msg непустой, свежие первыми. + 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.SourceMsg.Length > 0) + .OrderByDescending(joined => joined.move.CreatedAt) + .Take(limit) + .Select(joined => new AiMarkupExampleDto(joined.card.SourceMsg, 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 6b1900b..5bbe5f0 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Containers.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Containers.cs @@ -1,114 +1,107 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Контейнеры карточек — partial-часть (этап 9, T4): единый реестр -/// колонок/стадий/зон на таблице Containers: список/чтение/создание/полное обновление/удаление с -/// переносом карточек в «Неразобранное»/перестановка позиций. -/// -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-часть (этап 9, T4): единый реестр +/// колонок/стадий/зон на таблице Containers: список/чтение/создание/полное обновление/удаление с +/// переносом карточек в «Неразобранное»/перестановка позиций. +/// +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 5c3bb89..155637c 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Selected.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Selected.cs @@ -1,7 +1,6 @@ using Deal.Infrastructure.Persistence.Entities; using Deal.Modules.Kanban.Application.Models; using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; namespace Deal.Infrastructure.Persistence.Repositories; diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs index c5e418a..94c8f81 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs @@ -1,156 +1,148 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Правила хранения, конверсии и вход эвристики suggest — partial-часть -/// (C32: выделено из общего файла, поведение не менялось): кандидаты тика (автоархив/очистка архива и -/// корзины), batch-архивация и жёсткое удаление пачки, пересчёт бюджетных конверсий и «Неразобранное» -/// с исходным текстом для ИИ-предложений (tick_storage/recompute_conversions/suggest, Rulings 3/7/8). -/// -public sealed partial class KanbanStore -{ - /// - public async Task> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) - { - // Кандидаты автоархива: карточки пользовательских колонок (kind=board) и «Неразобранного» - // (tick_storage L462–467) — колонки динамические, поэтому id колонок читаем реестром контейнеров. - 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) - { - // Автоархив тика пачкой (tick_storage L462–473): один UPDATE вместо N по-карточных — как по-карточный - // UpdateColumnAsync автоархива: col=archive, is_new=false, archived_at=now, matchHits пусто; - // prev_col не трогается (Ruling 8). Возврат — сколько строк обновлено. - 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) - { - // Очистка архива: col='archive' и archived_at старше срока (tick_storage L475–478). - 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) - { - // Очистка корзины: col='trash' и received_at старше срока (tick_storage L480–483). - 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) - { - // Жёсткое удаление пачки (очистки тика и clear-col, Ruling 8): комментарии чистит каскад БД, - // строки дедупа удаляемых карточек — здесь же в транзакции (Ruling 3, _hard_delete L229). - 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) - { - // Карточки с бюджетом вне служебных колонок (recompute_conversions L106–130, Ruling 7): - // пересчёт читает только бюджетные/conv-поля, комментарии не нужны. - 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) - { - // Пишутся только conv-поля карточки (Ruling 7); convCur пуст — конверсия снята. - 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) - { - // Вход эвристики suggest (Ruling 3): «Неразобранное» с непустым source_msg — только тексты нужны. - List entities = await _dbContext.Cards - .AsNoTracking() - .Where(card => card.Col == CardIds.Inbox && card.SourceMsg != 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-часть +/// (C32: выделено из общего файла, поведение не менялось): кандидаты тика (автоархив/очистка архива и +/// корзины), batch-архивация и жёсткое удаление пачки, пересчёт бюджетных конверсий и «Неразобранное» +/// с исходным текстом для ИИ-предложений (tick_storage/recompute_conversions/suggest, Rulings 3/7/8). +/// +public sealed partial class KanbanStore +{ + /// + public async Task> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) + { + // Кандидаты автоархива: карточки пользовательских колонок (kind=board) и «Неразобранного» + // (tick_storage L462–467) — колонки динамические, поэтому id колонок читаем реестром контейнеров. + 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) + { + // Автоархив тика пачкой (tick_storage L462–473): один UPDATE вместо N по-карточных — как по-карточный + // UpdateColumnAsync автоархива: col=archive, is_new=false, archived_at=now, matchHits пусто; + // prev_col не трогается (Ruling 8). Возврат — сколько строк обновлено. + 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) + { + // Очистка архива: col='archive' и archived_at старше срока (tick_storage L475–478). + 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) + { + // Очистка корзины: col='trash' и received_at старше срока (tick_storage L480–483). + 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) + { + // Жёсткое удаление пачки (очистки тика и clear-col, Ruling 8): комментарии чистит каскад БД, + // строки дедупа удаляемых карточек — здесь же в транзакции (Ruling 3, _hard_delete L229). + 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) + { + // Карточки с бюджетом вне служебных колонок (recompute_conversions L106–130, Ruling 7): + // пересчёт читает только бюджетные/conv-поля, комментарии не нужны. + 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) + { + // Пишутся только conv-поля карточки (Ruling 7); convCur пуст — конверсия снята. + 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) + { + // Вход эвристики suggest (Ruling 3): «Неразобранное» с непустым source_msg — только тексты нужны. + List entities = await _dbContext.Cards + .AsNoTracking() + .Where(card => card.Col == CardIds.Inbox && card.SourceMsg != 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 4eb53d4..4afced7 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs @@ -1,324 +1,319 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища карточек и контейнеров (Ruling 1/12): таблицы Cards/Containers/LeadComments/CardMoves схемы тенанта. -/// -/// -/// Маппинг DTO ↔ строк выполняется вручную (эталон SettingsStore.cs): порт модуля не видит EF-сущности. -/// JSON-поля (KeywordsJson/VisibleFieldsJson/RulesJson, StackJson/ContactsJson/MatchHitsJson) хранятся текстом -/// с сериализованным JSON camelCase (конвенция value_json) — адаптер сериализует при записи и разбирает при чтении. -/// Времена — timestamptz (DateTimeOffset); наружу карточки отдают receivedAt epoch-ms (ToUnixTimeMilliseconds), -/// а human-метку «time» адаптер считает на лету от ReceivedAt/CreatedAt (Ruling 10, pipeline.py human_age L528–537). -/// Чтения — AsNoTracking; сортировки (received_at DESC и т.п.) — здесь, как требует порт. Id записей генерирует -/// модуль (Ruling 12) — адаптер только сохраняет готовые id. Транзакции: одиночная запись — SaveChanges -/// (ExecuteUpdate/ExecuteDelete — одним statement'ом); методы с несколькими изменениями (DeleteContainerAsync: -/// карточки + доска; ReorderContainersAsync: позиции всех досок) обёрнуты в явную транзакцию. -/// Удаление карточки чистит комментарии каскадом БД (FK LeadComments → Cards, Ruling 1); журнал CardMoves -/// и MlOutbox при удалении карточек не трогаются (прототип _hard_delete). Строки DedupEntries карточки -/// (LeadId = id карточки) удаляются вместе с ней в той же транзакции (Ruling 3) — «сирота» не должна -/// блокировать повторное создание карточки; таблицы соседние в том же TenantDbContext, порт Pipeline -/// не привлекается (циклов модулей нет). -/// C32: класс разделён на partial-файлы по темам (KanbanStore.Containers/Cards/Selected/Comments/StorageRules.cs); -/// поведение, сигнатуры и тексты ошибок не менялись. -/// -public sealed partial class KanbanStore : ICardStore -{ - private readonly TenantDbContext _dbContext; - - /// - /// Создаёт EF-адаптер хранилища канбана (зависимости — tenant-контекст БД). - /// - /// Контекст схемы тенанта (Containers/Cards/LeadComments/CardMoves/DedupEntries). - public KanbanStore(TenantDbContext dbContext) - { - _dbContext = dbContext; - } - - // JSON-представление «правил нет» (в DTO — null Rules, Ruling 1: как value_json настроек). - private const string NoRulesJson = "{}"; - - // Действие журнала CardMoves «перенос» (источник few-shot-примеров, python move). - private const string MoveAction = "move"; - - // Действие журнала CardMoves «возврат из корзины/архива» (python restore). - private const string RestoreAction = "restore"; - - private const long MillisPerMinute = 60_000L; - private const long MinutesPerHour = 60L; - private const long HoursPerDay = 24L; - - // Колонки, исключённые из пересчёта конверсий (recompute_conversions L106–130, Ruling 7). - private static readonly string[] ConversionExcludedCols = - [CardIds.Archive, CardIds.Trash]; - - // Id контейнеров-стадий пространства «Выбранные»: дашборд эти карточки не показывает (этап 9). - private static readonly string[] SelectedStageIds = CardsDefaultContainers.Ids.ToArray(); - - // Стадия «Отложено» — единственная, чьи напоминания проверяет проверка напоминаний (Ruling 3). - private const string HoldStage = CardsDefaultContainers.Hold; - - // Опции JSON: camelCase для записей модуля Kanban (1:1 с wire-именами §4.1/§4.2). - 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 = new CardSourceDto( - Kind: ResolveSourceKind(entity), - DisplayName: entity.ChannelName, - OriginRef: entity.SourceDialogId, - ReceivedAt: entity.ReceivedAt.ToUnixTimeMilliseconds()), - 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), - Channel = new CardChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue), - Time = HumanAge(entity.ReceivedAt), - ReceivedAtMs = entity.ReceivedAt.ToUnixTimeMilliseconds(), - SourceMsg = entity.SourceMsg, - SourceDialogId = entity.SourceDialogId, - SourceMsgId = entity.SourceMsgId, - 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(), - }; - - // Вид источника карточки (производная проекция): local/telegram/other. - // entity: Строка карточки. - // Возвращает: Вид источника (строка wire-контракта). - private static string ResolveSourceKind(CardEntity entity) - { - if (entity.Local) - { - return "local"; - } - - return entity.ChannelName.Length > 0 || entity.SourceDialogId.Length > 0 ? "telegram" : "other"; - } - - // Маппинг снимка (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), - ChannelName = snapshot.ChannelName, - ChannelHandle = snapshot.ChannelHandle, - ChannelHue = snapshot.ChannelHue, - ReceivedAt = snapshot.ReceivedAt, - SourceMsg = snapshot.SourceMsg, - SourceDialogId = snapshot.SourceDialogId, - SourceMsgId = snapshot.SourceMsgId, - PrevCol = snapshot.PrevCol, - ArchivedAt = snapshot.ArchivedAt, - MatchHitsJson = ToJson(snapshot.MatchHits), - TzText = snapshot.TzText, - HistoryJson = ToJson(snapshot.History), - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow, - }; - - // Маппинг строки LeadComments в DTO (human-метка time от CreatedAt, Ruling 10). - private static CardCommentDto ToCommentDto(LeadCommentEntity entity) => - new(entity.Id, entity.By, entity.Text, HumanAge(entity.CreatedAt)); - - // Человеческая метка возраста: «только что»/«N мин»/«N ч»/«N дн» (Ruling 10, pipeline.py human_age L528–537). - // timestamp: Время события (получения сообщения/добавления комментария). - // Возвращает: Метка от текущего UTC-момента; будущее время даёт «только что» (delta зажат в 0, как в прототипе). - 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} дн"; - } - - // Разбирает JSON-массив в список; пустая/битая строка — пустой список (как json.loads в прототипе). - 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.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер хранилища карточек и контейнеров (Ruling 1/12): таблицы Cards/Containers/LeadComments/CardMoves схемы тенанта. +/// +/// +/// Маппинг DTO ↔ строк выполняется вручную (эталон SettingsStore.cs): порт модуля не видит EF-сущности. +/// JSON-поля (KeywordsJson/VisibleFieldsJson/RulesJson, StackJson/ContactsJson/MatchHitsJson) хранятся текстом +/// с сериализованным JSON camelCase (конвенция value_json) — адаптер сериализует при записи и разбирает при чтении. +/// Времена — timestamptz (DateTimeOffset); наружу карточки отдают receivedAt epoch-ms (ToUnixTimeMilliseconds), +/// а human-метку «time» адаптер считает на лету от ReceivedAt/CreatedAt (Ruling 10, pipeline.py human_age L528–537). +/// Чтения — AsNoTracking; сортировки (received_at DESC и т.п.) — здесь, как требует порт. Id записей генерирует +/// модуль (Ruling 12) — адаптер только сохраняет готовые id. Транзакции: одиночная запись — SaveChanges +/// (ExecuteUpdate/ExecuteDelete — одним statement'ом); методы с несколькими изменениями (DeleteContainerAsync: +/// карточки + доска; ReorderContainersAsync: позиции всех досок) обёрнуты в явную транзакцию. +/// Удаление карточки чистит комментарии каскадом БД (FK LeadComments → Cards, Ruling 1); журнал CardMoves +/// и MlOutbox при удалении карточек не трогаются (прототип _hard_delete). Строки DedupEntries карточки +/// (LeadId = id карточки) удаляются вместе с ней в той же транзакции (Ruling 3) — «сирота» не должна +/// блокировать повторное создание карточки; таблицы соседние в том же TenantDbContext, порт Pipeline +/// не привлекается (циклов модулей нет). +/// C32: класс разделён на partial-файлы по темам (KanbanStore.Containers/Cards/Selected/Comments/StorageRules.cs); +/// поведение, сигнатуры и тексты ошибок не менялись. +/// +public sealed partial class KanbanStore : ICardStore +{ + private readonly TenantDbContext _dbContext; + + /// + /// Создаёт EF-адаптер хранилища канбана (зависимости — tenant-контекст БД). + /// + /// Контекст схемы тенанта (Containers/Cards/LeadComments/CardMoves/DedupEntries). + public KanbanStore(TenantDbContext dbContext) + { + _dbContext = dbContext; + } + + // JSON-представление «правил нет» (в DTO — null Rules, Ruling 1: как value_json настроек). + private const string NoRulesJson = "{}"; + + // Действие журнала CardMoves «перенос» (источник few-shot-примеров, python move). + private const string MoveAction = "move"; + + // Действие журнала CardMoves «возврат из корзины/архива» (python restore). + private const string RestoreAction = "restore"; + + private const long MillisPerMinute = 60_000L; + private const long MinutesPerHour = 60L; + private const long HoursPerDay = 24L; + + // Колонки, исключённые из пересчёта конверсий (recompute_conversions L106–130, Ruling 7). + private static readonly string[] ConversionExcludedCols = + [CardIds.Archive, CardIds.Trash]; + + // Id контейнеров-стадий пространства «Выбранные»: дашборд эти карточки не показывает (этап 9). + private static readonly string[] SelectedStageIds = CardsDefaultContainers.Ids.ToArray(); + + // Стадия «Отложено» — единственная, чьи напоминания проверяет проверка напоминаний (Ruling 3). + private const string HoldStage = CardsDefaultContainers.Hold; + + // Опции JSON: camelCase для записей модуля Kanban (1:1 с wire-именами §4.1/§4.2). + 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 = new CardSourceDto( + Kind: ResolveSourceKind(entity), + DisplayName: entity.ChannelName, + OriginRef: entity.SourceDialogId, + ReceivedAt: entity.ReceivedAt.ToUnixTimeMilliseconds()), + 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), + Channel = new CardChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue), + Time = HumanAge(entity.ReceivedAt), + ReceivedAtMs = entity.ReceivedAt.ToUnixTimeMilliseconds(), + SourceMsg = entity.SourceMsg, + SourceDialogId = entity.SourceDialogId, + SourceMsgId = entity.SourceMsgId, + 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(), + }; + + // Вид источника карточки (производная проекция): local/telegram/other. + // entity: Строка карточки. + // Возвращает: Вид источника (строка wire-контракта). + private static string ResolveSourceKind(CardEntity entity) + { + if (entity.Local) + { + return "local"; + } + + return entity.ChannelName.Length > 0 || entity.SourceDialogId.Length > 0 ? "telegram" : "other"; + } + + // Маппинг снимка (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), + ChannelName = snapshot.ChannelName, + ChannelHandle = snapshot.ChannelHandle, + ChannelHue = snapshot.ChannelHue, + ReceivedAt = snapshot.ReceivedAt, + SourceMsg = snapshot.SourceMsg, + SourceDialogId = snapshot.SourceDialogId, + SourceMsgId = snapshot.SourceMsgId, + PrevCol = snapshot.PrevCol, + ArchivedAt = snapshot.ArchivedAt, + MatchHitsJson = ToJson(snapshot.MatchHits), + TzText = snapshot.TzText, + HistoryJson = ToJson(snapshot.History), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + }; + + // Маппинг строки LeadComments в DTO (human-метка time от CreatedAt, Ruling 10). + private static CardCommentDto ToCommentDto(LeadCommentEntity entity) => + new(entity.Id, entity.By, entity.Text, HumanAge(entity.CreatedAt)); + + // Человеческая метка возраста: «только что»/«N мин»/«N ч»/«N дн» (Ruling 10, pipeline.py human_age L528–537). + // timestamp: Время события (получения сообщения/добавления комментария). + // Возвращает: Метка от текущего UTC-момента; будущее время даёт «только что» (delta зажат в 0, как в прототипе). + 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} дн"; + } + + // Разбирает JSON-массив в список; пустая/битая строка — пустой список (как json.loads в прототипе). + 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 7263939..1c26786 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/MlLearningStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/MlLearningStore.cs @@ -1,78 +1,74 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища обучения ML (Ruling 4, Task 5): очередь MlOutbox + журнал CardMoves схемы тенанта. -/// -/// -/// Реализация порта на (эталон -/// KanbanStore). Порт потребляет интеграционный адаптер LocalMlClient, чтобы тот -/// оставался unit-чистым (план Task 5). Id строки outbox приходят готовыми (Ruling 12); CreatedAt -/// проставляется здесь (UTC-now, как в AddMoveAsync). Одиночная запись — SaveChanges; полная очистка -/// очереди — ExecuteDelete одним statement'ом (reset_model L122); журнал CardMoves не трогается. -/// -/// 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) - { - // Порция в порядке created_at, без удаления (1:1 выборка python flush_outbox L66–69): удаление — - // отдельный шаг после успеха TrainBatch (Ruling 6). - 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; - } - - // Удаление отправленных строк одним statement'ом (аналог DELETE … WHERE id IN python L79). - 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 (Ruling 4, Task 5): очередь MlOutbox + журнал CardMoves схемы тенанта. +/// +/// +/// Реализация порта на (эталон +/// KanbanStore). Порт потребляет интеграционный адаптер LocalMlClient, чтобы тот +/// оставался unit-чистым (план Task 5). Id строки outbox приходят готовыми (Ruling 12); CreatedAt +/// проставляется здесь (UTC-now, как в AddMoveAsync). Одиночная запись — SaveChanges; полная очистка +/// очереди — ExecuteDelete одним statement'ом (reset_model L122); журнал CardMoves не трогается. +/// +/// 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) + { + // Порция в порядке created_at, без удаления (1:1 выборка python flush_outbox L66–69): удаление — + // отдельный шаг после успеха TrainBatch (Ruling 6). + 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; + } + + // Удаление отправленных строк одним statement'ом (аналог DELETE … WHERE id IN python L79). + 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 29085e5..360700e 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/OperatorAuthStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/OperatorAuthStore.cs @@ -1,87 +1,83 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища аутентификации оператора: таблицы public.operators и public.operator_sessions (Ruling 1). -/// -/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). -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.operators и public.operator_sessions (Ruling 1). +/// +/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). +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 8309aaf..9b9da79 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs @@ -1,382 +1,375 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища пайплайна (Rulings 1/10): таблицы QueueItems/RejectedItems/DedupEntries схемы тенанта. -/// -/// -/// Маппинг DTO ↔ строк выполняется вручную (эталон SettingsStore.cs/KanbanStore.cs): порт модуля не видит -/// EF-сущности; подписи stageLabel/sourceLabel считаются словарями -/// (processing.stage_label/source_label L40–45). JSON-полей в таблицах нет (Ruling 1(а)) — все колонки -/// плоские; SearchTsv (tsvector) — вычисляемая STORED-колонка БД (Ruling 6), адаптер читает её в поиске. -/// Времена — timestamptz (DateTimeOffset); наружу строки очереди/отсева отдают epoch-ms -/// (ToUnixTimeMilliseconds), обратно — FromUnixTimeMilliseconds. Чтения — AsNoTracking; сортировки -/// (CreatedAt ASC у очереди, RejectedAt DESC у отсева) и лимиты — здесь, как требует порт. -/// Id строк очереди (p_) генерирует модуль (Ruling 2); id отсева — детерминированный -/// r_<dialog>_<msgId> из RejectRecord либо r_+hex (общий PrefixId модуля Kanban), -/// как processing.record L77. Upsert/claim на ON CONFLICT и FTS-поиск с plainto_tsquery требуют raw SQL — -/// ExecuteSqlInterpolatedAsync/FromSqlInterpolated. Транзакции: все методы — одиночные statement'ы -/// (SaveChanges/ExecuteUpdate/ExecuteDelete/ExecuteSql), явных транзакций не нужно; чистку DedupEntries -/// при удалении карточки выполняет KanbanStore тем же TenantDbContext (Ruling 3) — циклов модулей нет. -/// -public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore -{ - // ── Очередь (QueueItems) ─────────────────────────────────────────────── - - /// - public Task ExistsDuplicateAsync( - string dialogId, - long? msgId, - CancellationToken ct) - { - // Дубль-гвард приёма (Ruling 2, enqueue L70–77): msgId null — проверять нечего. - return msgId is null - ? Task.FromResult(false) - : dbContext.QueueItems.AnyAsync(item => item.DialogId == dialogId && item.MsgId == msgId, 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) - { - // Безвозвратное удаление строки очереди на любом этапе отсева (pipeline.py _drop_row L809–813). - await dbContext.QueueItems - .Where(item => item.Id == id) - .ExecuteDeleteAsync(ct); - } - - // ── Отсев (RejectedItems) ────────────────────────────────────────────── - - /// - public async Task UpsertAsync(RejectRecord record, CancellationToken ct) - { - // Пустой/пробельный текст — no-op (processing.record L73–74). - if (string.IsNullOrWhiteSpace(record.Text)) - { - return; - } - - string id = record.DeterministicId ?? PrefixId.New(PipelineIdPrefixes.Rejected); - DateTimeOffset rejectedAt = DateTimeOffset.UtcNow; - - // Upsert по детерминированному id: повторное отбрасывание того же сообщения (dialog+msgId) - // обновляет запись, а не копит дубликаты (processing.record L79–101). SearchTsv — вычисляемая - // колонка (Ruling 6), в INSERT не входит; Returned/ReturnReason пишем явно false/'' (дефолтов БД - // нет — прототип полагался на дефолты SQLite). ON CONFLICT сбрасывает только поля прототипа: - // аудит возврата (returned/returnedAt/returnReason) при повторном отсеве переживает. - await dbContext.Database.ExecuteSqlInterpolatedAsync( - $""" - INSERT INTO "RejectedItems" ("Id", "DialogId", "MsgId", "ChannelName", "ChannelHandle", "ChannelHue", "Text", "Stage", "Reason", "Kw", "Source", "MsgAt", "RejectedAt", "Returned", "ReturnReason") - VALUES ({id}, {record.DialogId}, {record.MsgId}, {record.ChannelName}, {record.ChannelHandle}, {record.ChannelHue}, {record.Text}, {record.Stage}, {record.Reason}, {record.Kw}, {record.Source}, {FromEpochMs(record.MsgAtMs)}, {rejectedAt}, false, {string.Empty}) - ON CONFLICT ("Id") DO UPDATE SET - "Text" = excluded."Text", - "ChannelName" = excluded."ChannelName", - "ChannelHandle" = excluded."ChannelHandle", - "ChannelHue" = excluded."ChannelHue", - "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(); - } - - // FTS-кандидаты: SearchTsv @@ plainto_tsquery('russian', q), релевантность ts_rank DESC (Ruling 6). - // plainto_tsquery со стоп-словами даёт пустой tsquery — совпадений нет (не ошибка), LIKE ниже всё - // равно отработает (как связка fts_svc + like в processing.list_rejected L252–270). - 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); - - // LIKE-дополнение по lower(text)/reason/kw/ch_name (processing L259–265): свежие записи после - // последнего перестроения вектора и слова, которых нет в словаре морфологии. limitLike — как - // limit*2 прототипа (сверху ограничивает сервис). - 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) - || EF.Functions.Like(item.ChannelName.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) - { - // Удаление одной записи отсева безвозвратно (processing.delete_one L196–198). - await dbContext.RejectedItems - .Where(item => item.Id == id) - .ExecuteDeleteAsync(ct); - } - - /// - public async Task ClearAsync(CancellationToken ct) - { - // Полная очистка отсева (processing.clear_all L120–125); возврат — сколько удалено. - return await dbContext.RejectedItems.ExecuteDeleteAsync(ct); - } - - /// - public async Task PurgeExpiredAsync(DateTimeOffset olderThan, CancellationToken ct) - { - // Автоочистка: записи с RejectedAt старше срока хранения (processing.purge_expired L104–117). - return await dbContext.RejectedItems - .Where(item => item.RejectedAt < olderThan) - .ExecuteDeleteAsync(ct); - } - - /// - public async Task MarkReturnedAsync( - string id, - string reason, - DateTimeOffset returnedAt, - CancellationToken ct) - { - // Возврат из отсева: аудит-поля записи, строка не удаляется (return_to_queue L156–159). - 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) - { - // Заявка хэша за обрабатываемым сообщением: LeadId=null (Ruling 8, L947–949). ON CONFLICT DO - // NOTHING — параллельные дубли одного текста не проходят (сырой SQL: EF-вставка упала бы на PK). - // Число вставленных строк — результат: 1 = заявка за этим вызовом, 0 = хэш уже заявлен другим - // проходом pump (воркер не создаёт вторую карточку, Ruling 8). - 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) - { - // Снятие незанятой заявки (pipeline.py _drop_row L810–812): строки, уже связанные с карточкой - // (LeadId IS NOT NULL), не трогаем — иначе повторный текст прошёл бы дедуп после отсева. - await dbContext.DedupEntries - .Where(entry => entry.Hash == hash && entry.LeadId == null) - .ExecuteDeleteAsync(ct); - } - - /// - public async Task LinkAsync( - string hash, - string cardId, - CancellationToken ct) - { - // Связь заявки с созданной карточкой (Ruling 4, порядок AddCard → LinkDedup L512–513). - 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) - { - // Чистка «мягких» ссылок при жёстком удалении карточки (Ruling 3). В KanbanStore-удалениях - // дублируется тем же DELETE (см. KanbanStore.DeleteForeverAsync/PurgeAsync/ClearColAsync) — - // чтобы Kanban-адаптер не зависел от порта Pipeline; этот метод — для потребителей модуля. - await dbContext.DedupEntries - .Where(entry => entry.LeadId == cardId) - .ExecuteDeleteAsync(ct); - } - - // ── Маппинг (вручную: порт не видит EF-сущности) ─────────────────────── - - // Строка QueueItems → DTO очереди (времена — epoch-ms наружу, Ruling 1). - private static QueueItemDto ToQueueItemDto(QueueItemEntity entity) => new() - { - Id = entity.Id, - DialogId = entity.DialogId, - MsgId = entity.MsgId, - Text = entity.Text, - Status = entity.Status, - Channel = new PipelineChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue), - 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, - DialogId = item.DialogId, - MsgId = item.MsgId, - Text = item.Text, - Status = item.Status, - ChannelName = item.Channel.Name, - ChannelHandle = item.Channel.Handle, - ChannelHue = item.Channel.Hue, - MsgAt = FromEpochMs(item.MsgAtMs), - CreatedAt = queuedAt, - UpdatedAt = queuedAt, - Force = item.Force, - }; - } - - // Строка RejectedItems → DTO отсева (подписи этапа/источника — словари модуля Pipeline). - private static RejectedItemDto ToRejectedItemDto(RejectedItemEntity entity) => new() - { - Id = entity.Id, - DialogId = entity.DialogId, - MsgId = entity.MsgId, - Text = entity.Text, - Stage = entity.Stage, - StageLabel = PipelineRejectConstants.StageLabel(entity.Stage), - Reason = entity.Reason, - Kw = entity.Kw, - Source = entity.Source, - SourceLabel = PipelineRejectConstants.SourceLabel(entity.Source), - Channel = new PipelineChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue), - 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); -} +using Deal.Infrastructure.Persistence.Entities; +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-адаптер хранилища пайплайна (Rulings 1/10): таблицы QueueItems/RejectedItems/DedupEntries схемы тенанта. +/// +/// +/// Маппинг DTO ↔ строк выполняется вручную (эталон SettingsStore.cs/KanbanStore.cs): порт модуля не видит +/// EF-сущности; подписи stageLabel/sourceLabel считаются словарями +/// (processing.stage_label/source_label L40–45). JSON-полей в таблицах нет (Ruling 1(а)) — все колонки +/// плоские; SearchTsv (tsvector) — вычисляемая STORED-колонка БД (Ruling 6), адаптер читает её в поиске. +/// Времена — timestamptz (DateTimeOffset); наружу строки очереди/отсева отдают epoch-ms +/// (ToUnixTimeMilliseconds), обратно — FromUnixTimeMilliseconds. Чтения — AsNoTracking; сортировки +/// (CreatedAt ASC у очереди, RejectedAt DESC у отсева) и лимиты — здесь, как требует порт. +/// Id строк очереди (p_) генерирует модуль (Ruling 2); id отсева — детерминированный +/// r_<dialog>_<msgId> из RejectRecord либо r_+hex (общий PrefixId модуля Kanban), +/// как processing.record L77. Upsert/claim на ON CONFLICT и FTS-поиск с plainto_tsquery требуют raw SQL — +/// ExecuteSqlInterpolatedAsync/FromSqlInterpolated. Транзакции: все методы — одиночные statement'ы +/// (SaveChanges/ExecuteUpdate/ExecuteDelete/ExecuteSql), явных транзакций не нужно; чистку DedupEntries +/// при удалении карточки выполняет KanbanStore тем же TenantDbContext (Ruling 3) — циклов модулей нет. +/// +public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore +{ + // ── Очередь (QueueItems) ─────────────────────────────────────────────── + + /// + public Task ExistsDuplicateAsync( + string dialogId, + long? msgId, + CancellationToken ct) + { + // Дубль-гвард приёма (Ruling 2, enqueue L70–77): msgId null — проверять нечего. + return msgId is null + ? Task.FromResult(false) + : dbContext.QueueItems.AnyAsync(item => item.DialogId == dialogId && item.MsgId == msgId, 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) + { + // Безвозвратное удаление строки очереди на любом этапе отсева (pipeline.py _drop_row L809–813). + await dbContext.QueueItems + .Where(item => item.Id == id) + .ExecuteDeleteAsync(ct); + } + + // ── Отсев (RejectedItems) ────────────────────────────────────────────── + + /// + public async Task UpsertAsync(RejectRecord record, CancellationToken ct) + { + // Пустой/пробельный текст — no-op (processing.record L73–74). + if (string.IsNullOrWhiteSpace(record.Text)) + { + return; + } + + string id = record.DeterministicId ?? PrefixId.New(PipelineIdPrefixes.Rejected); + DateTimeOffset rejectedAt = DateTimeOffset.UtcNow; + + // Upsert по детерминированному id: повторное отбрасывание того же сообщения (dialog+msgId) + // обновляет запись, а не копит дубликаты (processing.record L79–101). SearchTsv — вычисляемая + // колонка (Ruling 6), в INSERT не входит; Returned/ReturnReason пишем явно false/'' (дефолтов БД + // нет — прототип полагался на дефолты SQLite). ON CONFLICT сбрасывает только поля прототипа: + // аудит возврата (returned/returnedAt/returnReason) при повторном отсеве переживает. + await dbContext.Database.ExecuteSqlInterpolatedAsync( + $""" + INSERT INTO "RejectedItems" ("Id", "DialogId", "MsgId", "ChannelName", "ChannelHandle", "ChannelHue", "Text", "Stage", "Reason", "Kw", "Source", "MsgAt", "RejectedAt", "Returned", "ReturnReason") + VALUES ({id}, {record.DialogId}, {record.MsgId}, {record.ChannelName}, {record.ChannelHandle}, {record.ChannelHue}, {record.Text}, {record.Stage}, {record.Reason}, {record.Kw}, {record.Source}, {FromEpochMs(record.MsgAtMs)}, {rejectedAt}, false, {string.Empty}) + ON CONFLICT ("Id") DO UPDATE SET + "Text" = excluded."Text", + "ChannelName" = excluded."ChannelName", + "ChannelHandle" = excluded."ChannelHandle", + "ChannelHue" = excluded."ChannelHue", + "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(); + } + + // FTS-кандидаты: SearchTsv @@ plainto_tsquery('russian', q), релевантность ts_rank DESC (Ruling 6). + // plainto_tsquery со стоп-словами даёт пустой tsquery — совпадений нет (не ошибка), LIKE ниже всё + // равно отработает (как связка fts_svc + like в processing.list_rejected L252–270). + 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); + + // LIKE-дополнение по lower(text)/reason/kw/ch_name (processing L259–265): свежие записи после + // последнего перестроения вектора и слова, которых нет в словаре морфологии. limitLike — как + // limit*2 прототипа (сверху ограничивает сервис). + 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) + || EF.Functions.Like(item.ChannelName.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) + { + // Удаление одной записи отсева безвозвратно (processing.delete_one L196–198). + await dbContext.RejectedItems + .Where(item => item.Id == id) + .ExecuteDeleteAsync(ct); + } + + /// + public async Task ClearAsync(CancellationToken ct) + { + // Полная очистка отсева (processing.clear_all L120–125); возврат — сколько удалено. + return await dbContext.RejectedItems.ExecuteDeleteAsync(ct); + } + + /// + public async Task PurgeExpiredAsync(DateTimeOffset olderThan, CancellationToken ct) + { + // Автоочистка: записи с RejectedAt старше срока хранения (processing.purge_expired L104–117). + return await dbContext.RejectedItems + .Where(item => item.RejectedAt < olderThan) + .ExecuteDeleteAsync(ct); + } + + /// + public async Task MarkReturnedAsync( + string id, + string reason, + DateTimeOffset returnedAt, + CancellationToken ct) + { + // Возврат из отсева: аудит-поля записи, строка не удаляется (return_to_queue L156–159). + 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) + { + // Заявка хэша за обрабатываемым сообщением: LeadId=null (Ruling 8, L947–949). ON CONFLICT DO + // NOTHING — параллельные дубли одного текста не проходят (сырой SQL: EF-вставка упала бы на PK). + // Число вставленных строк — результат: 1 = заявка за этим вызовом, 0 = хэш уже заявлен другим + // проходом pump (воркер не создаёт вторую карточку, Ruling 8). + 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) + { + // Снятие незанятой заявки (pipeline.py _drop_row L810–812): строки, уже связанные с карточкой + // (LeadId IS NOT NULL), не трогаем — иначе повторный текст прошёл бы дедуп после отсева. + await dbContext.DedupEntries + .Where(entry => entry.Hash == hash && entry.LeadId == null) + .ExecuteDeleteAsync(ct); + } + + /// + public async Task LinkAsync( + string hash, + string cardId, + CancellationToken ct) + { + // Связь заявки с созданной карточкой (Ruling 4, порядок AddCard → LinkDedup L512–513). + 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) + { + // Чистка «мягких» ссылок при жёстком удалении карточки (Ruling 3). В KanbanStore-удалениях + // дублируется тем же DELETE (см. KanbanStore.DeleteForeverAsync/PurgeAsync/ClearColAsync) — + // чтобы Kanban-адаптер не зависел от порта Pipeline; этот метод — для потребителей модуля. + await dbContext.DedupEntries + .Where(entry => entry.LeadId == cardId) + .ExecuteDeleteAsync(ct); + } + + // ── Маппинг (вручную: порт не видит EF-сущности) ─────────────────────── + + // Строка QueueItems → DTO очереди (времена — epoch-ms наружу, Ruling 1). + private static QueueItemDto ToQueueItemDto(QueueItemEntity entity) => new() + { + Id = entity.Id, + DialogId = entity.DialogId, + MsgId = entity.MsgId, + Text = entity.Text, + Status = entity.Status, + Channel = new PipelineChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue), + 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, + DialogId = item.DialogId, + MsgId = item.MsgId, + Text = item.Text, + Status = item.Status, + ChannelName = item.Channel.Name, + ChannelHandle = item.Channel.Handle, + ChannelHue = item.Channel.Hue, + MsgAt = FromEpochMs(item.MsgAtMs), + CreatedAt = queuedAt, + UpdatedAt = queuedAt, + Force = item.Force, + }; + } + + // Строка RejectedItems → DTO отсева (подписи этапа/источника — словари модуля Pipeline). + private static RejectedItemDto ToRejectedItemDto(RejectedItemEntity entity) => new() + { + Id = entity.Id, + DialogId = entity.DialogId, + MsgId = entity.MsgId, + Text = entity.Text, + Stage = entity.Stage, + StageLabel = PipelineRejectConstants.StageLabel(entity.Stage), + Reason = entity.Reason, + Kw = entity.Kw, + Source = entity.Source, + SourceLabel = PipelineRejectConstants.SourceLabel(entity.Source), + Channel = new PipelineChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue), + 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); +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs index 7265d3e..adf6dc0 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs @@ -3,12 +3,7 @@ using System.Data.Common; using System.Globalization; using Deal.Infrastructure.Persistence.Entities; using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; namespace Deal.Infrastructure.Persistence.Repositories; diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs index be3e83f..0cc450c 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs @@ -1,81 +1,78 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер KV-хранилища настроек тенанта: таблица settings (Ruling 1). -/// -/// -/// Маппинг 1:1 со строкой таблицы: key/value_json/updated_at (сущность TenantSettingEntity, -/// бессхемная модель TenantDbContext — таблица живёт в схеме тенанта через search_path). -/// Порт оперирует JSON-строками (сериализацию выполняет модуль Settings), поэтому адаптер -/// хранит value_json как текст без интерпретации. Маппинг DTO ↔ сущности выполняется вручную: -/// порт модуля не видит EF-сущности. пишет updated_at = UTC-now. -/// -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-хранилища настроек тенанта: таблица settings (Ruling 1). +/// +/// +/// Маппинг 1:1 со строкой таблицы: key/value_json/updated_at (сущность TenantSettingEntity, +/// бессхемная модель TenantDbContext — таблица живёт в схеме тенанта через search_path). +/// Порт оперирует JSON-строками (сериализацию выполняет модуль Settings), поэтому адаптер +/// хранит value_json как текст без интерпретации. Маппинг DTO ↔ сущности выполняется вручную: +/// порт модуля не видит EF-сущности. пишет updated_at = UTC-now. +/// +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 b47f0eb..6926927 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/TelegramStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/TelegramStore.cs @@ -1,263 +1,262 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Telegram.Application.Models; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища каталога диалогов: таблицы Dialogs/TgMessages схемы тенанта (Ruling 7, Task 13). -/// -/// -/// Реализация порта на (эталон KanbanStore/ -/// PipelineStore). Семантика 1:1 с python telegram.py/db.py: upsert каталога (ON CONFLICT DO UPDATE метаданных, -/// монитор только у новых — _persist_dialogs L484–490), удаление отсутствующих (L491–500), список «monitor DESC, -/// name» (L522), UPDATE monitor/backfilled, превью INSERT OR IGNORE (L602–605). Batch-операции — ExecuteUpdate -/// одним statement'ом; синхронизация каталога — один SaveChanges (порядок как python: upsert → delete). -/// -/// Scoped-контекст тенанта запроса (search_path). -public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore -{ - /// - public async Task SyncFromTelegramAsync( - IReadOnlyCollection entries, - bool autoMonitorNew, - CancellationToken ct) - { - if (entries.Count == 0) - { - // python L477–478: пустой каталог — возврат 0 без записи в БД. - return 0; - } - - // Существующие строки каталога — обновляем метаданные, монитор пользователя не трогаем (python L487–489). - 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 - { - // Новый диалог: монитор — по настройке autoMonitorNew (python L489), остальное — дефолты строки. - dbContext.Dialogs.Add(new DialogEntity - { - Id = entry.Id, - Name = entry.Name, - Handle = entry.Handle, - Kind = entry.Kind, - Hue = entry.Hue, - Monitor = autoMonitorNew, - UpdatedAt = now, - }); - } - } - - // Диалоги, которых больше нет в каталоге Telegram (вышел/удалил), удаляются (python L491–500). - 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) - { - // Список вкладки «Каналы»: включённые первыми, далее по имени (python list_dialogs L522). - 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) - { - // python backfill_dialog L387: только флаг, updated_at не трогаем (разбор — служебная операция). - 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) - { - // 1:1 python add_dialog_monitored L850–873 (INSERT/ON CONFLICT DO UPDATE): строка каталога после - // discovery-вступления — метаданные источника, monitor=TRUE, backfilled=FALSE (разбор подхватит - // первый фоновый Backfill). Значения нормализованы вызывающим (DialogsService). - 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) - { - // INSERT OR IGNORE python L602–605: первое вхождение сообщения не перезаписывается. - 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) - { - // python _on_message L270–273: last_text/last_at/updated_at одним моментом приёма. - 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(); - } - - // Маппит строку каталога в DTO списка каналов (list_dialogs L523–533). - // row: Строка Dialogs. - // Возвращает: Форма §4.8: type = kind (EN-канон), on = monitor, last {text, time}. - 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-адаптер хранилища каталога диалогов: таблицы Dialogs/TgMessages схемы тенанта (Ruling 7, Task 13). +/// +/// +/// Реализация порта на (эталон KanbanStore/ +/// PipelineStore). Семантика 1:1 с python telegram.py/db.py: upsert каталога (ON CONFLICT DO UPDATE метаданных, +/// монитор только у новых — _persist_dialogs L484–490), удаление отсутствующих (L491–500), список «monitor DESC, +/// name» (L522), UPDATE monitor/backfilled, превью INSERT OR IGNORE (L602–605). Batch-операции — ExecuteUpdate +/// одним statement'ом; синхронизация каталога — один SaveChanges (порядок как python: upsert → delete). +/// +/// Scoped-контекст тенанта запроса (search_path). +public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore +{ + /// + public async Task SyncFromTelegramAsync( + IReadOnlyCollection entries, + bool autoMonitorNew, + CancellationToken ct) + { + if (entries.Count == 0) + { + // python L477–478: пустой каталог — возврат 0 без записи в БД. + return 0; + } + + // Существующие строки каталога — обновляем метаданные, монитор пользователя не трогаем (python L487–489). + 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 + { + // Новый диалог: монитор — по настройке autoMonitorNew (python L489), остальное — дефолты строки. + dbContext.Dialogs.Add(new DialogEntity + { + Id = entry.Id, + Name = entry.Name, + Handle = entry.Handle, + Kind = entry.Kind, + Hue = entry.Hue, + Monitor = autoMonitorNew, + UpdatedAt = now, + }); + } + } + + // Диалоги, которых больше нет в каталоге Telegram (вышел/удалил), удаляются (python L491–500). + 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) + { + // Список вкладки «Каналы»: включённые первыми, далее по имени (python list_dialogs L522). + 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) + { + // python backfill_dialog L387: только флаг, updated_at не трогаем (разбор — служебная операция). + 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) + { + // 1:1 python add_dialog_monitored L850–873 (INSERT/ON CONFLICT DO UPDATE): строка каталога после + // discovery-вступления — метаданные источника, monitor=TRUE, backfilled=FALSE (разбор подхватит + // первый фоновый Backfill). Значения нормализованы вызывающим (DialogsService). + 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) + { + // INSERT OR IGNORE python L602–605: первое вхождение сообщения не перезаписывается. + 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) + { + // python _on_message L270–273: last_text/last_at/updated_at одним моментом приёма. + 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(); + } + + // Маппит строку каталога в DTO списка каналов (list_dialogs L523–533). + // row: Строка Dialogs. + // Возвращает: Форма §4.8: type = kind (EN-канон), on = monitor, last {text, time}. + 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 19775a1..aeb90ac 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/TenantLimitStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/TenantLimitStore.cs @@ -1,306 +1,303 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища лимитов ИИ-бюджета: таблица public.tenant_limits (Ruling 3 этапа 7, Task 8). -/// -/// -/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). Строка заводится -/// лениво с дефолт-бюджетом адаптера (конфигурация/env DEAL_DEFAULT_AI_BUDGET, фолбэк — константа -/// TokenBudgetDefaults) при первом чтении/списании — так лимит-поля закрыты на всех путях чтения (список -/// тенантов Task 7/10, recorder Task 8, гейт Task 9) без eager-создания в join/создании тенанта. Reset периода — -/// ленивый (, Ruling 3): применяется при чтении/записи, отдельного фонового -/// цикла нет. Списание — read-modify-write отслеживаемой строки в одном SaveChanges (НЕ ExecuteSql): одиночный -/// инстанс core, конкурентность на тенанта сериализована воркер-гейтами; rowversion-семантику не вводим. -/// Статус тенанта для BudgetStateDto читается из public.tenants тем же контекстом (Ruling 3: suspended -/// замораживает ИИ); при отсутствии строки тенанта (защита от рассинхрона) статус трактуется suspended — -/// Allowed=false (безопасный дефолт). -/// -public sealed class TenantLimitStore : ITenantLimitStore -{ - private readonly DealDbContext _dbContext; - private readonly TokenLimitDefaults _defaults; - private readonly TokenBudgetService _budgetService; - private readonly Func _utcNow; - - /// - /// Создаёт адаптер с дефолт-бюджетом модуля (TokenBudgetDefaults) и системными часами. - /// - /// Системный контекст (public-схема). - public TenantLimitStore(DealDbContext dbContext) - : this(dbContext, TokenBudgetDefaults.Default, new TokenBudgetService(), () => DateTimeOffset.UtcNow) - { - } - - /// - /// Создаёт адаптер с дефолт-бюджетом из конфигурации (Api/AddDealPersistence) и системными часами. - /// - /// Системный контекст (public-схема). - /// Дефолт-параметры лениво создаваемой строки (Ruling 3). - public TenantLimitStore(DealDbContext dbContext, TokenLimitDefaults defaults) - : this(dbContext, defaults, new TokenBudgetService(), () => DateTimeOffset.UtcNow) - { - } - - /// - /// Создаёт адаптер с явными зависимостями (тесты подменяют часы для проверки ленивого reset). - /// - /// Системный контекст (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; - } - - // Читает строку лимита; при отсутствии лениво создаёт с дефолт-бюджетом и сохраняет (Ruling 3). - // tenantId: Тенант. - // defaults: Дефолт-параметры создаваемой строки. - // ct: Токен отмены. - // Возвращает: Отслеживаемая строка лимита. - 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; - } - - // Ленивый reset (Ruling 3): период истёк — обнулить счётчик и флаги, PeriodStart=now, сохранить. - // entity: Отслеживаемая строка лимита. - // ct: Токен отмены. - 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; - } - - // Собирает состояние бюджета: строка лимита + статус тенанта + Allowed (гейт Ruling 3). - // entity: Строка лимита (после ленивого reset). - // ct: Токен отмены. - // Возвращает: Состояние бюджета тенанта. - private async Task ToStateDtoAsync(TenantLimitEntity entity, CancellationToken ct) - { - // Статус тенанта — из public.tenants тем же контекстом; отсутствие строки трактуем suspended - // (безопасный дефолт: Allowed=false, приостановка замораживает ИИ, Ruling 10(5)). - 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.tenant_limits (Ruling 3 этапа 7, Task 8). +/// +/// +/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). Строка заводится +/// лениво с дефолт-бюджетом адаптера (конфигурация/env DEAL_DEFAULT_AI_BUDGET, фолбэк — константа +/// TokenBudgetDefaults) при первом чтении/списании — так лимит-поля закрыты на всех путях чтения (список +/// тенантов Task 7/10, recorder Task 8, гейт Task 9) без eager-создания в join/создании тенанта. Reset периода — +/// ленивый (, Ruling 3): применяется при чтении/записи, отдельного фонового +/// цикла нет. Списание — read-modify-write отслеживаемой строки в одном SaveChanges (НЕ ExecuteSql): одиночный +/// инстанс core, конкурентность на тенанта сериализована воркер-гейтами; rowversion-семантику не вводим. +/// Статус тенанта для BudgetStateDto читается из public.tenants тем же контекстом (Ruling 3: suspended +/// замораживает ИИ); при отсутствии строки тенанта (защита от рассинхрона) статус трактуется suspended — +/// Allowed=false (безопасный дефолт). +/// +public sealed class TenantLimitStore : ITenantLimitStore +{ + private readonly DealDbContext _dbContext; + private readonly TokenLimitDefaults _defaults; + private readonly TokenBudgetService _budgetService; + private readonly Func _utcNow; + + /// + /// Создаёт адаптер с дефолт-бюджетом модуля (TokenBudgetDefaults) и системными часами. + /// + /// Системный контекст (public-схема). + public TenantLimitStore(DealDbContext dbContext) + : this(dbContext, TokenBudgetDefaults.Default, new TokenBudgetService(), () => DateTimeOffset.UtcNow) + { + } + + /// + /// Создаёт адаптер с дефолт-бюджетом из конфигурации (Api/AddDealPersistence) и системными часами. + /// + /// Системный контекст (public-схема). + /// Дефолт-параметры лениво создаваемой строки (Ruling 3). + public TenantLimitStore(DealDbContext dbContext, TokenLimitDefaults defaults) + : this(dbContext, defaults, new TokenBudgetService(), () => DateTimeOffset.UtcNow) + { + } + + /// + /// Создаёт адаптер с явными зависимостями (тесты подменяют часы для проверки ленивого reset). + /// + /// Системный контекст (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; + } + + // Читает строку лимита; при отсутствии лениво создаёт с дефолт-бюджетом и сохраняет (Ruling 3). + // tenantId: Тенант. + // defaults: Дефолт-параметры создаваемой строки. + // ct: Токен отмены. + // Возвращает: Отслеживаемая строка лимита. + 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; + } + + // Ленивый reset (Ruling 3): период истёк — обнулить счётчик и флаги, PeriodStart=now, сохранить. + // entity: Отслеживаемая строка лимита. + // ct: Токен отмены. + 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; + } + + // Собирает состояние бюджета: строка лимита + статус тенанта + Allowed (гейт Ruling 3). + // entity: Строка лимита (после ленивого reset). + // ct: Токен отмены. + // Возвращает: Состояние бюджета тенанта. + private async Task ToStateDtoAsync(TenantLimitEntity entity, CancellationToken ct) + { + // Статус тенанта — из public.tenants тем же контекстом; отсутствие строки трактуем suspended + // (безопасный дефолт: Allowed=false, приостановка замораживает ИИ, Ruling 10(5)). + 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 796ad3d..9bd5956 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/TenantRepository.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/TenantRepository.cs @@ -1,72 +1,68 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер реестра тенантов: таблица public.tenants. -/// -/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). -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) - .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.tenants. +/// +/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). +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) + .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 f85a460..ef1435f 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/TokenUsageEventStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/TokenUsageEventStore.cs @@ -1,185 +1,181 @@ -using System.Linq.Expressions; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер истории расхода токенов: таблица public.token_usage_events (append-only + агрегаты, этап 10, T2). -/// -/// -/// Маппинг DTO ↔ сущности вручную (порт модуля не видит EF-сущности, Ruling 1). Запись — только Add+SaveChanges. -/// Агрегация — group by день UTC (Year/Month/Day), тенант, провайдер или модель; фильтры TenantId/Provider/Model/ -/// Kind/At-range. Порядок: day — по возрастанию даты, остальные — по убыванию total (детерминизм витрины). -/// Неизвестный groupBy — (HTTP-слой валидирует и отвечает 400 раньше). -/// -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.token_usage_events (append-only + агрегаты, этап 10, T2). +/// +/// +/// Маппинг DTO ↔ сущности вручную (порт модуля не видит EF-сущности, Ruling 1). Запись — только Add+SaveChanges. +/// Агрегация — group by день UTC (Year/Month/Day), тенант, провайдер или модель; фильтры TenantId/Provider/Model/ +/// Kind/At-range. Порядок: day — по возрастанию даты, остальные — по убыванию total (детерминизм витрины). +/// Неизвестный groupBy — (HTTP-слой валидирует и отвечает 400 раньше). +/// +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 6ce7b17..ada1283 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.Entities; -using Microsoft.EntityFrameworkCore; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence; - -/// -/// Бессхемный DbContext тенанта: таблицы живут в схеме тенанта через search_path. -/// -public sealed class TenantDbContext(DbContextOptions options) : DbContext(options) -{ - /// - /// Настройки тенанта (таблица settings). - /// - public DbSet Settings => Set(); - - /// - /// Карточки дашборда (таблица Cards). - /// - public DbSet Cards => Set(); - - /// - /// Комментарии карточек (таблица LeadComments). - /// - public DbSet LeadComments => Set(); - - /// - /// Журнал действий над карточками для обучения ML (таблица CardMoves). - /// - public DbSet CardMoves => Set(); - - /// - /// Очередь обучающих сигналов ML (таблица MlOutbox). - /// - public DbSet MlOutbox => Set(); - - /// - /// Очередь входящих сообщений пайплайна (таблица QueueItems). - /// - public DbSet QueueItems => Set(); - - /// - /// Отсев пайплайна: сообщения, не прошедшие фильтры/ML/ИИ (таблица RejectedItems). - /// - public DbSet RejectedItems => Set(); - - /// - /// Дедуп-хэши текстов: защита от повторного заведения карточек (таблица DedupEntries). - /// - public DbSet DedupEntries => Set(); - - /// - /// Единые контейнеры карточек: колонки/стадии/служебные зоны (таблица Containers; этап 9). - /// - public DbSet Containers => Set(); - - /// - /// Каталог диалогов/каналов Telegram (таблица Dialogs; модуль Telegram, Ruling 7). - /// - public DbSet Dialogs => Set(); - - /// - /// Превью-сообщения диалогов (таблица TgMessages; модуль Telegram, Ruling 7). - /// - public DbSet TgMessages => Set(); - - /// - /// Задачи поиска Discovery (таблица DiscTasks; модуль Discovery, Ruling 9). - /// - public DbSet DiscTasks => Set(); - - /// - /// Кандидаты задач Discovery (таблица DiscCandidates; модуль Discovery, Ruling 9). - /// - public DbSet DiscCandidates => Set(); - - /// - /// Чёрный список Discovery (таблица DiscBlacklist; модуль Discovery, Ruling 9). - /// - public DbSet DiscBlacklist => Set(); - - /// - /// Лог событий задач Discovery (таблица DiscLog; модуль Discovery, Ruling 9). - /// - 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 тенанта: таблицы живут в схеме тенанта через search_path. +/// +public sealed class TenantDbContext(DbContextOptions options) : DbContext(options) +{ + /// + /// Настройки тенанта (таблица settings). + /// + public DbSet Settings => Set(); + + /// + /// Карточки дашборда (таблица Cards). + /// + public DbSet Cards => Set(); + + /// + /// Комментарии карточек (таблица LeadComments). + /// + public DbSet LeadComments => Set(); + + /// + /// Журнал действий над карточками для обучения ML (таблица CardMoves). + /// + public DbSet CardMoves => Set(); + + /// + /// Очередь обучающих сигналов ML (таблица MlOutbox). + /// + public DbSet MlOutbox => Set(); + + /// + /// Очередь входящих сообщений пайплайна (таблица QueueItems). + /// + public DbSet QueueItems => Set(); + + /// + /// Отсев пайплайна: сообщения, не прошедшие фильтры/ML/ИИ (таблица RejectedItems). + /// + public DbSet RejectedItems => Set(); + + /// + /// Дедуп-хэши текстов: защита от повторного заведения карточек (таблица DedupEntries). + /// + public DbSet DedupEntries => Set(); + + /// + /// Единые контейнеры карточек: колонки/стадии/служебные зоны (таблица Containers; этап 9). + /// + public DbSet Containers => Set(); + + /// + /// Каталог диалогов/каналов Telegram (таблица Dialogs; модуль Telegram, Ruling 7). + /// + public DbSet Dialogs => Set(); + + /// + /// Превью-сообщения диалогов (таблица TgMessages; модуль Telegram, Ruling 7). + /// + public DbSet TgMessages => Set(); + + /// + /// Задачи поиска Discovery (таблица DiscTasks; модуль Discovery, Ruling 9). + /// + public DbSet DiscTasks => Set(); + + /// + /// Кандидаты задач Discovery (таблица DiscCandidates; модуль Discovery, Ruling 9). + /// + public DbSet DiscCandidates => Set(); + + /// + /// Чёрный список Discovery (таблица DiscBlacklist; модуль Discovery, Ruling 9). + /// + public DbSet DiscBlacklist => Set(); + + /// + /// Лог событий задач Discovery (таблица DiscLog; модуль Discovery, Ruling 9). + /// + 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/Persistence/TenantDbDesignTimeFactory.cs b/src/core/Deal.Infrastructure/Persistence/TenantDbDesignTimeFactory.cs index 67a9a88..766f93e 100644 --- a/src/core/Deal.Infrastructure/Persistence/TenantDbDesignTimeFactory.cs +++ b/src/core/Deal.Infrastructure/Persistence/TenantDbDesignTimeFactory.cs @@ -1,21 +1,20 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; -using Deal.Infrastructure.Persistence.Configurations; - -namespace Deal.Infrastructure.Persistence; - -/// -/// Фабрика для dotnet-ef (миграции TenantDbContext). История миграций — без схемы. -/// -public sealed class TenantDbDesignTimeFactory : IDesignTimeDbContextFactory -{ - public TenantDbContext CreateDbContext(string[] args) - { - var connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION") - ?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password"; - var options = new DbContextOptionsBuilder() - .UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__TenantMigrationsHistory")) - .Options; - return new TenantDbContext(options); - } -} +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Deal.Infrastructure.Persistence; + +/// +/// Фабрика для dotnet-ef (миграции TenantDbContext). История миграций — без схемы. +/// +public sealed class TenantDbDesignTimeFactory : IDesignTimeDbContextFactory +{ + public TenantDbContext CreateDbContext(string[] args) + { + var connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION") + ?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password"; + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__TenantMigrationsHistory")) + .Options; + return new TenantDbContext(options); + } +} diff --git a/src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs b/src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs index 9020268..9353eca 100644 --- a/src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs +++ b/src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs @@ -1,9 +1,6 @@ using System.Security.Cryptography; using System.Text; using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; namespace Deal.Infrastructure.Security; diff --git a/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs b/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs index a29c720..fcdd981 100644 --- a/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs +++ b/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs @@ -1,47 +1,22 @@ using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; using Deal.Infrastructure.Integrations.Models; using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Services; using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; using Deal.Infrastructure.Persistence.Repositories; using Deal.Infrastructure.Security; using Deal.Infrastructure.Services; using Deal.Infrastructure.Tenancy; using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; 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.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; using Deal.Modules.Telegram.Application; using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; diff --git a/src/core/Deal.Infrastructure/Services/CardMover.cs b/src/core/Deal.Infrastructure/Services/CardMover.cs index f917f2e..923d5c5 100644 --- a/src/core/Deal.Infrastructure/Services/CardMover.cs +++ b/src/core/Deal.Infrastructure/Services/CardMover.cs @@ -1,10 +1,7 @@ using Deal.Modules.Cards.Application.Abstractions; using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; using Deal.Modules.Kanban.Application.Services; namespace Deal.Infrastructure.Services; diff --git a/src/core/Deal.Infrastructure/Services/FtsMaintenance.cs b/src/core/Deal.Infrastructure/Services/FtsMaintenance.cs index 0713254..1ac3a3d 100644 --- a/src/core/Deal.Infrastructure/Services/FtsMaintenance.cs +++ b/src/core/Deal.Infrastructure/Services/FtsMaintenance.cs @@ -1,5 +1,4 @@ using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; diff --git a/src/core/Deal.Infrastructure/Tenancy/DefaultContainerProvisioner.cs b/src/core/Deal.Infrastructure/Tenancy/DefaultContainerProvisioner.cs index c12e7cf..71a8b30 100644 --- a/src/core/Deal.Infrastructure/Tenancy/DefaultContainerProvisioner.cs +++ b/src/core/Deal.Infrastructure/Tenancy/DefaultContainerProvisioner.cs @@ -1,15 +1,8 @@ using System.Text.Json; using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Tenancy; diff --git a/src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs b/src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs index e83f2dc..b41bbb6 100644 --- a/src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs +++ b/src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs @@ -2,13 +2,7 @@ using System.Collections.Concurrent; using Deal.Infrastructure.Data; using Deal.Infrastructure.Migrations; using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Models; using Microsoft.EntityFrameworkCore; using Npgsql; diff --git a/src/core/Deal.Infrastructure/Tenancy/TenantSchemaMigrationService.cs b/src/core/Deal.Infrastructure/Tenancy/TenantSchemaMigrationService.cs index cf55d42..feb3796 100644 --- a/src/core/Deal.Infrastructure/Tenancy/TenantSchemaMigrationService.cs +++ b/src/core/Deal.Infrastructure/Tenancy/TenantSchemaMigrationService.cs @@ -1,11 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics; using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Models; using Microsoft.Extensions.Logging; diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IAiSource.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IAiSource.cs index 8508d0e..0e0bec9 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IAiSource.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IAiSource.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; - namespace Deal.Modules.Cards.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IApiSource.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IApiSource.cs index 2bce475..a2963b2 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IApiSource.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IApiSource.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; - namespace Deal.Modules.Cards.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IAttributedCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IAttributedCard.cs index c57eb86..9d90b03 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IAttributedCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IAttributedCard.cs @@ -1,4 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; namespace Deal.Modules.Cards.Application.Abstractions; diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IBudgetedCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IBudgetedCard.cs index 4fecb3a..a2a879b 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IBudgetedCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IBudgetedCard.cs @@ -1,4 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; namespace Deal.Modules.Cards.Application.Abstractions; diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ICard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ICard.cs index 7295cb9..19668e9 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ICard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ICard.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; - namespace Deal.Modules.Cards.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ICommentableCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ICommentableCard.cs index b84e8d3..1d67be9 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ICommentableCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ICommentableCard.cs @@ -1,4 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; namespace Deal.Modules.Cards.Application.Abstractions; diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ICompositeSource.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ICompositeSource.cs index b0e3b82..1b35045 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ICompositeSource.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ICompositeSource.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; - namespace Deal.Modules.Cards.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IContactCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IContactCard.cs index 6ce2d7d..928e06f 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IContactCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IContactCard.cs @@ -1,4 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; namespace Deal.Modules.Cards.Application.Abstractions; diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IContainer.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IContainer.cs index 2f7c2eb..cb098e9 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IContainer.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IContainer.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; - namespace Deal.Modules.Cards.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerPolicy.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerPolicy.cs index 90eeb44..9d57694 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerPolicy.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerPolicy.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; - namespace Deal.Modules.Cards.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerRules.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerRules.cs index 2321728..d0a3442 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerRules.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerRules.cs @@ -1,4 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; namespace Deal.Modules.Cards.Application.Abstractions; diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IContentCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IContentCard.cs index 2e365c9..7233a0a 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IContentCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IContentCard.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; - namespace Deal.Modules.Cards.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IFileCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IFileCard.cs index 4b74d0b..99fcda2 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IFileCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IFileCard.cs @@ -1,4 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; namespace Deal.Modules.Cards.Application.Abstractions; diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IFileSource.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IFileSource.cs index 9788799..1fc9451 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IFileSource.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IFileSource.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; - namespace Deal.Modules.Cards.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ILinkCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ILinkCard.cs index fcb2f02..d557c2d 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ILinkCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ILinkCard.cs @@ -1,4 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; namespace Deal.Modules.Cards.Application.Abstractions; diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ILocalSource.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ILocalSource.cs index 5dccaf5..b4eea05 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ILocalSource.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ILocalSource.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; - namespace Deal.Modules.Cards.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ILocatedCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ILocatedCard.cs index 56bd6bd..695e25e 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ILocatedCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ILocatedCard.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; - namespace Deal.Modules.Cards.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IRemindableCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IRemindableCard.cs index 5f30dd6..29a30e7 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IRemindableCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IRemindableCard.cs @@ -1,4 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; namespace Deal.Modules.Cards.Application.Abstractions; diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IRowSource.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IRowSource.cs index 6a621a5..32b6ede 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IRowSource.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IRowSource.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; - namespace Deal.Modules.Cards.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ISource.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ISource.cs index 27e8a6c..ae96d8d 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ISource.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ISource.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; - namespace Deal.Modules.Cards.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ITelegramSource.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ITelegramSource.cs index 0b904fc..330679b 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ITelegramSource.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ITelegramSource.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; - namespace Deal.Modules.Cards.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ITraceableCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ITraceableCard.cs index 43e7828..96340ad 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ITraceableCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ITraceableCard.cs @@ -1,4 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; namespace Deal.Modules.Cards.Application.Abstractions; diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ITzCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ITzCard.cs index 7ff892e..32948fd 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ITzCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ITzCard.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; - namespace Deal.Modules.Cards.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IWebSource.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IWebSource.cs index a10540f..8d3717b 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IWebSource.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IWebSource.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; - namespace Deal.Modules.Cards.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs b/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs index 63d31a4..15e2fcd 100644 --- a/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs +++ b/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs @@ -1,5 +1,4 @@ using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Models; namespace Deal.Modules.Cards.Application.Dtos; diff --git a/src/core/Deal.Modules.Cards/Application/Models/Card.cs b/src/core/Deal.Modules.Cards/Application/Models/Card.cs index 3577cc8..89af7ed 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/Card.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/Card.cs @@ -1,5 +1,4 @@ using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; namespace Deal.Modules.Cards.Application.Models; diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardAttribute.cs b/src/core/Deal.Modules.Cards/Application/Models/CardAttribute.cs index 853fdf1..6377f4f 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardAttribute.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardAttribute.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; - namespace Deal.Modules.Cards.Application.Models; /// diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardBudget.cs b/src/core/Deal.Modules.Cards/Application/Models/CardBudget.cs index 3180ed5..c82404d 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardBudget.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardBudget.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; - namespace Deal.Modules.Cards.Application.Models; /// diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardComment.cs b/src/core/Deal.Modules.Cards/Application/Models/CardComment.cs index 313a73b..eebcdb0 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardComment.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardComment.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; - namespace Deal.Modules.Cards.Application.Models; /// diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardContact.cs b/src/core/Deal.Modules.Cards/Application/Models/CardContact.cs index 52b8ac2..e31516b 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardContact.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardContact.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; - namespace Deal.Modules.Cards.Application.Models; /// diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardFile.cs b/src/core/Deal.Modules.Cards/Application/Models/CardFile.cs index bb071cc..45d02ea 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardFile.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardFile.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; - namespace Deal.Modules.Cards.Application.Models; /// diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardHistoryEntry.cs b/src/core/Deal.Modules.Cards/Application/Models/CardHistoryEntry.cs index ea5df89..2c777aa 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardHistoryEntry.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardHistoryEntry.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; - namespace Deal.Modules.Cards.Application.Models; /// diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardIds.cs b/src/core/Deal.Modules.Cards/Application/Models/CardIds.cs index abfe1f9..82c4ec8 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardIds.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardIds.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; - namespace Deal.Modules.Cards.Application.Models; /// diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardLink.cs b/src/core/Deal.Modules.Cards/Application/Models/CardLink.cs index 5444326..a0b21ee 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardLink.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardLink.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; - namespace Deal.Modules.Cards.Application.Models; /// diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardReminder.cs b/src/core/Deal.Modules.Cards/Application/Models/CardReminder.cs index 3023b56..cdd10ec 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardReminder.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardReminder.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; - namespace Deal.Modules.Cards.Application.Models; /// diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardsDefaultContainers.cs b/src/core/Deal.Modules.Cards/Application/Models/CardsDefaultContainers.cs index 8c3a4e6..cc07973 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardsDefaultContainers.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardsDefaultContainers.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; - namespace Deal.Modules.Cards.Application.Models; /// diff --git a/src/core/Deal.Modules.Cards/Application/Models/DefaultContainer.cs b/src/core/Deal.Modules.Cards/Application/Models/DefaultContainer.cs index f50258d..d949100 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/DefaultContainer.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/DefaultContainer.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; - namespace Deal.Modules.Cards.Application.Models; /// diff --git a/src/core/Deal.Modules.Cards/Application/Models/TransitionContext.cs b/src/core/Deal.Modules.Cards/Application/Models/TransitionContext.cs index 27e4ef5..3bec7e3 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/TransitionContext.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/TransitionContext.cs @@ -1,5 +1,4 @@ using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; namespace Deal.Modules.Cards.Application.Models; diff --git a/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryPacer.cs b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryPacer.cs index 144a1eb..0357b7a 100644 --- a/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryPacer.cs +++ b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryPacer.cs @@ -1,7 +1,3 @@ -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Registrars; using Deal.Modules.Discovery.Application.Services; namespace Deal.Modules.Discovery.Application.Abstractions; diff --git a/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoverySearchErrorCounter.cs b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoverySearchErrorCounter.cs index 261efbc..0d81cf3 100644 --- a/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoverySearchErrorCounter.cs +++ b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoverySearchErrorCounter.cs @@ -1,9 +1,3 @@ -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; - namespace Deal.Modules.Discovery.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryStore.cs b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryStore.cs index c462b50..6d8078b 100644 --- a/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryStore.cs +++ b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryStore.cs @@ -1,316 +1,312 @@ -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; - -namespace Deal.Modules.Discovery.Application.Abstractions; - -/// -/// Порт хранилища Discovery: таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog схемы тенанта (Ruling 9, Task 17). -/// -/// -/// Порт объявлен в модуле Discovery (чистый, без EF) и реализуется EF-адаптером DiscoveryStore -/// (Deal.Infrastructure, регистрация в AddDealPersistence) — 1:1 с таблицами db.py L136–196 и операциями -/// discovery.py. Порт оперирует DTO/row-типами модуля; маппинг DTO ↔ строки (включая JSON-колонки keywords/ -/// marks/topics и перевод времён в epoch-ms) выполняет адаптер вручную. Id записей (dt_/dl_) генерирует модуль -/// и передаёт готовыми; CreatedAt/UpdatedAt проставляет адаптер (UTC-now). Чтения списков сортируются как в -/// python (tasks — created_at ASC; candidates — created_at ASC; blacklist — created_at DESC; лог — created_at -/// DESC). Запись DiscCandidates в dialogs-каталог (monitored) в порт не входит — проверка «уже мониторится» -/// читает таблицу Dialogs (владелец — модуль Telegram; реверс-зависимостей нет, доступ через тот же TenantDbContext). -/// -public interface IDiscoveryStore -{ - // ── Задачи (disc_tasks; list_tasks/get_task/create_task/patch_task/delete_task L189–318) ── - - /// - /// Все задачи, старые первыми (list_tasks L189–191: ORDER BY created_at ASC). - /// - /// Токен отмены. - /// Задачи в порядке создания; пусто — задач нет. - public Task> ListTasksAsync(CancellationToken ct); - - /// - /// Одна задача по id (get_task L194–196). - /// - /// Id задачи (dt_...). - /// Токен отмены. - /// Задача или null, если строки нет. - public Task GetTaskAsync(string taskId, CancellationToken ct); - - /// - /// Создаёт задачу из полной записи (create_task L260–280; Status=draft, поиск/счётчики=0, CreatedAt/UpdatedAt — UTC-now). - /// - /// Полное состояние новой задачи (id сгенерирован модулем, см. ). - /// Токен отмены. - public Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct); - - /// - /// Точечная правка полей по присутствующим в патче + bump UpdatedAt (patch_task L285–311). - /// - /// Id задачи (dt_...). - /// Изменяемые поля (null — поле не меняется; keywords — полная замена JSON-массива). - /// Токен отмены. - /// True — строка обновлена; false — задачи нет (404-семантика сервиса). - public Task PatchTaskAsync( - string taskId, - DiscoveryTaskPatch patch, - CancellationToken ct); - - /// - /// Удаляет задачу вместе с её кандидатами и логом (delete_task L314–318; чёрный список общий — не трогается). - /// - /// Id задачи (dt_...). - /// Токен отмены. - /// True — задача была и удалена; false — строки нет. - public Task DeleteTaskAsync(string taskId, CancellationToken ct); - - /// - /// Переводит задачу в running (start_task L321–346). - /// - /// Id задачи (dt_...). - /// True — повторный старт терминальной (done/failed): search_idx=0, search_done=false, - /// счётчики found/evaluated/joined/rejected=0 (L333–339); False — draft/paused: только статус (L342–344). - /// Токен отмены. - /// True — задача обновлена; false — строки нет. - public Task SetTaskRunningAsync( - string taskId, - bool resetProgress, - CancellationToken ct); - - /// - /// Переводит задачу в paused (pause_task L349–356; прогресс поиска сохраняется). - /// - /// Id задачи (dt_...). - /// Токен отмены. - /// True — задача обновлена; false — строки нет. - public Task SetTaskPausedAsync(string taskId, CancellationToken ct); - - /// - /// Переводит задачу в done (воркер _finish_done L126–136: план вступлений выполнен; bump UpdatedAt). - /// - /// Id задачи (dt_...). - /// Токен отмены. - /// True — задача обновлена; false — строки нет. - public Task SetTaskDoneAsync(string taskId, CancellationToken ct); - - /// - /// Увеличивает счётчик прогресса задачи (bump_counter L359–368: поле += n, bump UpdatedAt). - /// - /// Id задачи (dt_...). - /// Счётчик (found/evaluated/joined/rejected). - /// Приращение (вызывающий передаёт ≥1; ≤0 — no-op, как max(0, n) python). - /// Токен отмены. - /// True — задача обновлена; false — строки нет. - public Task BumpTaskCounterAsync( - string taskId, - DiscoveryCounterField field, - int n, - CancellationToken ct); - - /// - /// Продвигает индекс поиска (advance_search L371–380: search_idx = nextIndex, search_done, bump UpdatedAt). - /// - /// 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 активных задач (python _active_plan_sum L85–91: status NOT IN done/failed). - /// - /// Id задачи, исключаемой из суммы (patch-рост плана); null — все активные. - /// Токен отмены. - /// Занятый бюджет авто-вступлений (0 — активных задач нет). - public Task SumActivePlanAsync(string? excludeTaskId, CancellationToken ct); - - // ── Кандидаты (disc_candidates; L385–520) ── - - /// - /// Кандидаты задачи, старые первыми (list_candidates L385–397); status — фильтр. - /// - /// Id задачи (dt_...). - /// Статус-фильтр (см. ); null — все статусы. - /// Токен отмены. - /// Кандидаты задачи (marks/topics — типизированными списками); пусто — кандидатов нет. - public Task> ListCandidatesAsync( - string taskId, - string? status, - CancellationToken ct); - - /// - /// Кандидат по dialog_id (python _get_candidate L400–402; ключ — источник, кандидат один). - /// - /// Подписанный id источника. - /// Токен отмены. - /// Кандидат или null, если строки нет. - public Task GetCandidateAsync(string dialogId, CancellationToken ct); - - /// - /// Считает события лога по типу с CreatedAt ≥ sinceUtc (ban_guard.joins_today_auto L29–35: суточная - /// квота авто-вступлений по DiscLog event='join_auto' за текущие UTC-сутки; Task 18). - /// - /// Событие (см. ; воркер считает «join_auto»). - /// Нижняя граница CreatedAt (UTC; бан-гард передаёт начало текущих суток). - /// Токен отмены. - /// Число событий с начала суток (0 — событий нет). - public Task CountLogEventAsync( - string logEvent, - DateTimeOffset sinceUtc, - CancellationToken ct); - - /// - /// Увеличивает join_failures кандидата, если запись жива и в статусе review (воркер L411–416; bump UpdatedAt). - /// - /// Подписанный id источника. - /// Токен отмены. - /// Новое значение счётчика; null — записи нет/не в review (счётчик не трогается). - public Task IncrementJoinFailuresAsync(string dialogId, CancellationToken ct); - - /// - /// Проверка «уже мониторится (мы состоим)»: источник есть в каталоге Dialogs (add_candidate L418–419). - /// - /// Подписанный id источника. - /// Токен отмены. - /// True — источник есть в Dialogs (добавление пропускается с логом skip). - public Task IsDialogMonitoredAsync(string dialogId, CancellationToken ct); - - /// - /// Проверка «источник в чёрном списке» (add_candidate L421–422, воркер _we_are_in L114–123). - /// - /// Подписанный id источника. - /// Токен отмены. - /// True — источник есть в DiscBlacklist. - public Task IsBlacklistedAsync(string dialogId, CancellationToken ct); - - /// - /// Добавляет кандидата со служебными дефолтами (add_candidate INSERT L436–449: participants/lang_ru NULL, - /// marks/topics «[]», fit_ratio NULL, status new, auto_joined false; CreatedAt/UpdatedAt — UTC-now). - /// - /// Новые значения кандидата (см. ). - /// Токен отмены. - public Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct); - - /// - /// Удаляет кандидата по dialog_id (delete_candidate L518–520; повторный вызов безопасен). - /// - /// Подписанный id источника. - /// Токен отмены. - public Task DeleteCandidateAsync(string dialogId, CancellationToken ct); - - /// - /// Правка полей кандидата (set_candidate UPDATE L456–494; bump UpdatedAt). - /// - /// Подписанный id источника. - /// Изменяемые поля (null — поле не меняется; marks/topics — полная замена JSON). - /// Токен отмены. - /// True — строка обновлена; false — кандидата нет. - public Task PatchCandidateAsync( - string dialogId, - DiscoveryCandidatePatch patch, - CancellationToken ct); - - /// - /// Переводит кандидата в new/review (set_candidate_status L497–515: только эти статусы; bump UpdatedAt). - /// - /// Подписанный id источника. - /// Новый статус: new|review (валидирует сервис). - /// Токен отмены. - /// True — строка обновлена; false — кандидата нет. - public Task SetCandidateStatusAsync( - string dialogId, - string status, - CancellationToken ct); - - /// - /// Ставит кандидату joined + auto_joined (mark_joined L531–534; bump UpdatedAt). - /// - /// Подписанный id источника. - /// True — авто-вступление воркера; false — ручное (join из UI). - /// Токен отмены. - /// True — строка обновлена; false — кандидата нет. - public Task SetCandidateJoinedAsync( - string dialogId, - bool autoJoined, - CancellationToken ct); - - /// - /// Ставит кандидату rejected (mark_rejected L555–558; bump UpdatedAt; счётчик/лог/чёрный список — сервис). - /// - /// Подписанный id источника. - /// Токен отмены. - /// True — строка обновлена; false — кандидата нет. - public Task SetCandidateRejectedAsync(string dialogId, CancellationToken ct); - - // ── Чёрный список (disc_blacklist; L568–589) ── - - /// - /// Помечает источник в чёрном списке (add_blacklist L568–578: INSERT … ON CONFLICT DO UPDATE name/reason; CreatedAt сохраняется). - /// - /// Подписанный id источника. - /// Имя источника (вызывающий передаёт нормализованное: пусто → DialogId). - /// Причина добавления. - /// Токен отмены. - public Task UpsertBlacklistAsync( - string dialogId, - string name, - string reason, - CancellationToken ct); - - /// - /// Снимает источник с чёрного списка (remove_blacklist L581–582; нет строки — no-op). - /// - /// Подписанный id источника. - /// Токен отмены. - public Task RemoveBlacklistAsync(string dialogId, CancellationToken ct); - - /// - /// Запись чёрного списка по dialog_id (echo add_blacklist L577–578). - /// - /// Подписанный id источника. - /// Токен отмены. - /// Запись или null, если строки нет. - public Task GetBlacklistAsync(string dialogId, CancellationToken ct); - - /// - /// Весь чёрный список, новые записи первыми (list_blacklist L585–589: ORDER BY created_at DESC). - /// - /// Токен отмены. - /// Записи от новых к старым; пусто — список пуст. - public Task> ListBlacklistAsync(CancellationToken ct); - - // ── Лог задачи (disc_log; L594–608) ── - - /// - /// Пишет строку лога (add_log L594–598; id dl_... генерирует модуль, CreatedAt — UTC-now). - /// - /// Готовый id записи (префикс dl_). - /// Id задачи поиска. - /// Событие (см. ). - /// Текст/детали события. - /// Токен отмены. - public Task AddLogAsync( - string logId, - string taskId, - string logEvent, - string text, - CancellationToken ct); - - /// - /// Последние события задачи, новые сверху (task_log L601–608: ORDER BY created_at DESC LIMIT). - /// - /// 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: таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog схемы тенанта (Ruling 9, Task 17). +/// +/// +/// Порт объявлен в модуле Discovery (чистый, без EF) и реализуется EF-адаптером DiscoveryStore +/// (Deal.Infrastructure, регистрация в AddDealPersistence) — 1:1 с таблицами db.py L136–196 и операциями +/// discovery.py. Порт оперирует DTO/row-типами модуля; маппинг DTO ↔ строки (включая JSON-колонки keywords/ +/// marks/topics и перевод времён в epoch-ms) выполняет адаптер вручную. Id записей (dt_/dl_) генерирует модуль +/// и передаёт готовыми; CreatedAt/UpdatedAt проставляет адаптер (UTC-now). Чтения списков сортируются как в +/// python (tasks — created_at ASC; candidates — created_at ASC; blacklist — created_at DESC; лог — created_at +/// DESC). Запись DiscCandidates в dialogs-каталог (monitored) в порт не входит — проверка «уже мониторится» +/// читает таблицу Dialogs (владелец — модуль Telegram; реверс-зависимостей нет, доступ через тот же TenantDbContext). +/// +public interface IDiscoveryStore +{ + // ── Задачи (disc_tasks; list_tasks/get_task/create_task/patch_task/delete_task L189–318) ── + + /// + /// Все задачи, старые первыми (list_tasks L189–191: ORDER BY created_at ASC). + /// + /// Токен отмены. + /// Задачи в порядке создания; пусто — задач нет. + public Task> ListTasksAsync(CancellationToken ct); + + /// + /// Одна задача по id (get_task L194–196). + /// + /// Id задачи (dt_...). + /// Токен отмены. + /// Задача или null, если строки нет. + public Task GetTaskAsync(string taskId, CancellationToken ct); + + /// + /// Создаёт задачу из полной записи (create_task L260–280; Status=draft, поиск/счётчики=0, CreatedAt/UpdatedAt — UTC-now). + /// + /// Полное состояние новой задачи (id сгенерирован модулем, см. ). + /// Токен отмены. + public Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct); + + /// + /// Точечная правка полей по присутствующим в патче + bump UpdatedAt (patch_task L285–311). + /// + /// Id задачи (dt_...). + /// Изменяемые поля (null — поле не меняется; keywords — полная замена JSON-массива). + /// Токен отмены. + /// True — строка обновлена; false — задачи нет (404-семантика сервиса). + public Task PatchTaskAsync( + string taskId, + DiscoveryTaskPatch patch, + CancellationToken ct); + + /// + /// Удаляет задачу вместе с её кандидатами и логом (delete_task L314–318; чёрный список общий — не трогается). + /// + /// Id задачи (dt_...). + /// Токен отмены. + /// True — задача была и удалена; false — строки нет. + public Task DeleteTaskAsync(string taskId, CancellationToken ct); + + /// + /// Переводит задачу в running (start_task L321–346). + /// + /// Id задачи (dt_...). + /// True — повторный старт терминальной (done/failed): search_idx=0, search_done=false, + /// счётчики found/evaluated/joined/rejected=0 (L333–339); False — draft/paused: только статус (L342–344). + /// Токен отмены. + /// True — задача обновлена; false — строки нет. + public Task SetTaskRunningAsync( + string taskId, + bool resetProgress, + CancellationToken ct); + + /// + /// Переводит задачу в paused (pause_task L349–356; прогресс поиска сохраняется). + /// + /// Id задачи (dt_...). + /// Токен отмены. + /// True — задача обновлена; false — строки нет. + public Task SetTaskPausedAsync(string taskId, CancellationToken ct); + + /// + /// Переводит задачу в done (воркер _finish_done L126–136: план вступлений выполнен; bump UpdatedAt). + /// + /// Id задачи (dt_...). + /// Токен отмены. + /// True — задача обновлена; false — строки нет. + public Task SetTaskDoneAsync(string taskId, CancellationToken ct); + + /// + /// Увеличивает счётчик прогресса задачи (bump_counter L359–368: поле += n, bump UpdatedAt). + /// + /// Id задачи (dt_...). + /// Счётчик (found/evaluated/joined/rejected). + /// Приращение (вызывающий передаёт ≥1; ≤0 — no-op, как max(0, n) python). + /// Токен отмены. + /// True — задача обновлена; false — строки нет. + public Task BumpTaskCounterAsync( + string taskId, + DiscoveryCounterField field, + int n, + CancellationToken ct); + + /// + /// Продвигает индекс поиска (advance_search L371–380: search_idx = nextIndex, search_done, bump UpdatedAt). + /// + /// 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 активных задач (python _active_plan_sum L85–91: status NOT IN done/failed). + /// + /// Id задачи, исключаемой из суммы (patch-рост плана); null — все активные. + /// Токен отмены. + /// Занятый бюджет авто-вступлений (0 — активных задач нет). + public Task SumActivePlanAsync(string? excludeTaskId, CancellationToken ct); + + // ── Кандидаты (disc_candidates; L385–520) ── + + /// + /// Кандидаты задачи, старые первыми (list_candidates L385–397); status — фильтр. + /// + /// Id задачи (dt_...). + /// Статус-фильтр (см. ); null — все статусы. + /// Токен отмены. + /// Кандидаты задачи (marks/topics — типизированными списками); пусто — кандидатов нет. + public Task> ListCandidatesAsync( + string taskId, + string? status, + CancellationToken ct); + + /// + /// Кандидат по dialog_id (python _get_candidate L400–402; ключ — источник, кандидат один). + /// + /// Подписанный id источника. + /// Токен отмены. + /// Кандидат или null, если строки нет. + public Task GetCandidateAsync(string dialogId, CancellationToken ct); + + /// + /// Считает события лога по типу с CreatedAt ≥ sinceUtc (ban_guard.joins_today_auto L29–35: суточная + /// квота авто-вступлений по DiscLog event='join_auto' за текущие UTC-сутки; Task 18). + /// + /// Событие (см. ; воркер считает «join_auto»). + /// Нижняя граница CreatedAt (UTC; бан-гард передаёт начало текущих суток). + /// Токен отмены. + /// Число событий с начала суток (0 — событий нет). + public Task CountLogEventAsync( + string logEvent, + DateTimeOffset sinceUtc, + CancellationToken ct); + + /// + /// Увеличивает join_failures кандидата, если запись жива и в статусе review (воркер L411–416; bump UpdatedAt). + /// + /// Подписанный id источника. + /// Токен отмены. + /// Новое значение счётчика; null — записи нет/не в review (счётчик не трогается). + public Task IncrementJoinFailuresAsync(string dialogId, CancellationToken ct); + + /// + /// Проверка «уже мониторится (мы состоим)»: источник есть в каталоге Dialogs (add_candidate L418–419). + /// + /// Подписанный id источника. + /// Токен отмены. + /// True — источник есть в Dialogs (добавление пропускается с логом skip). + public Task IsDialogMonitoredAsync(string dialogId, CancellationToken ct); + + /// + /// Проверка «источник в чёрном списке» (add_candidate L421–422, воркер _we_are_in L114–123). + /// + /// Подписанный id источника. + /// Токен отмены. + /// True — источник есть в DiscBlacklist. + public Task IsBlacklistedAsync(string dialogId, CancellationToken ct); + + /// + /// Добавляет кандидата со служебными дефолтами (add_candidate INSERT L436–449: participants/lang_ru NULL, + /// marks/topics «[]», fit_ratio NULL, status new, auto_joined false; CreatedAt/UpdatedAt — UTC-now). + /// + /// Новые значения кандидата (см. ). + /// Токен отмены. + public Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct); + + /// + /// Удаляет кандидата по dialog_id (delete_candidate L518–520; повторный вызов безопасен). + /// + /// Подписанный id источника. + /// Токен отмены. + public Task DeleteCandidateAsync(string dialogId, CancellationToken ct); + + /// + /// Правка полей кандидата (set_candidate UPDATE L456–494; bump UpdatedAt). + /// + /// Подписанный id источника. + /// Изменяемые поля (null — поле не меняется; marks/topics — полная замена JSON). + /// Токен отмены. + /// True — строка обновлена; false — кандидата нет. + public Task PatchCandidateAsync( + string dialogId, + DiscoveryCandidatePatch patch, + CancellationToken ct); + + /// + /// Переводит кандидата в new/review (set_candidate_status L497–515: только эти статусы; bump UpdatedAt). + /// + /// Подписанный id источника. + /// Новый статус: new|review (валидирует сервис). + /// Токен отмены. + /// True — строка обновлена; false — кандидата нет. + public Task SetCandidateStatusAsync( + string dialogId, + string status, + CancellationToken ct); + + /// + /// Ставит кандидату joined + auto_joined (mark_joined L531–534; bump UpdatedAt). + /// + /// Подписанный id источника. + /// True — авто-вступление воркера; false — ручное (join из UI). + /// Токен отмены. + /// True — строка обновлена; false — кандидата нет. + public Task SetCandidateJoinedAsync( + string dialogId, + bool autoJoined, + CancellationToken ct); + + /// + /// Ставит кандидату rejected (mark_rejected L555–558; bump UpdatedAt; счётчик/лог/чёрный список — сервис). + /// + /// Подписанный id источника. + /// Токен отмены. + /// True — строка обновлена; false — кандидата нет. + public Task SetCandidateRejectedAsync(string dialogId, CancellationToken ct); + + // ── Чёрный список (disc_blacklist; L568–589) ── + + /// + /// Помечает источник в чёрном списке (add_blacklist L568–578: INSERT … ON CONFLICT DO UPDATE name/reason; CreatedAt сохраняется). + /// + /// Подписанный id источника. + /// Имя источника (вызывающий передаёт нормализованное: пусто → DialogId). + /// Причина добавления. + /// Токен отмены. + public Task UpsertBlacklistAsync( + string dialogId, + string name, + string reason, + CancellationToken ct); + + /// + /// Снимает источник с чёрного списка (remove_blacklist L581–582; нет строки — no-op). + /// + /// Подписанный id источника. + /// Токен отмены. + public Task RemoveBlacklistAsync(string dialogId, CancellationToken ct); + + /// + /// Запись чёрного списка по dialog_id (echo add_blacklist L577–578). + /// + /// Подписанный id источника. + /// Токен отмены. + /// Запись или null, если строки нет. + public Task GetBlacklistAsync(string dialogId, CancellationToken ct); + + /// + /// Весь чёрный список, новые записи первыми (list_blacklist L585–589: ORDER BY created_at DESC). + /// + /// Токен отмены. + /// Записи от новых к старым; пусто — список пуст. + public Task> ListBlacklistAsync(CancellationToken ct); + + // ── Лог задачи (disc_log; L594–608) ── + + /// + /// Пишет строку лога (add_log L594–598; id dl_... генерирует модуль, CreatedAt — UTC-now). + /// + /// Готовый id записи (префикс dl_). + /// Id задачи поиска. + /// Событие (см. ). + /// Текст/детали события. + /// Токен отмены. + public Task AddLogAsync( + string logId, + string taskId, + string logEvent, + string text, + CancellationToken ct); + + /// + /// Последние события задачи, новые сверху (task_log L601–608: ORDER BY created_at DESC LIMIT). + /// + /// Id задачи поиска. + /// Сколько последних записей (кламп 1..500 выполняет сервис; дефолт 100). + /// Токен отмены. + /// События задачи от новых к старым; пусто — лога нет. + public Task> ListTaskLogAsync( + string taskId, + int limit, + CancellationToken ct); +} diff --git a/src/core/Deal.Modules.Discovery/Application/Exceptions/DiscoveryValidationException.cs b/src/core/Deal.Modules.Discovery/Application/Exceptions/DiscoveryValidationException.cs index a9304d8..596f633 100644 --- a/src/core/Deal.Modules.Discovery/Application/Exceptions/DiscoveryValidationException.cs +++ b/src/core/Deal.Modules.Discovery/Application/Exceptions/DiscoveryValidationException.cs @@ -1,9 +1,3 @@ -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; - namespace Deal.Modules.Discovery.Application.Exceptions; /// diff --git a/src/core/Deal.Modules.Discovery/Application/Extensions/DiscoveryTaskPatchExtensions.cs b/src/core/Deal.Modules.Discovery/Application/Extensions/DiscoveryTaskPatchExtensions.cs index 1b46874..1b9c88c 100644 --- a/src/core/Deal.Modules.Discovery/Application/Extensions/DiscoveryTaskPatchExtensions.cs +++ b/src/core/Deal.Modules.Discovery/Application/Extensions/DiscoveryTaskPatchExtensions.cs @@ -1,8 +1,4 @@ using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; namespace Deal.Modules.Discovery.Application.Extensions; diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateKinds.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateKinds.cs index 43c6b60..0cd59d8 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateKinds.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateKinds.cs @@ -1,9 +1,3 @@ -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; - namespace Deal.Modules.Discovery.Application.Models; /// diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateStatuses.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateStatuses.cs index 7f47c16..de4f430 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateStatuses.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateStatuses.cs @@ -1,9 +1,3 @@ -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; - namespace Deal.Modules.Discovery.Application.Models; /// diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCounterField.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCounterField.cs index 8563732..874ad21 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCounterField.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCounterField.cs @@ -1,9 +1,3 @@ -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; - namespace Deal.Modules.Discovery.Application.Models; /// diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryEvalSample.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryEvalSample.cs index 1733bc5..ea57335 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryEvalSample.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryEvalSample.cs @@ -1,9 +1,3 @@ -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; - namespace Deal.Modules.Discovery.Application.Models; /// diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryIdPrefixes.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryIdPrefixes.cs index fc9ff1f..1da49e4 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryIdPrefixes.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryIdPrefixes.cs @@ -1,9 +1,4 @@ using System.Security.Cryptography; -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; namespace Deal.Modules.Discovery.Application.Models; diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogEvents.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogEvents.cs index ddde80b..8deb0b3 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogEvents.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogEvents.cs @@ -1,9 +1,3 @@ -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; - namespace Deal.Modules.Discovery.Application.Models; /// diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryMessageFit.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryMessageFit.cs index 31683d5..de7235e 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryMessageFit.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryMessageFit.cs @@ -1,9 +1,3 @@ -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; - namespace Deal.Modules.Discovery.Application.Models; /// diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskStatuses.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskStatuses.cs index 961976e..d5d6854 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskStatuses.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskStatuses.cs @@ -1,9 +1,3 @@ -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; - namespace Deal.Modules.Discovery.Application.Models; /// diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTopicGroup.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTopicGroup.cs index 7a0a6db..ee07a76 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTopicGroup.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTopicGroup.cs @@ -1,9 +1,4 @@ using Deal.Contracts.Integrations.Models; -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; namespace Deal.Modules.Discovery.Application.Models; diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryWorkerOutcome.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryWorkerOutcome.cs index c6ded70..742da64 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryWorkerOutcome.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryWorkerOutcome.cs @@ -1,7 +1,3 @@ -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; using Deal.Modules.Discovery.Application.Services; namespace Deal.Modules.Discovery.Application.Models; diff --git a/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs b/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs index 5f471d1..2221831 100644 --- a/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs +++ b/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs @@ -1,13 +1,7 @@ -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Microsoft.Extensions.DependencyInjection; 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.Discovery.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Microsoft.Extensions.DependencyInjection; namespace Deal.Modules.Discovery.Application.Registrars; diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs index 2183e0e..ba26556 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs @@ -1,130 +1,125 @@ -using System.Text.Json; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -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.Discovery.Application.Registrars; - -namespace Deal.Modules.Discovery.Application.Services; - -/// -/// Бан-гард авто-вступлений Discovery: суточный лимит, flood-день, стоп-кран (1:1 ban_guard.py L1–81, план Task 18, Ruling 10). -/// -/// -/// Суточный лимит (discJoinLimit, дефолт 50) считается по DiscLog: число событий event='join_auto' за текущие -/// UTC-сутки (дискретность совпадает с логом mark_joined авто-вступления — дискретный счётчик 1:1 с python -/// L29–35). При FloodWait от Telegram воркер зовёт — блокировка авто-вступлений до -/// конца суток (внутренний ключ SettingsKeys.DiscFloodDay, epoch-ms начала суток). Стоп-кран discPaused — -/// ручная пауза вступлений. — общий вердикт «разрешено ли следующее авто- -/// вступление». Настройки читаются типизированным снимком TenantSettingsSnapshot (C30): отсутствие -/// строки/повреждение → SettingsDefaults. -/// -/// Паузу между вступлениями (discJoinDelayMin..Max, 50–70 с) гард НЕ держит — её делает -/// (рандом недоступен тестам; 1:1 ban_guard.wait_join_delay L44–56). -/// -/// -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); - } - - /// - /// Ручной стоп-кран авто-вступлений (setting discPaused; ban_guard.global_paused L69–71). - /// - /// Токен отмены. - /// True — вступления на паузе (воркер не делает сетевых шагов). - public async Task GlobalPausedAsync(CancellationToken ct) - { - TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct); - return settingsSnapshot.GetBool(SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused); - } - - /// - /// Авто-вступления за текущие UTC-сутки (ban_guard.joins_today_auto L29–35). - /// - /// События DiscLog event='join_auto' с CreatedAt ≥ начала текущих UTC-суток (все тенанты держат - /// времена UTC; счётчик учитывает и строки ручных вступлений с auto=false? — нет: фильтр по событию join_auto). - /// Токен отмены. - /// Число авто-вступлений сегодня (0 — вступлений нет). - public Task JoinsTodayAutoAsync(CancellationToken ct) - => _store.CountLogEventAsync(DiscoveryLogEvents.JoinAuto, StartOfDayUtc(), ct); - - /// - /// Была ли flood-блокировка в текущие UTC-сутки (ban_guard.flood_today L64–66). - /// - /// Токен отмены. - /// True — фиксировалась сегодня (стоп до конца суток). - public async Task FloodTodayAsync(CancellationToken ct) - { - TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct); - return IsFloodToday(snapshot.GetLong(SettingsKeys.DiscFloodDay, 0), StartOfDayMs()); - } - - /// - /// Разрешено ли авто-вступление: лимит не исчерпан, нет flood на сегодня, нет стоп-крана - /// (ban_guard.can_auto_join L38–41). - /// - /// Токен отмены. - /// 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: блокировка авто-вступлений до конца суток (ban_guard.note_flood L59–62). - /// - /// Запись идемпотентна в течение суток: DiscFloodDay = epoch-ms начала текущих UTC-суток - /// (повторный note_flood в тот же день перезаписывает тем же значением). - /// Токен отмены. - public Task NoteFloodAsync(CancellationToken ct) - => _settings.SetAsync(SettingsKeys.DiscFloodDay, JsonSerializer.Serialize(StartOfDayMs()), ct); - - // Начало текущих UTC-суток (python _start_of_day_ms L23–26). - private DateTimeOffset StartOfDayUtc() - { - DateTimeOffset now = _utcNow().ToUniversalTime(); - return new DateTimeOffset(now.Year, now.Month, now.Day, 0, 0, 0, TimeSpan.Zero); - } - - // Начало текущих UTC-суток в epoch-ms (python L25). - private long StartOfDayMs() => StartOfDayUtc().ToUnixTimeMilliseconds(); - - // Flood-день совпал с текущими сутками (ban_guard.flood_today L64–66: 0 — блокировок не было). - // floodDay: Epoch-ms начала суток flood-блокировки (0 — не было). - // startOfDayMs: Начало текущих UTC-суток в epoch-ms. - // Возвращает: True — flood фиксировался сегодня. - 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: суточный лимит, flood-день, стоп-кран (1:1 ban_guard.py L1–81, план Task 18, Ruling 10). +/// +/// +/// Суточный лимит (discJoinLimit, дефолт 50) считается по DiscLog: число событий event='join_auto' за текущие +/// UTC-сутки (дискретность совпадает с логом mark_joined авто-вступления — дискретный счётчик 1:1 с python +/// L29–35). При FloodWait от Telegram воркер зовёт — блокировка авто-вступлений до +/// конца суток (внутренний ключ SettingsKeys.DiscFloodDay, epoch-ms начала суток). Стоп-кран discPaused — +/// ручная пауза вступлений. — общий вердикт «разрешено ли следующее авто- +/// вступление». Настройки читаются типизированным снимком TenantSettingsSnapshot (C30): отсутствие +/// строки/повреждение → SettingsDefaults. +/// +/// Паузу между вступлениями (discJoinDelayMin..Max, 50–70 с) гард НЕ держит — её делает +/// (рандом недоступен тестам; 1:1 ban_guard.wait_join_delay L44–56). +/// +/// +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); + } + + /// + /// Ручной стоп-кран авто-вступлений (setting discPaused; ban_guard.global_paused L69–71). + /// + /// Токен отмены. + /// True — вступления на паузе (воркер не делает сетевых шагов). + public async Task GlobalPausedAsync(CancellationToken ct) + { + TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct); + return settingsSnapshot.GetBool(SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused); + } + + /// + /// Авто-вступления за текущие UTC-сутки (ban_guard.joins_today_auto L29–35). + /// + /// События DiscLog event='join_auto' с CreatedAt ≥ начала текущих UTC-суток (все тенанты держат + /// времена UTC; счётчик учитывает и строки ручных вступлений с auto=false? — нет: фильтр по событию join_auto). + /// Токен отмены. + /// Число авто-вступлений сегодня (0 — вступлений нет). + public Task JoinsTodayAutoAsync(CancellationToken ct) + => _store.CountLogEventAsync(DiscoveryLogEvents.JoinAuto, StartOfDayUtc(), ct); + + /// + /// Была ли flood-блокировка в текущие UTC-сутки (ban_guard.flood_today L64–66). + /// + /// Токен отмены. + /// True — фиксировалась сегодня (стоп до конца суток). + public async Task FloodTodayAsync(CancellationToken ct) + { + TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(_settings, ct); + return IsFloodToday(snapshot.GetLong(SettingsKeys.DiscFloodDay, 0), StartOfDayMs()); + } + + /// + /// Разрешено ли авто-вступление: лимит не исчерпан, нет flood на сегодня, нет стоп-крана + /// (ban_guard.can_auto_join L38–41). + /// + /// Токен отмены. + /// 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: блокировка авто-вступлений до конца суток (ban_guard.note_flood L59–62). + /// + /// Запись идемпотентна в течение суток: DiscFloodDay = epoch-ms начала текущих UTC-суток + /// (повторный note_flood в тот же день перезаписывает тем же значением). + /// Токен отмены. + public Task NoteFloodAsync(CancellationToken ct) + => _settings.SetAsync(SettingsKeys.DiscFloodDay, JsonSerializer.Serialize(StartOfDayMs()), ct); + + // Начало текущих UTC-суток (python _start_of_day_ms L23–26). + private DateTimeOffset StartOfDayUtc() + { + DateTimeOffset now = _utcNow().ToUniversalTime(); + return new DateTimeOffset(now.Year, now.Month, now.Day, 0, 0, 0, TimeSpan.Zero); + } + + // Начало текущих UTC-суток в epoch-ms (python L25). + private long StartOfDayMs() => StartOfDayUtc().ToUnixTimeMilliseconds(); + + // Flood-день совпал с текущими сутками (ban_guard.flood_today L64–66: 0 — блокировок не было). + // floodDay: Epoch-ms начала суток flood-блокировки (0 — не было). + // startOfDayMs: Начало текущих UTC-суток в epoch-ms. + // Возвращает: True — flood фиксировался сегодня. + 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 8eaf234..3851a29 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBlacklistService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBlacklistService.cs @@ -1,66 +1,63 @@ -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; - -namespace Deal.Modules.Discovery.Application.Services; - -/// -/// Сервис чёрного списка Discovery — добавление/снятие/список (1:1 add_blacklist/remove_blacklist/list_blacklist L568–589). -/// -/// -/// Чистый сервис поверх (таблица DiscBlacklist схемы тенанта). Список общий для -/// всех задач: источники из него пропускаются поиском и авто-вступлением (проверки делает кандидатный сервис и -/// воркер). Повторное добавление того же источника обновляет Name/Reason, CreatedAt сохраняется (ON CONFLICT -/// L572–575) — «перезапись» записи чёрного списка. Снимается вручную (DELETE …/blacklist) и при ручном join -/// (эндпоинты Task 19 зовут ). -/// -public sealed class DiscoveryBlacklistService(IDiscoveryStore store) -{ - /// - /// Помечает источник в чёрном списке (add_blacklist L568–578; существующая запись обновляется). - /// - /// Подписанный id источника. - /// Имя источника (пустое → DialogId, python L575). - /// Причина добавления («отклонено вручную», метка воркера). - /// Токен отмены. - /// Запись чёрного списка (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"); - } - - /// - /// Снимает источник с чёрного списка (remove_blacklist L581–582; нет строки — no-op). - /// - /// Подписанный id источника. - /// Токен отмены. - /// Завершается после удаления строки. - public Task RemoveAsync(string dialogId, CancellationToken ct) - { - return store.RemoveBlacklistAsync(dialogId, ct); - } - - /// - /// Весь чёрный список, новые записи первыми (list_blacklist L585–589). - /// - /// Токен отмены. - /// Записи от новых к старым. - 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 — добавление/снятие/список (1:1 add_blacklist/remove_blacklist/list_blacklist L568–589). +/// +/// +/// Чистый сервис поверх (таблица DiscBlacklist схемы тенанта). Список общий для +/// всех задач: источники из него пропускаются поиском и авто-вступлением (проверки делает кандидатный сервис и +/// воркер). Повторное добавление того же источника обновляет Name/Reason, CreatedAt сохраняется (ON CONFLICT +/// L572–575) — «перезапись» записи чёрного списка. Снимается вручную (DELETE …/blacklist) и при ручном join +/// (эндпоинты Task 19 зовут ). +/// +public sealed class DiscoveryBlacklistService(IDiscoveryStore store) +{ + /// + /// Помечает источник в чёрном списке (add_blacklist L568–578; существующая запись обновляется). + /// + /// Подписанный id источника. + /// Имя источника (пустое → DialogId, python L575). + /// Причина добавления («отклонено вручную», метка воркера). + /// Токен отмены. + /// Запись чёрного списка (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"); + } + + /// + /// Снимает источник с чёрного списка (remove_blacklist L581–582; нет строки — no-op). + /// + /// Подписанный id источника. + /// Токен отмены. + /// Завершается после удаления строки. + public Task RemoveAsync(string dialogId, CancellationToken ct) + { + return store.RemoveBlacklistAsync(dialogId, ct); + } + + /// + /// Весь чёрный список, новые записи первыми (list_blacklist L585–589). + /// + /// Токен отмены. + /// Записи от новых к старым. + 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 fb6293a..e2290aa 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryCandidatesService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryCandidatesService.cs @@ -1,355 +1,352 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; - -namespace Deal.Modules.Discovery.Application.Services; - -/// -/// Сервис кандидатов Discovery — add с исключениями, set_candidate, review-перевод, mark_joined/rejected, delete (1:1 L385–563). -/// -/// -/// Чистый оркестратор поверх (DiscCandidates + счётчики DiscTasks + проверки Dialogs/ -/// DiscBlacklist), (skip/review/join/reject-лог) и -/// (mark_rejected пишет в чёрный список). 404-семантика — null-результат; 400 — -/// (например, mark_rejected вступившего). Переводы в joined/rejected — ТОЛЬКО mark_joined/mark_rejected (счётчики -/// задачи, лог, чёрный список); add_candidate пропускает источники, которые уже мониторятся/в чёрном списке/уже -/// кандидаты (new/review/joined), а устаревшая запись rejected заменяется новой (L431–433). -/// -public sealed class DiscoveryCandidatesService( - IDiscoveryStore store, - DiscoveryLogService log, - DiscoveryBlacklistService blacklist) -{ - /// - /// 400 mark_rejected: источник уже joined (mark_rejected L550–551). - /// - public const string RejectJoinedDetail = "Нельзя отклонить источник, в который уже вступили"; - - /// - /// 400 set_candidate_status: не new/review (set_candidate_status L503–504). - /// - public const string TransitionNotAllowedFormat = "Статус {0} выставляется через mark_joined/mark_rejected"; - - // Skip-лог add_candidate: источник уже в Dialogs (add_candidate L419). - private const string SkipMonitoredFormat = "пропущен {0}: источник уже мониторится (мы состоим)"; - - // Skip-лог add_candidate: источник в чёрном списке (L422). - private const string SkipBlacklistedFormat = "пропущен {0}: источник в чёрном списке"; - - // Skip-лог add_candidate: кандидат уже есть (L429). - private const string SkipActiveFormat = "пропущен {0}: кандидат уже есть (статус {1})"; - - // Review-лог перевода кандидата (set_candidate_status L513). - private const string ReviewLogFormat = "кандидат {0} переведён в review"; - - // Join-лог mark_joined (L536). - private const string JoinedLogFormat = "вступили в {0}"; - - // Reject-лог mark_rejected (L560): текст по умолчанию. - private const string RejectedLogFormat = "отклонён {0}"; - - /// - /// Кандидаты задачи, старые первыми (list_candidates L385–397). - /// - /// 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 (echo _get_candidate L400–402; для эндпоинтов join/reject Task 19). - /// - /// Подписанный id источника. - /// Токен отмены. - /// Кандидат или null (404 «Кандидат не найден»). - public Task GetAsync(string dialogId, CancellationToken ct) - { - return store.GetCandidateAsync(dialogId, ct); - } - - /// - /// Добавляет найденный источник как кандидата задачи (add_candidate L409–453). - /// - /// - /// Исключения (возврат null + лог skip, 1:1 L418–430): источник есть в Dialogs («уже мониторится (мы состоим)»), - /// в чёрном списке или уже добавлен в статусе new/review/joined. Устаревшая запись rejected заменяется новой - /// (L431–433), счётчик found увеличивается (L451), статус нового кандидата — new. - /// - /// 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) - { - // python _task_or_raise L416 бросает KeyError; в .NET — null (воркер вызывает add_candidate только - // для существующих running-задач; строки нет → источник просто не добавляется). - 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) - { - // Устаревшая rejected-запись (например, после remove_blacklist): перезаписываем новым кандидатом (L431–433). - 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); - } - - /// - /// Обновляет поля кандидата по результатам оценки (set_candidate L456–494; зовёт воркер Task 18). - /// - /// Пустые после Trim name/username/kind/hue не затирают текущее значение (L480–482). - /// Id задачи (принадлежность кандидата проверяется — python L464–466). - /// Подписанный 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) - { - // python: KeyError (L463–466) — задача/кандидат исчезли между шагами воркера. - 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 (set_candidate_status L497–515; review пишет лог). - /// - /// joined/rejected переводятся только через / - /// (там счётчики/чёрный список/лог). В review воркер переводит оценённых кандидатов (finish_review, Task 18). - /// Подписанный 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); - } - - /// - /// Вступили в источник: status=joined, счётчик joined задачи, лог join_auto/join_manual (mark_joined L523–538). - /// - /// Подписанный id источника. - /// True — авто-вступление воркера; false — ручное (join из UI, Task 19). - /// Токен отмены. - /// Кандидат в joined либо null (кандидата нет). Повторный вызов для joined — идемпотентен (L528–529). - 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); - } - - /// - /// Отклоняет кандидата: status=rejected, счётчик rejected, лог reject, чёрный список (mark_rejected L541–563). - /// - /// Подписанный id источника. - /// Причина отклонения (в лог/чёрный список; «отклонено вручную» — эндпоинт reject). - /// Токен отмены. - /// Кандидат в rejected либо null (кандидата нет). Повторный вызов для rejected — идемпотентен (L552–553). - /// Источник уже 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); - } - - /// - /// Удаляет кандидата (delete_candidate L518–520; skip-ветки воркера; повторный вызов безопасен). - /// - /// Подписанный id источника. - /// Токен отмены. - /// Завершается после удаления строки. - public Task DeleteAsync(string dialogId, CancellationToken ct) - { - return store.DeleteCandidateAsync(dialogId, ct); - } - - // Нормализует патч кандидата (1:1 set_candidate L468–484). - // patch: Патч из запроса. - // current: Текущий кандидат (для «не затирать пустым» L480–482). - // Возвращает: Патч с нормализованными значениями (null-поля — «не менять»). - 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, - }; - } - - // Строковое поле патча: Trim; пустой результат — «не менять» (python L480–482 «or row[col]»). - // value: Значение патча (null — не менялось). - // current: Текущее значение строки. - // Возвращает: Обрезанное значение или null (поле не меняется). - private static string? NormalizeKeptOr(string? value, string current) - { - if (value is null) - { - return null; - } - - string trimmed = value.Trim(); - return trimmed.Length == 0 ? null : trimmed; - } - - // Обрезка строки с дефолтом при пустом результате (add_candidate L443–446). - 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 (1:1 L385–563). +/// +/// +/// Чистый оркестратор поверх (DiscCandidates + счётчики DiscTasks + проверки Dialogs/ +/// DiscBlacklist), (skip/review/join/reject-лог) и +/// (mark_rejected пишет в чёрный список). 404-семантика — null-результат; 400 — +/// (например, mark_rejected вступившего). Переводы в joined/rejected — ТОЛЬКО mark_joined/mark_rejected (счётчики +/// задачи, лог, чёрный список); add_candidate пропускает источники, которые уже мониторятся/в чёрном списке/уже +/// кандидаты (new/review/joined), а устаревшая запись rejected заменяется новой (L431–433). +/// +public sealed class DiscoveryCandidatesService( + IDiscoveryStore store, + DiscoveryLogService log, + DiscoveryBlacklistService blacklist) +{ + /// + /// 400 mark_rejected: источник уже joined (mark_rejected L550–551). + /// + public const string RejectJoinedDetail = "Нельзя отклонить источник, в который уже вступили"; + + /// + /// 400 set_candidate_status: не new/review (set_candidate_status L503–504). + /// + public const string TransitionNotAllowedFormat = "Статус {0} выставляется через mark_joined/mark_rejected"; + + // Skip-лог add_candidate: источник уже в Dialogs (add_candidate L419). + private const string SkipMonitoredFormat = "пропущен {0}: источник уже мониторится (мы состоим)"; + + // Skip-лог add_candidate: источник в чёрном списке (L422). + private const string SkipBlacklistedFormat = "пропущен {0}: источник в чёрном списке"; + + // Skip-лог add_candidate: кандидат уже есть (L429). + private const string SkipActiveFormat = "пропущен {0}: кандидат уже есть (статус {1})"; + + // Review-лог перевода кандидата (set_candidate_status L513). + private const string ReviewLogFormat = "кандидат {0} переведён в review"; + + // Join-лог mark_joined (L536). + private const string JoinedLogFormat = "вступили в {0}"; + + // Reject-лог mark_rejected (L560): текст по умолчанию. + private const string RejectedLogFormat = "отклонён {0}"; + + /// + /// Кандидаты задачи, старые первыми (list_candidates L385–397). + /// + /// 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 (echo _get_candidate L400–402; для эндпоинтов join/reject Task 19). + /// + /// Подписанный id источника. + /// Токен отмены. + /// Кандидат или null (404 «Кандидат не найден»). + public Task GetAsync(string dialogId, CancellationToken ct) + { + return store.GetCandidateAsync(dialogId, ct); + } + + /// + /// Добавляет найденный источник как кандидата задачи (add_candidate L409–453). + /// + /// + /// Исключения (возврат null + лог skip, 1:1 L418–430): источник есть в Dialogs («уже мониторится (мы состоим)»), + /// в чёрном списке или уже добавлен в статусе new/review/joined. Устаревшая запись rejected заменяется новой + /// (L431–433), счётчик found увеличивается (L451), статус нового кандидата — new. + /// + /// 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) + { + // python _task_or_raise L416 бросает KeyError; в .NET — null (воркер вызывает add_candidate только + // для существующих running-задач; строки нет → источник просто не добавляется). + 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) + { + // Устаревшая rejected-запись (например, после remove_blacklist): перезаписываем новым кандидатом (L431–433). + 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); + } + + /// + /// Обновляет поля кандидата по результатам оценки (set_candidate L456–494; зовёт воркер Task 18). + /// + /// Пустые после Trim name/username/kind/hue не затирают текущее значение (L480–482). + /// Id задачи (принадлежность кандидата проверяется — python L464–466). + /// Подписанный 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) + { + // python: KeyError (L463–466) — задача/кандидат исчезли между шагами воркера. + 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 (set_candidate_status L497–515; review пишет лог). + /// + /// joined/rejected переводятся только через / + /// (там счётчики/чёрный список/лог). В review воркер переводит оценённых кандидатов (finish_review, Task 18). + /// Подписанный 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); + } + + /// + /// Вступили в источник: status=joined, счётчик joined задачи, лог join_auto/join_manual (mark_joined L523–538). + /// + /// Подписанный id источника. + /// True — авто-вступление воркера; false — ручное (join из UI, Task 19). + /// Токен отмены. + /// Кандидат в joined либо null (кандидата нет). Повторный вызов для joined — идемпотентен (L528–529). + 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); + } + + /// + /// Отклоняет кандидата: status=rejected, счётчик rejected, лог reject, чёрный список (mark_rejected L541–563). + /// + /// Подписанный id источника. + /// Причина отклонения (в лог/чёрный список; «отклонено вручную» — эндпоинт reject). + /// Токен отмены. + /// Кандидат в rejected либо null (кандидата нет). Повторный вызов для rejected — идемпотентен (L552–553). + /// Источник уже 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); + } + + /// + /// Удаляет кандидата (delete_candidate L518–520; skip-ветки воркера; повторный вызов безопасен). + /// + /// Подписанный id источника. + /// Токен отмены. + /// Завершается после удаления строки. + public Task DeleteAsync(string dialogId, CancellationToken ct) + { + return store.DeleteCandidateAsync(dialogId, ct); + } + + // Нормализует патч кандидата (1:1 set_candidate L468–484). + // patch: Патч из запроса. + // current: Текущий кандидат (для «не затирать пустым» L480–482). + // Возвращает: Патч с нормализованными значениями (null-поля — «не менять»). + 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, + }; + } + + // Строковое поле патча: Trim; пустой результат — «не менять» (python L480–482 «or row[col]»). + // value: Значение патча (null — не менялось). + // current: Текущее значение строки. + // Возвращает: Обрезанное значение или null (поле не меняется). + private static string? NormalizeKeptOr(string? value, string current) + { + if (value is null) + { + return null; + } + + string trimmed = value.Trim(); + return trimmed.Length == 0 ? null : trimmed; + } + + // Обрезка строки с дефолтом при пустом результате (add_candidate L443–446). + 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 5ea4f45..f26f0e2 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryEvaluator.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryEvaluator.cs @@ -1,337 +1,331 @@ -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; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; - -namespace Deal.Modules.Discovery.Application.Services; - -/// -/// Оценка содержания кандидата: фит сообщений под задачу поиска (1:1 discovery_eval.py целиком, план Task 18). -/// -/// -/// Каскад оценки одного сообщения (python evaluate_message L174–194): -/// -/// текст пустой/короче 10 символов → «слишком короткое» (False, эвристика); -/// ML при mlEnabled (): take + label=spam → «ML: спам» (False); -/// сбой прогноза не роняет оценку (python L101–107: «не уверен»); -/// ИИ при aiEnabled (): любая ошибка (Local — NotSupportedException, -/// gRPC — AiUnavailableException/…, Ruling 10) ловится и оценка продолжается эвристикой (python L186–194); -/// эвристика: любой ключ задачи входит в очищенный текст без учёта регистра. -/// -/// — группировка выборки форума по topic_id (null → «main») с заголовками-сниппетами -/// (python L86–117); — вердикт «источник подходит»: выборка ≥3 сообщений и доля fit ≥ -/// threshold, % (python L229–237). Результаты по сообщениям собирает -/// в агрегат (fit_count/total/fit_ratio, -/// python L197–226). Язык выборки — отдельный детектор (зовёт воркер). -/// -public sealed class DiscoveryEvaluator -{ - // Минимальная длина сообщения для содержательной оценки (python _MIN_TEXT_LEN L39). - private const int MinTextLength = 10; - - // Лимит текста, уходящего ИИ-провайдеру, в символах (python _AI_TEXT_LIMIT L41). - private const int AiTextLimit = 4000; - - // Потолок причины из ИИ (python _AI_REASON_LIMIT L43). - private const int AiReasonLimit = 200; - - // Минимальный объём содержательной выборки для вердикта оценки (python _MIN_CONTENT L77). - private const int MinContentMessages = 3; - - // topic_id=null в выборке/группировке → общая тема «main» (python _MAIN_TOPIC L47). - private const string MainTopic = "main"; - - // Длина заголовка темы-сниппета (python _TITLE_LIMIT L45). - private const int TopicTitleLimit = 60; - - // Причина по умолчанию при фите ИИ (python _ai_reason L170). - private const string AiFitReasonDefault = "подходит"; - - // Причина по умолчанию при не-фите ИИ (python _ai_reason L170). - private const string AiNotFitReasonDefault = "не подходит"; - - private readonly ISettingsStore _settings; - private readonly IMlClient _mlClient; - private readonly IAiTools _aiTools; - - /// - /// Создаёт оценщик содержания кандидатов Discovery. - /// - /// KV-настройки тенанта (mlEnabled/aiEnabled — ветки каскада). - /// ML-порт (спам-отсев при mlEnabled; сбой — «не уверен»). - /// ИИ-порт (фит при aiEnabled; сбой — эвристика, Ruling 10). - public DiscoveryEvaluator( - ISettingsStore settings, - IMlClient mlClient, - IAiTools aiTools) - { - ArgumentNullException.ThrowIfNull(settings); - ArgumentNullException.ThrowIfNull(mlClient); - ArgumentNullException.ThrowIfNull(aiTools); - _settings = settings; - _mlClient = mlClient; - _aiTools = aiTools; - } - - /// - /// Последовательная оценка всех сообщений выборки (python evaluate_sample L197–226). - /// - /// fit_count/total/fit_ratio считаются по всей выборке; per-message повторяет входной порядок - /// (воркеру нужны только агрегаты; per-message — для тестов и будущих разборов). - /// Задача поиска (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); - } - - /// - /// Оценка фита одного сообщения под задачу: каскад короткое → ML-спам → ИИ → эвристика (python L174–194). - /// - /// Задача поиска (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) - { - // Прогноз недоступен («не уверен», python ml_client.predict L101–107) — решает ИИ/эвристика. - } - } - - 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) - { - // Сбой ИИ (нет ключа/сеть/не-JSON; Local — NotSupportedException) не роняет оценку (python L191–192). - } - } - - return Heuristic(task, raw); - } - - // Эвристика: ключ задачи входит в очищенный текст без учёта регистра (python _heuristic L144–150). - 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"); - } - - /// - /// Вердикт «источник подходит»: выборка ≥3 сообщений и доля fit ≥ threshold, % (python passed L229–237). - /// - /// Агрегат оценки выборки/темы. - /// Порог задачи (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 (null → «main») и сортирует группы по числу сообщений - /// (убыв.), порядок сообщений внутри группы — входной (python group_by_topic L96–117). - /// - /// Заголовок группы — сниппет первого непустого текста (python _topic_title L86–93, ≤60 символов, - /// whitespace схлопнут). Возвращаемые группы переиспользуют исходные DTO (без копий). - /// Сообщения выборки (для форумов заполнен 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)); - } - - // python L116: sort(key=len(messages), reverse=True) — стабильная сортировка сохраняет входной порядок равных. - return outGroups - .OrderByDescending(group => group.Messages.Count) - .ToList(); - } - - // Сниппет первого непустого текста темы (python _topic_title L86–93). - 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; - } - - // Очистка текста для эвристики: схлопывание whitespace (python clean_short ≈ «\n+»→« » + trim). - private static string CleanShort(string text) - { - string collapsed = CollapseWhitespace(text); - return collapsed.ToLowerInvariant(); - } - - // Схлопывает любые пробельные последовательности в один пробел и обрезает края (python " ".join(split())). - 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(); - } - - // Первые max кодовых точек строки (python-срез без разрыва суррогатных пар). - 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; + +/// +/// Оценка содержания кандидата: фит сообщений под задачу поиска (1:1 discovery_eval.py целиком, план Task 18). +/// +/// +/// Каскад оценки одного сообщения (python evaluate_message L174–194): +/// +/// текст пустой/короче 10 символов → «слишком короткое» (False, эвристика); +/// ML при mlEnabled (): take + label=spam → «ML: спам» (False); +/// сбой прогноза не роняет оценку (python L101–107: «не уверен»); +/// ИИ при aiEnabled (): любая ошибка (Local — NotSupportedException, +/// gRPC — AiUnavailableException/…, Ruling 10) ловится и оценка продолжается эвристикой (python L186–194); +/// эвристика: любой ключ задачи входит в очищенный текст без учёта регистра. +/// +/// — группировка выборки форума по topic_id (null → «main») с заголовками-сниппетами +/// (python L86–117); — вердикт «источник подходит»: выборка ≥3 сообщений и доля fit ≥ +/// threshold, % (python L229–237). Результаты по сообщениям собирает +/// в агрегат (fit_count/total/fit_ratio, +/// python L197–226). Язык выборки — отдельный детектор (зовёт воркер). +/// +public sealed class DiscoveryEvaluator +{ + // Минимальная длина сообщения для содержательной оценки (python _MIN_TEXT_LEN L39). + private const int MinTextLength = 10; + + // Лимит текста, уходящего ИИ-провайдеру, в символах (python _AI_TEXT_LIMIT L41). + private const int AiTextLimit = 4000; + + // Потолок причины из ИИ (python _AI_REASON_LIMIT L43). + private const int AiReasonLimit = 200; + + // Минимальный объём содержательной выборки для вердикта оценки (python _MIN_CONTENT L77). + private const int MinContentMessages = 3; + + // topic_id=null в выборке/группировке → общая тема «main» (python _MAIN_TOPIC L47). + private const string MainTopic = "main"; + + // Длина заголовка темы-сниппета (python _TITLE_LIMIT L45). + private const int TopicTitleLimit = 60; + + // Причина по умолчанию при фите ИИ (python _ai_reason L170). + private const string AiFitReasonDefault = "подходит"; + + // Причина по умолчанию при не-фите ИИ (python _ai_reason L170). + private const string AiNotFitReasonDefault = "не подходит"; + + private readonly ISettingsStore _settings; + private readonly IMlClient _mlClient; + private readonly IAiTools _aiTools; + + /// + /// Создаёт оценщик содержания кандидатов Discovery. + /// + /// KV-настройки тенанта (mlEnabled/aiEnabled — ветки каскада). + /// ML-порт (спам-отсев при mlEnabled; сбой — «не уверен»). + /// ИИ-порт (фит при aiEnabled; сбой — эвристика, Ruling 10). + public DiscoveryEvaluator( + ISettingsStore settings, + IMlClient mlClient, + IAiTools aiTools) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(mlClient); + ArgumentNullException.ThrowIfNull(aiTools); + _settings = settings; + _mlClient = mlClient; + _aiTools = aiTools; + } + + /// + /// Последовательная оценка всех сообщений выборки (python evaluate_sample L197–226). + /// + /// fit_count/total/fit_ratio считаются по всей выборке; per-message повторяет входной порядок + /// (воркеру нужны только агрегаты; per-message — для тестов и будущих разборов). + /// Задача поиска (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); + } + + /// + /// Оценка фита одного сообщения под задачу: каскад короткое → ML-спам → ИИ → эвристика (python L174–194). + /// + /// Задача поиска (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) + { + // Прогноз недоступен («не уверен», python ml_client.predict L101–107) — решает ИИ/эвристика. + } + } + + 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) + { + // Сбой ИИ (нет ключа/сеть/не-JSON; Local — NotSupportedException) не роняет оценку (python L191–192). + } + } + + return Heuristic(task, raw); + } + + // Эвристика: ключ задачи входит в очищенный текст без учёта регистра (python _heuristic L144–150). + 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"); + } + + /// + /// Вердикт «источник подходит»: выборка ≥3 сообщений и доля fit ≥ threshold, % (python passed L229–237). + /// + /// Агрегат оценки выборки/темы. + /// Порог задачи (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 (null → «main») и сортирует группы по числу сообщений + /// (убыв.), порядок сообщений внутри группы — входной (python group_by_topic L96–117). + /// + /// Заголовок группы — сниппет первого непустого текста (python _topic_title L86–93, ≤60 символов, + /// whitespace схлопнут). Возвращаемые группы переиспользуют исходные DTO (без копий). + /// Сообщения выборки (для форумов заполнен 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)); + } + + // python L116: sort(key=len(messages), reverse=True) — стабильная сортировка сохраняет входной порядок равных. + return outGroups + .OrderByDescending(group => group.Messages.Count) + .ToList(); + } + + // Сниппет первого непустого текста темы (python _topic_title L86–93). + 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; + } + + // Очистка текста для эвристики: схлопывание whitespace (python clean_short ≈ «\n+»→« » + trim). + private static string CleanShort(string text) + { + string collapsed = CollapseWhitespace(text); + return collapsed.ToLowerInvariant(); + } + + // Схлопывает любые пробельные последовательности в один пробел и обрезает края (python " ".join(split())). + 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(); + } + + // Первые max кодовых точек строки (python-срез без разрыва суррогатных пар). + 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/DiscoveryLangDetector.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLangDetector.cs index 6fde411..5bda7de 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLangDetector.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLangDetector.cs @@ -1,9 +1,3 @@ -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.Discovery.Application.Registrars; - namespace Deal.Modules.Discovery.Application.Services; /// diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLogService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLogService.cs index 9cdade7..6f246c9 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLogService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLogService.cs @@ -1,72 +1,69 @@ -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; - -namespace Deal.Modules.Discovery.Application.Services; - -/// -/// Сервис лога задач Discovery — запись событий и чтение истории (1:1 add_log/task_log discovery.py L594–608). -/// -/// -/// Чистый сервис поверх (таблица DiscLog схемы тенанта). Id записи (dl_) генерирует -/// модуль (); CreatedAt проставляет хранилище. Пишут лог кандидатный сервис -/// (skip/review/join/reject), воркер (Task 18: search/flood/error/done) и сервисы задач — единая точка входа. -/// Чтение — последние события, новые сверху (GET …/log); limit клампится 1..500, дефолт 100 (python L603). -/// -public sealed class DiscoveryLogService(IDiscoveryStore store) -{ - // Верхняя граница выборки лога (python task_log L603). - private const int MaxLogLimit = 500; - - // Нижняя граница выборки лога (python task_log L603). - private const int MinLogLimit = 1; - - // Размер выборки по умолчанию (python task_log L601). - private const int DefaultLogLimit = 100; - - /// - /// Пишет событие в лог задачи (add_log L594–598: id dl_, событие и текст 1:1 с python). - /// - /// Id задачи поиска. - /// Событие (см. ). - /// Текст/детали события (русская строка 1:1 с прототипом). - /// Токен отмены. - /// Завершается после записи. - public Task AddAsync( - string taskId, - string logEvent, - string text, - CancellationToken ct) - { - return store.AddLogAsync(DiscoveryIdPrefixes.NewLogId(), taskId, logEvent, text ?? string.Empty, ct); - } - - /// - /// Последние события задачи, новые сверху (task_log L601–608). - /// - /// Id задачи поиска. - /// Токен отмены. - /// События задачи от новых к старым (до 100; пусто — лога нет). - public Task> TaskLogAsync(string taskId, CancellationToken ct) - { - return TaskLogAsync(taskId, DefaultLogLimit, ct); - } - - /// - /// Последние события задачи с выборкой (limit клампится 1..500, как python L603). - /// - /// 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 — запись событий и чтение истории (1:1 add_log/task_log discovery.py L594–608). +/// +/// +/// Чистый сервис поверх (таблица DiscLog схемы тенанта). Id записи (dl_) генерирует +/// модуль (); CreatedAt проставляет хранилище. Пишут лог кандидатный сервис +/// (skip/review/join/reject), воркер (Task 18: search/flood/error/done) и сервисы задач — единая точка входа. +/// Чтение — последние события, новые сверху (GET …/log); limit клампится 1..500, дефолт 100 (python L603). +/// +public sealed class DiscoveryLogService(IDiscoveryStore store) +{ + // Верхняя граница выборки лога (python task_log L603). + private const int MaxLogLimit = 500; + + // Нижняя граница выборки лога (python task_log L603). + private const int MinLogLimit = 1; + + // Размер выборки по умолчанию (python task_log L601). + private const int DefaultLogLimit = 100; + + /// + /// Пишет событие в лог задачи (add_log L594–598: id dl_, событие и текст 1:1 с python). + /// + /// Id задачи поиска. + /// Событие (см. ). + /// Текст/детали события (русская строка 1:1 с прототипом). + /// Токен отмены. + /// Завершается после записи. + public Task AddAsync( + string taskId, + string logEvent, + string text, + CancellationToken ct) + { + return store.AddLogAsync(DiscoveryIdPrefixes.NewLogId(), taskId, logEvent, text ?? string.Empty, ct); + } + + /// + /// Последние события задачи, новые сверху (task_log L601–608). + /// + /// Id задачи поиска. + /// Токен отмены. + /// События задачи от новых к старым (до 100; пусто — лога нет). + public Task> TaskLogAsync(string taskId, CancellationToken ct) + { + return TaskLogAsync(taskId, DefaultLogLimit, ct); + } + + /// + /// Последние события задачи с выборкой (limit клампится 1..500, как python L603). + /// + /// 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 9fe124d..eebc3c1 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPacer.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPacer.cs @@ -1,42 +1,36 @@ -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -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.Discovery.Application.Registrars; - -namespace Deal.Modules.Discovery.Application.Services; - -/// -/// Продовая реализация : случайная пауза из настроек тенанта (ban_guard L44–56). -/// -/// -/// Защита инварианта min ≤ max как в python L47–51: настройки мог изменить один конец интервала (single-key -/// PATCH), поэтому при инверсии концы меняются местами. Обе настройки не заданы/≤0 — паузы нет (крайний случай -/// L52–53). Детерминизма нет: интервал сэмплируется из . -/// -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; + +/// +/// Продовая реализация : случайная пауза из настроек тенанта (ban_guard L44–56). +/// +/// +/// Защита инварианта min ≤ max как в python L47–51: настройки мог изменить один конец интервала (single-key +/// PATCH), поэтому при инверсии концы меняются местами. Обе настройки не заданы/≤0 — паузы нет (крайний случай +/// L52–53). Детерминизма нет: интервал сэмплируется из . +/// +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 8c3f78e..c91efc7 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPlanGuard.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPlanGuard.cs @@ -1,92 +1,87 @@ -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -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.Discovery.Application.Registrars; - -namespace Deal.Modules.Discovery.Application.Services; - -/// -/// План-бюджет авто-вступлений Discovery: лимит и правило суммы (python L80–111, Ruling 9). -/// -/// -/// Правило 1:1 с discovery.py: суточный лимит discJoinLimit (настройка, дефолт 50 — SettingsDefaults) — верхняя -/// граница plan_joins одной задачи (); сумма plan_joins активных задач (status NOT IN -/// done/failed) плюс новая/увеличиваемая задача не должна превышать лимит (). -/// Итог семантики: задача на 50 занимает весь бюджет (другую создать нельзя); задача на 25 оставляет остаток -/// 25 (следующая может быть не больше 25). Тексты 400 — 1:1 с python (L97–110). -/// -public sealed class DiscoveryPlanGuard(IDiscoveryStore store, ISettingsStore settings) -{ - /// - /// 400: план меньше 1 (python L97 «plan_joins должен быть не меньше 1»). - /// - public const string PlanTooSmallDetail = "plan_joins должен быть не меньше 1"; - - /// - /// 400: план больше суточного лимита (python L100). Формат: {plan}, {limit}. - /// - public const string PlanTooBigFormat = "plan_joins {0} больше суточного лимита авто-вступлений ({1})"; - - /// - /// 400: бюджет исчерпан (python L108–110). Формат: {used}, {limit}, {plan}. - /// - public const string BudgetExceededFormat = - "Бюджет авто-вступлений исчерпан: задачи уже занимают {0} из {1} в сутки, ещё {2} не влезает"; - - /// - /// Верхняя граница plan_joins: суточный лимит авто-вступлений (python _plan_limit L80–82). - /// - /// Токен отмены. - /// 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..discJoinLimit (python _assert_plan L94–100). - /// - /// Запрашиваемый план авто-вступлений. - /// Токен отмены. - /// План меньше 1 или больше суточного лимита (тексты python). - 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)); - } - } - - /// - /// Проверяет бюджет: занято активными задачами + новая ≤ discJoinLimit (python _assert_budget L103–111). - /// - /// План создаваемой/итоговый план увеличиваемой задачи. - /// Id задачи, исключаемой из занятого бюджета (patch-рост плана); null — создание. - /// Токен отмены. - /// Бюджет исчерпан (текст python L108–110). - 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: лимит и правило суммы (python L80–111, Ruling 9). +/// +/// +/// Правило 1:1 с discovery.py: суточный лимит discJoinLimit (настройка, дефолт 50 — SettingsDefaults) — верхняя +/// граница plan_joins одной задачи (); сумма plan_joins активных задач (status NOT IN +/// done/failed) плюс новая/увеличиваемая задача не должна превышать лимит (). +/// Итог семантики: задача на 50 занимает весь бюджет (другую создать нельзя); задача на 25 оставляет остаток +/// 25 (следующая может быть не больше 25). Тексты 400 — 1:1 с python (L97–110). +/// +public sealed class DiscoveryPlanGuard(IDiscoveryStore store, ISettingsStore settings) +{ + /// + /// 400: план меньше 1 (python L97 «plan_joins должен быть не меньше 1»). + /// + public const string PlanTooSmallDetail = "plan_joins должен быть не меньше 1"; + + /// + /// 400: план больше суточного лимита (python L100). Формат: {plan}, {limit}. + /// + public const string PlanTooBigFormat = "plan_joins {0} больше суточного лимита авто-вступлений ({1})"; + + /// + /// 400: бюджет исчерпан (python L108–110). Формат: {used}, {limit}, {plan}. + /// + public const string BudgetExceededFormat = + "Бюджет авто-вступлений исчерпан: задачи уже занимают {0} из {1} в сутки, ещё {2} не влезает"; + + /// + /// Верхняя граница plan_joins: суточный лимит авто-вступлений (python _plan_limit L80–82). + /// + /// Токен отмены. + /// 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..discJoinLimit (python _assert_plan L94–100). + /// + /// Запрашиваемый план авто-вступлений. + /// Токен отмены. + /// План меньше 1 или больше суточного лимита (тексты python). + 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)); + } + } + + /// + /// Проверяет бюджет: занято активными задачами + новая ≤ discJoinLimit (python _assert_budget L103–111). + /// + /// План создаваемой/итоговый план увеличиваемой задачи. + /// Id задачи, исключаемой из занятого бюджета (patch-рост плана); null — создание. + /// Токен отмены. + /// Бюджет исчерпан (текст python L108–110). + 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/DiscoverySearchErrorCounter.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoverySearchErrorCounter.cs index 4dd4228..de86e6f 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoverySearchErrorCounter.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoverySearchErrorCounter.cs @@ -1,9 +1,5 @@ using System.Collections.Concurrent; 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.Discovery.Application.Registrars; namespace Deal.Modules.Discovery.Application.Services; diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs index cd7a986..cbabe41 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs @@ -1,303 +1,300 @@ -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; - -namespace Deal.Modules.Discovery.Application.Services; - -/// -/// Сервис задач поиска Discovery — create/patch/delete/start/pause/advance/bump (план Task 17, 1:1 discovery.py L234–381). -/// -/// -/// Чистый оркестратор поверх портов (таблицы DiscTasks схемы тенанта), -/// (дефолты discEvalThreshold/discEvalSample) и -/// (план-бюджет, Ruling 9). 404-семантика — null-результат (эндпоинт Task 19 отвечает «Задача не найдена»); -/// 400-семантика — с текстом python (создание без имени/бюджет, -/// start без ключевых слов). Прогресс поиска (SearchIdx/SearchDone/cчётчики) ведут методы -/// и — их зовут кандидатный сервис и воркер (Task 18). -/// -public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGuard planGuard, ISettingsStore settings) -{ - /// - /// 400 create: пустое название после Trim (create_task L242–243). - /// - public const string NameRequiredDetail = "Укажите название задачи"; - - /// - /// 400 start: у задачи нет ключевых слов (start_task L330). - /// - public const string NoKeywordsDetail = "Нет ключевых слов для поиска — добавьте их в задачу"; - - /// - /// Все задачи, старые первыми (list_tasks L189–191; воркер берёт самую старую running). - /// - /// Токен отмены. - /// Задачи в порядке создания. - public Task> ListAsync(CancellationToken ct) - { - return store.ListTasksAsync(ct); - } - - /// - /// Одна задача по id (get_task L194–196). - /// - /// Id задачи (dt_...). - /// Токен отмены. - /// Задача или null (404 «Задача не найдена» у эндпоинта). - public Task GetAsync(string taskId, CancellationToken ct) - { - return store.GetTaskAsync(taskId, ct); - } - - /// - /// Создаёт задачу поиска (create_task L234–282: имя, план 1..discJoinLimit, бюджет активных задач). - /// - /// - /// 1:1 с прототипом: name обязательное (после Trim); planJoins — дефолт 1 + ; - /// threshold/sampleSize — из настроек discEvalThreshold/discEvalSample (дефолты 40/10); границы значений - /// клампятся как _validate_task_values L213–231. Задача создаётся draft, поиск не запущен; ключи ИИ-генерации - /// при создании НЕ запрашиваются (endpoint generate-keywords — Task 19, прототип discovery_routes L189–211). - /// - /// Поля новой задачи (см. ). - /// Токен отмены. - /// Созданная задача (полный 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; - } - - /// - /// Обновляет поля задачи (patch_task L285–311; рост plan_joins — с проверкой бюджета). - /// - /// Поля патча нормализуются как _validate_task_values L213–231 (клампы/Trim); увеличение PlanJoins - /// сверх текущего значения проходит с исключением задачи. - /// Пустой/полностью null патч — возврат текущей задачи без записи (python L289–290). - /// 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()) - { - // python L289–290: пустой патч — возврат текущей задачи без записи (updated_at не бампается). - return current; - } - - await store.PatchTaskAsync(taskId, normalized, ct).ConfigureAwait(false); - return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); - } - - /// - /// Удаляет задачу вместе с кандидатами и логом (delete_task L314–318; чёрный список общий). - /// - /// 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); - } - - /// - /// Запускает поиск (start_task L321–346): keywords непустые; status=running. - /// - /// Повторный старт завершённой/упавшей (done/failed) сбрасывает прогресс поиска (свежий проход по - /// ключам, L332–339); продолжение из paused сохраняет search_idx/счётчики (L342–344). - /// Id задачи (dt_...). - /// Токен отмены. - /// Задача в running либо null (404). - /// Ключевых слов нет (текст start_task L330). - 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); - } - - /// - /// Ставит задачу на паузу (pause_task L349–356; прогресс поиска сохраняется). - /// - /// 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); - } - - /// - /// Увеличивает счётчик прогресса задачи (bump_counter L359–368; n ≤ 0 — no-op, python max(0, n)). - /// - /// 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); - } - - /// - /// Продвигает индекс поиска (advance_search L371–380): search_idx+1; конец ключей → search_done. - /// - /// 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); - } - - // Нормализует патч задачи (1:1 _validate_task_values L213–231: Trim/клампы/язык/ключи). - // patch: Патч из запроса. - // Возвращает: Патч с нормализованными значениями (null-поля сохраняются как «не менять»). - 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, - }; - } - - // Очищает список ключей: Trim + без пустых (python L226–227). - // keywords: Сырые ключи (null — пусто). - // Возвращает: Список непустых ключей. - private static IReadOnlyList CleanKeywords(IReadOnlyList? keywords) - { - return keywords?.Where(keyword => !string.IsNullOrWhiteSpace(keyword)) - .Select(keyword => keyword.Trim()) - .ToList() ?? new List(); - } - - // Кламп значения в границы [min, max] (эталон clamp python). - 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 (план Task 17, 1:1 discovery.py L234–381). +/// +/// +/// Чистый оркестратор поверх портов (таблицы DiscTasks схемы тенанта), +/// (дефолты discEvalThreshold/discEvalSample) и +/// (план-бюджет, Ruling 9). 404-семантика — null-результат (эндпоинт Task 19 отвечает «Задача не найдена»); +/// 400-семантика — с текстом python (создание без имени/бюджет, +/// start без ключевых слов). Прогресс поиска (SearchIdx/SearchDone/cчётчики) ведут методы +/// и — их зовут кандидатный сервис и воркер (Task 18). +/// +public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGuard planGuard, ISettingsStore settings) +{ + /// + /// 400 create: пустое название после Trim (create_task L242–243). + /// + public const string NameRequiredDetail = "Укажите название задачи"; + + /// + /// 400 start: у задачи нет ключевых слов (start_task L330). + /// + public const string NoKeywordsDetail = "Нет ключевых слов для поиска — добавьте их в задачу"; + + /// + /// Все задачи, старые первыми (list_tasks L189–191; воркер берёт самую старую running). + /// + /// Токен отмены. + /// Задачи в порядке создания. + public Task> ListAsync(CancellationToken ct) + { + return store.ListTasksAsync(ct); + } + + /// + /// Одна задача по id (get_task L194–196). + /// + /// Id задачи (dt_...). + /// Токен отмены. + /// Задача или null (404 «Задача не найдена» у эндпоинта). + public Task GetAsync(string taskId, CancellationToken ct) + { + return store.GetTaskAsync(taskId, ct); + } + + /// + /// Создаёт задачу поиска (create_task L234–282: имя, план 1..discJoinLimit, бюджет активных задач). + /// + /// + /// 1:1 с прототипом: name обязательное (после Trim); planJoins — дефолт 1 + ; + /// threshold/sampleSize — из настроек discEvalThreshold/discEvalSample (дефолты 40/10); границы значений + /// клампятся как _validate_task_values L213–231. Задача создаётся draft, поиск не запущен; ключи ИИ-генерации + /// при создании НЕ запрашиваются (endpoint generate-keywords — Task 19, прототип discovery_routes L189–211). + /// + /// Поля новой задачи (см. ). + /// Токен отмены. + /// Созданная задача (полный 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; + } + + /// + /// Обновляет поля задачи (patch_task L285–311; рост plan_joins — с проверкой бюджета). + /// + /// Поля патча нормализуются как _validate_task_values L213–231 (клампы/Trim); увеличение PlanJoins + /// сверх текущего значения проходит с исключением задачи. + /// Пустой/полностью null патч — возврат текущей задачи без записи (python L289–290). + /// 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()) + { + // python L289–290: пустой патч — возврат текущей задачи без записи (updated_at не бампается). + return current; + } + + await store.PatchTaskAsync(taskId, normalized, ct).ConfigureAwait(false); + return await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); + } + + /// + /// Удаляет задачу вместе с кандидатами и логом (delete_task L314–318; чёрный список общий). + /// + /// 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); + } + + /// + /// Запускает поиск (start_task L321–346): keywords непустые; status=running. + /// + /// Повторный старт завершённой/упавшей (done/failed) сбрасывает прогресс поиска (свежий проход по + /// ключам, L332–339); продолжение из paused сохраняет search_idx/счётчики (L342–344). + /// Id задачи (dt_...). + /// Токен отмены. + /// Задача в running либо null (404). + /// Ключевых слов нет (текст start_task L330). + 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); + } + + /// + /// Ставит задачу на паузу (pause_task L349–356; прогресс поиска сохраняется). + /// + /// 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); + } + + /// + /// Увеличивает счётчик прогресса задачи (bump_counter L359–368; n ≤ 0 — no-op, python max(0, n)). + /// + /// 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); + } + + /// + /// Продвигает индекс поиска (advance_search L371–380): search_idx+1; конец ключей → search_done. + /// + /// 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); + } + + // Нормализует патч задачи (1:1 _validate_task_values L213–231: Trim/клампы/язык/ключи). + // patch: Патч из запроса. + // Возвращает: Патч с нормализованными значениями (null-поля сохраняются как «не менять»). + 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, + }; + } + + // Очищает список ключей: Trim + без пустых (python L226–227). + // keywords: Сырые ключи (null — пусто). + // Возвращает: Список непустых ключей. + private static IReadOnlyList CleanKeywords(IReadOnlyList? keywords) + { + return keywords?.Where(keyword => !string.IsNullOrWhiteSpace(keyword)) + .Select(keyword => keyword.Trim()) + .ToList() ?? new List(); + } + + // Кламп значения в границы [min, max] (эталон clamp python). + 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.Constants.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Constants.cs index 962ac34..20f7d5c 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Constants.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Constants.cs @@ -1,9 +1,3 @@ -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.Discovery.Application.Registrars; - namespace Deal.Modules.Discovery.Application.Services; // Часть DiscoveryWorkerService: константы — действия тика, пороги/размеры воркера и метки кандидатов 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 8375134..baeaf77 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Evaluate.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Evaluate.cs @@ -1,240 +1,235 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; - -namespace Deal.Modules.Discovery.Application.Services; - -// Часть DiscoveryWorkerService: шаг оценки кандидата status='new' — инфо источника, выборка, язык, объём -// и оценка содержания (python _eval_step L228–315/_evaluate_content L318–356) + перевод в review. -public sealed partial class DiscoveryWorkerService -{ - // Шаг оценки первого кандидата status='new' (все ветки — одно действие; python _eval_step L228–315). - // task: Running-задача кандидата. - // candidate: Самый старый кандидат статуса new. - // ct: Токен отмены. - // Возвращает: review — кандидат оценён; skip — пропущен (delete); error — сбой шага. - private async Task EvalStepAsync( - DiscoveryTaskDto task, - DiscoveryCandidateDto candidate, - CancellationToken ct) - { - string taskId = task.Id; - string dialogId = candidate.DialogId; - List marks = new(2); - - // ── Инфо об источнике: kind/forum, участники, имя/username (python L234–249) ── - 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) - { - // Имя/username обновляем только при успешном резолве: при fallback discovery_info возвращает - // name=dialog_id и не должен затирать имя (python L244–248). - 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; - - // ── Фильтр minSubscribers (python L252–265) ── - 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); - } - } - - // ── Чтение истории для оценки (python L267–277) ── - 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) - { - // История недоступна без членства: контент не оцениваем, фильтры помечаем (python L269–276). - 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); - } - - // ── Язык (только для ru-задач; python L279–289) ── - 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); - } - } - - // ── Объём выборки: меньше 3 содержательных — решает человек (python L291–296) ── - 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); - } - - // ── Оценка содержания (форумы — по темам; python _evaluate_content L318–356) ── - 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); - } - - // Перевести кандидата в review с метками/оценкой (python _finish_review L154–172; статус пишет лог review). - // taskId: Id задачи. - // dialogId: Id источника. - // marks: Метки (пустые отфильтровываются). - // langRu: Подтверждённый язык (null — не менять). - // fitRatio: Доля подходящих (null — контент не оценён). - // topics: Темы форума (null — не менять; для не-форумов не заполняется). - // ct: Токен отмены. - 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; + +// Часть DiscoveryWorkerService: шаг оценки кандидата status='new' — инфо источника, выборка, язык, объём +// и оценка содержания (python _eval_step L228–315/_evaluate_content L318–356) + перевод в review. +public sealed partial class DiscoveryWorkerService +{ + // Шаг оценки первого кандидата status='new' (все ветки — одно действие; python _eval_step L228–315). + // task: Running-задача кандидата. + // candidate: Самый старый кандидат статуса new. + // ct: Токен отмены. + // Возвращает: review — кандидат оценён; skip — пропущен (delete); error — сбой шага. + private async Task EvalStepAsync( + DiscoveryTaskDto task, + DiscoveryCandidateDto candidate, + CancellationToken ct) + { + string taskId = task.Id; + string dialogId = candidate.DialogId; + List marks = new(2); + + // ── Инфо об источнике: kind/forum, участники, имя/username (python L234–249) ── + 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) + { + // Имя/username обновляем только при успешном резолве: при fallback discovery_info возвращает + // name=dialog_id и не должен затирать имя (python L244–248). + 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; + + // ── Фильтр minSubscribers (python L252–265) ── + 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); + } + } + + // ── Чтение истории для оценки (python L267–277) ── + 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) + { + // История недоступна без членства: контент не оцениваем, фильтры помечаем (python L269–276). + 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); + } + + // ── Язык (только для ru-задач; python L279–289) ── + 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); + } + } + + // ── Объём выборки: меньше 3 содержательных — решает человек (python L291–296) ── + 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); + } + + // ── Оценка содержания (форумы — по темам; python _evaluate_content L318–356) ── + 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); + } + + // Перевести кандидата в review с метками/оценкой (python _finish_review L154–172; статус пишет лог review). + // taskId: Id задачи. + // dialogId: Id источника. + // marks: Метки (пустые отфильтровываются). + // langRu: Подтверждённый язык (null — не менять). + // fitRatio: Доля подходящих (null — контент не оценён). + // topics: Темы форума (null — не менять; для не-форумов не заполняется). + // ct: Токен отмены. + 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 4bbd755..7b93d81 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Helpers.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Helpers.cs @@ -1,53 +1,48 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; - -namespace Deal.Modules.Discovery.Application.Services; - -// Часть DiscoveryWorkerService: общие хелперы — распознавание FloodWait (IsFlood), нормализация kind -// источника (KindCode) и тексты сообщений для оценки (GroupTexts). -public sealed partial class DiscoveryWorkerService -{ - // Flood-ли это исключение гейта: RpcException RESOURCE_EXHAUSTED c detail-префиксом «flood:» - // (контракт telegram.proto, telegram-service L820–826). Модуль чистый (без Grpc.Core), поэтому признак — - // текст исключения: RpcException.Message кодирует Status как - // Status(StatusCode="ResourceExhausted", Detail="flood: FLOOD_WAIT_…") — ищем маркер «flood:» - // (префикс detail, которым сервис помечает FloodWait; обычные ошибки его не несут). - // exception: Исключение вызова гейта. - // Возвращает: True — FloodWait Telegram (стоп до конца суток, note_flood). - 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); - } - - // Нормализация kind источника: channel|group|forum (python _kind_code L99–106; RU-формы каталога). - // kind: Kind из гейта (EN-канон) либо русская форма каталога. - // isForum: True — мегагруппа-форум (темы) → forum. - // Возвращает: Код кандидата: channel/group/forum (неизвестное → group). - 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 +{ + // Flood-ли это исключение гейта: RpcException RESOURCE_EXHAUSTED c detail-префиксом «flood:» + // (контракт telegram.proto, telegram-service L820–826). Модуль чистый (без Grpc.Core), поэтому признак — + // текст исключения: RpcException.Message кодирует Status как + // Status(StatusCode="ResourceExhausted", Detail="flood: FLOOD_WAIT_…") — ищем маркер «flood:» + // (префикс detail, которым сервис помечает FloodWait; обычные ошибки его не несут). + // exception: Исключение вызова гейта. + // Возвращает: True — FloodWait Telegram (стоп до конца суток, note_flood). + 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); + } + + // Нормализация kind источника: channel|group|forum (python _kind_code L99–106; RU-формы каталога). + // kind: Kind из гейта (EN-канон) либо русская форма каталога. + // isForum: True — мегагруппа-форум (темы) → forum. + // Возвращает: Код кандидата: channel/group/forum (неизвестное → group). + 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 3c2663b..8a7755c 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Join.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Join.cs @@ -1,155 +1,150 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; - -namespace Deal.Modules.Discovery.Application.Services; - -// Часть DiscoveryWorkerService: шаг авто-вступления кандидата status='review' — повторные проверки, -// пауза, join, обработка FloodWait/неудач (python _join_step L359–439). -public sealed partial class DiscoveryWorkerService -{ - // Шаг авто-вступления одного кандидата status='review' (python _join_step L359–439). - // task: Running-задача с autoJoin. - // candidate: Самый старый кандидат статуса review. - // ct: Токен отмены. - // Возвращает: join/reject/flood/error/skip — действие; none — выход без вступления (изменения за паузу). - private async Task JoinStepAsync( - DiscoveryTaskDto task, - DiscoveryCandidateDto candidate, - CancellationToken ct) - { - string taskId = task.Id; - string dialogId = candidate.DialogId; - - // Между оценкой и вступлением могли вступить/отклонить источник (python L364–374). - 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) - { - // Кандидат уже joined (например, вступили вручную) — reject невозможен: пропускаем (python suppress). - } - - return new DiscoveryWorkerOutcome(ActionReject, taskId); - } - - // Спейсинг авто-вступлений (сек из настроек discJoinDelayMin/Max; python L376–377). - await _pacer.WaitJoinDelayAsync(ct).ConfigureAwait(false); - - // За время паузы задача/кандидат/состояние BanGuard могли измениться: вступаем только если кандидат - // всё ещё есть и в review, задача ещё running с autoJoin, мы не состоим и авто-вступления разрешены - // (стоп-кран/flood/лимит могли включиться во время паузы) — иначе выходим без join (python L379–394). - 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)) - { - // FloodWait: стоп авто-вступлений до конца суток; кандидат остаётся review (python L399–402). - await _banGuard.NoteFloodAsync(ct).ConfigureAwait(false); - await _log.AddAsync( - taskId, - DiscoveryLogEvents.Flood, - $"авто-вступление {dialogId}: flood — стоп до конца суток", - ct).ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionFlood, taskId); - } - - // Между паузой и неудачным join кандидата могли отклонить/удалить: счётчик и удаление трогаем - // только у живой записи в статусе review (python L404–410). - 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); - } - - // Вступление состоялось: joined(auto) + каталог/зеркало (монитор on) + фоновый разбор последних - // сообщений (backfill; сбой не роняет шаг) + снятие чёрного списка (python L424–438). - 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) - { - // Вступление уже состоялось — сбой backfill не роняет шаг (python L434–437: log.warning), - // но фиксируется в DiscLog, иначе «немое» глотание скрывает регулярные сбои. - 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; + +// Часть DiscoveryWorkerService: шаг авто-вступления кандидата status='review' — повторные проверки, +// пауза, join, обработка FloodWait/неудач (python _join_step L359–439). +public sealed partial class DiscoveryWorkerService +{ + // Шаг авто-вступления одного кандидата status='review' (python _join_step L359–439). + // task: Running-задача с autoJoin. + // candidate: Самый старый кандидат статуса review. + // ct: Токен отмены. + // Возвращает: join/reject/flood/error/skip — действие; none — выход без вступления (изменения за паузу). + private async Task JoinStepAsync( + DiscoveryTaskDto task, + DiscoveryCandidateDto candidate, + CancellationToken ct) + { + string taskId = task.Id; + string dialogId = candidate.DialogId; + + // Между оценкой и вступлением могли вступить/отклонить источник (python L364–374). + 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) + { + // Кандидат уже joined (например, вступили вручную) — reject невозможен: пропускаем (python suppress). + } + + return new DiscoveryWorkerOutcome(ActionReject, taskId); + } + + // Спейсинг авто-вступлений (сек из настроек discJoinDelayMin/Max; python L376–377). + await _pacer.WaitJoinDelayAsync(ct).ConfigureAwait(false); + + // За время паузы задача/кандидат/состояние BanGuard могли измениться: вступаем только если кандидат + // всё ещё есть и в review, задача ещё running с autoJoin, мы не состоим и авто-вступления разрешены + // (стоп-кран/flood/лимит могли включиться во время паузы) — иначе выходим без join (python L379–394). + 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)) + { + // FloodWait: стоп авто-вступлений до конца суток; кандидат остаётся review (python L399–402). + await _banGuard.NoteFloodAsync(ct).ConfigureAwait(false); + await _log.AddAsync( + taskId, + DiscoveryLogEvents.Flood, + $"авто-вступление {dialogId}: flood — стоп до конца суток", + ct).ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionFlood, taskId); + } + + // Между паузой и неудачным join кандидата могли отклонить/удалить: счётчик и удаление трогаем + // только у живой записи в статусе review (python L404–410). + 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); + } + + // Вступление состоялось: joined(auto) + каталог/зеркало (монитор on) + фоновый разбор последних + // сообщений (backfill; сбой не роняет шаг) + снятие чёрного списка (python L424–438). + 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) + { + // Вступление уже состоялось — сбой backfill не роняет шаг (python L434–437: log.warning), + // но фиксируется в DiscLog, иначе «немое» глотание скрывает регулярные сбои. + 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 ce7f171..c55456b 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Search.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Search.cs @@ -1,106 +1,101 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; - -namespace Deal.Modules.Discovery.Application.Services; - -// Часть DiscoveryWorkerService: шаг поиска — один ключ keywords[SearchIdx] → кандидаты + advance_search -// (python _search_step L177–225) и лог завершения прохода. -public sealed partial class DiscoveryWorkerService -{ - // Шаг поиска: один ключ keywords[SearchIdx] → кандидаты + advance_search (python _search_step L177–225). - // task: Running-задача с незавершённым проходом. - // ct: Токен отмены. - // Возвращает: search — ключ обработан; flood/error — сбой (индекс ключа/счётчик ошибок по сценарию). - 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) - { - // Ключи закончились/пустой список: закрываем проход без сетевого вызова (python _close_search L139–144). - 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) - { - // 3 ошибки подряд одного ключа: пропускаем (битый ключ не должен зацикливать поиск и - // блокировать оценку/вступления других задач; python L196–203). - _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") - { - // Люди/личные чаты и боты глобальным поиском не предлагаются (python L211–214). - 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); - } - - // Лог завершения поиска, если advance_search перевёл задачу в searchDone (python L147–151). - 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; + +// Часть DiscoveryWorkerService: шаг поиска — один ключ keywords[SearchIdx] → кандидаты + advance_search +// (python _search_step L177–225) и лог завершения прохода. +public sealed partial class DiscoveryWorkerService +{ + // Шаг поиска: один ключ keywords[SearchIdx] → кандидаты + advance_search (python _search_step L177–225). + // task: Running-задача с незавершённым проходом. + // ct: Токен отмены. + // Возвращает: search — ключ обработан; flood/error — сбой (индекс ключа/счётчик ошибок по сценарию). + 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) + { + // Ключи закончились/пустой список: закрываем проход без сетевого вызова (python _close_search L139–144). + 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) + { + // 3 ошибки подряд одного ключа: пропускаем (битый ключ не должен зацикливать поиск и + // блокировать оценку/вступления других задач; python L196–203). + _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") + { + // Люди/личные чаты и боты глобальным поиском не предлагаются (python L211–214). + 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); + } + + // Лог завершения поиска, если advance_search перевёл задачу в searchDone (python L147–151). + 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 0c39767..d7b1f05 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs @@ -1,182 +1,178 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Abstractions; -using Deal.Modules.Discovery.Application.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Registrars; - -namespace Deal.Modules.Discovery.Application.Services; - -/// -/// Воркер Discovery: поиск → оценка → авто-вступление, один шаг за тик (1:1 discovery_worker.py целиком, план Task 18, Ruling 10). -/// -/// -/// выполняет ОДНО действие для самой старой running-задачи и возвращает -/// {action, taskId}: «search»|«review»|«skip»|«join»|«reject»|«flood»|«error»|«done»|«none» (python L444–484). -/// Приоритеты внутри тика: -/// -/// стоп-краны: и flood-день — никаких действий (none); -/// задача достигла плана вступлений (joined ≥ planJoins) → status=done + лог done (занимаемый бюджет -/// планов освобождается сразу); -/// поиск: следующая задача с незавершённым проходом по ключам — один ключ keywords[searchIdx] → -/// gateway.SearchAsync (личные чаты/боты пропускаются с логом skip), каждый результат — add_candidate; -/// advance_search; после 3 ошибок подряд ключ пропускается (битый ключ не зацикливает поиск); -/// оценка первого кандидата status='new': gateway.InfoAsync (kind/forum/участники; minSubscribers → -/// delete+skip; участники не определены — метка), gateway.ReadForEvalAsync (история недоступна → review с -/// меткой «канал…»/«закрытая группа…»), язык ru-задач (не русский → delete+skip; неопределённо — метка), -/// <3 сообщений → метка «мало сообщений», оценка содержания (форумы — по -/// темам через GroupByTopic; вердикт — есть проходная тема) → review с fitRatio/topics либо delete+skip; -/// авто-вступление первого кандидата status='review' задачи с autoJoin (ниже оценки): повторная проверка -/// «не состоим» (Dialogs/чёрный список — mark_rejected с причиной), пауза -/// (50–70 с), после паузы — повторная перепроверка (кандидат/задача/BanGuard могли измениться) → join; -/// FloodWait → note_flood + лог flood (кандидат остаётся review); прочая ошибка → join_failures+1, после 3 -/// неудач кандидат удаляется (лог skip); успех → mark_joined(auto) + монитор-зеркало (SetMonitorAsync) + -/// фоновый Backfill (сбой не роняет шаг) + remove_blacklist. -/// -/// Метки кандидата (marks) — строки-чипы (1:1 python L85–89); темы форума (topics) — только для kind=forum -/// (L336–344). Все логи — через (DiscLog), счётчики прогресса — bump задачи. -/// Чистый модуль: сетевые вызовы только через (Contracts), без gRPC-типов. -/// -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-воркера (см. doc-комментарий типа и discovery_worker.tick L444–484). - /// - /// Токен отмены. - /// Результат действия: действие + id задачи (если применимо). - public async Task TickOnceAsync(CancellationToken ct) - { - if (await _banGuard.GlobalPausedAsync(ct).ConfigureAwait(false) - || await _banGuard.FloodTodayAsync(ct).ConfigureAwait(false)) - { - // Стоп-кран/flood-день: никаких сетевых действий (поиск/оценка/join) — воркер просто стоит (python L446–451). - 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; - } - - // 1. План достигнут — закрываем задачу (важно до поиска/оценки/join: задачу с выполненным планом - // нельзя продолжать обрабатывать; python L458–461). - foreach (DiscoveryTaskDto task in running) - { - if (task.Joined >= task.PlanJoins) - { - await FinishDoneAsync(task, ct).ConfigureAwait(false); - return new DiscoveryWorkerOutcome(ActionDone, task.Id); - } - } - - // 2. Поиск: следующая running-задача с незавершённым проходом по ключам (python L463–466). - foreach (DiscoveryTaskDto task in running) - { - if (!task.SearchDone) - { - return await SearchStepAsync(task, ct).ConfigureAwait(false); - } - } - - // 3. Оценка: первый кандидат status='new' (самая старая задача — первой; python L468–472). - 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); - } - } - - // 4. Авто-вступление: отдельный проход, приоритет ниже оценки (python L474–482). - 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)) - { - // Суточный лимит/flood/пауза — join никому нельзя (python L480–481). - return DiscoveryWorkerOutcome.None; - } - - return await JoinStepAsync(task, reviewCandidates[0], ct).ConfigureAwait(false); - } - - return DiscoveryWorkerOutcome.None; - } - - // Задача выполнила план вступлений: status=done + лог done (python _finish_done L126–136). - 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: поиск → оценка → авто-вступление, один шаг за тик (1:1 discovery_worker.py целиком, план Task 18, Ruling 10). +/// +/// +/// выполняет ОДНО действие для самой старой running-задачи и возвращает +/// {action, taskId}: «search»|«review»|«skip»|«join»|«reject»|«flood»|«error»|«done»|«none» (python L444–484). +/// Приоритеты внутри тика: +/// +/// стоп-краны: и flood-день — никаких действий (none); +/// задача достигла плана вступлений (joined ≥ planJoins) → status=done + лог done (занимаемый бюджет +/// планов освобождается сразу); +/// поиск: следующая задача с незавершённым проходом по ключам — один ключ keywords[searchIdx] → +/// gateway.SearchAsync (личные чаты/боты пропускаются с логом skip), каждый результат — add_candidate; +/// advance_search; после 3 ошибок подряд ключ пропускается (битый ключ не зацикливает поиск); +/// оценка первого кандидата status='new': gateway.InfoAsync (kind/forum/участники; minSubscribers → +/// delete+skip; участники не определены — метка), gateway.ReadForEvalAsync (история недоступна → review с +/// меткой «канал…»/«закрытая группа…»), язык ru-задач (не русский → delete+skip; неопределённо — метка), +/// <3 сообщений → метка «мало сообщений», оценка содержания (форумы — по +/// темам через GroupByTopic; вердикт — есть проходная тема) → review с fitRatio/topics либо delete+skip; +/// авто-вступление первого кандидата status='review' задачи с autoJoin (ниже оценки): повторная проверка +/// «не состоим» (Dialogs/чёрный список — mark_rejected с причиной), пауза +/// (50–70 с), после паузы — повторная перепроверка (кандидат/задача/BanGuard могли измениться) → join; +/// FloodWait → note_flood + лог flood (кандидат остаётся review); прочая ошибка → join_failures+1, после 3 +/// неудач кандидат удаляется (лог skip); успех → mark_joined(auto) + монитор-зеркало (SetMonitorAsync) + +/// фоновый Backfill (сбой не роняет шаг) + remove_blacklist. +/// +/// Метки кандидата (marks) — строки-чипы (1:1 python L85–89); темы форума (topics) — только для kind=forum +/// (L336–344). Все логи — через (DiscLog), счётчики прогресса — bump задачи. +/// Чистый модуль: сетевые вызовы только через (Contracts), без gRPC-типов. +/// +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-воркера (см. doc-комментарий типа и discovery_worker.tick L444–484). + /// + /// Токен отмены. + /// Результат действия: действие + id задачи (если применимо). + public async Task TickOnceAsync(CancellationToken ct) + { + if (await _banGuard.GlobalPausedAsync(ct).ConfigureAwait(false) + || await _banGuard.FloodTodayAsync(ct).ConfigureAwait(false)) + { + // Стоп-кран/flood-день: никаких сетевых действий (поиск/оценка/join) — воркер просто стоит (python L446–451). + 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; + } + + // 1. План достигнут — закрываем задачу (важно до поиска/оценки/join: задачу с выполненным планом + // нельзя продолжать обрабатывать; python L458–461). + foreach (DiscoveryTaskDto task in running) + { + if (task.Joined >= task.PlanJoins) + { + await FinishDoneAsync(task, ct).ConfigureAwait(false); + return new DiscoveryWorkerOutcome(ActionDone, task.Id); + } + } + + // 2. Поиск: следующая running-задача с незавершённым проходом по ключам (python L463–466). + foreach (DiscoveryTaskDto task in running) + { + if (!task.SearchDone) + { + return await SearchStepAsync(task, ct).ConfigureAwait(false); + } + } + + // 3. Оценка: первый кандидат status='new' (самая старая задача — первой; python L468–472). + 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); + } + } + + // 4. Авто-вступление: отдельный проход, приоритет ниже оценки (python L474–482). + 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)) + { + // Суточный лимит/flood/пауза — join никому нельзя (python L480–481). + return DiscoveryWorkerOutcome.None; + } + + return await JoinStepAsync(task, reviewCandidates[0], ct).ConfigureAwait(false); + } + + return DiscoveryWorkerOutcome.None; + } + + // Задача выполнила план вступлений: status=done + лог done (python _finish_done L126–136). + 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.Kanban/Application/Abstractions/ICardStore.cs b/src/core/Deal.Modules.Kanban/Application/Abstractions/ICardStore.cs index 15224aa..06b0e8d 100644 --- a/src/core/Deal.Modules.Kanban/Application/Abstractions/ICardStore.cs +++ b/src/core/Deal.Modules.Kanban/Application/Abstractions/ICardStore.cs @@ -1,453 +1,450 @@ -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; - -namespace Deal.Modules.Kanban.Application.Abstractions; - -/// -/// Единый порт хранилища карточек и контейнеров (таблицы Cards/Containers/LeadComments/CardMoves тенанта; -/// жёсткое удаление карточки дополнительно чистит строки DedupEntries — Ruling 3), Ruling 1. -/// -/// -/// Объявлен в модуле Kanban — владельце единой сущности карточки (этап 9); реализация — EF-адаптер -/// KanbanStore в Deal.Infrastructure (регистрация в AddDealPersistence). Порт покрывает оба -/// пространства одной таблицы Cards: дашборд (служебные зоны/доски) и «Выбранные» (контейнеры-стадии), -/// поэтому отдельный порт карточек «Выбранных» упразднён. Порт оперирует DTO модуля; маппинг -/// DTO ↔ строки (включая JSON-поля и human-метки времени, Ruling 10) выполняет адаптер вручную -/// (эталон SettingsStore.cs). Набор методов — ровно тот, что нужен сервису карточек (YAGNI): -/// контейнеры, карточки и переносы, комментарии, журнал CardMoves, кандидаты правил хранения (Ruling 8), -/// пересчёт конверсий (Ruling 7) и выборка «Неразобранного» для эвристики (Ruling 3). -/// Id записей генерирует модуль (Ruling 12: короткие префиксные id через KanbanIdPrefixes) и передаёт -/// готовыми — хранилище id не создаёт. Чтения — AsNoTracking; сортировки (received_at DESC и т.п.) — -/// обязанность адаптера. Удаление карточки навсегда (DeleteForeverAsync/ClearColAsync/PurgeAsync) -/// снимает и «мягкие» dedup-ссылки (Ruling 3): строки таблицы DedupEntries чужого модуля адаптер удаляет -/// напрямую тем же TenantDbContext, цикла Kanban → Pipeline не возникает (Kanban о Pipeline не знает). -/// -public interface ICardStore -{ - // ── Контейнеры (колонки/стадии/зоны) ───────────────────────────────── - - /// - /// Контейнеры пространства в порядке показа: ORDER BY space, position (этап 9, T4). - /// - /// Пространство (dashboard/selected) либо null — все контейнеры. - /// Токен отмены. - /// Контейнеры (DTO, счётчики заполняет сервис); пусто — контейнеров нет. - public Task> ListContainersAsync(string? space, CancellationToken ct); - - /// - /// Один контейнер по id (PATCH 404-семантика, переносы и валидация колонок). - /// - /// Id контейнера (inbox/archive/trash/стадия/b_...). - /// Токен отмены. - /// Контейнер или null, если строки нет. - public Task GetContainerAsync(string containerId, CancellationToken ct); - - /// - /// Создаёт контейнер (id/позицию/цвет/дефолты вычисляет ContainersService). - /// - /// Полный контейнер для вставки, включая . - /// Токен отмены. - public Task CreateContainerAsync(ContainerDto container, CancellationToken ct); - - /// - /// Обновляет контейнер целиком (сервис читает Get + применяет ContainerPatchDto). - /// - /// Контейнер с изменёнными полями (идентифицируется по Id). - /// Токен отмены. - public Task UpdateContainerAsync(ContainerDto container, CancellationToken ct); - - /// - /// Удаляет контейнер, предварительно перенося его карточки в «Неразобранное». - /// - /// Id удаляемого контейнера. - /// Токен отмены. - /// Сколько карточек перенесено в inbox (0 — контейнера/карточек не было; ответ {ok, movedToInbox}). - public Task DeleteContainerAsync(string containerId, CancellationToken ct); - - /// - /// Переставляет контейнеры пространства: позиции 0..N-1 в порядке списка. - /// - /// Пространство переставляемых контейнеров. - /// Id контейнеров в новом порядке. - /// Токен отмены. - public Task ReorderContainersAsync( - string space, - IReadOnlyList containerIds, - CancellationToken ct); - - // ── Карточки ────────────────────────────────────────────────────────── - - /// - /// Карточки колонки или всех колонок дашборда, ORDER BY received_at DESC (list_leads L151–156). - /// - /// Фильтр: Col — конкретная колонка либо null (все колонки дашборда). - /// Токен отмены. - /// Полные карточки (DTO): JSON-поля разобраны, комментарии приложены, time посчитан. - public Task> ListCardsAsync(CardsQuery query, CancellationToken ct); - - /// - /// Карточки пространства «Выбранные», ORDER BY updated_at DESC (list_cards projects.py L58–63). - /// - /// Фильтр по контейнеру-стадии (id каталога ); null — все стадии. - /// Токен отмены. - /// Полные карточки (DTO): JSON-поля разобраны, комментарии приложены, time посчитан. - public Task> ListSelectedCardsAsync(string? containerId, CancellationToken ct); - - /// - /// Полнотекстовый поиск карточек (FTS + LIKE-дополнение; leads.py search L509–551, Ruling 6/Task 12). - /// - /// Поисковый запрос (уже Trim+lowercase, как в отсев-поиске; короче 2 символов сервис не пропускает). - /// Ограничение результата (эндпоинт шлёт 12, Ruling 6). - /// Токен отмены. - /// Полные карточки (комментарии приложены, time посчитан): FTS-кандидаты по - /// убыванию ts_rank (SearchTsv @@ plainto_tsquery), затем LIKE-дополнение, внутри — ReceivedAt DESC. - public Task> SearchCardsAsync( - string q, - int limit, - CancellationToken ct); - - /// - /// Одна карточка по id (GET /api/cards/{id}, а также перечитывание после переноса). - /// - /// Id карточки (c_...). - /// Токен отмены. - /// Карточка или null, если строки нет. - public Task GetCardAsync(string cardId, CancellationToken ct); - - /// - /// Карточка по исходному сообщению (диалог + id сообщения) — ручная разметка ML (§8). - /// - /// Если сообщение давало несколько карточек (повторная разметка/пересоздание), берётся самая - /// свежая по ReceivedAt. Пустой dialogId — null (нечем искать). - /// Id диалога-источника сообщения. - /// Id исходного сообщения в Telegram. - /// Токен отмены. - /// Карточка или null, если сообщение не становилось карточкой. - public Task GetCardBySourceAsync( - string dialogId, - long msgId, - CancellationToken ct); - - /// - /// Создаёт карточку из готового снимка (пайплайн этапа 4, ручное создание; CreatedAt — UTC-now). - /// - /// Полное состояние новой карточки (id сгенерирован модулем, см. CardSnapshot). - /// Токен отмены. - public Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct); - - /// - /// Точечная правка полей по присутствующим в патче + bump UpdatedAt (patch_card projects.py L159–187). - /// - /// Id карточки (c_...). - /// Изменяемые поля (null — поле не меняется; JSON-поля — полная замена). - /// Токен отмены. - /// True — строка обновлена; false — карточки нет (404-семантика сервиса). - public Task PatchCardAsync( - string cardId, - CardPatch patch, - CancellationToken ct); - - /// - /// Атомарно дописывает ссылку в JSON-массив links ОДНИМ UPDATE (jsonb-append) + bump UpdatedAt. - /// - /// Id карточки (c_...). - /// Готовая ссылка {id,name,url} (id сгенерирован модулем). - /// Токен отмены. - /// True — строка обновлена; false — карточки нет (404-семантика сервиса). - public Task AddLinkAsync( - string cardId, - CardLinkDto link, - CancellationToken ct); - - /// - /// Атомарно убирает из JSON-массива links элемент с указанным id ОДНИМ UPDATE (jsonb-фильтрация) + bump UpdatedAt. - /// - /// Id карточки (c_...). - /// Id удаляемой ссылки (pl_...). - /// Токен отмены. - /// True — строка обновлена; false — карточки нет (404-семантика сервиса). - public Task RemoveLinkAsync( - string cardId, - string linkId, - CancellationToken ct); - - /// - /// Атомарно дописывает метаданные файла в JSON-массив files ОДНИМ UPDATE (jsonb-append) + bump UpdatedAt. - /// - /// 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 (jsonb-фильтрация) + bump UpdatedAt. - /// - /// Id карточки (c_...). - /// Id удаляемой записи файла (pf_...). - /// Токен отмены. - /// True — строка обновлена; false — карточки нет (404-семантика сервиса). - public Task RemoveFileAsync( - string cardId, - string fileId, - CancellationToken ct); - - /// - /// Смена контейнера-стадии: один UPDATE (col, reminder_at=NULL, reminder_fired=false, updated_at=atMs) - /// + перезапись history-массива с добавленной записью (move_stage projects.py L202–216; Ruling 7). - /// - /// 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); - - /// - /// Устанавливает напоминание: reminder_at, reminder_fired=false + bump UpdatedAt (set_reminder projects.py L236–243). - /// - /// Id карточки (c_...). - /// Время напоминания, epoch-ms. - /// Токен отмены. - public Task SetReminderAsync( - string cardId, - long atMs, - CancellationToken ct); - - /// - /// Снимает напоминание: reminder_at=NULL, reminder_fired=false (clear_reminder projects.py L246–247). - /// - /// Id карточки (c_...). - /// Токен отмены. - public Task ClearReminderAsync(string cardId, CancellationToken ct); - - /// - /// Полная ручная очистка контейнера-стадии: DELETE строк (clear_stage projects.py L223–231). - /// - /// Очищаемая стадия (допустимость — только rejected — валидирует сервис). - /// Токен отмены. - /// Сколько карточек удалено (0 — стадия пуста). - public Task ClearStageAsync(string containerId, CancellationToken ct); - - /// - /// Наступившие напоминания стадии hold: ReminderAt ≤ now, ReminderFired=false, ORDER BY ReminderAt (check_reminders projects.py L270–275). - /// - /// Текущий момент (UTC) для сравнения с ReminderAt. - /// Токен отмены. - /// Due-строки {id,title,containerId} в порядке наступления; пусто — сработавших нет. - public Task> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct); - - /// - /// Помечает due-карточки сработавшими: ReminderFired=true по списку id (check_reminders projects.py L277–278). - /// - /// Id карточек, чьи напоминания «выстрелили». - /// Токен отмены. - public Task MarkRemindersFiredAsync(IReadOnlyList cardIds, CancellationToken ct); - - /// - /// Очищает протухшие напоминания при выключенной настройке: ReminderAt=NULL WHERE ReminderAt ≤ now (check_reminders L266–269). - /// - /// Текущий момент (UTC). - /// Токен отмены. - /// Сколько строк очищено. - public Task ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct); - - /// - /// Меняет колонку/состояние карточки (move/trash/restore/автоархив) — см. CardColumnUpdateDto. - /// - /// Новое состояние колонки карточки (matchHits пересчитан в модуле, Ruling 2). - /// Токен отмены. - public Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct); - - /// - /// Применяет результат ручной переклассификации к существующей карточке ОДНИМ обновлением - /// (leads.py reclassify_lead L346–367): тип/заголовок/суть/стек/бюджет/конверсия/контакты/matchHits + колонка. - /// - /// Поля классификации (полная замена; см. CardReclassificationDto). - /// Токен отмены. - /// True — строка обновлена; false — карточки нет (404-семантика сервиса). - public Task ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct); - - /// - /// Снимает флаг «новое»: с одной карточки (cardId), с колонки (col) или со всех (оба null) — leads.py mark_seen L250–256. - /// - /// Id карточки либо null. - /// Колонка либо null. - /// Токен отмены. - public Task UpdateSeenAsync( - string? cardId, - string? col, - CancellationToken ct); - - /// - /// Удаляет карточку навсегда: Cards + комментарии (FK cascade) + строки дедупа карточки - /// (DedupEntries WHERE LeadId=?, Ruling 3); журнал/outbox не трогает (leads.py _hard_delete L225–234). - /// - /// Id карточки. - /// Токен отмены. - public Task DeleteForeverAsync(string cardId, CancellationToken ct); - - /// - /// Полная очистка служебной колонки (только trash|archive — валидирует сервис), leads.py clear_col L237–247; - /// строки дедупа удаляемых карточек чистятся вместе с ними (Ruling 3). - /// - /// Очищаемая колонка (trash/archive). - /// Токен отмены. - /// Сколько карточек удалено (0 — колонка пуста). - public Task ClearColAsync(string col, CancellationToken ct); - - /// - /// Счётчики карточек по колонкам (count + new) для GET /api/cards/counts (leads.py counts L268–279). - /// - /// Токен отмены. - /// Словарь col → {count, new}; ключи — только колонки с карточками. - public Task> CountCardsByColAsync(CancellationToken ct); - - // ── Комментарии (LeadComments) ──────────────────────────────────────── - - /// - /// Комментарии карточки (для ответа add-comment и карточки; сортировка по времени добавления). - /// - /// Id карточки. - /// Токен отмены. - /// Комментарии (time — human-метка от CreatedAt); пусто — комментариев нет. - public Task> ListCommentsAsync(string cardId, CancellationToken ct); - - /// - /// Добавляет комментарий (leads.py add_comment L259–265; CreatedAt — UTC-now). - /// - /// Готовый id (cm_..., генерирует модуль). - /// Id карточки. - /// Автор («Вы» — свои комментарии). - /// Текст (непустой — валидирует сервис, 400 «Пустой комментарий»). - /// Токен отмены. - public Task AddCommentAsync( - string commentId, - string cardId, - string by, - string text, - CancellationToken ct); - - // ── Журнал действий (CardMoves = learning_log) ──────────────────────── - - /// - /// Пишет строку журнала действия пользователя (move/trash/restore/comment) — leads.py _log_learning L40–44. - /// - /// Запись журнала (id lm_... сгенерирован модулем). - /// Токен отмены. - public Task AddMoveAsync(CardMoveDto move, CancellationToken ct); - - /// - /// Число записей журнала = счётчик learning (Ruling 4, Task 5 StatusAsync). - /// - /// Токен отмены. - /// Количество строк CardMoves. - public Task CountMovesAsync(CancellationToken ct); - - /// - /// Свежие примеры разметки пользователя для few-shot ИИ-классификации (pipeline.py _learning_examples L201–215). - /// - /// - /// Join журнала CardMoves с карточками (Cards.SourceMsg): действия move/restore, цель не служебная - /// (trash/archive), исходный текст непустой; ORDER BY created_at DESC, ≤ limit записей. Используется - /// контекст-билдером классификации (план Task 15, Ruling 5) — текст примера дополнительно режется - /// вызывающим до 500 кодовых точек (python L214). - /// - /// Максимум примеров (python L201: 8). - /// Токен отмены. - /// Примеры «текст → колонка», свежие первыми; пусто — истории разметки нет. - public Task> GetAiMarkupExamplesAsync(int limit, CancellationToken ct); - - // ── Правила хранения (тик, Ruling 8) ────────────────────────────────── - - /// - /// Кандидаты на автоархив: карточки досок и «Неразобранного» со ReceivedAt старше срока (tick_storage L462–467). - /// - /// Граница: received_at < now − archiveAfterDays. - /// Токен отмены. - /// Id карточек-кандидатов (колонка при архивации пишется archive через UpdateColumnAsync). - public Task> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct); - - /// - /// Архивирует пачку карточек ОДНИМ UPDATE: col=archive, is_new=false, archived_at=archivedAt, - /// matchHits — пустой массив (тик tick_storage L462–473; batch-замена по-карточных UpdateColumnAsync, - /// PrevCol при архивации не трогается — Ruling 8). - /// - /// Id карточек-кандидатов (список из ListArchiveCandidatesAsync). - /// Момент архивации (один «now» тика). - /// Токен отмены. - /// Сколько карточек реально архивировано (0 — кандидатов не было/уже не в рабочих колонках). - public Task ArchiveAsync( - IReadOnlyList cardIds, - DateTimeOffset archivedAt, - CancellationToken ct); - - /// - /// Кандидаты на очистку архива: col='archive' и ArchivedAt старше срока (tick_storage L475–478). - /// - /// Граница: archived_at < now − archiveClearDays. - /// Токен отмены. - /// Id карточек для жёсткого удаления. - public Task> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct); - - /// - /// Кандидаты на очистку корзины: col='trash' и ReceivedAt старше срока (tick_storage L480–483). - /// - /// Граница: received_at < now − trashClearDays. - /// Токен отмены. - /// Id карточек для жёсткого удаления. - public Task> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct); - - /// - /// Жёстко удаляет пачку карточек: Cards + комментарии (FK cascade) + строки дедупа карточек - /// (DedupEntries WHERE LeadId IN …, Ruling 3) — для очисток тика и clear-col (Ruling 8). - /// - /// Id карточек на удаление. - /// Токен отмены. - /// Сколько карточек удалено. - public Task PurgeAsync(IReadOnlyList cardIds, CancellationToken ct); - - // ── Пересчёт конверсий (Ruling 7, Task 12) ───────────────────────────── - - /// - /// Карточки для пересчёта ConvFrom/ConvTo/ConvCur: budgetCur непуст и col NOT IN (archive, trash) — recompute_conversions L106–130. - /// - /// Токен отмены. - /// Карточки с бюджетом (используются Budget/бюджетные поля; служебные колонки исключены). - public Task> ListCardsForConversionAsync(CancellationToken ct); - - /// - /// Записывает пересчитанную конверсию бюджета карточки (обновляет только conv-поля). - /// - /// Id карточки. - /// Сконвертированная нижняя граница, либо null. - /// Сконвертированная верхняя граница, либо null. - /// Валюта конверсии (целевая валюта тенанта); пусто — конверсия снята. - /// Токен отмены. - public Task UpdateConversionAsync( - string cardId, - double? convFrom, - double? convTo, - string convCur, - CancellationToken ct); - - // ── Эвристика ИИ-предложений (Ruling 3, Task 14) ─────────────────────── - - /// - /// Карточки «Неразобранного» с исходным текстом — вход эвристики suggest (suggest.py, Ruling 3). - /// - /// Токен отмены. - /// Карточки col='inbox' с непустым SourceMsg (частотные темы считаются по source_msg). - public Task> ListInboxWithSourceAsync(CancellationToken ct); -} +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Modules.Kanban.Application.Abstractions; + +/// +/// Единый порт хранилища карточек и контейнеров (таблицы Cards/Containers/LeadComments/CardMoves тенанта; +/// жёсткое удаление карточки дополнительно чистит строки DedupEntries — Ruling 3), Ruling 1. +/// +/// +/// Объявлен в модуле Kanban — владельце единой сущности карточки (этап 9); реализация — EF-адаптер +/// KanbanStore в Deal.Infrastructure (регистрация в AddDealPersistence). Порт покрывает оба +/// пространства одной таблицы Cards: дашборд (служебные зоны/доски) и «Выбранные» (контейнеры-стадии), +/// поэтому отдельный порт карточек «Выбранных» упразднён. Порт оперирует DTO модуля; маппинг +/// DTO ↔ строки (включая JSON-поля и human-метки времени, Ruling 10) выполняет адаптер вручную +/// (эталон SettingsStore.cs). Набор методов — ровно тот, что нужен сервису карточек (YAGNI): +/// контейнеры, карточки и переносы, комментарии, журнал CardMoves, кандидаты правил хранения (Ruling 8), +/// пересчёт конверсий (Ruling 7) и выборка «Неразобранного» для эвристики (Ruling 3). +/// Id записей генерирует модуль (Ruling 12: короткие префиксные id через KanbanIdPrefixes) и передаёт +/// готовыми — хранилище id не создаёт. Чтения — AsNoTracking; сортировки (received_at DESC и т.п.) — +/// обязанность адаптера. Удаление карточки навсегда (DeleteForeverAsync/ClearColAsync/PurgeAsync) +/// снимает и «мягкие» dedup-ссылки (Ruling 3): строки таблицы DedupEntries чужого модуля адаптер удаляет +/// напрямую тем же TenantDbContext, цикла Kanban → Pipeline не возникает (Kanban о Pipeline не знает). +/// +public interface ICardStore +{ + // ── Контейнеры (колонки/стадии/зоны) ───────────────────────────────── + + /// + /// Контейнеры пространства в порядке показа: ORDER BY space, position (этап 9, T4). + /// + /// Пространство (dashboard/selected) либо null — все контейнеры. + /// Токен отмены. + /// Контейнеры (DTO, счётчики заполняет сервис); пусто — контейнеров нет. + public Task> ListContainersAsync(string? space, CancellationToken ct); + + /// + /// Один контейнер по id (PATCH 404-семантика, переносы и валидация колонок). + /// + /// Id контейнера (inbox/archive/trash/стадия/b_...). + /// Токен отмены. + /// Контейнер или null, если строки нет. + public Task GetContainerAsync(string containerId, CancellationToken ct); + + /// + /// Создаёт контейнер (id/позицию/цвет/дефолты вычисляет ContainersService). + /// + /// Полный контейнер для вставки, включая . + /// Токен отмены. + public Task CreateContainerAsync(ContainerDto container, CancellationToken ct); + + /// + /// Обновляет контейнер целиком (сервис читает Get + применяет ContainerPatchDto). + /// + /// Контейнер с изменёнными полями (идентифицируется по Id). + /// Токен отмены. + public Task UpdateContainerAsync(ContainerDto container, CancellationToken ct); + + /// + /// Удаляет контейнер, предварительно перенося его карточки в «Неразобранное». + /// + /// Id удаляемого контейнера. + /// Токен отмены. + /// Сколько карточек перенесено в inbox (0 — контейнера/карточек не было; ответ {ok, movedToInbox}). + public Task DeleteContainerAsync(string containerId, CancellationToken ct); + + /// + /// Переставляет контейнеры пространства: позиции 0..N-1 в порядке списка. + /// + /// Пространство переставляемых контейнеров. + /// Id контейнеров в новом порядке. + /// Токен отмены. + public Task ReorderContainersAsync( + string space, + IReadOnlyList containerIds, + CancellationToken ct); + + // ── Карточки ────────────────────────────────────────────────────────── + + /// + /// Карточки колонки или всех колонок дашборда, ORDER BY received_at DESC (list_leads L151–156). + /// + /// Фильтр: Col — конкретная колонка либо null (все колонки дашборда). + /// Токен отмены. + /// Полные карточки (DTO): JSON-поля разобраны, комментарии приложены, time посчитан. + public Task> ListCardsAsync(CardsQuery query, CancellationToken ct); + + /// + /// Карточки пространства «Выбранные», ORDER BY updated_at DESC (list_cards projects.py L58–63). + /// + /// Фильтр по контейнеру-стадии (id каталога ); null — все стадии. + /// Токен отмены. + /// Полные карточки (DTO): JSON-поля разобраны, комментарии приложены, time посчитан. + public Task> ListSelectedCardsAsync(string? containerId, CancellationToken ct); + + /// + /// Полнотекстовый поиск карточек (FTS + LIKE-дополнение; leads.py search L509–551, Ruling 6/Task 12). + /// + /// Поисковый запрос (уже Trim+lowercase, как в отсев-поиске; короче 2 символов сервис не пропускает). + /// Ограничение результата (эндпоинт шлёт 12, Ruling 6). + /// Токен отмены. + /// Полные карточки (комментарии приложены, time посчитан): FTS-кандидаты по + /// убыванию ts_rank (SearchTsv @@ plainto_tsquery), затем LIKE-дополнение, внутри — ReceivedAt DESC. + public Task> SearchCardsAsync( + string q, + int limit, + CancellationToken ct); + + /// + /// Одна карточка по id (GET /api/cards/{id}, а также перечитывание после переноса). + /// + /// Id карточки (c_...). + /// Токен отмены. + /// Карточка или null, если строки нет. + public Task GetCardAsync(string cardId, CancellationToken ct); + + /// + /// Карточка по исходному сообщению (диалог + id сообщения) — ручная разметка ML (§8). + /// + /// Если сообщение давало несколько карточек (повторная разметка/пересоздание), берётся самая + /// свежая по ReceivedAt. Пустой dialogId — null (нечем искать). + /// Id диалога-источника сообщения. + /// Id исходного сообщения в Telegram. + /// Токен отмены. + /// Карточка или null, если сообщение не становилось карточкой. + public Task GetCardBySourceAsync( + string dialogId, + long msgId, + CancellationToken ct); + + /// + /// Создаёт карточку из готового снимка (пайплайн этапа 4, ручное создание; CreatedAt — UTC-now). + /// + /// Полное состояние новой карточки (id сгенерирован модулем, см. CardSnapshot). + /// Токен отмены. + public Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct); + + /// + /// Точечная правка полей по присутствующим в патче + bump UpdatedAt (patch_card projects.py L159–187). + /// + /// Id карточки (c_...). + /// Изменяемые поля (null — поле не меняется; JSON-поля — полная замена). + /// Токен отмены. + /// True — строка обновлена; false — карточки нет (404-семантика сервиса). + public Task PatchCardAsync( + string cardId, + CardPatch patch, + CancellationToken ct); + + /// + /// Атомарно дописывает ссылку в JSON-массив links ОДНИМ UPDATE (jsonb-append) + bump UpdatedAt. + /// + /// Id карточки (c_...). + /// Готовая ссылка {id,name,url} (id сгенерирован модулем). + /// Токен отмены. + /// True — строка обновлена; false — карточки нет (404-семантика сервиса). + public Task AddLinkAsync( + string cardId, + CardLinkDto link, + CancellationToken ct); + + /// + /// Атомарно убирает из JSON-массива links элемент с указанным id ОДНИМ UPDATE (jsonb-фильтрация) + bump UpdatedAt. + /// + /// Id карточки (c_...). + /// Id удаляемой ссылки (pl_...). + /// Токен отмены. + /// True — строка обновлена; false — карточки нет (404-семантика сервиса). + public Task RemoveLinkAsync( + string cardId, + string linkId, + CancellationToken ct); + + /// + /// Атомарно дописывает метаданные файла в JSON-массив files ОДНИМ UPDATE (jsonb-append) + bump UpdatedAt. + /// + /// 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 (jsonb-фильтрация) + bump UpdatedAt. + /// + /// Id карточки (c_...). + /// Id удаляемой записи файла (pf_...). + /// Токен отмены. + /// True — строка обновлена; false — карточки нет (404-семантика сервиса). + public Task RemoveFileAsync( + string cardId, + string fileId, + CancellationToken ct); + + /// + /// Смена контейнера-стадии: один UPDATE (col, reminder_at=NULL, reminder_fired=false, updated_at=atMs) + /// + перезапись history-массива с добавленной записью (move_stage projects.py L202–216; Ruling 7). + /// + /// 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); + + /// + /// Устанавливает напоминание: reminder_at, reminder_fired=false + bump UpdatedAt (set_reminder projects.py L236–243). + /// + /// Id карточки (c_...). + /// Время напоминания, epoch-ms. + /// Токен отмены. + public Task SetReminderAsync( + string cardId, + long atMs, + CancellationToken ct); + + /// + /// Снимает напоминание: reminder_at=NULL, reminder_fired=false (clear_reminder projects.py L246–247). + /// + /// Id карточки (c_...). + /// Токен отмены. + public Task ClearReminderAsync(string cardId, CancellationToken ct); + + /// + /// Полная ручная очистка контейнера-стадии: DELETE строк (clear_stage projects.py L223–231). + /// + /// Очищаемая стадия (допустимость — только rejected — валидирует сервис). + /// Токен отмены. + /// Сколько карточек удалено (0 — стадия пуста). + public Task ClearStageAsync(string containerId, CancellationToken ct); + + /// + /// Наступившие напоминания стадии hold: ReminderAt ≤ now, ReminderFired=false, ORDER BY ReminderAt (check_reminders projects.py L270–275). + /// + /// Текущий момент (UTC) для сравнения с ReminderAt. + /// Токен отмены. + /// Due-строки {id,title,containerId} в порядке наступления; пусто — сработавших нет. + public Task> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct); + + /// + /// Помечает due-карточки сработавшими: ReminderFired=true по списку id (check_reminders projects.py L277–278). + /// + /// Id карточек, чьи напоминания «выстрелили». + /// Токен отмены. + public Task MarkRemindersFiredAsync(IReadOnlyList cardIds, CancellationToken ct); + + /// + /// Очищает протухшие напоминания при выключенной настройке: ReminderAt=NULL WHERE ReminderAt ≤ now (check_reminders L266–269). + /// + /// Текущий момент (UTC). + /// Токен отмены. + /// Сколько строк очищено. + public Task ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct); + + /// + /// Меняет колонку/состояние карточки (move/trash/restore/автоархив) — см. CardColumnUpdateDto. + /// + /// Новое состояние колонки карточки (matchHits пересчитан в модуле, Ruling 2). + /// Токен отмены. + public Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct); + + /// + /// Применяет результат ручной переклассификации к существующей карточке ОДНИМ обновлением + /// (leads.py reclassify_lead L346–367): тип/заголовок/суть/стек/бюджет/конверсия/контакты/matchHits + колонка. + /// + /// Поля классификации (полная замена; см. CardReclassificationDto). + /// Токен отмены. + /// True — строка обновлена; false — карточки нет (404-семантика сервиса). + public Task ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct); + + /// + /// Снимает флаг «новое»: с одной карточки (cardId), с колонки (col) или со всех (оба null) — leads.py mark_seen L250–256. + /// + /// Id карточки либо null. + /// Колонка либо null. + /// Токен отмены. + public Task UpdateSeenAsync( + string? cardId, + string? col, + CancellationToken ct); + + /// + /// Удаляет карточку навсегда: Cards + комментарии (FK cascade) + строки дедупа карточки + /// (DedupEntries WHERE LeadId=?, Ruling 3); журнал/outbox не трогает (leads.py _hard_delete L225–234). + /// + /// Id карточки. + /// Токен отмены. + public Task DeleteForeverAsync(string cardId, CancellationToken ct); + + /// + /// Полная очистка служебной колонки (только trash|archive — валидирует сервис), leads.py clear_col L237–247; + /// строки дедупа удаляемых карточек чистятся вместе с ними (Ruling 3). + /// + /// Очищаемая колонка (trash/archive). + /// Токен отмены. + /// Сколько карточек удалено (0 — колонка пуста). + public Task ClearColAsync(string col, CancellationToken ct); + + /// + /// Счётчики карточек по колонкам (count + new) для GET /api/cards/counts (leads.py counts L268–279). + /// + /// Токен отмены. + /// Словарь col → {count, new}; ключи — только колонки с карточками. + public Task> CountCardsByColAsync(CancellationToken ct); + + // ── Комментарии (LeadComments) ──────────────────────────────────────── + + /// + /// Комментарии карточки (для ответа add-comment и карточки; сортировка по времени добавления). + /// + /// Id карточки. + /// Токен отмены. + /// Комментарии (time — human-метка от CreatedAt); пусто — комментариев нет. + public Task> ListCommentsAsync(string cardId, CancellationToken ct); + + /// + /// Добавляет комментарий (leads.py add_comment L259–265; CreatedAt — UTC-now). + /// + /// Готовый id (cm_..., генерирует модуль). + /// Id карточки. + /// Автор («Вы» — свои комментарии). + /// Текст (непустой — валидирует сервис, 400 «Пустой комментарий»). + /// Токен отмены. + public Task AddCommentAsync( + string commentId, + string cardId, + string by, + string text, + CancellationToken ct); + + // ── Журнал действий (CardMoves = learning_log) ──────────────────────── + + /// + /// Пишет строку журнала действия пользователя (move/trash/restore/comment) — leads.py _log_learning L40–44. + /// + /// Запись журнала (id lm_... сгенерирован модулем). + /// Токен отмены. + public Task AddMoveAsync(CardMoveDto move, CancellationToken ct); + + /// + /// Число записей журнала = счётчик learning (Ruling 4, Task 5 StatusAsync). + /// + /// Токен отмены. + /// Количество строк CardMoves. + public Task CountMovesAsync(CancellationToken ct); + + /// + /// Свежие примеры разметки пользователя для few-shot ИИ-классификации (pipeline.py _learning_examples L201–215). + /// + /// + /// Join журнала CardMoves с карточками (Cards.SourceMsg): действия move/restore, цель не служебная + /// (trash/archive), исходный текст непустой; ORDER BY created_at DESC, ≤ limit записей. Используется + /// контекст-билдером классификации (план Task 15, Ruling 5) — текст примера дополнительно режется + /// вызывающим до 500 кодовых точек (python L214). + /// + /// Максимум примеров (python L201: 8). + /// Токен отмены. + /// Примеры «текст → колонка», свежие первыми; пусто — истории разметки нет. + public Task> GetAiMarkupExamplesAsync(int limit, CancellationToken ct); + + // ── Правила хранения (тик, Ruling 8) ────────────────────────────────── + + /// + /// Кандидаты на автоархив: карточки досок и «Неразобранного» со ReceivedAt старше срока (tick_storage L462–467). + /// + /// Граница: received_at < now − archiveAfterDays. + /// Токен отмены. + /// Id карточек-кандидатов (колонка при архивации пишется archive через UpdateColumnAsync). + public Task> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct); + + /// + /// Архивирует пачку карточек ОДНИМ UPDATE: col=archive, is_new=false, archived_at=archivedAt, + /// matchHits — пустой массив (тик tick_storage L462–473; batch-замена по-карточных UpdateColumnAsync, + /// PrevCol при архивации не трогается — Ruling 8). + /// + /// Id карточек-кандидатов (список из ListArchiveCandidatesAsync). + /// Момент архивации (один «now» тика). + /// Токен отмены. + /// Сколько карточек реально архивировано (0 — кандидатов не было/уже не в рабочих колонках). + public Task ArchiveAsync( + IReadOnlyList cardIds, + DateTimeOffset archivedAt, + CancellationToken ct); + + /// + /// Кандидаты на очистку архива: col='archive' и ArchivedAt старше срока (tick_storage L475–478). + /// + /// Граница: archived_at < now − archiveClearDays. + /// Токен отмены. + /// Id карточек для жёсткого удаления. + public Task> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct); + + /// + /// Кандидаты на очистку корзины: col='trash' и ReceivedAt старше срока (tick_storage L480–483). + /// + /// Граница: received_at < now − trashClearDays. + /// Токен отмены. + /// Id карточек для жёсткого удаления. + public Task> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct); + + /// + /// Жёстко удаляет пачку карточек: Cards + комментарии (FK cascade) + строки дедупа карточек + /// (DedupEntries WHERE LeadId IN …, Ruling 3) — для очисток тика и clear-col (Ruling 8). + /// + /// Id карточек на удаление. + /// Токен отмены. + /// Сколько карточек удалено. + public Task PurgeAsync(IReadOnlyList cardIds, CancellationToken ct); + + // ── Пересчёт конверсий (Ruling 7, Task 12) ───────────────────────────── + + /// + /// Карточки для пересчёта ConvFrom/ConvTo/ConvCur: budgetCur непуст и col NOT IN (archive, trash) — recompute_conversions L106–130. + /// + /// Токен отмены. + /// Карточки с бюджетом (используются Budget/бюджетные поля; служебные колонки исключены). + public Task> ListCardsForConversionAsync(CancellationToken ct); + + /// + /// Записывает пересчитанную конверсию бюджета карточки (обновляет только conv-поля). + /// + /// Id карточки. + /// Сконвертированная нижняя граница, либо null. + /// Сконвертированная верхняя граница, либо null. + /// Валюта конверсии (целевая валюта тенанта); пусто — конверсия снята. + /// Токен отмены. + public Task UpdateConversionAsync( + string cardId, + double? convFrom, + double? convTo, + string convCur, + CancellationToken ct); + + // ── Эвристика ИИ-предложений (Ruling 3, Task 14) ─────────────────────── + + /// + /// Карточки «Неразобранного» с исходным текстом — вход эвристики suggest (suggest.py, Ruling 3). + /// + /// Токен отмены. + /// Карточки col='inbox' с непустым SourceMsg (частотные темы считаются по source_msg). + 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 29f6d1c..8b0a71f 100644 --- a/src/core/Deal.Modules.Kanban/Application/Abstractions/IMlLearningStore.cs +++ b/src/core/Deal.Modules.Kanban/Application/Abstractions/IMlLearningStore.cs @@ -1,74 +1,71 @@ -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; - -namespace Deal.Modules.Kanban.Application.Abstractions; - -/// -/// Порт хранилища обучения ML: очередь MlOutbox + счётчик журнала CardMoves (Ruling 4, Task 5). -/// -/// -/// Объявлен в модуле Kanban (чистый: без EF) и потребляется интеграционным адаптером -/// LocalMlClient (Deal.Infrastructure), чтобы тот оставался unit-чистым (план Task 5: -/// «подсчёты вынести за чистый порт IMlLearningCounters (модуль Kanban)»; состав порта — счётчики -/// learning/outbox плюс запись/очистка очереди — финальное решение исполнителя). Реализация — -/// EF-адаптер над таблицами CardMoves/MlOutbox схемы тенанта, регистрируется в AddDealPersistence. -/// Id строки очереди (mle_...) генерирует вызывающий (Ruling 12); CreatedAt проставляет -/// хранилище (UTC-now, как у CardMoves/комментариев). -/// -public interface IMlLearningStore -{ - /// - /// Число записей журнала обучения = счётчик learning: count(CardMoves) (ml_client.snapshot L144). - /// - /// Токен отмены. - /// Количество строк журнала CardMoves. - public Task CountLearningAsync(CancellationToken ct); - - /// - /// Число строк очереди обучения = счётчик outbox: count(MlOutbox) (ml_client.outbox_len L52–53). - /// - /// Токен отмены. - /// Количество строк очереди MlOutbox. - public Task CountOutboxAsync(CancellationToken ct); - - /// - /// Пишет строку очереди обучения (ml_client.push L40–49): текст уже trim-нут и обрезан до 6000 вызывающим. - /// - /// Готовый 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); - - /// - /// Полная очистка очереди обучения: DELETE FROM MlOutbox (ml_client.reset_model L122). Журнал CardMoves не трогается. - /// - /// Токен отмены. - public Task ClearOutboxAsync(CancellationToken ct); - - /// - /// Забирает следующую порцию очереди обучения: ORDER BY created_at LIMIT N (ml_client.flush_outbox L66–69). - /// - /// Строки НЕ удаляются — выборка и удаление разделены: вызывающий (MlOutboxFlushScheduler, план - /// Task 16) отправляет батч в ml-service и удаляет строки только после успеха (Ruling 6). - /// Размер порции (флашер шлёт по 10 строк за батч). - /// Токен отмены. - /// До строк очереди в порядке created_at. - public Task> TakeOutboxBatchAsync(int limit, CancellationToken ct); - - /// - /// Удаляет отправленные строки очереди по id (ml_client.flush_outbox L79 — DELETE после успеха батча). - /// - /// Id строк, успешно отправленных в ML-сервис. - /// Токен отмены. - /// Задача завершается после удаления строк. - public Task DeleteOutboxAsync(IReadOnlyCollection ids, CancellationToken ct); -} +using Deal.Modules.Kanban.Application.Models; + +namespace Deal.Modules.Kanban.Application.Abstractions; + +/// +/// Порт хранилища обучения ML: очередь MlOutbox + счётчик журнала CardMoves (Ruling 4, Task 5). +/// +/// +/// Объявлен в модуле Kanban (чистый: без EF) и потребляется интеграционным адаптером +/// LocalMlClient (Deal.Infrastructure), чтобы тот оставался unit-чистым (план Task 5: +/// «подсчёты вынести за чистый порт IMlLearningCounters (модуль Kanban)»; состав порта — счётчики +/// learning/outbox плюс запись/очистка очереди — финальное решение исполнителя). Реализация — +/// EF-адаптер над таблицами CardMoves/MlOutbox схемы тенанта, регистрируется в AddDealPersistence. +/// Id строки очереди (mle_...) генерирует вызывающий (Ruling 12); CreatedAt проставляет +/// хранилище (UTC-now, как у CardMoves/комментариев). +/// +public interface IMlLearningStore +{ + /// + /// Число записей журнала обучения = счётчик learning: count(CardMoves) (ml_client.snapshot L144). + /// + /// Токен отмены. + /// Количество строк журнала CardMoves. + public Task CountLearningAsync(CancellationToken ct); + + /// + /// Число строк очереди обучения = счётчик outbox: count(MlOutbox) (ml_client.outbox_len L52–53). + /// + /// Токен отмены. + /// Количество строк очереди MlOutbox. + public Task CountOutboxAsync(CancellationToken ct); + + /// + /// Пишет строку очереди обучения (ml_client.push L40–49): текст уже trim-нут и обрезан до 6000 вызывающим. + /// + /// Готовый 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); + + /// + /// Полная очистка очереди обучения: DELETE FROM MlOutbox (ml_client.reset_model L122). Журнал CardMoves не трогается. + /// + /// Токен отмены. + public Task ClearOutboxAsync(CancellationToken ct); + + /// + /// Забирает следующую порцию очереди обучения: ORDER BY created_at LIMIT N (ml_client.flush_outbox L66–69). + /// + /// Строки НЕ удаляются — выборка и удаление разделены: вызывающий (MlOutboxFlushScheduler, план + /// Task 16) отправляет батч в ml-service и удаляет строки только после успеха (Ruling 6). + /// Размер порции (флашер шлёт по 10 строк за батч). + /// Токен отмены. + /// До строк очереди в порядке created_at. + public Task> TakeOutboxBatchAsync(int limit, CancellationToken ct); + + /// + /// Удаляет отправленные строки очереди по id (ml_client.flush_outbox L79 — DELETE после успеха батча). + /// + /// Id строк, успешно отправленных в ML-сервис. + /// Токен отмены. + /// Задача завершается после удаления строк. + public Task DeleteOutboxAsync(IReadOnlyCollection ids, CancellationToken ct); +} diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetInRange.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetInRange.cs index 26a8766..8e052a7 100644 --- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetInRange.cs +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetInRange.cs @@ -1,7 +1,4 @@ using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; using Deal.Modules.Settings.Application.Services; namespace Deal.Modules.Kanban.Application.ColumnRules; diff --git a/src/core/Deal.Modules.Kanban/Application/Extensions/CharExtensions.cs b/src/core/Deal.Modules.Kanban/Application/Extensions/CharExtensions.cs index ebd4952..ad16e23 100644 --- a/src/core/Deal.Modules.Kanban/Application/Extensions/CharExtensions.cs +++ b/src/core/Deal.Modules.Kanban/Application/Extensions/CharExtensions.cs @@ -1,8 +1,3 @@ -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; - namespace Deal.Modules.Kanban.Application.Extensions; /// diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardFileKind.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardFileKind.cs index 06bb9c5..b1b1a06 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardFileKind.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardFileKind.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; using Deal.Modules.Kanban.Application.Services; namespace Deal.Modules.Kanban.Application.Models; diff --git a/src/core/Deal.Modules.Kanban/Application/Models/ContainerKinds.cs b/src/core/Deal.Modules.Kanban/Application/Models/ContainerKinds.cs index ebd68ce..a5e0e3d 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/ContainerKinds.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/ContainerKinds.cs @@ -1,8 +1,3 @@ -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; - namespace Deal.Modules.Kanban.Application.Models; /// diff --git a/src/core/Deal.Modules.Kanban/Application/Models/ContainerSpaces.cs b/src/core/Deal.Modules.Kanban/Application/Models/ContainerSpaces.cs index de06ba9..16403e1 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/ContainerSpaces.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/ContainerSpaces.cs @@ -1,8 +1,3 @@ -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; - namespace Deal.Modules.Kanban.Application.Models; /// diff --git a/src/core/Deal.Modules.Kanban/Application/Models/KanbanColumns.cs b/src/core/Deal.Modules.Kanban/Application/Models/KanbanColumns.cs index d7e2e68..34f7868 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/KanbanColumns.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/KanbanColumns.cs @@ -1,8 +1,3 @@ -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; - namespace Deal.Modules.Kanban.Application.Models; /// diff --git a/src/core/Deal.Modules.Kanban/Application/Models/KanbanIdPrefixes.cs b/src/core/Deal.Modules.Kanban/Application/Models/KanbanIdPrefixes.cs index 80caca3..cdf884c 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/KanbanIdPrefixes.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/KanbanIdPrefixes.cs @@ -1,10 +1,4 @@ -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; namespace Deal.Modules.Kanban.Application.Models; diff --git a/src/core/Deal.Modules.Kanban/Application/Models/PrefixId.cs b/src/core/Deal.Modules.Kanban/Application/Models/PrefixId.cs index 9e4c124..619582d 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/PrefixId.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/PrefixId.cs @@ -1,8 +1,4 @@ using System.Security.Cryptography; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; namespace Deal.Modules.Kanban.Application.Models; diff --git a/src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs b/src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs index ffe9a9f..e6072c4 100644 --- a/src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs +++ b/src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs @@ -1,48 +1,42 @@ -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Microsoft.Extensions.DependencyInjection; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Services; - -namespace Deal.Modules.Kanban.Application.Registrars; - -/// -/// DI-регистрация модуля Kanban. Паттерн «port & adapter» (Ruling 12). -/// -/// -/// Регистрируются только сервисы модуля. Порт-адаптеры (ICardStore → KanbanStore) реализованы в -/// Deal.Infrastructure и регистрируются там (AddDealPersistence); IColumnSuggester → LocalColumnSuggester — -/// в AddDealIntegrations (Ruling 12) — модуль не знает про EF и HTTP. Зависимости модуля — -/// Deal.Modules.Settings (порт ISettingsStore: настройки хранения/colState/курсы) и Deal.Contracts -/// (IMlClient: обучающие сигналы ML, Task 5); реверс-зависимостей нет (Global Constraints). -/// -public static class KanbanModuleRegistrar -{ - /// - /// Регистрирует сервисы модуля Kanban в контейнере. - /// - /// Коллекция сервисов. - /// Коллекция сервисов для цепочки вызовов. - /// - /// Здесь появляются scoped-сервисы модуля по мере их создания: ContainersService (Task 6), CardsService - /// (Task 7), StorageTickService (Task 10) и далее ConversionRecomputer (Task 12) с - /// AddScoped<IRatesChangedListener, ConversionRecomputer>() (Ruling 7). Вызывается из - /// Program.cs (AddKanbanModule, Task 4) после AddDealPersistence. Сервисы scoped, потому что их - /// зависимости (ICardStore/ISettingsStore) живут в рамках tenant-запроса (EF-контекст). - /// - public static IServiceCollection AddKanbanModule(this IServiceCollection services) - { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - // Порт модуля Settings реализует ConversionRecomputer (Ruling 7, Task 12): RatesService/SettingsService - // оповещают его после записи кэша курсов / смены targetCurrency|conversionOn (список может быть пуст). - services.AddScoped(); - return services; - } -} +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Modules.Kanban.Application.Registrars; + +/// +/// DI-регистрация модуля Kanban. Паттерн «port & adapter» (Ruling 12). +/// +/// +/// Регистрируются только сервисы модуля. Порт-адаптеры (ICardStore → KanbanStore) реализованы в +/// Deal.Infrastructure и регистрируются там (AddDealPersistence); IColumnSuggester → LocalColumnSuggester — +/// в AddDealIntegrations (Ruling 12) — модуль не знает про EF и HTTP. Зависимости модуля — +/// Deal.Modules.Settings (порт ISettingsStore: настройки хранения/colState/курсы) и Deal.Contracts +/// (IMlClient: обучающие сигналы ML, Task 5); реверс-зависимостей нет (Global Constraints). +/// +public static class KanbanModuleRegistrar +{ + /// + /// Регистрирует сервисы модуля Kanban в контейнере. + /// + /// Коллекция сервисов. + /// Коллекция сервисов для цепочки вызовов. + /// + /// Здесь появляются scoped-сервисы модуля по мере их создания: ContainersService (Task 6), CardsService + /// (Task 7), StorageTickService (Task 10) и далее ConversionRecomputer (Task 12) с + /// AddScoped<IRatesChangedListener, ConversionRecomputer>() (Ruling 7). Вызывается из + /// Program.cs (AddKanbanModule, Task 4) после AddDealPersistence. Сервисы scoped, потому что их + /// зависимости (ICardStore/ISettingsStore) живут в рамках tenant-запроса (EF-контекст). + /// + public static IServiceCollection AddKanbanModule(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Порт модуля Settings реализует ConversionRecomputer (Ruling 7, Task 12): RatesService/SettingsService + // оповещают его после записи кэша курсов / смены targetCurrency|conversionOn (список может быть пуст). + 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 24c0c73..386fd92 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/BudgetNormalizer.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/BudgetNormalizer.cs @@ -1,185 +1,180 @@ -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Нормализация бюджета «при поступлении»: приведение к форме хранения и пересчёт в целевую валюту -/// (ai.py clean_budget L316–326, budget_to_target L342–352; Ruling 7 — чистый BudgetNormalizer). -/// -/// -/// Чистый класс без хранилища: приводит произвольный бюджет (из ИИ/локального -/// разбора) к соглашению хранения — одна сумма X → from=to=X, «до X» → from=null, from=0 → null -/// («от 0 до X» == «до X»); валюта нормализуется алиасами к коду (ai.py _norm_currency L279–291). -/// пересчитывает нормализованный бюджет в целевую валюту тенанта по курсам — -/// «интерфейс курсов» это словарь «код → курс к рублю» + чистая -/// (USDT=USD, rates.py L86–103); чтение настроек conversionOn/targetCurrency и кэша ratesCache остаётся -/// за вызывающим (демо Task 13, пайплайн этапа 4, ConversionRecomputer Task 12). -/// -public static class BudgetNormalizer -{ - // Дефолтная целевая валюта (ai.py budget_to_target L347: targetCurrency or "RUB"). - private const string DefaultTargetCurrency = "RUB"; - - // Синонимы валют из ответов ИИ → коды хранения (ai.py _CUR_ALIASES L271–276, согласовано с rules._CUR_*). - 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", - }; - - /// - /// Нормализует бюджет к форме хранения (ai.py clean_budget L316–326). - /// - /// Бюджет из ИИ/локального разбора (null — бюджета нет). - /// - /// Нормализованный бюджет CardBudgetDto(from/to/cur) либо null: бюджет отсутствует, валюта не - /// распознана или обе границы отсутствуют/равны нулю (чистый dict → None в прототипе). - /// 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); - } - - /// - /// Пересчёт бюджета в целевую валюту «один раз при поступлении» (ai.py budget_to_target L342–352). - /// - /// Нормализованный бюджет (валюта — код, см. ). - /// Настройка conversionOn (Ruling 7); false → конверсия снята. - /// Настройка targetCurrency (пустая → RUB, как в прототипе). - /// Курсы к рублю «код → курс» (кэш ratesCache либо мок-курсы). - /// - /// Сконвертированный бюджет CardBudgetDto(convFrom, convTo, targetCurrency), либо null — конверсия - /// выключена/бюджета нет/валюта не задана (в прототипе это convCur=""). При валюте, отсутствующей в - /// курсах, границы null, но целевая валюта сохраняется (как budget_to_target L351–357). - /// - 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); - } - - // Число границы: 0 → null (отсутствие границы), иначе значение (ai.py _budget_num L294–313: x==0 → None). - // value: Значение границы из бюджета. - // Возвращает: Значение или null при 0. - private static double? NormalizeBound(double? value) - { - if (value is null || value.Value == 0) - { - return null; - } - - return value.Value; - } - - // Приводит название/символ валюты из ИИ к коду (ai.py _norm_currency L279–291). - // raw: Валюта как пришла («рублей», «$», «usd», …). - // Возвращает: Код валюты (USD/RUB/…) или null, если не распознана. - 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; + +/// +/// Нормализация бюджета «при поступлении»: приведение к форме хранения и пересчёт в целевую валюту +/// (ai.py clean_budget L316–326, budget_to_target L342–352; Ruling 7 — чистый BudgetNormalizer). +/// +/// +/// Чистый класс без хранилища: приводит произвольный бюджет (из ИИ/локального +/// разбора) к соглашению хранения — одна сумма X → from=to=X, «до X» → from=null, from=0 → null +/// («от 0 до X» == «до X»); валюта нормализуется алиасами к коду (ai.py _norm_currency L279–291). +/// пересчитывает нормализованный бюджет в целевую валюту тенанта по курсам — +/// «интерфейс курсов» это словарь «код → курс к рублю» + чистая +/// (USDT=USD, rates.py L86–103); чтение настроек conversionOn/targetCurrency и кэша ratesCache остаётся +/// за вызывающим (демо Task 13, пайплайн этапа 4, ConversionRecomputer Task 12). +/// +public static class BudgetNormalizer +{ + // Дефолтная целевая валюта (ai.py budget_to_target L347: targetCurrency or "RUB"). + private const string DefaultTargetCurrency = "RUB"; + + // Синонимы валют из ответов ИИ → коды хранения (ai.py _CUR_ALIASES L271–276, согласовано с rules._CUR_*). + 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", + }; + + /// + /// Нормализует бюджет к форме хранения (ai.py clean_budget L316–326). + /// + /// Бюджет из ИИ/локального разбора (null — бюджета нет). + /// + /// Нормализованный бюджет CardBudgetDto(from/to/cur) либо null: бюджет отсутствует, валюта не + /// распознана или обе границы отсутствуют/равны нулю (чистый dict → None в прототипе). + /// 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); + } + + /// + /// Пересчёт бюджета в целевую валюту «один раз при поступлении» (ai.py budget_to_target L342–352). + /// + /// Нормализованный бюджет (валюта — код, см. ). + /// Настройка conversionOn (Ruling 7); false → конверсия снята. + /// Настройка targetCurrency (пустая → RUB, как в прототипе). + /// Курсы к рублю «код → курс» (кэш ratesCache либо мок-курсы). + /// + /// Сконвертированный бюджет CardBudgetDto(convFrom, convTo, targetCurrency), либо null — конверсия + /// выключена/бюджета нет/валюта не задана (в прототипе это convCur=""). При валюте, отсутствующей в + /// курсах, границы null, но целевая валюта сохраняется (как budget_to_target L351–357). + /// + 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); + } + + // Число границы: 0 → null (отсутствие границы), иначе значение (ai.py _budget_num L294–313: x==0 → None). + // value: Значение границы из бюджета. + // Возвращает: Значение или null при 0. + private static double? NormalizeBound(double? value) + { + if (value is null || value.Value == 0) + { + return null; + } + + return value.Value; + } + + // Приводит название/символ валюты из ИИ к коду (ai.py _norm_currency L279–291). + // raw: Валюта как пришла («рублей», «$», «usd», …). + // Возвращает: Код валюты (USD/RUB/…) или null, если не распознана. + 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 4e6e9eb..00e9d7d 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs @@ -1,9 +1,5 @@ using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; namespace Deal.Modules.Kanban.Application.Services; 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 049fe2a..7f6305f 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Helpers.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Helpers.cs @@ -1,130 +1,120 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.ColumnRules; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён -// Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас. -using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Приватные помощники — partial-часть (C32: выделено из общего файла, -/// поведение не менялось): текст обучающего примера, перенос колонки с журналом, hits правил доски и -/// колонка возврата (leads.py L163–174, L209, L311–319; Ruling 2/4). -/// -public sealed partial class CardsService -{ - // ── Внутреннее ───────────────────────────────────────────────────────── - - // Текст обучающего примера: source_msg (после Trim) или title (leads.py L167, L189–190, L199–200). - // card: Карточка. - // Возвращает: source_msg без краевых пробелов; пустой — title как сохранён (1:1 с (x or "").strip() or (y or "")). - private static string LearningText(CardDto card) - { - string source = card.SourceMsg.Trim(); - return source.Length > 0 ? source : card.Title; - } - - // Меняет колонку карточки и пишет строку журнала CardMoves (leads.py _move L170–174). - // card: Карточка ДО переноса (для prev_col/from_col журнала). - // toCol: Новая колонка. - // hits: matchHits для новой колонки (пересчитаны вызывающим, Ruling 2). - // action: Действие журнала: move/trash. - // ct: Токен отмены. - 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); - } - - // Пишет строку журнала действия (leads.py _log_learning L40–44): id lm_ генерирует модуль. - // cardId: Id карточки. - // action: Действие: move/trash/restore/comment. - // fromCol: Прежняя колонка (для comment — null). - // toCol: Новая колонка (для comment — null). - // 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); - } - - // Совпавшие критерии правил доски (hits_for_board L311–319 через ColumnRules, Ruling 2). - // boardId: Id доски (b_...). - // text: Текст карточки для правил (source_msg или title). - // 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); - } - - // Совпавшие критерии правил колонки-доски (ColumnRules.ComputeHits с курсами для бюджета). - // Резолв имени — через алиас KanbanColumnRules: одноимённые класс и namespace ColumnRules в одном модуле. - // rules: Правила доски; null («правил нет») → пусто (Ruling 2). - // text: Текст карточки для правил. - // ct: Токен отмены. - // Возвращает: Совпавшие критерии (label/term[/word]); нет активных правил → пусто. - 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); - } - - // Колонка возврата карточки: prev_col, если inbox или существующая доска, иначе inbox (restore_lead L209). - // prevCol: Сохранённая prev_col карточки. - // ct: Токен отмены. - // Возвращает: Колонка возврата (inbox/доска). - 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-часть (C32: выделено из общего файла, +/// поведение не менялось): текст обучающего примера, перенос колонки с журналом, hits правил доски и +/// колонка возврата (leads.py L163–174, L209, L311–319; Ruling 2/4). +/// +public sealed partial class CardsService +{ + // ── Внутреннее ───────────────────────────────────────────────────────── + + // Текст обучающего примера: source_msg (после Trim) или title (leads.py L167, L189–190, L199–200). + // card: Карточка. + // Возвращает: source_msg без краевых пробелов; пустой — title как сохранён (1:1 с (x or "").strip() or (y or "")). + private static string LearningText(CardDto card) + { + string source = card.SourceMsg.Trim(); + return source.Length > 0 ? source : card.Title; + } + + // Меняет колонку карточки и пишет строку журнала CardMoves (leads.py _move L170–174). + // card: Карточка ДО переноса (для prev_col/from_col журнала). + // toCol: Новая колонка. + // hits: matchHits для новой колонки (пересчитаны вызывающим, Ruling 2). + // action: Действие журнала: move/trash. + // ct: Токен отмены. + 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); + } + + // Пишет строку журнала действия (leads.py _log_learning L40–44): id lm_ генерирует модуль. + // cardId: Id карточки. + // action: Действие: move/trash/restore/comment. + // fromCol: Прежняя колонка (для comment — null). + // toCol: Новая колонка (для comment — null). + // 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); + } + + // Совпавшие критерии правил доски (hits_for_board L311–319 через ColumnRules, Ruling 2). + // boardId: Id доски (b_...). + // text: Текст карточки для правил (source_msg или title). + // 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); + } + + // Совпавшие критерии правил колонки-доски (ColumnRules.ComputeHits с курсами для бюджета). + // Резолв имени — через алиас KanbanColumnRules: одноимённые класс и namespace ColumnRules в одном модуле. + // rules: Правила доски; null («правил нет») → пусто (Ruling 2). + // text: Текст карточки для правил. + // ct: Токен отмены. + // Возвращает: Совпавшие критерии (label/term[/word]); нет активных правил → пусто. + 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); + } + + // Колонка возврата карточки: prev_col, если inbox или существующая доска, иначе inbox (restore_lead L209). + // prevCol: Сохранённая prev_col карточки. + // ct: Токен отмены. + // Возвращает: Колонка возврата (inbox/доска). + 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 9105ddd..7b12b3f 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs @@ -1,361 +1,351 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.ColumnRules; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Публичные операции карточек — partial-часть (C32: выделено из общего -/// файла по темам, поведение не менялось): чтение списка/карточки, переносы/корзина/возврат/удаление/ -/// очистка колонки, комментарии, mark-seen, счётчики и поиск (leads.py L151–279, L509–551). -/// -public sealed partial class CardsService -{ - // ── Чтение (list_leads/get_lead L151–160) ─────────────────────────────── - - /// - /// Карточки колонки или всех колонок дашборда, received_at DESC (list_leads L151–156). - /// - /// Колонка-фильтр (inbox/archive/trash/доска); null — все колонки дашборда. - /// Токен отмены. - /// Полные карточки (маппинг/комментарии/time — адаптер); пусто — карточек нет. - public Task> ListCardsAsync(string? col, CancellationToken ct) - { - return _store.ListCardsAsync(new CardsQuery(col), ct); - } - - /// - /// Одна карточка по id (get_lead L159–160; GET /api/cards/{id}). - /// - /// Id карточки (c_...). - /// Токен отмены. - /// Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»). - public Task GetCardAsync(string cardId, CancellationToken ct) - { - return _store.GetCardAsync(cardId, ct); - } - - // ── Переносы / архив / корзина (L163–247) ─────────────────────────────── - - /// - /// Перенос карточки на доску или в «Неразобранное» (move_lead L177–191 + _move L163–174). - /// - /// - /// Цель валидируется до чтения карточки: не inbox и не существующая доска → 400 - /// . Исходная колонка archive/trash для MoveLeadAsync недоступна - /// → 400 : вывод из них — только restore_lead (иначе перенос минует - /// снятие метки «спам» возврата из корзины, Ruling 4). Перенос «в ту же колонку» — no-op (карточка - /// возвращается без изменений; журнал и обучение не пишутся). При реальном переносе: колонка меняется - /// (is_new=FALSE, prev_col = прежняя колонка), matchHits пересчитываются для доски через - /// (Ruling 2; для inbox — пусто), пишется строка журнала action=move, - /// а при to≠inbox — обучающий сигнал PushAsync(text, id доски, 1.0) (Ruling 4; текст = source_msg или title; - /// пустой текст не учим — L188–191). - /// - /// 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); - } - - // Из archive/trash карточку выводит только restore (L204–222): прямой move в доску - // прошёл бы мимо снятия у ML веса «спама» возврата из корзины (Ruling 4, L218–221). - 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)); - } - - /// - /// Перенос карточки в корзину (trash_lead L194–201): col=trash, is_new=FALSE, matchHits пусто. - /// - /// - /// Карточка уже в корзине — no-op (как в _move L165–166). Журнал action=trash пишется при реальном - /// переносе; обучающий сигнал «спам» 1.0 — только если карточка была НЕ в trash/archive (L198–201, - /// Ruling 4). Исключение archive: перенос архива в корзину не «переучивает» на спам. - /// - /// Id карточки (c_...). - /// Токен отмены. - /// Карточка после переноса (при no-op — как была) либо null — карточки нет (404). - public Task TrashCardAsync(string cardId, CancellationToken ct) - { - return TrashCardAsync(cardId, teach: true, ct); - } - - /// - /// Перенос карточки в корзину с управлением обучением ML (trash_lead L194–201). - /// - /// - /// = false — «тихое» перемещение без сигнала «спам»: используется ручной - /// переклассификацией (leads.py reclassify_lead L311/L316), где обучение кладётся ЯВНО одним сигналом - /// с весом гипотезы ИИ (0.4), а не весом действия пользователя (1.0). Журнал action=trash пишется всегда. - /// - /// 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); - } - - /// - /// Возврат карточки из архива/корзины на канбан (restore_lead L204–222). - /// - /// - /// Куда возвращаем: prev_col, если это «Неразобранное» или существующая доска, иначе inbox (L209). - /// При возврате is_new=TRUE, prev_col='inbox', archived_at=NULL (Ruling 10), matchHits пересчитаны для - /// доски (Ruling 2), журнал action=restore. Возврат ИЗ корзины снимает метку спам: - /// PushAsync(text, "spam", −1.0) (L218–221, Ruling 4); из архива сигнал не шлётся. - /// - /// 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; - } - - /// - /// Полное удаление карточки (delete_forever L225–234): Cards + комментарии (FK cascade), - /// журнал CardMoves/MlOutbox не трогаются (Ruling 10). - /// - /// 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 (clear_col L237–247). - /// - /// Другая колонка (inbox/доска/…) → 400 (как ValueError L239–240). - /// Очищаемая колонка: 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); - } - - // ── Комментарии (add_comment L259–265) ────────────────────────────────── - - /// - /// Добавляет комментарий к карточке: строка LeadComments (id cm_) + журнал action=comment. - /// - /// - /// Текст Trim'ится (пустой после Trim → 400 , как dashboard_routes L240–241); - /// автор — «Вы»; ответ — полный список комментариев (свежий — «только что», маппинг адаптера). Карточки - /// нет → Comments=null, Error=null (404 «Карточка не найдена» — сервис читает карточку до записи, порт - /// LeadComments ссылается FK, Task 4). - /// - /// 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); - } - - // ── Пометить прочитанным (mark_seen L250–256) ─────────────────────────── - - /// - /// Снимает флаг «новое»: с одной карточки (cardId), колонки (col) или всех (оба null/пустые). - /// - /// Семантика 1:1 с mark_seen L250–256 (проверка на truthiness: пустая строка = параметр не задан). - /// Эндпоинты этапа: mark-col-seen {col}, mark-all-seen (Ruling 11); /leads/{id}/seen фронтом не вызывается. - /// 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); - } - - // ── Счётчики (counts L268–279) ────────────────────────────────────────── - - /// - /// Счётчики колонок (count+new по Cards) + статистика обучения/решений ML (learning/ml/ai). - /// - /// - /// Форма CardCountsDto: Columns — только колонки с карточками; New — сумма «новых» по колонкам - /// (counts L270–274). learning/ml/ai — из IMlClient.StatusAsync (L275–278, план Task 7 L321): learning = - /// count(CardMoves), ml/ai — KV-счётчики решений пайплайна (на этапе 3 — 0, Ruling 4). Плоскую wire-форму - /// «{new, <col>:{…}, learning, ml, ai}» собирает эндпоинт Task 8. - /// - /// Токен отмены. - /// Счётчики: колонки + 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, - }; - } - - // ── Поиск (search L509–551, LIKE-вариант Ruling 6) ────────────────────── - - /// - /// Поиск карточек: FTS по Cards.SearchTsv + LIKE-дополнение (search L509–551, Ruling 6/Task 12). - /// - /// - /// q после Trim короче 2 символов → пусто, порт не вызывается (поведение этапа 3, L511–512). Сам поиск - /// выполняет адаптер — : SearchTsv @@ plainto_tsquery('russian') - /// (морфология) ∪ LIKE по lower(title/summary/contact/source_msg), контейнеры-стадии «Выбранных» - /// исключены, порядок - /// ts_rank DESC, ReceivedAt DESC, результат ограничен = 12. messages: [] — на - /// совесть эндпоинта (Task 8). Запрос нормализуется trim+lowercase (как отсев-поиск Ruling 6). - /// - /// Поисковый запрос (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-часть (C32: выделено из общего +/// файла по темам, поведение не менялось): чтение списка/карточки, переносы/корзина/возврат/удаление/ +/// очистка колонки, комментарии, mark-seen, счётчики и поиск (leads.py L151–279, L509–551). +/// +public sealed partial class CardsService +{ + // ── Чтение (list_leads/get_lead L151–160) ─────────────────────────────── + + /// + /// Карточки колонки или всех колонок дашборда, received_at DESC (list_leads L151–156). + /// + /// Колонка-фильтр (inbox/archive/trash/доска); null — все колонки дашборда. + /// Токен отмены. + /// Полные карточки (маппинг/комментарии/time — адаптер); пусто — карточек нет. + public Task> ListCardsAsync(string? col, CancellationToken ct) + { + return _store.ListCardsAsync(new CardsQuery(col), ct); + } + + /// + /// Одна карточка по id (get_lead L159–160; GET /api/cards/{id}). + /// + /// Id карточки (c_...). + /// Токен отмены. + /// Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»). + public Task GetCardAsync(string cardId, CancellationToken ct) + { + return _store.GetCardAsync(cardId, ct); + } + + // ── Переносы / архив / корзина (L163–247) ─────────────────────────────── + + /// + /// Перенос карточки на доску или в «Неразобранное» (move_lead L177–191 + _move L163–174). + /// + /// + /// Цель валидируется до чтения карточки: не inbox и не существующая доска → 400 + /// . Исходная колонка archive/trash для MoveLeadAsync недоступна + /// → 400 : вывод из них — только restore_lead (иначе перенос минует + /// снятие метки «спам» возврата из корзины, Ruling 4). Перенос «в ту же колонку» — no-op (карточка + /// возвращается без изменений; журнал и обучение не пишутся). При реальном переносе: колонка меняется + /// (is_new=FALSE, prev_col = прежняя колонка), matchHits пересчитываются для доски через + /// (Ruling 2; для inbox — пусто), пишется строка журнала action=move, + /// а при to≠inbox — обучающий сигнал PushAsync(text, id доски, 1.0) (Ruling 4; текст = source_msg или title; + /// пустой текст не учим — L188–191). + /// + /// 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); + } + + // Из archive/trash карточку выводит только restore (L204–222): прямой move в доску + // прошёл бы мимо снятия у ML веса «спама» возврата из корзины (Ruling 4, L218–221). + 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)); + } + + /// + /// Перенос карточки в корзину (trash_lead L194–201): col=trash, is_new=FALSE, matchHits пусто. + /// + /// + /// Карточка уже в корзине — no-op (как в _move L165–166). Журнал action=trash пишется при реальном + /// переносе; обучающий сигнал «спам» 1.0 — только если карточка была НЕ в trash/archive (L198–201, + /// Ruling 4). Исключение archive: перенос архива в корзину не «переучивает» на спам. + /// + /// Id карточки (c_...). + /// Токен отмены. + /// Карточка после переноса (при no-op — как была) либо null — карточки нет (404). + public Task TrashCardAsync(string cardId, CancellationToken ct) + { + return TrashCardAsync(cardId, teach: true, ct); + } + + /// + /// Перенос карточки в корзину с управлением обучением ML (trash_lead L194–201). + /// + /// + /// = false — «тихое» перемещение без сигнала «спам»: используется ручной + /// переклассификацией (leads.py reclassify_lead L311/L316), где обучение кладётся ЯВНО одним сигналом + /// с весом гипотезы ИИ (0.4), а не весом действия пользователя (1.0). Журнал action=trash пишется всегда. + /// + /// 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); + } + + /// + /// Возврат карточки из архива/корзины на канбан (restore_lead L204–222). + /// + /// + /// Куда возвращаем: prev_col, если это «Неразобранное» или существующая доска, иначе inbox (L209). + /// При возврате is_new=TRUE, prev_col='inbox', archived_at=NULL (Ruling 10), matchHits пересчитаны для + /// доски (Ruling 2), журнал action=restore. Возврат ИЗ корзины снимает метку спам: + /// PushAsync(text, "spam", −1.0) (L218–221, Ruling 4); из архива сигнал не шлётся. + /// + /// 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; + } + + /// + /// Полное удаление карточки (delete_forever L225–234): Cards + комментарии (FK cascade), + /// журнал CardMoves/MlOutbox не трогаются (Ruling 10). + /// + /// 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 (clear_col L237–247). + /// + /// Другая колонка (inbox/доска/…) → 400 (как ValueError L239–240). + /// Очищаемая колонка: 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); + } + + // ── Комментарии (add_comment L259–265) ────────────────────────────────── + + /// + /// Добавляет комментарий к карточке: строка LeadComments (id cm_) + журнал action=comment. + /// + /// + /// Текст Trim'ится (пустой после Trim → 400 , как dashboard_routes L240–241); + /// автор — «Вы»; ответ — полный список комментариев (свежий — «только что», маппинг адаптера). Карточки + /// нет → Comments=null, Error=null (404 «Карточка не найдена» — сервис читает карточку до записи, порт + /// LeadComments ссылается FK, Task 4). + /// + /// 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); + } + + // ── Пометить прочитанным (mark_seen L250–256) ─────────────────────────── + + /// + /// Снимает флаг «новое»: с одной карточки (cardId), колонки (col) или всех (оба null/пустые). + /// + /// Семантика 1:1 с mark_seen L250–256 (проверка на truthiness: пустая строка = параметр не задан). + /// Эндпоинты этапа: mark-col-seen {col}, mark-all-seen (Ruling 11); /leads/{id}/seen фронтом не вызывается. + /// 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); + } + + // ── Счётчики (counts L268–279) ────────────────────────────────────────── + + /// + /// Счётчики колонок (count+new по Cards) + статистика обучения/решений ML (learning/ml/ai). + /// + /// + /// Форма CardCountsDto: Columns — только колонки с карточками; New — сумма «новых» по колонкам + /// (counts L270–274). learning/ml/ai — из IMlClient.StatusAsync (L275–278, план Task 7 L321): learning = + /// count(CardMoves), ml/ai — KV-счётчики решений пайплайна (на этапе 3 — 0, Ruling 4). Плоскую wire-форму + /// «{new, <col>:{…}, learning, ml, ai}» собирает эндпоинт Task 8. + /// + /// Токен отмены. + /// Счётчики: колонки + 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, + }; + } + + // ── Поиск (search L509–551, LIKE-вариант Ruling 6) ────────────────────── + + /// + /// Поиск карточек: FTS по Cards.SearchTsv + LIKE-дополнение (search L509–551, Ruling 6/Task 12). + /// + /// + /// q после Trim короче 2 символов → пусто, порт не вызывается (поведение этапа 3, L511–512). Сам поиск + /// выполняет адаптер — : SearchTsv @@ plainto_tsquery('russian') + /// (морфология) ∪ LIKE по lower(title/summary/contact/source_msg), контейнеры-стадии «Выбранных» + /// исключены, порядок + /// ts_rank DESC, ReceivedAt DESC, результат ограничен = 12. messages: [] — на + /// совесть эндпоинта (Task 8). Запрос нормализуется trim+lowercase (как отсев-поиск Ruling 6). + /// + /// Поисковый запрос (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 cab9a87..a9a9de9 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Reminders.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Reminders.cs @@ -1,11 +1,5 @@ using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; namespace Deal.Modules.Kanban.Application.Services; 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 21c90b3..0618e6a 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs @@ -1,11 +1,7 @@ using System.Text.Json; -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.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Kanban.Application.Services; diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs index c287415..1c27297 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs @@ -1,128 +1,119 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.ColumnRules; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён -// Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас. -using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Сервис карточек — единый домен карточки (дашборд и «Выбранные»): leads.py L151–279 + L509–551 -/// и projects.py L103–282 (этап 9). -/// -/// -/// Чистый сервис модуля (без EF/HTTP): оркестрирует (карточки/контейнеры/ -/// комментарии/журнал), (кэш курсов ratesCache, настройка напоминаний, -/// Ruling 3/7), (обучающие сигналы move/trash/restore и счётчики counts) и -/// (объекты вложений карточки). -/// Полные карточки (JSON-поля, comments, human-метка time, matchHits) собирает адаптер (маппинг -/// строки → CardDto в хранилище); сервис добавляет поведение: валидации с фиксированными текстами прототипа, -/// пересчёт matchHits при размещении в доску (Ruling 2, ColumnRules), журнал CardMoves и push-сигналы ML, -/// операции пространства «Выбранные» (создание, патч, ссылки, файлы, перенос по стадии с историей, -/// напоминания) на той же сущности карточки. -/// Обучение ML идёт ВСЕГДА и синхронно — выключатель mlEnabled управляет только использованием ML в -/// пайплайне, а не записью действий пользователя (ml_client.py L6–7: «обучение идёт всегда»; Ruling 4). -/// 404-семантика «Карточка не найдена» выражается null-результатом методов; тексты 400 — константы класса. -/// C32: класс разделён на partial-файлы по темам (Operations/Helpers/Selected/Files/Reminders). -/// -/// Единый порт хранилища карточек/контейнеров/журнала тенанта. -/// 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; - - /// - /// Создаёт сервис карточек над портами модуля (поле-захват DI-зависимостей). - /// - /// Единый порт хранилища карточек/контейнеров/журнала тенанта. - /// KV-хранилище настроек тенанта (курсы, напоминания). - /// Клиент ML: PushAsync — обучающий сигнал действия, StatusAsync — счётчики counts. - /// Файловое хранилище вложений карточки (объекты файлов). - public CardsService( - ICardStore store, - ISettingsStore settings, - IMlClient mlClient, - IFileStorage storage) - { - _store = store; - _settings = settings; - _mlClient = mlClient; - _storage = storage; - } - - // ── Фиксированные строки прототипа (400-детали; leads.py L183–184, L239–240, dashboard_routes L241) ── - - /// - /// 400 move: целевой контейнер не существует (или это «Неразобранное» — оно допустимо). - /// - public const string MoveTargetInvalidDetail = "Переносить можно только в существующий контейнер или в «Неразобранное»"; - - /// - /// 400 move: исходная колонка archive/trash — из них карточку выводит только restore_lead - /// (кнопка «Вернуть»): прямой перенос в доску миновал бы снятие метки «спам» при возврате из корзины - /// (Ruling 4, PushAsync(spam, −1.0) только в RestoreLeadAsync) и журнал restore. - /// - public const string MoveSourceRestrictedDetail = "Переносить из корзины, архива или «взятых в работу» нельзя — верните карточку на канбан"; - - /// - /// 400 clear-col: колонка не trash/archive (clear_col L239–240). - /// - public const string ClearColInvalidDetail = "Очищать можно только корзину или архив"; - - /// - /// 400 комментарий: пустой текст после Trim (dashboard_routes L240–241). - /// - public const string EmptyCommentDetail = "Пустой комментарий"; - - // ── Журнал CardMoves: действия (leads.py _log_learning L40–44) ────────── - - // Действие журнала: перенос на доску/в «Неразобранное» (_move L174). - private const string ActionMove = "move"; - - // Действие журнала: в корзину (_move action='trash' L196). - private const string ActionTrash = "trash"; - - // Действие журнала: возврат из архива/корзины (restore_lead L216). - private const string ActionRestore = "restore"; - - // Действие журнала: добавлен комментарий (add_comment L264). - private const string ActionComment = "comment"; - - /// - /// Автор комментария — «Вы» (свои комментарии, add_comment L262). Единственный источник строки - /// для комментариев карточки. - /// - public const string CommentAuthor = "Вы"; - - /// - /// Human-метка времени свежего комментария (add_comment L259–265; «только что» = возраст < 1 мин). - /// Единственный источник строки: адаптер KanbanStore считает её в HumanAge. - /// - public const string JustNowLabel = "только что"; - - // Минимальная длина поискового запроса после Trim: q короче → пустой ответ (search L511–512, Ruling 6). - private const int MinSearchQueryLength = 2; - - // Ограничение результатов поиска: не больше 12 карточек (search L509, Ruling 6). - private const int SearchLimit = 12; - - // Вес сигнала пользователя: действие = истина (ml_client.py USER_WEIGHT L26, Ruling 4). - private const double PushWeightUser = 1.0; - - // Вес снятия метки: возврат из корзины (restore_lead L221, delta=-1.0). - private const double PushWeightUnlearn = -1.0; -} +using Deal.Contracts.Integrations.Abstractions; +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; + +/// +/// Сервис карточек — единый домен карточки (дашборд и «Выбранные»): leads.py L151–279 + L509–551 +/// и projects.py L103–282 (этап 9). +/// +/// +/// Чистый сервис модуля (без EF/HTTP): оркестрирует (карточки/контейнеры/ +/// комментарии/журнал), (кэш курсов ratesCache, настройка напоминаний, +/// Ruling 3/7), (обучающие сигналы move/trash/restore и счётчики counts) и +/// (объекты вложений карточки). +/// Полные карточки (JSON-поля, comments, human-метка time, matchHits) собирает адаптер (маппинг +/// строки → CardDto в хранилище); сервис добавляет поведение: валидации с фиксированными текстами прототипа, +/// пересчёт matchHits при размещении в доску (Ruling 2, ColumnRules), журнал CardMoves и push-сигналы ML, +/// операции пространства «Выбранные» (создание, патч, ссылки, файлы, перенос по стадии с историей, +/// напоминания) на той же сущности карточки. +/// Обучение ML идёт ВСЕГДА и синхронно — выключатель mlEnabled управляет только использованием ML в +/// пайплайне, а не записью действий пользователя (ml_client.py L6–7: «обучение идёт всегда»; Ruling 4). +/// 404-семантика «Карточка не найдена» выражается null-результатом методов; тексты 400 — константы класса. +/// C32: класс разделён на partial-файлы по темам (Operations/Helpers/Selected/Files/Reminders). +/// +/// Единый порт хранилища карточек/контейнеров/журнала тенанта. +/// 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; + + /// + /// Создаёт сервис карточек над портами модуля (поле-захват DI-зависимостей). + /// + /// Единый порт хранилища карточек/контейнеров/журнала тенанта. + /// KV-хранилище настроек тенанта (курсы, напоминания). + /// Клиент ML: PushAsync — обучающий сигнал действия, StatusAsync — счётчики counts. + /// Файловое хранилище вложений карточки (объекты файлов). + public CardsService( + ICardStore store, + ISettingsStore settings, + IMlClient mlClient, + IFileStorage storage) + { + _store = store; + _settings = settings; + _mlClient = mlClient; + _storage = storage; + } + + // ── Фиксированные строки прототипа (400-детали; leads.py L183–184, L239–240, dashboard_routes L241) ── + + /// + /// 400 move: целевой контейнер не существует (или это «Неразобранное» — оно допустимо). + /// + public const string MoveTargetInvalidDetail = "Переносить можно только в существующий контейнер или в «Неразобранное»"; + + /// + /// 400 move: исходная колонка archive/trash — из них карточку выводит только restore_lead + /// (кнопка «Вернуть»): прямой перенос в доску миновал бы снятие метки «спам» при возврате из корзины + /// (Ruling 4, PushAsync(spam, −1.0) только в RestoreLeadAsync) и журнал restore. + /// + public const string MoveSourceRestrictedDetail = "Переносить из корзины, архива или «взятых в работу» нельзя — верните карточку на канбан"; + + /// + /// 400 clear-col: колонка не trash/archive (clear_col L239–240). + /// + public const string ClearColInvalidDetail = "Очищать можно только корзину или архив"; + + /// + /// 400 комментарий: пустой текст после Trim (dashboard_routes L240–241). + /// + public const string EmptyCommentDetail = "Пустой комментарий"; + + // ── Журнал CardMoves: действия (leads.py _log_learning L40–44) ────────── + + // Действие журнала: перенос на доску/в «Неразобранное» (_move L174). + private const string ActionMove = "move"; + + // Действие журнала: в корзину (_move action='trash' L196). + private const string ActionTrash = "trash"; + + // Действие журнала: возврат из архива/корзины (restore_lead L216). + private const string ActionRestore = "restore"; + + // Действие журнала: добавлен комментарий (add_comment L264). + private const string ActionComment = "comment"; + + /// + /// Автор комментария — «Вы» (свои комментарии, add_comment L262). Единственный источник строки + /// для комментариев карточки. + /// + public const string CommentAuthor = "Вы"; + + /// + /// Human-метка времени свежего комментария (add_comment L259–265; «только что» = возраст < 1 мин). + /// Единственный источник строки: адаптер KanbanStore считает её в HumanAge. + /// + public const string JustNowLabel = "только что"; + + // Минимальная длина поискового запроса после Trim: q короче → пустой ответ (search L511–512, Ruling 6). + private const int MinSearchQueryLength = 2; + + // Ограничение результатов поиска: не больше 12 карточек (search L509, Ruling 6). + private const int SearchLimit = 12; + + // Вес сигнала пользователя: действие = истина (ml_client.py USER_WEIGHT L26, Ruling 4). + private const double PushWeightUser = 1.0; + + // Вес снятия метки: возврат из корзины (restore_lead L221, delta=-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 e633f2d..57f2230 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs @@ -1,298 +1,294 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Сервис контейнеров (колонок/стадий/зон) и состояния колонок (colState) — этап 9, T4. -/// -/// -/// Приходит на смену BoardsService: Containers — единственный реестр колонок; сервис выполняет CRUD -/// (список по пространству, создание, патч, удаление с переносом карточек в inbox, reorder, приём -/// ИИ-предложений) и держит colState (KV-ключ ). Счётчики карточек -/// контейнера заполняются при чтении из реестра Cards. Чистый сервис модуля (без EF/HTTP). -/// -/// Порт хранилища (контейнеры/карточки тенанта). -/// 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, - }; - - // ── Контейнеры ─────────────────────────────────────────────────────── - - /// - /// Контейнеры пространства в порядке показа, со счётчиками карточек (этап 9, T4). - /// - /// Пространство (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 }; - } - - /// - /// Создаёт контейнер с дефолтами: order = MAX+1, цвет палитры, имя «Новая колонка» при пустом. - /// - /// Вход создания (имя обязательно; цвет/правила/пространство/вид — опциональны). - /// Токен отмены. - /// Созданный контейнер (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; - } - - /// - /// Частичное обновление контейнера: меняются только не-null поля патча; rules/policy заменяются целиком. - /// - /// 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; - } - - /// - /// Принимает ИИ-предложение: снимает флаг suggested у контейнера. - /// - /// 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); - } - - /// - /// Переставляет контейнеры пространства: позиции 0..N-1 в порядке списка. - /// - /// Пространство переставляемых контейнеров. - /// 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 состояния одной колонки: merge патча в текущее значение и запись всего объекта. - /// - /// 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; + +/// +/// Сервис контейнеров (колонок/стадий/зон) и состояния колонок (colState) — этап 9, T4. +/// +/// +/// Приходит на смену BoardsService: Containers — единственный реестр колонок; сервис выполняет CRUD +/// (список по пространству, создание, патч, удаление с переносом карточек в inbox, reorder, приём +/// ИИ-предложений) и держит colState (KV-ключ ). Счётчики карточек +/// контейнера заполняются при чтении из реестра Cards. Чистый сервис модуля (без EF/HTTP). +/// +/// Порт хранилища (контейнеры/карточки тенанта). +/// 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, + }; + + // ── Контейнеры ─────────────────────────────────────────────────────── + + /// + /// Контейнеры пространства в порядке показа, со счётчиками карточек (этап 9, T4). + /// + /// Пространство (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 }; + } + + /// + /// Создаёт контейнер с дефолтами: order = MAX+1, цвет палитры, имя «Новая колонка» при пустом. + /// + /// Вход создания (имя обязательно; цвет/правила/пространство/вид — опциональны). + /// Токен отмены. + /// Созданный контейнер (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; + } + + /// + /// Частичное обновление контейнера: меняются только не-null поля патча; rules/policy заменяются целиком. + /// + /// 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; + } + + /// + /// Принимает ИИ-предложение: снимает флаг suggested у контейнера. + /// + /// 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); + } + + /// + /// Переставляет контейнеры пространства: позиции 0..N-1 в порядке списка. + /// + /// Пространство переставляемых контейнеров. + /// 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 состояния одной колонки: merge патча в текущее значение и запись всего объекта. + /// + /// 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 cee9400..30616dd 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/ConversionRecomputer.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/ConversionRecomputer.cs @@ -1,98 +1,95 @@ -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Пересчёт конверсий бюджетов карточек при смене курсов/целевой валюты (Ruling 7, план Task 12). -/// Реализация порта модуля Settings (регистрация — AddKanbanModule). -/// -/// -/// Чистый сервис модуля (без EF/HTTP), повторяет rates.py recompute_conversions (L106–130). Полный -/// пересчёт: кандидаты (budget_cur != '' и колонка не -/// archive/trash — фильтрует хранилище), курсы — из кэша ratesCache -/// ( через типизированный снимок настроек TenantSettingsSnapshot, C30; -/// конвертация — , USDT=USD), целевая валюта — настройка -/// targetCurrency. conversionOn=false → 0 без изменений (rates.py L112–113). Карточка, нижняя граница которой -/// не конвертируется (нет курса валюты либо budget_from не задан), пропускается ЦЕЛИКОМ — conv-поля не -/// трогаются (rates.py L123–124: cf is None → continue). Нет кэша курсов → пересчёт не выполняется (мягкая -/// семантика, дефолт-мок НЕ подставляется — по прототипу). Повторный вызов идемпотентен: пересчитывает по -/// текущим настройкам/курсам всё заново. -/// -/// 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 - } - - // Нижняя граница; верхняя — как есть, при отсутствии — равна нижней (одна сумма/«от X», L121–122). - 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; + +/// +/// Пересчёт конверсий бюджетов карточек при смене курсов/целевой валюты (Ruling 7, план Task 12). +/// Реализация порта модуля Settings (регистрация — AddKanbanModule). +/// +/// +/// Чистый сервис модуля (без EF/HTTP), повторяет rates.py recompute_conversions (L106–130). Полный +/// пересчёт: кандидаты (budget_cur != '' и колонка не +/// archive/trash — фильтрует хранилище), курсы — из кэша ratesCache +/// ( через типизированный снимок настроек TenantSettingsSnapshot, C30; +/// конвертация — , USDT=USD), целевая валюта — настройка +/// targetCurrency. conversionOn=false → 0 без изменений (rates.py L112–113). Карточка, нижняя граница которой +/// не конвертируется (нет курса валюты либо budget_from не задан), пропускается ЦЕЛИКОМ — conv-поля не +/// трогаются (rates.py L123–124: cf is None → continue). Нет кэша курсов → пересчёт не выполняется (мягкая +/// семантика, дефолт-мок НЕ подставляется — по прототипу). Повторный вызов идемпотентен: пересчитывает по +/// текущим настройкам/курсам всё заново. +/// +/// 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 + } + + // Нижняя граница; верхняя — как есть, при отсутствии — равна нижней (одна сумма/«от X», L121–122). + 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/FileKindDetector.cs b/src/core/Deal.Modules.Kanban/Application/Services/FileKindDetector.cs index f71e0fe..8a17b06 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/FileKindDetector.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/FileKindDetector.cs @@ -1,7 +1,4 @@ -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; namespace Deal.Modules.Kanban.Application.Services; diff --git a/src/core/Deal.Modules.Kanban/Application/Services/StorageTickService.cs b/src/core/Deal.Modules.Kanban/Application/Services/StorageTickService.cs index 3001950..5b2209d 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/StorageTickService.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/StorageTickService.cs @@ -1,82 +1,78 @@ -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; - -namespace Deal.Modules.Kanban.Application.Services; - -/// -/// Сервис правил хранения: автоархив и очистка архива/корзины по срокам — Ruling 8, план Task 10. -/// -/// -/// Чистый сервис модуля (без EF/HTTP), повторяет leads.py tick_storage (L454–493). Настройки -/// autoArchive/archiveAfterDays/archiveClearDays/trashClearDays читает типизированным снимком -/// (C30: один GetAllAsync на тик; отсутствие/повреждение → дефолт из -/// SettingsDefaults). Один «now» -/// (UTC) на весь тик, как в прототипе. Последовательность 1:1 с tick_storage: -/// (1) автоархив — карточки досок и «Неразобранного» со ReceivedAt старше archiveAfterDays → col=archive, -/// is_new=false, archived_at=now (PrevCol НЕ трогается — Ruling 8: null в CardColumnUpdateDto), пачкой -/// (один UPDATE вместо N); -/// (2) очистка архива — col='archive' с ArchivedAt старше archiveClearDays (дефолт 90) → жёсткое удаление; -/// (3) очистка корзины — col='trash' с ReceivedAt старше trashClearDays (дефолт 7) → жёсткое удаление. -/// Удаление — пачкой (Cards + комментарии каскадом; журнал CardMoves -/// и MlOutbox не трогаются, Ruling 10). purgedRejected — отсев пайплайна (этап 4); в этапе 3 всегда 0. -/// SSE-тосты сервис НЕ публикует: это обязанность эндпоинтов Api (Ruling 5) по статистике TickAsync. -/// -/// Порт хранилища канбана: кандидаты тика, batch-перенос в архив, жёсткое удаление пачки. -/// KV-хранилище настроек тенанта (снимок правил хранения, Ruling 8). -public sealed class StorageTickService(ICardStore store, ISettingsStore settings) -{ - /// - /// Один тик правил хранения тенанта (Ruling 8; leads.py tick_storage L454–493). - /// - /// Токен отмены. - /// Статистика тика: сколько карточек архивировано/очищено из архива и корзины (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); - - // Один «now» на весь тик — границы автоархива и очисток от одного момента (tick_storage L459). - DateTimeOffset now = DateTimeOffset.UtcNow; - - int archived = 0; - if (autoArchive) - { - // Автоархив: карточки досок и «Неразобранного» (tick_storage L462–467). В архив — пачкой одним - // UPDATE (архивация как системный перенос: col=archive, is_new=false, archived_at=now, matchHits - // пусто; prev_col не трогается, Ruling 8); по-карточные UPDATE'ы тика заменены на batch. - IReadOnlyList candidates = await store.ListArchiveCandidatesAsync( - now.AddDays(-archiveAfterDays), ct); - archived = candidates.Count == 0 ? 0 : await store.ArchiveAsync(candidates, now, ct); - } - - // Очистка архива по сроку хранения (tick_storage L475–478). - int purgedArchive = await PurgeAsync( - await store.ListExpiredArchiveCandidatesAsync(now.AddDays(-archiveClearDays), ct), ct); - - // Очистка корзины по сроку хранения (tick_storage L480–483). - int purgedTrash = await PurgeAsync( - await store.ListTrashCandidatesAsync(now.AddDays(-trashClearDays), ct), ct); - - // purgedRejected — отсев пайплайна живёт в Pipeline (этап 4); в этапе 3 всегда 0 (Ruling 8). - return new StorageTickStatsDto(archived, purgedArchive, purgedTrash, PurgedRejected: 0); - } - - // Жёстко удаляет пачку кандидатов очистки (Ruling 8; KanbanStore.PurgeAsync — комментарии каскадом). - // cardIds: Id карточек-кандидатов из выборки хранилища. - // ct: Токен отмены. - // Возвращает: Сколько карточек реально удалено (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; + +/// +/// Сервис правил хранения: автоархив и очистка архива/корзины по срокам — Ruling 8, план Task 10. +/// +/// +/// Чистый сервис модуля (без EF/HTTP), повторяет leads.py tick_storage (L454–493). Настройки +/// autoArchive/archiveAfterDays/archiveClearDays/trashClearDays читает типизированным снимком +/// (C30: один GetAllAsync на тик; отсутствие/повреждение → дефолт из +/// SettingsDefaults). Один «now» +/// (UTC) на весь тик, как в прототипе. Последовательность 1:1 с tick_storage: +/// (1) автоархив — карточки досок и «Неразобранного» со ReceivedAt старше archiveAfterDays → col=archive, +/// is_new=false, archived_at=now (PrevCol НЕ трогается — Ruling 8: null в CardColumnUpdateDto), пачкой +/// (один UPDATE вместо N); +/// (2) очистка архива — col='archive' с ArchivedAt старше archiveClearDays (дефолт 90) → жёсткое удаление; +/// (3) очистка корзины — col='trash' с ReceivedAt старше trashClearDays (дефолт 7) → жёсткое удаление. +/// Удаление — пачкой (Cards + комментарии каскадом; журнал CardMoves +/// и MlOutbox не трогаются, Ruling 10). purgedRejected — отсев пайплайна (этап 4); в этапе 3 всегда 0. +/// SSE-тосты сервис НЕ публикует: это обязанность эндпоинтов Api (Ruling 5) по статистике TickAsync. +/// +/// Порт хранилища канбана: кандидаты тика, batch-перенос в архив, жёсткое удаление пачки. +/// KV-хранилище настроек тенанта (снимок правил хранения, Ruling 8). +public sealed class StorageTickService(ICardStore store, ISettingsStore settings) +{ + /// + /// Один тик правил хранения тенанта (Ruling 8; leads.py tick_storage L454–493). + /// + /// Токен отмены. + /// Статистика тика: сколько карточек архивировано/очищено из архива и корзины (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); + + // Один «now» на весь тик — границы автоархива и очисток от одного момента (tick_storage L459). + DateTimeOffset now = DateTimeOffset.UtcNow; + + int archived = 0; + if (autoArchive) + { + // Автоархив: карточки досок и «Неразобранного» (tick_storage L462–467). В архив — пачкой одним + // UPDATE (архивация как системный перенос: col=archive, is_new=false, archived_at=now, matchHits + // пусто; prev_col не трогается, Ruling 8); по-карточные UPDATE'ы тика заменены на batch. + IReadOnlyList candidates = await store.ListArchiveCandidatesAsync( + now.AddDays(-archiveAfterDays), ct); + archived = candidates.Count == 0 ? 0 : await store.ArchiveAsync(candidates, now, ct); + } + + // Очистка архива по сроку хранения (tick_storage L475–478). + int purgedArchive = await PurgeAsync( + await store.ListExpiredArchiveCandidatesAsync(now.AddDays(-archiveClearDays), ct), ct); + + // Очистка корзины по сроку хранения (tick_storage L480–483). + int purgedTrash = await PurgeAsync( + await store.ListTrashCandidatesAsync(now.AddDays(-trashClearDays), ct), ct); + + // purgedRejected — отсев пайплайна живёт в Pipeline (этап 4); в этапе 3 всегда 0 (Ruling 8). + return new StorageTickStatsDto(archived, purgedArchive, purgedTrash, PurgedRejected: 0); + } + + // Жёстко удаляет пачку кандидатов очистки (Ruling 8; KanbanStore.PurgeAsync — комментарии каскадом). + // cardIds: Id карточек-кандидатов из выборки хранилища. + // ct: Токен отмены. + // Возвращает: Сколько карточек реально удалено (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 80b45c1..12115c1 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/SuggestHeuristics.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/SuggestHeuristics.cs @@ -1,11 +1,5 @@ using System.Text; -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.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Registrars; namespace Deal.Modules.Kanban.Application.Services; diff --git a/src/core/Deal.Modules.Pipeline/Application/Abstractions/IPipelineStore.cs b/src/core/Deal.Modules.Pipeline/Application/Abstractions/IPipelineStore.cs index 6abb0be..5334d80 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Abstractions/IPipelineStore.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Abstractions/IPipelineStore.cs @@ -1,6 +1,4 @@ using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; namespace Deal.Modules.Pipeline.Application.Abstractions; diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineIdPrefixes.cs b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineIdPrefixes.cs index ee58512..fc66c7b 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineIdPrefixes.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineIdPrefixes.cs @@ -1,7 +1,3 @@ -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; - namespace Deal.Modules.Pipeline.Application.Models; /// diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineQueueStatuses.cs b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineQueueStatuses.cs index 8deaeaa..f2bd4f9 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineQueueStatuses.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineQueueStatuses.cs @@ -1,7 +1,3 @@ -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; - namespace Deal.Modules.Pipeline.Application.Models; /// diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineRejectConstants.cs b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineRejectConstants.cs index c9195a5..7a96226 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineRejectConstants.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineRejectConstants.cs @@ -1,7 +1,3 @@ -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; - namespace Deal.Modules.Pipeline.Application.Models; /// diff --git a/src/core/Deal.Modules.Pipeline/Application/Parse/LocalFieldsParser.cs b/src/core/Deal.Modules.Pipeline/Application/Parse/LocalFieldsParser.cs index 9f1947d..49a3234 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Parse/LocalFieldsParser.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Parse/LocalFieldsParser.cs @@ -4,8 +4,6 @@ using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; namespace Deal.Modules.Pipeline.Application.Parse; diff --git a/src/core/Deal.Modules.Pipeline/Application/Registrars/PipelineModuleRegistrar.cs b/src/core/Deal.Modules.Pipeline/Application/Registrars/PipelineModuleRegistrar.cs index 3928c49..114dad4 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Registrars/PipelineModuleRegistrar.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Registrars/PipelineModuleRegistrar.cs @@ -1,75 +1,73 @@ -using Deal.Modules.Pipeline.Application.Parse; -using Microsoft.Extensions.DependencyInjection; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Services; - -namespace Deal.Modules.Pipeline.Application.Registrars; - -/// -/// DI-регистрация модуля Pipeline. Паттерн «port & adapter» (Ruling 10). -/// -/// -/// Регистрируются сервисы модуля: scoped-ядра с настройками (), приём сообщений -/// (, Ruling 2), обработка/мониторинг (, Task 5), -/// создание карточек через Kanban (/, Task 7) и воркер pump -/// (, Task 8) — остальные сервисы этапа 4 добавляются по мере задач. -/// Ядра разбора — статические чистые классы -/// (задача 4), регистрация не нужна. Порт-адаптер (IPipelineStore → PipelineStore) реализован в -/// Deal.Infrastructure и регистрируется там -/// (AddDealPersistence, Task 3); IAiClassifier → LocalAiClassifier — в AddDealIntegrations (Task 6) — модуль -/// не знает про EF и HTTP. Зависимости модуля — Deal.Modules.Settings (ISettingsStore/IncomingRules), -/// Deal.Modules.Kanban (ICardStore + чистые ColumnRules/BudgetNormalizer/AmountParser/PrefixId, Ruling 3) и -/// Deal.Contracts (IMlClient, Task 5/8) — реверс-зависимостей нет (Kanban/Settings о Pipeline не знают). -/// -public static class PipelineModuleRegistrar -{ - /// - /// Регистрирует сервисы модуля Pipeline в контейнере. - /// - /// Коллекция сервисов. - /// Коллекция сервисов для цепочки вызовов. - /// - /// Время жизни — scoped: зависимости (IPipelineStore/ISettingsStore/ICardStore/IMlClient) живут в рамках - /// tenant-запроса (EF-контекст), как IncomingRules/Kanban-сервисы (эталон KanbanModuleRegistrar). - /// зависит от (возврат в - /// обработку ставит force-строку через полный путь приёма, Ruling 10) — цикла нет (Ingest о Processing - /// не знает). Вызывается из Program.cs (AddPipelineModule, Task 9) после AddDealPersistence. - /// - public static IServiceCollection AddPipelineModule(this IServiceCollection services) - { - // Ядра разбора задачи 4: статические (MessageTextCleaner/…/SummaryComposer) не регистрируются; - // LocalFieldsParser читает маркеры через ISettingsStore — scoped, как IncomingRules (эталон KanbanModuleRegistrar). - services.AddScoped(); - - // Task 5: приём входящих (gRPC-ингресс telegram-service) и обработка/мониторинг вкладки «Обработка». - services.AddScoped(); - services.AddScoped(); - - // Task 7: композитор карточки (Ruling 4) и обёртка создания через ICardStore.AddCardAsync (Ruling 3) — - // оба читают настройки тенанта/доски (ISettingsStore/ICardStore scoped, как IncomingRules). - services.AddScoped(); - services.AddScoped(); - - // Task 8: воркер pump — один проход очереди (stale/правила/дедуп/ML/ИИ/карточка, Ruling 8). Вызывают - // фоновый цикл (Task 11) и POST /api/admin/tick (Task 10) из Api — модуль сам циклы не заводит. - services.AddScoped(); - - // Task 15: контекст ИИ-классификации (промпты + доски + few-shot-примеры; Ruling 5). Читает настройки - // и доски/журнал тенанта — scoped, как CardComposer. Потребитель — gRPC-адаптер GrpcAiClassifier - // (Infrastructure, реализует порт IAiClassifier); локальный путь контекст не строит. - services.AddScoped(); - - // Этап 12, пакет D: ручная переклассификация карточек (POST /api/cards/reclassify|{id}/reclassify). - // Синхронный проход той же механикой, что и воркер (фильтр/классификация/сборка/ML), поэтому scoped — - // как воркер. Замок single-flight живёт в singleton (общий для всех tenant-запросов процесса). - services.AddSingleton(); - services.AddScoped(); - - // Этап 12, добивка по ТЗ §8: ручная проверка/разметка ML на сообщениях канала — кандидаты и применение - // решения (обучение ML + перенос/корзина/отсев через существующие сервисы). Scoped — как остальные - // сервисы модуля (EF-контекст tenant-запроса). - 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. Паттерн «port & adapter» (Ruling 10). +/// +/// +/// Регистрируются сервисы модуля: scoped-ядра с настройками (), приём сообщений +/// (, Ruling 2), обработка/мониторинг (, Task 5), +/// создание карточек через Kanban (/, Task 7) и воркер pump +/// (, Task 8) — остальные сервисы этапа 4 добавляются по мере задач. +/// Ядра разбора — статические чистые классы +/// (задача 4), регистрация не нужна. Порт-адаптер (IPipelineStore → PipelineStore) реализован в +/// Deal.Infrastructure и регистрируется там +/// (AddDealPersistence, Task 3); IAiClassifier → LocalAiClassifier — в AddDealIntegrations (Task 6) — модуль +/// не знает про EF и HTTP. Зависимости модуля — Deal.Modules.Settings (ISettingsStore/IncomingRules), +/// Deal.Modules.Kanban (ICardStore + чистые ColumnRules/BudgetNormalizer/AmountParser/PrefixId, Ruling 3) и +/// Deal.Contracts (IMlClient, Task 5/8) — реверс-зависимостей нет (Kanban/Settings о Pipeline не знают). +/// +public static class PipelineModuleRegistrar +{ + /// + /// Регистрирует сервисы модуля Pipeline в контейнере. + /// + /// Коллекция сервисов. + /// Коллекция сервисов для цепочки вызовов. + /// + /// Время жизни — scoped: зависимости (IPipelineStore/ISettingsStore/ICardStore/IMlClient) живут в рамках + /// tenant-запроса (EF-контекст), как IncomingRules/Kanban-сервисы (эталон KanbanModuleRegistrar). + /// зависит от (возврат в + /// обработку ставит force-строку через полный путь приёма, Ruling 10) — цикла нет (Ingest о Processing + /// не знает). Вызывается из Program.cs (AddPipelineModule, Task 9) после AddDealPersistence. + /// + public static IServiceCollection AddPipelineModule(this IServiceCollection services) + { + // Ядра разбора задачи 4: статические (MessageTextCleaner/…/SummaryComposer) не регистрируются; + // LocalFieldsParser читает маркеры через ISettingsStore — scoped, как IncomingRules (эталон KanbanModuleRegistrar). + services.AddScoped(); + + // Task 5: приём входящих (gRPC-ингресс telegram-service) и обработка/мониторинг вкладки «Обработка». + services.AddScoped(); + services.AddScoped(); + + // Task 7: композитор карточки (Ruling 4) и обёртка создания через ICardStore.AddCardAsync (Ruling 3) — + // оба читают настройки тенанта/доски (ISettingsStore/ICardStore scoped, как IncomingRules). + services.AddScoped(); + services.AddScoped(); + + // Task 8: воркер pump — один проход очереди (stale/правила/дедуп/ML/ИИ/карточка, Ruling 8). Вызывают + // фоновый цикл (Task 11) и POST /api/admin/tick (Task 10) из Api — модуль сам циклы не заводит. + services.AddScoped(); + + // Task 15: контекст ИИ-классификации (промпты + доски + few-shot-примеры; Ruling 5). Читает настройки + // и доски/журнал тенанта — scoped, как CardComposer. Потребитель — gRPC-адаптер GrpcAiClassifier + // (Infrastructure, реализует порт IAiClassifier); локальный путь контекст не строит. + services.AddScoped(); + + // Этап 12, пакет D: ручная переклассификация карточек (POST /api/cards/reclassify|{id}/reclassify). + // Синхронный проход той же механикой, что и воркер (фильтр/классификация/сборка/ML), поэтому scoped — + // как воркер. Замок single-flight живёт в singleton (общий для всех tenant-запросов процесса). + services.AddSingleton(); + services.AddScoped(); + + // Этап 12, добивка по ТЗ §8: ручная проверка/разметка ML на сообщениях канала — кандидаты и применение + // решения (обучение ML + перенос/корзина/отсев через существующие сервисы). Scoped — как остальные + // сервисы модуля (EF-контекст tenant-запроса). + 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 8adde08..828a2f8 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/AiCardLearning.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/AiCardLearning.cs @@ -1,69 +1,61 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Kanban.Application.ColumnRules; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Обучающие сигналы ML по карточке ИИ-пути (pipeline.py L1155–1180): колонка-доска и тип заявки. -/// -/// -/// Общий источник для воркера pump () и ручной переклассификации -/// (): оба «докладывают» ML те же сигналы гипотезы ИИ с весом -/// , поэтому логика вынесена из воркера без дублей. -/// Свободная колонка — не служебная (inbox/trash/archive), не ИИ-предложение и без активных правил: -/// именно такие доски 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 по карточке ИИ-пути (pipeline.py L1155–1180): колонка-доска и тип заявки. +/// +/// +/// Общий источник для воркера pump () и ручной переклассификации +/// (): оба «докладывают» ML те же сигналы гипотезы ИИ с весом +/// , поэтому логика вынесена из воркера без дублей. +/// Свободная колонка — не служебная (inbox/trash/archive), не ИИ-предложение и без активных правил: +/// именно такие доски 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 a2ec164..05154d2 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/AiCardMapper.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/AiCardMapper.cs @@ -1,70 +1,65 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Маппинг локального разбора в контрактный (pipeline.py _local_fields L718–798 → raw-словарь карточки). -/// -/// -/// Единый модульный способ превратить (LocalFieldsParser, Ruling 7) в форму -/// разбора, которую ест : бюджет нормализуется -/// (ai.py clean_budget L316–326), контакты квалифицируются (build_contacts -/// L389–421), блок «О заявке» — только legacy-суть (), тип — маркерная -/// гипотеза (is_vacancy_known=false), доска — inbox (board=null: «смысловые колонки до ИИ не -/// назначаем», python L954–958/L796–797). Маппинг используют локальные пути воркера (Ruling 8: aiEnabled=false, -/// сбой ИИ, ML-ветка с локальными полями) и адаптер LocalAiClassifier (Ruling 5 — эталон «ядро владельца + -/// тонкий адаптер»), поэтому он живёт в модуле, а не в Infrastructure: один источник истины для обеих сторон. -/// -public static class AiCardMapper -{ - /// - /// Строит разбор карточки из локальных полей и исходного текста (форма классификатора Ruling 5). - /// - /// - /// Квалификация контактов идёт по строке с fallback-поиском в тексте - /// (python build_contacts L389–421, ≤6, боты/сервисные ссылки отбрасываются) — как при сборке карточки - /// CardComposer'ом, чтобы разбор и карточка не расходились. Вызывающий при необходимости перекрывает - /// поля через with (ML-ветка: доска/тип ML, доклад terms в стек; локальные пути — нет). - /// - /// Локальные поля (заголовок/суть/стек/бюджет/контакты/признак найма). - /// Исходный текст сообщения (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; + +/// +/// Маппинг локального разбора в контрактный (pipeline.py _local_fields L718–798 → raw-словарь карточки). +/// +/// +/// Единый модульный способ превратить (LocalFieldsParser, Ruling 7) в форму +/// разбора, которую ест : бюджет нормализуется +/// (ai.py clean_budget L316–326), контакты квалифицируются (build_contacts +/// L389–421), блок «О заявке» — только legacy-суть (), тип — маркерная +/// гипотеза (is_vacancy_known=false), доска — inbox (board=null: «смысловые колонки до ИИ не +/// назначаем», python L954–958/L796–797). Маппинг используют локальные пути воркера (Ruling 8: aiEnabled=false, +/// сбой ИИ, ML-ветка с локальными полями) и адаптер LocalAiClassifier (Ruling 5 — эталон «ядро владельца + +/// тонкий адаптер»), поэтому он живёт в модуле, а не в Infrastructure: один источник истины для обеих сторон. +/// +public static class AiCardMapper +{ + /// + /// Строит разбор карточки из локальных полей и исходного текста (форма классификатора Ruling 5). + /// + /// + /// Квалификация контактов идёт по строке с fallback-поиском в тексте + /// (python build_contacts L389–421, ≤6, боты/сервисные ссылки отбрасываются) — как при сборке карточки + /// CardComposer'ом, чтобы разбор и карточка не расходились. Вызывающий при необходимости перекрывает + /// поля через with (ML-ветка: доска/тип ML, доклад terms в стек; локальные пути — нет). + /// + /// Локальные поля (заголовок/суть/стек/бюджет/контакты/признак найма). + /// Исходный текст сообщения (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 76dc7af..78519ce 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/AiClassifyContextBuilder.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/AiClassifyContextBuilder.cs @@ -1,201 +1,194 @@ -using System.Text; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Kanban.Application.ColumnRules; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Сборка контекста ИИ-классификации из настроек и данных тенанта (план Task 15, Ruling 5; -/// ai.py fill_prompt L63–77, classify L218–258). -/// -/// -/// Чистый сервис модуля Pipeline (без EF/HTTP): готовит то, что ядро кладёт в запросы ai-service, — 1:1 с -/// прототипом: -/// -/// — заполненный aiFilterPrompt (fill_prompt: подстановка -/// {domain}/{keywords} из настроек «Сфера и ключи», python L192); -/// — заполненные aiPrompt + cardPrompt, склеенные через -/// пустую строку, если cardPrompt непуст (python L252–257); -/// — user-контекст «Доски + примеры разметки + Сообщение» -/// (python L226–251): доски non-suggested с критериями правил (RulesDescriber, python describe L341–368) либо -/// ключевыми словами (≤8) и описанием (≤160), few-shot-примеры пользовательской разметки по журналу CardMoves -/// (≤8, текст ≤500) и текст сообщения (≤5000). -/// -/// Ветки выключателей (aiEnabled/aiFilterEnabled) и решение «когда звать ИИ» остаются за воркером (Ruling 5 -/// этапа 4) — билдер вызывает только потребитель gRPC-адаптера (GrpcAiClassifier); локальный классификатор -/// контекст не строит. Scoped: настройки/доски/журнал читаются из tenant-scope запроса (ISettingsStore/ -/// ICardStore, как CardComposer). -/// -/// KV-настройки тенанта (промпты и «Сфера и ключи»). -/// Порт канбана: доски (non-suggested) и few-shot-примеры журнала CardMoves. -public sealed class AiClassifyContextBuilder(ISettingsStore settings, ICardStore kanjStore) -{ - /// - /// Лимит примеров разметки в контексте (python _learning_examples L201: limit=8). - /// - public const int MaxLearningExamples = 8; - - /// - /// Лимит описания колонки в строке-описании (python L241: description[:160]). - /// - public const int MaxBoardDescriptionCodePoints = 160; - - /// - /// Лимит текста примера разметки (python L214: msg[:500]). - /// - public const int MaxMarkupTextCodePoints = 500; - - /// - /// Лимит текста сообщения в контексте (python L250: message_text[:5000]). - /// - public const int MaxMessageTextCodePoints = 5000; - - // Текст карты досок, когда колонок нет (python L243). - private const string NoBoardsLine = "- (колонок пока нет — верните board: null)"; - - // Заголовок раздела досок user-контекста (python L246). - private const string BoardsHeader = "Доски: "; - - // Заголовок раздела примеров разметки (python L247). - private const string ExamplesHeader = "Примеры разметки пользователя:"; - - // Заголовок раздела нового сообщения (python L250). - private const string MessageHeader = "Новое сообщение:"; - - /// - /// Заполненный промпт ИИ-фильтра: aiFilterPrompt с подстановкой {domain}/{keywords} (python L192). - /// - /// Токен отмены. - /// Текст system-промпта фильтра для FilterRequest. - public async Task BuildFilterPromptAsync(CancellationToken ct) - { - TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); - return FillPrompt(SettingsKeys.AiFilterPrompt, SettingsDefaults.AiFilterPrompt, settingsSnapshot); - } - - /// - /// Заполненный system-промпт классификации: aiPrompt + cardPrompt (python L252–257). - /// - /// Токен отмены. - /// Текст system_prompt ClassifyRequest (cardPrompt приклеен, если непуст). - 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-контекст классификации «Доски + примеры разметки + Сообщение» (python L226–251). - /// - /// Текст сообщения (режется до ). - /// Токен отмены. - /// Текст 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(); - } - - // Карта досок классификации: non-suggested с критериями/ключами и описанием (python L226–243). - // ct: Токен отмены. - // Возвращает: Список строк «- id: имя (критерии) — описание»; пусто — фраза «колонок нет». - 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); - } - - // Строка колонки: «- id: имя (критерии)» + « — описание» (python L230–242). - // container: Колонка (non-suggested, kind=board). - // Возвращает: Однострочное описание колонки для промпта. - 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; - } - - // Заполненный промпт: значение настройки (или дефолт) + PromptFiller (python fill_prompt L63–77). - // key: Ключ промпта (aiPrompt/aiFilterPrompt/cardPrompt). - // defaultValue: Дефолт из SettingsDefaults (когда переопределения нет). - // settingsSnapshot: Типизированный снимок настроек тенанта (промпты/«Сфера и ключи»). - // Возвращает: Текст промпта с подставленными {domain}/{keywords}. - 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; + +/// +/// Сборка контекста ИИ-классификации из настроек и данных тенанта (план Task 15, Ruling 5; +/// ai.py fill_prompt L63–77, classify L218–258). +/// +/// +/// Чистый сервис модуля Pipeline (без EF/HTTP): готовит то, что ядро кладёт в запросы ai-service, — 1:1 с +/// прототипом: +/// +/// — заполненный aiFilterPrompt (fill_prompt: подстановка +/// {domain}/{keywords} из настроек «Сфера и ключи», python L192); +/// — заполненные aiPrompt + cardPrompt, склеенные через +/// пустую строку, если cardPrompt непуст (python L252–257); +/// — user-контекст «Доски + примеры разметки + Сообщение» +/// (python L226–251): доски non-suggested с критериями правил (RulesDescriber, python describe L341–368) либо +/// ключевыми словами (≤8) и описанием (≤160), few-shot-примеры пользовательской разметки по журналу CardMoves +/// (≤8, текст ≤500) и текст сообщения (≤5000). +/// +/// Ветки выключателей (aiEnabled/aiFilterEnabled) и решение «когда звать ИИ» остаются за воркером (Ruling 5 +/// этапа 4) — билдер вызывает только потребитель gRPC-адаптера (GrpcAiClassifier); локальный классификатор +/// контекст не строит. Scoped: настройки/доски/журнал читаются из tenant-scope запроса (ISettingsStore/ +/// ICardStore, как CardComposer). +/// +/// KV-настройки тенанта (промпты и «Сфера и ключи»). +/// Порт канбана: доски (non-suggested) и few-shot-примеры журнала CardMoves. +public sealed class AiClassifyContextBuilder(ISettingsStore settings, ICardStore kanjStore) +{ + /// + /// Лимит примеров разметки в контексте (python _learning_examples L201: limit=8). + /// + public const int MaxLearningExamples = 8; + + /// + /// Лимит описания колонки в строке-описании (python L241: description[:160]). + /// + public const int MaxBoardDescriptionCodePoints = 160; + + /// + /// Лимит текста примера разметки (python L214: msg[:500]). + /// + public const int MaxMarkupTextCodePoints = 500; + + /// + /// Лимит текста сообщения в контексте (python L250: message_text[:5000]). + /// + public const int MaxMessageTextCodePoints = 5000; + + // Текст карты досок, когда колонок нет (python L243). + private const string NoBoardsLine = "- (колонок пока нет — верните board: null)"; + + // Заголовок раздела досок user-контекста (python L246). + private const string BoardsHeader = "Доски: "; + + // Заголовок раздела примеров разметки (python L247). + private const string ExamplesHeader = "Примеры разметки пользователя:"; + + // Заголовок раздела нового сообщения (python L250). + private const string MessageHeader = "Новое сообщение:"; + + /// + /// Заполненный промпт ИИ-фильтра: aiFilterPrompt с подстановкой {domain}/{keywords} (python L192). + /// + /// Токен отмены. + /// Текст system-промпта фильтра для FilterRequest. + public async Task BuildFilterPromptAsync(CancellationToken ct) + { + TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); + return FillPrompt(SettingsKeys.AiFilterPrompt, SettingsDefaults.AiFilterPrompt, settingsSnapshot); + } + + /// + /// Заполненный system-промпт классификации: aiPrompt + cardPrompt (python L252–257). + /// + /// Токен отмены. + /// Текст system_prompt ClassifyRequest (cardPrompt приклеен, если непуст). + 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-контекст классификации «Доски + примеры разметки + Сообщение» (python L226–251). + /// + /// Текст сообщения (режется до ). + /// Токен отмены. + /// Текст 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(); + } + + // Карта досок классификации: non-suggested с критериями/ключами и описанием (python L226–243). + // ct: Токен отмены. + // Возвращает: Список строк «- id: имя (критерии) — описание»; пусто — фраза «колонок нет». + 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); + } + + // Строка колонки: «- id: имя (критерии)» + « — описание» (python L230–242). + // container: Колонка (non-suggested, kind=board). + // Возвращает: Однострочное описание колонки для промпта. + 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; + } + + // Заполненный промпт: значение настройки (или дефолт) + PromptFiller (python fill_prompt L63–77). + // key: Ключ промпта (aiPrompt/aiFilterPrompt/cardPrompt). + // defaultValue: Дефолт из SettingsDefaults (когда переопределения нет). + // settingsSnapshot: Типизированный снимок настроек тенанта (промпты/«Сфера и ключи»). + // Возвращает: Текст промпта с подставленными {domain}/{keywords}. + 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 9432a57..a1a2170 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/AiRawCardMapper.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/AiRawCardMapper.cs @@ -1,354 +1,348 @@ -using System.Globalization; -using System.Text.Json; -using System.Text.Json.Nodes; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Строгий маппинг JSON-ответа ИИ-классификатора в контрактный разбор карточки (план Task 15, Ruling 5; -/// python _store_lead L433–514 + clean_budget L316–339 + build_contacts L389–421). -/// -/// -/// Потребитель — gRPC-адаптер GrpcAiClassifier (реализация на этапе 6): -/// ответ ai-service (ClassifyReply.Json — сырой JSON модели, типовую схему задаёт промпт aiPrompt/cardPrompt) -/// маппится 1:1 с python: заголовок — (≤140, fallback — начало -/// исходника L455), блок «О заявке» — структурированные поля raw (company/format/task/requirements/plus/ -/// conditions) + legacy-суть summary (композицию в текст делает CardComposer через SummaryComposer), стек — -/// (normalize_stack L332–341), бюджет — -/// (clean_budget L316–339: алиасы валют, «2к»→2000, одна сумма → from=to, 0 → null; не распознан → null), контакты — -/// (build_contacts L389–421, ≤6, дедуп, fallback-поиск в тексте), типы/спам/ -/// доска — как вернула модель (доску страхует CardComposer правилами ContainerAccepts, python L446–450). -/// Ключи полей 1:1 с прототипом (title/summary/company/format/task/requirements/plus/conditions/stack/budget/ -/// contacts/is_vacancy/is_vacancy_known/is_spam/board); вызов вне данных — . -/// -public static class AiRawCardMapper -{ - // Лимит заголовка карточки (python L455: clean_short(title, 140)). - private const int MaxTitleCodePoints = 140; - - // Лимит исходного текста как fallback-заголовка (python L455: clean_short(text, 140)). - private const int MaxFallbackTextCodePoints = 140; - - // Ключи числа бюджета: цифры с необязательным суффиксом «к/К» и «руб/р/₽» (ai.py _budget_num L294–313). - private const string BudgetAmountPattern = @"[0-9.,]+\s*[кkКK]?"; - - // Имя поля валюты в объекте бюджета (ai.py L327: currency или cur). - private const string CurrencyFieldName = "currency"; - - // Альтернативное имя поля валюты в объекте бюджета (ai.py L327: budget.get("cur")). - private const string CurrencyFieldNameShort = "cur"; - - // Регулярное выражение числа бюджета: число + необязательные «к»/валюта-суффиксы. - private static readonly System.Text.RegularExpressions.Regex BudgetNumRe = - new(@"\A" + BudgetAmountPattern + @"(?:руб|р|₽)?\z", System.Text.RegularExpressions.RegexOptions.CultureInvariant); - - /// - /// Маппит JSON-ответ модели в контрактный разбор карточки (поля 1:1 с _store_lead). - /// - /// Сырой JSON-ответ модели (ClassifyReply.Json; типовую схему задаёт промпт). - /// Исходный текст сообщения (fallback заголовка/контактов, python L455/L406). - /// Разбор: структурированный блок «О заявке»/суть, стек, бюджет, контакты, тип/спам/доска. - /// Ответ не объект/не разбирается — «ИИ не дал разбора» (ветка 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); - } - - // Заголовок/строковые поля: значение как строка (python str(raw.get(k) or "")). - // root: Корневой объект ответа модели. - // field: Имя поля. - // Возвращает: Значение строкой (отсутствие/null → пустая строка). - 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; - } - - // Строковое поле блока «О заявке»: null/отсутствие/пусто → null (python: блока нет). - // root: Корневой объект ответа модели. - // field: Имя поля. - // Возвращает: Значение или null. - private static string? ReadNullableString(JsonObject root, string field) - { - string value = ReadRawString(root, field); - return value.Length == 0 ? null : value; - } - - // Булево поле: true/1/строковые «да»-подобные → true, иначе false (python bool(raw.get(...))). - // root: Корневой объект ответа модели. - // field: Имя поля. - // Возвращает: True/False; отсутствие/неразбираемое → false. - 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; - } - - // Список пунктов (requirements/plus): python compose_summary _items L235–241 — normalize_list + чистка. - // root: Корневой объект ответа модели. - // field: Имя поля. - // Возвращает: Очищенные пункты; отсутствие/null → null (блока нет), пустой список → пустой список. - 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; - } - - // Стек из ответа модели (python normalize_stack L332–341: список или строка, ≤12, ≥2 символов). - // root: Корневой объект ответа модели. - // Возвращает: Нормализованный стек. - 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(); - } - - // Бюджет из ответа модели (ai.py clean_budget L316–339 через BudgetNormalizer). - // root: Корневой объект ответа модели. - // Возвращает: Нормализованный бюджет контракта или null (нет объекта/валюты/границ). - 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); - } - - // Граница бюджета (ai.py _budget_num L294–313): число, «2к» → 2000, «2000₽» → 2000. - // budget: Объект бюджета ответа модели. - // field: Имя границы (from/to). - // Возвращает: Число или null — поле отсутствует/пусто/не разбирается. - 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; - } - - // Контакты из ответа модели (python build_contacts L389–421: строка/список/значения-объекты). - // root: Корневой объект ответа модели. - // text: Исходный текст сообщения (fallback-кандидаты, если контактов в ответе нет). - // Возвращает: Квалифицированные контакты (≤6, дедуп по casefold-значению). - 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; - } - - // Значение JsonNode строкой: JsonValue — как ValueToString, прочее/отсутствие — пустая строка. - // node: Узел JSON (элемент массива). - // Возвращает: Строковое представление (python str()). - private static string NodeToString(JsonNode? node) - => node is JsonValue value ? ValueToString(value) : string.Empty; - - // Значение JsonValue строкой (python str(): строка — как есть, число — без дробного хвоста). - // value: JSON-значение. - // Возвращает: Строковое представление. - 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-ответа ИИ-классификатора в контрактный разбор карточки (план Task 15, Ruling 5; +/// python _store_lead L433–514 + clean_budget L316–339 + build_contacts L389–421). +/// +/// +/// Потребитель — gRPC-адаптер GrpcAiClassifier (реализация на этапе 6): +/// ответ ai-service (ClassifyReply.Json — сырой JSON модели, типовую схему задаёт промпт aiPrompt/cardPrompt) +/// маппится 1:1 с python: заголовок — (≤140, fallback — начало +/// исходника L455), блок «О заявке» — структурированные поля raw (company/format/task/requirements/plus/ +/// conditions) + legacy-суть summary (композицию в текст делает CardComposer через SummaryComposer), стек — +/// (normalize_stack L332–341), бюджет — +/// (clean_budget L316–339: алиасы валют, «2к»→2000, одна сумма → from=to, 0 → null; не распознан → null), контакты — +/// (build_contacts L389–421, ≤6, дедуп, fallback-поиск в тексте), типы/спам/ +/// доска — как вернула модель (доску страхует CardComposer правилами ContainerAccepts, python L446–450). +/// Ключи полей 1:1 с прототипом (title/summary/company/format/task/requirements/plus/conditions/stack/budget/ +/// contacts/is_vacancy/is_vacancy_known/is_spam/board); вызов вне данных — . +/// +public static class AiRawCardMapper +{ + // Лимит заголовка карточки (python L455: clean_short(title, 140)). + private const int MaxTitleCodePoints = 140; + + // Лимит исходного текста как fallback-заголовка (python L455: clean_short(text, 140)). + private const int MaxFallbackTextCodePoints = 140; + + // Ключи числа бюджета: цифры с необязательным суффиксом «к/К» и «руб/р/₽» (ai.py _budget_num L294–313). + private const string BudgetAmountPattern = @"[0-9.,]+\s*[кkКK]?"; + + // Имя поля валюты в объекте бюджета (ai.py L327: currency или cur). + private const string CurrencyFieldName = "currency"; + + // Альтернативное имя поля валюты в объекте бюджета (ai.py L327: budget.get("cur")). + private const string CurrencyFieldNameShort = "cur"; + + // Регулярное выражение числа бюджета: число + необязательные «к»/валюта-суффиксы. + private static readonly System.Text.RegularExpressions.Regex BudgetNumRe = + new(@"\A" + BudgetAmountPattern + @"(?:руб|р|₽)?\z", System.Text.RegularExpressions.RegexOptions.CultureInvariant); + + /// + /// Маппит JSON-ответ модели в контрактный разбор карточки (поля 1:1 с _store_lead). + /// + /// Сырой JSON-ответ модели (ClassifyReply.Json; типовую схему задаёт промпт). + /// Исходный текст сообщения (fallback заголовка/контактов, python L455/L406). + /// Разбор: структурированный блок «О заявке»/суть, стек, бюджет, контакты, тип/спам/доска. + /// Ответ не объект/не разбирается — «ИИ не дал разбора» (ветка 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); + } + + // Заголовок/строковые поля: значение как строка (python str(raw.get(k) or "")). + // root: Корневой объект ответа модели. + // field: Имя поля. + // Возвращает: Значение строкой (отсутствие/null → пустая строка). + 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; + } + + // Строковое поле блока «О заявке»: null/отсутствие/пусто → null (python: блока нет). + // root: Корневой объект ответа модели. + // field: Имя поля. + // Возвращает: Значение или null. + private static string? ReadNullableString(JsonObject root, string field) + { + string value = ReadRawString(root, field); + return value.Length == 0 ? null : value; + } + + // Булево поле: true/1/строковые «да»-подобные → true, иначе false (python bool(raw.get(...))). + // root: Корневой объект ответа модели. + // field: Имя поля. + // Возвращает: True/False; отсутствие/неразбираемое → false. + 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; + } + + // Список пунктов (requirements/plus): python compose_summary _items L235–241 — normalize_list + чистка. + // root: Корневой объект ответа модели. + // field: Имя поля. + // Возвращает: Очищенные пункты; отсутствие/null → null (блока нет), пустой список → пустой список. + 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; + } + + // Стек из ответа модели (python normalize_stack L332–341: список или строка, ≤12, ≥2 символов). + // root: Корневой объект ответа модели. + // Возвращает: Нормализованный стек. + 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(); + } + + // Бюджет из ответа модели (ai.py clean_budget L316–339 через BudgetNormalizer). + // root: Корневой объект ответа модели. + // Возвращает: Нормализованный бюджет контракта или null (нет объекта/валюты/границ). + 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); + } + + // Граница бюджета (ai.py _budget_num L294–313): число, «2к» → 2000, «2000₽» → 2000. + // budget: Объект бюджета ответа модели. + // field: Имя границы (from/to). + // Возвращает: Число или null — поле отсутствует/пусто/не разбирается. + 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; + } + + // Контакты из ответа модели (python build_contacts L389–421: строка/список/значения-объекты). + // root: Корневой объект ответа модели. + // text: Исходный текст сообщения (fallback-кандидаты, если контактов в ответе нет). + // Возвращает: Квалифицированные контакты (≤6, дедуп по casefold-значению). + 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; + } + + // Значение JsonNode строкой: JsonValue — как ValueToString, прочее/отсутствие — пустая строка. + // node: Узел JSON (элемент массива). + // Возвращает: Строковое представление (python str()). + private static string NodeToString(JsonNode? node) + => node is JsonValue value ? ValueToString(value) : string.Empty; + + // Значение JsonValue строкой (python str(): строка — как есть, число — без дробного хвоста). + // value: JSON-значение. + // Возвращает: Строковое представление. + 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 7615d72..e07d8b8 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/CardComposer.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/CardComposer.cs @@ -1,212 +1,204 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Kanban.Application.ColumnRules; -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.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Сборка снимка карточки из разобранного сообщения для записи через Kanban (план Task 7 L393–399, -/// Ruling 4; python pipeline.py _store_lead L433–514). -/// -/// -/// Чистый класс модуля Pipeline (без EF/HTTP): из разбора (ИИ/локальный путь) -/// и строки-сообщения собирает полный 1:1 с _store_lead: -/// title — (140, fallback — начало исходника); «О заявке» — -/// (блоки Компания → … → Условия, 1:1 с cardPrompt/compose_summary -/// L225–284) через (2000, fallback — clean_short исходника); -/// stack — (≤12); бюджет — -/// из разбора (форма хранения) + fallback первой суммы по -/// исходнику/«О заявке» (L459–468); конверсия один раз при поступлении — -/// (conversionOn/targetCurrency/ratesCache из типизированного снимка TenantSettingsSnapshot, C30, с мок-фолбэком); -/// контакты — из значений разбора или текста (L389–421, ≤6, дедуп), -/// contact = (L424–430, ≤200); ch-поля канала, sourceMsg = text[:4000], -/// prevCol=inbox, isVacancy/isVacancyKnown из разбора. -/// -/// Колонка: разбор может назначить доску (parsed.Board) — читает её через -/// и применяет страховку (python -/// L449–450): доска отсутствует или текст не прошёл правила → col=inbox (ИИ/ML не кладут в отфильтрованную -/// колонку); прошла → col=доска, matchHits = для прошедшей доски (иначе -/// пусто). Id карточки (c_) генерирует вызывающий (PipelineCardWriter) и передаёт готовым (Ruling 12). -/// -/// -public sealed class CardComposer(ICardStore kanjStore, ISettingsStore settings) -{ - // Лимит заголовка карточки (python _store_lead L455: clean_short(title, 140)). - private const int MaxTitleCodePoints = 140; - - // Лимит блока «О заявке» (python L458: clean_block(summary, 2000)). - private const int MaxSummaryCodePoints = 2000; - - // Лимит исходного сообщения на карточке (python L503: text[:4000]). - private const int MaxSourceMsgCodePoints = 4000; - - // Лимит «быстрого» контакта карточки (python L471: primary_contact(contacts)[:200]). - private const int MaxPrimaryContactCodePoints = 200; - - /// - /// Собирает полный снимок новой карточки из разбора и строки сообщения (1:1 с _store_lead L433–514). - /// - /// - /// Использует только поля-сообщения QueueItemDto (DialogId/Channel/Text/MsgId/MsgAtMs) — статус/время - /// постановки строки на карточку не влияют. «Повтор по дедупу» здесь не проверяется (этап воркера, - /// Ruling 8): вызывающий (PipelineCardWriter/воркер) уже заявил хэш и связал карточку после записи. - /// - /// Разбор сообщения (ИИ-классификатор или локальный путь; контакты квалифицированы). - /// Строка очереди с сообщением-источником (метаданные канала, текст, время). - /// Готовый 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); - } - - // Колонка и «почему карточка здесь»: страховка L449–450 (доски нет/не прошла правила → inbox). - 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); - string hue = string.IsNullOrWhiteSpace(message.Channel.Hue) ? SourceDefaults.DefaultHue : message.Channel.Hue; - DateTimeOffset receivedAt = message.MsgAtMs != 0 - ? DateTimeOffset.FromUnixTimeMilliseconds(message.MsgAtMs) - : DateTimeOffset.UtcNow; // python row.get("msg_at") or now (L501) - - 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, - ChannelName = message.Channel.Name, - ChannelHandle = message.Channel.Handle, - ChannelHue = hue, - ReceivedAt = receivedAt, - SourceMsg = MessageTextCleaner.SliceCodePoints(message.Text, MaxSourceMsgCodePoints), - SourceDialogId = message.DialogId, - SourceMsgId = message.MsgId, - PrevCol = CardIds.Inbox, - ArchivedAt = null, - MatchHits = matchHits, - }; - } - - // Бюджет карточки: нормализация из разбора, иначе fallback первой суммы по исходнику/«О заявке» - // (python L453/L459–468; формы хранения — CardBudgetDto). - // parsedBudget: Бюджет разбора (форма контракта; null — разбор не выделил сумму). - // text: Текст исходного сообщения (первый источник fallback). - // summary: Блок «О заявке» (второй источник fallback — сумма часто уходит в «Условия»). - // Возвращает: Нормализованный бюджет либо null — суммы с валютой нет ни в разборе, ни в тексте. - 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); - } - - // Маппинг разбора классификатора в структуру блока «О заявке» (поля 1:1 с cardPrompt L116–123). - // parsed: Разбор (пустые/отсутствующие поля блок не дают — SummaryComposer). - // Возвращает: Структура для SummaryComposer.Compose. - 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.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 (план Task 7 L393–399, +/// Ruling 4; python pipeline.py _store_lead L433–514). +/// +/// +/// Чистый класс модуля Pipeline (без EF/HTTP): из разбора (ИИ/локальный путь) +/// и строки-сообщения собирает полный 1:1 с _store_lead: +/// title — (140, fallback — начало исходника); «О заявке» — +/// (блоки Компания → … → Условия, 1:1 с cardPrompt/compose_summary +/// L225–284) через (2000, fallback — clean_short исходника); +/// stack — (≤12); бюджет — +/// из разбора (форма хранения) + fallback первой суммы по +/// исходнику/«О заявке» (L459–468); конверсия один раз при поступлении — +/// (conversionOn/targetCurrency/ratesCache из типизированного снимка TenantSettingsSnapshot, C30, с мок-фолбэком); +/// контакты — из значений разбора или текста (L389–421, ≤6, дедуп), +/// contact = (L424–430, ≤200); ch-поля канала, sourceMsg = text[:4000], +/// prevCol=inbox, isVacancy/isVacancyKnown из разбора. +/// +/// Колонка: разбор может назначить доску (parsed.Board) — читает её через +/// и применяет страховку (python +/// L449–450): доска отсутствует или текст не прошёл правила → col=inbox (ИИ/ML не кладут в отфильтрованную +/// колонку); прошла → col=доска, matchHits = для прошедшей доски (иначе +/// пусто). Id карточки (c_) генерирует вызывающий (PipelineCardWriter) и передаёт готовым (Ruling 12). +/// +/// +public sealed class CardComposer(ICardStore kanjStore, ISettingsStore settings) +{ + // Лимит заголовка карточки (python _store_lead L455: clean_short(title, 140)). + private const int MaxTitleCodePoints = 140; + + // Лимит блока «О заявке» (python L458: clean_block(summary, 2000)). + private const int MaxSummaryCodePoints = 2000; + + // Лимит исходного сообщения на карточке (python L503: text[:4000]). + private const int MaxSourceMsgCodePoints = 4000; + + // Лимит «быстрого» контакта карточки (python L471: primary_contact(contacts)[:200]). + private const int MaxPrimaryContactCodePoints = 200; + + /// + /// Собирает полный снимок новой карточки из разбора и строки сообщения (1:1 с _store_lead L433–514). + /// + /// + /// Использует только поля-сообщения QueueItemDto (DialogId/Channel/Text/MsgId/MsgAtMs) — статус/время + /// постановки строки на карточку не влияют. «Повтор по дедупу» здесь не проверяется (этап воркера, + /// Ruling 8): вызывающий (PipelineCardWriter/воркер) уже заявил хэш и связал карточку после записи. + /// + /// Разбор сообщения (ИИ-классификатор или локальный путь; контакты квалифицированы). + /// Строка очереди с сообщением-источником (метаданные канала, текст, время). + /// Готовый 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); + } + + // Колонка и «почему карточка здесь»: страховка L449–450 (доски нет/не прошла правила → inbox). + 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); + string hue = string.IsNullOrWhiteSpace(message.Channel.Hue) ? SourceDefaults.DefaultHue : message.Channel.Hue; + DateTimeOffset receivedAt = message.MsgAtMs != 0 + ? DateTimeOffset.FromUnixTimeMilliseconds(message.MsgAtMs) + : DateTimeOffset.UtcNow; // python row.get("msg_at") or now (L501) + + 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, + ChannelName = message.Channel.Name, + ChannelHandle = message.Channel.Handle, + ChannelHue = hue, + ReceivedAt = receivedAt, + SourceMsg = MessageTextCleaner.SliceCodePoints(message.Text, MaxSourceMsgCodePoints), + SourceDialogId = message.DialogId, + SourceMsgId = message.MsgId, + PrevCol = CardIds.Inbox, + ArchivedAt = null, + MatchHits = matchHits, + }; + } + + // Бюджет карточки: нормализация из разбора, иначе fallback первой суммы по исходнику/«О заявке» + // (python L453/L459–468; формы хранения — CardBudgetDto). + // parsedBudget: Бюджет разбора (форма контракта; null — разбор не выделил сумму). + // text: Текст исходного сообщения (первый источник fallback). + // summary: Блок «О заявке» (второй источник fallback — сумма часто уходит в «Условия»). + // Возвращает: Нормализованный бюджет либо null — суммы с валютой нет ни в разборе, ни в тексте. + 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); + } + + // Маппинг разбора классификатора в структуру блока «О заявке» (поля 1:1 с cardPrompt L116–123). + // parsed: Разбор (пустые/отсутствующие поля блок не дают — SummaryComposer). + // Возвращает: Структура для SummaryComposer.Compose. + 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 d828408..c55a73d 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs @@ -1,398 +1,390 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -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.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Ручная переклассификация карточек: повторный прогон через тот же конвейер, что и пайплайн -/// (leads.py reclassify_lead L292–389 + reclassify_inbox L392–417), но без создания новой карточки. -/// -/// -/// Для каждой карточки повторяются шаги «filtered»-прохода воркера: ИИ-фильтр (при включённом ИИ) → -/// классификация через → отсев спама в корзину с обучением ML → сборка контента -/// (заголовок/«О заявке»/стек/бюджет/контакты) → страховка ContainerAccepts → -/// обновление карточки одним запросом и обучающие сигналы ML (). -/// -/// Путь без ИИ (выключен настройкой aiEnabled) и сбой классификатора — локальный детерминированный -/// разбор ( + , как ветка воркера aiEnabled=false/ -/// aiFail): сервис не падает без кредов/сервиса ИИ. ИИ-фильтр уважает выключатель aiFilterEnabled. -/// -/// -/// Проход синхронный, одна переклассификация за раз — (состояние singleton); -/// занятый проход отвечает busy без ожидания. Токены ИИ-пути учитывает сам адаптер -/// GrpcAiClassifier через TokenUsageRecorder — отдельного учёта переклассификации не нужно. -/// -/// -/// Единый порт хранилища карточек (чтение 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) -{ - /// - /// Причина: в «Неразобранном» нет карточек для переклассификации (пустой target). - /// - public const string EmptyInboxReason = "В «Неразобранном» нет карточек для переклассификации"; - - /// - /// Причина: у карточки нет исходного текста (переклассифицировать нечего). - /// - public const string NoSourceTextReason = "У карточки нет исходного текста для переклассификации"; - - // Ответ «фильтр пропущен» (выключен/сбой/путь без ИИ: python L1097–1106). - private static readonly AiFilterResultDto PassSkipped = new(Pass: true, Reason: null, Skipped: true); - - /// - /// Пакетная переклассификация «Неразобранного»: все карточки inbox либо пересечение с ids. - /// - /// Порядок обхода — как отдаёт хранилище (received_at DESC); ids ограничивает выборку. - /// Опциональный список id (null/пусто — все карточки inbox). - /// Токен отмены. - /// Итог прохода (счётчики исхода) либо busy, если проход уже идёт. - public async Task ReclassifyInboxAsync(IReadOnlyList? ids, CancellationToken ct) - { - 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); - } - - foreach (CardDto card in target) - { - await ReclassifyOneAsync(card, pass, ct); - } - - 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.SourceMsg)) - { - 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.SourceMsg; - 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) - { - // Классификатор недоступен/сбой — локальный разбор (как raw={} python L1112–1114). - parsed = null; - } - - if (parsed is not null) - { - // Успешная классификация ИИ подтверждает тип по контексту (python L1108–1111). - 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); - } - - // Отправляет карточку в корзину и обучает ML «спаму» с весом гипотезы ИИ (python L311–318). - // card: Карточка. - // text: Исходный текст (обучающий пример). - // pass: Накопители прохода. - // 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: Исходный текст (source_msg). - // Возвращает: Строка очереди, эквивалентная исходному сообщению карточки. - private static QueueItemDto BuildMessage(CardDto card, string text) => new() - { - DialogId = card.SourceDialogId, - MsgId = card.SourceMsgId, - Text = text, - Channel = new PipelineChannelDto(card.Channel.Name, card.Channel.Handle, card.Channel.Hue), - 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); - - // Контакт карточки: новый из разбора, иначе — валидный старый (python L327–331). - // card: Карточка до переклассификации (старый контакт). - // computed: Контакт, собранный из нового разбора. - // Возвращает: Значение основного контакта либо пустая строка. - 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; - } - - // Отбор карточек inbox по опциональному списку id (python L394–399). - // inbox: Карточки «Неразобранного» (порядок хранилища). - // ids: Опциональный фильтр id. - // Возвращает: Целевые карточки в порядке хранилища. - 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; + +/// +/// Ручная переклассификация карточек: повторный прогон через тот же конвейер, что и пайплайн +/// (leads.py reclassify_lead L292–389 + reclassify_inbox L392–417), но без создания новой карточки. +/// +/// +/// Для каждой карточки повторяются шаги «filtered»-прохода воркера: ИИ-фильтр (при включённом ИИ) → +/// классификация через → отсев спама в корзину с обучением ML → сборка контента +/// (заголовок/«О заявке»/стек/бюджет/контакты) → страховка ContainerAccepts → +/// обновление карточки одним запросом и обучающие сигналы ML (). +/// +/// Путь без ИИ (выключен настройкой aiEnabled) и сбой классификатора — локальный детерминированный +/// разбор ( + , как ветка воркера aiEnabled=false/ +/// aiFail): сервис не падает без кредов/сервиса ИИ. ИИ-фильтр уважает выключатель aiFilterEnabled. +/// +/// +/// Проход синхронный, одна переклассификация за раз — (состояние singleton); +/// занятый проход отвечает busy без ожидания. Токены ИИ-пути учитывает сам адаптер +/// GrpcAiClassifier через TokenUsageRecorder — отдельного учёта переклассификации не нужно. +/// +/// +/// Единый порт хранилища карточек (чтение 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) +{ + /// + /// Причина: в «Неразобранном» нет карточек для переклассификации (пустой target). + /// + public const string EmptyInboxReason = "В «Неразобранном» нет карточек для переклассификации"; + + /// + /// Причина: у карточки нет исходного текста (переклассифицировать нечего). + /// + public const string NoSourceTextReason = "У карточки нет исходного текста для переклассификации"; + + // Ответ «фильтр пропущен» (выключен/сбой/путь без ИИ: python L1097–1106). + private static readonly AiFilterResultDto PassSkipped = new(Pass: true, Reason: null, Skipped: true); + + /// + /// Пакетная переклассификация «Неразобранного»: все карточки inbox либо пересечение с ids. + /// + /// Порядок обхода — как отдаёт хранилище (received_at DESC); ids ограничивает выборку. + /// Опциональный список id (null/пусто — все карточки inbox). + /// Токен отмены. + /// Итог прохода (счётчики исхода) либо busy, если проход уже идёт. + public async Task ReclassifyInboxAsync(IReadOnlyList? ids, CancellationToken ct) + { + 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); + } + + foreach (CardDto card in target) + { + await ReclassifyOneAsync(card, pass, ct); + } + + 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.SourceMsg)) + { + 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.SourceMsg; + 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) + { + // Классификатор недоступен/сбой — локальный разбор (как raw={} python L1112–1114). + parsed = null; + } + + if (parsed is not null) + { + // Успешная классификация ИИ подтверждает тип по контексту (python L1108–1111). + 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); + } + + // Отправляет карточку в корзину и обучает ML «спаму» с весом гипотезы ИИ (python L311–318). + // card: Карточка. + // text: Исходный текст (обучающий пример). + // pass: Накопители прохода. + // 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: Исходный текст (source_msg). + // Возвращает: Строка очереди, эквивалентная исходному сообщению карточки. + private static QueueItemDto BuildMessage(CardDto card, string text) => new() + { + DialogId = card.SourceDialogId, + MsgId = card.SourceMsgId, + Text = text, + Channel = new PipelineChannelDto(card.Channel.Name, card.Channel.Handle, card.Channel.Hue), + 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); + + // Контакт карточки: новый из разбора, иначе — валидный старый (python L327–331). + // card: Карточка до переклассификации (старый контакт). + // computed: Контакт, собранный из нового разбора. + // Возвращает: Значение основного контакта либо пустая строка. + 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; + } + + // Отбор карточек inbox по опциональному списку id (python L394–399). + // inbox: Карточки «Неразобранного» (порядок хранилища). + // ids: Опциональный фильтр id. + // Возвращает: Целевые карточки в порядке хранилища. + 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 51d7150..83c2dee 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/GlobalExclusionRules.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/GlobalExclusionRules.cs @@ -1,8 +1,5 @@ using Deal.Modules.Kanban.Application.ColumnRules; -using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; namespace Deal.Modules.Pipeline.Application.Services; diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs b/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs index 179467c..e4c04e0 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs @@ -1,447 +1,442 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Ручная проверка/разметка ML на сообщениях канала (§8 ML: «проверка на сообщении/канале»). -/// -/// -/// -/// Candidates: собирает реальные сообщения-кандидаты по каналу (dialogId) либо по всей выборке, если -/// канал не задан, из трёх существующих источников тенанта — очереди обработки (), -/// отсева () и карточек (), -/// объединяя по (dialogId, msgId): карточка «перекрывает» отсев, отсев — очередь. Каждый кандидат несёт -/// исходный текст и текущий вердикт; мнение ML добавляется прогнозом . -/// -/// -/// Apply: ручное решение пользователя — «спам», «в колонку», «пропустить» — применяется через -/// существующие сервисы/ядро: обучение ML — , перенос/корзина карточки — -/// (он сам учит ML, дублирования сигналов нет), отсев сообщения из очереди — -/// . Действие 1:1 с прототипом ml_routes.py L137–171 -/// (skip/spam/board:<id>), плюс отсев ещё не обработанного сообщения и защита от неизвестной доски. -/// -/// -public sealed class MlReviewService( - IPipelineStore pipelineStore, - ICardStore cardStore, - CardsService cards, - PipelineProcessingService processing, - IMlClient mlClient) -{ - /// - /// Минимум сообщений в выборке кандидатов (кламп запроса 1..60, как прототип). - /// - public const int MinCandidates = 1; - - /// - /// Максимум сообщений в выборке кандидатов (кламп запроса 1..60, как прототип). - /// - public const int MaxCandidates = 60; - - // Размер одного чтения из очереди/отсева при объединении кандидатов. - private const int MaxScan = 500; - - // Длина текста кандидата в ответе (ml_routes.py L129: text[:600]). - private const int TextPreviewLength = 600; - - /// - /// Вердикт кандидата: по сообщению уже есть карточка. - /// - public const string VerdictCard = "card"; - - /// - /// Вердикт кандидата: сообщение в отсеве. - /// - public const string VerdictRejected = "rejected"; - - /// - /// Вердикт кандидата: сообщение ждёт обработки в очереди. - /// - public const string VerdictQueued = "queued"; - - /// - /// Действие: пропустить без обучения (ml_routes.py L144–145). - /// - public const string ActionSkip = "skip"; - - /// - /// Действие: спам — учим ML и (если есть) карточку в корзину (ml_routes.py L150–156). - /// - public const string ActionSpam = "spam"; - - /// - /// Префикс действия «в колонку»: board:<id> (ml_routes.py L157). - /// - public const string ActionBoardPrefix = "board:"; - - /// - /// 400 apply: неизвестная доска-цель (ml_routes.py L159–160). - /// - public const string UnknownBoardDetail = "Неизвестная доска"; - - /// - /// 400 apply: неизвестное действие (ml_routes.py L169–170). - /// - public const string UnknownActionDetail = "Неизвестное действие"; - - // Причина отсева при ручной разметке «спам» ещё не обработанного сообщения. - private const string ManualSpamReason = "ручная разметка ML: спам"; - - // Этап отсева при ручной разметке «спам» (отсев решением ML). - private const string ManualSpamStage = "spam_ml"; - - // Источник решения при ручной разметке. - private const string ManualSource = "ml"; - - // Вес обучающего сигнала ручной разметки — действие пользователя (ml_client.py USER_WEIGHT 1.0). - private const double UserPushWeight = 1.0; - - /// - /// Отбирает сообщения-кандидаты для проверки ML по каналу и/или размеру выборки. - /// - /// Id канала/диалога; пусто — выборка по всем источникам тенанта. - /// Сколько последних сообщений вернуть (кламп 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); - - // Объединение по (dialogId, msgId): очередь → отсев → карточка (последняя перекрывает предыдущие). - var merged = new Dictionary<(string Dialog, long MsgId), MlCandidateDto>(); - foreach (QueueItemDto row in queue) - { - if (row.MsgId is not { } msgId || !MatchesDialog(dialog, row.DialogId)) - { - continue; - } - - merged[(row.DialogId, msgId)] = BuildQueued(row, msgId); - } - - foreach (RejectedItemDto row in rejected) - { - if (row.MsgId is not { } msgId || !MatchesDialog(dialog, row.DialogId)) - { - continue; - } - - merged[(row.DialogId, msgId)] = BuildRejected(row, msgId); - } - - foreach (CardDto card in cardList) - { - if (card.SourceMsgId is not { } msgId || !MatchesDialog(dialog, card.SourceDialogId)) - { - continue; - } - - merged[(card.SourceDialogId, msgId)] = BuildCard(card, msgId); - } - - 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; - } - - /// - /// Применяет ручное решение по сообщению: обучение ML + перенос/корзина/отсев. - /// - /// Id канала/диалога сообщения. - /// 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(); - - CardDto? card = await cardStore.GetCardBySourceAsync(dialog, msgId, ct); - string? text = await FindTextAsync(dialog, msgId, 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, msgId, card, text, ct); - } - - if (normalized.StartsWith(ActionBoardPrefix, StringComparison.Ordinal)) - { - string boardId = normalized[ActionBoardPrefix.Length..].Trim(); - return await ApplyBoardAsync(dialog, msgId, boardId, card, text, ct); - } - - return new MlApplyResult(UnknownActionDetail, Ok: false, Learned: false, Moved: null, LeadId: null); - } - - // Действие «спам»: карточку — в корзину (с обучением), сообщение из очереди — в отсев; иначе учим ML. - // dialog: Id диалога. - // msgId: Id сообщения. - // card: Карточка сообщения (null — сообщение не становилось карточкой). - // text: Текст сообщения. - // ct: Токен отмены. - // Возвращает: Результат решения. - private async Task ApplySpamAsync( - string dialog, - long msgId, - 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, msgId, ct); - if (queued is not null) - { - await processing.RejectAsync(new RejectRecord - { - DialogId = queued.DialogId, - MsgId = queued.MsgId, - Text = queued.Text, - ChannelName = queued.Channel.Name, - ChannelHandle = queued.Channel.Handle, - ChannelHue = queued.Channel.Hue, - MsgAtMs = queued.MsgAtMs, - Source = 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. - // dialog: Id диалога. - // msgId: Id сообщения. - // boardId: Id колонки-цели (inbox или b_...). - // card: Карточка сообщения (null — сообщение не становилось карточкой). - // text: Текст сообщения. - // ct: Токен отмены. - // Возвращает: Результат решения. - private async Task ApplyBoardAsync( - string dialog, - long msgId, - 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) - { - // Повторная разметка карточки в той же колонке — только обучение (ml_routes.py L163–165). - 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); - } - - // Текст исходного сообщения: source_msg карточки, иначе текст строки очереди/записи отсева. - // dialog: Id диалога. - // msgId: Id сообщения. - // card: Карточка сообщения (уже прочитана вызывающим). - // ct: Токен отмены. - // Возвращает: Непустой текст либо null, если сообщения нет ни в одном источнике. - private async Task FindTextAsync( - string dialog, - long msgId, - CardDto? card, - CancellationToken ct) - { - if (card is not null && !string.IsNullOrWhiteSpace(card.SourceMsg)) - { - return card.SourceMsg; - } - - QueueItemDto? queued = await FindQueuedAsync(dialog, msgId, 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 (row.MsgId == msgId && string.Equals(row.DialogId, dialog, StringComparison.Ordinal)) - { - return row.Text; - } - } - - return null; - } - - // Строка очереди сообщения (для отсева при ручной разметке «спам»). - // dialog: Id диалога. - // msgId: Id сообщения. - // ct: Токен отмены. - // Возвращает: Строка очереди либо null — сообщение уже обработано/не в очереди. - private async Task FindQueuedAsync( - string dialog, - long msgId, - CancellationToken ct) - { - IReadOnlyList queue = await pipelineStore.ListAsync(status: null, MaxScan, ct); - foreach (QueueItemDto row in queue) - { - if (row.MsgId == msgId && string.Equals(row.DialogId, dialog, StringComparison.Ordinal)) - { - 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; - } - } - - // Соответствует ли диалог фильтру канала (пустой фильтр — все диалоги). - // filter: Запрошенный канал (пусто — без фильтра). - // dialogId: Диалог сообщения. - // Возвращает: True — кандидат подходит. - private static bool MatchesDialog(string filter, string dialogId) => - filter.Length == 0 || string.Equals(filter, dialogId, StringComparison.Ordinal); - - // Кандидат из строки очереди (вердикт queued). - // row: Строка очереди. - // msgId: Id сообщения. - // Возвращает: Кандидат. - private static MlCandidateDto BuildQueued(QueueItemDto row, long msgId) => new() - { - Id = msgId, - DialogId = row.DialogId, - Text = Truncate(row.Text), - Time = row.MsgAtMs == 0 ? null : row.MsgAtMs, - Lead = false, - Verdict = VerdictQueued, - Stage = row.Status, - }; - - // Кандидат из записи отсева (вердикт rejected). - // row: Запись отсева. - // msgId: Id сообщения. - // Возвращает: Кандидат. - private static MlCandidateDto BuildRejected(RejectedItemDto row, long msgId) => new() - { - Id = msgId, - DialogId = row.DialogId, - Text = Truncate(row.Text), - Time = row.MsgAtMs == 0 ? null : row.MsgAtMs, - Lead = false, - Verdict = VerdictRejected, - Stage = row.Stage, - Reason = row.Reason, - }; - - // Кандидат из карточки (вердикт card). - // card: Карточка. - // msgId: Id сообщения. - // Возвращает: Кандидат. - private static MlCandidateDto BuildCard(CardDto card, long msgId) => new() - { - Id = msgId, - DialogId = card.SourceDialogId, - Text = Truncate(card.SourceMsg), - 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 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.Abstractions; +using Deal.Modules.Pipeline.Application.Models; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Ручная проверка/разметка ML на сообщениях канала (§8 ML: «проверка на сообщении/канале»). +/// +/// +/// +/// Candidates: собирает реальные сообщения-кандидаты по каналу (dialogId) либо по всей выборке, если +/// канал не задан, из трёх существующих источников тенанта — очереди обработки (), +/// отсева () и карточек (), +/// объединяя по (dialogId, msgId): карточка «перекрывает» отсев, отсев — очередь. Каждый кандидат несёт +/// исходный текст и текущий вердикт; мнение ML добавляется прогнозом . +/// +/// +/// Apply: ручное решение пользователя — «спам», «в колонку», «пропустить» — применяется через +/// существующие сервисы/ядро: обучение ML — , перенос/корзина карточки — +/// (он сам учит ML, дублирования сигналов нет), отсев сообщения из очереди — +/// . Действие 1:1 с прототипом ml_routes.py L137–171 +/// (skip/spam/board:<id>), плюс отсев ещё не обработанного сообщения и защита от неизвестной доски. +/// +/// +public sealed class MlReviewService( + IPipelineStore pipelineStore, + ICardStore cardStore, + CardsService cards, + PipelineProcessingService processing, + IMlClient mlClient) +{ + /// + /// Минимум сообщений в выборке кандидатов (кламп запроса 1..60, как прототип). + /// + public const int MinCandidates = 1; + + /// + /// Максимум сообщений в выборке кандидатов (кламп запроса 1..60, как прототип). + /// + public const int MaxCandidates = 60; + + // Размер одного чтения из очереди/отсева при объединении кандидатов. + private const int MaxScan = 500; + + // Длина текста кандидата в ответе (ml_routes.py L129: text[:600]). + private const int TextPreviewLength = 600; + + /// + /// Вердикт кандидата: по сообщению уже есть карточка. + /// + public const string VerdictCard = "card"; + + /// + /// Вердикт кандидата: сообщение в отсеве. + /// + public const string VerdictRejected = "rejected"; + + /// + /// Вердикт кандидата: сообщение ждёт обработки в очереди. + /// + public const string VerdictQueued = "queued"; + + /// + /// Действие: пропустить без обучения (ml_routes.py L144–145). + /// + public const string ActionSkip = "skip"; + + /// + /// Действие: спам — учим ML и (если есть) карточку в корзину (ml_routes.py L150–156). + /// + public const string ActionSpam = "spam"; + + /// + /// Префикс действия «в колонку»: board:<id> (ml_routes.py L157). + /// + public const string ActionBoardPrefix = "board:"; + + /// + /// 400 apply: неизвестная доска-цель (ml_routes.py L159–160). + /// + public const string UnknownBoardDetail = "Неизвестная доска"; + + /// + /// 400 apply: неизвестное действие (ml_routes.py L169–170). + /// + public const string UnknownActionDetail = "Неизвестное действие"; + + // Причина отсева при ручной разметке «спам» ещё не обработанного сообщения. + private const string ManualSpamReason = "ручная разметка ML: спам"; + + // Этап отсева при ручной разметке «спам» (отсев решением ML). + private const string ManualSpamStage = "spam_ml"; + + // Источник решения при ручной разметке. + private const string ManualSource = "ml"; + + // Вес обучающего сигнала ручной разметки — действие пользователя (ml_client.py USER_WEIGHT 1.0). + private const double UserPushWeight = 1.0; + + /// + /// Отбирает сообщения-кандидаты для проверки ML по каналу и/или размеру выборки. + /// + /// Id канала/диалога; пусто — выборка по всем источникам тенанта. + /// Сколько последних сообщений вернуть (кламп 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); + + // Объединение по (dialogId, msgId): очередь → отсев → карточка (последняя перекрывает предыдущие). + var merged = new Dictionary<(string Dialog, long MsgId), MlCandidateDto>(); + foreach (QueueItemDto row in queue) + { + if (row.MsgId is not { } msgId || !MatchesDialog(dialog, row.DialogId)) + { + continue; + } + + merged[(row.DialogId, msgId)] = BuildQueued(row, msgId); + } + + foreach (RejectedItemDto row in rejected) + { + if (row.MsgId is not { } msgId || !MatchesDialog(dialog, row.DialogId)) + { + continue; + } + + merged[(row.DialogId, msgId)] = BuildRejected(row, msgId); + } + + foreach (CardDto card in cardList) + { + if (card.SourceMsgId is not { } msgId || !MatchesDialog(dialog, card.SourceDialogId)) + { + continue; + } + + merged[(card.SourceDialogId, msgId)] = BuildCard(card, msgId); + } + + 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; + } + + /// + /// Применяет ручное решение по сообщению: обучение ML + перенос/корзина/отсев. + /// + /// Id канала/диалога сообщения. + /// 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(); + + CardDto? card = await cardStore.GetCardBySourceAsync(dialog, msgId, ct); + string? text = await FindTextAsync(dialog, msgId, 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, msgId, card, text, ct); + } + + if (normalized.StartsWith(ActionBoardPrefix, StringComparison.Ordinal)) + { + string boardId = normalized[ActionBoardPrefix.Length..].Trim(); + return await ApplyBoardAsync(dialog, msgId, boardId, card, text, ct); + } + + return new MlApplyResult(UnknownActionDetail, Ok: false, Learned: false, Moved: null, LeadId: null); + } + + // Действие «спам»: карточку — в корзину (с обучением), сообщение из очереди — в отсев; иначе учим ML. + // dialog: Id диалога. + // msgId: Id сообщения. + // card: Карточка сообщения (null — сообщение не становилось карточкой). + // text: Текст сообщения. + // ct: Токен отмены. + // Возвращает: Результат решения. + private async Task ApplySpamAsync( + string dialog, + long msgId, + 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, msgId, ct); + if (queued is not null) + { + await processing.RejectAsync(new RejectRecord + { + DialogId = queued.DialogId, + MsgId = queued.MsgId, + Text = queued.Text, + ChannelName = queued.Channel.Name, + ChannelHandle = queued.Channel.Handle, + ChannelHue = queued.Channel.Hue, + MsgAtMs = queued.MsgAtMs, + Source = 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. + // dialog: Id диалога. + // msgId: Id сообщения. + // boardId: Id колонки-цели (inbox или b_...). + // card: Карточка сообщения (null — сообщение не становилось карточкой). + // text: Текст сообщения. + // ct: Токен отмены. + // Возвращает: Результат решения. + private async Task ApplyBoardAsync( + string dialog, + long msgId, + 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) + { + // Повторная разметка карточки в той же колонке — только обучение (ml_routes.py L163–165). + 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); + } + + // Текст исходного сообщения: source_msg карточки, иначе текст строки очереди/записи отсева. + // dialog: Id диалога. + // msgId: Id сообщения. + // card: Карточка сообщения (уже прочитана вызывающим). + // ct: Токен отмены. + // Возвращает: Непустой текст либо null, если сообщения нет ни в одном источнике. + private async Task FindTextAsync( + string dialog, + long msgId, + CardDto? card, + CancellationToken ct) + { + if (card is not null && !string.IsNullOrWhiteSpace(card.SourceMsg)) + { + return card.SourceMsg; + } + + QueueItemDto? queued = await FindQueuedAsync(dialog, msgId, 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 (row.MsgId == msgId && string.Equals(row.DialogId, dialog, StringComparison.Ordinal)) + { + return row.Text; + } + } + + return null; + } + + // Строка очереди сообщения (для отсева при ручной разметке «спам»). + // dialog: Id диалога. + // msgId: Id сообщения. + // ct: Токен отмены. + // Возвращает: Строка очереди либо null — сообщение уже обработано/не в очереди. + private async Task FindQueuedAsync( + string dialog, + long msgId, + CancellationToken ct) + { + IReadOnlyList queue = await pipelineStore.ListAsync(status: null, MaxScan, ct); + foreach (QueueItemDto row in queue) + { + if (row.MsgId == msgId && string.Equals(row.DialogId, dialog, StringComparison.Ordinal)) + { + 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; + } + } + + // Соответствует ли диалог фильтру канала (пустой фильтр — все диалоги). + // filter: Запрошенный канал (пусто — без фильтра). + // dialogId: Диалог сообщения. + // Возвращает: True — кандидат подходит. + private static bool MatchesDialog(string filter, string dialogId) => + filter.Length == 0 || string.Equals(filter, dialogId, StringComparison.Ordinal); + + // Кандидат из строки очереди (вердикт queued). + // row: Строка очереди. + // msgId: Id сообщения. + // Возвращает: Кандидат. + private static MlCandidateDto BuildQueued(QueueItemDto row, long msgId) => new() + { + Id = msgId, + DialogId = row.DialogId, + Text = Truncate(row.Text), + Time = row.MsgAtMs == 0 ? null : row.MsgAtMs, + Lead = false, + Verdict = VerdictQueued, + Stage = row.Status, + }; + + // Кандидат из записи отсева (вердикт rejected). + // row: Запись отсева. + // msgId: Id сообщения. + // Возвращает: Кандидат. + private static MlCandidateDto BuildRejected(RejectedItemDto row, long msgId) => new() + { + Id = msgId, + DialogId = row.DialogId, + Text = Truncate(row.Text), + Time = row.MsgAtMs == 0 ? null : row.MsgAtMs, + Lead = false, + Verdict = VerdictRejected, + Stage = row.Stage, + Reason = row.Reason, + }; + + // Кандидат из карточки (вердикт card). + // card: Карточка. + // msgId: Id сообщения. + // Возвращает: Кандидат. + private static MlCandidateDto BuildCard(CardDto card, long msgId) => new() + { + Id = msgId, + DialogId = card.SourceDialogId, + Text = Truncate(card.SourceMsg), + 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 85b40b9..46f728e 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineCardWriter.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineCardWriter.cs @@ -1,54 +1,50 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Создание карточки пайплайна через публичный интерфейс Kanban (план Task 7 L400–402, Ruling 3/4; -/// python _store_lead L483–514: INSERT карточки → UPDATE dedup.lead_id → чтение после записи). -/// -/// -/// Тонкая обёртка создания: генерирует id карточки (c_, / — -/// переиспользуем генератор владельца), собирает полный снимок через и пишет его -/// , после чего связывает заявку дедупа с карточкой -/// ( — порядок AddCard → Link 1:1 с Ruling 4, L512–513) и перечитывает -/// полный (для SSE new_card, Ruling 8/9). -/// -/// Проверку «уже есть карточка по дедупу» обёртка НЕ дублирует — её делает воркер на «new»-проходе pump -/// (Ruling 8, L940–951): к моменту записи сообщение уже прошло дедуп-гвард и его хэш заявлен (LeadId=null). -/// Транзакционности карточка + dedup-link нет (как в прототипе — два отдельных statement'а адаптеров на общем -/// TenantDbContext, Ruling 3/4): сбой LinkAsync оставляет карточку без связи и пробрасывается вызывающему. -/// -/// -public sealed class PipelineCardWriter(ICardStore kanjStore, IPipelineStore pipelineStore, CardComposer composer) -{ - /// - /// Создаёт карточку из разобранного сообщения и связывает её с заявкой дедупа. - /// - /// Разбор сообщения (ИИ/локальный путь; колонка разбора пройдёт ContainerAccepts-страховку). - /// Строка очереди с сообщением-источником (метаданные канала/текст/время). - /// SHA1-hex хэша текста (заявка дедупа уже создана воркером, Ruling 8). - /// Токен отмены. - /// Полная карточка (чтение после записи — §4.1, как lead_to_dict после INSERT, L514). - /// Карточка не прочиталась сразу после создания. - 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 (план Task 7 L400–402, Ruling 3/4; +/// python _store_lead L483–514: INSERT карточки → UPDATE dedup.lead_id → чтение после записи). +/// +/// +/// Тонкая обёртка создания: генерирует id карточки (c_, / — +/// переиспользуем генератор владельца), собирает полный снимок через и пишет его +/// , после чего связывает заявку дедупа с карточкой +/// ( — порядок AddCard → Link 1:1 с Ruling 4, L512–513) и перечитывает +/// полный (для SSE new_card, Ruling 8/9). +/// +/// Проверку «уже есть карточка по дедупу» обёртка НЕ дублирует — её делает воркер на «new»-проходе pump +/// (Ruling 8, L940–951): к моменту записи сообщение уже прошло дедуп-гвард и его хэш заявлен (LeadId=null). +/// Транзакционности карточка + dedup-link нет (как в прототипе — два отдельных statement'а адаптеров на общем +/// TenantDbContext, Ruling 3/4): сбой LinkAsync оставляет карточку без связи и пробрасывается вызывающему. +/// +/// +public sealed class PipelineCardWriter(ICardStore kanjStore, IPipelineStore pipelineStore, CardComposer composer) +{ + /// + /// Создаёт карточку из разобранного сообщения и связывает её с заявкой дедупа. + /// + /// Разбор сообщения (ИИ/локальный путь; колонка разбора пройдёт ContainerAccepts-страховку). + /// Строка очереди с сообщением-источником (метаданные канала/текст/время). + /// SHA1-hex хэша текста (заявка дедупа уже создана воркером, Ruling 8). + /// Токен отмены. + /// Полная карточка (чтение после записи — §4.1, как lead_to_dict после INSERT, L514). + /// Карточка не прочиталась сразу после создания. + 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 4b463d3..c94ef4d 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineIngestService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineIngestService.cs @@ -1,70 +1,65 @@ -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Приём входящих сообщений пайплайна — постановка сырого сообщения в очередь QueueItems (Ruling 2, pipeline.py enqueue L53–85). -/// -/// -/// Единственная точка входа сообщений в пайплайн этапа 4: вызывает gRPC-ингресс telegram-service -/// (интерфейс не плодим — YAGNI). -/// 1:1 с прототипом enqueue: текст тримится, пустой текст или нет dialogId → no-op; текст режется до 6000 -/// кодовых точек (python text[:6000]); при msgId — дубль-гвард «диалог+сообщение уже в очереди» (защита от -/// двойного события Telethon: строка живёт, пока сообщение в очереди/обработке). Строка пишется со статусом -/// new, id p_ генерирует (Ruling 10), CreatedAt=UpdatedAt=now, msgAt=now -/// при отсутствии. Разбор очереди (правила/дедуп по тексту/ML/ИИ) — воркер (Ruling 8), не этот сервис; очистка -/// текста карточки — тоже позже (здесь только trim+лимит, как в прототипе). -/// -public sealed class PipelineIngestService(IPipelineStore store) -{ - // Лимит текста строки очереди (enqueue L84: text[:6000], Ruling 2). - private const int MaxQueueTextLength = 6000; - - /// - /// Ставит входящее сообщение в очередь: trim → no-op пустого текста/нет dialogId → дубль-гвард → INSERT. - /// - /// Сырое сообщение: диалог, канальные поля, текст, время; Force — возврат из отсева (Ruling 2/10). - /// Токен отмены. - /// Результат: Id строки (p_...) либо null, если сообщение не принято; Duplicate — дубль диалога. - public async Task EnqueueAsync(QueuedMessage message, CancellationToken ct) - { - // Пустой текст или нет диалога — no-op (enqueue L68–70): принимаем, но в очередь не пишем. - string text = (message.Text ?? string.Empty).Trim(); - if (text.Length == 0 || string.IsNullOrEmpty(message.DialogId)) - { - return new PipelineIngestResultDto(null, false); - } - - long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - // Дубль-гвард приёма: то же сообщение диалога уже в очереди/обработке — Telethon-повтор не пишем (L74–80). - if (message.MsgId is not null - && await store.ExistsDuplicateAsync(message.DialogId, message.MsgId, ct)) - { - return new PipelineIngestResultDto(null, true); - } - - var item = new QueueItemDto - { - Id = PrefixId.New(PipelineIdPrefixes.Queue), - DialogId = message.DialogId, - MsgId = message.MsgId, - Text = MessageTextCleaner.SliceCodePoints(text, MaxQueueTextLength), - Status = PipelineQueueStatuses.New, - Channel = new PipelineChannelDto(message.ChannelName, message.ChannelHandle, message.ChannelHue), - MsgAtMs = message.MsgAtMs ?? nowMs, - QueuedAtMs = nowMs, - Force = message.Force, - }; - await store.AddAsync(item, ct); - return new PipelineIngestResultDto(item.Id, false); - } -} +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 (Ruling 2, pipeline.py enqueue L53–85). +/// +/// +/// Единственная точка входа сообщений в пайплайн этапа 4: вызывает gRPC-ингресс telegram-service +/// (интерфейс не плодим — YAGNI). +/// 1:1 с прототипом enqueue: текст тримится, пустой текст или нет dialogId → no-op; текст режется до 6000 +/// кодовых точек (python text[:6000]); при msgId — дубль-гвард «диалог+сообщение уже в очереди» (защита от +/// двойного события Telethon: строка живёт, пока сообщение в очереди/обработке). Строка пишется со статусом +/// new, id p_ генерирует (Ruling 10), CreatedAt=UpdatedAt=now, msgAt=now +/// при отсутствии. Разбор очереди (правила/дедуп по тексту/ML/ИИ) — воркер (Ruling 8), не этот сервис; очистка +/// текста карточки — тоже позже (здесь только trim+лимит, как в прототипе). +/// +public sealed class PipelineIngestService(IPipelineStore store) +{ + // Лимит текста строки очереди (enqueue L84: text[:6000], Ruling 2). + private const int MaxQueueTextLength = 6000; + + /// + /// Ставит входящее сообщение в очередь: trim → no-op пустого текста/нет dialogId → дубль-гвард → INSERT. + /// + /// Сырое сообщение: диалог, канальные поля, текст, время; Force — возврат из отсева (Ruling 2/10). + /// Токен отмены. + /// Результат: Id строки (p_...) либо null, если сообщение не принято; Duplicate — дубль диалога. + public async Task EnqueueAsync(QueuedMessage message, CancellationToken ct) + { + // Пустой текст или нет диалога — no-op (enqueue L68–70): принимаем, но в очередь не пишем. + string text = (message.Text ?? string.Empty).Trim(); + if (text.Length == 0 || string.IsNullOrEmpty(message.DialogId)) + { + return new PipelineIngestResultDto(null, false); + } + + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + // Дубль-гвард приёма: то же сообщение диалога уже в очереди/обработке — Telethon-повтор не пишем (L74–80). + if (message.MsgId is not null + && await store.ExistsDuplicateAsync(message.DialogId, message.MsgId, ct)) + { + return new PipelineIngestResultDto(null, true); + } + + var item = new QueueItemDto + { + Id = PrefixId.New(PipelineIdPrefixes.Queue), + DialogId = message.DialogId, + MsgId = message.MsgId, + Text = MessageTextCleaner.SliceCodePoints(text, MaxQueueTextLength), + Status = PipelineQueueStatuses.New, + Channel = new PipelineChannelDto(message.ChannelName, message.ChannelHandle, message.ChannelHue), + MsgAtMs = message.MsgAtMs ?? nowMs, + 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 7f91892..b6a111d 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineProcessingService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineProcessingService.cs @@ -1,354 +1,349 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Мониторинг и обслуживание пайплайна — вкладка «Обработка»: очередь, отсев, возврат, очистки, счётчики (processing.py L66–320, Rulings 8/10). -/// -/// -/// Чистый сервис модуля (без EF/HTTP): запись отсева (1:1 processing.record L66–101 — детерминированный id -/// r_<dialog>_<msgId> либо случайный r_+hex в адаптере, лимиты text/reason/kw, -/// дефолт цвета канала #666; повторное отбрасывание того же сообщения — upsert, а не дубликат), -/// чтение очереди (list_queue L218–241, clamp лимита 1..500), счётчики (queue_counts L207–215 / -/// rejected_count L201–202), страницы отсева (list_rejected L246–312: no-q путь по RejectedAt DESC; q-путь — -/// FTS-кандидаты ∪ LIKE-дополнение, total = размер объединения, Ruling 6), возврат в обработку -/// (return_to_queue L128–193), ручные Delete/Clear и автоочистка (3 суток от -/// RejectedAt; вызывает тик/фоновый цикл, Ruling 8/9), сводка (форма /pipeline/stats). -/// -/// Возврат (Ruling 5/10): ошибки 400 — строки Ruling 10 (уже возвращено / повтор-dup / нет текста); отсев по -/// решению «спам» (spam_ml/spam_ai/filter_ai) снимает у ML вес спама PushAsync(text, "spam", −1.0); -/// запись помечается returned+returnedAt+returnReason и НЕ удаляется (аудит); сообщение уходит в очередь с -/// force=true (правила/устарело/ML/ИИ-отсев для него игнорируются воркером). Записи нет — возвращает null -/// (эндпоинт отвечает 404 «Запись не найдена», текст 404 — слой эндпоинтов). -/// -/// -public sealed class PipelineProcessingService( - IPipelineStore store, - IMlClient mlClient, - PipelineIngestService ingest) -{ - // ── Фиксированные строки прототипа (400-детали return; Ruling 10) ─────── - - /// - /// 400 return: запись уже возвращалась в обработку (return_to_queue L139–140). - /// - public const string AlreadyReturnedDetail = "Сообщение уже возвращено в обработку"; - - /// - /// 400 return: повтор по дедупу — карточка с текстом уже в системе (return_to_queue L141–142, Ruling 10). - /// - public const string DuplicateReturnDetail = "Повтор: карточка с таким текстом уже есть в системе — возвращать нечего"; - - /// - /// 400 return: в записи нет текста сообщения (return_to_queue L146–147, Ruling 10). - /// - public const string EmptyReturnTextDetail = "В записи нет текста сообщения"; - - // ── Лимиты страниц (processing.DEFAULT_LIMIT/MAX_LIMIT L48–49) ─────────── - - /// - /// Размер страницы по умолчанию для списков очереди/отсева. - /// - public const int DefaultPageSize = 100; - - /// - /// Максимальный размер страницы списков (clamp 1..500). - /// - public const int MaxPageSize = 500; - - // ── Обучение ML при возврате (Ruling 5/10, return_to_queue L149–153) ───── - - // Вес снятия метки «спам»: реальное действие пользователя «это не спам» (delta=−1.0). - private const double SpamUnlearnDelta = -1.0; - - // Источник решения «повтор» (source=dup): возврат заблокирован (Ruling 10). - private const string DuplicateSource = "dup"; - - // Этапы отсева по решению «спам»: при возврате снимаем у ML вес спама (L149–153). - private static readonly HashSet SpamStages = new(StringComparer.Ordinal) - { - "spam_ml", - "spam_ai", - "filter_ai", - }; - - // ── Лимиты текста отсева и дефолты (Ruling 1/10; processing.record L86–96) ─── - - // Лимит текста записи отсева (record L90: text[:6000]). - private const int MaxRejectedTextLength = 6000; - - // Лимит причины отсева (record L95: reason[:500]). - private const int MaxReasonLength = 500; - - // Лимит фразы-ключа kw (record L96: kw[:200]). - private const int MaxKwLength = 200; - - // Лимит причины возврата (return_to_queue L158: return_reason[:500]). - private const int MaxReturnReasonLength = 500; - - // ── Отсев: запись (processing.record L66–101) ──────────────────────────── - - /// - /// Сохраняет отброшенное сообщение в отсев (вызывают воркер/этапы pump, Ruling 8). - /// - /// - /// Пустой/пробельный текст — no-op (L73–74). Текст/причина/фраза режутся по лимитам (6000/500/200), - /// цвет канала — дефолт #666; id детерминированный по dialog+msgId (иначе случайный r_+hex — адаптер); - /// повторное отбрасывание того же сообщения обновляет запись (upsert), а не копит дубликаты. - /// - /// Команда записи отсева (поля без ограничений длин — сервис нормализует). - /// Токен отмены. - /// Задача завершается после записи (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), - ChannelHue = string.IsNullOrEmpty(record.ChannelHue) ? SourceDefaults.DefaultHue : record.ChannelHue, - Reason = MessageTextCleaner.SliceCodePoints(record.Reason, MaxReasonLength), - Kw = MessageTextCleaner.SliceCodePoints(record.Kw, MaxKwLength), - }, ct); - } - - // ── Очередь (list_queue L218–241 / queue_counts L207–215) ──────────────── - - /// - /// Сырые сообщения очереди в порядке постановки (CreatedAt ASC), все статусы (list_queue L218–241). - /// - /// Максимум строк; clamp 1..500 (L219) — запрашивается больше — вернётся не больше 500. - /// Токен отмены. - /// Строки очереди (item'ы §4.5; внутренний Force в JSON не выходит). - public async Task> ListQueueAsync(int limit, CancellationToken ct) - { - int clamped = Math.Clamp(limit, 1, MaxPageSize); - return await store.ListAsync(null, clamped, ct); - } - - /// - /// Счётчики очереди по статусам: new / filtered (bucket «ai») / total (queue_counts L207–215). - /// - /// Токен отмены. - /// 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 }; - } - - /// - /// Число записей в отсеве (rejected_count L201–202; счётчик вкладки «Обработка»). - /// - /// Токен отмены. - /// Всего записей RejectedItems. - public Task RejectedCountAsync(CancellationToken ct) - { - return store.CountAsync(ct); - } - - // ── Отсев: чтение/страницы (list_rejected L246–312) ────────────────────── - - /// - /// Страница отсева: без q — свежие первыми (RejectedAt DESC); с q — FTS ∪ LIKE-кандидаты (Ruling 6). - /// - /// - /// q нормализуется trim+lowercase (L249). Без поиска total = rejected_count, страница из БД (offset/limit). - /// С поиском total = размер объединения кандидатов (FTS-ранжированные первыми, затем LIKE-дополнение по - /// lower(text/reason/kw/ch_name)), страница срезается из кандидатов в памяти (как python ids[offset:…]); - /// кандидаты возвращаются портом полными строками одним вызовом — без N+1 чтений по id. - /// offset/limit clamp: offset ≥ 0, limit 1..500; значения эхом в ответе. - /// - /// Поисковый запрос (текст/причина/фраза/имя канала); пустой — весь отсев. - /// Сдвиг от начала (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); - } - - // Поиск: кандидаты — полные записи FTS (limit) + LIKE-дополнение (limit*2), без дублей (порт, Ruling 6; - // L252–270). Порт возвращает строки сразу — без N+1 «id → GetAsync» по каждому кандидату страницы. - 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); - } - - // ── Отсев: возврат в обработку (return_to_queue L128–193, Ruling 5/10) ─── - - /// - /// Возвращает отсеянное сообщение в обработку (кнопка в «Обработке»): force-строка в очередь + аудит. - /// - /// - /// Порядок проверок 1:1 с прототипом: записи нет → null (404); уже returned → 400; source=dup → 400 (повтор - /// — карточка с текстом уже в системе); нет текста → 400. Этап spam_ml/spam_ai/filter_ai — снимаем у ML вес - /// спама PushAsync(text, "spam", −1.0). Запись помечается returned/returnedAt/returnReason (НЕ удаляется). - /// Сообщение уходит в очередь с force=true: с dialog+msgId — через полный путь приёма (дубль-гвард - /// сохраняется, L163–173); старые записи без dialog/msgId — прямым INSERT (L174–192). msg_at записи - /// сохраняется (0/нет — now, как row.get("msg_at") or now). - /// - /// 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.Source == 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)) - { - // Реальное действие пользователя: этот текст НЕ спам — снимаем у ML вес спама (L149–153). - 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); - - // msg_at исходного сообщения или now (python row.get("msg_at") or now, L171/L188). - long msgAtMs = row.MsgAtMs != 0 ? row.MsgAtMs : nowMs; - string hue = string.IsNullOrEmpty(row.Channel.Hue) ? SourceDefaults.DefaultHue : row.Channel.Hue; - if (row.DialogId.Length > 0 && row.MsgId is not null) - { - // Полный путь приёма: дубль-гвард по dialog+msgId сохраняется (enqueue L163–173), force=true. - await ingest.EnqueueAsync(new QueuedMessage - { - DialogId = row.DialogId, - ChannelName = row.Channel.Name, - ChannelHandle = row.Channel.Handle, - ChannelHue = hue, - MsgId = row.MsgId, - Text = text, - MsgAtMs = msgAtMs, - Force = true, - }, ct); - } - else - { - // Старые записи (до сохранения dialog_id/msg_id): текст сохранился — возвращаем без ссылки - // на исходное сообщение прямым INSERT (return_to_queue L174–192, force=true). - var item = new QueueItemDto - { - Id = PrefixId.New(PipelineIdPrefixes.Queue), - DialogId = row.DialogId, - MsgId = row.MsgId, - Text = MessageTextCleaner.SliceCodePoints(text, MaxRejectedTextLength), - Status = PipelineQueueStatuses.New, - Channel = new PipelineChannelDto(row.Channel.Name, row.Channel.Handle, hue), - MsgAtMs = msgAtMs, - QueuedAtMs = nowMs, - Force = true, - }; - await store.AddAsync(item, ct); - } - - return new RejectReturnResultDto(null, rejectedId, true, nowMs); - } - - // ── Отсев: удаление/очистки (L104–125, L196–198) ───────────────────────── - - /// - /// Удаляет одну запись отсева безвозвратно (DELETE /rejected/{id}, delete_one L196–198). - /// - /// Id записи (r_...). - /// Токен отмены. - /// Задача завершается после удаления (прототип всегда отвечает ok, 404 не шлёт — Ruling 10). - public Task DeleteAsync(string rejectedId, CancellationToken ct) - { - return store.DeleteAsync(rejectedId, ct); - } - - /// - /// Полная ручная очистка отсева (POST /rejected/clear, clear_all L120–125). - /// - /// Токен отмены. - /// Сколько записей удалено (0 — отсев пуст). - public Task ClearAsync(CancellationToken ct) - { - return store.ClearAsync(ct); - } - - /// - /// Автоочистка отсева: записи старше 3 суток от RejectedAt удаляются безвозвратно (purge_expired L104–117, Ruling 8). - /// - /// - /// Срок — (3 суток). Вызывается из /admin/tick и - /// фонового StorageTickScheduler (Ruling 9/11) — модуль сам циклы не заводит. - /// - /// Токен отмены. - /// Сколько записей удалено. - public Task PurgeExpiredAsync(CancellationToken ct) - { - DateTimeOffset olderThan = DateTimeOffset.UtcNow.AddDays(-PipelineRejectConstants.RetentionDays); - return store.PurgeExpiredAsync(olderThan, ct); - } - - // ── Сводка (stats L315–320) ────────────────────────────────────────────── - - /// - /// Сводка вкладки «Обработка» — форма GET /api/pipeline/stats: {queue: {new, ai, total}, rejected}. - /// - /// Токен отмены. - /// Счётчики очереди и число записей отсева (поллинг вкладки, SSE pipeline_stats не публикуем — Ruling 9). - 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.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; + +/// +/// Мониторинг и обслуживание пайплайна — вкладка «Обработка»: очередь, отсев, возврат, очистки, счётчики (processing.py L66–320, Rulings 8/10). +/// +/// +/// Чистый сервис модуля (без EF/HTTP): запись отсева (1:1 processing.record L66–101 — детерминированный id +/// r_<dialog>_<msgId> либо случайный r_+hex в адаптере, лимиты text/reason/kw, +/// дефолт цвета канала #666; повторное отбрасывание того же сообщения — upsert, а не дубликат), +/// чтение очереди (list_queue L218–241, clamp лимита 1..500), счётчики (queue_counts L207–215 / +/// rejected_count L201–202), страницы отсева (list_rejected L246–312: no-q путь по RejectedAt DESC; q-путь — +/// FTS-кандидаты ∪ LIKE-дополнение, total = размер объединения, Ruling 6), возврат в обработку +/// (return_to_queue L128–193), ручные Delete/Clear и автоочистка (3 суток от +/// RejectedAt; вызывает тик/фоновый цикл, Ruling 8/9), сводка (форма /pipeline/stats). +/// +/// Возврат (Ruling 5/10): ошибки 400 — строки Ruling 10 (уже возвращено / повтор-dup / нет текста); отсев по +/// решению «спам» (spam_ml/spam_ai/filter_ai) снимает у ML вес спама PushAsync(text, "spam", −1.0); +/// запись помечается returned+returnedAt+returnReason и НЕ удаляется (аудит); сообщение уходит в очередь с +/// force=true (правила/устарело/ML/ИИ-отсев для него игнорируются воркером). Записи нет — возвращает null +/// (эндпоинт отвечает 404 «Запись не найдена», текст 404 — слой эндпоинтов). +/// +/// +public sealed class PipelineProcessingService( + IPipelineStore store, + IMlClient mlClient, + PipelineIngestService ingest) +{ + // ── Фиксированные строки прототипа (400-детали return; Ruling 10) ─────── + + /// + /// 400 return: запись уже возвращалась в обработку (return_to_queue L139–140). + /// + public const string AlreadyReturnedDetail = "Сообщение уже возвращено в обработку"; + + /// + /// 400 return: повтор по дедупу — карточка с текстом уже в системе (return_to_queue L141–142, Ruling 10). + /// + public const string DuplicateReturnDetail = "Повтор: карточка с таким текстом уже есть в системе — возвращать нечего"; + + /// + /// 400 return: в записи нет текста сообщения (return_to_queue L146–147, Ruling 10). + /// + public const string EmptyReturnTextDetail = "В записи нет текста сообщения"; + + // ── Лимиты страниц (processing.DEFAULT_LIMIT/MAX_LIMIT L48–49) ─────────── + + /// + /// Размер страницы по умолчанию для списков очереди/отсева. + /// + public const int DefaultPageSize = 100; + + /// + /// Максимальный размер страницы списков (clamp 1..500). + /// + public const int MaxPageSize = 500; + + // ── Обучение ML при возврате (Ruling 5/10, return_to_queue L149–153) ───── + + // Вес снятия метки «спам»: реальное действие пользователя «это не спам» (delta=−1.0). + private const double SpamUnlearnDelta = -1.0; + + // Источник решения «повтор» (source=dup): возврат заблокирован (Ruling 10). + private const string DuplicateSource = "dup"; + + // Этапы отсева по решению «спам»: при возврате снимаем у ML вес спама (L149–153). + private static readonly HashSet SpamStages = new(StringComparer.Ordinal) + { + "spam_ml", + "spam_ai", + "filter_ai", + }; + + // ── Лимиты текста отсева и дефолты (Ruling 1/10; processing.record L86–96) ─── + + // Лимит текста записи отсева (record L90: text[:6000]). + private const int MaxRejectedTextLength = 6000; + + // Лимит причины отсева (record L95: reason[:500]). + private const int MaxReasonLength = 500; + + // Лимит фразы-ключа kw (record L96: kw[:200]). + private const int MaxKwLength = 200; + + // Лимит причины возврата (return_to_queue L158: return_reason[:500]). + private const int MaxReturnReasonLength = 500; + + // ── Отсев: запись (processing.record L66–101) ──────────────────────────── + + /// + /// Сохраняет отброшенное сообщение в отсев (вызывают воркер/этапы pump, Ruling 8). + /// + /// + /// Пустой/пробельный текст — no-op (L73–74). Текст/причина/фраза режутся по лимитам (6000/500/200), + /// цвет канала — дефолт #666; id детерминированный по dialog+msgId (иначе случайный r_+hex — адаптер); + /// повторное отбрасывание того же сообщения обновляет запись (upsert), а не копит дубликаты. + /// + /// Команда записи отсева (поля без ограничений длин — сервис нормализует). + /// Токен отмены. + /// Задача завершается после записи (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), + ChannelHue = string.IsNullOrEmpty(record.ChannelHue) ? SourceDefaults.DefaultHue : record.ChannelHue, + Reason = MessageTextCleaner.SliceCodePoints(record.Reason, MaxReasonLength), + Kw = MessageTextCleaner.SliceCodePoints(record.Kw, MaxKwLength), + }, ct); + } + + // ── Очередь (list_queue L218–241 / queue_counts L207–215) ──────────────── + + /// + /// Сырые сообщения очереди в порядке постановки (CreatedAt ASC), все статусы (list_queue L218–241). + /// + /// Максимум строк; clamp 1..500 (L219) — запрашивается больше — вернётся не больше 500. + /// Токен отмены. + /// Строки очереди (item'ы §4.5; внутренний Force в JSON не выходит). + public async Task> ListQueueAsync(int limit, CancellationToken ct) + { + int clamped = Math.Clamp(limit, 1, MaxPageSize); + return await store.ListAsync(null, clamped, ct); + } + + /// + /// Счётчики очереди по статусам: new / filtered (bucket «ai») / total (queue_counts L207–215). + /// + /// Токен отмены. + /// 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 }; + } + + /// + /// Число записей в отсеве (rejected_count L201–202; счётчик вкладки «Обработка»). + /// + /// Токен отмены. + /// Всего записей RejectedItems. + public Task RejectedCountAsync(CancellationToken ct) + { + return store.CountAsync(ct); + } + + // ── Отсев: чтение/страницы (list_rejected L246–312) ────────────────────── + + /// + /// Страница отсева: без q — свежие первыми (RejectedAt DESC); с q — FTS ∪ LIKE-кандидаты (Ruling 6). + /// + /// + /// q нормализуется trim+lowercase (L249). Без поиска total = rejected_count, страница из БД (offset/limit). + /// С поиском total = размер объединения кандидатов (FTS-ранжированные первыми, затем LIKE-дополнение по + /// lower(text/reason/kw/ch_name)), страница срезается из кандидатов в памяти (как python ids[offset:…]); + /// кандидаты возвращаются портом полными строками одним вызовом — без N+1 чтений по id. + /// offset/limit clamp: offset ≥ 0, limit 1..500; значения эхом в ответе. + /// + /// Поисковый запрос (текст/причина/фраза/имя канала); пустой — весь отсев. + /// Сдвиг от начала (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); + } + + // Поиск: кандидаты — полные записи FTS (limit) + LIKE-дополнение (limit*2), без дублей (порт, Ruling 6; + // L252–270). Порт возвращает строки сразу — без N+1 «id → GetAsync» по каждому кандидату страницы. + 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); + } + + // ── Отсев: возврат в обработку (return_to_queue L128–193, Ruling 5/10) ─── + + /// + /// Возвращает отсеянное сообщение в обработку (кнопка в «Обработке»): force-строка в очередь + аудит. + /// + /// + /// Порядок проверок 1:1 с прототипом: записи нет → null (404); уже returned → 400; source=dup → 400 (повтор + /// — карточка с текстом уже в системе); нет текста → 400. Этап spam_ml/spam_ai/filter_ai — снимаем у ML вес + /// спама PushAsync(text, "spam", −1.0). Запись помечается returned/returnedAt/returnReason (НЕ удаляется). + /// Сообщение уходит в очередь с force=true: с dialog+msgId — через полный путь приёма (дубль-гвард + /// сохраняется, L163–173); старые записи без dialog/msgId — прямым INSERT (L174–192). msg_at записи + /// сохраняется (0/нет — now, как row.get("msg_at") or now). + /// + /// 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.Source == 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)) + { + // Реальное действие пользователя: этот текст НЕ спам — снимаем у ML вес спама (L149–153). + 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); + + // msg_at исходного сообщения или now (python row.get("msg_at") or now, L171/L188). + long msgAtMs = row.MsgAtMs != 0 ? row.MsgAtMs : nowMs; + string hue = string.IsNullOrEmpty(row.Channel.Hue) ? SourceDefaults.DefaultHue : row.Channel.Hue; + if (row.DialogId.Length > 0 && row.MsgId is not null) + { + // Полный путь приёма: дубль-гвард по dialog+msgId сохраняется (enqueue L163–173), force=true. + await ingest.EnqueueAsync(new QueuedMessage + { + DialogId = row.DialogId, + ChannelName = row.Channel.Name, + ChannelHandle = row.Channel.Handle, + ChannelHue = hue, + MsgId = row.MsgId, + Text = text, + MsgAtMs = msgAtMs, + Force = true, + }, ct); + } + else + { + // Старые записи (до сохранения dialog_id/msg_id): текст сохранился — возвращаем без ссылки + // на исходное сообщение прямым INSERT (return_to_queue L174–192, force=true). + var item = new QueueItemDto + { + Id = PrefixId.New(PipelineIdPrefixes.Queue), + DialogId = row.DialogId, + MsgId = row.MsgId, + Text = MessageTextCleaner.SliceCodePoints(text, MaxRejectedTextLength), + Status = PipelineQueueStatuses.New, + Channel = new PipelineChannelDto(row.Channel.Name, row.Channel.Handle, hue), + MsgAtMs = msgAtMs, + QueuedAtMs = nowMs, + Force = true, + }; + await store.AddAsync(item, ct); + } + + return new RejectReturnResultDto(null, rejectedId, true, nowMs); + } + + // ── Отсев: удаление/очистки (L104–125, L196–198) ───────────────────────── + + /// + /// Удаляет одну запись отсева безвозвратно (DELETE /rejected/{id}, delete_one L196–198). + /// + /// Id записи (r_...). + /// Токен отмены. + /// Задача завершается после удаления (прототип всегда отвечает ok, 404 не шлёт — Ruling 10). + public Task DeleteAsync(string rejectedId, CancellationToken ct) + { + return store.DeleteAsync(rejectedId, ct); + } + + /// + /// Полная ручная очистка отсева (POST /rejected/clear, clear_all L120–125). + /// + /// Токен отмены. + /// Сколько записей удалено (0 — отсев пуст). + public Task ClearAsync(CancellationToken ct) + { + return store.ClearAsync(ct); + } + + /// + /// Автоочистка отсева: записи старше 3 суток от RejectedAt удаляются безвозвратно (purge_expired L104–117, Ruling 8). + /// + /// + /// Срок — (3 суток). Вызывается из /admin/tick и + /// фонового StorageTickScheduler (Ruling 9/11) — модуль сам циклы не заводит. + /// + /// Токен отмены. + /// Сколько записей удалено. + public Task PurgeExpiredAsync(CancellationToken ct) + { + DateTimeOffset olderThan = DateTimeOffset.UtcNow.AddDays(-PipelineRejectConstants.RetentionDays); + return store.PurgeExpiredAsync(olderThan, ct); + } + + // ── Сводка (stats L315–320) ────────────────────────────────────────────── + + /// + /// Сводка вкладки «Обработка» — форма GET /api/pipeline/stats: {queue: {new, ai, total}, rejected}. + /// + /// Токен отмены. + /// Счётчики очереди и число записей отсева (поллинг вкладки, SSE pipeline_stats не публикуем — Ruling 9). + 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 5f89b3b..ca0b906 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Checks.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Checks.cs @@ -1,165 +1,151 @@ -using System.Globalization; -using System.Text.Json; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Kanban.Application.ColumnRules; -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.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Проверки воркера — partial-часть (C32: выделено из общего файла, -/// поведение не менялось): срок актуальности (IsStale), фильтр «без суммы» (_skip_no_budget), слияние -/// ML-терминов в стек и сбой-безопасные вызовы ML/ИИ-фильтра. -/// -public sealed partial class PipelineWorkerService -{ - // ── Проверки воркера ─────────────────────────────────────────────────── - - // Срок актуальности сообщения = срок до автоархива (python _stale_max_age L816–826): автоархив - // включён и msg_at старше archiveAfterDays суток → устарело. msg_at=0/пусто — не устарело. Флаги — из - // снимка прохода (читаются один раз на pump, не на каждое сообщение). - // row: Строка очереди (время исходного сообщения). - // run: Снимок настроек воркера (autoArchive/archiveAfterDays). - // Возвращает: True — сообщение старше срока до автоархива (не заводим в систему). - 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; - } - - // Глобальный фильтр «без суммы» (python _skip_no_budget L196–218): для типа заявки включён - // соответствующий флаг (budgetRequiredHire/Order) и суммы нет ни в разборе, ни в тексте → карточку не создаём. - // Суммой считаем нормализованный бюджет разбора либо сумму с валютой в тексте (AmountParser.Parse, - // rules_svc.extract_amounts). Claim дедупа при отсеве снимается — после выключения фильтра сообщение можно - // обработать заново (как python L1015). - // parsed: Разбор сообщения (тип заявки и бюджет). - // text: Исходный текст сообщения (fallback-источник суммы). - // run: Снимок настроек воркера (флаги budgetRequiredHire/Order). - // ct: Токен отмены. - // Возвращает: True — фильтр требует сумму, а её нет (сообщение пропускаем). - 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); - } - - // Докладывает узнанные ML термины в стек карточки (python L997–1011): непустые, ≥2 символов, без - // ведущей «~», не стоп-слова стека, не дубликаты — до MaxMlTermsAdded терминов. - // terms: Термины класса из ответа ML (dec.terms). - // stack: Стек локального разбора (к нему добавляем; лимит 12 доберёт CardComposer). - // Возвращает: Стек с добавленными терминами ML. - 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; - } - - // Предсказание ML с защитой от сбоя: недоступность сервиса → «не уверен» (решит ИИ; ml_client.predict L101–107). - // text: Текст сообщения. - // ct: Токен отмены. - // Возвращает: Решение модели либо фиксированный «не готов/не уверен». - private async Task PredictSafelyAsync(string text, CancellationToken ct) - { - try - { - return await _mlClient.PredictAsync(text, ct); - } - catch (Exception) - { - return NotReadyPrediction; - } - } - - // ИИ-фильтр с защитой от сбоя: сбой фильтра — «пропустить» (python L1102–1106). - // text: Текст сообщения. - // ct: Токен отмены. - // Возвращает: Решение фильтра либо пропуск при сбое. - 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-часть (C32: выделено из общего файла, +/// поведение не менялось): срок актуальности (IsStale), фильтр «без суммы» (_skip_no_budget), слияние +/// ML-терминов в стек и сбой-безопасные вызовы ML/ИИ-фильтра. +/// +public sealed partial class PipelineWorkerService +{ + // ── Проверки воркера ─────────────────────────────────────────────────── + + // Срок актуальности сообщения = срок до автоархива (python _stale_max_age L816–826): автоархив + // включён и msg_at старше archiveAfterDays суток → устарело. msg_at=0/пусто — не устарело. Флаги — из + // снимка прохода (читаются один раз на pump, не на каждое сообщение). + // row: Строка очереди (время исходного сообщения). + // run: Снимок настроек воркера (autoArchive/archiveAfterDays). + // Возвращает: True — сообщение старше срока до автоархива (не заводим в систему). + 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; + } + + // Глобальный фильтр «без суммы» (python _skip_no_budget L196–218): для типа заявки включён + // соответствующий флаг (budgetRequiredHire/Order) и суммы нет ни в разборе, ни в тексте → карточку не создаём. + // Суммой считаем нормализованный бюджет разбора либо сумму с валютой в тексте (AmountParser.Parse, + // rules_svc.extract_amounts). Claim дедупа при отсеве снимается — после выключения фильтра сообщение можно + // обработать заново (как python L1015). + // parsed: Разбор сообщения (тип заявки и бюджет). + // text: Исходный текст сообщения (fallback-источник суммы). + // run: Снимок настроек воркера (флаги budgetRequiredHire/Order). + // ct: Токен отмены. + // Возвращает: True — фильтр требует сумму, а её нет (сообщение пропускаем). + 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); + } + + // Докладывает узнанные ML термины в стек карточки (python L997–1011): непустые, ≥2 символов, без + // ведущей «~», не стоп-слова стека, не дубликаты — до MaxMlTermsAdded терминов. + // terms: Термины класса из ответа ML (dec.terms). + // stack: Стек локального разбора (к нему добавляем; лимит 12 доберёт CardComposer). + // Возвращает: Стек с добавленными терминами ML. + 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; + } + + // Предсказание ML с защитой от сбоя: недоступность сервиса → «не уверен» (решит ИИ; ml_client.predict L101–107). + // text: Текст сообщения. + // ct: Токен отмены. + // Возвращает: Решение модели либо фиксированный «не готов/не уверен». + private async Task PredictSafelyAsync(string text, CancellationToken ct) + { + try + { + return await _mlClient.PredictAsync(text, ct); + } + catch (Exception) + { + return NotReadyPrediction; + } + } + + // ИИ-фильтр с защитой от сбоя: сбой фильтра — «пропустить» (python L1102–1106). + // text: Текст сообщения. + // ct: Токен отмены. + // Возвращает: Решение фильтра либо пропуск при сбое. + 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 331eaac..f5573ad 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Decisions.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Decisions.cs @@ -1,81 +1,65 @@ -using System.Globalization; -using System.Text.Json; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Kanban.Application.ColumnRules; -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.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// KV-счётчики решений — partial-часть (C32: выделено из общего -/// файла, поведение не менялось): mlDecisions/aiDecisions после pump (track_decisions L153–157, Ruling 5). -/// -public sealed partial class PipelineWorkerService -{ - // ── Счётчики решений KV (Ruling 5, ml_client.track_decisions L153–157) ── - - // Инкрементирует KV-счётчики решений после pump: mlDecisions = mlStored+mlDrop, aiDecisions = aiStored+aiDrop. - // Read-modify-write через ISettingsStore (те же ключи читает LocalMlClient.StatusAsync — - // stats.ml/stats.ai вкладки «ML»). Нулевые приращения не пишутся (как python L154–157). - // state: Накопители результата pump. - // ct: Токен отмены. - 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-часть (C32: выделено из общего +/// файла, поведение не менялось): mlDecisions/aiDecisions после pump (track_decisions L153–157, Ruling 5). +/// +public sealed partial class PipelineWorkerService +{ + // ── Счётчики решений KV (Ruling 5, ml_client.track_decisions L153–157) ── + + // Инкрементирует KV-счётчики решений после pump: mlDecisions = mlStored+mlDrop, aiDecisions = aiStored+aiDrop. + // Read-modify-write через ISettingsStore (те же ключи читает LocalMlClient.StatusAsync — + // stats.ml/stats.ai вкладки «ML»). Нулевые приращения не пишутся (как python L154–157). + // state: Накопители результата pump. + // ct: Токен отмены. + 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.Learning.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Learning.cs index 68b7f4d..75b2054 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Learning.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Learning.cs @@ -1,77 +1,68 @@ -using System.Globalization; -using System.Text.Json; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Карточка и обучение ML — partial-часть (C32: выделено из общего -/// файла, поведение не менялось): создание карточки через PipelineCardWriter и обучающие push-сигналы по -/// карточке ИИ-пути (Ruling 5, L1155–1180). -/// -public sealed partial class PipelineWorkerService -{ - // ── Карточка и обучение ML ──────────────────────────────────────────── - - // Создаёт карточку (CardComposer + PipelineCardWriter: AddCard → LinkDedup → чтение) и снимает строку очереди. - // Dedup-claim НЕ удаляется: он уже связан с карточкой (IPipelineStore.LinkAsync в писателе; - // python _drop_row(row, with_dedup=False) L1021/L1057/L1092/L1152). Счётчик — mlStored (ML-путь, Ruling 5) - // либо aiStored (ИИ/локальный путь) — имена прототипа. - // state: Накопители результата pump. - // parsed: Разбор сообщения (поля карточки + назначенная колонка). - // row: Строка очереди (метаданные канала/текст/время). - // digest: SHA1-hex текста (claim уже заявлен и будет связан с карточкой). - // isAiPath: true — ИИ/локальный путь (aiStored); false — ML-ветка (mlStored). - // ct: Токен отмены. - // Возвращает: Созданная карточка (полный CardDto, для CreatedCards/обучения). - 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; - } - - // Обучающие сигналы ML по карточке ИИ-пути (L1155–1180): колонка-доска (не inbox/служебная, - // без активных правил и не-suggested) и тип (t:hire/t:order при is_vacancy_known). - // «Не знаю» (inbox) и служебные колонки не учим; доска с активными правилами/ИИ-предложение — тоже - // (ML в своём пути назначает только такие же свободные колонки — собираем аналогичные примеры). - // card: Созданная карточка (реальная колонка после ContainerAccepts-страховки). - // parsed: Разбор, на котором собрана карточка (тип/спам из классификатора). - // text: Текст сообщения (обучающий пример — как source_msg карточки). - // ct: Токен отмены. - 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-часть (C32: выделено из общего +/// файла, поведение не менялось): создание карточки через PipelineCardWriter и обучающие push-сигналы по +/// карточке ИИ-пути (Ruling 5, L1155–1180). +/// +public sealed partial class PipelineWorkerService +{ + // ── Карточка и обучение ML ──────────────────────────────────────────── + + // Создаёт карточку (CardComposer + PipelineCardWriter: AddCard → LinkDedup → чтение) и снимает строку очереди. + // Dedup-claim НЕ удаляется: он уже связан с карточкой (IPipelineStore.LinkAsync в писателе; + // python _drop_row(row, with_dedup=False) L1021/L1057/L1092/L1152). Счётчик — mlStored (ML-путь, Ruling 5) + // либо aiStored (ИИ/локальный путь) — имена прототипа. + // state: Накопители результата pump. + // parsed: Разбор сообщения (поля карточки + назначенная колонка). + // row: Строка очереди (метаданные канала/текст/время). + // digest: SHA1-hex текста (claim уже заявлен и будет связан с карточкой). + // isAiPath: true — ИИ/локальный путь (aiStored); false — ML-ветка (mlStored). + // ct: Токен отмены. + // Возвращает: Созданная карточка (полный CardDto, для CreatedCards/обучения). + 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; + } + + // Обучающие сигналы ML по карточке ИИ-пути (L1155–1180): колонка-доска (не inbox/служебная, + // без активных правил и не-suggested) и тип (t:hire/t:order при is_vacancy_known). + // «Не знаю» (inbox) и служебные колонки не учим; доска с активными правилами/ИИ-предложение — тоже + // (ML в своём пути назначает только такие же свободные колонки — собираем аналогичные примеры). + // card: Созданная карточка (реальная колонка после ContainerAccepts-страховки). + // parsed: Разбор, на котором собрана карточка (тип/спам из классификатора). + // text: Текст сообщения (обучающий пример — как source_msg карточки). + // ct: Токен отмены. + 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 00cfb45..0e12ea6 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Pump.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Pump.cs @@ -1,349 +1,338 @@ -using System.Globalization; -using System.Text.Json; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Kanban.Application.ColumnRules; -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.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Проход pump — partial-часть (C32: выделено из общего файла, -/// поведение не менялось): PumpOnceAsync и «new»/«filtered»-проходы батчей (pump_once L890–1183, Ruling 8). -/// -public sealed partial class PipelineWorkerService -{ - // ── Проход воркера ───────────────────────────────────────────────────── - - /// - /// Один проход по очереди: батч «new» (правила/дедуп/ML) → батч «filtered» (ИИ) (pump_once L890–917). - /// - /// - /// Порядок и счётчики 1:1 с _pump_unlocked L920–1183. Строки, переведённые в filtered «new»-проходом, - /// видит «filtered»-батч того же вызова (как в прототипе: запросы на общем соединении). Публикацию new_card - /// воркер НЕ делает — она живёт в Api-слое (Ruling 8/9: из PumpOnce созданные карточки возвращаются в - /// , публикует вызывающий — admin/tick или фоновый цикл T11). - /// Исключения (хранилище/писатель) пробрасываются наружу — как в прототипе, очередь останавливается до - /// следующего тика, а упавшая строка остаётся в очереди со своим dedup-claim. - /// - /// Токен отмены. - /// Сводка прохода: счётчики решений + созданные карточки (SSE new_card). - public async Task PumpOnceAsync(CancellationToken ct) - { - // Настройки воркера читаются ОДИН раз на pump (Ruling 8): переопределения тенанта — одним GetAllAsync, - // значения/дефолты резолвятся в снимок, который передаётся во все проверки сообщений батча и - // LocalFieldsParser (раньше настройки читались на каждое сообщение — до 16 чтений таблицы за pump). - 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(); - } - - // ── «new»-проход: правила → дедуп → ML (python L923–1065) ────────────── - - // Батч сообщений статуса new: устарело/правила/повтор отсекаются, остальные — ML или в filtered. - // Настройки (сроки/флаги ML/ИИ/бюджета) — из снимка прохода: читаются один раз, не на каждое сообщение. - // state: Накопители результата pump (счётчики/карточки). - // run: Снимок настроек воркера на проход (Ruling 8). - // ct: Токен отмены. - 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; - - // Устарело: только не force; старше срока до автоархива — не заводим в систему (L929–932, L836–844). - if (!force && IsStaleAsync(row, run)) - { - await DropStaleAsync(row, run, ct); - continue; - } - - // Этап-1 правила: длина → стоп-фразы → резюме → тип (L933–939; source=stop, stage=kind). - 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; - } - - // Глобальные исключения тенанта (§5.14): стоп ДО дедупа/ML/ИИ, чтобы не тратить токены; - // причина называет конкретное сработавшее исключение (source=stop). - 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; - } - } - - // Дедуп по нормализованному тексту: повтор — отсев «система»; иначе заявляем хэш (L940–951). - 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; - } - - // Claim атомарен (INSERT … ON CONFLICT DO NOTHING): false — хэш уже заявлен ПАРАЛЛЕЛЬНЫМ проходом - // pump после нашей проверки ExistsAsync. Карточку не создаём (иначе два прохода завели бы две) — - // отсев «повтор» + снятие строки; чужую заявку НЕ удаляем (свяжется с карточкой победителя, Ruling 8). - if (!await _store.ClaimAsync(digest, ct)) - { - await RejectRowAsync(row, SourceDup, StageDup, DupReason, string.Empty, ct); - await _store.RemoveAsync(row.Id, ct); - continue; - } - - state.Staged++; - - // ML-слот (Ruling 5): включён (mlEnabled не false) и не force — force идёт мимо ML к ИИ (L963–965). - if (run.MlEnabled && !force) - { - MlPredictResultDto decision = await PredictSafelyAsync(row.Text, ct); - string? label = decision.Label; - - // Модель готова и уверена: spam → отсев; доска без правил/не-suggested → карточка (L969–1025). - 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; - } - } - - // ML уверен в типе (t:hire/t:order), даже если колонку не назначил (L1026–1061). - if (decision.Ready && decision.Type is { Take: true } typeDecision) - { - bool isHire = typeDecision.Label == MlLearningLabels.TypeHireLabel; - string wanted = run.WantedType; - - // Тип не под режим «что собираем» — не тратим ИИ (typeDrop, L1029–1042). - 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; - } - } - - // ИИ выключен, но тип ML знает — карточка сама (inbox; L1043–1061). - 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; - } - } - } - - // ML не решил — сообщение ждёт ИИ (L1062–1065). - await _store.SetStatusAsync(row.Id, PipelineQueueStatuses.Filtered, ct); - } - } - - // ── «filtered»-проход: ИИ-фильтр → классификация → карточка/отсев (L1067–1180) ── - - // Батч сообщений статуса filtered: локальный путь (aiEnabled=false) или фильтр+классификация ИИ. - // Настройки — из снимка прохода (читаются один раз, не на каждое сообщение). - // state: Накопители результата pump (счётчики/карточки). - // run: Снимок настроек воркера на проход (Ruling 8). - // 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; - - // Устарело: как в «new»-проходе, только не force (L1073–1076). - if (!force && IsStaleAsync(row, run)) - { - await DropStaleAsync(row, run, ct); - continue; - } - - string text = row.Text; - string digest = DedupHasher.Hash(text); - - // ИИ выключен: классификацию/фильтр не зовём — карточку собирает локальный разбор (L1079–1096). - 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; - } - - // ИИ-фильтр (этап 2): возврат из отсева фильтр не пересматривает; выключен/сбой — пропуск (L1097–1106). - 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) - { - // Классификатор недоступен/сбой — как raw={} в прототипе (L1112–1114): локальный разбор, aiFail. - parsed = null; - } - - // python L1108–1111: успешная классификация ИИ — тип определён по контексту (не маркерной - // эвристикой). Стемп ставится ДО создания карточки (карточка получает is_vacancy_known=true) и - // переживает force-отмену вердикта «спам»; локальный разбор aiFail/aiEnabled=false стемпа не имеет. - if (parsed is not null) - { - parsed = parsed with { IsVacancyKnown = true }; - } - } - - // Вердикт «спам»: не прошёл фильтр либо классификатор пометил мусором (L1116–1121). Возврат (force) - // отменяет вердикт — пользователь уже подтвердил релевантность, карточку создаём без обучения «спаму». - bool isSpam = !filter.Pass || (parsed?.IsSpam ?? false); - if (force && filter.Pass && parsed?.IsSpam == true) - { - parsed = parsed with { IsSpam = false }; - isSpam = false; - } - - if (isSpam) - { - // Отсев spam_ai / filter_ai + обучение ML отличать спам (вес гипотезы, L1122–1138). - 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) - { - // ИИ не дал разбора (сбой), но не спам — локальный разбор (L1139–1142). - parsed = AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text); - state.AiFail++; - } - - // Глобальный фильтр «без суммы» (не force; Ruling 8, L1143–1147). - 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-часть (C32: выделено из общего файла, +/// поведение не менялось): PumpOnceAsync и «new»/«filtered»-проходы батчей (pump_once L890–1183, Ruling 8). +/// +public sealed partial class PipelineWorkerService +{ + // ── Проход воркера ───────────────────────────────────────────────────── + + /// + /// Один проход по очереди: батч «new» (правила/дедуп/ML) → батч «filtered» (ИИ) (pump_once L890–917). + /// + /// + /// Порядок и счётчики 1:1 с _pump_unlocked L920–1183. Строки, переведённые в filtered «new»-проходом, + /// видит «filtered»-батч того же вызова (как в прототипе: запросы на общем соединении). Публикацию new_card + /// воркер НЕ делает — она живёт в Api-слое (Ruling 8/9: из PumpOnce созданные карточки возвращаются в + /// , публикует вызывающий — admin/tick или фоновый цикл T11). + /// Исключения (хранилище/писатель) пробрасываются наружу — как в прототипе, очередь останавливается до + /// следующего тика, а упавшая строка остаётся в очереди со своим dedup-claim. + /// + /// Токен отмены. + /// Сводка прохода: счётчики решений + созданные карточки (SSE new_card). + public async Task PumpOnceAsync(CancellationToken ct) + { + // Настройки воркера читаются ОДИН раз на pump (Ruling 8): переопределения тенанта — одним GetAllAsync, + // значения/дефолты резолвятся в снимок, который передаётся во все проверки сообщений батча и + // LocalFieldsParser (раньше настройки читались на каждое сообщение — до 16 чтений таблицы за pump). + 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(); + } + + // ── «new»-проход: правила → дедуп → ML (python L923–1065) ────────────── + + // Батч сообщений статуса new: устарело/правила/повтор отсекаются, остальные — ML или в filtered. + // Настройки (сроки/флаги ML/ИИ/бюджета) — из снимка прохода: читаются один раз, не на каждое сообщение. + // state: Накопители результата pump (счётчики/карточки). + // run: Снимок настроек воркера на проход (Ruling 8). + // ct: Токен отмены. + 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; + + // Устарело: только не force; старше срока до автоархива — не заводим в систему (L929–932, L836–844). + if (!force && IsStaleAsync(row, run)) + { + await DropStaleAsync(row, run, ct); + continue; + } + + // Этап-1 правила: длина → стоп-фразы → резюме → тип (L933–939; source=stop, stage=kind). + 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; + } + + // Глобальные исключения тенанта (§5.14): стоп ДО дедупа/ML/ИИ, чтобы не тратить токены; + // причина называет конкретное сработавшее исключение (source=stop). + 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; + } + } + + // Дедуп по нормализованному тексту: повтор — отсев «система»; иначе заявляем хэш (L940–951). + 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; + } + + // Claim атомарен (INSERT … ON CONFLICT DO NOTHING): false — хэш уже заявлен ПАРАЛЛЕЛЬНЫМ проходом + // pump после нашей проверки ExistsAsync. Карточку не создаём (иначе два прохода завели бы две) — + // отсев «повтор» + снятие строки; чужую заявку НЕ удаляем (свяжется с карточкой победителя, Ruling 8). + if (!await _store.ClaimAsync(digest, ct)) + { + await RejectRowAsync(row, SourceDup, StageDup, DupReason, string.Empty, ct); + await _store.RemoveAsync(row.Id, ct); + continue; + } + + state.Staged++; + + // ML-слот (Ruling 5): включён (mlEnabled не false) и не force — force идёт мимо ML к ИИ (L963–965). + if (run.MlEnabled && !force) + { + MlPredictResultDto decision = await PredictSafelyAsync(row.Text, ct); + string? label = decision.Label; + + // Модель готова и уверена: spam → отсев; доска без правил/не-suggested → карточка (L969–1025). + 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; + } + } + + // ML уверен в типе (t:hire/t:order), даже если колонку не назначил (L1026–1061). + if (decision.Ready && decision.Type is { Take: true } typeDecision) + { + bool isHire = typeDecision.Label == MlLearningLabels.TypeHireLabel; + string wanted = run.WantedType; + + // Тип не под режим «что собираем» — не тратим ИИ (typeDrop, L1029–1042). + 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; + } + } + + // ИИ выключен, но тип ML знает — карточка сама (inbox; L1043–1061). + 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; + } + } + } + + // ML не решил — сообщение ждёт ИИ (L1062–1065). + await _store.SetStatusAsync(row.Id, PipelineQueueStatuses.Filtered, ct); + } + } + + // ── «filtered»-проход: ИИ-фильтр → классификация → карточка/отсев (L1067–1180) ── + + // Батч сообщений статуса filtered: локальный путь (aiEnabled=false) или фильтр+классификация ИИ. + // Настройки — из снимка прохода (читаются один раз, не на каждое сообщение). + // state: Накопители результата pump (счётчики/карточки). + // run: Снимок настроек воркера на проход (Ruling 8). + // 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; + + // Устарело: как в «new»-проходе, только не force (L1073–1076). + if (!force && IsStaleAsync(row, run)) + { + await DropStaleAsync(row, run, ct); + continue; + } + + string text = row.Text; + string digest = DedupHasher.Hash(text); + + // ИИ выключен: классификацию/фильтр не зовём — карточку собирает локальный разбор (L1079–1096). + 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; + } + + // ИИ-фильтр (этап 2): возврат из отсева фильтр не пересматривает; выключен/сбой — пропуск (L1097–1106). + 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) + { + // Классификатор недоступен/сбой — как raw={} в прототипе (L1112–1114): локальный разбор, aiFail. + parsed = null; + } + + // python L1108–1111: успешная классификация ИИ — тип определён по контексту (не маркерной + // эвристикой). Стемп ставится ДО создания карточки (карточка получает is_vacancy_known=true) и + // переживает force-отмену вердикта «спам»; локальный разбор aiFail/aiEnabled=false стемпа не имеет. + if (parsed is not null) + { + parsed = parsed with { IsVacancyKnown = true }; + } + } + + // Вердикт «спам»: не прошёл фильтр либо классификатор пометил мусором (L1116–1121). Возврат (force) + // отменяет вердикт — пользователь уже подтвердил релевантность, карточку создаём без обучения «спаму». + bool isSpam = !filter.Pass || (parsed?.IsSpam ?? false); + if (force && filter.Pass && parsed?.IsSpam == true) + { + parsed = parsed with { IsSpam = false }; + isSpam = false; + } + + if (isSpam) + { + // Отсев spam_ai / filter_ai + обучение ML отличать спам (вес гипотезы, L1122–1138). + 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) + { + // ИИ не дал разбора (сбой), но не спам — локальный разбор (L1139–1142). + parsed = AiCardMapper.FromLocal(_fieldsParser.Parse(text, run.Settings), text); + state.AiFail++; + } + + // Глобальный фильтр «без суммы» (не force; Ruling 8, L1143–1147). + 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 5f50973..c830868 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Rejections.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Rejections.cs @@ -1,94 +1,78 @@ -using System.Globalization; -using System.Text.Json; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Kanban.Application.ColumnRules; -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.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Отсев и удаление строк — partial-часть (C32: выделено из общего -/// файла, поведение не менялось): запись отсева (RejectRowAsync), удаление строки очереди со снятием claim -/// и отсев «устарело» (_drop_row/_drop_stale, Ruling 8). -/// -public sealed partial class PipelineWorkerService -{ - // ── Отсев / удаление строк ───────────────────────────────────────────── - - // Пишет запись отсева по строке очереди (источник/этап/причина/фраза; обработка.record, Ruling 8). - // row: Строка очереди — источник полей записи (текст/канал/время). - // source: Источник решения: stop|ml|ai|stale|dup. - // stage: Этап отсева: length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup. - // reason: Причина (текст 1:1 с прототипом; режется сервисом до 500). - // kw: Сработавшая фраза/маркер правила (пусто — не правило). - // ct: Токен отмены. - private Task RejectRowAsync( - QueueItemDto row, - string source, - string stage, - string reason, - string kw, - CancellationToken ct) - { - return _processing.RejectAsync(new RejectRecord - { - DialogId = row.DialogId, - MsgId = row.MsgId, - Text = row.Text, - ChannelName = row.Channel.Name, - ChannelHandle = row.Channel.Handle, - ChannelHue = row.Channel.Hue, - MsgAtMs = row.MsgAtMs, - Source = source, - Stage = stage, - Reason = reason, - Kw = kw, - }, ct); - } - - // Удаляет строку очереди (отсев на любом этапе; python _drop_row L809–814). - // row: Строка очереди. - // digest: Хэш текста (для снятия незанятого dedup-claim). - // withDedup: Снять claim дедупа (true — сообщение можно обработать заново после отсева). - // 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); - } - - // Отсев «устарело»: запись + удаление строки, карточка НЕ создаётся (python _drop_stale L836–844). - // row: Строка очереди со старым msg_at. - // run: Снимок настроек воркера (срок до автоархива уже прочитан). - // 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-часть (C32: выделено из общего +/// файла, поведение не менялось): запись отсева (RejectRowAsync), удаление строки очереди со снятием claim +/// и отсев «устарело» (_drop_row/_drop_stale, Ruling 8). +/// +public sealed partial class PipelineWorkerService +{ + // ── Отсев / удаление строк ───────────────────────────────────────────── + + // Пишет запись отсева по строке очереди (источник/этап/причина/фраза; обработка.record, Ruling 8). + // row: Строка очереди — источник полей записи (текст/канал/время). + // source: Источник решения: stop|ml|ai|stale|dup. + // stage: Этап отсева: length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup. + // reason: Причина (текст 1:1 с прототипом; режется сервисом до 500). + // kw: Сработавшая фраза/маркер правила (пусто — не правило). + // ct: Токен отмены. + private Task RejectRowAsync( + QueueItemDto row, + string source, + string stage, + string reason, + string kw, + CancellationToken ct) + { + return _processing.RejectAsync(new RejectRecord + { + DialogId = row.DialogId, + MsgId = row.MsgId, + Text = row.Text, + ChannelName = row.Channel.Name, + ChannelHandle = row.Channel.Handle, + ChannelHue = row.Channel.Hue, + MsgAtMs = row.MsgAtMs, + Source = source, + Stage = stage, + Reason = reason, + Kw = kw, + }, ct); + } + + // Удаляет строку очереди (отсев на любом этапе; python _drop_row L809–814). + // row: Строка очереди. + // digest: Хэш текста (для снятия незанятого dedup-claim). + // withDedup: Снять claim дедупа (true — сообщение можно обработать заново после отсева). + // 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); + } + + // Отсев «устарело»: запись + удаление строки, карточка НЕ создаётся (python _drop_stale L836–844). + // row: Строка очереди со старым msg_at. + // run: Снимок настроек воркера (срок до автоархива уже прочитан). + // 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 dcc1034..389626e 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Settings.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Settings.cs @@ -1,103 +1,88 @@ -using System.Globalization; -using System.Text.Json; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Kanban.Application.ColumnRules; -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.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Снимок настроек прохода — partial-часть (C32: выделено из общего -/// файла, поведение не менялось): WorkerRunSettings + один GetAllAsync на pump и резолв дефолтов. -/// -public sealed partial class PipelineWorkerService -{ - // ── Снимок настроек на проход pump (Ruling 8/C30: один GetAllAsync вместо чтений на каждое сообщение) ── - - // Снимок настроек воркера на один проход pump: значения прочитаны ОДИН раз (LoadRunSettingsAsync) - // и передаются во все проверки сообщений батча и в LocalFieldsParser — раньше настройки читались на каждое - // сообщение (ReadBool/ReadInt + GetAllAsync парсера — до 16 чтений таблицы за pump). Значения — из - // типизированного снимка TenantSettingsSnapshot (C30). - // Settings: Типизированный снимок переопределений тенанта (передаётся LocalFieldsParser). - // AutoArchive: Флаг автоархива (срок актуальности сообщения). - // ArchiveAfterDays: Срок до автоархива, сутки (stale/причина отсева). - // MlEnabled: ML-слот включён (не false — прогноз/решения). - // AiEnabled: ИИ-слот включён (классификация/фильтр). - // AiFilterEnabled: ИИ-фильтр включён (этап 2). - // BudgetRequiredHire: Фильтр «без суммы» для найма. - // BudgetRequiredOrder: Фильтр «без суммы» для разовых заказов. - // WantedType: Тип заявок «что собираем» (both|vacancy|freelance). - // GlobalExclusions: Снимок глобальных исключений (стоп до ML/ИИ, §5.14). - 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)); - } - - // Собирает снимок глобальных исключений тенанта (§5.14): списки слов/локаций/типов и границы бюджета. - // Граница бюджета 0 в настройках трактуется как «не задана» (null); сравнение сумм — числовое, - // без конвертации валют (у настройки нет валюты). - // 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); - } - - // wantedType: тип заявок (both|vacancy|freelance) в нижнем регистре (python L1030); пустая/отсут- - // ствующая/повреждённая строка → дефолт «both» (даёт GetString с дефолтом + нормализация). - // value: Значение настройки wantedType (JSON-строка либо дефолт из SettingsDefaults). - // Возвращает: Значение в нижнем регистре (пустое хранимое значение остаётся пустым — как wantedType=""). - 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-часть (C32: выделено из общего +/// файла, поведение не менялось): WorkerRunSettings + один GetAllAsync на pump и резолв дефолтов. +/// +public sealed partial class PipelineWorkerService +{ + // ── Снимок настроек на проход pump (Ruling 8/C30: один GetAllAsync вместо чтений на каждое сообщение) ── + + // Снимок настроек воркера на один проход pump: значения прочитаны ОДИН раз (LoadRunSettingsAsync) + // и передаются во все проверки сообщений батча и в LocalFieldsParser — раньше настройки читались на каждое + // сообщение (ReadBool/ReadInt + GetAllAsync парсера — до 16 чтений таблицы за pump). Значения — из + // типизированного снимка TenantSettingsSnapshot (C30). + // Settings: Типизированный снимок переопределений тенанта (передаётся LocalFieldsParser). + // AutoArchive: Флаг автоархива (срок актуальности сообщения). + // ArchiveAfterDays: Срок до автоархива, сутки (stale/причина отсева). + // MlEnabled: ML-слот включён (не false — прогноз/решения). + // AiEnabled: ИИ-слот включён (классификация/фильтр). + // AiFilterEnabled: ИИ-фильтр включён (этап 2). + // BudgetRequiredHire: Фильтр «без суммы» для найма. + // BudgetRequiredOrder: Фильтр «без суммы» для разовых заказов. + // WantedType: Тип заявок «что собираем» (both|vacancy|freelance). + // GlobalExclusions: Снимок глобальных исключений (стоп до ML/ИИ, §5.14). + 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)); + } + + // Собирает снимок глобальных исключений тенанта (§5.14): списки слов/локаций/типов и границы бюджета. + // Граница бюджета 0 в настройках трактуется как «не задана» (null); сравнение сумм — числовое, + // без конвертации валют (у настройки нет валюты). + // 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); + } + + // wantedType: тип заявок (both|vacancy|freelance) в нижнем регистре (python L1030); пустая/отсут- + // ствующая/повреждённая строка → дефолт «both» (даёт GetString с дефолтом + нормализация). + // value: Значение настройки wantedType (JSON-строка либо дефолт из SettingsDefaults). + // Возвращает: Значение в нижнем регистре (пустое хранимое значение остаётся пустым — как wantedType=""). + 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 b7e9526..1fbab52 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.State.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.State.cs @@ -1,95 +1,79 @@ -using System.Globalization; -using System.Text.Json; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Kanban.Application.ColumnRules; -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.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Накопители результата pump — partial-часть (C32: выделено из -/// общего файла, поведение не менялось): приватный PumpState (счётчики словаря python L921 + карточки). -/// -public sealed partial class PipelineWorkerService -{ - // ── Накопители результата pump (счётчики и карточки одного прохода) ── - - // Мутабельные накопители одного прохода pump: счётчики словаря python L921 + созданные карточки. - private sealed class PumpState - { - /// - /// Прошли «new»-проход и переведены в filtered (L952). - /// - public int Staged { get; set; } - - /// - /// Карточек создано ML-веткой (mlStored). - /// - public int MlStored { get; set; } - - /// - /// Отсевов решением ML «спам» (mlDrop). - /// - public int MlDrop { get; set; } - - /// - /// Отсевов «тип не под режим» по решению ML (typeDrop). - /// - public int TypeDrop { get; set; } - - /// - /// Карточек создано ИИ-веткой/локальным путём (aiStored). - /// - public int AiStored { get; set; } - - /// - /// Отсевов решением ИИ: spam_ai/filter_ai (aiDrop). - /// - public int AiDrop { get; set; } - - /// - /// Сообщений, где ИИ не дал разбора — собран локальный разбор (aiFail). - /// - public int AiFail { get; set; } - - /// - /// Отсевов фильтром «без суммы» (noBudget). - /// - public int NoBudget { get; set; } - - /// - /// Карточки, созданные за проход (порядок создания; SSE new_card). - /// - public List Created { get; } = []; - - /// - /// Собирает неизменяемый результат прохода (счётчики 1:1 со словарём python L921). - /// - /// Результат 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-часть (C32: выделено из +/// общего файла, поведение не менялось): приватный PumpState (счётчики словаря python L921 + карточки). +/// +public sealed partial class PipelineWorkerService +{ + // ── Накопители результата pump (счётчики и карточки одного прохода) ── + + // Мутабельные накопители одного прохода pump: счётчики словаря python L921 + созданные карточки. + private sealed class PumpState + { + /// + /// Прошли «new»-проход и переведены в filtered (L952). + /// + public int Staged { get; set; } + + /// + /// Карточек создано ML-веткой (mlStored). + /// + public int MlStored { get; set; } + + /// + /// Отсевов решением ML «спам» (mlDrop). + /// + public int MlDrop { get; set; } + + /// + /// Отсевов «тип не под режим» по решению ML (typeDrop). + /// + public int TypeDrop { get; set; } + + /// + /// Карточек создано ИИ-веткой/локальным путём (aiStored). + /// + public int AiStored { get; set; } + + /// + /// Отсевов решением ИИ: spam_ai/filter_ai (aiDrop). + /// + public int AiDrop { get; set; } + + /// + /// Сообщений, где ИИ не дал разбора — собран локальный разбор (aiFail). + /// + public int AiFail { get; set; } + + /// + /// Отсевов фильтром «без суммы» (noBudget). + /// + public int NoBudget { get; set; } + + /// + /// Карточки, созданные за проход (порядок создания; SSE new_card). + /// + public List Created { get; } = []; + + /// + /// Собирает неизменяемый результат прохода (счётчики 1:1 со словарём python L921). + /// + /// Результат 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 b132c87..db98a73 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.cs @@ -1,203 +1,193 @@ -using System.Globalization; -using System.Text.Json; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Kanban.Application.ColumnRules; -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.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Registrars; - -namespace Deal.Modules.Pipeline.Application.Services; - -/// -/// Воркер разбора очереди входящих — один проход pump (Ruling 8; прототип _pump_unlocked L920–1183, порядок строго 1:1). -/// -/// -/// Чистый оркестратор модуля (без EF/HTTP/циклов): забирает батч «new» (лимит 12) и -/// «filtered» (лимит 4) и двигает сообщение по конвейеру: стоп-фразы → дедуп → ML → ИИ → карточка/отсев. -/// -/// «new»-проход (Ruling 8 L928–1065): force? → stale (только не force; msgAt старше archiveAfterDays суток при -/// autoArchive=true → отсев «устарело» без карточки) → (не прошёл → отсев правил) → -/// дедуп по тексту (; хэш в системе → отсев «повтор»; иначе claim) → ML-слот -/// (Ruling 5: mlEnabled не false и не force → ; готовая и уверенная модель -/// решает: spam → отсев spam_ml; доска без активных правил и не-suggested → карточка в доску; тип → typeDrop по -/// wantedType либо карточка inbox при aiEnabled=false) → не решено → статус filtered. -/// -/// -/// «filtered»-проход (L1067–1180): stale → aiEnabled=false → локальный разбор () и -/// карточка inbox; иначе force → пропуск ИИ-фильтра (возврат из отсева, L1097–1100), aiFilterEnabled=false → -/// фильтр пропущен; фильтр (, сбой → пропуск) → классификация -/// (; сбой → локальный разбор, aiFail) — успешная классификация -/// подтверждает тип: is_vacancy_known=true (python L1108–1111, карточка и обучающий push получают known) → -/// вердикт «спам» (сила отменяет -/// только для force, L1117–1121) → отсев spam_ai/filter_ai + обучение ML «спам» (вес 0.4) → no-budget (не force) -/// → карточка (, col по ContainerAccepts/правилам, иначе inbox) + обучение ML по -/// колонке/типу (0.4). Результат — (+CreatedCards для SSE); счётчики решений -/// mlDecisions/aiDecisions инкрементируются после pump (Ruling 5: mlStored+mlDrop / aiStored+aiDrop, через -/// KV read-modify-write). Исключения хранилища/писателя пробрасываются вызывающему (фоновый цикл Api логирует и -/// продолжит на следующем тике; строка остаётся в очереди с claim — как прототип). -/// -/// C32: класс разделён на partial-файлы по темам (Pump/Learning/Rejections/Checks/Decisions/Settings/State.cs); -/// поведение, сигнатуры и тексты ошибок не менялись. -/// -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 (DI-зависимости прохода). - /// - /// Хранилище очереди/отсева/дедупа (порт IPipelineStore). - /// KV-настройки тенанта (флаги/сроки воркера, Ruling 8). - /// Этап-1 правила фильтра входящих (длина/стоп-фразы/резюме/тип, Settings). - /// Порт канбана: доски (проверка allowed-колонок ML/обучения) и чтение карточки. - /// Порт ML-сервиса: predict (решения «решил сам») и push (обучение). - /// Порт ИИ: фильтр и классификация (этап 4 — LocalAiClassifier). - /// Запись отсева (RejectAsync) и обслуживание вкладки «Обработка». - /// Создание карточки через публичный интерфейс Kanban + связь дедупа (Ruling 3/4). - /// Локальный структуратор (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; - } - // ── Лимиты батчей pump (прототип pump_once new_limit=12 / ai_limit=4, L890) ── - - // Лимит «new»-прохода: сколько сообщений за проход проходят правила/дедуп/ML. - private const int NewBatchLimit = 12; - - // Лимит «filtered»-прохода: сколько сообщений за проход идут на ИИ (дорогой шаг). - private const int FilteredBatchLimit = 4; - - // Сутки в миллисекундах (срок актуальности сообщения, Ruling 8). - private const long DayMs = 86_400_000; - - // ── Источники/этапы отсева (RejectRecord; словари подписей — PipelineRejectConstants) ── - - // Источник «правила» (этап-1 фильтр/no-budget). - 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"; - - // Этап отсева «повтор» / «устарело» / «нет суммы» (словарь подписей Ruling 1). - private const string StageDup = "dup"; - - private const string StageStale = "stale"; - - private const string StageBudget = "budget"; - - private const string StageType = "type"; - - // ── Обучение ML (ml_client.py L26–27: AI_WEIGHT=0.4 — MlLearningLabels.AiPushWeight; метки L47/L1177–1178) ── - - // Максимум терминов ML, докладываемых в стек карточки (python L1010: added >= 4 → break). - private const int MaxMlTermsAdded = 4; - - // ── Типы заявок wantedType (строка настройки, как у IncomingRules) ── - - // wantedType: только вакансии/занятость. - private const string WantedTypeVacancy = "vacancy"; - - // wantedType: только разовые заказы. - private const string WantedTypeFreelance = "freelance"; - - // ── Фиксированные строки причин (1:1 с прототипом) ── - - // Причина отсева «устарело» (python _drop_stale L841–843: старше срока до автоархива). - private const string StaleReasonFormat = "сообщение старше {0} дн. (срок до автоархива) — не заводим в систему"; - - // Причина отсева «повтор» (python L942–945). - private const string DupReason = "сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается"; - - // Причина no-budget фильтра (python _reject_no_budget L853–858). - private const string NoBudgetReason = "включён фильтр «не создавать карточку без суммы» — в тексте не указан бюджет"; - - // Причина отсева ML-спама: «(score 0.90)» — вес класса spam (python L973–976). - private const string MlSpamReasonFormat = "ML уверен, что это спам/не заявка (score {0})"; - - // Причина typeDrop ML: тип не под режим «что собираем» (python L1037–1039). - private const string MlTypeDropReasonFormat = "ML: тип «{0}», а вы ищете только «{1}»"; - - // Подпись типа «найм/занятость» в причине typeDrop (python L1038). - private const string MlHireKindName = "найм/занятость"; - - // Подпись типа «разовые заказы» в причине typeDrop (python L1038). - private const string MlOrderKindName = "разовые заказы"; - - // Причина отсева «спам (ИИ)» (python L1131–1134). - private const string AiSpamReason = "ИИ: не заявка — спам, реклама, скам или служебное сообщение"; - - // Префикс причины отсева «ИИ-фильтр» (python L1126–1129). - private const string AiFilterReasonPrefix = "ИИ-фильтр: "; - - // Дефолтная причина ИИ-фильтра, если фильтр не дал свою (python L1128). - private const string AiFilterDefaultReason = "сообщение не относится к вашим интересам"; - - // Ответ «не готова/не уверена» неготовая модель (LocalMlClient ready:false → predict, Ruling 5). - private static readonly MlPredictResultDto NotReadyPrediction = new( - Take: false, - Label: null, - Scores: new Dictionary(), - Hits: 0, - Ready: false, - Margin: null, - Terms: Array.Empty(), - Type: null); - - // Ответ «фильтр пропущен» (force/выключен/сбой: python L1097–1106). - 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 (Ruling 8; прототип _pump_unlocked L920–1183, порядок строго 1:1). +/// +/// +/// Чистый оркестратор модуля (без EF/HTTP/циклов): забирает батч «new» (лимит 12) и +/// «filtered» (лимит 4) и двигает сообщение по конвейеру: стоп-фразы → дедуп → ML → ИИ → карточка/отсев. +/// +/// «new»-проход (Ruling 8 L928–1065): force? → stale (только не force; msgAt старше archiveAfterDays суток при +/// autoArchive=true → отсев «устарело» без карточки) → (не прошёл → отсев правил) → +/// дедуп по тексту (; хэш в системе → отсев «повтор»; иначе claim) → ML-слот +/// (Ruling 5: mlEnabled не false и не force → ; готовая и уверенная модель +/// решает: spam → отсев spam_ml; доска без активных правил и не-suggested → карточка в доску; тип → typeDrop по +/// wantedType либо карточка inbox при aiEnabled=false) → не решено → статус filtered. +/// +/// +/// «filtered»-проход (L1067–1180): stale → aiEnabled=false → локальный разбор () и +/// карточка inbox; иначе force → пропуск ИИ-фильтра (возврат из отсева, L1097–1100), aiFilterEnabled=false → +/// фильтр пропущен; фильтр (, сбой → пропуск) → классификация +/// (; сбой → локальный разбор, aiFail) — успешная классификация +/// подтверждает тип: is_vacancy_known=true (python L1108–1111, карточка и обучающий push получают known) → +/// вердикт «спам» (сила отменяет +/// только для force, L1117–1121) → отсев spam_ai/filter_ai + обучение ML «спам» (вес 0.4) → no-budget (не force) +/// → карточка (, col по ContainerAccepts/правилам, иначе inbox) + обучение ML по +/// колонке/типу (0.4). Результат — (+CreatedCards для SSE); счётчики решений +/// mlDecisions/aiDecisions инкрементируются после pump (Ruling 5: mlStored+mlDrop / aiStored+aiDrop, через +/// KV read-modify-write). Исключения хранилища/писателя пробрасываются вызывающему (фоновый цикл Api логирует и +/// продолжит на следующем тике; строка остаётся в очереди с claim — как прототип). +/// +/// C32: класс разделён на partial-файлы по темам (Pump/Learning/Rejections/Checks/Decisions/Settings/State.cs); +/// поведение, сигнатуры и тексты ошибок не менялись. +/// +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 (DI-зависимости прохода). + /// + /// Хранилище очереди/отсева/дедупа (порт IPipelineStore). + /// KV-настройки тенанта (флаги/сроки воркера, Ruling 8). + /// Этап-1 правила фильтра входящих (длина/стоп-фразы/резюме/тип, Settings). + /// Порт канбана: доски (проверка allowed-колонок ML/обучения) и чтение карточки. + /// Порт ML-сервиса: predict (решения «решил сам») и push (обучение). + /// Порт ИИ: фильтр и классификация (этап 4 — LocalAiClassifier). + /// Запись отсева (RejectAsync) и обслуживание вкладки «Обработка». + /// Создание карточки через публичный интерфейс Kanban + связь дедупа (Ruling 3/4). + /// Локальный структуратор (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; + } + // ── Лимиты батчей pump (прототип pump_once new_limit=12 / ai_limit=4, L890) ── + + // Лимит «new»-прохода: сколько сообщений за проход проходят правила/дедуп/ML. + private const int NewBatchLimit = 12; + + // Лимит «filtered»-прохода: сколько сообщений за проход идут на ИИ (дорогой шаг). + private const int FilteredBatchLimit = 4; + + // Сутки в миллисекундах (срок актуальности сообщения, Ruling 8). + private const long DayMs = 86_400_000; + + // ── Источники/этапы отсева (RejectRecord; словари подписей — PipelineRejectConstants) ── + + // Источник «правила» (этап-1 фильтр/no-budget). + 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"; + + // Этап отсева «повтор» / «устарело» / «нет суммы» (словарь подписей Ruling 1). + private const string StageDup = "dup"; + + private const string StageStale = "stale"; + + private const string StageBudget = "budget"; + + private const string StageType = "type"; + + // ── Обучение ML (ml_client.py L26–27: AI_WEIGHT=0.4 — MlLearningLabels.AiPushWeight; метки L47/L1177–1178) ── + + // Максимум терминов ML, докладываемых в стек карточки (python L1010: added >= 4 → break). + private const int MaxMlTermsAdded = 4; + + // ── Типы заявок wantedType (строка настройки, как у IncomingRules) ── + + // wantedType: только вакансии/занятость. + private const string WantedTypeVacancy = "vacancy"; + + // wantedType: только разовые заказы. + private const string WantedTypeFreelance = "freelance"; + + // ── Фиксированные строки причин (1:1 с прототипом) ── + + // Причина отсева «устарело» (python _drop_stale L841–843: старше срока до автоархива). + private const string StaleReasonFormat = "сообщение старше {0} дн. (срок до автоархива) — не заводим в систему"; + + // Причина отсева «повтор» (python L942–945). + private const string DupReason = "сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается"; + + // Причина no-budget фильтра (python _reject_no_budget L853–858). + private const string NoBudgetReason = "включён фильтр «не создавать карточку без суммы» — в тексте не указан бюджет"; + + // Причина отсева ML-спама: «(score 0.90)» — вес класса spam (python L973–976). + private const string MlSpamReasonFormat = "ML уверен, что это спам/не заявка (score {0})"; + + // Причина typeDrop ML: тип не под режим «что собираем» (python L1037–1039). + private const string MlTypeDropReasonFormat = "ML: тип «{0}», а вы ищете только «{1}»"; + + // Подпись типа «найм/занятость» в причине typeDrop (python L1038). + private const string MlHireKindName = "найм/занятость"; + + // Подпись типа «разовые заказы» в причине typeDrop (python L1038). + private const string MlOrderKindName = "разовые заказы"; + + // Причина отсева «спам (ИИ)» (python L1131–1134). + private const string AiSpamReason = "ИИ: не заявка — спам, реклама, скам или служебное сообщение"; + + // Префикс причины отсева «ИИ-фильтр» (python L1126–1129). + private const string AiFilterReasonPrefix = "ИИ-фильтр: "; + + // Дефолтная причина ИИ-фильтра, если фильтр не дал свою (python L1128). + private const string AiFilterDefaultReason = "сообщение не относится к вашим интересам"; + + // Ответ «не готова/не уверена» неготовая модель (LocalMlClient ready:false → predict, Ruling 5). + private static readonly MlPredictResultDto NotReadyPrediction = new( + Take: false, + Label: null, + Scores: new Dictionary(), + Hits: 0, + Ready: false, + Margin: null, + Terms: Array.Empty(), + Type: null); + + // Ответ «фильтр пропущен» (force/выключен/сбой: python L1097–1106). + 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 0ec57df..5c12ada 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/ReclassifyGate.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/ReclassifyGate.cs @@ -1,7 +1,3 @@ -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; - namespace Deal.Modules.Pipeline.Application.Services; /// diff --git a/src/core/Deal.Modules.Settings/Application/Abstractions/IAiConnectionChecker.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/IAiConnectionChecker.cs index 2a504dd..430f523 100644 --- a/src/core/Deal.Modules.Settings/Application/Abstractions/IAiConnectionChecker.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/IAiConnectionChecker.cs @@ -1,6 +1,4 @@ using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; namespace Deal.Modules.Settings.Application.Abstractions; diff --git a/src/core/Deal.Modules.Settings/Application/Abstractions/IGlobalSettingsStore.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/IGlobalSettingsStore.cs index b97ef17..f4a8212 100644 --- a/src/core/Deal.Modules.Settings/Application/Abstractions/IGlobalSettingsStore.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/IGlobalSettingsStore.cs @@ -1,6 +1,4 @@ using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; namespace Deal.Modules.Settings.Application.Abstractions; diff --git a/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesChangedListener.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesChangedListener.cs index 7d74bc5..29f2b15 100644 --- a/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesChangedListener.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesChangedListener.cs @@ -1,5 +1,3 @@ -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; using Deal.Modules.Settings.Application.Services; namespace Deal.Modules.Settings.Application.Abstractions; diff --git a/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesSource.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesSource.cs index a004282..274084f 100644 --- a/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesSource.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesSource.cs @@ -1,5 +1,3 @@ -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; using Deal.Modules.Settings.Application.Services; namespace Deal.Modules.Settings.Application.Abstractions; diff --git a/src/core/Deal.Modules.Settings/Application/Abstractions/ISecretCipher.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/ISecretCipher.cs index 30d79f4..7a1aa68 100644 --- a/src/core/Deal.Modules.Settings/Application/Abstractions/ISecretCipher.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/ISecretCipher.cs @@ -1,7 +1,3 @@ -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; - namespace Deal.Modules.Settings.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Settings/Application/Abstractions/ISettingsStore.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/ISettingsStore.cs index b056315..de33e92 100644 --- a/src/core/Deal.Modules.Settings/Application/Abstractions/ISettingsStore.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/ISettingsStore.cs @@ -1,6 +1,4 @@ using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; namespace Deal.Modules.Settings.Application.Abstractions; diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiProviderDefinition.cs b/src/core/Deal.Modules.Settings/Application/Models/AiProviderDefinition.cs index 1a1929e..9d20ac8 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/AiProviderDefinition.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/AiProviderDefinition.cs @@ -1,7 +1,3 @@ -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; - namespace Deal.Modules.Settings.Application.Models; /// diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiProviders.cs b/src/core/Deal.Modules.Settings/Application/Models/AiProviders.cs index d0e3433..a4d076d 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/AiProviders.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/AiProviders.cs @@ -1,7 +1,3 @@ -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; - namespace Deal.Modules.Settings.Application.Models; /// diff --git a/src/core/Deal.Modules.Settings/Application/Models/DefaultPrompts.cs b/src/core/Deal.Modules.Settings/Application/Models/DefaultPrompts.cs index 63ab498..cec195d 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/DefaultPrompts.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/DefaultPrompts.cs @@ -1,7 +1,3 @@ -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; - namespace Deal.Modules.Settings.Application.Models; /// diff --git a/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs b/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs index eaf82a5..7fd50bc 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs @@ -1,7 +1,3 @@ -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; - namespace Deal.Modules.Settings.Application.Models; /// diff --git a/src/core/Deal.Modules.Settings/Application/Models/IncomingRules.cs b/src/core/Deal.Modules.Settings/Application/Models/IncomingRules.cs index 0d889bc..9fb198b 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/IncomingRules.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/IncomingRules.cs @@ -1,285 +1,282 @@ -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; - -namespace Deal.Modules.Settings.Application.Models; - -/// -/// Этап-1 правила фильтра входящих — чистая реализация stage1_plain (pipeline.py L94–124). -/// -/// -/// Детерминированные правила без ИИ поверх настроек тенанта (): -/// минимальная длина (minLen), стоп-фразы (stopPhrases), отсев резюме -/// (blockResumes + resumeMarkers), тип заявки (wantedType + маркеры найма -/// hireMarkers). Порядок правил и строки причин — 1:1 с stage1_plain: длина → стоп-фразы → -/// резюме → тип. Возврат — {pass, reason, stage, kind, kw}, где kw — -/// конкретная стоп-фраза/маркер (для тестера и мониторинга отсева этапа 4). Правила переиспользуются -/// пайплайном этапа 4 (Ruling 8). Эффективные значения = дефолты , перекрытые -/// сохранёнными (Ruling 1); повреждённые строки хранилища мягко трактуются как отсутствующие (как в -/// SettingsService/RatesService). -/// -/// Семантика чтения 1:1 с прототипом: текст тримится и сравнивается casefold-ом (в .NET — -/// ToLowerInvariant); у слова «резюме» есть контекстный guard: если ДО маркера встречается маркер найма -/// (hireMarkers) — это объявление работодателя («…вакансия…, присылайте резюме»), а не резюме соискателя, -/// его не отсекаем (pipeline._resume_reason L644–654). Маркеры найма/резюме нормализуются -/// (trim + lowercase), стоп-фразы сравниваются как сохранены (kw — исходный вид фразы). -/// -/// -public sealed class IncomingRules(ISettingsStore store) -{ - /// - /// Номер этапа пайплайна для результата правил (всегда 1 — без ИИ). - /// - public const int Stage = 1; - - /// - /// kind: текст короче minLen (pipeline.py L104). - /// - public const string KindLength = "length"; - - /// - /// kind: найдена стоп-фраза (pipeline.py L108). - /// - public const string KindStop = "stop"; - - /// - /// kind: текст опознан как резюме соискателя (pipeline.py L114). - /// - public const string KindResume = "resume"; - - /// - /// kind: тип текста не совпал с wantedType (pipeline.py L121/L123). - /// - public const string KindType = "type"; - - /// - /// kind на проходе — пустая строка (как в python-результате stage1_plain). - /// - public const string KindPass = ""; - - // Маркер резюме с контекстным guard («…вакансия…, присылайте резюме», pipeline.py L655). - private const string ResumeMarkerGuardWord = "резюме"; - - // wantedType: собираем и вакансии, и разовые заказы (дефолт). - private const string WantedTypeBoth = "both"; - - // wantedType: только вакансии/занятость. - private const string WantedTypeVacancy = "vacancy"; - - // wantedType: только разовые заказы/фриланс. - private const string WantedTypeFreelance = "freelance"; - - // Причина отсева по длине (pipeline.py L104). - private const string ReasonFormatLength = "короче {0} символов"; - - // Причина отсева по стоп-фразе (pipeline.py L108). - private const string ReasonFormatStop = "стоп-фраза «{0}»"; - - // Причина отсева резюме (pipeline.py L114). - private const string ReasonFormatResume = "резюме соискателя («{0}»)"; - - // Причина отсева типа при wantedType=freelance (pipeline.py L121). - private const string ReasonTypeFreelanceOnly = "ищете только разовые заявки — сообщение похоже на вакансию/занятость"; - - // Причина отсева типа при wantedType=vacancy (pipeline.py L123). - private const string ReasonTypeVacancyOnly = "ищете только занятость — сообщение похоже на разовый заказ/услугу"; - - /// - /// Проверяет текст этапом-1 фильтра по настройкам тенанта (pipeline.py stage1_plain L94–124). - /// - /// Текст сообщения; null/пустой трактуется как «короче minLen» (как (text or "").strip()). - /// Токен отмены. - /// Вердикт этапа 1: {pass, reason, stage:1, kind, kw}. - public async Task CheckAsync(string? text, CancellationToken ct) - { - // Читаем переопределения одним запросом (GetAllAsync) и считаем чистой функцией над снимком - // «дефолты + сохранённые» (Ruling 1): правила детерминированы, порядок чтения не влияет. - RulesSettings settings = ReadSettings(await TenantSettingsSnapshot.LoadAsync(store, ct)); - return Evaluate(text, settings); - } - - // Чистая проверка текста по снимку настроек (1:1 stage1_plain L101–124). - // text: Исходный текст сообщения (может быть null — как None из python). - // settings: Эффективные настройки этапа-1 (дефолты + переопределения). - // Возвращает: Вердикт: причина/правило/фраза либо проход. - 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(); - - // Стоп-фразы: подстрочное вхождение casefold-фразы; kw — фраза как сохранена (pipeline.py L106–108). - 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); - } - } - - // Отсев резюме соискателей (blockResumes): до ИИ и правил колонок (pipeline.py L109–114). - if (settings.BlockResumes) - { - string? marker = FindResumeMarker(lower, settings.ResumeMarkers, settings.HireMarkers); - if (marker is not null) - { - return Fail(KindResume, string.Format(ReasonFormatResume, marker), marker); - } - } - - // Тип заявок «только вакансии»/«только разовые» — по маркерам найма (pipeline.py L115–123). - 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); - } - } - - // Проход: kind/kw — пустые строки, reason — null (форма stage1_plain L124). - return new IncomingRulesResult(Pass: true, Reason: null, Stage, KindPass, Kw: string.Empty); - } - - // Ищет маркер резюме в тексте с контекстным guard для слова «резюме» (pipeline.py L644–658). - // lower: Текст, приведённый к нижнему регистру (casefold). - // resumeMarkers: Нормализованные маркеры резюме (в порядке списка настроек). - // hireMarkers: Нормализованные маркеры найма (для guard). - // Возвращает: Найденный маркер (нормализованный) или null — текст не похож на резюме. - // Guard (pipeline.py L655): маркер «резюме» с маркером найма ДО него в тексте — это объявление - // работодателя («…вакансия…, присылайте резюме»), а не резюме соискателя — пропускаем маркер - // и продолжаем поиск по остальным (детерминированно, в порядке списка; python итерирует set — - // порядок произвольный). - 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; - } - - // Собирает вердикт-отсев: stage всегда 1, причина фиксированная. - // kind: Правило, сработавшее на тексте (length|stop|resume|type). - // reason: Причина для UI. - // Kw: Фраза/маркер, по которому текст отсечён (для мониторинга); иначе "" (как python). - // Возвращает: Результат этапа 1: pass=false. - private static IncomingRulesResult Fail( - string kind, - string reason, - string Kw) - { - return new IncomingRulesResult(Pass: false, Reason: reason, Stage, kind, Kw); - } - - // Читает эффективные настройки этапа-1 из типизированного снимка (Ruling 1, C30). - // settings: Снимок настроек тенанта (дефолты, перекрытые сохранёнными). - // Возвращает: Снимок значений для чистой проверки. - 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; - } - - // Нормализует маркеры для сравнения: trim + lowercase, пустые отбрасываются (pipeline.py L619–641). - // markers: Список маркеров как сохранён (или дефолт). - // Возвращает: Нормализованный список в порядке исходного (детерминированно). - 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; - } - - // Снимок эффективных настроек этапа-1 для чистой проверки текста. - // MinLen: Минимальная длина текста (minLen). - // StopPhrases: Стоп-фразы как сохранены (kw отдаётся исходным списком). - // BlockResumes: True — резюме соискателей отсекаем. - // ResumeMarkers: Нормализованные маркеры резюме (для FindResumeMarker). - // HireMarkers: Нормализованные маркеры найма (guard «резюме» + тип заявки). - // WantedType: Тип собираемых заявок (both|vacancy|freelance). - 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 (pipeline.py L94–124). +/// +/// +/// Детерминированные правила без ИИ поверх настроек тенанта (): +/// минимальная длина (minLen), стоп-фразы (stopPhrases), отсев резюме +/// (blockResumes + resumeMarkers), тип заявки (wantedType + маркеры найма +/// hireMarkers). Порядок правил и строки причин — 1:1 с stage1_plain: длина → стоп-фразы → +/// резюме → тип. Возврат — {pass, reason, stage, kind, kw}, где kw — +/// конкретная стоп-фраза/маркер (для тестера и мониторинга отсева этапа 4). Правила переиспользуются +/// пайплайном этапа 4 (Ruling 8). Эффективные значения = дефолты , перекрытые +/// сохранёнными (Ruling 1); повреждённые строки хранилища мягко трактуются как отсутствующие (как в +/// SettingsService/RatesService). +/// +/// Семантика чтения 1:1 с прототипом: текст тримится и сравнивается casefold-ом (в .NET — +/// ToLowerInvariant); у слова «резюме» есть контекстный guard: если ДО маркера встречается маркер найма +/// (hireMarkers) — это объявление работодателя («…вакансия…, присылайте резюме»), а не резюме соискателя, +/// его не отсекаем (pipeline._resume_reason L644–654). Маркеры найма/резюме нормализуются +/// (trim + lowercase), стоп-фразы сравниваются как сохранены (kw — исходный вид фразы). +/// +/// +public sealed class IncomingRules(ISettingsStore store) +{ + /// + /// Номер этапа пайплайна для результата правил (всегда 1 — без ИИ). + /// + public const int Stage = 1; + + /// + /// kind: текст короче minLen (pipeline.py L104). + /// + public const string KindLength = "length"; + + /// + /// kind: найдена стоп-фраза (pipeline.py L108). + /// + public const string KindStop = "stop"; + + /// + /// kind: текст опознан как резюме соискателя (pipeline.py L114). + /// + public const string KindResume = "resume"; + + /// + /// kind: тип текста не совпал с wantedType (pipeline.py L121/L123). + /// + public const string KindType = "type"; + + /// + /// kind на проходе — пустая строка (как в python-результате stage1_plain). + /// + public const string KindPass = ""; + + // Маркер резюме с контекстным guard («…вакансия…, присылайте резюме», pipeline.py L655). + private const string ResumeMarkerGuardWord = "резюме"; + + // wantedType: собираем и вакансии, и разовые заказы (дефолт). + private const string WantedTypeBoth = "both"; + + // wantedType: только вакансии/занятость. + private const string WantedTypeVacancy = "vacancy"; + + // wantedType: только разовые заказы/фриланс. + private const string WantedTypeFreelance = "freelance"; + + // Причина отсева по длине (pipeline.py L104). + private const string ReasonFormatLength = "короче {0} символов"; + + // Причина отсева по стоп-фразе (pipeline.py L108). + private const string ReasonFormatStop = "стоп-фраза «{0}»"; + + // Причина отсева резюме (pipeline.py L114). + private const string ReasonFormatResume = "резюме соискателя («{0}»)"; + + // Причина отсева типа при wantedType=freelance (pipeline.py L121). + private const string ReasonTypeFreelanceOnly = "ищете только разовые заявки — сообщение похоже на вакансию/занятость"; + + // Причина отсева типа при wantedType=vacancy (pipeline.py L123). + private const string ReasonTypeVacancyOnly = "ищете только занятость — сообщение похоже на разовый заказ/услугу"; + + /// + /// Проверяет текст этапом-1 фильтра по настройкам тенанта (pipeline.py stage1_plain L94–124). + /// + /// Текст сообщения; null/пустой трактуется как «короче minLen» (как (text or "").strip()). + /// Токен отмены. + /// Вердикт этапа 1: {pass, reason, stage:1, kind, kw}. + public async Task CheckAsync(string? text, CancellationToken ct) + { + // Читаем переопределения одним запросом (GetAllAsync) и считаем чистой функцией над снимком + // «дефолты + сохранённые» (Ruling 1): правила детерминированы, порядок чтения не влияет. + RulesSettings settings = ReadSettings(await TenantSettingsSnapshot.LoadAsync(store, ct)); + return Evaluate(text, settings); + } + + // Чистая проверка текста по снимку настроек (1:1 stage1_plain L101–124). + // text: Исходный текст сообщения (может быть null — как None из python). + // settings: Эффективные настройки этапа-1 (дефолты + переопределения). + // Возвращает: Вердикт: причина/правило/фраза либо проход. + 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(); + + // Стоп-фразы: подстрочное вхождение casefold-фразы; kw — фраза как сохранена (pipeline.py L106–108). + 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); + } + } + + // Отсев резюме соискателей (blockResumes): до ИИ и правил колонок (pipeline.py L109–114). + if (settings.BlockResumes) + { + string? marker = FindResumeMarker(lower, settings.ResumeMarkers, settings.HireMarkers); + if (marker is not null) + { + return Fail(KindResume, string.Format(ReasonFormatResume, marker), marker); + } + } + + // Тип заявок «только вакансии»/«только разовые» — по маркерам найма (pipeline.py L115–123). + 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); + } + } + + // Проход: kind/kw — пустые строки, reason — null (форма stage1_plain L124). + return new IncomingRulesResult(Pass: true, Reason: null, Stage, KindPass, Kw: string.Empty); + } + + // Ищет маркер резюме в тексте с контекстным guard для слова «резюме» (pipeline.py L644–658). + // lower: Текст, приведённый к нижнему регистру (casefold). + // resumeMarkers: Нормализованные маркеры резюме (в порядке списка настроек). + // hireMarkers: Нормализованные маркеры найма (для guard). + // Возвращает: Найденный маркер (нормализованный) или null — текст не похож на резюме. + // Guard (pipeline.py L655): маркер «резюме» с маркером найма ДО него в тексте — это объявление + // работодателя («…вакансия…, присылайте резюме»), а не резюме соискателя — пропускаем маркер + // и продолжаем поиск по остальным (детерминированно, в порядке списка; python итерирует set — + // порядок произвольный). + 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; + } + + // Собирает вердикт-отсев: stage всегда 1, причина фиксированная. + // kind: Правило, сработавшее на тексте (length|stop|resume|type). + // reason: Причина для UI. + // Kw: Фраза/маркер, по которому текст отсечён (для мониторинга); иначе "" (как python). + // Возвращает: Результат этапа 1: pass=false. + private static IncomingRulesResult Fail( + string kind, + string reason, + string Kw) + { + return new IncomingRulesResult(Pass: false, Reason: reason, Stage, kind, Kw); + } + + // Читает эффективные настройки этапа-1 из типизированного снимка (Ruling 1, C30). + // settings: Снимок настроек тенанта (дефолты, перекрытые сохранёнными). + // Возвращает: Снимок значений для чистой проверки. + 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; + } + + // Нормализует маркеры для сравнения: trim + lowercase, пустые отбрасываются (pipeline.py L619–641). + // markers: Список маркеров как сохранён (или дефолт). + // Возвращает: Нормализованный список в порядке исходного (детерминированно). + 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; + } + + // Снимок эффективных настроек этапа-1 для чистой проверки текста. + // MinLen: Минимальная длина текста (minLen). + // StopPhrases: Стоп-фразы как сохранены (kw отдаётся исходным списком). + // BlockResumes: True — резюме соискателей отсекаем. + // ResumeMarkers: Нормализованные маркеры резюме (для FindResumeMarker). + // HireMarkers: Нормализованные маркеры найма (guard «резюме» + тип заявки). + // WantedType: Тип собираемых заявок (both|vacancy|freelance). + 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/SettingKind.cs b/src/core/Deal.Modules.Settings/Application/Models/SettingKind.cs index 66ee22c..90f0fdf 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/SettingKind.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/SettingKind.cs @@ -1,7 +1,3 @@ -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; - namespace Deal.Modules.Settings.Application.Models; /// diff --git a/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs b/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs index ee3c091..4de4a50 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs @@ -1,285 +1,280 @@ -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; - -namespace Deal.Modules.Settings.Application.Models; - -/// -/// Дефолтные значения настроек тенанта (Ruling 1: снимок = дефолты, перекрытые сохранёнными). -/// -/// -/// Значения — из constants.DEFAULT_SETTINGS (constants.py L189–245) и связанных констант: -/// стоп-фразы L55, маркеры найма/грейдов/резюме L144–167, aiConfigs — для каждого провайдера -/// с первой моделью и пустым ключом. Хранилище держит только -/// переопределения; строки-дефолты в БД не пишутся. discPaused (рантайм-настройка, -/// дефолта в DEFAULT_SETTINGS нет) = false — как в GET /api/settings прототипа. -/// -public static class SettingsDefaults -{ - // ── Хранилище ── - - /// - /// Дефолт «autoArchive»: авто-архивация включена. - /// - public const bool AutoArchive = true; - - /// - /// Дефолт «archiveAfterDays»: 14 дней (кламп 1..30). - /// - public const int ArchiveAfterDays = 14; - - /// - /// Дефолт «archiveClearDays»: 90 дней хранения в архиве. - /// - public const int ArchiveClearDays = 90; - - /// - /// Дефолт «trashClearDays»: 7 дней хранения в корзине. - /// - public const int TrashClearDays = 7; - - // ── Фильтры входящих ── - - /// - /// Дефолт «minLen»: минимальная длина сообщения — 24 (constants.py L57, кламп 10..500). - /// - public const int MinLen = 24; - - /// - /// Дефолтные стоп-фразы (constants.py L55). - /// - public static readonly IReadOnlyList StopPhrases = new[] - { - "взаимный пиар", - "резюме", - "ищу работу", - "набор в команду", - }; - - /// - /// Дефолт «mlEnabled»: локальный ML-слой включён. - /// - public const bool MlEnabled = true; - - /// - /// Дефолт «aiEnabled»: полный выключатель ИИ — включён. - /// - public const bool AiEnabled = true; - - /// - /// Дефолт «aiFilterEnabled»: ИИ-фильтр входящих включён. - /// - public const bool AiFilterEnabled = true; - - /// - /// Дефолт «aiPrompt» — текст из DefaultPrompts (data.js L94–114). - /// - public static readonly string AiPrompt = DefaultPrompts.DefaultAiPrompt; - - /// - /// Дефолт «aiFilterPrompt» — текст из DefaultPrompts (data.js L125–141). - /// - public static readonly string AiFilterPrompt = DefaultPrompts.DefaultAiFilterPrompt; - - /// - /// Дефолт «cardPrompt» — текст из DefaultPrompts (data.js L116–123). - /// - public static readonly string CardPrompt = DefaultPrompts.DefaultCardPrompt; - - // ── Сфера и ключи ── - - /// - /// Дефолт «domainDescription»: описание сферы пустое. - /// - public const string DomainDescription = ""; - - /// - /// Дефолт «domainKeywords»: пустой список. - /// - public static readonly IReadOnlyList DomainKeywords = Array.Empty(); - - /// - /// Дефолтные маркеры найма (constants.py L144–149). - /// - public static readonly IReadOnlyList HireMarkers = new[] - { - "вакансия", "вакансию", "вакансии", "вакантна", "вакант", "нанимаем", "найм", "full-time", - "на постоянную", "занятость", "в офис", "официальное оформление", "пятидневка", - "грейд", "в компанию", "полная занятость", "на постоянную работу", "в команду", "нанимает", - "на постоянную основу", "в штат", - }; - - /// - /// Дефолтные термины грейдов/уровней (constants.py L151–154). - /// - public static readonly IReadOnlyList LevelTerms = new[] - { - "junior", "джун", "джуниор", "middle", "мидл", "mid", "senior", "сеньор", "сеньйор", - "lead", "тимлид", "тиэмлид", "architect", "архитектор", "стажёр", "стажер", "intern", "trainee", - }; - - /// - /// Дефолтные маркеры резюме соискателей (constants.py L163–167). - /// - public static readonly IReadOnlyList ResumeMarkers = new[] - { - "резюме", "#резюме", "моё резюме", "мое резюме", "ищу работу", "ищу вакансию", - "рассмотрю предложения", "готов к собеседованию", "в поиске работы", - "ищу проект", "ищу подработку", - }; - - /// - /// Дефолт «blockResumes»: резюме соискателей отсекаем. - /// - public const bool BlockResumes = true; - - // ── Глобальные исключения (стоп-уровень, §5.14) ── - - /// - /// Дефолт «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»: нижняя граница исключения по бюджету не задана (0). - /// - public const int ExcludeBudgetFrom = 0; - - /// - /// Дефолт «excludeBudgetTo»: верхняя граница исключения по бюджету не задана (0). - /// - 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»: ЦБ РФ (cbr | mock). - /// - public const string RateSource = "cbr"; - - /// - /// Дефолт «colState»: состояние колонок пустое (Ruling 9). - /// - public static readonly IReadOnlyDictionary ColState = new Dictionary(); - - // ── ИИ ── - - /// - /// Дефолт «aiProvider»: deepseek. - /// - public const string AiProvider = "deepseek"; - - /// - /// Дефолт «aiConfigs»: конфиг на каждого провайдера (пустой ключ, base, первая модель). - /// - public static readonly IReadOnlyDictionary AiConfigs = BuildDefaultAiConfigs(); - - // ── Telegram / Discovery ── - - /// - /// Дефолт «autoMonitorNew»: авто-мониторинг новых чатов включён. - /// - public const bool AutoMonitorNew = true; - - /// - /// Дефолт «discJoinLimit»: суточный лимит авто-вступлений — 50 (1..200). - /// - public const int DiscJoinLimit = 50; - - /// - /// Дефолт «discJoinDelayMin»: 50 сек (5..600). - /// - public const int DiscJoinDelayMin = 50; - - /// - /// Дефолт «discJoinDelayMax»: 70 сек (5..600). - /// - public const int DiscJoinDelayMax = 70; - - /// - /// Дефолт «discEvalSample»: 10 сообщений выборки (3..30). - /// - public const int DiscEvalSample = 10; - - /// - /// Дефолт «discEvalThreshold»: 40% подходящих сообщений (1..100). - /// - public const int DiscEvalThreshold = 40; - - /// - /// Дефолт «discPaused»: авто-вступления не на паузе (рантайм, в DEFAULT_SETTINGS нет). - /// - public const bool DiscPaused = false; - - // Собирает дефолтный «aiConfigs» из списка провайдеров (семантика constants.py L231–234). - // Возвращает: Словарь: id провайдера → конфиг с пустым ключом, базовым URL и первой моделью. - 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; + +/// +/// Дефолтные значения настроек тенанта (Ruling 1: снимок = дефолты, перекрытые сохранёнными). +/// +/// +/// Значения — из constants.DEFAULT_SETTINGS (constants.py L189–245) и связанных констант: +/// стоп-фразы L55, маркеры найма/грейдов/резюме L144–167, aiConfigs — для каждого провайдера +/// с первой моделью и пустым ключом. Хранилище держит только +/// переопределения; строки-дефолты в БД не пишутся. discPaused (рантайм-настройка, +/// дефолта в DEFAULT_SETTINGS нет) = false — как в GET /api/settings прототипа. +/// +public static class SettingsDefaults +{ + // ── Хранилище ── + + /// + /// Дефолт «autoArchive»: авто-архивация включена. + /// + public const bool AutoArchive = true; + + /// + /// Дефолт «archiveAfterDays»: 14 дней (кламп 1..30). + /// + public const int ArchiveAfterDays = 14; + + /// + /// Дефолт «archiveClearDays»: 90 дней хранения в архиве. + /// + public const int ArchiveClearDays = 90; + + /// + /// Дефолт «trashClearDays»: 7 дней хранения в корзине. + /// + public const int TrashClearDays = 7; + + // ── Фильтры входящих ── + + /// + /// Дефолт «minLen»: минимальная длина сообщения — 24 (constants.py L57, кламп 10..500). + /// + public const int MinLen = 24; + + /// + /// Дефолтные стоп-фразы (constants.py L55). + /// + public static readonly IReadOnlyList StopPhrases = new[] + { + "взаимный пиар", + "резюме", + "ищу работу", + "набор в команду", + }; + + /// + /// Дефолт «mlEnabled»: локальный ML-слой включён. + /// + public const bool MlEnabled = true; + + /// + /// Дефолт «aiEnabled»: полный выключатель ИИ — включён. + /// + public const bool AiEnabled = true; + + /// + /// Дефолт «aiFilterEnabled»: ИИ-фильтр входящих включён. + /// + public const bool AiFilterEnabled = true; + + /// + /// Дефолт «aiPrompt» — текст из DefaultPrompts (data.js L94–114). + /// + public static readonly string AiPrompt = DefaultPrompts.DefaultAiPrompt; + + /// + /// Дефолт «aiFilterPrompt» — текст из DefaultPrompts (data.js L125–141). + /// + public static readonly string AiFilterPrompt = DefaultPrompts.DefaultAiFilterPrompt; + + /// + /// Дефолт «cardPrompt» — текст из DefaultPrompts (data.js L116–123). + /// + public static readonly string CardPrompt = DefaultPrompts.DefaultCardPrompt; + + // ── Сфера и ключи ── + + /// + /// Дефолт «domainDescription»: описание сферы пустое. + /// + public const string DomainDescription = ""; + + /// + /// Дефолт «domainKeywords»: пустой список. + /// + public static readonly IReadOnlyList DomainKeywords = Array.Empty(); + + /// + /// Дефолтные маркеры найма (constants.py L144–149). + /// + public static readonly IReadOnlyList HireMarkers = new[] + { + "вакансия", "вакансию", "вакансии", "вакантна", "вакант", "нанимаем", "найм", "full-time", + "на постоянную", "занятость", "в офис", "официальное оформление", "пятидневка", + "грейд", "в компанию", "полная занятость", "на постоянную работу", "в команду", "нанимает", + "на постоянную основу", "в штат", + }; + + /// + /// Дефолтные термины грейдов/уровней (constants.py L151–154). + /// + public static readonly IReadOnlyList LevelTerms = new[] + { + "junior", "джун", "джуниор", "middle", "мидл", "mid", "senior", "сеньор", "сеньйор", + "lead", "тимлид", "тиэмлид", "architect", "архитектор", "стажёр", "стажер", "intern", "trainee", + }; + + /// + /// Дефолтные маркеры резюме соискателей (constants.py L163–167). + /// + public static readonly IReadOnlyList ResumeMarkers = new[] + { + "резюме", "#резюме", "моё резюме", "мое резюме", "ищу работу", "ищу вакансию", + "рассмотрю предложения", "готов к собеседованию", "в поиске работы", + "ищу проект", "ищу подработку", + }; + + /// + /// Дефолт «blockResumes»: резюме соискателей отсекаем. + /// + public const bool BlockResumes = true; + + // ── Глобальные исключения (стоп-уровень, §5.14) ── + + /// + /// Дефолт «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»: нижняя граница исключения по бюджету не задана (0). + /// + public const int ExcludeBudgetFrom = 0; + + /// + /// Дефолт «excludeBudgetTo»: верхняя граница исключения по бюджету не задана (0). + /// + 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»: ЦБ РФ (cbr | mock). + /// + public const string RateSource = "cbr"; + + /// + /// Дефолт «colState»: состояние колонок пустое (Ruling 9). + /// + public static readonly IReadOnlyDictionary ColState = new Dictionary(); + + // ── ИИ ── + + /// + /// Дефолт «aiProvider»: deepseek. + /// + public const string AiProvider = "deepseek"; + + /// + /// Дефолт «aiConfigs»: конфиг на каждого провайдера (пустой ключ, base, первая модель). + /// + public static readonly IReadOnlyDictionary AiConfigs = BuildDefaultAiConfigs(); + + // ── Telegram / Discovery ── + + /// + /// Дефолт «autoMonitorNew»: авто-мониторинг новых чатов включён. + /// + public const bool AutoMonitorNew = true; + + /// + /// Дефолт «discJoinLimit»: суточный лимит авто-вступлений — 50 (1..200). + /// + public const int DiscJoinLimit = 50; + + /// + /// Дефолт «discJoinDelayMin»: 50 сек (5..600). + /// + public const int DiscJoinDelayMin = 50; + + /// + /// Дефолт «discJoinDelayMax»: 70 сек (5..600). + /// + public const int DiscJoinDelayMax = 70; + + /// + /// Дефолт «discEvalSample»: 10 сообщений выборки (3..30). + /// + public const int DiscEvalSample = 10; + + /// + /// Дефолт «discEvalThreshold»: 40% подходящих сообщений (1..100). + /// + public const int DiscEvalThreshold = 40; + + /// + /// Дефолт «discPaused»: авто-вступления не на паузе (рантайм, в DEFAULT_SETTINGS нет). + /// + public const bool DiscPaused = false; + + // Собирает дефолтный «aiConfigs» из списка провайдеров (семантика constants.py L231–234). + // Возвращает: Словарь: id провайдера → конфиг с пустым ключом, базовым URL и первой моделью. + 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/SettingsKeys.cs b/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs index a4079c7..de1b96a 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs @@ -1,7 +1,3 @@ -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; - namespace Deal.Modules.Settings.Application.Models; /// diff --git a/src/core/Deal.Modules.Settings/Application/Models/TenantSettingsSnapshot.cs b/src/core/Deal.Modules.Settings/Application/Models/TenantSettingsSnapshot.cs index 20916cf..83af369 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/TenantSettingsSnapshot.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/TenantSettingsSnapshot.cs @@ -1,253 +1,250 @@ -using System.Globalization; -using System.Text.Json; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; - -namespace Deal.Modules.Settings.Application.Models; - -/// -/// Типизированный снимок настроек тенанта (C30: один читатель вместо копий GetAsync+Parse+дефолт). -/// -/// -/// Все переопределения хранилища читаются ОДНИМ (Ruling 1: хранилище -/// держит только переопределения; дефолты — ), значения отдаются строго типизировано -/// через методы по категориям JSON (bool/int/string/string-list). Снимок неизменяем и рассчитан на одну операцию -/// сервиса-читателя (pump/тик/запрос): семантика мягкого чтения единая и совпадает с каноном SettingsService -/// (GET /api/settings): повреждённые строки и значения «не того» JSON-вида трактуются как отсутствующие → дефолт. -/// Семантические особенности конкретных ключей (нормализация wantedType в нижний регистр, targetCurrency в -/// верхний, клампы диапазонов у калл-сайтов) остаются за потребителями — здесь только чтение. -/// -/// Расхождения копий, сведённые к единой семантике (описаны в ответе C30): целые принимают JSON-число И числовую -/// строку (python int(...)), 0 — валидное значение (не «дефолт»); строковые списки принимают JSON-массив -/// и одиночную JSON-строку (python: [x], pipeline.py L620–623/fill_prompt); булевы — только JSON true/false -/// (план Task 3: строки не «питон-булеватся»). -/// -/// -public sealed class TenantSettingsSnapshot -{ - // Опции JSON кэша курсов: camelCase + терпимость регистра (форма ratesCache, Ruling 6). - 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); - } - - /// - /// Булева настройка: только JSON true/false (план Task 3), иначе дефолт. - /// - /// Ключ настройки (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; - } - - /// - /// Целочисленная настройка: JSON-число или числовая строка (python int(...), как - /// SettingsService.TryReadInt); 0 — валидное значение. Отсутствие/повреждение/нечисловое → дефолт. - /// - /// Ключ настройки (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; - } - - /// - /// Длинная целочисленная настройка (счётчики вне int32, например discFloodDay): JSON-число или - /// числовая строка; отсутствие/повреждение/нечисловое → дефолт. - /// - /// Ключ настройки (числовой JSON). - /// Дефолт (0 для внутренних счётчиков). - /// Значение настройки либо дефолт. - public long GetLong(string key, long defaultValue) - { - return TryReadInt64(key, out long wide) ? wide : defaultValue; - } - - /// - /// Строковая настройка: только JSON-строка (пустая строка — валидное значение); отсутствие/ - /// повреждение/другой JSON-вид → дефолт. Нормализация (регистр/trim) — за калл-сайтом. - /// - /// Ключ настройки (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; - } - - /// - /// Список-настройка строк: JSON-массив → элементы-строки (пустой сохранённый массив остаётся - /// пустым — это не дефолт); одиночная JSON-строка → список из одного (python: raw = [raw]); - /// отсутствие/повреждение/другой JSON-вид → дефолт. - /// - /// Ключ настройки (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 (Ruling 6): типизированное значение или null. - /// - /// Кэш {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; + +/// +/// Типизированный снимок настроек тенанта (C30: один читатель вместо копий GetAsync+Parse+дефолт). +/// +/// +/// Все переопределения хранилища читаются ОДНИМ (Ruling 1: хранилище +/// держит только переопределения; дефолты — ), значения отдаются строго типизировано +/// через методы по категориям JSON (bool/int/string/string-list). Снимок неизменяем и рассчитан на одну операцию +/// сервиса-читателя (pump/тик/запрос): семантика мягкого чтения единая и совпадает с каноном SettingsService +/// (GET /api/settings): повреждённые строки и значения «не того» JSON-вида трактуются как отсутствующие → дефолт. +/// Семантические особенности конкретных ключей (нормализация wantedType в нижний регистр, targetCurrency в +/// верхний, клампы диапазонов у калл-сайтов) остаются за потребителями — здесь только чтение. +/// +/// Расхождения копий, сведённые к единой семантике (описаны в ответе C30): целые принимают JSON-число И числовую +/// строку (python int(...)), 0 — валидное значение (не «дефолт»); строковые списки принимают JSON-массив +/// и одиночную JSON-строку (python: [x], pipeline.py L620–623/fill_prompt); булевы — только JSON true/false +/// (план Task 3: строки не «питон-булеватся»). +/// +/// +public sealed class TenantSettingsSnapshot +{ + // Опции JSON кэша курсов: camelCase + терпимость регистра (форма ratesCache, Ruling 6). + 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); + } + + /// + /// Булева настройка: только JSON true/false (план Task 3), иначе дефолт. + /// + /// Ключ настройки (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; + } + + /// + /// Целочисленная настройка: JSON-число или числовая строка (python int(...), как + /// SettingsService.TryReadInt); 0 — валидное значение. Отсутствие/повреждение/нечисловое → дефолт. + /// + /// Ключ настройки (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; + } + + /// + /// Длинная целочисленная настройка (счётчики вне int32, например discFloodDay): JSON-число или + /// числовая строка; отсутствие/повреждение/нечисловое → дефолт. + /// + /// Ключ настройки (числовой JSON). + /// Дефолт (0 для внутренних счётчиков). + /// Значение настройки либо дефолт. + public long GetLong(string key, long defaultValue) + { + return TryReadInt64(key, out long wide) ? wide : defaultValue; + } + + /// + /// Строковая настройка: только JSON-строка (пустая строка — валидное значение); отсутствие/ + /// повреждение/другой JSON-вид → дефолт. Нормализация (регистр/trim) — за калл-сайтом. + /// + /// Ключ настройки (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; + } + + /// + /// Список-настройка строк: JSON-массив → элементы-строки (пустой сохранённый массив остаётся + /// пустым — это не дефолт); одиночная JSON-строка → список из одного (python: raw = [raw]); + /// отсутствие/повреждение/другой JSON-вид → дефолт. + /// + /// Ключ настройки (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 (Ruling 6): типизированное значение или null. + /// + /// Кэш {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 757e3c5..c24e2ee 100644 --- a/src/core/Deal.Modules.Settings/Application/Registrars/SettingsModuleRegistrar.cs +++ b/src/core/Deal.Modules.Settings/Application/Registrars/SettingsModuleRegistrar.cs @@ -1,36 +1,35 @@ -using Microsoft.Extensions.DependencyInjection; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Services; - -namespace Deal.Modules.Settings.Application.Registrars; - -/// -/// DI-регистрация модуля Settings. Паттерн «port & adapter» (Ruling 1). -/// -/// -/// Регистрируются только сервисы модуля. Порт-адаптеры (ISettingsStore, ISecretCipher) -/// реализованы в Deal.Infrastructure и регистрируются там (AddDealPersistence/AddDealSecurity) — -/// модуль не знает про EF и шифрование. Регистрация расширяется по мере появления сервисов модуля. -/// -public static class SettingsModuleRegistrar -{ - /// - /// Регистрирует сервисы модуля Settings в контейнере. - /// - /// Коллекция сервисов. - /// Коллекция сервисов для цепочки вызовов. - /// - /// Время жизни: SettingsService/RatesService/IncomingRules — scoped, потому что их зависимость ISettingsStore - /// реализована на EF-контексте со scoped-жизнью, и сервисы должны жить в рамках запроса. - /// Порт IRatesSource (Task 8) регистрируется HTTP-адаптером в Deal.Api (AddHttpClient) — - /// модуль не знает про сеть. - /// - 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. Паттерн «port & adapter» (Ruling 1). +/// +/// +/// Регистрируются только сервисы модуля. Порт-адаптеры (ISettingsStore, ISecretCipher) +/// реализованы в Deal.Infrastructure и регистрируются там (AddDealPersistence/AddDealSecurity) — +/// модуль не знает про EF и шифрование. Регистрация расширяется по мере появления сервисов модуля. +/// +public static class SettingsModuleRegistrar +{ + /// + /// Регистрирует сервисы модуля Settings в контейнере. + /// + /// Коллекция сервисов. + /// Коллекция сервисов для цепочки вызовов. + /// + /// Время жизни: SettingsService/RatesService/IncomingRules — scoped, потому что их зависимость ISettingsStore + /// реализована на EF-контексте со scoped-жизнью, и сервисы должны жить в рамках запроса. + /// Порт IRatesSource (Task 8) регистрируется HTTP-адаптером в Deal.Api (AddHttpClient) — + /// модуль не знает про сеть. + /// + 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/MockRates.cs b/src/core/Deal.Modules.Settings/Application/Services/MockRates.cs index b6d1a5a..b009ba0 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/MockRates.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/MockRates.cs @@ -1,7 +1,3 @@ -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; - namespace Deal.Modules.Settings.Application.Services; /// diff --git a/src/core/Deal.Modules.Settings/Application/Services/PromptFiller.cs b/src/core/Deal.Modules.Settings/Application/Services/PromptFiller.cs index 31abb38..95e8ed6 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/PromptFiller.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/PromptFiller.cs @@ -1,7 +1,3 @@ -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; - namespace Deal.Modules.Settings.Application.Services; /// diff --git a/src/core/Deal.Modules.Settings/Application/Services/RatesService.cs b/src/core/Deal.Modules.Settings/Application/Services/RatesService.cs index 37e42be..36ff2e5 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/RatesService.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/RatesService.cs @@ -1,210 +1,209 @@ -using System.Text.Json; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Registrars; - -namespace Deal.Modules.Settings.Application.Services; - -/// -/// Курсы валют: кэш в tenant-настройке ratesCache, источник по rateSource (Ruling 6, Task 8). -/// -/// -/// Референс — backend/app/services/rates.py целиком (Ruling 6 L83–89, план Task 8 L308–331). Кэш — -/// внутренний KV-ключ (Ruling 1): значения {rates, source, updatedAtMs} -/// пишутся через в таблицу settings тенанта; дефолт кэша (нет строки) — -/// мок-курсы с source "mock" и null updatedAt. Обновление — не чаще раза -/// в 6 часов (): ленивый запуск на GET (HTTP-слой), синхронно на -/// POST /rates/refresh, фоново при PATCH rateSource. Мок-режим (rateSource="mock") не ходит по сети: -/// RefreshAsync просто сохраняет константу. Любая иная настройка источника трактуется как cbr -/// (семантика прототипа «не mock — ЦБ»), при сбое ЦБ кэш не трогается и RefreshAsync возвращает false. -/// Повреждённые строки хранилища (rateSource/ratesCache) мягко трактуются как отсутствующие — как в -/// SettingsService. — чистая функция. После успешного обновления кэша -/// RefreshAsync оповещает слушателей (Ruling 7): пересчёт конверсий -/// карточек выполняет реализация порта в модуле Kanban (ConversionRecomputer), здесь их список — пустой no-op. -/// -/// KV-хранилище настроек тенанта (таблица settings). -/// Порт источника курсов (ЦБ РФ); не используется в мок-режиме. -/// Слушатели смены курсов (пересчёт карточек, Ruling 7); пусто — 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"; - - // Опции JSON: camelCase для записей в ratesCache (1:1 с формой Ruling 6). - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - /// - /// Текущее состояние кэша курсов (тело GET /api/rates). - /// - /// Токен отмены. - /// 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 (POST /rates/refresh, фон). - /// - /// Токен отмены. - /// 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; - } - - // Не-mock источник: запрос к ЦБ; сбой порта (null) — кэш не трогаем (rates.py L69–72). - 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; - } - - // Оповещает слушателей о смене курсов ПОСЛЕ успешной записи кэша (Ruling 7, план Task 12). - // ct: Токен отмены. - // Вызов синхронный в рамках refresh (как recompute_conversions после save_rates в rates.py L62–74); - // пустой список — no-op. При сбое источника (кэш не записан) слушатели НЕ вызываются. - private async Task NotifyRatesChangedAsync(CancellationToken ct) - { - foreach (IRatesChangedListener listener in listeners) - { - await listener.OnRatesChangedAsync(fullRecompute: true, ct); - } - } - - /// - /// Нужно ли обновить кэш: нет кэша / сменился источник / прошло ≥6 часов (rates.py L77–83). - /// - /// Токен отмены. - /// True — GET /api/rates должен запустить фоновое обновление (Ruling 6). - /// - /// Сравнивается НОРМАЛИЗОВАННЫЙ источник: значение настройки приводится к mock|cbr (не-mock → cbr), - /// поэтому произвольная строка в rateSource (ручное вмешательство в БД) не вызывает обновление - /// на каждом GET. Расхождение «кэш mock / настройка cbr» (и обратное) трактуется как смена источника. - /// - 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; - } - - /// - /// Конвертирует сумму из одной валюты в другую по курсам к рублю (rates.py L86–103). - /// - /// Сумма в исходной валюте; null → null (поле бюджета не заполнено). - /// Код исходной валюты (RUB/USD/EUR/USDT/…). - /// Код целевой валюты. - /// Курсы к рублю (кэш ratesCache или мок-курсы). - /// Сумма в целевой валюте, округлённая до 2 знаков, или null при отсутствии валюты в курсах. - /// - /// Чистая функция (Ruling 6: пересчёт карточек — этап 3): чтение кэша остаётся за вызывающим. - /// USDT приравнивается к USD — у ЦБ нет тикера USDT (rates.py L86–91); если USD в курсах нет, - /// используется собственный курс USDT (если присутствует). Округление — банковское (как round в python). - /// - 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); - } - - // Нормализует значение настройки источника к известным source кэша (mock|cbr). - // setting: Значение настройки rateSource. - // Возвращает: mock, если настройка — ровно «mock»; иначе cbr (семантика прототипа). - private static string NormalizeSource(string setting) => setting == MockRateSource ? MockSource : CbrSource; - - // Текущее время в Unix-миллисекундах (UTC), как в прототипе (rates.py L39). - // Возвращает: Количество миллисекунд с 1970-01-01 UTC. - 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 (Ruling 6, Task 8). +/// +/// +/// Референс — backend/app/services/rates.py целиком (Ruling 6 L83–89, план Task 8 L308–331). Кэш — +/// внутренний KV-ключ (Ruling 1): значения {rates, source, updatedAtMs} +/// пишутся через в таблицу settings тенанта; дефолт кэша (нет строки) — +/// мок-курсы с source "mock" и null updatedAt. Обновление — не чаще раза +/// в 6 часов (): ленивый запуск на GET (HTTP-слой), синхронно на +/// POST /rates/refresh, фоново при PATCH rateSource. Мок-режим (rateSource="mock") не ходит по сети: +/// RefreshAsync просто сохраняет константу. Любая иная настройка источника трактуется как cbr +/// (семантика прототипа «не mock — ЦБ»), при сбое ЦБ кэш не трогается и RefreshAsync возвращает false. +/// Повреждённые строки хранилища (rateSource/ratesCache) мягко трактуются как отсутствующие — как в +/// SettingsService. — чистая функция. После успешного обновления кэша +/// RefreshAsync оповещает слушателей (Ruling 7): пересчёт конверсий +/// карточек выполняет реализация порта в модуле Kanban (ConversionRecomputer), здесь их список — пустой no-op. +/// +/// KV-хранилище настроек тенанта (таблица settings). +/// Порт источника курсов (ЦБ РФ); не используется в мок-режиме. +/// Слушатели смены курсов (пересчёт карточек, Ruling 7); пусто — 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"; + + // Опции JSON: camelCase для записей в ratesCache (1:1 с формой Ruling 6). + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + /// + /// Текущее состояние кэша курсов (тело GET /api/rates). + /// + /// Токен отмены. + /// 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 (POST /rates/refresh, фон). + /// + /// Токен отмены. + /// 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; + } + + // Не-mock источник: запрос к ЦБ; сбой порта (null) — кэш не трогаем (rates.py L69–72). + 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; + } + + // Оповещает слушателей о смене курсов ПОСЛЕ успешной записи кэша (Ruling 7, план Task 12). + // ct: Токен отмены. + // Вызов синхронный в рамках refresh (как recompute_conversions после save_rates в rates.py L62–74); + // пустой список — no-op. При сбое источника (кэш не записан) слушатели НЕ вызываются. + private async Task NotifyRatesChangedAsync(CancellationToken ct) + { + foreach (IRatesChangedListener listener in listeners) + { + await listener.OnRatesChangedAsync(fullRecompute: true, ct); + } + } + + /// + /// Нужно ли обновить кэш: нет кэша / сменился источник / прошло ≥6 часов (rates.py L77–83). + /// + /// Токен отмены. + /// True — GET /api/rates должен запустить фоновое обновление (Ruling 6). + /// + /// Сравнивается НОРМАЛИЗОВАННЫЙ источник: значение настройки приводится к mock|cbr (не-mock → cbr), + /// поэтому произвольная строка в rateSource (ручное вмешательство в БД) не вызывает обновление + /// на каждом GET. Расхождение «кэш mock / настройка cbr» (и обратное) трактуется как смена источника. + /// + 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; + } + + /// + /// Конвертирует сумму из одной валюты в другую по курсам к рублю (rates.py L86–103). + /// + /// Сумма в исходной валюте; null → null (поле бюджета не заполнено). + /// Код исходной валюты (RUB/USD/EUR/USDT/…). + /// Код целевой валюты. + /// Курсы к рублю (кэш ratesCache или мок-курсы). + /// Сумма в целевой валюте, округлённая до 2 знаков, или null при отсутствии валюты в курсах. + /// + /// Чистая функция (Ruling 6: пересчёт карточек — этап 3): чтение кэша остаётся за вызывающим. + /// USDT приравнивается к USD — у ЦБ нет тикера USDT (rates.py L86–91); если USD в курсах нет, + /// используется собственный курс USDT (если присутствует). Округление — банковское (как round в python). + /// + 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); + } + + // Нормализует значение настройки источника к известным source кэша (mock|cbr). + // setting: Значение настройки rateSource. + // Возвращает: mock, если настройка — ровно «mock»; иначе cbr (семантика прототипа). + private static string NormalizeSource(string setting) => setting == MockRateSource ? MockSource : CbrSource; + + // Текущее время в Unix-миллисекундах (UTC), как в прототипе (rates.py L39). + // Возвращает: Количество миллисекунд с 1970-01-01 UTC. + 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 0e076cc..082f536 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchMyPrompts.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchMyPrompts.cs @@ -1,84 +1,82 @@ -using System.Security.Cryptography; -using System.Text.Json; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Registrars; - -namespace Deal.Modules.Settings.Application.Services; - -// Часть SettingsService: PATCH-ключ myPrompts — чистка и сериализация «Моих промптов» (SerializeCleanMyPrompts -// и чтение полей элемента: ReadTrimmedField/ReadPromptId). colState — passthrough в главном ApplyPatchAsync. -public sealed partial class SettingsService -{ - // Чистит «Мои промпты» и сериализует для хранения (L143–160): ≤100, name/prompt обязательны. - // array: JSON-массив из тела PATCH. - // Возвращает: JSON-строка списка {id,name,description,prompt} (camelCase). - 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 +{ + // Чистит «Мои промпты» и сериализует для хранения (L143–160): ≤100, name/prompt обязательны. + // array: JSON-массив из тела PATCH. + // Возвращает: JSON-строка списка {id,name,description,prompt} (camelCase). + 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 07b5ad4..bbb41b3 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs @@ -1,149 +1,147 @@ -using System.Text.Json; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Registrars; - -namespace Deal.Modules.Settings.Application.Services; - -// Часть SettingsService: применение скалярных PATCH-ключей — пары пауз авто-вступлений (PrepareDelayClamps), -// int/string-ключи и списки строк (ApplyIntKey/ApplyStringKey/SerializeStringList). -public sealed partial class SettingsService -{ - // Клампы пары/одиночных концов интервала авто-вступлений (референс L78–109). - // body: Тело PATCH (не мутируется). - // overrides: Сохранённые переопределения (для «другого конца» интервала). - // Возвращает: Ключ → уже склампированное значение; ключи в результате пропускаются общим циклом. - 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); - } - - // Сериализует список строк для хранения: элементы приводятся к строке, срез 200 (L137–139). - // array: JSON-массив из тела PATCH. - // Возвращает: JSON-строка массива строк. - 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 +{ + // Клампы пары/одиночных концов интервала авто-вступлений (референс L78–109). + // body: Тело PATCH (не мутируется). + // overrides: Сохранённые переопределения (для «другого конца» интервала). + // Возвращает: Ключ → уже склампированное значение; ключи в результате пропускаются общим циклом. + 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); + } + + // Сериализует список строк для хранения: элементы приводятся к строке, срез 200 (L137–139). + // array: JSON-массив из тела PATCH. + // Возвращает: JSON-строка массива строк. + 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 45ca9e6..4c0fe6c 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchSecrets.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchSecrets.cs @@ -1,110 +1,108 @@ -using System.Text.Json; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Registrars; - -namespace Deal.Modules.Settings.Application.Services; - -// Часть SettingsService: PATCH-ключ секрета aiConfigs (ApplyAiConfigsKey, SSRF-гейт baseUrl, шифрование -// apiKey), включая удаление переопределения, вернувшегося к дефолту (EqualsDefault). -public sealed partial class SettingsService -{ - // Применяет ключ aiConfigs (L161–175): только существующие провайдеры; apiKey ≥8 → шифруется. - // value: JSON-объект {id провайдера → конфиг} из тела PATCH. - // overrides: Сохранённые переопределения. - // writes: Накопитель записей (key → valueJson). - // removals: Список ключей на удаление (переопределение вернулось к дефолту). - 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()) - { - // Неизвестный провайдер (нет в каталоге) — пропуск, как `pid not in cfg` прототипа. - 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 +{ + // Применяет ключ aiConfigs (L161–175): только существующие провайдеры; apiKey ≥8 → шифруется. + // value: JSON-объект {id провайдера → конфиг} из тела PATCH. + // overrides: Сохранённые переопределения. + // writes: Накопитель записей (key → valueJson). + // removals: Список ключей на удаление (переопределение вернулось к дефолту). + 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()) + { + // Неизвестный провайдер (нет в каталоге) — пропуск, как `pid not in cfg` прототипа. + 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 2596e0e..65cc140 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PublicForms.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PublicForms.cs @@ -1,37 +1,34 @@ -using System.Text.Json; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Registrars; - -namespace Deal.Modules.Settings.Application.Services; - -// Часть SettingsService: публичные формы секретов — маски и флаги наружу (Ruling 3), ToPublic/MaskSecret. -public sealed partial class SettingsService -{ - // Публичная форма конфигурации провайдера: ключ расшифровывается и маскируется (Ruling 3). - // config: Сохранённая конфигурация (apiKey — enc: или пустая строка). - // Возвращает: DTO с флагом keySet и маской keyMasked. - 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)); - } - - // Маска СЕКРЕТА (apiKey ИИ): всегда скрывает, кроме пустого — короткие значения не раскрываются - // (Security review: echo-маска ключа наружу). Длина ≤ 8 → «x…», иначе «1234…5678» (как ai.py mask_key). - // 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 Deal.Modules.Settings.Application.Models; + +namespace Deal.Modules.Settings.Application.Services; + +// Часть SettingsService: публичные формы секретов — маски и флаги наружу (Ruling 3), ToPublic/MaskSecret. +public sealed partial class SettingsService +{ + // Публичная форма конфигурации провайдера: ключ расшифровывается и маскируется (Ruling 3). + // config: Сохранённая конфигурация (apiKey — enc: или пустая строка). + // Возвращает: DTO с флагом keySet и маской keyMasked. + 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)); + } + + // Маска СЕКРЕТА (apiKey ИИ): всегда скрывает, кроме пустого — короткие значения не раскрываются + // (Security review: echo-маска ключа наружу). Длина ≤ 8 → «x…», иначе «1234…5678» (как ai.py mask_key). + // 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.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs index 8be54cf..f2719cd 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs @@ -1,270 +1,268 @@ -using System.Globalization; -using System.Text.Json; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Registrars; - -namespace Deal.Modules.Settings.Application.Services; - -// Часть SettingsService: общие хелперы чтения/слияния снимка (Ruling 1) — LoadStoredOverridesAsync, -// TryRead* и Merge* по категориям ключей (канон мягкого чтения для GET /api/settings). -public sealed partial class SettingsService -{ - // Читает все сохранённые переопределения как распарсенные JSON-значения (Ruling 1). - // ct: Токен отмены. - // Возвращает: Словарь: ключ → JsonElement (клоны, живут после вызова). Повреждённые JSON-строки пропускаются. - 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; - } - - // Читает целое: JSON-число или числовая строка (семантика int(...) прототипа). - // element: JSON-значение. - // value: Прочитанное число (диапазон long сжимается до int). - // Возвращает: True — значение прочитано; иначе False (нечисловое — пропуск ключа). - 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; - } - - // Читает булево: только JSON True/False (строки не конвертируются — план Task 3). - // element: JSON-значение. - // value: Прочитанное значение. - // Возвращает: True — значение вида true/false. - 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; - } - - // Читает скаляр как строку: строка/число/булево (семантика str(...) прототипа). - // element: JSON-значение. - // text: Текст; null — если значение не скаляр (объект/массив/null). - // Возвращает: True — значение скалярное. - 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; + +// Часть SettingsService: общие хелперы чтения/слияния снимка (Ruling 1) — LoadStoredOverridesAsync, +// TryRead* и Merge* по категориям ключей (канон мягкого чтения для GET /api/settings). +public sealed partial class SettingsService +{ + // Читает все сохранённые переопределения как распарсенные JSON-значения (Ruling 1). + // ct: Токен отмены. + // Возвращает: Словарь: ключ → JsonElement (клоны, живут после вызова). Повреждённые JSON-строки пропускаются. + 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; + } + + // Читает целое: JSON-число или числовая строка (семантика int(...) прототипа). + // element: JSON-значение. + // value: Прочитанное число (диапазон long сжимается до int). + // Возвращает: True — значение прочитано; иначе False (нечисловое — пропуск ключа). + 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; + } + + // Читает булево: только JSON True/False (строки не конвертируются — план Task 3). + // element: JSON-значение. + // value: Прочитанное значение. + // Возвращает: True — значение вида true/false. + 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; + } + + // Читает скаляр как строку: строка/число/булево (семантика str(...) прототипа). + // element: JSON-значение. + // text: Текст; null — если значение не скаляр (объект/массив/null). + // Возвращает: True — значение скалярное. + 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 9be3122..372358b 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs @@ -1,324 +1,321 @@ -using System.Globalization; -using System.Security.Cryptography; -using System.Text.Json; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Registrars; - -namespace Deal.Modules.Settings.Application.Services; - -/// -/// Сервис настроек тенанта: public-снимок (GET) и частичное обновление (PATCH-семантика 1:1). -/// -/// -/// Референс — backend/app/routers/settings_routes.py L75–192 (Ruling 1/3/9/10). Сервис чистый: -/// работает поверх порта (хранилище держит только переопределения, -/// дефолты — в ) и (секрет aiConfigs -/// в БД шифруется, наружу — маска/флаг). Снимок = дефолты, перекрытые сохранёнными значениями; -/// внутренние ключи (ratesCache и др.) в снимок не входят (Ruling 1). Ответ PATCH — полный -/// снимок после применения (фронт затирает локальный state ответом, api-map L147/L341). -/// Мягкая семантика: невалидное поле PATCH просто не применяется, исключений нет (Task 5). -/// Расхождение с прототипом (намеренное, по плану Task 3): Bool-ключи принимают только JSON-булево -/// (строки не «питон-булеватся»); неизвестные/внутренние ключи игнорируются. -/// -/// KV-хранилище настроек тенанта (таблица settings). -/// Шифр секретов (T1: формат enc:, сбой расшифровки → ""). -/// Слушатели смены настроек конверсии (пересчёт карточек, Ruling 7); пусто — no-op. -public sealed partial class SettingsService( - ISettingsStore store, - ISecretCipher secretCipher, - IEnumerable listeners) -{ - // ── Константы (референс settings_routes.py L28–32, L80–127, L143–185) ── - - // Префикс зашифрованного значения в БД (Ruling 2). - 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; - - // Клампы целочисленных ключей: имя ключа → диапазон [min..max] (L80–127). - 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); - - // Статический список провайдеров для public-снимка (без внутреннего api_style, Ruling 3). - private static readonly IReadOnlyList PublicProviders = - AiProviders.All - .Select(provider => new ProviderPublicDto( - provider.Id, provider.Name, provider.Base, provider.Local, provider.Models)) - .ToList(); - - // Опции JSON: camelCase для записей модуля (1:1 с wire-именами §4.6). - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - /// - /// Публичный снимок настроек: дефолты, перекрытые сохранёнными значениями, секреты — маски. - /// - /// Токен отмены. - /// Полный public-снимок §4.6 (тело GET/PATCH /api/settings). - public async Task GetPublicAsync(CancellationToken ct) - { - Dictionary overrides = await LoadStoredOverridesAsync(ct); - return BuildSnapshot(overrides); - } - - /// - /// Частичное обновление настроек: применяет только переданные поля, клампы и валидацию 1:1 с PATCH прототипа. - /// - /// Тело PATCH — произвольный JSON-объект из публичных ключей §4.6. - /// Токен отмены. - /// Полный public-снимок после применения (как GET). - /// - /// Неизвестные и внутренние ключи игнорируются (Ruling 1); невалидное значение поля не применяется - /// (мягкая семантика). Побочный эффект смены rateSource (фоновое обновление кэша курсов) остаётся за - /// HTTP-слоем (SettingsEndpoints, Ruling 6); пересчёт карточек при смене targetCurrency/conversionOn - /// (settings_routes.py L186–192) выполняют слушатели здесь, в сервисе, - /// синхронно после сохранения (Ruling 7, план Task 12). - /// - public async Task ApplyPatchAsync(Dictionary body, CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(body); - - Dictionary overrides = await LoadStoredOverridesAsync(ct); - - // Инвариант пауз авто-вступлений обрабатывается до общего цикла (L78–109): при паре — - // клампы 5..600 и swap при min > max; при одном конце — кламп относительно сохранённого другого. - 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: - // Только JSON-булево; строки не «питон-булеватся» (план Task 3). - 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: - // colState — passthrough: произвольный JSON-объект сохраняется как есть (Ruling 9). - 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); - } - - // Пересчёт конверсий при смене целевой валюты/выключателя конверсии (settings_routes.py L190–191, - // Ruling 7): слушатели вызываются ПОСЛЕ сохранения, чтобы пересчёт читал уже новые настройки - // (реализация порта — ConversionRecomputer модуля Kanban; пустой список — no-op). - if (AffectsConversion(body)) - { - await NotifyRatesChangedAsync(ct); - } - - return await GetPublicAsync(ct); - } - - // Вызвана ли PATCH-ом смена настроек конверсии: в теле есть targetCurrency/conversionOn со значением (L190–191). - // body: Тело PATCH. - // Возвращает: True — нужен пересчёт конверсий после применения. - // Присутствие с JSON-null трактуется как отсутствие (семантика прототипа is not None). - 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; - } - - // Оповещает слушателей о смене настроек конверсии (Ruling 7, план Task 12). - // ct: Токен отмены. - // Вызов синхронный после записи (settings_routes.py L186–192); пустой список — no-op. - 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), - - // Глобальные исключения (§5.14) - 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; + +/// +/// Сервис настроек тенанта: public-снимок (GET) и частичное обновление (PATCH-семантика 1:1). +/// +/// +/// Референс — backend/app/routers/settings_routes.py L75–192 (Ruling 1/3/9/10). Сервис чистый: +/// работает поверх порта (хранилище держит только переопределения, +/// дефолты — в ) и (секрет aiConfigs +/// в БД шифруется, наружу — маска/флаг). Снимок = дефолты, перекрытые сохранёнными значениями; +/// внутренние ключи (ratesCache и др.) в снимок не входят (Ruling 1). Ответ PATCH — полный +/// снимок после применения (фронт затирает локальный state ответом, api-map L147/L341). +/// Мягкая семантика: невалидное поле PATCH просто не применяется, исключений нет (Task 5). +/// Расхождение с прототипом (намеренное, по плану Task 3): Bool-ключи принимают только JSON-булево +/// (строки не «питон-булеватся»); неизвестные/внутренние ключи игнорируются. +/// +/// KV-хранилище настроек тенанта (таблица settings). +/// Шифр секретов (T1: формат enc:, сбой расшифровки → ""). +/// Слушатели смены настроек конверсии (пересчёт карточек, Ruling 7); пусто — no-op. +public sealed partial class SettingsService( + ISettingsStore store, + ISecretCipher secretCipher, + IEnumerable listeners) +{ + // ── Константы (референс settings_routes.py L28–32, L80–127, L143–185) ── + + // Префикс зашифрованного значения в БД (Ruling 2). + 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; + + // Клампы целочисленных ключей: имя ключа → диапазон [min..max] (L80–127). + 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); + + // Статический список провайдеров для public-снимка (без внутреннего api_style, Ruling 3). + private static readonly IReadOnlyList PublicProviders = + AiProviders.All + .Select(provider => new ProviderPublicDto( + provider.Id, provider.Name, provider.Base, provider.Local, provider.Models)) + .ToList(); + + // Опции JSON: camelCase для записей модуля (1:1 с wire-именами §4.6). + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + /// + /// Публичный снимок настроек: дефолты, перекрытые сохранёнными значениями, секреты — маски. + /// + /// Токен отмены. + /// Полный public-снимок §4.6 (тело GET/PATCH /api/settings). + public async Task GetPublicAsync(CancellationToken ct) + { + Dictionary overrides = await LoadStoredOverridesAsync(ct); + return BuildSnapshot(overrides); + } + + /// + /// Частичное обновление настроек: применяет только переданные поля, клампы и валидацию 1:1 с PATCH прототипа. + /// + /// Тело PATCH — произвольный JSON-объект из публичных ключей §4.6. + /// Токен отмены. + /// Полный public-снимок после применения (как GET). + /// + /// Неизвестные и внутренние ключи игнорируются (Ruling 1); невалидное значение поля не применяется + /// (мягкая семантика). Побочный эффект смены rateSource (фоновое обновление кэша курсов) остаётся за + /// HTTP-слоем (SettingsEndpoints, Ruling 6); пересчёт карточек при смене targetCurrency/conversionOn + /// (settings_routes.py L186–192) выполняют слушатели здесь, в сервисе, + /// синхронно после сохранения (Ruling 7, план Task 12). + /// + public async Task ApplyPatchAsync(Dictionary body, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(body); + + Dictionary overrides = await LoadStoredOverridesAsync(ct); + + // Инвариант пауз авто-вступлений обрабатывается до общего цикла (L78–109): при паре — + // клампы 5..600 и swap при min > max; при одном конце — кламп относительно сохранённого другого. + 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: + // Только JSON-булево; строки не «питон-булеватся» (план Task 3). + 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: + // colState — passthrough: произвольный JSON-объект сохраняется как есть (Ruling 9). + 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); + } + + // Пересчёт конверсий при смене целевой валюты/выключателя конверсии (settings_routes.py L190–191, + // Ruling 7): слушатели вызываются ПОСЛЕ сохранения, чтобы пересчёт читал уже новые настройки + // (реализация порта — ConversionRecomputer модуля Kanban; пустой список — no-op). + if (AffectsConversion(body)) + { + await NotifyRatesChangedAsync(ct); + } + + return await GetPublicAsync(ct); + } + + // Вызвана ли PATCH-ом смена настроек конверсии: в теле есть targetCurrency/conversionOn со значением (L190–191). + // body: Тело PATCH. + // Возвращает: True — нужен пересчёт конверсий после применения. + // Присутствие с JSON-null трактуется как отсутствие (семантика прототипа is not None). + 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; + } + + // Оповещает слушателей о смене настроек конверсии (Ruling 7, план Task 12). + // ct: Токен отмены. + // Вызов синхронный после записи (settings_routes.py L186–192); пустой список — no-op. + 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), + + // Глобальные исключения (§5.14) + 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.Telegram/Application/DialogsService.cs b/src/core/Deal.Modules.Telegram/Application/DialogsService.cs index 8201fef..9d190bd 100644 --- a/src/core/Deal.Modules.Telegram/Application/DialogsService.cs +++ b/src/core/Deal.Modules.Telegram/Application/DialogsService.cs @@ -2,8 +2,6 @@ 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.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; using Deal.Modules.Telegram.Application.Models; using Microsoft.Extensions.Logging; diff --git a/src/core/Deal.Modules.Telegram/Application/Models/TelegramDialogDto.cs b/src/core/Deal.Modules.Telegram/Application/Models/TelegramDialogDto.cs index b0d0f20..3c28584 100644 --- a/src/core/Deal.Modules.Telegram/Application/Models/TelegramDialogDto.cs +++ b/src/core/Deal.Modules.Telegram/Application/Models/TelegramDialogDto.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Deal.Modules.Telegram.Application.Models; /// diff --git a/src/core/Deal.Modules.Telegram/Application/TelegramModuleRegistrar.cs b/src/core/Deal.Modules.Telegram/Application/TelegramModuleRegistrar.cs index 82ee8b8..47b7931 100644 --- a/src/core/Deal.Modules.Telegram/Application/TelegramModuleRegistrar.cs +++ b/src/core/Deal.Modules.Telegram/Application/TelegramModuleRegistrar.cs @@ -1,9 +1,3 @@ -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.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; using Microsoft.Extensions.DependencyInjection; namespace Deal.Modules.Telegram.Application; diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuditLogStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuditLogStore.cs index 0b6f6e1..d439b88 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuditLogStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuditLogStore.cs @@ -1,52 +1,50 @@ -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Modules.Tenants.Application.Abstractions; - -/// -/// Порт хранилища аудита: append-only запись и чтение ленты (реализация — EF-адаптер AuditLogStore в Infrastructure). -/// -/// -/// Update/Delete в приложении отсутствуют (Ruling 4: аудит append-only на уровне кода и конвенции; DB-триггеры -/// не добавляем). Единственное исключение — : операционная авто-очистка по -/// retention (этап 12, пакет B) удаляет только устаревшие записи и не меняет остальные. Запись — только Add; -/// чтение — фильтруемая выборка At DESC с limit ≤500 (клампит адаптер). -/// Вызывается только через (единая точка записи; At=UTC-now проставляет сервис). -/// -public interface IAuditLogStore -{ - /// - /// Добавляет запись аудита (Id генерирует БД; At приходит готовым от сервиса). - /// - /// Запись для сохранения. - /// Токен отмены. - public Task AppendAsync(AuditRecordDto record, CancellationToken ct); - - /// - /// Записи по фильтру, новые сверху (At DESC), не более filter.Limit (клампится 1..500). - /// - /// Фильтр выборки. - /// Токен отмены. - /// Записи от новых к старым (пусто — записей нет). - public Task> QueryAsync(AuditQueryDto filter, CancellationToken ct); - - /// - /// Число записей, удовлетворяющих фильтру (для ответа {items, total}). - /// - /// Фильтр выборки (Limit не влияет на подсчёт). - /// Токен отмены. - /// Полное число записей по фильтру. - public Task CountAsync(AuditQueryDto filter, CancellationToken ct); - - /// - /// Удаляет записи старше retention-границы (этап 12, пакет B): операционная авто-очистка - /// фоновым циклом. Существующие записи не изменяются, удаляются только устаревшие — append-only - /// семантика приложения сохраняется (Ruling 4). - /// - /// Граница: удаляются записи со строгим 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; + +/// +/// Порт хранилища аудита: append-only запись и чтение ленты (реализация — EF-адаптер AuditLogStore в Infrastructure). +/// +/// +/// Update/Delete в приложении отсутствуют (Ruling 4: аудит append-only на уровне кода и конвенции; DB-триггеры +/// не добавляем). Единственное исключение — : операционная авто-очистка по +/// retention (этап 12, пакет B) удаляет только устаревшие записи и не меняет остальные. Запись — только Add; +/// чтение — фильтруемая выборка At DESC с limit ≤500 (клампит адаптер). +/// Вызывается только через (единая точка записи; At=UTC-now проставляет сервис). +/// +public interface IAuditLogStore +{ + /// + /// Добавляет запись аудита (Id генерирует БД; At приходит готовым от сервиса). + /// + /// Запись для сохранения. + /// Токен отмены. + public Task AppendAsync(AuditRecordDto record, CancellationToken ct); + + /// + /// Записи по фильтру, новые сверху (At DESC), не более filter.Limit (клампится 1..500). + /// + /// Фильтр выборки. + /// Токен отмены. + /// Записи от новых к старым (пусто — записей нет). + public Task> QueryAsync(AuditQueryDto filter, CancellationToken ct); + + /// + /// Число записей, удовлетворяющих фильтру (для ответа {items, total}). + /// + /// Фильтр выборки (Limit не влияет на подсчёт). + /// Токен отмены. + /// Полное число записей по фильтру. + public Task CountAsync(AuditQueryDto filter, CancellationToken ct); + + /// + /// Удаляет записи старше retention-границы (этап 12, пакет B): операционная авто-очистка + /// фоновым циклом. Существующие записи не изменяются, удаляются только устаревшие — append-only + /// семантика приложения сохраняется (Ruling 4). + /// + /// Граница: удаляются записи со строгим 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 4547844..6a4dee7 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuthStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuthStore.cs @@ -1,90 +1,87 @@ -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Modules.Tenants.Application.Abstractions; - -/// -/// Порт хранилища аутентификации: пользователи и сессии (реализация — EF-адаптер в Infrastructure). -/// -/// Все методы асинхронные и принимают ; отказы — возвратом null. -public interface IAuthStore -{ - /// - /// Ищет пользователя по нормализованному логину (нижний регистр). - /// - /// Нормализованный логин. - /// Токен отмены. - /// Пользователь с хэшем пароля или null. - public Task FindUserByLoginAsync(string login, CancellationToken ct); - - /// - /// Создаёт пользователя (seed/bootstrap). Идентификатор задаёт вызывающий. - /// - /// Данные нового пользователя (логин нормализован, хэш пароля готов). - /// Токен отмены. - 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); - - /// - /// Возвращает пользователей тенанта (операторские список/детали и impersonation, план Task 7). - /// - /// Идентификатор тенанта. - /// Токен отмены. - /// Пользователи тенанта, упорядоченные по времени создания (пусто — пользователей нет). - public Task> ListUsersByTenantIdAsync(Guid tenantId, CancellationToken ct); - - /// - /// Сохраняет новую сессию. - /// - /// Сессия для сохранения. - /// Токен отмены. - public Task CreateSessionAsync(SessionDto session, CancellationToken ct); - - /// - /// Удаляет сессию по SHA-256-хэшу токена (нет сессии — no-op). - /// - /// 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; + +/// +/// Порт хранилища аутентификации: пользователи и сессии (реализация — EF-адаптер в Infrastructure). +/// +/// Все методы асинхронные и принимают ; отказы — возвратом null. +public interface IAuthStore +{ + /// + /// Ищет пользователя по нормализованному логину (нижний регистр). + /// + /// Нормализованный логин. + /// Токен отмены. + /// Пользователь с хэшем пароля или null. + public Task FindUserByLoginAsync(string login, CancellationToken ct); + + /// + /// Создаёт пользователя (seed/bootstrap). Идентификатор задаёт вызывающий. + /// + /// Данные нового пользователя (логин нормализован, хэш пароля готов). + /// Токен отмены. + 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); + + /// + /// Возвращает пользователей тенанта (операторские список/детали и impersonation, план Task 7). + /// + /// Идентификатор тенанта. + /// Токен отмены. + /// Пользователи тенанта, упорядоченные по времени создания (пусто — пользователей нет). + public Task> ListUsersByTenantIdAsync(Guid tenantId, CancellationToken ct); + + /// + /// Сохраняет новую сессию. + /// + /// Сессия для сохранения. + /// Токен отмены. + public Task CreateSessionAsync(SessionDto session, CancellationToken ct); + + /// + /// Удаляет сессию по SHA-256-хэшу токена (нет сессии — no-op). + /// + /// 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 50543d1..005d00f 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/IInviteStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IInviteStore.cs @@ -1,76 +1,74 @@ -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Modules.Tenants.Application.Abstractions; - -/// -/// Порт хранилища приглашений: таблица public.invites (реализация — EF-адаптер InviteStore в Infrastructure). -/// -/// -/// Статусные переходы (revoke/expire/activate) инициирует только прикладной слой — -/// и Join-поток (Task 6); порт хранит строки и меняет статус атомарно по коду. Email нормализует вызывающий. -/// «expired» порт не вычисляет — протухшее pending-приглашение переводит сервис при чтении/проверке. -/// -public interface IInviteStore -{ - /// - /// Сохраняет новое приглашение (Code — первичный ключ; все поля приходят в DTO готовыми). - /// - /// Новое приглашение (код/email/tenantId/срок/автор заданы сервисом). - /// Токен отмены. - public Task CreateAsync(InviteDto invite, CancellationToken ct); - - /// - /// Приглашение по коду (как хранится, без вычисления статуса expired — его считает сервис при чтении). - /// - /// Код приглашения. - /// Токен отмены. - /// Приглашение или null, если кода нет. - public Task GetByCodeAsync(string code, CancellationToken ct); - - /// - /// Все приглашения, новые сверху (CreatedAt DESC) — источник GET /api/operator/invites. - /// - /// Токен отмены. - /// Список приглашений (пусто — приглашений нет). - public Task> ListAsync(CancellationToken ct); - - /// - /// Меняет статус приглашения по коду; для активации (Task 6) обновляет и ActivatedAt. - /// - /// Код приглашения. - /// Новый статус (константа ). - /// Момент активации при статусе activated; иначе null. - /// Токен отмены. - /// true, если приглашение с таким кодом найдено и статус изменён; false — кода нет. - public Task UpdateStatusAsync( - string code, - string status, - DateTimeOffset? activatedAt, - CancellationToken ct); - - /// - /// Атомарно активирует приглашение (CAS, Task 6): переход pending → activated выполняется только если - /// текущий статус строки — pending. Реализация — условный UPDATE (WHERE Code=@code AND Status='pending'), - /// поэтому параллельный отзыв/повторная активация не перезаписываются: если к моменту обновления статус уже - /// не pending, изменений нет и метод возвращает false (реальный статус прочитает вызывающий). - /// - /// Код приглашения. - /// Момент активации (UTC) для колонки ActivatedAt. - /// Токен отмены. - /// true, если строка была в статусе pending и переведена в activated; false — кода нет или статус уже иной. - public Task TryActivateAsync( - string code, - DateTimeOffset activatedAt, - CancellationToken ct); - - /// - /// Активное (pending) приглашение на email — антидубль создания (частичная уникальность, Ruling 2). - /// - /// Нормализованный 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.invites (реализация — EF-адаптер InviteStore в Infrastructure). +/// +/// +/// Статусные переходы (revoke/expire/activate) инициирует только прикладной слой — +/// и Join-поток (Task 6); порт хранит строки и меняет статус атомарно по коду. Email нормализует вызывающий. +/// «expired» порт не вычисляет — протухшее pending-приглашение переводит сервис при чтении/проверке. +/// +public interface IInviteStore +{ + /// + /// Сохраняет новое приглашение (Code — первичный ключ; все поля приходят в DTO готовыми). + /// + /// Новое приглашение (код/email/tenantId/срок/автор заданы сервисом). + /// Токен отмены. + public Task CreateAsync(InviteDto invite, CancellationToken ct); + + /// + /// Приглашение по коду (как хранится, без вычисления статуса expired — его считает сервис при чтении). + /// + /// Код приглашения. + /// Токен отмены. + /// Приглашение или null, если кода нет. + public Task GetByCodeAsync(string code, CancellationToken ct); + + /// + /// Все приглашения, новые сверху (CreatedAt DESC) — источник GET /api/operator/invites. + /// + /// Токен отмены. + /// Список приглашений (пусто — приглашений нет). + public Task> ListAsync(CancellationToken ct); + + /// + /// Меняет статус приглашения по коду; для активации (Task 6) обновляет и ActivatedAt. + /// + /// Код приглашения. + /// Новый статус (константа ). + /// Момент активации при статусе activated; иначе null. + /// Токен отмены. + /// true, если приглашение с таким кодом найдено и статус изменён; false — кода нет. + public Task UpdateStatusAsync( + string code, + string status, + DateTimeOffset? activatedAt, + CancellationToken ct); + + /// + /// Атомарно активирует приглашение (CAS, Task 6): переход pending → activated выполняется только если + /// текущий статус строки — pending. Реализация — условный UPDATE (WHERE Code=@code AND Status='pending'), + /// поэтому параллельный отзыв/повторная активация не перезаписываются: если к моменту обновления статус уже + /// не pending, изменений нет и метод возвращает false (реальный статус прочитает вызывающий). + /// + /// Код приглашения. + /// Момент активации (UTC) для колонки ActivatedAt. + /// Токен отмены. + /// true, если строка была в статусе pending и переведена в activated; false — кода нет или статус уже иной. + public Task TryActivateAsync( + string code, + DateTimeOffset activatedAt, + CancellationToken ct); + + /// + /// Активное (pending) приглашение на email — антидубль создания (частичная уникальность, Ruling 2). + /// + /// Нормализованный 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 118fd67..8dc2f4c 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/IOperatorAuthStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IOperatorAuthStore.cs @@ -1,60 +1,57 @@ -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Modules.Tenants.Application.Abstractions; - -/// -/// Порт хранилища аутентификации оператора: учётные записи и сессии (реализация — EF-адаптер в Infrastructure). -/// -/// -/// Отдельный от порт: операторские данные живут в public-таблицах -/// Operators/OperatorSessions (Ruling 1 этапа 7) и не пересекаются с пользователями/сессиями тенантов. -/// Все методы асинхронные и принимают ; отказы — возвратом null. -/// -public interface IOperatorAuthStore -{ - /// - /// Ищет оператора по нормализованному логину (нижний регистр). - /// - /// Нормализованный логин. - /// Токен отмены. - /// Оператор с хэшем пароля или null. - public Task FindByLoginAsync(string login, CancellationToken ct); - - /// - /// Создаёт оператора (seed/bootstrap). Идентификатор задаёт вызывающий. - /// - /// Данные нового оператора (логин нормализован, хэш пароля готов). - /// Токен отмены. - 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-хэшу токена (нет сессии — no-op). - /// - /// 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; + +/// +/// Порт хранилища аутентификации оператора: учётные записи и сессии (реализация — EF-адаптер в Infrastructure). +/// +/// +/// Отдельный от порт: операторские данные живут в public-таблицах +/// Operators/OperatorSessions (Ruling 1 этапа 7) и не пересекаются с пользователями/сессиями тенантов. +/// Все методы асинхронные и принимают ; отказы — возвратом null. +/// +public interface IOperatorAuthStore +{ + /// + /// Ищет оператора по нормализованному логину (нижний регистр). + /// + /// Нормализованный логин. + /// Токен отмены. + /// Оператор с хэшем пароля или null. + public Task FindByLoginAsync(string login, CancellationToken ct); + + /// + /// Создаёт оператора (seed/bootstrap). Идентификатор задаёт вызывающий. + /// + /// Данные нового оператора (логин нормализован, хэш пароля готов). + /// Токен отмены. + 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-хэшу токена (нет сессии — no-op). + /// + /// 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/IPasswordHasher.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IPasswordHasher.cs index 65d4336..d5b1846 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/IPasswordHasher.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IPasswordHasher.cs @@ -1,8 +1,3 @@ -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; - namespace Deal.Modules.Tenants.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/IRateLimitCounterStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IRateLimitCounterStore.cs index b091cb6..4fadb52 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/IRateLimitCounterStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IRateLimitCounterStore.cs @@ -1,8 +1,3 @@ -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; - namespace Deal.Modules.Tenants.Application.Abstractions; /// diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantLimitStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantLimitStore.cs index 380caac..75c6271 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantLimitStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantLimitStore.cs @@ -1,104 +1,102 @@ -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Modules.Tenants.Application.Abstractions; - -/// -/// Порт хранилища лимитов ИИ-бюджета тенанта: таблица public.tenant_limits (Ruling 3 этапа 7; -/// реализация — EF-адаптер TenantLimitStore в Infrastructure). -/// -/// -/// Строка лимита заводится лениво (GetOrCreateAsync) с дефолт-бюджетом (Ruling 3: TokenBudgetDefaults, -/// переопределение env DEAL_DEFAULT_AI_BUDGET) — на всех путях чтения (операторский список Task 7/10, -/// гейт/рекордер Task 8/9) тенант без строки виден как «дефолт, расход 0». Reset периода — ленивый: если при -/// чтении/записи сейчас ≥ конца периода (PeriodStart+месяц/сутки, ), счётчик и -/// флаги обнуляются и PeriodStart=now. AddUsage — простое read-modify-write отслеживаемой строки в одном -/// сохранении (одиночный инстанс core; конкурентность на тенанта сериализована воркер-гейтами, Task 9-план). -/// Флаги Warned80/NotifiedExhausted выставляет ТОЛЬКО TryMark* — планировщик SSE-алертов BudgetAlertScheduler в -/// момент фактического перехода порога (Task 9; AddUsage их не трогает — иначе списание «съедало» бы переход и -/// тост при естественном расходе не вышел бы); сбрасываются ленивым reset периода и сменой бюджета оператором -/// (UpdateBudgetAsync, Task 10). -/// -public interface ITenantLimitStore -{ - /// - /// Возвращает строку лимита тенанта, при отсутствии — лениво создаёт с дефолт-бюджетом - /// (параметр перекрывает дефолт адаптера на один вызов). - /// - /// Тенант (существующая строка public.tenants). - /// Токен отмены. - /// Дефолт-параметры создаваемой строки; null — дефолт адаптера (конфигурация/env). - /// Строка лимита (созданная или существующая). - public Task GetOrCreateAsync( - Guid tenantId, - CancellationToken ct, - TokenLimitDefaults? defaults = null); - - /// - /// Текущее состояние бюджета тенанта (ленивый reset периода + статус тенанта и признак Allowed - /// для бюджетного гейта, Task 9). При отсутствии строки — создаёт с дефолт-бюджетом. - /// - /// Тенант. - /// Токен отмены. - /// Состояние бюджета на сейчас. - public Task GetStateAsync(Guid tenantId, CancellationToken ct); - - /// - /// Списывает с бюджета тенанта: ленивый reset периода (если истёк), - /// UsedTokens += tokens — в одном сохранении. Флаги порогов НЕ выставляются (их ставит TryMark*, Task 9). - /// - /// Тенант. - /// Списываемые токены (usage.Total ответа ai-service; ≤0 — no-op). - /// Токен отмены. - /// Состояние бюджета после списания (источник для гейта/сводки usage). - public Task AddUsageAsync( - Guid tenantId, - long tokens, - CancellationToken ct); - - /// - /// Меняет бюджет/период тенанта (операторский PATCH, Task 10) и сбрасывает флаги Warned80/ - /// NotifiedExhausted (Ruling 3: один тост на период на порог; смена бюджета открывает новые пороги). - /// - /// Тенант. - /// Новый бюджет периода (≥0; 0 — ИИ запрещён). - /// Новый тип периода (). - /// Токен отмены. - /// Состояние бюджета после изменения. - public Task UpdateBudgetAsync( - Guid tenantId, - long budgetTokens, - string period, - CancellationToken ct); - - /// - /// Атомарно выставляет флаг Warned80, если порог 80% достигнут и флаг ещё не стоял (Task 9: - /// SSE-алерт один раз на период на порог). - /// - /// Тенант. - /// Токен отмены. - /// True — флаг только что установлен (нужно публиковать тост); false — уже стоял/порог не достигнут. - public Task TryMarkWarnedAsync(Guid tenantId, CancellationToken ct); - - /// - /// Атомарно выставляет флаг NotifiedExhausted, если бюджет исчерпан и флаг ещё не стоял (Task 9: - /// SSE-алерт один раз на период на порог). - /// - /// Тенант. - /// Токен отмены. - /// True — флаг только что установлен; false — уже стоял/бюджет не исчерпан. - public Task TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct); - - /// - /// Авто-очистка накопительных полей прошедших периодов (этап 12, пакет B): обнуляет - /// UsedTokens/Warned80/NotifiedExhausted строк, чей период завершился к - /// (период-математика ), и сдвигает PeriodStart=now. История периодов - /// отдельно не хранится — чистить больше нечего. Идемпотентно; строки без накоплений не трогаются. - /// - /// Текущий момент (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.tenant_limits (Ruling 3 этапа 7; +/// реализация — EF-адаптер TenantLimitStore в Infrastructure). +/// +/// +/// Строка лимита заводится лениво (GetOrCreateAsync) с дефолт-бюджетом (Ruling 3: TokenBudgetDefaults, +/// переопределение env DEAL_DEFAULT_AI_BUDGET) — на всех путях чтения (операторский список Task 7/10, +/// гейт/рекордер Task 8/9) тенант без строки виден как «дефолт, расход 0». Reset периода — ленивый: если при +/// чтении/записи сейчас ≥ конца периода (PeriodStart+месяц/сутки, ), счётчик и +/// флаги обнуляются и PeriodStart=now. AddUsage — простое read-modify-write отслеживаемой строки в одном +/// сохранении (одиночный инстанс core; конкурентность на тенанта сериализована воркер-гейтами, Task 9-план). +/// Флаги Warned80/NotifiedExhausted выставляет ТОЛЬКО TryMark* — планировщик SSE-алертов BudgetAlertScheduler в +/// момент фактического перехода порога (Task 9; AddUsage их не трогает — иначе списание «съедало» бы переход и +/// тост при естественном расходе не вышел бы); сбрасываются ленивым reset периода и сменой бюджета оператором +/// (UpdateBudgetAsync, Task 10). +/// +public interface ITenantLimitStore +{ + /// + /// Возвращает строку лимита тенанта, при отсутствии — лениво создаёт с дефолт-бюджетом + /// (параметр перекрывает дефолт адаптера на один вызов). + /// + /// Тенант (существующая строка public.tenants). + /// Токен отмены. + /// Дефолт-параметры создаваемой строки; null — дефолт адаптера (конфигурация/env). + /// Строка лимита (созданная или существующая). + public Task GetOrCreateAsync( + Guid tenantId, + CancellationToken ct, + TokenLimitDefaults? defaults = null); + + /// + /// Текущее состояние бюджета тенанта (ленивый reset периода + статус тенанта и признак Allowed + /// для бюджетного гейта, Task 9). При отсутствии строки — создаёт с дефолт-бюджетом. + /// + /// Тенант. + /// Токен отмены. + /// Состояние бюджета на сейчас. + public Task GetStateAsync(Guid tenantId, CancellationToken ct); + + /// + /// Списывает с бюджета тенанта: ленивый reset периода (если истёк), + /// UsedTokens += tokens — в одном сохранении. Флаги порогов НЕ выставляются (их ставит TryMark*, Task 9). + /// + /// Тенант. + /// Списываемые токены (usage.Total ответа ai-service; ≤0 — no-op). + /// Токен отмены. + /// Состояние бюджета после списания (источник для гейта/сводки usage). + public Task AddUsageAsync( + Guid tenantId, + long tokens, + CancellationToken ct); + + /// + /// Меняет бюджет/период тенанта (операторский PATCH, Task 10) и сбрасывает флаги Warned80/ + /// NotifiedExhausted (Ruling 3: один тост на период на порог; смена бюджета открывает новые пороги). + /// + /// Тенант. + /// Новый бюджет периода (≥0; 0 — ИИ запрещён). + /// Новый тип периода (). + /// Токен отмены. + /// Состояние бюджета после изменения. + public Task UpdateBudgetAsync( + Guid tenantId, + long budgetTokens, + string period, + CancellationToken ct); + + /// + /// Атомарно выставляет флаг Warned80, если порог 80% достигнут и флаг ещё не стоял (Task 9: + /// SSE-алерт один раз на период на порог). + /// + /// Тенант. + /// Токен отмены. + /// True — флаг только что установлен (нужно публиковать тост); false — уже стоял/порог не достигнут. + public Task TryMarkWarnedAsync(Guid tenantId, CancellationToken ct); + + /// + /// Атомарно выставляет флаг NotifiedExhausted, если бюджет исчерпан и флаг ещё не стоял (Task 9: + /// SSE-алерт один раз на период на порог). + /// + /// Тенант. + /// Токен отмены. + /// True — флаг только что установлен; false — уже стоял/бюджет не исчерпан. + public Task TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct); + + /// + /// Авто-очистка накопительных полей прошедших периодов (этап 12, пакет B): обнуляет + /// UsedTokens/Warned80/NotifiedExhausted строк, чей период завершился к + /// (период-математика ), и сдвигает PeriodStart=now. История периодов + /// отдельно не хранится — чистить больше нечего. Идемпотентно; строки без накоплений не трогаются. + /// + /// Текущий момент (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 a363848..6d73c7b 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantProvisioner.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantProvisioner.cs @@ -1,22 +1,17 @@ -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Modules.Tenants.Application.Abstractions; - -/// -/// Порт провижининга схемы тенанта (создание схемы и применение tenant-миграций). -/// -/// Реализация — TenantProvisioningService в Infrastructure (Task 5, Ruling 3). -public interface ITenantProvisioner -{ - /// - /// Провижинит схему тенанта: создаёт схему tenant_<id> и применяет tenant-миграции. - /// - /// Идентификатор тенанта. - /// Токен отмены. - public Task ProvisionAsync(TenantId tenantId, CancellationToken ct); -} +using Deal.SharedKernel.Tenants.Models; + +namespace Deal.Modules.Tenants.Application.Abstractions; + +/// +/// Порт провижининга схемы тенанта (создание схемы и применение tenant-миграций). +/// +/// Реализация — TenantProvisioningService в Infrastructure (Task 5, Ruling 3). +public interface ITenantProvisioner +{ + /// + /// Провижинит схему тенанта: создаёт схему tenant_<id> и применяет tenant-миграции. + /// + /// Идентификатор тенанта. + /// Токен отмены. + 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 8882ec4..d55d150 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantRepository.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantRepository.cs @@ -1,46 +1,43 @@ -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Modules.Tenants.Application.Abstractions; - -/// -/// Порт реестра тенантов (реализация — EF-адаптер в Infrastructure, таблица public.tenants). -/// -public interface ITenantRepository -{ - /// - /// Ищет тенанта по идентификатору. - /// - /// Идентификатор тенанта. - /// Токен отмены. - /// Запись тенанта или null. - public Task FindByIdAsync(Guid id, CancellationToken ct); - - /// - /// Сохраняет нового тенанта. - /// - /// Запись тенанта. - /// Токен отмены. - public Task CreateAsync(TenantRecordDto tenant, CancellationToken ct); - - /// - /// Возвращает список всех тенантов. - /// - /// Токен отмены. - /// Список тенантов. - public Task> ListAsync(CancellationToken ct); - - /// - /// Устанавливает статус тенанта (операторская приостановка/возобновление, план Task 7). - /// - /// Идентификатор тенанта. - /// Новый статус — константа 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; + +/// +/// Порт реестра тенантов (реализация — EF-адаптер в Infrastructure, таблица public.tenants). +/// +public interface ITenantRepository +{ + /// + /// Ищет тенанта по идентификатору. + /// + /// Идентификатор тенанта. + /// Токен отмены. + /// Запись тенанта или null. + public Task FindByIdAsync(Guid id, CancellationToken ct); + + /// + /// Сохраняет нового тенанта. + /// + /// Запись тенанта. + /// Токен отмены. + public Task CreateAsync(TenantRecordDto tenant, CancellationToken ct); + + /// + /// Возвращает список всех тенантов. + /// + /// Токен отмены. + /// Список тенантов. + public Task> ListAsync(CancellationToken ct); + + /// + /// Устанавливает статус тенанта (операторская приостановка/возобновление, план Task 7). + /// + /// Идентификатор тенанта. + /// Новый статус — константа 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 a641b67..8d1d414 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITokenUsageEventStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITokenUsageEventStore.cs @@ -1,6 +1,4 @@ using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; namespace Deal.Modules.Tenants.Application.Abstractions; diff --git a/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs b/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs index 9e67e3c..f7a461a 100644 --- a/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs +++ b/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs @@ -1,8 +1,3 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; - namespace Deal.Modules.Tenants.Application.Models; /// diff --git a/src/core/Deal.Modules.Tenants/Application/Extensions/InviteDtoExtensions.cs b/src/core/Deal.Modules.Tenants/Application/Extensions/InviteDtoExtensions.cs index c79e8be..60d4895 100644 --- a/src/core/Deal.Modules.Tenants/Application/Extensions/InviteDtoExtensions.cs +++ b/src/core/Deal.Modules.Tenants/Application/Extensions/InviteDtoExtensions.cs @@ -1,7 +1,4 @@ using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; namespace Deal.Modules.Tenants.Application.Extensions; diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AuditActorTypes.cs b/src/core/Deal.Modules.Tenants/Application/Models/AuditActorTypes.cs index 69c7fac..fef47ef 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/AuditActorTypes.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/AuditActorTypes.cs @@ -1,8 +1,3 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; - namespace Deal.Modules.Tenants.Application.Models; /// diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs b/src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs index da7f260..24dae2e 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs @@ -1,8 +1,3 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; - namespace Deal.Modules.Tenants.Application.Models; /// diff --git a/src/core/Deal.Modules.Tenants/Application/Models/InviteStatuses.cs b/src/core/Deal.Modules.Tenants/Application/Models/InviteStatuses.cs index 11f547d..ba51652 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/InviteStatuses.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/InviteStatuses.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; namespace Deal.Modules.Tenants.Application.Models; diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TenantLimitPeriods.cs b/src/core/Deal.Modules.Tenants/Application/Models/TenantLimitPeriods.cs index 6fca297..cc80705 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TenantLimitPeriods.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TenantLimitPeriods.cs @@ -1,8 +1,3 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; - namespace Deal.Modules.Tenants.Application.Models; /// diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TenantStatuses.cs b/src/core/Deal.Modules.Tenants/Application/Models/TenantStatuses.cs index f352a26..c1610cc 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TenantStatuses.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TenantStatuses.cs @@ -1,6 +1,3 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; namespace Deal.Modules.Tenants.Application.Models; diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TokenBudgetDefaults.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenBudgetDefaults.cs index 1df3ca5..0928e63 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenBudgetDefaults.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenBudgetDefaults.cs @@ -1,35 +1,29 @@ -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Modules.Tenants.Application.Models; - -/// -/// Дефолт-бюджет нового тенанта (Ruling 3 этапа 7). -/// -/// -/// Единый источник значения по умолчанию для строки public.tenant_limits, которую GetOrCreateAsync лениво -/// создаёт при первом чтении/списании (задачи 6–8/10: join-активация, recorder, операторский список). -/// Значение-константа может быть переопределено конфигурацией/env DEAL_DEFAULT_AI_BUDGET на старте -/// Api (Program.cs): там строится с прочитанным бюджетом и -/// и передаётся адаптеру ITenantLimitStore (AddDealPersistence). -/// -public static class TokenBudgetDefaults -{ - /// - /// Дефолт бюджета, токенов в месяц (Ruling 3: 10 000 000; оператор меняет через PATCH лимита, Task 10). - /// - public const long DefaultBudgetTokens = 10_000_000; - - /// - /// Дефолт периода нового тенанта (Ruling 3: месяц). - /// - public const string DefaultPeriod = TenantLimitPeriods.Month; - - /// - /// Готовый набор дефолтов (константы выше) — для адаптеров/тестов без конфигурации. - /// - public static TokenLimitDefaults Default => new(DefaultBudgetTokens, DefaultPeriod); -} +namespace Deal.Modules.Tenants.Application.Models; + +/// +/// Дефолт-бюджет нового тенанта (Ruling 3 этапа 7). +/// +/// +/// Единый источник значения по умолчанию для строки public.tenant_limits, которую GetOrCreateAsync лениво +/// создаёт при первом чтении/списании (задачи 6–8/10: join-активация, recorder, операторский список). +/// Значение-константа может быть переопределено конфигурацией/env DEAL_DEFAULT_AI_BUDGET на старте +/// Api (Program.cs): там строится с прочитанным бюджетом и +/// и передаётся адаптеру ITenantLimitStore (AddDealPersistence). +/// +public static class TokenBudgetDefaults +{ + /// + /// Дефолт бюджета, токенов в месяц (Ruling 3: 10 000 000; оператор меняет через PATCH лимита, Task 10). + /// + public const long DefaultBudgetTokens = 10_000_000; + + /// + /// Дефолт периода нового тенанта (Ruling 3: месяц). + /// + public const string DefaultPeriod = TenantLimitPeriods.Month; + + /// + /// Готовый набор дефолтов (константы выше) — для адаптеров/тестов без конфигурации. + /// + public static TokenLimitDefaults Default => new(DefaultBudgetTokens, DefaultPeriod); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventKinds.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventKinds.cs index 45a1dc9..fb4f0e0 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventKinds.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventKinds.cs @@ -1,8 +1,3 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; - namespace Deal.Modules.Tenants.Application.Models; /// diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageGroupBys.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageGroupBys.cs index 82c3676..2b998dd 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageGroupBys.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageGroupBys.cs @@ -1,8 +1,3 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; - namespace Deal.Modules.Tenants.Application.Models; /// diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageSources.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageSources.cs index 615c098..14cf4cf 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageSources.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageSources.cs @@ -1,8 +1,3 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; - namespace Deal.Modules.Tenants.Application.Models; /// diff --git a/src/core/Deal.Modules.Tenants/Application/Registrars/TenantModuleRegistrar.cs b/src/core/Deal.Modules.Tenants/Application/Registrars/TenantModuleRegistrar.cs index 9f017fe..ee6a6e5 100644 --- a/src/core/Deal.Modules.Tenants/Application/Registrars/TenantModuleRegistrar.cs +++ b/src/core/Deal.Modules.Tenants/Application/Registrars/TenantModuleRegistrar.cs @@ -1,48 +1,46 @@ -using Microsoft.Extensions.DependencyInjection; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Services; - -namespace Deal.Modules.Tenants.Application.Registrars; - -/// -/// DI-регистрация модуля Tenants. Паттерн «port & adapter» (Ruling 1). -/// -/// -/// Регистрируются только сервисы и реализации внутри модуля. Порт-адаптеры -/// (IAuthStore, IOperatorAuthStore, ITenantRepository, IAuditLogStore, IInviteStore, ITenantProvisioner) -/// реализованы в Deal.Infrastructure и регистрируются там (AddDealPersistence, см. Program.cs) — модуль не -/// знает про EF. -/// -public static class TenantModuleRegistrar -{ - /// - /// Регистрирует сервисы модуля Tenants в контейнере. - /// - /// Коллекция сервисов. - /// Коллекция сервисов для цепочки вызовов. - /// - /// Время жизни: AuthService, TenantService, TenantAdminService, OperatorAuthService, OperatorBootstrapService, - /// AuditService, TokenUsageEventService, AnalyticsService, InvitesService и JoinService — scoped, потому что их - /// зависимости-адаптеры (IAuthStore/IOperatorAuthStore/ITenantRepository/IAuditLogStore/ITokenUsageEventStore/ - /// IInviteStore) реализованы на EF-контекстах со scoped-жизнью, и сервис должен жить не дольше контекста. - /// DefaultPasswordHasher — без состояния, поэтому singleton. - /// - 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. Паттерн «port & adapter» (Ruling 1). +/// +/// +/// Регистрируются только сервисы и реализации внутри модуля. Порт-адаптеры +/// (IAuthStore, IOperatorAuthStore, ITenantRepository, IAuditLogStore, IInviteStore, ITenantProvisioner) +/// реализованы в Deal.Infrastructure и регистрируются там (AddDealPersistence, см. Program.cs) — модуль не +/// знает про EF. +/// +public static class TenantModuleRegistrar +{ + /// + /// Регистрирует сервисы модуля Tenants в контейнере. + /// + /// Коллекция сервисов. + /// Коллекция сервисов для цепочки вызовов. + /// + /// Время жизни: AuthService, TenantService, TenantAdminService, OperatorAuthService, OperatorBootstrapService, + /// AuditService, TokenUsageEventService, AnalyticsService, InvitesService и JoinService — scoped, потому что их + /// зависимости-адаптеры (IAuthStore/IOperatorAuthStore/ITenantRepository/IAuditLogStore/ITokenUsageEventStore/ + /// IInviteStore) реализованы на EF-контекстах со scoped-жизнью, и сервис должен жить не дольше контекста. + /// DefaultPasswordHasher — без состояния, поэтому singleton. + /// + 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 37c4891..59a8da9 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/AnalyticsService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/AnalyticsService.cs @@ -1,7 +1,5 @@ -using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Models; namespace Deal.Modules.Tenants.Application.Services; diff --git a/src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs b/src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs index 639422c..d27f835 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs @@ -1,87 +1,85 @@ -using System.Text.Json; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Observability; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Прикладной сервис аудита (Ruling 4 этапа 7): append-only запись событий и чтение ленты оператором. -/// -/// -/// Единственная точка записи в public.audit_log: сам проставляет At=UTC-now, вызывается -/// из эндпоинтов/сервисов (входы, инвайты, impersonation, действия оператора — задачи 4–10). Update/Delete в -/// порту отсутствуют (append-only на уровне кода); TTL/авто-очистка не делаются (Ruling 4). Чтение — только -/// оператору (GET /api/operator/audit через QueryAsync/CountAsync). Каталог событий — , -/// типы акторов — , JSON деталей — (camelCase, без секретов). -/// -public sealed class AuditService(IAuditLogStore store) -{ - /// - /// Верхняя граница выборки аудита (Ruling 4: limit ≤500). - /// - public const int MaxQueryLimit = 500; - - /// - /// Размер выборки по умолчанию при отсутствии limit в запросе (эталон DiscoveryLogService). - /// - public const int DefaultQueryLimit = 100; - - // Опции JSON деталей: camelCase (конвенция DetailJson/JSON проекта). - private static readonly JsonSerializerOptions DetailJsonOptions = new(JsonSerializerDefaults.Web); - - /// - /// Записывает событие аудита (append-only; At = сейчас, UTC). - /// - /// Запись события (At и Id игнорируются: At проставляет сервис, Id — БД). - /// Токен отмены. - public async Task AppendAsync(AuditRecordDto record, CancellationToken ct) - { - await store.AppendAsync(record with { At = DateTimeOffset.UtcNow }, ct); - // Прикладная метрика (этап 12, пакет A): счётчик событий аудита по типу/актору - // (низкокардинальные метки — без tenantId/actorId). - DealMetrics.RecordAuditEvent(record.EventType, record.ActorType); - } - - /// - /// Записи по фильтру, новые сверху (прокси порта; чтение — операторский эндпоинт). - /// - /// Фильтр выборки. - /// Токен отмены. - /// Записи от новых к старым. - public Task> QueryAsync(AuditQueryDto filter, CancellationToken ct) => - store.QueryAsync(filter, ct); - - /// - /// Число записей по фильтру (для ответа {items, total}). - /// - /// Фильтр выборки. - /// Токен отмены. - /// Полное число записей по фильтру. - public Task CountAsync(AuditQueryDto filter, CancellationToken ct) => store.CountAsync(filter, ct); - - /// - /// Сериализует детали события в JSON (camelCase; секреты в объект не класть — правило Ruling 4). - /// - /// Объект деталей (обычно анонимный: { login = ... }). - /// JSON-строка деталей. - public static string ToDetailJson(object? details) => JsonSerializer.Serialize(details, DetailJsonOptions); - - /// - /// Актор «пользователь тенанта» по разрешённой сессии: (ActorType, ActorId, TenantId). - /// - /// Идентичность пользователя тенанта. - /// Кортеж актора для полей записи аудита. - public static (string ActorType, Guid? ActorId, Guid? TenantId) ActorFromUser(UserIdentityDto user) => - (AuditActorTypes.Tenant, user.Id, user.TenantId); - - /// - /// Актор «оператор» по разрешённой операторской сессии: (ActorType, ActorId, TenantId=null). - /// - /// Идентичность оператора. - /// Кортеж актора для полей записи аудита. - 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; + +/// +/// Прикладной сервис аудита (Ruling 4 этапа 7): append-only запись событий и чтение ленты оператором. +/// +/// +/// Единственная точка записи в public.audit_log: сам проставляет At=UTC-now, вызывается +/// из эндпоинтов/сервисов (входы, инвайты, impersonation, действия оператора — задачи 4–10). Update/Delete в +/// порту отсутствуют (append-only на уровне кода); TTL/авто-очистка не делаются (Ruling 4). Чтение — только +/// оператору (GET /api/operator/audit через QueryAsync/CountAsync). Каталог событий — , +/// типы акторов — , JSON деталей — (camelCase, без секретов). +/// +public sealed class AuditService(IAuditLogStore store) +{ + /// + /// Верхняя граница выборки аудита (Ruling 4: limit ≤500). + /// + public const int MaxQueryLimit = 500; + + /// + /// Размер выборки по умолчанию при отсутствии limit в запросе (эталон DiscoveryLogService). + /// + public const int DefaultQueryLimit = 100; + + // Опции JSON деталей: camelCase (конвенция DetailJson/JSON проекта). + private static readonly JsonSerializerOptions DetailJsonOptions = new(JsonSerializerDefaults.Web); + + /// + /// Записывает событие аудита (append-only; At = сейчас, UTC). + /// + /// Запись события (At и Id игнорируются: At проставляет сервис, Id — БД). + /// Токен отмены. + public async Task AppendAsync(AuditRecordDto record, CancellationToken ct) + { + await store.AppendAsync(record with { At = DateTimeOffset.UtcNow }, ct); + // Прикладная метрика (этап 12, пакет A): счётчик событий аудита по типу/актору + // (низкокардинальные метки — без tenantId/actorId). + DealMetrics.RecordAuditEvent(record.EventType, record.ActorType); + } + + /// + /// Записи по фильтру, новые сверху (прокси порта; чтение — операторский эндпоинт). + /// + /// Фильтр выборки. + /// Токен отмены. + /// Записи от новых к старым. + public Task> QueryAsync(AuditQueryDto filter, CancellationToken ct) => + store.QueryAsync(filter, ct); + + /// + /// Число записей по фильтру (для ответа {items, total}). + /// + /// Фильтр выборки. + /// Токен отмены. + /// Полное число записей по фильтру. + public Task CountAsync(AuditQueryDto filter, CancellationToken ct) => store.CountAsync(filter, ct); + + /// + /// Сериализует детали события в JSON (camelCase; секреты в объект не класть — правило Ruling 4). + /// + /// Объект деталей (обычно анонимный: { login = ... }). + /// JSON-строка деталей. + public static string ToDetailJson(object? details) => JsonSerializer.Serialize(details, DetailJsonOptions); + + /// + /// Актор «пользователь тенанта» по разрешённой сессии: (ActorType, ActorId, TenantId). + /// + /// Идентичность пользователя тенанта. + /// Кортеж актора для полей записи аудита. + public static (string ActorType, Guid? ActorId, Guid? TenantId) ActorFromUser(UserIdentityDto user) => + (AuditActorTypes.Tenant, user.Id, user.TenantId); + + /// + /// Актор «оператор» по разрешённой операторской сессии: (ActorType, ActorId, TenantId=null). + /// + /// Идентичность оператора. + /// Кортеж актора для полей записи аудита. + 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/AuthService.cs b/src/core/Deal.Modules.Tenants/Application/Services/AuthService.cs index 29e0aca..6713fa9 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/AuthService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/AuthService.cs @@ -1,317 +1,315 @@ -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Прикладной сервис аутентификации: login, logout, смена пароля, разрешение сессии, impersonation. -/// -/// -/// Семантика повторяет прототип LeadRadar (backend/app/auth.py): сообщения об ошибках -/// фиксирует HTTP-слой (Task 4/7), сервис возвращает коды/null и не бросает исключений -/// для бизнес-отказов. Логин нормализуется в нижний регистр (Ruling: сравнение по lowercase). -/// -/// Гейт приостановки (Task 7/Ruling 10(5); этап 12, пакет B): login проверяет статус тенанта через -/// — suspended возвращает -/// (HTTP-слой отвечает 403 и пишет tenant_login_failed с tenantId); -/// проверяет статус на каждом запросе — активные сессии suspended-тенанта перестают действовать немедленно -/// (включая impersonation), при resume — вновь работают. Impersonation выпускает -/// обычную tenant-сессию выбранного пользователя с маркером оператора (); -/// завершение — logout'ом пользователя, о нём сервис сообщает для аудита -/// impersonation_stopped. Пароль при impersonation не меняется. -/// -/// -public sealed class AuthService( - IAuthStore authStore, - IPasswordHasher passwordHasher, - ITenantRepository tenantRepository) -{ - /// - /// Срок жизни сессии, дней (Ruling 6: 30). Единый источник «30» — на него ссылается кука (Task 5). - /// - public const int SessionLifetimeDays = 30; - - /// - /// Минимальная длина нового пароля. Единый источник — на него ссылается активация инвайта - /// (JoinService, Task 6), чтобы минимум не разошёлся. - /// - public const int MinNewPasswordLength = 8; - - // Статус «активен»: только активного пользователя разрешает ResolveSessionAsync (деактивированный - // вручную с живой сессией — null, как у оператора OperatorAuthService). - private const string UserActiveStatus = "active"; - - /// - /// Вход: при успехе создаёт сессию и возвращает её raw-токен. Вход suspended-тенанта заблокирован (Ruling 10(5)). - /// - /// Логин (регистр и пробелы не важны — нормализуется). - /// Пароль в открытом виде. - /// Токен отмены. - /// - /// При успехе — Login и Token (UserId/TenantId для аудита). Иначе Login/Token null: Error может отличать - /// заблокированный вход приостановленного тенанта (, - /// UserId/TenantId заполнены) от «неверные учётные данные» (Error null, UserId/TenantId пусты). - /// - public async Task LoginAsync( - string login, - string password, - CancellationToken ct) - { - var normalizedLogin = NormalizeLogin(login); - if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password)) - { - return new LoginResultDto(null, null); - } - - var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct); - // Порядок проверок: сначала учётные данные, затем статус тенанта — неверный пароль не раскрывает - // приостановку (тот же «Неверный логин или пароль», что и для активного тенанта). - if (user is null || !passwordHasher.Verify(password, user.PasswordHash)) - { - return new LoginResultDto(null, null); - } - - var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct); - if (tenant is not null && tenant.Status == TenantStatuses.Suspended) - { - // Заблокированный вход: HTTP-слой пишет tenant_login_failed с tenantId (замечание ревью Task 4). - return new LoginResultDto( - Login: null, - Token: null, - UserId: user.Id, - TenantId: user.TenantId, - Error: LoginResultDto.ErrorTenantSuspended); - } - - var token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct); - return new LoginResultDto(user.Login, token, user.Id, user.TenantId); - } - - /// - /// Выход: удаляет сессию по raw-токену (no-op без токена). - /// - /// Raw-токен из куки (может отсутствовать — no-op). - /// Токен отмены. - /// - /// Не null, если удалённая сессия была impersonation — сведения для аудита impersonation_stopped - /// (актор — оператор по маркеру сессии). Обычный logout и no-op возвращают null. - /// - public async Task LogoutAsync(string? rawToken, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(rawToken)) - { - return null; - } - - var tokenHash = SessionTokens.HashToken(rawToken); - var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct); - await authStore.DeleteSessionAsync(tokenHash, ct); - - if (session is null || session.ImpersonatedByOperatorId is null) - { - return null; - } - - // Пользователь не удаляется при живой сессии (FK sessions→users Cascade, SessionConfiguration): - // TenantId берём из реестра пользователей для поля-фильтра аудита. - var user = await authStore.FindUserByIdAsync(session.UserId, ct); - return user is null - ? null - : new LogoutResultDto( - OperatorId: session.ImpersonatedByOperatorId.Value, - Login: session.Login, - UserId: user.Id, - TenantId: user.TenantId); - } - - /// - /// Смена пароля: инвалидирует все сессии, обновляет хэш и выдаёт свежую сессию. - /// - /// Логин пользователя. - /// Текущий пароль. - /// Новый пароль (минимум 8 символов). - /// Токен отмены. - /// - /// При успехе — Ok=true и NewToken (raw-токен свежей сессии). Иначе Ok=false и код ошибки: - /// или . - /// - public async Task ChangePasswordAsync( - string login, - string oldPassword, - string newPassword, - CancellationToken ct) - { - var normalizedLogin = NormalizeLogin(login); - var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct); - if (user is null || oldPassword is null || !passwordHasher.Verify(oldPassword, user.PasswordHash)) - { - return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorOldPassword, NewToken: null); - } - - if (string.IsNullOrEmpty(newPassword) || newPassword.Length < MinNewPasswordLength) - { - return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorTooShort, NewToken: null); - } - - // Семантика прототипа (change_password + auth_routes.change): разлогиниваем все старые - // сессии, обновляем хэш и выдаём свежую — её raw-токен вернёт HTTP-слой в куке. - await authStore.DeleteSessionsByUserIdAsync(user.Id, ct); - await authStore.UpdatePasswordHashAsync(user.Id, passwordHasher.Hash(newPassword), ct); - var token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct); - return new ChangePasswordResultDto(Ok: true, Error: null, NewToken: token); - } - - /// - /// Impersonation: tenant-сессия целевого пользователя от имени оператора (план Task 7). - /// - /// - /// Пароль пользователя не меняется и не требуется: сессия выпускается оператором напрямую с маркером - /// (для аудита stopped при logout). login опционален: - /// не задан — берётся первый пользователь тенанта (по CreatedAt); задан — пользователь обязан - /// принадлежать тенанту. Приостановленный тенант не блокирует impersonation (операторский доступ, - /// аудируется; ИИ-расход всё равно заморожен гейтом Task 9). - /// - /// Идентификатор тенанта. - /// Логин пользователя (null/пустой — первый пользователь тенанта). - /// Идентификатор оператора, начинающего impersonation (маркер сессии). - /// Токен отмены. - /// Результат: Ok=true — SessionToken (raw-токен для куки deal_session) и срок жизни; иначе код ошибки. - public async Task ImpersonateAsync( - Guid tenantId, - string? targetLogin, - Guid operatorId, - CancellationToken ct) - { - var tenant = await tenantRepository.FindByIdAsync(tenantId, ct); - if (tenant is null) - { - return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantNotFound); - } - - var normalizedLogin = NormalizeLogin(targetLogin); - if (string.IsNullOrEmpty(normalizedLogin)) - { - // login не задан — первый пользователь тенанта (по CreatedAt, порядок ListUsersByTenantIdAsync). - var tenantUsers = await authStore.ListUsersByTenantIdAsync(tenantId, ct); - if (tenantUsers.Count == 0) - { - return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantHasNoUsers); - } - - UserIdentityDto firstUser = tenantUsers[0]; - var rawTokenForFirst = await CreateSessionForUserAsync(firstUser.Id, firstUser.Login, operatorId, ct); - return Success(rawTokenForFirst, firstUser.Id, firstUser.Login, firstUser.TenantId); - } - - var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct); - if (user is null || user.TenantId != tenantId) - { - // Пользователь не найден или принадлежит другому тенанту — не раскрываем существование логина. - return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorUserNotFound); - } - - var rawToken = await CreateSessionForUserAsync(user.Id, user.Login, operatorId, ct); - return Success(rawToken, user.Id, user.Login, user.TenantId); - } - - /// - /// Разрешение сессии по raw-токену: возвращает пользователя или null (нет/протухла). - /// - /// Raw-токен из куки. - /// Токен отмены. - /// Идентичность пользователя или null. - public async Task ResolveSessionAsync(string? rawToken, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(rawToken)) - { - return null; - } - - var tokenHash = SessionTokens.HashToken(rawToken); - var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct); - UserIdentityDto? user = null; - if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow) - { - user = await authStore.FindUserByIdAsync(session.UserId, ct); - // Деактивированный пользователь при живой сессии не получает доступ (Security review): - // сессия разрешается только для активной учётки (как операторская в OperatorAuthService). - if (user is not null && user.Status != UserActiveStatus) - { - user = null; - } - - if (user is not null) - { - // Приостановка тенанта действует немедленно (этап 12, пакет B): статус проверяется на - // каждом разрешении сессии, поэтому активные сессии suspended-тенанта перестают работать - // сразу после suspend, а не доживают до expiry (ранее — Ruling 10(5)). Флаг состояния не - // храним: при resume доступ возвращается тем же путём (login не блокирует активные сессии). - // Касается и impersonation-сессий (та же tenant-сессия с маркером оператора). - var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct); - if (tenant is not null && tenant.Status == TenantStatuses.Suspended) - { - user = null; - } - } - } - else if (session is not null) - { - // Сессия протухла: глобальная очистка протухших сессий выполняется ТОЛЬКО при обнаружении - // протухшей (редкий случай), а не на каждом разрешении сессии (hot-path, Security review). - await authStore.DeleteExpiredSessionsAsync(ct); - } - - return user; - } - - // Создаёт сессию пользователя: raw-токен наружу, в хранилище — его SHA-256-хэш. - // userId: Идентификатор пользователя. - // login: Логин (денормализуется в сессию для чтения без join). - // impersonatedByOperatorId: Маркер impersonation: оператор, создавший сессию; null — обычный вход. - // ct: Токен отмены. - // Возвращает: Raw-токен для выдачи клиенту. - private async Task CreateSessionForUserAsync( - Guid userId, - string login, - Guid? impersonatedByOperatorId, - CancellationToken ct) - { - var rawToken = SessionTokens.NewToken(); - var session = new SessionDto( - TokenHash: SessionTokens.HashToken(rawToken), - UserId: userId, - Login: login, - ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays), - ImpersonatedByOperatorId: impersonatedByOperatorId); - - await authStore.CreateSessionAsync(session, ct); - return rawToken; - } - - // Успешный результат impersonation (токен + пользователь + тенант). - // rawToken: Raw-токен созданной сессии. - // userId: Идентификатор пользователя. - // login: Логин пользователя. - // tenantId: Тенант пользователя. - // Возвращает: ImpersonationResultDto с Ok=true. - private ImpersonationResultDto Success( - string rawToken, - Guid userId, - string login, - Guid tenantId) => - new( - Ok: true, - Error: null, - SessionToken: rawToken, - ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays), - Login: login, - UserId: userId, - TenantId: tenantId); - - // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения). - // login: Входной логин. - // Возвращает: Нормализованный логин (пустая строка, если вход был пустым). - private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim(); -} +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Modules.Tenants.Application.Services; + +/// +/// Прикладной сервис аутентификации: login, logout, смена пароля, разрешение сессии, impersonation. +/// +/// +/// Семантика повторяет прототип LeadRadar (backend/app/auth.py): сообщения об ошибках +/// фиксирует HTTP-слой (Task 4/7), сервис возвращает коды/null и не бросает исключений +/// для бизнес-отказов. Логин нормализуется в нижний регистр (Ruling: сравнение по lowercase). +/// +/// Гейт приостановки (Task 7/Ruling 10(5); этап 12, пакет B): login проверяет статус тенанта через +/// — suspended возвращает +/// (HTTP-слой отвечает 403 и пишет tenant_login_failed с tenantId); +/// проверяет статус на каждом запросе — активные сессии suspended-тенанта перестают действовать немедленно +/// (включая impersonation), при resume — вновь работают. Impersonation выпускает +/// обычную tenant-сессию выбранного пользователя с маркером оператора (); +/// завершение — logout'ом пользователя, о нём сервис сообщает для аудита +/// impersonation_stopped. Пароль при impersonation не меняется. +/// +/// +public sealed class AuthService( + IAuthStore authStore, + IPasswordHasher passwordHasher, + ITenantRepository tenantRepository) +{ + /// + /// Срок жизни сессии, дней (Ruling 6: 30). Единый источник «30» — на него ссылается кука (Task 5). + /// + public const int SessionLifetimeDays = 30; + + /// + /// Минимальная длина нового пароля. Единый источник — на него ссылается активация инвайта + /// (JoinService, Task 6), чтобы минимум не разошёлся. + /// + public const int MinNewPasswordLength = 8; + + // Статус «активен»: только активного пользователя разрешает ResolveSessionAsync (деактивированный + // вручную с живой сессией — null, как у оператора OperatorAuthService). + private const string UserActiveStatus = "active"; + + /// + /// Вход: при успехе создаёт сессию и возвращает её raw-токен. Вход suspended-тенанта заблокирован (Ruling 10(5)). + /// + /// Логин (регистр и пробелы не важны — нормализуется). + /// Пароль в открытом виде. + /// Токен отмены. + /// + /// При успехе — Login и Token (UserId/TenantId для аудита). Иначе Login/Token null: Error может отличать + /// заблокированный вход приостановленного тенанта (, + /// UserId/TenantId заполнены) от «неверные учётные данные» (Error null, UserId/TenantId пусты). + /// + public async Task LoginAsync( + string login, + string password, + CancellationToken ct) + { + var normalizedLogin = NormalizeLogin(login); + if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password)) + { + return new LoginResultDto(null, null); + } + + var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct); + // Порядок проверок: сначала учётные данные, затем статус тенанта — неверный пароль не раскрывает + // приостановку (тот же «Неверный логин или пароль», что и для активного тенанта). + if (user is null || !passwordHasher.Verify(password, user.PasswordHash)) + { + return new LoginResultDto(null, null); + } + + var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct); + if (tenant is not null && tenant.Status == TenantStatuses.Suspended) + { + // Заблокированный вход: HTTP-слой пишет tenant_login_failed с tenantId (замечание ревью Task 4). + return new LoginResultDto( + Login: null, + Token: null, + UserId: user.Id, + TenantId: user.TenantId, + Error: LoginResultDto.ErrorTenantSuspended); + } + + var token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct); + return new LoginResultDto(user.Login, token, user.Id, user.TenantId); + } + + /// + /// Выход: удаляет сессию по raw-токену (no-op без токена). + /// + /// Raw-токен из куки (может отсутствовать — no-op). + /// Токен отмены. + /// + /// Не null, если удалённая сессия была impersonation — сведения для аудита impersonation_stopped + /// (актор — оператор по маркеру сессии). Обычный logout и no-op возвращают null. + /// + public async Task LogoutAsync(string? rawToken, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(rawToken)) + { + return null; + } + + var tokenHash = SessionTokens.HashToken(rawToken); + var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct); + await authStore.DeleteSessionAsync(tokenHash, ct); + + if (session is null || session.ImpersonatedByOperatorId is null) + { + return null; + } + + // Пользователь не удаляется при живой сессии (FK sessions→users Cascade, SessionConfiguration): + // TenantId берём из реестра пользователей для поля-фильтра аудита. + var user = await authStore.FindUserByIdAsync(session.UserId, ct); + return user is null + ? null + : new LogoutResultDto( + OperatorId: session.ImpersonatedByOperatorId.Value, + Login: session.Login, + UserId: user.Id, + TenantId: user.TenantId); + } + + /// + /// Смена пароля: инвалидирует все сессии, обновляет хэш и выдаёт свежую сессию. + /// + /// Логин пользователя. + /// Текущий пароль. + /// Новый пароль (минимум 8 символов). + /// Токен отмены. + /// + /// При успехе — Ok=true и NewToken (raw-токен свежей сессии). Иначе Ok=false и код ошибки: + /// или . + /// + public async Task ChangePasswordAsync( + string login, + string oldPassword, + string newPassword, + CancellationToken ct) + { + var normalizedLogin = NormalizeLogin(login); + var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct); + if (user is null || oldPassword is null || !passwordHasher.Verify(oldPassword, user.PasswordHash)) + { + return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorOldPassword, NewToken: null); + } + + if (string.IsNullOrEmpty(newPassword) || newPassword.Length < MinNewPasswordLength) + { + return new ChangePasswordResultDto(Ok: false, Error: ChangePasswordResultDto.ErrorTooShort, NewToken: null); + } + + // Семантика прототипа (change_password + auth_routes.change): разлогиниваем все старые + // сессии, обновляем хэш и выдаём свежую — её raw-токен вернёт HTTP-слой в куке. + await authStore.DeleteSessionsByUserIdAsync(user.Id, ct); + await authStore.UpdatePasswordHashAsync(user.Id, passwordHasher.Hash(newPassword), ct); + var token = await CreateSessionForUserAsync(user.Id, user.Login, impersonatedByOperatorId: null, ct); + return new ChangePasswordResultDto(Ok: true, Error: null, NewToken: token); + } + + /// + /// Impersonation: tenant-сессия целевого пользователя от имени оператора (план Task 7). + /// + /// + /// Пароль пользователя не меняется и не требуется: сессия выпускается оператором напрямую с маркером + /// (для аудита stopped при logout). login опционален: + /// не задан — берётся первый пользователь тенанта (по CreatedAt); задан — пользователь обязан + /// принадлежать тенанту. Приостановленный тенант не блокирует impersonation (операторский доступ, + /// аудируется; ИИ-расход всё равно заморожен гейтом Task 9). + /// + /// Идентификатор тенанта. + /// Логин пользователя (null/пустой — первый пользователь тенанта). + /// Идентификатор оператора, начинающего impersonation (маркер сессии). + /// Токен отмены. + /// Результат: Ok=true — SessionToken (raw-токен для куки deal_session) и срок жизни; иначе код ошибки. + public async Task ImpersonateAsync( + Guid tenantId, + string? targetLogin, + Guid operatorId, + CancellationToken ct) + { + var tenant = await tenantRepository.FindByIdAsync(tenantId, ct); + if (tenant is null) + { + return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantNotFound); + } + + var normalizedLogin = NormalizeLogin(targetLogin); + if (string.IsNullOrEmpty(normalizedLogin)) + { + // login не задан — первый пользователь тенанта (по CreatedAt, порядок ListUsersByTenantIdAsync). + var tenantUsers = await authStore.ListUsersByTenantIdAsync(tenantId, ct); + if (tenantUsers.Count == 0) + { + return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorTenantHasNoUsers); + } + + UserIdentityDto firstUser = tenantUsers[0]; + var rawTokenForFirst = await CreateSessionForUserAsync(firstUser.Id, firstUser.Login, operatorId, ct); + return Success(rawTokenForFirst, firstUser.Id, firstUser.Login, firstUser.TenantId); + } + + var user = await authStore.FindUserByLoginAsync(normalizedLogin, ct); + if (user is null || user.TenantId != tenantId) + { + // Пользователь не найден или принадлежит другому тенанту — не раскрываем существование логина. + return ImpersonationResultDto.Failed(ImpersonationResultDto.ErrorUserNotFound); + } + + var rawToken = await CreateSessionForUserAsync(user.Id, user.Login, operatorId, ct); + return Success(rawToken, user.Id, user.Login, user.TenantId); + } + + /// + /// Разрешение сессии по raw-токену: возвращает пользователя или null (нет/протухла). + /// + /// Raw-токен из куки. + /// Токен отмены. + /// Идентичность пользователя или null. + public async Task ResolveSessionAsync(string? rawToken, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(rawToken)) + { + return null; + } + + var tokenHash = SessionTokens.HashToken(rawToken); + var session = await authStore.FindSessionByTokenHashAsync(tokenHash, ct); + UserIdentityDto? user = null; + if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow) + { + user = await authStore.FindUserByIdAsync(session.UserId, ct); + // Деактивированный пользователь при живой сессии не получает доступ (Security review): + // сессия разрешается только для активной учётки (как операторская в OperatorAuthService). + if (user is not null && user.Status != UserActiveStatus) + { + user = null; + } + + if (user is not null) + { + // Приостановка тенанта действует немедленно (этап 12, пакет B): статус проверяется на + // каждом разрешении сессии, поэтому активные сессии suspended-тенанта перестают работать + // сразу после suspend, а не доживают до expiry (ранее — Ruling 10(5)). Флаг состояния не + // храним: при resume доступ возвращается тем же путём (login не блокирует активные сессии). + // Касается и impersonation-сессий (та же tenant-сессия с маркером оператора). + var tenant = await tenantRepository.FindByIdAsync(user.TenantId, ct); + if (tenant is not null && tenant.Status == TenantStatuses.Suspended) + { + user = null; + } + } + } + else if (session is not null) + { + // Сессия протухла: глобальная очистка протухших сессий выполняется ТОЛЬКО при обнаружении + // протухшей (редкий случай), а не на каждом разрешении сессии (hot-path, Security review). + await authStore.DeleteExpiredSessionsAsync(ct); + } + + return user; + } + + // Создаёт сессию пользователя: raw-токен наружу, в хранилище — его SHA-256-хэш. + // userId: Идентификатор пользователя. + // login: Логин (денормализуется в сессию для чтения без join). + // impersonatedByOperatorId: Маркер impersonation: оператор, создавший сессию; null — обычный вход. + // ct: Токен отмены. + // Возвращает: Raw-токен для выдачи клиенту. + private async Task CreateSessionForUserAsync( + Guid userId, + string login, + Guid? impersonatedByOperatorId, + CancellationToken ct) + { + var rawToken = SessionTokens.NewToken(); + var session = new SessionDto( + TokenHash: SessionTokens.HashToken(rawToken), + UserId: userId, + Login: login, + ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays), + ImpersonatedByOperatorId: impersonatedByOperatorId); + + await authStore.CreateSessionAsync(session, ct); + return rawToken; + } + + // Успешный результат impersonation (токен + пользователь + тенант). + // rawToken: Raw-токен созданной сессии. + // userId: Идентификатор пользователя. + // login: Логин пользователя. + // tenantId: Тенант пользователя. + // Возвращает: ImpersonationResultDto с Ok=true. + private ImpersonationResultDto Success( + string rawToken, + Guid userId, + string login, + Guid tenantId) => + new( + Ok: true, + Error: null, + SessionToken: rawToken, + ExpiresAt: DateTimeOffset.UtcNow.AddDays(SessionLifetimeDays), + Login: login, + UserId: userId, + TenantId: tenantId); + + // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения). + // login: Входной логин. + // Возвращает: Нормализованный логин (пустая строка, если вход был пустым). + private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim(); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs b/src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs index 03df8e8..ae4e434 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs @@ -1,25 +1,22 @@ -using Isopoh.Cryptography.Argon2; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Реализация на Argon2id (Ruling 5). -/// -/// -/// Используются дефолты пакета Isopoh.Cryptography.Argon2 (версия 2.0.0): -/// соль — 16 случайных байт, t=3, m=65536 (64 MiB), p=1, вариант Argon2id -/// (в библиотеке — Argon2Type.HybridAddressing), длина хэша 32 байта. -/// Класс без состояния — регистрируется как singleton. -/// -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 (Ruling 5). +/// +/// +/// Используются дефолты пакета Isopoh.Cryptography.Argon2 (версия 2.0.0): +/// соль — 16 случайных байт, t=3, m=65536 (64 MiB), p=1, вариант Argon2id +/// (в библиотеке — Argon2Type.HybridAddressing), длина хэша 32 байта. +/// Класс без состояния — регистрируется как singleton. +/// +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 a676e07..2a653fb 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/InviteCodeGenerator.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/InviteCodeGenerator.cs @@ -1,33 +1,29 @@ -using Deal.SharedKernel; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Генератор кодов приглашений: случайный url-safe код, 16 символов, без префикса (Ruling 2 этапа 7). -/// -/// -/// Ровно 16 символов получаются из 12 случайных байт в Base64Url без padding (12 байт → 16 символов). -/// Код — первичный ключ public.invites и одноразовый «секрет» приглашения (его вводит приглашённый в /api/join, -/// Task 6), поэтому источник — криптостойкий -/// (общий генератор , Security review C36). -/// -public static class InviteCodeGenerator -{ - /// - /// Длина кода в символах (Ruling 2: 16). - /// - public const int CodeLength = 16; - - // Случайные байты одного кода: 12 → ровно 16 символов Base64Url. - private const int RandomByteCount = 12; - - /// - /// Новый код приглашения: 16 url-safe символов (Base64Url 12 случайных байт, без '+' и '/'). - /// - /// Строка кода длиной символов. - public static string NewCode() => UrlSafeToken.New(RandomByteCount); -} +using Deal.SharedKernel; + +namespace Deal.Modules.Tenants.Application.Services; + +/// +/// Генератор кодов приглашений: случайный url-safe код, 16 символов, без префикса (Ruling 2 этапа 7). +/// +/// +/// Ровно 16 символов получаются из 12 случайных байт в Base64Url без padding (12 байт → 16 символов). +/// Код — первичный ключ public.invites и одноразовый «секрет» приглашения (его вводит приглашённый в /api/join, +/// Task 6), поэтому источник — криптостойкий +/// (общий генератор , Security review C36). +/// +public static class InviteCodeGenerator +{ + /// + /// Длина кода в символах (Ruling 2: 16). + /// + public const int CodeLength = 16; + + // Случайные байты одного кода: 12 → ровно 16 символов Base64Url. + private const int RandomByteCount = 12; + + /// + /// Новый код приглашения: 16 url-safe символов (Base64Url 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 379f94a..4f94a4a 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/InvitesService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/InvitesService.cs @@ -1,192 +1,191 @@ -using System.Text.RegularExpressions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Прикладной сервис приглашений (Ruling 2 этапа 7): создание оператором, отзыв, список, чтение по коду. -/// -/// -/// Жизненный цикл статусов: pending → revoked | expired | activated. «expired» хранилище не проставляет само: -/// он вычисляется лениво при чтении/проверке (/) и сохраняется, -/// иначе частичный unique-индекс invites.Email по pending (InviteConfiguration, Task 1) заблокировал бы повторное -/// приглашение на тот же email после истечения. Создание/отзыв — только оператор (вызывается из -/// /api/operator/invites, Task 5); тексты HTTP-ошибок фиксирует слой эндпоинтов — сервис возвращает коды/null и -/// не бросает исключений для бизнес-отказов (паттерн AuthService). Активацию (pending → activated) выполняет -/// /api/join (Task 6): он читает приглашение через (валидация статуса/expiry). -/// -public sealed partial class InvitesService(IInviteStore inviteStore) -{ - /// - /// Срок действия приглашения, часов (Ruling 2: 72). - /// - public const int ExpiryHours = 72; - - // Максимальная длина email — совпадает с шириной колонки invites.Email (InviteConfiguration, Task 1). - private const int MaxEmailLength = 200; - - // Проверяемый формат: один '@', непустые локальная часть и домен с точкой, без пробелов. - private const string EmailFormatPattern = @"^[^@\s]+@[^@\s]+\.[^@\s]+$"; - - // Компилируется из EmailFormatPattern; вход ограничен 200 символами, паттерн линейный. - [GeneratedRegex(EmailFormatPattern, RegexOptions.CultureInvariant)] - private static partial Regex EmailFormatRegex(); - - /// - /// Создаёт приглашение оператором: нормализация/валидация email, антидубль активного, срок +72 часа. - /// - /// Идентификатор оператора (CreatedById приглашения). - /// Email приглашённого (регистр/пробелы не важны — нормализуется). - /// Целевой тенант; null — при активации будет создан новый тенант (Task 6). - /// Токен отмены. - /// 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); - } - - // Антидубль «одно активное приглашение на email» (Ruling 2): pending блокирует новое. Если найденное - // pending уже истекло (статус ещё не переведён), сначала помечаем expired — иначе partial unique-индекс - // по pending не пустит новую строку; затем создаём новое приглашение. Отозванный/активированный/ - // истёкший email свободен (глобальную уникальность регистрации держит unique-индекс users.Login). - 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); - } - - /// - /// Отзывает приглашение: только pending переводится в revoked; иные статусы не трогаем (Ruling 2). - /// - /// Код приглашения. - /// Токен отмены. - /// 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 }); - } - - /// - /// Список приглашений для оператора (новые сверху) с ленивой пометкой истёкших (status → expired сохраняется). - /// - /// Токен отмены. - /// Приглашения в порядке 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 при чтении (проверку кода использует /api/join, Task 6). - /// - /// Код приглашения. - /// Токен отмены. - /// Приглашение (протухшее 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-переходом (Task 6): атомарный pending → activated, ActivatedAt = сейчас. - /// Прокси над — реальное условие на статус исполняет хранилище - /// (условный UPDATE), сервис лишь фиксирует момент активации. Возвращает false, если к моменту обновления - /// приглашение уже не pending (параллельно отозвано/активировано) — вызывающий (JoinService) перечитает статус. - /// - /// Код приглашения. - /// Токен отмены. - /// 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; + +/// +/// Прикладной сервис приглашений (Ruling 2 этапа 7): создание оператором, отзыв, список, чтение по коду. +/// +/// +/// Жизненный цикл статусов: pending → revoked | expired | activated. «expired» хранилище не проставляет само: +/// он вычисляется лениво при чтении/проверке (/) и сохраняется, +/// иначе частичный unique-индекс invites.Email по pending (InviteConfiguration, Task 1) заблокировал бы повторное +/// приглашение на тот же email после истечения. Создание/отзыв — только оператор (вызывается из +/// /api/operator/invites, Task 5); тексты HTTP-ошибок фиксирует слой эндпоинтов — сервис возвращает коды/null и +/// не бросает исключений для бизнес-отказов (паттерн AuthService). Активацию (pending → activated) выполняет +/// /api/join (Task 6): он читает приглашение через (валидация статуса/expiry). +/// +public sealed partial class InvitesService(IInviteStore inviteStore) +{ + /// + /// Срок действия приглашения, часов (Ruling 2: 72). + /// + public const int ExpiryHours = 72; + + // Максимальная длина email — совпадает с шириной колонки invites.Email (InviteConfiguration, Task 1). + private const int MaxEmailLength = 200; + + // Проверяемый формат: один '@', непустые локальная часть и домен с точкой, без пробелов. + private const string EmailFormatPattern = @"^[^@\s]+@[^@\s]+\.[^@\s]+$"; + + // Компилируется из EmailFormatPattern; вход ограничен 200 символами, паттерн линейный. + [GeneratedRegex(EmailFormatPattern, RegexOptions.CultureInvariant)] + private static partial Regex EmailFormatRegex(); + + /// + /// Создаёт приглашение оператором: нормализация/валидация email, антидубль активного, срок +72 часа. + /// + /// Идентификатор оператора (CreatedById приглашения). + /// Email приглашённого (регистр/пробелы не важны — нормализуется). + /// Целевой тенант; null — при активации будет создан новый тенант (Task 6). + /// Токен отмены. + /// 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); + } + + // Антидубль «одно активное приглашение на email» (Ruling 2): pending блокирует новое. Если найденное + // pending уже истекло (статус ещё не переведён), сначала помечаем expired — иначе partial unique-индекс + // по pending не пустит новую строку; затем создаём новое приглашение. Отозванный/активированный/ + // истёкший email свободен (глобальную уникальность регистрации держит unique-индекс users.Login). + 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); + } + + /// + /// Отзывает приглашение: только pending переводится в revoked; иные статусы не трогаем (Ruling 2). + /// + /// Код приглашения. + /// Токен отмены. + /// 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 }); + } + + /// + /// Список приглашений для оператора (новые сверху) с ленивой пометкой истёкших (status → expired сохраняется). + /// + /// Токен отмены. + /// Приглашения в порядке 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 при чтении (проверку кода использует /api/join, Task 6). + /// + /// Код приглашения. + /// Токен отмены. + /// Приглашение (протухшее 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-переходом (Task 6): атомарный pending → activated, ActivatedAt = сейчас. + /// Прокси над — реальное условие на статус исполняет хранилище + /// (условный UPDATE), сервис лишь фиксирует момент активации. Возвращает false, если к моменту обновления + /// приглашение уже не pending (параллельно отозвано/активировано) — вызывающий (JoinService) перечитает статус. + /// + /// Код приглашения. + /// Токен отмены. + /// 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 3ae02da..415e0d3 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/JoinService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/JoinService.cs @@ -1,153 +1,151 @@ -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Прикладной сервис активации инвайта через публичную ручку POST /api/join (Ruling 2, Task 6 этапа 7): -/// код + email + пароль → пользователь (login=email), при пустом TenantId — новый тенант с провижинингом схемы, -/// инвайт переводится в activated. Кука НЕ ставится — после активации клиент входит обычным /api/auth/login. -/// -/// -/// Порядок операций и CAS-семантика (замечание ревью T5): валидации (код/email/пароль/дубль email) выполняются -/// до записи; затем инвайт резервируется атомарным переходом pending → activated ( -/// → условный UPDATE в хранилище). Резервирование первым гарантирует, что из двух параллельных активаций одного кода -/// (или активации против параллельного отзыва) победит ровно одна, а проигравший не создаст «лишних» пользователя/ -/// тенанта. Если CAS не прошёл — статус перечитывается, и возвращается фактическая причина (already used / revoked). -/// После резервирования создаётся тенант (если TenantId инвайта пуст) и пользователь; сбой на этом шаге — серверная -/// ошибка (исключение наружу), инвайт остаётся активированным и оператор видит аномалию в списке/аудите. Сервис не -/// бросает исключений для бизнес-отказов — коды ошибок, тексты на HTTP-слое (паттерн AuthService/InvitesService). -/// Глобальную уникальность email держит unique-индекс users.login; предпроверка здесь закрывает типичный случай -/// «уже зарегистрирован» без побочных эффектов (инвайт остаётся pending для повторного использования). -/// -public sealed class JoinService( - InvitesService invitesService, - TenantService tenantService, - IAuthStore authStore, - IPasswordHasher passwordHasher, - ITenantRepository tenantRepository) -{ - // Статус создаваемого пользователя (как у seed-пользователя TenantBootstrapService). - private const string UserActiveStatus = "active"; - - /// - /// Активирует инвайт: валидация, CAS-резервирование, создание тенанта (при необходимости) и пользователя. - /// - /// Код приглашения (пробелы по краям не важны). - /// 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; - - // 1. Чтение приглашения: GetByCodeAsync сам помечает протухшее pending как expired (ленивый переход, Task 5), - // поэтому вернувшийся pending гарантированно не истёк; отдельная проверка кода не нужна — отвечает статус. - 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)); - } - - // 2. Email обязан совпасть с приглашением (Ruling 2); пустой/иной email не проходит — инвайт не расходуется. - if (normalizedEmail != invite.Email) - { - return Failed(JoinResultDto.ErrorEmailMismatch); - } - - // 3. Пароль: минимум AuthService.MinNewPasswordLength (единый источник), текст на HTTP-слое. - if (password is null || password.Length < AuthService.MinNewPasswordLength) - { - return Failed(JoinResultDto.ErrorPasswordTooShort); - } - - // 4. Глобальная уникальность email (users.login unique, Ruling 2): предпроверка до записи, чтобы занятый - // email не создавал тенанта и не расходовал инвайт (остаётся pending — оператор видит/отзывает его). - 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 (Ruling 2, Task 6 этапа 7): +/// код + email + пароль → пользователь (login=email), при пустом TenantId — новый тенант с провижинингом схемы, +/// инвайт переводится в activated. Кука НЕ ставится — после активации клиент входит обычным /api/auth/login. +/// +/// +/// Порядок операций и CAS-семантика (замечание ревью T5): валидации (код/email/пароль/дубль email) выполняются +/// до записи; затем инвайт резервируется атомарным переходом pending → activated ( +/// → условный UPDATE в хранилище). Резервирование первым гарантирует, что из двух параллельных активаций одного кода +/// (или активации против параллельного отзыва) победит ровно одна, а проигравший не создаст «лишних» пользователя/ +/// тенанта. Если CAS не прошёл — статус перечитывается, и возвращается фактическая причина (already used / revoked). +/// После резервирования создаётся тенант (если TenantId инвайта пуст) и пользователь; сбой на этом шаге — серверная +/// ошибка (исключение наружу), инвайт остаётся активированным и оператор видит аномалию в списке/аудите. Сервис не +/// бросает исключений для бизнес-отказов — коды ошибок, тексты на HTTP-слое (паттерн AuthService/InvitesService). +/// Глобальную уникальность email держит unique-индекс users.login; предпроверка здесь закрывает типичный случай +/// «уже зарегистрирован» без побочных эффектов (инвайт остаётся pending для повторного использования). +/// +public sealed class JoinService( + InvitesService invitesService, + TenantService tenantService, + IAuthStore authStore, + IPasswordHasher passwordHasher, + ITenantRepository tenantRepository) +{ + // Статус создаваемого пользователя (как у seed-пользователя TenantBootstrapService). + private const string UserActiveStatus = "active"; + + /// + /// Активирует инвайт: валидация, CAS-резервирование, создание тенанта (при необходимости) и пользователя. + /// + /// Код приглашения (пробелы по краям не важны). + /// 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; + + // 1. Чтение приглашения: GetByCodeAsync сам помечает протухшее pending как expired (ленивый переход, Task 5), + // поэтому вернувшийся pending гарантированно не истёк; отдельная проверка кода не нужна — отвечает статус. + 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)); + } + + // 2. Email обязан совпасть с приглашением (Ruling 2); пустой/иной email не проходит — инвайт не расходуется. + if (normalizedEmail != invite.Email) + { + return Failed(JoinResultDto.ErrorEmailMismatch); + } + + // 3. Пароль: минимум AuthService.MinNewPasswordLength (единый источник), текст на HTTP-слое. + if (password is null || password.Length < AuthService.MinNewPasswordLength) + { + return Failed(JoinResultDto.ErrorPasswordTooShort); + } + + // 4. Глобальная уникальность email (users.login unique, Ruling 2): предпроверка до записи, чтобы занятый + // email не создавал тенанта и не расходовал инвайт (остаётся pending — оператор видит/отзывает его). + 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/OperatorAuthService.cs b/src/core/Deal.Modules.Tenants/Application/Services/OperatorAuthService.cs index 24200ae..4d0d5fb 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/OperatorAuthService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/OperatorAuthService.cs @@ -1,129 +1,127 @@ -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Прикладной сервис аутентификации оператора (Ruling 1 этапа 7): login, logout, разрешение сессии. -/// -/// -/// Оператор ≠ пользователь тенанта: учётные записи и сессии живут в отдельных public-таблицах -/// (Operators/OperatorSessions) и за отдельным портом , а кука -/// deal_operator_session (Task 3) не совпадает с тенантной deal_session — взаимной подмены сессий нет. -/// Срок жизни операторской сессии — 12 часов. Семантика повторяет : бизнес-отказы -/// возвращаются кодами/null, тексты сообщений фиксирует HTTP-слой (Task 3). -/// -public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher) -{ - /// - /// Срок жизни сессии оператора, часов (Ruling 1: 12). Единый источник «12» — на него ссылается кука (Task 3). - /// - public const int SessionLifetimeHours = 12; - - // Статус «активен»: только активного оператора разрешает ResolveSessionAsync (удалённый/приостановленный — null). - private const string ActiveStatus = "active"; - - /// - /// Вход оператора: при успехе создаёт сессию и возвращает её raw-токен. - /// - /// Логин (регистр и пробелы не важны — нормализуется). - /// Пароль в открытом виде. - /// Токен отмены. - /// При успехе — Login и Token; иначе оба null (текст «Неверный логин или пароль оператора» фиксирует endpoint). - public async Task LoginAsync( - string login, - string password, - CancellationToken ct) - { - var normalizedLogin = NormalizeLogin(login); - if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password)) - { - return new OperatorLoginResultDto(null, null); - } - - var operatorRecord = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct); - if (operatorRecord is null || !passwordHasher.Verify(password, operatorRecord.PasswordHash)) - { - return new OperatorLoginResultDto(null, null); - } - - var token = await CreateSessionForOperatorAsync(operatorRecord, ct); - return new OperatorLoginResultDto(operatorRecord.Login, token, operatorRecord.Id); - } - - /// - /// Выход оператора: удаляет сессию по raw-токену, если он передан. - /// - /// Raw-токен из куки (может отсутствовать — no-op). - /// Токен отмены. - public async Task LogoutAsync(string? rawToken, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(rawToken)) - { - return; - } - - await operatorAuthStore.DeleteSessionAsync(SessionTokens.HashToken(rawToken), ct); - } - - /// - /// Разрешение операторской сессии по raw-токену: возвращает оператора или null (нет/протухла/оператор не активен). - /// - /// Raw-токен из куки deal_operator_session. - /// Токен отмены. - /// Идентичность активного оператора или null. - public async Task ResolveSessionAsync(string? rawToken, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(rawToken)) - { - return null; - } - - var tokenHash = SessionTokens.HashToken(rawToken); - var session = await operatorAuthStore.FindSessionByTokenHashAsync(tokenHash, ct); - OperatorIdentityDto? operatorIdentity = null; - if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow) - { - // Оператора ищем по денормализованному в сессию логину: он уникален и не меняется. - // Сессия разрешается только для активного оператора (решение ревью Task 2): удалённый - // или приостановленный оператор при живой сессии получает null — 401 на HTTP-слое. - var operatorRecord = await operatorAuthStore.FindByLoginAsync(session.Login, ct); - if (operatorRecord is not null && operatorRecord.Status == ActiveStatus) - { - operatorIdentity = new OperatorIdentityDto(operatorRecord.Id, operatorRecord.Login, operatorRecord.Status); - } - } - else if (session is not null) - { - // Очистка протухших сессий — только при обнаружении протухшей (редкий случай), не на каждый запрос - // (hot-path, Security review). - await operatorAuthStore.DeleteExpiredSessionsAsync(ct); - } - - return operatorIdentity; - } - - // Создаёт сессию оператора: raw-токен наружу, в хранилище — его SHA-256-хэш (формат как у SessionTokens). - // operatorRecord: Оператор (логин денормализуется в сессию). - // ct: Токен отмены. - // Возвращает: Raw-токен для выдачи клиенту. - private async Task CreateSessionForOperatorAsync(StoredOperatorDto operatorRecord, CancellationToken ct) - { - var rawToken = SessionTokens.NewToken(); - var session = new OperatorSessionDto( - TokenHash: SessionTokens.HashToken(rawToken), - OperatorId: operatorRecord.Id, - Login: operatorRecord.Login, - ExpiresAt: DateTimeOffset.UtcNow.AddHours(SessionLifetimeHours)); - - await operatorAuthStore.CreateSessionAsync(session, ct); - return rawToken; - } - - // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения). - // login: Входной логин. - // Возвращает: Нормализованный логин (пустая строка, если вход был пустым). - private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim(); -} +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Modules.Tenants.Application.Services; + +/// +/// Прикладной сервис аутентификации оператора (Ruling 1 этапа 7): login, logout, разрешение сессии. +/// +/// +/// Оператор ≠ пользователь тенанта: учётные записи и сессии живут в отдельных public-таблицах +/// (Operators/OperatorSessions) и за отдельным портом , а кука +/// deal_operator_session (Task 3) не совпадает с тенантной deal_session — взаимной подмены сессий нет. +/// Срок жизни операторской сессии — 12 часов. Семантика повторяет : бизнес-отказы +/// возвращаются кодами/null, тексты сообщений фиксирует HTTP-слой (Task 3). +/// +public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher) +{ + /// + /// Срок жизни сессии оператора, часов (Ruling 1: 12). Единый источник «12» — на него ссылается кука (Task 3). + /// + public const int SessionLifetimeHours = 12; + + // Статус «активен»: только активного оператора разрешает ResolveSessionAsync (удалённый/приостановленный — null). + private const string ActiveStatus = "active"; + + /// + /// Вход оператора: при успехе создаёт сессию и возвращает её raw-токен. + /// + /// Логин (регистр и пробелы не важны — нормализуется). + /// Пароль в открытом виде. + /// Токен отмены. + /// При успехе — Login и Token; иначе оба null (текст «Неверный логин или пароль оператора» фиксирует endpoint). + public async Task LoginAsync( + string login, + string password, + CancellationToken ct) + { + var normalizedLogin = NormalizeLogin(login); + if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(password)) + { + return new OperatorLoginResultDto(null, null); + } + + var operatorRecord = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct); + if (operatorRecord is null || !passwordHasher.Verify(password, operatorRecord.PasswordHash)) + { + return new OperatorLoginResultDto(null, null); + } + + var token = await CreateSessionForOperatorAsync(operatorRecord, ct); + return new OperatorLoginResultDto(operatorRecord.Login, token, operatorRecord.Id); + } + + /// + /// Выход оператора: удаляет сессию по raw-токену, если он передан. + /// + /// Raw-токен из куки (может отсутствовать — no-op). + /// Токен отмены. + public async Task LogoutAsync(string? rawToken, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(rawToken)) + { + return; + } + + await operatorAuthStore.DeleteSessionAsync(SessionTokens.HashToken(rawToken), ct); + } + + /// + /// Разрешение операторской сессии по raw-токену: возвращает оператора или null (нет/протухла/оператор не активен). + /// + /// Raw-токен из куки deal_operator_session. + /// Токен отмены. + /// Идентичность активного оператора или null. + public async Task ResolveSessionAsync(string? rawToken, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(rawToken)) + { + return null; + } + + var tokenHash = SessionTokens.HashToken(rawToken); + var session = await operatorAuthStore.FindSessionByTokenHashAsync(tokenHash, ct); + OperatorIdentityDto? operatorIdentity = null; + if (session is not null && session.ExpiresAt > DateTimeOffset.UtcNow) + { + // Оператора ищем по денормализованному в сессию логину: он уникален и не меняется. + // Сессия разрешается только для активного оператора (решение ревью Task 2): удалённый + // или приостановленный оператор при живой сессии получает null — 401 на HTTP-слое. + var operatorRecord = await operatorAuthStore.FindByLoginAsync(session.Login, ct); + if (operatorRecord is not null && operatorRecord.Status == ActiveStatus) + { + operatorIdentity = new OperatorIdentityDto(operatorRecord.Id, operatorRecord.Login, operatorRecord.Status); + } + } + else if (session is not null) + { + // Очистка протухших сессий — только при обнаружении протухшей (редкий случай), не на каждый запрос + // (hot-path, Security review). + await operatorAuthStore.DeleteExpiredSessionsAsync(ct); + } + + return operatorIdentity; + } + + // Создаёт сессию оператора: raw-токен наружу, в хранилище — его SHA-256-хэш (формат как у SessionTokens). + // operatorRecord: Оператор (логин денормализуется в сессию). + // ct: Токен отмены. + // Возвращает: Raw-токен для выдачи клиенту. + private async Task CreateSessionForOperatorAsync(StoredOperatorDto operatorRecord, CancellationToken ct) + { + var rawToken = SessionTokens.NewToken(); + var session = new OperatorSessionDto( + TokenHash: SessionTokens.HashToken(rawToken), + OperatorId: operatorRecord.Id, + Login: operatorRecord.Login, + ExpiresAt: DateTimeOffset.UtcNow.AddHours(SessionLifetimeHours)); + + await operatorAuthStore.CreateSessionAsync(session, ct); + return rawToken; + } + + // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения/сравнения). + // login: Входной логин. + // Возвращает: Нормализованный логин (пустая строка, если вход был пустым). + private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim(); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Services/OperatorBootstrapService.cs b/src/core/Deal.Modules.Tenants/Application/Services/OperatorBootstrapService.cs index e459f10..2347050 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/OperatorBootstrapService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/OperatorBootstrapService.cs @@ -1,93 +1,91 @@ -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Bootstrap оператора при старте (Ruling 1 этапа 7): идемпотентный seed из env DEAL_OPERATOR_*. -/// -/// -/// Шаг вызывается хостом при старте (встраивается в TenantBootstrapService или идёт отдельным -/// hosted-шагом после него — подключение в Task 3 вместе с EF-адаптером ). -/// В Development при отсутствии кред используется дефолт operator/operator (зеркало dev-seed admin/admin); -/// в Production без env-кред шаг пропускается — оператора заводит админ позже через env и рестарт, -/// кода регистрации оператора нет. Секреты не логируются и не возвращаются. -/// -public sealed class OperatorBootstrapService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher) -{ - /// - /// Переменная окружения: логин оператора. - /// - public const string LoginEnvKey = "DEAL_OPERATOR_LOGIN"; - - /// - /// Переменная окружения: пароль оператора. - /// - public const string PasswordEnvKey = "DEAL_OPERATOR_PASSWORD"; - - /// - /// Дефолтный логин в Development при отсутствии env-кред (Ruling 1). - /// - public const string DefaultOperatorLogin = "operator"; - - /// - /// Дефолтный пароль в Development при отсутствии env-кред (Ruling 1). - /// - public const string DefaultOperatorPassword = "operator"; - - private const string ActiveStatus = "active"; - - /// - /// Гарантирует наличие оператора: создаёт, если его ещё нет (идемпотентно). - /// - /// Логин из env () или null/пусто, если не задан. - /// Пароль из env () или null/пусто, если не задан. - /// - /// true в Development: при отсутствии кред берутся дефолты operator/operator; - /// false (Production) при отсутствии кред — шаг пропускается, хост логирует warning. - /// - /// Токен отмены. - /// Логин оператора, присутствующего после шага (созданного или уже существовавшего); null — шаг пропущен. - public async Task EnsureOperatorAsync( - string? login, - string? password, - bool allowDevelopmentDefaults, - CancellationToken ct) - { - var normalizedLogin = NormalizeLogin(login); - var resolvedPassword = password; - if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(resolvedPassword)) - { - if (!allowDevelopmentDefaults) - { - return null; - } - - normalizedLogin = DefaultOperatorLogin; - resolvedPassword = DefaultOperatorPassword; - } - - // Идемпотентность: оператор с таким логином уже есть — пароль не перезаписываем. - var existing = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct); - if (existing is not null) - { - return existing.Login; - } - - await operatorAuthStore.CreateAsync( - new StoredOperatorDto( - Id: Guid.NewGuid(), - Login: normalizedLogin, - Status: ActiveStatus, - PasswordHash: passwordHasher.Hash(resolvedPassword)), - ct); - return normalizedLogin; - } - - // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения). - // login: Входной логин (может быть null — как «не задан»). - // Возвращает: Нормализованный логин (пустая строка, если вход был пустым). - private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim(); -} +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Modules.Tenants.Application.Services; + +/// +/// Bootstrap оператора при старте (Ruling 1 этапа 7): идемпотентный seed из env DEAL_OPERATOR_*. +/// +/// +/// Шаг вызывается хостом при старте (встраивается в TenantBootstrapService или идёт отдельным +/// hosted-шагом после него — подключение в Task 3 вместе с EF-адаптером ). +/// В Development при отсутствии кред используется дефолт operator/operator (зеркало dev-seed admin/admin); +/// в Production без env-кред шаг пропускается — оператора заводит админ позже через env и рестарт, +/// кода регистрации оператора нет. Секреты не логируются и не возвращаются. +/// +public sealed class OperatorBootstrapService(IOperatorAuthStore operatorAuthStore, IPasswordHasher passwordHasher) +{ + /// + /// Переменная окружения: логин оператора. + /// + public const string LoginEnvKey = "DEAL_OPERATOR_LOGIN"; + + /// + /// Переменная окружения: пароль оператора. + /// + public const string PasswordEnvKey = "DEAL_OPERATOR_PASSWORD"; + + /// + /// Дефолтный логин в Development при отсутствии env-кред (Ruling 1). + /// + public const string DefaultOperatorLogin = "operator"; + + /// + /// Дефолтный пароль в Development при отсутствии env-кред (Ruling 1). + /// + public const string DefaultOperatorPassword = "operator"; + + private const string ActiveStatus = "active"; + + /// + /// Гарантирует наличие оператора: создаёт, если его ещё нет (идемпотентно). + /// + /// Логин из env () или null/пусто, если не задан. + /// Пароль из env () или null/пусто, если не задан. + /// + /// true в Development: при отсутствии кред берутся дефолты operator/operator; + /// false (Production) при отсутствии кред — шаг пропускается, хост логирует warning. + /// + /// Токен отмены. + /// Логин оператора, присутствующего после шага (созданного или уже существовавшего); null — шаг пропущен. + public async Task EnsureOperatorAsync( + string? login, + string? password, + bool allowDevelopmentDefaults, + CancellationToken ct) + { + var normalizedLogin = NormalizeLogin(login); + var resolvedPassword = password; + if (string.IsNullOrEmpty(normalizedLogin) || string.IsNullOrEmpty(resolvedPassword)) + { + if (!allowDevelopmentDefaults) + { + return null; + } + + normalizedLogin = DefaultOperatorLogin; + resolvedPassword = DefaultOperatorPassword; + } + + // Идемпотентность: оператор с таким логином уже есть — пароль не перезаписываем. + var existing = await operatorAuthStore.FindByLoginAsync(normalizedLogin, ct); + if (existing is not null) + { + return existing.Login; + } + + await operatorAuthStore.CreateAsync( + new StoredOperatorDto( + Id: Guid.NewGuid(), + Login: normalizedLogin, + Status: ActiveStatus, + PasswordHash: passwordHasher.Hash(resolvedPassword)), + ct); + return normalizedLogin; + } + + // Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения). + // login: Входной логин (может быть null — как «не задан»). + // Возвращает: Нормализованный логин (пустая строка, если вход был пустым). + private static string NormalizeLogin(string? login) => (login ?? string.Empty).ToLowerInvariant().Trim(); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Services/SessionTokens.cs b/src/core/Deal.Modules.Tenants/Application/Services/SessionTokens.cs index 84d1a0f..30b9584 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/SessionTokens.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/SessionTokens.cs @@ -1,10 +1,6 @@ using System.Security.Cryptography; using System.Text; using Deal.SharedKernel; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; namespace Deal.Modules.Tenants.Application.Services; diff --git a/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs b/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs index e6c7907..79c71ff 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs @@ -1,8 +1,6 @@ using System.Text.Json; -using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Models; namespace Deal.Modules.Tenants.Application.Services; diff --git a/src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs b/src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs index 1e947dc..1240a87 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs @@ -1,172 +1,170 @@ -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Прикладной сервис операторского реестра тенантов (GET /api/operator/tenants[/{id}], POST (создание), -/// suspend/unsuspend — план Task 7): чтение реестра + пользователей, создание тенанта (с провижинингом схемы) и -/// смена статуса приостановки. -/// -/// -/// Аудит (tenant_created/tenant_status_changed) пишет HTTP-слой (паттерн Task 4/5), сервис возвращает результат -/// с кодами ошибок; impersonation живёт в (создание tenant-сессии -/// пользователя). Создание с email (план Task 7: «email-опция создаёт сразу пользователя-владельца») заводит -/// пользователя с автоматическим одноразовым паролем — владелец получает его от оператора и меняет после первого -/// входа (прямой ввод пароля оператором в контракте не предусмотрен; см. ). -/// Счётчики пользователей считаются по списку пользователей тенанта (порт ); на масштабах -/// операторской админки N+1 осознан (сводка usage/лимитов появится в Task 8–10). -/// -public sealed class TenantAdminService( - ITenantRepository tenantRepository, - IAuthStore authStore, - TenantService tenantService, - IPasswordHasher passwordHasher) -{ - // Случайные байты одноразового пароля владельца: 12 → ровно 16 символов Base64Url (как InviteCodeGenerator). - private const int InitialPasswordRandomByteCount = 12; - - /// - /// Создаёт тенанта оператором (план Task 7): строка реестра (Status active) + провижининг схемы - /// (); при email — сразу пользователь-владелец с одноразовым паролем. - /// - /// Имя тенанта (обязательно; обрезается). - /// Email владельца (опционально): создаёт пользователя-владельца сразу, иначе владелец - /// заводится инвайтом (Ruling 2). - /// Токен отмены. - /// - /// 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); - } - - // Глобальная уникальность email (users.login unique, Ruling 2): предпроверка до записи, чтобы занятый - // email не создавал тенанта без владельца (как JoinService, Task 6). Гонка закрыта unique-индексом. - 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); - } - - /// - /// Список тенантов со счётчиками пользователей (реестр + счётчики, план Task 7). - /// - /// Токен отмены. - /// Тенанты в порядке создания (реестр) с числом пользователей каждого. - 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; - } - - /// - /// Детали тенанта с пользователями (GET /api/operator/tenants/{id}). - /// - /// Идентификатор тенанта. - /// Токен отмены. - /// Детали и пользователи тенанта (по 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); - } - - /// - /// Меняет статус тенанта (POST …/suspend → suspended, …/unsuspend → active; аудит пишет HTTP-слой). - /// - /// Идентификатор тенанта. - /// Новый статус — константа 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; + +namespace Deal.Modules.Tenants.Application.Services; + +/// +/// Прикладной сервис операторского реестра тенантов (GET /api/operator/tenants[/{id}], POST (создание), +/// suspend/unsuspend — план Task 7): чтение реестра + пользователей, создание тенанта (с провижинингом схемы) и +/// смена статуса приостановки. +/// +/// +/// Аудит (tenant_created/tenant_status_changed) пишет HTTP-слой (паттерн Task 4/5), сервис возвращает результат +/// с кодами ошибок; impersonation живёт в (создание tenant-сессии +/// пользователя). Создание с email (план Task 7: «email-опция создаёт сразу пользователя-владельца») заводит +/// пользователя с автоматическим одноразовым паролем — владелец получает его от оператора и меняет после первого +/// входа (прямой ввод пароля оператором в контракте не предусмотрен; см. ). +/// Счётчики пользователей считаются по списку пользователей тенанта (порт ); на масштабах +/// операторской админки N+1 осознан (сводка usage/лимитов появится в Task 8–10). +/// +public sealed class TenantAdminService( + ITenantRepository tenantRepository, + IAuthStore authStore, + TenantService tenantService, + IPasswordHasher passwordHasher) +{ + // Случайные байты одноразового пароля владельца: 12 → ровно 16 символов Base64Url (как InviteCodeGenerator). + private const int InitialPasswordRandomByteCount = 12; + + /// + /// Создаёт тенанта оператором (план Task 7): строка реестра (Status active) + провижининг схемы + /// (); при email — сразу пользователь-владелец с одноразовым паролем. + /// + /// Имя тенанта (обязательно; обрезается). + /// Email владельца (опционально): создаёт пользователя-владельца сразу, иначе владелец + /// заводится инвайтом (Ruling 2). + /// Токен отмены. + /// + /// 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); + } + + // Глобальная уникальность email (users.login unique, Ruling 2): предпроверка до записи, чтобы занятый + // email не создавал тенанта без владельца (как JoinService, Task 6). Гонка закрыта unique-индексом. + 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); + } + + /// + /// Список тенантов со счётчиками пользователей (реестр + счётчики, план Task 7). + /// + /// Токен отмены. + /// Тенанты в порядке создания (реестр) с числом пользователей каждого. + 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; + } + + /// + /// Детали тенанта с пользователями (GET /api/operator/tenants/{id}). + /// + /// Идентификатор тенанта. + /// Токен отмены. + /// Детали и пользователи тенанта (по 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); + } + + /// + /// Меняет статус тенанта (POST …/suspend → suspended, …/unsuspend → active; аудит пишет HTTP-слой). + /// + /// Идентификатор тенанта. + /// Новый статус — константа 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/TenantService.cs b/src/core/Deal.Modules.Tenants/Application/Services/TenantService.cs index 5d4a4dd..261410b 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/TenantService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/TenantService.cs @@ -1,57 +1,54 @@ -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; - -namespace Deal.Modules.Tenants.Application.Services; - -/// -/// Прикладной сервис реестра тенантов: создание тенанта и его списка. -/// -/// Создание тенанта — единая транзакция по смыслу: запись в public.tenants и провижининг схемы. -public sealed class TenantService(ITenantRepository tenantRepository, ITenantProvisioner tenantProvisioner) -{ - /// - /// Создаёт тенанта (id = новый Guid в формате "N") и провижинит его схему. - /// - /// Имя тенанта. - /// Токен отмены. - /// Идентификатор созданного тенанта (он же имя схемы tenant_<id>). - public Task CreateTenantAsync(string name, CancellationToken ct) => - CreateTenantAsync(name, Guid.NewGuid(), ct); - - /// - /// Создаёт тенанта с явным id и провижинит его схему (bootstrap дефолтного тенанта, Ruling 8). - /// - /// Имя тенанта. - /// Идентификатор тенанта (определяет имя схемы). - /// Токен отмены. - /// Идентификатор созданного тенанта (он же имя схемы tenant_<id>). - public async Task CreateTenantAsync( - string name, - Guid id, - CancellationToken ct) - { - await tenantRepository.CreateAsync( - new TenantRecordDto( - Id: id, - Name: name, - Status: TenantStatuses.Active, - CreatedAt: DateTimeOffset.UtcNow), - ct); - - var tenantIdValue = id.ToString("N"); - await tenantProvisioner.ProvisionAsync(new TenantId(tenantIdValue), ct); - return new TenantId(tenantIdValue); - } - - /// - /// Возвращает список всех тенантов. - /// - /// Токен отмены. - /// Список тенантов. - public Task> ListTenantsAsync(CancellationToken ct) => - tenantRepository.ListAsync(ct); -} +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.SharedKernel.Tenants.Models; + +namespace Deal.Modules.Tenants.Application.Services; + +/// +/// Прикладной сервис реестра тенантов: создание тенанта и его списка. +/// +/// Создание тенанта — единая транзакция по смыслу: запись в public.tenants и провижининг схемы. +public sealed class TenantService(ITenantRepository tenantRepository, ITenantProvisioner tenantProvisioner) +{ + /// + /// Создаёт тенанта (id = новый Guid в формате "N") и провижинит его схему. + /// + /// Имя тенанта. + /// Токен отмены. + /// Идентификатор созданного тенанта (он же имя схемы tenant_<id>). + public Task CreateTenantAsync(string name, CancellationToken ct) => + CreateTenantAsync(name, Guid.NewGuid(), ct); + + /// + /// Создаёт тенанта с явным id и провижинит его схему (bootstrap дефолтного тенанта, Ruling 8). + /// + /// Имя тенанта. + /// Идентификатор тенанта (определяет имя схемы). + /// Токен отмены. + /// Идентификатор созданного тенанта (он же имя схемы tenant_<id>). + public async Task CreateTenantAsync( + string name, + Guid id, + CancellationToken ct) + { + await tenantRepository.CreateAsync( + new TenantRecordDto( + Id: id, + Name: name, + Status: TenantStatuses.Active, + CreatedAt: DateTimeOffset.UtcNow), + ct); + + var tenantIdValue = id.ToString("N"); + await tenantProvisioner.ProvisionAsync(new TenantId(tenantIdValue), ct); + return new TenantId(tenantIdValue); + } + + /// + /// Возвращает список всех тенантов. + /// + /// Токен отмены. + /// Список тенантов. + public Task> ListTenantsAsync(CancellationToken ct) => + tenantRepository.ListAsync(ct); +} diff --git a/src/core/Deal.Modules.Tenants/Application/Services/TokenBudgetService.cs b/src/core/Deal.Modules.Tenants/Application/Services/TokenBudgetService.cs index 713fcf2..2134fa2 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/TokenBudgetService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/TokenBudgetService.cs @@ -1,7 +1,4 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; namespace Deal.Modules.Tenants.Application.Services; diff --git a/src/core/Deal.Modules.Tenants/Application/Services/TokenUsageEventService.cs b/src/core/Deal.Modules.Tenants/Application/Services/TokenUsageEventService.cs index 27f3a3e..7d1334f 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/TokenUsageEventService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/TokenUsageEventService.cs @@ -1,7 +1,5 @@ -using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Models; namespace Deal.Modules.Tenants.Application.Services; diff --git a/src/core/Deal.SharedKernel/Tenants/Models/TenantId.cs b/src/core/Deal.SharedKernel/Tenants/Models/TenantId.cs index 03f5328..6ac8f26 100644 --- a/src/core/Deal.SharedKernel/Tenants/Models/TenantId.cs +++ b/src/core/Deal.SharedKernel/Tenants/Models/TenantId.cs @@ -1,5 +1,3 @@ -using Deal.SharedKernel.Tenants.Abstractions; - namespace Deal.SharedKernel.Tenants.Models; /// diff --git a/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs index fe9c2b6..3120757 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs @@ -1,117 +1,113 @@ -using Deal.Api.Configuration; -using Deal.Api.Hosting; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Tenants; - -namespace Deal.Tests.Unit.Api; - -/// -/// Тесты логики прохода (этап 12, пакет B): авто-очистка -/// audit_log по retention, сброс накопительных полей tenant_limits прошедших периодов и уборка -/// завершившихся окон распределённых счётчиков. Тайминги цикла не тестируются — итерация через публичный -/// . -/// -/// -/// Скоупы/DI — реальный ServiceCollection с фейками хранилищ (, -/// , ), зеркалящими семантику -/// EF-адаптеров. Срок хранения аудита — . -/// -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; + +/// +/// Тесты логики прохода (этап 12, пакет B): авто-очистка +/// audit_log по retention, сброс накопительных полей tenant_limits прошедших периодов и уборка +/// завершившихся окон распределённых счётчиков. Тайминги цикла не тестируются — итерация через публичный +/// . +/// +/// +/// Скоупы/DI — реальный ServiceCollection с фейками хранилищ (, +/// , ), зеркалящими семантику +/// EF-адаптеров. Срок хранения аудита — . +/// +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/DiscoveryEndpointsHelpersTests.cs b/src/core/tests/Deal.Tests.Unit/Api/DiscoveryEndpointsHelpersTests.cs index f743f52..c581cc0 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/DiscoveryEndpointsHelpersTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/DiscoveryEndpointsHelpersTests.cs @@ -1,5 +1,4 @@ using Deal.Api.Endpoints; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Api; diff --git a/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs index 1389d1b..9d0eda8 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs @@ -1,226 +1,217 @@ -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.Exceptions; -using Deal.Modules.Discovery.Application.Extensions; -using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Discovery; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Tenants; - -namespace Deal.Tests.Unit.Api; - -/// -/// Тесты DiscoveryWorkerScheduler — фоновый цикл воркера Discovery (план Task 18, Ruling 10; эталон -/// PipelineWorkerSchedulerTests): каждые 5 с обход ВСЕХ тенантов реестра, на каждый — собственный scope с -/// ITenantContext и один тик DiscoveryWorkerService. -/// -/// -/// Тайминги цикла (Timer 5 с, первый проход, stop) не тестируются — тестируется тело прохода RunCycleAsync -/// (как PipelineWorkerSchedulerTests). Провайдер собирает РЕАЛЬНЫЕ сервисы модуля Discovery (AddDiscoveryModule) -/// на тенант-фейках (FakeDiscoveryStore/FakeSettingsStore/FakeDiscoveryGateway по ITenantContext): воркер ходит -/// тем же путём, что и в проде (SetTenant → scoped-резолв → TickOnce). ML/ИИ в настройках тенантов выключены — -/// оценка идёт эвристикой по ключам. -/// -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 (план Task 18, Ruling 10; эталон +/// PipelineWorkerSchedulerTests): каждые 5 с обход ВСЕХ тенантов реестра, на каждый — собственный scope с +/// ITenantContext и один тик DiscoveryWorkerService. +/// +/// +/// Тайминги цикла (Timer 5 с, первый проход, stop) не тестируются — тестируется тело прохода RunCycleAsync +/// (как PipelineWorkerSchedulerTests). Провайдер собирает РЕАЛЬНЫЕ сервисы модуля Discovery (AddDiscoveryModule) +/// на тенант-фейках (FakeDiscoveryStore/FakeSettingsStore/FakeDiscoveryGateway по ITenantContext): воркер ходит +/// тем же путём, что и в проде (SetTenant → scoped-резолв → TickOnce). ML/ИИ в настройках тенантов выключены — +/// оценка идёт эвристикой по ключам. +/// +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/IngressRateLimitInterceptorTests.cs b/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs index eff2d48..36ade9e 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs @@ -1,169 +1,163 @@ -using Deal.Api.Configuration; -using Deal.Api.Middleware; -using Deal.Grpc.Telegram; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Grpc.Core; -using Grpc.Health.V1; -using Grpc.Net.Client; -using Microsoft.Extensions.DependencyInjection; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Tenants; -using Deal.Tests.Unit.Modules.Kanban; - -namespace Deal.Tests.Unit.Api; - -/// -/// In-proc gRPC-тесты IngressRateLimitInterceptor (план Task 11, Ruling 5): фиксированное окно -/// (GrpcIngressPerMinute в минуту) по tenant-id из metadata — превышение → RESOURCE_EXHAUSTED; -/// у разных тенантов собственные окна; стандартный grpc.health.v1.Health лимитом не режется. -/// -/// -/// Хост — TelegramIngressTestHost с Enabled-опциями (добавляет интерцептор, общий singleton-лимитер -/// и grpc.health.v1, как в Program.cs). Все вызовы несут service-token сценария. -/// -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 диалога сценариев PushMessage. - private const string DialogId = "d_channel_rate_limit"; - - // Окно сценария по умолчанию: 2/мин — быстрый тест без ожидания реального окна. - private const int DefaultPermitsPerMinute = 2; - - // Окно health-сценария: 1/мин — единственный вызов исчерпывает лимит. - private const int HealthScenarioPermitsPerMinute = 1; - - // ─── Окно интерцептора ───────────────────────────────────────────────── - - /// - /// 3-й вызов тенанта в минуту (окно 2/мин) — RPC RESOURCE_EXHAUSTED до метода сервиса; - /// другой тенант имеет собственное окно и проходит (партиция по tenant-id). - /// - [Fact] - public async Task PushMessage_ExceedingTenantWindow_ThirdRejectedOtherTenantPasses() - { - var registry = new FakeTenantRegistry(Tenant(TenantA), Tenant(TenantB)); - - await RunAsync( - registry, - services => services.AddScoped(_ => new FakePipelineStore()), - async channel => - { - PushMessageReply first = await PushAsync(channel, TenantA); - PushMessageReply 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 — отдельная партиция: вызов проходит. - PushMessageReply tenantB = await PushAsync(channel, TenantB); - Assert.True(tenantB.Accepted); - }); - } - - /// - /// Health (grpc.health.v1) освобождён от лимита: при исчерпанном окне ингресса health отвечает SERVING. - /// - [Fact] - public async Task HealthCheck_IsNotRateLimited_WhenIngressWindowExhausted() - { - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync( - registry, - services => services.AddScoped(_ => new FakePipelineStore()), - async channel => - { - // Окно ингресса — 1/мин: единственный разрешённый вызов исчерпывает лимит. - PushMessageReply push = await PushAsync(channel, TenantA); - Assert.True(push.Accepted); - - // Health не потребляет и не режется лимитом ингресса (liveness инфраструктуры, Ruling 5). - 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, - }); - - // Вызывает PushMessage с metadata сценария (service-token + tenant-id, deadline 10 с). - // channel: Канал к хосту. - // tenantId: Id тенанта в metadata. - private static Task PushAsync(GrpcChannel channel, Guid tenantId) - { - var client = new IngressService.IngressServiceClient(channel); - var request = new PushMessageRequest - { - DialogId = DialogId, - ChannelName = "Канал", - ChannelHandle = "kanal_handle", - ChannelHue = "#a33", - Text = "Сообщение сценария лимита", - MsgId = 1, - }; - AsyncUnaryCall call = client.PushMessageAsync( - 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.Telegram; +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 (план Task 11, Ruling 5): фиксированное окно +/// (GrpcIngressPerMinute в минуту) по tenant-id из metadata — превышение → RESOURCE_EXHAUSTED; +/// у разных тенантов собственные окна; стандартный grpc.health.v1.Health лимитом не режется. +/// +/// +/// Хост — TelegramIngressTestHost с Enabled-опциями (добавляет интерцептор, общий singleton-лимитер +/// и grpc.health.v1, как в Program.cs). Все вызовы несут service-token сценария. +/// +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 диалога сценариев PushMessage. + private const string DialogId = "d_channel_rate_limit"; + + // Окно сценария по умолчанию: 2/мин — быстрый тест без ожидания реального окна. + private const int DefaultPermitsPerMinute = 2; + + // Окно health-сценария: 1/мин — единственный вызов исчерпывает лимит. + private const int HealthScenarioPermitsPerMinute = 1; + + // ─── Окно интерцептора ───────────────────────────────────────────────── + + /// + /// 3-й вызов тенанта в минуту (окно 2/мин) — RPC RESOURCE_EXHAUSTED до метода сервиса; + /// другой тенант имеет собственное окно и проходит (партиция по tenant-id). + /// + [Fact] + public async Task PushMessage_ExceedingTenantWindow_ThirdRejectedOtherTenantPasses() + { + var registry = new FakeTenantRegistry(Tenant(TenantA), Tenant(TenantB)); + + await RunAsync( + registry, + services => services.AddScoped(_ => new FakePipelineStore()), + async channel => + { + PushMessageReply first = await PushAsync(channel, TenantA); + PushMessageReply 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 — отдельная партиция: вызов проходит. + PushMessageReply tenantB = await PushAsync(channel, TenantB); + Assert.True(tenantB.Accepted); + }); + } + + /// + /// Health (grpc.health.v1) освобождён от лимита: при исчерпанном окне ингресса health отвечает SERVING. + /// + [Fact] + public async Task HealthCheck_IsNotRateLimited_WhenIngressWindowExhausted() + { + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync( + registry, + services => services.AddScoped(_ => new FakePipelineStore()), + async channel => + { + // Окно ингресса — 1/мин: единственный разрешённый вызов исчерпывает лимит. + PushMessageReply push = await PushAsync(channel, TenantA); + Assert.True(push.Accepted); + + // Health не потребляет и не режется лимитом ингресса (liveness инфраструктуры, Ruling 5). + 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, + }); + + // Вызывает PushMessage с metadata сценария (service-token + tenant-id, deadline 10 с). + // channel: Канал к хосту. + // tenantId: Id тенанта в metadata. + private static Task PushAsync(GrpcChannel channel, Guid tenantId) + { + var client = new IngressService.IngressServiceClient(channel); + var request = new PushMessageRequest + { + DialogId = DialogId, + ChannelName = "Канал", + ChannelHandle = "kanal_handle", + ChannelHue = "#a33", + Text = "Сообщение сценария лимита", + MsgId = 1, + }; + AsyncUnaryCall call = client.PushMessageAsync( + 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/LoginAttemptGuardTests.cs b/src/core/tests/Deal.Tests.Unit/Api/LoginAttemptGuardTests.cs index 0ee0e97..cdf2b9a 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/LoginAttemptGuardTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/LoginAttemptGuardTests.cs @@ -1,215 +1,212 @@ -using Deal.Api.Configuration; -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Tenants; - -namespace Deal.Tests.Unit.Api; - -/// -/// Unit-тесты LoginAttemptGuard (план Task 11, Ruling 5; этап 12, пакет B — хранилище Postgres): -/// фиксированное окно по ключу ip|login — 5 неудач за 15 минут блокируют следующий вход; успех -/// сбрасывает счётчик; окно истекает; ключи разных ip/login изолированы; при RateLimit:Enabled=false -/// (dev/тесты) гвард выключен. -/// -/// -/// Хранилище — (семантика public.rate_limit_counters); часы — -/// инъекцией Func<DateTimeOffset>: границы окна тестируются на фиксированном «сейчас» без ожидания. -/// -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 неудач подряд в окне — следующий вход ключа блокирован (Ruling 5: ≥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)); - } - - // ─── Сброс при успехе (Ruling 5) ─────────────────────────────────────── - - /// - /// Успешный вход сбрасывает счётчик: после части неудач и Reset ключ снова проходит полные 5. - /// - [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 (дефолт dev/тестов) — гвард выключен: неудачи не копятся, блокировок нет. - /// - [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)); - } - - /// - /// Пустой/пробельный логин ключа не имеет — блокировке не подлежит (нет ключа ip|""). - /// - [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 (план Task 11, Ruling 5; этап 12, пакет B — хранилище Postgres): +/// фиксированное окно по ключу ip|login — 5 неудач за 15 минут блокируют следующий вход; успех +/// сбрасывает счётчик; окно истекает; ключи разных ip/login изолированы; при RateLimit:Enabled=false +/// (dev/тесты) гвард выключен. +/// +/// +/// Хранилище — (семантика public.rate_limit_counters); часы — +/// инъекцией Func<DateTimeOffset>: границы окна тестируются на фиксированном «сейчас» без ожидания. +/// +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 неудач подряд в окне — следующий вход ключа блокирован (Ruling 5: ≥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)); + } + + // ─── Сброс при успехе (Ruling 5) ─────────────────────────────────────── + + /// + /// Успешный вход сбрасывает счётчик: после части неудач и Reset ключ снова проходит полные 5. + /// + [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 (дефолт dev/тестов) — гвард выключен: неудачи не копятся, блокировок нет. + /// + [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)); + } + + /// + /// Пустой/пробельный логин ключа не имеет — блокировке не подлежит (нет ключа ip|""). + /// + [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 450b915..7cd6d89 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs @@ -1,238 +1,226 @@ -using Deal.Api.Hosting; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Grpc; -using Deal.Tests.Unit.Modules.Tenants; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Api; - -/// -/// Тесты MlOutboxFlushScheduler — логика прохода цикла выгрузки обучения ML (план Task 16, Ruling 6; -/// аналог flush_outbox ml_client.py L56–82): обход всех тенантов реестра, в собственном scope каждого — -/// порции по 10 строк (≤100/цикл) в ml-service (TrainBatch на реальном канале к фейк-серверу), удаление строк -/// ТОЛЬКО после успеха; при недоступности сервиса строки остаются (ретрай на следующем цикле). -/// -/// -/// Тайминги цикла (Timer 10 с) не тестируются — тестируется итерация через публичный -/// . DI-провайдер поднимается на реальном ServiceCollection: -/// ITenantRepository — фейк, IMlLearningStore/ISettingsStore выбираются по текущему ITenantContext (как -/// реальные адаптеры, строящие TenantDbContext от схемы тенанта); IMlTrainClient — реальный GrpcMlClient -/// к in-proc фейк-ml-service (проверяются metadata/маппинг TrainBatch «по проводу»). Сеть наружу не используется. -/// -[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); - - // Потолок 100/цикл (flush_outbox L56): 10 батчей по 10, остаток 5 ждёт следующего цикла. - 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()); - // Recorder GrpcMlClient (этап 10, T2): история событий + лимиты — фейки (TrainBatch recorder не зовёт). - 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 (план Task 16, Ruling 6; +/// аналог flush_outbox ml_client.py L56–82): обход всех тенантов реестра, в собственном scope каждого — +/// порции по 10 строк (≤100/цикл) в ml-service (TrainBatch на реальном канале к фейк-серверу), удаление строк +/// ТОЛЬКО после успеха; при недоступности сервиса строки остаются (ретрай на следующем цикле). +/// +/// +/// Тайминги цикла (Timer 10 с) не тестируются — тестируется итерация через публичный +/// . DI-провайдер поднимается на реальном ServiceCollection: +/// ITenantRepository — фейк, IMlLearningStore/ISettingsStore выбираются по текущему ITenantContext (как +/// реальные адаптеры, строящие TenantDbContext от схемы тенанта); IMlTrainClient — реальный GrpcMlClient +/// к in-proc фейк-ml-service (проверяются metadata/маппинг TrainBatch «по проводу»). Сеть наружу не используется. +/// +[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); + + // Потолок 100/цикл (flush_outbox L56): 10 батчей по 10, остаток 5 ждёт следующего цикла. + 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()); + // Recorder GrpcMlClient (этап 10, T2): история событий + лимиты — фейки (TrainBatch recorder не зовёт). + 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/OperatorAuditEndpointsHelpersTests.cs b/src/core/tests/Deal.Tests.Unit/Api/OperatorAuditEndpointsHelpersTests.cs index 53b6e2f..d433177 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/OperatorAuditEndpointsHelpersTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/OperatorAuditEndpointsHelpersTests.cs @@ -1,10 +1,5 @@ using Deal.Api.Endpoints; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Api; diff --git a/src/core/tests/Deal.Tests.Unit/Api/OperatorBootstrapHostedServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Api/OperatorBootstrapHostedServiceTests.cs index 29c1dc6..6daa436 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/OperatorBootstrapHostedServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/OperatorBootstrapHostedServiceTests.cs @@ -1,209 +1,205 @@ -using Deal.Api.Hosting; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Tenants; - -namespace Deal.Tests.Unit.Api; - -/// -/// Тесты hosted-шага bootstrap оператора (Task 3, Ruling 1): env DEAL_OPERATOR_* → EnsureOperatorAsync -/// на старте + warning-логи при skip в prod и при частичной env-конфигурации (решение ревью Task 2). -/// -/// -/// Сам шаг (создание/идемпотентность/нормализация) покрыт OperatorBootstrapServiceTests (Task 2); здесь -/// проверяется встраивание в старт: чтение env, dev-дефолт, пропуск в Production и тексты предупреждений. -/// Секреты (пароли) в логах не появляются — проверяется на сценарии с явными кредами. -/// -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); - - // Частичная env-конфигурация — warning, а не молчаливый дефолт (решение ревью Task 2). - 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 оператора (Task 3, Ruling 1): env DEAL_OPERATOR_* → EnsureOperatorAsync +/// на старте + warning-логи при skip в prod и при частичной env-конфигурации (решение ревью Task 2). +/// +/// +/// Сам шаг (создание/идемпотентность/нормализация) покрыт OperatorBootstrapServiceTests (Task 2); здесь +/// проверяется встраивание в старт: чтение env, dev-дефолт, пропуск в Production и тексты предупреждений. +/// Секреты (пароли) в логах не появляются — проверяется на сценарии с явными кредами. +/// +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); + + // Частичная env-конфигурация — warning, а не молчаливый дефолт (решение ревью Task 2). + 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/OperatorCookieOptionsTests.cs b/src/core/tests/Deal.Tests.Unit/Api/OperatorCookieOptionsTests.cs index 3f21e96..969f0d4 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/OperatorCookieOptionsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/OperatorCookieOptionsTests.cs @@ -1,10 +1,5 @@ using Deal.Api.Configuration; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Api; diff --git a/src/core/tests/Deal.Tests.Unit/Api/OperatorLimitsEndpointsHelpersTests.cs b/src/core/tests/Deal.Tests.Unit/Api/OperatorLimitsEndpointsHelpersTests.cs index c6bd5bc..5f9a20a 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/OperatorLimitsEndpointsHelpersTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/OperatorLimitsEndpointsHelpersTests.cs @@ -1,5 +1,4 @@ using Deal.Api.Endpoints; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Api; diff --git a/src/core/tests/Deal.Tests.Unit/Api/PipelinePumpGateTests.cs b/src/core/tests/Deal.Tests.Unit/Api/PipelinePumpGateTests.cs index 330e0a3..ce1450c 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/PipelinePumpGateTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/PipelinePumpGateTests.cs @@ -1,7 +1,4 @@ -using Deal.Api; -using Deal.Api.Dtos; using Deal.Api.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Api; diff --git a/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs b/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs index dd417af..533598b 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs @@ -1,127 +1,116 @@ -using Deal.Api.Observability; -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.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Tenants; -using Deal.Tests.Unit.Contracts; - -namespace Deal.Tests.Unit.Api; - -/// -/// Тесты сборщика глубин очередей/сессий (§10.2): агрегат по тенантам через существующие сервисы. -/// -/// -/// DealDbContext в хосте не регистрируется: секция активных сессий ловит сбой и отдаёт 0 — так тест -/// фокусируется на агрегации очередей пайплайна (PipelineProcessingService на FakePipelineStore) и -/// MlOutbox (FakeMlLearningStore) по двум тенантам, не поднимая Postgres. -/// -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"); - - [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); - } - - [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, - DialogId = "d_1", - Text = "текст", - Status = PipelineQueueStatuses.New, - }; - - // Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext). - private static RuntimeDepthsCollector Build( - IReadOnlyDictionary pipelineByTenant, - IReadOnlyDictionary outboxByTenant, - IReadOnlyList? tenants = 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)]); - - 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; + +/// +/// Тесты сборщика глубин очередей/сессий (§10.2): агрегат по тенантам через существующие сервисы. +/// +/// +/// DealDbContext в хосте не регистрируется: секция активных сессий ловит сбой и отдаёт 0 — так тест +/// фокусируется на агрегации очередей пайплайна (PipelineProcessingService на FakePipelineStore) и +/// MlOutbox (FakeMlLearningStore) по двум тенантам, не поднимая Postgres. +/// +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"); + + [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); + } + + [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, + DialogId = "d_1", + Text = "текст", + Status = PipelineQueueStatuses.New, + }; + + // Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext). + private static RuntimeDepthsCollector Build( + IReadOnlyDictionary pipelineByTenant, + IReadOnlyDictionary outboxByTenant, + IReadOnlyList? tenants = 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)]); + + 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/TelegramEndpointsMappingTests.cs b/src/core/tests/Deal.Tests.Unit/Api/TelegramEndpointsMappingTests.cs index d8da4f5..9b2dee1 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/TelegramEndpointsMappingTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/TelegramEndpointsMappingTests.cs @@ -1,62 +1,61 @@ -using Deal.Api.Endpoints; -using Grpc.Core; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Api; - -/// -/// Тесты чистых хелперов эндпоинтов /api/tg (план Task 14): RU-маппинг типа диалога на границе -/// (заметка Task 1, api-map §4.8 L349) и текст причины ошибки гейта для {detail} (Ruling 7/8). -/// -public sealed class TelegramEndpointsMappingTests -{ - /// - /// EN-канон каталога → русская подпись вкладки «Каналы» (channel/group/forum/chat, python L461–466). - /// - [Theory] - [InlineData("channel", "канал")] - [InlineData("group", "группа")] - [InlineData("forum", "группа")] // форум отображается как группа (python: мегагруппа-форум → «группа») - [InlineData("chat", "чат")] - public void ToRussianDialogType_MapsEnCanonToRussianLabels(string kind, string expected) - { - Assert.Equal(expected, TelegramEndpoints.ToRussianDialogType(kind)); - } - - /// - /// Неизвестная подпись (например, уже русская из discovery-вступлений) проходит как есть. - /// - [Theory] - [InlineData("канал")] - [InlineData("")] - [InlineData("supergroup")] - public void ToRussianDialogType_UnknownKind_PassesThrough(string kind) - { - Assert.Equal(kind, TelegramEndpoints.ToRussianDialogType(kind)); - } - - /// - /// Доменная RPC-ошибка отдаёт канонический detail telegram-service (текст причины 1:1). - /// - [Fact] - public void GatewayErrorText_RpcDomainError_ReturnsServiceDetail() - { - var rpc = new RpcException(new Status(StatusCode.FailedPrecondition, "Telegram не подключён")); - - Assert.Equal("Telegram не подключён", TelegramEndpoints.GatewayErrorText(rpc)); - } - - /// - /// Транспортный сбой/неизвестное исключение → «Telegram не подключён» (Ruling 7). - /// - [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 (план Task 14): RU-маппинг типа диалога на границе +/// (заметка Task 1, api-map §4.8 L349) и текст причины ошибки гейта для {detail} (Ruling 7/8). +/// +public sealed class TelegramEndpointsMappingTests +{ + /// + /// EN-канон каталога → русская подпись вкладки «Каналы» (channel/group/forum/chat, python L461–466). + /// + [Theory] + [InlineData("channel", "канал")] + [InlineData("group", "группа")] + [InlineData("forum", "группа")] // форум отображается как группа (python: мегагруппа-форум → «группа») + [InlineData("chat", "чат")] + public void ToRussianDialogType_MapsEnCanonToRussianLabels(string kind, string expected) + { + Assert.Equal(expected, TelegramEndpoints.ToRussianDialogType(kind)); + } + + /// + /// Неизвестная подпись (например, уже русская из discovery-вступлений) проходит как есть. + /// + [Theory] + [InlineData("канал")] + [InlineData("")] + [InlineData("supergroup")] + public void ToRussianDialogType_UnknownKind_PassesThrough(string kind) + { + Assert.Equal(kind, TelegramEndpoints.ToRussianDialogType(kind)); + } + + /// + /// Доменная RPC-ошибка отдаёт канонический detail telegram-service (текст причины 1:1). + /// + [Fact] + public void GatewayErrorText_RpcDomainError_ReturnsServiceDetail() + { + var rpc = new RpcException(new Status(StatusCode.FailedPrecondition, "Telegram не подключён")); + + Assert.Equal("Telegram не подключён", TelegramEndpoints.GatewayErrorText(rpc)); + } + + /// + /// Транспортный сбой/неизвестное исключение → «Telegram не подключён» (Ruling 7). + /// + [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 a43034c..209fefa 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/TelegramKeysServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/TelegramKeysServiceTests.cs @@ -1,10 +1,7 @@ using Deal.Api.Telegram; -using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Api; diff --git a/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs index b1b33fd..049e247 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs @@ -1,150 +1,147 @@ -using Deal.Api.Telegram; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Telegram.Application.Models; -using Grpc.Core; -using Microsoft.Extensions.Logging.Abstractions; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Api; - -/// -/// Тесты сборки статуса вкладки Telegram — GET /api/tg/status (Ruling 8, api-map §4.9; план Task 14). -/// -/// -/// Сервис чистый: live-поля из FakeTelegramGateway (idle по умолчанию / настраиваемый Status / падение -/// StatusFailure — «сервис недоступен → idle-форма»), account из KV tgAccount (FakeSettingsStore), monitored = -/// count мониторящихся строк FakeTelegramStore, keysSet — по глобальным ключам telegramKeys (FakeGlobalSettingsStore, -/// apiHash шифруется FakeSecretCipher). Сценарии 1:1 с status() python L103–119 и формой §4.9. -/// -public sealed class TgStatusServiceTests -{ - /// - /// Гейт по умолчанию (idle/не подключён) и пустые KV/каталог → idle-форма §4.9 целиком. - /// - [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); - } - - /// - /// Сервис недоступен (RPC-отказ) → idle live-поля, но account/счётчик/keysSet ядро докладывает само. - /// - [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); - } - - /// - /// Готовый аккаунт: live-поля гейта, account — из KV (источник истины, не поле гейта), счётчики/ключи. - /// - [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 из гейта (фронт рисует QR/поллит статус до ready, store.js tg-флоу). - /// - [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 (Ruling 8, api-map §4.9; план Task 14). +/// +/// +/// Сервис чистый: live-поля из FakeTelegramGateway (idle по умолчанию / настраиваемый Status / падение +/// StatusFailure — «сервис недоступен → idle-форма»), account из KV tgAccount (FakeSettingsStore), monitored = +/// count мониторящихся строк FakeTelegramStore, keysSet — по глобальным ключам telegramKeys (FakeGlobalSettingsStore, +/// apiHash шифруется FakeSecretCipher). Сценарии 1:1 с status() python L103–119 и формой §4.9. +/// +public sealed class TgStatusServiceTests +{ + /// + /// Гейт по умолчанию (idle/не подключён) и пустые KV/каталог → idle-форма §4.9 целиком. + /// + [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); + } + + /// + /// Сервис недоступен (RPC-отказ) → idle live-поля, но account/счётчик/keysSet ядро докладывает само. + /// + [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); + } + + /// + /// Готовый аккаунт: live-поля гейта, account — из KV (источник истины, не поле гейта), счётчики/ключи. + /// + [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 из гейта (фронт рисует QR/поллит статус до ready, store.js tg-флоу). + /// + [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 b83ca30..83eb953 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs @@ -1,204 +1,188 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.Extensions.Logging.Abstractions; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Tenants; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты декоратора бюджетного гейта (план Task 9, Ruling 3): перед -/// каждым вызовом гейт (ITenantLimitStore.GetStateAsync → BudgetStateDto.Allowed); бюджет исчерпан/лимит 0 либо -/// тенант приостановлен → фильтр/классификация через Local-реализацию (платный исполнитель не зовётся, расход -/// не происходит); лимит большой → вызов уходит на «платный» фейк как есть. -/// -/// -/// Платный исполнитель — (счётчики вызовов); Local-fallback — реальный -/// на FakeSettingsStore (детерминированный, бесплатный — как в проде). -/// Сценарии Acceptance Task 9: лимит 0 → Local-ветка; лимит большой → платный фейк вызван; suspended → Local. -/// -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() - { - // Acceptance Task 9 «лимит 0 → Local-ветка»: лимит 0 запрещает ИИ с нулевого расхода (Ruling 3). - 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); - // Local-фильтр: реального ИИ-фильтра нет — всегда «пропустить» (семантика aiEnabled=false, Ruling 3). - Assert.True(result.Pass); - Assert.True(result.Skipped); - Assert.Null(result.Reason); - } - - [Fact] - public async Task Classify_LargeBudgetLimit_CallsPaidAndReturnsItsResult() - { - // Acceptance Task 9 «лимит большой → gRPC-фейк вызван»: строки лимита нет — ленивый дефолт - // (10 000 000/месяц, расход 0) → Allowed=true → вызов уходит на платного исполнителя. - 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() - { - // Acceptance Task 9 «suspended → Local»: приостановка замораживает ИИ (Ruling 3/10(5)) — Allowed=false - // даже при неисчерпанном бюджете. - 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; + +/// +/// Тесты декоратора бюджетного гейта (план Task 9, Ruling 3): перед +/// каждым вызовом гейт (ITenantLimitStore.GetStateAsync → BudgetStateDto.Allowed); бюджет исчерпан/лимит 0 либо +/// тенант приостановлен → фильтр/классификация через Local-реализацию (платный исполнитель не зовётся, расход +/// не происходит); лимит большой → вызов уходит на «платный» фейк как есть. +/// +/// +/// Платный исполнитель — (счётчики вызовов); Local-fallback — реальный +/// на FakeSettingsStore (детерминированный, бесплатный — как в проде). +/// Сценарии Acceptance Task 9: лимит 0 → Local-ветка; лимит большой → платный фейк вызван; suspended → Local. +/// +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() + { + // Acceptance Task 9 «лимит 0 → Local-ветка»: лимит 0 запрещает ИИ с нулевого расхода (Ruling 3). + 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); + // Local-фильтр: реального ИИ-фильтра нет — всегда «пропустить» (семантика aiEnabled=false, Ruling 3). + Assert.True(result.Pass); + Assert.True(result.Skipped); + Assert.Null(result.Reason); + } + + [Fact] + public async Task Classify_LargeBudgetLimit_CallsPaidAndReturnsItsResult() + { + // Acceptance Task 9 «лимит большой → gRPC-фейк вызван»: строки лимита нет — ленивый дефолт + // (10 000 000/месяц, расход 0) → Allowed=true → вызов уходит на платного исполнителя. + 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() + { + // Acceptance Task 9 «suspended → Local»: приостановка замораживает ИИ (Ruling 3/10(5)) — Allowed=false + // даже при неисчерпанном бюджете. + 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 2e55bee..2344c0c 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs @@ -1,175 +1,164 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.Extensions.Logging.Abstractions; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Tenants; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты декоратора бюджетного гейта (план Task 9, Ruling 3): при запрете -/// гейта (исчерпано/приостановлено) платный инструмент не зовётся — EvaluateFitAsync бросает -/// (воркер Discovery уходит в эвристику), GenerateKeywordsAsync отдаёт -/// мягкую ошибку {ok:false, keywords:[], error}; при разрешённом бюджете — вызов на «платном» фейке как есть. -/// -/// -/// Платный исполнитель — (счётчик EvaluateFitCalls; GenerateKeywords бросает -/// NotSupportedException — неожиданный вызов тест поймает сразу). Acceptance Task 9: «исчерпано → -/// AiUnavailableException» (плюс suspended → то же исключение/мягкая ошибка, Ruling 3/10(5)). -/// -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() - { - // Acceptance Task 9 «исчерпано → AiUnavailableException»: платный fit не зван — воркер Discovery сам - // падает в эвристику (Ruling 3/10, python L186–194). - 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() - { - // Лимит 0 запрещает ИИ с нулевого расхода (Ruling 3) — как и исчерпание. - 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() - { - // Мягкая ошибка {ok:false, keywords:[], error} (Ruling 3/11): generate-keywords-эндпоинт отвечает HTTP 200. - 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; + +/// +/// Тесты декоратора бюджетного гейта (план Task 9, Ruling 3): при запрете +/// гейта (исчерпано/приостановлено) платный инструмент не зовётся — EvaluateFitAsync бросает +/// (воркер Discovery уходит в эвристику), GenerateKeywordsAsync отдаёт +/// мягкую ошибку {ok:false, keywords:[], error}; при разрешённом бюджете — вызов на «платном» фейке как есть. +/// +/// +/// Платный исполнитель — (счётчик EvaluateFitCalls; GenerateKeywords бросает +/// NotSupportedException — неожиданный вызов тест поймает сразу). Acceptance Task 9: «исчерпано → +/// AiUnavailableException» (плюс suspended → то же исключение/мягкая ошибка, Ruling 3/10(5)). +/// +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() + { + // Acceptance Task 9 «исчерпано → AiUnavailableException»: платный fit не зван — воркер Discovery сам + // падает в эвристику (Ruling 3/10, python L186–194). + 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() + { + // Лимит 0 запрещает ИИ с нулевого расхода (Ruling 3) — как и исчерпание. + 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() + { + // Мягкая ошибка {ok:false, keywords:[], error} (Ruling 3/11): generate-keywords-эндпоинт отвечает HTTP 200. + 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 2b61105..d30fd69 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/CardComposerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/CardComposerTests.cs @@ -1,350 +1,341 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты композитора карточки CardComposer (план Task 7 L403–406, Ruling 4; python _store_lead L433–514). -/// -/// -/// Композитор чистый (порты FakeKanjStore + FakeSettingsStore): сборка полного снимка из разбора AiParsedCardDto -/// и строки сообщения — блоки «О заявке» (Компания → … → Условия, compose_summary L225–284), бюджет+конверсия -/// (clean_budget/budget_to_target с мок-курсами дефолта, Ruling 7), контакты (build_contacts L389–421, fallback -/// из текста), ch/source-поля и sourceMsg ≤4000. Страховка колонки (L449–450): назначенная доска без правил → -/// колонка доски с пустыми matchHits; доска с несовпадающими правилами/отсутствующая → inbox (ContainerAccepts). -/// Курсы по умолчанию — мок (ratesCache пуст → MockRates), conversionOn/targetCurrency — переопределениями -/// FakeSettingsStore (дефолты: конверсия включена, цель RUB). -/// -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); - - // Структура создания 1:1 с _store_lead: id готов, «Неразобранное», новая, prevCol=inbox. - 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); - - // ch/source-поля сообщения 1:1 (канал, время, диалог, msg_id, исходник). - Assert.Equal(new CardChannelDto("Канал найма", "hire_ch", "#ababab"), Channel(snapshot)); - Assert.Equal(1_700_000_000_000L, snapshot.ReceivedAt.ToUnixTimeMilliseconds()); - Assert.Equal("ООО Ромашка ищет Python-разработчика на бота для CRM, удалённо.", snapshot.SourceMsg); - Assert.Equal("d_1", snapshot.SourceDialogId); - Assert.Equal(123, snapshot.SourceMsgId); - } - - [Fact] - public async Task BuildAsync_LongSourceText_TruncatesSourceMsgTo4000CodePoints() - { - (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.SourceMsg.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) - } - - // ─── Колонка: назначенная доска (ContainerAccepts-страховка L449–450) ───────────────────── - - [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); - - // Доска без правил принимает любой текст (rules.py L266–268); совпадений критериев нет. - 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); - - // Страховка: ИИ/ML не кладут в отфильтрованную колонку текст, не прошедший правила (L449–450). - 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() - { - // Доска разбора удалена после классификации — как отсутствующая строка board_accepts (rules.py L260–262). - (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); - - // Fallback бюджета (python L459–468): первая сумма с валютой из исходника → 2000 USD, from=to. - 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); - - // build_contacts L389–421: контактов в разборе нет — кандидаты из текста (@ник, e-mail). - 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 важнее почты - } - - [Fact] - public async Task BuildAsync_EmptyChannelHue_DefaultsTo666() - { - (CardComposer composer, _, _) = Create(); - - CardSnapshot snapshot = await composer.BuildAsync( - Parsed(title: "Заголовок"), - Message("Сообщение", channelHue: string.Empty), - cardId: "l_test11", - CancellationToken.None); - - // Дефолт цвета канала #666 (Ruling 1) — как у строк очереди/отсева при пустом hue. - Assert.Equal("#666", snapshot.ChannelHue); - } - - // ─── Хелперы ────────────────────────────────────────────────────────────────────────── - - // Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок). - // Возвращает: Кортеж (композитор, канбан-хранилище, 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 channelHandle = "hire_ch", - string channelHue = "#ababab", - long msgAtMs = 1_700_000_000_000) => new() - { - Id = "p_1", - DialogId = dialogId, - MsgId = msgId, - Text = text, - Status = PipelineQueueStatuses.Filtered, - Channel = new PipelineChannelDto(channelName, channelHandle, 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); - - // Канал снимка (как карточка CardDto.Channel после записи адаптера). - private static CardChannelDto Channel(CardSnapshot snapshot) => - new(snapshot.ChannelName, snapshot.ChannelHandle, snapshot.ChannelHue); -} +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.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.Contracts; + +/// +/// Тесты композитора карточки CardComposer (план Task 7 L403–406, Ruling 4; python _store_lead L433–514). +/// +/// +/// Композитор чистый (порты FakeKanjStore + FakeSettingsStore): сборка полного снимка из разбора AiParsedCardDto +/// и строки сообщения — блоки «О заявке» (Компания → … → Условия, compose_summary L225–284), бюджет+конверсия +/// (clean_budget/budget_to_target с мок-курсами дефолта, Ruling 7), контакты (build_contacts L389–421, fallback +/// из текста), ch/source-поля и sourceMsg ≤4000. Страховка колонки (L449–450): назначенная доска без правил → +/// колонка доски с пустыми matchHits; доска с несовпадающими правилами/отсутствующая → inbox (ContainerAccepts). +/// Курсы по умолчанию — мок (ratesCache пуст → MockRates), conversionOn/targetCurrency — переопределениями +/// FakeSettingsStore (дефолты: конверсия включена, цель RUB). +/// +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); + + // Структура создания 1:1 с _store_lead: id готов, «Неразобранное», новая, prevCol=inbox. + 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); + + // ch/source-поля сообщения 1:1 (канал, время, диалог, msg_id, исходник). + Assert.Equal(new CardChannelDto("Канал найма", "hire_ch", "#ababab"), Channel(snapshot)); + Assert.Equal(1_700_000_000_000L, snapshot.ReceivedAt.ToUnixTimeMilliseconds()); + Assert.Equal("ООО Ромашка ищет Python-разработчика на бота для CRM, удалённо.", snapshot.SourceMsg); + Assert.Equal("d_1", snapshot.SourceDialogId); + Assert.Equal(123, snapshot.SourceMsgId); + } + + [Fact] + public async Task BuildAsync_LongSourceText_TruncatesSourceMsgTo4000CodePoints() + { + (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.SourceMsg.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) + } + + // ─── Колонка: назначенная доска (ContainerAccepts-страховка L449–450) ───────────────────── + + [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); + + // Доска без правил принимает любой текст (rules.py L266–268); совпадений критериев нет. + 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); + + // Страховка: ИИ/ML не кладут в отфильтрованную колонку текст, не прошедший правила (L449–450). + 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() + { + // Доска разбора удалена после классификации — как отсутствующая строка board_accepts (rules.py L260–262). + (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); + + // Fallback бюджета (python L459–468): первая сумма с валютой из исходника → 2000 USD, from=to. + 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); + + // build_contacts L389–421: контактов в разборе нет — кандидаты из текста (@ник, e-mail). + 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 важнее почты + } + + [Fact] + public async Task BuildAsync_EmptyChannelHue_DefaultsTo666() + { + (CardComposer composer, _, _) = Create(); + + CardSnapshot snapshot = await composer.BuildAsync( + Parsed(title: "Заголовок"), + Message("Сообщение", channelHue: string.Empty), + cardId: "l_test11", + CancellationToken.None); + + // Дефолт цвета канала #666 (Ruling 1) — как у строк очереди/отсева при пустом hue. + Assert.Equal("#666", snapshot.ChannelHue); + } + + // ─── Хелперы ────────────────────────────────────────────────────────────────────────── + + // Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок). + // Возвращает: Кортеж (композитор, канбан-хранилище, 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 channelHandle = "hire_ch", + string channelHue = "#ababab", + long msgAtMs = 1_700_000_000_000) => new() + { + Id = "p_1", + DialogId = dialogId, + MsgId = msgId, + Text = text, + Status = PipelineQueueStatuses.Filtered, + Channel = new PipelineChannelDto(channelName, channelHandle, 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); + + // Канал снимка (как карточка CardDto.Channel после записи адаптера). + private static CardChannelDto Channel(CardSnapshot snapshot) => + new(snapshot.ChannelName, snapshot.ChannelHandle, snapshot.ChannelHue); +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs index e87bfb6..f923d21 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs @@ -1,824 +1,819 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Kanban.Application.ColumnRules; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты CardsService — карточки: чтение, переносы, архив/корзина, комментарии, counts, поиск -/// (план Task 7 L305–334; leads.py L151–279, L509–551; Rulings 2/4/10; Ruling 6 — поиск). -/// -/// -/// Служебные фейки: (карточки/журнал/комментарии), -/// (ratesCache для бюджетных правил; пустое хранилище = мок-курсы, как RatesService) и -/// (PushAsync/StatusAsync). Тексты ошибок сверяются с константами -/// CardsService (строки прототипа leads.py L183–184, L239–240; dashboard_routes L240–241). Журнал -/// проверяется по записям FakeKanjStore.Moves (id lm_+hex, action, from/to), обучение — по FakeMlClient.Pushed. -/// -public sealed class CardsServiceTests -{ - // ─── Чтение (list_leads/get_lead L151–160) ───────────────────────────── - - [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); - } - - // ─── Перенос (move_lead L177–191, _move L163–174) ────────────────────── - - [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(); - - // Валидация цели — до чтения карточки (leads.py L183–184): 400, а не 404. - 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); - - // Guard «в ту же колонку» (leads.py _move L165–166): карточка не меняется, журнал/обучение не пишутся. - 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_EmptySourceMsg_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); - - // Текст для правил и обучения = source_msg.strip() or title (L167, L189–191). - 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); - - // Прямой move из архива миновал бы снятие метки «спам» возврата (Ruling 4, Push −1.0 только у - // restore_lead) — из archive/trash карточку выводит только «Вернуть» (кнопка restore). - 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); - - // Из корзины карточка возвращается только кнопкой «Вернуть» (restore снимает спам-обучение): - // прямой move в доску «воскресил» бы карточку без unlearn (Ruling 4). - 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); - } - - // ─── Корзина (trash_lead L194–201) ───────────────────────────────────── - - [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); - } - - // ─── Возврат (restore_lead L204–222) ─────────────────────────────────── - - [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(); - // Доска b_gone удалена — prev_col больше не валидна → возврат в inbox (L209). - 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 «Карточка не найдена» - } - - // ─── Удаление навсегда (delete_forever L225–234) ─────────────────────── - - [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); - - // Журнал живёт дольше карточки (Ruling 1/10: CardMoves без FK, _hard_delete их не чистит). - 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 «Карточка не найдена» - } - - // ─── Очистка колонки (clear_col L237–247) ────────────────────────────── - - [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); - } - - // ─── Пометить прочитанным (mark_seen L250–256) ───────────────────────── - - [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)); - } - - // ─── Комментарии (add_comment L259–265) ──────────────────────────────── - - [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 «Карточка не найдена» - } - - // ─── Счётчики (counts L268–279) ──────────────────────────────────────── - - [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); - } - - // ─── Поиск (search L509–551; FTS + LIKE — адаптер, Ruling 6/Task 12) ──────────── - - [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); - - // Порт вызывается с trim+lowercase запросом и лимитом 12 (Ruling 6); результат — как вернул порт. - 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_FindsByTitleSummaryContactSourceMsg_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, - SourceMsg = 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.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 — карточки: чтение, переносы, архив/корзина, комментарии, counts, поиск +/// (план Task 7 L305–334; leads.py L151–279, L509–551; Rulings 2/4/10; Ruling 6 — поиск). +/// +/// +/// Служебные фейки: (карточки/журнал/комментарии), +/// (ratesCache для бюджетных правил; пустое хранилище = мок-курсы, как RatesService) и +/// (PushAsync/StatusAsync). Тексты ошибок сверяются с константами +/// CardsService (строки прототипа leads.py L183–184, L239–240; dashboard_routes L240–241). Журнал +/// проверяется по записям FakeKanjStore.Moves (id lm_+hex, action, from/to), обучение — по FakeMlClient.Pushed. +/// +public sealed class CardsServiceTests +{ + // ─── Чтение (list_leads/get_lead L151–160) ───────────────────────────── + + [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); + } + + // ─── Перенос (move_lead L177–191, _move L163–174) ────────────────────── + + [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(); + + // Валидация цели — до чтения карточки (leads.py L183–184): 400, а не 404. + 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); + + // Guard «в ту же колонку» (leads.py _move L165–166): карточка не меняется, журнал/обучение не пишутся. + 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_EmptySourceMsg_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); + + // Текст для правил и обучения = source_msg.strip() or title (L167, L189–191). + 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); + + // Прямой move из архива миновал бы снятие метки «спам» возврата (Ruling 4, Push −1.0 только у + // restore_lead) — из archive/trash карточку выводит только «Вернуть» (кнопка restore). + 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); + + // Из корзины карточка возвращается только кнопкой «Вернуть» (restore снимает спам-обучение): + // прямой move в доску «воскресил» бы карточку без unlearn (Ruling 4). + 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); + } + + // ─── Корзина (trash_lead L194–201) ───────────────────────────────────── + + [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); + } + + // ─── Возврат (restore_lead L204–222) ─────────────────────────────────── + + [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(); + // Доска b_gone удалена — prev_col больше не валидна → возврат в inbox (L209). + 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 «Карточка не найдена» + } + + // ─── Удаление навсегда (delete_forever L225–234) ─────────────────────── + + [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); + + // Журнал живёт дольше карточки (Ruling 1/10: CardMoves без FK, _hard_delete их не чистит). + 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 «Карточка не найдена» + } + + // ─── Очистка колонки (clear_col L237–247) ────────────────────────────── + + [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); + } + + // ─── Пометить прочитанным (mark_seen L250–256) ───────────────────────── + + [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)); + } + + // ─── Комментарии (add_comment L259–265) ──────────────────────────────── + + [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 «Карточка не найдена» + } + + // ─── Счётчики (counts L268–279) ──────────────────────────────────────── + + [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); + } + + // ─── Поиск (search L509–551; FTS + LIKE — адаптер, Ruling 6/Task 12) ──────────── + + [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); + + // Порт вызывается с trim+lowercase запросом и лимитом 12 (Ruling 6); результат — как вернул порт. + 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_FindsByTitleSummaryContactSourceMsg_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, + SourceMsg = 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 1b29c79..3a94c3e 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/DialogsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/DialogsServiceTests.cs @@ -1,545 +1,540 @@ -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.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Telegram.Application.Models; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты DialogsService — каталог диалогов/каналов и зеркало мониторинга ядра (Ruling 7, план Task 13). -/// -/// -/// Сервис чистый: оркестрирует FakeTelegramStore (семантика 1:1 с TelegramStore), читает autoMonitorNew через -/// FakeSettingsStore (отсутствие строки → дефолт SettingsDefaults.AutoMonitorNew=true) и шлёт команды наружу -/// через FakeTelegramGateway (запись вызовов SetMonitor/SetMonitorAll/Backfill — «что ушло в telegram-service»). -/// Сценарии 1:1 с python telegram.py: _persist_dialogs L468–503 (sync), set_monitor L536–546 (SetMonitor), -/// set_monitor_all L548–567 (SetMonitorAll), backfill_monitored L569–581 (ReadRecent), _on_message L270–274 -/// (SavePreview). Фоновый спуск Backfill в ядре живёт в Api-слое (Task 14) — здесь проверяются флаги -/// «нужен разбор» и состав списков, а не сам фоновый запуск. -/// -public sealed class DialogsServiceTests -{ - // ─── SyncFromTelegram: авто-мониторинг новых, обновление, удаление отсутствующих ── - - /// - /// Новые диалоги при дефолтной autoMonitorNew=true появляются включёнными (1:1 _persist_dialogs L489). - /// - [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: новые диалоги появляются отключёнными (настройка читается из KV). - /// - [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, монитор пользователя не трогается (python L487–489). - /// - [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); - } - - /// - /// Диалог, которого больше нет в каталоге (вышел/удалил), удаляется (python L491–500). - /// - [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 (python L477–478: возврат 0 без записи), существующие строки целы. - /// - [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 + «нужен первый разбор» ── - - /// - /// Первое включение неразобранного диалога: флаг on, RPC SetMonitor(id,true), BackfillNeeded=true. - /// - [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); - } - - /// - /// Включение уже разобранного диалога: флаг on + RPC, BackfillNeeded=false (python L544: row backfilled). - /// - [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), разбор не нужен (python L536–540). - /// - [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 (UPDATE без строк python не меняет зеркало сервиса). - /// - [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), неразобранные — списком (python L548–567). - /// - [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); - } - - /// - /// Выключение всех: мониторинг снят со всех, неразобранные пусты (backfill при выключении не нужен). - /// - [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 ставит флаг разобранности (backfill_dialog L387). - /// - [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); - } - - // ─── BackfillOne / ReadRecent: частичный сбой не отменяет остальные (замечание ревью T13) ── - - /// - /// «Перечитать» при падении одного канала продолжает остальные; флаг разбора — только после успеха. - /// - [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; успех помечает разобранным (L360–387). - /// - [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 обходит флаг разобранности («Перечитать» кнопкой, python L352–353). - /// - [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 ── - - /// - /// Перечитываются только включённые диалоги: Backfill(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 без вызовов гейта (python L577–579). - /// - [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 возвращает каталог: включённые первыми, далее по имени, с «последним сообщением» (L521–534). - /// - [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 пишет строку превью (m_<dialog>_<msg>, текст ≤4000) и обновляет last каталога (≤200). - /// - [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 (python L259: пустые сообщения не обрабатываются). - /// - [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 строка превью не пишется (нет стабильного 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); - } - - /// - /// Дубль превью того же сообщения не перезаписывает первую строку (INSERT OR IGNORE python L602–605). - /// - [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); - } - - // ─── AddDiscoveredMonitored: строка каталога после вступления + зеркало (python add_dialog_monitored L850–873) ── - - /// - /// Новый источник после вступления: строка каталога (монитор on, backfilled false) + RPC SetMonitor(true). - /// - [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); - } - - /// - /// Существующая строка (пользователь снял монитор, канал разобран) — upsert как python ON CONFLICT: - /// метаданные обновлены, монитор принудительно on, backfilled сброшен. - /// - [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 нормализуются как python L866–869: name → dialogId, hue → «#666». - /// - [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); - } - - /// - /// Сбой зеркала (telegram-service недоступен) не роняет вступление: строка каталога уже записана. - /// - [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 — каталог диалогов/каналов и зеркало мониторинга ядра (Ruling 7, план Task 13). +/// +/// +/// Сервис чистый: оркестрирует FakeTelegramStore (семантика 1:1 с TelegramStore), читает autoMonitorNew через +/// FakeSettingsStore (отсутствие строки → дефолт SettingsDefaults.AutoMonitorNew=true) и шлёт команды наружу +/// через FakeTelegramGateway (запись вызовов SetMonitor/SetMonitorAll/Backfill — «что ушло в telegram-service»). +/// Сценарии 1:1 с python telegram.py: _persist_dialogs L468–503 (sync), set_monitor L536–546 (SetMonitor), +/// set_monitor_all L548–567 (SetMonitorAll), backfill_monitored L569–581 (ReadRecent), _on_message L270–274 +/// (SavePreview). Фоновый спуск Backfill в ядре живёт в Api-слое (Task 14) — здесь проверяются флаги +/// «нужен разбор» и состав списков, а не сам фоновый запуск. +/// +public sealed class DialogsServiceTests +{ + // ─── SyncFromTelegram: авто-мониторинг новых, обновление, удаление отсутствующих ── + + /// + /// Новые диалоги при дефолтной autoMonitorNew=true появляются включёнными (1:1 _persist_dialogs L489). + /// + [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: новые диалоги появляются отключёнными (настройка читается из KV). + /// + [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, монитор пользователя не трогается (python L487–489). + /// + [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); + } + + /// + /// Диалог, которого больше нет в каталоге (вышел/удалил), удаляется (python L491–500). + /// + [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 (python L477–478: возврат 0 без записи), существующие строки целы. + /// + [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 + «нужен первый разбор» ── + + /// + /// Первое включение неразобранного диалога: флаг on, RPC SetMonitor(id,true), BackfillNeeded=true. + /// + [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); + } + + /// + /// Включение уже разобранного диалога: флаг on + RPC, BackfillNeeded=false (python L544: row backfilled). + /// + [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), разбор не нужен (python L536–540). + /// + [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 (UPDATE без строк python не меняет зеркало сервиса). + /// + [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), неразобранные — списком (python L548–567). + /// + [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); + } + + /// + /// Выключение всех: мониторинг снят со всех, неразобранные пусты (backfill при выключении не нужен). + /// + [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 ставит флаг разобранности (backfill_dialog L387). + /// + [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); + } + + // ─── BackfillOne / ReadRecent: частичный сбой не отменяет остальные (замечание ревью T13) ── + + /// + /// «Перечитать» при падении одного канала продолжает остальные; флаг разбора — только после успеха. + /// + [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; успех помечает разобранным (L360–387). + /// + [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 обходит флаг разобранности («Перечитать» кнопкой, python L352–353). + /// + [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 ── + + /// + /// Перечитываются только включённые диалоги: Backfill(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 без вызовов гейта (python L577–579). + /// + [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 возвращает каталог: включённые первыми, далее по имени, с «последним сообщением» (L521–534). + /// + [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 пишет строку превью (m_<dialog>_<msg>, текст ≤4000) и обновляет last каталога (≤200). + /// + [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 (python L259: пустые сообщения не обрабатываются). + /// + [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 строка превью не пишется (нет стабильного 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); + } + + /// + /// Дубль превью того же сообщения не перезаписывает первую строку (INSERT OR IGNORE python L602–605). + /// + [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); + } + + // ─── AddDiscoveredMonitored: строка каталога после вступления + зеркало (python add_dialog_monitored L850–873) ── + + /// + /// Новый источник после вступления: строка каталога (монитор on, backfilled false) + RPC SetMonitor(true). + /// + [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); + } + + /// + /// Существующая строка (пользователь снял монитор, канал разобран) — upsert как python ON CONFLICT: + /// метаданные обновлены, монитор принудительно on, backfilled сброшен. + /// + [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 нормализуются как python L866–869: name → dialogId, hue → «#666». + /// + [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); + } + + /// + /// Сбой зеркала (telegram-service недоступен) не роняет вступление: строка каталога уже записана. + /// + [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 b0149e7..e9ce7b0 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryEvaluatorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryEvaluatorTests.cs @@ -1,219 +1,211 @@ -using Deal.Contracts.Integrations.Models; -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.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты DiscoveryEvaluator — каскад оценки сообщения и агрегаты выборки (план Task 18, 1:1 -/// discovery_eval.py целиком): короткие → нет, ML-спам, ИИ (успех/сбой → эвристика), эвристика по ключам, -/// вердикт passed (объём+порог), группировка форумов по темам. -/// -/// -/// Сервис чистый: KV-флаги — через (по умолчанию дефолты SettingsDefaults: -/// mlEnabled/aiEnabled = true), ML — , ИИ — . Тесты выключают -/// ветки, чтобы проверять каскад изолированно (эталон тестов воркера Pipeline). -/// -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() - { - // Local-режим (UseLocal=true): LocalAiTools.EvaluateFitAsync бросает NotSupportedException (Ruling 6). - (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() - { - // 3+ сообщений и доля ≥ порога (python passed L229–237). - 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 — каскад оценки сообщения и агрегаты выборки (план Task 18, 1:1 +/// discovery_eval.py целиком): короткие → нет, ML-спам, ИИ (успех/сбой → эвристика), эвристика по ключам, +/// вердикт passed (объём+порог), группировка форумов по темам. +/// +/// +/// Сервис чистый: KV-флаги — через (по умолчанию дефолты SettingsDefaults: +/// mlEnabled/aiEnabled = true), ML — , ИИ — . Тесты выключают +/// ветки, чтобы проверять каскад изолированно (эталон тестов воркера Pipeline). +/// +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() + { + // Local-режим (UseLocal=true): LocalAiTools.EvaluateFitAsync бросает NotSupportedException (Ruling 6). + (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() + { + // 3+ сообщений и доля ≥ порога (python passed L229–237). + 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 427832c..7529582 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs @@ -1,563 +1,555 @@ -using Deal.Contracts.Integrations.Models; -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.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Grpc.Core; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Discovery; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты DiscoveryWorkerService — один шаг за тик (план Task 18, 1:1 discovery_worker.py целиком, -/// Ruling 10): поиск → кандидаты; оценка → review (фильтры/язык/содержание); авто-вступление с паузами/ -/// квотами/бан-гардом; план выполнен → done; идемпотентность/изоляция. -/// -/// -/// Воркер чистый: гейт — (поиск/инфо/чтение/join настраиваются сценарием), -/// ML/ИИ — фейки с выключенными ветками (mlEnabled/aiEnabled=false: оценка идёт эвристикой по ключам; -/// сценарии веток каскада покрыты DiscoveryEvaluatorTests). Пауза перед join — -/// (мгновенная, с хуком изменений за «паузу»). FloodWait — настоящий -/// RESOURCE_EXHAUSTED с detail-префиксом «flood:» (как бросает GrpcTelegramClient, контракт telegram.proto). -/// -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); - - // Форум оценивается по темам: тема 10 прошла порог (2 из 3 ≥ 40%) — форум подходит (python L326–347). - 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); - } - - // FloodWait-исключение поиска/join (контракт telegram-service L820–826). - 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 — один шаг за тик (план Task 18, 1:1 discovery_worker.py целиком, +/// Ruling 10): поиск → кандидаты; оценка → review (фильтры/язык/содержание); авто-вступление с паузами/ +/// квотами/бан-гардом; план выполнен → done; идемпотентность/изоляция. +/// +/// +/// Воркер чистый: гейт — (поиск/инфо/чтение/join настраиваются сценарием), +/// ML/ИИ — фейки с выключенными ветками (mlEnabled/aiEnabled=false: оценка идёт эвристикой по ключам; +/// сценарии веток каскада покрыты DiscoveryEvaluatorTests). Пауза перед join — +/// (мгновенная, с хуком изменений за «паузу»). FloodWait — настоящий +/// RESOURCE_EXHAUSTED с detail-префиксом «flood:» (как бросает GrpcTelegramClient, контракт telegram.proto). +/// +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); + + // Форум оценивается по темам: тема 10 прошла порог (2 из 3 ≥ 40%) — форум подходит (python L326–347). + 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); + } + + // FloodWait-исключение поиска/join (контракт telegram-service L820–826). + private static RpcException FloodException() => + new(new Status(StatusCode.ResourceExhausted, "flood: FLOOD_WAIT_120")); +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiClassifier.cs b/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiClassifier.cs index 591991a..c6760c0 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiClassifier.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiClassifier.cs @@ -1,6 +1,5 @@ using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Models; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Contracts; diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiTools.cs b/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiTools.cs index 16bfc65..fc42d03 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiTools.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiTools.cs @@ -1,6 +1,5 @@ using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Models; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Contracts; diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeDiscoveryGateway.cs b/src/core/tests/Deal.Tests.Unit/Contracts/FakeDiscoveryGateway.cs index 492e7f5..2299b1b 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeDiscoveryGateway.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/FakeDiscoveryGateway.cs @@ -1,6 +1,5 @@ using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Models; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Contracts; diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeFileStorage.cs b/src/core/tests/Deal.Tests.Unit/Contracts/FakeFileStorage.cs index e833cb7..f4cd6bc 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeFileStorage.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/FakeFileStorage.cs @@ -1,6 +1,5 @@ using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Models; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Contracts; diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeMlClient.cs b/src/core/tests/Deal.Tests.Unit/Contracts/FakeMlClient.cs index 3313cdf..0392417 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeMlClient.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/FakeMlClient.cs @@ -1,6 +1,5 @@ using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Models; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Contracts; diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramGateway.cs b/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramGateway.cs index 59f06e8..384b4db 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramGateway.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramGateway.cs @@ -1,179 +1,178 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Grpc.Core; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// In-memory реализация для юнит-тестов модуля Telegram/ингресса (Task 13/14). -/// -/// -/// Записывает команды наружу (мониторинг/backfill) — тесты проверяют, ЧТО ушло в telegram-service, не выполняя -/// сетевых вызовов (Global Constraints: приёмка — unit/in-proc с фейками). Чтение (Status/Refresh/Search/…) — -/// нейтральные ответы; для сценариев каталога результаты настраиваются свойствами () или -/// seed-методами. Частичный сбой сервиса инжектится: (StatusAsync падает — -/// «сервис недоступен → idle-форма», Ruling 8) и (диалоги, чей Backfill падает, — -/// «Перечитать» продолжает остальные, замечание ревью T13). -/// -public sealed class FakeTelegramGateway : ITelegramGateway -{ - // Фаза idle-формы (по умолчанию не подключён). - private const string IdlePhase = "idle"; - - // Деталь недоступного telegram-service (Ruling 7: «недоступность → не подключён»). - private const string NotConnectedDetail = "Telegram не подключён"; - - /// - /// Вызовы SetMonitor (dialogId, enabled) в порядке вызова. - /// - public List<(string DialogId, bool Enabled)> SetMonitorCalls { get; } = []; - - /// - /// Вызовы SetMonitorAll (enabled) в порядке вызова. - /// - public List SetMonitorAllCalls { get; } = []; - - /// - /// Вызовы Backfill (dialogId, force) в порядке вызова. - /// - public List<(string DialogId, bool Force)> BackfillCalls { get; } = []; - - /// - /// Сколько сообщений «разобрал» Backfill (ответ {processed}). - /// - public int BackfillResult { get; set; } = 3; - - /// - /// Каталог, который возвращает RefreshDialogsAsync/SearchAsync (пуст — каталога нет). - /// - public IReadOnlyList Catalog { get; set; } = []; - - /// - /// Статус, который возвращает StatusAsync (по умолчанию idle/не подключён). - /// - public TelegramAccountStatusDto Status { get; set; } = - new(IdlePhase, Connected: false, Listener: false, Account: string.Empty, Error: null, QrUrl: null); - - /// - /// Заданное исключение StatusAsync (сценарий «сервис недоступен»); null — вернуть . - /// - public RpcException? StatusFailure { get; set; } - - /// - /// Диалоги, чей BackfillAsync падает (частичный сбой перечитывания); остальные — успешны. - /// - public HashSet BackfillFailures { get; } = new(StringComparer.Ordinal); - - /// - /// Диалоги, чей SetMonitorAsync падает (сбой зеркала после discovery-вступления); остальные — успешны. - /// - 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> 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/ингресса (Task 13/14). +/// +/// +/// Записывает команды наружу (мониторинг/backfill) — тесты проверяют, ЧТО ушло в telegram-service, не выполняя +/// сетевых вызовов (Global Constraints: приёмка — unit/in-proc с фейками). Чтение (Status/Refresh/Search/…) — +/// нейтральные ответы; для сценариев каталога результаты настраиваются свойствами () или +/// seed-методами. Частичный сбой сервиса инжектится: (StatusAsync падает — +/// «сервис недоступен → idle-форма», Ruling 8) и (диалоги, чей Backfill падает, — +/// «Перечитать» продолжает остальные, замечание ревью T13). +/// +public sealed class FakeTelegramGateway : ITelegramGateway +{ + // Фаза idle-формы (по умолчанию не подключён). + private const string IdlePhase = "idle"; + + // Деталь недоступного telegram-service (Ruling 7: «недоступность → не подключён»). + private const string NotConnectedDetail = "Telegram не подключён"; + + /// + /// Вызовы SetMonitor (dialogId, enabled) в порядке вызова. + /// + public List<(string DialogId, bool Enabled)> SetMonitorCalls { get; } = []; + + /// + /// Вызовы SetMonitorAll (enabled) в порядке вызова. + /// + public List SetMonitorAllCalls { get; } = []; + + /// + /// Вызовы Backfill (dialogId, force) в порядке вызова. + /// + public List<(string DialogId, bool Force)> BackfillCalls { get; } = []; + + /// + /// Сколько сообщений «разобрал» Backfill (ответ {processed}). + /// + public int BackfillResult { get; set; } = 3; + + /// + /// Каталог, который возвращает RefreshDialogsAsync/SearchAsync (пуст — каталога нет). + /// + public IReadOnlyList Catalog { get; set; } = []; + + /// + /// Статус, который возвращает StatusAsync (по умолчанию idle/не подключён). + /// + public TelegramAccountStatusDto Status { get; set; } = + new(IdlePhase, Connected: false, Listener: false, Account: string.Empty, Error: null, QrUrl: null); + + /// + /// Заданное исключение StatusAsync (сценарий «сервис недоступен»); null — вернуть . + /// + public RpcException? StatusFailure { get; set; } + + /// + /// Диалоги, чей BackfillAsync падает (частичный сбой перечитывания); остальные — успешны. + /// + public HashSet BackfillFailures { get; } = new(StringComparer.Ordinal); + + /// + /// Диалоги, чей SetMonitorAsync падает (сбой зеркала после discovery-вступления); остальные — успешны. + /// + 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> 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 55f84fe..f92c961 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs @@ -1,160 +1,149 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Ai; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.Extensions.Logging.Abstractions; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Tenants; -using Deal.Tests.Unit.Grpc; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты gRPC-адаптера IAiTools к ai-service (Ruling 9, план Task 15): маппинг GenerateKeywords/ -/// EvaluateFit ↔ ai.proto, metadata (Ruling 1), мягкая ошибка generate-keywords {ok:false, keywords:[], error} -/// (Ruling 11), сбой evaluate-fit → AiUnavailableException (воркер Discovery падает в эвристику, Ruling 10) и -/// списание usage: бюджет периода tenant_limits + lifetime-KV aiTokenUsage (Ruling 3 этапа 7). Харнесс — in-proc -/// фейк-ai-service (эталон GrpcAiClassifierTests). -/// -[Collection("MlGrpcTests")] -public sealed class GrpcAiToolsTests -{ - // Id тенанта сценариев строкой (формат N) — ожидаемое значение metadata tenant-id. - private const string TenantIdValue = "fedcba9876543210fedcba9876543210"; - - // Guid того же тенанта — ключ строк лимита в FakeTenantLimitStore (списание usage, Ruling 3). - 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()); - - // Мягкая ошибка (Ruling 11): эндпоинт Task 19 отвечает HTTP 200 {keywords: [], error}. - 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()); - - // Сбой ИИ-оценки не роняет оценку кандидата — воркер Discovery падает в эвристику (Ruling 10). - 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 (Ruling 9, план Task 15): маппинг GenerateKeywords/ +/// EvaluateFit ↔ ai.proto, metadata (Ruling 1), мягкая ошибка generate-keywords {ok:false, keywords:[], error} +/// (Ruling 11), сбой evaluate-fit → AiUnavailableException (воркер Discovery падает в эвристику, Ruling 10) и +/// списание usage: бюджет периода tenant_limits + lifetime-KV aiTokenUsage (Ruling 3 этапа 7). Харнесс — in-proc +/// фейк-ai-service (эталон GrpcAiClassifierTests). +/// +[Collection("MlGrpcTests")] +public sealed class GrpcAiToolsTests +{ + // Id тенанта сценариев строкой (формат N) — ожидаемое значение metadata tenant-id. + private const string TenantIdValue = "fedcba9876543210fedcba9876543210"; + + // Guid того же тенанта — ключ строк лимита в FakeTenantLimitStore (списание usage, Ruling 3). + 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()); + + // Мягкая ошибка (Ruling 11): эндпоинт Task 19 отвечает HTTP 200 {keywords: [], error}. + 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()); + + // Сбой ИИ-оценки не роняет оценку кандидата — воркер Discovery падает в эвристику (Ruling 10). + 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 e969e16..00c6ffa 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs @@ -1,355 +1,345 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Ml; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -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.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.Extensions.Logging.Abstractions; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Grpc; -using Deal.Tests.Unit.Modules.Tenants; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты gRPC-адаптера IMlClient/IMlTrainClient к ml-service (план Task 16, Ruling 6; Acceptance L449). -/// -/// -/// Сценарии гоняются против in-proc фейк-ml-service (, Kestrel HTTP/2 на -/// эфемерном порту) на реальном канале GrpcMlClient: проверяются маппинг DTO↔ml.proto (Predict/Status/ -/// Reset/TrainBatch), metadata tenant-id/service-token (Ruling 1), кэш статуса 15 с + reachable при -/// недоступности, predict-фолбэк «не уверен», reset (очистка outbox при успехе / мягкая ошибка при сбое) -/// и push (всегда запись в MlOutbox). Локальная статистика — на in-memory фейках (как LocalMlClientTests). -/// Сеть наружу не используется. -/// -[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); - - // Маппинг полей 1:1 (ml.proto PredictReply → MlPredictResultDto). - 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); - - // Metadata вызова (Ruling 1): tenant-id формата N + service-token из env. - 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); - - // Сбой → фиксированный «не уверен» (python predict L101–107): решит ИИ/локальный путь воркера. - 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); - - // Сервис недоступен и кэш пуст: статус «не готова» + reachable=false (python L132–135). - 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); - - // Кэш статуса инвалидирован: следующий StatusAsync снова ходит в сервис (python reset_model L123). - _ = 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); - - // Мягкая ошибка {ok:false, error} — очередь не тронута (reset_model L117–121). - 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)); - }); - } - - // ─── PushAsync: всегда запись в MlOutbox (Ruling 6) ───────────────────── - - [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 (план Task 16, Ruling 6; Acceptance L449). +/// +/// +/// Сценарии гоняются против in-proc фейк-ml-service (, Kestrel HTTP/2 на +/// эфемерном порту) на реальном канале GrpcMlClient: проверяются маппинг DTO↔ml.proto (Predict/Status/ +/// Reset/TrainBatch), metadata tenant-id/service-token (Ruling 1), кэш статуса 15 с + reachable при +/// недоступности, predict-фолбэк «не уверен», reset (очистка outbox при успехе / мягкая ошибка при сбое) +/// и push (всегда запись в MlOutbox). Локальная статистика — на in-memory фейках (как LocalMlClientTests). +/// Сеть наружу не используется. +/// +[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); + + // Маппинг полей 1:1 (ml.proto PredictReply → MlPredictResultDto). + 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); + + // Metadata вызова (Ruling 1): tenant-id формата N + service-token из env. + 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); + + // Сбой → фиксированный «не уверен» (python predict L101–107): решит ИИ/локальный путь воркера. + 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); + + // Сервис недоступен и кэш пуст: статус «не готова» + reachable=false (python L132–135). + 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); + + // Кэш статуса инвалидирован: следующий StatusAsync снова ходит в сервис (python reset_model L123). + _ = 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); + + // Мягкая ошибка {ok:false, error} — очередь не тронута (reset_model L117–121). + 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)); + }); + } + + // ─── PushAsync: всегда запись в MlOutbox (Ruling 6) ───────────────────── + + [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 55d0e8b..b223163 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcTelegramClientTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcTelegramClientTests.cs @@ -1,223 +1,219 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Telegram; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Grpc.Core; -using Microsoft.Extensions.Logging.Abstractions; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Grpc; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// In-proc тесты gRPC-адаптера гейта GrpcTelegramClient «по проводу» (план Task 14, Ruling 1/2/7). -/// -/// -/// Клиент ходит в фейк-telegram-service (, Kestrel HTTP/2 в процессе теста): -/// проверяются metadata tenant-id/service-token, deadline и маппинг DTO↔telegram.proto (status/start/qr/catalog/ -/// backfill/read_recent), а также проброс доменных RPC-ошибок с каноническим detail («Telegram не подключён», -/// Ruling 1/7). Env DEAL_SERVICE_TOKEN меняется на время сценария — коллекция MlGrpcTests (сериализована). -/// -[Collection("MlGrpcTests")] -public sealed class GrpcTelegramClientTests -{ - /// - /// StatusAsync маппит live-поля telegram-service (phase/connected/listener/account/error/qrUrl). - /// - [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-отказ сервиса пробрасывается как есть: RpcException c каноническим detail (Ruling 1/7). - /// - [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 и возвращает фазу ("code"). - /// - [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 (пустой qrUrl при phase != qr). - /// - [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 маппит каталог (username → handle; kind/hue 1:1). - /// - [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 (сколько отправлено PushMessage). - /// - [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 и маппит сообщения превью (id строкой/time epoch-ms). - /// - [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); - }); - } - - /// - /// Вызов вне tenant-контекста — ошибка конфигурации (metadata tenant-id невозможен, Ruling 1). - /// - [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 «по проводу» (план Task 14, Ruling 1/2/7). +/// +/// +/// Клиент ходит в фейк-telegram-service (, Kestrel HTTP/2 в процессе теста): +/// проверяются metadata tenant-id/service-token, deadline и маппинг DTO↔telegram.proto (status/start/qr/catalog/ +/// backfill/read_recent), а также проброс доменных RPC-ошибок с каноническим detail («Telegram не подключён», +/// Ruling 1/7). Env DEAL_SERVICE_TOKEN меняется на время сценария — коллекция MlGrpcTests (сериализована). +/// +[Collection("MlGrpcTests")] +public sealed class GrpcTelegramClientTests +{ + /// + /// StatusAsync маппит live-поля telegram-service (phase/connected/listener/account/error/qrUrl). + /// + [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-отказ сервиса пробрасывается как есть: RpcException c каноническим detail (Ruling 1/7). + /// + [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 и возвращает фазу ("code"). + /// + [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 (пустой qrUrl при phase != qr). + /// + [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 маппит каталог (username → handle; kind/hue 1:1). + /// + [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 (сколько отправлено PushMessage). + /// + [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 и маппит сообщения превью (id строкой/time epoch-ms). + /// + [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); + }); + } + + /// + /// Вызов вне tenant-контекста — ошибка конфигурации (metadata tenant-id невозможен, Ruling 1). + /// + [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 81ac2d6..6d46890 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs @@ -1,40 +1,24 @@ using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; using Deal.Infrastructure; using Deal.Infrastructure.Data; using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; using Deal.Infrastructure.Integrations.Models; using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Services; using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; 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.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Models; -using Microsoft.Extensions.DependencyInjection; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Settings; 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; diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiClassifierTests.cs index 5509135..9372530 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiClassifierTests.cs @@ -1,173 +1,163 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты детерминированного ИИ-классификатора (Ruling 5, план Task 6 L382–384). -/// -/// -/// Кейсы Acceptance: фильтр всегда пропускает (pass+skipped — ветка «ИИ недоступен» L1103–1106); классификатор -/// детерминирован (одинаковый текст → одинаковый DTO); бюджет «до 2к$» → {from:null, to:2000, cur:USD}; -/// контакты квалифицированы (боты/служебные отброшены); локальная структура разбора: заголовок/суть/стек, -/// is_vacancy по hire-маркерам, is_vacancy_known=false, board=null (смысловые колонки до ИИ не назначаем, -/// python L954–958), блок «О заявке» — legacy-суть при пустых структурированных полях (compose_summary L264–268). -/// -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); - - // Реального ИИ-фильтра нет (Ruling 5): ответ — как ветка «ИИ недоступен» L1103–1106, выключатель - // aiFilterEnabled классификатор не читает (его обрабатывает воркер, filter_incoming L190–192). - 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); - - // «до 2к$»: только верхняя граница, суффикс «к» — тысячи (rules.py _AMT L58). - 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; + +/// +/// Тесты детерминированного ИИ-классификатора (Ruling 5, план Task 6 L382–384). +/// +/// +/// Кейсы Acceptance: фильтр всегда пропускает (pass+skipped — ветка «ИИ недоступен» L1103–1106); классификатор +/// детерминирован (одинаковый текст → одинаковый DTO); бюджет «до 2к$» → {from:null, to:2000, cur:USD}; +/// контакты квалифицированы (боты/служебные отброшены); локальная структура разбора: заголовок/суть/стек, +/// is_vacancy по hire-маркерам, is_vacancy_known=false, board=null (смысловые колонки до ИИ не назначаем, +/// python L954–958), блок «О заявке» — legacy-суть при пустых структурированных полях (compose_summary L264–268). +/// +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); + + // Реального ИИ-фильтра нет (Ruling 5): ответ — как ветка «ИИ недоступен» L1103–1106, выключатель + // aiFilterEnabled классификатор не читает (его обрабатывает воркер, filter_incoming L190–192). + 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); + + // «до 2к$»: только верхняя граница, суффикс «к» — тысячи (rules.py _AMT L58). + 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/LocalAiToolsTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiToolsTests.cs index 0108e81..582dc8f 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiToolsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiToolsTests.cs @@ -1,12 +1,4 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Contracts; diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs index 7dbdaba..7a65f34 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs @@ -1,141 +1,134 @@ -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Тесты обёртки создания карточки PipelineCardWriter (план Task 7 L400–402, Ruling 3/4; -/// python _store_lead L483–514: INSERT карточки → UPDATE dedup.lead_id → чтение после записи). -/// -/// -/// Тонкая обёртка: id карточки генерирует (l_), снимок собирает CardComposer (FakeKanjStore -/// без досок — «Неразобранное»), запись — ICardStore.AddCardAsync, связь дедупа — IPipelineStore.LinkAsync -/// (порядок AddCard → Link, Ruling 4 L512–513), наружу — перечитанный CardDto (SSE new_card, Ruling 8/9). -/// Гвард «уже есть карточка по дедупу» у обёртки нет — это «new»-проход воркера (Ruling 8, L940–951); тест -/// дубля здесь проверяет, что сбой записи карточки НЕ связывает заявку (карточки нет — линковать нечего). -/// -public sealed class PipelineCardWriterTests -{ - // ─── Создание + dedup-link (AddCard → Link, L512–513) ──────────────────────────────── - - [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); - - // Заявка дедупа связана с карточкой (UPDATE dedup SET lead_id = ?, L512–513). - 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) => new() - { - Id = "p_1", - DialogId = dialogId, - MsgId = msgId, - Text = text, - Status = PipelineQueueStatuses.Filtered, - Channel = new PipelineChannelDto("Канал", "ch", "#666"), - MsgAtMs = 1_700_000_000_000, - QueuedAtMs = 1_700_000_000_000, - }; -} +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; + +namespace Deal.Tests.Unit.Contracts; + +/// +/// Тесты обёртки создания карточки PipelineCardWriter (план Task 7 L400–402, Ruling 3/4; +/// python _store_lead L483–514: INSERT карточки → UPDATE dedup.lead_id → чтение после записи). +/// +/// +/// Тонкая обёртка: id карточки генерирует (l_), снимок собирает CardComposer (FakeKanjStore +/// без досок — «Неразобранное»), запись — ICardStore.AddCardAsync, связь дедупа — IPipelineStore.LinkAsync +/// (порядок AddCard → Link, Ruling 4 L512–513), наружу — перечитанный CardDto (SSE new_card, Ruling 8/9). +/// Гвард «уже есть карточка по дедупу» у обёртки нет — это «new»-проход воркера (Ruling 8, L940–951); тест +/// дубля здесь проверяет, что сбой записи карточки НЕ связывает заявку (карточки нет — линковать нечего). +/// +public sealed class PipelineCardWriterTests +{ + // ─── Создание + dedup-link (AddCard → Link, L512–513) ──────────────────────────────── + + [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); + + // Заявка дедупа связана с карточкой (UPDATE dedup SET lead_id = ?, L512–513). + 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) => new() + { + Id = "p_1", + DialogId = dialogId, + MsgId = msgId, + Text = text, + Status = PipelineQueueStatuses.Filtered, + Channel = new PipelineChannelDto("Канал", "ch", "#666"), + MsgAtMs = 1_700_000_000_000, + QueuedAtMs = 1_700_000_000_000, + }; +} diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs index 99f8bd6..41f3faa 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs @@ -1,271 +1,255 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Ai; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.Extensions.Logging.Abstractions; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Tenants; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Grpc; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Сквозной тест воркера pump с gRPC-классификатором ai-service (план Task 15, Acceptance L433–434: -/// «воркер с GrpcAiClassifier (UseLocal=false) проходит фильтр/классификацию против in-proc ai-service»). -/// -/// -/// Сообщение статуса filtered прогоняется реальным : ИИ-фильтр (RPC Filter — -/// in-proc фейк-ai-service) → классификация (RPC Classify + маппинг AiRawCardMapper) → карточка. Проверяются -/// вызовы обоих RPC (metadata tenant-id/service-token), карточка создана (не inbox-заглушка локального пути), -/// счётчики pump, списание usage с бюджета tenant_limits и накопление lifetime-суммы в KV (Ruling 3 этапа 7). -/// Локальный разбор (LocalAiClassifier) в этой ветке не участвует. Бюджетный гейт Task 9 (Acceptance -/// «карточка создаётся при исчерпании через Local»): тот же воркер, но классификатор обёрнут декоратором -/// — при исчерпанном бюджете платные RPC не вызываются (счётчики фейк-сервиса -/// нулевые), карточку собирает Local-разбор. -/// -[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); - - // Оба RPC ai-service вызваны с metadata tenant-id/service-token (Ruling 1). - 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)); - - // Usage фильтра + классификации накоплены в lifetime-KV и списаны с бюджета периода (Ruling 3). - 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); - - // Недоступность ИИ: фильтр — «пропустить», классификация — локальный разбор (aiFail), карточка жива - // (запуск против мёртвого ai-service не роняет pump — поведение Local-фолбэка Task 20). - 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() - { - // Acceptance Task 9 «PipelineWorker-путь»: карточка создаётся при исчерпании бюджета через Local — - // декоратор BudgetedAiClassifier поверх реального GrpcAiClassifier не пускает к платному ИИ (бюджет - // исчерпан), фильтр/классификацию выполняет Local-реализация, pump завершается карточкой. - 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); - - // Карточка создана из Local-разбора (детерминированный разбор ядра, Ruling 3): ИИ-путь pump, - // aiFail не засчитан (классификатор вернул разбор, а не упал). Расход не списывался. - 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); - - // Собирает воркер с реальным GrpcAiClassifier к in-proc ai-service (остальные порты — фейки). - // port: Порт хоста-фейка ai-service. - // limits: Фейк лимитов (списание usage/гейт); null — собственный экземпляр. - // budgeted: True — классификатор обёрнут декоратором BudgetedAiClassifier - // (гейт Task 9; fallback — реальный LocalAiClassifier). - // Возвращает: Воркер и фейки для проверок. - 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) => new() - { - Id = id, - DialogId = "d_1", - MsgId = 100, - Text = text, - Status = PipelineQueueStatuses.Filtered, - Channel = new PipelineChannelDto("Канал А", "channel_a", "#333"), - MsgAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - QueuedAtMs = 1_700_000_000_000, - Force = false, - }; - - // Неготовая модель: «не уверена» (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 (план Task 15, Acceptance L433–434: +/// «воркер с GrpcAiClassifier (UseLocal=false) проходит фильтр/классификацию против in-proc ai-service»). +/// +/// +/// Сообщение статуса filtered прогоняется реальным : ИИ-фильтр (RPC Filter — +/// in-proc фейк-ai-service) → классификация (RPC Classify + маппинг AiRawCardMapper) → карточка. Проверяются +/// вызовы обоих RPC (metadata tenant-id/service-token), карточка создана (не inbox-заглушка локального пути), +/// счётчики pump, списание usage с бюджета tenant_limits и накопление lifetime-суммы в KV (Ruling 3 этапа 7). +/// Локальный разбор (LocalAiClassifier) в этой ветке не участвует. Бюджетный гейт Task 9 (Acceptance +/// «карточка создаётся при исчерпании через Local»): тот же воркер, но классификатор обёрнут декоратором +/// — при исчерпанном бюджете платные RPC не вызываются (счётчики фейк-сервиса +/// нулевые), карточку собирает Local-разбор. +/// +[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); + + // Оба RPC ai-service вызваны с metadata tenant-id/service-token (Ruling 1). + 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)); + + // Usage фильтра + классификации накоплены в lifetime-KV и списаны с бюджета периода (Ruling 3). + 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); + + // Недоступность ИИ: фильтр — «пропустить», классификация — локальный разбор (aiFail), карточка жива + // (запуск против мёртвого ai-service не роняет pump — поведение Local-фолбэка Task 20). + 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() + { + // Acceptance Task 9 «PipelineWorker-путь»: карточка создаётся при исчерпании бюджета через Local — + // декоратор BudgetedAiClassifier поверх реального GrpcAiClassifier не пускает к платному ИИ (бюджет + // исчерпан), фильтр/классификацию выполняет Local-реализация, pump завершается карточкой. + 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); + + // Карточка создана из Local-разбора (детерминированный разбор ядра, Ruling 3): ИИ-путь pump, + // aiFail не засчитан (классификатор вернул разбор, а не упал). Расход не списывался. + 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); + + // Собирает воркер с реальным GrpcAiClassifier к in-proc ai-service (остальные порты — фейки). + // port: Порт хоста-фейка ai-service. + // limits: Фейк лимитов (списание usage/гейт); null — собственный экземпляр. + // budgeted: True — классификатор обёрнут декоратором BudgetedAiClassifier + // (гейт Task 9; fallback — реальный LocalAiClassifier). + // Возвращает: Воркер и фейки для проверок. + 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) => new() + { + Id = id, + DialogId = "d_1", + MsgId = 100, + Text = text, + Status = PipelineQueueStatuses.Filtered, + Channel = new PipelineChannelDto("Канал А", "channel_a", "#333"), + MsgAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + QueuedAtMs = 1_700_000_000_000, + Force = false, + }; + + // Неготовая модель: «не уверена» (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/Contracts/TelegramGatewayPortTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/TelegramGatewayPortTests.cs index 674271b..957e1d9 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/TelegramGatewayPortTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/TelegramGatewayPortTests.cs @@ -1,6 +1,4 @@ using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Contracts; diff --git a/src/core/tests/Deal.Tests.Unit/Grpc/RecordingAiService.cs b/src/core/tests/Deal.Tests.Unit/Grpc/RecordingAiService.cs index bf06d65..c4a0722 100644 --- a/src/core/tests/Deal.Tests.Unit/Grpc/RecordingAiService.cs +++ b/src/core/tests/Deal.Tests.Unit/Grpc/RecordingAiService.cs @@ -1,179 +1,178 @@ -using Deal.Grpc.Ai; -using Grpc.Core; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Grpc; - -/// -/// In-proc фейк ai-service для тестов gRPC-адаптеров ядра (план Task 15): Recording-сервер AiService. -/// -/// -/// Реализует 4 RPC ai.proto (Filter/Classify/GenerateKeywords/EvaluateFit) поверх сценария: ответы задаются -/// свойствами (/// -/// ), сбой вызова — флагом *Unavailable (UNAVAILABLE, как у реального -/// сервиса при недоступности провайдера). Сервер записывает обращения (счётчики, последний запрос, tenant-id/ -/// service-token из metadata) для проверок адаптера: маппинг DTO↔proto «по проводу», заполненные промпты/ -/// ProviderConfig в теле запроса, metadata (Ruling 1), usage. Токен НЕ проверяется — проверку service-token -/// интерцептором покрывают тесты хост-сервисов (Tasks 2–4); здесь важно, что клиент его шлёт. -/// -public sealed class RecordingAiService : AiService.AiServiceBase -{ - /// - /// Ответ Filter по умолчанию (pass=true, usage нулевой). - /// - public FilterReply FilterReply { get; set; } = new() { Pass = true }; - - /// - /// Ответ Classify по умолчанию (ok=false — «разбора нет»). - /// - public ClassifyReply ClassifyReply { get; set; } = new(); - - /// - /// Ответ GenerateKeywords по умолчанию (пустой список). - /// - public GenerateKeywordsReply GenerateKeywordsReply { get; set; } = new(); - - /// - /// Ответ EvaluateFit по умолчанию (fit=true). - /// - public EvaluateFitReply EvaluateFitReply { get; set; } = new() { Fit = true }; - - /// - /// Сбоить ли Filter статусом UNAVAILABLE (недоступность ai-service/провайдера). - /// - 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 (проверка промпта/текста/ProviderConfig). - /// - public FilterRequest? LastFilter { get; private set; } - - /// - /// Последний запрос Classify (проверка system_prompt/user_context/ProviderConfig). - /// - 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); - } - - // Записывает tenant-id/service-token вызова (Ruling 1) для проверок адаптера. - // context: Контекст вызова (metadata запроса). - private void RecordMetadata(ServerCallContext context) - { - RequestTenantIds.Add(context.RequestHeaders.GetValue("tenant-id") ?? string.Empty); - RequestTokens.Add(context.RequestHeaders.GetValue("service-token") ?? string.Empty); - } - - // Статус недоступности ai-service (как при недоступности провайдера, Ruling 5). - // Возвращает: Исключение RPC UNAVAILABLE. - 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-адаптеров ядра (план Task 15): Recording-сервер AiService. +/// +/// +/// Реализует 4 RPC ai.proto (Filter/Classify/GenerateKeywords/EvaluateFit) поверх сценария: ответы задаются +/// свойствами (/// +/// ), сбой вызова — флагом *Unavailable (UNAVAILABLE, как у реального +/// сервиса при недоступности провайдера). Сервер записывает обращения (счётчики, последний запрос, tenant-id/ +/// service-token из metadata) для проверок адаптера: маппинг DTO↔proto «по проводу», заполненные промпты/ +/// ProviderConfig в теле запроса, metadata (Ruling 1), usage. Токен НЕ проверяется — проверку service-token +/// интерцептором покрывают тесты хост-сервисов (Tasks 2–4); здесь важно, что клиент его шлёт. +/// +public sealed class RecordingAiService : AiService.AiServiceBase +{ + /// + /// Ответ Filter по умолчанию (pass=true, usage нулевой). + /// + public FilterReply FilterReply { get; set; } = new() { Pass = true }; + + /// + /// Ответ Classify по умолчанию (ok=false — «разбора нет»). + /// + public ClassifyReply ClassifyReply { get; set; } = new(); + + /// + /// Ответ GenerateKeywords по умолчанию (пустой список). + /// + public GenerateKeywordsReply GenerateKeywordsReply { get; set; } = new(); + + /// + /// Ответ EvaluateFit по умолчанию (fit=true). + /// + public EvaluateFitReply EvaluateFitReply { get; set; } = new() { Fit = true }; + + /// + /// Сбоить ли Filter статусом UNAVAILABLE (недоступность ai-service/провайдера). + /// + 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 (проверка промпта/текста/ProviderConfig). + /// + public FilterRequest? LastFilter { get; private set; } + + /// + /// Последний запрос Classify (проверка system_prompt/user_context/ProviderConfig). + /// + 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); + } + + // Записывает tenant-id/service-token вызова (Ruling 1) для проверок адаптера. + // context: Контекст вызова (metadata запроса). + private void RecordMetadata(ServerCallContext context) + { + RequestTenantIds.Add(context.RequestHeaders.GetValue("tenant-id") ?? string.Empty); + RequestTokens.Add(context.RequestHeaders.GetValue("service-token") ?? string.Empty); + } + + // Статус недоступности ai-service (как при недоступности провайдера, Ruling 5). + // Возвращает: Исключение RPC UNAVAILABLE. + 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 bca947f..b2b0e00 100644 --- a/src/core/tests/Deal.Tests.Unit/Grpc/RecordingMlService.cs +++ b/src/core/tests/Deal.Tests.Unit/Grpc/RecordingMlService.cs @@ -1,155 +1,154 @@ -using Deal.Grpc.Ml; -using Grpc.Core; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Grpc; - -/// -/// In-proc фейк ml-service для тестов gRPC-клиента ядра (план Task 16): Recording-сервер MlService. -/// -/// -/// Реализует 4 RPC ml.proto (Predict/Status/Reset/TrainBatch) поверх сценария: ответы задаются свойствами -/// (//), сбой вызова — флагом -/// *Unavailable (UNAVAILABLE, как у реального сервиса при недоступности хранилища/провайдера). -/// Сервер записывает обращения (счётчики, tenant-id/service-token из metadata) для проверок клиента -/// (metadata Ruling 1, батчи TrainBatch, кэш статуса, predict-фолбэк). Токен НЕ проверяется — проверку -/// service-token интерцептором покрывают тесты хост-сервисов (Tasks 2–4); здесь важно, что клиент его шлёт. -/// -public sealed class RecordingMlService : MlService.MlServiceBase -{ - /// - /// Ответ Predict по умолчанию (take=false — «не уверен», как у неготовой модели). - /// - public PredictReply PredictReply { get; set; } = new(); - - /// - /// Ответ Status по умолчанию (модель не готова). - /// - public StatusReply StatusReply { get; set; } = new(); - - /// - /// Ответ Reset по умолчанию (успех). - /// - public ResetReply ResetReply { get; set; } = new() { Ok = true }; - - /// - /// Сбоить ли Predict статусом UNAVAILABLE (недоступность ml-service). - /// - 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 }); - } - - // Записывает tenant-id/service-token вызова (Ruling 1) для проверок клиента. - // context: Контекст вызова (metadata запроса). - private void RecordMetadata(ServerCallContext context) - { - RequestTenantIds.Add(context.RequestHeaders.GetValue("tenant-id") ?? string.Empty); - RequestTokens.Add(context.RequestHeaders.GetValue("service-token") ?? string.Empty); - } - - // Статус недоступности ml-service (как при сбое хранилища модели, Ruling 1). - // Возвращает: Исключение RPC UNAVAILABLE. - 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-клиента ядра (план Task 16): Recording-сервер MlService. +/// +/// +/// Реализует 4 RPC ml.proto (Predict/Status/Reset/TrainBatch) поверх сценария: ответы задаются свойствами +/// (//), сбой вызова — флагом +/// *Unavailable (UNAVAILABLE, как у реального сервиса при недоступности хранилища/провайдера). +/// Сервер записывает обращения (счётчики, tenant-id/service-token из metadata) для проверок клиента +/// (metadata Ruling 1, батчи TrainBatch, кэш статуса, predict-фолбэк). Токен НЕ проверяется — проверку +/// service-token интерцептором покрывают тесты хост-сервисов (Tasks 2–4); здесь важно, что клиент его шлёт. +/// +public sealed class RecordingMlService : MlService.MlServiceBase +{ + /// + /// Ответ Predict по умолчанию (take=false — «не уверен», как у неготовой модели). + /// + public PredictReply PredictReply { get; set; } = new(); + + /// + /// Ответ Status по умолчанию (модель не готова). + /// + public StatusReply StatusReply { get; set; } = new(); + + /// + /// Ответ Reset по умолчанию (успех). + /// + public ResetReply ResetReply { get; set; } = new() { Ok = true }; + + /// + /// Сбоить ли Predict статусом UNAVAILABLE (недоступность ml-service). + /// + 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 }); + } + + // Записывает tenant-id/service-token вызова (Ruling 1) для проверок клиента. + // context: Контекст вызова (metadata запроса). + private void RecordMetadata(ServerCallContext context) + { + RequestTenantIds.Add(context.RequestHeaders.GetValue("tenant-id") ?? string.Empty); + RequestTokens.Add(context.RequestHeaders.GetValue("service-token") ?? string.Empty); + } + + // Статус недоступности ml-service (как при сбое хранилища модели, Ruling 1). + // Возвращает: Исключение RPC UNAVAILABLE. + 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 8d23993..1f48e77 100644 --- a/src/core/tests/Deal.Tests.Unit/Grpc/RecordingTelegramService.cs +++ b/src/core/tests/Deal.Tests.Unit/Grpc/RecordingTelegramService.cs @@ -1,165 +1,164 @@ -using Deal.Grpc.Telegram; -using Grpc.Core; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Grpc; - -/// -/// Фейк-сервер TelegramService (telegram.proto) для in-proc тестов GrpcTelegramClient (план Task 14). -/// -/// -/// Реализует сценариевые RPC ядра наружу (GetStatus/StartPhone/StartQr/RefreshDialogs/Backfill/ReadRecent); -/// остальные наследуют UNIMPLEMENTED (в тестах не вызываются). Записывает входящие запросы (проверка «что ушло -/// по проводу»: api_id/api_hash/dialog_id/force) и отдаёт настроенные ответы; сбой домена инжектится -/// — RpcException с detail (как шлёт telegram-service, Ruling 1). -/// -public sealed class RecordingTelegramService : TelegramService.TelegramServiceBase -{ - /// - /// Последний запрос StartPhone (phone/apiId/apiHash). - /// - public StartPhoneRequest? LastStartPhone { get; private set; } - - /// - /// Последний запрос StartQr (apiId/apiHash). - /// - public StartQrRequest? LastStartQr { get; private set; } - - /// - /// Запросы Backfill (dialogId/force) в порядке вызова. - /// - public List BackfillRequests { get; } = []; - - /// - /// Запросы ReadRecent (dialogId/limit) в порядке вызова. - /// - public List ReadRecentRequests { get; } = []; - - /// - /// Каталог ответа RefreshDialogs (DialogEntry). - /// - public List Catalog { get; } = []; - - /// - /// Сколько сообщений «разобрал» Backfill (ответ processed). - /// - public int BackfillProcessed { get; set; } = 5; - - /// - /// Доменный сбой следующего вызова (RpcException с detail telegram-service); null — без сбоя. - /// - public RpcException? DomainFailure { get; set; } - - /// - /// Фаза/поля ответа GetStatus. - /// - public string StatusPhase { get; set; } = "idle"; - - /// - /// Аккаунт ответа GetStatus (account). - /// - 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); - } - - // Бросает доменный сбой сценария (если задан) — как 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 (telegram.proto) для in-proc тестов GrpcTelegramClient (план Task 14). +/// +/// +/// Реализует сценариевые RPC ядра наружу (GetStatus/StartPhone/StartQr/RefreshDialogs/Backfill/ReadRecent); +/// остальные наследуют UNIMPLEMENTED (в тестах не вызываются). Записывает входящие запросы (проверка «что ушло +/// по проводу»: api_id/api_hash/dialog_id/force) и отдаёт настроенные ответы; сбой домена инжектится +/// — RpcException с detail (как шлёт telegram-service, Ruling 1). +/// +public sealed class RecordingTelegramService : TelegramService.TelegramServiceBase +{ + /// + /// Последний запрос StartPhone (phone/apiId/apiHash). + /// + public StartPhoneRequest? LastStartPhone { get; private set; } + + /// + /// Последний запрос StartQr (apiId/apiHash). + /// + public StartQrRequest? LastStartQr { get; private set; } + + /// + /// Запросы Backfill (dialogId/force) в порядке вызова. + /// + public List BackfillRequests { get; } = []; + + /// + /// Запросы ReadRecent (dialogId/limit) в порядке вызова. + /// + public List ReadRecentRequests { get; } = []; + + /// + /// Каталог ответа RefreshDialogs (DialogEntry). + /// + public List Catalog { get; } = []; + + /// + /// Сколько сообщений «разобрал» Backfill (ответ processed). + /// + public int BackfillProcessed { get; set; } = 5; + + /// + /// Доменный сбой следующего вызова (RpcException с detail telegram-service); null — без сбоя. + /// + public RpcException? DomainFailure { get; set; } + + /// + /// Фаза/поля ответа GetStatus. + /// + public string StatusPhase { get; set; } = "idle"; + + /// + /// Аккаунт ответа GetStatus (account). + /// + 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); + } + + // Бросает доменный сбой сценария (если задан) — как 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 30c83ee..21b9652 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/AuditLogStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/AuditLogStoreTests.cs @@ -1,155 +1,150 @@ -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; -using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Юнит-тесты EF-адаптера на InMemory-провайдере: маппинг DTO↔сущности, -/// фильтры выборки, сортировка At DESC, limit ≤500 (Task 4, Ruling 4). -/// -/// -/// Покрываются append + query/count round-trip'ы — все поля маппятся вручную (порт модуля не видит EF-сущности, -/// Ruling 1). Append-only: тест рефлексией проверяет отсутствие Update/Delete в порте (AuditServiceTests); -/// DB-триггеры не добавляем (Ruling 4). Identity-Id генерирует Postgres — здесь значения провайдера 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); - } - - /// - /// Авто-очистка (этап 12, пакет B): удаляет только записи старше границы, свежие остаются. - /// - [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-провайдере: маппинг DTO↔сущности, +/// фильтры выборки, сортировка At DESC, limit ≤500 (Task 4, Ruling 4). +/// +/// +/// Покрываются append + query/count round-trip'ы — все поля маппятся вручную (порт модуля не видит EF-сущности, +/// Ruling 1). Append-only: тест рефлексией проверяет отсутствие Update/Delete в порте (AuditServiceTests); +/// DB-триггеры не добавляем (Ruling 4). Identity-Id генерирует Postgres — здесь значения провайдера 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); + } + + /// + /// Авто-очистка (этап 12, пакет B): удаляет только записи старше границы, свежие остаются. + /// + [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/AuthStoreTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/AuthStoreTests.cs index a2a3739..73db465 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/AuthStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/AuthStoreTests.cs @@ -1,91 +1,85 @@ -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; -using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Юнит-тесты EF-адаптера на InMemory-провайдере: список пользователей тенанта -/// (Task 7) и маркер impersonation в сессии (план Task 7). -/// -/// -/// Покрываются round-trip'ы новых маппингов DTO↔сущности (порт модуля не видит EF-сущности, Ruling 1). -/// Удаления (ExecuteDeleteAsync) InMemory-провайдер не поддерживает — не тестируются (Postgres, ⚠ Manual). -/// -public sealed class AuthStoreTests -{ - [Fact] - public async Task ListUsersByTenantIdAsync_ReturnsOnlyTenantUsers() - { - var store = CreateStore(); - var tenantId = Guid.NewGuid(); - var otherTenantId = Guid.NewGuid(); - await CreateUserAsync(store, tenantId, "first@example.com"); - await CreateUserAsync(store, tenantId, "second@example.com"); - await CreateUserAsync(store, otherTenantId, "other@example.com"); - - IReadOnlyList users = await store.ListUsersByTenantIdAsync(tenantId, CancellationToken.None); - - Assert.Equal(2, users.Count); - // Порядок EF: CreatedAt, затем Login — «первый пользователь» для impersonation без login детерминирован. - Assert.Equal("first@example.com", users[0].Login); - Assert.Equal("second@example.com", users[1].Login); - Assert.All(users, u => Assert.Equal(tenantId, u.TenantId)); - Assert.DoesNotContain(users, u => u.Login == "other@example.com"); - } - - [Fact] - public async Task CreateSession_ThenFindByTokenHash_ReturnsImpersonationMarker() - { - var store = CreateStore(); - var userId = Guid.NewGuid(); - await CreateUserAsync(store, Guid.NewGuid(), "user@example.com"); - var operatorId = Guid.NewGuid(); - var tokenHash = "token-hash-impersonation"; - - await store.CreateSessionAsync( - new SessionDto(tokenHash, userId, "user@example.com", DateTimeOffset.UtcNow.AddDays(30), operatorId), - CancellationToken.None); - - SessionDto? found = await store.FindSessionByTokenHashAsync(tokenHash, CancellationToken.None); - Assert.NotNull(found); - Assert.Equal(operatorId, found!.ImpersonatedByOperatorId); - Assert.Equal(userId, found.UserId); - - // Обычная сессия (без маркера) читается как ImpersonatedByOperatorId=null. - var plainHash = "token-hash-plain"; - await store.CreateSessionAsync( - new SessionDto(plainHash, userId, "user@example.com", DateTimeOffset.UtcNow.AddDays(30)), - CancellationToken.None); - SessionDto? plain = await store.FindSessionByTokenHashAsync(plainHash, CancellationToken.None); - Assert.NotNull(plain); - Assert.Null(plain!.ImpersonatedByOperatorId); - } - - // Создаёт пользователя тенанта (хэш пароля — заглушка, для чтения не нужен). - private static async Task CreateUserAsync( - AuthStore store, - Guid tenantId, - string login) - { - await store.CreateUserAsync( - new StoredUserDto(Guid.NewGuid(), login, tenantId, "active", "fake-argon2-encoded-hash"), - CancellationToken.None); - } - - // Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются). - private static AuthStore CreateStore() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) - .Options; - return new AuthStore(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-провайдере: список пользователей тенанта +/// (Task 7) и маркер impersonation в сессии (план Task 7). +/// +/// +/// Покрываются round-trip'ы новых маппингов DTO↔сущности (порт модуля не видит EF-сущности, Ruling 1). +/// Удаления (ExecuteDeleteAsync) InMemory-провайдер не поддерживает — не тестируются (Postgres, ⚠ Manual). +/// +public sealed class AuthStoreTests +{ + [Fact] + public async Task ListUsersByTenantIdAsync_ReturnsOnlyTenantUsers() + { + var store = CreateStore(); + var tenantId = Guid.NewGuid(); + var otherTenantId = Guid.NewGuid(); + await CreateUserAsync(store, tenantId, "first@example.com"); + await CreateUserAsync(store, tenantId, "second@example.com"); + await CreateUserAsync(store, otherTenantId, "other@example.com"); + + IReadOnlyList users = await store.ListUsersByTenantIdAsync(tenantId, CancellationToken.None); + + Assert.Equal(2, users.Count); + // Порядок EF: CreatedAt, затем Login — «первый пользователь» для impersonation без login детерминирован. + Assert.Equal("first@example.com", users[0].Login); + Assert.Equal("second@example.com", users[1].Login); + Assert.All(users, u => Assert.Equal(tenantId, u.TenantId)); + Assert.DoesNotContain(users, u => u.Login == "other@example.com"); + } + + [Fact] + public async Task CreateSession_ThenFindByTokenHash_ReturnsImpersonationMarker() + { + var store = CreateStore(); + var userId = Guid.NewGuid(); + await CreateUserAsync(store, Guid.NewGuid(), "user@example.com"); + var operatorId = Guid.NewGuid(); + var tokenHash = "token-hash-impersonation"; + + await store.CreateSessionAsync( + new SessionDto(tokenHash, userId, "user@example.com", DateTimeOffset.UtcNow.AddDays(30), operatorId), + CancellationToken.None); + + SessionDto? found = await store.FindSessionByTokenHashAsync(tokenHash, CancellationToken.None); + Assert.NotNull(found); + Assert.Equal(operatorId, found!.ImpersonatedByOperatorId); + Assert.Equal(userId, found.UserId); + + // Обычная сессия (без маркера) читается как ImpersonatedByOperatorId=null. + var plainHash = "token-hash-plain"; + await store.CreateSessionAsync( + new SessionDto(plainHash, userId, "user@example.com", DateTimeOffset.UtcNow.AddDays(30)), + CancellationToken.None); + SessionDto? plain = await store.FindSessionByTokenHashAsync(plainHash, CancellationToken.None); + Assert.NotNull(plain); + Assert.Null(plain!.ImpersonatedByOperatorId); + } + + // Создаёт пользователя тенанта (хэш пароля — заглушка, для чтения не нужен). + private static async Task CreateUserAsync( + AuthStore store, + Guid tenantId, + string login) + { + await store.CreateUserAsync( + new StoredUserDto(Guid.NewGuid(), login, tenantId, "active", "fake-argon2-encoded-hash"), + CancellationToken.None); + } + + // Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются). + private static AuthStore CreateStore() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options; + return new AuthStore(new DealDbContext(options)); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs index 55568a4..6dea6eb 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs @@ -1,98 +1,93 @@ -using Deal.Infrastructure.Services; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Contracts; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Тесты единой точки перехода карточки (R4 этапа 9): маршрутизация цели -/// «стадия Выбранных» → (история + сброс напоминания) и -/// «дашборд-контейнер» → (журнал/matchHits/обучение ML). -/// Нормализация результатов — к . -/// -/// -/// Фейки: (единые строки Cards), , -/// и — зависимости CardsService. -/// -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, SourceMsg = "Нужен 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, SourceMsg = "Текст" }); - - 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.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; + +/// +/// Тесты единой точки перехода карточки (R4 этапа 9): маршрутизация цели +/// «стадия Выбранных» → (история + сброс напоминания) и +/// «дашборд-контейнер» → (журнал/matchHits/обучение ML). +/// Нормализация результатов — к . +/// +/// +/// Фейки: (единые строки Cards), , +/// и — зависимости CardsService. +/// +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, SourceMsg = "Нужен 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, SourceMsg = "Текст" }); + + 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 b42dda6..b7af223 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/GlobalSettingsStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/GlobalSettingsStoreTests.cs @@ -1,65 +1,60 @@ -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; -using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Юнит-тесты EF-адаптера на InMemory-провайдере (ТЗ §4.1/§8.1): -/// чтение отсутствующего ключа, upsert (создание/обновление), сохранение строки как есть. -/// -/// -/// Проверяется семантика адаптера; реальный Postgres (схема public) — в dev-приёмке. -/// -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-провайдере (ТЗ §4.1/§8.1): +/// чтение отсутствующего ключа, upsert (создание/обновление), сохранение строки как есть. +/// +/// +/// Проверяется семантика адаптера; реальный Postgres (схема public) — в dev-приёмке. +/// +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 19734e3..3b26fbd 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/InviteStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/InviteStoreTests.cs @@ -1,131 +1,126 @@ -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; -using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Юнит-тесты EF-адаптера на InMemory-провайдере: маппинг DTO↔сущности и статусные операции. -/// -/// -/// Покрываются create/find/list/update round-trip'ы (порт модуля не видит EF-сущности, Ruling 1; маппинг ручной). -/// Частичный unique-индекс invites.Email по pending и FK на оператора InMemory-провайдер не исполняет — -/// их поведение завязано на реляционный Postgres (проверка ⚠ Manual). «expired» адаптер не вычисляет — -/// это зона InvitesService. -/// -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-провайдере: маппинг DTO↔сущности и статусные операции. +/// +/// +/// Покрываются create/find/list/update round-trip'ы (порт модуля не видит EF-сущности, Ruling 1; маппинг ручной). +/// Частичный unique-индекс invites.Email по pending и FK на оператора InMemory-провайдер не исполняет — +/// их поведение завязано на реляционный Postgres (проверка ⚠ Manual). «expired» адаптер не вычисляет — +/// это зона InvitesService. +/// +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 163e92b..968844e 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/MtlsOptionsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/MtlsOptionsTests.cs @@ -1,118 +1,112 @@ -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Microsoft.Extensions.Configuration; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Тесты конфиг-парсинга mTLS (план Task 13, Ruling 6): env DEAL_MTLS_* — флаг и пути/пароли; -/// dev-дефолт Enabled=false (режим plaintext + service-token не меняется). -/// -/// -/// MtlsOptions.FromConfiguration читает только IConfiguration (env-провайдер) — файлы не трогаются -/// (загрузка сертификатов — MtlsCertificatesTests). Значения env: «1»/«true» включают флаг, «0»/«false»/ -/// отсутствие — выключают; пути обрезаются. -/// -public sealed class MtlsOptionsTests -{ - /// - /// Пустой конфиг — флаг выключен и все пути пустые (dev-дефолт, plaintext не меняется). - /// - [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 выключен (dev). - /// - [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); - } - - /// - /// Ключ DEAL_MTLS_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 (план Task 13, Ruling 6): env DEAL_MTLS_* — флаг и пути/пароли; +/// dev-дефолт Enabled=false (режим plaintext + service-token не меняется). +/// +/// +/// MtlsOptions.FromConfiguration читает только IConfiguration (env-провайдер) — файлы не трогаются +/// (загрузка сертификатов — MtlsCertificatesTests). Значения env: «1»/«true» включают флаг, «0»/«false»/ +/// отсутствие — выключают; пути обрезаются. +/// +public sealed class MtlsOptionsTests +{ + /// + /// Пустой конфиг — флаг выключен и все пути пустые (dev-дефолт, plaintext не меняется). + /// + [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 выключен (dev). + /// + [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); + } + + /// + /// Ключ DEAL_MTLS_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 50f20cb..a94ce54 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/OperatorAuthStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/OperatorAuthStoreTests.cs @@ -1,95 +1,93 @@ -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; -using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Tenants.Application.Models; -using Microsoft.EntityFrameworkCore; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Юнит-тесты EF-адаптера на InMemory-провайдере: маппинг DTO↔сущности (минимально). -/// -/// -/// Покрываются create/find round-trip'ы оператора и сессии — все поля маппятся вручную (порт модуля не -/// видит EF-сущности, Ruling 1). Удаления (ExecuteDeleteAsync) InMemory-провайдер не поддерживает и здесь -/// не тестируются — их поведение завязано на реляционный провайдер (Postgres, проверка ⚠ Manual). -/// -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-провайдере: маппинг DTO↔сущности (минимально). +/// +/// +/// Покрываются create/find round-trip'ы оператора и сессии — все поля маппятся вручную (порт модуля не +/// видит EF-сущности, Ruling 1). Удаления (ExecuteDeleteAsync) InMemory-провайдер не поддерживает и здесь +/// не тестируются — их поведение завязано на реляционный провайдер (Postgres, проверка ⚠ Manual). +/// +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 fe5e8e4..5042173 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/RateLimitCounterStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/RateLimitCounterStoreTests.cs @@ -1,8 +1,6 @@ using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; using Deal.Infrastructure.Persistence.Repositories; using Microsoft.EntityFrameworkCore; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Infrastructure; diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/SecretCipherTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/SecretCipherTests.cs index 90e06e9..6bc8e53 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/SecretCipherTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/SecretCipherTests.cs @@ -1,9 +1,5 @@ using Deal.Infrastructure.Security; using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Infrastructure; diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantEntityTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantEntityTests.cs index 4b03591..a06bbd6 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantEntityTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantEntityTests.cs @@ -1,5 +1,4 @@ using Deal.Infrastructure.Persistence.Entities; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Infrastructure; diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantLimitStoreTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantLimitStoreTests.cs index d1c8aca..6a5a195 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantLimitStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantLimitStoreTests.cs @@ -1,321 +1,316 @@ -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Юнит-тесты EF-адаптера на InMemory-провайдере (Task 8, Ruling 3 этапа 7): -/// ленивый GetOrCreate с дефолт-бюджетом, списание (инкремент БЕЗ установки флагов — их ставит TryMark*, Task 9), -/// ленивый reset периода, смена бюджета оператором (сброс флагов) и TryMark*-CAS для SSE-алертов Task 9. -/// -/// -/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). Часы адаптера -/// подменяются фиксированным «сейчас» (как MlStatusCache): запись с PeriodStart прошлого месяца обнуляет -/// UsedTokens и ставит PeriodStart=now (acceptance Task 8). Для статуса тенанта (BudgetStateDto.Allowed) -/// рядом с лимитом заводится строка public.tenants — FK-семантика Restrict в 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); - - // Дефолт модуля (Ruling 3): 10 000 000 токенов, период месяц, расход 0, период стартует «сейчас». - 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); - - // Приостановленный тенант → Allowed=false (Ruling 3/10(5): suspended замораживает ИИ). - 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() - { - // Acceptance Task 8: запись с PeriodStart прошлого месяца — списание обнуляет UsedTokens и ставит новый - // PeriodStart (ленивый reset при записи, Ruling 3). Флаги старого периода тоже сброшены. - 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() - { - // Review-fix Task 9: порог 80% (budget 1000: 700+100 = 800 ≥ floor(0.8·1000)) при списании НЕ выставляет - // Warned80 — флаг ставит только TryMark* (планировщик алертов в момент фактического перехода), иначе - // списание «съедало» бы переход и тост при естественном расходе не вышел бы никогда. - 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() - { - // Review-fix Task 9: исчерпание (1050 ≥ 1000) при списании НЕ выставляет NotifiedExhausted (его ставит - // TryMark*); Allowed=false при этом считается от used/budget (1050 ≥ 1000), а не от флагов. - 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); - - // Порог достигнут и флаг не стоял → true (SSE-алерт публикуется один раз, Task 9). - 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); - } - - /// - /// Авто-очистка (этап 12, пакет B): строки с завершившимся периодом сбрасываются, текущие — нет. - /// - [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-провайдере (Task 8, Ruling 3 этапа 7): +/// ленивый GetOrCreate с дефолт-бюджетом, списание (инкремент БЕЗ установки флагов — их ставит TryMark*, Task 9), +/// ленивый reset периода, смена бюджета оператором (сброс флагов) и TryMark*-CAS для SSE-алертов Task 9. +/// +/// +/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). Часы адаптера +/// подменяются фиксированным «сейчас» (как MlStatusCache): запись с PeriodStart прошлого месяца обнуляет +/// UsedTokens и ставит PeriodStart=now (acceptance Task 8). Для статуса тенанта (BudgetStateDto.Allowed) +/// рядом с лимитом заводится строка public.tenants — FK-семантика Restrict в 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); + + // Дефолт модуля (Ruling 3): 10 000 000 токенов, период месяц, расход 0, период стартует «сейчас». + 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); + + // Приостановленный тенант → Allowed=false (Ruling 3/10(5): suspended замораживает ИИ). + 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() + { + // Acceptance Task 8: запись с PeriodStart прошлого месяца — списание обнуляет UsedTokens и ставит новый + // PeriodStart (ленивый reset при записи, Ruling 3). Флаги старого периода тоже сброшены. + 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() + { + // Review-fix Task 9: порог 80% (budget 1000: 700+100 = 800 ≥ floor(0.8·1000)) при списании НЕ выставляет + // Warned80 — флаг ставит только TryMark* (планировщик алертов в момент фактического перехода), иначе + // списание «съедало» бы переход и тост при естественном расходе не вышел бы никогда. + 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() + { + // Review-fix Task 9: исчерпание (1050 ≥ 1000) при списании НЕ выставляет NotifiedExhausted (его ставит + // TryMark*); Allowed=false при этом считается от used/budget (1050 ≥ 1000), а не от флагов. + 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); + + // Порог достигнут и флаг не стоял → true (SSE-алерт публикуется один раз, Task 9). + 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); + } + + /// + /// Авто-очистка (этап 12, пакет B): строки с завершившимся периодом сбрасываются, текущие — нет. + /// + [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 cfc0a88..31bbd33 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantRepositoryTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantRepositoryTests.cs @@ -1,58 +1,52 @@ -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; -using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.EntityFrameworkCore; -using Deal.Tests.Unit.Support; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Юнит-тесты EF-адаптера на InMemory-провайдере: смена статуса (Task 7). -/// -/// -/// CreateAsync/FindByIdAsync/ListAsync покрыты косвенно (Task 1–6); здесь — новый маппинг UpdateStatusAsync -/// (suspend/unsuspend оператора): существующий тенант обновляется (true), неизвестный — false без записи. -/// -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-провайдере: смена статуса (Task 7). +/// +/// +/// CreateAsync/FindByIdAsync/ListAsync покрыты косвенно (Task 1–6); здесь — новый маппинг UpdateStatusAsync +/// (suspend/unsuspend оператора): существующий тенант обновляется (true), неизвестный — false без записи. +/// +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/Infrastructure/TenantSchemaMigrationServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigrationServiceTests.cs index c36efd4..3715471 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigrationServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigrationServiceTests.cs @@ -1,97 +1,93 @@ -using Deal.Infrastructure.Tenancy; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.Extensions.Logging.Abstractions; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Tenants; - -namespace Deal.Tests.Unit.Infrastructure; - -/// -/// Тесты пакетной миграции схем тенантов (этап 12, пакет C): провижининг всех схем реестра, отказ одной -/// схемы не прерывает остальные, пустой реестр — корректная пустая сводка. -/// -/// -/// Реальный TenantProvisioningService требует Postgres, поэтому проверяется координация сервиса на -/// фейках (, ), зеркалящих порты. -/// Идемпотентность повторного прогона обеспечивает EF MigrateAsync (см. TenantProvisioningService). -/// -public sealed class TenantSchemaMigrationServiceTests -{ - // Тенант сценария A. - private static readonly Guid TenantA = Guid.Parse("11111111-1111-1111-1111-111111111111"); - - // Тенант сценария B. - private static readonly Guid TenantB = Guid.Parse("22222222-2222-2222-2222-222222222222"); - - [Fact] - public async Task MigrateAllAsync_EmptyRegistry_ReturnsEmptySummary() - { - var service = NewService(new FakeTenantRepository(), new FakeTenantProvisioner()); - - TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); - - Assert.True(summary.Ok); - Assert.Equal(0, summary.Total); - Assert.Equal(0, summary.Migrated); - Assert.Empty(summary.FailedSchemas); - } - - [Fact] - public async Task MigrateAllAsync_ProvisionsEveryTenantSchema() - { - var provisioner = new FakeTenantProvisioner(); - var repository = new FakeTenantRepository( - TenantRecord(TenantA, "A"), - TenantRecord(TenantB, "B")); - var service = NewService(repository, provisioner); - - TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); - - Assert.True(summary.Ok); - Assert.Equal(2, summary.Total); - Assert.Equal(2, summary.Migrated); - Assert.Equal(0, summary.Failed); - Assert.Equal( - new[] { $"tenant_{TenantA:N}", $"tenant_{TenantB:N}" }.OrderBy(name => name, StringComparer.Ordinal), - provisioner.ProvisionedSchemaNames.OrderBy(name => name, StringComparer.Ordinal)); - } - - [Fact] - public async Task MigrateAllAsync_OneSchemaFails_ContinuesAndReportsFailed() - { - var failingSchema = $"tenant_{TenantA:N}"; - var provisioner = new FailingTenantProvisioner(failingSchema); - var repository = new FakeTenantRepository( - TenantRecord(TenantA, "A"), - TenantRecord(TenantB, "B")); - var service = NewService(repository, provisioner); - - TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); - - Assert.False(summary.Ok); - Assert.Equal(2, summary.Total); - Assert.Equal(1, summary.Migrated); - Assert.Equal(1, summary.Failed); - Assert.Equal(new[] { failingSchema }, summary.FailedSchemas); - // Уцелевшая схема мигрирована, сбойная — не попала в список успешных. - Assert.Equal(new[] { $"tenant_{TenantB:N}" }, provisioner.ProvisionedSchemaNames); - } - - // Создаёт сервис на фейках (логирование не проверяется). - // repository: Реестр тенантов. - // provisioner: Провижинер схем. - // Возвращает: Тестируемый сервис. - private static TenantSchemaMigrationService NewService(ITenantRepository repository, ITenantProvisioner provisioner) - => new(repository, provisioner, NullLogger.Instance); - - // Строка реестра тенантов для сценария. - // id: Id тенанта. - // name: Имя тенанта. - // Возвращает: Запись реестра. - private static TenantRecordDto TenantRecord(Guid id, string name) - => new(id, name, TenantStatuses.Active, DateTimeOffset.UtcNow); -} +using Deal.Infrastructure.Tenancy; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Tests.Unit.Modules.Tenants; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Infrastructure; + +/// +/// Тесты пакетной миграции схем тенантов (этап 12, пакет C): провижининг всех схем реестра, отказ одной +/// схемы не прерывает остальные, пустой реестр — корректная пустая сводка. +/// +/// +/// Реальный TenantProvisioningService требует Postgres, поэтому проверяется координация сервиса на +/// фейках (, ), зеркалящих порты. +/// Идемпотентность повторного прогона обеспечивает EF MigrateAsync (см. TenantProvisioningService). +/// +public sealed class TenantSchemaMigrationServiceTests +{ + // Тенант сценария A. + private static readonly Guid TenantA = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + // Тенант сценария B. + private static readonly Guid TenantB = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + [Fact] + public async Task MigrateAllAsync_EmptyRegistry_ReturnsEmptySummary() + { + var service = NewService(new FakeTenantRepository(), new FakeTenantProvisioner()); + + TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); + + Assert.True(summary.Ok); + Assert.Equal(0, summary.Total); + Assert.Equal(0, summary.Migrated); + Assert.Empty(summary.FailedSchemas); + } + + [Fact] + public async Task MigrateAllAsync_ProvisionsEveryTenantSchema() + { + var provisioner = new FakeTenantProvisioner(); + var repository = new FakeTenantRepository( + TenantRecord(TenantA, "A"), + TenantRecord(TenantB, "B")); + var service = NewService(repository, provisioner); + + TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); + + Assert.True(summary.Ok); + Assert.Equal(2, summary.Total); + Assert.Equal(2, summary.Migrated); + Assert.Equal(0, summary.Failed); + Assert.Equal( + new[] { $"tenant_{TenantA:N}", $"tenant_{TenantB:N}" }.OrderBy(name => name, StringComparer.Ordinal), + provisioner.ProvisionedSchemaNames.OrderBy(name => name, StringComparer.Ordinal)); + } + + [Fact] + public async Task MigrateAllAsync_OneSchemaFails_ContinuesAndReportsFailed() + { + var failingSchema = $"tenant_{TenantA:N}"; + var provisioner = new FailingTenantProvisioner(failingSchema); + var repository = new FakeTenantRepository( + TenantRecord(TenantA, "A"), + TenantRecord(TenantB, "B")); + var service = NewService(repository, provisioner); + + TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); + + Assert.False(summary.Ok); + Assert.Equal(2, summary.Total); + Assert.Equal(1, summary.Migrated); + Assert.Equal(1, summary.Failed); + Assert.Equal(new[] { failingSchema }, summary.FailedSchemas); + // Уцелевшая схема мигрирована, сбойная — не попала в список успешных. + Assert.Equal(new[] { $"tenant_{TenantB:N}" }, provisioner.ProvisionedSchemaNames); + } + + // Создаёт сервис на фейках (логирование не проверяется). + // repository: Реестр тенантов. + // provisioner: Провижинер схем. + // Возвращает: Тестируемый сервис. + private static TenantSchemaMigrationService NewService(ITenantRepository repository, ITenantProvisioner provisioner) + => new(repository, provisioner, NullLogger.Instance); + + // Строка реестра тенантов для сценария. + // id: Id тенанта. + // name: Имя тенанта. + // Возвращает: Запись реестра. + private static TenantRecordDto TenantRecord(Guid id, string name) + => new(id, name, TenantStatuses.Active, DateTimeOffset.UtcNow); +} diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigratorTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigratorTests.cs index f9cef86..b1b5fdc 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigratorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigratorTests.cs @@ -1,5 +1,4 @@ using Deal.Infrastructure.Migrations; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Infrastructure; diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSettingEntityTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSettingEntityTests.cs index 040d1b9..98b032c 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSettingEntityTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSettingEntityTests.cs @@ -1,5 +1,4 @@ using Deal.Infrastructure.Persistence.Entities; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Infrastructure; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Cards/CardsDomainTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Cards/CardsDomainTests.cs index 4c03a56..007c0d1 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Cards/CardsDomainTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Cards/CardsDomainTests.cs @@ -1,8 +1,6 @@ using Deal.Modules.Cards; using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Cards; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Cards/FakeKanjStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Cards/FakeKanjStore.cs index 9bf2bb4..2bd8617 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Cards/FakeKanjStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Cards/FakeKanjStore.cs @@ -1,12 +1,6 @@ -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Cards; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Cards/LocalSourceStub.cs b/src/core/tests/Deal.Tests.Unit/Modules/Cards/LocalSourceStub.cs index 7a82592..1ccd87e 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Cards/LocalSourceStub.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Cards/LocalSourceStub.cs @@ -1,7 +1,4 @@ using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Cards; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Cards/TelegramSourceStub.cs b/src/core/tests/Deal.Tests.Unit/Modules/Cards/TelegramSourceStub.cs index 19937c6..9aedfd9 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Cards/TelegramSourceStub.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Cards/TelegramSourceStub.cs @@ -1,7 +1,4 @@ using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Cards; 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 458641a..4d7abc0 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryBanGuardTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryBanGuardTests.cs @@ -1,133 +1,125 @@ -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.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Modules.Discovery; - -/// -/// Тесты DiscoveryBanGuard — суточный лимит авто-вступлений, flood-день, стоп-кран (план Task 18, -/// 1:1 ban_guard.py L1–81, Ruling 10). -/// -/// -/// Гард чистый: считает DiscLog (event=join_auto) за UTC-сутки через и читает -/// KV-настройки через . «Часы» фиксированы и совпадают у стора и гарда (эталон -/// FakeDiscoveryStore: конструктор принимает источник времени) — границы суток детерминированы. -/// -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(); - - // Суточный лимит 50 исчерпан (50 авто-вступлений сегодня) — вступление нельзя (python L38–41). - 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-день, стоп-кран (план Task 18, +/// 1:1 ban_guard.py L1–81, Ruling 10). +/// +/// +/// Гард чистый: считает DiscLog (event=join_auto) за UTC-сутки через и читает +/// KV-настройки через . «Часы» фиксированы и совпадают у стора и гарда (эталон +/// FakeDiscoveryStore: конструктор принимает источник времени) — границы суток детерминированы. +/// +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(); + + // Суточный лимит 50 исчерпан (50 авто-вступлений сегодня) — вступление нельзя (python L38–41). + 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/DiscoveryBlacklistServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryBlacklistServiceTests.cs index e04b0cb..f3658e1 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryBlacklistServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryBlacklistServiceTests.cs @@ -1,10 +1,5 @@ -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.Discovery.Application.Registrars; using Deal.Modules.Discovery.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Discovery; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryCandidatesServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryCandidatesServiceTests.cs index e5d08bd..6504a17 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryCandidatesServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryCandidatesServiceTests.cs @@ -1,10 +1,6 @@ -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.Discovery.Application.Registrars; using Deal.Modules.Discovery.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Discovery; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryLangDetectorTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryLangDetectorTests.cs index ca13eca..fadca97 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryLangDetectorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryLangDetectorTests.cs @@ -1,10 +1,4 @@ -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.Discovery.Application.Registrars; using Deal.Modules.Discovery.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Discovery; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryLogServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryLogServiceTests.cs index b2ed9e6..f684097 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryLogServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryLogServiceTests.cs @@ -1,10 +1,5 @@ -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.Discovery.Application.Registrars; using Deal.Modules.Discovery.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Discovery; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoverySearchErrorCounterTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoverySearchErrorCounterTests.cs index d793716..109caea 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoverySearchErrorCounterTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoverySearchErrorCounterTests.cs @@ -1,10 +1,4 @@ -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.Discovery.Application.Registrars; using Deal.Modules.Discovery.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Discovery; 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 25310dc..a28ebd3 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs @@ -1,414 +1,410 @@ -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.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Modules.Discovery; - -/// -/// Тесты DiscoveryTasksService — создание/патч/удаление/start/pause и план-бюджет (план Task 17, 1:1 discovery.py L234–381). -/// -/// -/// Сервис чистый: оркестрирует (семантика 1:1 с DiscoveryStore), читает дефолты -/// через (отсутствие строки → SettingsDefaults: discJoinLimit=50, discEvalThreshold=40, -/// discEvalSample=10) и план-бюджет через . 400-семантика — исключение -/// с текстом python; 404 — null (текст у эндпоинта Task 19). -/// -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)); - - // Бюджет: занято 50 из 50 — новую задачу создать нельзя (python L108–110). - 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 и план-бюджет (план Task 17, 1:1 discovery.py L234–381). +/// +/// +/// Сервис чистый: оркестрирует (семантика 1:1 с DiscoveryStore), читает дефолты +/// через (отсутствие строки → SettingsDefaults: discJoinLimit=50, discEvalThreshold=40, +/// discEvalSample=10) и план-бюджет через . 400-семантика — исключение +/// с текстом python; 404 — null (текст у эндпоинта Task 19). +/// +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)); + + // Бюджет: занято 50 из 50 — новую задачу создать нельзя (python L108–110). + 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/Discovery/FakeDiscoveryPacer.cs b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/FakeDiscoveryPacer.cs index 18575db..59582db 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/FakeDiscoveryPacer.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/FakeDiscoveryPacer.cs @@ -1,10 +1,4 @@ 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.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Discovery; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/FakeDiscoveryStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/FakeDiscoveryStore.cs index d4aefe7..589e4bc 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/FakeDiscoveryStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/FakeDiscoveryStore.cs @@ -1,10 +1,5 @@ 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.Discovery.Application.Registrars; -using Deal.Modules.Discovery.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Discovery; 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 078b683..e575a94 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AiClassifyContextBuilderTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AiClassifyContextBuilderTests.cs @@ -1,197 +1,191 @@ -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Modules.Kanban; - -/// -/// Тесты контекст-билдера ИИ-классификации (план Task 15, Ruling 5; -/// python ai.py fill_prompt L63–77, classify L226–251). -/// -/// -/// Проверяются: заполнение промптов ({domain}/{keywords} из настроек), склейка system_prompt aiPrompt+cardPrompt, -/// user-контекст «Доски + примеры разметки + Сообщение» (строки досок 1:1 с python L230–242 — критерии правил -/// через RulesDescriber либо ключевые слова ≤8 + описание ≤160; few-shot-примеры по журналу CardMoves ≤8), -/// фраза «колонок пока нет» для пустых досок и обрезка текста сообщения до 5000 кодовых точек. -/// -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); - - // Дефолты сферы 1:1 с ai.py fill_prompt L69–76 (PromptFiller). - 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); - - // python L255–257: prompt + "\n\n" + card (когда cardPrompt непуст). - 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, - }); - // ИИ-предложение (suggested) в контекст не попадает (python L227). - 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); - - // python L244–249: раздел «Примеры разметки пользователя» — свежие первыми (вставка в порядке 1→2). - 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, SourceMsg = text }); - kanj.AddMoveAsync(new CardMoveDto("lm_" + cardId, cardId, "move", "inbox", board), CancellationToken.None).GetAwaiter().GetResult(); - } -} +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; + +/// +/// Тесты контекст-билдера ИИ-классификации (план Task 15, Ruling 5; +/// python ai.py fill_prompt L63–77, classify L226–251). +/// +/// +/// Проверяются: заполнение промптов ({domain}/{keywords} из настроек), склейка system_prompt aiPrompt+cardPrompt, +/// user-контекст «Доски + примеры разметки + Сообщение» (строки досок 1:1 с python L230–242 — критерии правил +/// через RulesDescriber либо ключевые слова ≤8 + описание ≤160; few-shot-примеры по журналу CardMoves ≤8), +/// фраза «колонок пока нет» для пустых досок и обрезка текста сообщения до 5000 кодовых точек. +/// +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); + + // Дефолты сферы 1:1 с ai.py fill_prompt L69–76 (PromptFiller). + 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); + + // python L255–257: prompt + "\n\n" + card (когда cardPrompt непуст). + 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, + }); + // ИИ-предложение (suggested) в контекст не попадает (python L227). + 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); + + // python L244–249: раздел «Примеры разметки пользователя» — свежие первыми (вставка в порядке 1→2). + 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, SourceMsg = 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/AmountParserTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AmountParserTests.cs index 1ca4327..b0c65dc 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AmountParserTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AmountParserTests.cs @@ -1,5 +1,4 @@ using Deal.Modules.Kanban.Application.ColumnRules; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Kanban; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/BudgetNormalizerTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/BudgetNormalizerTests.cs index f30aefe..cfbe5ac 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/BudgetNormalizerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/BudgetNormalizerTests.cs @@ -1,9 +1,5 @@ -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; using Deal.Modules.Kanban.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Kanban; 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 bed4507..0b86e49 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/CardsServiceRemindersTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/CardsServiceRemindersTests.cs @@ -1,244 +1,237 @@ -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Contracts; - -namespace Deal.Tests.Unit.Modules.Kanban; - -/// -/// Тесты напоминаний «Отложено» — (единый домен карточки, этап 9): set -/// (выключатель/404/запись), clear, snooze (+24 ч), CheckDueRemindersAsync (disabled → очистка протухших, -/// enabled → fired + due) (projects.py L236–282; Ruling 3). -/// -/// -/// Зависимости — фейки (строки Cards: Reminder + «fired»-множество) и -/// (remindersEnabled: отсутствие строки = дефолт true из SettingsDefaults; -/// выключение — Preload("false")). Семантика результатов: Error = 400-текст прототипа, Card=null без Error = -/// 404 «Карточка не найдена», bool-методы — true = ok / false = 404. Прошлые at допустимы (валидации времени -/// нет, Ruling 3); «выстреливание» проверяется через store.ListDueRemindersAsync. -/// -public sealed class CardsServiceRemindersTests -{ - // Шаг snooze — +24 часа в epoch-мс (прототип C.DAY_MS; допуск оконного сравнения теста). - private const long DayMs = 86_400_000; - - // ─── Set (set_reminder L236–243) ─────────────────────────────────────── - - [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() - { - // Порядок 1:1 с роутером: карточка проверяется ДО выключателя — 404 раньше 400. - (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() - { - // Стадия карточки НЕ проверяется (Ruling 3: фронт шлёт напоминание только для hold). - (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); - } - - // ─── Clear (clear_reminder L246–247) ─────────────────────────────────── - - [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() - { - // clear выключатель НЕ проверяет (Ruling 3) — снять можно и при выключенных. - (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); - } - - // ─── Snooze (snooze L257–261) ────────────────────────────────────────── - - [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() - { - // snooze выключатель НЕ проверяет (Ruling 3): баннер напоминания зовёт его и при выключенной настройке. - (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); - } - - // ─── CheckDueRemindersAsync (check_reminders L264–282) ────────────────── - - [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; + +/// +/// Тесты напоминаний «Отложено» — (единый домен карточки, этап 9): set +/// (выключатель/404/запись), clear, snooze (+24 ч), CheckDueRemindersAsync (disabled → очистка протухших, +/// enabled → fired + due) (projects.py L236–282; Ruling 3). +/// +/// +/// Зависимости — фейки (строки Cards: Reminder + «fired»-множество) и +/// (remindersEnabled: отсутствие строки = дефолт true из SettingsDefaults; +/// выключение — Preload("false")). Семантика результатов: Error = 400-текст прототипа, Card=null без Error = +/// 404 «Карточка не найдена», bool-методы — true = ok / false = 404. Прошлые at допустимы (валидации времени +/// нет, Ruling 3); «выстреливание» проверяется через store.ListDueRemindersAsync. +/// +public sealed class CardsServiceRemindersTests +{ + // Шаг snooze — +24 часа в epoch-мс (прототип C.DAY_MS; допуск оконного сравнения теста). + private const long DayMs = 86_400_000; + + // ─── Set (set_reminder L236–243) ─────────────────────────────────────── + + [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() + { + // Порядок 1:1 с роутером: карточка проверяется ДО выключателя — 404 раньше 400. + (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() + { + // Стадия карточки НЕ проверяется (Ruling 3: фронт шлёт напоминание только для hold). + (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); + } + + // ─── Clear (clear_reminder L246–247) ─────────────────────────────────── + + [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() + { + // clear выключатель НЕ проверяет (Ruling 3) — снять можно и при выключенных. + (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); + } + + // ─── Snooze (snooze L257–261) ────────────────────────────────────────── + + [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() + { + // snooze выключатель НЕ проверяет (Ruling 3): баннер напоминания зовёт его и при выключенной настройке. + (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); + } + + // ─── CheckDueRemindersAsync (check_reminders L264–282) ────────────────── + + [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/ColumnRulesTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/ColumnRulesTests.cs index 6299d50..835351c 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/ColumnRulesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/ColumnRulesTests.cs @@ -1,6 +1,5 @@ using Deal.Modules.Kanban.Application.ColumnRules; using Deal.Modules.Kanban.Application.Models; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Kanban; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakeMlLearningStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakeMlLearningStore.cs index 29db669..d76f12a 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakeMlLearningStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakeMlLearningStore.cs @@ -1,9 +1,5 @@ using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Kanban; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakePipelineStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakePipelineStore.cs index 671a725..8c7f4b8 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakePipelineStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakePipelineStore.cs @@ -1,13 +1,6 @@ -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Kanban; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FileKindDetectorTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FileKindDetectorTests.cs index 3a4c8f4..87a2074 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FileKindDetectorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FileKindDetectorTests.cs @@ -1,9 +1,5 @@ -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; using Deal.Modules.Kanban.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Kanban; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MarkerTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MarkerTests.cs index 7c8736d..d694937 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MarkerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MarkerTests.cs @@ -1,6 +1,5 @@ using Deal.Modules.Kanban; using Deal.Modules.Pipeline; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Kanban; 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 ffbca0f..6fde342 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs @@ -1,260 +1,254 @@ -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Modules.Kanban; - -/// -/// Тесты ручной проверки/разметки ML (§8): кандидаты канала/выборки и применение решения. -/// -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) => new() - { - Id = $"p_{msgId}", - DialogId = dialog, - MsgId = msgId, - Text = text, - Status = PipelineQueueStatuses.New, - Channel = new PipelineChannelDto("Канал", "ch", "#333"), - MsgAtMs = 1_700_000_000_000 + msgId, - }; - - private static RejectedItemDto RejectedRow( - long msgId, - string text, - string dialog = Dialog) => new() - { - Id = $"r_{msgId}", - DialogId = dialog, - MsgId = msgId, - Text = text, - Stage = "spam_ml", - Reason = "спам", - Source = "ml", - 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, - SourceMsg = text, - SourceDialogId = dialog, - SourceMsgId = msgId, - 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.Id == 101 && item.Verdict == MlReviewService.VerdictQueued && !item.Lead); - Assert.Contains(items, item => item.Id == 102 && item.Verdict == MlReviewService.VerdictRejected); - MlCandidateDto cardItem = Assert.Single(items, item => item.Id == 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.Id); - } - - [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.Source); - 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 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; + +namespace Deal.Tests.Unit.Modules.Kanban; + +/// +/// Тесты ручной проверки/разметки ML (§8): кандидаты канала/выборки и применение решения. +/// +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) => new() + { + Id = $"p_{msgId}", + DialogId = dialog, + MsgId = msgId, + Text = text, + Status = PipelineQueueStatuses.New, + Channel = new PipelineChannelDto("Канал", "ch", "#333"), + MsgAtMs = 1_700_000_000_000 + msgId, + }; + + private static RejectedItemDto RejectedRow( + long msgId, + string text, + string dialog = Dialog) => new() + { + Id = $"r_{msgId}", + DialogId = dialog, + MsgId = msgId, + Text = text, + Stage = "spam_ml", + Reason = "спам", + Source = "ml", + 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, + SourceMsg = text, + SourceDialogId = dialog, + SourceMsgId = msgId, + 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.Id == 101 && item.Verdict == MlReviewService.VerdictQueued && !item.Lead); + Assert.Contains(items, item => item.Id == 102 && item.Verdict == MlReviewService.VerdictRejected); + MlCandidateDto cardItem = Assert.Single(items, item => item.Id == 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.Id); + } + + [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.Source); + 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 a69b17c..fac5119 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/StorageTickServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/StorageTickServiceTests.cs @@ -1,246 +1,239 @@ -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Modules.Kanban; - -/// -/// Тесты StorageTickService — тик правил хранения (Ruling 8, план Task 10 L395; leads.py tick_storage L454–493). -/// -/// -/// Сервис чистый: оркестрирует кандидатов и удаления FakeKanjStore (семантика кандидатов 1:1 с -/// KanbanStore — автоархив по ReceivedAt досок+inbox, очистка архива по ArchivedAt, корзины по ReceivedAt) -/// и читает настройки хранения через FakeSettingsStore (отсутствие строки → дефолты SettingsDefaults: -/// autoArchive=true, archiveAfterDays=14, archiveClearDays=90, trashClearDays=7). Возраст карточек задаётся -/// с запасом ≥1 суток к порогу — детерминированность не зависит от миллисекундного «now» сервиса -/// (граница тика считается от DateTimeOffset.UtcNow внутри TickAsync). -/// -public sealed class StorageTickServiceTests -{ - // ─── Автоархив (tick_storage L462–473) ────────────────────────────────── - - [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")); - } - - // ─── Очистка архива по сроку (tick_storage L475–478) ──────────────────── - - [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"); - } - - // ─── Очистка корзины по сроку (tick_storage L480–483) ─────────────────── - - [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; - - // matchHits карточки после тика (у автоархива всегда пусто, Ruling 2). - 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 — тик правил хранения (Ruling 8, план Task 10 L395; leads.py tick_storage L454–493). +/// +/// +/// Сервис чистый: оркестрирует кандидатов и удаления FakeKanjStore (семантика кандидатов 1:1 с +/// KanbanStore — автоархив по ReceivedAt досок+inbox, очистка архива по ArchivedAt, корзины по ReceivedAt) +/// и читает настройки хранения через FakeSettingsStore (отсутствие строки → дефолты SettingsDefaults: +/// autoArchive=true, archiveAfterDays=14, archiveClearDays=90, trashClearDays=7). Возраст карточек задаётся +/// с запасом ≥1 суток к порогу — детерминированность не зависит от миллисекундного «now» сервиса +/// (граница тика считается от DateTimeOffset.UtcNow внутри TickAsync). +/// +public sealed class StorageTickServiceTests +{ + // ─── Автоархив (tick_storage L462–473) ────────────────────────────────── + + [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")); + } + + // ─── Очистка архива по сроку (tick_storage L475–478) ──────────────────── + + [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"); + } + + // ─── Очистка корзины по сроку (tick_storage L480–483) ─────────────────── + + [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; + + // matchHits карточки после тика (у автоархива всегда пусто, Ruling 2). + 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/Kanban/SuggestHeuristicsTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/SuggestHeuristicsTests.cs index f590282..351cc9f 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/SuggestHeuristicsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/SuggestHeuristicsTests.cs @@ -1,9 +1,5 @@ -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; using Deal.Modules.Kanban.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Kanban; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/GlobalExclusionRulesTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/GlobalExclusionRulesTests.cs index fb422bf..7c138fa 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/GlobalExclusionRulesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/GlobalExclusionRulesTests.cs @@ -1,8 +1,5 @@ -using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; using Deal.Modules.Pipeline.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Pipeline; 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 94a46c9..edc054c 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs @@ -1,192 +1,189 @@ -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Kanban; - -namespace Deal.Tests.Unit.Modules.Pipeline; - -/// -/// Тесты приёма входящих сообщений — PipelineIngestService (план Task 5 L353–354, Ruling 2; pipeline.py enqueue L53–85). -/// -/// -/// Кейсы Acceptance Task 5: trim текста; no-op пустого текста и отсутствующего dialogId; дубль-гвард -/// dialogId+msgId (Telethon-повтор не пишется); текст[:6000]; id p_; статус new; msgAt отсутствует → now. -/// Разбор/дедуп по тексту — этап воркера (Task 8), на приёме не проверяются (1:1 с прототипом). -/// -public sealed class PipelineIngestServiceTests -{ - // ─── Приём: нормализация и строка очереди ─────────────────────────────── - - [Fact] - public async Task EnqueueAsync_TrimsTextAndWritesNewRowWithPId() - { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); - - PipelineIngestResultDto result = await service.EnqueueAsync( - Message( - dialogId: "d_1", - msgId: 42, - text: " Нужен Python-разработчик, удалёнка ", - channelName: "Канал", - channelHandle: "ch", - channelHue: "#ababab", - msgAtMs: 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.DialogId); - Assert.Equal(42, item.MsgId); - Assert.Equal("Нужен Python-разработчик, удалёнка", item.Text); // trim краёв (enqueue L68) - Assert.Equal(PipelineQueueStatuses.New, item.Status); - Assert.Equal("Канал", item.Channel.Name); - Assert.Equal("ch", item.Channel.Handle); - Assert.Equal("#ababab", item.Channel.Hue); - 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_UsesNow() - { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); - - await service.EnqueueAsync(Message(text: "Сообщение без времени"), CancellationToken.None); - - QueueItemDto item = Assert.Single(store.Queue); - Assert.True(item.MsgAtMs > 0, "msgAt = now, если время сообщения не передано (Ruling 2)"); - Assert.Equal(item.MsgAtMs, item.QueuedAtMs); - } - - [Fact] - public async Task EnqueueAsync_ForceFlag_PropagatedToQueueRow() - { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); - - await service.EnqueueAsync(Message(text: "Возврат из отсева", 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_MissingDialogId_Noop() - { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); - - PipelineIngestResultDto result = await service.EnqueueAsync(Message(text: "Текст без диалога", dialogId: string.Empty), CancellationToken.None); - - Assert.Null(result.Id); - Assert.Empty(store.Queue); // нет dialogId — no-op (enqueue L69–70) - } - - // ─── Приём: дубль-гвард (dialogId + msgId) ────────────────────────────── - - [Fact] - public async Task EnqueueAsync_SameDialogAndMsgIdTwice_SecondIsDuplicate() - { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); - QueuedMessage first = Message(dialogId: "d_1", msgId: 7, text: "Сообщение"); - QueuedMessage second = Message(dialogId: "d_1", msgId: 7, text: "Сообщение снова"); - - 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); // Telethon-повтор не пишем (enqueue L74–80) - Assert.Single(store.Queue); - Assert.Equal("Сообщение", Assert.Single(store.Queue).Text); // первая вставка не перезаписана - } - - [Fact] - public async Task EnqueueAsync_SameDialogDifferentMsgIds_BothAccepted() - { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); - - await service.EnqueueAsync(Message(dialogId: "d_1", msgId: 1, text: "Первое"), CancellationToken.None); - PipelineIngestResultDto second = await service.EnqueueAsync(Message(dialogId: "d_1", msgId: 2, text: "Второе"), CancellationToken.None); - - Assert.NotNull(second.Id); - Assert.Equal(2, store.Queue.Count); // гвард — по паре dialog+msgId, разные сообщения проходят - } - - [Fact] - public async Task EnqueueAsync_NoMsgId_SameDialogTwice_BothAccepted() - { - FakePipelineStore store = new(); - PipelineIngestService service = new(store); - - PipelineIngestResultDto first = await service.EnqueueAsync(Message(dialogId: "d_1", text: "Первое"), CancellationToken.None); - PipelineIngestResultDto second = await service.EnqueueAsync(Message(dialogId: "d_1", text: "Второе"), CancellationToken.None); - - Assert.NotNull(first.Id); - Assert.NotNull(second.Id); // дубль-гвард работает только при msgId (enqueue L75: if msg_id) - Assert.Equal(2, store.Queue.Count); - } - - // ─── Приём: лимит текста 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(text: 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 dialogId = "d_1", - long? msgId = null, - string channelName = "", - string channelHandle = "", - string channelHue = "", - long? msgAtMs = null, - bool force = false) => new() - { - DialogId = dialogId, - MsgId = msgId, - Text = text, - ChannelName = channelName, - ChannelHandle = channelHandle, - ChannelHue = channelHue, - MsgAtMs = msgAtMs, - Force = force, - }; -} +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Tests.Unit.Modules.Kanban; + +namespace Deal.Tests.Unit.Modules.Pipeline; + +/// +/// Тесты приёма входящих сообщений — PipelineIngestService (план Task 5 L353–354, Ruling 2; pipeline.py enqueue L53–85). +/// +/// +/// Кейсы Acceptance Task 5: trim текста; no-op пустого текста и отсутствующего dialogId; дубль-гвард +/// dialogId+msgId (Telethon-повтор не пишется); текст[:6000]; id p_; статус new; msgAt отсутствует → now. +/// Разбор/дедуп по тексту — этап воркера (Task 8), на приёме не проверяются (1:1 с прототипом). +/// +public sealed class PipelineIngestServiceTests +{ + // ─── Приём: нормализация и строка очереди ─────────────────────────────── + + [Fact] + public async Task EnqueueAsync_TrimsTextAndWritesNewRowWithPId() + { + FakePipelineStore store = new(); + PipelineIngestService service = new(store); + + PipelineIngestResultDto result = await service.EnqueueAsync( + Message( + dialogId: "d_1", + msgId: 42, + text: " Нужен Python-разработчик, удалёнка ", + channelName: "Канал", + channelHandle: "ch", + channelHue: "#ababab", + msgAtMs: 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.DialogId); + Assert.Equal(42, item.MsgId); + Assert.Equal("Нужен Python-разработчик, удалёнка", item.Text); // trim краёв (enqueue L68) + Assert.Equal(PipelineQueueStatuses.New, item.Status); + Assert.Equal("Канал", item.Channel.Name); + Assert.Equal("ch", item.Channel.Handle); + Assert.Equal("#ababab", item.Channel.Hue); + 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_UsesNow() + { + FakePipelineStore store = new(); + PipelineIngestService service = new(store); + + await service.EnqueueAsync(Message(text: "Сообщение без времени"), CancellationToken.None); + + QueueItemDto item = Assert.Single(store.Queue); + Assert.True(item.MsgAtMs > 0, "msgAt = now, если время сообщения не передано (Ruling 2)"); + Assert.Equal(item.MsgAtMs, item.QueuedAtMs); + } + + [Fact] + public async Task EnqueueAsync_ForceFlag_PropagatedToQueueRow() + { + FakePipelineStore store = new(); + PipelineIngestService service = new(store); + + await service.EnqueueAsync(Message(text: "Возврат из отсева", 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_MissingDialogId_Noop() + { + FakePipelineStore store = new(); + PipelineIngestService service = new(store); + + PipelineIngestResultDto result = await service.EnqueueAsync(Message(text: "Текст без диалога", dialogId: string.Empty), CancellationToken.None); + + Assert.Null(result.Id); + Assert.Empty(store.Queue); // нет dialogId — no-op (enqueue L69–70) + } + + // ─── Приём: дубль-гвард (dialogId + msgId) ────────────────────────────── + + [Fact] + public async Task EnqueueAsync_SameDialogAndMsgIdTwice_SecondIsDuplicate() + { + FakePipelineStore store = new(); + PipelineIngestService service = new(store); + QueuedMessage first = Message(dialogId: "d_1", msgId: 7, text: "Сообщение"); + QueuedMessage second = Message(dialogId: "d_1", msgId: 7, text: "Сообщение снова"); + + 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); // Telethon-повтор не пишем (enqueue L74–80) + Assert.Single(store.Queue); + Assert.Equal("Сообщение", Assert.Single(store.Queue).Text); // первая вставка не перезаписана + } + + [Fact] + public async Task EnqueueAsync_SameDialogDifferentMsgIds_BothAccepted() + { + FakePipelineStore store = new(); + PipelineIngestService service = new(store); + + await service.EnqueueAsync(Message(dialogId: "d_1", msgId: 1, text: "Первое"), CancellationToken.None); + PipelineIngestResultDto second = await service.EnqueueAsync(Message(dialogId: "d_1", msgId: 2, text: "Второе"), CancellationToken.None); + + Assert.NotNull(second.Id); + Assert.Equal(2, store.Queue.Count); // гвард — по паре dialog+msgId, разные сообщения проходят + } + + [Fact] + public async Task EnqueueAsync_NoMsgId_SameDialogTwice_BothAccepted() + { + FakePipelineStore store = new(); + PipelineIngestService service = new(store); + + PipelineIngestResultDto first = await service.EnqueueAsync(Message(dialogId: "d_1", text: "Первое"), CancellationToken.None); + PipelineIngestResultDto second = await service.EnqueueAsync(Message(dialogId: "d_1", text: "Второе"), CancellationToken.None); + + Assert.NotNull(first.Id); + Assert.NotNull(second.Id); // дубль-гвард работает только при msgId (enqueue L75: if msg_id) + Assert.Equal(2, store.Queue.Count); + } + + // ─── Приём: лимит текста 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(text: 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 dialogId = "d_1", + long? msgId = null, + string channelName = "", + string channelHandle = "", + string channelHue = "", + long? msgAtMs = null, + bool force = false) => new() + { + DialogId = dialogId, + MsgId = msgId, + Text = text, + ChannelName = channelName, + ChannelHandle = channelHandle, + ChannelHue = channelHue, + MsgAtMs = msgAtMs, + Force = 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 f3f6e6b..c9ddc8a 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs @@ -1,465 +1,462 @@ -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Tests.Unit.Support; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Contracts; - -namespace Deal.Tests.Unit.Modules.Pipeline; - -/// -/// Тесты обслуживания пайплайна — PipelineProcessingService (план Task 5 L355–361, Ruling 8/10; -/// processing.py record/list_queue/queue_counts/list_rejected/return_to_queue/очистки). -/// -/// -/// Кейсы Acceptance Task 5: запись отсева (детерминированный upsert, лимиты/дефолт цвета), чтение очереди -/// (clamp лимита 1..500), счётчики (new/ai/total + rejected), страницы отсева с поиском (FTS∪LIKE-семантика -/// — поиск по text/reason/kw/ch_name, total = размер кандидатов), возврат (не найдена → 404-результат null; -/// повтор/dup/нет текста → 400-тексты; спам-этап → PushAsync(spam, −1.0); причина; force-строка в очередь), -/// delete/clear/purge-expired. Fakes: , . -/// -public sealed class PipelineProcessingServiceTests -{ - // ─── Отсев: запись (processing.record L66–101) ─────────────────────────── - - [Fact] - public async Task RejectAsync_WhitespaceText_Noop() - { - (PipelineProcessingService service, FakePipelineStore store, _) = Create(); - - await service.RejectAsync(new RejectRecord { Text = " ", Source = "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 - { - DialogId = "d_1", - MsgId = 7, - Text = new string('а', 7_000), - Reason = new string('р', 600), - Kw = new string('к', 300), - Source = "stop", - Stage = "stop", - }, CancellationToken.None); - - RejectedItemDto row = Assert.Single(store.Rejected); - Assert.Equal("r_d_1_7", row.Id); // детерминированный id по dialog+msgId (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.Channel.Hue); // row.get("ch_hue") or "#666" (L93) - Assert.Equal("stop", row.Stage); - Assert.Equal("stop", row.Source); - } - - [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_d_1_7", row.Id); - Assert.Equal("stop", row.Stage); - Assert.Equal("стоп-фраза «реклама»", row.Reason); - } - - // ─── Очередь и счётчики (L201–241) ────────────────────────────────────── - - [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 - } - - // ─── Отсев: страницы и поиск (list_rejected L246–312) ─────────────────── - - [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_QueryMatchesTextReasonKwAndChannel() - { - (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: "обычный текст", channelName: "Хороший чат", 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 byChannel = 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(byChannel.Items).Id); // LIKE по ch_name - } - - [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 = размер объединения кандидатов (не весь отсев) - } - - // ─── Отсев: возврат в обработку (return_to_queue L128–193) ────────────── - - [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", source: "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", - source: "ai", - dialogId: "d_9", - msgId: 5, - channelName: "Спам", - channelHandle: "spam_ch", - channelHue: "#ff0000", - msgAtMs: 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.DialogId); - Assert.Equal(5, queued.MsgId); - Assert.Equal("Продвижение в каналах", queued.Text); - Assert.Equal("Спам", queued.Channel.Name); - Assert.Equal("spam_ch", queued.Channel.Handle); - Assert.Equal("#ff0000", queued.Channel.Hue); - 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", - source: "stop", - dialogId: "d_1", - msgId: 3, - msgAtMs: 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", source: "stale", dialogId: "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_NoDialogOrMsgId_EnqueuesDirectlyWithFallbackNow() - { - (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); - store.SeedRejected(Rejected( - "r_old", - text: "Старая запись до сохранения dialog_id", - stage: "spam_ml", - source: "ml")); - - 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.DialogId); - Assert.Null(queued.MsgId); - Assert.Equal("#666", queued.Channel.Hue); // hue не сохранён — дефолт (L181) - Assert.True(queued.MsgAtMs > 0); // msg_at нет — now (row.get("msg_at") or now) - } - - // ─── Отсев: удаление и очистки (L104–125, L196–198) ───────────────────── - - [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) => new() - { - DialogId = dialogId, - MsgId = msgId, - Text = "Текст сообщения", - Stage = stage, - Source = "stop", - Reason = reason, - }; - - // Строка очереди для сидирования (id/статус/время постановки). - private static QueueItemDto QueueItem( - string id, - string status, - long queuedAtMs) => new() - { - Id = id, - DialogId = "d_1", - Text = "текст", - Status = status, - Channel = new PipelineChannelDto(string.Empty, string.Empty, string.Empty), - MsgAtMs = queuedAtMs, - QueuedAtMs = queuedAtMs, - }; - - // Запись отсева для сидирования (как строка БД после маппинга адаптера). - private static RejectedItemDto Rejected( - string id, - string text = "Текст сообщения", - string stage = "length", - string source = "stop", - string dialogId = "", - long? msgId = null, - string reason = "", - string kw = "", - string channelName = "", - string channelHandle = "", - string channelHue = "", - long msgAtMs = 0, - long rejectedAtMs = 1_000, - bool returned = false, - string returnReason = "") => new() - { - Id = id, - DialogId = dialogId, - MsgId = msgId, - Text = text, - Stage = stage, - StageLabel = PipelineRejectConstants.StageLabel(stage), - Reason = reason, - Kw = kw, - Source = source, - SourceLabel = PipelineRejectConstants.SourceLabel(source), - Channel = new PipelineChannelDto(channelName, channelHandle, channelHue), - MsgAtMs = msgAtMs, - RejectedAtMs = rejectedAtMs, - Returned = returned, - ReturnReason = returnReason, - }; -} +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Kanban; + +namespace Deal.Tests.Unit.Modules.Pipeline; + +/// +/// Тесты обслуживания пайплайна — PipelineProcessingService (план Task 5 L355–361, Ruling 8/10; +/// processing.py record/list_queue/queue_counts/list_rejected/return_to_queue/очистки). +/// +/// +/// Кейсы Acceptance Task 5: запись отсева (детерминированный upsert, лимиты/дефолт цвета), чтение очереди +/// (clamp лимита 1..500), счётчики (new/ai/total + rejected), страницы отсева с поиском (FTS∪LIKE-семантика +/// — поиск по text/reason/kw/ch_name, total = размер кандидатов), возврат (не найдена → 404-результат null; +/// повтор/dup/нет текста → 400-тексты; спам-этап → PushAsync(spam, −1.0); причина; force-строка в очередь), +/// delete/clear/purge-expired. Fakes: , . +/// +public sealed class PipelineProcessingServiceTests +{ + // ─── Отсев: запись (processing.record L66–101) ─────────────────────────── + + [Fact] + public async Task RejectAsync_WhitespaceText_Noop() + { + (PipelineProcessingService service, FakePipelineStore store, _) = Create(); + + await service.RejectAsync(new RejectRecord { Text = " ", Source = "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 + { + DialogId = "d_1", + MsgId = 7, + Text = new string('а', 7_000), + Reason = new string('р', 600), + Kw = new string('к', 300), + Source = "stop", + Stage = "stop", + }, CancellationToken.None); + + RejectedItemDto row = Assert.Single(store.Rejected); + Assert.Equal("r_d_1_7", row.Id); // детерминированный id по dialog+msgId (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.Channel.Hue); // row.get("ch_hue") or "#666" (L93) + Assert.Equal("stop", row.Stage); + Assert.Equal("stop", row.Source); + } + + [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_d_1_7", row.Id); + Assert.Equal("stop", row.Stage); + Assert.Equal("стоп-фраза «реклама»", row.Reason); + } + + // ─── Очередь и счётчики (L201–241) ────────────────────────────────────── + + [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 + } + + // ─── Отсев: страницы и поиск (list_rejected L246–312) ─────────────────── + + [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_QueryMatchesTextReasonKwAndChannel() + { + (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: "обычный текст", channelName: "Хороший чат", 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 byChannel = 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(byChannel.Items).Id); // LIKE по ch_name + } + + [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 = размер объединения кандидатов (не весь отсев) + } + + // ─── Отсев: возврат в обработку (return_to_queue L128–193) ────────────── + + [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", source: "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", + source: "ai", + dialogId: "d_9", + msgId: 5, + channelName: "Спам", + channelHandle: "spam_ch", + channelHue: "#ff0000", + msgAtMs: 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.DialogId); + Assert.Equal(5, queued.MsgId); + Assert.Equal("Продвижение в каналах", queued.Text); + Assert.Equal("Спам", queued.Channel.Name); + Assert.Equal("spam_ch", queued.Channel.Handle); + Assert.Equal("#ff0000", queued.Channel.Hue); + 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", + source: "stop", + dialogId: "d_1", + msgId: 3, + msgAtMs: 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", source: "stale", dialogId: "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_NoDialogOrMsgId_EnqueuesDirectlyWithFallbackNow() + { + (PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create(); + store.SeedRejected(Rejected( + "r_old", + text: "Старая запись до сохранения dialog_id", + stage: "spam_ml", + source: "ml")); + + 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.DialogId); + Assert.Null(queued.MsgId); + Assert.Equal("#666", queued.Channel.Hue); // hue не сохранён — дефолт (L181) + Assert.True(queued.MsgAtMs > 0); // msg_at нет — now (row.get("msg_at") or now) + } + + // ─── Отсев: удаление и очистки (L104–125, L196–198) ───────────────────── + + [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) => new() + { + DialogId = dialogId, + MsgId = msgId, + Text = "Текст сообщения", + Stage = stage, + Source = "stop", + Reason = reason, + }; + + // Строка очереди для сидирования (id/статус/время постановки). + private static QueueItemDto QueueItem( + string id, + string status, + long queuedAtMs) => new() + { + Id = id, + DialogId = "d_1", + Text = "текст", + Status = status, + Channel = new PipelineChannelDto(string.Empty, string.Empty, string.Empty), + MsgAtMs = queuedAtMs, + QueuedAtMs = queuedAtMs, + }; + + // Запись отсева для сидирования (как строка БД после маппинга адаптера). + private static RejectedItemDto Rejected( + string id, + string text = "Текст сообщения", + string stage = "length", + string source = "stop", + string dialogId = "", + long? msgId = null, + string reason = "", + string kw = "", + string channelName = "", + string channelHandle = "", + string channelHue = "", + long msgAtMs = 0, + long rejectedAtMs = 1_000, + bool returned = false, + string returnReason = "") => new() + { + Id = id, + DialogId = dialogId, + MsgId = msgId, + Text = text, + Stage = stage, + StageLabel = PipelineRejectConstants.StageLabel(stage), + Reason = reason, + Kw = kw, + Source = source, + SourceLabel = PipelineRejectConstants.SourceLabel(source), + Channel = new PipelineChannelDto(channelName, channelHandle, channelHue), + MsgAtMs = msgAtMs, + RejectedAtMs = rejectedAtMs, + Returned = returned, + ReturnReason = returnReason, + }; +} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeGlobalSettingsStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeGlobalSettingsStore.cs index 770bac9..923f93c 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeGlobalSettingsStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeGlobalSettingsStore.cs @@ -1,8 +1,5 @@ using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Settings; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeRatesListener.cs b/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeRatesListener.cs index 3a48285..6f69445 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeRatesListener.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeRatesListener.cs @@ -1,8 +1,4 @@ using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Settings; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeRatesSource.cs b/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeRatesSource.cs index 139a481..81aa4fa 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeRatesSource.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeRatesSource.cs @@ -1,8 +1,4 @@ using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Settings; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeSettingsStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeSettingsStore.cs index df856d1..3afa5cf 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeSettingsStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeSettingsStore.cs @@ -1,8 +1,5 @@ using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Settings; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Settings/PromptDefaultsTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Settings/PromptDefaultsTests.cs index 5802812..f39f886 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Settings/PromptDefaultsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Settings/PromptDefaultsTests.cs @@ -1,8 +1,5 @@ -using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Settings; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Settings/SettingsCatalogTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Settings/SettingsCatalogTests.cs index 638a53c..e7a93f6 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Settings/SettingsCatalogTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Settings/SettingsCatalogTests.cs @@ -1,8 +1,5 @@ -using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Settings; 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 c9149ae..a27480d 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditEventsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditEventsTests.cs @@ -1,9 +1,4 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditServiceTests.cs index b8e66da..a64fe65 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditServiceTests.cs @@ -1,9 +1,6 @@ using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuthServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuthServiceTests.cs index d30fdb5..b3ea63f 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuthServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuthServiceTests.cs @@ -1,9 +1,5 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; 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 5519904..228c6db 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FailingTenantProvisioner.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FailingTenantProvisioner.cs @@ -1,11 +1,5 @@ using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Models; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuditLogStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuditLogStore.cs index 74c835f..8916883 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuditLogStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuditLogStore.cs @@ -1,9 +1,6 @@ using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuthStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuthStore.cs index ff9041c..dbca917 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuthStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuthStore.cs @@ -1,9 +1,5 @@ using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeInviteStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeInviteStore.cs index 113dd47..5b851a2 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeInviteStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeInviteStore.cs @@ -1,9 +1,5 @@ using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeOperatorAuthStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeOperatorAuthStore.cs index d1e3d45..317540b 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeOperatorAuthStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeOperatorAuthStore.cs @@ -1,9 +1,5 @@ using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakePasswordHasher.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakePasswordHasher.cs index ce0ab6c..bc4e2b7 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakePasswordHasher.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakePasswordHasher.cs @@ -1,9 +1,4 @@ using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; 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 b6b11b4..d5957c7 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeRateLimitCounterStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeRateLimitCounterStore.cs @@ -1,9 +1,4 @@ using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantLimitStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantLimitStore.cs index 080494d..58d668b 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantLimitStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantLimitStore.cs @@ -1,9 +1,6 @@ using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantProvisioner.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantProvisioner.cs index 6631aab..6c95bfe 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantProvisioner.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantProvisioner.cs @@ -1,11 +1,5 @@ using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Models; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantRegistry.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantRegistry.cs index 7a4cb60..cb32dd0 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantRegistry.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantRegistry.cs @@ -1,9 +1,5 @@ using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantRepository.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantRepository.cs index d15ade4..beee8eb 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantRepository.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantRepository.cs @@ -1,9 +1,5 @@ using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantStore.cs index 0c568f1..fb7a144 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantStore.cs @@ -1,9 +1,5 @@ using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTokenUsageEventStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTokenUsageEventStore.cs index 45a32c9..8dfe9e9 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTokenUsageEventStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTokenUsageEventStore.cs @@ -1,9 +1,5 @@ using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/InviteCodeGeneratorTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/InviteCodeGeneratorTests.cs index 1515eb0..487adda 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/InviteCodeGeneratorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/InviteCodeGeneratorTests.cs @@ -1,9 +1,4 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/InvitesServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/InvitesServiceTests.cs index 8a6661b..41432ba 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/InvitesServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/InvitesServiceTests.cs @@ -1,9 +1,5 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/JoinFlowTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/JoinFlowTests.cs index 4a2a820..7b8aa88 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/JoinFlowTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/JoinFlowTests.cs @@ -1,9 +1,5 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorAuthServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorAuthServiceTests.cs index 81c8088..e28d881 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorAuthServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorAuthServiceTests.cs @@ -1,9 +1,5 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorBootstrapServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorBootstrapServiceTests.cs index 5884742..d0535d3 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorBootstrapServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorBootstrapServiceTests.cs @@ -1,9 +1,5 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/PasswordHasherTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/PasswordHasherTests.cs index c8ab56b..89ce83c 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/PasswordHasherTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/PasswordHasherTests.cs @@ -1,9 +1,5 @@ using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SessionTokensTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SessionTokensTests.cs index 481b9a6..8a4548c 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SessionTokensTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SessionTokensTests.cs @@ -1,9 +1,4 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SuspiciousActivityServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SuspiciousActivityServiceTests.cs index 5d0c6c8..85ad737 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SuspiciousActivityServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SuspiciousActivityServiceTests.cs @@ -1,9 +1,5 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TenantAdminServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TenantAdminServiceTests.cs index 9d3f90c..479bf83 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TenantAdminServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TenantAdminServiceTests.cs @@ -1,9 +1,5 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TokenBudgetServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TokenBudgetServiceTests.cs index 23c06ef..5501e08 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TokenBudgetServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TokenBudgetServiceTests.cs @@ -1,9 +1,5 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TokenUsageEventServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TokenUsageEventServiceTests.cs index a8b5909..f7a430d 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TokenUsageEventServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TokenUsageEventServiceTests.cs @@ -1,9 +1,5 @@ -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/SharedKernel/TenantIdTests.cs b/src/core/tests/Deal.Tests.Unit/SharedKernel/TenantIdTests.cs index 0fc22c7..e22636a 100644 --- a/src/core/tests/Deal.Tests.Unit/SharedKernel/TenantIdTests.cs +++ b/src/core/tests/Deal.Tests.Unit/SharedKernel/TenantIdTests.cs @@ -1,6 +1,4 @@ -using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Models; -using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.SharedKernel; diff --git a/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs b/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs index da7a0b6..9ac3021 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs @@ -1,375 +1,366 @@ -using System.Text.Json; -using Deal.Api; -using Deal.Api.Dtos; -using Deal.Api.Services; -using Deal.Api.Events; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Microsoft.Extensions.Logging.Abstractions; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Contracts; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты AdminTickOrchestrator — состав POST /api/admin/tick (план Tasks 10–11, Ruling 8/9; dashboard_routes.py -/// admin_tick L327–337): тик правил хранения + очистка отсева (3 суток, merge в storage.purgedRejected) + проверка -/// напоминаний «Отложено» (reminders ответа + SSE reminder_due, Ruling 8) + один проход pump + SSE-тосты/new_card -/// + queue после pump. -/// -/// -/// Оркестратор — Api-слой поверх реальных сервисов модулей на фейках (как StorageTickSchedulerTests): общий -/// FakeKanjStore у StorageTickService и воркера (карточки pump живут в том же хранилище, что тикает Kanban), -/// общий FakePipelineStore у обработки/воркера, общий FakeKanjStore + FakeSettingsStore у CardsService -/// (напоминания — операции того же домена карточки). -/// Возраст записей отсева задаётся с запасом к сроку хранения -/// 3 суток (PipelineRejectConstants.RetentionDays) — детерминированность не зависит от «now» сервиса. -/// Сценарий pump повторяет worker-тест вакансии (ML «спит» → filtered → ИИ-классификация → карточка inbox). -/// Сбой чтения очереди (store.ListAsync бросает) НЕ роняет тик: ответ содержит storage/queue и пустой -/// pipeline ({}, как при занятом локе прототипа L901–902), тосты статистики уже опубликованы. Сбой проверки -/// напоминаний (ListDueAsync бросает) тоже не роняет тик: reminders ответа пуст, остальной тик продолжается. -/// -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); - - // Ответ 1:1 {storage, reminders, pipeline, queue}: storage.purgedRejected = очистка отсева тика (Ruling 9). - 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: строка ушла в карточку - - // pipeline-словарь: 9 ключей python L921; вакансия: правила → дедуп → ML «спит» → ИИ → карточка. - 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); - - // SSE: тост автоочистки отсева (notify_tick_stats L503–504) + new_card по созданной карточке (Ruling 8/9). - 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$")); - - // Сбой чтения очереди (ListAsync бросает): pump не выполнился — тик возвращает storage/purge и - // пустой pipeline ({}), очередь остаётся до следующего тика/фонового цикла (Task 11). - 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$")); - - // Фоновый цикл (Task 11) уже разбирает очередь тенанта: гейт занят — тик НЕ зовёт pump, очередь - // ждёт следующего срабатывания (как прототип L901–902: занятый lock → {}). Очистка отсева тика - // выполняется (purge не гейтится — в прототипе живёт в tick_storage, а не в pump). - 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); - } - - // ─── Тик: due-напоминания «Отложено» → reminders ответа + SSE reminder_due (план Task 11, Ruling 8) ── - - [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); - - // SSE reminder_due {id,title,containerId} по «выстрелившему» (Ruling 8; toast НЕ публикуется). - 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(); - // Настройка выключена (Ruling 3): протухшие очищаются, «выстрелов»/событий нет (check_reminders L266–269). - 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) => new() - { - Id = id, - DialogId = "d_1", - MsgId = 100, - Text = text, - Status = PipelineQueueStatuses.New, - Channel = new PipelineChannelDto("Канал А", "channel_a", "#333"), - MsgAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - QueuedAtMs = 1_700_000_000_000, - }; - - // Минимальный разбор карточки (карточку домыслит 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) => new() - { - Id = id, - DialogId = string.Empty, - Text = "Текст сообщения", - Stage = "length", - StageLabel = PipelineRejectConstants.StageLabel("length"), - Reason = string.Empty, - Kw = string.Empty, - Source = "stop", - SourceLabel = PipelineRejectConstants.SourceLabel("stop"), - Channel = new PipelineChannelDto(string.Empty, string.Empty, string.Empty), - MsgAtMs = 0, - RejectedAtMs = rejectedAtMs, - Returned = false, - ReturnReason = string.Empty, - }; - - // Вычитывает все события канала: (тип, 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 (план Tasks 10–11, Ruling 8/9; dashboard_routes.py +/// admin_tick L327–337): тик правил хранения + очистка отсева (3 суток, merge в storage.purgedRejected) + проверка +/// напоминаний «Отложено» (reminders ответа + SSE reminder_due, Ruling 8) + один проход pump + SSE-тосты/new_card +/// + queue после pump. +/// +/// +/// Оркестратор — Api-слой поверх реальных сервисов модулей на фейках (как StorageTickSchedulerTests): общий +/// FakeKanjStore у StorageTickService и воркера (карточки pump живут в том же хранилище, что тикает Kanban), +/// общий FakePipelineStore у обработки/воркера, общий FakeKanjStore + FakeSettingsStore у CardsService +/// (напоминания — операции того же домена карточки). +/// Возраст записей отсева задаётся с запасом к сроку хранения +/// 3 суток (PipelineRejectConstants.RetentionDays) — детерминированность не зависит от «now» сервиса. +/// Сценарий pump повторяет worker-тест вакансии (ML «спит» → filtered → ИИ-классификация → карточка inbox). +/// Сбой чтения очереди (store.ListAsync бросает) НЕ роняет тик: ответ содержит storage/queue и пустой +/// pipeline ({}, как при занятом локе прототипа L901–902), тосты статистики уже опубликованы. Сбой проверки +/// напоминаний (ListDueAsync бросает) тоже не роняет тик: reminders ответа пуст, остальной тик продолжается. +/// +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); + + // Ответ 1:1 {storage, reminders, pipeline, queue}: storage.purgedRejected = очистка отсева тика (Ruling 9). + 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: строка ушла в карточку + + // pipeline-словарь: 9 ключей python L921; вакансия: правила → дедуп → ML «спит» → ИИ → карточка. + 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); + + // SSE: тост автоочистки отсева (notify_tick_stats L503–504) + new_card по созданной карточке (Ruling 8/9). + 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$")); + + // Сбой чтения очереди (ListAsync бросает): pump не выполнился — тик возвращает storage/purge и + // пустой pipeline ({}), очередь остаётся до следующего тика/фонового цикла (Task 11). + 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$")); + + // Фоновый цикл (Task 11) уже разбирает очередь тенанта: гейт занят — тик НЕ зовёт pump, очередь + // ждёт следующего срабатывания (как прототип L901–902: занятый lock → {}). Очистка отсева тика + // выполняется (purge не гейтится — в прототипе живёт в tick_storage, а не в pump). + 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); + } + + // ─── Тик: due-напоминания «Отложено» → reminders ответа + SSE reminder_due (план Task 11, Ruling 8) ── + + [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); + + // SSE reminder_due {id,title,containerId} по «выстрелившему» (Ruling 8; toast НЕ публикуется). + 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(); + // Настройка выключена (Ruling 3): протухшие очищаются, «выстрелов»/событий нет (check_reminders L266–269). + 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) => new() + { + Id = id, + DialogId = "d_1", + MsgId = 100, + Text = text, + Status = PipelineQueueStatuses.New, + Channel = new PipelineChannelDto("Канал А", "channel_a", "#333"), + MsgAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + QueuedAtMs = 1_700_000_000_000, + }; + + // Минимальный разбор карточки (карточку домыслит 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) => new() + { + Id = id, + DialogId = string.Empty, + Text = "Текст сообщения", + Stage = "length", + StageLabel = PipelineRejectConstants.StageLabel("length"), + Reason = string.Empty, + Kw = string.Empty, + Source = "stop", + SourceLabel = PipelineRejectConstants.SourceLabel("stop"), + Channel = new PipelineChannelDto(string.Empty, string.Empty, string.Empty), + MsgAtMs = 0, + RejectedAtMs = rejectedAtMs, + Returned = false, + ReturnReason = string.Empty, + }; + + // Вычитывает все события канала: (тип, 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/AiConnectionCheckerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/AiConnectionCheckerTests.cs index 6adaa64..dc5afdd 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/AiConnectionCheckerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/AiConnectionCheckerTests.cs @@ -1,9 +1,4 @@ using System.Net; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Services; using Deal.Modules.Settings.Application.Models; diff --git a/src/core/tests/Deal.Tests.Unit/Support/AiGrpcTestHost.cs b/src/core/tests/Deal.Tests.Unit/Support/AiGrpcTestHost.cs index 4a9d30a..799cbec 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/AiGrpcTestHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/AiGrpcTestHost.cs @@ -1,16 +1,10 @@ using System.Net; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Grpc.Net.Client; +using Deal.Tests.Unit.Grpc; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection; -using Deal.Tests.Unit.Grpc; namespace Deal.Tests.Unit.Support; diff --git a/src/core/tests/Deal.Tests.Unit/Support/AiRawCardMapperTests.cs b/src/core/tests/Deal.Tests.Unit/Support/AiRawCardMapperTests.cs index 9f839e6..44effeb 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/AiRawCardMapperTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/AiRawCardMapperTests.cs @@ -1,8 +1,5 @@ using System.Text.Json; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; using Deal.Modules.Pipeline.Application.Services; namespace Deal.Tests.Unit.Support; diff --git a/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs index 6ce0bfc..a35f238 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs @@ -1,195 +1,192 @@ -using System.Text.Json; -using Deal.Api.Events; -using Deal.Api.Hosting; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Deal.Tests.Unit.Modules.Tenants; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты логики прохода (план Task 9, Ruling 3): фоновая проверка -/// порогов ИИ-бюджета — обход всех тенантов реестра и SSE-тост в канал тенанта при пересечении 80%/100%; -/// TryMark*-CAS (Task 8) гарантирует один тост на порог за период. -/// -/// -/// Тайминги цикла (Timer 60 с, первый проход, stop) не тестируются — тестируется итерация через публичный -/// . Скоупы/DI поднимаются на реальном ServiceCollection с фейками: -/// ITenantRepository — FakeTenantRepository, ITenantLimitStore — FakeTenantLimitStore (scoped, поведение зеркалит -/// EF-адаптер: TryMark* возвращает true только при «флаг не стоял и порог достигнут»). Публикации проверяются -/// реальным SseBroker с подпиской канала (как в StorageTickSchedulerTests). -/// -public sealed class BudgetAlertSchedulerTests -{ - // Тенант A теста (канал подписки). - private static readonly Guid TenantA = Guid.NewGuid(); - - // Тенант B теста (канал подписки). - private static readonly Guid TenantB = Guid.NewGuid(); - - // Текст тоста порога 80% (зеркало BudgetAlertScheduler, Ruling 3). - private const string Warned80ToastText = "ИИ-бюджет израсходован на 80%"; - - // Текст тоста исчерпания (зеркало BudgetAlertScheduler, Ruling 3). - private const string ExhaustedToastText = "ИИ-бюджет исчерпан — обработка в локальном режиме"; - - [Fact] - public async Task RunCycle_TwoCycles_PublishesToastOncePerThresholdPerTenant() - { - // Acceptance Task 9 «тост один раз на порог»: A на пороге 80%, B исчерпан (оба порога за период). - 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); - - // A: один тост 80%; B: 80% и затем исчерпание (порядок публикации — 80% → 100%, Ruling 3). - 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() - { - // Review-fix Task 9: флаги порогов выставляет только TryMark* — естественный расход (AddUsage) пересекает - // 80% → ближайший проход публикует РОВНО один тост; повторный проход — без тоста; пересечение 100% (ещё - // расход) → ещё ровно один тост (100%), 80% не дублируется. - 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); - - // Расход до порога 80% (850 ≥ floor(0.8·1000)): флаги списанием не выставляются (review-fix Task 9) — - // переход остаётся непомеченным, и TryMark* на проходе вернёт true ровно один раз. - 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; + +/// +/// Тесты логики прохода (план Task 9, Ruling 3): фоновая проверка +/// порогов ИИ-бюджета — обход всех тенантов реестра и SSE-тост в канал тенанта при пересечении 80%/100%; +/// TryMark*-CAS (Task 8) гарантирует один тост на порог за период. +/// +/// +/// Тайминги цикла (Timer 60 с, первый проход, stop) не тестируются — тестируется итерация через публичный +/// . Скоупы/DI поднимаются на реальном ServiceCollection с фейками: +/// ITenantRepository — FakeTenantRepository, ITenantLimitStore — FakeTenantLimitStore (scoped, поведение зеркалит +/// EF-адаптер: TryMark* возвращает true только при «флаг не стоял и порог достигнут»). Публикации проверяются +/// реальным SseBroker с подпиской канала (как в StorageTickSchedulerTests). +/// +public sealed class BudgetAlertSchedulerTests +{ + // Тенант A теста (канал подписки). + private static readonly Guid TenantA = Guid.NewGuid(); + + // Тенант B теста (канал подписки). + private static readonly Guid TenantB = Guid.NewGuid(); + + // Текст тоста порога 80% (зеркало BudgetAlertScheduler, Ruling 3). + private const string Warned80ToastText = "ИИ-бюджет израсходован на 80%"; + + // Текст тоста исчерпания (зеркало BudgetAlertScheduler, Ruling 3). + private const string ExhaustedToastText = "ИИ-бюджет исчерпан — обработка в локальном режиме"; + + [Fact] + public async Task RunCycle_TwoCycles_PublishesToastOncePerThresholdPerTenant() + { + // Acceptance Task 9 «тост один раз на порог»: A на пороге 80%, B исчерпан (оба порога за период). + 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); + + // A: один тост 80%; B: 80% и затем исчерпание (порядок публикации — 80% → 100%, Ruling 3). + 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() + { + // Review-fix Task 9: флаги порогов выставляет только TryMark* — естественный расход (AddUsage) пересекает + // 80% → ближайший проход публикует РОВНО один тост; повторный проход — без тоста; пересечение 100% (ещё + // расход) → ещё ровно один тост (100%), 80% не дублируется. + 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); + + // Расход до порога 80% (850 ≥ floor(0.8·1000)): флаги списанием не выставляются (review-fix Task 9) — + // переход остаётся непомеченным, и TryMark* на проходе вернёт true ровно один раз. + 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 55e9968..a0be59b 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs @@ -1,383 +1,375 @@ -using System.Text.Json; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Contracts; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты ручной переклассификации карточек — CardReclassifier (этап 12, пакет D; -/// прототип leads.py reclassify_lead L292–389 + reclassify_inbox L392–417). -/// -/// -/// Проверяется поведение без ИИ (детерминированный локальный разбор — «несанкционированный» сбой без кредов не -/// роняет проход), ИИ-путь (доска + обучающие сигналы ML), отсев спама/непройденного фильтра в корзину, -/// single-flight-замок и счётчики исхода. Всё — на in-memory фейках (FakeKanjStore/FakeMlClient/FakeAiClassifier). -/// -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 карточки. - // sourceMsg: Исходный текст. - // contact: Старый контакт карточки. - // receivedAtMs: Время получения (для порядка батча). - // Возвращает: Карточка как если бы была сохранена в БД. - private static CardDto Card( - string id, - string sourceMsg, - string contact = "", - long receivedAtMs = 1_700_000_000_000) => new() - { - Id = id, - Col = "inbox", - SourceMsg = sourceMsg, - 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 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); - } - - [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(); - } - } -} +using System.Text.Json; +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.Settings; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты ручной переклассификации карточек — CardReclassifier (этап 12, пакет D; +/// прототип leads.py reclassify_lead L292–389 + reclassify_inbox L392–417). +/// +/// +/// Проверяется поведение без ИИ (детерминированный локальный разбор — «несанкционированный» сбой без кредов не +/// роняет проход), ИИ-путь (доска + обучающие сигналы ML), отсев спама/непройденного фильтра в корзину, +/// single-flight-замок и счётчики исхода. Всё — на in-memory фейках (FakeKanjStore/FakeMlClient/FakeAiClassifier). +/// +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 карточки. + // sourceMsg: Исходный текст. + // contact: Старый контакт карточки. + // receivedAtMs: Время получения (для порядка батча). + // Возвращает: Карточка как если бы была сохранена в БД. + private static CardDto Card( + string id, + string sourceMsg, + string contact = "", + long receivedAtMs = 1_700_000_000_000) => new() + { + Id = id, + Col = "inbox", + SourceMsg = sourceMsg, + 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 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); + } + + [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(); + } + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs index 442d004..e691f2c 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs @@ -1,275 +1,272 @@ -using System.Text; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Contracts; -using Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты вложений карточки — (единый домен карточки, этап 9): add (детект -/// kind, objectKey-форма, мета в FilesJson, порядок файлов), get-entry для download, remove (объект + мета), -/// 404-семантика и отсутствие записи объекта на несуществующей карточке (files.py L57–94; Ruling 4/11). -/// -/// -/// Зависимости — фейки (строки Cards) и -/// (in-memory IFileStorage, 1:1 с контрактом порта: Put с позиции 0). -/// Мета записи — wire-форма {id pf_, name, size, kind, label, objectKey}; objectKey — -/// projects/{card}/{fileId}_{ms}_{safeName}. -/// -public sealed class CardsServiceFilesTests -{ - // ─── Add (files.py add_file L57–75) ───────────────────────────────────── - - [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); - - // 1:1 прототип: «f.filename or "file"» — пустое имя не ошибка, а дефолт «file». - 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)); - } - - // ─── GetEntry (files.py get_file_entry L78–83; download-эндпоинт) ──────── - - [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-семантика - } - - // ─── Remove (files.py remove_file L86–94) ─────────────────────────────── - - [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); - - // 1:1 remove_file («entry and entry.get("objectKey")»): у записи-мока пустой ключ — удалять нечего. - 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; + +/// +/// Тесты вложений карточки — (единый домен карточки, этап 9): add (детект +/// kind, objectKey-форма, мета в FilesJson, порядок файлов), get-entry для download, remove (объект + мета), +/// 404-семантика и отсутствие записи объекта на несуществующей карточке (files.py L57–94; Ruling 4/11). +/// +/// +/// Зависимости — фейки (строки Cards) и +/// (in-memory IFileStorage, 1:1 с контрактом порта: Put с позиции 0). +/// Мета записи — wire-форма {id pf_, name, size, kind, label, objectKey}; objectKey — +/// projects/{card}/{fileId}_{ms}_{safeName}. +/// +public sealed class CardsServiceFilesTests +{ + // ─── Add (files.py add_file L57–75) ───────────────────────────────────── + + [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); + + // 1:1 прототип: «f.filename or "file"» — пустое имя не ошибка, а дефолт «file». + 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)); + } + + // ─── GetEntry (files.py get_file_entry L78–83; download-эндпоинт) ──────── + + [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-семантика + } + + // ─── Remove (files.py remove_file L86–94) ─────────────────────────────── + + [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); + + // 1:1 remove_file («entry and entry.get("objectKey")»): у записи-мока пустой ключ — удалять нечего. + 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 ad36acb..9095bb5 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs @@ -1,608 +1,602 @@ -using System.Text.Json; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Contracts; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты операций карточек пространства «Выбранные» — (единый домен -/// карточки, этап 9): чтение, ручное создание, «взять в работу», патч, комментарии/ссылки, -/// move по стадии + история/сброс напоминания, очистка «Отклонено» (projects.py L103–231). -/// -/// -/// Зависимости — фейк (единые строки Cards; поведение 1:1 с EF-адаптером -/// KanbanStore), FakeSettingsStore, FakeMlClient и FakeFileStorage. -/// Семантика результатов: null-карточка = 404 (текст у эндпоинта), Error = 400-строка прототипа -/// (сверяется с константой ). -/// -public sealed class CardsServiceSelectedTests -{ - // ─── Чтение (list_cards/get_card L58–68) ──────────────────────────────── - - [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); - } - - // ─── Ручное создание (create_local_card L103–124, Ruling 6) ───────────── - - [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); - } - - // ─── «Взять в работу» (take_lead_to_projects L127–156, Ruling 5) ──────── - - [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 при существующей карточке"); - - // Та же карточка переехала в стадию planned, поля сохранены (Ruling 4/5). - 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); - } - - // ─── Правка полей (patch_card L159–187; presence-aware тело PATCH) ────── - - [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); - - // Фронт шлёт явный budget:null для очистки — budget снят (Ruling 11). - 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); - - // Прототип: _json(patch["stack"] or []) — явный null очищает стек (patch_card L172–173). - 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); - - // Текстовые поля прототип НЕ очищает null-ом (str(None) — баг); JSON-null трактуется как отсутствие - // ключа — title не тронут, summary обновлён. - 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); - } - - // ─── Move + история + сброс напоминания (move_stage L202–216) ────────── - - [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) - - // История КОПИТСЯ (append, не замена): создание + записи обоих move в порядке переносов (Ruling 7). - 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 «Карточка не найдена» — текст у эндпоинта - } - - // ─── Очистка «Отклонено» (clear_stage L223–231, Ruling 9) ─────────────── - - [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); - } - - // ─── Комментарии (add_comment L194–199, Ruling 11) ───────────────────── - - [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); - } - - // ─── Ссылки (add_link/remove_link L133–150) ──────────────────────────── - - [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; + +/// +/// Тесты операций карточек пространства «Выбранные» — (единый домен +/// карточки, этап 9): чтение, ручное создание, «взять в работу», патч, комментарии/ссылки, +/// move по стадии + история/сброс напоминания, очистка «Отклонено» (projects.py L103–231). +/// +/// +/// Зависимости — фейк (единые строки Cards; поведение 1:1 с EF-адаптером +/// KanbanStore), FakeSettingsStore, FakeMlClient и FakeFileStorage. +/// Семантика результатов: null-карточка = 404 (текст у эндпоинта), Error = 400-строка прототипа +/// (сверяется с константой ). +/// +public sealed class CardsServiceSelectedTests +{ + // ─── Чтение (list_cards/get_card L58–68) ──────────────────────────────── + + [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); + } + + // ─── Ручное создание (create_local_card L103–124, Ruling 6) ───────────── + + [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); + } + + // ─── «Взять в работу» (take_lead_to_projects L127–156, Ruling 5) ──────── + + [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 при существующей карточке"); + + // Та же карточка переехала в стадию planned, поля сохранены (Ruling 4/5). + 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); + } + + // ─── Правка полей (patch_card L159–187; presence-aware тело PATCH) ────── + + [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); + + // Фронт шлёт явный budget:null для очистки — budget снят (Ruling 11). + 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); + + // Прототип: _json(patch["stack"] or []) — явный null очищает стек (patch_card L172–173). + 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); + + // Текстовые поля прототип НЕ очищает null-ом (str(None) — баг); JSON-null трактуется как отсутствие + // ключа — title не тронут, summary обновлён. + 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); + } + + // ─── Move + история + сброс напоминания (move_stage L202–216) ────────── + + [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) + + // История КОПИТСЯ (append, не замена): создание + записи обоих move в порядке переносов (Ruling 7). + 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 «Карточка не найдена» — текст у эндпоинта + } + + // ─── Очистка «Отклонено» (clear_stage L223–231, Ruling 9) ─────────────── + + [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); + } + + // ─── Комментарии (add_comment L194–199, Ruling 11) ───────────────────── + + [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); + } + + // ─── Ссылки (add_link/remove_link L133–150) ──────────────────────────── + + [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/CbrRateSourceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CbrRateSourceTests.cs index 0b91879..1b4a49b 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CbrRateSourceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CbrRateSourceTests.cs @@ -1,10 +1,5 @@ using System.Net; using System.Text; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Services; using Microsoft.Extensions.Logging.Abstractions; diff --git a/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs index 83649aa..16c8197 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs @@ -1,8 +1,5 @@ using System.Text.Json; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; using Deal.Modules.Kanban.Application.Services; using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Settings; diff --git a/src/core/tests/Deal.Tests.Unit/Support/ConversionRecomputerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/ConversionRecomputerTests.cs index 52e46f4..adbaa6a 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/ConversionRecomputerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/ConversionRecomputerTests.cs @@ -1,12 +1,8 @@ using System.Text.Json; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; using Deal.Modules.Settings.Application.Services; using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Settings; diff --git a/src/core/tests/Deal.Tests.Unit/Support/FakeSecretCipher.cs b/src/core/tests/Deal.Tests.Unit/Support/FakeSecretCipher.cs index 0adf2f1..00446af 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/FakeSecretCipher.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/FakeSecretCipher.cs @@ -1,8 +1,5 @@ using System.Text; using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit.Support; diff --git a/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs index fef85d4..4cbabb3 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs @@ -1,360 +1,346 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Ai; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -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.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.Extensions.Logging.Abstractions; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Tenants; -using Deal.Tests.Unit.Grpc; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты gRPC-адаптера IAiClassifier к ai-service (план Task 15, Ruling 5/6; Acceptance L433–434). -/// -/// -/// Сценарии гоняются против in-proc фейк-ai-service (, Kestrel HTTP/2 на -/// эфемерном порту) на реальном канале GrpcAiClassifier: проверяются маппинг DTO↔ai.proto (Filter/Classify), -/// заполненные промпты и user-контекст классификации (доски/сообщение — AiClassifyContextBuilder по данным -/// тенанта), ProviderConfig из настроек (расшифровка apiKey), metadata tenant-id/service-token (Ruling 1), -/// недоступность → AiUnavailableException (воркер отвечает «пропустить»/aiFail) и списание usage: бюджет -/// периода tenant_limits + lifetime-KV aiTokenUsage (TokenUsageRecorder, Ruling 3 этапа 7). Сеть наружу не используется. -/// -[Collection("MlGrpcTests")] -public sealed class GrpcAiClassifierTests -{ - // Id тенанта сценариев строкой (формат N) — ожидаемое значение metadata tenant-id. - private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; - - // Guid того же тенанта — ключ строк лимита в FakeTenantLimitStore (списание usage, Ruling 3). - 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); - - // Маппинг 1:1 (FilterReply → AiFilterResultDto): фильтр применён (skipped=false), причина — из ответа. - 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); - - // Metadata вызова (Ruling 1). - 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); - - // Недоступность → AiUnavailableException: воркер отвечает {pass:true, skipped:true} (python L1102–1106). - 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); - - // python filter_incoming L193: text[:4000] — лимит текста фильтра (ai.proto FilterRequest). - 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); - - // Маппинг JSON → DTO 1:1: заголовок, блок «О заявке», стек, бюджет, контакты (бот отброшен), тип. - 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); - - // Тело запроса: system_prompt = aiPrompt+cardPrompt (подстановка сферы), user_context — доски+сообщение. - 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); - - // Недоступность классификатора → AiUnavailableException (raw={} python L1112–1114 — aiFail). - 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); - - // python classify L250: message_text[:5000] в user-контексте (без разрыва суррогатных пар). - 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); - - // Проверяет накопленный usage в tenant-KV aiTokenUsage (Ruling 5). - // settings: KV-хранилище тенанта. - // prompt: Ожидаемые токены запроса. - // completion: Ожидаемые токены ответа. - // total: Ожидаемые суммарные токены. - 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 (план Task 15, Ruling 5/6; Acceptance L433–434). +/// +/// +/// Сценарии гоняются против in-proc фейк-ai-service (, Kestrel HTTP/2 на +/// эфемерном порту) на реальном канале GrpcAiClassifier: проверяются маппинг DTO↔ai.proto (Filter/Classify), +/// заполненные промпты и user-контекст классификации (доски/сообщение — AiClassifyContextBuilder по данным +/// тенанта), ProviderConfig из настроек (расшифровка apiKey), metadata tenant-id/service-token (Ruling 1), +/// недоступность → AiUnavailableException (воркер отвечает «пропустить»/aiFail) и списание usage: бюджет +/// периода tenant_limits + lifetime-KV aiTokenUsage (TokenUsageRecorder, Ruling 3 этапа 7). Сеть наружу не используется. +/// +[Collection("MlGrpcTests")] +public sealed class GrpcAiClassifierTests +{ + // Id тенанта сценариев строкой (формат N) — ожидаемое значение metadata tenant-id. + private const string TenantIdValue = "0123456789abcdef0123456789abcdef"; + + // Guid того же тенанта — ключ строк лимита в FakeTenantLimitStore (списание usage, Ruling 3). + 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); + + // Маппинг 1:1 (FilterReply → AiFilterResultDto): фильтр применён (skipped=false), причина — из ответа. + 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); + + // Metadata вызова (Ruling 1). + 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); + + // Недоступность → AiUnavailableException: воркер отвечает {pass:true, skipped:true} (python L1102–1106). + 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); + + // python filter_incoming L193: text[:4000] — лимит текста фильтра (ai.proto FilterRequest). + 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); + + // Маппинг JSON → DTO 1:1: заголовок, блок «О заявке», стек, бюджет, контакты (бот отброшен), тип. + 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); + + // Тело запроса: system_prompt = aiPrompt+cardPrompt (подстановка сферы), user_context — доски+сообщение. + 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); + + // Недоступность классификатора → AiUnavailableException (raw={} python L1112–1114 — aiFail). + 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); + + // python classify L250: message_text[:5000] в user-контексте (без разрыва суррогатных пар). + 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); + + // Проверяет накопленный usage в tenant-KV aiTokenUsage (Ruling 5). + // settings: KV-хранилище тенанта. + // prompt: Ожидаемые токены запроса. + // completion: Ожидаемые токены ответа. + // total: Ожидаемые суммарные токены. + 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/IncomingRulesTests.cs b/src/core/tests/Deal.Tests.Unit/Support/IncomingRulesTests.cs index 1cdb2bb..817459b 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/IncomingRulesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/IncomingRulesTests.cs @@ -1,8 +1,5 @@ using System.Text.Json; -using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; diff --git a/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs index 503ac35..1e4098e 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs @@ -1,351 +1,350 @@ -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.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection; -using Deal.Tests.Unit.Modules.Tenants; - -namespace Deal.Tests.Unit.Support; - -/// -/// HTTP-тесты POST /api/join (Task 6, Ruling 2/11): эквивалент curl-сценария активации на in-process Kestrel. -/// -/// -/// Ручка публичная (без сессии) — успех {ok:true, login}, кука НЕ ставится (далее обычный /api/auth/login); -/// все отказы — 400 {detail} с фиксированным текстом (невалидный/протухший/revoked код, чужой email, занятый -/// email, короткий пароль). Аудит invite_joined пишется при успехе (актор — новый пользователь тенанта). -/// Живая curl/psql-приёмка (провижининг схемы реальным TenantProvisioningService) — ⚠ Manual (нужен Postgres); -/// здесь провижининг заменён FakeTenantProvisioner, остальная семантика — как в проде (реальные сервисы модуля). -/// -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()); - // План Task 6: кука НЕ ставится — после активации обычный /api/auth/login. - 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); - - // Аудит invite_joined: актор — пользователь тенанта, детали email+codeHash (Ruling 4, Task 6; этап 10 T1). - 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 (Task 6, Ruling 2/11): эквивалент curl-сценария активации на in-process Kestrel. +/// +/// +/// Ручка публичная (без сессии) — успех {ok:true, login}, кука НЕ ставится (далее обычный /api/auth/login); +/// все отказы — 400 {detail} с фиксированным текстом (невалидный/протухший/revoked код, чужой email, занятый +/// email, короткий пароль). Аудит invite_joined пишется при успехе (актор — новый пользователь тенанта). +/// Живая curl/psql-приёмка (провижининг схемы реальным TenantProvisioningService) — ⚠ Manual (нужен Postgres); +/// здесь провижининг заменён FakeTenantProvisioner, остальная семантика — как в проде (реальные сервисы модуля). +/// +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()); + // План Task 6: кука НЕ ставится — после активации обычный /api/auth/login. + 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); + + // Аудит invite_joined: актор — пользователь тенанта, детали email+codeHash (Ruling 4, Task 6; этап 10 T1). + 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/LocalColumnSuggesterTests.cs b/src/core/tests/Deal.Tests.Unit/Support/LocalColumnSuggesterTests.cs index d1f4373..b657739 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/LocalColumnSuggesterTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/LocalColumnSuggesterTests.cs @@ -1,21 +1,9 @@ using System.Globalization; -using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Settings; diff --git a/src/core/tests/Deal.Tests.Unit/Support/LocalFileStorageTests.cs b/src/core/tests/Deal.Tests.Unit/Support/LocalFileStorageTests.cs index da06a73..b44cb85 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/LocalFileStorageTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/LocalFileStorageTests.cs @@ -1,7 +1,5 @@ using System.Text; using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Integrations.Storage.Extensions; -using Deal.Infrastructure.Integrations.Storage.Options; using Deal.Infrastructure.Integrations.Storage.Services; namespace Deal.Tests.Unit.Support; diff --git a/src/core/tests/Deal.Tests.Unit/Support/LocalMlClientTests.cs b/src/core/tests/Deal.Tests.Unit/Support/LocalMlClientTests.cs index d02a4f1..38375c6 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/LocalMlClientTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/LocalMlClientTests.cs @@ -1,373 +1,361 @@ -using System.Text.Json; -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Kanban; - -namespace Deal.Tests.Unit.Support; - -/// -/// Локальная реализация IMlClient без внешнего ML-сервиса (Ruling 4/5, план Task 5 L266–286). -/// -/// -/// Проверяются формы ответов и поведение 1:1 с ml_client.py/ml_routes.py: статус (модель не готова -/// до этапа 4, reachable=true, счётчики ml/ai — из KV-настроек, Ruling 1; learning/outbox — из -/// таблиц через порт IMlLearningStore), push (строка очереди MlOutbox: trim/no-op/обрезание до 6000, -/// id mle_+hex, delta), сброс {ok:true} (чистит только outbox; журнал и KV не трогаются), -/// предсказание неготовой модели (фиксированный «не уверен») и wire-сериализация DTO в camelCase. -/// -public sealed class LocalMlClientTests -{ - // Опции сериализации, как у минимальных API (JsonSerializerDefaults.Web → camelCase). - private static readonly JsonSerializerOptions CamelCaseOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - }; - - // Максимальная длина текста обучающего примера (ml_client.push L48: text[:6000]). - private const int MaxLearningTextLength = 6000; - - // Длина случайного hex-хвоста id outbox (прототип store.uid: uuid4().hex[:12]). - 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()); - - // ─── StatusAsync: детерминированная форма (Ruling 5) ─────────────────── - - [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); - - // Статус сервиса: модель не обучена до этапа 3 — «не готова» (Ruling 5 L77–79). - 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); - - // Семантика прототипа «mlEnabled !== false» (ml_routes.py L71): false — только JSON-false. - 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); - - // learning/outbox — из таблиц (Ruling 4: count(CardMoves)/count(MlOutbox)); ml/ai — KV-счётчики - // решений пайплайна (этап 4): на этапе 3 всегда 0 (Ruling 4 L106–107). - Assert.Equal(3, status.Stats.Learning); - Assert.Equal(2, status.Stats.Outbox); - Assert.Equal(0, status.Stats.Ml); - Assert.Equal(0, status.Stats.Ai); - } - - // ─── PredictAsync: неготовая модель — фиксированный «не уверен» (Ruling 5 L79–80) ── - - [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); - } - - // ─── PushAsync: очередь обучения MlOutbox (Ruling 4, ml_client.push L40–49) ── - - [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); - - // Прототип L44–45: пустые text/label после trim — тихий no-op, строка не пишется. - 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); - - // Возврат из корзины — «не спам»: метка снимается (restore_lead L218–221, delta=−1.0). - 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); - } - - // ─── ResetAsync: {ok:true}, чистит только очередь (ml_client.py reset_model L110–124) ── - - [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-счётчики не тронуты - } - - // ─── Wire-формат (camelCase, 1:1 с ml_routes.py/api-map §4.10 L363) ────── - - [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); - - // Ответ 1:1 с api-map §3.7 L193 (без text — его добавляет эндпоинт); порядок полей записи = - // порядок объявления. Неготовая модель: take/label/scores/hits/ready/margin/terms/type. - 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); - - // Успех — ровно {ok:true}: ключ error появляется только при сбое (ml_client.py L121). - 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-сервиса (Ruling 4/5, план Task 5 L266–286). +/// +/// +/// Проверяются формы ответов и поведение 1:1 с ml_client.py/ml_routes.py: статус (модель не готова +/// до этапа 4, reachable=true, счётчики ml/ai — из KV-настроек, Ruling 1; learning/outbox — из +/// таблиц через порт IMlLearningStore), push (строка очереди MlOutbox: trim/no-op/обрезание до 6000, +/// id mle_+hex, delta), сброс {ok:true} (чистит только outbox; журнал и KV не трогаются), +/// предсказание неготовой модели (фиксированный «не уверен») и wire-сериализация DTO в camelCase. +/// +public sealed class LocalMlClientTests +{ + // Опции сериализации, как у минимальных API (JsonSerializerDefaults.Web → camelCase). + private static readonly JsonSerializerOptions CamelCaseOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + // Максимальная длина текста обучающего примера (ml_client.push L48: text[:6000]). + private const int MaxLearningTextLength = 6000; + + // Длина случайного hex-хвоста id outbox (прототип store.uid: uuid4().hex[:12]). + 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()); + + // ─── StatusAsync: детерминированная форма (Ruling 5) ─────────────────── + + [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); + + // Статус сервиса: модель не обучена до этапа 3 — «не готова» (Ruling 5 L77–79). + 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); + + // Семантика прототипа «mlEnabled !== false» (ml_routes.py L71): false — только JSON-false. + 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); + + // learning/outbox — из таблиц (Ruling 4: count(CardMoves)/count(MlOutbox)); ml/ai — KV-счётчики + // решений пайплайна (этап 4): на этапе 3 всегда 0 (Ruling 4 L106–107). + Assert.Equal(3, status.Stats.Learning); + Assert.Equal(2, status.Stats.Outbox); + Assert.Equal(0, status.Stats.Ml); + Assert.Equal(0, status.Stats.Ai); + } + + // ─── PredictAsync: неготовая модель — фиксированный «не уверен» (Ruling 5 L79–80) ── + + [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); + } + + // ─── PushAsync: очередь обучения MlOutbox (Ruling 4, ml_client.push L40–49) ── + + [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); + + // Прототип L44–45: пустые text/label после trim — тихий no-op, строка не пишется. + 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); + + // Возврат из корзины — «не спам»: метка снимается (restore_lead L218–221, delta=−1.0). + 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); + } + + // ─── ResetAsync: {ok:true}, чистит только очередь (ml_client.py reset_model L110–124) ── + + [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-счётчики не тронуты + } + + // ─── Wire-формат (camelCase, 1:1 с ml_routes.py/api-map §4.10 L363) ────── + + [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); + + // Ответ 1:1 с api-map §3.7 L193 (без text — его добавляет эндпоинт); порядок полей записи = + // порядок объявления. Неготовая модель: take/label/scores/hits/ready/margin/terms/type. + 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); + + // Успех — ровно {ok:true}: ключ error появляется только при сбое (ml_client.py L121). + 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 75d2599..564d73a 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/LoginAttemptEndpointHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/LoginAttemptEndpointHttpTests.cs @@ -2,8 +2,6 @@ using System.Net; using System.Text; using System.Text.Json; using Deal.Api.Configuration; -using Deal.Api.Extensions; -using Deal.Api.Models; using Deal.Api.Services; using Deal.Modules.Tenants.Application.Models; using Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Support/MessageParseCoreTests.cs b/src/core/tests/Deal.Tests.Unit/Support/MessageParseCoreTests.cs index c6ce45d..e463465 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/MessageParseCoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/MessageParseCoreTests.cs @@ -2,10 +2,7 @@ using System.Text.Json; using Deal.Modules.Kanban.Application.Models; 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.Registrars; -using Deal.Modules.Settings.Application.Services; using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; diff --git a/src/core/tests/Deal.Tests.Unit/Support/MlGrpcTestHost.cs b/src/core/tests/Deal.Tests.Unit/Support/MlGrpcTestHost.cs index 20184cd..4e310ed 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/MlGrpcTestHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/MlGrpcTestHost.cs @@ -1,10 +1,9 @@ using System.Net; -using Grpc.Net.Client; +using Deal.Tests.Unit.Grpc; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection; -using Deal.Tests.Unit.Grpc; namespace Deal.Tests.Unit.Support; diff --git a/src/core/tests/Deal.Tests.Unit/Support/MtlsCertificatesTests.cs b/src/core/tests/Deal.Tests.Unit/Support/MtlsCertificatesTests.cs index 1a4dbc5..4568bee 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/MtlsCertificatesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/MtlsCertificatesTests.cs @@ -1,12 +1,8 @@ using System.Net.Security; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; using Deal.Infrastructure.Integrations.Models; using Deal.Infrastructure.Integrations.Options; -using Deal.Infrastructure.Integrations.Services; namespace Deal.Tests.Unit.Support; diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs index b08f8ca..c5bb6a6 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs @@ -1,10 +1,7 @@ using System.Net; using System.Text; using System.Text.Json; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; using Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuditEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuditEndpointsHttpTests.cs index 4437eec..93d3cd4 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuditEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuditEndpointsHttpTests.cs @@ -1,11 +1,7 @@ using System.Net; using System.Text; using System.Text.Json; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; using Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Support; diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthEndpointsHttpTests.cs index 7dc483a..6c1f0e5 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthEndpointsHttpTests.cs @@ -2,13 +2,7 @@ using System.Net; using System.Text; using System.Text.Json; using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; using Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Support; diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs index 41dd599..cff92c8 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs @@ -1,38 +1,24 @@ using System.Net; using Deal.Api.Configuration; using Deal.Api.Endpoints; -using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; using Deal.Api.Middleware; +using Deal.Api.Services; using Deal.Api.Telegram; using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Services; using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Configurations; using Deal.Infrastructure.Tenancy; using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; +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 Deal.Tests.Unit.Modules.Tenants; -using Deal.Tests.Unit.Modules.Settings; using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions; using TenantCookieOptions = Deal.Api.Configuration.CookieOptions; diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs index f22e1dd..eaae28c 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs @@ -1,11 +1,7 @@ using System.Net; using System.Text; using System.Text.Json; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; using Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Support; diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorInvitesEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorInvitesEndpointsHttpTests.cs index 93823df..e2ffd2c 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorInvitesEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorInvitesEndpointsHttpTests.cs @@ -1,10 +1,7 @@ using System.Net; using System.Text; using System.Text.Json; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; using Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs index aedf562..3a8f57f 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs @@ -1,11 +1,7 @@ using System.Net; using System.Text; using System.Text.Json; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; using Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Support; diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorMaintenanceEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorMaintenanceEndpointsHttpTests.cs index 19eb9dd..9268111 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorMaintenanceEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorMaintenanceEndpointsHttpTests.cs @@ -1,11 +1,7 @@ using System.Net; using System.Text; using System.Text.Json; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; using Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Support; diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs index 81c058e..55fca01 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs @@ -1,337 +1,330 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -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 Deal.Tests.Unit.Modules.Settings; - -namespace Deal.Tests.Unit.Support; - -/// -/// HTTP-тесты операторских ручек глобальных настроек Telegram (ТЗ §4.1/§8.1): -/// GET/PUT /api/operator/settings/telegram-keys. -/// -/// -/// Прогон на in-process Kestrel (OperatorAuthHttpHost) с фейками: глобальное KV-хранилище -/// (FakeGlobalSettingsStore), шифр (FakeSecretCipher), аудит (настоящий сервис на фейк-сторе). -/// Проверяются маскирование ответа, шифрование apiHash в хранилище, валидация (api_id/api_hash), -/// 401 без операторской сессии и аудит telegram_keys_changed (без секретов). -/// -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 (ТЗ §4.1/§8.1): +/// GET/PUT /api/operator/settings/telegram-keys. +/// +/// +/// Прогон на in-process Kestrel (OperatorAuthHttpHost) с фейками: глобальное KV-хранилище +/// (FakeGlobalSettingsStore), шифр (FakeSecretCipher), аудит (настоящий сервис на фейк-сторе). +/// Проверяются маскирование ответа, шифрование apiHash в хранилище, валидация (api_id/api_hash), +/// 401 без операторской сессии и аудит telegram_keys_changed (без секретов). +/// +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/OperatorTenantsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorTenantsEndpointsHttpTests.cs index 478d60f..29f0c45 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorTenantsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorTenantsEndpointsHttpTests.cs @@ -1,10 +1,7 @@ using System.Net; using System.Text; using System.Text.Json; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; using Deal.Tests.Unit.Modules.Tenants; diff --git a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs index e371e60..1880c5c 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs @@ -1,338 +1,327 @@ -using System.Text.Json; -using Deal.Api; -using Deal.Api.Dtos; -using Deal.Api.Services; -using Deal.Api.Events; -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.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Tenants; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Contracts; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты PipelineWorkerScheduler — фоновый цикл разбора очереди входящих (план Task 11, Ruling 8; -/// аналог _pipeline_loop main.py L79–88): каждые 2 с обход ВСЕХ тенантов реестра, на каждый — собственный -/// scope с ITenantContext, pump PipelineWorkerService под общим PipelinePumpGate и SSE new_card по созданным -/// карточкам. -/// -/// -/// Тайминги цикла (Timer 2 с, первый проход, stop) не тестируются — тестируется тело прохода RunCycleAsync -/// (как StorageTickSchedulerTests). Провайдер собирает РЕАЛЬНЫЕ сервисы модуля Pipeline на тенант-фейках -/// (FakePipelineStore/FakeKanjStore по ITenantContext — эталон StorageTickSchedulerTests): воркер ходит тем же -/// путём, что и в проде (SetTenant → scoped-резолв → PumpOnce). ML «спит» (FakeMlClient.Predict не задан → -/// сбой → «не уверен» → filtered), ИИ — FakeAiClassifier с разбором вакансии → карточка inbox (путь как в -/// AdminTickOrchestratorTests). Возраст строк — «сейчас» (stale-проверка воркера не срабатывает). -/// -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); - - // SSE new_card — по карточке каждого тенанта, в канал тенанта (Ruling 8/9). - 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); - - // Пустые очереди — тихий no-op (прототип: pump на пустой очереди возвращает нули): публикаций нет. - 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")); - - // Очередь тенанта A уже разбирает другой воркер (ручной POST /api/admin/tick): гейт занят — цикл - // пропускает A (как прототип L901–902: занятый lock → {}), очередь ждёт следующего срабатывания. - 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) => new() - { - Id = id, - DialogId = dialogId, - MsgId = 100, - Text = text, - Status = PipelineQueueStatuses.New, - Channel = new PipelineChannelDto("Канал", "channel", "#333"), - MsgAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - QueuedAtMs = 1_700_000_000_000, - }; - - // Минимальный разбор карточки (карточку домыслит 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 — фоновый цикл разбора очереди входящих (план Task 11, Ruling 8; +/// аналог _pipeline_loop main.py L79–88): каждые 2 с обход ВСЕХ тенантов реестра, на каждый — собственный +/// scope с ITenantContext, pump PipelineWorkerService под общим PipelinePumpGate и SSE new_card по созданным +/// карточкам. +/// +/// +/// Тайминги цикла (Timer 2 с, первый проход, stop) не тестируются — тестируется тело прохода RunCycleAsync +/// (как StorageTickSchedulerTests). Провайдер собирает РЕАЛЬНЫЕ сервисы модуля Pipeline на тенант-фейках +/// (FakePipelineStore/FakeKanjStore по ITenantContext — эталон StorageTickSchedulerTests): воркер ходит тем же +/// путём, что и в проде (SetTenant → scoped-резолв → PumpOnce). ML «спит» (FakeMlClient.Predict не задан → +/// сбой → «не уверен» → filtered), ИИ — FakeAiClassifier с разбором вакансии → карточка inbox (путь как в +/// AdminTickOrchestratorTests). Возраст строк — «сейчас» (stale-проверка воркера не срабатывает). +/// +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); + + // SSE new_card — по карточке каждого тенанта, в канал тенанта (Ruling 8/9). + 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); + + // Пустые очереди — тихий no-op (прототип: pump на пустой очереди возвращает нули): публикаций нет. + 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")); + + // Очередь тенанта A уже разбирает другой воркер (ручной POST /api/admin/tick): гейт занят — цикл + // пропускает A (как прототип L901–902: занятый lock → {}), очередь ждёт следующего срабатывания. + 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) => new() + { + Id = id, + DialogId = dialogId, + MsgId = 100, + Text = text, + Status = PipelineQueueStatuses.New, + Channel = new PipelineChannelDto("Канал", "channel", "#333"), + MsgAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + QueuedAtMs = 1_700_000_000_000, + }; + + // Минимальный разбор карточки (карточку домыслит 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/PipelineWorkerServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs index 2ca45ac..123ff09 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs @@ -1,721 +1,712 @@ -using System.Text.Json; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application.Abstractions; -using Deal.Modules.Kanban.Application.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Contracts; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты воркера pump — PipelineWorkerService (план Task 8 L423–431, Ruling 8; прототип _pump_unlocked L920–1183). -/// -/// -/// Референс — путь сообщения 1:1: очередь → стоп-фразы → дедуп → ML → ИИ → карточка/отсев (план Rulings). -/// Кейсы Acceptance Task 8: (1) короткое → отсев length; (2) стоп-фраза → отсев stop с kw; (3) резюме → отсев -/// resume; (4) повтор по тексту → отсев dup; (5) stale (msgAt старше срока) → отсев БЕЗ карточки; (6) вакансия → -/// карточка inbox с is_vacancy_known=true (стемп успешной классификации L1108–1111) + aiDecisions + CreatedCards; -/// (7) no-budget (budgetRequiredHire) → отсев budget + claim снят; -/// (8) ML spam → spam_ml + mlDecisions; (9) ML доска → карточка в доску без обучения; (10) ML тип + aiEnabled=false -/// → карточка inbox с типом ML; (11) force минует правила/stale/no-budget/ML; (12) ИИ-слот: доска под ContainerAccepts- -/// страховку / is_spam → spam_ai + push / сбой классификатора → локальный разбор (aiFail); (13) сбой записи карточки -/// (исключение пробрасывается, строка с claim остаётся); (14) force отменяет вердикт «спам» ИИ (тип подтверждён → -/// учим t:hire); (15) ИИ-карточка в свободную доску → обучение колонки и типа (0.4), хотя классификатор вернул -/// known=false (стемп воркера). ML-ветки гоняются на fake-клиенте с ready:true -/// (); неготовая модель (дефолт сценариев) — «не уверен» → ИИ-ветка (Ruling 5). -/// -public sealed class PipelineWorkerServiceTests -{ - // ─── Контекст и хелперы ───────────────────────────────────────────────── - - // Сутки в миллисекундах (для msgAt в прошлом — stale, Ruling 8). - private const long DayMs = 86_400_000; - - // Контекст теста: воркер поверх in-memory фейков хранилищ/портов. - private sealed record Context( - PipelineWorkerService Worker, - FakePipelineStore PipelineStore, - FakeKanjStore KanjStore, - FakeSettingsStore Settings, - FakeMlClient MlClient, - FakeAiClassifier AiClassifier); - - // Собирает контекст: дефолты настроек/досок, ML «не готов» (как LocalMlClient), ИИ-фильтр пропускает. - // pipelineStore: Хранилище очереди (по умолчанию — обычный фейк; сценарий гонки claim'а — RacingClaimPipelineStore). - // Возвращает: Воркер и фейки (сценарий до-настраивает строки очереди/ответы портов). - private static Context CreateContext(FakePipelineStore? pipelineStore = null) - { - var settings = new FakeSettingsStore(); - var store = pipelineStore ?? new FakePipelineStore(); - var kanjStore = new FakeKanjStore(); - var mlClient = new FakeMlClient { Predict = NotReadyPrediction() }; - var aiClassifier = new FakeAiClassifier(); - var rules = new IncomingRules(settings); - var fieldsParser = new LocalFieldsParser(settings); - var processing = new PipelineProcessingService(store, mlClient, new PipelineIngestService(store)); - 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); - return new Context(worker, store, kanjStore, settings, mlClient, aiClassifier); - } - - // Хранилище сценария гонки claim'а (два параллельных pump): наша ExistsAsync-проверка ещё не - // видит заявку, но ClaimAsync (INSERT … ON CONFLICT DO NOTHING в адаптере) возвращает false — хэш успел - // заявить другой проход. DeleteClaimAsync записывается — проверяем, что чужая заявка НЕ снимается. - // claimResult: Результат ClaimAsync (по умолчанию false — «победил другой проход»). - private sealed class RacingClaimPipelineStore(bool claimResult = false) : FakePipelineStore - { - /// - /// Сколько раз вызван ClaimAsync (проверка: конфликт обрабатывается, а не молча пропускается). - /// - public int ClaimCalls { get; private set; } - - /// - /// Сколько раз вызван DeleteClaimAsync (должен остаться 0 — заявка не наша). - /// - public int DeleteClaimCalls { get; private set; } - - /// - public override Task ExistsAsync(string hash, CancellationToken ct) - { - return Task.FromResult(false); // параллельный проход ещё не вставил строку на момент проверки - } - - /// - public override Task ClaimAsync(string hash, CancellationToken ct) - { - ClaimCalls++; - return Task.FromResult(claimResult); // ON CONFLICT DO NOTHING: вставил другой проход → false - } - - /// - public override Task DeleteClaimAsync(string hash, CancellationToken ct) - { - DeleteClaimCalls++; - return Task.CompletedTask; - } - } - - // Сериализует значение настройки в JSON-строку строки settings (как пишет SettingsStore). - // value: Значение (число/булево/строка/список). - // Возвращает: JSON для Preload. - private static string Json(object value) => JsonSerializer.Serialize(value); - - // Строка очереди (status new; сценарий перекрывает текст/время/флаги). - // id: Id строки (p_…). - // text: Текст сообщения. - // msgAtMs: Время исходного сообщения (дефолт — сейчас: не устарело). - // force: Флаг «возвращено из отсева». - // Возвращает: Строка как если бы была сохранена в БД. - private static QueueItemDto QueueRow( - string id, - string text, - long? msgAtMs = null, - bool force = false) => new() - { - Id = id, - DialogId = "d_1", - MsgId = 100, - Text = text, - Status = PipelineQueueStatuses.New, - Channel = new PipelineChannelDto("Канал А", "channel_a", "#333"), - MsgAtMs = msgAtMs ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - QueuedAtMs = 1_700_000_000_000, - Force = force, - }; - - // Неготовая модель: «не уверена» (LocalMlClient ready:false → predict, Ruling 5). - // Возвращает: Фиксированный «не готов/не уверен». - private static MlPredictResultDto NotReadyPrediction() => new( - Take: false, - Label: null, - Scores: new Dictionary(), - Hits: 0, - Ready: false, - Margin: null, - Terms: Array.Empty(), - Type: null); - - // Минимальный разбор карточки (сценарий перекрывает нужные поля; карточку домыслит CardComposer). - // title: Заголовок. - // budget: Бюджет разбора (null — суммы в разборе нет). - // isVacancy: Признак найма. - // isVacancyKnown: Тип подтверждён (ИИ/ML). - // isSpam: Вердикт «не заявка». - // board: Назначенная колонка (пройдёт ContainerAccepts-страховку). - // Возвращает: Разбор классификатора. - private static AiParsedCardDto Parsed( - string title, - AiBudgetDto? budget = null, - bool isVacancy = false, - bool isVacancyKnown = false, - bool isSpam = 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, - board); - - // ─── (1) Короткое сообщение → отсев length ───────────────────────────── - - [Fact] - public async Task Pump_ShortText_RejectsLengthAndRemovesRow() - { - Context ctx = CreateContext(); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", "Короткий текст")); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("length", rejected.Stage); - Assert.Equal("stop", rejected.Source); - Assert.Equal("короче 24 символов", rejected.Reason); - Assert.Empty(rejected.Kw); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(result.CreatedCards); - Assert.Equal(0, result.Staged); // отсев правилами не доходит до дедупа/ML (как в прототипе L933–939) - } - - // ─── (2) Стоп-фраза → отсев stop с kw ────────────────────────────────── - - [Fact] - public async Task Pump_StopPhraseHit_RejectsStopWithPhraseAsKw() - { - Context ctx = CreateContext(); - ctx.PipelineStore.SeedQueue(QueueRow( - "p_1", - "Отличный сервис! Взаимный пиар в чатах и каналах — продвижение без бюджета и вложений")); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("stop", rejected.Stage); - Assert.Equal("stop", rejected.Source); - Assert.Equal("взаимный пиар", rejected.Kw); - Assert.Equal("стоп-фраза «взаимный пиар»", rejected.Reason); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(result.CreatedCards); - } - - // ─── (2a) Глобальное исключение по слову → отсев до ML/ИИ (§5.14) ────── - - [Fact] - public async Task Pump_GlobalExclusionKeyword_RejectsBeforeMlAndAi() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.ExcludeKeywords, Json(new[] { "крипт" })); - ctx.PipelineStore.SeedQueue(QueueRow( - "p_1", - "Продам крипто-курс: пассивный доход, пишите в личку")); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("exclude_kw", rejected.Stage); - Assert.Equal("stop", rejected.Source); - Assert.Equal("крипт", rejected.Kw); - Assert.Equal("глобальное исключение: слово/технология «крипт»", rejected.Reason); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(result.CreatedCards); - Assert.Equal(0, result.Staged); // исключение срабатывает до ML/ИИ — токены не тратятся - Assert.Equal(0, ctx.MlClient.PredictCalls); - } - - [Fact] - public async Task Pump_GlobalExclusionBudget_RejectsWithinRange() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.ExcludeBudgetFrom, Json(1000)); - ctx.Settings.Preload(SettingsKeys.ExcludeBudgetTo, Json(5000)); - ctx.PipelineStore.SeedQueue(QueueRow( - "p_1", - "Ищу специалиста на разовую задачу, бюджет 3000$ за проект")); - - await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("exclude_budget", rejected.Stage); - Assert.Equal("stop", rejected.Source); - Assert.Equal(0, ctx.MlClient.PredictCalls); - } - - // ─── (3) Резюме соискателя → отсев resume ────────────────────────────── - - [Fact] - public async Task Pump_ResumeMarkerHit_RejectsResume() - { - Context ctx = CreateContext(); - // Стоп-фразы по умолчанию перекрываем пустыми — проверяем именно resume-ветку (как IncomingRulesTests). - ctx.Settings.Preload(SettingsKeys.StopPhrases, Json(Array.Empty())); - ctx.PipelineStore.SeedQueue(QueueRow( - "p_1", - "Алексей, 5 лет коммерческой разработки на C#, готов к собеседованию на позицию разработчика")); - - await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("resume", rejected.Stage); - Assert.Equal("stop", rejected.Source); - Assert.Equal("готов к собеседованию", rejected.Kw); - Assert.Equal("резюме соискателя («готов к собеседованию»)", rejected.Reason); - Assert.Empty(ctx.PipelineStore.Queue); - } - - // ─── (4) Повтор: тот же текст второй раз → отсев dup ─────────────────── - - [Fact] - public async Task Pump_SameTextTwice_SecondMessageRejectedAsDuplicate() - { - Context ctx = CreateContext(); - const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; - ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", isVacancy: true); - long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text, msgAtMs: nowMs)); - ctx.PipelineStore.SeedQueue(QueueRow("p_2", text, msgAtMs: nowMs + 100)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - // Первое сообщение ушло на ИИ и дало карточку; второе — «повтор» (хэш уже в системе, L940–947). - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("dup", rejected.Stage); - Assert.Equal("dup", rejected.Source); - Assert.Equal("сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается", rejected.Reason); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Equal(1, result.Staged); - Assert.Single(result.CreatedCards); - Assert.Equal(1, result.AiStored); - Assert.Single(ctx.KanjStore.CardDtos); - } - - // ─── (4a) Гонка параллельных pump: claim занят другим проходом → без второй карточки ── - - [Fact] - public async Task Pump_ClaimLostToParallelPass_RejectsDuplicateAndDoesNotCreateCard() - { - Context ctx = CreateContext(new RacingClaimPipelineStore()); - const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - // Другой проход pump заявил хэш между нашей ExistsAsync-проверкой и ClaimAsync (INSERT … ON CONFLICT - // DO NOTHING вернул 0 строк): карточку НЕ создаём — отсев dup + снятие строки (Ruling 8). - RacingClaimPipelineStore store = Assert.IsType(ctx.PipelineStore); - Assert.Equal(1, store.ClaimCalls); // конфликт claim'а обработан, а не пропущен молча - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("dup", rejected.Stage); - Assert.Equal("dup", rejected.Source); - Assert.Equal("сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается", rejected.Reason); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(ctx.KanjStore.CardDtos); // второй карточки нет - Assert.Empty(result.CreatedCards); - Assert.Equal(0, result.Staged); // до ML/ИИ строка не дошла - Assert.Equal(0, ctx.MlClient.PredictCalls); - Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); - // Чужая заявка НЕ снимается (DeleteClaimAsync не зовётся) — её свяжет карточка победителя. - Assert.Equal(0, store.DeleteClaimCalls); - } - - // ─── (5) Устарело → отсев stale без карточки ─────────────────────────── - - [Fact] - public async Task Pump_MessageOlderThanArchiveAfterDays_RejectsStaleWithoutCard() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.ArchiveAfterDays, Json(1)); - long oldMsgAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - (2 * DayMs); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", "Вакансия: разработчик в команду, оплата 2000$", msgAtMs: oldMsgAt)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("stale", rejected.Stage); - Assert.Equal("stale", rejected.Source); - Assert.Equal("сообщение старше 1 дн. (срок до автоархива) — не заводим в систему", rejected.Reason); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(result.CreatedCards); // устаревшие не попадают в систему ни карточкой, ни ML/ИИ - Assert.Empty(ctx.KanjStore.CardDtos); - } - - // ─── (6) Вакансия → карточка inbox (aiStored/aiDecisions/CreatedCards) ── - - [Fact] - public async Task Pump_VacancyMessage_CreatesInboxCardAndTracksAiDecision() - { - Context ctx = CreateContext(); - const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; - ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик в команду", isVacancy: true); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - // Путь 1:1: правила → дедуп (claim) → ML «спит» (не готов) → filtered → ИИ (фильтр пропустил) → карточка. - CardDto card = Assert.Single(ctx.KanjStore.CardDtos); - Assert.Equal(KanbanColumns.Inbox, card.Col); - Assert.True(card.IsNew); - Assert.True(card.IsVacancyKnown); // успешная классификация подтвердила тип (python L1108–1111) - Assert.Same(card, Assert.Single(result.CreatedCards)); // для SSE new_card (Ruling 8/9) - Assert.Equal(1, result.Staged); - Assert.Equal(1, result.AiStored); - Assert.Equal(1, ctx.AiClassifier.FilterCalls); - Assert.Equal(1, ctx.AiClassifier.ClassifyCalls); - Assert.Equal(1, ctx.MlClient.PredictCalls); // ML включён, но не готов — predict дал «не уверен» - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(ctx.PipelineStore.Rejected); - Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); // aiStored+aiDrop (Ruling 5) - Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.MlDecisions)); - // Тип известен (стемп ИИ) — учим ML t:hire весом 0.4 (L1175–1180); карточка в inbox → колонку не учим. - Assert.Equal((text, "t:hire", 0.4), Assert.Single(ctx.MlClient.Pushed)); - } - - // ─── (7) Нет суммы (budgetRequiredHire) → отсев budget + claim снят ───── - - [Fact] - public async Task Pump_NoBudgetForHireWithRequiredFilter_RejectsBudgetAndReleasesClaim() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.BudgetRequiredHire, Json(true)); - const string text = "Вакансия: разработчик в команду, удалённая работа, опыт от двух лет"; - ctx.AiClassifier.ClassifyResult = Parsed("Разработчик в команду", isVacancy: true); // суммы нет - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("budget", rejected.Stage); - Assert.Equal("stop", rejected.Source); - Assert.Equal("включён фильтр «не создавать карточку без суммы» — в тексте не указан бюджет", rejected.Reason); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(ctx.KanjStore.CardDtos); - Assert.Equal(1, result.NoBudget); - Assert.Equal(0, result.AiStored); // отсев фильтром не считается решением ИИ (python L1143–1147) - // Claim снят: после выключения фильтра сообщение можно обработать заново (L1015). - Assert.False(await ctx.PipelineStore.ExistsAsync(DedupHasher.Hash(text), default)); - Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); - } - - // ─── (8) ML готов и уверен: spam → отсев spam_ml + mlDecisions ───────── - - [Fact] - public async Task Pump_MlReadyAndSpam_RejectsSpamMlAndTracksMlDecision() - { - Context ctx = CreateContext(); - const string text = "Инвестируй в наш фонд и получай пассивный доход каждый день без риска"; - ctx.MlClient.Predict = new MlPredictResultDto( - Take: true, - Label: "spam", - Scores: new Dictionary { ["spam"] = 0.9 }, - Hits: 4, - Ready: true, - Margin: 0.55, - Terms: new[] { "заработок" }, - Type: null); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("spam_ml", rejected.Stage); - Assert.Equal("ml", rejected.Source); - Assert.Equal("ML уверен, что это спам/не заявка (score 0.90)", rejected.Reason); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(ctx.KanjStore.CardDtos); - Assert.Equal(1, result.MlDrop); - Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); // ИИ не тратим: сообщение отсеяно на ML-слоте - Assert.Empty(ctx.MlClient.Pushed); // ML-отсев не учит (обучение «спаму» — только на решениях ИИ, L1122–1138) - Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.MlDecisions)); // mlStored+mlDrop (Ruling 5) - Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); - } - - // ─── (9) ML готов: доска без правил → карточка в доску без обучения ───── - - [Fact] - public async Task Pump_MlReadyBoard_StoresCardIntoBoardWithoutLearning() - { - Context ctx = CreateContext(); - ctx.KanjStore.SeedBoard(new ContainerDto { Id = "b_py" }); // без правил и не suggested — ML может назначить - const string text = "Вакансия: разработчик на python в команду, удалённая работа над ботом"; - ctx.MlClient.Predict = new MlPredictResultDto( - Take: true, - Label: "b_py", - Scores: new Dictionary { ["b_py"] = 0.8 }, - Hits: 2, - Ready: true, - Margin: 0.6, - Terms: new[] { "python" }, - Type: null); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - // ML назначил колонку (не-suggested, без активных правил) — карточка в доску с локальными полями. - CardDto card = Assert.Single(ctx.KanjStore.CardDtos); - Assert.Equal("b_py", card.Col); - Assert.Contains("python", card.Stack); // узнанный термин ML доложен в стек (L997–1011) - Assert.Equal(1, result.MlStored); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(ctx.PipelineStore.Rejected); - Assert.Empty(ctx.MlClient.Pushed); // ML-путь не учит (L1017–1021) - Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.MlDecisions)); - } - - // ─── (10) ML уверен в типе + aiEnabled=false → карточка inbox с типом ─── - - [Fact] - public async Task Pump_MlTypeKnownAndAiDisabled_CreatesInboxCardWithMlType() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); - const string text = "Нужен разработчик для телеграм бота, оплата достойная, подробности в личку"; - ctx.MlClient.Predict = new MlPredictResultDto( - Take: false, // колонку ML не назначил — только тип - Label: null, - Scores: new Dictionary(), - Hits: 0, - Ready: true, - Margin: 0.62, - Terms: Array.Empty(), - Type: new MlTypeDecisionDto(Take: true, Label: "hire", Value: "t:hire", Margin: 0.62)); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - // ИИ выключен, но тип ML знает: карточку создаём сами (inbox, is_vacancy/known от ML, L1043–1061). - CardDto card = Assert.Single(ctx.KanjStore.CardDtos); - Assert.Equal(KanbanColumns.Inbox, card.Col); - Assert.True(card.IsVacancy); - Assert.True(card.IsVacancyKnown); - Assert.Equal(1, result.MlStored); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(ctx.PipelineStore.Rejected); - Assert.Empty(ctx.MlClient.Pushed); // ML-путь не учит - Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.MlDecisions)); - Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); - } - - // ─── (11) force: минует правила/stale/no-budget/ML и создаёт карточку ── - - [Fact] - public async Task Pump_ForceMessage_BypassesRulesStaleBudgetAndMl() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.BudgetRequiredHire, Json(true)); - // Всё «против» сообщения: стоп-фраза, msgAt старше срока до автоархива, нет суммы, ML готов и видит спам. - const string text = "Ищу работу, вот моё резюме — взаимный пиар не предлагать, вакансия разработчика в команду"; - ctx.MlClient.Predict = new MlPredictResultDto( - Take: true, - Label: "spam", - Scores: new Dictionary { ["spam"] = 0.95 }, - Hits: 3, - Ready: true, - Margin: 0.7, - Terms: Array.Empty(), - Type: null); - ctx.AiClassifier.ClassifyResult = Parsed("Разработчик в команду", isVacancy: true); - long oldMsgAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - (40 * DayMs); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text, msgAtMs: oldMsgAt, force: true)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - // Возврат из отсева: пользователь подтвердил релевантность — правила/устарело/ML не пересматриваем - // (Ruling 2/8 L963–965/L1097–1100), no-budget для force пропущен (L1143) — карточка создана. - CardDto card = Assert.Single(ctx.KanjStore.CardDtos); - Assert.Equal(KanbanColumns.Inbox, card.Col); - Assert.Equal(1, result.AiStored); - Assert.Empty(ctx.PipelineStore.Rejected); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Equal(0, ctx.MlClient.PredictCalls); // force идёт мимо ML к ИИ - Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); - } - - // ─── (12a) ИИ-слот: доска под ContainerAccepts-страховку → inbox ─────────── - - [Fact] - public async Task Pump_AiBoardNotAcceptedByRules_FallsBackToInbox() - { - Context ctx = CreateContext(); - // Колонка с активными правилами «nestjs»: текст про python её не проходит — страховка L449–450. - ctx.KanjStore.SeedBoard(new ContainerDto - { - Id = "b_js", - Rules = new ContainerRulesDto("any", Array.Empty(), new[] { "nestjs" }, Array.Empty(), Array.Empty(), Array.Empty(), null), - }); - const string text = "Вакансия: python-разработчик в команду, удалённая работа, оплата 2000$"; - ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", isVacancy: true, board: "b_js"); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - await ctx.Worker.PumpOnceAsync(default); - - // ИИ/ML не кладут в отфильтрованную колонку: ContainerAccepts не прошёл → «Неразобранное». - CardDto card = Assert.Single(ctx.KanjStore.CardDtos); - Assert.Equal(KanbanColumns.Inbox, card.Col); - // Колонку (inbox) не учим, но успешная классификация подтвердила тип — t:hire учится (L1175–1180). - Assert.True(card.IsVacancyKnown); - Assert.Equal((text, "t:hire", 0.4), Assert.Single(ctx.MlClient.Pushed)); - } - - // ─── (12b) ИИ-слот: is_spam → отсев spam_ai + Push(spam, 0.4) ────────── - - [Fact] - public async Task Pump_AiClassifiesSpam_RejectsSpamAiAndTrainsSpamLabel() - { - Context ctx = CreateContext(); - const string text = "Заработок на крипте 300 процентов в месяц, подпишись на канал за бесплатным курсом"; - ctx.AiClassifier.ClassifyResult = Parsed("Заработок на крипте", isSpam: true); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("spam_ai", rejected.Stage); - Assert.Equal("ai", rejected.Source); - Assert.Equal("ИИ: не заявка — спам, реклама, скам или служебное сообщение", rejected.Reason); - Assert.Empty(ctx.PipelineStore.Queue); - Assert.Empty(ctx.KanjStore.CardDtos); - Assert.Equal(1, result.AiDrop); - // Гипотеза ИИ «спам» учит ML с весом AI_WEIGHT=0.4 (ml_client.push, L1135). - Assert.Contains((text, "spam", 0.4), ctx.MlClient.Pushed); - Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); - } - - // ─── (12c) ИИ-слот: сбой классификатора → локальный разбор (aiFail) ───── - - [Fact] - public async Task Pump_AiClassifierFails_LocalParseFillsCardAndCountsAiFail() - { - Context ctx = CreateContext(); - ctx.AiClassifier.ClassifyThrows = true; // «ИИ недоступен/сбой» (python L1112–1114) - const string text = "Вакансия: разработчик на C# в команду, удалённая работа, оплата 2000$ в месяц"; - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - // Фильтр прошёл, классификация упала — карточку собирает локальный разбор (L1139–1142). - CardDto card = Assert.Single(ctx.KanjStore.CardDtos); - Assert.Equal(KanbanColumns.Inbox, card.Col); - Assert.Equal(1, result.AiFail); - Assert.Equal(1, result.AiStored); - Assert.Empty(ctx.PipelineStore.Rejected); - Assert.Empty(ctx.PipelineStore.Queue); - } - - // ─── (12d) ИИ-слот: фильтр заблокировал → отсев filter_ai + Push(spam) ── - - [Fact] - public async Task Pump_AiFilterBlocks_RejectsFilterAiWithoutClassification() - { - Context ctx = CreateContext(); - ctx.AiClassifier.FilterResult = new AiFilterResultDto(Pass: false, Reason: "реклама и скам", Skipped: false); - const string text = "Забери бесплатный курс по трейдингу по ссылке внизу поста, доход гарантирован"; - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); - Assert.Equal("filter_ai", rejected.Stage); - Assert.Equal("ai", rejected.Source); - Assert.Equal("ИИ-фильтр: реклама и скам", rejected.Reason); - Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); // заблокировано фильтром — классификацию не звали (L1108) - Assert.Equal(1, result.AiDrop); - Assert.Contains((text, "spam", 0.4), ctx.MlClient.Pushed); - } - - // ─── (13) Сбой записи карточки → исключение наружу, строка с claim жива ── - - [Fact] - public async Task Pump_CardWriteFails_ExceptionPropagatesRowAndClaimRemain() - { - Context ctx = CreateContext(); - ctx.KanjStore.FailAddCard = true; // сбой адаптера (например, БД тенанта недоступна) - const string text = "Вакансия: разработчик в команду, удалённая работа, оплата 2000$ в месяц"; - ctx.AiClassifier.ClassifyResult = Parsed("Разработчик", isVacancy: true); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - // Как в прототипе: сбой создания карточки роняет проход (обработчик фонового цикла логирует и - // продолжит следующим тиком) — сообщение не отсеивается и не теряется. - await Assert.ThrowsAsync(() => ctx.Worker.PumpOnceAsync(default)); - - QueueItemDto row = Assert.Single(ctx.PipelineStore.Queue); - Assert.Equal(PipelineQueueStatuses.Filtered, row.Status); // прошла «new»-проход, ИИ-шаг не завершился - Assert.True(await ctx.PipelineStore.ExistsAsync(DedupHasher.Hash(text), default)); // claim на месте - Assert.Empty(ctx.PipelineStore.Rejected); - Assert.Empty(ctx.KanjStore.CardDtos); - } - - // ─── (14) force отменяет вердикт «спам» ИИ ────────────────────────────── - - [Fact] - public async Task Pump_ForceCancelsAiSpamVerdict_CreatesCardWithoutSpamTraining() - { - Context ctx = CreateContext(); - const string text = "Разработчик на python для парсера, оплата 2000$, подробности в личку"; - ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", isVacancy: true, isSpam: true); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text, force: true)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - // Пользователь вернул сообщение из отсева: вердикт «спам» отменяется (L1117–1121) — карточка создаётся - // БЕЗ обучения «спаму» (push "spam" отсутствует); тип ИИ уже разложил (стемп L1108–1111) — учим t:hire. - CardDto card = Assert.Single(ctx.KanjStore.CardDtos); - Assert.True(card.IsVacancyKnown); - Assert.Empty(ctx.PipelineStore.Rejected); - Assert.Equal(1, result.AiStored); - Assert.Equal(0, result.AiDrop); - Assert.Equal((text, "t:hire", 0.4), Assert.Single(ctx.MlClient.Pushed)); - } - - // ─── (15) Позитивное обучение из AI-карточки: свободная доска + тип подтверждён ── - - [Fact] - public async Task Pump_AiCardIntoFreeBoard_TrainsBoardAndTypeWithAiWeight() - { - Context ctx = CreateContext(); - // Свободная доска (без правил, не suggested): и колонку, и тип ИИ-карточки учим ML (L1155–1180). - ctx.KanjStore.SeedBoard(new ContainerDto { Id = "b_py" }); - const string text = "Вакансия: python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; - // Классификатор сам вернул is_vacancy_known=false (детерминированный LocalAiClassifier) — воркер на - // ИИ-пути подтверждает тип после успешной классификации (python L1108–1111) и учит ML. - ctx.AiClassifier.ClassifyResult = Parsed( - "Python-разработчик", budget: new AiBudgetDto(2000, 2000, "USD"), isVacancy: true, board: "b_py"); - ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - - PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - - // Доска без правил приняла текст (ContainerAccepts) — карточка в колонке, тип подтверждён (стемп воркера). - CardDto card = Assert.Single(ctx.KanjStore.CardDtos); - Assert.Equal("b_py", card.Col); - Assert.True(card.IsVacancyKnown); - Assert.Equal(1, result.AiStored); - // Оба обучающих сигнала ИИ-карточки (вес AI_WEIGHT=0.4): колонка и тип (порядок как L1172/L1176–1180). - Assert.Equal( - new[] { (text, "b_py", 0.4), (text, "t:hire", 0.4) }, - ctx.MlClient.Pushed); - Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); - } -} +using System.Text.Json; +using Deal.Contracts.Integrations.Models; +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.Tests.Unit.Contracts; +using Deal.Tests.Unit.Modules.Cards; +using Deal.Tests.Unit.Modules.Kanban; +using Deal.Tests.Unit.Modules.Settings; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты воркера pump — PipelineWorkerService (план Task 8 L423–431, Ruling 8; прототип _pump_unlocked L920–1183). +/// +/// +/// Референс — путь сообщения 1:1: очередь → стоп-фразы → дедуп → ML → ИИ → карточка/отсев (план Rulings). +/// Кейсы Acceptance Task 8: (1) короткое → отсев length; (2) стоп-фраза → отсев stop с kw; (3) резюме → отсев +/// resume; (4) повтор по тексту → отсев dup; (5) stale (msgAt старше срока) → отсев БЕЗ карточки; (6) вакансия → +/// карточка inbox с is_vacancy_known=true (стемп успешной классификации L1108–1111) + aiDecisions + CreatedCards; +/// (7) no-budget (budgetRequiredHire) → отсев budget + claim снят; +/// (8) ML spam → spam_ml + mlDecisions; (9) ML доска → карточка в доску без обучения; (10) ML тип + aiEnabled=false +/// → карточка inbox с типом ML; (11) force минует правила/stale/no-budget/ML; (12) ИИ-слот: доска под ContainerAccepts- +/// страховку / is_spam → spam_ai + push / сбой классификатора → локальный разбор (aiFail); (13) сбой записи карточки +/// (исключение пробрасывается, строка с claim остаётся); (14) force отменяет вердикт «спам» ИИ (тип подтверждён → +/// учим t:hire); (15) ИИ-карточка в свободную доску → обучение колонки и типа (0.4), хотя классификатор вернул +/// known=false (стемп воркера). ML-ветки гоняются на fake-клиенте с ready:true +/// (); неготовая модель (дефолт сценариев) — «не уверен» → ИИ-ветка (Ruling 5). +/// +public sealed class PipelineWorkerServiceTests +{ + // ─── Контекст и хелперы ───────────────────────────────────────────────── + + // Сутки в миллисекундах (для msgAt в прошлом — stale, Ruling 8). + private const long DayMs = 86_400_000; + + // Контекст теста: воркер поверх in-memory фейков хранилищ/портов. + private sealed record Context( + PipelineWorkerService Worker, + FakePipelineStore PipelineStore, + FakeKanjStore KanjStore, + FakeSettingsStore Settings, + FakeMlClient MlClient, + FakeAiClassifier AiClassifier); + + // Собирает контекст: дефолты настроек/досок, ML «не готов» (как LocalMlClient), ИИ-фильтр пропускает. + // pipelineStore: Хранилище очереди (по умолчанию — обычный фейк; сценарий гонки claim'а — RacingClaimPipelineStore). + // Возвращает: Воркер и фейки (сценарий до-настраивает строки очереди/ответы портов). + private static Context CreateContext(FakePipelineStore? pipelineStore = null) + { + var settings = new FakeSettingsStore(); + var store = pipelineStore ?? new FakePipelineStore(); + var kanjStore = new FakeKanjStore(); + var mlClient = new FakeMlClient { Predict = NotReadyPrediction() }; + var aiClassifier = new FakeAiClassifier(); + var rules = new IncomingRules(settings); + var fieldsParser = new LocalFieldsParser(settings); + var processing = new PipelineProcessingService(store, mlClient, new PipelineIngestService(store)); + 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); + return new Context(worker, store, kanjStore, settings, mlClient, aiClassifier); + } + + // Хранилище сценария гонки claim'а (два параллельных pump): наша ExistsAsync-проверка ещё не + // видит заявку, но ClaimAsync (INSERT … ON CONFLICT DO NOTHING в адаптере) возвращает false — хэш успел + // заявить другой проход. DeleteClaimAsync записывается — проверяем, что чужая заявка НЕ снимается. + // claimResult: Результат ClaimAsync (по умолчанию false — «победил другой проход»). + private sealed class RacingClaimPipelineStore(bool claimResult = false) : FakePipelineStore + { + /// + /// Сколько раз вызван ClaimAsync (проверка: конфликт обрабатывается, а не молча пропускается). + /// + public int ClaimCalls { get; private set; } + + /// + /// Сколько раз вызван DeleteClaimAsync (должен остаться 0 — заявка не наша). + /// + public int DeleteClaimCalls { get; private set; } + + /// + public override Task ExistsAsync(string hash, CancellationToken ct) + { + return Task.FromResult(false); // параллельный проход ещё не вставил строку на момент проверки + } + + /// + public override Task ClaimAsync(string hash, CancellationToken ct) + { + ClaimCalls++; + return Task.FromResult(claimResult); // ON CONFLICT DO NOTHING: вставил другой проход → false + } + + /// + public override Task DeleteClaimAsync(string hash, CancellationToken ct) + { + DeleteClaimCalls++; + return Task.CompletedTask; + } + } + + // Сериализует значение настройки в JSON-строку строки settings (как пишет SettingsStore). + // value: Значение (число/булево/строка/список). + // Возвращает: JSON для Preload. + private static string Json(object value) => JsonSerializer.Serialize(value); + + // Строка очереди (status new; сценарий перекрывает текст/время/флаги). + // id: Id строки (p_…). + // text: Текст сообщения. + // msgAtMs: Время исходного сообщения (дефолт — сейчас: не устарело). + // force: Флаг «возвращено из отсева». + // Возвращает: Строка как если бы была сохранена в БД. + private static QueueItemDto QueueRow( + string id, + string text, + long? msgAtMs = null, + bool force = false) => new() + { + Id = id, + DialogId = "d_1", + MsgId = 100, + Text = text, + Status = PipelineQueueStatuses.New, + Channel = new PipelineChannelDto("Канал А", "channel_a", "#333"), + MsgAtMs = msgAtMs ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + QueuedAtMs = 1_700_000_000_000, + Force = force, + }; + + // Неготовая модель: «не уверена» (LocalMlClient ready:false → predict, Ruling 5). + // Возвращает: Фиксированный «не готов/не уверен». + private static MlPredictResultDto NotReadyPrediction() => new( + Take: false, + Label: null, + Scores: new Dictionary(), + Hits: 0, + Ready: false, + Margin: null, + Terms: Array.Empty(), + Type: null); + + // Минимальный разбор карточки (сценарий перекрывает нужные поля; карточку домыслит CardComposer). + // title: Заголовок. + // budget: Бюджет разбора (null — суммы в разборе нет). + // isVacancy: Признак найма. + // isVacancyKnown: Тип подтверждён (ИИ/ML). + // isSpam: Вердикт «не заявка». + // board: Назначенная колонка (пройдёт ContainerAccepts-страховку). + // Возвращает: Разбор классификатора. + private static AiParsedCardDto Parsed( + string title, + AiBudgetDto? budget = null, + bool isVacancy = false, + bool isVacancyKnown = false, + bool isSpam = 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, + board); + + // ─── (1) Короткое сообщение → отсев length ───────────────────────────── + + [Fact] + public async Task Pump_ShortText_RejectsLengthAndRemovesRow() + { + Context ctx = CreateContext(); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", "Короткий текст")); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("length", rejected.Stage); + Assert.Equal("stop", rejected.Source); + Assert.Equal("короче 24 символов", rejected.Reason); + Assert.Empty(rejected.Kw); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(result.CreatedCards); + Assert.Equal(0, result.Staged); // отсев правилами не доходит до дедупа/ML (как в прототипе L933–939) + } + + // ─── (2) Стоп-фраза → отсев stop с kw ────────────────────────────────── + + [Fact] + public async Task Pump_StopPhraseHit_RejectsStopWithPhraseAsKw() + { + Context ctx = CreateContext(); + ctx.PipelineStore.SeedQueue(QueueRow( + "p_1", + "Отличный сервис! Взаимный пиар в чатах и каналах — продвижение без бюджета и вложений")); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("stop", rejected.Stage); + Assert.Equal("stop", rejected.Source); + Assert.Equal("взаимный пиар", rejected.Kw); + Assert.Equal("стоп-фраза «взаимный пиар»", rejected.Reason); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(result.CreatedCards); + } + + // ─── (2a) Глобальное исключение по слову → отсев до ML/ИИ (§5.14) ────── + + [Fact] + public async Task Pump_GlobalExclusionKeyword_RejectsBeforeMlAndAi() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.ExcludeKeywords, Json(new[] { "крипт" })); + ctx.PipelineStore.SeedQueue(QueueRow( + "p_1", + "Продам крипто-курс: пассивный доход, пишите в личку")); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("exclude_kw", rejected.Stage); + Assert.Equal("stop", rejected.Source); + Assert.Equal("крипт", rejected.Kw); + Assert.Equal("глобальное исключение: слово/технология «крипт»", rejected.Reason); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(result.CreatedCards); + Assert.Equal(0, result.Staged); // исключение срабатывает до ML/ИИ — токены не тратятся + Assert.Equal(0, ctx.MlClient.PredictCalls); + } + + [Fact] + public async Task Pump_GlobalExclusionBudget_RejectsWithinRange() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.ExcludeBudgetFrom, Json(1000)); + ctx.Settings.Preload(SettingsKeys.ExcludeBudgetTo, Json(5000)); + ctx.PipelineStore.SeedQueue(QueueRow( + "p_1", + "Ищу специалиста на разовую задачу, бюджет 3000$ за проект")); + + await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("exclude_budget", rejected.Stage); + Assert.Equal("stop", rejected.Source); + Assert.Equal(0, ctx.MlClient.PredictCalls); + } + + // ─── (3) Резюме соискателя → отсев resume ────────────────────────────── + + [Fact] + public async Task Pump_ResumeMarkerHit_RejectsResume() + { + Context ctx = CreateContext(); + // Стоп-фразы по умолчанию перекрываем пустыми — проверяем именно resume-ветку (как IncomingRulesTests). + ctx.Settings.Preload(SettingsKeys.StopPhrases, Json(Array.Empty())); + ctx.PipelineStore.SeedQueue(QueueRow( + "p_1", + "Алексей, 5 лет коммерческой разработки на C#, готов к собеседованию на позицию разработчика")); + + await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("resume", rejected.Stage); + Assert.Equal("stop", rejected.Source); + Assert.Equal("готов к собеседованию", rejected.Kw); + Assert.Equal("резюме соискателя («готов к собеседованию»)", rejected.Reason); + Assert.Empty(ctx.PipelineStore.Queue); + } + + // ─── (4) Повтор: тот же текст второй раз → отсев dup ─────────────────── + + [Fact] + public async Task Pump_SameTextTwice_SecondMessageRejectedAsDuplicate() + { + Context ctx = CreateContext(); + const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; + ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", isVacancy: true); + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text, msgAtMs: nowMs)); + ctx.PipelineStore.SeedQueue(QueueRow("p_2", text, msgAtMs: nowMs + 100)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + // Первое сообщение ушло на ИИ и дало карточку; второе — «повтор» (хэш уже в системе, L940–947). + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("dup", rejected.Stage); + Assert.Equal("dup", rejected.Source); + Assert.Equal("сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается", rejected.Reason); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Equal(1, result.Staged); + Assert.Single(result.CreatedCards); + Assert.Equal(1, result.AiStored); + Assert.Single(ctx.KanjStore.CardDtos); + } + + // ─── (4a) Гонка параллельных pump: claim занят другим проходом → без второй карточки ── + + [Fact] + public async Task Pump_ClaimLostToParallelPass_RejectsDuplicateAndDoesNotCreateCard() + { + Context ctx = CreateContext(new RacingClaimPipelineStore()); + const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + // Другой проход pump заявил хэш между нашей ExistsAsync-проверкой и ClaimAsync (INSERT … ON CONFLICT + // DO NOTHING вернул 0 строк): карточку НЕ создаём — отсев dup + снятие строки (Ruling 8). + RacingClaimPipelineStore store = Assert.IsType(ctx.PipelineStore); + Assert.Equal(1, store.ClaimCalls); // конфликт claim'а обработан, а не пропущен молча + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("dup", rejected.Stage); + Assert.Equal("dup", rejected.Source); + Assert.Equal("сообщение уже в системе: карточка создана ранее или этот текст уже обрабатывается", rejected.Reason); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(ctx.KanjStore.CardDtos); // второй карточки нет + Assert.Empty(result.CreatedCards); + Assert.Equal(0, result.Staged); // до ML/ИИ строка не дошла + Assert.Equal(0, ctx.MlClient.PredictCalls); + Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); + // Чужая заявка НЕ снимается (DeleteClaimAsync не зовётся) — её свяжет карточка победителя. + Assert.Equal(0, store.DeleteClaimCalls); + } + + // ─── (5) Устарело → отсев stale без карточки ─────────────────────────── + + [Fact] + public async Task Pump_MessageOlderThanArchiveAfterDays_RejectsStaleWithoutCard() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.ArchiveAfterDays, Json(1)); + long oldMsgAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - (2 * DayMs); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", "Вакансия: разработчик в команду, оплата 2000$", msgAtMs: oldMsgAt)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("stale", rejected.Stage); + Assert.Equal("stale", rejected.Source); + Assert.Equal("сообщение старше 1 дн. (срок до автоархива) — не заводим в систему", rejected.Reason); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(result.CreatedCards); // устаревшие не попадают в систему ни карточкой, ни ML/ИИ + Assert.Empty(ctx.KanjStore.CardDtos); + } + + // ─── (6) Вакансия → карточка inbox (aiStored/aiDecisions/CreatedCards) ── + + [Fact] + public async Task Pump_VacancyMessage_CreatesInboxCardAndTracksAiDecision() + { + Context ctx = CreateContext(); + const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; + ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик в команду", isVacancy: true); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + // Путь 1:1: правила → дедуп (claim) → ML «спит» (не готов) → filtered → ИИ (фильтр пропустил) → карточка. + CardDto card = Assert.Single(ctx.KanjStore.CardDtos); + Assert.Equal(KanbanColumns.Inbox, card.Col); + Assert.True(card.IsNew); + Assert.True(card.IsVacancyKnown); // успешная классификация подтвердила тип (python L1108–1111) + Assert.Same(card, Assert.Single(result.CreatedCards)); // для SSE new_card (Ruling 8/9) + Assert.Equal(1, result.Staged); + Assert.Equal(1, result.AiStored); + Assert.Equal(1, ctx.AiClassifier.FilterCalls); + Assert.Equal(1, ctx.AiClassifier.ClassifyCalls); + Assert.Equal(1, ctx.MlClient.PredictCalls); // ML включён, но не готов — predict дал «не уверен» + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(ctx.PipelineStore.Rejected); + Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); // aiStored+aiDrop (Ruling 5) + Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.MlDecisions)); + // Тип известен (стемп ИИ) — учим ML t:hire весом 0.4 (L1175–1180); карточка в inbox → колонку не учим. + Assert.Equal((text, "t:hire", 0.4), Assert.Single(ctx.MlClient.Pushed)); + } + + // ─── (7) Нет суммы (budgetRequiredHire) → отсев budget + claim снят ───── + + [Fact] + public async Task Pump_NoBudgetForHireWithRequiredFilter_RejectsBudgetAndReleasesClaim() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.BudgetRequiredHire, Json(true)); + const string text = "Вакансия: разработчик в команду, удалённая работа, опыт от двух лет"; + ctx.AiClassifier.ClassifyResult = Parsed("Разработчик в команду", isVacancy: true); // суммы нет + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("budget", rejected.Stage); + Assert.Equal("stop", rejected.Source); + Assert.Equal("включён фильтр «не создавать карточку без суммы» — в тексте не указан бюджет", rejected.Reason); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(ctx.KanjStore.CardDtos); + Assert.Equal(1, result.NoBudget); + Assert.Equal(0, result.AiStored); // отсев фильтром не считается решением ИИ (python L1143–1147) + // Claim снят: после выключения фильтра сообщение можно обработать заново (L1015). + Assert.False(await ctx.PipelineStore.ExistsAsync(DedupHasher.Hash(text), default)); + Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); + } + + // ─── (8) ML готов и уверен: spam → отсев spam_ml + mlDecisions ───────── + + [Fact] + public async Task Pump_MlReadyAndSpam_RejectsSpamMlAndTracksMlDecision() + { + Context ctx = CreateContext(); + const string text = "Инвестируй в наш фонд и получай пассивный доход каждый день без риска"; + ctx.MlClient.Predict = new MlPredictResultDto( + Take: true, + Label: "spam", + Scores: new Dictionary { ["spam"] = 0.9 }, + Hits: 4, + Ready: true, + Margin: 0.55, + Terms: new[] { "заработок" }, + Type: null); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("spam_ml", rejected.Stage); + Assert.Equal("ml", rejected.Source); + Assert.Equal("ML уверен, что это спам/не заявка (score 0.90)", rejected.Reason); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(ctx.KanjStore.CardDtos); + Assert.Equal(1, result.MlDrop); + Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); // ИИ не тратим: сообщение отсеяно на ML-слоте + Assert.Empty(ctx.MlClient.Pushed); // ML-отсев не учит (обучение «спаму» — только на решениях ИИ, L1122–1138) + Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.MlDecisions)); // mlStored+mlDrop (Ruling 5) + Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); + } + + // ─── (9) ML готов: доска без правил → карточка в доску без обучения ───── + + [Fact] + public async Task Pump_MlReadyBoard_StoresCardIntoBoardWithoutLearning() + { + Context ctx = CreateContext(); + ctx.KanjStore.SeedBoard(new ContainerDto { Id = "b_py" }); // без правил и не suggested — ML может назначить + const string text = "Вакансия: разработчик на python в команду, удалённая работа над ботом"; + ctx.MlClient.Predict = new MlPredictResultDto( + Take: true, + Label: "b_py", + Scores: new Dictionary { ["b_py"] = 0.8 }, + Hits: 2, + Ready: true, + Margin: 0.6, + Terms: new[] { "python" }, + Type: null); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + // ML назначил колонку (не-suggested, без активных правил) — карточка в доску с локальными полями. + CardDto card = Assert.Single(ctx.KanjStore.CardDtos); + Assert.Equal("b_py", card.Col); + Assert.Contains("python", card.Stack); // узнанный термин ML доложен в стек (L997–1011) + Assert.Equal(1, result.MlStored); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(ctx.PipelineStore.Rejected); + Assert.Empty(ctx.MlClient.Pushed); // ML-путь не учит (L1017–1021) + Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.MlDecisions)); + } + + // ─── (10) ML уверен в типе + aiEnabled=false → карточка inbox с типом ─── + + [Fact] + public async Task Pump_MlTypeKnownAndAiDisabled_CreatesInboxCardWithMlType() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); + const string text = "Нужен разработчик для телеграм бота, оплата достойная, подробности в личку"; + ctx.MlClient.Predict = new MlPredictResultDto( + Take: false, // колонку ML не назначил — только тип + Label: null, + Scores: new Dictionary(), + Hits: 0, + Ready: true, + Margin: 0.62, + Terms: Array.Empty(), + Type: new MlTypeDecisionDto(Take: true, Label: "hire", Value: "t:hire", Margin: 0.62)); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + // ИИ выключен, но тип ML знает: карточку создаём сами (inbox, is_vacancy/known от ML, L1043–1061). + CardDto card = Assert.Single(ctx.KanjStore.CardDtos); + Assert.Equal(KanbanColumns.Inbox, card.Col); + Assert.True(card.IsVacancy); + Assert.True(card.IsVacancyKnown); + Assert.Equal(1, result.MlStored); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(ctx.PipelineStore.Rejected); + Assert.Empty(ctx.MlClient.Pushed); // ML-путь не учит + Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.MlDecisions)); + Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); + } + + // ─── (11) force: минует правила/stale/no-budget/ML и создаёт карточку ── + + [Fact] + public async Task Pump_ForceMessage_BypassesRulesStaleBudgetAndMl() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.BudgetRequiredHire, Json(true)); + // Всё «против» сообщения: стоп-фраза, msgAt старше срока до автоархива, нет суммы, ML готов и видит спам. + const string text = "Ищу работу, вот моё резюме — взаимный пиар не предлагать, вакансия разработчика в команду"; + ctx.MlClient.Predict = new MlPredictResultDto( + Take: true, + Label: "spam", + Scores: new Dictionary { ["spam"] = 0.95 }, + Hits: 3, + Ready: true, + Margin: 0.7, + Terms: Array.Empty(), + Type: null); + ctx.AiClassifier.ClassifyResult = Parsed("Разработчик в команду", isVacancy: true); + long oldMsgAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - (40 * DayMs); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text, msgAtMs: oldMsgAt, force: true)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + // Возврат из отсева: пользователь подтвердил релевантность — правила/устарело/ML не пересматриваем + // (Ruling 2/8 L963–965/L1097–1100), no-budget для force пропущен (L1143) — карточка создана. + CardDto card = Assert.Single(ctx.KanjStore.CardDtos); + Assert.Equal(KanbanColumns.Inbox, card.Col); + Assert.Equal(1, result.AiStored); + Assert.Empty(ctx.PipelineStore.Rejected); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Equal(0, ctx.MlClient.PredictCalls); // force идёт мимо ML к ИИ + Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); + } + + // ─── (12a) ИИ-слот: доска под ContainerAccepts-страховку → inbox ─────────── + + [Fact] + public async Task Pump_AiBoardNotAcceptedByRules_FallsBackToInbox() + { + Context ctx = CreateContext(); + // Колонка с активными правилами «nestjs»: текст про python её не проходит — страховка L449–450. + ctx.KanjStore.SeedBoard(new ContainerDto + { + Id = "b_js", + Rules = new ContainerRulesDto("any", Array.Empty(), new[] { "nestjs" }, Array.Empty(), Array.Empty(), Array.Empty(), null), + }); + const string text = "Вакансия: python-разработчик в команду, удалённая работа, оплата 2000$"; + ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", isVacancy: true, board: "b_js"); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + await ctx.Worker.PumpOnceAsync(default); + + // ИИ/ML не кладут в отфильтрованную колонку: ContainerAccepts не прошёл → «Неразобранное». + CardDto card = Assert.Single(ctx.KanjStore.CardDtos); + Assert.Equal(KanbanColumns.Inbox, card.Col); + // Колонку (inbox) не учим, но успешная классификация подтвердила тип — t:hire учится (L1175–1180). + Assert.True(card.IsVacancyKnown); + Assert.Equal((text, "t:hire", 0.4), Assert.Single(ctx.MlClient.Pushed)); + } + + // ─── (12b) ИИ-слот: is_spam → отсев spam_ai + Push(spam, 0.4) ────────── + + [Fact] + public async Task Pump_AiClassifiesSpam_RejectsSpamAiAndTrainsSpamLabel() + { + Context ctx = CreateContext(); + const string text = "Заработок на крипте 300 процентов в месяц, подпишись на канал за бесплатным курсом"; + ctx.AiClassifier.ClassifyResult = Parsed("Заработок на крипте", isSpam: true); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("spam_ai", rejected.Stage); + Assert.Equal("ai", rejected.Source); + Assert.Equal("ИИ: не заявка — спам, реклама, скам или служебное сообщение", rejected.Reason); + Assert.Empty(ctx.PipelineStore.Queue); + Assert.Empty(ctx.KanjStore.CardDtos); + Assert.Equal(1, result.AiDrop); + // Гипотеза ИИ «спам» учит ML с весом AI_WEIGHT=0.4 (ml_client.push, L1135). + Assert.Contains((text, "spam", 0.4), ctx.MlClient.Pushed); + Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); + } + + // ─── (12c) ИИ-слот: сбой классификатора → локальный разбор (aiFail) ───── + + [Fact] + public async Task Pump_AiClassifierFails_LocalParseFillsCardAndCountsAiFail() + { + Context ctx = CreateContext(); + ctx.AiClassifier.ClassifyThrows = true; // «ИИ недоступен/сбой» (python L1112–1114) + const string text = "Вакансия: разработчик на C# в команду, удалённая работа, оплата 2000$ в месяц"; + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + // Фильтр прошёл, классификация упала — карточку собирает локальный разбор (L1139–1142). + CardDto card = Assert.Single(ctx.KanjStore.CardDtos); + Assert.Equal(KanbanColumns.Inbox, card.Col); + Assert.Equal(1, result.AiFail); + Assert.Equal(1, result.AiStored); + Assert.Empty(ctx.PipelineStore.Rejected); + Assert.Empty(ctx.PipelineStore.Queue); + } + + // ─── (12d) ИИ-слот: фильтр заблокировал → отсев filter_ai + Push(spam) ── + + [Fact] + public async Task Pump_AiFilterBlocks_RejectsFilterAiWithoutClassification() + { + Context ctx = CreateContext(); + ctx.AiClassifier.FilterResult = new AiFilterResultDto(Pass: false, Reason: "реклама и скам", Skipped: false); + const string text = "Забери бесплатный курс по трейдингу по ссылке внизу поста, доход гарантирован"; + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); + Assert.Equal("filter_ai", rejected.Stage); + Assert.Equal("ai", rejected.Source); + Assert.Equal("ИИ-фильтр: реклама и скам", rejected.Reason); + Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); // заблокировано фильтром — классификацию не звали (L1108) + Assert.Equal(1, result.AiDrop); + Assert.Contains((text, "spam", 0.4), ctx.MlClient.Pushed); + } + + // ─── (13) Сбой записи карточки → исключение наружу, строка с claim жива ── + + [Fact] + public async Task Pump_CardWriteFails_ExceptionPropagatesRowAndClaimRemain() + { + Context ctx = CreateContext(); + ctx.KanjStore.FailAddCard = true; // сбой адаптера (например, БД тенанта недоступна) + const string text = "Вакансия: разработчик в команду, удалённая работа, оплата 2000$ в месяц"; + ctx.AiClassifier.ClassifyResult = Parsed("Разработчик", isVacancy: true); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + // Как в прототипе: сбой создания карточки роняет проход (обработчик фонового цикла логирует и + // продолжит следующим тиком) — сообщение не отсеивается и не теряется. + await Assert.ThrowsAsync(() => ctx.Worker.PumpOnceAsync(default)); + + QueueItemDto row = Assert.Single(ctx.PipelineStore.Queue); + Assert.Equal(PipelineQueueStatuses.Filtered, row.Status); // прошла «new»-проход, ИИ-шаг не завершился + Assert.True(await ctx.PipelineStore.ExistsAsync(DedupHasher.Hash(text), default)); // claim на месте + Assert.Empty(ctx.PipelineStore.Rejected); + Assert.Empty(ctx.KanjStore.CardDtos); + } + + // ─── (14) force отменяет вердикт «спам» ИИ ────────────────────────────── + + [Fact] + public async Task Pump_ForceCancelsAiSpamVerdict_CreatesCardWithoutSpamTraining() + { + Context ctx = CreateContext(); + const string text = "Разработчик на python для парсера, оплата 2000$, подробности в личку"; + ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", isVacancy: true, isSpam: true); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text, force: true)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + // Пользователь вернул сообщение из отсева: вердикт «спам» отменяется (L1117–1121) — карточка создаётся + // БЕЗ обучения «спаму» (push "spam" отсутствует); тип ИИ уже разложил (стемп L1108–1111) — учим t:hire. + CardDto card = Assert.Single(ctx.KanjStore.CardDtos); + Assert.True(card.IsVacancyKnown); + Assert.Empty(ctx.PipelineStore.Rejected); + Assert.Equal(1, result.AiStored); + Assert.Equal(0, result.AiDrop); + Assert.Equal((text, "t:hire", 0.4), Assert.Single(ctx.MlClient.Pushed)); + } + + // ─── (15) Позитивное обучение из AI-карточки: свободная доска + тип подтверждён ── + + [Fact] + public async Task Pump_AiCardIntoFreeBoard_TrainsBoardAndTypeWithAiWeight() + { + Context ctx = CreateContext(); + // Свободная доска (без правил, не suggested): и колонку, и тип ИИ-карточки учим ML (L1155–1180). + ctx.KanjStore.SeedBoard(new ContainerDto { Id = "b_py" }); + const string text = "Вакансия: python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; + // Классификатор сам вернул is_vacancy_known=false (детерминированный LocalAiClassifier) — воркер на + // ИИ-пути подтверждает тип после успешной классификации (python L1108–1111) и учит ML. + ctx.AiClassifier.ClassifyResult = Parsed( + "Python-разработчик", budget: new AiBudgetDto(2000, 2000, "USD"), isVacancy: true, board: "b_py"); + ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); + + PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); + + // Доска без правил приняла текст (ContainerAccepts) — карточка в колонке, тип подтверждён (стемп воркера). + CardDto card = Assert.Single(ctx.KanjStore.CardDtos); + Assert.Equal("b_py", card.Col); + Assert.True(card.IsVacancyKnown); + Assert.Equal(1, result.AiStored); + // Оба обучающих сигнала ИИ-карточки (вес AI_WEIGHT=0.4): колонка и тип (порядок как L1172/L1176–1180). + Assert.Equal( + new[] { (text, "b_py", 0.4), (text, "t:hire", 0.4) }, + ctx.MlClient.Pushed); + Assert.Equal("1", ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/RateLimitHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/RateLimitHttpTests.cs index eb2988a..6518618 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/RateLimitHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/RateLimitHttpTests.cs @@ -2,19 +2,14 @@ using System.Net; using System.Text.Json; using Deal.Api.Configuration; using Deal.Api.Extensions; -using Deal.Api.Models; -using Deal.Api.Services; using Deal.Api.Middleware; +using Deal.Api.Models; using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -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.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -using Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Support; diff --git a/src/core/tests/Deal.Tests.Unit/Support/RatesServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/RatesServiceTests.cs index 4a06ba6..9e5dc59 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/RatesServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/RatesServiceTests.cs @@ -1,7 +1,6 @@ using System.Text.Json; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; using Deal.Modules.Settings.Application.Services; using Deal.Tests.Unit.Modules.Settings; diff --git a/src/core/tests/Deal.Tests.Unit/Support/ServiceHealthProbeTests.cs b/src/core/tests/Deal.Tests.Unit/Support/ServiceHealthProbeTests.cs index 2ccb925..280801b 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/ServiceHealthProbeTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/ServiceHealthProbeTests.cs @@ -1,9 +1,5 @@ using System.Net; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; diff --git a/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs index 63c889c..22b1499 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs @@ -1,7 +1,6 @@ using System.Text.Json; using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; using Deal.Modules.Settings.Application.Services; using Deal.Tests.Unit.Modules.Settings; diff --git a/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs index 296489e..e2ff852 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs @@ -1,544 +1,535 @@ -using System.Text.Json; -using Deal.Api.Events; -using Deal.Api.Hosting; -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.Extensions; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Kanban.Application.Registrars; -using Deal.Modules.Kanban.Application.Services; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.Models; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Deal.Tests.Unit.Modules.Tenants; -using Deal.Tests.Unit.Modules.Cards; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Contracts; - -namespace Deal.Tests.Unit.Support; - -/// -/// Тесты StorageTickScheduler — логика прохода цикла правил хранения (план Task 11, Ruling 8; -/// аналог _storage_loop main.py L43–53): обход всех тенантов реестра, на каждый — собственный scope с -/// ITenantContext, тик StorageTickService + автоочистка отсева пайплайна (3 суток, Task 11), SSE-тосты -/// статистики (включая «Отсев очищен») в канал тенанта и проверка наступивших напоминаний «Отложено» -/// (CheckDueAsync + SSE reminder_due, план Task 12, Ruling 3/8 — фоновый аналог ветки AdminTickOrchestrator). -/// -/// -/// Тайминги цикла (Timer 30 с, первый проход, stop) не тестируются — тестируется итерация через -/// публичный . Скоупы/DI поднимаются на реальном -/// ServiceCollection с фейками: ITenantRepository — FakeTenantRepository; ICardStore/ISettingsStore — выбираются -/// по текущему ITenantContext (как реальные адаптеры, строящие TenantDbContext от схемы тенанта), -/// StorageTickService/CardsService — реальные на фейках. -/// Публикации проверяются реальным SseBroker с подпиской канала -/// (как в тестах тостов). Возраст карточек задаётся с запасом к дефолтным срокам SettingsDefaults -/// (autoArchive=true, archiveAfterDays=14) — как в StorageTickServiceTests. -/// -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); - - // Автоочистка отсева в фоновом тике (Task 11, Ruling 8/9; tick_storage L485–493): запись старше - // 3 суток удалена безвозвратно, свежая пережила; тост «Отсев очищен» — только в канал тенанта A - // (у B счётчик purge = 0 — тоста нет, как notify_tick_stats L503–504). - 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() - { - // Тенант A: hold-карточка с напоминанием в прошлом («выстреливает») и в будущем + не-hold с прошлым - // (не «выстреливают», как check_reminders L270–275); тенант B — без due (событий в его канал нет). - 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); - - // SSE reminder_due {id,title,containerId} по «выстрелившему» (Ruling 8; toast НЕ публикуется — счётчики - // тика нулевые), в канал только тенанта A (у B due нет). - (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() - { - // Выключенная настройка (Ruling 3): протухшие напоминания только очищаются, «выстрелов»/событий нет - // (check_reminders L266–269) — фон ведёт себя как ручная ветка AdminTickOrchestrator. - 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)]); - // Модуль Pipeline для автоочистки отсева в фоновом тике (Task 11): PurgeExpiredAsync ходит только в - // IPipelineStore; ML-клиент не готов (как в проде этапа 4) — обработка его при purge не зовёт. - services.AddSingleton(new FakeMlClient()); - services.AddSingleton(new FakeFileStorage()); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - // Проверка напоминаний фонового тика (Task 12) — реальный CardsService на ICardStore/FakeSettingsStore - // тенанта (как в AdminTickOrchestratorTests); сбой ветки имитируется подклассом FakeKanjStore с - // падающим ListDueRemindersAsync. - 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(); - - // Карточка hold с напоминанием (как строка Cards после маппинга адаптера). - // id: Id карточки (c_...). - // title: Заголовок (ушёл в SSE reminder_due). - // reminderAtMs: Время напоминания, epoch-ms (прошлое — «выстрелит» на проходе). - // stage: Контейнер карточки (по умолчанию hold — её напоминания проверяет тик, Ruling 3). - // Возвращает: Карточка как DTO хранилища. - 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 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 — логика прохода цикла правил хранения (план Task 11, Ruling 8; +/// аналог _storage_loop main.py L43–53): обход всех тенантов реестра, на каждый — собственный scope с +/// ITenantContext, тик StorageTickService + автоочистка отсева пайплайна (3 суток, Task 11), SSE-тосты +/// статистики (включая «Отсев очищен») в канал тенанта и проверка наступивших напоминаний «Отложено» +/// (CheckDueAsync + SSE reminder_due, план Task 12, Ruling 3/8 — фоновый аналог ветки AdminTickOrchestrator). +/// +/// +/// Тайминги цикла (Timer 30 с, первый проход, stop) не тестируются — тестируется итерация через +/// публичный . Скоупы/DI поднимаются на реальном +/// ServiceCollection с фейками: ITenantRepository — FakeTenantRepository; ICardStore/ISettingsStore — выбираются +/// по текущему ITenantContext (как реальные адаптеры, строящие TenantDbContext от схемы тенанта), +/// StorageTickService/CardsService — реальные на фейках. +/// Публикации проверяются реальным SseBroker с подпиской канала +/// (как в тестах тостов). Возраст карточек задаётся с запасом к дефолтным срокам SettingsDefaults +/// (autoArchive=true, archiveAfterDays=14) — как в StorageTickServiceTests. +/// +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); + + // Автоочистка отсева в фоновом тике (Task 11, Ruling 8/9; tick_storage L485–493): запись старше + // 3 суток удалена безвозвратно, свежая пережила; тост «Отсев очищен» — только в канал тенанта A + // (у B счётчик purge = 0 — тоста нет, как notify_tick_stats L503–504). + 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() + { + // Тенант A: hold-карточка с напоминанием в прошлом («выстреливает») и в будущем + не-hold с прошлым + // (не «выстреливают», как check_reminders L270–275); тенант B — без due (событий в его канал нет). + 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); + + // SSE reminder_due {id,title,containerId} по «выстрелившему» (Ruling 8; toast НЕ публикуется — счётчики + // тика нулевые), в канал только тенанта A (у B due нет). + (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() + { + // Выключенная настройка (Ruling 3): протухшие напоминания только очищаются, «выстрелов»/событий нет + // (check_reminders L266–269) — фон ведёт себя как ручная ветка AdminTickOrchestrator. + 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)]); + // Модуль Pipeline для автоочистки отсева в фоновом тике (Task 11): PurgeExpiredAsync ходит только в + // IPipelineStore; ML-клиент не готов (как в проде этапа 4) — обработка его при purge не зовёт. + services.AddSingleton(new FakeMlClient()); + services.AddSingleton(new FakeFileStorage()); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // Проверка напоминаний фонового тика (Task 12) — реальный CardsService на ICardStore/FakeSettingsStore + // тенанта (как в AdminTickOrchestratorTests); сбой ветки имитируется подклассом FakeKanjStore с + // падающим ListDueRemindersAsync. + 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(); + + // Карточка hold с напоминанием (как строка Cards после маппинга адаптера). + // id: Id карточки (c_...). + // title: Заголовок (ушёл в SSE reminder_due). + // reminderAtMs: Время напоминания, epoch-ms (прошлое — «выстрелит» на проходе). + // stage: Контейнер карточки (по умолчанию hold — её напоминания проверяет тик, Ruling 3). + // Возвращает: Карточка как DTO хранилища. + 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 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/TelegramGrpcTestHost.cs b/src/core/tests/Deal.Tests.Unit/Support/TelegramGrpcTestHost.cs index 9ac431f..525f3f8 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TelegramGrpcTestHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TelegramGrpcTestHost.cs @@ -1,10 +1,9 @@ using System.Net; -using Grpc.Net.Client; +using Deal.Tests.Unit.Grpc; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection; -using Deal.Tests.Unit.Grpc; namespace Deal.Tests.Unit.Support; diff --git a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs index cf4df5c..0af96eb 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs @@ -1,501 +1,494 @@ -using System.Text.Json; -using Deal.Api.Events; -using Deal.Grpc.Telegram; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; -using Deal.Modules.Pipeline.Application.Services; -using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; -using Grpc.Core; -using Grpc.Net.Client; -using Microsoft.Extensions.DependencyInjection; -using Deal.Tests.Unit.Modules.Tenants; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Modules.Kanban; -using Deal.Tests.Unit.Contracts; - -namespace Deal.Tests.Unit.Support; - -/// -/// In-proc gRPC-тесты входящего потока telegram-service → ядро (план Task 12, L361–377; Ruling 1/7). -/// -/// Хост Deal.Api-ингресса (Kestrel HTTP/2, эфемерный порт) поднимается в процессе теста через -/// — те же регистрации, что в Program.cs (AddGrpc + -/// IngressServiceTokenInterceptor + TelegramIngressService), но tenant-адаптеры заменены фейками -/// (реестр FakeTenantRegistry, FakePipelineStore/FakeSettingsStore — сквозная проверка без Telegram/БД). -/// Сценарии: PushMessage кладёт строку очереди тенанта (приём/дубль dialog+msgId/неизвестный тенант → -/// not-accepted без падения RPC) + превью (модуль Telegram, Task 13: строка TgMessages и «последнее -/// сообщение» каталога); интерцептор service-token и metadata tenant-id (UNAUTHENTICATED); -/// SyncDialogs применяет каталог и отвечает актуальным списком monitored id (авто-мониторинг новых — -/// по настройке autoMonitorNew); ReportStatus пишет KV tgStatus/tgAccount и публикует SSE -/// system_status/тосты на переходах connected. -/// -public sealed class TelegramIngressServiceTests -{ - // Токен сценариев теста. - private const string ValidToken = TelegramIngressTestHost.DefaultToken; - - // Тенант A сценариев (в реестре). - private static readonly Guid TenantA = Guid.NewGuid(); - - // Id диалога сценариев PushMessage. - private const string DialogId = "d_channel_100"; - - // ─── PushMessage: приём, дубль, несуществующий тенант ────────────────── - - /// - /// PushMessage кладёт строку очереди тенанта (эмуляция входящего сообщения — сквозная проверка без - /// Telegram, план Task 12): accepted=true, строка в очереди фейка с канальными полями и msgId. - /// - [Fact] - public async Task PushMessage_ValidTenant_EnqueuesQueueRowAndAccepts() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync( - registry, - services => services.AddScoped(_ => store), - async channel => - { - PushMessageReply reply = await PushAsync(channel, Request("Вакансия Python-разработчика", msgId: 7), TenantA, ValidToken); - - Assert.True(reply.Accepted); - Assert.False(reply.Duplicate); - - QueueItemDto row = Assert.Single(store.Queue); - Assert.Equal(DialogId, row.DialogId); - Assert.Equal(7, row.MsgId); - Assert.Equal("Вакансия Python-разработчика", row.Text); - Assert.Equal("Канал", row.Channel.Name); - Assert.Equal("kanal_handle", row.Channel.Handle); - Assert.Equal("#a33", row.Channel.Hue); - }); - } - - /// - /// Повтор PushMessage того же dialogId+msgId — duplicate=true, очередь не растёт (гвард EnqueueAsync). - /// - [Fact] - public async Task PushMessage_SameDialogAndMsgIdTwice_SecondIsDuplicateAndQueueNotGrown() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync( - registry, - services => services.AddScoped(_ => store), - async channel => - { - PushMessageReply first = await PushAsync(channel, Request("Сообщение канала", msgId: 42), TenantA, ValidToken); - PushMessageReply second = await PushAsync(channel, Request("Сообщение канала", msgId: 42), TenantA, ValidToken); - - Assert.True(first.Accepted); - Assert.True(second.Accepted); - Assert.True(second.Duplicate); - Assert.Single(store.Queue); - }); - } - - /// - /// PushMessage для несуществующего тенанта не падает (нет записи в реестре/схемы → ошибка ловится, - /// reply not-accepted, план Task 12): RPC завершается штатно, очередь не растёт, исключения нет. - /// - [Fact] - public async Task PushMessage_UnknownTenant_NotAcceptedWithoutRpcError() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync( - registry, - services => services.AddScoped(_ => store), - async channel => - { - PushMessageReply reply = await PushAsync(channel, Request("Сообщение чужого тенанта"), Guid.NewGuid(), ValidToken); - - Assert.False(reply.Accepted); - Assert.False(reply.Duplicate); - Assert.Empty(store.Queue); - }); - } - - // ─── Интерцептор service-token и metadata tenant-id ───────────────────── - - /// - /// Запрос без metadata «service-token» → UNAUTHENTICATED (Ruling 1). - /// - [Fact] - public async Task PushMessage_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("текст"), TenantA, null)); - Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); - }); - } - - /// - /// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1). - /// - [Fact] - public async Task PushMessage_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("текст"), TenantA, "wrong-token")); - Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); - }); - } - - /// - /// Fail-closed: DEAL_SERVICE_TOKEN не задан — RPC ингресса отклоняется даже с «каким-то» токеном. - /// - [Fact] - public async Task PushMessage_UnsetEnvToken_FailsClosed() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await TelegramIngressTestHost.RunAsync( - serviceToken: null, - configureServices: services => - { - services.AddSingleton(registry); - services.AddScoped(_ => new FakeSettingsStore()); - services.AddScoped(_ => store); - }, - scenario: async channel => - { - RpcException exception = await Assert.ThrowsAsync( - () => PushAsync(channel, Request("текст"), TenantA, ValidToken)); - Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); - }); - } - - /// - /// Metadata tenant-id отсутствует → UNAUTHENTICATED (README src/contracts: tenant-id обязателен). - /// - [Fact] - public async Task PushMessage_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("текст"), null, ValidToken)); - Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); - }); - } - - // ─── SyncDialogs и ReportStatus ───────────────────────────────────────── - - /// - /// SyncDialogs применяет каталог тенанта к таблице Dialogs и отвечает актуальным списком monitored id - /// (план Task 13, Ruling 7): по умолчанию autoMonitorNew=true — новые диалоги каталога появляются - /// включёнными, ответ содержит их id (зеркало telegram-service обновится по reply). - /// - [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 добавляет новые диалоги отключёнными — зеркало мониторинга пусто - /// (Ruling 7): ответ monitored_ids пуст, каталог применён (строки Dialogs есть). - /// - [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 (false→true «подключён», true→false «отключён») — как сервис шлёт - /// статус heartbeat'ом, без гарда переходов тосты дублировались бы (Ruling 7). - /// - [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 не тронут (план Task 12). - /// - [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"; - - // Текст тоста подключения (зеркало TelegramIngressService, Ruling 7). - private const string ConnectedToastText = "Telegram подключён, сессия сохранена"; - - // Текст тоста отключения (зеркало TelegramIngressService, Ruling 7). - 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 адаптеры сценария - // (IPipelineStore/ISettingsStore/брокер). - // scenario: Сценарий с gRPC-каналом. - private static Task RunAsync( - FakeTenantRegistry registry, - Action registerTenantServices, - Func scenario) - => TelegramIngressTestHost.RunAsync( - ValidToken, - services => - { - services.AddSingleton(registry); - registerTenantServices(services); - }, - scenario); - - // Вызывает PushMessage с metadata сценария (deadline 10 с — контракт README). - // channel: Канал к хосту. - // request: Запрос PushMessage. - // tenantId: Id тенанта в metadata (null — без заголовка tenant-id). - // tokenHeader: Значение metadata «service-token» (null — без заголовка). - private static Task PushAsync( - GrpcChannel channel, - PushMessageRequest request, - Guid? tenantId, - string? tokenHeader) - { - var client = new IngressService.IngressServiceClient(channel); - AsyncUnaryCall call = client.PushMessageAsync( - request, - new CallOptions(TelegramIngressTestHost.CallMetadata(tokenHeader, tenantId), deadline: Deadline())); - return call.ResponseAsync; - } - - // Вызывает 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; - } - - // Запрос PushMessage сценария (канальные поля фиксированы). - // text: Текст сообщения. - // msgId: Id сообщения в Telegram (null — без msg_id). - private static PushMessageRequest Request(string text, long? msgId = null) - { - var request = new PushMessageRequest - { - DialogId = DialogId, - ChannelName = "Канал", - ChannelHandle = "kanal_handle", - ChannelHue = "#a33", - Text = text, - }; - if (msgId is not null) - { - request.MsgId = msgId.Value; - } - - return request; - } - - // 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.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +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.Kanban; +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 → ядро (план Task 12, L361–377; Ruling 1/7). +/// +/// Хост Deal.Api-ингресса (Kestrel HTTP/2, эфемерный порт) поднимается в процессе теста через +/// — те же регистрации, что в Program.cs (AddGrpc + +/// IngressServiceTokenInterceptor + TelegramIngressService), но tenant-адаптеры заменены фейками +/// (реестр FakeTenantRegistry, FakePipelineStore/FakeSettingsStore — сквозная проверка без Telegram/БД). +/// Сценарии: PushMessage кладёт строку очереди тенанта (приём/дубль dialog+msgId/неизвестный тенант → +/// not-accepted без падения RPC) + превью (модуль Telegram, Task 13: строка TgMessages и «последнее +/// сообщение» каталога); интерцептор service-token и metadata tenant-id (UNAUTHENTICATED); +/// SyncDialogs применяет каталог и отвечает актуальным списком monitored id (авто-мониторинг новых — +/// по настройке autoMonitorNew); ReportStatus пишет KV tgStatus/tgAccount и публикует SSE +/// system_status/тосты на переходах connected. +/// +public sealed class TelegramIngressServiceTests +{ + // Токен сценариев теста. + private const string ValidToken = TelegramIngressTestHost.DefaultToken; + + // Тенант A сценариев (в реестре). + private static readonly Guid TenantA = Guid.NewGuid(); + + // Id диалога сценариев PushMessage. + private const string DialogId = "d_channel_100"; + + // ─── PushMessage: приём, дубль, несуществующий тенант ────────────────── + + /// + /// PushMessage кладёт строку очереди тенанта (эмуляция входящего сообщения — сквозная проверка без + /// Telegram, план Task 12): accepted=true, строка в очереди фейка с канальными полями и msgId. + /// + [Fact] + public async Task PushMessage_ValidTenant_EnqueuesQueueRowAndAccepts() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync( + registry, + services => services.AddScoped(_ => store), + async channel => + { + PushMessageReply reply = await PushAsync(channel, Request("Вакансия Python-разработчика", msgId: 7), TenantA, ValidToken); + + Assert.True(reply.Accepted); + Assert.False(reply.Duplicate); + + QueueItemDto row = Assert.Single(store.Queue); + Assert.Equal(DialogId, row.DialogId); + Assert.Equal(7, row.MsgId); + Assert.Equal("Вакансия Python-разработчика", row.Text); + Assert.Equal("Канал", row.Channel.Name); + Assert.Equal("kanal_handle", row.Channel.Handle); + Assert.Equal("#a33", row.Channel.Hue); + }); + } + + /// + /// Повтор PushMessage того же dialogId+msgId — duplicate=true, очередь не растёт (гвард EnqueueAsync). + /// + [Fact] + public async Task PushMessage_SameDialogAndMsgIdTwice_SecondIsDuplicateAndQueueNotGrown() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync( + registry, + services => services.AddScoped(_ => store), + async channel => + { + PushMessageReply first = await PushAsync(channel, Request("Сообщение канала", msgId: 42), TenantA, ValidToken); + PushMessageReply second = await PushAsync(channel, Request("Сообщение канала", msgId: 42), TenantA, ValidToken); + + Assert.True(first.Accepted); + Assert.True(second.Accepted); + Assert.True(second.Duplicate); + Assert.Single(store.Queue); + }); + } + + /// + /// PushMessage для несуществующего тенанта не падает (нет записи в реестре/схемы → ошибка ловится, + /// reply not-accepted, план Task 12): RPC завершается штатно, очередь не растёт, исключения нет. + /// + [Fact] + public async Task PushMessage_UnknownTenant_NotAcceptedWithoutRpcError() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync( + registry, + services => services.AddScoped(_ => store), + async channel => + { + PushMessageReply reply = await PushAsync(channel, Request("Сообщение чужого тенанта"), Guid.NewGuid(), ValidToken); + + Assert.False(reply.Accepted); + Assert.False(reply.Duplicate); + Assert.Empty(store.Queue); + }); + } + + // ─── Интерцептор service-token и metadata tenant-id ───────────────────── + + /// + /// Запрос без metadata «service-token» → UNAUTHENTICATED (Ruling 1). + /// + [Fact] + public async Task PushMessage_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("текст"), TenantA, null)); + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + }); + } + + /// + /// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1). + /// + [Fact] + public async Task PushMessage_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("текст"), TenantA, "wrong-token")); + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + }); + } + + /// + /// Fail-closed: DEAL_SERVICE_TOKEN не задан — RPC ингресса отклоняется даже с «каким-то» токеном. + /// + [Fact] + public async Task PushMessage_UnsetEnvToken_FailsClosed() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await TelegramIngressTestHost.RunAsync( + serviceToken: null, + configureServices: services => + { + services.AddSingleton(registry); + services.AddScoped(_ => new FakeSettingsStore()); + services.AddScoped(_ => store); + }, + scenario: async channel => + { + RpcException exception = await Assert.ThrowsAsync( + () => PushAsync(channel, Request("текст"), TenantA, ValidToken)); + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + }); + } + + /// + /// Metadata tenant-id отсутствует → UNAUTHENTICATED (README src/contracts: tenant-id обязателен). + /// + [Fact] + public async Task PushMessage_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("текст"), null, ValidToken)); + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + }); + } + + // ─── SyncDialogs и ReportStatus ───────────────────────────────────────── + + /// + /// SyncDialogs применяет каталог тенанта к таблице Dialogs и отвечает актуальным списком monitored id + /// (план Task 13, Ruling 7): по умолчанию autoMonitorNew=true — новые диалоги каталога появляются + /// включёнными, ответ содержит их id (зеркало telegram-service обновится по reply). + /// + [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 добавляет новые диалоги отключёнными — зеркало мониторинга пусто + /// (Ruling 7): ответ monitored_ids пуст, каталог применён (строки Dialogs есть). + /// + [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 (false→true «подключён», true→false «отключён») — как сервис шлёт + /// статус heartbeat'ом, без гарда переходов тосты дублировались бы (Ruling 7). + /// + [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 не тронут (план Task 12). + /// + [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"; + + // Текст тоста подключения (зеркало TelegramIngressService, Ruling 7). + private const string ConnectedToastText = "Telegram подключён, сессия сохранена"; + + // Текст тоста отключения (зеркало TelegramIngressService, Ruling 7). + 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 адаптеры сценария + // (IPipelineStore/ISettingsStore/брокер). + // scenario: Сценарий с gRPC-каналом. + private static Task RunAsync( + FakeTenantRegistry registry, + Action registerTenantServices, + Func scenario) + => TelegramIngressTestHost.RunAsync( + ValidToken, + services => + { + services.AddSingleton(registry); + registerTenantServices(services); + }, + scenario); + + // Вызывает PushMessage с metadata сценария (deadline 10 с — контракт README). + // channel: Канал к хосту. + // request: Запрос PushMessage. + // tenantId: Id тенанта в metadata (null — без заголовка tenant-id). + // tokenHeader: Значение metadata «service-token» (null — без заголовка). + private static Task PushAsync( + GrpcChannel channel, + PushMessageRequest request, + Guid? tenantId, + string? tokenHeader) + { + var client = new IngressService.IngressServiceClient(channel); + AsyncUnaryCall call = client.PushMessageAsync( + request, + new CallOptions(TelegramIngressTestHost.CallMetadata(tokenHeader, tenantId), deadline: Deadline())); + return call.ResponseAsync; + } + + // Вызывает 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; + } + + // Запрос PushMessage сценария (канальные поля фиксированы). + // text: Текст сообщения. + // msgId: Id сообщения в Telegram (null — без msg_id). + private static PushMessageRequest Request(string text, long? msgId = null) + { + var request = new PushMessageRequest + { + DialogId = DialogId, + ChannelName = "Канал", + ChannelHandle = "kanal_handle", + ChannelHue = "#a33", + Text = text, + }; + if (msgId is not null) + { + request.MsgId = msgId.Value; + } + + return request; + } + + // 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 f852c4f..ab91e8c 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs @@ -1,30 +1,17 @@ using System.Net; -using Deal.Api; -using Deal.Api.Dtos; -using Deal.Api.Services; using Deal.Api.Configuration; using Deal.Api.Events; using Deal.Api.Telegram; using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Telegram; using Deal.Infrastructure.Data; -using Deal.Modules.Pipeline.Application.Abstractions; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Registrars; using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Settings.Application.Abstractions; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; using Deal.Modules.Telegram.Application; using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; -using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; -using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants.Abstractions; -using Deal.SharedKernel.Tenants.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.AspNetCore.Builder; @@ -32,9 +19,6 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; -using Deal.Tests.Unit.Modules.Tenants; -using Deal.Tests.Unit.Modules.Settings; -using Deal.Tests.Unit.Contracts; namespace Deal.Tests.Unit.Support; diff --git a/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs b/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs index 4b94c20..92cd010 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs @@ -1,22 +1,10 @@ using System.Text.Json; using Deal.Grpc.Ai; using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations.Abstractions; -using Deal.Infrastructure.Integrations.Exceptions; -using Deal.Infrastructure.Integrations.Extensions; -using Deal.Infrastructure.Integrations.Models; -using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Services; -using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Settings.Application.Registrars; -using Deal.Modules.Settings.Application.Services; -using Deal.Modules.Tenants.Application.Abstractions; -using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; -using Deal.Modules.Tenants.Application.Registrars; using Deal.Modules.Tenants.Application.Services; -using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Models; using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Tenants; diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/DealMetricsHosting.cs b/src/grpc-hosting/Deal.Grpc.Hosting/DealMetricsHosting.cs index 5bce344..9ff85e5 100644 --- a/src/grpc-hosting/Deal.Grpc.Hosting/DealMetricsHosting.cs +++ b/src/grpc-hosting/Deal.Grpc.Hosting/DealMetricsHosting.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection; -using OpenTelemetry; using OpenTelemetry.Metrics; namespace Deal.Grpc.Hosting; diff --git a/src/ml-service/Deal.Ml.Tests/AssemblyInfo.cs b/src/ml-service/Deal.Ml.Tests/AssemblyInfo.cs index e8e56c6..ec64fbf 100644 --- a/src/ml-service/Deal.Ml.Tests/AssemblyInfo.cs +++ b/src/ml-service/Deal.Ml.Tests/AssemblyInfo.cs @@ -1,5 +1,3 @@ -using Xunit; - // Интеграционные тесты ml-service поднимают реальные Kestrel-хосты и меняют процесс-глобальные // env-переменные (DEAL_SERVICE_TOKEN/DEAL_ML_DATA_DIR) на время сценария (MlTestHost и // MlServiceHostTests). Параллельный прогон классов дал бы гонки на env — тесты сериализованы diff --git a/src/ml-service/Deal.Ml.Tests/Grpc/MlRpcTests.cs b/src/ml-service/Deal.Ml.Tests/Grpc/MlRpcTests.cs index 4877414..5b99a89 100644 --- a/src/ml-service/Deal.Ml.Tests/Grpc/MlRpcTests.cs +++ b/src/ml-service/Deal.Ml.Tests/Grpc/MlRpcTests.cs @@ -1,284 +1,283 @@ -using Deal.Grpc.Ml; -using Grpc.Core; -using Grpc.Net.Client; -using Deal.Ml.Tests.Ml; - -namespace Deal.Ml.Tests.Grpc; - -/// -/// In-proc gRPC-тесты MlService (план Task 6, Acceptance): Predict/Status/Reset/TrainBatch через -/// реальный хост (Kestrel HTTP/2, интерцептор service-token) с tenant-id из metadata; модель -/// тенанта создаётся лениво (Predict неготовой модели — «не уверен», не ошибка). Без сети. -/// -public sealed class MlRpcTests -{ - /// - /// Свежий тенант: Status — пустой ответ, Predict — фиксированный «не уверен» 1:1. - /// - [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; модель остаётся неготовой (ниже MIN_TOTAL). - /// - [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 обнуляет модель тенанта: Status снова пуст, Predict «не уверен». - /// - [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); - }); - } - - /// - /// Изоляция тенантов: обучение одного не влияет на модель другого (пул per-tenant). - /// - [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 (Ruling 1; шаблон T5). - /// - [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 в батче пропускаются: learned = применённые, ответ не падает. - /// - [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); - }); - } - - /// - /// Серверный лимит батча (ml.proto: ≤100 примеров): 101 → INVALID_ARGUMENT до обучения. - /// - [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); - }); - } - - /// - /// Серверный лимит длины текста примера (source_msg ≤4000): превышение → INVALID_ARGUMENT. - /// - [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 (план Task 6, Acceptance): Predict/Status/Reset/TrainBatch через +/// реальный хост (Kestrel HTTP/2, интерцептор service-token) с tenant-id из metadata; модель +/// тенанта создаётся лениво (Predict неготовой модели — «не уверен», не ошибка). Без сети. +/// +public sealed class MlRpcTests +{ + /// + /// Свежий тенант: Status — пустой ответ, Predict — фиксированный «не уверен» 1:1. + /// + [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; модель остаётся неготовой (ниже MIN_TOTAL). + /// + [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 обнуляет модель тенанта: Status снова пуст, Predict «не уверен». + /// + [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); + }); + } + + /// + /// Изоляция тенантов: обучение одного не влияет на модель другого (пул per-tenant). + /// + [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 (Ruling 1; шаблон T5). + /// + [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 в батче пропускаются: learned = применённые, ответ не падает. + /// + [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); + }); + } + + /// + /// Серверный лимит батча (ml.proto: ≤100 примеров): 101 → INVALID_ARGUMENT до обучения. + /// + [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); + }); + } + + /// + /// Серверный лимит длины текста примера (source_msg ≤4000): превышение → INVALID_ARGUMENT. + /// + [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 46c8024..fa4e050 100644 --- a/src/ml-service/Deal.Ml.Tests/Grpc/MlServiceHostTests.cs +++ b/src/ml-service/Deal.Ml.Tests/Grpc/MlServiceHostTests.cs @@ -1,247 +1,245 @@ -using Deal.Grpc.Ml; -using Deal.Ml; -using Microsoft.AspNetCore.Builder; -using Grpc.Core; -using Grpc.Health.V1; -using Grpc.Net.Client; -using System.Net; -using System.Net.Sockets; -using Xunit; - -namespace Deal.Ml.Tests.Grpc; - -/// -/// Интеграционные тесты хоста ml-service (каркас план Task 3 + Status задач 5–6). -/// -/// Хост поднимается В процессе теста (Kestrel HTTP/2, эфемерный порт) через MlServiceHost.Create — -/// ту же сборку хоста, что использует Program.cs, поэтому тесты покрывают реальную настройку -/// Kestrel/AddGrpc/health, а не её копию. Проверки: gRPC-health → SERVING; ServiceTokenInterceptor -/// (Ruling 1): запрос без токена и с неверным токеном → UNAUTHENTICATED; верный токен проходит к -/// реализованному Status (свежая модель → ready=false); при незаданном DEAL_SERVICE_TOKEN — fail-closed. -/// -/// Токен интерцептор читает из конфигурации (env DEAL_SERVICE_TOKEN), Status создаёт модель тенанта — -/// каталог моделей (DEAL_ML_DATA_DIR) на время сценария направляется во временную папку; тесты -/// выставляют env и восстанавливают исходные значения. Все тесты класса живут в одном процессе/классе, -/// чтобы env и свободные порты не конфликтовали (xunit исполняет методы класса последовательно). -/// -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-сервис работают (Ruling 12; health освобождён от service-token). - /// - [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 (Ruling 1). - /// - [Fact] - public async Task Status_WithoutToken_IsUnauthenticated() - { - await AssertDealRpcRejectedAsync( - ValidToken, - tokenHeader: null, - expected: StatusCode.Unauthenticated); - } - - /// - /// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1). - /// - [Fact] - public async Task Status_WithWrongToken_IsUnauthenticated() - { - await AssertDealRpcRejectedAsync( - ValidToken, - tokenHeader: "wrong-token", - expected: StatusCode.Unauthenticated); - } - - /// - /// Верный токен проходит интерцептор к методу Status (задачи 5–6): кодогенерация и маппинг - /// сервиса работают, свежая модель тенанта отвечает готовым ответом (ready=false), а не UNIMPLEMENTED. - /// - [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 (замечание ревью Task 2): DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, - /// в т.ч. запрос с «пустым» значением metadata (без гарда сравнение «» == «» пропустило бы его); - /// health при этом продолжает отвечать SERVING (инфраструктурный liveness не ломается). - /// - [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 (каркас план Task 3 + Status задач 5–6). +/// +/// Хост поднимается В процессе теста (Kestrel HTTP/2, эфемерный порт) через MlServiceHost.Create — +/// ту же сборку хоста, что использует Program.cs, поэтому тесты покрывают реальную настройку +/// Kestrel/AddGrpc/health, а не её копию. Проверки: gRPC-health → SERVING; ServiceTokenInterceptor +/// (Ruling 1): запрос без токена и с неверным токеном → UNAUTHENTICATED; верный токен проходит к +/// реализованному Status (свежая модель → ready=false); при незаданном DEAL_SERVICE_TOKEN — fail-closed. +/// +/// Токен интерцептор читает из конфигурации (env DEAL_SERVICE_TOKEN), Status создаёт модель тенанта — +/// каталог моделей (DEAL_ML_DATA_DIR) на время сценария направляется во временную папку; тесты +/// выставляют env и восстанавливают исходные значения. Все тесты класса живут в одном процессе/классе, +/// чтобы env и свободные порты не конфликтовали (xunit исполняет методы класса последовательно). +/// +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-сервис работают (Ruling 12; health освобождён от service-token). + /// + [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 (Ruling 1). + /// + [Fact] + public async Task Status_WithoutToken_IsUnauthenticated() + { + await AssertDealRpcRejectedAsync( + ValidToken, + tokenHeader: null, + expected: StatusCode.Unauthenticated); + } + + /// + /// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1). + /// + [Fact] + public async Task Status_WithWrongToken_IsUnauthenticated() + { + await AssertDealRpcRejectedAsync( + ValidToken, + tokenHeader: "wrong-token", + expected: StatusCode.Unauthenticated); + } + + /// + /// Верный токен проходит интерцептор к методу Status (задачи 5–6): кодогенерация и маппинг + /// сервиса работают, свежая модель тенанта отвечает готовым ответом (ready=false), а не UNIMPLEMENTED. + /// + [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 (замечание ревью Task 2): DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, + /// в т.ч. запрос с «пустым» значением metadata (без гарда сравнение «» == «» пропустило бы его); + /// health при этом продолжает отвечать SERVING (инфраструктурный liveness не ломается). + /// + [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 c4c2a9f..dcc1287 100644 --- a/src/ml-service/Deal.Ml.Tests/Grpc/MlTestHost.cs +++ b/src/ml-service/Deal.Ml.Tests/Grpc/MlTestHost.cs @@ -1,144 +1,141 @@ -using Deal.Grpc.Hosting; -using Deal.Ml; -using Deal.Ml.Model; -using Deal.Ml.Extensions; -using Grpc.Core; -using Grpc.Net.Client; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; -using System.Net; -using System.Net.Sockets; - -namespace Deal.Ml.Tests.Grpc; - -// Общий харнесс RPC-тестов ml-service (план Task 6): поднимает хост (MlServiceHost.Create) в -// процессе теста на эфемерном порту и направляет каталог моделей (DEAL_ML_DATA_DIR) во временную -// папку — файлы data/ml/<tenant>.sqlite тестов не попадают в репозиторий. Исходные значения -// env восстанавливаются; каталог удаляется после сценария (шаблон TelegramTestHost). -internal static class MlTestHost -{ - /// - /// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor). - /// - public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - - /// - /// Env-ключ каталога моделей (зеркало MlOptions.DataDirEnvVarName). - /// - public const string DataDirEnvKey = MlOptions.DataDirEnvVarName; - - /// - /// Ключ gRPC-metadata с service-token (зеркало ServiceTokenInterceptor). - /// - public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey; - - /// - /// Ключ gRPC-metadata с tenant-id (зеркало MlServiceImpl.TenantIdMetadataKey). - /// - 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 (+ tenant-id, если задан). - /// - /// Значение заголовка 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 + deadline (рекомендации README src/contracts L62–73). - /// - /// 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; +using Deal.Ml.Model; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; + +namespace Deal.Ml.Tests.Grpc; + +// Общий харнесс RPC-тестов ml-service (план Task 6): поднимает хост (MlServiceHost.Create) в +// процессе теста на эфемерном порту и направляет каталог моделей (DEAL_ML_DATA_DIR) во временную +// папку — файлы data/ml/<tenant>.sqlite тестов не попадают в репозиторий. Исходные значения +// env восстанавливаются; каталог удаляется после сценария (шаблон TelegramTestHost). +internal static class MlTestHost +{ + /// + /// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor). + /// + public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; + + /// + /// Env-ключ каталога моделей (зеркало MlOptions.DataDirEnvVarName). + /// + public const string DataDirEnvKey = MlOptions.DataDirEnvVarName; + + /// + /// Ключ gRPC-metadata с service-token (зеркало ServiceTokenInterceptor). + /// + public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey; + + /// + /// Ключ gRPC-metadata с tenant-id (зеркало MlServiceImpl.TenantIdMetadataKey). + /// + 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 (+ tenant-id, если задан). + /// + /// Значение заголовка 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 + deadline (рекомендации README src/contracts L62–73). + /// + /// 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 f725b81..37c622d 100644 --- a/src/ml-service/Deal.Ml.Tests/Ml/LearningData.cs +++ b/src/ml-service/Deal.Ml.Tests/Ml/LearningData.cs @@ -1,105 +1,104 @@ -using Deal.Ml.Model; -using Deal.Ml.Extensions; - -namespace Deal.Ml.Tests.Ml; - -// Тексты и батчи обучения для тестов модели/RPC (1:1 сценарии приёмки плана Task 5: -// «нужен middle python…» → колонка + тип, «резюме…» → spam после обучения). Словари классов -// намеренно не пересекаются: колонка/тип разработки (Dev), разовая сделка (Order) и спам — -// разные термины, чтобы предсказания были детерминированы порогами, а не шумом пересечений. -internal static class LearningData -{ - /// - /// Id колонки канбана «разработка/найм» (форма b_… как в ядре). - /// - 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"; - - /// - /// Сообщение-заявка на разработчика (→ колонка b_col_dev + тип hire). - /// - public const string DevMessage = - "нужен middle python разработчик в команду, удаленная работа, стек django postgres, оффер конкурентный"; - - /// - /// Сообщение-заказ сайта/лендинга (→ колонка b_col_order + тип order). - /// - public const string OrderMessage = - "закажу сайт визитку и лендинг для малого бизнеса, недорого, дизайнер и верстка"; - - /// - /// Спам-резюме/рассылка (→ колонка spam; словарь не пересекается с dev/order). - /// - 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); - - /// - /// Канонический батч, доводящий модель до готовности и уверенных предсказаний (28 примеров): - /// колонки 8/6/6 (dev/order/spam), типы t:hire/t:order по 4, после «включения» — 4 реальных - /// действия (delta=1) для журнала самооценки. Баланс 1:1 со сценарием приёмки Task 5. - /// - 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; + +// Тексты и батчи обучения для тестов модели/RPC (1:1 сценарии приёмки плана Task 5: +// «нужен middle python…» → колонка + тип, «резюме…» → spam после обучения). Словари классов +// намеренно не пересекаются: колонка/тип разработки (Dev), разовая сделка (Order) и спам — +// разные термины, чтобы предсказания были детерминированы порогами, а не шумом пересечений. +internal static class LearningData +{ + /// + /// Id колонки канбана «разработка/найм» (форма b_… как в ядре). + /// + 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"; + + /// + /// Сообщение-заявка на разработчика (→ колонка b_col_dev + тип hire). + /// + public const string DevMessage = + "нужен middle python разработчик в команду, удаленная работа, стек django postgres, оффер конкурентный"; + + /// + /// Сообщение-заказ сайта/лендинга (→ колонка b_col_order + тип order). + /// + public const string OrderMessage = + "закажу сайт визитку и лендинг для малого бизнеса, недорого, дизайнер и верстка"; + + /// + /// Спам-резюме/рассылка (→ колонка spam; словарь не пересекается с dev/order). + /// + 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); + + /// + /// Канонический батч, доводящий модель до готовности и уверенных предсказаний (28 примеров): + /// колонки 8/6/6 (dev/order/spam), типы t:hire/t:order по 4, после «включения» — 4 реальных + /// действия (delta=1) для журнала самооценки. Баланс 1:1 со сценарием приёмки Task 5. + /// + 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 1bcd8bc..fc0edef 100644 --- a/src/ml-service/Deal.Ml.Tests/Ml/MlTokenizerTests.cs +++ b/src/ml-service/Deal.Ml.Tests/Ml/MlTokenizerTests.cs @@ -1,78 +1,77 @@ -using Deal.Ml.Model; -using Deal.Ml.Extensions; - -namespace Deal.Ml.Tests.Ml; - -/// -/// Тесты токенизации (план Task 5; 1:1 mlservice/model.py tokenize L78–87): удаление ссылок -/// (включая markdown), lowercase, отбрасывание слов короче 3, «хвостовые» ~prefix-термины для -/// слов длиной ≥ 6, разрешённый алфавит [a-zа-яё0-9@+.#]. -/// -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("а б вг")); - } - - /// - /// Кириллица/ё обрабатываются как в python: lower + префикс первых 4 символов. - /// - [Fact] - public void Tokenize_CyrillicWithYo() - { - string[] tokens = MlTokenizer.Tokenize("Программист Ёжик"); - - Assert.Equal(new[] { "программист", "~прог", "ёжик" }, tokens); - } - - /// - /// Разрешённый алфавит включает @ + . # — email остаётся одним термином (1:1 python). - /// - [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; + +/// +/// Тесты токенизации (план Task 5; 1:1 mlservice/model.py tokenize L78–87): удаление ссылок +/// (включая markdown), lowercase, отбрасывание слов короче 3, «хвостовые» ~prefix-термины для +/// слов длиной ≥ 6, разрешённый алфавит [a-zа-яё0-9@+.#]. +/// +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("а б вг")); + } + + /// + /// Кириллица/ё обрабатываются как в python: lower + префикс первых 4 символов. + /// + [Fact] + public void Tokenize_CyrillicWithYo() + { + string[] tokens = MlTokenizer.Tokenize("Программист Ёжик"); + + Assert.Equal(new[] { "программист", "~прог", "ёжик" }, tokens); + } + + /// + /// Разрешённый алфавит включает @ + . # — email остаётся одним термином (1:1 python). + /// + [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 27358fa..974a137 100644 --- a/src/ml-service/Deal.Ml.Tests/Ml/ModelPersistenceTests.cs +++ b/src/ml-service/Deal.Ml.Tests/Ml/ModelPersistenceTests.cs @@ -1,160 +1,159 @@ -using Deal.Ml.Model; -using Deal.Ml.Extensions; -using Microsoft.Data.Sqlite; - -namespace Deal.Ml.Tests.Ml; - -/// -/// Тесты персистентности модели (план Task 5): веса переживают перезапуск пула (второй инстанс -/// на тот же SQLite-файл), журнал самооценки прунится до EVAL_KEEP=200, статус отдаёт окно -/// EVAL_WINDOW=50 последних решений. -/// -public sealed class ModelPersistenceTests -{ - /// - /// Перезапуск пула сохраняет веса: обучение на первом пуле → закрытие → второй пул на тот же - /// каталог отдаёт те же классы/learned/eval и те же предсказания. - /// - [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); - } - } - - /// - /// Окно самооценки: журнал хранит не больше EVAL_KEEP (200) строк (проверка в файле), статус - /// считает последние EVAL_WINDOW (50) решений. Сценарий — 250 дополнительных «реальных» - /// действий поверх канонического батча. - /// - [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); - } - } - - /// - /// Прунинг переживает перезапуск: reload держит последние EVAL_KEEP и окно 50. - /// - [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; + +/// +/// Тесты персистентности модели (план Task 5): веса переживают перезапуск пула (второй инстанс +/// на тот же SQLite-файл), журнал самооценки прунится до EVAL_KEEP=200, статус отдаёт окно +/// EVAL_WINDOW=50 последних решений. +/// +public sealed class ModelPersistenceTests +{ + /// + /// Перезапуск пула сохраняет веса: обучение на первом пуле → закрытие → второй пул на тот же + /// каталог отдаёт те же классы/learned/eval и те же предсказания. + /// + [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); + } + } + + /// + /// Окно самооценки: журнал хранит не больше EVAL_KEEP (200) строк (проверка в файле), статус + /// считает последние EVAL_WINDOW (50) решений. Сценарий — 250 дополнительных «реальных» + /// действий поверх канонического батча. + /// + [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); + } + } + + /// + /// Прунинг переживает перезапуск: reload держит последние EVAL_KEEP и окно 50. + /// + [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 ea49d21..decb1cf 100644 --- a/src/ml-service/Deal.Ml.Tests/Ml/ModelPoolTests.cs +++ b/src/ml-service/Deal.Ml.Tests/Ml/ModelPoolTests.cs @@ -1,86 +1,85 @@ -using Deal.Ml.Model; -using Deal.Ml.Extensions; - -namespace Deal.Ml.Tests.Ml; - -/// -/// Тесты пула моделей (план Task 5, Ruling 4): ленивое создание по тенанту (один инстанс на -/// тенанта), файл data/ml/<tenantId>.sqlite, защита tenant-id от выхода из каталога. -/// -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 в каталоге данных (Ruling 4). - /// - [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; + +/// +/// Тесты пула моделей (план Task 5, Ruling 4): ленивое создание по тенанту (один инстанс на +/// тенанта), файл data/ml/<tenantId>.sqlite, защита tenant-id от выхода из каталога. +/// +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 в каталоге данных (Ruling 4). + /// + [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 75395a0..d37f3ff 100644 --- a/src/ml-service/Deal.Ml.Tests/Ml/TenantModelLearningTests.cs +++ b/src/ml-service/Deal.Ml.Tests/Ml/TenantModelLearningTests.cs @@ -1,379 +1,378 @@ -using Deal.Ml.Model; -using Deal.Ml.Extensions; - -namespace Deal.Ml.Tests.Ml; - -/// -/// Тесты движка модели (план Task 5): обучение → предсказание спама/колонки/типа на русских -/// примерах, ready-пороги (20/6/4/2), адаптивный margin, delta<0 «разучивание», дробные веса -/// ИИ-сигналов, журнал самооценки. Модель — поверх реального SQLite-файла во временной папке. -/// -public sealed class TenantModelLearningTests -{ - /// - /// Пустая модель: predict — фиксированный «не уверен» 1:1 (take:false, ready:false). - /// - [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 (20), модель не готова и ничего не решает - /// (ready=false, «не уверен») — не может ошибочно удалить заявку как спам. - /// - [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): после добавления t:hire модель - /// «включается» и начинает уверенно решать (приёмка Task 5: «нужен middle python…» → колонка). - /// - [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:-класс — тип не определяется - }); - } - - /// - /// Полный сценарий приёмки Task 5: обучение колонок dev/order + spam + типы t:hire/t:order → - /// предсказания: Dev → колонка b_col_dev + тип hire, Order → b_col_order + тип order, - /// Spam → spam без типа. Параллельно проверяются статус (learned/classes) и журнал самооценки. - /// - [Fact] - public void CanonicalTraining_LearnsColumnsSpamAndTypes() - { - RunWithPool(pool => - { - TenantModel model = pool.GetOrCreate("tenant-canonical"); - List items = LearningData.CanonicalTrainItems(); - - // Батчами, как флашер ядра (Ruling 6): сначала доводим до готовности (24 примера), - // затем «реальные действия» (4) — они и дают строки самооценки. - 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); - - // Минимальный отклик одного термина (паритет с python): weight=6 → score 1.714. - 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: одно совпадение у победителя ещё не «взятие» (take=false, hits=1), двух — уже да. - /// - [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); - }); - } - - /// - /// Незнакомый текст готовой модели: «не уверен», но ready=true и margin пуст. - /// - [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: 0.9 на старте, 0.7 после ≥ 60 суммарных примеров (L42–55). - /// - [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); - }); - } - - /// - /// Дробные веса ИИ-сигналов (delta 0.4): класс копится, но не кратен 1 (гипотезы ИИ). - /// - [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 пропускаются тихо (1:1 _upsert_one L112–114). - /// - [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 очищает модель и пересоздаёт файл при следующем обучении (план Task 6). - /// - [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; + +/// +/// Тесты движка модели (план Task 5): обучение → предсказание спама/колонки/типа на русских +/// примерах, ready-пороги (20/6/4/2), адаптивный margin, delta<0 «разучивание», дробные веса +/// ИИ-сигналов, журнал самооценки. Модель — поверх реального SQLite-файла во временной папке. +/// +public sealed class TenantModelLearningTests +{ + /// + /// Пустая модель: predict — фиксированный «не уверен» 1:1 (take:false, ready:false). + /// + [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 (20), модель не готова и ничего не решает + /// (ready=false, «не уверен») — не может ошибочно удалить заявку как спам. + /// + [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): после добавления t:hire модель + /// «включается» и начинает уверенно решать (приёмка Task 5: «нужен middle python…» → колонка). + /// + [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:-класс — тип не определяется + }); + } + + /// + /// Полный сценарий приёмки Task 5: обучение колонок dev/order + spam + типы t:hire/t:order → + /// предсказания: Dev → колонка b_col_dev + тип hire, Order → b_col_order + тип order, + /// Spam → spam без типа. Параллельно проверяются статус (learned/classes) и журнал самооценки. + /// + [Fact] + public void CanonicalTraining_LearnsColumnsSpamAndTypes() + { + RunWithPool(pool => + { + TenantModel model = pool.GetOrCreate("tenant-canonical"); + List items = LearningData.CanonicalTrainItems(); + + // Батчами, как флашер ядра (Ruling 6): сначала доводим до готовности (24 примера), + // затем «реальные действия» (4) — они и дают строки самооценки. + 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); + + // Минимальный отклик одного термина (паритет с python): weight=6 → score 1.714. + 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: одно совпадение у победителя ещё не «взятие» (take=false, hits=1), двух — уже да. + /// + [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); + }); + } + + /// + /// Незнакомый текст готовой модели: «не уверен», но ready=true и margin пуст. + /// + [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: 0.9 на старте, 0.7 после ≥ 60 суммарных примеров (L42–55). + /// + [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); + }); + } + + /// + /// Дробные веса ИИ-сигналов (delta 0.4): класс копится, но не кратен 1 (гипотезы ИИ). + /// + [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 пропускаются тихо (1:1 _upsert_one L112–114). + /// + [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 очищает модель и пересоздаёт файл при следующем обучении (план Task 6). + /// + [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/Extensions/LabelExtensions.cs b/src/ml-service/Deal.Ml/Extensions/LabelExtensions.cs index ccc9775..ef550e8 100644 --- a/src/ml-service/Deal.Ml/Extensions/LabelExtensions.cs +++ b/src/ml-service/Deal.Ml/Extensions/LabelExtensions.cs @@ -1,5 +1,4 @@ using Deal.Ml.Model; -using Deal.Ml.Extensions; namespace Deal.Ml.Extensions; diff --git a/src/ml-service/Deal.Ml/MlServiceHost.cs b/src/ml-service/Deal.Ml/MlServiceHost.cs index 08adb10..6f71732 100644 --- a/src/ml-service/Deal.Ml/MlServiceHost.cs +++ b/src/ml-service/Deal.Ml/MlServiceHost.cs @@ -1,6 +1,5 @@ using Deal.Grpc.Hosting; using Deal.Ml.Model; -using Deal.Ml.Extensions; namespace Deal.Ml; diff --git a/src/ml-service/Deal.Ml/MlServiceImpl.cs b/src/ml-service/Deal.Ml/MlServiceImpl.cs index 79cf39d..b1b10ae 100644 --- a/src/ml-service/Deal.Ml/MlServiceImpl.cs +++ b/src/ml-service/Deal.Ml/MlServiceImpl.cs @@ -1,6 +1,5 @@ using Deal.Grpc.Ml; using Deal.Ml.Model; -using Deal.Ml.Extensions; using Grpc.Core; namespace Deal.Ml; diff --git a/src/ml-service/Deal.Ml/Model/EvalEntry.cs b/src/ml-service/Deal.Ml/Model/EvalEntry.cs index 587c85e..7333044 100644 --- a/src/ml-service/Deal.Ml/Model/EvalEntry.cs +++ b/src/ml-service/Deal.Ml/Model/EvalEntry.cs @@ -1,5 +1,3 @@ -using Deal.Ml.Extensions; - namespace Deal.Ml.Model; /// diff --git a/src/ml-service/Deal.Ml/Model/LearnItem.cs b/src/ml-service/Deal.Ml/Model/LearnItem.cs index d4995b2..20dfbd1 100644 --- a/src/ml-service/Deal.Ml/Model/LearnItem.cs +++ b/src/ml-service/Deal.Ml/Model/LearnItem.cs @@ -1,5 +1,3 @@ -using Deal.Ml.Extensions; - namespace Deal.Ml.Model; /// diff --git a/src/ml-service/Deal.Ml/Model/MlEvalInfo.cs b/src/ml-service/Deal.Ml/Model/MlEvalInfo.cs index baffcad..48546b0 100644 --- a/src/ml-service/Deal.Ml/Model/MlEvalInfo.cs +++ b/src/ml-service/Deal.Ml/Model/MlEvalInfo.cs @@ -1,5 +1,3 @@ -using Deal.Ml.Extensions; - namespace Deal.Ml.Model; /// diff --git a/src/ml-service/Deal.Ml/Model/MlOptions.cs b/src/ml-service/Deal.Ml/Model/MlOptions.cs index 3166611..1661486 100644 --- a/src/ml-service/Deal.Ml/Model/MlOptions.cs +++ b/src/ml-service/Deal.Ml/Model/MlOptions.cs @@ -1,5 +1,3 @@ -using Deal.Ml.Extensions; - namespace Deal.Ml.Model; /// diff --git a/src/ml-service/Deal.Ml/Model/MlPredictResult.cs b/src/ml-service/Deal.Ml/Model/MlPredictResult.cs index 4f216e1..7bda992 100644 --- a/src/ml-service/Deal.Ml/Model/MlPredictResult.cs +++ b/src/ml-service/Deal.Ml/Model/MlPredictResult.cs @@ -1,5 +1,3 @@ -using Deal.Ml.Extensions; - namespace Deal.Ml.Model; /// diff --git a/src/ml-service/Deal.Ml/Model/MlStatusResult.cs b/src/ml-service/Deal.Ml/Model/MlStatusResult.cs index 62388fa..410386e 100644 --- a/src/ml-service/Deal.Ml/Model/MlStatusResult.cs +++ b/src/ml-service/Deal.Ml/Model/MlStatusResult.cs @@ -1,5 +1,3 @@ -using Deal.Ml.Extensions; - namespace Deal.Ml.Model; /// diff --git a/src/ml-service/Deal.Ml/Model/MlTypeDecision.cs b/src/ml-service/Deal.Ml/Model/MlTypeDecision.cs index c49580b..d3844c3 100644 --- a/src/ml-service/Deal.Ml/Model/MlTypeDecision.cs +++ b/src/ml-service/Deal.Ml/Model/MlTypeDecision.cs @@ -1,5 +1,3 @@ -using Deal.Ml.Extensions; - namespace Deal.Ml.Model; /// diff --git a/src/ml-service/Deal.Ml/Model/ModelConstants.cs b/src/ml-service/Deal.Ml/Model/ModelConstants.cs index eb0938e..c601191 100644 --- a/src/ml-service/Deal.Ml/Model/ModelConstants.cs +++ b/src/ml-service/Deal.Ml/Model/ModelConstants.cs @@ -1,5 +1,3 @@ -using Deal.Ml.Extensions; - namespace Deal.Ml.Model; /// diff --git a/src/ml-service/Deal.Ml/Model/ModelState.cs b/src/ml-service/Deal.Ml/Model/ModelState.cs index 302a3f8..264b0bf 100644 --- a/src/ml-service/Deal.Ml/Model/ModelState.cs +++ b/src/ml-service/Deal.Ml/Model/ModelState.cs @@ -1,5 +1,3 @@ -using Deal.Ml.Extensions; - namespace Deal.Ml.Model; /// diff --git a/src/ml-service/Deal.Ml/Storage/MlDb.cs b/src/ml-service/Deal.Ml/Storage/MlDb.cs index 8bd4cfa..c50490e 100644 --- a/src/ml-service/Deal.Ml/Storage/MlDb.cs +++ b/src/ml-service/Deal.Ml/Storage/MlDb.cs @@ -1,5 +1,4 @@ using Deal.Ml.Model; -using Deal.Ml.Extensions; using Microsoft.Data.Sqlite; namespace Deal.Ml.Storage; diff --git a/src/telegram-service/Deal.Telegram.Tests/AssemblyInfo.cs b/src/telegram-service/Deal.Telegram.Tests/AssemblyInfo.cs index 1a65c30..2ad82d0 100644 --- a/src/telegram-service/Deal.Telegram.Tests/AssemblyInfo.cs +++ b/src/telegram-service/Deal.Telegram.Tests/AssemblyInfo.cs @@ -1,5 +1,3 @@ -using Xunit; - // Интеграционные тесты telegram-service поднимают реальные Kestrel-хосты и меняют процесс-глобальные // env-переменные (DEAL_SERVICE_TOKEN/DEAL_TELEGRAM_SESSION_KEY/DEAL_TELEGRAM_SESSION_DIR) на время // сценария (TelegramTestHost). Параллельный прогон классов дал бы гонки на env — тесты сериализованы. diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/CoreIngressClientTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/CoreIngressClientTests.cs index 154e2a8..55c1693 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/CoreIngressClientTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/CoreIngressClientTests.cs @@ -4,8 +4,6 @@ using Deal.Telegram.Sessions; using Grpc.Core; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Logging.Abstractions; -using Xunit; -using Deal.Telegram.Tests.Support; namespace Deal.Telegram.Tests.Grpc; diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/DialogRpcTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/DialogRpcTests.cs index 20a7c6c..6d8cd8e 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/DialogRpcTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/DialogRpcTests.cs @@ -1,15 +1,12 @@ using Deal.Grpc.Telegram; using Deal.Telegram.Dialogs; using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; +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; -using Xunit; -using Deal.Telegram.Tests.Support; -using Deal.Telegram.Tests.Telegram; namespace Deal.Telegram.Tests.Grpc; diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryProtoMapperTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryProtoMapperTests.cs index 88ffca4..aea4f4f 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryProtoMapperTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryProtoMapperTests.cs @@ -1,10 +1,7 @@ using Deal.Grpc.Telegram; -using Deal.Telegram.Discovery; using Deal.Telegram.Dialogs; +using Deal.Telegram.Discovery; using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; -using Xunit; -using Deal.Telegram.Tests.Support; namespace Deal.Telegram.Tests.Grpc; diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryRpcTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryRpcTests.cs index ef4f998..bb9c4b2 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryRpcTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryRpcTests.cs @@ -1,15 +1,11 @@ using Deal.Grpc.Telegram; using Deal.Telegram.Dialogs; using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; +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; -using Xunit; -using Deal.Telegram.Tests.Support; -using Deal.Telegram.Tests.Telegram; namespace Deal.Telegram.Tests.Grpc; diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/FakeIngressServer.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/FakeIngressServer.cs index 672ef2a..8e67777 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/FakeIngressServer.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/FakeIngressServer.cs @@ -1,12 +1,11 @@ +using System.Net; +using System.Net.Sockets; 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; -using System.Net; -using System.Net.Sockets; -using Deal.Telegram.Tests.Support; namespace Deal.Telegram.Tests.Grpc; diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/RealtimeListenerTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/RealtimeListenerTests.cs index 717a22a..ff93f4b 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/RealtimeListenerTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/RealtimeListenerTests.cs @@ -2,12 +2,9 @@ using Deal.Grpc.Telegram; using Deal.Telegram.Dialogs; using Deal.Telegram.Sessions; using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; +using Deal.Telegram.Tests.Telegram; using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; -using Xunit; -using Deal.Telegram.Tests.Support; -using Deal.Telegram.Tests.Telegram; namespace Deal.Telegram.Tests.Grpc; diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramServiceHostTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramServiceHostTests.cs index 92ab83c..82e8e65 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramServiceHostTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramServiceHostTests.cs @@ -2,8 +2,6 @@ using Deal.Grpc.Telegram; using Grpc.Core; using Grpc.Health.V1; using Grpc.Net.Client; -using Xunit; -using Deal.Telegram.Tests.Support; namespace Deal.Telegram.Tests.Grpc; diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramSessionRpcTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramSessionRpcTests.cs index f9406b2..b06e128 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramSessionRpcTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramSessionRpcTests.cs @@ -1,12 +1,9 @@ using Deal.Grpc.Telegram; using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; +using Deal.Telegram.Tests.Telegram; using Grpc.Core; using Grpc.Net.Client; using Microsoft.Extensions.DependencyInjection; -using Xunit; -using Deal.Telegram.Tests.Support; -using Deal.Telegram.Tests.Telegram; namespace Deal.Telegram.Tests.Grpc; diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramTestHost.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramTestHost.cs index f522363..aa68661 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramTestHost.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramTestHost.cs @@ -1,14 +1,12 @@ +using System.Net; +using System.Net.Sockets; using Deal.Grpc.Hosting; -using Deal.Telegram; using Deal.Telegram.Core; using Deal.Telegram.Sessions; using Grpc.Core; using Grpc.Net.Client; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; -using System.Net; -using System.Net.Sockets; -using Deal.Telegram.Tests.Support; namespace Deal.Telegram.Tests.Grpc; diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/TestDoubles.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/TestDoubles.cs index 5c862c0..f1bf51e 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/TestDoubles.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/TestDoubles.cs @@ -1,8 +1,6 @@ using Deal.Grpc.Telegram; using Deal.Telegram.Core; using Deal.Telegram.Dialogs; -using Grpc.Core; -using Deal.Telegram.Tests.Support; namespace Deal.Telegram.Tests.Grpc; diff --git a/src/telegram-service/Deal.Telegram.Tests/Support/SessionStorageTests.cs b/src/telegram-service/Deal.Telegram.Tests/Support/SessionStorageTests.cs index 7e6e7ac..167095f 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Support/SessionStorageTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Support/SessionStorageTests.cs @@ -1,8 +1,7 @@ using System.Security.Cryptography; using Deal.Telegram.Sessions; -using Grpc.Core; -using Xunit; using Deal.Telegram.Tests.Telegram; +using Grpc.Core; namespace Deal.Telegram.Tests.Support; diff --git a/src/telegram-service/Deal.Telegram.Tests/Support/TenantSessionTests.cs b/src/telegram-service/Deal.Telegram.Tests/Support/TenantSessionTests.cs index f5e80d3..01c94a4 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Support/TenantSessionTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Support/TenantSessionTests.cs @@ -1,9 +1,8 @@ using System.Diagnostics; using Deal.Telegram.Sessions; +using Deal.Telegram.Tests.Telegram; using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; -using Xunit; -using Deal.Telegram.Tests.Telegram; namespace Deal.Telegram.Tests.Support; diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/BackfillServiceTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/BackfillServiceTests.cs index 2f379bf..f3813c8 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/BackfillServiceTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/BackfillServiceTests.cs @@ -1,12 +1,9 @@ using Deal.Telegram.Dialogs; using Deal.Telegram.Sessions; using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; +using Deal.Telegram.Tests.Grpc; using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; -using Xunit; -using Deal.Telegram.Tests.Support; -using Deal.Telegram.Tests.Grpc; namespace Deal.Telegram.Tests.Telegram; diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogCatalogTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogCatalogTests.cs index 1a18604..37b8594 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogCatalogTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogCatalogTests.cs @@ -1,6 +1,4 @@ using Deal.Telegram.Dialogs; -using Xunit; -using Deal.Telegram.Tests.Support; namespace Deal.Telegram.Tests.Telegram; diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogHueTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogHueTests.cs index ae41df2..3f9e880 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogHueTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogHueTests.cs @@ -1,6 +1,4 @@ using Deal.Telegram.Dialogs; -using Xunit; -using Deal.Telegram.Tests.Support; namespace Deal.Telegram.Tests.Telegram; diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/DiscoveryOpsTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/DiscoveryOpsTests.cs index a318f9d..b1cf1ca 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/DiscoveryOpsTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/DiscoveryOpsTests.cs @@ -1,12 +1,9 @@ using Deal.Telegram.Discovery; using Deal.Telegram.Sessions; using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; +using Deal.Telegram.Tests.Grpc; using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; -using Xunit; -using Deal.Telegram.Tests.Support; -using Deal.Telegram.Tests.Grpc; namespace Deal.Telegram.Tests.Telegram; diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/FakeSessionClient.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/FakeSessionClient.cs index 25d3d2c..d2c2557 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/FakeSessionClient.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/FakeSessionClient.cs @@ -1,7 +1,4 @@ -using Deal.Telegram.Sessions; using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; -using Deal.Telegram.Tests.Support; namespace Deal.Telegram.Tests.Telegram; diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/FarmHarness.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/FarmHarness.cs index 4ff574c..15660e5 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/FarmHarness.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/FarmHarness.cs @@ -1,7 +1,5 @@ using Deal.Telegram.Sessions; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Deal.Telegram.Tests.Support; namespace Deal.Telegram.Tests.Telegram; diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/LruCacheTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/LruCacheTests.cs index 8edbcec..d9ff345 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/LruCacheTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/LruCacheTests.cs @@ -1,6 +1,4 @@ using Deal.Telegram.Caching; -using Xunit; -using Deal.Telegram.Tests.Support; namespace Deal.Telegram.Tests.Telegram; diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/RealtimeSweepTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/RealtimeSweepTests.cs index 0ab8a0e..abe011a 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/RealtimeSweepTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/RealtimeSweepTests.cs @@ -1,12 +1,9 @@ using Deal.Telegram.Dialogs; using Deal.Telegram.Sessions; using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; +using Deal.Telegram.Tests.Grpc; using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; -using Xunit; -using Deal.Telegram.Tests.Support; -using Deal.Telegram.Tests.Grpc; namespace Deal.Telegram.Tests.Telegram; diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/SessionHarness.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/SessionHarness.cs index 4a41fb0..09620b7 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/SessionHarness.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/SessionHarness.cs @@ -1,6 +1,5 @@ using Deal.Telegram.Sessions; using Microsoft.Extensions.Logging.Abstractions; -using Deal.Telegram.Tests.Support; namespace Deal.Telegram.Tests.Telegram; diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/TestSessionFactory.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/TestSessionFactory.cs index a6b5d44..f22ffa1 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/TestSessionFactory.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/TestSessionFactory.cs @@ -1,6 +1,5 @@ using Deal.Telegram.Sessions; using Microsoft.Extensions.Logging.Abstractions; -using Deal.Telegram.Tests.Support; namespace Deal.Telegram.Tests.Telegram; diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/TlMessageMapperTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/TlMessageMapperTests.cs index 5b90c9f..fa17054 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/TlMessageMapperTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/TlMessageMapperTests.cs @@ -1,8 +1,5 @@ using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; using TL; -using Xunit; -using Deal.Telegram.Tests.Support; namespace Deal.Telegram.Tests.Telegram; diff --git a/src/telegram-service/Deal.Telegram/Dialogs/BackfillService.cs b/src/telegram-service/Deal.Telegram/Dialogs/BackfillService.cs index f7c7b93..a33fc2f 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/BackfillService.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/BackfillService.cs @@ -3,7 +3,6 @@ using Deal.Grpc.Telegram; using Deal.Telegram.Core; using Deal.Telegram.Sessions; using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; namespace Deal.Telegram.Dialogs; diff --git a/src/telegram-service/Deal.Telegram/Dialogs/DialogProtoMapper.cs b/src/telegram-service/Deal.Telegram/Dialogs/DialogProtoMapper.cs index 1c408e7..014e39a 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/DialogProtoMapper.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/DialogProtoMapper.cs @@ -1,7 +1,6 @@ using System.Globalization; using Deal.Grpc.Telegram; using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; namespace Deal.Telegram.Dialogs; diff --git a/src/telegram-service/Deal.Telegram/Dialogs/RealtimeListener.cs b/src/telegram-service/Deal.Telegram/Dialogs/RealtimeListener.cs index 7fb2221..d54aad7 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/RealtimeListener.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/RealtimeListener.cs @@ -2,7 +2,6 @@ using Deal.Grpc.Telegram; using Deal.Telegram.Core; using Deal.Telegram.Sessions; using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; namespace Deal.Telegram.Dialogs; diff --git a/src/telegram-service/Deal.Telegram/Dialogs/RealtimeSweep.cs b/src/telegram-service/Deal.Telegram/Dialogs/RealtimeSweep.cs index aae1679..883a14a 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/RealtimeSweep.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/RealtimeSweep.cs @@ -2,7 +2,6 @@ using Deal.Grpc.Telegram; using Deal.Telegram.Core; using Deal.Telegram.Sessions; using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; namespace Deal.Telegram.Dialogs; diff --git a/src/telegram-service/Deal.Telegram/Discovery/DiscoveryOps.cs b/src/telegram-service/Deal.Telegram/Discovery/DiscoveryOps.cs index 9483216..a482b71 100644 --- a/src/telegram-service/Deal.Telegram/Discovery/DiscoveryOps.cs +++ b/src/telegram-service/Deal.Telegram/Discovery/DiscoveryOps.cs @@ -1,7 +1,6 @@ using Deal.Telegram.Dialogs; using Deal.Telegram.Sessions; using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; using Grpc.Core; namespace Deal.Telegram.Discovery; diff --git a/src/telegram-service/Deal.Telegram/Discovery/DiscoveryProtoMapper.cs b/src/telegram-service/Deal.Telegram/Discovery/DiscoveryProtoMapper.cs index af6d6d7..231229a 100644 --- a/src/telegram-service/Deal.Telegram/Discovery/DiscoveryProtoMapper.cs +++ b/src/telegram-service/Deal.Telegram/Discovery/DiscoveryProtoMapper.cs @@ -1,7 +1,6 @@ using Deal.Grpc.Telegram; using Deal.Telegram.Dialogs; using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; namespace Deal.Telegram.Discovery; diff --git a/src/telegram-service/Deal.Telegram/Extensions/ExceptionExtensions.cs b/src/telegram-service/Deal.Telegram/Extensions/ExceptionExtensions.cs index 8a89b44..2ef627d 100644 --- a/src/telegram-service/Deal.Telegram/Extensions/ExceptionExtensions.cs +++ b/src/telegram-service/Deal.Telegram/Extensions/ExceptionExtensions.cs @@ -1,8 +1,4 @@ -using System.IO; -using System.Net.Http; using Grpc.Core; -using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; namespace Deal.Telegram.Extensions; diff --git a/src/telegram-service/Deal.Telegram/Sessions/SessionFarm.cs b/src/telegram-service/Deal.Telegram/Sessions/SessionFarm.cs index a17aaa4..0ca8860 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/SessionFarm.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/SessionFarm.cs @@ -1,7 +1,6 @@ using System.Collections.Concurrent; -using Grpc.Core; using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; +using Grpc.Core; namespace Deal.Telegram.Sessions; diff --git a/src/telegram-service/Deal.Telegram/Sessions/TenantSession.cs b/src/telegram-service/Deal.Telegram/Sessions/TenantSession.cs index 455d934..f1dcbf7 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/TenantSession.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/TenantSession.cs @@ -1,6 +1,5 @@ -using Grpc.Core; using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; +using Grpc.Core; namespace Deal.Telegram.Sessions; diff --git a/src/telegram-service/Deal.Telegram/Telegram/ClientFactory.cs b/src/telegram-service/Deal.Telegram/Telegram/ClientFactory.cs index cf7311f..dcf278c 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/ClientFactory.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/ClientFactory.cs @@ -1,5 +1,3 @@ -using Deal.Telegram.Extensions; - namespace Deal.Telegram.Telegram; /// diff --git a/src/telegram-service/Deal.Telegram/Telegram/DialogKinds.cs b/src/telegram-service/Deal.Telegram/Telegram/DialogKinds.cs index ef05bf3..8e6cec3 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/DialogKinds.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/DialogKinds.cs @@ -1,5 +1,3 @@ -using Deal.Telegram.Extensions; - namespace Deal.Telegram.Telegram; /// diff --git a/src/telegram-service/Deal.Telegram/Telegram/DiscoveryMessage.cs b/src/telegram-service/Deal.Telegram/Telegram/DiscoveryMessage.cs index ae07109..848a413 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/DiscoveryMessage.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/DiscoveryMessage.cs @@ -1,5 +1,3 @@ -using Deal.Telegram.Extensions; - namespace Deal.Telegram.Telegram; /// diff --git a/src/telegram-service/Deal.Telegram/Telegram/DiscoveryReadResult.cs b/src/telegram-service/Deal.Telegram/Telegram/DiscoveryReadResult.cs index ff15945..4f024be 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/DiscoveryReadResult.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/DiscoveryReadResult.cs @@ -1,5 +1,3 @@ -using Deal.Telegram.Extensions; - namespace Deal.Telegram.Telegram; /// diff --git a/src/telegram-service/Deal.Telegram/Telegram/ISessionClient.cs b/src/telegram-service/Deal.Telegram/Telegram/ISessionClient.cs index 6af5cde..f7be887 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/ISessionClient.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/ISessionClient.cs @@ -1,5 +1,3 @@ -using Deal.Telegram.Extensions; - namespace Deal.Telegram.Telegram; /// diff --git a/src/telegram-service/Deal.Telegram/Telegram/ITelegramClientFactory.cs b/src/telegram-service/Deal.Telegram/Telegram/ITelegramClientFactory.cs index 7c94c8f..68d9d37 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/ITelegramClientFactory.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/ITelegramClientFactory.cs @@ -1,5 +1,3 @@ -using Deal.Telegram.Extensions; - namespace Deal.Telegram.Telegram; /// diff --git a/src/telegram-service/Deal.Telegram/Telegram/TelegramDialog.cs b/src/telegram-service/Deal.Telegram/Telegram/TelegramDialog.cs index 0635179..410bcb5 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/TelegramDialog.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/TelegramDialog.cs @@ -1,5 +1,3 @@ -using Deal.Telegram.Extensions; - namespace Deal.Telegram.Telegram; /// diff --git a/src/telegram-service/Deal.Telegram/Telegram/TelegramMessage.cs b/src/telegram-service/Deal.Telegram/Telegram/TelegramMessage.cs index c76170e..2c56da3 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/TelegramMessage.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/TelegramMessage.cs @@ -1,5 +1,3 @@ -using Deal.Telegram.Extensions; - namespace Deal.Telegram.Telegram; /// diff --git a/src/telegram-service/Deal.Telegram/Telegram/TelegramSourceInfo.cs b/src/telegram-service/Deal.Telegram/Telegram/TelegramSourceInfo.cs index c36d481..9d8df17 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/TelegramSourceInfo.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/TelegramSourceInfo.cs @@ -1,5 +1,3 @@ -using Deal.Telegram.Extensions; - namespace Deal.Telegram.Telegram; /// diff --git a/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs b/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs index d6facba..aa3010d 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs @@ -1,7 +1,7 @@ using System.Globalization; -using Grpc.Core; using Deal.Telegram.Caching; using Deal.Telegram.Sessions; +using Grpc.Core; using TL; using WTelegram; using RpcException = TL.RpcException; diff --git a/src/telegram-service/Deal.Telegram/TelegramServiceHost.cs b/src/telegram-service/Deal.Telegram/TelegramServiceHost.cs index fef7e5f..6eb0647 100644 --- a/src/telegram-service/Deal.Telegram/TelegramServiceHost.cs +++ b/src/telegram-service/Deal.Telegram/TelegramServiceHost.cs @@ -5,7 +5,6 @@ using Deal.Telegram.Discovery; using Deal.Telegram.Hosting; using Deal.Telegram.Sessions; using Deal.Telegram.Telegram; -using Deal.Telegram.Extensions; namespace Deal.Telegram; diff --git a/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs b/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs index 8f31cdf..5880114 100644 --- a/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs +++ b/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs @@ -1,11 +1,10 @@ -using System.Net.Http; 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 Deal.Telegram.Extensions; using Grpc.Core; namespace Deal.Telegram;