From b053d58335ddf072aeefb27812fc8ec1e335e6cd Mon Sep 17 00:00:00 2001 From: Rustam Khalimov Date: Fri, 11 Sep 2026 13:39:39 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D1=87=D0=B8=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B8=20=D0=BE=D1=82=20=D1=83=D0=BF=D0=BE=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D0=BD=D0=B8=D0=B9=20=D0=BF=D1=80=D0=BE=D1=86?= =?UTF-8?q?=D0=B5=D1=81=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Удалены , сжаты до короткой фразы, вырезаны ссылки на Task/Ruling/этап/python/прототип; //-комментарии со ссылками на процесс удалены; то же в .proto. Правила обновлены в docs/spec/Код-стайл-Дейл.md. Строк комментариев 27210 -> ~19100. --- backlog.md | 2 +- docs/spec/Код-стайл-Дейл.md | 9 + .../_slim_comments.cpython-312.pyc | Bin 0 -> 9349 bytes src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs | 52 +- src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs | 17 +- .../Deal.Ai.Tests/Ai/FakeProviderClient.cs | 3 +- .../Deal.Ai.Tests/Ai/ProviderCallerTests.cs | 21 +- .../Deal.Ai.Tests/Ai/TokenEstimatorTests.cs | 12 +- .../Deal.Ai.Tests/Grpc/AiServiceHostTests.cs | 30 +- .../Support/JsonExtractorTests.cs | 9 +- .../Support/LlmHttpClientTests.cs | 20 +- .../Support/StubHttpMessageHandler.cs | 5 +- src/ai-service/Deal.Ai/AiServiceHost.cs | 35 +- src/ai-service/Deal.Ai/AiServiceImpl.cs | 60 +- src/ai-service/Deal.Ai/Llm/IProviderClient.cs | 6 +- src/ai-service/Deal.Ai/Llm/JsonExtractor.cs | 5 +- .../Deal.Ai/Llm/LlmCallException.cs | 7 +- .../Deal.Ai/Llm/LlmCallFailureKind.cs | 8 +- src/ai-service/Deal.Ai/Llm/LlmCallResult.cs | 5 +- src/ai-service/Deal.Ai/Llm/LlmConfig.cs | 13 +- src/ai-service/Deal.Ai/Llm/LlmHttpClient.cs | 24 +- .../Deal.Ai/Llm/LlmHttpException.cs | 4 +- src/ai-service/Deal.Ai/Llm/LlmRetryPolicy.cs | 8 +- src/ai-service/Deal.Ai/Llm/LlmUsage.cs | 4 +- src/ai-service/Deal.Ai/Llm/ProviderCaller.cs | 9 +- .../Deal.Ai/Llm/ProviderChatResult.cs | 2 +- src/ai-service/Deal.Ai/Llm/ProviderUsage.cs | 4 +- src/ai-service/Deal.Ai/Llm/TokenEstimator.cs | 7 +- src/ai-service/Deal.Ai/Program.cs | 10 - src/contracts/ai.proto | 310 +++---- src/contracts/ml.proto | 274 +++--- src/contracts/telegram.proto | 854 ++++++++---------- .../Deal.Api/Configuration/CookieOptions.cs | 17 +- .../Configuration/DataRetentionOptions.cs | 13 +- .../Configuration/ForwardedHeadersConfig.cs | 25 +- .../Configuration/OperatorCookieOptions.cs | 19 +- .../Configuration/RateLimitOptions.cs | 20 +- .../Deal.Api/Configuration/SecurityOptions.cs | 17 +- src/core/Deal.Api/Dtos/AdminTickResultDto.cs | 14 +- .../Deal.Api/Endpoints/AiCheckEndpoint.cs | 16 +- .../Deal.Api/Endpoints/AiSuggestEndpoints.cs | 23 +- src/core/Deal.Api/Endpoints/AuthEndpoints.cs | 23 +- .../Endpoints/CardDetailsEndpoints.cs | 13 +- src/core/Deal.Api/Endpoints/CardsEndpoints.cs | 40 +- .../Endpoints/ChangePasswordRequest.cs | 2 +- .../Deal.Api/Endpoints/CheckMessageRequest.cs | 4 +- .../Deal.Api/Endpoints/ContainersEndpoints.cs | 15 +- .../Deal.Api/Endpoints/DiscoveryEndpoints.cs | 54 +- src/core/Deal.Api/Endpoints/EventsEndpoint.cs | 16 +- .../Endpoints/FilterTesterEndpoints.cs | 19 +- src/core/Deal.Api/Endpoints/JoinEndpoint.cs | 16 +- src/core/Deal.Api/Endpoints/JoinRequest.cs | 2 +- src/core/Deal.Api/Endpoints/LoginRequest.cs | 2 +- src/core/Deal.Api/Endpoints/MlApplyRequest.cs | 4 +- .../Deal.Api/Endpoints/MlCandidatesRequest.cs | 2 +- src/core/Deal.Api/Endpoints/MlEndpoints.cs | 29 +- .../Deal.Api/Endpoints/MlPredictRequest.cs | 4 +- .../Endpoints/OperatorAnalyticsEndpoints.cs | 13 +- .../Endpoints/OperatorAuditEndpoints.cs | 18 +- .../Endpoints/OperatorAuthEndpoints.cs | 19 +- .../Endpoints/OperatorHealthEndpoints.cs | 20 +- .../Endpoints/OperatorInviteCreateRequest.cs | 4 +- .../Endpoints/OperatorInvitesEndpoints.cs | 13 +- .../Endpoints/OperatorLimitUpdateRequest.cs | 4 +- .../Endpoints/OperatorLimitsEndpoints.cs | 31 +- .../Endpoints/OperatorMaintenanceEndpoints.cs | 12 +- .../Endpoints/OperatorSettingsEndpoints.cs | 18 +- .../Endpoints/OperatorTenantCreateRequest.cs | 5 +- .../OperatorTenantImpersonateRequest.cs | 5 +- .../Endpoints/OperatorTenantsEndpoints.cs | 24 +- .../Deal.Api/Endpoints/PipelineEndpoints.cs | 36 +- src/core/Deal.Api/Endpoints/RatesEndpoints.cs | 16 +- .../RequestModels/CardLinkRequest.cs | 7 +- .../Endpoints/RequestModels/ClearColBody.cs | 6 +- .../Endpoints/RequestModels/ColStateBody.cs | 7 +- .../Endpoints/RequestModels/CommentBody.cs | 6 +- .../RequestModels/ContainerCreateRequest.cs | 8 +- .../RequestModels/ContainerPatchRequest.cs | 7 +- .../RequestModels/CreateCardRequest.cs | 9 +- .../RequestModels/DiscoveryTaskCreateBody.cs | 22 +- .../RequestModels/DiscoveryTaskPatchBody.cs | 25 +- .../Endpoints/RequestModels/MarkColBody.cs | 8 +- .../Endpoints/RequestModels/MoveBody.cs | 7 +- .../OperatorTelegramKeysRequest.cs | 5 +- .../Endpoints/RequestModels/OrderBody.cs | 6 +- .../Endpoints/RequestModels/ReclassifyBody.cs | 7 +- .../RequestModels/ReminderSetRequest.cs | 12 +- .../RequestModels/ReturnReasonRequest.cs | 7 +- .../RequestModels/TakeCardRequest.cs | 7 +- .../Endpoints/RequestModels/TgMonitorBody.cs | 2 +- .../Endpoints/RequestModels/TgPreviewBody.cs | 4 +- .../RequestModels/TgSendCodeRequest.cs | 4 +- .../RequestModels/TgSendPasswordRequest.cs | 4 +- .../RequestModels/TgStartPhoneRequest.cs | 4 +- .../Deal.Api/Endpoints/SettingsEndpoints.cs | 26 +- .../Deal.Api/Endpoints/StorageEndpoints.cs | 29 +- .../Deal.Api/Endpoints/TelegramEndpoints.cs | 56 +- .../Endpoints/TelegramQrImageEndpoint.cs | 17 +- src/core/Deal.Api/Events/SseBroker.cs | 26 +- src/core/Deal.Api/Events/SseEvent.cs | 7 +- src/core/Deal.Api/Events/SseSubscription.cs | 7 +- .../Deal.Api/Events/StorageToastPublisher.cs | 23 +- src/core/Deal.Api/Extensions/AuthHelpers.cs | 6 +- .../Deal.Api/Hosting/BudgetAlertScheduler.cs | 38 +- .../Hosting/DataRetentionScheduler.cs | 24 +- .../Hosting/DiscoveryWorkerScheduler.cs | 20 +- .../Hosting/MlOutboxFlushScheduler.cs | 32 +- .../Hosting/OperatorBootstrapHostedService.cs | 14 +- .../Deal.Api/Hosting/StorageTickScheduler.cs | 38 +- .../Hosting/TenantBootstrapService.cs | 16 +- src/core/Deal.Api/Logging/DealLogging.cs | 10 +- .../Middleware/HttpAccessLogMiddleware.cs | 17 +- .../Middleware/OperatorSessionMiddleware.cs | 13 +- .../Middleware/OriginGuardMiddleware.cs | 24 +- .../Deal.Api/Middleware/RateLimitPolicies.cs | 25 +- .../Deal.Api/Middleware/SessionMiddleware.cs | 15 +- src/core/Deal.Api/Models/CurrentOperator.cs | 2 +- .../Observability/DealMetricsCollector.cs | 17 +- .../Observability/DealMetricsHosting.cs | 27 +- .../Observability/RuntimeDepthsCollector.cs | 19 +- .../Observability/RuntimeDepthsDto.cs | 2 +- src/core/Deal.Api/Program.cs | 116 +-- .../Services/AdminTickOrchestrator.cs | 51 +- src/core/Deal.Api/Services/AuditAppender.cs | 15 +- src/core/Deal.Api/Services/EndpointResults.cs | 17 +- .../Deal.Api/Services/LoginAttemptGuard.cs | 28 +- .../Deal.Api/Services/PipelinePumpGate.cs | 16 +- .../Services/PipelineWorkerScheduler.cs | 30 +- .../Services/RatesRefreshScheduler.cs | 15 +- .../Deal.Api/Services/SessionCookieWriter.cs | 8 +- .../StoreBackedFixedWindowRateLimiter.cs | 19 +- .../Services/TelegramBackfillScheduler.cs | 20 +- .../Telegram/IngressRateLimitInterceptor.cs | 27 +- .../IngressServiceTokenInterceptor.cs | 25 +- .../Telegram/RpcCallLoggingInterceptor.cs | 20 +- .../Telegram/TelegramIngressService.cs | 61 +- .../Telegram/TelegramKeysMaskedDto.cs | 6 +- .../Deal.Api/Telegram/TelegramKeysService.cs | 29 +- .../Deal.Api/Telegram/TelegramKeysValue.cs | 5 - src/core/Deal.Api/Telegram/TgKeysSnapshot.cs | 10 +- .../Deal.Api/Telegram/TgReportedStatus.cs | 14 +- src/core/Deal.Api/Telegram/TgStatusService.cs | 21 +- src/core/Deal.Contracts/ContractsMarker.cs | 2 +- .../Abstractions/IAiClassifier.cs | 34 +- .../Integrations/Abstractions/IAiTools.cs | 35 +- .../Abstractions/IColumnSuggester.cs | 30 +- .../Integrations/Abstractions/IFileStorage.cs | 54 +- .../Integrations/Abstractions/IMlClient.cs | 44 +- .../Abstractions/ITelegramGateway.cs | 69 +- .../Integrations/Models/AiBudgetDto.cs | 8 +- .../Integrations/Models/AiContactDto.cs | 7 - .../Models/AiEvaluateFitResultDto.cs | 9 +- .../Integrations/Models/AiFilterResultDto.cs | 9 +- .../Models/AiGenerateKeywordsResultDto.cs | 9 +- .../Integrations/Models/AiParsedCardDto.cs | 18 +- .../Integrations/Models/FileMeta.cs | 15 +- .../Integrations/Models/MlEvalDto.cs | 7 +- .../Integrations/Models/MlLearningLabels.cs | 14 +- .../Integrations/Models/MlPredictResultDto.cs | 11 +- .../Integrations/Models/MlResetResultDto.cs | 8 +- .../Integrations/Models/MlServiceStatusDto.cs | 9 +- .../Integrations/Models/MlStatsDto.cs | 12 +- .../Models/MlStatusResponseDto.cs | 12 +- .../Integrations/Models/MlTypeDecisionDto.cs | 8 +- .../Integrations/Models/SourceDefaults.cs | 7 +- .../Models/SuggestColumnsResultDto.cs | 12 +- .../Models/SuggestKeywordsResultDto.cs | 10 +- .../Models/TelegramAccountStatusDto.cs | 7 +- .../Models/TelegramAuthResultDto.cs | 6 +- .../Models/TelegramChannelInfoDto.cs | 6 +- .../Models/TelegramDialogEntryDto.cs | 7 +- .../Models/TelegramEvalMessageDto.cs | 6 +- .../Models/TelegramEvalReadDto.cs | 6 +- .../Models/TelegramRecentMessageDto.cs | 7 +- .../Data/ConnectionStringProvider.cs | 9 +- .../Deal.Infrastructure/Data/TenantContext.cs | 2 +- .../InfrastructureMarker.cs | 2 +- .../Abstractions/IMlTrainClient.cs | 13 +- .../Exceptions/AiUnavailableException.cs | 11 +- .../Extensions/RpcExceptionExtensions.cs | 2 +- .../Integrations/Extensions/UriExtensions.cs | 6 +- .../Integrations/Models/AiGrpcConnection.cs | 26 +- .../Integrations/Models/MlGrpcConnection.cs | 25 +- .../Integrations/Models/MtlsCertificates.cs | 29 +- .../Models/ServiceHealthResult.cs | 10 +- .../Models/TelegramGrpcConnection.cs | 25 +- .../Integrations/Options/AiServiceOptions.cs | 17 +- .../Integrations/Options/MlServiceOptions.cs | 16 +- .../Integrations/Options/MtlsOptions.cs | 33 +- .../Options/TelegramServiceOptions.cs | 17 +- .../Services/AiConnectionChecker.cs | 24 +- .../Services/AiProviderConfigBuilder.cs | 18 +- .../Services/BudgetedAiClassifier.cs | 22 +- .../Integrations/Services/BudgetedAiTools.cs | 26 +- .../Integrations/Services/CbrRateSource.cs | 19 +- .../Integrations/Services/GrpcAiClassifier.cs | 35 +- .../Integrations/Services/GrpcAiTools.cs | 32 +- .../Integrations/Services/GrpcMlClient.cs | 44 +- .../Services/GrpcTelegramClient.cs | 30 +- .../Services/LocalAiClassifier.cs | 19 +- .../Integrations/Services/LocalAiTools.cs | 10 +- .../Services/LocalColumnSuggester.cs | 37 +- .../Integrations/Services/LocalMlClient.cs | 28 +- .../Services/LocalTelegramGateway.cs | 13 +- .../Integrations/Services/MlOutboxQueue.cs | 11 +- .../Integrations/Services/MlStatusCache.cs | 22 +- .../Services/ServiceHealthProbe.cs | 21 +- .../Services/TokenUsageRecorder.cs | 37 +- .../MinioStorageOptionsExtensions.cs | 4 +- .../Storage/Extensions/StringExtensions.cs | 2 +- .../Storage/Options/LocalStorageOptions.cs | 10 +- .../Storage/Options/MinioStorageOptions.cs | 19 +- .../Storage/Options/StorageOptions.cs | 14 +- .../Storage/Services/FileStorageRegistrar.cs | 23 +- .../Storage/Services/LocalFileStorage.cs | 22 +- .../Storage/Services/MinioFileStorage.cs | 28 +- .../Migrations/TenantSchemaMigrator.cs | 4 +- .../Configurations/AuditLogConfiguration.cs | 2 +- .../Configurations/CardConfiguration.cs | 3 +- .../Configurations/CardMoveConfiguration.cs | 3 +- .../Configurations/ContainerConfiguration.cs | 7 +- .../Configurations/DedupEntryConfiguration.cs | 6 +- .../Configurations/DialogConfiguration.cs | 4 +- .../DiscBlacklistConfiguration.cs | 7 +- .../DiscCandidateConfiguration.cs | 7 +- .../Configurations/DiscLogConfiguration.cs | 6 +- .../Configurations/DiscTaskConfiguration.cs | 6 +- .../GlobalSettingConfiguration.cs | 3 +- .../Configurations/InviteConfiguration.cs | 2 +- .../LeadCommentConfiguration.cs | 3 +- .../Configurations/MlOutboxConfiguration.cs | 3 +- .../Configurations/OperatorConfiguration.cs | 2 +- .../OperatorSessionConfiguration.cs | 2 +- .../Configurations/QueueItemConfiguration.cs | 4 +- .../RateLimitCounterConfiguration.cs | 3 +- .../RejectedItemConfiguration.cs | 8 +- .../Configurations/SessionConfiguration.cs | 2 +- .../Configurations/TenantConfiguration.cs | 2 +- .../TenantLimitConfiguration.cs | 2 +- .../TenantSettingConfiguration.cs | 2 +- .../Configurations/TgMessageConfiguration.cs | 3 +- .../TokenUsageEventConfiguration.cs | 2 +- .../Configurations/UserConfiguration.cs | 2 +- .../Persistence/DealDbContext.cs | 9 +- .../Persistence/DealDbDesignTimeFactory.cs | 2 +- .../Persistence/Entities/AuditLogEntity.cs | 4 +- .../Persistence/Entities/CardEntity.cs | 61 +- .../Persistence/Entities/CardMoveEntity.cs | 16 +- .../Persistence/Entities/ContainerEntity.cs | 34 +- .../Persistence/Entities/DedupEntryEntity.cs | 10 +- .../Persistence/Entities/DialogEntity.cs | 25 +- .../Entities/DiscBlacklistEntity.cs | 14 +- .../Entities/DiscCandidateEntity.cs | 32 +- .../Persistence/Entities/DiscLogEntity.cs | 13 +- .../Persistence/Entities/DiscTaskEntity.cs | 30 +- .../Entities/GlobalSettingEntity.cs | 11 +- .../Persistence/Entities/InviteEntity.cs | 8 +- .../Persistence/Entities/LeadCommentEntity.cs | 8 +- .../Persistence/Entities/MlOutboxEntity.cs | 14 +- .../Persistence/Entities/OperatorEntity.cs | 2 +- .../Entities/OperatorSessionEntity.cs | 4 +- .../Persistence/Entities/QueueItemEntity.cs | 21 +- .../Entities/RateLimitCounterEntity.cs | 13 +- .../Entities/RejectedItemEntity.cs | 31 +- .../Persistence/Entities/SessionEntity.cs | 7 +- .../Persistence/Entities/TenantLimitEntity.cs | 8 +- .../Entities/TenantSettingEntity.cs | 2 +- .../Persistence/Entities/TgMessageEntity.cs | 15 +- .../Entities/TokenUsageEventEntity.cs | 10 +- .../Persistence/Entities/UserEntity.cs | 2 +- .../Persistence/Repositories/AuditLogStore.cs | 9 +- .../Persistence/Repositories/AuthStore.cs | 4 +- .../Repositories/DiscoveryStore.Blacklist.cs | 4 +- .../Repositories/DiscoveryStore.Candidates.cs | 6 +- .../Repositories/DiscoveryStore.Logs.cs | 4 +- .../Repositories/DiscoveryStore.Tasks.cs | 9 +- .../Repositories/DiscoveryStore.cs | 23 +- .../Repositories/GlobalSettingsStore.cs | 8 +- .../Persistence/Repositories/InviteStore.cs | 9 +- .../Repositories/KanbanStore.Cards.cs | 17 +- .../Repositories/KanbanStore.Comments.cs | 8 +- .../Repositories/KanbanStore.Containers.cs | 4 +- .../Repositories/KanbanStore.Selected.cs | 15 +- .../Repositories/KanbanStore.StorageRules.cs | 13 - .../Persistence/Repositories/KanbanStore.cs | 33 +- .../Repositories/MlLearningStore.cs | 12 +- .../Repositories/OperatorAuthStore.cs | 3 +- .../Persistence/Repositories/PipelineStore.cs | 38 +- .../Repositories/RateLimitCounterStore.cs | 11 +- .../Persistence/Repositories/SettingsStore.cs | 9 +- .../Persistence/Repositories/TelegramStore.cs | 19 +- .../Repositories/TenantLimitStore.cs | 26 +- .../Repositories/TenantRepository.cs | 3 +- .../Repositories/TokenUsageEventStore.cs | 8 +- .../Persistence/TenantDbContext.cs | 32 +- .../Persistence/TenantDbDesignTimeFactory.cs | 2 +- .../Security/AesGcmSecretCipher.cs | 7 +- .../Security/EncryptionKeyProvider.cs | 13 +- .../ServiceCollectionExtensions.cs | 84 +- .../Deal.Infrastructure/Services/CardMover.cs | 12 +- .../Services/FtsMaintenance.cs | 20 +- .../Tenancy/DefaultContainerProvisioner.cs | 9 +- .../Tenancy/TenantMigrationSummary.cs | 2 +- .../Tenancy/TenantProvisioningService.cs | 8 +- .../Tenancy/TenantSchemaMigrationService.cs | 18 +- .../Application/Abstractions/IAiSource.cs | 11 +- .../Application/Abstractions/IApiSource.cs | 6 +- .../Abstractions/IAttributedCard.cs | 5 +- .../Application/Abstractions/IBudgetedCard.cs | 2 +- .../Application/Abstractions/ICard.cs | 15 +- .../Application/Abstractions/ICardMover.cs | 8 - .../Abstractions/ICommentableCard.cs | 2 +- .../Abstractions/ICompositeSource.cs | 9 +- .../Application/Abstractions/IContactCard.cs | 4 +- .../Application/Abstractions/IContainer.cs | 21 +- .../Abstractions/IContainerPolicy.cs | 16 +- .../Abstractions/IContainerRules.cs | 26 +- .../Application/Abstractions/IContentCard.cs | 8 +- .../Application/Abstractions/IFileCard.cs | 4 +- .../Application/Abstractions/IFileSource.cs | 4 +- .../Application/Abstractions/ILinkCard.cs | 2 +- .../Application/Abstractions/ILocalSource.cs | 3 +- .../Application/Abstractions/ILocatedCard.cs | 11 +- .../Abstractions/IRemindableCard.cs | 2 +- .../Application/Abstractions/IRowSource.cs | 6 +- .../Application/Abstractions/ISource.cs | 14 +- .../Abstractions/ITelegramSource.cs | 11 +- .../Abstractions/ITraceableCard.cs | 4 +- .../Application/Abstractions/IWebSource.cs | 2 +- .../Application/Dtos/CardMoveResultDto.cs | 5 - .../Application/Models/Card.cs | 9 +- .../Application/Models/CardAttribute.cs | 13 +- .../Application/Models/CardBudget.cs | 2 +- .../Application/Models/CardContact.cs | 2 +- .../Application/Models/CardFile.cs | 4 +- .../Application/Models/CardHistoryEntry.cs | 2 +- .../Application/Models/CardIds.cs | 17 +- .../Application/Models/CardLink.cs | 4 +- .../Application/Models/CardReminder.cs | 4 +- .../Models/CardsDefaultContainers.cs | 21 +- .../Application/Models/DefaultContainer.cs | 2 +- .../Application/Models/TransitionContext.cs | 9 +- .../Deal.Modules.Cards/CardsModuleMarker.cs | 2 +- .../Abstractions/IDiscoveryPacer.cs | 10 +- .../IDiscoverySearchErrorCounter.cs | 11 +- .../Abstractions/IDiscoveryStore.cs | 110 +-- .../DiscoveryValidationException.cs | 10 +- .../DiscoveryTaskPatchExtensions.cs | 2 +- .../Models/DiscoveryBlacklistDto.cs | 8 +- .../Models/DiscoveryCandidateDto.cs | 10 +- .../Models/DiscoveryCandidateKinds.cs | 6 +- .../Models/DiscoveryCandidatePatch.cs | 25 +- .../Models/DiscoveryCandidateRow.cs | 15 +- .../Models/DiscoveryCandidateStatuses.cs | 17 +- .../Models/DiscoveryCounterField.cs | 15 +- .../Application/Models/DiscoveryEvalSample.cs | 2 +- .../Application/Models/DiscoveryIdPrefixes.cs | 17 +- .../Application/Models/DiscoveryLogDto.cs | 9 +- .../Application/Models/DiscoveryLogEvents.cs | 23 +- .../Application/Models/DiscoveryMessageFit.cs | 2 +- .../Application/Models/DiscoveryTaskDraft.cs | 22 +- .../Application/Models/DiscoveryTaskDto.cs | 9 +- .../Application/Models/DiscoveryTaskPatch.cs | 26 +- .../Application/Models/DiscoveryTaskRow.cs | 27 +- .../Models/DiscoveryTaskStatuses.cs | 22 +- .../Application/Models/DiscoveryTopicDto.cs | 7 +- .../Application/Models/DiscoveryTopicGroup.cs | 2 +- .../Models/DiscoveryWorkerOutcome.cs | 4 +- .../Registrars/DiscoveryModuleRegistrar.cs | 15 +- .../Application/Services/DiscoveryBanGuard.cs | 41 +- .../Services/DiscoveryBlacklistService.cs | 20 +- .../Services/DiscoveryCandidatesService.cs | 67 +- .../Services/DiscoveryEvaluator.cs | 51 +- .../Services/DiscoveryLangDetector.cs | 12 +- .../Services/DiscoveryLogService.cs | 22 +- .../Application/Services/DiscoveryPacer.cs | 7 +- .../Services/DiscoveryPlanGuard.cs | 28 +- .../Services/DiscoverySearchErrorCounter.cs | 14 +- .../Services/DiscoveryTasksService.cs | 58 +- .../DiscoveryWorkerService.Constants.cs | 24 +- .../DiscoveryWorkerService.Evaluate.cs | 11 - .../DiscoveryWorkerService.Helpers.cs | 2 - .../Services/DiscoveryWorkerService.Join.cs | 10 - .../Services/DiscoveryWorkerService.Search.cs | 6 - .../Services/DiscoveryWorkerService.cs | 41 +- .../DiscoveryModuleMarker.cs | 2 +- .../Application/Abstractions/ICardStore.cs | 170 +--- .../Abstractions/IMlLearningStore.cs | 31 +- .../Application/ColumnRules/AmountParser.cs | 31 +- .../Application/ColumnRules/AmountRange.cs | 8 +- .../Application/ColumnRules/BudgetInRange.cs | 18 +- .../ColumnRules/BudgetRangeDtoExtensions.cs | 3 +- .../ColumnRules/ColumnExclusions.cs | 13 +- .../Application/ColumnRules/ColumnMatcher.cs | 31 +- .../Application/ColumnRules/ColumnRules.cs | 35 +- .../ColumnRules/ContentNormalizer.cs | 13 +- .../Application/ColumnRules/GradeAliases.cs | 16 +- .../ColumnRules/MatchHitBuilder.cs | 21 +- .../Application/ColumnRules/RulesDescriber.cs | 15 +- .../ColumnRules/TermListExtensions.cs | 2 +- .../Application/ColumnRules/TypeAliases.cs | 13 +- .../Application/Extensions/CharExtensions.cs | 2 +- .../Application/Models/AddCommentResultDto.cs | 9 +- .../Application/Models/AiMarkupExampleDto.cs | 10 +- .../Application/Models/BudgetRangeDto.cs | 7 +- .../Application/Models/CardBudgetDto.cs | 8 +- .../Application/Models/CardChannelDto.cs | 6 +- .../Application/Models/CardColumnCountDto.cs | 6 +- .../Application/Models/CardColumnUpdateDto.cs | 10 +- .../Application/Models/CardCommentDto.cs | 8 +- .../Application/Models/CardContactDto.cs | 8 +- .../Application/Models/CardCountsDto.cs | 18 +- .../Application/Models/CardDto.cs | 49 +- .../Application/Models/CardFileDto.cs | 2 +- .../Application/Models/CardFileKind.cs | 7 +- .../Application/Models/CardHistoryDto.cs | 6 +- .../Application/Models/CardLinkDto.cs | 2 +- .../Application/Models/CardLocalCreateDto.cs | 12 +- .../Application/Models/CardMoveDto.cs | 7 +- .../Application/Models/CardPatch.cs | 9 +- .../Models/CardReclassificationDto.cs | 10 +- .../Application/Models/CardReminderDto.cs | 2 +- .../Application/Models/CardReminderDueDto.cs | 7 +- .../Application/Models/CardResultDto.cs | 7 +- .../Application/Models/CardSnapshot.cs | 51 +- .../Application/Models/CardSourceDto.cs | 7 +- .../Application/Models/CardsQuery.cs | 7 +- .../Application/Models/ClearColResultDto.cs | 7 +- .../Application/Models/ColumnStateDto.cs | 11 +- .../Application/Models/ContainerCountsDto.cs | 6 +- .../Application/Models/ContainerCreateDto.cs | 9 +- .../Application/Models/ContainerDto.cs | 32 +- .../Application/Models/ContainerKinds.cs | 11 +- .../Application/Models/ContainerPatchDto.cs | 7 +- .../Application/Models/ContainerPolicyDto.cs | 11 +- .../Application/Models/ContainerRulesDto.cs | 12 +- .../Application/Models/ContainerSpaces.cs | 11 +- .../Application/Models/KanbanColumns.cs | 14 +- .../Application/Models/KanbanIdPrefixes.cs | 26 +- .../Application/Models/MatchHitDto.cs | 8 +- .../Application/Models/MlOutboxEntryDto.cs | 8 +- .../Application/Models/PrefixId.cs | 8 +- .../Application/Models/StorageTickStatsDto.cs | 8 +- .../Application/Models/SuggestedColumnPlan.cs | 12 +- .../Registrars/KanbanModuleRegistrar.cs | 17 +- .../Application/Services/BudgetNormalizer.cs | 36 +- .../Services/CardsService.Files.cs | 39 +- .../Services/CardsService.Helpers.cs | 12 +- .../Services/CardsService.Operations.cs | 99 +- .../Services/CardsService.Reminders.cs | 50 +- .../Services/CardsService.Selected.cs | 79 +- .../Application/Services/CardsService.cs | 44 +- .../Application/Services/ContainersService.cs | 33 +- .../Services/ConversionRecomputer.cs | 17 +- .../Application/Services/FileKindDetector.cs | 29 +- .../Services/StorageTickService.cs | 29 +- .../Application/Services/SuggestHeuristics.cs | 59 +- .../Deal.Modules.Kanban/KanbanModuleMarker.cs | 2 +- .../Abstractions/IPipelineStore.cs | 88 +- .../Models/GlobalExcludeSettings.cs | 2 +- .../Models/GlobalExclusionResult.cs | 2 +- .../Application/Models/LocalParsedFields.cs | 14 +- .../Application/Models/MlApplyResult.cs | 2 +- .../Application/Models/MlCandidateDto.cs | 19 +- .../Models/MlCandidatePredictionDto.cs | 2 +- .../Application/Models/ParsedCardContent.cs | 12 +- .../Application/Models/PipelineChannelDto.cs | 11 +- .../Application/Models/PipelineIdPrefixes.cs | 13 +- .../Models/PipelineIngestResultDto.cs | 8 +- .../Application/Models/PipelinePumpResult.cs | 28 +- .../Models/PipelineQueueStatuses.cs | 12 +- .../Models/PipelineRejectConstants.cs | 19 +- .../Application/Models/PipelineStatsDto.cs | 7 +- .../Application/Models/QueueCountsDto.cs | 12 +- .../Application/Models/QueueItemDto.cs | 25 +- .../Application/Models/QueuedMessage.cs | 19 +- .../Application/Models/ReclassifyResultDto.cs | 15 +- .../Application/Models/RejectRecord.cs | 26 +- .../Models/RejectReturnResultDto.cs | 8 +- .../Application/Models/RejectedItemDto.cs | 34 +- .../Application/Models/RejectedPageDto.cs | 7 +- .../Parse/AmountRangeBudgetFallback.cs | 15 +- .../Application/Parse/CodePointExtensions.cs | 2 +- .../Application/Parse/ContactsQualifier.cs | 56 +- .../Application/Parse/DedupHasher.cs | 14 +- .../Application/Parse/LocalFieldsParser.cs | 41 +- .../Parse/MessageListNormalizer.cs | 32 +- .../Application/Parse/MessageTextCleaner.cs | 51 +- .../Application/Parse/StringExtensions.cs | 3 +- .../Application/Parse/SummaryComposer.cs | 27 +- .../Registrars/PipelineModuleRegistrar.cs | 27 +- .../Application/Services/AiCardLearning.cs | 12 +- .../Application/Services/AiCardMapper.cs | 20 +- .../Services/AiClassifyContextBuilder.cs | 48 +- .../Application/Services/AiRawCardMapper.cs | 35 +- .../Application/Services/CardComposer.cs | 39 +- .../Application/Services/CardReclassifier.cs | 38 +- .../Services/GlobalExclusionRules.cs | 15 +- .../Application/Services/MlReviewService.cs | 45 +- .../Services/PipelineCardWriter.cs | 21 +- .../Services/PipelineIngestService.cs | 20 +- .../Services/PipelineProcessingService.cs | 109 +-- .../Services/PipelineWorkerService.Checks.cs | 10 +- .../PipelineWorkerService.Decisions.cs | 5 +- .../PipelineWorkerService.Learning.cs | 8 +- .../Services/PipelineWorkerService.Pump.cs | 38 +- .../PipelineWorkerService.Rejections.cs | 8 +- .../PipelineWorkerService.Settings.cs | 6 +- .../Services/PipelineWorkerService.State.cs | 24 +- .../Services/PipelineWorkerService.cs | 58 +- .../Application/Services/ReclassifyGate.cs | 7 +- .../PipelineModuleMarker.cs | 2 +- .../Abstractions/IAiConnectionChecker.cs | 12 +- .../Abstractions/IGlobalSettingsStore.cs | 12 +- .../Abstractions/IRatesChangedListener.cs | 15 +- .../Application/Abstractions/IRatesSource.cs | 10 +- .../Application/Abstractions/ISecretCipher.cs | 13 +- .../Abstractions/ISettingsStore.cs | 18 +- .../Application/Models/AiCheckRequest.cs | 9 +- .../Application/Models/AiCheckResultDto.cs | 12 +- .../Application/Models/AiConfigPublicDto.cs | 7 +- .../Application/Models/AiConfigSetting.cs | 7 +- .../Models/AiProviderDefinition.cs | 6 +- .../Application/Models/AiProviders.cs | 9 +- .../Application/Models/DefaultPrompts.cs | 15 +- .../Application/Models/GlobalSettingsKeys.cs | 9 +- .../Application/Models/IncomingRules.cs | 61 +- .../Application/Models/IncomingRulesResult.cs | 19 +- .../Application/Models/MyPromptDto.cs | 7 +- .../Application/Models/ProviderPublicDto.cs | 6 +- .../Application/Models/PublicSettingsDto.cs | 60 +- .../Application/Models/RatesCacheValue.cs | 8 +- .../Application/Models/RatesDto.cs | 9 +- .../Application/Models/SettingKind.cs | 18 +- .../Application/Models/SettingValue.cs | 7 +- .../Application/Models/SettingsDefaults.cs | 96 +- .../Application/Models/SettingsKeys.cs | 116 ++- .../Models/TenantSettingsSnapshot.cs | 39 +- .../Registrars/SettingsModuleRegistrar.cs | 13 +- .../Application/Services/MockRates.cs | 11 +- .../Application/Services/PromptFiller.cs | 14 +- .../Application/Services/RatesService.cs | 48 +- .../SettingsService.PatchMyPrompts.cs | 1 - .../SettingsService.PatchScalarKeys.cs | 2 - .../Services/SettingsService.PatchSecrets.cs | 2 - .../Services/SettingsService.PublicForms.cs | 3 - .../Services/SettingsService.ReadMerge.cs | 5 - .../Application/Services/SettingsService.cs | 44 +- .../SettingsModuleMarker.cs | 2 +- .../Application/DialogsService.cs | 100 +- .../Application/ITelegramStore.cs | 62 +- .../Application/Models/TelegramDialogDto.cs | 8 +- .../Models/TelegramDialogLastDto.cs | 4 +- .../Application/Models/TelegramMessageDto.cs | 6 +- .../Models/TelegramMonitorAllDto.cs | 6 +- .../Models/TelegramMonitorToggleDto.cs | 7 +- .../Application/Models/TgStatusDto.cs | 7 +- .../Application/TelegramModuleRegistrar.cs | 14 +- .../TelegramModuleMarker.cs | 2 +- .../Abstractions/IAuditLogStore.cs | 23 +- .../Application/Abstractions/IAuthStore.cs | 23 +- .../Application/Abstractions/IInviteStore.cs | 28 +- .../Abstractions/IOperatorAuthStore.cs | 19 +- .../Abstractions/IPasswordHasher.cs | 5 +- .../Abstractions/IRateLimitCounterStore.cs | 24 +- .../Abstractions/ITenantLimitStore.cs | 43 +- .../Abstractions/ITenantProvisioner.cs | 6 +- .../Abstractions/ITenantRepository.cs | 8 +- .../Abstractions/ITokenUsageEventStore.cs | 13 +- .../Extensions/AuditRecordDtoExtensions.cs | 6 +- .../Extensions/InviteDtoExtensions.cs | 2 +- .../Models/AnalyticsActivityDto.cs | 2 +- .../Models/AnalyticsOverviewDto.cs | 2 +- .../Application/Models/AnalyticsTokensDto.cs | 2 +- .../Application/Models/AuditActorTypes.cs | 6 +- .../Application/Models/AuditEvents.cs | 69 +- .../Application/Models/AuditQueryDto.cs | 2 +- .../Application/Models/AuditRecordDto.cs | 7 +- .../Application/Models/BudgetStateDto.cs | 5 +- .../Models/ChangePasswordResultDto.cs | 6 +- .../Models/ImpersonationResultDto.cs | 13 +- .../Models/InviteCreateResultDto.cs | 6 +- .../Application/Models/InviteDto.cs | 8 +- .../Models/InviteRevokeResultDto.cs | 7 +- .../Application/Models/InviteStatuses.cs | 14 +- .../Application/Models/JoinResultDto.cs | 20 +- .../Application/Models/LoginResultDto.cs | 9 +- .../Application/Models/LogoutResultDto.cs | 7 +- .../Application/Models/OperatorIdentityDto.cs | 2 +- .../Models/OperatorLoginResultDto.cs | 2 +- .../Application/Models/OperatorSessionDto.cs | 3 +- .../Application/Models/SessionDto.cs | 5 +- .../Application/Models/StoredOperatorDto.cs | 3 +- .../Application/Models/StoredUserDto.cs | 2 +- .../Models/SuspiciousActivityDto.cs | 14 +- .../Models/SuspiciousFindingDto.cs | 2 +- .../Models/TenantCreateResultDto.cs | 14 +- .../Application/Models/TenantDetailDto.cs | 2 +- .../Application/Models/TenantLimitDto.cs | 6 +- .../Application/Models/TenantLimitPeriods.cs | 12 +- .../Application/Models/TenantListItemDto.cs | 3 +- .../Application/Models/TenantRecordDto.cs | 2 +- .../Models/TenantStatusChangeResultDto.cs | 8 +- .../Application/Models/TenantStatuses.cs | 15 +- .../Application/Models/TokenBudgetDefaults.cs | 15 +- .../Application/Models/TokenLimitDefaults.cs | 2 +- .../Models/TokenUsageAggregateDto.cs | 2 +- .../Application/Models/TokenUsageEventDto.cs | 8 +- .../Models/TokenUsageEventKinds.cs | 6 +- .../Models/TokenUsageEventQueryDto.cs | 2 +- .../Application/Models/TokenUsageGroupBys.cs | 10 +- .../Application/Models/TokenUsageSources.cs | 10 +- .../Application/Models/UserIdentityDto.cs | 2 +- .../Registrars/TenantModuleRegistrar.cs | 15 +- .../Application/Services/AnalyticsService.cs | 22 +- .../Application/Services/AuditService.cs | 31 +- .../Application/Services/AuthService.cs | 62 +- .../Services/DefaultPasswordHasher.cs | 8 +- .../Services/InviteCodeGenerator.cs | 12 +- .../Application/Services/InvitesService.cs | 39 +- .../Application/Services/JoinService.cs | 22 +- .../Services/OperatorAuthService.cs | 19 +- .../Services/OperatorBootstrapService.cs | 25 +- .../Application/Services/SessionTokens.cs | 11 +- .../Services/SuspiciousActivityService.cs | 31 +- .../Services/TenantAdminService.cs | 36 +- .../Application/Services/TenantService.cs | 14 +- .../Services/TokenBudgetService.cs | 18 +- .../Services/TokenUsageEventService.cs | 12 +- .../TenantsModuleMarker.cs | 2 +- .../Observability/DealMetrics.cs | 52 +- .../Deal.SharedKernel/SharedKernelMarker.cs | 2 +- .../Tenants/Abstractions/ITenantContext.cs | 9 +- .../Tenants/Models/TenantId.cs | 8 +- .../Utilities/UrlSafeToken.cs | 6 +- .../Api/DataRetentionSchedulerTests.cs | 12 +- .../Api/DiscoveryEndpointsHelpersTests.cs | 13 +- .../Api/DiscoveryWorkerSchedulerTests.cs | 11 +- .../Api/IngressRateLimitInterceptorTests.cs | 14 +- .../Api/LoginAttemptGuardTests.cs | 22 +- .../Api/MlOutboxFlushSchedulerTests.cs | 14 +- .../Api/OperatorAuditEndpointsHelpersTests.cs | 2 +- .../OperatorBootstrapHostedServiceTests.cs | 9 +- .../Api/OperatorCookieOptionsTests.cs | 3 +- .../OperatorLimitsEndpointsHelpersTests.cs | 3 +- .../Api/PipelinePumpGateTests.cs | 10 +- .../Api/RuntimeDepthsCollectorTests.cs | 7 +- .../Api/TelegramEndpointsMappingTests.cs | 11 +- .../Api/TelegramKeysServiceTests.cs | 7 +- .../Api/TgStatusServiceTests.cs | 16 +- .../Contracts/BudgetedAiClassifierTests.cs | 14 +- .../Contracts/BudgetedAiToolsTests.cs | 14 +- .../Contracts/CardComposerTests.cs | 20 +- .../Contracts/CardsServiceTests.cs | 28 +- .../Contracts/DialogsServiceTests.cs | 64 +- .../Contracts/DiscoveryEvaluatorTests.cs | 11 +- .../Contracts/DiscoveryWorkerServiceTests.cs | 13 +- .../Contracts/FakeAiClassifier.cs | 24 +- .../Deal.Tests.Unit/Contracts/FakeAiTools.cs | 13 +- .../Contracts/FakeDiscoveryGateway.cs | 29 +- .../Contracts/FakeFileStorage.cs | 19 +- .../Deal.Tests.Unit/Contracts/FakeMlClient.cs | 21 +- .../Contracts/FakeTelegramGateway.cs | 29 +- .../Contracts/FakeTelegramStore.cs | 20 +- .../Contracts/GrpcAiToolsTests.cs | 9 +- .../Contracts/GrpcMlClientTests.cs | 17 +- .../Contracts/GrpcTelegramClientTests.cs | 24 +- .../Contracts/IntegrationsDiTests.cs | 22 +- .../Contracts/LocalAiClassifierTests.cs | 12 +- .../Contracts/LocalAiToolsTests.cs | 4 +- .../Contracts/PipelineCardWriterTests.cs | 12 +- .../Contracts/PipelineWorkerGrpcAiTests.cs | 19 +- .../Contracts/TelegramGatewayPortTests.cs | 9 +- .../Grpc/RecordingAiService.cs | 31 +- .../Grpc/RecordingMlService.cs | 26 +- .../Grpc/RecordingTelegramService.cs | 28 +- .../Infrastructure/AuditLogStoreTests.cs | 11 +- .../Infrastructure/AuthStoreTests.cs | 7 +- .../Infrastructure/CardMoverTests.cs | 9 +- .../GlobalSettingsStoreTests.cs | 6 +- .../Infrastructure/InviteStoreTests.cs | 8 +- .../Infrastructure/MtlsOptionsTests.cs | 12 +- .../Infrastructure/OperatorAuthStoreTests.cs | 7 +- .../RateLimitCounterStoreTests.cs | 8 +- .../Infrastructure/SecretCipherTests.cs | 2 +- .../Infrastructure/TenantLimitStoreTests.cs | 19 +- .../Infrastructure/TenantRepositoryTests.cs | 6 +- .../TenantSchemaMigrationServiceTests.cs | 8 +- .../TenantSettingEntityTests.cs | 7 +- .../Modules/Cards/CardsDomainTests.cs | 2 +- .../Modules/Cards/FakeKanjStore.cs | 65 +- .../Modules/Cards/LocalSourceStub.cs | 1 - .../Modules/Cards/TelegramSourceStub.cs | 1 - .../Discovery/DiscoveryBanGuardTests.cs | 9 +- .../DiscoveryBlacklistServiceTests.cs | 9 +- .../DiscoveryCandidatesServiceTests.cs | 12 +- .../Discovery/DiscoveryLangDetectorTests.cs | 3 +- .../Discovery/DiscoveryLogServiceTests.cs | 6 +- .../DiscoverySearchErrorCounterTests.cs | 7 +- .../Discovery/DiscoveryTasksServiceTests.cs | 9 +- .../Modules/Discovery/FakeDiscoveryPacer.cs | 8 +- .../Modules/Discovery/FakeDiscoveryStore.cs | 31 +- .../Kanban/AiClassifyContextBuilderTests.cs | 13 +- .../Modules/Kanban/AmountParserTests.cs | 15 +- .../Modules/Kanban/BudgetNormalizerTests.cs | 17 +- .../Kanban/CardsServiceRemindersTests.cs | 20 +- .../Modules/Kanban/ColumnRulesTests.cs | 22 +- .../Modules/Kanban/FakeMlLearningStore.cs | 16 +- .../Modules/Kanban/FakePipelineStore.cs | 32 +- .../Modules/Kanban/FileKindDetectorTests.cs | 12 +- .../Modules/Kanban/MlReviewServiceTests.cs | 2 +- .../Modules/Kanban/StorageTickServiceTests.cs | 14 +- .../Modules/Kanban/SuggestHeuristicsTests.cs | 18 +- .../Pipeline/GlobalExclusionRulesTests.cs | 2 +- .../Pipeline/PipelineIngestServiceTests.cs | 7 +- .../PipelineProcessingServiceTests.cs | 15 +- .../Settings/FakeGlobalSettingsStore.cs | 9 +- .../Modules/Settings/FakeRatesListener.cs | 4 +- .../Modules/Settings/FakeRatesSource.cs | 11 +- .../Modules/Settings/FakeSettingsStore.cs | 11 +- .../Modules/Settings/PromptDefaultsTests.cs | 12 +- .../Modules/Settings/SettingsCatalogTests.cs | 6 +- .../Modules/Tenants/AuditEventsTests.cs | 6 +- .../Modules/Tenants/AuditServiceTests.cs | 5 +- .../Modules/Tenants/AuthServiceTests.cs | 4 - .../Tenants/FailingTenantProvisioner.cs | 6 +- .../Modules/Tenants/FakeAuditLogStore.cs | 10 +- .../Modules/Tenants/FakeAuthStore.cs | 9 +- .../Modules/Tenants/FakeInviteStore.cs | 12 +- .../Modules/Tenants/FakeOperatorAuthStore.cs | 9 +- .../Modules/Tenants/FakePasswordHasher.cs | 3 +- .../Tenants/FakeRateLimitCounterStore.cs | 7 +- .../Modules/Tenants/FakeTenantLimitStore.cs | 22 +- .../Modules/Tenants/FakeTenantProvisioner.cs | 10 +- .../Modules/Tenants/FakeTenantRegistry.cs | 8 +- .../Modules/Tenants/FakeTenantRepository.cs | 7 +- .../Modules/Tenants/FakeTenantStore.cs | 8 +- .../Tenants/FakeTokenUsageEventStore.cs | 7 +- .../Tenants/InviteCodeGeneratorTests.cs | 2 +- .../Modules/Tenants/InvitesServiceTests.cs | 4 +- .../Modules/Tenants/JoinFlowTests.cs | 13 +- .../Tenants/OperatorAuthServiceTests.cs | 2 - .../Tenants/OperatorBootstrapServiceTests.cs | 2 +- .../Modules/Tenants/PasswordHasherTests.cs | 2 +- .../Modules/Tenants/SessionTokensTests.cs | 2 +- .../Tenants/SuspiciousActivityServiceTests.cs | 3 +- .../Tenants/TenantAdminServiceTests.cs | 10 +- .../Tenants/TokenBudgetServiceTests.cs | 10 +- .../Tenants/TokenUsageEventServiceTests.cs | 2 +- .../Support/AdminTickOrchestratorTests.cs | 27 +- .../Support/AiConnectionCheckerTests.cs | 10 +- .../Deal.Tests.Unit/Support/AiGrpcTestHost.cs | 4 +- .../Support/AiRawCardMapperTests.cs | 17 +- .../Support/BudgetAlertSchedulerTests.cs | 17 +- .../Support/CardReclassifierTests.cs | 8 +- .../Support/CardsServiceFilesTests.cs | 15 +- .../Support/CardsServiceSelectedTests.cs | 23 +- .../Support/CbrRateSourceTests.cs | 10 +- .../Support/ColumnRulesNewGroupsTests.cs | 6 +- .../Support/ContainersServiceTests.cs | 8 +- .../Support/ConversionRecomputerTests.cs | 17 +- .../Support/FakeSecretCipher.cs | 7 +- .../Support/FakeTelegramDialogRow.cs | 2 +- .../Support/FakeTelegramMessageRow.cs | 2 +- .../Support/ForwardedHeadersHttpTests.cs | 33 +- .../Support/GrpcAiClassifierTests.cs | 20 +- .../Support/IncomingRulesTests.cs | 19 +- .../Support/JoinEndpointHttpTests.cs | 11 +- .../Support/LocalColumnSuggesterTests.cs | 15 +- .../Support/LocalFileStorageTests.cs | 14 +- .../Support/LocalMlClientTests.cs | 24 +- .../Support/LoginAttemptEndpointHttpTests.cs | 12 +- .../Support/MessageParseCoreTests.cs | 16 +- .../Deal.Tests.Unit/Support/MlGrpcTestHost.cs | 4 +- .../Support/MlGrpcTestsCollection.cs | 3 +- .../Support/MtlsCertificatesTests.cs | 19 +- .../OperatorAnalyticsEndpointsHttpTests.cs | 8 +- .../OperatorAuditEndpointsHttpTests.cs | 8 +- .../Support/OperatorAuthEndpointsHttpTests.cs | 11 +- .../Support/OperatorAuthHttpHost.cs | 23 +- .../OperatorHealthEndpointsHttpTests.cs | 10 +- .../OperatorInvitesEndpointsHttpTests.cs | 10 +- .../OperatorLimitsEndpointsHttpTests.cs | 11 +- .../OperatorMaintenanceEndpointsHttpTests.cs | 6 - .../OperatorSettingsEndpointsHttpTests.cs | 9 +- .../OperatorTenantsEndpointsHttpTests.cs | 12 +- .../Support/OriginGuardHttpTests.cs | 24 +- .../Support/PipelineWorkerSchedulerTests.cs | 16 +- .../Support/PipelineWorkerServiceTests.cs | 39 +- .../Support/RateLimitHttpTests.cs | 21 +- .../Support/RatesServiceTests.cs | 16 +- .../Support/ServiceHealthProbeTests.cs | 10 +- .../Support/SettingsServiceTests.cs | 14 +- .../Deal.Tests.Unit/Support/SseBrokerTests.cs | 10 +- .../Support/StorageTickSchedulerTests.cs | 26 +- .../Support/StorageToastPublisherTests.cs | 9 +- .../Support/StubHttpMessageHandler.cs | 10 +- .../Support/SuggestResultDtosTests.cs | 9 +- .../Support/TelegramGrpcTestHost.cs | 4 +- .../Support/TelegramIngressServiceTests.cs | 38 +- .../Support/TelegramIngressTestHost.cs | 19 +- .../tests/Deal.Tests.Unit/Support/TestPort.cs | 1 - .../Support/TokenUsageRecorderTests.cs | 6 +- .../Interceptors/RpcCallLoggingInterceptor.cs | 23 +- .../Interceptors/ServiceTokenInterceptor.cs | 24 +- .../Models/MtlsCertificates.cs | 31 +- .../Deal.Grpc.Hosting/Options/MtlsOptions.cs | 33 +- .../Deal.Grpc.Hosting/Services/DealLogging.cs | 20 +- .../Services/DealMetricsHosting.cs | 31 +- .../Services/GrpcHostEnvironment.cs | 16 +- .../Deal.Grpc.Hosting/Services/GrpcServer.cs | 24 +- .../Deal.Ml.Tests/Grpc/MlRpcTests.cs | 22 +- .../Deal.Ml.Tests/Grpc/MlServiceHostTests.cs | 27 +- .../Deal.Ml.Tests/Grpc/MlTestHost.cs | 15 +- .../Deal.Ml.Tests/Ml/LearningData.cs | 18 +- .../Deal.Ml.Tests/Ml/MlTokenizerTests.cs | 10 +- .../Deal.Ml.Tests/Ml/ModelPersistenceTests.cs | 13 +- .../Deal.Ml.Tests/Ml/ModelPoolTests.cs | 7 +- .../Ml/TenantModelLearningTests.cs | 32 +- .../Deal.Ml/Extensions/LabelExtensions.cs | 4 +- src/ml-service/Deal.Ml/MlServiceHost.cs | 32 +- src/ml-service/Deal.Ml/MlServiceImpl.cs | 31 +- src/ml-service/Deal.Ml/Model/EvalEntry.cs | 4 +- src/ml-service/Deal.Ml/Model/LearnItem.cs | 4 +- src/ml-service/Deal.Ml/Model/MlEvalInfo.cs | 2 +- src/ml-service/Deal.Ml/Model/MlOptions.cs | 19 +- .../Deal.Ml/Model/MlPredictResult.cs | 5 +- .../Deal.Ml/Model/MlStatusResult.cs | 5 +- src/ml-service/Deal.Ml/Model/MlTokenizer.cs | 11 +- .../Deal.Ml/Model/MlTypeDecision.cs | 4 +- .../Deal.Ml/Model/ModelConstants.cs | 48 +- src/ml-service/Deal.Ml/Model/ModelPool.cs | 11 +- src/ml-service/Deal.Ml/Model/ModelState.cs | 16 +- .../Deal.Ml/Model/OnlineNaiveBayes.cs | 27 +- src/ml-service/Deal.Ml/Model/TenantModel.cs | 31 +- src/ml-service/Deal.Ml/Program.cs | 11 - src/ml-service/Deal.Ml/Storage/MlDb.cs | 31 +- .../Grpc/CoreIngressClientTests.cs | 9 +- .../Grpc/DialogRpcTests.cs | 19 +- .../Grpc/DiscoveryProtoMapperTests.cs | 16 +- .../Grpc/DiscoveryRpcTests.cs | 24 +- .../Grpc/FakeIngressServer.cs | 10 +- .../Grpc/RealtimeListenerTests.cs | 13 +- .../Grpc/TelegramServiceHostTests.cs | 27 +- .../Grpc/TelegramSessionRpcTests.cs | 10 +- .../Grpc/TelegramTestHost.cs | 18 +- .../Deal.Telegram.Tests/Grpc/TestDoubles.cs | 16 +- .../Support/SessionStorageTests.cs | 25 +- .../Support/TenantSessionTests.cs | 20 +- .../Telegram/BackfillServiceTests.cs | 14 +- .../Telegram/DialogCatalogTests.cs | 14 +- .../Telegram/DialogHueTests.cs | 11 +- .../Telegram/DiscoveryOpsTests.cs | 26 +- .../Telegram/FakeSessionClient.cs | 65 +- .../Telegram/FarmHarness.cs | 8 +- .../Telegram/LruCacheTests.cs | 9 +- .../Telegram/RealtimeSweepTests.cs | 10 +- .../Telegram/SessionHarness.cs | 7 +- .../Telegram/TestSessionFactory.cs | 6 +- .../Telegram/TlMessageMapperTests.cs | 22 +- .../Deal.Telegram/Caching/LruCache.cs | 18 +- .../Deal.Telegram/Core/CoreIngressClient.cs | 17 +- .../Deal.Telegram/Core/CoreIngressOptions.cs | 26 +- .../Deal.Telegram/Core/ICoreIngressClient.cs | 19 +- .../Deal.Telegram/Dialogs/BackfillService.cs | 31 +- .../Deal.Telegram/Dialogs/DialogCatalog.cs | 34 +- .../Deal.Telegram/Dialogs/DialogHue.cs | 11 +- .../Dialogs/DialogProtoMapper.cs | 11 +- .../Deal.Telegram/Dialogs/IBackfillPacer.cs | 9 +- .../Dialogs/RandomBackfillPacer.cs | 3 +- .../Deal.Telegram/Dialogs/RealtimeListener.cs | 15 +- .../Deal.Telegram/Dialogs/RealtimeSweep.cs | 20 +- .../Deal.Telegram/Discovery/DiscoveryOps.cs | 38 +- .../Discovery/DiscoveryProtoMapper.cs | 12 +- .../Extensions/ExceptionExtensions.cs | 2 +- .../Hosting/RealtimeMonitorService.cs | 9 +- .../Hosting/RealtimeSweepService.cs | 5 +- .../Hosting/SessionHeartbeatService.cs | 13 +- src/telegram-service/Deal.Telegram/Program.cs | 12 - .../Deal.Telegram/Sessions/AuthPhase.cs | 15 +- .../Sessions/SessionErrorMessages.cs | 43 +- .../Sessions/SessionException.cs | 9 +- .../Deal.Telegram/Sessions/SessionFarm.cs | 39 +- .../Sessions/SessionFileCipher.cs | 19 +- .../Deal.Telegram/Sessions/SessionStore.cs | 23 +- .../Deal.Telegram/Sessions/StoredSession.cs | 12 +- .../Deal.Telegram/Sessions/TenantSession.cs | 80 +- .../Sessions/TenantSessionSnapshot.cs | 16 +- .../Deal.Telegram/Sessions/TgOptions.cs | 26 +- .../Deal.Telegram/Telegram/ClientFactory.cs | 8 +- .../Deal.Telegram/Telegram/DialogKinds.cs | 12 +- .../Telegram/DiscoveryMessage.cs | 13 +- .../Telegram/DiscoveryReadResult.cs | 16 +- .../Deal.Telegram/Telegram/ISessionClient.cs | 86 +- .../Telegram/ITelegramClientFactory.cs | 11 +- .../Deal.Telegram/Telegram/TelegramDialog.cs | 16 +- .../Deal.Telegram/Telegram/TelegramMessage.cs | 13 +- .../Telegram/TelegramSourceInfo.cs | 17 +- .../Deal.Telegram/Telegram/TlMessageMapper.cs | 35 +- .../Telegram/WTelegramSessionClient.cs | 51 +- .../Deal.Telegram/TelegramServiceHost.cs | 41 +- .../Deal.Telegram/TelegramServiceImpl.cs | 60 +- 902 files changed, 3902 insertions(+), 12074 deletions(-) create mode 100644 scripts/__pycache__/_slim_comments.cpython-312.pyc diff --git a/backlog.md b/backlog.md index 8df60fb..7b5aeca 100644 --- a/backlog.md +++ b/backlog.md @@ -15,7 +15,7 @@ | BL-ML-EXP | Экспорт/импорт ML-моделей (перенос «мозгов» между инстансами) | обсуждение этапа 12 | P3 | DEFERRED (решено не делать; вернуться при SaaS-масштабе) | | BL-RECLASS-SSE | Стриминг-прогресс пакетной переклассификации + финальный тост (сейчас синхронный проход + событие `cards_reclassified`) | этап 12, D | P3 | BACKLOG | | TD-CARD-MERGE | Полное слияние внутренних DTO карточки в единый `CardDto` (наружу уже единый) | этап 9/11 | P3 | TECHDEBT | -| TD-PROTO-COMMENTS | Убрать из XML/обычных комментариев ссылки на прототип (`backend/app/*.py`, `LEADRADAR_*`, «прототип», номера строк python) и **переписать комментарии с нуля** — описывать текущее поведение и контракт, а не происхождение. Масштаб: ~**374 файла** (core 302, telegram 27, ml 15, ai 11). Делать **после окончательного перехода на новый стек**; правки только в комментариях (логику не трогать), с проверкой build+тестов | запрос владельца 2026-09-11 | P2 | TECHDEBT | +| TD-PROTO-COMMENTS | **Сделано (2026-09-11):** из комментариев убраны ссылки на процесс/прототип (`Task/Ruling/этап/python L…/main.py/прототип/LEADRADAR_*`), удалены блоки ``, `` сжаты до короткой фразы; `//`-комментарии со ссылками удалены, в `.proto` — тоже. Строк комментариев 27 210 → ~19 100 | запрос владельца 2026-09-11 | P2 | DONE | | TD-COMMENTS-IFACE | Привести код к правилам код-стайла (`docs/spec/Код-стайл-Дейл.md`). **Сделано (2026-09-11):** (1) `` только блочно — исправлено 5286 шт. в 833 файлах; (2) комментарии только на public/protected — понижено 2028 XML-доков с private/internal (359 файлов). **Осталось:** (3) не дублировать `` интерфейса в реализации (нужен Roslyn-анализ); (4) явная реализация интерфейсов там, где возможно (61 интерфейс, точечный ревью). Скрипты: `scripts/fix_summary_blocks.py`, `scripts/fix_private_docs.py`. Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md` | запрос владельца 2026-09-11 | P2 | TECHDEBT (1,2 — DONE; 3,4 — BACKLOG) | | TD-STYLE-ANALYZERS | Остаток мягких правил код-стайла: `var` для встроенных/неочевидных типов (1529, сейчас `silent`), дедупликация ``→`` (Roslyn), решение по переводам строк (`.editorconfig` = CRLF, фактически 231 CRLF / 697 LF). Уже закрыто в `.editorconfig` (+build-проверка): запрет `this.` и именование приватных полей (`_camelCase`; `const`/`static readonly` — Pascal). Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md` | аудит 2026-09-11 | P3 | BACKLOG | diff --git a/docs/spec/Код-стайл-Дейл.md b/docs/spec/Код-стайл-Дейл.md index 274eefd..bb39d9d 100644 --- a/docs/spec/Код-стайл-Дейл.md +++ b/docs/spec/Код-стайл-Дейл.md @@ -112,6 +112,15 @@ там, где неочевидна причина/ограничение (короткий обычный комментарий). - **Кратко.** Комментарий объясняет **зачем и что**, а не пересказывает код. Не дублировать имя и сигнатуру словами. +- **`` — короткое описание (одна фраза).** Это назначение типа/члена, а **не** «как оно + работает» и не пояснения/детали реализации. Несколько предложений в summary не пишем. +- **`` не используем** — подробные пояснения «как устроено» не нужны; rationale — только если + поведение действительно неочевидно, коротким обычным комментарием. +- **Никаких упоминаний процесса:** в комментариях запрещены ссылки на таски/этапы/рулинги/планы и + прототип (`Task N`, `Ruling N`, `этап N`, `python L…`, `main.py`, `прототип`, `LEADRADAR_*` и т.п.). +- **Внутренние `//`-комментарии — только для неочевидного поведения** (причина, ограничение, подвох). + Пересказ кода, пошаговая навигация и «что делает следующая строка» — удалять. +- **``/``** — только если смысл не очевиден из имени/типа; не переписывать сигнатуру. - **`` — только блочный.** Открывающий `` и закрывающий `` — **каждый на своей строке**; запись в одну строку (`/// текст`) **не допускается**. **[изм.]** diff --git a/scripts/__pycache__/_slim_comments.cpython-312.pyc b/scripts/__pycache__/_slim_comments.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f0ce1595c0b47731df239ddfc1f8ce4b21e70643 GIT binary patch literal 9349 zcmb_CYit`wdb`Wlid<48MLjJ0Y)ZClQBNy&tXP)i)5|_Pu`ibF#+E1(6nFKYB}&?* zUd$55_|lfFbrlsGCmKx<-nGRkNDfdRS18vcPI1>ZMSpY|(h$ATk06%<^*<-h0Zsq3 z-z>RH%AxHO(6KqQGxN>&nEmE^%7v)M0RydvnxYL*8)*t^6K#aLm@(6)>$MohBHG38W?93rMPiX~6wkf| z1Hvzajl^q-8^W)Jbvsla2_L|}UkM+=(0U>yY{2v1w%HRu7XAi$>xmnQC3`!|OuE_Y zTt|aF@tW`t@+dbu3B!wM^vA;A3+uu^0%`;g-MbRv4dFu={t$+j?ZREa_fg_cgkJ&q z*M#-v?7$5eya8nI3hPY|5XTn+rtUVUpP3kTbBvQ?Jr1>~8XotFs;ObGNcpB{#x*%b z&rUF0K;1Mt?hlx*)jGL4M{{#?ZBw1&z-$0sL+z=yOK$G^AUAXh=A3jr48X(S7ol5? zLzxlBDZX!ZV%#_S9iZk08KjO+`~>ZcaMw9smqGCaT!8IyiC_u;Qg3HZUuREepUXL4 z&rdJ<$EJL|n_KjG#;5o}=X_K1P`$HhdXaxAgEz|{T@U!2!-JRneM2ze9EMIyTZ`1X z2HlN;Aygf1JnkB*=O@NFziWbV(;PqP_It)$erCbXd!{C*r#QxyeNDV)jPYD|!K)ni z(G1J+EHlC~+?b2=yZy7M%Q1emvCIVT9|uUzKgGI78Gf3b^18S=5AOjIJ{LDO#R76S zOTWwl1wSvnVf6Ap68;V(b_7Ug?clXq$535>%(PC2Cwnj?jnqP|g5RSHP_JTsWsb&W zMeo6eDljaF`w3|UR9=nzlxt~%$KK6C1hJ(`HReaSrSfcee~|(!8v%I$T3RJxM|?sF z=5wjeZ2;G*ckOPJT+7pOqlZZD3v<=c@ zfT}&!e$vS~oK4QA`rng4&YoeT(V-PJtlKxrh(#}V_Vr$H_4S?>Rov{bsD&r!i3H1t z8qUv-Pdf+^_lvlTLz3F<|8pSUU3dV%&pBP(#Q3BOOyDHr^K)QdMU7;>SlD{39DZCM zn4J)|Wwb_H{`^W9-=a#F&qvP3_Qz(DR8<6jsKQKTUy`O}eMBEU62p^ZWvFwD)Guoz z+VEVIi*+W+y)cqnZ(2SbIUQ|_vGJB9Rf8}}BSK02TK{@gk~*34Llq$GwGU9d%St_KZBzZJ!KSKHLk=k*;tx0;Kva!6 zv@B{wjTD23stLv?YDU?q*=bQd0x<`RY>z|DmLVW~QpCqaV)8i-`CdB|792h%>ZI_3 z@v(=X*9brEub_f+3MY?@kbxa-C|0ECCbGvVIMv@W8H>XYPd&~rF|6_EcQmqtH2X^o?A+W z@@Ye~qu~iZvd2;>l-wxsi;BjAYS7w7Khg%N@T#YCt8k&=HLSsJQPwF5lMm-LK{QK1 ztIp#UxunMmt)+F(;`TlAU8Gk~g4$T4E5wpbjRlJkOrAuAcrfgM2hu}G92kK*S%@;wEe5*! zXHunhK-KUa$qgX6p%8sVaw-Tgw2{}&VL{_47Bul{*2bH7oHn6E8QNm0RcCu<)Q+kG zNHP;&1Q|tJ-Y%0IzEqUAkA-}Zg(qcS#NtbK-sDAr!(#>LT6x`HU~~yjLT!6XgXqc{ zk7G;i5Vz@p`uETd^N6nYiI_?y9A$5*9>cK@a8Q6_e^vstTapA%#8l236rL)+!&435 zGosylf<(Kv)8|e7prttGQ3c7MZa(u&1j+0Z8a2#gj>-!x+D3K{R3fh5W((12JOaN* zA*f!&aIl9wHgpX31b>EIRO04>Hb&X$LVi=Wf3&LXq!z(vi-gnN_&DEZPxf|5Id z^X=fYr`@c3lJ_r8Gtv_Z*M06u=A^S4GIwY7P`$%h4MaP(F;HL~>;;hV&$2#lJFGky zINr`Ic$jJb_>>Qbc;L?A=|mLhj)0+k+&9Ls<9^uYfDtZt?26z4HR=XT(x9?4vnYbN zCj1;OB7Ti-wCvK&Qs#2&X22o=OY+(!VXFf~bw_|~Zv@@oaDIa8PVL@RU<9QSF@?_hsTFEyuW7&zOk!OG$|o@vz9>i8>T6 zxhEz>t$TW!@zIWAQ3Ll>3@2*Q-Hp#Ls?iOPNN|%7FlBDxM4S`VaEAklkx#_uM6Dzh zC+f07h`2}8E8KFTVa(0BvdqHG+N_^L?i>YTc_pLzhCF>|djRSL{5Ut9b(BAum#;>y zMrUIeR~C{+`=;?=!gvtU6Qd(^I&COkJ{~!qKt~{H>q;8BLOm$X+7;O)lpI~BKj^yK z^L|gVs58`+Hd#YwAmrMjEO{{(wKt64uiU)^0o~HMxCJ@}DqU6{KC@*oE+2~=6Rh=X zee1Rl`tDwO|58dq|D=g8P3C29#4GF?7p_mGX1L9n*~H9j$~5jSA{`W%I(>7GYL17vW@^j>ll9DIsyc1 z9P!ThOtj@T6YIP+_C{mcI)Vrm13`fK+`2D*6e7~li)lR->b|ctrAb3n8{3yK*RCC0 zKlV=Z#+gK2_s4%ET)39#y(Sd95~M4v`pjGwQHSwxi)f&3_pM%8xgzX6zR_~8^kkYU zUOpE&7o8KTkF0mDA6XyXuxzL{UJ&d(iSnL%RPRF#W+{K9#Z0AP99Apv@y2z0ja#o1 z>?afDC+|@m0B9-&K+A50lmaYNw{GC;+(z}r#f|O_Ua-HED1Yf5bsiz#{goCYOF#@M zuRbR1Ih-gtoFZG&22<$Vw~riXpTGUWH2Cc!0_^~Y&cko1^0RjBKO3!Q532vAsQhfb zcAINa63o{@BUdp94dCWJNaU^+A>GPeF$PsU!K-V4x%E3>?6F!tE2*%aa7X^1+NnD=VoQs%$|AHY6#$!B*2UO^J9fq z5^q+U+qq;vm>TThkt>V(+@9YmHU?oTf+W|C?-GXZ<<+=;MQUXe<+QF4o8 ze#H{d`rPOuMjM{KdyqU5G!<0&l~m4)qQ=LHzfvBMaYOR;J4n9Z4F}ahO;F2gPy(Nq zJnJBFM~RPA=5R>!C|;LIo;^fR$7^V~K-`V-TEO%HV1WeAyHAGCwX{*D2pCHJ=&npE zUVHW-uPL;9ygHY(28(iUgfII4(14(xHt~AUV1hRJa9R^I@Op0nt>6u^6rdFa-e-{F zDIUMx3T6ji3iI$mMe=yyouMDy#`PbdN4%lnPEaxratj{rBQ>`4pYVxH(H@=vS&Bgx zGi~8j^5qPCF_)8ENOv993xT4>#%Y$B8)xP-=`Fzy2Q;((k;WHT2fUJijmvy^Xm(}6 zn*0!;|j*P>1@HU49K9(#(2H<8#=R^(45*9@D6eJHQDR9%G zmS%uH`hqe&G42;N^N?3D>|tPC@&KY{njMF{f|vpv^X`f3qN=~ITU7O4=|-&6-2AwI zOjJ+1{bLRzi~NP;engGLCFGol3z49w;pY>H5lBI35g!rpX;Hl&#y<5fB<%P(?o2s|TJLKD@JEr)n$%-TE z?nFgvqWFcdddqB!UWy$}n(dqBnuNI~X+E@RZc3P&*5;Gu)=l%tg!$wKoiulcHCrSl z8288f*IPgCN|9$a$@2;F{3rB(jQ`ts>Ix%_Tu)w^NM4vsk-ja`D46O}WPO@6rK!?4 zOWv-yQz1}QpPvL6`hG!y?5m24R2Ut zT?un@n25B5HJ@2Z!kx%dwXL+pm{iFD@JU-{>#A-=C%MbMxFu=cAMV_;mPKb`2W~ZP zS`Q?w2a?uSTrT!1KxG1#m(kN8s+>@XI`k(Apea(sBdXxTUM8SH@nK#L+=IF(E|C-h z(LnjS%k>T>Ip0heL$+uB2Bd?X0Z0_hakrs*sKRmlD*}T*;rR?JlK$&I*OrG=X>9|l z%%R~w^}gm6EY(Tv{*daSR;xLUzV+eihgM8)3tN{fBbCwaoBKlAG*uSvlG3GDPm_0{{yzn3jP29 literal 0 HcmV?d00001 diff --git a/src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs b/src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs index 094cf06..a6a0111 100644 --- a/src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs +++ b/src/ai-service/Deal.Ai.Tests/Ai/AiRpcTests.cs @@ -7,11 +7,7 @@ using Microsoft.Extensions.DependencyInjection; namespace Deal.Ai.Tests.Ai; /// -/// In-proc gRPC-тесты AiService поверх фейк-провайдера (план Task 8, Acceptance): все 4 RPC -/// (Filter/Classify/GenerateKeywords/EvaluateFit) с подменой LLM-фасада (без сети) через -/// реальный хост (Kestrel HTTP/2, интерцептор service-token). Проверяются: разбор решений и -/// usage в ответах, собранные сервисом промпты, недоступность провайдера → UNAVAILABLE с текстом -/// 1:1 Ruling 5, ответ без JSON в Classify → ok=false (не ошибка), INVALID_ARGUMENT конфига. +/// In-proc gRPC-тесты AiService поверх фейк-провайдера /// public sealed class AiRpcTests { @@ -21,7 +17,6 @@ public sealed class AiRpcTests // Usage API-ответа сценариев (проверка проброса в reply). private static readonly ProviderUsage SampleUsage = new(11, 5, 16); - // Текст ошибки UNAVAILABLE 1:1 Ruling 5 / ai.py L115–117. private const string UnavailableDetail = "ИИ (DeepSeek) не ответил корректно — повторите попытку через несколько секунд"; @@ -49,7 +44,6 @@ public sealed class AiRpcTests Assert.Equal("похоже на заявку", reply.Reason); AssertUsage(reply.Usage, SampleUsage); - // Сервис передаёт промпт system-сообщением и оборачивает текст как ai.py L193. FakeProviderCall call = Assert.Single(fake.Calls); Assert.Equal("Фильтр: {domain}", call.SystemPrompt); Assert.Equal("Сообщение:\nИщем разработчика на проект", call.UserText); @@ -77,7 +71,7 @@ public sealed class AiRpcTests } /// - /// Filter: модель не вернула pass — по умолчанию пропуск (1:1 ai.py L195: bool(get(pass, true))). + /// Filter: модель не вернула pass — по умолчанию пропуск /// [Fact] public async Task Filter_MissingPassField_DefaultsToPass() @@ -97,8 +91,7 @@ public sealed class AiRpcTests } /// - /// Filter: провайдер недоступен после ретраев (3 попытки) → UNAVAILABLE с текстом 1:1 Ruling 5 - /// (ядро трактует как «ИИ недоступен» и пропускает сообщение локальным путём). + /// Filter: провайдер недоступен после ретраев /// [Fact] public async Task Filter_ProviderUnavailable_ThrowsUnavailableWithDetail() @@ -120,7 +113,7 @@ public sealed class AiRpcTests } /// - /// Filter: ответ модели без JSON после ретраев — тоже UNAVAILABLE (у метода нет ok-поля). + /// Filter: ответ модели без JSON после ретраев — тоже UNAVAILABLE /// [Fact] public async Task Filter_AnswerWithoutJson_ThrowsUnavailable() @@ -141,8 +134,7 @@ public sealed class AiRpcTests } /// - /// Classify: модель вернула JSON — ok=true, json = извлечённый ответ строкой (маппинг в ядре), - /// usage пробрасывается. + /// Classify: модель вернула JSON — ok=true, json = извлечённый ответ строкой /// [Fact] public async Task Classify_ModelAnsweredJson_ReturnsOkAndJson() @@ -178,8 +170,7 @@ public sealed class AiRpcTests } /// - /// Classify: модель отвечала, но без разбираемого JSON после ретраев → ok=false, НЕ RPC-ошибка - /// (README ai.proto L201–204); usage последней попытки в ответе (оценка по символам). + /// Classify: модель отвечала, но без разбираемого JSON после ретраев → ok=false, НЕ RPC-ошибка; usage последней попытки в ответе /// [Fact] public async Task Classify_AnswerWithoutJson_ReturnsOkFalseWithUsage() @@ -211,7 +202,7 @@ public sealed class AiRpcTests } /// - /// Classify: провайдер недоступен — UNAVAILABLE (ядро падает в локальный разбор, aiFail). + /// Classify: провайдер недоступен — UNAVAILABLE /// [Fact] public async Task Classify_ProviderUnavailable_ThrowsUnavailable() @@ -232,7 +223,7 @@ public sealed class AiRpcTests } /// - /// GenerateKeywords: ключи из JSON-ответа + фиксированный промпт с описанием задачи. + /// GenerateKeywords /// [Fact] public async Task GenerateKeywords_ModelReturnedKeywords_ReturnsList() @@ -253,7 +244,6 @@ public sealed class AiRpcTests Assert.Equal(["стройка", "ремонт квартир", "подряды"], reply.Keywords); AssertUsage(reply.Usage, SampleUsage); - // Фиксированный промпт (routes L36–47) и пользовательское сообщение с описанием. FakeProviderCall call = Assert.Single(fake.Calls); Assert.Contains("эксперт по поиску Telegram-каналов", call.SystemPrompt, StringComparison.Ordinal); Assert.Contains("Верни строго JSON", call.SystemPrompt, StringComparison.Ordinal); @@ -262,7 +252,7 @@ public sealed class AiRpcTests } /// - /// GenerateKeywords: не-строковые элементы списка пропускаются (чистку делает ядро). + /// GenerateKeywords /// [Fact] public async Task GenerateKeywords_NonStringItems_Skipped() @@ -281,7 +271,7 @@ public sealed class AiRpcTests } /// - /// GenerateKeywords: модель не вернула ключи — пустой список (не ошибка). + /// GenerateKeywords /// [Fact] public async Task GenerateKeywords_NoKeywordsField_ReturnsEmpty() @@ -300,8 +290,7 @@ public sealed class AiRpcTests } /// - /// EvaluateFit: fit=1 + причина; промпт собран сервисом из описания и ключей (discovery_eval - /// L50–54), сообщение — как «Сообщение:\n…». + /// EvaluateFit: fit=1 + причина; промпт собран сервисом из описания и ключей, сообщение — как «Сообщение:\n…». /// [Fact] public async Task EvaluateFit_ModelFits_ReturnsFitAndReason() @@ -332,7 +321,7 @@ public sealed class AiRpcTests } /// - /// EvaluateFit: ключи задачи подставляются в промпт строкой через запятую (1:1 _ai_prompt). + /// EvaluateFit: ключи задачи подставляются в промпт строкой через запятую. /// [Fact] public async Task EvaluateFit_KeywordsJoinedIntoPrompt() @@ -358,8 +347,7 @@ public sealed class AiRpcTests } /// - /// EvaluateFit: fit=0 без причины модели — причина по умолчанию «не подходит» (1:1 _ai_reason - /// discovery_eval L167–171); строковое «нет» трактуется как ложь (1:1 _ai_fit L158–164). + /// EvaluateFit: fit=0 без причины модели — причина по умолчанию «не подходит»; строковое «нет» трактуется как ложь. /// [Fact] public async Task EvaluateFit_ModelNoFit_ReturnsDefaultReason() @@ -379,7 +367,7 @@ public sealed class AiRpcTests } /// - /// EvaluateFit: fit строкой «нет» — false (паритет _ai_fit), причина из модели. + /// EvaluateFit: fit строкой «нет» — false, причина из модели. /// [Fact] public async Task EvaluateFit_StringFalsyFit_ReturnsFalse() @@ -399,7 +387,7 @@ public sealed class AiRpcTests } /// - /// EvaluateFit: длинная причина модели усекается до 200 символов (1:1 _AI_REASON_LIMIT). + /// EvaluateFit: длинная причина модели усекается до 200 символов. /// [Fact] public async Task EvaluateFit_LongReason_IsTruncatedTo200() @@ -420,7 +408,7 @@ public sealed class AiRpcTests } /// - /// Конфиг-валидация: запрос без конфига провайдера (пустой base_url) → INVALID_ARGUMENT. + /// Конфиг-валидация /// [Fact] public async Task Classify_WithoutProviderConfig_IsInvalidArgument() @@ -441,7 +429,7 @@ public sealed class AiRpcTests } /// - /// Конфиг-валидация: пустая model конфига → INVALID_ARGUMENT (вызов модели невозможен). + /// Конфиг-валидация /// [Fact] public async Task GenerateKeywords_EmptyModel_IsInvalidArgument() @@ -466,7 +454,7 @@ public sealed class AiRpcTests } /// - /// Серверный лимит text (ai.proto Filter.text: core обрезает до 4000): превышение → INVALID_ARGUMENT. + /// Серверный лимит text /// [Fact] public async Task Filter_TooLongText_IsInvalidArgument() @@ -492,7 +480,7 @@ public sealed class AiRpcTests } /// - /// Серверный лимит description (ai.proto GenerateKeywords.description: core обрезает до 4000). + /// Серверный лимит description /// [Fact] public async Task GenerateKeywords_TooLongDescription_IsInvalidArgument() @@ -517,7 +505,7 @@ public sealed class AiRpcTests } /// - /// Обязательный tenant-id в metadata (Ruling 1): отсутствует → UNAUTHENTICATED до вызова. + /// Обязательный tenant-id в metadata /// [Fact] public async Task Classify_WithoutTenantId_IsUnauthenticated() diff --git a/src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs b/src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs index 4d0796e..0ee5f0b 100644 --- a/src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs +++ b/src/ai-service/Deal.Ai.Tests/Ai/AiTestHost.cs @@ -12,7 +12,6 @@ using Microsoft.Extensions.DependencyInjection; namespace Deal.Ai.Tests.Ai; -// Общий харнесс in-proc gRPC-тестов ai-service (план Task 8): поднимает хост (AiServiceHost.Create) // в процессе теста на эфемерном порту и через configureServices-хук подменяет LLM-фасад фейком // (FakeProviderClient, без сети) и функцию паузы ретраев (мгновенная) — сценарии не // ждут 0.8/2 с между попытками. Регистрация, добавленная харнессом после дефолтных, побеждает @@ -20,17 +19,17 @@ namespace Deal.Ai.Tests.Ai; internal static class AiTestHost { /// - /// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor). + /// Env-ключ ожидаемого service-token /// public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; /// - /// Ключ gRPC-metadata с service-token (зеркало ServiceTokenInterceptor). + /// Ключ gRPC-metadata с service-token /// public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey; /// - /// Ключ gRPC-metadata с tenant-id (зеркало AiServiceImpl). + /// Ключ gRPC-metadata с tenant-id /// public const string TenantIdMetadataKey = AiServiceImpl.TenantIdMetadataKey; @@ -45,7 +44,7 @@ internal static class AiTestHost public const string DefaultTenantId = "tenant-test"; /// - /// Deadline RPC-вызовов теста (сек). + /// Deadline RPC-вызовов теста /// public const int RpcDeadlineSeconds = 15; @@ -100,7 +99,7 @@ internal static class AiTestHost } /// - /// Подменяет HTTP-фасад вызовов модели фейком сценария (последняя регистрация побеждает). + /// Подменяет HTTP-фасад вызовов модели фейком сценария /// /// Коллекция сервисов хоста. /// Фейк-провайдер сценария. @@ -108,14 +107,14 @@ internal static class AiTestHost => services.AddSingleton(fake); /// - /// Делает паузы ретраев мгновенными (иначе сценарии ждали бы 0.8/2 с). + /// Делает паузы ретраев мгновенными /// /// Коллекция сервисов хоста. public static void DisableRetryDelays(IServiceCollection services) => services.AddSingleton>(static (_, _) => Task.CompletedTask); /// - /// Строит metadata вызова: service-token (+ tenant-id, если задан). + /// Строит metadata вызова /// /// Значение заголовка service-token. /// Id тенанта (null — без заголовка tenant-id). @@ -136,7 +135,7 @@ internal static class AiTestHost } /// - /// CallOptions RPC: metadata + deadline (рекомендации README src/contracts L62–73). + /// CallOptions RPC /// /// Metadata вызова. public static CallOptions CallOptions(Metadata metadata) diff --git a/src/ai-service/Deal.Ai.Tests/Ai/FakeProviderClient.cs b/src/ai-service/Deal.Ai.Tests/Ai/FakeProviderClient.cs index e5d7f75..5e2397f 100644 --- a/src/ai-service/Deal.Ai.Tests/Ai/FakeProviderClient.cs +++ b/src/ai-service/Deal.Ai.Tests/Ai/FakeProviderClient.cs @@ -8,7 +8,6 @@ namespace Deal.Ai.Tests.Ai; // Config: Конфиг провайдера вызова. internal sealed record FakeProviderCall(string SystemPrompt, string UserText, LlmConfig Config); -// Фейк-провайдер LLM-вызовов (без сети; план Task 8): поведение задаётся сценарием (ответ текстом, // usage либо сбой), каждый вызов записывается в Calls — тесты проверяют и собранные // RPC-слоем промпты, и число попыток ретраев. internal sealed class FakeProviderClient : IProviderClient @@ -25,7 +24,7 @@ internal sealed class FakeProviderClient : IProviderClient } /// - /// Все вызовы фейка в порядке поступления (для проверок RPC-веток). + /// Все вызовы фейка в порядке поступления /// public List Calls { get; } = []; diff --git a/src/ai-service/Deal.Ai.Tests/Ai/ProviderCallerTests.cs b/src/ai-service/Deal.Ai.Tests/Ai/ProviderCallerTests.cs index df2749c..155df62 100644 --- a/src/ai-service/Deal.Ai.Tests/Ai/ProviderCallerTests.cs +++ b/src/ai-service/Deal.Ai.Tests/Ai/ProviderCallerTests.cs @@ -4,14 +4,12 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Deal.Ai.Tests.Ai; /// -/// Unit-тесты оркестратора вызовов модели (план Task 7; ProviderCaller, 1:1 chat_json ai.py L80–117): -/// ретраи 2 с паузами 0.8/2 с, извлечение JSON, различение «провайдер не ответил» и «ответ без -/// JSON», usage. Фейк-провайдер без сети; паузы ретраев мгновенные (харнесс-делегат). +/// Unit-тесты оркестратора вызовов модели /// public sealed class ProviderCallerTests { /// - /// Успех с первого вызова: JSON-объект извлечён, usage API-ответа в результате. + /// Успех с первого вызова /// [Fact] public async Task ChatJsonAsync_FirstAttemptSuccess_ReturnsJsonAndUsage() @@ -29,7 +27,7 @@ public sealed class ProviderCallerTests } /// - /// Марdown-обёртка ответа модели снимается фасадом (extract_json L175–183). + /// Марdown-обёртка ответа модели снимается фасадом. /// [Fact] public async Task ChatJsonAsync_MarkdownWrappedJson_Extracts() @@ -45,7 +43,7 @@ public sealed class ProviderCallerTests } /// - /// Без usage API-ответа фасад оценивает токены по символам (≈chars/4). + /// Без usage API-ответа фасад оценивает токены по символам /// [Fact] public async Task ChatJsonAsync_WithoutProviderUsage_EstimatesTokens() @@ -63,7 +61,7 @@ public sealed class ProviderCallerTests } /// - /// Сбой на первых двух попытках и успех на третьей: итог успешен, попыток — 3. + /// Сбой на первых двух попытках и успех на третьей /// [Fact] public async Task ChatJsonAsync_TwoFailuresThenSuccess_RetriesAndSucceeds() @@ -86,8 +84,7 @@ public sealed class ProviderCallerTests } /// - /// Все попытки — транспортный сбой: LlmCallException ProviderUnavailable с текстом 1:1 Ruling 5 - /// (ai.py L115–117); usage отсутствует (модель не отвечала). + /// Все попытки — транспортный сбой /// [Fact] public async Task ChatJsonAsync_AllTransportFailures_ThrowsProviderUnavailable() @@ -108,8 +105,7 @@ public sealed class ProviderCallerTests } /// - /// Модель отвечала, но ни одна попытка не дала разбираемый JSON: AnswerNotJson с usage последней - /// попытки (Classify вернёт ok=false; README ai.proto L201–204). + /// Модель отвечала, но ни одна попытка не дала разбираемый JSON /// [Fact] public async Task ChatJsonAsync_AllAnswersNotJson_ThrowsAnswerNotJsonWithUsage() @@ -128,8 +124,7 @@ public sealed class ProviderCallerTests } /// - /// Отмена (deadline RPC) прерывает вызов без «упаковки» в ошибку провайдера: исключение - /// отмены из попытки не перехватывается как сбой (ретрятся только LlmHttpException). + /// Отмена (deadline RPC) прерывает вызов без «упаковки» в ошибку провайдера /// [Fact] public async Task ChatJsonAsync_Cancelled_PropagatesCancellation() diff --git a/src/ai-service/Deal.Ai.Tests/Ai/TokenEstimatorTests.cs b/src/ai-service/Deal.Ai.Tests/Ai/TokenEstimatorTests.cs index c8d3f25..5085d8f 100644 --- a/src/ai-service/Deal.Ai.Tests/Ai/TokenEstimatorTests.cs +++ b/src/ai-service/Deal.Ai.Tests/Ai/TokenEstimatorTests.cs @@ -3,14 +3,12 @@ using Deal.Ai.Llm; namespace Deal.Ai.Tests.Ai; /// -/// Unit-тесты оценки токенов (план Task 7; TokenEstimator, Ruling 5): usage API-ответа провайдера -/// проходит как есть (total «берём как есть»), при отсутствии — оценка по символам ≈ceil(chars/4). -/// Без сети. +/// Unit-тесты оценки токенов /// public sealed class TokenEstimatorTests { /// - /// Usage провайдера проходит без изменений, включая total ≠ сумме (как отдал API). + /// Usage провайдера проходит без изменений, включая total ≠ сумме /// [Fact] public void Resolve_WithProviderUsage_PassesThrough() @@ -25,7 +23,7 @@ public sealed class TokenEstimatorTests } /// - /// Нет usage провайдера — оценка по символам: prompt из system+user, completion из текста. + /// Нет usage провайдера — оценка по символам /// [Fact] public void Resolve_WithoutProviderUsage_EstimatesByChars() @@ -39,7 +37,7 @@ public sealed class TokenEstimatorTests } /// - /// Округление вверх: ровно 4 символа — 1 токен, 5 символов — 2 токена. + /// Округление вверх /// [Fact] public void Resolve_WithoutProviderUsage_RoundsUp() @@ -52,7 +50,7 @@ public sealed class TokenEstimatorTests } /// - /// Пустые тексты дают нулевую оценку (не отрицательную). + /// Пустые тексты дают нулевую оценку /// [Fact] public void Resolve_EmptyTexts_ReturnsZeroTokens() diff --git a/src/ai-service/Deal.Ai.Tests/Grpc/AiServiceHostTests.cs b/src/ai-service/Deal.Ai.Tests/Grpc/AiServiceHostTests.cs index d7b1fdc..b7ace63 100644 --- a/src/ai-service/Deal.Ai.Tests/Grpc/AiServiceHostTests.cs +++ b/src/ai-service/Deal.Ai.Tests/Grpc/AiServiceHostTests.cs @@ -9,18 +9,7 @@ using Microsoft.AspNetCore.Builder; namespace Deal.Ai.Tests.Grpc; /// -/// Интеграционные тесты хоста ai-service (каркас Task 4 + логика Task 8). -/// -/// Хост поднимается В процессе теста (Kestrel HTTP/2, эфемерный порт) через AiServiceHost.Create — -/// ту же сборку хоста, что использует Program.cs, поэтому тесты покрывают реальную настройку -/// Kestrel/AddGrpc/health, а не её копию. Проверки: gRPC-health → SERVING; ServiceTokenInterceptor -/// (Ruling 1): запрос без токена и с неверным токеном → UNAUTHENTICATED; верный токен проходит к -/// методу (реализация Task 8: пустой запрос без конфига провайдера → INVALID_ARGUMENT); при -/// незаданном DEAL_SERVICE_TOKEN — fail-closed. -/// -/// Токен интерцептор читает из конфигурации (env DEAL_SERVICE_TOKEN) — тесты выставляют env на время -/// сценария и восстанавливают исходное значение. Все тесты класса живут в одном процессе/классе, -/// чтобы env и свободные порты не конфликтовали (xunit исполняет методы класса последовательно). +/// Интеграционные тесты хоста ai-service. /// public sealed class AiServiceHostTests { @@ -33,15 +22,13 @@ public sealed class AiServiceHostTests // Токен сценариев теста. private const string ValidToken = "task4-test-token"; - // Id тенанта запросов (доходит до метода при верном токене; Ruling 1). private const string TenantId = "tenant-test"; // Deadline RPC-вызовов теста (сек). private const int RpcDeadlineSeconds = 10; /// - /// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура - /// и health-сервис работают (Ruling 12; health освобождён от service-token). + /// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура и health-сервис работают. /// [Fact] public async Task HealthCheck_ReturnsServing() @@ -60,7 +47,7 @@ public sealed class AiServiceHostTests } /// - /// Запрос без metadata «service-token» → UNAUTHENTICATED (Ruling 1). + /// Запрос без metadata «service-token» → UNAUTHENTICATED. /// [Fact] public async Task Classify_WithoutToken_IsUnauthenticated() @@ -72,7 +59,7 @@ public sealed class AiServiceHostTests } /// - /// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1). + /// Запрос с неверным токеном → UNAUTHENTICATED. /// [Fact] public async Task Classify_WithWrongToken_IsUnauthenticated() @@ -84,9 +71,7 @@ public sealed class AiServiceHostTests } /// - /// Верный токен проходит интерцептор к методу — кодогенерация и маппинг сервиса работают: - /// пустой запрос (без конфига провайдера) доходит до реализации Classify (Task 8) и отклоняется - /// валидацией INVALID_ARGUMENT, а не UNIMPLEMENTED. + /// Верный токен проходит интерцептор к методу — кодогенерация и маппинг сервиса работают /// [Fact] public async Task Classify_WithValidToken_ReachesServiceAndValidatesConfig() @@ -98,9 +83,7 @@ public sealed class AiServiceHostTests } /// - /// Fail-closed (шаблон Task 3): DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, - /// в т.ч. запрос с «пустым» значением metadata (без гарда сравнение «» == «» пропустило бы его); - /// health при этом продолжает отвечать SERVING (инфраструктурный liveness не ломается). + /// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, в т.ч. /// [Fact] public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing() @@ -138,7 +121,6 @@ public sealed class AiServiceHostTests // Вызывает Classify и проверяет, что сервер ответил ожидаемым кодом статуса. // Запрос несёт полный metadata (service-token + tenant-id), чтобы верный токен доходил - // до реализации метода (заглушки больше нет — Task 8), а не падал на tenant-проверке. // channel: Канал к хосту ai-service. // tokenHeader: Значение metadata «service-token» либо null (нет заголовка). // expected: Ожидаемый StatusCode. diff --git a/src/ai-service/Deal.Ai.Tests/Support/JsonExtractorTests.cs b/src/ai-service/Deal.Ai.Tests/Support/JsonExtractorTests.cs index 1d726b5..c64216a 100644 --- a/src/ai-service/Deal.Ai.Tests/Support/JsonExtractorTests.cs +++ b/src/ai-service/Deal.Ai.Tests/Support/JsonExtractorTests.cs @@ -4,8 +4,7 @@ using Deal.Ai.Llm; namespace Deal.Ai.Tests.Support; /// -/// Unit-тесты извлечения JSON из ответа модели (план Task 7; JsonExtractor, 1:1 extract_json -/// ai.py L175–183): чистая строка JSON, markdown-обёртки, проза вокруг, отказы. Без сети. +/// Unit-тесты извлечения JSON из ответа модели /// public sealed class JsonExtractorTests { @@ -22,7 +21,7 @@ public sealed class JsonExtractorTests } /// - /// Markdown-обёртка ```json снимается (типичный ответ DeepSeek). + /// Markdown-обёртка ```json снимается /// [Fact] public void TryExtractObject_JsonFence_Unwraps() @@ -59,7 +58,7 @@ public sealed class JsonExtractorTests } /// - /// Текст без JSON — null (попытка неудачна, фасад повторяет вызов). + /// Текст без JSON — null /// [Fact] public void TryExtractObject_NoJson_ReturnsNull() @@ -78,7 +77,7 @@ public sealed class JsonExtractorTests } /// - /// JSON верхнего уровня не объект (массив/строка) — null (схемы ответов всегда объект). + /// JSON верхнего уровня не объект /// [Fact] public void TryExtractObject_NonObjectRoot_ReturnsNull() diff --git a/src/ai-service/Deal.Ai.Tests/Support/LlmHttpClientTests.cs b/src/ai-service/Deal.Ai.Tests/Support/LlmHttpClientTests.cs index db60f45..97f5016 100644 --- a/src/ai-service/Deal.Ai.Tests/Support/LlmHttpClientTests.cs +++ b/src/ai-service/Deal.Ai.Tests/Support/LlmHttpClientTests.cs @@ -5,10 +5,7 @@ using Deal.Ai.Llm; namespace Deal.Ai.Tests.Support; /// -/// HTTP-тесты LlmHttpClient на заглушке HttpMessageHandler (план Task 7 Acceptance; без сети): -/// форма OpenAI-совместимого запроса ({base}/chat/completions, Bearer, temperature 0.2, -/// max_tokens 8000), форма Anthropic ({base}/v1/messages, x-api-key + anthropic-version), -/// разбор ответов/usage, отказ без ключа, reasoning-без-ответа, HTTP-ошибка и таймаут. +/// HTTP-тесты LlmHttpClient на заглушке HttpMessageHandler /// public sealed class LlmHttpClientTests { @@ -50,7 +47,7 @@ public sealed class LlmHttpClientTests """; /// - /// OpenAI-совместимый вызов: URL, Bearer, форма тела (model/messages/temperature/max_tokens). + /// OpenAI-совместимый вызов /// [Fact] public async Task ChatAsync_OpenAiStyle_BuildsWireRequest() @@ -84,7 +81,7 @@ public sealed class LlmHttpClientTests } /// - /// Локальный OpenAI-совместимый провайдер без ключа: заголовок Authorization не шлётся. + /// Локальный OpenAI-совместимый провайдер без ключа /// [Fact] public async Task ChatAsync_OpenAiStyleWithoutApiKey_SkipsAuthorization() @@ -98,7 +95,7 @@ public sealed class LlmHttpClientTests } /// - /// Модель вернула только reasoning без ответа — сбой попытки (ai.py L149–151). + /// Модель вернула только reasoning без ответа — сбой попытки. /// [Fact] public async Task ChatAsync_OpenAiReasoningOnly_Throws() @@ -115,7 +112,7 @@ public sealed class LlmHttpClientTests } /// - /// Anthropic-вызов: URL /v1/messages, x-api-key + anthropic-version, форма тела Messages API. + /// Anthropic-вызов /// [Fact] public async Task ChatAsync_AnthropicStyle_BuildsWireRequest() @@ -145,13 +142,12 @@ public sealed class LlmHttpClientTests Assert.Equal("user", (string?)messages[0]!["role"]); Assert.Equal("Сообщение", (string?)messages[0]!["content"]); - // Склейка text-блоков content[] + usage (input/output → total = сумма; ai.py L168–172). Assert.Equal("{\"fit\": 1} ещё текст", result.Text); Assert.Equal(new ProviderUsage(4, 6, 10), result.Usage); } /// - /// HTTP-ошибка провайдера — сбой попытки с кодом статуса (повод для ретрая). + /// HTTP-ошибка провайдера — сбой попытки с кодом статуса /// [Fact] public async Task ChatAsync_HttpError_Throws() @@ -166,7 +162,7 @@ public sealed class LlmHttpClientTests } /// - /// Неожиданная форма ответа (не JSON) — сбой попытки, а не падение. + /// Неожиданная форма ответа /// [Fact] public async Task ChatAsync_UnexpectedBody_Throws() @@ -182,7 +178,7 @@ public sealed class LlmHttpClientTests } /// - /// Таймаут попытки (90/60 с в проде; в тесте — 60 мс) → LlmHttpException. + /// Таймаут попытки /// [Fact] public async Task ChatAsync_Timeout_Throws() diff --git a/src/ai-service/Deal.Ai.Tests/Support/StubHttpMessageHandler.cs b/src/ai-service/Deal.Ai.Tests/Support/StubHttpMessageHandler.cs index 9fc1499..75cc353 100644 --- a/src/ai-service/Deal.Ai.Tests/Support/StubHttpMessageHandler.cs +++ b/src/ai-service/Deal.Ai.Tests/Support/StubHttpMessageHandler.cs @@ -13,7 +13,6 @@ internal sealed record CapturedHttpRequest( IReadOnlyDictionary Headers, string? Body); -// Заглушка HttpMessageHandler для HTTP-тестов LlmHttpClient (план Task 7; без сети): записывает // запросы (URL/заголовки/тело) и отвечает по сценарию; опциональная задержка — для теста таймаута. internal sealed class StubHttpMessageHandler : HttpMessageHandler { @@ -93,14 +92,14 @@ internal sealed class StubHttpMessageHandler : HttpMessageHandler => _ => new HttpResponseMessage(statusCode); /// - /// Разбирает тело запроса как JSON-объект (для проверок формы). + /// Разбирает тело запроса как JSON-объект /// /// Снимок запроса. public static JsonObject BodyOf(CapturedHttpRequest request) => JsonNode.Parse(request.Body!)!.AsObject(); /// - /// Снимает заголовок авторизации Bearer (null — заголовка нет). + /// Снимает заголовок авторизации Bearer /// /// Снимок запроса. public static string? BearerOf(CapturedHttpRequest request) diff --git a/src/ai-service/Deal.Ai/AiServiceHost.cs b/src/ai-service/Deal.Ai/AiServiceHost.cs index 134f929..a4b30bb 100644 --- a/src/ai-service/Deal.Ai/AiServiceHost.cs +++ b/src/ai-service/Deal.Ai/AiServiceHost.cs @@ -6,41 +6,17 @@ using Deal.Grpc.Hosting.Services; namespace Deal.Ai; /// -/// Собирает WebApplication gRPC-хоста ai-service (план Task 4/7/8; Ruling 1/2/5/12). -/// -/// Продакшн-точка входа вызывает из Program.cs (порт из env GRPC_PORT/PORT); -/// интеграционные тесты (Deal.Ai.Tests) — из своего процесса на эфемерном порту, поэтому -/// конфигурация хоста живёт здесь один раз и не дублируется в тестах. -/// Транспорт/AddGrpc/health — общая серверная обвязка (Deal.Grpc.Hosting, -/// C31): mTLS (env DEAL_MTLS_*, Ruling 6/Task 13), Kestrel HTTP/2, интерцепторы service-token и -/// access-лога, gRPC-health; здесь — только регистрации логики ai-service. -/// Регистрации логики (план Task 7/8, Ruling 5): LLM-фасад провайдеров — HTTP-клиент -/// (, OpenAI-совместимые chat/completions + Anthropic Messages API, -/// таймауты 90/60 с) как и оркестратор вызовов -/// (: ретраи 2 с паузами 0.8/2 с, извлечение JSON из markdown, -/// usage API или оценка по символам). Сервис без БД и настроек (ядро передаёт заполненные промпты -/// и конфиг провайдера в теле запроса); configureServices-хук — seam для фейков тестов -/// (подмена IProviderClient и функции паузы ретраев). +/// Собирает WebApplication gRPC-хоста ai-service. /// public static class AiServiceHost { /// - /// Создаёт (не запускает) хост: общая обвязка GrpcServer (Kestrel HTTP/2 на 0.0.0.0:grpcPort, - /// dev — plaintext + service-token, Ruling 2; при DEAL_MTLS_ENABLED=1 — HTTPS с серверным - /// сертификатом и требованием клиентского, Ruling 6/Task 13), затем LLM-фасад (Task 7) - /// и маппинг (Task 8). + /// Создаёт (не запускает) хост /// /// TCP-порт Kestrel. /// Аргументы командной строки (Program.cs); в тестах не нужны. - /// - /// Опциональный хук DI для тестов (подмена LLM-фасада фейками: регистрация, добавленная здесь, - /// побеждает — DI резолвит последнюю; см. AiServiceHostTests/AiRpcTests). - /// - /// - /// Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog - /// (DealLogging.Configure, Ruling 7/Task 14). Тесты хост поднимают БЕЗ этого хука — логирование - /// файлов/консоли тестам не нужно. - /// + /// Опциональный хук DI для тестов (подмена LLM-фасада фейками: регистрация, добавленная здесь, побеждает — DI резолвит последнюю; см. AiServiceHostTests/AiRpcTests). + /// Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog. Тесты хост поднимают БЕЗ этого хука — логирование файлов/консоли тестам не нужно. /// Собранный хост; запуск — StartAsync/RunAsync у вызывающего. public static WebApplication Create( int grpcPort, @@ -52,14 +28,11 @@ public static class AiServiceHost // Общая серверная обвязка (Deal.Grpc.Hosting, C31): mTLS env DEAL_MTLS_* — загрузка // сертификатов сразу с fail-fast (compose-prod монтирует deploy/certs, scripts/mtls-certs.sh); - // Kestrel HTTP/2 (dev — plaintext + обязательный service-token, Ruling 2); AddGrpc - // (access-лог первым, затем service-token, потолок сообщения) и gRPC-health (Ruling 12). MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder); GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates); builder.Services.AddDealGrpcServer(); builder.Services.AddReadyHealthCheck("хост ai-service готов"); - // LLM-фасад провайдеров (план Task 7, Ruling 5): HTTP-клиент одной попытки вызова (таймаут // попытки управляется внутри — 90 с OpenAI / 60 с Anthropic; клиент без общего таймаута) и // оркестратор ретраев/JSON/usage поверх него. Ключи API — в конфиге запроса, не в DI/логах. builder.Services.AddHttpClient(static httpClient => diff --git a/src/ai-service/Deal.Ai/AiServiceImpl.cs b/src/ai-service/Deal.Ai/AiServiceImpl.cs index c4e5d1b..e6a130c 100644 --- a/src/ai-service/Deal.Ai/AiServiceImpl.cs +++ b/src/ai-service/Deal.Ai/AiServiceImpl.cs @@ -7,23 +7,12 @@ using Grpc.Core; namespace Deal.Ai; /// -/// Реализация серверной стороны Deal.Grpc.Ai.AiService — команды ядра в ai-service -/// (ai.proto, контракты Task 1; Ruling 1/5). -/// -/// Логика (план Task 8, поверх LLM-фасада Task 7): Filter/Classify/GenerateKeywords/EvaluateFit -/// вызывают модель через по конфигу ProviderConfig из тела запроса; -/// сервис без БД, настроек и большинства промптов не знает (ядро передаёт заполненные промпты). -/// Сервисные промпты только там, где прототип держит их фиксированными: генерация ключевых слов -/// (discovery_routes L36–47) и оценка fit (discovery_eval L50–54). Каждый ответ несёт usage -/// (Ruling 5). Недоступность провайдера после ретраев → UNAVAILABLE с detail -/// «ИИ (имя) не ответил корректно — повторите попытку через несколько секунд» (ядро падает в -/// локальный разбор); ответ модели без разбираемого JSON в Classify — ok=false, не RPC-ошибка -/// (README ai.proto L201–204). +/// Реализация серверной стороны Deal.Grpc.Ai.AiService — команды ядра в ai-service. /// public sealed class AiServiceImpl : AiService.AiServiceBase { /// - /// Ключ gRPC-metadata с id тенанта (обязателен на всех RPC — Ruling 1). + /// Ключ gRPC-metadata с id тенанта. /// public const string TenantIdMetadataKey = "tenant-id"; @@ -57,10 +46,8 @@ public sealed class AiServiceImpl : AiService.AiServiceBase // Деталь отказа: ключ задачи длиннее лимита (INVALID_ARGUMENT). private const string KeywordTooLongDetail = "Слишком длинный ключ задачи"; - // Потолок длины текста сообщения (1:1: core обрезает до 4000 — ai.proto Filter.text/EvaluateFit.text). private const int MaxTextLength = 4000; - // Потолок длины описания ниши/задачи (1:1: core обрезает до 4000 — ai.proto GenerateKeywords.description). private const int MaxDescriptionLength = 4000; // Защитный потолок длины промпта (Filter.prompt/Classify.system_prompt; лимит не декларирован). @@ -69,20 +56,14 @@ public sealed class AiServiceImpl : AiService.AiServiceBase // Защитный потолок длины user-контекста Classify (лимит не декларирован). private const int MaxUserContextLength = 20000; - // Потолок числа ключей задачи EvaluateFit (после _clean_keywords ядро шлёт ≤30 — запас на рост). private const int MaxKeywordsCount = 200; - // Потолок длины одного ключа задачи EvaluateFit (_clean_keywords: ≤60 симв. — запас на рост). private const int MaxKeywordLength = 200; - // Префикс пользовательского сообщения фильтра (1:1 ai.py filter_incoming L193). private const string FilterUserPrefix = "Сообщение:\n"; - // Префикс пользовательского сообщения генератора ключей (1:1 discovery_routes L206). private const string KeywordsUserPrefix = "Описание ниши/задачи:\n"; - // Фиксированный системный промпт генерации ключевых слов (1:1 _KEYWORDS_PROMPT - // discovery_routes L36–47; пользовательское сообщение — описание задачи). private const string GenerateKeywordsSystemPrompt = "Ты — эксперт по поиску Telegram-каналов и групп. По описанию ниши/задачи " + "составь поисковые ключевые слова, по которым в глобальном поиске Telegram " @@ -95,35 +76,26 @@ public sealed class AiServiceImpl : AiService.AiServiceBase + "- конкретные для ниши, включая сленг заказчиков и подрядчиков;\n" + "- без дублей и близких по смыслу повторов."; - // Шаблон системного промпта оценки fit (1:1 _AI_PROMPT discovery_eval L50–54): подставляются - // описание и ключи задачи (строкой через запятую, как _ai_prompt L153–155). private const string EvaluateFitSystemPromptTemplate = "Оцени, относится ли сообщение к сфере/задаче. Описание: {0}. Ключи: {1}. " + "Верни JSON {{\"fit\": 0|1, \"reason\": \"краткая причина\"}}."; - // Потолок длины причины решения модели (1:1 _AI_REASON_LIMIT discovery_eval L43). private const int MaxEvalReasonLength = 200; - // Причина по умолчанию при fit=true, если модель причину не дала (1:1 _ai_reason L170). private const string FitReasonDefault = "подходит"; - // Причина по умолчанию при fit=false, если модель причину не дала (1:1 _ai_reason L170). private const string NotFitReasonDefault = "не подходит"; - // Имя поля решения фильтра в JSON-ответе модели (1:1 ai.py L195). private const string PassFieldName = "pass"; // Имя поля причины в JSON-ответе модели. private const string ReasonFieldName = "reason"; - // Имя поля решения fit в JSON-ответе модели (1:1 discovery_eval _ai_fit). private const string FitFieldName = "fit"; // Имя поля списка ключевых слов в JSON-ответе модели. private const string KeywordsFieldName = "keywords"; - // Значения, которые строковый ответ модели трактует как «ложь» (bool() в python: fit/filter — - // 1:1 _ai_fit discovery_eval L158–164; для фильтра отсутствие поля = true, 1:1 ai.py L195). private static readonly IReadOnlySet FalsyAnswerValues = new HashSet(StringComparer.OrdinalIgnoreCase) { "0", @@ -141,7 +113,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase /// Создаёт gRPC-сервис команд ядра поверх LLM-фасада. /// /// Оркестратор вызовов модели (ретраи + извлечение JSON + usage). - /// Логгер аудита (Ruling 13; ключи API не логируются). + /// Логгер аудита. public AiServiceImpl(ProviderCaller caller, ILogger logger) { _caller = caller; @@ -149,10 +121,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase } /// - /// Filter — ИИ-фильтр входящих сообщений (ai.py filter_incoming L188–198): решение {pass, reason} - /// по заполненному ядром aiFilterPrompt (system) и тексту. Ветку «фильтр не применялся» - /// (aiFilterEnabled/недоступность) ядро обрабатывает до вызова; недоступность провайдера — - /// UNAVAILABLE (ядро пропускает сообщение). + /// Filter — ИИ-фильтр входящих сообщений /// public override async Task Filter(FilterRequest request, ServerCallContext context) { @@ -190,11 +159,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase } /// - /// Classify — полный разбор лида (ai.py classify L218–258): ответ {ok, json}, где json — строка - /// с извлечённым ответом модели (типовую схему задаёт промпт); строгий маппинг json → карточку - /// делает ядро (1:1 normalize_stack/clean_budget/build_contacts). Модель отвечала без - /// разбираемого JSON после ретраев → ok=false (не RPC-ошибка; ядро падает в локальный разбор); - /// провайдер недоступен → UNAVAILABLE. + /// Classify — полный разбор лида /// public override async Task Classify(ClassifyRequest request, ServerCallContext context) { @@ -235,9 +200,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase } /// - /// GenerateKeywords — ключевые слова discovery-задачи по описанию (фикс. промпт discovery_routes - /// L36–47 + описание): ответ {keywords}. Очистку (_clean_keywords: ≤30, ≤60 симв., дедуп) и мягкую - /// ошибку для UI делает ядро (Ruling 11); недоступность провайдера — UNAVAILABLE. + /// GenerateKeywords — ключевые слова discovery-задачи по описанию /// public override async Task GenerateKeywords(GenerateKeywordsRequest request, ServerCallContext context) { @@ -272,9 +235,7 @@ public sealed class AiServiceImpl : AiService.AiServiceBase } /// - /// EvaluateFit — оценка соответствия сообщения задаче поиска (промпт discovery_eval L50–54; - /// текст + описание + ключи задачи): ответ {fit, reason}. Ядро зовёт только при aiEnabled; - /// сбой — фолбэк на эвристику (Ruling 10). + /// EvaluateFit — оценка соответствия сообщения задаче поиска /// public override async Task EvaluateFit(EvaluateFitRequest request, ServerCallContext context) { @@ -314,7 +275,6 @@ public sealed class AiServiceImpl : AiService.AiServiceBase } } - // Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, Ruling 1). // context: Контекст вызова. private static string RequireTenantId(ServerCallContext context) { @@ -389,7 +349,6 @@ public sealed class AiServiceImpl : AiService.AiServiceBase providerConfig.ApiStyle); } - // Пишет предупреждение аудита о недоступности ИИ (Ruling 13; без ключей и текстов). // method: Имя RPC для аудита. // tenantId: Id тенанта. // config: Конфиг провайдера вызова. @@ -406,14 +365,11 @@ public sealed class AiServiceImpl : AiService.AiServiceBase config.DisplayName, callError.Kind); - // Превращает ошибку фасада в RPC-ошибку UNAVAILABLE с detail 1:1 Ruling 5. // callError: Итоговая ошибка фасада. private static RpcException ToUnavailable(LlmCallException callError) => new(new Status(StatusCode.Unavailable, callError.Message)); // Читает булево поле JSON-ответа модели: bool как есть; строка — ложь только для значений из - // FalsyAnswerValues (1:1 _ai_fit discovery_eval L158–164); число — ненулевое = true; - // поля нет/не разбирается — defaultValue (фильтр: true, ai.py L195; fit: false, discovery_eval). // json: Корневой объект ответа модели. // fieldName: Имя поля. // defaultValue: Значение при отсутствии/неразбираемости поля. @@ -461,7 +417,6 @@ public sealed class AiServiceImpl : AiService.AiServiceBase } // Причина решения fit: поле reason модели, при отсутствии — «подходит»/«не подходит» - // (1:1 _ai_reason discovery_eval L167–171), потолок длины MaxEvalReasonLength. // json: Корневой объект ответа модели. // fit: Решение модели. private static string ReadFitReason(JsonObject json, bool fit) @@ -492,7 +447,6 @@ public sealed class AiServiceImpl : AiService.AiServiceBase } } - // Склеивает ключи задачи для промпта оценки (1:1 _ai_prompt discovery_eval L153–155). // keywords: Ключи задачи. private static string JoinKeywords(IEnumerable keywords) => string.Join( diff --git a/src/ai-service/Deal.Ai/Llm/IProviderClient.cs b/src/ai-service/Deal.Ai/Llm/IProviderClient.cs index bf8da9c..104d074 100644 --- a/src/ai-service/Deal.Ai/Llm/IProviderClient.cs +++ b/src/ai-service/Deal.Ai/Llm/IProviderClient.cs @@ -1,10 +1,7 @@ namespace Deal.Ai.Llm; /// -/// Абстракция одного HTTP-вызова LLM-провайдера (план Task 7; seam для фейков тестов RPC-веток). -/// Реализация по конфигу выбирает схему вызова: OpenAI-совместимые POST {base}/chat/completions -/// (Bearer) либо Anthropic POST {base}/v1/messages (x-api-key + anthropic-version). Сетевые -/// сбои/неожиданные ответы — (одна попытка; ретраи — ). +/// Абстракция одного HTTP-вызова LLM-провайдера. /// public interface IProviderClient { @@ -14,7 +11,6 @@ public interface IProviderClient /// Конфиг активного провайдера (стиль API выбирается по ApiStyle). /// Системный промпт (заполненный ядром либо фиксированный сервиса). /// Пользовательское сообщение/контекст. - /// Токен отмены (deadline RPC). /// Текст ответа модели и usage API-ответа (null — провайдер usage не вернул). public Task ChatAsync( LlmConfig config, diff --git a/src/ai-service/Deal.Ai/Llm/JsonExtractor.cs b/src/ai-service/Deal.Ai/Llm/JsonExtractor.cs index e9fd863..c3eeda7 100644 --- a/src/ai-service/Deal.Ai/Llm/JsonExtractor.cs +++ b/src/ai-service/Deal.Ai/Llm/JsonExtractor.cs @@ -4,9 +4,7 @@ using System.Text.Json.Nodes; namespace Deal.Ai.Llm; /// -/// Извлечение JSON-объекта из ответа модели (план Task 7; 1:1 extract_json ai.py L175–183): -/// снимается markdown-обёртка ```json … ```, затем берётся срез между первой «{» и последней «}», -/// результат парсится как объект. Любая аномалия — null (попытка считается неудачной и повторяется). +/// Извлечение JSON-объекта из ответа модели /// public static class JsonExtractor { @@ -48,7 +46,6 @@ public static class JsonExtractor } } - // Снимает markdown-обёртку ```json … ``` (как в extract_json L177–179): возвращает содержимое // между открывающей и закрывающей обёртками; обёртки нет/незакрыта — null. // raw: Текст ответа (уже обрезанный). private static string? UnwrapFence(string raw) diff --git a/src/ai-service/Deal.Ai/Llm/LlmCallException.cs b/src/ai-service/Deal.Ai/Llm/LlmCallException.cs index b7a17be..242fa6a 100644 --- a/src/ai-service/Deal.Ai/Llm/LlmCallException.cs +++ b/src/ai-service/Deal.Ai/Llm/LlmCallException.cs @@ -1,9 +1,7 @@ namespace Deal.Ai.Llm; /// -/// Итоговая ошибка вызова после исчерпания ретраев (план Task 7: «ошибки → исключение с кодом»). -/// Текст сообщения — 1:1 Ruling 5 / ai.py L115–117: «ИИ (имя) не ответил корректно — повторите -/// попытку через несколько секунд»; RPC-слой отдаёт его как detail статуса UNAVAILABLE. +/// Итоговая ошибка вызова после исчерпания ретраев. /// public sealed class LlmCallException : Exception { @@ -32,8 +30,7 @@ public sealed class LlmCallException : Exception public LlmCallFailureKind Kind { get; } /// - /// Usage последней попытки, вернувшей текст модели: заполнен при - /// (Classify отвечает ok=false и всё равно несёт usage; Ruling 5). + /// Usage последней попытки, вернувшей текст модели /// public LlmUsage? Usage { get; } } diff --git a/src/ai-service/Deal.Ai/Llm/LlmCallFailureKind.cs b/src/ai-service/Deal.Ai/Llm/LlmCallFailureKind.cs index 8e5faa4..652ef88 100644 --- a/src/ai-service/Deal.Ai/Llm/LlmCallFailureKind.cs +++ b/src/ai-service/Deal.Ai/Llm/LlmCallFailureKind.cs @@ -1,19 +1,17 @@ namespace Deal.Ai.Llm; /// -/// Причина исчерпания попыток вызова (Ruling 5 / README ai.proto L201–204): провайдер не ответил -/// корректно (UNAVAILABLE) либо модель отвечала, но ни один ответ не разобран как JSON -/// (Classify — ok=false, не RPC-ошибка). +/// Причина исчерпания попыток вызова /// public enum LlmCallFailureKind { /// - /// Провайдер не ответил после ретраев (сеть/таймаут/HTTP/пустой ответ) → UNAVAILABLE. + /// Провайдер не ответил после ретраев /// ProviderUnavailable, /// - /// Модель отвечала текстом, но JSON не извлечён после ретраев (Classify → ok=false). + /// Модель отвечала текстом, но JSON не извлечён после ретраев /// AnswerNotJson, } diff --git a/src/ai-service/Deal.Ai/Llm/LlmCallResult.cs b/src/ai-service/Deal.Ai/Llm/LlmCallResult.cs index 9836454..797941c 100644 --- a/src/ai-service/Deal.Ai/Llm/LlmCallResult.cs +++ b/src/ai-service/Deal.Ai/Llm/LlmCallResult.cs @@ -3,15 +3,14 @@ using System.Text.Json.Nodes; namespace Deal.Ai.Llm; /// -/// Успешный результат вызова модели: извлечённый JSON-объект (схему задаёт промпт) и итоговая -/// оценка токенов (usage API или оценка по символам). +/// Успешный результат вызова модели /// /// Корневой объект JSON-ответа модели. /// Итоговая оценка токенов вызова. public sealed record LlmCallResult(JsonObject Json, LlmUsage Usage) { /// - /// Извлечённый ответ модели компактной json-строкой (ClassifyReply.json — маппинг в ядре). + /// Извлечённый ответ модели компактной json-строкой /// public string JsonText => Json.ToJsonString(); } diff --git a/src/ai-service/Deal.Ai/Llm/LlmConfig.cs b/src/ai-service/Deal.Ai/Llm/LlmConfig.cs index b5961e4..5dbbb5e 100644 --- a/src/ai-service/Deal.Ai/Llm/LlmConfig.cs +++ b/src/ai-service/Deal.Ai/Llm/LlmConfig.cs @@ -1,10 +1,7 @@ namespace Deal.Ai.Llm; /// -/// Эффективный конфиг LLM-провайдера на один вызов (план Task 7, Ruling 5): зеркало -/// ProviderConfig из ai.proto. Ядро передаёт заполненный конфиг в теле каждого запроса -/// (base_url/model/api_key расшифрованы, api_style — из каталога AiProviders); сервис настроек -/// тенанта не хранит и не знает. +/// Эффективный конфиг LLM-провайдера на один вызов /// public sealed record LlmConfig( string ProviderId, @@ -14,12 +11,10 @@ public sealed record LlmConfig( string? ApiStyle) { /// - /// Значение api_style для Anthropic Messages API (пусто/иное — OpenAI-совместимый). + /// Значение api_style для Anthropic Messages API /// public const string AnthropicApiStyle = "anthropic"; - // Отображаемые имена известных провайдеров (1:1 каталог AiProviders констант python) — для - // текста ошибки «ИИ (имя) …» (Ruling 5, ai.py L115–117). Неизвестный id — как есть. private static readonly IReadOnlyDictionary KnownProviderNames = new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -33,12 +28,12 @@ public sealed record LlmConfig( }; /// - /// Истинно, когда конфиг задаёт Anthropic Messages API (x-api-key + anthropic-version). + /// Истинно, когда конфиг задаёт Anthropic Messages API /// public bool IsAnthropic => string.Equals(ApiStyle, AnthropicApiStyle, StringComparison.OrdinalIgnoreCase); /// - /// Имя провайдера для сообщений об ошибках и логов (ключ API в него не входит). + /// Имя провайдера для сообщений об ошибках и логов /// public string DisplayName => KnownProviderNames.TryGetValue(ProviderId, out string? name) ? name diff --git a/src/ai-service/Deal.Ai/Llm/LlmHttpClient.cs b/src/ai-service/Deal.Ai/Llm/LlmHttpClient.cs index 9fddfae..636a3f6 100644 --- a/src/ai-service/Deal.Ai/Llm/LlmHttpClient.cs +++ b/src/ai-service/Deal.Ai/Llm/LlmHttpClient.cs @@ -5,12 +5,7 @@ using System.Text.Json.Nodes; namespace Deal.Ai.Llm; /// -/// HTTP-реализация (план Task 7; 1:1 ai.py _call_openai/_call_anthropic -/// L126–172): по api_style конфига выбирается схема вызова — OpenAI-совместимые -/// POST {base}/chat/completions (Bearer; temperature 0.2; max_tokens 8000) либо Anthropic -/// POST {base}/v1/messages (x-api-key + anthropic-version). Таймауты 90 с (OpenAI) / 60 с -/// (Anthropic) на попытку; usage берётся из API-ответа (null — оценит TokenEstimator). -/// Ключи API в логи и исключения не попадают (Ruling 13). +/// HTTP-реализация /// public sealed class LlmHttpClient : IProviderClient { @@ -23,22 +18,17 @@ public sealed class LlmHttpClient : IProviderClient // Заголовок версии Anthropic API. private const string AnthropicVersionHeader = "anthropic-version"; - // Значение версии Anthropic API (фиксированное, как в python). private const string AnthropicVersionValue = "2023-06-01"; // Заголовок ключа Anthropic API. private const string AnthropicApiKeyHeader = "x-api-key"; - // Температура вызовов OpenAI-совместимых API (Ruling 5; как в ai.py L137). private const double Temperature = 0.2; - // Потолок токенов ответа (max_tokens; как в python для обоих стилей). private const int MaxResponseTokens = 8000; - // Таймаут одной попытки OpenAI-совместимого вызова (Ruling 5; 90 с). private static readonly TimeSpan OpenAiCallTimeout = TimeSpan.FromSeconds(90); - // Таймаут одной попытки Anthropic-вызова (Ruling 5; 60 с). private static readonly TimeSpan AnthropicCallTimeout = TimeSpan.FromSeconds(60); private readonly HttpClient _httpClient; @@ -46,7 +36,7 @@ public sealed class LlmHttpClient : IProviderClient private readonly TimeSpan _anthropicCallTimeout; /// - /// Создаёт HTTP-клиент провайдеров с типовыми таймаутами (90/60 с). + /// Создаёт HTTP-клиент провайдеров с типовыми таймаутами /// /// HttpClient (регистрируется в DI; таймаут управляется на попытку). public LlmHttpClient(HttpClient httpClient) @@ -69,12 +59,11 @@ public sealed class LlmHttpClient : IProviderClient } /// - /// Выполняет один вызов модели по выбранной схеме API (Ruling 5). + /// Выполняет один вызов модели по выбранной схеме API. /// /// Конфиг провайдера (стиль — ApiStyle). /// Системный промпт. /// Пользовательское сообщение/контекст. - /// Токен отмены (deadline RPC). /// Текст ответа и usage API-ответа (null при его отсутствии). public async Task ChatAsync( LlmConfig config, @@ -109,7 +98,6 @@ public sealed class LlmHttpClient : IProviderClient } // Собирает запрос OpenAI-совместимого чата: {base}/chat/completions, Bearer при заданном ключе, - // тело 1:1 ai.py L126–139 (messages system/user, temperature 0.2, max_tokens 8000). // config: Конфиг провайдера. // systemPrompt: Системный промпт. // userText: Пользовательское сообщение. @@ -139,7 +127,6 @@ public sealed class LlmHttpClient : IProviderClient } // Собирает запрос Anthropic Messages API: {base}/v1/messages, x-api-key + anthropic-version, - // тело 1:1 ai.py L155–167 (system отдельным полем, messages=[user]). // config: Конфиг провайдера. // systemPrompt: Системный промпт. // userText: Пользовательское сообщение. @@ -184,7 +171,6 @@ public sealed class LlmHttpClient : IProviderClient return isAnthropic ? ReadAnthropicBody(body) : ReadOpenAiBody(body); } - // Разбирает OpenAI-совместимый ответ: choices[0].message.content (+ usage; ai.py L143–152). // body: Тело ответа. private static ProviderChatResult ReadOpenAiBody(string body) { @@ -201,14 +187,12 @@ public sealed class LlmHttpClient : IProviderClient string? content = ReadStringField(message, "content"); if (string.IsNullOrEmpty(content) && !string.IsNullOrEmpty(ReadStringField(message, "reasoning_content"))) { - // Модель «подумала», но ответа не дала (переполнение/обрыв) — сбой, пробуем ещё раз (ai.py L149–151). throw new LlmHttpException("Модель вернула только reasoning без ответа"); } return new ProviderChatResult(content ?? string.Empty, ReadOpenAiUsage(payload["usage"])); } - // Разбирает Anthropic-ответ: склейка text блоков content[] (+ usage input/output; ai.py L168–172). // body: Тело ответа. private static ProviderChatResult ReadAnthropicBody(string body) { @@ -281,7 +265,6 @@ public sealed class LlmHttpClient : IProviderClient throw UnexpectedApiResponse("тело не является JSON-объектом"); } - // Собирает текст ошибки неожиданного ответа: только тип/причина, без содержимого тела (Ruling 13). // failureKind: Короткая причина (без тела ответа и секретов). private static LlmHttpException UnexpectedApiResponse(string failureKind) => new($"Неожиданный ответ ИИ-провайдера: {failureKind}"); @@ -327,7 +310,6 @@ public sealed class LlmHttpClient : IProviderClient return result; } - // Убирает хвостовые «/» базового URL (как ai.py L90: rstrip("/")). // baseUrl: Базовый URL из конфига. private static string NormalizeBaseUrl(string baseUrl) => baseUrl.TrimEnd('/'); } diff --git a/src/ai-service/Deal.Ai/Llm/LlmHttpException.cs b/src/ai-service/Deal.Ai/Llm/LlmHttpException.cs index b03f3a0..086ffa1 100644 --- a/src/ai-service/Deal.Ai/Llm/LlmHttpException.cs +++ b/src/ai-service/Deal.Ai/Llm/LlmHttpException.cs @@ -1,9 +1,7 @@ namespace Deal.Ai.Llm; /// -/// Ошибка одной HTTP-попытки вызова провайдера (план Task 7): сетевой сбой, таймаут, HTTP-ошибка -/// или неожиданная форма ответа API. Обрабатывается в как повод для -/// ретрая; текст внутренний (ключи/секреты и тело ответа в него не попадают — Ruling 13). +/// Ошибка одной HTTP-попытки вызова провайдера /// public sealed class LlmHttpException : Exception { diff --git a/src/ai-service/Deal.Ai/Llm/LlmRetryPolicy.cs b/src/ai-service/Deal.Ai/Llm/LlmRetryPolicy.cs index 8a874b6..6c1c04d 100644 --- a/src/ai-service/Deal.Ai/Llm/LlmRetryPolicy.cs +++ b/src/ai-service/Deal.Ai/Llm/LlmRetryPolicy.cs @@ -1,14 +1,12 @@ namespace Deal.Ai.Llm; /// -/// Политика ретраев вызова LLM (план Task 7; ai.py chat_json L96–117): max_retries=2 → всего 3 -/// попытки с нарастающими паузами 0.8 с и 2 с между ними. Разовый сбой (перегрузка API, пустой/ -/// не-JSON ответ) не должен превращаться в «ИИ недоступен» без повторных попыток. +/// Политика ретраев вызова LLM /// public static class LlmRetryPolicy { /// - /// Число дополнительных попыток после первой (всего — ). + /// Число дополнительных попыток после первой /// public const int RetryCount = 2; @@ -18,7 +16,7 @@ public static class LlmRetryPolicy public const int AttemptCount = RetryCount + 1; /// - /// Паузы между попытками: 0.8 с (после 1-й) и 2 с (после 2-й). + /// Паузы между попытками /// public static readonly IReadOnlyList RetryDelays = [ diff --git a/src/ai-service/Deal.Ai/Llm/LlmUsage.cs b/src/ai-service/Deal.Ai/Llm/LlmUsage.cs index b2462b8..ba6b778 100644 --- a/src/ai-service/Deal.Ai/Llm/LlmUsage.cs +++ b/src/ai-service/Deal.Ai/Llm/LlmUsage.cs @@ -1,9 +1,7 @@ namespace Deal.Ai.Llm; /// -/// Итоговая оценка токенов вызова для gRPC-ответа (Ruling 5): берётся из usage API-ответа -/// провайдера, при его отсутствии оценивается по символам (≈chars/4). Ядро копит значения -/// в tenant-KV aiTokenUsage. +/// Итоговая оценка токенов вызова для gRPC-ответа /// /// Токены запроса (система + пользователь). /// Токены ответа модели. diff --git a/src/ai-service/Deal.Ai/Llm/ProviderCaller.cs b/src/ai-service/Deal.Ai/Llm/ProviderCaller.cs index 9ef0412..d1a8a87 100644 --- a/src/ai-service/Deal.Ai/Llm/ProviderCaller.cs +++ b/src/ai-service/Deal.Ai/Llm/ProviderCaller.cs @@ -3,12 +3,7 @@ using System.Text.Json.Nodes; namespace Deal.Ai.Llm; /// -/// Оркестратор вызова модели с ретраями и извлечением JSON (план Task 7; 1:1 chat_json ai.py -/// L80–117): до попыток с паузами 0.8/2 с; каждая попытка — -/// HTTP-вызов () + извлечение JSON (). После -/// исчерпания попыток — : «провайдер не ответил» (Kind=ProviderUnavailable) -/// либо «модель отвечала, но не JSON» (Kind=AnswerNotJson — Classify отвечает ok=false, Ruling 5). -/// Usage итога — из usage API-ответа последней попытки или оценка по символам (TokenEstimator). +/// Оркестратор вызова модели с ретраями и извлечением JSON /// public sealed class ProviderCaller { @@ -38,7 +33,6 @@ public sealed class ProviderCaller /// Конфиг активного провайдера. /// Системный промпт (заполненный ядром или фиксированный сервиса). /// Пользовательское сообщение/контекст. - /// Токен отмены (deadline RPC). /// Извлечённый JSON-объект и итоговую оценку токенов. /// Все попытки исчерпаны (см. ). public async Task ChatJsonAsync( @@ -82,7 +76,6 @@ public sealed class ProviderCaller if (lastModelText is not null) { - // Модель отвечала текстом, но ни одна попытка не дала разбираемый JSON (README ai.proto L201–204). LlmUsage usage = TokenEstimator.Resolve(lastUsage, promptText, lastModelText); throw new LlmCallException(LlmCallFailureKind.AnswerNotJson, config.DisplayName, usage); } diff --git a/src/ai-service/Deal.Ai/Llm/ProviderChatResult.cs b/src/ai-service/Deal.Ai/Llm/ProviderChatResult.cs index 883cc8f..384df24 100644 --- a/src/ai-service/Deal.Ai/Llm/ProviderChatResult.cs +++ b/src/ai-service/Deal.Ai/Llm/ProviderChatResult.cs @@ -1,7 +1,7 @@ namespace Deal.Ai.Llm; /// -/// Результат одной успешной HTTP-попытки вызова модели (текст ответа + usage API). +/// Результат одной успешной HTTP-попытки вызова модели /// /// Текст ответа модели (может быть не-JSON — разбор в ). /// Usage из API-ответа провайдера; null — провайдер его не вернул (оценка по символам). diff --git a/src/ai-service/Deal.Ai/Llm/ProviderUsage.cs b/src/ai-service/Deal.Ai/Llm/ProviderUsage.cs index d90f330..a7d4207 100644 --- a/src/ai-service/Deal.Ai/Llm/ProviderUsage.cs +++ b/src/ai-service/Deal.Ai/Llm/ProviderUsage.cs @@ -1,9 +1,7 @@ namespace Deal.Ai.Llm; /// -/// Usage токенов из API-ответа провайдера (Ruling 5). Поля не путать с : -/// здесь «как отдал провайдер» (total берём как есть — у провайдера он может отличаться от суммы), -/// финальную оценку/подстановку делает . +/// Usage токенов из API-ответа провайдера. /// /// Токены запроса (система + пользователь). /// Токены ответа модели. diff --git a/src/ai-service/Deal.Ai/Llm/TokenEstimator.cs b/src/ai-service/Deal.Ai/Llm/TokenEstimator.cs index bc54f4b..1ffa1bc 100644 --- a/src/ai-service/Deal.Ai/Llm/TokenEstimator.cs +++ b/src/ai-service/Deal.Ai/Llm/TokenEstimator.cs @@ -1,17 +1,14 @@ namespace Deal.Ai.Llm; /// -/// Оценка токенов вызова (план Task 7, Ruling 5): при отсутствии usage в API-ответе токены -/// оцениваются по символам ≈ chars/4 (округление вверх). Запрос = system + user, ответ = текст модели. +/// Оценка токенов вызова /// public static class TokenEstimator { - // Примерное число символов на один токен (Ruling 5: «≈chars/4»). private const int EstimatedCharsPerToken = 4; /// - /// Сводит usage вызова: usage провайдера как есть (total «берём как есть»), при отсутствии — - /// оценка по длинам промпта и ответа. + /// Сводит usage вызова /// /// Usage из API-ответа (null — провайдер его не вернул). /// Полный текст запроса (система + пользователь) для оценки. diff --git a/src/ai-service/Deal.Ai/Program.cs b/src/ai-service/Deal.Ai/Program.cs index b35dc1f..a72e627 100644 --- a/src/ai-service/Deal.Ai/Program.cs +++ b/src/ai-service/Deal.Ai/Program.cs @@ -1,14 +1,10 @@ -// ai-service — точка входа gRPC-хоста (план Task 4/7/8; Ruling 1/2/5/12). // // Kestrel HTTP/2 на порту 5102 (env GRPC_PORT, затем PORT) + AddGrpc с интерцепторами service-token // и access-лога + стандартный gRPC-health (grpc.health.v1.Health). Транспорт: dev — plaintext -// (Ruling 2); mTLS (TLS + клиентский сертификат) — при DEAL_MTLS_ENABLED=1 (Ruling 6, план Task 13; -// сертификаты deploy/certs — scripts/mtls-certs.sh, env передаёт compose-prod Task 14); fail-closed: // Production без mTLS не стартует (GrpcHostEnvironment.RequireMtlsInProduction). // Серверная обвязка (Kestrel/AddGrpc/health) — общий Deal.Grpc.Hosting (C31): хост-фабрика // AiServiceHost.Create используется и интеграционными тестами (Deal.Ai.Tests), которые поднимают // его в своём процессе на эфемерном порту. Методы AiService (Filter/Classify/GenerateKeywords/ -// EvaluateFit) реализованы поверх LLM-фасада (OpenAI-совместимые + Anthropic, без БД — Ruling 5; // задачи 7–8): фасад живёт в Deal.Ai/Llm. using Deal.Ai; @@ -17,17 +13,13 @@ using Deal.Grpc.Hosting.Models; using Deal.Grpc.Hosting.Options; using Deal.Grpc.Hosting.Services; -// Порт по умолчанию — 5102 (Ruling 12, compose.dev.yml); переопределяется env GRPC_PORT (контейнер) // или PORT (общий конвенциональный env хостинг-платформ) — см. GrpcHostEnvironment.ResolveGrpcPort. const int defaultGrpcPort = 5102; -// Имя процесса для rolling-файла логов (Ruling 7, Task 14): data/logs/deal-ai-<дата>.json. const string aiProcessName = "ai"; int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort); -// Порт эндпоинта метрик /metrics (HTTP/1.1, отдельно от gRPC HTTP/2; этап 12, пакет A). int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); -// Serilog (Ruling 7, план Task 14): JSON-консоль + rolling-файл data/logs/deal-ai-*.json — // конфигурируется production-точкой входа через configureBuilder-хук хоста (тесты хост поднимают // без Serilog, DealLogging.Configure в AiServiceHost/Create вызывается только здесь). Метрики // (OTel → Prometheus, /metrics) — тем же хуком до builder.Build(). @@ -39,10 +31,8 @@ WebApplication app = AiServiceHost.Create( DealMetricsHosting.AddDealMetrics(builder, metricsPort); }); -// Эндпоинт метрик /metrics (HTTP/1.1 на отдельном порту): формат Prometheus (этап 12, пакет A). DealMetricsHosting.MapDealMetrics(app); -// Режим транспорта — из тех же env, что читал хост (Ruling 6, Task 13): mTLS при DEAL_MTLS_ENABLED=1. MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration); // Fail-closed (замечание code-review): отсутствие/опечатка DEAL_MTLS_ENABLED не должны давать diff --git a/src/contracts/ai.proto b/src/contracts/ai.proto index 73fbbf4..e0bc14c 100644 --- a/src/contracts/ai.proto +++ b/src/contracts/ai.proto @@ -1,172 +1,138 @@ -// ai.proto — контракт между ядром Deal и ai-service (этап 6). -// -// ai-service — фасад LLM-провайдеров без БД (Ruling 5, дизайн-док §7.3): -// ядро передаёт в теле каждого запроса готовые (заполненные) промпты и/или -// текст + конфиг активного провайдера (ProviderConfig); сервис вызывает -// провайдера (OpenAI-совместимые POST {base}/chat/completions, Anthropic -// POST {base}/v1/messages; temperature 0.2, таймауты 90/60 с, retry 2 с -// паузами 0.8/2 с) и возвращает ответ + оценку токенов. Конфиг провайдера -// (id/base/model/apiKey/api_style) ядро кладёт в поле provider_config каждого -// запроса — сервис настроек тенанта не знает и не хранит. -// -// Каждый RPC обязан нести в gRPC-metadata два заголовка (Ruling 1): -// tenant-id — id тенанта (строка; учёт токенов в ядре по нему); -// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/ -// пустой → UNAUTHENTICATED. -// -// Ошибки домена — gRPC-статусы (Ruling 1/5): -// INVALID_ARGUMENT — невалидный запрос (пустой текст/промпт и т.п.); -// UNAVAILABLE — провайдер не ответил корректно после ретраев; detail = -// «ИИ (имя) не ответил корректно — повторите попытку через -// несколько секунд» (ядро падает в локальный разбор). -// -// Учёт токенов (Ruling 5): каждый reply несёт usage{prompt/completion/total}. -// Берётся из usage API-ответа провайдера; при отсутствии оценивается по -// символам (≈chars/4). Ядро копит значения в tenant-KV aiTokenUsage. -// -// Deadlines (клиент ядра): все RPC — 120 с (90 с провайдер + ретраи 0.8/2 с; -// при недоступности ядро не ждёт повторно — Ruling 6 кэш/фолбэк). -syntax = "proto3"; - -package deal.ai.v1; - -option csharp_namespace = "Deal.Grpc.Ai"; - -service AiService { - // ИИ-фильтр входящих сообщений (ai.py filter_incoming L188–198, Ruling 5): - // ядро шлёт заполненный aiFilterPrompt (fill_prompt делает ядро) и текст; - // решение {pass, reason}. Ветку «фильтр не применялся» (aiFilterEnabled/ - // недоступность) обрабатывает ядро до вызова — сервис всегда отвечает. - rpc Filter(FilterRequest) returns (FilterReply); - - // Полный разбор лида (ai.py classify L218–258, Ruling 5): ядро собирает - // system_prompt = заполненные aiPrompt + cardPrompt и user-контекст - // «Доски + примеры разметки + Сообщение»; сервис возвращает извлечённый - // ответ модели как json-строку (типовую схему задаёт промпт). Строгий - // маппинг json → AiParsedCardDto делает ядро (1:1 normalize_stack/ - // clean_budget/build_contacts). - rpc Classify(ClassifyRequest) returns (ClassifyReply); - - // Генерация ключевых слов для discovery-задачи по описанию (фикс. промпт - // discovery_routes L36–47 + описание; Ruling 5): ответ {keywords}. Очистку - // (_clean_keywords: ≤30, ≤60 симв., дедуп) и мягкие ошибки делает ядро. - rpc GenerateKeywords(GenerateKeywordsRequest) returns (GenerateKeywordsReply); - - // Оценка соответствия сообщения задаче поиска (промпт discovery_eval - // L50–54; Ruling 5/10): текст + описание + ключи задачи → {fit, reason}. - // Ядро зовёт только при aiEnabled; сбой/не-JSON — фолбэк на эвристику. - rpc EvaluateFit(EvaluateFitRequest) returns (EvaluateFitReply); -} - -// --- Запросы/ответы AiService --- - -// Конфиг активного LLM-провайдера на запрос (Ruling 5: ядро расшифровывает -// aiConfigs и передаёт в теле каждого запроса; сервис не хранит настроек). -// Форма 1:1 с эффективным конфигом core: настройка aiConfigs тенанта хранит -// {apiKey, baseUrl, model} (camelCase; apiKey шифруется AES-GCM этапа 2), -// api_style — из каталога AiProviders (Settings); HTTP-клиенту провайдера -// нужны baseUrl+model+apiKey для запроса и api_style для выбора схемы вызова -// (OpenAI-совместимые chat/completions vs Anthropic Messages API). -message ProviderConfig { - // Id провайдера (ключ aiConfigs / каталога AiProviders: deepseek, openai, - // anthropic, ollama, lmstudio, custom…). - string provider_id = 1; - // Эффективный базовый URL API (aiConfigs.baseUrl или дефолт каталога). - string base_url = 2; - // API-ключ открытым текстом (расшифрован ядром); пуст для локальных - // провайдеров (ollama/lmstudio) — заголовок авторизации не шлётся. - optional string api_key = 3; - // Активная модель (aiConfigs.model или первая из каталога провайдера). - string model = 4; - // Стиль API: пуст — OpenAI-совместимый (POST {base}/chat/completions, - // Bearer); "anthropic" — Messages API (POST {base}/v1/messages, - // x-api-key + anthropic-version). - optional string api_style = 5; -} - -message FilterRequest { - // Заполненный промпт фильтра (настройка aiFilterPrompt с подстановкой - // {domain}/{keywords} — делает ядро; Ruling 5). - string prompt = 1; - // Текст сообщения (ядро обрезает до 4000, как ai.py L193). - string text = 2; - // Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса). - ProviderConfig provider_config = 3; -} - -message FilterReply { - // True — сообщение проходит фильтр (не спам/реклама/служебное). - bool pass = 1; - // Причина отказа при pass=false (текст ветки filter_ai; пуст при пропуске). - optional string reason = 2; - // Оценка токенов вызова (Ruling 5). - Usage usage = 3; -} - -message ClassifyRequest { - // system_prompt = заполненные aiPrompt + cardPrompt (структура карточки, - // «О заявке»; собирает ядро — Ruling 5). - string system_prompt = 1; - // user-контекст: «Доски + примеры разметки + Новое сообщение» (собирает - // ядро, 1:1 classify L243–251). - string user_context = 2; - // Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса). - ProviderConfig provider_config = 3; -} - -message ClassifyReply { - // True — модель вернула разбираемый JSON (ok=false — ответ без JSON после - // ретраев; ядро трактует как «не разобрано» и падает в локальный путь). - bool ok = 1; - // Сырой JSON-ответ модели (строкой; маппинг в карточку — в ядре). - optional string json = 2; - // Оценка токенов вызова (Ruling 5). - Usage usage = 3; -} - -message GenerateKeywordsRequest { - // Описание ниши/задачи (ядро обрезает до 4000, discovery_routes L29). - string description = 1; - // Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса). - ProviderConfig provider_config = 2; -} - -message GenerateKeywordsReply { - // Сгенерированные ключи (пустой список — модель не выделила ключи; - // чистку/дедуп и мягкую ошибку для UI делает ядро — Ruling 11). - repeated string keywords = 1; - // Оценка токенов вызова (Ruling 5). - Usage usage = 2; -} - -message EvaluateFitRequest { - // Текст сообщения для оценки (выборка кандидата; ядро ограничивает 4000). - string text = 1; - // Описание задачи поиска (discovery_eval L51). - string description = 2; - // Ключи задачи (discovery_eval L52; подставляются в промпт сервисом). - repeated string keywords = 3; - // Конфиг активного провайдера (Ruling 5; все запросы ai-сервиса). - ProviderConfig provider_config = 4; -} - -message EvaluateFitReply { - // True — сообщение относится к сфере/задаче (JSON {"fit": 0|1}). - bool fit = 1; - // Краткая причина решения модели (пуст, если модель её не дала). - optional string reason = 2; - // Оценка токенов вызова (Ruling 5). - Usage usage = 3; -} - -// Оценка токенов вызова провайдера (Ruling 5: usage{prompt/completion/total}; -// из usage API-ответа, при отсутствии — по символам ≈chars/4). -message Usage { - // Токены запроса (system + user). - uint32 prompt = 1; - // Токены ответа модели. - uint32 completion = 2; - // Суммарно (prompt + completion; может отличаться от суммы при подсчёте - // провайдером — берём как есть). - uint32 total = 3; -} +// +// ядро передаёт в теле каждого запроса готовые (заполненные) промпты и/или +// текст + конфиг активного провайдера (ProviderConfig); сервис вызывает +// провайдера (OpenAI-совместимые POST {base}/chat/completions, Anthropic +// POST {base}/v1/messages; temperature 0.2, таймауты 90/60 с, retry 2 с +// паузами 0.8/2 с) и возвращает ответ + оценку токенов. Конфиг провайдера +// (id/base/model/apiKey/api_style) ядро кладёт в поле provider_config каждого +// запроса — сервис настроек тенанта не знает и не хранит. +// +// tenant-id — id тенанта (строка; учёт токенов в ядре по нему); +// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/ +// пустой → UNAUTHENTICATED. +// +// INVALID_ARGUMENT — невалидный запрос (пустой текст/промпт и т.п.); +// UNAVAILABLE — провайдер не ответил корректно после ретраев; detail = +// «ИИ (имя) не ответил корректно — повторите попытку через +// несколько секунд» (ядро падает в локальный разбор). +// +// Берётся из usage API-ответа провайдера; при отсутствии оценивается по +// символам (≈chars/4). Ядро копит значения в tenant-KV aiTokenUsage. +// +// Deadlines (клиент ядра): все RPC — 120 с (90 с провайдер + ретраи 0.8/2 с; +syntax = "proto3"; + +package deal.ai.v1; + +option csharp_namespace = "Deal.Grpc.Ai"; + +service AiService { + // ядро шлёт заполненный aiFilterPrompt (fill_prompt делает ядро) и текст; + // решение {pass, reason}. Ветку «фильтр не применялся» (aiFilterEnabled/ + // недоступность) обрабатывает ядро до вызова — сервис всегда отвечает. + rpc Filter(FilterRequest) returns (FilterReply); + + // «Доски + примеры разметки + Сообщение»; сервис возвращает извлечённый + // ответ модели как json-строку (типовую схему задаёт промпт). Строгий + // clean_budget/build_contacts). + rpc Classify(ClassifyRequest) returns (ClassifyReply); + + // Генерация ключевых слов для discovery-задачи по описанию (фикс. промпт + rpc GenerateKeywords(GenerateKeywordsRequest) returns (GenerateKeywordsReply); + + // Оценка соответствия сообщения задаче поиска (промпт discovery_eval + // Ядро зовёт только при aiEnabled; сбой/не-JSON — фолбэк на эвристику. + rpc EvaluateFit(EvaluateFitRequest) returns (EvaluateFitReply); +} + +// --- Запросы/ответы AiService --- + +// aiConfigs и передаёт в теле каждого запроса; сервис не хранит настроек). +// api_style — из каталога AiProviders (Settings); HTTP-клиенту провайдера +// нужны baseUrl+model+apiKey для запроса и api_style для выбора схемы вызова +// (OpenAI-совместимые chat/completions vs Anthropic Messages API). +message ProviderConfig { + // Id провайдера (ключ aiConfigs / каталога AiProviders: deepseek, openai, + // anthropic, ollama, lmstudio, custom…). + string provider_id = 1; + // Эффективный базовый URL API (aiConfigs.baseUrl или дефолт каталога). + string base_url = 2; + // API-ключ открытым текстом (расшифрован ядром); пуст для локальных + // провайдеров (ollama/lmstudio) — заголовок авторизации не шлётся. + optional string api_key = 3; + // Активная модель (aiConfigs.model или первая из каталога провайдера). + string model = 4; + // Стиль API: пуст — OpenAI-совместимый (POST {base}/chat/completions, + // Bearer); "anthropic" — Messages API (POST {base}/v1/messages, + // x-api-key + anthropic-version). + optional string api_style = 5; +} + +message FilterRequest { + // Заполненный промпт фильтра (настройка aiFilterPrompt с подстановкой + string prompt = 1; + string text = 2; + ProviderConfig provider_config = 3; +} + +message FilterReply { + // True — сообщение проходит фильтр (не спам/реклама/служебное). + bool pass = 1; + // Причина отказа при pass=false (текст ветки filter_ai; пуст при пропуске). + optional string reason = 2; + Usage usage = 3; +} + +message ClassifyRequest { + string system_prompt = 1; + // user-контекст: «Доски + примеры разметки + Новое сообщение» (собирает + string user_context = 2; + ProviderConfig provider_config = 3; +} + +message ClassifyReply { + // True — модель вернула разбираемый JSON (ok=false — ответ без JSON после + // ретраев; ядро трактует как «не разобрано» и падает в локальный путь). + bool ok = 1; + // Сырой JSON-ответ модели (строкой; маппинг в карточку — в ядре). + optional string json = 2; + Usage usage = 3; +} + +message GenerateKeywordsRequest { + string description = 1; + ProviderConfig provider_config = 2; +} + +message GenerateKeywordsReply { + // Сгенерированные ключи (пустой список — модель не выделила ключи; + repeated string keywords = 1; + Usage usage = 2; +} + +message EvaluateFitRequest { + // Текст сообщения для оценки (выборка кандидата; ядро ограничивает 4000). + string text = 1; + string description = 2; + repeated string keywords = 3; + ProviderConfig provider_config = 4; +} + +message EvaluateFitReply { + // True — сообщение относится к сфере/задаче (JSON {"fit": 0|1}). + bool fit = 1; + // Краткая причина решения модели (пуст, если модель её не дала). + optional string reason = 2; + Usage usage = 3; +} + +// из usage API-ответа, при отсутствии — по символам ≈chars/4). +message Usage { + // Токены запроса (system + user). + uint32 prompt = 1; + // Токены ответа модели. + uint32 completion = 2; + // Суммарно (prompt + completion; может отличаться от суммы при подсчёте + // провайдером — берём как есть). + uint32 total = 3; +} diff --git a/src/contracts/ml.proto b/src/contracts/ml.proto index 6a0cc8d..501c2d2 100644 --- a/src/contracts/ml.proto +++ b/src/contracts/ml.proto @@ -1,147 +1,127 @@ -// ml.proto — контракт между ядром Deal и ml-service (этап 6). -// -// Инкрементальная наивно-байесовская модель по терминам, 1:1 с python -// mlservice/model.py (predict L184–293, status L325–345, reset L348–354, -// learn_batch L147–173) и DTO ядра Deal.Contracts.Integrations.Models -// (MlPredictResultDto/MlServiceStatusDto/MlEvalDto/MlResetResultDto). -// Модель per-tenant: пул в ml-service, файл SQLite data/ml/.sqlite -// (Ruling 4). Обучение ядро шлёт батчами из очереди ml_outbox -// (MlOutboxFlushScheduler, Ruling 6). -// -// Каждый RPC обязан нести в gRPC-metadata два заголовка (Ruling 1): -// tenant-id — id тенанта (строка; модель тенанта — в пуле сервиса); -// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/ -// пустой → UNAUTHENTICATED. -// -// Ошибки домена — gRPC-статусы с detail = текст причины 1:1 (Ruling 1): -// INVALID_ARGUMENT — невалидный запрос (пустой text и т.п.); -// UNAVAILABLE — хранилище модели недоступно (ядро отвечает «не уверен», -// Ruling 6 — кэш reachable 15 с). -// -// Семантика неготовой модели: Predict НЕ ошибка — модель без опыта отвечает -// фиксированным «не уверен»: take=false, label пуст, scores пуст, hits=0, -// ready=false, margin пуст, terms пуст, type пуст (Ruling 5 этапа 2, 1:1). -// -// Deadlines (клиент ядра): Predict — 5 с; Status/Reset — 10 с; TrainBatch — 30 с -// (батч ≤100 примеров, одна транзакция). -syntax = "proto3"; - -package deal.ml.v1; - -option csharp_namespace = "Deal.Grpc.Ml"; - -service MlService { - // Предсказание по тексту сообщения (model.py predict L184–293). - // take/label/scores/hits/margin/terms/type осмысленны только при take=true; - // scores — до 5 лучших «класс → вес» (round 3), margin — адаптивный порог - // (0.9/0.7/0.5/0.35 после 0/60/150/400 примеров), terms — узнанные термины - // класса-победителя, type — решение о типе заявки (t:hire/t:order). - rpc Predict(PredictRequest) returns (PredictReply); - - // Статус модели тенанта (model.py status L325–345): ready/classes/learned/eval. - // classes — «label → вес» (round 2, по убыванию); eval — самооценка по окну - // последних подтверждённых решений (EVAL_WINDOW). Модель создаётся лениво - // по первому обращению (Ruling 4) — отсутствие опыта это НЕ ошибка. - rpc Status(StatusRequest) returns (StatusReply); - - // Полный сброс модели тенанта (model.py reset L348–354): очистка классов, - // терминов и журнала самооценки + пересоздание файла. Ok=true; мягкая - // ошибка — Ok=false + error (ядро чистит свою ml_outbox только при успехе). - rpc Reset(ResetRequest) returns (ResetReply); - - // Пакетное обучение (model.py learn_batch L147–173): одна транзакция + - // пакетные вставки терминов; самооценка по действиям пользователя (delta=1, - // не t:*) до применения. Ответ — число применённых примеров. - rpc TrainBatch(TrainBatchRequest) returns (TrainBatchReply); -} - -message PredictRequest { - // Текст сообщения (ядро передаёт уже обрезанный/нормализованный, как - // ml_routes.py L86–90; пустой/пробельный — не ошибка: ответ «не уверен»). - string text = 1; -} - -message PredictReply { - // True — модель уверена (take) и решение можно использовать без ИИ. - bool take = 1; - // Класс решения: id колонки канбана (b_…) или "spam"; пуст, если не уверена. - optional string label = 2; - // Веса классов: «label → вес» (до 5 лучших; пуст у неготовой модели). - map scores = 3; - // Сколько терминов класса-победителя модель узнала в тексте. - int32 hits = 4; - // Модель обучена (набрала MIN_TOTAL/MIN_WINNER/MIN_WINNER_SPAM) и может - // принимать решения. - bool ready = 5; - // Порог уверенности решения (адаптивный margin, 2 знака); пуст — нет решения. - optional double margin = 6; - // Узнанные термины класса-победителя (подсказка структуры карточки, ≤8). - repeated string terms = 7; - // Решение о типе заявки (hire/order); пуст — модель тип не определила. - TypeDecision type = 8; -} - -// Решение ML о типе заявки (predict L233–238; MlTypeDecisionDto). -message TypeDecision { - // True — модель уверена в типе. - bool take = 1; - // Тип: "hire" | "order". - string label = 2; - // Внутренний класс ML: "t:hire" | "t:order" (не показывается UI). - string value = 3; - // Запас уверенности (margin, 2 знака). - double margin = 4; -} - -message StatusRequest {} - -message StatusReply { - // Модель готова принимать решения. - bool ready = 1; - // Классы модели: «label → вес» (round 2; пуст, пока нет обучения). - map classes = 2; - // Всего примеров, на которых модель обучалась (сумма по классам). - int32 learned = 3; - // Самооценка модели по последним подтверждённым решениям. - ModelEval eval = 4; -} - -// Окно самооценки модели (model.py status L329–339; MlEvalDto). -message ModelEval { - // Решений в окне самооценки (последние EVAL_WINDOW). - int32 count = 1; - // Из них совпавших с действием пользователя. - int32 correct = 2; - // Доля верных (correct/count, 0..1; 0 при пустом окне). - double accuracy = 3; -} - -message ResetRequest {} - -message ResetReply { - // True — модель сброшена (и ядро очищает свою очередь обучения). - bool ok = 1; - // Текст ошибки при сбое сброса (пуст при успехе) — мягкая ошибка. - optional string error = 2; -} - -message TrainBatchRequest { - // Примеры обучения (1 транзакция на батч; ядро шлёт ≤100 за цикл, Ruling 6). - repeated TrainExample items = 1; -} - -// Один обучающий пример (строка ml_outbox ядра: text/label/delta). -message TrainExample { - // Текст примера (source_msg карточки или title). - string text = 1; - // Метка: id доски (b_…), "spam" либо тип "t:hire"/"t:order". - string label = 2; - // Вес сигнала: 1.0 — действие пользователя; −1.0 — снять метку; - // 0.4/0.6 — сигналы ИИ/правил (этапы 4/6). - double delta = 3; -} - -message TrainBatchReply { - // Число применённых примеров (= len(items) при успехе). - int32 learned = 1; -} +// +// (MlPredictResultDto/MlServiceStatusDto/MlEvalDto/MlResetResultDto). +// Модель per-tenant: пул в ml-service, файл SQLite data/ml/.sqlite +// +// tenant-id — id тенанта (строка; модель тенанта — в пуле сервиса); +// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/ +// пустой → UNAUTHENTICATED. +// +// INVALID_ARGUMENT — невалидный запрос (пустой text и т.п.); +// UNAVAILABLE — хранилище модели недоступно (ядро отвечает «не уверен», +// +// Семантика неготовой модели: Predict НЕ ошибка — модель без опыта отвечает +// фиксированным «не уверен»: take=false, label пуст, scores пуст, hits=0, +// +// Deadlines (клиент ядра): Predict — 5 с; Status/Reset — 10 с; TrainBatch — 30 с +// (батч ≤100 примеров, одна транзакция). +syntax = "proto3"; + +package deal.ml.v1; + +option csharp_namespace = "Deal.Grpc.Ml"; + +service MlService { + // take/label/scores/hits/margin/terms/type осмысленны только при take=true; + // scores — до 5 лучших «класс → вес» (round 3), margin — адаптивный порог + // (0.9/0.7/0.5/0.35 после 0/60/150/400 примеров), terms — узнанные термины + // класса-победителя, type — решение о типе заявки (t:hire/t:order). + rpc Predict(PredictRequest) returns (PredictReply); + + // classes — «label → вес» (round 2, по убыванию); eval — самооценка по окну + // последних подтверждённых решений (EVAL_WINDOW). Модель создаётся лениво + rpc Status(StatusRequest) returns (StatusReply); + + // терминов и журнала самооценки + пересоздание файла. Ok=true; мягкая + // ошибка — Ok=false + error (ядро чистит свою ml_outbox только при успехе). + rpc Reset(ResetRequest) returns (ResetReply); + + // пакетные вставки терминов; самооценка по действиям пользователя (delta=1, + // не t:*) до применения. Ответ — число применённых примеров. + rpc TrainBatch(TrainBatchRequest) returns (TrainBatchReply); +} + +message PredictRequest { + // Текст сообщения (ядро передаёт уже обрезанный/нормализованный, как + string text = 1; +} + +message PredictReply { + // True — модель уверена (take) и решение можно использовать без ИИ. + bool take = 1; + // Класс решения: id колонки канбана (b_…) или "spam"; пуст, если не уверена. + optional string label = 2; + // Веса классов: «label → вес» (до 5 лучших; пуст у неготовой модели). + map scores = 3; + // Сколько терминов класса-победителя модель узнала в тексте. + int32 hits = 4; + // Модель обучена (набрала MIN_TOTAL/MIN_WINNER/MIN_WINNER_SPAM) и может + // принимать решения. + bool ready = 5; + // Порог уверенности решения (адаптивный margin, 2 знака); пуст — нет решения. + optional double margin = 6; + // Узнанные термины класса-победителя (подсказка структуры карточки, ≤8). + repeated string terms = 7; + // Решение о типе заявки (hire/order); пуст — модель тип не определила. + TypeDecision type = 8; +} + +message TypeDecision { + // True — модель уверена в типе. + bool take = 1; + // Тип: "hire" | "order". + string label = 2; + // Внутренний класс ML: "t:hire" | "t:order" (не показывается UI). + string value = 3; + // Запас уверенности (margin, 2 знака). + double margin = 4; +} + +message StatusRequest {} + +message StatusReply { + // Модель готова принимать решения. + bool ready = 1; + // Классы модели: «label → вес» (round 2; пуст, пока нет обучения). + map classes = 2; + // Всего примеров, на которых модель обучалась (сумма по классам). + int32 learned = 3; + // Самооценка модели по последним подтверждённым решениям. + ModelEval eval = 4; +} + +message ModelEval { + // Решений в окне самооценки (последние EVAL_WINDOW). + int32 count = 1; + // Из них совпавших с действием пользователя. + int32 correct = 2; + // Доля верных (correct/count, 0..1; 0 при пустом окне). + double accuracy = 3; +} + +message ResetRequest {} + +message ResetReply { + // True — модель сброшена (и ядро очищает свою очередь обучения). + bool ok = 1; + // Текст ошибки при сбое сброса (пуст при успехе) — мягкая ошибка. + optional string error = 2; +} + +message TrainBatchRequest { + repeated TrainExample items = 1; +} + +// Один обучающий пример (строка ml_outbox ядра: text/label/delta). +message TrainExample { + // Текст примера (source_msg карточки или title). + string text = 1; + // Метка: id доски (b_…), "spam" либо тип "t:hire"/"t:order". + string label = 2; + // Вес сигнала: 1.0 — действие пользователя; −1.0 — снять метку; + double delta = 3; +} + +message TrainBatchReply { + // Число применённых примеров (= len(items) при успехе). + int32 learned = 1; +} diff --git a/src/contracts/telegram.proto b/src/contracts/telegram.proto index 8d4682f..83b3e95 100644 --- a/src/contracts/telegram.proto +++ b/src/contracts/telegram.proto @@ -1,453 +1,401 @@ -// telegram.proto — контракт между ядром Deal и telegram-service (этап 6). -// -// Два сервиса в одном файле (дизайн-док §6.2, план Task 1, Ruling 1/7): -// * TelegramService — команды ядра к telegram-service (порт-гейт ITelegramGateway): -// подключение/отключение аккаунта, каталог диалогов, мониторинг, backfill, -// превью, discovery-операции (поиск/инфо/чтение/вступление/выход); -// * IngressService — исходящий поток telegram-service → ядро: сырые сообщения -// (PushMessage), синхронизация каталога (SyncDialogs), статус аккаунта -// (ReportStatus). Сервер ингресса живёт в Deal.Api (:5082, Ruling 7). -// -// Семантика методов 1:1 с python-прототипом backend/app/services/telegram.py -// (имена L134–873) и api-map §3.3/§4.8/§4.9; хранение диалогов/статуса — только -// в ядре (модуль Deal.Modules.Telegram, Ruling 7), сервис БД тенантов не знает. -// -// Каждый RPC обязан нести в gRPC-metadata два заголовка (Ruling 1): -// tenant-id — id тенанта (строка; единственный источник принадлежности, -// полю в теле не доверяем); -// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/ -// пустой → UNAUTHENTICATED. -// -// Ошибки домена — gRPC-статусы с detail = текст причины 1:1 с прототипом: -// INVALID_ARGUMENT — неверный ввод/неверный код/неверный пароль и т.п.; -// NOT_FOUND — диалог/сущность не найдены (нет сессии тенанта и т.п.); -// FAILED_PRECONDITION— операция невозможна в текущей фазе (нет сессии и т.п.); -// RESOURCE_EXHAUSTED — FloodWait Telegram (detail начинается с префикса "flood"); -// UNAVAILABLE — недоступность Telegram/сети (безопасный повтор). -// -// Значения строк (канон контракта, .NET-код обеих сторон — новый): -// * phase: idle|phone|code|password|qr|ready (как status() прототипа L85); -// * kind: channel (канал) | group (группа/супергруппа) | forum (форум) | -// chat (личный чат/бот). 1:1 с _kind_of (L461–466): broadcast → -// channel, megagroup/gigagroup/group → group, остальное → chat. -// Forum выставляется отдельным флагом is_forum (GetInfo); в -// каталоге (RefreshDialogs) форум приходит как group. -// -// Deadlines (клиент ядра; уточняются адаптерами T2+): -// * быстрые команды статуса/мониторинга — 10 с; -// * сетевые операции Telegram (QR/код/поиск/инфо/чтение/вступление) — 60 с; -// * Backfill/RefreshDialogs (паузы анти-бана 1.5–3 с/сообщение) — 120 с; -// * IngressService (локальная сеть core) — 10 с (сбой догоняет sweep). -syntax = "proto3"; - -package deal.telegram.v1; - -option csharp_namespace = "Deal.Grpc.Telegram"; - -// --------------------------------------------------------------------------- -// TelegramService — команды ядра → telegram-service (клиентская сторона в core) -// --------------------------------------------------------------------------- - -service TelegramService { - // Текущий статус аккаунта/фазы входа тенанта (status() прототипа L103–119). - // live-поля для GET /api/tg/status (Ruling 8); monitored/keysSet ядро считает - // само. Нет сессии — FAILED_PRECONDITION «Telegram не подключён». - rpc GetStatus(GetStatusRequest) returns (GetStatusReply); - - // Вход по номеру телефона: запросить код (start_phone L134–147). - // api_id/api_hash — глобальные ключи приложения Telegram, задаёт оператор (ТЗ §4.1/§8.1), - // передаёт ядро в теле (Ruling 3); нет ключей — ядро отвечает 400 «Ключи Telegram - // не заданы оператором» до вызова. Ответ: новая фаза ("code"). - rpc StartPhone(StartPhoneRequest) returns (StartPhoneReply); - - // Начать QR-вход (qr_start L286–300). Ответ: фаза + qrUrl (t.me/qr/...); - // если аккаунт уже авторизован — фаза "ready", qrUrl пуст. - rpc StartQr(StartQrRequest) returns (StartQrReply); - - // Отправить SMS-код (submit_code L149–166). Ошибки: «Неверный код», - // «Код истёк — запросите новый» → INVALID_ARGUMENT. Нужен 2FA — фаза - // "password", иначе "ready". Фазы вне "code" → FAILED_PRECONDITION. - rpc SendCode(SendCodeRequest) returns (SendCodeReply); - - // Облачный пароль 2FA (submit_password L168–176). Ошибка «Неверный облачный - // пароль» → INVALID_ARGUMENT. Ответ: фаза ("ready"). - rpc SendPassword(SendPasswordRequest) returns (SendPasswordReply); - - // Отключить аккаунт, удалить сессию тенанта (disconnect L189–207). - rpc Logout(LogoutRequest) returns (LogoutReply); - - // Синхронизировать каталог диалогов из Telegram (refresh_dialogs L505–519): - // актуальный список sources диалогов аккаунта (entries). Удаление/обновление - // каталога и авто-мониторинг новых делает ядро (SyncFromTelegram, Ruling 7). - rpc RefreshDialogs(RefreshDialogsRequest) returns (RefreshDialogsReply); - - // Включить/выключить мониторинг диалога (set_monitor L536–546): обновляет - // зеркало monitored в сервисе. Первый backfill (force=false) запускает ядро - // отдельным RPC Backfill. Ответ: ok/enabled. - rpc SetMonitor(SetMonitorRequest) returns (SetMonitorReply); - - // Мониторинг всех диалогов сразу (set_monitor_all L548–567). Ответ: - // ok/count/enabled (count — сколько диалогов в каталоге тенанта). - rpc SetMonitorAll(SetMonitorAllRequest) returns (SetMonitorAllReply); - - // Перечитать последние ~10 сообщений диалога и отправить их в ядро потоком - // PushMessage (backfill_dialog L349–390; паузы анти-бана 1.5–3 с/сообщение, - // mark-as-read). force=true — «Перечитать» по кнопке даже для разобранных. - // Ответ: сколько сообщений отправлено (processed). - rpc Backfill(BackfillRequest) returns (BackfillReply); - - // Последние сообщения диалога для превью (dialog_messages L583–620): - // свежие из Telegram; признак lead и фолбэк на БД добавляет ядро - // (у сервиса нет БД тенанта). limit 1..50 (api-map /dialogs/preview). - rpc ReadRecent(ReadRecentRequest) returns (ReadRecentReply); - - // Глобальный поиск каналов/групп по ключу (discovery_search L624–664). - // Пауза анти-бана после поиска — внутри сервиса. Личные чаты/боты ядро - // отсеивает само (Ruling 10). Результат — entries канала/группы. - rpc Search(SearchRequest) returns (SearchReply); - - // Инфо об источнике для оценки (discovery_info L666–716): имя/username/kind/ - // hue + participants и is_forum (полный чат). Сбои определения не роняют - // RPC: participants пуст, остальные поля — из entity/каталога. - rpc GetInfo(GetInfoRequest) returns (GetInfoReply); - - // Выборка последних сообщений источника для оценки кандидата - // (discovery_read L718–760): форумы читаются по активным темам. История - // недоступна (приватный/закрытый источник) — ok=false, error="no_history", - // это НЕ ошибка RPC. limit ≥ 1; topic_id/topic_title заполнены для форумов. - rpc ReadForEval(ReadForEvalRequest) returns (ReadForEvalReply); - - // Вступить в канал/группу по @username (discovery_join L818–839; ручной - // join вне квот — паузу перед авто-join делает воркер ядра, Ruling 10). - // FloodWait → RESOURCE_EXHAUSTED (detail с префиксом "flood"). - rpc Join(JoinRequest) returns (JoinReply); - - // Выйти из канала/группы (discovery_leave L841–848). NOT_FOUND — нет - // диалога/членства. - rpc Leave(LeaveRequest) returns (LeaveReply); -} - -// --- Запросы/ответы TelegramService --- - -message GetStatusRequest {} - -// Статус аккаунта/фазы входа (shape прототипа status() L110–118; monitored и -// keysSet ядро добавляет само из своей БД/настроек — Ruling 8). -message GetStatusReply { - // Фаза входа: idle|phone|code|password|qr|ready. - string phase = 1; - // Клиент Telegram подключён и авторизован. - bool connected = 2; - // Жив ли realtime-listener (поток новых сообщений → PushMessage). - bool listener = 3; - // Аккаунт "@username" (для справки; источник истины — KV tgAccount по - // ReportStatus, ядро использует KV — Ruling 8). - string account = 4; - // Текст последней ошибки (null, если ошибки нет). - optional string error = 5; - // URL QR-входа (заполнен только при phase == "qr"). - optional string qr_url = 6; -} - -// Подключение по телефону: ключи API передаёт ядро (Ruling 3). -message StartPhoneRequest { - // Номер телефона в международном формате (как ввёл пользователь). - string phone = 1; - // api_id приложения Telegram (глобальные ключи, задаёт оператор; 5..9 цифр). - int32 api_id = 2; - // api_hash приложения Telegram (глобальные ключи, задаёт оператор). - string api_hash = 3; -} - -message StartPhoneReply { - // Фаза после запроса кода ("code"); при ошибке — RPC-статус. - string phase = 1; -} - -message StartQrRequest { - // api_id/api_hash приложения Telegram (см. StartPhoneRequest). - int32 api_id = 1; - string api_hash = 2; -} - -message StartQrReply { - // Фаза после запуска: "qr" (ждём сканирования) либо "ready" (уже вошли). - string phase = 1; - // URL вида https://t.me/qr/... для отрисовки QR; пуст при phase != "qr". - string qr_url = 2; -} - -message SendCodeRequest { - // Код из SMS/Telegram-сообщения. - string code = 1; -} - -message SendCodeReply { - // Фаза после проверки кода: "password" (нужен 2FA) или "ready". - string phase = 1; -} - -message SendPasswordRequest { - // Облачный пароль 2FA. - string password = 1; -} - -message SendPasswordReply { - // Фаза после входа ("ready"). - string phase = 1; -} - -message LogoutRequest {} - -message LogoutReply { - // True — аккаунт отключён, сессия тенанта удалена. - bool ok = 1; -} - -message RefreshDialogsRequest {} - -message RefreshDialogsReply { - // Актуальный каталог диалогов аккаунта (id/name/username/kind/hue). - // Ядро применяет его через SyncFromTelegram (Ruling 7). - repeated DialogEntry entries = 1; -} - -// Один диалог/канал каталога или результат поиска (shape refresh L516 и -// discovery_search L653–660: tuple id/name/handle/kind/hue; handle == username). -message DialogEntry { - // Подписанный id диалога: каналы "-100…", группы "-…", личные "+…". - string id = 1; - // Отображаемое имя (title/first_name) или id, если имени нет. - string name = 2; - // Username (handle) источника; пуст, если нет публичного username. - string username = 3; - // Тип: channel|group|forum|chat (канон контракта, см. шапку файла). - string kind = 4; - // Цвет источника из палитры DIALOG_HUES (hex, "#rrggbb") — считает сервис. - string hue = 5; -} - -message SetMonitorRequest { - // Id диалога каталога. - string dialog_id = 1; - // True — мониторить (сообщения → PushMessage в ядро), false — выключить. - bool enabled = 2; -} - -message SetMonitorReply { - bool ok = 1; - // Зеркальное значение enabled (для ответов эндпоинтов {ok, enabled}). - bool enabled = 2; -} - -message SetMonitorAllRequest { - // True — мониторить все диалоги каталога, false — снять мониторинг со всех. - bool enabled = 1; -} - -message SetMonitorAllReply { - bool ok = 1; - // Сколько диалогов в каталоге тенанта (api-map /monitor-all → count). - int32 count = 2; - bool enabled = 3; -} - -message BackfillRequest { - // Id диалога для перечитывания. - string dialog_id = 1; - // True — перечитать, даже если диалог уже разобран (кнопка «Перечитать»). - bool force = 2; -} - -message BackfillReply { - // Сколько сообщений отправлено в ядро потоком PushMessage. - int32 processed = 1; -} - -message ReadRecentRequest { - // Id диалога. - string dialog_id = 1; - // Сколько последних сообщений (1..50; api-map /dialogs/preview limit 1..50). - int32 limit = 2; -} - -message ReadRecentReply { - // Последние сообщения (от новых к старым). lead/фолбэк на БД — в ядре. - repeated PreviewMessage messages = 1; -} - -// Сообщение превью диалога (api-map §4.8 L351: {id, text, time, lead}). -message PreviewMessage { - // Id сообщения в Telegram (int); фолбэк-сообщения из БД ядра — строки - // "m__", поэтому значение передаётся строкой. - string id = 1; - // Текст сообщения. - string text = 2; - // Время сообщения, epoch-ms. - int64 time = 3; -} - -message SearchRequest { - // Поисковый запрос (ключ задачи discovery). - string query = 1; - // Верхняя граница результатов (прототип: default 30). - int32 limit = 2; -} - -message SearchReply { - // Найденные источники (каналы/группы; личные чаты/ботов отсеивает ядро). - repeated DialogEntry results = 1; -} - -message GetInfoRequest { - // Id источника (подписанный; из каталога или результата поиска). - string dialog_id = 1; -} - -// Инфо об источнике для оценки кандидата discovery (discovery_info L674–682). -message ChannelInfo { - string id = 1; - string name = 2; - string username = 3; - // Тип: channel|group|forum|chat. - string kind = 4; - string hue = 5; - // Число участников (full_chat); пусто — определить не удалось. - optional int32 participants = 6; - // True — мегагруппа-форум (темы); ядро трактует kind как "forum" (Ruling 10). - bool is_forum = 7; -} - -message GetInfoReply { - ChannelInfo info = 1; -} - -message ReadForEvalRequest { - // Id источника. - string dialog_id = 1; - // Размер выборки (прототип discovery_read: limit сообщений/тем). - int32 limit = 2; -} - -message ReadForEvalReply { - // True — выборка получена; false — история недоступна без членства. - bool ok = 1; - // Код причины при ok=false: "no_history" (остальные поля пусты). - optional string error = 2; - // Сообщения выборки (форумы — по активным темам, topic_id/topic_title - // заполнены; для обычных источников — null). - repeated EvalMessage messages = 3; -} - -// Сообщение выборки discovery_read (_discovery_message_item L803–816). -message EvalMessage { - // Id сообщения в Telegram. - int64 id = 1; - // Текст сообщения (непустой; пустые тексты отбрасывает сервис). - string text = 2; - // Время сообщения, epoch-ms. - int64 date_ms = 3; - // Id темы форума (для обычных источников пусто). - optional int64 topic_id = 4; - // Название темы форума (для обычных источников пусто). - optional string topic_title = 5; -} - -message JoinRequest { - // @username источника (без "@"; пусто → INVALID_ARGUMENT). - string username = 1; -} - -message JoinReply { - bool ok = 1; -} - -message LeaveRequest { - // Id диалога для выхода. - string dialog_id = 1; -} - -message LeaveReply { - bool ok = 1; -} - -// --------------------------------------------------------------------------- -// IngressService — исходящий поток telegram-service → ядро -// (gRPC-сервер в Deal.Api :5082; Ruling 7; интерцептор service-token; -// tenantId из metadata → собственный scope с ITenantContext.SetTenant) -// --------------------------------------------------------------------------- - -service IngressService { - // Новое/догоняющее сообщение мониторящегося диалога → очередь пайплайна - // ядра (PipelineIngestService.EnqueueAsync, контракт demo-ingest; + превью в - // TgMessages). Дубль dialog+msgId уже в очереди — не растёт (duplicate=true). - rpc PushMessage(PushMessageRequest) returns (PushMessageReply); - - // Синхронизация каталога диалогов: ядро применяет entries (SyncFromTelegram: - // авто-мониторинг по autoMonitorNew, обновление, удаление отсутствующих) и - // отвечает актуальным списком monitored id — сервис держит зеркало - // мониторинга в памяти (Ruling 7), по нему фильтрует события realtime. - rpc SyncDialogs(SyncDialogsRequest) returns (SyncDialogsReply); - - // Периодический/событийный статус аккаунта: ядро пишет KV tgStatus/tgAccount - // и публикует SSE system_status + тосты на переходах фаз (Ruling 7). - rpc ReportStatus(ReportStatusRequest) returns (ReportStatusReply); -} - -// Сообщение из потока в ядро. Поля 1:1 с QueuedMessage/PipelineIngestRequest -// (Ruling 7, demo-ingest L7–59): dialog_id + канальные поля плоские; msg_id — -// дубль-гвард; msg_at — время исходного сообщения, без него ядро подставит now. -message PushMessageRequest { - // Id диалога-источника (подписанный; пуст — приём no-op). - string dialog_id = 1; - // Имя канала/диалога (title/first_name или id). - string channel_name = 2; - // Username канала/диалога (пуст, если нет). - string channel_handle = 3; - // Цвет канала из палитры DIALOG_HUES (hex; считает сервис — Ruling 7). - string channel_hue = 4; - // Id исходного сообщения в Telegram (дубль-гвард dialog+msgId). - optional int64 msg_id = 5; - // Текст сообщения (сервис шлёт как есть; приём обрежет до 6000). - string text = 6; - // Время исходного сообщения, epoch-ms; пусто — ядро подставит now. - optional int64 msg_at = 7; -} - -message PushMessageReply { - // True — сообщение принято (no-op с пустым текстом/диалогом — accepted=false). - bool accepted = 1; - // True — дубль dialog_id+msg_id уже в очереди (очередь не выросла). - bool duplicate = 2; -} - -message SyncDialogsRequest { - // Актуальный каталог диалогов (собирает сервис, как refresh_dialogs). - repeated DialogEntry entries = 1; -} - -message SyncDialogsReply { - // Id диалогов с включённым мониторингом (зеркало сервиса после синка). - repeated string monitored_ids = 1; -} - -// Статус аккаунта для ядра (shape прототипа _publish_status L315–316/status()). -message ReportStatusRequest { - // Фаза: idle|phone|code|password|qr|ready. - string phase = 1; - // Клиент подключён и авторизован. - bool connected = 2; - // Realtime-listener жив. - bool listener = 3; - // Аккаунт "@username" (пуст после выхода) → KV tgAccount. - string account = 4; - // Текст ошибки (пуст, если нет) → KV tgStatus.error. - optional string error = 5; - // URL QR-входа при phase == "qr". - optional string qr_url = 6; -} - -message ReportStatusReply { - // True — статус принят и сохранён ядром. - bool ok = 1; -} +// +// * TelegramService — команды ядра к telegram-service (порт-гейт ITelegramGateway): +// подключение/отключение аккаунта, каталог диалогов, мониторинг, backfill, +// превью, discovery-операции (поиск/инфо/чтение/вступление/выход); +// * IngressService — исходящий поток telegram-service → ядро: сырые сообщения +// (PushMessage), синхронизация каталога (SyncDialogs), статус аккаунта +// +// +// tenant-id — id тенанта (строка; единственный источник принадлежности, +// полю в теле не доверяем); +// service-token — общий токен сервисов (env DEAL_SERVICE_TOKEN); неверный/ +// пустой → UNAUTHENTICATED. +// +// INVALID_ARGUMENT — неверный ввод/неверный код/неверный пароль и т.п.; +// NOT_FOUND — диалог/сущность не найдены (нет сессии тенанта и т.п.); +// FAILED_PRECONDITION— операция невозможна в текущей фазе (нет сессии и т.п.); +// RESOURCE_EXHAUSTED — FloodWait Telegram (detail начинается с префикса "flood"); +// UNAVAILABLE — недоступность Telegram/сети (безопасный повтор). +// +// Значения строк (канон контракта, .NET-код обеих сторон — новый): +// * kind: channel (канал) | group (группа/супергруппа) | forum (форум) | +// channel, megagroup/gigagroup/group → group, остальное → chat. +// Forum выставляется отдельным флагом is_forum (GetInfo); в +// каталоге (RefreshDialogs) форум приходит как group. +// +// * быстрые команды статуса/мониторинга — 10 с; +// * сетевые операции Telegram (QR/код/поиск/инфо/чтение/вступление) — 60 с; +// * Backfill/RefreshDialogs (паузы анти-бана 1.5–3 с/сообщение) — 120 с; +// * IngressService (локальная сеть core) — 10 с (сбой догоняет sweep). +syntax = "proto3"; + +package deal.telegram.v1; + +option csharp_namespace = "Deal.Grpc.Telegram"; + +// --------------------------------------------------------------------------- +// TelegramService — команды ядра → telegram-service (клиентская сторона в core) +// --------------------------------------------------------------------------- + +service TelegramService { + // само. Нет сессии — FAILED_PRECONDITION «Telegram не подключён». + rpc GetStatus(GetStatusRequest) returns (GetStatusReply); + + // api_id/api_hash — глобальные ключи приложения Telegram, задаёт оператор (ТЗ §4.1/§8.1), + // не заданы оператором» до вызова. Ответ: новая фаза ("code"). + rpc StartPhone(StartPhoneRequest) returns (StartPhoneReply); + + // если аккаунт уже авторизован — фаза "ready", qrUrl пуст. + rpc StartQr(StartQrRequest) returns (StartQrReply); + + // «Код истёк — запросите новый» → INVALID_ARGUMENT. Нужен 2FA — фаза + // "password", иначе "ready". Фазы вне "code" → FAILED_PRECONDITION. + rpc SendCode(SendCodeRequest) returns (SendCodeReply); + + // пароль» → INVALID_ARGUMENT. Ответ: фаза ("ready"). + rpc SendPassword(SendPasswordRequest) returns (SendPasswordReply); + + rpc Logout(LogoutRequest) returns (LogoutReply); + + // актуальный список sources диалогов аккаунта (entries). Удаление/обновление + rpc RefreshDialogs(RefreshDialogsRequest) returns (RefreshDialogsReply); + + // зеркало monitored в сервисе. Первый backfill (force=false) запускает ядро + // отдельным RPC Backfill. Ответ: ok/enabled. + rpc SetMonitor(SetMonitorRequest) returns (SetMonitorReply); + + // ok/count/enabled (count — сколько диалогов в каталоге тенанта). + rpc SetMonitorAll(SetMonitorAllRequest) returns (SetMonitorAllReply); + + // Перечитать последние ~10 сообщений диалога и отправить их в ядро потоком + // mark-as-read). force=true — «Перечитать» по кнопке даже для разобранных. + // Ответ: сколько сообщений отправлено (processed). + rpc Backfill(BackfillRequest) returns (BackfillReply); + + // свежие из Telegram; признак lead и фолбэк на БД добавляет ядро + // (у сервиса нет БД тенанта). limit 1..50 (api-map /dialogs/preview). + rpc ReadRecent(ReadRecentRequest) returns (ReadRecentReply); + + // Пауза анти-бана после поиска — внутри сервиса. Личные чаты/боты ядро + rpc Search(SearchRequest) returns (SearchReply); + + // hue + participants и is_forum (полный чат). Сбои определения не роняют + // RPC: participants пуст, остальные поля — из entity/каталога. + rpc GetInfo(GetInfoRequest) returns (GetInfoReply); + + // Выборка последних сообщений источника для оценки кандидата + // недоступна (приватный/закрытый источник) — ok=false, error="no_history", + // это НЕ ошибка RPC. limit ≥ 1; topic_id/topic_title заполнены для форумов. + rpc ReadForEval(ReadForEvalRequest) returns (ReadForEvalReply); + + // FloodWait → RESOURCE_EXHAUSTED (detail с префиксом "flood"). + rpc Join(JoinRequest) returns (JoinReply); + + // диалога/членства. + rpc Leave(LeaveRequest) returns (LeaveReply); +} + +// --- Запросы/ответы TelegramService --- + +message GetStatusRequest {} + +message GetStatusReply { + // Фаза входа: idle|phone|code|password|qr|ready. + string phase = 1; + // Клиент Telegram подключён и авторизован. + bool connected = 2; + // Жив ли realtime-listener (поток новых сообщений → PushMessage). + bool listener = 3; + // Аккаунт "@username" (для справки; источник истины — KV tgAccount по + string account = 4; + // Текст последней ошибки (null, если ошибки нет). + optional string error = 5; + // URL QR-входа (заполнен только при phase == "qr"). + optional string qr_url = 6; +} + +message StartPhoneRequest { + // Номер телефона в международном формате (как ввёл пользователь). + string phone = 1; + // api_id приложения Telegram (глобальные ключи, задаёт оператор; 5..9 цифр). + int32 api_id = 2; + // api_hash приложения Telegram (глобальные ключи, задаёт оператор). + string api_hash = 3; +} + +message StartPhoneReply { + // Фаза после запроса кода ("code"); при ошибке — RPC-статус. + string phase = 1; +} + +message StartQrRequest { + // api_id/api_hash приложения Telegram (см. StartPhoneRequest). + int32 api_id = 1; + string api_hash = 2; +} + +message StartQrReply { + // Фаза после запуска: "qr" (ждём сканирования) либо "ready" (уже вошли). + string phase = 1; + // URL вида https://t.me/qr/... для отрисовки QR; пуст при phase != "qr". + string qr_url = 2; +} + +message SendCodeRequest { + // Код из SMS/Telegram-сообщения. + string code = 1; +} + +message SendCodeReply { + // Фаза после проверки кода: "password" (нужен 2FA) или "ready". + string phase = 1; +} + +message SendPasswordRequest { + // Облачный пароль 2FA. + string password = 1; +} + +message SendPasswordReply { + // Фаза после входа ("ready"). + string phase = 1; +} + +message LogoutRequest {} + +message LogoutReply { + // True — аккаунт отключён, сессия тенанта удалена. + bool ok = 1; +} + +message RefreshDialogsRequest {} + +message RefreshDialogsReply { + // Актуальный каталог диалогов аккаунта (id/name/username/kind/hue). + repeated DialogEntry entries = 1; +} + +message DialogEntry { + // Подписанный id диалога: каналы "-100…", группы "-…", личные "+…". + string id = 1; + // Отображаемое имя (title/first_name) или id, если имени нет. + string name = 2; + // Username (handle) источника; пуст, если нет публичного username. + string username = 3; + // Тип: channel|group|forum|chat (канон контракта, см. шапку файла). + string kind = 4; + // Цвет источника из палитры DIALOG_HUES (hex, "#rrggbb") — считает сервис. + string hue = 5; +} + +message SetMonitorRequest { + // Id диалога каталога. + string dialog_id = 1; + // True — мониторить (сообщения → PushMessage в ядро), false — выключить. + bool enabled = 2; +} + +message SetMonitorReply { + bool ok = 1; + // Зеркальное значение enabled (для ответов эндпоинтов {ok, enabled}). + bool enabled = 2; +} + +message SetMonitorAllRequest { + // True — мониторить все диалоги каталога, false — снять мониторинг со всех. + bool enabled = 1; +} + +message SetMonitorAllReply { + bool ok = 1; + // Сколько диалогов в каталоге тенанта (api-map /monitor-all → count). + int32 count = 2; + bool enabled = 3; +} + +message BackfillRequest { + // Id диалога для перечитывания. + string dialog_id = 1; + // True — перечитать, даже если диалог уже разобран (кнопка «Перечитать»). + bool force = 2; +} + +message BackfillReply { + // Сколько сообщений отправлено в ядро потоком PushMessage. + int32 processed = 1; +} + +message ReadRecentRequest { + // Id диалога. + string dialog_id = 1; + // Сколько последних сообщений (1..50; api-map /dialogs/preview limit 1..50). + int32 limit = 2; +} + +message ReadRecentReply { + // Последние сообщения (от новых к старым). lead/фолбэк на БД — в ядре. + repeated PreviewMessage messages = 1; +} + +message PreviewMessage { + // Id сообщения в Telegram (int); фолбэк-сообщения из БД ядра — строки + // "m__", поэтому значение передаётся строкой. + string id = 1; + // Текст сообщения. + string text = 2; + // Время сообщения, epoch-ms. + int64 time = 3; +} + +message SearchRequest { + // Поисковый запрос (ключ задачи discovery). + string query = 1; + int32 limit = 2; +} + +message SearchReply { + // Найденные источники (каналы/группы; личные чаты/ботов отсеивает ядро). + repeated DialogEntry results = 1; +} + +message GetInfoRequest { + // Id источника (подписанный; из каталога или результата поиска). + string dialog_id = 1; +} + +message ChannelInfo { + string id = 1; + string name = 2; + string username = 3; + // Тип: channel|group|forum|chat. + string kind = 4; + string hue = 5; + // Число участников (full_chat); пусто — определить не удалось. + optional int32 participants = 6; + bool is_forum = 7; +} + +message GetInfoReply { + ChannelInfo info = 1; +} + +message ReadForEvalRequest { + // Id источника. + string dialog_id = 1; + int32 limit = 2; +} + +message ReadForEvalReply { + // True — выборка получена; false — история недоступна без членства. + bool ok = 1; + // Код причины при ok=false: "no_history" (остальные поля пусты). + optional string error = 2; + // Сообщения выборки (форумы — по активным темам, topic_id/topic_title + // заполнены; для обычных источников — null). + repeated EvalMessage messages = 3; +} + +message EvalMessage { + // Id сообщения в Telegram. + int64 id = 1; + // Текст сообщения (непустой; пустые тексты отбрасывает сервис). + string text = 2; + // Время сообщения, epoch-ms. + int64 date_ms = 3; + // Id темы форума (для обычных источников пусто). + optional int64 topic_id = 4; + // Название темы форума (для обычных источников пусто). + optional string topic_title = 5; +} + +message JoinRequest { + // @username источника (без "@"; пусто → INVALID_ARGUMENT). + string username = 1; +} + +message JoinReply { + bool ok = 1; +} + +message LeaveRequest { + // Id диалога для выхода. + string dialog_id = 1; +} + +message LeaveReply { + bool ok = 1; +} + +// --------------------------------------------------------------------------- +// IngressService — исходящий поток telegram-service → ядро +// tenantId из metadata → собственный scope с ITenantContext.SetTenant) +// --------------------------------------------------------------------------- + +service IngressService { + // Новое/догоняющее сообщение мониторящегося диалога → очередь пайплайна + // ядра (PipelineIngestService.EnqueueAsync, контракт demo-ingest; + превью в + // TgMessages). Дубль dialog+msgId уже в очереди — не растёт (duplicate=true). + rpc PushMessage(PushMessageRequest) returns (PushMessageReply); + + // Синхронизация каталога диалогов: ядро применяет entries (SyncFromTelegram: + // авто-мониторинг по autoMonitorNew, обновление, удаление отсутствующих) и + // отвечает актуальным списком monitored id — сервис держит зеркало + rpc SyncDialogs(SyncDialogsRequest) returns (SyncDialogsReply); + + // Периодический/событийный статус аккаунта: ядро пишет KV tgStatus/tgAccount + rpc ReportStatus(ReportStatusRequest) returns (ReportStatusReply); +} + +// дубль-гвард; msg_at — время исходного сообщения, без него ядро подставит now. +message PushMessageRequest { + // Id диалога-источника (подписанный; пуст — приём no-op). + string dialog_id = 1; + // Имя канала/диалога (title/first_name или id). + string channel_name = 2; + // Username канала/диалога (пуст, если нет). + string channel_handle = 3; + string channel_hue = 4; + // Id исходного сообщения в Telegram (дубль-гвард dialog+msgId). + optional int64 msg_id = 5; + // Текст сообщения (сервис шлёт как есть; приём обрежет до 6000). + string text = 6; + // Время исходного сообщения, epoch-ms; пусто — ядро подставит now. + optional int64 msg_at = 7; +} + +message PushMessageReply { + // True — сообщение принято (no-op с пустым текстом/диалогом — accepted=false). + bool accepted = 1; + // True — дубль dialog_id+msg_id уже в очереди (очередь не выросла). + bool duplicate = 2; +} + +message SyncDialogsRequest { + // Актуальный каталог диалогов (собирает сервис, как refresh_dialogs). + repeated DialogEntry entries = 1; +} + +message SyncDialogsReply { + // Id диалогов с включённым мониторингом (зеркало сервиса после синка). + repeated string monitored_ids = 1; +} + +message ReportStatusRequest { + // Фаза: idle|phone|code|password|qr|ready. + string phase = 1; + // Клиент подключён и авторизован. + bool connected = 2; + // Realtime-listener жив. + bool listener = 3; + // Аккаунт "@username" (пуст после выхода) → KV tgAccount. + string account = 4; + // Текст ошибки (пуст, если нет) → KV tgStatus.error. + optional string error = 5; + // URL QR-входа при phase == "qr". + optional string qr_url = 6; +} + +message ReportStatusReply { + // True — статус принят и сохранён ядром. + bool ok = 1; +} diff --git a/src/core/Deal.Api/Configuration/CookieOptions.cs b/src/core/Deal.Api/Configuration/CookieOptions.cs index 1531337..ef70cb6 100644 --- a/src/core/Deal.Api/Configuration/CookieOptions.cs +++ b/src/core/Deal.Api/Configuration/CookieOptions.cs @@ -3,31 +3,22 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Configuration; /// -/// Настройки httpOnly-куки сессии. Привязываются из секции "Cookies" конфигурации (IOptions). +/// Настройки httpOnly-куки сессии. /// -/// -/// Источник значений — конфигурация: секция 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). + /// Флаг Secure куки. /// public bool Secure { get; set; } } diff --git a/src/core/Deal.Api/Configuration/DataRetentionOptions.cs b/src/core/Deal.Api/Configuration/DataRetentionOptions.cs index 66faf83..4cb40b8 100644 --- a/src/core/Deal.Api/Configuration/DataRetentionOptions.cs +++ b/src/core/Deal.Api/Configuration/DataRetentionOptions.cs @@ -1,24 +1,17 @@ namespace Deal.Api.Configuration; /// -/// Настройки авто-очистки данных (этап 12, пакет B): секция DataRetention конфигурации -/// (appsettings.json + переменные окружения DataRetention__*). +/// Настройки авто-очистки данных /// -/// -/// Управляет фоновым циклом DataRetentionScheduler: удаление записей аудита старше -/// (по умолчанию 180 дней — разумный операционный срок) и очистка -/// накопительных полей лимитов/счётчиков прошедших окон. =false полностью -/// выключает фоновую очистку (например, при внешнем управлении retention). -/// public sealed class DataRetentionOptions { /// - /// Включён ли фоновый цикл авто-очистки (по умолчанию — да). + /// Включён ли фоновый цикл авто-очистки /// public bool Enabled { get; set; } = true; /// - /// Срок хранения записей аудита в днях (по умолчанию 180); неположительное значение — дефолт. + /// Срок хранения записей аудита в днях /// public int AuditRetentionDays { get; set; } = 180; } diff --git a/src/core/Deal.Api/Configuration/ForwardedHeadersConfig.cs b/src/core/Deal.Api/Configuration/ForwardedHeadersConfig.cs index deeb96e..47b655e 100644 --- a/src/core/Deal.Api/Configuration/ForwardedHeadersConfig.cs +++ b/src/core/Deal.Api/Configuration/ForwardedHeadersConfig.cs @@ -1,39 +1,22 @@ 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). + /// Доверенные подсети прокси в CIDR /// public string[] KnownNetworks { get; set; } = []; } diff --git a/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs b/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs index 0524791..d9d41f5 100644 --- a/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs +++ b/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs @@ -3,33 +3,22 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Configuration; /// -/// Настройки httpOnly-куки сессии оператора. Привязываются из секции "OperatorCookies" конфигурации (IOptions). +/// Настройки httpOnly-куки сессии оператора. /// -/// -/// Источник значений — конфигурация: секция 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). + /// Флаг Secure куки. /// public bool Secure { get; set; } } diff --git a/src/core/Deal.Api/Configuration/RateLimitOptions.cs b/src/core/Deal.Api/Configuration/RateLimitOptions.cs index 0970c26..8507b8d 100644 --- a/src/core/Deal.Api/Configuration/RateLimitOptions.cs +++ b/src/core/Deal.Api/Configuration/RateLimitOptions.cs @@ -1,43 +1,37 @@ namespace Deal.Api.Configuration; /// -/// Настройки rate limiting и защиты входа (план Task 11, Ruling 5): секция RateLimit конфигурации -/// (appsettings.json + переменные окружения RateLimit__*). +/// Настройки rate limiting и защиты входа /// -/// -/// 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). + /// Включены ли rate limiting и LoginAttemptGuard. /// public bool Enabled { get; set; } /// - /// Лимит политики "auth" (фиксированное окно в минуту на IP) для /api/auth/login и /api/operator/auth/login. + /// Лимит политики "auth" /// public int AuthPerMinute { get; set; } = 10; /// - /// Лимит политики "api" (в минуту на тенанта либо IP анонима) для остальных /api-эндпоинтов. + /// Лимит политики "api" /// public int ApiPerMinute { get; set; } = 600; /// - /// Лимит gRPC-ингресса (в минуту на tenant-id из metadata; интерцептор IngressRateLimitInterceptor). + /// Лимит gRPC-ингресса /// public int GrpcIngressPerMinute { get; set; } = 600; /// - /// Порог неудачных попыток входа ключа ip|login до блокировки (LoginAttemptGuard). + /// Порог неудачных попыток входа ключа ip|login до блокировки /// 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 7f20ba6..57a869a 100644 --- a/src/core/Deal.Api/Configuration/SecurityOptions.cs +++ b/src/core/Deal.Api/Configuration/SecurityOptions.cs @@ -1,25 +1,12 @@ namespace Deal.Api.Configuration; /// -/// Настройки безопасности HTTP (план Task 12, Ruling 10(2)/9): секция Security конфигурации -/// (appsettings.json + переменные окружения Security__*). +/// Настройки безопасности HTTP /// -/// -/// — единый явный 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». + /// Явный allowlist Origin/CORS /// public string[] AllowedOrigins { get; set; } = []; } diff --git a/src/core/Deal.Api/Dtos/AdminTickResultDto.cs b/src/core/Deal.Api/Dtos/AdminTickResultDto.cs index 09db2e5..ff93b7e 100644 --- a/src/core/Deal.Api/Dtos/AdminTickResultDto.cs +++ b/src/core/Deal.Api/Dtos/AdminTickResultDto.cs @@ -3,21 +3,11 @@ 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). +/// Ответ POST /api/admin/tick — форма {storage, reminders, pipeline, queue}. /// -/// -/// Поля 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: словарь ключей; пуст, если pump не дал результата. /// Строк очереди входящих после прохода pump (queue_len). public sealed record AdminTickResultDto( StorageTickStatsDto Storage, diff --git a/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs b/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs index b2ead6f..16bf566 100644 --- a/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs +++ b/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs @@ -7,26 +7,15 @@ using Deal.Modules.Settings.Application.Models; namespace Deal.Api.Endpoints; /// -/// Эндпоинт проверки подключения AI-провайдера: POST /api/ai/check (Ruling 7/8, api-map §4.10). +/// Эндпоинт проверки подключения AI-провайдера /// -/// -/// «Только для 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"; /// @@ -59,14 +48,11 @@ public static class AiCheckEndpoint 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, diff --git a/src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs b/src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs index b172654..1577fae 100644 --- a/src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs @@ -7,40 +7,22 @@ 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"; /// @@ -57,9 +39,7 @@ public static class AiSuggestEndpoints } // 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()) @@ -82,7 +62,6 @@ public static class AiSuggestEndpoints } // 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()) diff --git a/src/core/Deal.Api/Endpoints/AuthEndpoints.cs b/src/core/Deal.Api/Endpoints/AuthEndpoints.cs index 0497ed8..8cf15f8 100644 --- a/src/core/Deal.Api/Endpoints/AuthEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/AuthEndpoints.cs @@ -11,13 +11,8 @@ using CookieOptions = Deal.Api.Configuration.CookieOptions; namespace Deal.Api.Endpoints; /// -/// HTTP-эндпоинты аутентификации (группа /api/auth). Контракт 1:1 с прототипом auth_routes.py. +/// HTTP-эндпоинты аутентификации /// -/// -/// Успех-ответы — {ok:true,...}, ошибки — HTTP-код + {"detail":"..."} (Ruling 10). -/// Кука сессии выставляется на login и change-password (свежий токен). Сообщения об ошибках — -/// фиксированные строки прототипа. -/// public static class AuthEndpoints { private const string InvalidCredentialsDetail = "Неверный логин или пароль"; @@ -28,7 +23,7 @@ public static class AuthEndpoints private const string AuthOpenApiTag = "auth"; /// - /// Регистрирует группу /api/auth: login, logout, me, change-password. + /// Регистрирует группу /api/auth /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. @@ -36,7 +31,6 @@ public static class AuthEndpoints { 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); @@ -46,8 +40,6 @@ public static class AuthEndpoints 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, @@ -59,7 +51,6 @@ public static class AuthEndpoints { string? attemptedLogin = NormalizeLogin(body.Login); - // Защита входа (план Task 11, Ruling 5): блокировка ключа ip|login до проверки учётных данных — // 429 «Слишком много попыток входа…» (в dev при RateLimit:Enabled=false гвард выключен). if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct)) { @@ -68,10 +59,6 @@ public static class AuthEndpoints 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( @@ -87,8 +74,6 @@ public static class AuthEndpoints if (result.Login is null || result.Token is null) { - // Пустой/пробельный login и неверные учётные данные — одно сообщение (семантика прототипа). - // Аудит tenant_login_failed пишем только для реальной попытки (непустой логин), без пароля (Ruling 4); // счётчик неудач гварда растёт там же — пустые логины ключа не имеют (блокирует только auth-политика). if (attemptedLogin is not null) { @@ -105,7 +90,6 @@ public static class AuthEndpoints return EndpointResults.Unauthorized(InvalidCredentialsDetail); } - // Успешный вход сбрасывает счётчик неудач ключа ip|login (Ruling 5). await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct); await auditService.AppendAsync(new AuditRecordDto( @@ -121,7 +105,6 @@ public static class AuthEndpoints } // POST /api/auth/logout: удаление сессии по токену из куки и очистка куки (всегда ok). - // Если удалённая сессия была impersonation — пишется аудит impersonation_stopped (Task 7, ревью: полный аудит). private static async Task LogoutAsync( AuthService authService, AuditService auditService, @@ -146,7 +129,6 @@ public static class AuthEndpoints 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); @@ -185,7 +167,6 @@ public static class AuthEndpoints 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; diff --git a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs index c8316fc..6420992 100644 --- a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs @@ -11,15 +11,8 @@ using Deal.Modules.Tenants.Application.Models; namespace Deal.Api.Endpoints; /// -/// Детальные операции карточки: создание локальной, «взять в работу», патч, ссылки, файлы, -/// напоминания, очистка «Отклонено» — продолжение группы /api/cards (этап 9, T6). +/// Детальные операции карточки /// -/// -/// Единый контракт /api/cards (R5): операции проектной карточки (патч полей, ссылки, файлы, напоминания) -/// теперь живут на том же ресурсе карточки. Список/чтение/перенос/комментарии/корзина — в -/// ; здесь — уникальные подпути. Все мутации возвращают обновлённую -/// единую карточку (чтение после записи через ). Все эндпоинты требуют сессию. -/// public static class CardDetailsEndpoints { // Префикс группы (общий с CardsEndpoints). @@ -83,7 +76,7 @@ public static class CardDetailsEndpoints private const string FileNameQuoteCharacter = "\""; /// - /// Регистрирует уникальные подпути /api/cards (создание, take, патч, ссылки, файлы, напоминания). + /// Регистрирует уникальные подпути /api/cards /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. @@ -122,7 +115,6 @@ public static class CardDetailsEndpoints 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); } @@ -433,7 +425,6 @@ public static class CardDetailsEndpoints // Возвращает: Имя, безопасное для заголовка. 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) diff --git a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs index b96e0d1..1f20ad2 100644 --- a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs @@ -14,21 +14,8 @@ 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 { // Префикс группы карточек. @@ -40,26 +27,22 @@ public static class CardsEndpoints // Путь поиска (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}. + /// Регистрирует группы /api/cards и /api /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. @@ -112,7 +95,6 @@ public static class CardsEndpoints 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()) @@ -123,7 +105,6 @@ public static class CardsEndpoints 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) { @@ -136,7 +117,6 @@ public static class CardsEndpoints return Results.Ok(wire); } - // GET /api/cards/{cardId}: одна карточка; 404 «Карточка не найдена» (L166–168). private static async Task GetCardAsync( string cardId, HttpContext context, @@ -154,7 +134,6 @@ public static class CardsEndpoints : 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()) @@ -167,7 +146,6 @@ public static class CardsEndpoints 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, @@ -181,7 +159,6 @@ public static class CardsEndpoints if (body.Col is null) { // Пустая/отсутствующая col попала бы в mark_seen как «не задана» и сняла бы «новое» со ВСЕХ - // карточек (truthiness python, L250–256) — эндпоинт защищает от вызова с null (прототип: 422). return EndpointResults.BadRequest(UnknownColumnDetail); } @@ -190,7 +167,6 @@ public static class CardsEndpoints return Results.Ok(new { ok = true }); } - // POST /api/cards/{cardId}/move {to}: перенос карточки между контейнерами (этап 9, R4). // Маршрутизацию цели (стадия «Выбранных» vs дашборд-контейнер) и побочные эффекты выполняет единый // доменный механизм перехода ICardMover: стадия — запись истории и сброс напоминания // (move_stage), дашборд-контейнер — журнал/обучение ML. Ответ — обновлённая карточка; 400 при @@ -218,7 +194,6 @@ public static class CardsEndpoints return EndpointResults.NotFound(CardNotFoundDetail); } - // Аудит переноса карточки (этап 10, T1): цель — минимальный безопасный идентификатор. await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct); CardsService cardsService = context.RequestServices.GetRequiredService(); @@ -228,7 +203,6 @@ public static class CardsEndpoints : Results.Ok(unified); } - // POST /api/cards/{cardId}/trash: в корзину + обучение ML spam (L203–207); ответ {ok:true}; 404. private static async Task TrashAsync( string cardId, HttpContext context, @@ -246,12 +220,10 @@ public static class CardsEndpoints 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, @@ -269,12 +241,10 @@ public static class CardsEndpoints 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, @@ -292,12 +262,10 @@ public static class CardsEndpoints 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, @@ -315,7 +283,6 @@ public static class CardsEndpoints : 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, @@ -339,7 +306,6 @@ public static class CardsEndpoints return EndpointResults.NotFound(CardNotFoundDetail); } - // Аудит добавления комментария (этап 10, T1): текст комментария в детали не пишется. await AuditAppender.AppendTenantAsync(context, AuditEvents.CardCommentAdded, new { cardId }, ct); return Results.Ok(new { comments = result.Comments }); } @@ -414,7 +380,6 @@ public static class CardsEndpoints reason = result.Reason, }; - // Публикует SSE cards_reclassified после успешного прохода (Ruling 5: публикации — из Api). // Публикуется только когда проход реально выполнен и что-то изменил (started и // reclassified > 0): пустой inbox/всё пропущено не меняют доску — событие не шлём. Нагрузка // минимальная: сколько обработано и перемещено (фронт перечитывает доску). Без подписчиков — no-op. @@ -455,7 +420,6 @@ public static class CardsEndpoints 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, diff --git a/src/core/Deal.Api/Endpoints/ChangePasswordRequest.cs b/src/core/Deal.Api/Endpoints/ChangePasswordRequest.cs index 3a4db92..18c45f6 100644 --- a/src/core/Deal.Api/Endpoints/ChangePasswordRequest.cs +++ b/src/core/Deal.Api/Endpoints/ChangePasswordRequest.cs @@ -1,7 +1,7 @@ namespace Deal.Api.Endpoints; /// -/// Тело POST /api/auth/change-password. Входящий JSON — camelCase (oldPassword, newPassword). +/// Тело POST /api/auth/change-password. /// /// Текущий пароль. /// Новый пароль (минимум 8 символов). diff --git a/src/core/Deal.Api/Endpoints/CheckMessageRequest.cs b/src/core/Deal.Api/Endpoints/CheckMessageRequest.cs index f838792..e8f4ae9 100644 --- a/src/core/Deal.Api/Endpoints/CheckMessageRequest.cs +++ b/src/core/Deal.Api/Endpoints/CheckMessageRequest.cs @@ -1,7 +1,7 @@ namespace Deal.Api.Endpoints; /// -/// Тело POST /api/admin/check-message (1:1 с CheckMessageBody, dashboard_routes.py L72–73). +/// Тело POST /api/admin/check-message. /// -/// Текст сообщения для проверки фильтром (этап 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 966c2d7..e26250f 100644 --- a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs @@ -9,15 +9,8 @@ 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 { // Префикс группы контейнеров. @@ -42,7 +35,7 @@ public static class ContainersEndpoints private static readonly JsonSerializerOptions RequestJsonOptions = new(JsonSerializerDefaults.Web); /// - /// Регистрирует группы /api/containers (контейнеры + состояние колонок). + /// Регистрирует группы /api/containers /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. @@ -104,7 +97,6 @@ public static class ContainersEndpoints 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 }); } @@ -168,7 +160,6 @@ public static class ContainersEndpoints return EndpointResults.NotFound(ContainerNotFoundDetail); } - // Аудит изменения контейнера (этап 10, T1). await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = updated.Id }, ct); return Results.Ok(new { id = updated.Id }); } @@ -191,7 +182,6 @@ public static class ContainersEndpoints return EndpointResults.NotFound(ContainerNotFoundDetail); } - // Аудит изменения контейнера (принятие ИИ-предложения) — этап 10, T1. await AuditAppender.AppendTenantAsync(context, AuditEvents.ContainerUpdated, new { id = accepted.Id }, ct); return Results.Ok(accepted); } @@ -210,7 +200,6 @@ public static class ContainersEndpoints 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 }); } diff --git a/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs b/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs index 9b71b74..51e4414 100644 --- a/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs @@ -14,27 +14,12 @@ using Deal.Modules.Telegram.Application; namespace Deal.Api.Endpoints; /// -/// Эндпоинты /api/discovery: задачи поиска, кандидаты, чёрный список, лог, генерация ключей (Ruling 11, api-map §3.8). +/// Эндпоинты /api/discovery /// -/// -/// 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). @@ -70,38 +55,28 @@ public static class DiscoveryEndpoints // Путь лога задачи (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). + /// Регистрирует группу /api/discovery /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. @@ -126,7 +101,6 @@ public static class DiscoveryEndpoints return app; } - // GET /api/discovery/tasks: список задач, старые первыми (list_tasks L141–143). private static async Task ListTasksAsync(HttpContext context, CancellationToken ct) { if (!context.HasUser()) @@ -139,7 +113,6 @@ public static class DiscoveryEndpoints return Results.Ok(new { items }); } - // POST /api/discovery/tasks: создать задачу поиска (create_task L146–151; дефолты — в сервисе). private static async Task CreateTaskAsync( DiscoveryTaskCreateBody body, HttpContext context, @@ -162,7 +135,6 @@ public static class DiscoveryEndpoints } } - // PATCH /api/discovery/tasks/{task_id}: обновить задачу (patch_task L154–161; 404/400). private static async Task PatchTaskAsync( string task_id, DiscoveryTaskPatchBody body, @@ -186,7 +158,6 @@ public static class DiscoveryEndpoints } } - // DELETE /api/discovery/tasks/{task_id}: удалить задачу с кандидатами и логом (delete_task L164–168). private static async Task DeleteTaskAsync( string task_id, HttpContext context, @@ -202,7 +173,6 @@ public static class DiscoveryEndpoints 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, @@ -225,7 +195,6 @@ public static class DiscoveryEndpoints } } - // POST /api/discovery/tasks/{task_id}/pause: пауза поиска (pause_task L181–187). private static async Task PauseTaskAsync( string task_id, HttpContext context, @@ -242,8 +211,6 @@ public static class DiscoveryEndpoints } // 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( @@ -284,18 +251,15 @@ public static class DiscoveryEndpoints 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, @@ -319,7 +283,6 @@ public static class DiscoveryEndpoints 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( @@ -357,12 +320,10 @@ public static class DiscoveryEndpoints } // Источник в каталоге (монитор 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(); @@ -380,7 +341,6 @@ public static class DiscoveryEndpoints } // POST /api/discovery/candidates/{dialog_id}/reject: отклонить кандидата — в чёрный список - // (reject_candidate L253–264; уже вступившего — нельзя, 400). private static async Task RejectCandidateAsync( string dialog_id, HttpContext context, @@ -414,7 +374,6 @@ public static class DiscoveryEndpoints } } - // GET /api/discovery/blacklist: чёрный список источников (list_blacklist L269–271). private static async Task ListBlacklistAsync(HttpContext context, CancellationToken ct) { if (!context.HasUser()) @@ -427,7 +386,6 @@ public static class DiscoveryEndpoints 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, @@ -443,7 +401,6 @@ public static class DiscoveryEndpoints 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, @@ -467,10 +424,8 @@ public static class DiscoveryEndpoints } /// - /// Ключи из ответа ИИ: строки без пустых/длинных и повторов (python _clean_keywords L111–128). + /// Ключи из ответа ИИ /// - /// Повтор считается по casefold python: здесь — регистронезависимое сравнение - /// (RU/EN-ключи; StringComparer.OrdinalIgnoreCase). Потолок списка — . /// Сырые ключи ответа модели (null — пусто). /// Очищенные ключи (не более 30, каждый ≤60 символов). public static IReadOnlyList CleanKeywords(IEnumerable? raw) @@ -505,13 +460,11 @@ public static class DiscoveryEndpoints 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); @@ -552,7 +505,6 @@ public static class DiscoveryEndpoints }; } - // Маппит тело патча в сервисный патч (не-null значения; как python model_dump(exclude_none=True)). // body: Тело запроса (wire-поля camelCase). // Возвращает: Патч задачи (DiscoveryTaskPatch). private static DiscoveryTaskPatch ToPatch(DiscoveryTaskPatchBody body) diff --git a/src/core/Deal.Api/Endpoints/EventsEndpoint.cs b/src/core/Deal.Api/Endpoints/EventsEndpoint.cs index cf036fe..3474c91 100644 --- a/src/core/Deal.Api/Endpoints/EventsEndpoint.cs +++ b/src/core/Deal.Api/Endpoints/EventsEndpoint.cs @@ -6,37 +6,23 @@ using Deal.Api.Services; namespace Deal.Api.Endpoints; /// -/// SSE-поток событий канбана: GET /api/events (Ruling 5; прототип events_routes.py L15–38). +/// SSE-поток событий канбана /// -/// -/// Открывает 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); /// diff --git a/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs b/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs index dd5e368..65bb083 100644 --- a/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs @@ -5,29 +5,14 @@ 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"; /// @@ -42,7 +27,6 @@ public static class FilterTesterEndpoints return app; } - // POST /api/admin/check-message: этап-1 правила + этап-2 (skipped) для тестера (dashboard_routes.py L267–284). private static async Task CheckAsync( CheckMessageRequest body, HttpContext context, @@ -57,7 +41,6 @@ public static class FilterTesterEndpoints 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 diff --git a/src/core/Deal.Api/Endpoints/JoinEndpoint.cs b/src/core/Deal.Api/Endpoints/JoinEndpoint.cs index 2d4802c..2ea1e24 100644 --- a/src/core/Deal.Api/Endpoints/JoinEndpoint.cs +++ b/src/core/Deal.Api/Endpoints/JoinEndpoint.cs @@ -5,17 +5,8 @@ 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 — публичная). @@ -27,7 +18,6 @@ public static class JoinEndpoint // Текст 400: приглашение с таким кодом не найдено. private const string InviteNotFoundDetail = "Приглашение не найдено"; - // Текст 400: срок действия приглашения истёк (план Task 6, Ruling 2). private const string InviteExpiredDetail = "Срок действия приглашения истёк"; // Текст 400: приглашение уже активировано (повторная активация тем же кодом). @@ -36,13 +26,10 @@ public static class JoinEndpoint // Текст 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). @@ -62,7 +49,6 @@ public static class JoinEndpoint return app; } - // POST /api/join: активация инвайта; успех пишется в аудит (invite_activated, Task 6/Ruling 4). private static async Task JoinAsync( JoinRequest body, JoinService joinService, diff --git a/src/core/Deal.Api/Endpoints/JoinRequest.cs b/src/core/Deal.Api/Endpoints/JoinRequest.cs index 15af596..7eeae08 100644 --- a/src/core/Deal.Api/Endpoints/JoinRequest.cs +++ b/src/core/Deal.Api/Endpoints/JoinRequest.cs @@ -1,7 +1,7 @@ namespace Deal.Api.Endpoints; /// -/// Тело POST /api/join — активация инвайта (Ruling 2, Task 6 этапа 7). Входящий JSON — camelCase (code, email, name?, password). +/// Тело POST /api/join — активация инвайта. /// /// Код приглашения (16 url-safe символов). /// Email активирующего; обязан совпасть с email приглашения (нормализует JoinService). diff --git a/src/core/Deal.Api/Endpoints/LoginRequest.cs b/src/core/Deal.Api/Endpoints/LoginRequest.cs index 3a8f6f6..e5c26f3 100644 --- a/src/core/Deal.Api/Endpoints/LoginRequest.cs +++ b/src/core/Deal.Api/Endpoints/LoginRequest.cs @@ -1,7 +1,7 @@ namespace Deal.Api.Endpoints; /// -/// Тело POST /api/auth/login. Входящий JSON — camelCase (login, password). +/// Тело POST /api/auth/login. /// /// Логин пользователя. /// Пароль в открытом виде. diff --git a/src/core/Deal.Api/Endpoints/MlApplyRequest.cs b/src/core/Deal.Api/Endpoints/MlApplyRequest.cs index 14c7fad..2b48e40 100644 --- a/src/core/Deal.Api/Endpoints/MlApplyRequest.cs +++ b/src/core/Deal.Api/Endpoints/MlApplyRequest.cs @@ -1,9 +1,9 @@ namespace Deal.Api.Endpoints; /// -/// Тело POST /api/ml/apply. Входящий JSON — camelCase (dialogId, msgId, action). +/// Тело POST /api/ml/apply. /// /// Id диалога/канала Telegram, где лежит исходное сообщение. /// Id сообщения внутри диалога. -/// Ручное решение: spam | board:<id> | skip (api-map §3.7 L197). +/// Ручное решение: spam | board:<id> | skip. public sealed record MlApplyRequest(string DialogId, int MsgId, string Action); diff --git a/src/core/Deal.Api/Endpoints/MlCandidatesRequest.cs b/src/core/Deal.Api/Endpoints/MlCandidatesRequest.cs index 56d01d7..2fd69e8 100644 --- a/src/core/Deal.Api/Endpoints/MlCandidatesRequest.cs +++ b/src/core/Deal.Api/Endpoints/MlCandidatesRequest.cs @@ -1,7 +1,7 @@ namespace Deal.Api.Endpoints; /// -/// Тело POST /api/ml/candidates. Входящий JSON — camelCase (dialogId, limit). +/// Тело POST /api/ml/candidates. /// /// Id диалога/канала Telegram; пусто — выборка по всем источникам тенанта (§8). /// Сколько последних сообщений вернуть (кламп 1..60, дефолт 10). diff --git a/src/core/Deal.Api/Endpoints/MlEndpoints.cs b/src/core/Deal.Api/Endpoints/MlEndpoints.cs index b596650..1a67c02 100644 --- a/src/core/Deal.Api/Endpoints/MlEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/MlEndpoints.cs @@ -8,27 +8,12 @@ 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). +/// Эндпоинты ML-панели /// -/// -/// Тела ответов 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). @@ -46,20 +31,16 @@ public static class MlEndpoints // Путь ручного решения по сообщению (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. + /// Регистрирует группу /api/ml /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. @@ -76,7 +57,6 @@ public static class MlEndpoints return app; } - // GET /api/ml/status: статус ML-сервиса + локальная статистика (ml_routes.py L66–75). private static async Task StatusAsync(HttpContext context, CancellationToken ct) { if (!context.HasUser()) @@ -88,7 +68,6 @@ public static class MlEndpoints 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()) @@ -100,7 +79,6 @@ public static class MlEndpoints 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, @@ -120,7 +98,6 @@ public static class MlEndpoints 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]; @@ -138,7 +115,6 @@ public static class MlEndpoints }); } - // POST /api/ml/candidates: последние сообщения канала + мнение ML (ml_routes.py L112–134). private static async Task CandidatesAsync( MlCandidatesRequest body, HttpContext context, @@ -154,7 +130,6 @@ public static class MlEndpoints return Results.Ok(new { items }); } - // POST /api/ml/apply: ручное решение по сообщению (ml_routes.py L137–171). private static async Task ApplyAsync( MlApplyRequest body, HttpContext context, diff --git a/src/core/Deal.Api/Endpoints/MlPredictRequest.cs b/src/core/Deal.Api/Endpoints/MlPredictRequest.cs index be27fd9..213b54b 100644 --- a/src/core/Deal.Api/Endpoints/MlPredictRequest.cs +++ b/src/core/Deal.Api/Endpoints/MlPredictRequest.cs @@ -1,7 +1,7 @@ namespace Deal.Api.Endpoints; /// -/// Тело POST /api/ml/predict. Входящий JSON — camelCase (text). +/// Тело POST /api/ml/predict. /// -/// Текст сообщения для проверки ML (обрезается/тримится обработчиком, как ml_routes.py L86). +/// Текст сообщения для проверки ML. public sealed record MlPredictRequest(string Text); diff --git a/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs index dc5d899..e919b8b 100644 --- a/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs @@ -6,17 +6,10 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Endpoints; /// -/// Операторские read-only эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3). +/// Операторские read-only эндпоинты аналитики /// -/// -/// Только под операторской сессией: без неё 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-тег группы. @@ -29,7 +22,7 @@ public static class OperatorAnalyticsEndpoints private const string InvalidGroupByDetail = "Неизвестная группировка (day|tenant|provider|model)"; /// - /// Регистрирует группу /api/operator/analytics: overview/tokens/activity. + /// Регистрирует группу /api/operator/analytics /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. @@ -159,7 +152,7 @@ public static class OperatorAnalyticsEndpoints } /// - /// Известна ли группировка расхода токенов (day|tenant|provider|model). + /// Известна ли группировка расхода токенов /// /// Значение группировки. /// True — поддерживаемая группировка. diff --git a/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs index d5904de..4d567b1 100644 --- a/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs @@ -6,28 +6,19 @@ 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). + /// Регистрирует группу /api/operator /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. @@ -45,7 +36,6 @@ public static class OperatorAuditEndpoints // from: Нижняя граница At (включительно; ISO-8601). // to: Верхняя граница At (включительно; ISO-8601). // limit: Размер выборки (дефолт 100, клампится 1..500). - // offset: Смещение страницы (≥0; этап 10, T3). // context: Контекст запроса. // auditService: Сервис аудита (scoped). // ct: Токен отмены. @@ -77,7 +67,7 @@ public static class OperatorAuditEndpoints } /// - /// Нормализует limit запроса: дефолт , кламп 1..500 (Ruling 4). + /// Нормализует limit запроса /// /// Запрошенный размер выборки (null — не задан). /// Значение для фильтра. @@ -87,7 +77,7 @@ public static class OperatorAuditEndpoints : Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit.Value)); /// - /// Нормализует offset запроса: отрицательное/отсутствующее — 0 (этап 10, T3). + /// Нормализует offset запроса /// /// Запрошенное смещение (null — не задано). /// Неотрицательное смещение. diff --git a/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs index 365199b..bf297e5 100644 --- a/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs @@ -12,15 +12,8 @@ using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions; namespace Deal.Api.Endpoints; /// -/// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1). +/// HTTP-эндпоинты аутентификации оператора /// -/// -/// Оператор ≠ пользователь тенанта: вход по отдельным 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 = "Неверный логин или пароль оператора"; @@ -28,7 +21,7 @@ public static class OperatorAuthEndpoints private const string OperatorAuthOpenApiTag = "operator-auth"; /// - /// Регистрирует группу /api/operator/auth: login, logout, me. + /// Регистрирует группу /api/operator/auth /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. @@ -36,7 +29,6 @@ public static class OperatorAuthEndpoints { 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); @@ -45,8 +37,6 @@ public static class OperatorAuthEndpoints 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, @@ -58,7 +48,6 @@ public static class OperatorAuthEndpoints { string? attemptedLogin = NormalizeLogin(body.Login); - // Защита входа оператора (план Task 11, Ruling 5): зеркало AuthEndpoints — блокировка ключа // ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен). if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct)) { @@ -69,7 +58,6 @@ public static class OperatorAuthEndpoints if (result.Login is null || result.Token is null) { // Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints). - // Аудит operator_login_failed — только для реальной попытки (непустой логин), без пароля (Ruling 4); // счётчик неудач гварда растёт там же (пустые логины ключа не имеют). if (attemptedLogin is not null) { @@ -86,7 +74,6 @@ public static class OperatorAuthEndpoints return EndpointResults.Unauthorized(InvalidCredentialsDetail); } - // Успешный вход оператора сбрасывает счётчик неудач ключа ip|login (Ruling 5). await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct); await auditService.AppendAsync(new AuditRecordDto( @@ -115,7 +102,6 @@ public static class OperatorAuthEndpoints 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); @@ -124,7 +110,6 @@ public static class OperatorAuthEndpoints return Results.Ok(new { ok = true }); } - // GET /api/operator/auth/me: проверка живой операторской сессии (401 без неё, Ruling 1). private static IResult MeAsync(HttpContext context) { var operatorIdentity = context.GetCurrentOperator(); diff --git a/src/core/Deal.Api/Endpoints/OperatorHealthEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorHealthEndpoints.cs index d761491..4f7d89a 100644 --- a/src/core/Deal.Api/Endpoints/OperatorHealthEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorHealthEndpoints.cs @@ -10,25 +10,10 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Api.Endpoints; /// -/// Операторский health: GET /api/operator/health (план Task 10, Ruling 3/6/9/11) — ядро/БД и -/// автономные сервисы ml/ai/telegram. +/// Операторский health /// -/// -/// Ручка — только оператору (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-ручки. @@ -46,7 +31,6 @@ public static class OperatorHealthEndpoints // Статус сервиса: ответил, но не SERVING (grpc NOT_SERVING/SERVICE_UNKNOWN). private const string StatusUnhealthy = "unhealthy"; - // Статус сервиса в Local-режиме: реальный сервис не подключён (UseLocal=true, Ruling 6). private const string StatusLocal = "local"; // Режим сервиса: Local-адаптеры (UseLocal=true). @@ -68,7 +52,7 @@ public static class OperatorHealthEndpoints private const int DatabaseProbeTimeoutMilliseconds = 5000; /// - /// Регистрирует GET /api/operator/health (health ядра/БД и автономных сервисов). + /// Регистрирует GET /api/operator/health /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. diff --git a/src/core/Deal.Api/Endpoints/OperatorInviteCreateRequest.cs b/src/core/Deal.Api/Endpoints/OperatorInviteCreateRequest.cs index f422e75..0c0aaa0 100644 --- a/src/core/Deal.Api/Endpoints/OperatorInviteCreateRequest.cs +++ b/src/core/Deal.Api/Endpoints/OperatorInviteCreateRequest.cs @@ -1,8 +1,8 @@ namespace Deal.Api.Endpoints; /// -/// Тело POST /api/operator/invites: email приглашённого и опциональный целевой тенант (Ruling 2 этапа 7). +/// Тело POST /api/operator/invites /// /// Email приглашённого (регистр/пробелы не важны — нормализует InvitesService). -/// Целевой тенант; null — при активации будет создан новый тенант (Task 6). +/// Целевой тенант; null — при активации будет создан новый тенант. public sealed record OperatorInviteCreateRequest(string? Email, Guid? TenantId); diff --git a/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs index 38658f8..c916590 100644 --- a/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs @@ -6,21 +6,13 @@ 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: приглашение с таким кодом не найдено. @@ -29,7 +21,6 @@ public static class OperatorInvitesEndpoints // Текст 400: отзыв приглашения не в статусе pending (уже отозвано/использовано/истекло). private const string InviteNotPendingDetail = "Отозвать можно только ожидающее активации приглашение"; - // Префикс группы операторских ручек приглашений (Ruling 11). private const string InvitesGroupPrefix = "/api/operator/invites"; // Относительный путь отзыва приглашения. @@ -39,7 +30,7 @@ public static class OperatorInvitesEndpoints private const string InvitesOpenApiTag = "operator-invites"; /// - /// Регистрирует группу /api/operator/invites: GET (список), POST (создание), POST {code}/revoke (отзыв). + /// Регистрирует группу /api/operator/invites /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. diff --git a/src/core/Deal.Api/Endpoints/OperatorLimitUpdateRequest.cs b/src/core/Deal.Api/Endpoints/OperatorLimitUpdateRequest.cs index bf78810..0f8ffd8 100644 --- a/src/core/Deal.Api/Endpoints/OperatorLimitUpdateRequest.cs +++ b/src/core/Deal.Api/Endpoints/OperatorLimitUpdateRequest.cs @@ -1,9 +1,7 @@ namespace Deal.Api.Endpoints; /// -/// Тело PATCH /api/operator/tenants/{id}/limit: смена лимитов ИИ-бюджета тенанта (план Task 10, Ruling 3). -/// Оба поля опциональны — меняется только заданное; смена бюджета/периода сбрасывает флаги Warned80/ -/// NotifiedExhausted (новый период открывает пороги тостов, один тост на период на порог, Ruling 3). +/// Тело PATCH /api/operator/tenants/{id}/limit /// /// Новый бюджет периода в токенах (≥0; 0 — ИИ запрещён); null — оставить текущий. /// Новый тип периода (константа TenantLimitPeriods: month|day); null — оставить текущий. diff --git a/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs index 047deb0..ae74af6 100644 --- a/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs @@ -7,21 +7,8 @@ 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-тело/пустой объект). @@ -39,10 +26,8 @@ public static class OperatorLimitsEndpoints // Верхняя граница процента расхода (диапазон 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"; // Путь сводки лимитов по всем тенантам. @@ -54,12 +39,10 @@ public static class OperatorLimitsEndpoints // 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 (детали/смена). + /// Регистрирует ручки лимитов /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. @@ -72,7 +55,6 @@ public static class OperatorLimitsEndpoints return app; } - // GET /api/operator/limits: сводка бюджета/расхода по всем тенантам (план Task 10). private static async Task ListSummaryAsync( HttpContext context, ITenantRepository tenantRepository, @@ -89,7 +71,6 @@ public static class OperatorLimitsEndpoints 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 { @@ -199,14 +180,8 @@ public static class OperatorLimitsEndpoints } /// - /// Процент расхода бюджета для операторской сводки/деталей (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). diff --git a/src/core/Deal.Api/Endpoints/OperatorMaintenanceEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorMaintenanceEndpoints.cs index 053f8aa..c073e99 100644 --- a/src/core/Deal.Api/Endpoints/OperatorMaintenanceEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorMaintenanceEndpoints.cs @@ -5,16 +5,8 @@ using Deal.Infrastructure.Tenancy; namespace Deal.Api.Endpoints; /// -/// Операторские maintenance-ручки (этап 12, пакет C): пакетная миграция схем всех тенантов. +/// Операторские maintenance-ручки /// -/// -/// Ручка — только оператору (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-ручек. @@ -27,7 +19,7 @@ public static class OperatorMaintenanceEndpoints private const string MaintenanceOpenApiTag = "operator-maintenance"; /// - /// Регистрирует группу /api/operator/maintenance: пакетная миграция схем тенантов. + /// Регистрирует группу /api/operator/maintenance /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. diff --git a/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs index 635c1ae..d4e0657 100644 --- a/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs @@ -8,21 +8,8 @@ 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 { // Префикс группы операторских настроек. @@ -47,7 +34,7 @@ public static class OperatorSettingsEndpoints private const string InvalidApiHashDetail = "Укажите непустой api_hash"; /// - /// Регистрирует группу /api/operator/settings: telegram-keys (GET/PUT). + /// Регистрирует группу /api/operator/settings /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. @@ -135,7 +122,6 @@ public static class OperatorSettingsEndpoints await keys.SaveAsync(effectiveApiId, effectiveApiHash, ct); - // Аудит смены глобальных ключей: apiId — не секрет, apiHash в детали не пишется (Ruling 4). await auditService.AppendAsync(new AuditRecordDto( AuditEvents.TelegramKeysChanged, AuditActorTypes.Operator, diff --git a/src/core/Deal.Api/Endpoints/OperatorTenantCreateRequest.cs b/src/core/Deal.Api/Endpoints/OperatorTenantCreateRequest.cs index bcf628f..ecde959 100644 --- a/src/core/Deal.Api/Endpoints/OperatorTenantCreateRequest.cs +++ b/src/core/Deal.Api/Endpoints/OperatorTenantCreateRequest.cs @@ -1,9 +1,8 @@ namespace Deal.Api.Endpoints; /// -/// Тело POST /api/operator/tenants: создание тенанта оператором (план Task 7, Ruling 11). +/// Тело POST /api/operator/tenants /// /// Имя тенанта (обязательно; пробелы по краям обрезаются). -/// Email владельца (опционально): создаёт сразу пользователя-владельца с одноразовым -/// паролем (иначе владелец заводится инвайтом, Ruling 2). +/// Email владельца (опционально): создаёт сразу пользователя-владельца с одноразовым паролем. public sealed record OperatorTenantCreateRequest(string? Name, string? Email); diff --git a/src/core/Deal.Api/Endpoints/OperatorTenantImpersonateRequest.cs b/src/core/Deal.Api/Endpoints/OperatorTenantImpersonateRequest.cs index 9f15c8e..f18217f 100644 --- a/src/core/Deal.Api/Endpoints/OperatorTenantImpersonateRequest.cs +++ b/src/core/Deal.Api/Endpoints/OperatorTenantImpersonateRequest.cs @@ -1,8 +1,7 @@ namespace Deal.Api.Endpoints; /// -/// Тело POST /api/operator/tenants/{id}/impersonate: опциональный логин пользователя тенанта (план Task 7). +/// Тело POST /api/operator/tenants/{id}/impersonate /// -/// Логин пользователя, под которым оператор входит (impersonation); null/пустой — -/// берётся первый пользователь тенанта (по времени создания). +/// Логин пользователя, под которым оператор входит (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 c60b113..8b77967 100644 --- a/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs @@ -8,24 +8,8 @@ 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). @@ -46,7 +30,6 @@ public static class OperatorTenantsEndpoints // Текст 400: в тенанте нет пользователей, а login не указан (impersonation без выбора). private const string TenantHasNoUsersDetail = "В тенанте нет пользователей для входа"; - // Префикс группы операторских ручек тенантов (Ruling 11). private const string TenantsGroupPrefix = "/api/operator/tenants"; // Относительный путь деталей тенанта. @@ -65,7 +48,7 @@ public static class OperatorTenantsEndpoints private const string TenantsOpenApiTag = "operator-tenants"; /// - /// Регистрирует группу /api/operator/tenants: список, create, детали, suspend/unsuspend, impersonate. + /// Регистрирует группу /api/operator/tenants /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. @@ -83,7 +66,6 @@ public static class OperatorTenantsEndpoints return app; } - // GET /api/operator/tenants: список тенантов со счётчиками пользователей (план Task 7). private static async Task ListAsync( HttpContext context, TenantAdminService tenantAdminService, @@ -100,8 +82,6 @@ public static class OperatorTenantsEndpoints } // 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, diff --git a/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs b/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs index a61a47e..0db71cc 100644 --- a/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs @@ -7,28 +7,12 @@ 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). @@ -49,14 +33,12 @@ public static class PipelineEndpoints // Путь возврата записи отсева в обработку (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. + /// Регистрирует группу /api/pipeline /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. @@ -64,7 +46,6 @@ public static class PipelineEndpoints { 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); @@ -75,7 +56,6 @@ public static class PipelineEndpoints 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()) @@ -87,9 +67,6 @@ public static class PipelineEndpoints 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, @@ -107,8 +84,6 @@ public static class PipelineEndpoints 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, @@ -127,7 +102,6 @@ public static class PipelineEndpoints 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()) @@ -140,8 +114,6 @@ public static class PipelineEndpoints 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, @@ -157,10 +129,6 @@ public static class PipelineEndpoints 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, diff --git a/src/core/Deal.Api/Endpoints/RatesEndpoints.cs b/src/core/Deal.Api/Endpoints/RatesEndpoints.cs index 0250ca2..b1f6c1b 100644 --- a/src/core/Deal.Api/Endpoints/RatesEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/RatesEndpoints.cs @@ -6,21 +6,10 @@ 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). +/// HTTP-эндпоинты курсов валют /// -/// -/// «Только для 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). @@ -29,7 +18,6 @@ public static class RatesEndpoints // Путь принудительного обновления (POST). private const string RatesRefreshPath = "/rates/refresh"; - // OpenAPI-тег группы (в прототипе роутер settings — settings_routes.py). private const string OpenApiTag = "settings"; /// @@ -58,7 +46,6 @@ public static class RatesEndpoints RatesDto current = await ratesService.GetAsync(ct); - // Ленивое обновление (Ruling 6, план Task 8): протухший кэш / смена источника / нет кэша — // фоновый RefreshAsync в отдельном scope; ответ — текущий кэш. if (await ratesService.ShouldFetchAsync(ct)) { @@ -68,7 +55,6 @@ public static class RatesEndpoints 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()) diff --git a/src/core/Deal.Api/Endpoints/RequestModels/CardLinkRequest.cs b/src/core/Deal.Api/Endpoints/RequestModels/CardLinkRequest.cs index 06a74f5..30c31c3 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/CardLinkRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/CardLinkRequest.cs @@ -1,13 +1,8 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело POST /api/cards/{cardId}/links — добавление ссылки карточке (add_link L133–143). +/// Тело POST /api/cards/{cardId}/links — добавление ссылки карточке. /// -/// -/// Wire-имена — camelCase: name (пустой по умолчанию) и url. url Trim'ится; пустой url → 400 «Пустая -/// ссылка» (валидация CardsService.AddLinkAsync); url без схемы http://https:// получает префикс https://. -/// Пустое name → ссылка называется url. Ответ — карточка после мутации. -/// /// Название ссылки; пустое → name = url. /// URL ссылки (без схемы — добавится https://). public sealed record CardLinkRequest(string? Name = null, string? Url = null); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/ClearColBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/ClearColBody.cs index 3f6c85b..349ba19 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ClearColBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ClearColBody.cs @@ -1,10 +1,6 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело POST /api/cards/clear-col — полная очистка служебной колонки (dashboard_routes.py ClearColBody L224–225, api-map §3.2 L93). +/// Тело POST /api/cards/clear-col — полная очистка служебной колонки. /// -/// -/// 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 e2ed3b5..1ac5bcd 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ColStateBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ColStateBody.cs @@ -1,11 +1,6 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело PATCH /api/containers/{containerId}/state — смена состояния колонки (этап 9, T4). +/// Тело PATCH /api/containers/{containerId}/state — смена состояния колонки. /// -/// -/// 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 939c16a..c064769 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/CommentBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/CommentBody.cs @@ -1,10 +1,6 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело POST /api/cards/{cardId}/comments — добавление комментария (dashboard_routes.py CommentBody L60–61). +/// Тело POST /api/cards/{cardId}/comments — добавление комментария. /// -/// -/// 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 4f08c52..467131a 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ContainerCreateRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ContainerCreateRequest.cs @@ -3,14 +3,8 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело POST /api/containers — создание контейнера (этап 9, T4). +/// Тело POST /api/containers — создание контейнера. /// -/// -/// Wire-имена — camelCase: name/description/color/space/kind/suggested/note/rules. Name — обязательное: -/// отсутствие либо явный null → 400 «Укажите название колонки»; пустая строка допустима — сервис -/// подставит «Новая колонка». Description/Note имеют дефолт "". Отсутствующие группы правил -/// трактуются как пустые (null-устойчивость). -/// /// Имя контейнера (обязательно). /// Описание (опционально). /// Цвет (опционально; null — палитра). diff --git a/src/core/Deal.Api/Endpoints/RequestModels/ContainerPatchRequest.cs b/src/core/Deal.Api/Endpoints/RequestModels/ContainerPatchRequest.cs index 4f46bc7..61726dd 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ContainerPatchRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ContainerPatchRequest.cs @@ -3,13 +3,8 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело PATCH /api/containers/{id} — частичное обновление контейнера (этап 9, T4). +/// Тело PATCH /api/containers/{id} — частичное обновление контейнера. /// -/// -/// Wire-имена — camelCase: name/description/color/collapsed/suggested/note/rules/policy. Поле со -/// значением null означает «не менять»; исключение — ЯВНЫЙ null у name → 400 «Укажите название колонки». -/// JSON-объекты rules/policy заменяются целиком. -/// /// Новое имя (null — не менять). /// Новое описание (null — не менять). /// Новый цвет (null — не менять). diff --git a/src/core/Deal.Api/Endpoints/RequestModels/CreateCardRequest.cs b/src/core/Deal.Api/Endpoints/RequestModels/CreateCardRequest.cs index 79da3a7..d1836de 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/CreateCardRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/CreateCardRequest.cs @@ -3,14 +3,9 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело POST /api/cards — ручное («локальное») создание карточки (этап 9, T6). +/// Тело POST /api/cards — ручное /// -/// -/// Wire-имена — camelCase: title/summary/stack/budget/contact/tzText/containerId (алиас stage). -/// containerId — id контейнера (стадии/доски); неизвестный/отсутствующий не отвергается: карточка -/// создаётся в planned. budget — объект {from,to,cur} либо null. -/// -/// Заголовок карточки (Trim() в сервисе; пустой допустим). +/// Заголовок карточки (Trim в сервисе; пустой допустим). /// Краткое содержание карточки. /// Стек/направления (null — пустой стек). /// Бюджет (from/to/cur); null — бюджета нет. diff --git a/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskCreateBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskCreateBody.cs index e74568d..f882dd8 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskCreateBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskCreateBody.cs @@ -1,28 +1,22 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело POST /api/discovery/tasks (TaskCreate discovery_routes.py L50–60; api-map §3.8 L204). +/// Тело POST /api/discovery/tasks. /// -/// -/// 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). + /// Ключевые слова поиска; null/пусто — список пуст /// public IReadOnlyList? Keywords { get; init; } @@ -32,22 +26,22 @@ public sealed record DiscoveryTaskCreateBody 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; } diff --git a/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskPatchBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskPatchBody.cs index 969d4b8..6462d1c 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskPatchBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/DiscoveryTaskPatchBody.cs @@ -1,57 +1,52 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело PATCH /api/discovery/tasks/{task_id} (TaskPatch discovery_routes.py L62–71; api-map §3.8 L205). +/// Тело PATCH /api/discovery/tasks/{task_id}. /// -/// -/// 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»). + /// Новый язык: «ru»|«any» /// 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 df843a9..0576fae 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/MarkColBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/MarkColBody.cs @@ -1,12 +1,6 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело POST /api/cards/mark-col-seen — снять «новое» с колонки (dashboard_routes.py MarkColBody L183–184, api-map §3.2 L88). +/// Тело POST /api/cards/mark-col-seen — снять «новое» с колонки. /// -/// -/// 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 f360c82..dd2494b 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/MoveBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/MoveBody.cs @@ -1,11 +1,6 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело POST /api/cards/{lead_id}/move — перенос карточки (dashboard_routes.py MoveBody L56–57, api-map §3.2 L89). +/// Тело POST /api/cards/{lead_id}/move — перенос карточки. /// -/// -/// 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 8d56427..bcf109e 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/OperatorTelegramKeysRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/OperatorTelegramKeysRequest.cs @@ -1,10 +1,7 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело PUT /api/operator/settings/telegram-keys: глобальные ключи приложения Telegram, -/// задаваемые оператором (ТЗ §4.1/§8.1). Поддерживается частичное обновление: непереданное поле -/// (null) сохраняет текущее значение, явное значение валидируется. Если ключей ещё нет, -/// оба поля обязательны. +/// Тело PUT /api/operator/settings/telegram-keys /// /// api_id приложения Telegram (5..9 цифр); null — не менялось. /// api_hash приложения Telegram (непустой секрет; хранится зашифрованным); null — не менялось. diff --git a/src/core/Deal.Api/Endpoints/RequestModels/OrderBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/OrderBody.cs index a93ebef..79e4cf7 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/OrderBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/OrderBody.cs @@ -1,12 +1,8 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело POST /api/containers/reorder — новый порядок контейнеров пространства (этап 9, T4). +/// Тело POST /api/containers/reorder — новый порядок контейнеров пространства. /// -/// -/// 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 6e45996..13d7c1a 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ReclassifyBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ReclassifyBody.cs @@ -1,11 +1,6 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело POST /api/cards/reclassify — ИИ-переклассификация «Неразобранного» (dashboard_routes.py ReclassifyBody L64–65, api-map §3.2 L95). +/// Тело POST /api/cards/reclassify — ИИ-переклассификация «Неразобранного». /// -/// -/// 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 ceda572..3bf12ae 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ReminderSetRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ReminderSetRequest.cs @@ -1,17 +1,7 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело POST /api/cards/{cardId}/reminder — установка напоминания hold-карточке -/// (projects_routes.py ReminderBody L52–53, api-map §3.5 L172; Ruling 3). +/// Тело POST /api/cards/{cardId}/reminder — установка напоминания hold-карточке. /// -/// -/// 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 d8afa70..1c5a79b 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/ReturnReasonRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/ReturnReasonRequest.cs @@ -1,11 +1,6 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело POST /api/pipeline/rejected/{rejId}/return — причина возврата в обработку (processing_routes.py ReturnBody L13–15, api-map §3.6 L185). +/// Тело POST /api/pipeline/rejected/{rejId}/return — причина возврата в обработку. /// -/// -/// 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 da23b13..2db06a5 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/TakeCardRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/TakeCardRequest.cs @@ -1,13 +1,8 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело POST /api/cards/take — «взять в работу» карточки с дашборда (этап 9, T6). +/// Тело POST /api/cards/take — «взять в работу» карточки с дашборда. /// -/// -/// Wire-имена — camelCase: cardId — id карточки; leadId — алиас (совместимость со старым фронтом). -/// Карточка не клонируется: она переносится в контейнер planned. Отсутствующий/несуществующий id → -/// 404 «Карточка не найдена». -/// /// Id карточки, берущейся в работу. /// Алиас cardId. public sealed record TakeCardRequest(string? CardId = null, string? LeadId = null); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/TgMonitorBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/TgMonitorBody.cs index 69334b7..058a067 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/TgMonitorBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/TgMonitorBody.cs @@ -1,7 +1,7 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело POST /api/tg/dialogs/{dialog_id}/monitor и /monitor-all (tg_routes.py MonitorBody L54–56). +/// Тело POST /api/tg/dialogs/{dialog_id}/monitor и /monitor-all. /// /// True — мониторить (сообщения → PushMessage в ядро), false — выключить. public sealed record TgMonitorBody(bool Enabled); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/TgPreviewBody.cs b/src/core/Deal.Api/Endpoints/RequestModels/TgPreviewBody.cs index 3f24b87..b590594 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/TgPreviewBody.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/TgPreviewBody.cs @@ -1,8 +1,8 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело POST /api/tg/dialogs/preview — последние сообщения диалога (tg_routes.py PreviewBody L58–60). +/// Тело POST /api/tg/dialogs/preview — последние сообщения диалога. /// /// Id диалога (подписанный). -/// Сколько последних сообщений; дефолт 24, кламп 1..50 (python L153). +/// Сколько последних сообщений; дефолт 24, кламп 1..50. public sealed record TgPreviewBody(string DialogId, int? Limit); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/TgSendCodeRequest.cs b/src/core/Deal.Api/Endpoints/RequestModels/TgSendCodeRequest.cs index 4391952..08054fa 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/TgSendCodeRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/TgSendCodeRequest.cs @@ -1,7 +1,7 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело POST /api/tg/send-code — SMS-код входа (tg_routes.py CodeBody L46–48). +/// Тело POST /api/tg/send-code — SMS-код входа. /// -/// Код из SMS/Telegram-сообщения (trim перед отправкой, python L89). +/// Код из SMS/Telegram-сообщения. public sealed record TgSendCodeRequest(string Code); diff --git a/src/core/Deal.Api/Endpoints/RequestModels/TgSendPasswordRequest.cs b/src/core/Deal.Api/Endpoints/RequestModels/TgSendPasswordRequest.cs index be3fb1c..6e9279a 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/TgSendPasswordRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/TgSendPasswordRequest.cs @@ -1,7 +1,7 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело POST /api/tg/send-password — облачный пароль 2FA (tg_routes.py PasswordBody L50–52). +/// Тело POST /api/tg/send-password — облачный пароль 2FA. /// -/// Пароль облачной защиты (как ввёл пользователь, без 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 ba0408c..efe83e5 100644 --- a/src/core/Deal.Api/Endpoints/RequestModels/TgStartPhoneRequest.cs +++ b/src/core/Deal.Api/Endpoints/RequestModels/TgStartPhoneRequest.cs @@ -1,7 +1,7 @@ namespace Deal.Api.Endpoints.RequestModels; /// -/// Тело POST /api/tg/start-phone — вход по номеру телефона (tg_routes.py PhoneBody L42–44). +/// Тело POST /api/tg/start-phone — вход по номеру телефона. /// -/// Номер в международном формате (как ввёл пользователь; обрезается обработчиком, 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 16d9e56..a5fd33e 100644 --- a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs @@ -9,26 +9,8 @@ using Deal.Modules.Tenants.Application.Models; namespace Deal.Api.Endpoints; /// -/// HTTP-эндпоинты настроек тенанта: GET/PATCH /api/settings (api-map §3.4 L146–147, §4.6). +/// HTTP-эндпоинты настроек тенанта /// -/// -/// 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"; @@ -83,7 +65,6 @@ public static class SettingsEndpoints catch (JsonException) { // Не-JSON или не-объект целиком — ошибка запроса: 400 + detail - // (в прототипе FastAPI на такое тело — 422). return EndpointResults.BadRequest(InvalidBodyDetail); } @@ -95,11 +76,8 @@ public static class SettingsEndpoints 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(); @@ -108,7 +86,6 @@ public static class SettingsEndpoints return Results.Ok(result); } - // Запускать ли фоновый refresh курсов после PATCH (семантика if body.get("rateSource") L188). // body: Тело PATCH — публичные ключи §4.6. // Возвращает: True — поле rateSource передано «правдивым» значением (не null/пустая строка). private static bool ShouldScheduleRatesRefresh(Dictionary body) @@ -118,7 +95,6 @@ public static class SettingsEndpoints return false; } - // JSON-булево/число в python «правдивы» и запускают refresh; пустая строка/null — нет. return element.ValueKind switch { JsonValueKind.String => !string.IsNullOrEmpty(element.GetString()), diff --git a/src/core/Deal.Api/Endpoints/StorageEndpoints.cs b/src/core/Deal.Api/Endpoints/StorageEndpoints.cs index 0a1abe3..1790c00 100644 --- a/src/core/Deal.Api/Endpoints/StorageEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/StorageEndpoints.cs @@ -5,37 +5,16 @@ 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). +/// Служебные storage-эндпоинты /// -/// -/// Контракт 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"; /// @@ -51,12 +30,9 @@ public static class StorageEndpoints 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()) @@ -69,11 +45,8 @@ public static class StorageEndpoints 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()) diff --git a/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs b/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs index 5811086..2a88bb1 100644 --- a/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs @@ -11,25 +11,12 @@ using Deal.Modules.Tenants.Application.Models; namespace Deal.Api.Endpoints; /// -/// Эндпоинты /api/tg: статус, веб-авторизация (phone/QR/код/2FA/logout), диалоги и мониторинг (Ruling 8, api-map §3.3). +/// Эндпоинты /api/tg /// -/// -/// Тела ответов 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). @@ -80,24 +67,18 @@ public static class TelegramEndpoints // Деталь недоступного 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). + /// Регистрирует группу /api/tg /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. @@ -122,7 +103,6 @@ public static class TelegramEndpoints 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()) @@ -134,7 +114,6 @@ public static class TelegramEndpoints 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, @@ -169,7 +148,6 @@ public static class TelegramEndpoints } } - // 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()) @@ -194,7 +172,6 @@ public static class TelegramEndpoints 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); } @@ -206,7 +183,6 @@ public static class TelegramEndpoints } } - // 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, @@ -223,7 +199,6 @@ public static class TelegramEndpoints 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); } @@ -235,7 +210,6 @@ public static class TelegramEndpoints } } - // 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, @@ -252,7 +226,6 @@ public static class TelegramEndpoints 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); } @@ -264,7 +237,6 @@ public static class TelegramEndpoints } } - // 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()) @@ -284,7 +256,6 @@ public static class TelegramEndpoints } } - // 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()) @@ -295,8 +266,6 @@ public static class TelegramEndpoints 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) { @@ -317,7 +286,6 @@ public static class TelegramEndpoints 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()) @@ -328,7 +296,6 @@ public static class TelegramEndpoints 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 @@ -358,7 +325,6 @@ public static class TelegramEndpoints } } - // POST /api/tg/dialogs/monitor-all: мониторинг всех каналов (tg_routes.py L125–129; L548–567). private static async Task MonitorAllAsync( TgMonitorBody body, HttpContext context, @@ -375,13 +341,11 @@ public static class TelegramEndpoints 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); } @@ -393,7 +357,6 @@ public static class TelegramEndpoints } } - // POST /api/tg/dialogs/backfill-all: «Перечитать» включённые каналы в фоне (tg_routes.py L132–136). private static async Task BackfillAllAsync(HttpContext context, CancellationToken ct) { if (!context.HasUser()) @@ -405,14 +368,12 @@ public static class TelegramEndpoints 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, @@ -430,13 +391,11 @@ public static class TelegramEndpoints 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); } @@ -448,8 +407,6 @@ public static class TelegramEndpoints } } - // 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, @@ -472,7 +429,6 @@ public static class TelegramEndpoints } } - // POST /api/tg/dialogs/preview: последние сообщения диалога (tg_routes.py L151–153; dialog_messages L583–620). private static async Task PreviewAsync( TgPreviewBody body, HttpContext context, @@ -500,7 +456,6 @@ public static class TelegramEndpoints return keys.KeysSet ? keys : null; } - // 400 {detail} по ошибке гейта: канонический detail RPC либо «Telegram не подключён» (Ruling 7/8). // exception: Исключение вызова гейта (RpcException домена/транспорта, прочее). // Возвращает: 400-ответ с текстом причины. private static IResult GatewayError(Exception exception) @@ -509,7 +464,7 @@ public static class TelegramEndpoints } /// - /// Текст причины ошибки гейта для {detail} (канонические тексты telegram-service 1:1, Ruling 7). + /// Текст причины ошибки гейта для {detail}. /// /// Исключение вызова гейта. /// Текст причины. @@ -519,16 +474,13 @@ public static class TelegramEndpoints { // Доменная 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) diff --git a/src/core/Deal.Api/Endpoints/TelegramQrImageEndpoint.cs b/src/core/Deal.Api/Endpoints/TelegramQrImageEndpoint.cs index 12b3ace..7b7bff0 100644 --- a/src/core/Deal.Api/Endpoints/TelegramQrImageEndpoint.cs +++ b/src/core/Deal.Api/Endpoints/TelegramQrImageEndpoint.cs @@ -7,16 +7,8 @@ using Net.Codecrete.QrCodeGenerator; namespace Deal.Api.Endpoints; /// -/// GET /api/tg/qr-image: SVG QR-кода входа (tg_routes.py L30–39; Ruling 8). +/// GET /api/tg/qr-image /// -/// -/// Фронт рисует 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). @@ -25,16 +17,12 @@ public static class TelegramQrImageEndpoint // Путь 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; /// @@ -49,7 +37,6 @@ public static class TelegramQrImageEndpoint 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()) @@ -57,7 +44,6 @@ public static class TelegramQrImageEndpoint return EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail); } - // Активен только в фазе «qr» живого статуса telegram-service; сервис недоступен/фаза иная — 404 (python L33–34). TelegramAccountStatusDto live; try { @@ -77,7 +63,6 @@ public static class TelegramQrImageEndpoint 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 20c1c1f..5e51a61 100644 --- a/src/core/Deal.Api/Events/SseBroker.cs +++ b/src/core/Deal.Api/Events/SseBroker.cs @@ -5,21 +5,12 @@ using System.Threading.Channels; namespace Deal.Api.Events; /// -/// Singleton SSE-брокер этапа: per-tenant каналы событий (Ruling 5, план Task 9). +/// Singleton SSE-брокер /// -/// -/// Канал заводится на тенанта при подписке (тенант сессии — 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; @@ -36,15 +27,14 @@ public sealed class SseBroker 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, @@ -66,7 +56,7 @@ public sealed class SseBroker } /// - /// Отписывает клиента по завершении SSE-соединения (events_routes.py L27–28). + /// Отписывает клиента по завершении SSE-соединения. /// /// Тенант канала подписки. /// Идентификатор подписки из . @@ -89,10 +79,10 @@ public sealed class SseBroker } /// - /// Публикует событие в канал тенанта (Ruling 5: публикации — из эндпоинтов Api). + /// Публикует событие в канал тенанта. /// /// Тенант-получатель; без подписчиков — no-op, не падает. - /// Тип события (new_card/toast этапа 3; api.js L78–79). + /// Тип события. /// Полезная нагрузка — сериализуется в JSON (camelCase, без \u). public void Publish( Guid tenantId, @@ -101,7 +91,7 @@ public sealed class SseBroker Publish(tenantId, new SseEvent(eventType, JsonSerializer.Serialize(payload, PublishJsonOptions))); /// - /// Публикует готовое событие (тип + JSON) в канал тенанта. + /// Публикует готовое событие /// /// Тенант-получатель; без подписчиков — no-op, не падает. /// Событие с уже сериализованной нагрузкой. diff --git a/src/core/Deal.Api/Events/SseEvent.cs b/src/core/Deal.Api/Events/SseEvent.cs index 4af8411..d0a8099 100644 --- a/src/core/Deal.Api/Events/SseEvent.cs +++ b/src/core/Deal.Api/Events/SseEvent.cs @@ -1,15 +1,14 @@ namespace Deal.Api.Events; /// -/// Событие SSE-потока: тип + JSON-полезная нагрузка (Ruling 5; прототип sse.py L29–30). +/// Событие SSE-потока /// -/// Тип события — фронт слушает addEventListener по имени -/// (api.js L78–81): на этапе 3 — new_card (полный объект карточки) и toast {text, icon}. +/// Тип события — фронт слушает addEventListener по имени: на — 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 протокола SSE /// /// Готовый frame для отправки в поток ответа. public string RenderFrame() => $"event: {Type}\ndata: {Json}\n\n"; diff --git a/src/core/Deal.Api/Events/SseSubscription.cs b/src/core/Deal.Api/Events/SseSubscription.cs index 1bee684..604154e 100644 --- a/src/core/Deal.Api/Events/SseSubscription.cs +++ b/src/core/Deal.Api/Events/SseSubscription.cs @@ -3,10 +3,9 @@ using System.Threading.Channels; namespace Deal.Api.Events; /// -/// Активная подписка на канал SSE тенанта (прототип sse.py — очередь подписчика L19–20). +/// Активная подписка на канал SSE тенанта. /// /// Идентификатор подписки — передаётся в . -/// Тенант канала: тенант сессии при подписке (Ruling 5). -/// Канал событий подписчика: bounded-очередь ≤200 с вытеснением старых -/// (DropOldest, как get_nowait+put_nowait прототипа L36–44). +/// Тенант канала: тенант сессии при подписке. +/// Канал событий подписчика: bounded-очередь ≤200 с вытеснением старых. public sealed record SseSubscription(Guid Id, Guid TenantId, ChannelReader Events); diff --git a/src/core/Deal.Api/Events/StorageToastPublisher.cs b/src/core/Deal.Api/Events/StorageToastPublisher.cs index af8a839..12c59b3 100644 --- a/src/core/Deal.Api/Events/StorageToastPublisher.cs +++ b/src/core/Deal.Api/Events/StorageToastPublisher.cs @@ -3,36 +3,22 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Api.Events; /// -/// Публикация SSE-тостов статистики тика правил хранения в канал тенанта (Ruling 8; notify_tick_stats L496–504). +/// Публикация SSE-тостов статистики тика правил хранения в канал тенанта. /// -/// -/// Единый хелпер 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; @@ -48,13 +34,8 @@ public sealed class StorageToastPublisher } /// - /// Публикует тосты статистики тика по ненулевым счётчикам (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) diff --git a/src/core/Deal.Api/Extensions/AuthHelpers.cs b/src/core/Deal.Api/Extensions/AuthHelpers.cs index 4de1d17..a958a9d 100644 --- a/src/core/Deal.Api/Extensions/AuthHelpers.cs +++ b/src/core/Deal.Api/Extensions/AuthHelpers.cs @@ -3,7 +3,7 @@ using Deal.Api.Models; namespace Deal.Api.Extensions; /// -/// Хелперы доступа к текущему пользователю запроса (минимальные API). +/// Хелперы доступа к текущему пользователю запроса /// public static class AuthHelpers { @@ -18,12 +18,12 @@ public static class AuthHelpers public const string CurrentOperatorItemKey = "CurrentOperator"; /// - /// Сообщение 401 для эндпоинтов, требующих авторизации (семантика прототипа, Ruling 10). + /// Сообщение 401 для эндпоинтов, требующих авторизации. /// public const string UnauthorizedDetail = "Требуется авторизация"; /// - /// Сообщение 401 для ручек /api/operator/* без разрешённой операторской сессии (Ruling 1). + /// Сообщение 401 для ручек /api/operator/* без разрешённой операторской сессии. /// public const string OperatorUnauthorizedDetail = "Требуется вход оператора"; diff --git a/src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs b/src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs index 0556a2d..9cc9976 100644 --- a/src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs +++ b/src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs @@ -5,48 +5,21 @@ using Deal.Modules.Tenants.Application.Models; namespace Deal.Api.Hosting; /// -/// Фоновый цикл SSE-алертов ИИ-бюджета (Ruling 3, Task 9; эталон StorageTickScheduler): каждые 60 с -/// обходит ВСЕ тенанты реестра и публикует в канал тенанта тост при пересечении порогов бюджета 80%/100%. +/// Фоновый цикл SSE-алертов ИИ-бюджета /// -/// -/// Пересечение порога детектируется 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: фоновая проверка порогов бюджета). + /// Период проходов цикла — 60 с. /// 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); @@ -119,12 +92,8 @@ public sealed class BudgetAlertScheduler : IHostedService } /// - /// Один проход цикла: список тенантов реестра и проверка порогов каждого (no-op, если проход идёт). + /// Один проход цикла /// - /// Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются); таймер - /// вызывает этот же метод. Ошибки и отмена токена наружу не выбрасываются: сбои логируются (цикл живёт), - /// отмена по токену останова завершает проход штатно. - /// Токен отмены прохода (в проде — токен остановки хоста). /// Задача прохода (завершается без исключений). public Task RunCycleAsync(CancellationToken ct) { @@ -190,7 +159,6 @@ public sealed class BudgetAlertScheduler : IHostedService 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); diff --git a/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs b/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs index ca6cbde..7177219 100644 --- a/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs +++ b/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs @@ -4,32 +4,17 @@ using Deal.Modules.Tenants.Application.Abstractions; namespace Deal.Api.Hosting; /// -/// Фоновый цикл авто-очистки данных (этап 12, пакет B; эталон DealMetricsCollector): раз в сутки -/// удаляет устаревшие записи аудита по retention, сбрасывает накопительные поля лимитов прошедших периодов -/// и убирает завершившиеся окна распределённых счётчиков rate limiting. +/// Фоновый цикл авто-очистки данных /// -/// -/// -/// аудит — по границе -/// now − DataRetention:AuditRetentionDays (дефолт 180 дней); -/// лимиты — : отдельной истории периодов нет, -/// поэтому очищаются накопительные поля (UsedTokens/флаги) строк с завершившимся периодом; -/// счётчики — удаляет строки с истёкшим -/// ExpiresAt (окна rate limiting и попыток входа). -/// -/// Идемпотентно: повторный проход на тех же данных не находит, что чистить. Ошибки логируются и наружу -/// не выбрасываются (сбой очистки не валит хост); перекрывающиеся проходы исключены in-flight guard -/// (Interlocked); при остановке хоста таймер останавливается и текущий проход отменяется. -/// public sealed class DataRetentionScheduler : IHostedService { /// - /// Период проходов цикла — 24 часа (авто-очистка редко меняющихся данных). + /// Период проходов цикла — 24 часа /// public const int RunPeriodHours = 24; /// - /// Задержка первого прохода — 60 с (после bootstrap/провижининга, без конкуренции со стартом). + /// Задержка первого прохода — 60 с /// public const int InitialDelaySeconds = 60; @@ -108,9 +93,8 @@ public sealed class DataRetentionScheduler : IHostedService } /// - /// Один проход очистки (no-op, если проход уже идёт); публичен как точка запуска для тестов. + /// Один проход очистки /// - /// Токен отмены прохода. /// Задача прохода (завершается без исключений). public Task RunCycleAsync(CancellationToken ct) { diff --git a/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs b/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs index 0eec3f0..aeb8f9a 100644 --- a/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs +++ b/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs @@ -8,22 +8,10 @@ using Deal.SharedKernel.Tenants.Models; namespace Deal.Api.Hosting; /// -/// Фоновый цикл Discovery-воркера по всем тенантам (план Task 18, Ruling 10; эталон PipelineWorkerScheduler/StorageTickScheduler). +/// Фоновый цикл Discovery-воркера по всем тенантам. /// -/// -/// Каждые 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); @@ -89,12 +77,8 @@ public sealed class DiscoveryWorkerScheduler : IHostedService } /// - /// Один проход цикла: список тенантов реестра и тик каждого (no-op, если проход уже идёт). + /// Один проход цикла /// - /// Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются) и - /// ручного вызова при отладке; таймер вызывает этот же метод. Ошибки и отмена токена наружу не - /// выбрасываются: сбои логируются (цикл живёт), отмена по токену останова завершает проход штатно. - /// Токен отмены прохода (в проде — токен остановки хоста). /// Задача прохода (завершается без исключений). public Task RunCycleAsync(CancellationToken ct) { diff --git a/src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs b/src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs index 11e749c..1210ee0 100644 --- a/src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs +++ b/src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs @@ -9,37 +9,22 @@ using Deal.SharedKernel.Tenants.Models; namespace Deal.Api.Hosting; /// -/// Фоновый флашер очереди обучения ML — выгрузка MlOutbox в ml-service батчами (план Task 16, Ruling 6). +/// Фоновый флашер очереди обучения ML — выгрузка MlOutbox в ml-service батчами. /// -/// -/// Аналог _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). + /// Период циклов выгрузки — 10 с. /// public const int FlushPeriodSeconds = 10; /// - /// Размер порции за один TrainBatch — 10 строк (ml_client.flush_outbox L63: chunk=10). + /// Размер порции за один TrainBatch — 10 строк. /// public const int BatchSize = 10; /// - /// Потолок выгрузки за один цикл тенанта — 100 строк (ml_client.flush_outbox L56: batch=100). + /// Потолок выгрузки за один цикл тенанта — 100 строк. /// public const int MaxPerCycle = 100; @@ -104,12 +89,8 @@ public sealed class MlOutboxFlushScheduler : IHostedService } /// - /// Один проход цикла: список тенантов реестра и выгрузка каждого (no-op, если проход уже идёт). + /// Один проход цикла /// - /// Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются); - /// таймер вызывает этот же метод. Ошибки и отмена токена наружу не выбрасываются: сбои логируются - /// (цикл живёт), отмена по токену останова завершает проход штатно. - /// Токен отмены прохода (в проде — токен остановки хоста). /// Задача прохода (завершается без исключений). public Task RunCycleAsync(CancellationToken ct) { @@ -160,7 +141,6 @@ public sealed class MlOutboxFlushScheduler : IHostedService } // Выгрузка очереди одного тенанта в собственном scope: SetTenant → порции по 10 до ≤100/цикл. - // Строки удаляются только после успешного TrainBatch (Ruling 6); сбой батча — порция остаётся, // цикл тенанта завершается (следующая попытка — следующий тик). Сбой хранилища тенанта не валит проход: // ошибка логируется, остальные тенанты обрабатываются; отмена (OCE) пробрасывается наверх. // tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>). @@ -196,7 +176,6 @@ public sealed class MlOutboxFlushScheduler : IHostedService } catch (Exception exception) { - // Недоступность/сбой ml-service: порция остаётся в очереди (python L75–77), следующая // попытка — на следующем тике; флашер не роняет проход цикла. _logger.LogWarning( exception, @@ -206,7 +185,6 @@ public sealed class MlOutboxFlushScheduler : IHostedService break; } - // Удаление только после успеха (python L78–79): отправленные строки больше не нужны. await learningStore.DeleteOutboxAsync(rows.Select(row => row.Id).ToList(), ct); total += rows.Count; } diff --git a/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs b/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs index 120c196..9d5c156 100644 --- a/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs +++ b/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs @@ -3,19 +3,8 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Hosting; /// -/// Hosted-шаг bootstrap оператора при старте (Ruling 1 этапа 7): env DEAL_OPERATOR_* → OperatorBootstrapService.EnsureOperatorAsync. +/// Hosted-шаг bootstrap оператора при старте /// -/// -/// Регистрируется после и работает тем же паттерном: 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, @@ -44,7 +33,6 @@ public sealed class OperatorBootstrapHostedService( } else if (!allowDevelopmentDefaults) { - // Production без кред: пропуск с warning (Ruling 1) — кода регистрации оператора нет. logger.LogWarning( "DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Production) — bootstrap оператора " + "пропущен. Оператор заводится позже: задайте env DEAL_OPERATOR_* и перезапустите хост."); diff --git a/src/core/Deal.Api/Hosting/StorageTickScheduler.cs b/src/core/Deal.Api/Hosting/StorageTickScheduler.cs index 0f65fe8..a127a8e 100644 --- a/src/core/Deal.Api/Hosting/StorageTickScheduler.cs +++ b/src/core/Deal.Api/Hosting/StorageTickScheduler.cs @@ -10,31 +10,12 @@ 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); @@ -56,8 +37,7 @@ public sealed class StorageTickScheduler : IHostedService /// /// Фабрика scope: проход цикла и тик каждого тенанта — в собственных scope. /// Публикатор SSE-тостов статистики тика (общий с POST /api/admin/tick). - /// SSE-брокер каналов тенантов: reminder_due «выстреливших» напоминаний в канал тенанта - /// (как StorageToastPublisher; без подписчиков публикация — no-op, Ruling 5). + /// SSE-брокер каналов тенантов: reminder_due «выстреливших» напоминаний в канал тенанта. /// Логгер ошибок цикла. public StorageTickScheduler( IServiceScopeFactory scopeFactory, @@ -78,7 +58,6 @@ public sealed class StorageTickScheduler : IHostedService /// public Task StartAsync(CancellationToken ct) { - // Первый проход — сразу после старта (в прототипе тик выполняется до первого sleep); далее каждые 30 с. _timer = new Timer( static state => ((StorageTickScheduler)state!).RunIteration(), this, @@ -113,12 +92,8 @@ public sealed class StorageTickScheduler : IHostedService } /// - /// Один проход цикла: список тенантов реестра и тик каждого (no-op, если проход уже идёт). + /// Один проход цикла /// - /// Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются) и - /// ручного вызова при отладке; таймер вызывает этот же метод. Ошибки и отмена токена наружу не - /// выбрасываются: сбои логируются (цикл живёт), отмена по токену останова завершает проход штатно. - /// Токен отмены прохода (в проде — токен остановки хоста). /// Задача прохода (завершается без исключений). public Task RunCycleAsync(CancellationToken ct) { @@ -169,7 +144,6 @@ public sealed class StorageTickScheduler : IHostedService } // Тик одного тенанта в собственном scope: SetTenant → Kanban-тик → purge отсева → SSE-тосты → - // проверка напоминаний + SSE reminder_due; Reset в finally (1:1 с _storage_loop main.py L47–53: тик → // тосты → напоминания). // Контекст AsyncLocal сбрасывается в finally, чтобы не переживать scope тенанта (как // SessionMiddleware). Тик одного тенанта не валит проход: ошибка ветки/тенанта логируется, остальные @@ -188,21 +162,15 @@ public sealed class StorageTickScheduler : IHostedService 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 { diff --git a/src/core/Deal.Api/Hosting/TenantBootstrapService.cs b/src/core/Deal.Api/Hosting/TenantBootstrapService.cs index 3f0e330..eb97c75 100644 --- a/src/core/Deal.Api/Hosting/TenantBootstrapService.cs +++ b/src/core/Deal.Api/Hosting/TenantBootstrapService.cs @@ -6,21 +6,12 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Hosting; /// -/// Bootstrap при старте (Ruling 8): дефолтный тенант + admin (dev-only), провижининг схем всех тенантов. +/// Bootstrap при старте /// -/// -/// Идемпотентен. 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"; @@ -29,10 +20,8 @@ public sealed class TenantBootstrapService(IServiceScopeFactory scopeFactory) : 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"; /// @@ -49,7 +38,6 @@ public sealed class TenantBootstrapService(IServiceScopeFactory scopeFactory) : 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; @@ -77,10 +65,8 @@ public sealed class TenantBootstrapService(IServiceScopeFactory scopeFactory) : } } - // Провижининг схем ВСЕХ тенантов реестра выполняется всегда (Ruling 1 этапа 7), включая только // что созданного в dev: пакетная идемпотентная миграция (CREATE SCHEMA IF NOT EXISTS + Migrate, // применяющий только неприменённые миграции) с ограниченным параллелизмом и логированием прогресса — - // масштабируется на сотни/тысячи схем (этап 12, пакет C). Повторный старт безопасен. var tenantSchemaMigrationService = scope.ServiceProvider.GetRequiredService(); await tenantSchemaMigrationService.MigrateAllAsync(ct); } diff --git a/src/core/Deal.Api/Logging/DealLogging.cs b/src/core/Deal.Api/Logging/DealLogging.cs index e594149..2e09e59 100644 --- a/src/core/Deal.Api/Logging/DealLogging.cs +++ b/src/core/Deal.Api/Logging/DealLogging.cs @@ -4,17 +4,12 @@ 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 @@ -51,8 +46,7 @@ internal static class DealLogging private const LogEventLevel DefaultMinimumLevel = LogEventLevel.Information; /// - /// Подключает Serilog к хосту (builder.Host.UseSerilog). Регистрация отложенная: конфигурация - /// логгера применяется при builder.Build(), когда среда/конфигурация (env) уже собраны. + /// Подключает Serilog к хосту /// /// Билдер WebApplication процесса (до Build). /// Имя процесса для имени файла-лога (core/telegram/ai/ml). @@ -94,12 +88,10 @@ internal static class DealLogging 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()); } } diff --git a/src/core/Deal.Api/Middleware/HttpAccessLogMiddleware.cs b/src/core/Deal.Api/Middleware/HttpAccessLogMiddleware.cs index 919453a..499bdb7 100644 --- a/src/core/Deal.Api/Middleware/HttpAccessLogMiddleware.cs +++ b/src/core/Deal.Api/Middleware/HttpAccessLogMiddleware.cs @@ -3,19 +3,8 @@ using System.Diagnostics; namespace Deal.Api.Middleware; /// -/// Access-лог HTTP-запросов core (Ruling 7, план Task 14): каждый запрос — одна структурированная -/// строка «метод путь → статус за N мс» (Serilog JSON). Логируются метод и путь БЕЗ query-строки, -/// заголовков и тела — секреты/токены в query не попадают в логи (Ruling 13); клиентский IP не -/// логируется (аудит-IP живёт в AuditLog, операторский контур). +/// Access-лог HTTP-запросов core /// -/// -/// Регистрируется самым первым в 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. @@ -28,7 +17,7 @@ public sealed class HttpAccessLogMiddleware /// Создаёт middleware access-лога HTTP-запросов. /// /// Следующий обработчик конвейера. - /// Логгер (Serilog, Ruling 7). + /// Логгер. public HttpAccessLogMiddleware(RequestDelegate next, ILogger logger) { ArgumentNullException.ThrowIfNull(next); @@ -38,7 +27,7 @@ public sealed class HttpAccessLogMiddleware } /// - /// Обрабатывает запрос: пропускает gRPC-ингресс, остальные логирует по завершении. + /// Обрабатывает запрос /// /// Контекст запроса. public async Task InvokeAsync(HttpContext context) diff --git a/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs b/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs index 74d6aaa..e617ec4 100644 --- a/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs +++ b/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs @@ -8,17 +8,8 @@ using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions; namespace Deal.Api.Middleware; /// -/// Middleware операторской сессии: читает httpOnly-куку deal_operator_session, разрешает сессию через -/// OperatorAuthService и наполняет HttpContext.Items["CurrentOperator"] (Ruling 1 этапа 7). +/// Middleware операторской сессии /// -/// -/// Зеркало для операторов: отдельная кука и отдельный ключ 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; @@ -31,7 +22,7 @@ public sealed class OperatorSessionMiddleware } /// - /// Обрабатывает запрос: разрешает операторскую сессию по куке и наполняет контекст. + /// Обрабатывает запрос /// /// Контекст запроса. public async Task InvokeAsync(HttpContext context) diff --git a/src/core/Deal.Api/Middleware/OriginGuardMiddleware.cs b/src/core/Deal.Api/Middleware/OriginGuardMiddleware.cs index 716c4d5..a952f66 100644 --- a/src/core/Deal.Api/Middleware/OriginGuardMiddleware.cs +++ b/src/core/Deal.Api/Middleware/OriginGuardMiddleware.cs @@ -4,33 +4,15 @@ 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":"…"}. +/// Origin-проверка мутаций /api /// -/// -/// Дополнительный слой 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)): единая формулировка для всех отказов. + /// Текст 403 Origin-проверки /// public const string OriginRejectedDetail = "Запрос отклонён: недопустимый Origin"; - // Префикс пути, под которым живут все HTTP-эндпоинты приложения (Ruling 11). private const string ApiPathPrefix = "/api"; private readonly RequestDelegate _next; @@ -46,7 +28,7 @@ public sealed class OriginGuardMiddleware } /// - /// Обрабатывает запрос: отклоняет мутации /api с чужим Origin (403 {detail}). + /// Обрабатывает запрос /// /// Контекст запроса. public async Task InvokeAsync(HttpContext context) diff --git a/src/core/Deal.Api/Middleware/RateLimitPolicies.cs b/src/core/Deal.Api/Middleware/RateLimitPolicies.cs index 5ef9f22..7627d8a 100644 --- a/src/core/Deal.Api/Middleware/RateLimitPolicies.cs +++ b/src/core/Deal.Api/Middleware/RateLimitPolicies.cs @@ -8,38 +8,22 @@ 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": "…"}. +/// Регистрация встроенного rate limiter ASP.NET Core и его политики /// -/// -/// Регистрируется только при (в 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). + /// Имя API-политики /// public const string ApiPolicy = "api"; /// - /// Текст 429 rate limiter (Ruling 5): все отказы лимитов запросов — единый detail. + /// Текст 429 rate limiter /// public const string RejectedDetail = "Слишком много запросов. Повторите позже"; @@ -142,7 +126,6 @@ public static class RateLimitPolicies return string.IsNullOrEmpty(ip) ? UnknownClientKey : IpKeyPrefix + ip; } - // Пишет ответ 429 формата прототипа: {"detail": "…"} (Ruling 10/5). // context: Контекст отклонённого запроса. // cancellationToken: Токен отмены ответа. private static async ValueTask OnRejectedAsync(OnRejectedContext context, CancellationToken cancellationToken) diff --git a/src/core/Deal.Api/Middleware/SessionMiddleware.cs b/src/core/Deal.Api/Middleware/SessionMiddleware.cs index 5beb12c..fc85f89 100644 --- a/src/core/Deal.Api/Middleware/SessionMiddleware.cs +++ b/src/core/Deal.Api/Middleware/SessionMiddleware.cs @@ -10,19 +10,8 @@ using CookieOptions = Deal.Api.Configuration.CookieOptions; namespace Deal.Api.Middleware; /// -/// Middleware сессии: читает httpOnly-куку, разрешает сессию через AuthService и наполняет -/// HttpContext.Items["CurrentUser"] + tenant-контекст запроса (ITenantContext). +/// Middleware сессии /// -/// -/// Middleware НЕ отвечает 401 сама (pass-through): эндпоинты, требующие авторизации, проверяют -/// пользователя и выставляют 401. Нет куки или сессия невалидна — запрос идёт дальше без -/// пользователя. Tenant-контекст сбрасывается в finally после обработки запроса. -/// -/// AuthService — scoped, поэтому на запрос создаётся собственный scope через RequestServices. -/// Экземпляр middleware — singleton (стандартный паттерн UseMiddleware), опции читаются через -/// IOptionsMonitor, чтобы подхватывать изменения конфигурации. -/// -/// public sealed class SessionMiddleware { private readonly RequestDelegate _next; @@ -40,7 +29,7 @@ public sealed class SessionMiddleware } /// - /// Обрабатывает запрос: разрешает сессию по куке и наполняет контекст. + /// Обрабатывает запрос /// /// Контекст запроса. public async Task InvokeAsync(HttpContext context) diff --git a/src/core/Deal.Api/Models/CurrentOperator.cs b/src/core/Deal.Api/Models/CurrentOperator.cs index d06af7a..ddc2a09 100644 --- a/src/core/Deal.Api/Models/CurrentOperator.cs +++ b/src/core/Deal.Api/Models/CurrentOperator.cs @@ -1,7 +1,7 @@ namespace Deal.Api.Models; /// -/// Текущий оператор запроса — кладёт OperatorSessionMiddleware в HttpContext.Items (Ruling 1). +/// Текущий оператор запроса — кладёт OperatorSessionMiddleware в HttpContext.Items. /// /// Идентификатор оператора. /// Логин в нижнем регистре. diff --git a/src/core/Deal.Api/Observability/DealMetricsCollector.cs b/src/core/Deal.Api/Observability/DealMetricsCollector.cs index ed1e49a..3ac873a 100644 --- a/src/core/Deal.Api/Observability/DealMetricsCollector.cs +++ b/src/core/Deal.Api/Observability/DealMetricsCollector.cs @@ -3,22 +3,12 @@ using Deal.SharedKernel.Observability; namespace Deal.Api.Observability; /// -/// Фоновый сборщик gauge-метрик ядра (этап 12, пакет A): глубины очередей и активные сессии. +/// Фоновый сборщик gauge-метрик ядра /// -/// -/// -/// Каждые 15 с собирает снимок через (обход реестра тенантов и подсчёт -/// существующими сервисами/портами — логика не дублируется) и публикует значения в -/// (callback ObservableGauge отдаёт их Prometheus при scrape): суммарные глубины pipeline/ML-outbox и число -/// активных сессий. Ошибки сбора логируются и наружу не выбрасываются (сбой тенанта не валит проход); -/// перекрывающиеся проходы исключены in-flight guard (Interlocked); при остановке хоста таймер -/// останавливается, текущий проход отменяется. Gauge без сбора остаётся на последнем значении. -/// -/// public sealed class DealMetricsCollector : IHostedService { /// - /// Период сбора gauge-метрик — 15 с (запас по нагрузке: запросы лёгкие, счётчики — агрегаты). + /// Период сбора gauge-метрик — 15 с /// public const int CollectionPeriodSeconds = 15; @@ -79,9 +69,8 @@ public sealed class DealMetricsCollector : IHostedService } /// - /// Один проход сбора (no-op, если проход уже идёт); публичен как точка запуска для тестов. + /// Один проход сбора /// - /// Токен отмены прохода. /// Задача прохода (завершается без исключений). public Task RunCycleAsync(CancellationToken ct) { diff --git a/src/core/Deal.Api/Observability/DealMetricsHosting.cs b/src/core/Deal.Api/Observability/DealMetricsHosting.cs index 324c46a..7c4771c 100644 --- a/src/core/Deal.Api/Observability/DealMetricsHosting.cs +++ b/src/core/Deal.Api/Observability/DealMetricsHosting.cs @@ -5,26 +5,12 @@ using OpenTelemetry.Metrics; namespace Deal.Api.Observability; /// -/// Настройка метрик ядра Deal.Api (этап 12, пакет A): OpenTelemetry → экспортёр Prometheus и эндпоинт -/// /metrics на отдельном HTTP/1.1 Kestrel-эндпоинте (порт 9464 по умолчанию). +/// Настройка метрик ядра Deal.Api /// -/// -/// -/// Зеркалит общий хелпер сервисов (Deal.Grpc.Hosting.Services.DealMetricsHosting) — ядро не ссылается на -/// обвязку gRPC-сервисов, поэтому держит свою копию по конвенции проекта (см. комментарий в -/// Deal.Grpc.Hosting.csproj). Отдельный HTTP/1.1-эндпоинт нужен, т.к. основной HTTP :5080 уже -/// обслуживает приложение, а gRPC-ингресс :5082 слушает только HTTP/2. -/// -/// -/// Инструментация: входящие ASP.NET Core-запросы (http.server.*: RPS/латентность/ошибки по route) -/// и исходящие HTTP-клиенты (http.client.*); прикладные метрики — meter . -/// Порт метрик не публикуется наружу — scrape идёт внутри compose-сети от сервиса prometheus. -/// -/// public static class DealMetricsHosting { /// - /// Порт эндпоинта /metrics по умолчанию (конвенция OpenTelemetry Prometheus). + /// Порт эндпоинта /metrics по умолчанию /// public const int DefaultMetricsPort = 9464; @@ -32,9 +18,7 @@ public static class DealMetricsHosting private const string MetricsPortEnvKey = "METRICS_PORT"; /// - /// Порт эндпоинта метрик: env METRICS_PORT (нечисловое значение игнорируется), иначе - /// . Локальный запуск нескольких процессов на хосте без compose - /// требует разных значений (в compose порты контейнеров изолированы). + /// Порт эндпоинта метрик /// /// Дефолтный порт (обычно ). /// Порт HTTP/1.1-эндпоинта метрик. @@ -44,8 +28,7 @@ public static class DealMetricsHosting : defaultPort; /// - /// Регистрирует OTel-метрики и Kestrel-эндпоинт метрик (HTTP/1.1, 0.0.0.0:). - /// Вызывать до builder.Build(). + /// Регистрирует OTel-метрики и Kestrel-эндпоинт метрик /// /// Билдер ядра (Program.cs, до Build). /// Порт HTTP/1.1-эндпоинта метрик. @@ -68,7 +51,7 @@ public static class DealMetricsHosting } /// - /// Мапит эндпоинт /metrics (формат Prometheus). Вызывать после builder.Build(). + /// Мапит эндпоинт /metrics /// /// Собранное приложение ядра. public static void MapDealMetrics(WebApplication app) diff --git a/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs b/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs index df648da..06d9e8f 100644 --- a/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs +++ b/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs @@ -11,22 +11,8 @@ 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; @@ -46,9 +32,8 @@ public sealed class RuntimeDepthsCollector } /// - /// Собирает снимок глубин очередей и числа активных сессий (агрегат по всем тенантам). + /// Собирает снимок глубин очередей и числа активных сессий /// - /// Токен отмены (пробрасывается в EF-запросы; отмена — единственное исключение наружу). /// Снимок: суммарные глубины pipeline/ML-outbox и число активных сессий. public async Task CollectAsync(CancellationToken ct) { diff --git a/src/core/Deal.Api/Observability/RuntimeDepthsDto.cs b/src/core/Deal.Api/Observability/RuntimeDepthsDto.cs index 885217f..2c31660 100644 --- a/src/core/Deal.Api/Observability/RuntimeDepthsDto.cs +++ b/src/core/Deal.Api/Observability/RuntimeDepthsDto.cs @@ -1,7 +1,7 @@ namespace Deal.Api.Observability; /// -/// Снимок глубин очередей и активных сессий ядра — агрегат по всем тенантам (health/метрики, этап 12). +/// Снимок глубин очередей и активных сессий ядра — агрегат по всем тенантам. /// /// Суммарная глубина очереди пайплайна (new+filtered) по всем тенантам. /// Суммарная глубина очереди обучения ML (MlOutbox) по всем тенантам. diff --git a/src/core/Deal.Api/Program.cs b/src/core/Deal.Api/Program.cs index de1a5d3..33d7a81 100644 --- a/src/core/Deal.Api/Program.cs +++ b/src/core/Deal.Api/Program.cs @@ -37,52 +37,38 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; 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); @@ -96,9 +82,6 @@ builder.Services.AddDbContext(options => options.UseNpgsql(connec 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). @@ -111,9 +94,7 @@ if (mtlsCertificates is not null) // 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]); @@ -123,9 +104,7 @@ builder.WebHost.ConfigureKestrel(kestrel => 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; @@ -160,10 +139,8 @@ builder.Services.AddDbContext( 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-путь тоже закрыт). @@ -171,104 +148,74 @@ 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 @@ -283,19 +230,16 @@ 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(); @@ -311,80 +255,59 @@ if (rateLimitOptions.Enabled) 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 (реестр тенантов провижинен до первого прохода). @@ -397,15 +320,12 @@ 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 получает его конструктором. @@ -414,20 +334,14 @@ SecurityOptions securityOptions = builder.Configuration .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 => { @@ -446,7 +360,6 @@ builder.Services.AddCors(options => var app = builder.Build(); -// Эндпоинт метрик /metrics (HTTP/1.1 на отдельном порту): формат Prometheus (этап 12, пакет A). DealMetricsHosting.MapDealMetrics(app); // Fail-closed для Production (Security review): дефолты кода рассчитаны на dev/тесты (rate limit выключен, @@ -467,46 +380,36 @@ if (app.Environment.IsProduction()) } } -// Стартовый лог выбранного режима файлового хранилища (приёмка 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(); @@ -516,34 +419,27 @@ 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(); @@ -559,14 +455,10 @@ 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(); @@ -639,9 +531,7 @@ static List ParseHttpAddresses(string? urlsConfig) 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]; @@ -653,11 +543,7 @@ long ResolveDefaultAiBudget(IConfiguration configuration) 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). + /// Строит опции UseForwardedHeaders из ForwardedHeadersConfig /// /// Секция ForwardedHeaders конфигурации. /// Опции для app.UseForwardedHeaders. diff --git a/src/core/Deal.Api/Services/AdminTickOrchestrator.cs b/src/core/Deal.Api/Services/AdminTickOrchestrator.cs index 9ea02df..3c2e62e 100644 --- a/src/core/Deal.Api/Services/AdminTickOrchestrator.cs +++ b/src/core/Deal.Api/Services/AdminTickOrchestrator.cs @@ -8,35 +8,15 @@ 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). +/// Оркестратор ручного тика POST /api/admin/tick. /// -/// -/// 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-тостов статистики тика. /// SSE-брокер канала тенанта (публикация reminder_due/new_card). -/// Общий воркер-гейт pump тенанта (singleton; общий с фоновым циклом Task 11). +/// Общий воркер-гейт pump тенанта. /// Логгер сбоя проверки напоминаний/pump (тик продолжается без этих веток). public sealed class AdminTickOrchestrator( StorageTickService storageTick, @@ -48,39 +28,29 @@ public sealed class AdminTickOrchestrator( 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 @@ -98,19 +68,15 @@ public sealed class AdminTickOrchestrator( 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) @@ -119,7 +85,6 @@ public sealed class AdminTickOrchestrator( } } - // (7) Строк очереди после pump (queue_len L337: total = new + ai). QueueCountsDto queueCounts = await processing.QueueCountsAsync(ct); return new AdminTickResultDto( @@ -129,15 +94,11 @@ public sealed class AdminTickOrchestrator( 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; @@ -163,10 +124,8 @@ public sealed class AdminTickOrchestrator( } } - // Счётчики результата pump → pipeline-словарь ответа (9 ключей словаря python L921; карточки в // wire не выходят — они ушли отдельными SSE new_card). // pump: Результат успешного прохода pump. - // Возвращает: Словарь счётчиков в wire-порядке прототипа. private static IReadOnlyDictionary ToPipelineWireDict(PipelinePumpResult pump) { int[] counters = diff --git a/src/core/Deal.Api/Services/AuditAppender.cs b/src/core/Deal.Api/Services/AuditAppender.cs index 5dc4e62..46076fc 100644 --- a/src/core/Deal.Api/Services/AuditAppender.cs +++ b/src/core/Deal.Api/Services/AuditAppender.cs @@ -6,24 +6,16 @@ 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, @@ -49,12 +41,11 @@ public static class AuditAppender } /// - /// Пишет событие действия оператора (актор operator, без tenantId). + /// Пишет событие действия оператора /// /// Контекст запроса (источник актора и IP). /// Тип события — константа . /// Минимальные детали события (обычно анонимный объект) или null. - /// Токен отмены. public static async Task AppendOperatorAsync( HttpContext context, string eventType, diff --git a/src/core/Deal.Api/Services/EndpointResults.cs b/src/core/Deal.Api/Services/EndpointResults.cs index 08740be..0bc95bf 100644 --- a/src/core/Deal.Api/Services/EndpointResults.cs +++ b/src/core/Deal.Api/Services/EndpointResults.cs @@ -3,12 +3,8 @@ using Deal.Api.Extensions; namespace Deal.Api.Services; /// -/// Общие ответы ошибок минимальных API: HTTP-код + {"detail":"…"} (формат прототипа, Ruling 10). +/// Общие ответы ошибок минимальных API /// -/// -/// Единая точка для всех Endpoints-файлов (Auth/Settings/Ai-проверка): сообщения деталей — -/// фиксированные строки прототипа (FastAPI HTTPException), задаются вызывающим. -/// public static class EndpointResults { /// @@ -20,7 +16,7 @@ public static class EndpointResults Results.Json(new { detail }, statusCode: StatusCodes.Status401Unauthorized); /// - /// 400-ответ: некорректный запрос (тело/значения). + /// 400-ответ: некорректный запрос /// /// Текст ошибки. /// JSON-ответ {detail} со статусом 400. @@ -28,7 +24,7 @@ public static class EndpointResults Results.Json(new { detail }, statusCode: StatusCodes.Status400BadRequest); /// - /// 404-ответ: ресурс не найден (формат прототипа, Ruling 10). + /// 404-ответ: ресурс не найден. /// /// Текст ошибки. /// JSON-ответ {detail} со статусом 404. @@ -36,7 +32,7 @@ public static class EndpointResults Results.Json(new { detail }, statusCode: StatusCodes.Status404NotFound); /// - /// 403-ответ: запрос понятен, но доступ запрещён (приостановленный тенант, план Task 7/Ruling 10(5)). + /// 403-ответ: запрос понятен, но доступ запрещён /// /// Текст ошибки. /// JSON-ответ {detail} со статусом 403. @@ -44,7 +40,7 @@ public static class EndpointResults Results.Json(new { detail }, statusCode: StatusCodes.Status403Forbidden); /// - /// 429-ответ: превышен лимит запросов/попыток входа (план Task 11, Ruling 5: LoginAttemptGuard). + /// 429-ответ: превышен лимит запросов/попыток входа. /// /// Текст ошибки. /// JSON-ответ {detail} со статусом 429. @@ -52,8 +48,7 @@ public static class EndpointResults Results.Json(new { detail }, statusCode: StatusCodes.Status429TooManyRequests); /// - /// 410-ответ: ресурс больше недоступен (Ruling 4: файл без objectKey — «Файл не сохранён - /// в объектном хранилище»; api-map §1: 410 в списке кодов ошибок прототипа). + /// 410-ответ: ресурс больше недоступен. /// /// Текст ошибки. /// JSON-ответ {detail} со статусом 410. diff --git a/src/core/Deal.Api/Services/LoginAttemptGuard.cs b/src/core/Deal.Api/Services/LoginAttemptGuard.cs index 749f5ac..7861f1e 100644 --- a/src/core/Deal.Api/Services/LoginAttemptGuard.cs +++ b/src/core/Deal.Api/Services/LoginAttemptGuard.cs @@ -4,23 +4,12 @@ using Deal.Modules.Tenants.Application.Abstractions; namespace Deal.Api.Services; /// -/// Прикладной guard неудачных попыток входа (план Task 11, Ruling 5; этап 12, пакет B — хранилище на -/// Postgres): фиксированное окно по ключу ip|login — после -/// неудач в окне минут последующие попытки ключа -/// отклоняются (429 «Слишком много попыток входа…»), успешный вход сбрасывает счётчик ключа. +/// Прикладной guard неудачных попыток входа /// -/// -/// Счётчики живут в общем хранилище (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 минут»). + /// Текст 429 при блокировке ключа. /// public const string BlockedDetail = "Слишком много попыток входа. Попробуйте через 15 минут"; @@ -43,7 +32,7 @@ public sealed class LoginAttemptGuard private readonly Func _clock; /// - /// Создаёт гвард с часами UTC-«сейчас» (боевые регистрации). + /// Создаёт гвард с часами UTC-«сейчас» /// /// Настройки rate limiting (секция RateLimit). /// Хранилище счётчиков фиксированного окна (public.rate_limit_counters). @@ -53,7 +42,7 @@ public sealed class LoginAttemptGuard } /// - /// Создаёт гвард с инъекцией часов (тесты фиксируют границы окна). + /// Создаёт гвард с инъекцией часов /// /// Настройки rate limiting (секция RateLimit). /// Хранилище счётчиков фиксированного окна (public.rate_limit_counters). @@ -75,11 +64,10 @@ public sealed class LoginAttemptGuard } /// - /// Заблокирован ли ключ ip|login (неудач в текущем окне ≥ LoginAttemptsMax). + /// Заблокирован ли ключ ip|login /// /// IP клиента (null/пустой — фолбэк unknown). /// Нормализованный логин; пустой/пробельный — блокировке не подлежит. - /// Токен отмены. /// true — следующий вход ключа отклоняется 429 до проверки учётных данных. public async Task IsBlockedAsync( string? ip, @@ -96,11 +84,10 @@ public sealed class LoginAttemptGuard } /// - /// Записывает неудачную попытку входа ключа (счётчик текущего окна). + /// Записывает неудачную попытку входа ключа /// /// IP клиента (null/пустой — фолбэк unknown). /// Нормализованный логин; пустой/пробельный — не записывается. - /// Токен отмены. public async Task RecordFailureAsync( string? ip, string? login, @@ -116,11 +103,10 @@ public sealed class LoginAttemptGuard } /// - /// Сбрасывает счётчик ключа (успешный вход, Ruling 5). + /// Сбрасывает счётчик ключа. /// /// IP клиента (null/пустой — фолбэк unknown). /// Логин успешно вошедшего (нормализованный). - /// Токен отмены. public async Task ResetAsync( string? ip, string? login, diff --git a/src/core/Deal.Api/Services/PipelinePumpGate.cs b/src/core/Deal.Api/Services/PipelinePumpGate.cs index e890be7..ba9cb88 100644 --- a/src/core/Deal.Api/Services/PipelinePumpGate.cs +++ b/src/core/Deal.Api/Services/PipelinePumpGate.cs @@ -3,32 +3,22 @@ using System.Collections.Concurrent; namespace Deal.Api.Services; /// -/// Общий воркер-гейт pump одного тенанта: admin/tick и фоновый цикл не разбирают очередь одновременно (Ruling 8, план Task 11; аналог asyncio.Lock pipeline.py L38–42). +/// Общий воркер-гейт pump одного тенанта /// -/// -/// Прототип держит один глобальный 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 — гейт свободен и вызывающий начинает разбор очереди. + /// Пытается захватить pump тенанта /// /// Тенант (Guid из реестра/сессии — один и тот же для тика и цикла). /// true — захват выполнен (после прохода обязателен парный в finally); false — pump этого тенанта уже идёт. public bool TryEnter(Guid tenantId) => _activePumps.TryAdd(tenantId, 0); /// - /// Освобождает pump тенанта после прохода (парный к успешному ). + /// Освобождает 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 6cbc599..9ec88c9 100644 --- a/src/core/Deal.Api/Services/PipelineWorkerScheduler.cs +++ b/src/core/Deal.Api/Services/PipelineWorkerScheduler.cs @@ -10,32 +10,14 @@ 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; @@ -76,7 +58,6 @@ public sealed class PipelineWorkerScheduler : IHostedService /// public Task StartAsync(CancellationToken ct) { - // Первый проход — сразу после старта (в прототипе pump выполняется до первого sleep); далее каждые 2 с. _timer = new Timer( static state => ((PipelineWorkerScheduler)state!).RunIteration(), this, @@ -111,12 +92,8 @@ public sealed class PipelineWorkerScheduler : IHostedService } /// - /// Один проход цикла: список тенантов реестра и pump каждого (no-op, если проход уже идёт). + /// Один проход цикла /// - /// Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются) и - /// ручного вызова при отладке; таймер вызывает этот же метод. Ошибки и отмена токена наружу не - /// выбрасываются: сбои логируются (цикл живёт), отмена по токену останова завершает проход штатно. - /// Токен отмены прохода (в проде — токен остановки хоста). /// Задача прохода (завершается без исключений). public Task RunCycleAsync(CancellationToken ct) { @@ -181,8 +158,6 @@ public sealed class PipelineWorkerScheduler : IHostedService { tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); - // Общий гейт pump тенанта (Ruling 8): занят другим воркером (ручной тик/прошлый проход) — - // пропускаем тенанта, очередь остаётся до следующего срабатывания (прототип L901–902). if (!_pumpGate.TryEnter(tenant.Id)) { return; @@ -194,7 +169,6 @@ public sealed class PipelineWorkerScheduler : IHostedService 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); diff --git a/src/core/Deal.Api/Services/RatesRefreshScheduler.cs b/src/core/Deal.Api/Services/RatesRefreshScheduler.cs index bf1c58e..2f27900 100644 --- a/src/core/Deal.Api/Services/RatesRefreshScheduler.cs +++ b/src/core/Deal.Api/Services/RatesRefreshScheduler.cs @@ -3,19 +3,8 @@ 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; @@ -36,7 +25,7 @@ public sealed class RatesRefreshScheduler } /// - /// Планирует фоновый RefreshAsync (no-op, если обновление уже в процессе). + /// Планирует фоновый RefreshAsync /// public void Schedule() { diff --git a/src/core/Deal.Api/Services/SessionCookieWriter.cs b/src/core/Deal.Api/Services/SessionCookieWriter.cs index 9146a8c..7babf74 100644 --- a/src/core/Deal.Api/Services/SessionCookieWriter.cs +++ b/src/core/Deal.Api/Services/SessionCookieWriter.cs @@ -4,14 +4,8 @@ using CookieOptions = Deal.Api.Configuration.CookieOptions; namespace Deal.Api.Services; /// -/// Единая запись httpOnly-куки сессии тенант-пользователя (login/change-password/impersonation). +/// Единая запись httpOnly-куки сессии тенант-пользователя /// -/// -/// Кука — deal_session (имя из ): httpOnly, SameSite=Lax, Path=/, -/// MaxAge = дни сессии, Secure — из конфигурации (Ruling 6). Вынесено из AuthEndpoints, чтобы -/// impersonation выставлял ту же куку тем же способом (иначе браузер оператора не получает tenant-сессию: -/// JS не может записать httpOnly-куку). -/// public static class SessionCookieWriter { /// diff --git a/src/core/Deal.Api/Services/StoreBackedFixedWindowRateLimiter.cs b/src/core/Deal.Api/Services/StoreBackedFixedWindowRateLimiter.cs index cde6cb3..e25f5eb 100644 --- a/src/core/Deal.Api/Services/StoreBackedFixedWindowRateLimiter.cs +++ b/src/core/Deal.Api/Services/StoreBackedFixedWindowRateLimiter.cs @@ -4,23 +4,8 @@ using Deal.Modules.Tenants.Application.Abstractions; namespace Deal.Api.Services; /// -/// RateLimiter фиксированного окна с состоянием в Postgres (этап 12, пакет B): лимиты переживают -/// несколько инстансов core (ранее — память одного процесса, Ruling 5). +/// RateLimiter фиксированного окна с состоянием в Postgres /// -/// -/// -/// Один экземпляр обслуживает одну партицию (ключ) с одним порогом — создаётся лениво фабрикой -/// RateLimitPartition.Get в и кешируется -/// PartitionedRateLimiter до истечения (партиции не растут бесконечно). -/// Счётчик живёт в : каждый acquire — атомарный инкремент окна; -/// при смене выровненного окна счётчик сбрасывается. Для сопоставимости с прежним in-memory-лимитером -/// окно выровнено по границам длины окна, разрешено ровно permitLimit запросов. -/// -/// -/// Хранилище — scoped EF-адаптер, поэтому на каждое приобретение создаётся собственный DI-scope -/// через : держать scoped-контекст в кешируемом лимитере нельзя. -/// -/// public sealed class StoreBackedFixedWindowRateLimiter : RateLimiter { private readonly IServiceScopeFactory _scopeFactory; @@ -63,8 +48,6 @@ public sealed class StoreBackedFixedWindowRateLimiter : RateLimiter } /// - /// Партиция неактивна столько же, сколько её окно: устаревшие лимитеры вытесняются менеджером - /// партиций и создаются заново при необходимости (состояние — в Postgres, потери нет). public override TimeSpan? IdleDuration => _idleDuration; /// diff --git a/src/core/Deal.Api/Services/TelegramBackfillScheduler.cs b/src/core/Deal.Api/Services/TelegramBackfillScheduler.cs index db32b1d..97cbe71 100644 --- a/src/core/Deal.Api/Services/TelegramBackfillScheduler.cs +++ b/src/core/Deal.Api/Services/TelegramBackfillScheduler.cs @@ -3,17 +3,8 @@ 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( @@ -26,10 +17,8 @@ public sealed class TelegramBackfillScheduler( 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) @@ -42,7 +31,7 @@ public sealed class TelegramBackfillScheduler( } /// - /// Запускает фоновый первый разбор одного канала (первое включение мониторинга, python L546). + /// Запускает фоновый первый разбор одного канала. /// /// Id диалога, включённого впервые (Backfilled=false). public void ScheduleFirstBackfill(string dialogId) @@ -51,7 +40,7 @@ public sealed class TelegramBackfillScheduler( } /// - /// Запускает фоновый первый разбор списка каналов (set_monitor_all L566: неразобранные при включении). + /// Запускает фоновый первый разбор списка каналов. /// /// Id неразобранных диалогов (пусто — no-op). public void ScheduleFirstBackfills(IReadOnlyCollection dialogIds) @@ -128,7 +117,6 @@ public sealed class TelegramBackfillScheduler( } 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 f4726a8..912f0d6 100644 --- a/src/core/Deal.Api/Telegram/IngressRateLimitInterceptor.cs +++ b/src/core/Deal.Api/Telegram/IngressRateLimitInterceptor.cs @@ -7,21 +7,8 @@ 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) до вызова метода сервиса. +/// Серверный интерцептор rate limit gRPC-ингресса /// -/// -/// Окно считает общий (регистрируется в DI singleton'ом -/// рядом с AddGrpc): экземпляры интерцептора создаются фреймворком, но партиции окон живут в одном -/// лимитере — иначе каждая регистрация/вызов получил бы собственное окно и лимит не работал бы. -/// Счётчики партиций — в Postgres через . Лимитер -/// регистрируется и интерцептор добавляется только при RateLimit:Enabled=true (dev — plaintext-поток -/// без лимита, как HTTP-политики, Ruling 5). -/// public sealed class IngressRateLimitInterceptor : Interceptor { // Ключ партиции вызовов без metadata tenant-id (общий «мусорный» бакет — сервис всё равно отклонит). @@ -36,7 +23,7 @@ public sealed class IngressRateLimitInterceptor : Interceptor private readonly PartitionedRateLimiter _limiter; /// - /// Создаёт интерцептор с общим лимитером ингресса (см. CreateLimiter). + /// Создаёт интерцептор с общим лимитером ингресса /// /// Singleton-лимитер, зарегистрированный хостом. public IngressRateLimitInterceptor(PartitionedRateLimiter limiter) @@ -45,8 +32,7 @@ public sealed class IngressRateLimitInterceptor : Interceptor } /// - /// Создаёт лимитер ингресса: фиксированное окно в минуту, партиция на каждый tenant-id, - /// счётчики — в общем хранилище Postgres. + /// Создаёт лимитер ингресса /// /// Фабрика scope: store-backed лимитер резолвит хранилище на каждое приобретение. /// Разрешено вызовов на тенанта в минуту (RateLimit:GrpcIngressPerMinute). @@ -69,11 +55,10 @@ public sealed class IngressRateLimitInterceptor : Interceptor } /// - /// Проверка лимита для unary-RPC: health-методы пропускаются, остальные получают разрешение - /// партиции tenant-id; исчерпание окна — RESOURCE_EXHAUSTED. + /// Проверка лимита для unary-RPC /// - /// Тип запроса gRPC. - /// Тип ответа gRPC. + /// Тип запроса gRPC. + /// Тип ответа gRPC. /// Тело запроса. /// Контекст вызова (metadata tenant-id/service-token). /// Следующий обработчик в цепочке. diff --git a/src/core/Deal.Api/Telegram/IngressServiceTokenInterceptor.cs b/src/core/Deal.Api/Telegram/IngressServiceTokenInterceptor.cs index afcc50b..4ee09f8 100644 --- a/src/core/Deal.Api/Telegram/IngressServiceTokenInterceptor.cs +++ b/src/core/Deal.Api/Telegram/IngressServiceTokenInterceptor.cs @@ -4,40 +4,28 @@ 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). +/// Серверный интерцептор service-token gRPC-ингресса. /// public sealed class IngressServiceTokenInterceptor : Interceptor { /// - /// Ключ gRPC-metadata с токеном сервиса (контракт — README src/contracts). + /// Ключ gRPC-metadata с токеном сервиса /// 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) @@ -46,11 +34,10 @@ public sealed class IngressServiceTokenInterceptor : Interceptor } /// - /// Проверка токена для unary-RPC: сначала пропускаются методы gRPC-health (безопасны), затем - /// сверяется metadata «service-token» с ожидаемым значением; несовпадение — UNAUTHENTICATED. + /// Проверка токена для unary-RPC /// - /// Тип запроса gRPC. - /// Тип ответа gRPC. + /// Тип запроса gRPC. + /// Тип ответа gRPC. /// Тело запроса. /// Контекст вызова (metadata из заголовков). /// Следующий обработчик в цепочке. diff --git a/src/core/Deal.Api/Telegram/RpcCallLoggingInterceptor.cs b/src/core/Deal.Api/Telegram/RpcCallLoggingInterceptor.cs index 9ff15fd..94cc949 100644 --- a/src/core/Deal.Api/Telegram/RpcCallLoggingInterceptor.cs +++ b/src/core/Deal.Api/Telegram/RpcCallLoggingInterceptor.cs @@ -5,18 +5,8 @@ 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. +/// Access-лог RPC 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). @@ -27,7 +17,7 @@ public sealed class RpcCallLoggingInterceptor : Interceptor /// /// Создаёт интерцептор access-лога gRPC-вызовов. /// - /// Логгер (Serilog, Ruling 7). + /// Логгер. public RpcCallLoggingInterceptor(ILogger logger) { ArgumentNullException.ThrowIfNull(logger); @@ -35,10 +25,10 @@ public sealed class RpcCallLoggingInterceptor : Interceptor } /// - /// Логирует unary-RPC: время вызова и итоговый gRPC-статус (успех либо статус исключения). + /// Логирует unary-RPC /// - /// Тип запроса gRPC. - /// Тип ответа gRPC. + /// Тип запроса gRPC. + /// Тип ответа gRPC. /// Тело запроса. /// Контекст вызова (метод — context.Method). /// Следующий обработчик в цепочке. diff --git a/src/core/Deal.Api/Telegram/TelegramIngressService.cs b/src/core/Deal.Api/Telegram/TelegramIngressService.cs index d1a72af..4a42cd7 100644 --- a/src/core/Deal.Api/Telegram/TelegramIngressService.cs +++ b/src/core/Deal.Api/Telegram/TelegramIngressService.cs @@ -16,49 +16,24 @@ 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/тосты на переходах фаз. +/// gRPC-сервер входящего потока telegram-service → ядро. /// -/// -/// 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). + /// Ключ gRPC-metadata с id тенанта. /// 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 фронта). @@ -70,7 +45,6 @@ public sealed class TelegramIngressService( // Деталь отказа: 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, @@ -78,14 +52,8 @@ public sealed class TelegramIngressService( }; /// - /// PushMessage — сообщение диалога в очередь пайплайна тенанта + превью (PushMessageRequest, Ruling 7). + /// PushMessage — сообщение диалога в очередь пайплайна тенанта + превью. /// - /// Дубль 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 — уже было в очереди. @@ -140,7 +108,6 @@ public sealed class TelegramIngressService( catch (Exception exception) { // Сбой схемы/БД тенанта (напр. схема ещё не провижинена): RPC не падает — reply not-accepted - // (план Task 12), упущенное сообщение при необходимости догонит realtime-sweep сервиса. logger.LogWarning(exception, "Аудит: PushMessage {TenantId} → не принято (сбой схемы/БД)", tenant.Id); return new PushMessageReply(); } @@ -151,15 +118,8 @@ public sealed class TelegramIngressService( } /// - /// SyncDialogs — синхронизация каталога диалогов аккаунта (Ruling 7, L386–390). + /// SyncDialogs — синхронизация каталога диалогов аккаунта. /// - /// - /// Модуль Deal.Modules.Telegram (план Task 13) применяет entries к таблице Dialogs - /// (DialogsService.SyncFromTelegram: upsert + удаление отсутствующих; авто-мониторинг новых — по - /// настройке autoMonitorNew). Ответ несёт актуальный список monitored id — по нему telegram-service - /// держит своё зеркало мониторинга в памяти (обновляется ответом SyncDialogs и командой SetMonitor, - /// Ruling 7) и фильтрует события realtime. - /// /// Актуальный каталог диалогов (entries). /// Контекст вызова. /// monitored_ids — диалоги с включённым мониторингом после применения каталога. @@ -215,14 +175,9 @@ public sealed class TelegramIngressService( } /// - /// ReportStatus — статус аккаунта в KV + SSE system_status/тосты на переходах фаз (Ruling 7). + /// ReportStatus — статус аккаунта в KV + SSE system_status/тосты на переходах фаз. /// - /// 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) @@ -274,7 +229,6 @@ public sealed class TelegramIngressService( // Разрешает тенанта запроса: 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) @@ -312,9 +266,7 @@ public sealed class TelegramIngressService( } // Пишет превью принятого сообщения (TgMessages + «последнее сообщение» каталога) без влияния на приём. - // Ruling 7: PushMessage → EnqueueAsync + превью. Сбой превью (нет таблиц/строки каталога и т.п.) // не роняет RPC и не меняет accepted — очередь уже записана, упущенное догонит realtime-sweep (как - // python: обновление last_text после enqueue в том же обработчике, ошибка не отменяет приём). // tenantScope: Scope тенанта (TenantDbContext построен на схеме тенанта). // tenant: Тенант канала (для лога аудита). // request: Сообщение PushMessage. @@ -373,7 +325,6 @@ public sealed class TelegramIngressService( } } - // Публикует SSE-тост в канал тенанта (без подписчиков — no-op, Ruling 5). // tenantId: Тенант-получатель. // text: Текст тоста. // icon: Иконка тоста (набор Icon.vue фронта). diff --git a/src/core/Deal.Api/Telegram/TelegramKeysMaskedDto.cs b/src/core/Deal.Api/Telegram/TelegramKeysMaskedDto.cs index 9eb78cc..698bf91 100644 --- a/src/core/Deal.Api/Telegram/TelegramKeysMaskedDto.cs +++ b/src/core/Deal.Api/Telegram/TelegramKeysMaskedDto.cs @@ -1,12 +1,8 @@ namespace Deal.Api.Telegram; /// -/// Маскированная форма глобальных ключей Telegram для операторской ручки (секрет не раскрывается). +/// Маскированная форма глобальных ключей Telegram для операторской ручки /// -/// -/// apiId — публичный идентификатор приложения (не секрет), отдаётся открытым; apiHash — всегда маска -/// (пусто/«x…»/«1234…5678»). keysSet — оба ключа заданы оператором. -/// /// Идентификатор приложения Telegram (открыт; пусто — не задан). /// Маска api_hash (никогда не открытый секрет). /// True — оба ключа заданы оператором. diff --git a/src/core/Deal.Api/Telegram/TelegramKeysService.cs b/src/core/Deal.Api/Telegram/TelegramKeysService.cs index d5cb6dd..3e61769 100644 --- a/src/core/Deal.Api/Telegram/TelegramKeysService.cs +++ b/src/core/Deal.Api/Telegram/TelegramKeysService.cs @@ -5,35 +5,25 @@ using Deal.Modules.Settings.Application.Models; namespace Deal.Api.Telegram; /// -/// Читает, расшифровывает и сохраняет ключи приложения Telegram из глобальной (системной) -/// настройки оператора (ТЗ §4.1/§8.1). +/// Читает, расшифровывает и сохраняет ключи приложения Telegram из глобальной /// -/// -/// Источник ключей — операторский уровень: 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 (только цифры). + /// Минимальная длина api_id приложения Telegram /// public const int ApiIdMinDigits = 5; /// - /// Максимальная длина api_id приложения Telegram (только цифры). + /// Максимальная длина 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) + терпимость регистра. @@ -44,9 +34,8 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe }; /// - /// Читает текущие ключи приложения: apiId (открыт в БД) + расшифрованный apiHash. + /// Читает текущие ключи приложения /// - /// Токен отмены. /// Снимок ключей (пустые — настройка не задана/повреждена). public async Task GetAsync(CancellationToken ct) { @@ -72,9 +61,8 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe } /// - /// Маскированная форма ключей для операторской ручки: apiId открыт, apiHash — маска. + /// Маскированная форма ключей для операторской ручки /// - /// Токен отмены. /// DTO с флагом keysSet (оба ключа заданы) и маской apiHash. public async Task GetMaskedAsync(CancellationToken ct) { @@ -86,11 +74,10 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe } /// - /// Сохраняет глобальные ключи приложения: apiId открытым, apiHash — зашифрованным. + /// Сохраняет глобальные ключи приложения /// /// api_id приложения (5..9 цифр). /// api_hash приложения (непустой секрет). - /// Токен отмены. /// Значения не прошли валидацию (см. /). public async Task SaveAsync( string apiId, @@ -115,7 +102,7 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe } /// - /// Валиден ли api_id: только ASCII-цифры, длина 5..9 (ТЗ §4.1/§8.1). + /// Валиден ли api_id /// /// Проверяемое значение (уже без пробелов). /// True — значение допустимо. @@ -123,7 +110,7 @@ public sealed class TelegramKeysService(IGlobalSettingsStore store, ISecretCiphe apiId.Length is >= ApiIdMinDigits and <= ApiIdMaxDigits && apiId.All(char.IsAsciiDigit); /// - /// Валиден ли api_hash: непустой, без маски (…), без префикса enc:. + /// Валиден ли api_hash /// /// Проверяемое значение (уже без пробелов). /// True — значение допустимо к шифрованию и сохранению. diff --git a/src/core/Deal.Api/Telegram/TelegramKeysValue.cs b/src/core/Deal.Api/Telegram/TelegramKeysValue.cs index 0e0edfc..19f39ff 100644 --- a/src/core/Deal.Api/Telegram/TelegramKeysValue.cs +++ b/src/core/Deal.Api/Telegram/TelegramKeysValue.cs @@ -2,12 +2,7 @@ namespace Deal.Api.Telegram; /// /// Сохранённые глобальные ключи приложения Telegram в настройке telegramKeys -/// (json: {"apiId":…, "apiHash":…}). /// -/// -/// Внутренняя (БД) форма значения: apiId — открытым (не секрет), apiHash — зашифрованным -/// (префикс enc:, Ruling 2). Наружу в открытом виде не отдаётся. -/// /// Идентификатор приложения Telegram (только цифры, длина 5..9). /// Секрет приложения: enc:-значение. public sealed record TelegramKeysValue(string ApiId, string ApiHash); diff --git a/src/core/Deal.Api/Telegram/TgKeysSnapshot.cs b/src/core/Deal.Api/Telegram/TgKeysSnapshot.cs index 12ac3b0..dfdcdc2 100644 --- a/src/core/Deal.Api/Telegram/TgKeysSnapshot.cs +++ b/src/core/Deal.Api/Telegram/TgKeysSnapshot.cs @@ -1,20 +1,14 @@ namespace Deal.Api.Telegram; /// -/// Снимок глобальных ключей приложения Telegram для команд входа (ТЗ §4.1/§8.1, Ruling 3). +/// Снимок глобальных ключей приложения Telegram для команд входа. /// -/// -/// — уже расшифрованный секрет (глобальная настройка 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 b6c70ec..b7871bb 100644 --- a/src/core/Deal.Api/Telegram/TgReportedStatus.cs +++ b/src/core/Deal.Api/Telegram/TgReportedStatus.cs @@ -1,18 +1,12 @@ namespace Deal.Api.Telegram; /// -/// Снимок статуса Telegram-аккаунта из RPC ReportStatus (план Task 12; Ruling 7). +/// Снимок статуса Telegram-аккаунта из RPC ReportStatus. /// -/// -/// Форма 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). + /// Фаза входа: idle|phone|code|password|qr|ready /// public string Phase { get; init; } = string.Empty; @@ -27,12 +21,12 @@ public sealed record TgReportedStatus public bool Listener { get; init; } /// - /// Текст ошибки (null — ошибки нет). + /// Текст ошибки /// public string? Error { get; init; } /// - /// URL QR-входа при phase == "qr" (иначе null). + /// URL QR-входа при phase == "qr" /// public string? QrUrl { get; init; } } diff --git a/src/core/Deal.Api/Telegram/TgStatusService.cs b/src/core/Deal.Api/Telegram/TgStatusService.cs index 742bb80..43c6086 100644 --- a/src/core/Deal.Api/Telegram/TgStatusService.cs +++ b/src/core/Deal.Api/Telegram/TgStatusService.cs @@ -9,19 +9,8 @@ using Deal.Modules.Telegram.Application.Models; namespace Deal.Api.Telegram; /// -/// Сборка статуса вкладки Telegram — GET /api/tg/status (Ruling 8, api-map §4.9 L357–359). +/// Сборка статуса вкладки Telegram — GET /api/tg/status. /// -/// -/// Форма 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). @@ -32,7 +21,6 @@ public sealed class TgStatusService( ISettingsStore settings, TelegramKeysService keys) { - // Фаза idle-формы (аккаунт не подключён/сервис недоступен — Ruling 8). private const string IdlePhase = "idle"; // Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра. @@ -43,9 +31,8 @@ public sealed class TgStatusService( }; /// - /// Форма GET /api/tg/status текущего тенанта (поля §4.9). + /// Форма GET /api/tg/status текущего тенанта /// - /// Токен отмены. /// Полный статус вкладки Telegram. public async Task GetAsync(CancellationToken ct) { @@ -65,7 +52,6 @@ public sealed class TgStatusService( QrUrl: live.QrUrl); } - // Живой статус из гейта; сбой (сервис недоступен/нет сессии) → idle-форма (Ruling 8). // ct: Токен отмены. // Возвращает: Статус гейта либо idle-поля. private async Task ReadLiveAsync(CancellationToken ct) @@ -76,13 +62,10 @@ public sealed class TgStatusService( } 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) diff --git a/src/core/Deal.Contracts/ContractsMarker.cs b/src/core/Deal.Contracts/ContractsMarker.cs index ca83776..4d74d4a 100644 --- a/src/core/Deal.Contracts/ContractsMarker.cs +++ b/src/core/Deal.Contracts/ContractsMarker.cs @@ -1,7 +1,7 @@ namespace Deal.Contracts; /// -/// Маркер слоя Contracts: используется для DI-сканирования и тестов. +/// Маркер слоя Contracts /// public sealed class ContractsMarker { diff --git a/src/core/Deal.Contracts/Integrations/Abstractions/IAiClassifier.cs b/src/core/Deal.Contracts/Integrations/Abstractions/IAiClassifier.cs index 8c61e00..19ea94d 100644 --- a/src/core/Deal.Contracts/Integrations/Abstractions/IAiClassifier.cs +++ b/src/core/Deal.Contracts/Integrations/Abstractions/IAiClassifier.cs @@ -3,47 +3,21 @@ using Deal.Contracts.Integrations.Models; namespace Deal.Contracts.Integrations.Abstractions; /// -/// Порт ИИ-классификатора входящих сообщений (Ruling 5, план Task 6 L370–388). +/// Порт ИИ-классификатора входящих сообщений. /// -/// -/// Порт объявлен в Contracts, потому что контракт потребляет модуль Pipeline (воркер) и в будущем — -/// переклассификация из Api. На этапе 4 реализация — детерминированная Deal.Infrastructure.Integrations.Services.LocalAiClassifier -/// (Ruling 5): фильтр всегда пропускает (pass+skipped), классификация — локальный разбор ядра -/// MessageParseCore модуля Pipeline (маркерная гипотеза типа, is_vacancy_known=false, board=null — -/// «смысловые колонки до ИИ не назначаем», pipeline.py L954–958); на этапе 6 реализация заменяется -/// gRPC-клиентом ai-service с тем же контрактом. Методы повторяют прототип 1:1: backend/app/services/ai.py -/// (filter_incoming L188–198, classify L218–258). Выключатели aiEnabled/aiFilterEnabled порт не читает — -/// ветки выключателей отрабатывает воркер модуля Pipeline (Ruling 8), как в прототипе L1081–1106. -/// public interface IAiClassifier { /// - /// ИИ-фильтр входящих (этап 2 прототипа, ai.py filter_incoming L188–198; Ruling 5). + /// ИИ-фильтр входящих. /// - /// - /// Локальная реализация этапа 4 не умеет отсеивать спам/рекламу/служебное и всегда отвечает - /// {pass:true, skipped:true} — ветка «ИИ недоступен» прототипа L1103–1106; отсевы - /// spam_ai/filter_ai станут достижимы этапом 6 (реальный фильтр). Воркер сам решает, вызывать ли - /// фильтр (force → пропуск, aiEnabled=false → локальный разбор без порта). - /// /// Текст сообщения (уже обрезанный/нормализованный вызывающим). - /// Токен отмены. - /// Решение фильтра: pass/reason/skipped (формы прототипа L194–198). + /// Решение фильтра: pass/reason/skipped. public Task FilterAsync(string text, CancellationToken ct); /// - /// Полный разбор карточки — структура классификации ТЗ §5 L104–106 и ai.py classify L218–258 (Ruling 5). + /// Полный разбор карточки — структура классификации ТЗ §5 и classify. /// - /// - /// Возвращает структурированную карточку: заголовок, поля блока «О заявке» (company/format/task/ - /// requirements/plus/conditions), стек, бюджет с валютой, контакты, признак вакансии и назначенную - /// колонку. Этап 4 (LocalAiClassifier): локальный разбор LocalFieldsParser — блок «О заявке» - /// заполняет только legacy-суть , is_vacancy — маркерная гипотеза - /// (is_vacancy_known=false), board=null (карточка пойдёт в «Неразобранное», Ruling 5 L954–958). - /// Сбой/пустой результат разбора воркер не различает — классификатор детерминирован и не падает. - /// /// Текст сообщения (как в очереди, уже обрезанный до 6000 при приёме). - /// Токен отмены. /// Структурированный разбор карточки (поля карточки + решение о типе/колонке). public Task ClassifyAsync(string text, CancellationToken ct); } diff --git a/src/core/Deal.Contracts/Integrations/Abstractions/IAiTools.cs b/src/core/Deal.Contracts/Integrations/Abstractions/IAiTools.cs index 1c685ce..ee0ff70 100644 --- a/src/core/Deal.Contracts/Integrations/Abstractions/IAiTools.cs +++ b/src/core/Deal.Contracts/Integrations/Abstractions/IAiTools.cs @@ -3,45 +3,24 @@ using Deal.Contracts.Integrations.Models; namespace Deal.Contracts.Integrations.Abstractions; /// -/// Порт ИИ-инструментов Discovery и генерации ключевых слов (Ruling 9, план Task 15/18/19). +/// Порт ИИ-инструментов Discovery и генерации ключевых слов. /// -/// -/// Порт объявлен в Contracts, потому что контракт потребляет модуль Discovery (воркер/эндпоинты) на этапе 6; -/// реализация — gRPC-адаптер ai-service GrpcAiTools, регистрируемый при Services:Ai:UseLocal=false -/// (Ruling 6). Локальная реализация LocalAiTools (UseLocal=true) методы не поддерживает: Discovery-воркер -/// при aiEnabled=false/сбое сам выбирает эвристику (python discovery_eval L186–194), generate-keywords-эндпоинт -/// (Task 19) отдаёт мягкую ошибку {keywords: [], error}. Методы повторяют прототип 1:1: -/// backend/app/routers/discovery_routes.py L189–211 (generate-keywords) и -/// backend/app/services/discovery_eval.py L50–54/L153–194 (оценка fit). Выключатели aiEnabled/aiFilterEnabled -/// порт не читает — ветки выключателей отрабатывает вызывающий (воркер Discovery, Ruling 10). -/// public interface IAiTools { /// - /// Генерация поисковых ключевых слов discovery-задачи по описанию ниши (discovery_routes L36–47). + /// Генерация поисковых ключевых слов discovery-задачи по описанию ниши. /// - /// - /// Очистку (_clean_keywords: ≤30, ≤60 символов, дедуп) и мягкую ошибку {keywords: [], error} для UI - /// делает вызывающий (эндпоинт Task 19, Ruling 11); адаптер возвращает сырые ключи модели и ok=false при - /// недоступности сервиса/провайдера (без исключений наружу — мягкая форма Ruling 11). - /// - /// Описание ниши/задачи (вызывающий режет до 4000, как discovery_routes L29). - /// Токен отмены. + /// Описание ниши/задачи. /// Результат: ok + ключевые слова (пусто — модель не выделила ключей или сервис недоступен) + текст ошибки. public Task GenerateKeywordsAsync(string description, CancellationToken ct); /// - /// Оценка соответствия сообщения задаче поиска (промпт discovery_eval L50–54; Ruling 5/10). + /// Оценка соответствия сообщения задаче поиска. /// - /// - /// Вызывающий (воркер Discovery, Task 18) зовёт только при aiEnabled и при сбое/исключении сам падает в - /// эвристику по ключам (python evaluate_message L186–194) — порт ошибки пробрасывает наружу. - /// /// Текст сообщения кандидата (вызывающий режет до 4000). - /// Описание задачи поиска (discovery_eval L51). - /// Ключи задачи (discovery_eval L52). - /// Токен отмены. - /// Решение fit + краткая причина (потолок причины 200, как _AI_REASON_LIMIT L43). + /// Описание задачи поиска. + /// Ключи задачи. + /// Решение fit + краткая причина. public Task EvaluateFitAsync( string text, string description, diff --git a/src/core/Deal.Contracts/Integrations/Abstractions/IColumnSuggester.cs b/src/core/Deal.Contracts/Integrations/Abstractions/IColumnSuggester.cs index c57b2c1..ac27198 100644 --- a/src/core/Deal.Contracts/Integrations/Abstractions/IColumnSuggester.cs +++ b/src/core/Deal.Contracts/Integrations/Abstractions/IColumnSuggester.cs @@ -3,43 +3,19 @@ using Deal.Contracts.Integrations.Models; namespace Deal.Contracts.Integrations.Abstractions; /// -/// Порт ИИ-предложений колонок и ключей — план Task 14 (L462–491), Ruling 3. +/// Порт ИИ-предложений колонок и ключей — план. /// -/// -/// Порт объявлен в Contracts, потому что контракт потребляют модуль Kanban и Api. На этапе 3 -/// реализация — детерминированная эвристика Deal.Infrastructure.Integrations.Services.LocalColumnSuggester -/// (Ruling 3): чистое ядро SuggestHeuristics в модуле Kanban (частотные слова-темы по source_msg -/// «Неразобранного») + тонкий адаптер, читающий карточки через ICardStore и создающий доски -/// suggested=true c note. На этапе 6 реализация заменяется gRPC-клиентом ai-service с тем же контрактом. -/// Методы повторяют прототип 1:1: backend/app/services/suggest.py (suggest_from_inbox L76–163, -/// suggest_domain_keywords L166–193). Мягкие ошибки — результат с Ok=false и текстом Reason -/// (эндпоинты отвечают HTTP 200, api-map L120–121): HTTP-статусы кодам 4xx/5xx не мапятся. -/// public interface IColumnSuggester { /// - /// Предлагает тематические колонки-доски по «Неразобранному» (suggest_from_inbox; ручной запуск кнопкой фронта). + /// Предлагает тематические колонки-доски по «Неразобранному» /// - /// - /// Анализ идёт по карточкам col='inbox' с непустым source_msg: эвристика группирует их по - /// повторяющимся словам-темам (группа ≥2 карточек, до 4 колонок), создаёт доски suggested=true - /// (RulesJson {mode:"any", keywords:[…]}, note-обоснование) и раскладывает карточки (is_new=TRUE, - /// prev_col='inbox', matchHits по правилам доски, Ruling 2). Фоновый автоцикл НЕ заводится (Ruling 3): - /// вызов — только из эндпоинта POST /api/ai/suggest-columns. - /// - /// Токен отмены. /// {ok:true, created:N} либо {ok:false, reason} (+ cooldown при кулдауне). public Task SuggestColumnsAsync(CancellationToken ct); /// - /// Предлагает общие слова-маркеры сферы по карточкам тенанта (suggest_domain_keywords). + /// Предлагает общие слова-маркеры сферы по карточкам тенанта /// - /// - /// Выборка — карточки вне trash/archive с непустым source_msg (свежие 40); маркеры — повторяющиеся - /// в выборке слова (частотные, ≤60 шт., ≤40 симв.). Пользователь правит список в настройках - /// «Сфера и ключи» и сохраняет в domainKeywords (SettingsView.vue → store.js suggestDomainKeywords). - /// - /// Токен отмены. /// {ok:true, keywords:[…]} либо {ok:false, reason}. public Task SuggestKeywordsAsync(CancellationToken ct); } diff --git a/src/core/Deal.Contracts/Integrations/Abstractions/IFileStorage.cs b/src/core/Deal.Contracts/Integrations/Abstractions/IFileStorage.cs index 06c3fc7..279e0d0 100644 --- a/src/core/Deal.Contracts/Integrations/Abstractions/IFileStorage.cs +++ b/src/core/Deal.Contracts/Integrations/Abstractions/IFileStorage.cs @@ -3,41 +3,16 @@ using Deal.Contracts.Integrations.Models; namespace Deal.Contracts.Integrations.Abstractions; /// -/// Порт файлового хранилища вложений (Ruling 4, план Task 6; 1:1 backend/app/services/object_store.py L61–107). +/// Порт файлового хранилища вложений. /// -/// -/// Порт объявлен в Contracts, потому что контракт потребляет модуль Kanban (CardsService.Files) и в будущем — -/// другие владельцы вложений. Реализации — адаптеры Deal.Infrastructure.Integrations.Storage -/// (Task 6): LocalFileStorage (dev/curl/unit по умолчанию: каталог data/attachments под ContentRoot, -/// fallback object_store.py L54–79) и MinioFileStorage (MinIO S3-клиент; включается, только когда -/// сконфигурирован MinIO — Ruling 4, требование «заглушка-адаптер, если MinIO недоступен»). objectKey — -/// opaque-строка формата projects/<cardId>/<unixMs>_<safeName> (Ruling 4, object_store.py L65): -/// его строит владелец (CardsService), хранилище ключ не интерпретирует, кроме безопасного -/// разрешения в путь (LocalFileStorage защищает от выхода за root). Единственный бакет и отсутствие -/// tenant-префикса — как в прототипе; мульти-аренда объектного хранилища — этап 7 SaaS. -/// -/// Дескриптор объекта — (Ruling T6): возвращается методом -/// (MinIO StatObject / FileInfo локального файла) и задействуется download-эндпоинтом проектных файлов -/// (Task 9): Content-Length/Content-Type ответа — из него, а отсутствие объекта (null) — 404 -/// «Файл не найден в MinIO» (1:1 с projects_routes.py L170–173, где исключение get → 404). -/// -/// public interface IFileStorage { /// - /// Сохраняет объект (put object_store.py L61–79) и возвращает objectKey. + /// Сохраняет объект и возвращает objectKey. /// - /// - /// MinioFileStorage при первом вызове лениво проверяет/создаёт бакет (object_store.py L26–51); содержимое - /// читается С ПОЗИЦИИ 0: перемотаемый поток адаптер сбрасывает в начало, неперемотаемый читается с текущей - /// позиции (Ruling T6; адаптер сам узнаёт длину — сигнатура без size, как Ruling 4). - /// ContentType хранится у объекта (MinIO); LocalFileStorage пишет только байты (как прототип). - /// Пустой/пробельный contentType нормализуется в application/octet-stream (object_store.py L72). - /// - /// Ключ объекта (opaque; формат projects/<card>/<ms>_<name>). + /// Ключ объекта (opaque; формат projects/<card>/<ms>_<name>). /// Поток содержимого файла. /// MIME-тип (например, image/png); может быть пустым. - /// Токен отмены. /// Сохранённый objectKey (как передан). public Task PutAsync( string objectKey, @@ -46,39 +21,22 @@ public interface IFileStorage CancellationToken ct); /// - /// Возвращает содержимое объекта потоком (get object_store.py L82–93). + /// Возвращает содержимое объекта потоком. /// /// Ключ объекта. - /// Токен отмены. /// Поток для чтения (позиция 0; владелец потока — вызывающий, он же закрывает) либо null, если объекта нет. public Task GetAsync(string objectKey, CancellationToken ct); /// - /// Возвращает дескриптор объекта — размер и MIME-тип (stat: MinIO StatObject / FileInfo локального файла). + /// Возвращает дескриптор объекта — размер и MIME-тип /// - /// - /// Контракт снимает Ruling T6 (FileMeta остаётся в контракте и задействуется): download-эндпоинт файлов - /// проектных карточек (Task 9) берёт из дескриптора Content-Length и Content-Type ответа и различает - /// «объекта нет» (null → 404 «Файл не найден в MinIO») до открытия потока. MinIO хранит contentType - /// объекта (кладётся при put) — MinioFileStorage отдаёт его как есть; LocalFileStorage contentType не - /// хранит (пишутся только байты, как прототип) — в дескрипторе MIME пуст, download отвечает фиксированным - /// application/octet-stream (1:1 projects_routes.py L174–179). - /// /// Ключ объекта. - /// Токен отмены. /// Дескриптор (Key/Size/ContentType) либо null — объекта нет. public Task StatAsync(string objectKey, CancellationToken ct); /// - /// Удаляет объект (remove object_store.py L96–108). + /// Удаляет объект. /// - /// - /// Удаление отсутствующего объекта — успех без действий (как у прототипа: Local — is_file проверка, - /// MinIO — идемпотентный 204). Сбои хранилища (недоступный MinIO) логируются адаптером и не бросаются: - /// 1:1 с object_store.py L96–108, где remove гасит исключения warning-логом — метаданные карточки - /// чистит сервис в любом случае (Task 7). - /// /// Ключ объекта. - /// Токен отмены. public Task DeleteAsync(string objectKey, CancellationToken ct); } diff --git a/src/core/Deal.Contracts/Integrations/Abstractions/IMlClient.cs b/src/core/Deal.Contracts/Integrations/Abstractions/IMlClient.cs index f837901..4a1a3d9 100644 --- a/src/core/Deal.Contracts/Integrations/Abstractions/IMlClient.cs +++ b/src/core/Deal.Contracts/Integrations/Abstractions/IMlClient.cs @@ -3,54 +3,36 @@ using Deal.Contracts.Integrations.Models; namespace Deal.Contracts.Integrations.Abstractions; /// -/// Порт клиента автономного ML-сервиса (Ruling 4, план Task 9 L333–367). +/// Порт клиента автономного ML-сервиса. /// -/// -/// Порт объявлен в Contracts, потому что контракт потребляют несколько модулей и Api. На этапе 2 -/// реализация — детерминированная заглушка Deal.Infrastructure.Integrations.Services.LocalMlClient -/// (Ruling 5); на этапе 6 она заменяется gRPC-клиентом с тем же контрактом. Методы и record-DTO -/// повторяют прототип 1:1: backend/app/services/ml_client.py (status/predict/reset/push) и -/// mlservice/model.py (эталон полей status/predict). Обучение на действиях пользователя -/// (PushAsync) добавлено этапом 3 (Kanban, Task 5) вместе с таблицей ml_outbox (Ruling 4, -/// план L97–107); очередь отправляется в ML-сервис фоновым воркером этапа 6. -/// public interface IMlClient { /// - /// Статус ML-сервиса + локальная статистика тенанта (тело GET /api/ml/status, ml_routes.py L66–75). + /// Статус ML-сервиса + локальная статистика тенанта. /// - /// Токен отмены. - /// DTO ответа: enabled/service/reachable/stats (форма Ruling 5 и ml_client.snapshot()). + /// DTO ответа: enabled/service/reachable/stats (форма и ml_client.snapshot). public Task StatusAsync(CancellationToken ct); /// - /// Предсказание ML по тексту сообщения (mlservice/model.py predict L184–293). + /// Предсказание ML по тексту сообщения. /// - /// Текст сообщения (уже обрезанный/нормализованный вызывающим, как в ml_routes.py L86–90). - /// Токен отмены. - /// Результат: take/label/scores/hits/ready/margin/terms/type. Неготовая модель всегда «не уверена» (Ruling 5). + /// Текст сообщения. + /// Результат: take/label/scores/hits/ready/margin/terms/type. Неготовая модель всегда «не уверена». public Task PredictAsync(string text, CancellationToken ct); /// - /// Полный сброс ML-модели + очистка очереди обучения (ml_routes.py L78–81, ml_client.py reset_model L110–124). + /// Полный сброс ML-модели + очистка очереди обучения. /// - /// Токен отмены. - /// Результат сброса: {ok:true} или (реальный сервис, этап 6) мягкая ошибка {ok:false,error}. + /// Результат сброса: {ok:true} или мягкая ошибка {ok:false,error}. public Task ResetAsync(CancellationToken ct); /// - /// Обучающий сигнал: действие пользователя пишется в очередь обучения MlOutbox (ml_client.push L40–49). + /// Обучающий сигнал /// - /// - /// Обучение идёт всегда и синхронно (модуль Kanban, Task 7: перенос на доску → push(text, id доски, 1.0), - /// корзина → push(text, "spam", 1.0), возврат из корзины → push(text, "spam", −1.0)). Реализация обрезает - /// text после trim до 6000 символов и игнорирует пустые text/label (no-op, как в прототипе L44–45). - /// - /// Текст обучающего примера — source_msg карточки, иначе title (leads.py L189–191). - /// Метка: id доски (b_...), spam, либо t:hire/t:order (этапы 4/6). - /// Вес сигнала: 1.0 — действие пользователя, −1.0 — снять метку; ИИ-сигналы (0.4/0.6) — этапы 4/6. - /// Токен отмены. - /// Задача завершается после записи строки в outbox (отправка в ML-сервис — фоновый воркер этапа 6). + /// Текст обучающего примера — source_msg карточки, иначе title. + /// Метка: id доски (b_...), spam, либо t:hire/t:order. + /// Вес сигнала: 1.0 — действие пользователя, −1.0 — снять метку; ИИ-сигналы (0.4/0.6) — /6. + /// Задача завершается после записи строки в outbox. public Task PushAsync( string text, string label, diff --git a/src/core/Deal.Contracts/Integrations/Abstractions/ITelegramGateway.cs b/src/core/Deal.Contracts/Integrations/Abstractions/ITelegramGateway.cs index dc979b1..0f6acb4 100644 --- a/src/core/Deal.Contracts/Integrations/Abstractions/ITelegramGateway.cs +++ b/src/core/Deal.Contracts/Integrations/Abstractions/ITelegramGateway.cs @@ -3,33 +3,22 @@ using Deal.Contracts.Integrations.Models; namespace Deal.Contracts.Integrations.Abstractions; /// -/// Порт-гейт к автономному telegram-service: команды ядра наружу (Ruling 7, план Task 13/14/18). +/// Порт-гейт к автономному telegram-service /// -/// -/// Порт объявлен в Contracts, потому что контракт потребляют модуль Telegram (DialogsService.SetMonitor*), -/// эндпоинты /api/tg (Task 14) и Discovery-воркер (Task 18). Набор методов 1:1 со списком Ruling 7 и RPC -/// TelegramService telegram.proto: подключение/отключение аккаунта, каталог диалогов, мониторинг, backfill, -/// превью и discovery-операции. Каждый вызов несёт metadata tenant-id + service-token (реализация — -/// gRPC-клиент GrpcTelegramClient при Services:Telegram:UseLocal=false, Ruling 1/6); Local-заглушка -/// (UseLocal=true, dev-дефолт) — нейтральный no-op, реальный сервис в dev не поднят. Недоступность сервиса — -/// исключение наружу: ветки эндпоинтов отвечают «Telegram не подключён»/400 (Ruling 7). -/// public interface ITelegramGateway { /// - /// Текущий статус аккаунта/фазы входа тенанта (status() python L103–119; GET /api/tg/status). + /// Текущий статус аккаунта/фазы входа тенанта /// - /// Токен отмены. /// Живой статус; нет сессии — RPC-ошибка «Telegram не подключён». public Task StatusAsync(CancellationToken ct); /// - /// Вход по номеру телефона: запросить код (start_phone python L134–147). + /// Вход по номеру телефона /// /// Номер в международном формате (как ввёл пользователь). /// api_id приложения Telegram (глобальные ключи, задаёт оператор; 5..9 цифр). /// api_hash приложения Telegram (глобальные ключи, задаёт оператор). - /// Токен отмены. /// Новая фаза ("code"); нет ключей — ядро отвечает 400 «Ключи Telegram не заданы оператором» до вызова. public Task StartPhoneAsync( string phone, @@ -38,11 +27,10 @@ public interface ITelegramGateway CancellationToken ct); /// - /// Начать QR-вход (qr_start python L286–300). + /// Начать QR-вход. /// /// api_id приложения Telegram (глобальные ключи, задаёт оператор). /// api_hash приложения Telegram (глобальные ключи, задаёт оператор). - /// Токен отмены. /// Фаза + qrUrl; аккаунт уже авторизован — фаза "ready", qrUrl пуст. public Task StartQrAsync( int apiId, @@ -50,41 +38,36 @@ public interface ITelegramGateway CancellationToken ct); /// - /// Отправить SMS-код (submit_code python L149–166). + /// Отправить SMS-код. /// /// Код из SMS/Telegram-сообщения. - /// Токен отмены. /// Новая фаза: "password" (нужен 2FA) либо "ready". public Task SendCodeAsync(string code, CancellationToken ct); /// - /// Облачный пароль 2FA (submit_password python L168–176). + /// Облачный пароль 2FA. /// /// Пароль облачной защиты. - /// Токен отмены. /// Новая фаза ("ready"). public Task SendPasswordAsync(string password, CancellationToken ct); /// - /// Отключить аккаунт, удалить сессию тенанта (disconnect python L189–207). + /// Отключить аккаунт, удалить сессию тенанта. /// - /// Токен отмены. /// Завершается после отключения. public Task LogoutAsync(CancellationToken ct); /// - /// Синхронизировать каталог диалогов из Telegram (refresh_dialogs python L505–519). + /// Синхронизировать каталог диалогов из Telegram. /// - /// Токен отмены. - /// Актуальный каталог диалогов аккаунта (entries); применение — SyncFromTelegram ядра (Ruling 7). + /// Актуальный каталог диалогов аккаунта (entries); применение — SyncFromTelegram ядра. public Task> RefreshDialogsAsync(CancellationToken ct); /// - /// Включить/выключить мониторинг диалога (set_monitor python L536–546): обновляет зеркало сервиса. + /// Включить/выключить мониторинг диалога /// /// Id диалога каталога. /// True — мониторить (сообщения → PushMessage в ядро), false — выключить. - /// Токен отмены. /// Завершается после обновления зеркала сервиса. public Task SetMonitorAsync( string dialogId, @@ -92,19 +75,17 @@ public interface ITelegramGateway CancellationToken ct); /// - /// Мониторинг всех диалогов сразу (set_monitor_all python L548–567): зеркало сервиса = каталог. + /// Мониторинг всех диалогов сразу /// /// True — мониторить все диалоги каталога, false — снять мониторинг со всех. - /// Токен отмены. /// Завершается после обновления зеркала сервиса. public Task SetMonitorAllAsync(bool enabled, CancellationToken ct); /// - /// Перечитать последние ~10 сообщений диалога потоком PushMessage (backfill_dialog python L349–390). + /// Перечитать последние ~10 сообщений диалога потоком PushMessage. /// /// Id диалога для перечитывания. /// True — перечитать, даже если диалог уже разобран (кнопка «Перечитать»). - /// Токен отмены. /// Сколько сообщений отправлено в ядро потоком PushMessage. public Task BackfillAsync( string dialogId, @@ -112,43 +93,39 @@ public interface ITelegramGateway CancellationToken ct); /// - /// Последние сообщения диалога для превью (dialog_messages python L583–620), свежие из Telegram. + /// Последние сообщения диалога для превью, свежие из Telegram. /// /// Id диалога. /// Сколько последних сообщений (1..50; api-map /dialogs/preview). - /// Токен отмены. - /// Сообщения от новых к старым; признак lead и фолбэк на БД добавляет ядро (Ruling 7). + /// Сообщения от новых к старым; признак lead и фолбэк на БД добавляет ядро. public Task> ReadRecentAsync( string dialogId, int limit, CancellationToken ct); /// - /// Глобальный поиск каналов/групп по ключу (discovery_search python L624–664). + /// Глобальный поиск каналов/групп по ключу. /// /// Поисковый запрос (ключ задачи discovery). - /// Верхняя граница результатов (прототип: default 30). - /// Токен отмены. - /// Найденные источники (каналы/группы; личные чаты/ботов отсеивает ядро — Ruling 10). + /// Верхняя граница результатов. + /// Найденные источники. public Task> SearchAsync( string query, int limit, CancellationToken ct); /// - /// Инфо об источнике для оценки (discovery_info python L666–716). + /// Инфо об источнике для оценки. /// /// Id источника (подписанный; из каталога или результата поиска). - /// Токен отмены. /// Имя/username/kind/hue + participants и is_forum (полный чат). public Task InfoAsync(string dialogId, CancellationToken ct); /// - /// Выборка последних сообщений источника для оценки кандидата (discovery_read python L718–800). + /// Выборка последних сообщений источника для оценки кандидата. /// /// Id источника. - /// Размер выборки (прототип discovery_read: limit сообщений/тем). - /// Токен отмены. + /// Размер выборки. /// ok + сообщения (форумы — по активным темам) либо ok=false + error="no_history". public Task ReadForEvalAsync( string dialogId, @@ -156,18 +133,16 @@ public interface ITelegramGateway CancellationToken ct); /// - /// Вступить в канал/группу по @username (discovery_join python L818–839). + /// Вступить в канал/группу по @username. /// /// Username источника без «@» (пусто → RPC-ошибка INVALID_ARGUMENT). - /// Токен отмены. /// Завершается после вступления; FloodWait → RPC-ошибка RESOURCE_EXHAUSTED с кодом flood. public Task JoinAsync(string username, CancellationToken ct); /// - /// Выйти из канала/группы (discovery_leave python L841–848). + /// Выйти из канала/группы. /// /// Id диалога для выхода. - /// Токен отмены. /// Завершается после выхода. public Task LeaveAsync(string dialogId, CancellationToken ct); } diff --git a/src/core/Deal.Contracts/Integrations/Models/AiBudgetDto.cs b/src/core/Deal.Contracts/Integrations/Models/AiBudgetDto.cs index 0e9a7cb..b23666d 100644 --- a/src/core/Deal.Contracts/Integrations/Models/AiBudgetDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/AiBudgetDto.cs @@ -1,14 +1,8 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Бюджет разбора классификатора — поле budget AiParsedCardDto (ai.py clean_budget L316–326, Ruling 5). +/// Бюджет разбора классификатора — поле budget AiParsedCardDto. /// -/// -/// Нормализованная форма хранения (как бюджет карточки §4.1 L240 и CardBudgetDto Kanban): одна сумма -/// X → from=to=X, «до X» → from=null/to=X, диапазон «от X до Y» → from=X/to=Y; from=0 трактуется как -/// отсутствие нижней границы. — код валюты (USD/EUR/RUB/…), распознанный из символа/ -/// слова/алиаса; бюджет без распознанной валюты не хранится (null). Сериализуется в camelCase: from/to/cur. -/// /// Нижняя граница (null — «до X»). /// Верхняя граница (одна сумма/«от X» → равна From). /// Код валюты (USD/EUR/RUB/USDT/…). diff --git a/src/core/Deal.Contracts/Integrations/Models/AiContactDto.cs b/src/core/Deal.Contracts/Integrations/Models/AiContactDto.cs index 66048fe..c143544 100644 --- a/src/core/Deal.Contracts/Integrations/Models/AiContactDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/AiContactDto.cs @@ -3,13 +3,6 @@ namespace Deal.Contracts.Integrations.Models; /// /// Контакт из разбора классификатора — элемент массива contacts AiParsedCardDto. /// -/// -/// Контрактная форма квалифицированного контакта (аналог CardContactDto карточки Kanban — модуль не -/// может быть ссылкой из Contracts, поэтому форма продублирована): type — tg|phone|whatsapp|email|linkedin| -/// site, value — нормализованное значение (у телефона — только цифры с «+», у e-mail — нижний регистр). -/// Квалификацию выполняет реализация классификатора (этап 4 — ContactsQualifier ядра Pipeline); -/// боты (@…bot), сервисные t.me-ссылки и «постовые» сайты отбрасываются. Сериализуется в camelCase: type/value. -/// /// Тип контакта: tg|phone|whatsapp|email|linkedin|site. /// Нормализованное значение контакта («@user», телефон, e-mail, ссылка). public sealed record AiContactDto(string Type, string Value); diff --git a/src/core/Deal.Contracts/Integrations/Models/AiEvaluateFitResultDto.cs b/src/core/Deal.Contracts/Integrations/Models/AiEvaluateFitResultDto.cs index 126cf75..11bbd29 100644 --- a/src/core/Deal.Contracts/Integrations/Models/AiEvaluateFitResultDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/AiEvaluateFitResultDto.cs @@ -1,15 +1,8 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Оценка соответствия сообщения задаче поиска — ответ EvaluateFitAsync (Ruling 9/10; форма -/// discovery_eval evaluate_message L174–194). +/// Оценка соответствия сообщения задаче поиска — ответ EvaluateFitAsync. /// -/// -/// Форма 1:1 с python-веткой ИИ: {fit, reason} (reason — краткая причина решения, потолок 200 символов, -/// дефолты «подходит»/«не подходит»). Вызывающий (воркер Discovery, Task 18) при исключении порта сам падает -/// в эвристику по ключам (python L186–194), поэтому транспортных ошибок в ответе нет. -/// Сериализуется в camelCase: fit/reason. -/// /// True — сообщение относится к сфере/задаче (JSON {"fit": 0|1}). /// Краткая причина решения модели (≤200; дефолт «подходит»/«не подходит»). public sealed record AiEvaluateFitResultDto(bool Fit, string Reason); diff --git a/src/core/Deal.Contracts/Integrations/Models/AiFilterResultDto.cs b/src/core/Deal.Contracts/Integrations/Models/AiFilterResultDto.cs index 066709d..0273c95 100644 --- a/src/core/Deal.Contracts/Integrations/Models/AiFilterResultDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/AiFilterResultDto.cs @@ -1,15 +1,8 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Решение ИИ-фильтра входящих — результат FilterAsync (ai.py filter_incoming L188–198, Ruling 5). +/// Решение ИИ-фильтра входящих — результат FilterAsync. /// -/// -/// Форма 1:1 с прототипом: {pass: bool, reason: str|null, skipped: bool}. — фильтр -/// не применялся (выключен aiFilterEnabled либо ИИ недоступен), сообщение считается пропущенным; -/// — причина отказа при pass=false (ветка filter_ai прототипа L1126–1129, этап 6). -/// Детерминированная реализация этапа 4 всегда возвращает {pass:true, reason:null, skipped:true} -/// (Ruling 5: реального ИИ-фильтра нет). Сериализуется в camelCase: pass/reason/skipped. -/// /// True — сообщение проходит фильтр (не спам/реклама/служебное). /// Причина отказа при Pass=false (текст ветки filter_ai); null при пропуске. /// True — фильтр не применялся (выключен/недоступен), решение — «пропустить». diff --git a/src/core/Deal.Contracts/Integrations/Models/AiGenerateKeywordsResultDto.cs b/src/core/Deal.Contracts/Integrations/Models/AiGenerateKeywordsResultDto.cs index 97c1ecc..b5bf6be 100644 --- a/src/core/Deal.Contracts/Integrations/Models/AiGenerateKeywordsResultDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/AiGenerateKeywordsResultDto.cs @@ -1,15 +1,8 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Результат генерации поисковых ключевых слов — ответ GenerateKeywordsAsync (Ruling 9/11; мягкая форма -/// {ok, keywords, error} эндпоинта generate-keywords, discovery_routes L36–47). +/// Результат генерации поисковых ключевых слов — ответ GenerateKeywordsAsync. /// -/// -/// Форма 1:1 с прототипом ветки: успех — ok=true + список ключей модели; недоступность сервиса/провайдера — -/// ok=false, keywords=[], error — текст для UI (эндпоинт Task 19 отвечает HTTP 200 {keywords: [], error}, -/// Ruling 11). Очистку ключей (_clean_keywords: ≤30, ≤60 симв., дедуп) делает вызывающий, а не порт. -/// Сериализуется в camelCase: ok/keywords/error. -/// /// True — сервис ответил списком ключей (может быть пустым — модель не выделила). /// Ключевые слова (сырые, до очистки вызывающим); пусто при ошибке. /// Текст ошибки для UI при Ok=false; null при успехе. diff --git a/src/core/Deal.Contracts/Integrations/Models/AiParsedCardDto.cs b/src/core/Deal.Contracts/Integrations/Models/AiParsedCardDto.cs index d86df45..75c1247 100644 --- a/src/core/Deal.Contracts/Integrations/Models/AiParsedCardDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/AiParsedCardDto.cs @@ -1,21 +1,8 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Структурированный разбор карточки — результат ClassifyAsync (структура классификации ТЗ §5 L104–106, -/// ai.py classify L218–258, Ruling 5). +/// Структурированный разбор карточки — результат ClassifyAsync. /// -/// -/// Набор полей 1:1 с решением классификатора: заголовок, блок «О заявке» (company/format/task/requirements/ -/// plus/conditions — содержимое cardPrompt L116–123), legacy-суть (путь локального -/// разбора), стек, бюджет (нормализованный, ), контакты, признак найма -/// ( + ) и назначенная колонка . -/// Реализация этапа 4 (LocalAiClassifier) заполняет только локальные поля: блок «О заявке» пуст (его поля — -/// null/пустые списки), суть — «О задаче» из SummaryComposer.LocalSummary; is_vacancy — маркерная -/// гипотеза по hireMarkers (is_vacancy_known=false — тип подтверждает только ИИ по контексту, ТЗ §4.7 L108); -/// board=null — смысловые колонки до ИИ не назначаются (pipeline.py L954–958), карточку в колонку кладут -/// воркер/правила после проверки ContainerAccepts. Пустые поля блока карточку не дают (сборку «О заявке» из -/// блоков делает CardComposer этапа 7 через SummaryComposer — недостающие блоки пропускаются). -/// /// Заголовок карточки (первая содержательная строка, очищенная, ≤140). /// Кто разместил заявку (компания/бренд/частное лицо); null — не указано. /// Формат работы/выполнения (удалённо/офис, город, график); null — не указано. @@ -23,8 +10,7 @@ namespace Deal.Contracts.Integrations.Models; /// Требования/обязанности (пункты); null — нет. /// «Будет плюсом» (пункты); null — нет. /// Условия одной строкой (оплата/сроки/объём); null — не указано. -/// Неструктурированная суть разбора (legacy-путь «О заявке»: возвращается как есть, если -/// не похожа на служебный футер, иначе блок «О задаче: …» из текста; python compose_summary L264–284). +/// Неструктурированная суть разбора. /// Стек/направления (≤12, нормализованный, без стоп-слов). /// Нормализованный бюджет (форма хранения); null — суммы с валютой нет. /// Квалифицированные контакты (≤6, без ботов/сервисных ссылок). diff --git a/src/core/Deal.Contracts/Integrations/Models/FileMeta.cs b/src/core/Deal.Contracts/Integrations/Models/FileMeta.cs index 663e084..02b09bb 100644 --- a/src/core/Deal.Contracts/Integrations/Models/FileMeta.cs +++ b/src/core/Deal.Contracts/Integrations/Models/FileMeta.cs @@ -1,20 +1,9 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Дескриптор объекта файлового хранилища: ключ, размер и MIME-тип (Ruling 4, Task 6). +/// Дескриптор объекта файлового хранилища /// -/// -/// Тип-описатель порта : в нём хранилище отдаёт -/// метаданные объекта (аналог stat object_store.py — MinIO StatObject / размер файла на диске) методом -/// StatAsync. Потребитель — download-эндпоинт файлов проектных карточек (Task 9, Ruling T6): -/// Content-Length/Content-Type ответа берутся из дескриптора, а null (объекта нет) мапится в 404 -/// «Файл не найден в MinIO». Метаданные вложений карточки при этом живут отдельно — в -/// Cards.FilesJson (запись {id, name, size, kind, label, objectKey}, Ruling 1). -/// может быть пустым: LocalFileStorage не хранит contentType (пишутся только -/// байты, как прототип) — в этом случае download отдаёт фиксированный application/octet-stream -/// (Ruling 4, projects_routes.py L174–179); MinIO-адаптер возвращает MIME, сохранённый при put. -/// -/// objectKey объекта (формат projects/<card>/<ms>_<name>). +/// objectKey объекта (формат projects/<card>/<ms>_<name>). /// Размер объекта в байтах. /// MIME-тип объекта (может быть пустым — например, LocalFileStorage не хранит contentType). public sealed record FileMeta( diff --git a/src/core/Deal.Contracts/Integrations/Models/MlEvalDto.cs b/src/core/Deal.Contracts/Integrations/Models/MlEvalDto.cs index d562cbd..7c6ab5d 100644 --- a/src/core/Deal.Contracts/Integrations/Models/MlEvalDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/MlEvalDto.cs @@ -1,13 +1,8 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Самооценка ML-модели — поле eval статуса сервиса (mlservice/model.py status L325–345). +/// Самооценка ML-модели — поле eval статуса сервиса. /// -/// -/// Окно самооценки: последние решения модели, подтверждённые действиями пользователя -/// (перенос на доску, корзина, возврат, ручная разметка). В этапе 2 модель не обучена — -/// всегда {count:0, correct:0, accuracy:0} (Ruling 5). Сериализуется в camelCase: count/correct/accuracy. -/// /// Решений в окне самооценки. /// Из них совпавших с действием пользователя. /// Доля верных (correct/count, 0..1); 0 при пустом окне. diff --git a/src/core/Deal.Contracts/Integrations/Models/MlLearningLabels.cs b/src/core/Deal.Contracts/Integrations/Models/MlLearningLabels.cs index 348d8e1..cfc5b8c 100644 --- a/src/core/Deal.Contracts/Integrations/Models/MlLearningLabels.cs +++ b/src/core/Deal.Contracts/Integrations/Models/MlLearningLabels.cs @@ -3,34 +3,32 @@ using Deal.Contracts.Integrations.Abstractions; namespace Deal.Contracts.Integrations.Models; /// -/// Общие метки обучения ML-модели (значения 1:1 с ml_client.py). Единый источник для всех модулей, -/// которые шлют обучающие сигналы через (Kanban/Pipeline/Discovery): -/// раньше константа «spam» дублировалась в каждом модуле (Security/quality review, C35). +/// Общие метки обучения ML-модели. /// public static class MlLearningLabels { /// - /// Метка «спам»: положительный сигнал (trash) и снятие веса (restore/return-to-queue, delta −1). + /// Метка «спам»: положительный сигнал /// public const string Spam = "spam"; /// - /// Класс типа ML «найм/занятость» (решение predict.type; value — ). + /// Класс типа ML «найм/занятость» /// public const string TypeHireLabel = "hire"; /// - /// Метка обучения типу «найм/занятость» (python L1177–1178). + /// Метка обучения типу «найм/занятость». /// public const string TypeHireValue = "t:hire"; /// - /// Метка обучения типу «разовые заказы» (python L1177–1178). + /// Метка обучения типу «разовые заказы». /// public const string TypeOrderValue = "t:order"; /// - /// Вес обучающего сигнала ИИ — гипотезы: ниже действий пользователя (1.0) (ml_client.py AI_WEIGHT L26). + /// Вес обучающего сигнала ИИ — гипотезы /// public const double AiPushWeight = 0.4; } diff --git a/src/core/Deal.Contracts/Integrations/Models/MlPredictResultDto.cs b/src/core/Deal.Contracts/Integrations/Models/MlPredictResultDto.cs index aa04004..42f4d35 100644 --- a/src/core/Deal.Contracts/Integrations/Models/MlPredictResultDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/MlPredictResultDto.cs @@ -1,17 +1,8 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Результат предсказания ML по тексту — тело «хвоста» POST /api/ml/predict (mlservice/model.py predict L184–293). +/// Результат предсказания ML по тексту — тело «хвоста» POST /api/ml/predict. /// -/// -/// 1:1 с ответом ML-сервиса: take — модель «взяла» решение (иначе сообщение уходит ИИ); -/// label/scores/hits/margin/terms/type осмысленны только при take; scores — до 5 лучших -/// «класс → вес»; margin — порог уверенности; terms — узнанные термины класса (подсказка -/// структуры карточки); type — решение о типе заявки (или null). Неготовая модель (этап 2) -/// всегда возвращает фиксированный «не уверен»: take:false, label:null, scores:{}, hits:0, -/// ready:false, margin:null, terms:[], type:null (Ruling 5). Эндпоинт дополняет ответ полем -/// text (первые 200 символов) — см. MlEndpoints. Сериализуется в camelCase. -/// /// True — модель уверена и сообщение можно разобрать без ИИ. /// Класс решения: id колонки канбана или spam (null, если не уверена). /// Веса классов: «label → вес» (до 5 лучших; пусто у неготовой модели). diff --git a/src/core/Deal.Contracts/Integrations/Models/MlResetResultDto.cs b/src/core/Deal.Contracts/Integrations/Models/MlResetResultDto.cs index b2e5349..c70b70c 100644 --- a/src/core/Deal.Contracts/Integrations/Models/MlResetResultDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/MlResetResultDto.cs @@ -3,14 +3,8 @@ using System.Text.Json.Serialization; namespace Deal.Contracts.Integrations.Models; /// -/// Результат полного сброса ML-модели — тело POST /api/ml/reset (ml_routes.py L78–81, ml_client.py L110–124). +/// Результат полного сброса ML-модели — тело POST /api/ml/reset. /// -/// -/// Успех — {ok:true}; мягкая ошибка реального сервиса — HTTP 200 с {ok:false,error} -/// (api-map §3.7 L192, план Task 9 L347 — ветка зарезервирована: заглушка этапа 2 всегда ok). -/// Поле опускается при null (JsonIgnoreCondition.WhenWritingNull) — ответ -/// успеха ровно {"ok":true}, как в прототипе (там ключ error появляется только при сбое). -/// /// True — модель сброшена и очередь обучения очищена. /// Текст ошибки при сбое сброса (null при успехе). public sealed record MlResetResultDto( diff --git a/src/core/Deal.Contracts/Integrations/Models/MlServiceStatusDto.cs b/src/core/Deal.Contracts/Integrations/Models/MlServiceStatusDto.cs index ee23e3d..d7f42eb 100644 --- a/src/core/Deal.Contracts/Integrations/Models/MlServiceStatusDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/MlServiceStatusDto.cs @@ -1,15 +1,8 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Статус автономного ML-сервиса — поле service ответа GET /api/ml/status (ml_routes.py L70–75). +/// Статус автономного ML-сервиса — поле service ответа GET /api/ml/status. /// -/// -/// Это «сырой» статус модели из самого ML-сервиса (его GET /status — mlservice/model.py status -/// L325–345): модель не обучается в основном приложении. Классы — «имя класса → вес/число -/// примеров»; spam и id колонок канбана. В этапе 2 детерминированно: ready=false, classes={}, -/// learned=0, eval обнулён (Ruling 5 — обучение появится этапом 3). Сериализуется в camelCase: -/// ready/classes/learned/eval. -/// /// Модель набрала достаточно примеров и может принимать решения (классы/термины обучены). /// Классы модели: «label → вес» (пусто, пока нет обучения). /// Всего примеров, на которых модель обучалась (сумма по классам). diff --git a/src/core/Deal.Contracts/Integrations/Models/MlStatsDto.cs b/src/core/Deal.Contracts/Integrations/Models/MlStatsDto.cs index b656096..4016460 100644 --- a/src/core/Deal.Contracts/Integrations/Models/MlStatsDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/MlStatsDto.cs @@ -1,21 +1,15 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Локальная статистика ML в тенанте — поле stats ответа GET /api/ml/status (ml_client.snapshot() L138–150). +/// Локальная статистика ML в тенанте — поле stats ответа GET /api/ml/status /// -/// -/// Снимок ML-подсистемы приложения: счётчики решений (mlDecisions/aiDecisions — внутренние -/// KV-настройки, Ruling 1) + последний известный статус сервиса (ready/classes/learned/reachable). -/// learning/outbox — из таблиц схемы тенанта (владелец — Kanban, этап 3, Ruling 4): learning = count(CardMoves), -/// outbox = count(MlOutbox). Сериализуется в camelCase: ml/ai/learning/ready/classes/learned/reachable/outbox. -/// /// Сколько сообщений обработал ML (счётчик mlDecisions). /// Сколько сообщений обработал ИИ (счётчик aiDecisions). -/// Записей журнала обучения (count(CardMoves) = learning_log прототипа). +/// Записей журнала обучения (count(CardMoves) = learning_log). /// Модель готова (зеркало service.ready). /// Классы модели (зеркало service.classes). /// Примеров обучено (зеркало service.learned). -/// ML-сервис доступен (в этапе 2 заглушка всегда true). +/// ML-сервис доступен. /// Событий обучения в очереди ml_outbox (count(MlOutbox)). public sealed record MlStatsDto( int Ml, diff --git a/src/core/Deal.Contracts/Integrations/Models/MlStatusResponseDto.cs b/src/core/Deal.Contracts/Integrations/Models/MlStatusResponseDto.cs index e2e73eb..ac6876f 100644 --- a/src/core/Deal.Contracts/Integrations/Models/MlStatusResponseDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/MlStatusResponseDto.cs @@ -1,19 +1,11 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Тело ответа GET /api/ml/status (ml_routes.py L66–75 + ml_client.snapshot() L138–150). +/// Тело ответа GET /api/ml/status /// -/// -/// Собирается реализацией из статуса -/// ML-сервиса () и локальных настроек/счётчиков тенанта (KV, Ruling 1): -/// enabled — выключатель mlEnabled (семантика «не false», ml_routes.py L71), reachable — -/// доступность сервиса, stats — локальная статистика. Фронт читает: reachable, -/// service.ready/classes/learned/eval.{count,correct,accuracy}, stats.outbox (applyMlStatus, store.js L487–502). -/// Сериализуется в camelCase: enabled/service/reachable/stats. -/// /// Использовать ли ML в пайплайне (настройка mlEnabled; обучение идёт всегда). /// Статус ML-сервиса: ready/classes/learned/eval. -/// ML-сервис доступен (заглушка этапа 2 всегда true). +/// ML-сервис доступен. /// Локальная статистика тенанта: счётчики + зеркало статуса + outbox. public sealed record MlStatusResponseDto( bool Enabled, diff --git a/src/core/Deal.Contracts/Integrations/Models/MlTypeDecisionDto.cs b/src/core/Deal.Contracts/Integrations/Models/MlTypeDecisionDto.cs index da995cf..ef19896 100644 --- a/src/core/Deal.Contracts/Integrations/Models/MlTypeDecisionDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/MlTypeDecisionDto.cs @@ -1,14 +1,8 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Решение ML о типе заявки — поле type предсказания (mlservice/model.py predict L233–238). +/// Решение ML о типе заявки — поле type предсказания. /// -/// -/// ML отдельно от колонки/спама решает тип заявки (найм/разовый заказ), когда уверен и накопил -/// минимум примеров типов: {take, label:"hire"|"order", value:"t:hire"|"t:order", margin}. -/// В этапе 2 модель не обучена — тип всегда null (Ruling 5). Сериализуется в camelCase: -/// take/label/value/margin. -/// /// Модель уверена в типе (иначе поля не осмысленны). /// Тип: hire | order. /// Внутренний класс ML (префикс t:), не показывается UI. diff --git a/src/core/Deal.Contracts/Integrations/Models/SourceDefaults.cs b/src/core/Deal.Contracts/Integrations/Models/SourceDefaults.cs index ef51083..b851935 100644 --- a/src/core/Deal.Contracts/Integrations/Models/SourceDefaults.cs +++ b/src/core/Deal.Contracts/Integrations/Models/SourceDefaults.cs @@ -1,15 +1,12 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Общие дефолты источника (канал/группа/кандидат), 1:1 с db.py (hue по умолчанию — «#666»). -/// Единый источник для модулей, которые создают строки источников/карточек с цветом канала -/// (Telegram/DialogsService, Pipeline/CardComposer и отсев, Discovery/кандидаты) — раньше -/// константа «#666» дублировалась в каждом модуле (quality review, C35). +/// Общие дефолты источника /// public static class SourceDefaults { /// - /// Дефолтный цвет источника каталога (hex; db.py L81: hue DEFAULT '#666'). + /// Дефолтный цвет источника каталога. /// public const string DefaultHue = "#666"; } diff --git a/src/core/Deal.Contracts/Integrations/Models/SuggestColumnsResultDto.cs b/src/core/Deal.Contracts/Integrations/Models/SuggestColumnsResultDto.cs index f8b97bd..d3b24fe 100644 --- a/src/core/Deal.Contracts/Integrations/Models/SuggestColumnsResultDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/SuggestColumnsResultDto.cs @@ -3,19 +3,11 @@ using System.Text.Json.Serialization; namespace Deal.Contracts.Integrations.Models; /// -/// Результат ИИ-предложений колонок — тело POST /api/ai/suggest-columns (api-map §3.2 L120, Ruling 3). +/// Результат ИИ-предложений колонок — тело POST /api/ai/suggest-columns. /// -/// -/// Успех — {ok:true, created:N} (создано N досок-предложений suggested=true с разложенными -/// карточками); мягкая ошибка — HTTP 200 с {ok:false, reason} (прототип suggest.py L87–102, -/// L158–163). При кулдауне ответ дополняется cooldown:true (L93–95: «недавно предлагали — -/// подождите»). Поля (0) и (false) при дефолтных значениях -/// опускаются, — при null: ответы 1:1 с прототипом, где ключи отсутствуют, а не -/// приходят пустыми. SSE-toast после успеха публикует эндпоинт (Ruling 5) — DTO про него не знает. -/// /// True — доски-предложения созданы и карточки разложены. /// Сколько досок-предложений создано (≥1 при Ok). -/// Текст причины при Ok=false (детерминированная строка Ruling 3); null при успехе. +/// Текст причины при Ok=false; null при успехе. /// True — сработал кулдаун повторов (повторный вызов слишком рано). public sealed record SuggestColumnsResultDto( bool Ok, diff --git a/src/core/Deal.Contracts/Integrations/Models/SuggestKeywordsResultDto.cs b/src/core/Deal.Contracts/Integrations/Models/SuggestKeywordsResultDto.cs index 08131e0..b3424d7 100644 --- a/src/core/Deal.Contracts/Integrations/Models/SuggestKeywordsResultDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/SuggestKeywordsResultDto.cs @@ -3,16 +3,8 @@ using System.Text.Json.Serialization; namespace Deal.Contracts.Integrations.Models; /// -/// Результат ИИ-предложений ключевых слов-маркеров — тело POST /api/ai/suggest-keywords (api-map §3.2 L121, Ruling 3). +/// Результат ИИ-предложений ключевых слов-маркеров — тело POST /api/ai/suggest-keywords. /// -/// -/// Успех — {ok:true, keywords:[…]}: общие слова-маркеры сферы по карточкам тенанта (≤60 слов, -/// длина ≤40, нижний регистр — как прототип suggest.py L188–193). Мягкая ошибка — HTTP 200 с -/// {ok:false, reason} («мало карточек…», L178; «ИИ не смог выделить ключи…», L187). Поля -/// (null) и (null) при отсутствии опускаются — ответ 1:1 -/// с прототипом. Пользователь смотрит/правит список и сохраняет его в настройках «Сфера и ключи» -/// (SettingsView.vue → domainKeywords), либо использует для правил колонок. -/// /// True — ключи выделены по карточкам. /// Слова-маркеры (≤60, каждое ≤40 символов); null при Ok=false. /// Текст причины при Ok=false; null при успехе. diff --git a/src/core/Deal.Contracts/Integrations/Models/TelegramAccountStatusDto.cs b/src/core/Deal.Contracts/Integrations/Models/TelegramAccountStatusDto.cs index 5338615..a21149c 100644 --- a/src/core/Deal.Contracts/Integrations/Models/TelegramAccountStatusDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/TelegramAccountStatusDto.cs @@ -1,13 +1,8 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Живой статус аккаунта Telegram — ответ GetStatusAsync гейта (proto GetStatusReply, Ruling 7/8). +/// Живой статус аккаунта Telegram — ответ GetStatusAsync гейта. /// -/// -/// Форма 1:1 с status() прототипа (python L103–119). monitored и keysSet ядро считает само из своей БД/ -/// настроек (Ruling 8) — в этом DTO их нет. Для GET /api/tg/status (Task 14) ядро объединяет гейт + KV tgStatus -/// (account — из KV tgAccount, источник истины) + счётчик monitored. -/// /// Фаза входа: idle|phone|code|password|qr|ready. /// Клиент Telegram подключён и авторизован. /// Жив ли realtime-listener (поток новых сообщений → PushMessage). diff --git a/src/core/Deal.Contracts/Integrations/Models/TelegramAuthResultDto.cs b/src/core/Deal.Contracts/Integrations/Models/TelegramAuthResultDto.cs index 2b2d770..6eae453 100644 --- a/src/core/Deal.Contracts/Integrations/Models/TelegramAuthResultDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/TelegramAuthResultDto.cs @@ -1,12 +1,8 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Результат команды входа — StartPhoneAsync/StartQrAsync гейта (proto StartPhoneReply/StartQrReply). +/// Результат команды входа — StartPhoneAsync/StartQrAsync гейта /// -/// -/// StartPhone отвечает фазой "code"; StartQr — фазой "qr" (+qrUrl для отрисовки) либо "ready", если аккаунт -/// уже авторизован. SendCode/SendPassword возвращают только фазу (строкой) — см. ITelegramGateway. -/// /// Фаза после команды: code|qr|ready|…. /// URL QR-входа вида https://t.me/qr/… (заполнен при phase == "qr"), либо null. public sealed record TelegramAuthResultDto(string Phase, string? QrUrl); diff --git a/src/core/Deal.Contracts/Integrations/Models/TelegramChannelInfoDto.cs b/src/core/Deal.Contracts/Integrations/Models/TelegramChannelInfoDto.cs index 1c80ee5..7067efe 100644 --- a/src/core/Deal.Contracts/Integrations/Models/TelegramChannelInfoDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/TelegramChannelInfoDto.cs @@ -1,12 +1,8 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Инфо об источнике для оценки Discovery — ответ InfoAsync гейта (proto ChannelInfo, Ruling 10). +/// Инфо об источнике для оценки Discovery — ответ InfoAsync гейта. /// -/// -/// Форма 1:1 с discovery_info python L674–682. Сбои определения участников не роняют RPC: participants пуст, -/// остальные поля — из entity/каталога. is_forum=true трактует ядро как kind "forum" (Ruling 10). -/// /// Подписанный id источника. /// Имя источника. /// Username (handle) источника; пуст, если нет публичного username. diff --git a/src/core/Deal.Contracts/Integrations/Models/TelegramDialogEntryDto.cs b/src/core/Deal.Contracts/Integrations/Models/TelegramDialogEntryDto.cs index e3722de..930a8cf 100644 --- a/src/core/Deal.Contracts/Integrations/Models/TelegramDialogEntryDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/TelegramDialogEntryDto.cs @@ -1,13 +1,8 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Диалог/источник каталога или результат поиска telegram-service (proto DialogEntry, Ruling 7). +/// Диалог/источник каталога или результат поиска telegram-service. /// -/// -/// Форма 1:1 с кортежем refresh_dialogs/discovery_search (python L516 и L653–660): id/name/handle/kind/hue; -/// kind — EN-канон контракта (channel|group|forum|chat). Вход SyncFromTelegram ядра и результат -/// RefreshDialogsAsync/SearchAsync гейта; сериализуется в camelCase. -/// /// Подписанный id диалога: каналы «-100…», группы «-…», личные «+…». /// Отображаемое имя (title/first_name) или id, если имени нет. /// Username (handle) источника; пуст, если нет публичного username. diff --git a/src/core/Deal.Contracts/Integrations/Models/TelegramEvalMessageDto.cs b/src/core/Deal.Contracts/Integrations/Models/TelegramEvalMessageDto.cs index b790bea..3900e6c 100644 --- a/src/core/Deal.Contracts/Integrations/Models/TelegramEvalMessageDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/TelegramEvalMessageDto.cs @@ -1,12 +1,8 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Сообщение выборки discovery_read — элемент ответа ReadForEvalAsync (proto EvalMessage, Ruling 10). +/// Сообщение выборки discovery_read — элемент ответа ReadForEvalAsync. /// -/// -/// Форма 1:1 с _discovery_message_item python L803–816. Для форумов заполнены topic_id/topic_title (сообщения -/// разложены по активным темам), для обычных источников — null; пустые тексты отбрасывает telegram-service. -/// /// Id сообщения в Telegram. /// Текст сообщения (непустой). /// Время сообщения, epoch-ms. diff --git a/src/core/Deal.Contracts/Integrations/Models/TelegramEvalReadDto.cs b/src/core/Deal.Contracts/Integrations/Models/TelegramEvalReadDto.cs index a1acca2..cfe952b 100644 --- a/src/core/Deal.Contracts/Integrations/Models/TelegramEvalReadDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/TelegramEvalReadDto.cs @@ -1,12 +1,8 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Результат выборки сообщений для оценки кандидата — ответ ReadForEvalAsync (proto ReadForEvalReply). +/// Результат выборки сообщений для оценки кандидата — ответ ReadForEvalAsync /// -/// -/// ok=false + error="no_history" — нормальный результат (история недоступна без членства), а не ошибка RPC -/// (Ruling 10): воркер Discovery ставит кандидату метку «канал: история недоступна» и пропускает его. -/// /// True — выборка получена; false — история недоступна без членства. /// Код причины при ok=false: "no_history" (остальные поля пусты). /// Сообщения выборки (форумы — по активным темам). diff --git a/src/core/Deal.Contracts/Integrations/Models/TelegramRecentMessageDto.cs b/src/core/Deal.Contracts/Integrations/Models/TelegramRecentMessageDto.cs index c16ef28..2089dea 100644 --- a/src/core/Deal.Contracts/Integrations/Models/TelegramRecentMessageDto.cs +++ b/src/core/Deal.Contracts/Integrations/Models/TelegramRecentMessageDto.cs @@ -1,13 +1,8 @@ namespace Deal.Contracts.Integrations.Models; /// -/// Свежее сообщение диалога для превью — ответ ReadRecentAsync гейта (proto PreviewMessage). +/// Свежее сообщение диалога для превью — ответ ReadRecentAsync гейта /// -/// -/// telegram-service возвращает только свежие сообщения из Telegram (БД тенанта у сервиса нет): id сообщения -/// передаётся строкой (из Telegram — int как строка; фолбэк-сообщения ядра из TgMessages — строки -/// «m_<dialog>_<msg>»). Признак lead и фолбэк на БД добавляет ядро (Ruling 7, api-map §4.8 L351). -/// /// Id сообщения (из Telegram — int как строка; фолбэк БД — «m_<dialog>_<msg>»). /// Текст сообщения (непустой). /// Время сообщения, epoch-ms. diff --git a/src/core/Deal.Infrastructure/Data/ConnectionStringProvider.cs b/src/core/Deal.Infrastructure/Data/ConnectionStringProvider.cs index 5f62151..e640c34 100644 --- a/src/core/Deal.Infrastructure/Data/ConnectionStringProvider.cs +++ b/src/core/Deal.Infrastructure/Data/ConnectionStringProvider.cs @@ -6,12 +6,6 @@ namespace Deal.Infrastructure.Data; /// /// Строит строку подключения к Postgres с учётом схемы тенанта. /// -/// -/// Две строки (Security review, least privilege): ConnectionStrings:DealPostgres — прикладная роль -/// runtime (без DDL в проде); ConnectionStrings:DealMigrator (опционально) — служебная роль для DDL -/// (CREATE SCHEMA/миграции схемы). Если мигратор-строка не задана (dev/тесты/один пользователь) — DDL -/// выполняется прикладной строкой (текущее поведение). -/// public sealed class ConnectionStringProvider { private readonly string _baseConnectionString; @@ -39,8 +33,7 @@ public sealed class ConnectionStringProvider } /// - /// Строка подключения для DDL (провижининг схемы/миграции): мигратор-роль, если задана, - /// иначе прикладная (dev/тесты). Search Path — как в . + /// Строка подключения для DDL /// public string ForSchemaDdl(TenantId? tenantId) { diff --git a/src/core/Deal.Infrastructure/Data/TenantContext.cs b/src/core/Deal.Infrastructure/Data/TenantContext.cs index 3250384..e65eb93 100644 --- a/src/core/Deal.Infrastructure/Data/TenantContext.cs +++ b/src/core/Deal.Infrastructure/Data/TenantContext.cs @@ -4,7 +4,7 @@ using Deal.SharedKernel.Tenants.Models; namespace Deal.Infrastructure.Data; /// -/// Контекст тенанта на AsyncLocal: пробрасывается через весь запрос. +/// Контекст тенанта на AsyncLocal /// public sealed class TenantContext : ITenantContext { diff --git a/src/core/Deal.Infrastructure/InfrastructureMarker.cs b/src/core/Deal.Infrastructure/InfrastructureMarker.cs index 3e5cb90..087b5d2 100644 --- a/src/core/Deal.Infrastructure/InfrastructureMarker.cs +++ b/src/core/Deal.Infrastructure/InfrastructureMarker.cs @@ -1,7 +1,7 @@ namespace Deal.Infrastructure; /// -/// Маркер слоя Infrastructure: используется для DI-сканирования и тестов. +/// Маркер слоя Infrastructure /// public sealed class InfrastructureMarker { diff --git a/src/core/Deal.Infrastructure/Integrations/Abstractions/IMlTrainClient.cs b/src/core/Deal.Infrastructure/Integrations/Abstractions/IMlTrainClient.cs index d10c215..c10ed17 100644 --- a/src/core/Deal.Infrastructure/Integrations/Abstractions/IMlTrainClient.cs +++ b/src/core/Deal.Infrastructure/Integrations/Abstractions/IMlTrainClient.cs @@ -4,22 +4,15 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Infrastructure.Integrations.Abstractions; /// -/// Порт отправки батча обучения в ml-service (RPC TrainBatch, ml.proto L52–55) — для MlOutboxFlushScheduler. +/// Порт отправки батча обучения в ml-service — для 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). + /// Отправляет порцию очереди обучения в ml-service. /// /// Строки 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 d5304e6..2815e33 100644 --- a/src/core/Deal.Infrastructure/Integrations/Exceptions/AiUnavailableException.cs +++ b/src/core/Deal.Infrastructure/Integrations/Exceptions/AiUnavailableException.cs @@ -1,17 +1,8 @@ namespace Deal.Infrastructure.Integrations.Exceptions; /// -/// Сбой вызова ИИ-сервиса (провайдер недоступен/не ответил корректно либо ответ без разбора) — -/// сигнал порта IAiClassifier/IAiTools для веток фолбэка вызывающего. +/// Сбой вызова ИИ-сервиса /// -/// -/// Семантика 1:1 с прототипом, где chat_json бросает RuntimeError (ai.py L115–117): воркер Pipeline -/// ловит исключение классификатора → локальный разбор (aiFail, python L1108–1114), ИИ-фильтр → «пропустить» -/// (L1102–1106); Discovery-воркер при сбое EvaluateFit падает в эвристику (discovery_eval L186–194). Локальные -/// реализации (LocalAiClassifier) детерминированы и этого исключения не бросают. Текст — стабильная строка -/// без секретов и тел ответов (Ruling 13); detail gRPC-ошибки (дружелюбный текст ai-service «ИИ (имя) не -/// ответил корректно…») пробрасывается, когда он есть. -/// public sealed class AiUnavailableException : Exception { /// diff --git a/src/core/Deal.Infrastructure/Integrations/Extensions/RpcExceptionExtensions.cs b/src/core/Deal.Infrastructure/Integrations/Extensions/RpcExceptionExtensions.cs index 882cb6e..640e6cf 100644 --- a/src/core/Deal.Infrastructure/Integrations/Extensions/RpcExceptionExtensions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Extensions/RpcExceptionExtensions.cs @@ -8,7 +8,7 @@ namespace Deal.Infrastructure.Integrations.Extensions; internal static class RpcExceptionExtensions { /// - /// Ошибки коммуникации, при которых сервис считается недоступным (всё, кроме прикладных статусов). + /// Ошибки коммуникации, при которых сервис считается недоступным /// /// Исключение RPC. /// True — транспорта/контракта health нет (down); false — прикладной статус (не наша зона). diff --git a/src/core/Deal.Infrastructure/Integrations/Extensions/UriExtensions.cs b/src/core/Deal.Infrastructure/Integrations/Extensions/UriExtensions.cs index e3fb647..d2d76a9 100644 --- a/src/core/Deal.Infrastructure/Integrations/Extensions/UriExtensions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Extensions/UriExtensions.cs @@ -4,14 +4,12 @@ using System.Net.Sockets; namespace Deal.Infrastructure.Integrations.Extensions; /// -/// Расширения для SSRF-гейта интеграций (проверка приватности адреса). +/// Расширения для SSRF-гейта интеграций /// internal static class UriExtensions { /// - /// Проверяет, указывает ли URL на приватный/loopback/link-local адрес (SSRF-гейт). - /// Распознаются IP-литералы (IPv4/IPv6) и имя localhost; DNS-имена считаются публичными - /// (полный egress-контроль с резолвом выполняется на сетевом периметре). + /// Проверяет, указывает ли URL на приватный/loopback/link-local адрес /// /// Абсолютный http(s)-адрес. /// True — адрес приватный/локальный (HTTP к нему запрещён). diff --git a/src/core/Deal.Infrastructure/Integrations/Models/AiGrpcConnection.cs b/src/core/Deal.Infrastructure/Integrations/Models/AiGrpcConnection.cs index 26c1fb3..15538d2 100644 --- a/src/core/Deal.Infrastructure/Integrations/Models/AiGrpcConnection.cs +++ b/src/core/Deal.Infrastructure/Integrations/Models/AiGrpcConnection.cs @@ -6,32 +6,22 @@ using Grpc.Net.Client; namespace Deal.Infrastructure.Integrations.Models; /// -/// Транспорт gRPC-клиентов ai-service: общий канал + обязательные metadata (Ruling 1, эталон -/// MlGrpcConnection). +/// Транспорт gRPC-клиентов ai-service /// -/// -/// 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). + /// Env-ключ ожидаемого service-token. /// public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; /// - /// Ключ gRPC-metadata с tenant-id (зеркало AiServiceImpl, Ruling 1). + /// Ключ gRPC-metadata с tenant-id. /// public const string TenantIdMetadataKey = "tenant-id"; /// - /// Ключ gRPC-metadata с service-token (зеркало AiServiceImpl, Ruling 1). + /// Ключ gRPC-metadata с service-token. /// public const string ServiceTokenMetadataKey = "service-token"; @@ -39,10 +29,10 @@ public sealed class AiGrpcConnection : IDisposable private readonly string _serviceToken; /// - /// Создаёт транспорт ai-service по конфигурации и env-токену (валидация fail-closed). + /// Создаёт транспорт ai-service по конфигурации и env-токену /// /// Конфигурация секции Services:Ai (endpoint). - /// Сертификаты mTLS (Ruling 6): null — plaintext-канал (dev, флаг выключен). + /// Сертификаты mTLS: null — plaintext-канал (dev, флаг выключен). /// Пустой endpoint или пустой DEAL_SERVICE_TOKEN. public AiGrpcConnection(AiServiceOptions options, MtlsCertificates? mtlsCertificates = null) { @@ -70,13 +60,13 @@ public sealed class AiGrpcConnection : IDisposable } /// - /// Создаёт клиент RPC AiService поверх общего канала (клиент — лёгкий, на каждый вызов). + /// Создаёт клиент RPC AiService поверх общего канала /// /// Клиент сервиса AI (Filter/Classify/GenerateKeywords/EvaluateFit). public AiService.AiServiceClient CreateClient() => new(_channel); /// - /// Собирает обязательные metadata вызова: tenant-id + service-token (Ruling 1). + /// Собирает обязательные metadata вызова /// /// Id тенанта (строка, формат N — как в ai-service). /// Metadata для CallOptions вызова. diff --git a/src/core/Deal.Infrastructure/Integrations/Models/MlGrpcConnection.cs b/src/core/Deal.Infrastructure/Integrations/Models/MlGrpcConnection.cs index df475f7..3756d38 100644 --- a/src/core/Deal.Infrastructure/Integrations/Models/MlGrpcConnection.cs +++ b/src/core/Deal.Infrastructure/Integrations/Models/MlGrpcConnection.cs @@ -6,31 +6,22 @@ using Grpc.Net.Client; namespace Deal.Infrastructure.Integrations.Models; /// -/// Транспорт gRPC-клиента ml-service: общий канал + обязательные metadata (Ruling 1). +/// Транспорт gRPC-клиента ml-service /// -/// -/// 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). + /// Env-ключ ожидаемого service-token. /// public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; /// - /// Ключ gRPC-metadata с tenant-id (зеркало MlServiceImpl, Ruling 1). + /// Ключ gRPC-metadata с tenant-id. /// public const string TenantIdMetadataKey = "tenant-id"; /// - /// Ключ gRPC-metadata с service-token (зеркало MlServiceImpl, Ruling 1). + /// Ключ gRPC-metadata с service-token. /// public const string ServiceTokenMetadataKey = "service-token"; @@ -38,10 +29,10 @@ public sealed class MlGrpcConnection : IDisposable private readonly string _serviceToken; /// - /// Создаёт транспорт ml-service по конфигурации и env-токену (валидация fail-closed). + /// Создаёт транспорт ml-service по конфигурации и env-токену /// /// Конфигурация секции Services:Ml (endpoint). - /// Сертификаты mTLS (Ruling 6): null — plaintext-канал (dev, флаг выключен). + /// Сертификаты mTLS: null — plaintext-канал (dev, флаг выключен). /// Пустой endpoint или пустой DEAL_SERVICE_TOKEN. public MlGrpcConnection(MlServiceOptions options, MtlsCertificates? mtlsCertificates = null) { @@ -69,13 +60,13 @@ public sealed class MlGrpcConnection : IDisposable } /// - /// Создаёт клиент RPC MlService поверх общего канала (клиент — лёгкий, на каждый вызов). + /// Создаёт клиент RPC MlService поверх общего канала /// /// Клиент сервиса ML (Predict/Status/Reset/TrainBatch). public MlService.MlServiceClient CreateClient() => new(_channel); /// - /// Собирает обязательные metadata вызова: tenant-id + service-token (Ruling 1). + /// Собирает обязательные metadata вызова /// /// Id тенанта (строка, формат N — как в пуле модели ml-service). /// Metadata для CallOptions вызова. diff --git a/src/core/Deal.Infrastructure/Integrations/Models/MtlsCertificates.cs b/src/core/Deal.Infrastructure/Integrations/Models/MtlsCertificates.cs index c6354c5..fd2f534 100644 --- a/src/core/Deal.Infrastructure/Integrations/Models/MtlsCertificates.cs +++ b/src/core/Deal.Infrastructure/Integrations/Models/MtlsCertificates.cs @@ -6,20 +6,8 @@ using Deal.Infrastructure.Integrations.Options; namespace Deal.Infrastructure.Integrations.Models; /// -/// Загруженный набор сертификатов mTLS внутреннего gRPC (Ruling 6, план Task 13). +/// Загруженный набор сертификатов mTLS внутреннего gRPC. /// -/// -/// Создаётся один раз на старте процесса, когда =true, из файлов -/// deploy/certs (генерация — scripts/mtls-certs.sh); при выключенном флаге возвращает -/// null — процесс остаётся на plaintext + service-token (Ruling 2 этапа 6). Экземпляр живёт до конца -/// процесса: сертификаты держат Kestrel (серверный) и исходящие gRPC-каналы (клиентский), поэтому -/// IDisposable сознательно нет — преждевременный Dispose сломал бы живые соединения. Fail-fast: при -/// включённом флаге любой пустой/битый путь или пароль — на старте. -/// -/// Проверка второй стороны — цепочка на нашу CA (CustomRootTrust, без revocation): dev-CA не в системном -/// хранилище, поэтому стандартная проверка доверия дала бы RemoteCertificateChainErrors и без кастомного -/// билда цепочки каждое соединение отвергалось бы. -/// public sealed class MtlsCertificates { // Роль в сообщениях об ошибках: CA-сертификат (проверка второй стороны). @@ -42,23 +30,22 @@ public sealed class MtlsCertificates } /// - /// CA-сертификат из CaPem: корень доверия для проверки второй стороны. + /// CA-сертификат из CaPem /// public X509Certificate2 CaCertificate { get; } /// - /// Серверный сертификат процесса из PFX (подпись своего Kestrel-gRPC-эндпоинта). + /// Серверный сертификат процесса из PFX /// public X509Certificate2 ServerCertificate { get; } /// - /// Клиентский сертификат из PFX (подпись исходящих каналов, общий deal-client). + /// Клиентский сертификат из PFX /// public X509Certificate2 ClientCertificate { get; } /// - /// Загружает сертификаты из : null при выключенном флаге (режим plaintext), - /// иначе — CA + серверный + клиентский с fail-fast на битые пути/пароли. + /// Загружает сертификаты из /// /// Опции mTLS (env DEAL_MTLS_*). /// Набор сертификатов либо null (флаг выключен). @@ -78,9 +65,7 @@ public sealed class MtlsCertificates } /// - /// Серверная проверка клиентского сертификата для Kestrel (ClientCertificateValidation): сертификат - /// обязан быть подписан нашей CA (цепочка до CaPem). Стандартные ошибки цепочки (наша CA вне системного - /// хранилища) пересобираются кастомным билдом; иные ошибки (нет сертификата/недоступен) — отказ. + /// Серверная проверка клиентского сертификата для Kestrel /// /// Клиентский сертификат из рукопожатия (null — RequireCertificate не выполнен). /// Цепочка стандартной проверки (игнорируется — пересобирается на нашу CA). @@ -109,7 +94,7 @@ public sealed class MtlsCertificates } /// - /// Создаёт HTTP/2-хендлер исходящего канала: клиентский сертификат + проверка CA сервера. + /// Создаёт HTTP/2-хендлер исходящего канала /// /// Новый SocketsHttpHandler (владелец — создатель; канал GrpcChannel закроет его вместе с собой). public SocketsHttpHandler CreateClientHttpHandler() diff --git a/src/core/Deal.Infrastructure/Integrations/Models/ServiceHealthResult.cs b/src/core/Deal.Infrastructure/Integrations/Models/ServiceHealthResult.cs index 48eb3e6..45b5ca4 100644 --- a/src/core/Deal.Infrastructure/Integrations/Models/ServiceHealthResult.cs +++ b/src/core/Deal.Infrastructure/Integrations/Models/ServiceHealthResult.cs @@ -3,18 +3,12 @@ using Deal.Infrastructure.Integrations.Services; namespace Deal.Infrastructure.Integrations.Models; /// -/// Результат health-пробы grpc.health.v1 автономного сервиса (Task 10; формирует ). +/// Результат health-пробы grpc.health.v1 автономного сервиса. /// -/// -/// Reachable — сервис ответил на health-RPC (канал/транспорт жив); Serving — статус ответа -/// SERVING (health-контракт в порядке). Комбинации: (true, true) = ok; (true, false) = сервис жив, но -/// не готов (NOT_SERVING/SERVICE_UNKNOWN — «unhealthy»); (false, false) = недоступен (таймаут/нет слушателя — -/// «down»). Значение-сирота (false, true) не возникает (Serving=true без ответа невозможно). -/// public sealed record ServiceHealthResult(bool Reachable, bool Serving) { /// - /// Недоступен: RPC не выполнен (нет соединения/дедлайн/ошибка транспорта). + /// Недоступен: RPC не выполнен /// public static ServiceHealthResult Unreachable { get; } = new(Reachable: false, Serving: false); } diff --git a/src/core/Deal.Infrastructure/Integrations/Models/TelegramGrpcConnection.cs b/src/core/Deal.Infrastructure/Integrations/Models/TelegramGrpcConnection.cs index 066a8b7..343f6d8 100644 --- a/src/core/Deal.Infrastructure/Integrations/Models/TelegramGrpcConnection.cs +++ b/src/core/Deal.Infrastructure/Integrations/Models/TelegramGrpcConnection.cs @@ -6,31 +6,22 @@ using Grpc.Net.Client; namespace Deal.Infrastructure.Integrations.Models; /// -/// Транспорт gRPC-клиента telegram-service: общий канал + обязательные metadata (Ruling 1, план Task 14). +/// Транспорт gRPC-клиента telegram-service /// -/// -/// 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). + /// Env-ключ ожидаемого service-token. /// public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; /// - /// Ключ gRPC-metadata с tenant-id (зеркало TelegramServiceImpl, Ruling 1). + /// Ключ gRPC-metadata с tenant-id. /// public const string TenantIdMetadataKey = "tenant-id"; /// - /// Ключ gRPC-metadata с service-token (зеркало TelegramServiceImpl, Ruling 1). + /// Ключ gRPC-metadata с service-token. /// public const string ServiceTokenMetadataKey = "service-token"; @@ -38,10 +29,10 @@ public sealed class TelegramGrpcConnection : IDisposable private readonly string _serviceToken; /// - /// Создаёт транспорт telegram-service по конфигурации и env-токену (валидация fail-closed). + /// Создаёт транспорт telegram-service по конфигурации и env-токену /// /// Конфигурация секции Services:Telegram (endpoint). - /// Сертификаты mTLS (Ruling 6): null — plaintext-канал (dev, флаг выключен). + /// Сертификаты mTLS: null — plaintext-канал (dev, флаг выключен). /// Пустой endpoint или пустой DEAL_SERVICE_TOKEN. public TelegramGrpcConnection(TelegramServiceOptions options, MtlsCertificates? mtlsCertificates = null) { @@ -69,13 +60,13 @@ public sealed class TelegramGrpcConnection : IDisposable } /// - /// Создаёт клиент RPC TelegramService поверх общего канала (клиент — лёгкий, на каждый вызов). + /// Создаёт клиент RPC TelegramService поверх общего канала /// /// Клиент сервиса Telegram (команды ядра наружу). public TelegramService.TelegramServiceClient CreateClient() => new(_channel); /// - /// Собирает обязательные metadata вызова: tenant-id + service-token (Ruling 1). + /// Собирает обязательные metadata вызова /// /// Id тенанта (строка, формат N — как в сессиях telegram-service). /// Metadata для CallOptions вызова. diff --git a/src/core/Deal.Infrastructure/Integrations/Options/AiServiceOptions.cs b/src/core/Deal.Infrastructure/Integrations/Options/AiServiceOptions.cs index e26f184..75d8443 100644 --- a/src/core/Deal.Infrastructure/Integrations/Options/AiServiceOptions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Options/AiServiceOptions.cs @@ -1,34 +1,27 @@ namespace Deal.Infrastructure.Integrations.Options; /// -/// Конфигурация клиента AI-сервиса — секция Services:Ai (Ruling 6, план Task 15). +/// Конфигурация клиента AI-сервиса — секция Services:Ai. /// -/// -/// По умолчанию dev = Local-адаптеры: UseLocal=true регистрирует LocalAiClassifier/LocalAiTools -/// (фолбэк этапов 4–5: локальный разбор ядра, фильтр пропускает, ИИ-инструменты не поддерживаются), реальный -/// ai-service подключается Services:Ai:UseLocal=false + endpoint (env -/// SERVICES__AI__USELOCAL=false, SERVICES__AI__ENDPOINT=http://localhost:5102, compose — Ruling 12). -/// Выбор реализации — на старте, логики переключения в рантайме нет (Ruling 6). -/// public sealed class AiServiceOptions { /// - /// Имя секции конфигурации (appsettings.json / env-префикс SERVICES__AI__*). + /// Имя секции конфигурации /// public const string SectionName = "Services:Ai"; /// - /// Endpoint ai-service по умолчанию (dev-порт сервиса, Ruling 12). + /// Endpoint ai-service по умолчанию. /// public const string DefaultEndpoint = "http://localhost:5102"; /// - /// True — Local-адаптеры (default), false — gRPC-клиенты GrpcAiClassifier/GrpcAiTools. + /// True — Local-адаптеры /// public bool UseLocal { get; set; } = true; /// - /// Базовый адрес ai-service (http://host:port; только без TLS — Ruling 2). + /// Базовый адрес ai-service. /// public string Endpoint { get; set; } = DefaultEndpoint; } diff --git a/src/core/Deal.Infrastructure/Integrations/Options/MlServiceOptions.cs b/src/core/Deal.Infrastructure/Integrations/Options/MlServiceOptions.cs index 9b81c3b..7b9a5e4 100644 --- a/src/core/Deal.Infrastructure/Integrations/Options/MlServiceOptions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Options/MlServiceOptions.cs @@ -1,33 +1,27 @@ namespace Deal.Infrastructure.Integrations.Options; /// -/// Конфигурация клиента ML-сервиса — секция Services:Ml (Ruling 6, план Task 16). +/// Конфигурация клиента ML-сервиса — секция Services:Ml. /// -/// -/// По умолчанию dev = Local-заглушка: UseLocal=true регистрирует LocalMlClient -/// (фолбэк этапов 2–5), реальный ml-service подключается Services:Ml:UseLocal=false + -/// endpoint (env SERVICES__ML__USELOCAL=false, SERVICES__ML__ENDPOINT=http://localhost:5103, -/// compose — Ruling 12). Выбор реализации — на старте, логики переключения в рантайме нет (Ruling 6). -/// public sealed class MlServiceOptions { /// - /// Имя секции конфигурации (appsettings.json / env-префикс SERVICES__ML__*). + /// Имя секции конфигурации /// public const string SectionName = "Services:Ml"; /// - /// Endpoint ml-service по умолчанию (dev-порт сервиса, Ruling 12). + /// Endpoint ml-service по умолчанию. /// public const string DefaultEndpoint = "http://localhost:5103"; /// - /// True — Local-заглушка LocalMlClient (default), false — gRPC-клиент GrpcMlClient. + /// True — Local-заглушка LocalMlClient /// public bool UseLocal { get; set; } = true; /// - /// Базовый адрес ml-service (http://host:port; только без TLS — Ruling 2). + /// Базовый адрес ml-service. /// public string Endpoint { get; set; } = DefaultEndpoint; } diff --git a/src/core/Deal.Infrastructure/Integrations/Options/MtlsOptions.cs b/src/core/Deal.Infrastructure/Integrations/Options/MtlsOptions.cs index 41498b9..c790d88 100644 --- a/src/core/Deal.Infrastructure/Integrations/Options/MtlsOptions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Options/MtlsOptions.cs @@ -3,26 +3,17 @@ using Microsoft.Extensions.Configuration; namespace Deal.Infrastructure.Integrations.Options; /// -/// Конфигурация mTLS-транспорта внутреннего gRPC (Ruling 6, план Task 13). +/// Конфигурация mTLS-транспорта внутреннего gRPC. /// -/// -/// Только 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-флаги сервиса). + /// Env-ключ флага: 1/true включает mTLS /// public const string EnabledEnvKey = "DEAL_MTLS_ENABLED"; /// - /// Env-ключ пути к PFX серверного сертификата процесса (Kestrel-gRPC). + /// Env-ключ пути к PFX серверного сертификата процесса /// public const string ServerCertPfxEnvKey = "DEAL_MTLS_SERVER_CERT_PFX"; @@ -32,7 +23,7 @@ public sealed class MtlsOptions public const string ServerCertPasswordEnvKey = "DEAL_MTLS_SERVER_CERT_PASSWORD"; /// - /// Env-ключ пути к PFX клиентского сертификата (общий deal-client исходящих каналов). + /// Env-ключ пути к PFX клиентского сертификата /// public const string ClientCertPfxEnvKey = "DEAL_MTLS_CLIENT_CERT_PFX"; @@ -42,7 +33,7 @@ public sealed class MtlsOptions public const string ClientCertPasswordEnvKey = "DEAL_MTLS_CLIENT_CERT_PASSWORD"; /// - /// Env-ключ пути к PEM dev-CA (проверка сертификата второй стороны). + /// Env-ключ пути к PEM dev-CA /// public const string CaPemEnvKey = "DEAL_MTLS_CA_PEM"; @@ -52,32 +43,32 @@ public sealed class MtlsOptions public bool Enabled { get; init; } /// - /// Путь к PFX серверного сертификата процесса (см. ). + /// Путь к PFX серверного сертификата процесса /// public string ServerCertPfx { get; init; } = string.Empty; /// - /// Пароль серверного PFX (см. ). + /// Пароль серверного PFX /// public string ServerCertPassword { get; init; } = string.Empty; /// - /// Путь к PFX клиентского сертификата (см. ). + /// Путь к PFX клиентского сертификата /// public string ClientCertPfx { get; init; } = string.Empty; /// - /// Пароль клиентского PFX (см. ). + /// Пароль клиентского PFX /// public string ClientCertPassword { get; init; } = string.Empty; /// - /// Путь к PEM-файлу dev-CA (см. ). + /// Путь к PEM-файлу dev-CA /// public string CaPem { get; init; } = string.Empty; /// - /// Читает опции из конфигурации хоста (env-ключи DEAL_MTLS_*, только env — Ruling 13). + /// Читает опции из конфигурации хоста. /// /// Конфигурация хоста (env-провайдер WebApplicationBuilder). /// Опции mTLS (флаг выключен — остальные поля пустые). @@ -96,7 +87,7 @@ public sealed class MtlsOptions } /// - /// Разбирает значение флага DEAL_MTLS_ENABLED: «1»/«true» (без учёта регистра) — включено. + /// Разбирает значение флага DEAL_MTLS_ENABLED /// /// Сырое значение env (null/пусто — выключено). public static bool IsEnabled(string? rawValue) diff --git a/src/core/Deal.Infrastructure/Integrations/Options/TelegramServiceOptions.cs b/src/core/Deal.Infrastructure/Integrations/Options/TelegramServiceOptions.cs index 61fc8fe..2454b4c 100644 --- a/src/core/Deal.Infrastructure/Integrations/Options/TelegramServiceOptions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Options/TelegramServiceOptions.cs @@ -1,34 +1,27 @@ namespace Deal.Infrastructure.Integrations.Options; /// -/// Конфигурация клиента telegram-service — секция Services:Telegram (Ruling 6, план Task 14). +/// Конфигурация клиента telegram-service — секция Services:Telegram. /// -/// -/// По умолчанию dev = Local-заглушка: UseLocal=true регистрирует LocalTelegramGateway -/// (нейтральный no-op/idle — реальный telegram-service в dev не поднят), реальный сервис подключается -/// Services:Telegram:UseLocal=false + endpoint (env SERVICES__TELEGRAM__USELOCAL=false, -/// SERVICES__TELEGRAM__ENDPOINT=http://localhost:5101, compose — Ruling 12). Выбор реализации — на -/// старте, логики переключения в рантайме нет (Ruling 6). -/// public sealed class TelegramServiceOptions { /// - /// Имя секции конфигурации (appsettings.json / env-префикс SERVICES__TELEGRAM__*). + /// Имя секции конфигурации /// public const string SectionName = "Services:Telegram"; /// - /// Endpoint telegram-service по умолчанию (dev-порт сервиса, Ruling 12). + /// Endpoint telegram-service по умолчанию. /// public const string DefaultEndpoint = "http://localhost:5101"; /// - /// True — Local-заглушка LocalTelegramGateway (default), false — gRPC-клиент GrpcTelegramClient. + /// True — Local-заглушка LocalTelegramGateway /// public bool UseLocal { get; set; } = true; /// - /// Базовый адрес telegram-service (http://host:port; только без TLS — Ruling 2). + /// Базовый адрес telegram-service. /// public string Endpoint { get; set; } = DefaultEndpoint; } diff --git a/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs b/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs index 5f8ff4a..33b65c1 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs @@ -7,29 +7,16 @@ using Deal.Modules.Settings.Application.Models; namespace Deal.Infrastructure.Integrations.Services; /// -/// HTTP-реализация проверки подключения к AI-провайдеру (Ruling 7; 1:1 settings_routes.py L195–219). +/// HTTP-реализация проверки подключения к AI-провайдеру. /// -/// -/// Лёгкая проверка БЕЗ 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-регистрацией клиента. + /// Таймаут HTTP-запроса проверки в секундах; применяется DI-регистрацией клиента. /// public const int RequestTimeoutSeconds = 12; - // ── Фиксированные сообщения веток (Ruling 7, 1:1 с прототипом) ── - // Сообщение ветки «локальный провайдер» (вместо HTTP — ping на этапе 6). private const string LocalServerMessageTemplate = "Локальный сервер «{0}» (ping в проде)"; // Сообщение ветки «API-ключ не задан». @@ -56,7 +43,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker // Сообщение 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"; @@ -108,7 +94,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker 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)); @@ -157,7 +142,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { - // Сработал HttpClient.Timeout (12 с) — ветка сетевого сбоя (прототип ловит все исключения). return BuildResult(request, name, ok: false, message: TimeoutMessage); } catch (HttpRequestException exception) @@ -167,7 +151,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker } } - // Собирает ответ ветки: {ok, message} + статус провайдера (Ruling 7). // request: Запрос проверки (поля статуса провайдера). // name: Имя провайдера из каталога AiProviders. // ok: Результат подключения. @@ -191,7 +174,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker KeyMasked: MaskKey(request.ApiKey)); } - // Маска ключа: пусто → "", len ≤ 8 → «x…», иначе «1234…5678» (ai.py mask_key L53–58). // key: Ключ открытым текстом. // Возвращает: Маскированная строка. private static string MaskKey(string key) @@ -209,7 +191,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker 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). @@ -226,7 +207,6 @@ public sealed class AiConnectionChecker : IAiConnectionChecker return false; } - // rstrip("/") как в прототипе: baseUrl из настроек может заканчиваться слэшем. string root = baseUrl.TrimEnd('/'); string relativePath = apiStyle == AnthropicApiStyle ? AnthropicModelsPath : OpenAiModelsPath; if (!Uri.TryCreate(root + relativePath, UriKind.Absolute, out Uri? endpoint)) diff --git a/src/core/Deal.Infrastructure/Integrations/Services/AiProviderConfigBuilder.cs b/src/core/Deal.Infrastructure/Integrations/Services/AiProviderConfigBuilder.cs index 4086005..bfa422a 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/AiProviderConfigBuilder.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/AiProviderConfigBuilder.cs @@ -6,19 +6,8 @@ using Deal.Modules.Settings.Application.Models; namespace Deal.Infrastructure.Integrations.Services; /// -/// Собирает конфиг активного ИИ-провайдера для запросов ai-service (Ruling 5: ядро расшифровывает -/// aiConfigs и передаёт ProviderConfig в теле каждого запроса; сервис настроек тенанта не знает). +/// Собирает конфиг активного ИИ-провайдера для запросов ai-service. /// -/// -/// Эффективный конфиг 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 переопределения провайдера. @@ -30,7 +19,6 @@ public sealed class AiProviderConfigBuilder // Ключ 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; @@ -40,7 +28,7 @@ public sealed class AiProviderConfigBuilder /// Создаёт сборщик конфига провайдера. /// /// KV-хранилище настроек тенанта (aiProvider/aiConfigs). - /// Расшифровка секрета aiConfigs.apiKey (AES-GCM, Ruling 2). + /// Расшифровка секрета aiConfigs.apiKey. public AiProviderConfigBuilder(ISettingsStore store, ISecretCipher secretCipher) { ArgumentNullException.ThrowIfNull(store); @@ -52,7 +40,6 @@ public sealed class AiProviderConfigBuilder /// /// Собирает ProviderConfig активного провайдера для тела запроса ai-service. /// - /// Токен отмены. /// Конфиг: provider_id/base/model/api_key (расшифрованный)/api_style (см. ai.proto). public async Task BuildAsync(CancellationToken ct) { @@ -100,7 +87,6 @@ public sealed class AiProviderConfigBuilder return config; } - // Активный провайдер из настройки aiProvider (дефолт «deepseek», python L26). // ct: Токен отмены. // Возвращает: Id провайдера (каталога AiProviders). private async Task ReadProviderIdAsync(CancellationToken ct) diff --git a/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiClassifier.cs b/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiClassifier.cs index c918c54..ad0de46 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiClassifier.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiClassifier.cs @@ -10,25 +10,8 @@ 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-контекста (гейт читает лимиты по тенанту). @@ -80,7 +63,6 @@ public sealed class BudgetedAiClassifier : IAiClassifier } // Запрет гейта — фильтр через Local-реализацию {pass:true, skipped:true} (семантика «фильтр недоступен», - // Ruling 3): сообщение не блокируется, платный фильтр не зовётся. _logger.LogDebug( "ИИ-фильтр: {Reason} — Local-пропуск (тенант {TenantId})", GateDeniedLogText, TenantIdForLog()); return await _localClassifier.FilterAsync(text, ct); @@ -94,14 +76,12 @@ public sealed class BudgetedAiClassifier : IAiClassifier 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) diff --git a/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiTools.cs b/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiTools.cs index 5025563..6369794 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiTools.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/BudgetedAiTools.cs @@ -10,36 +10,16 @@ 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; @@ -79,7 +59,6 @@ public sealed class BudgetedAiTools : IAiTools return await _paidTools.GenerateKeywordsAsync(description, ct); } - // Мягкая ошибка для UI (Ruling 3/11): {ok:false, keywords:[], error} — эндпоинт отвечает HTTP 200. _logger.LogDebug( "generate-keywords: {Reason} — мягкая ошибка (тенант {TenantId})", DenyLogText(state), @@ -103,8 +82,6 @@ public sealed class BudgetedAiTools : IAiTools return await _paidTools.EvaluateFitAsync(text, description, keywords, ct); } - // Сбой ИИ-оценки не роняет оценку кандидата: воркер Discovery падает в эвристику (Ruling 3/10, - // python L186–194 — код вызывающего не меняется). _logger.LogDebug( "evaluate-fit: {Reason} — эвристика (тенант {TenantId})", DenyLogText(state), @@ -113,7 +90,6 @@ public sealed class BudgetedAiTools : IAiTools state.Status == TenantStatuses.Suspended ? SuspendedFitError : ExhaustedFitError); } - // Текущее состояние бюджета тенанта (ленивый reset периода + Allowed/Status для гейта, Task 9). // ct: Токен отмены. // Возвращает: Состояние бюджета тенанта на сейчас. private async Task GateStateAsync(CancellationToken ct) diff --git a/src/core/Deal.Infrastructure/Integrations/Services/CbrRateSource.cs b/src/core/Deal.Infrastructure/Integrations/Services/CbrRateSource.cs index 4f05f20..aba1332 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/CbrRateSource.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/CbrRateSource.cs @@ -6,25 +6,15 @@ using Microsoft.Extensions.Logging; namespace Deal.Infrastructure.Integrations.Services; /// -/// HTTP-источник курсов ЦБ РФ: GET daily_json.js (Ruling 6, Task 8; 1:1 rates.py L43–59). +/// HTTP-источник курсов ЦБ РФ /// -/// -/// Запрос — 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). + /// Таймаут HTTP-запроса в секундах. /// 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, …}. @@ -39,7 +29,6 @@ public sealed class CbrRateSource : IRatesSource // Базовая валюта ответа: курсы даются к рублю. private const string BaseCurrency = "RUB"; - // Курс рубля к рублю (всегда 1.0, rates.py L50). private const double RubToRubRate = 1.0; private readonly HttpClient _httpClient; @@ -74,7 +63,6 @@ public sealed class CbrRateSource : IRatesSource } catch (Exception exception) { - // Любой сбой HTTP/парсинга = неуспех источника (python ловит все исключения, rates.py L57–59). _logger.LogWarning("CBR fetch failed: {Reason}", exception.Message); return null; } @@ -103,7 +91,6 @@ public sealed class CbrRateSource : IRatesSource { if (!TryParseCurrency(currency, out double rate)) { - // Нераспознанная запись валюты: как и исключение python внутри цикла, роняет весь fetch. return null; } @@ -113,7 +100,6 @@ public sealed class CbrRateSource : IRatesSource return rates; } - // Разбирает одну запись валюты: курс = Value / Nominal, округлён до 6 знаков (rates.py L51–55). // currency: Пара «код валюты → объект {Value, Nominal}». // rate: Курс единицы валюты к рублю (валиден при возврате true). // Возвращает: True — запись распознана; False — повреждённая запись (весь fetch — сбой). @@ -127,7 +113,6 @@ public sealed class CbrRateSource : IRatesSource JsonElement item = currency.Value; - // Значение по умолчанию, как в python: отсутствующий Value → 0, Nominal → 1 (иначе — сбой). double value = 0; if (item.TryGetProperty(ValuePropertyName, out JsonElement valueElement)) { diff --git a/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiClassifier.cs b/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiClassifier.cs index 7106cee..e75b33b 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiClassifier.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiClassifier.cs @@ -12,39 +12,17 @@ using Microsoft.Extensions.Logging; namespace Deal.Infrastructure.Integrations.Services; /// -/// gRPC-адаптер порта к автономному ai-service (Ruling 5/6, план Task 15 -/// L412–434). +/// gRPC-адаптер порта к автономному ai-service. /// -/// -/// Регистрируется вместо 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 с). + /// Deadline RPC ai-service — 120 с /// public const int RpcDeadlineSeconds = 120; /// - /// Лимит текста сообщения фильтра (ai.py filter_incoming L193: text[:4000]). + /// Лимит текста сообщения фильтра. /// public const int MaxFilterTextCodePoints = 4000; @@ -112,7 +90,6 @@ public sealed class GrpcAiClassifier : IAiClassifier 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, @@ -120,7 +97,6 @@ public sealed class GrpcAiClassifier : IAiClassifier } catch (RpcException exception) { - // Провайдер/сервис недоступен — воркер отвечает «пропустить» (python L1102–1106: r2=pass+skipped). _logger.LogDebug(exception, "ИИ-фильтр недоступен (тенант {TenantId})", tenantId.Value); throw new AiUnavailableException(ErrorText(exception)); } @@ -154,7 +130,6 @@ public sealed class GrpcAiClassifier : IAiClassifier } catch (RpcException exception) { - // Классификатор недоступен — как raw={} в прототипе (L1112–1114): локальный разбор, aiFail. _logger.LogDebug(exception, "ИИ-классификация недоступна (тенант {TenantId})", tenantId.Value); throw new AiUnavailableException(ErrorText(exception)); } @@ -168,7 +143,6 @@ public sealed class GrpcAiClassifier : IAiClassifier if (!reply.Ok) { // Модель не вернула разбираемый JSON после ретраев — контрактная ok=false (README ai.proto): - // ядро трактует как «разбора нет» и падает в локальный путь (python: RuntimeError → raw={}). _logger.LogWarning("ИИ-классификация: ok=false (тенант {TenantId})", tenantId.Value); throw new AiUnavailableException(NoJsonAnswerText); } @@ -193,7 +167,6 @@ public sealed class GrpcAiClassifier : IAiClassifier ?? throw new InvalidOperationException( "GrpcAiClassifier запрошен вне tenant-контекста (ITenantContext.TenantId == null)."); - // CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1). // tenantId: Id тенанта (формат N). // ct: Токен отмены вызова. // Возвращает: Опции вызова с заголовками, deadline и отменой. @@ -203,7 +176,6 @@ public sealed class GrpcAiClassifier : IAiClassifier deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)), cancellationToken: ct); - // Краткий текст ошибки: detail gRPC-ошибки (дружелюбный текст ai-service) либо фолбэк (Ruling 13: // секреты/тела ответов не логируются и в текст не попадают). // exception: Исключение RPC-вызова. // Возвращает: Текст ошибки. @@ -213,7 +185,6 @@ public sealed class GrpcAiClassifier : IAiClassifier return detail.Length > 0 ? detail : ServiceUnavailableText; } - // Первые max кодовых точек строки (python-срез без разрыва суррогатных пар). // text: Строка. // max: Лимит. // Возвращает: Усечённая строка. diff --git a/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiTools.cs b/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiTools.cs index ab36341..e639492 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiTools.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/GrpcAiTools.cs @@ -11,49 +11,30 @@ using Microsoft.Extensions.Logging; namespace Deal.Infrastructure.Integrations.Services; /// -/// gRPC-адаптер порта к автономному ai-service (Ruling 9, план Task 15/18/19). +/// gRPC-адаптер порта к автономному ai-service. /// -/// -/// Регистрируется вместо 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 с). + /// Deadline RPC ai-service — 120 с /// public const int RpcDeadlineSeconds = 120; /// - /// Лимит описания ниши generate-keywords (discovery_routes L29: обрезает до 4000). + /// Лимит описания ниши generate-keywords. /// public const int MaxDescriptionCodePoints = 4000; /// - /// Лимит текста сообщения evaluate-fit (discovery_eval L41: _AI_TEXT_LIMIT=4000). + /// Лимит текста сообщения evaluate-fit. /// 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; @@ -112,7 +93,6 @@ public sealed class GrpcAiTools : IAiTools } 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)); } @@ -160,7 +140,6 @@ public sealed class GrpcAiTools : IAiTools } catch (RpcException exception) { - // Сбой ИИ-оценки не роняет оценку кандидата — воркер падает в эвристику (Ruling 10, python L191–192). _logger.LogDebug(exception, "evaluate-fit недоступен (тенант {TenantId})", tenantId.Value); throw new AiUnavailableException(ErrorText(exception)); } @@ -179,7 +158,6 @@ public sealed class GrpcAiTools : IAiTools ?? throw new InvalidOperationException( "GrpcAiTools запрошен вне tenant-контекста (ITenantContext.TenantId == null)."); - // CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1). // tenantId: Id тенанта (формат N). // ct: Токен отмены вызова. // Возвращает: Опции вызова с заголовками, deadline и отменой. @@ -189,7 +167,6 @@ public sealed class GrpcAiTools : IAiTools deadline: DateTime.UtcNow.Add(TimeSpan.FromSeconds(RpcDeadlineSeconds)), cancellationToken: ct); - // Краткий текст ошибки: detail gRPC-ошибки (дружелюбный текст ai-service) либо фолбэк (Ruling 13: // секреты/тела ответов не логируются и в текст не попадают). // exception: Исключение RPC-вызова. // Возвращает: Текст ошибки. @@ -199,7 +176,6 @@ public sealed class GrpcAiTools : IAiTools return detail.Length > 0 ? detail : ServiceUnavailableText; } - // Первые max кодовых точек строки (python-срез без разрыва суррогатных пар). // text: Строка. // max: Лимит. // Возвращает: Усечённая строка. diff --git a/src/core/Deal.Infrastructure/Integrations/Services/GrpcMlClient.cs b/src/core/Deal.Infrastructure/Integrations/Services/GrpcMlClient.cs index 59920aa..e85bc9a 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/GrpcMlClient.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/GrpcMlClient.cs @@ -17,43 +17,22 @@ using Microsoft.Extensions.Logging; namespace Deal.Infrastructure.Integrations.Services; /// -/// gRPC-адаптер порта IMlClient к автономному ml-service (Ruling 4/6, план Task 16 L438–445). +/// gRPC-адаптер порта IMlClient к автономному ml-service. /// -/// -/// Регистрируется вместо 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 контрактов: локальная модель). + /// Deadline Predict — 5 с /// public const int PredictDeadlineSeconds = 5; /// - /// Deadline Status/Reset — 10 с (README контрактов). + /// Deadline Status/Reset — 10 с /// public const int StatusDeadlineSeconds = 10; /// - /// Deadline TrainBatch — 30 с (README контрактов: батч ≤100, 1 транзакция). + /// Deadline TrainBatch — 30 с /// public const int TrainBatchDeadlineSeconds = 30; @@ -78,7 +57,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient // Кэш статуса сервиса на тенанта (15 с). private readonly MlStatusCache _statusCache; - // Recorder истории расхода (ML-событие расхода, этап 10, T2): оценка токенов входного текста. private readonly TokenUsageRecorder _usageRecorder; // Логгер сбоев вызовов ml-service. @@ -92,7 +70,7 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient /// Хранилище обучения ML (очередь MlOutbox + журнал). /// Транспорт ml-service (singleton-канал + service-token). /// Кэш статуса сервиса на тенанта (singleton). - /// Recorder истории расхода (ML-событие predict, этап 10, T2). + /// Recorder истории расхода. /// Логгер сбоев. public GrpcMlClient( ITenantContext tenantContext, @@ -155,14 +133,12 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient 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; } @@ -182,7 +158,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient } 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)); } @@ -192,7 +167,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient 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); @@ -205,8 +179,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient double delta, CancellationToken ct) { - // Обучение гарантированно и локально (Ruling 6): сигнал всегда пишется в MlOutbox, отправку батчами - // делает MlOutboxFlushScheduler — и в Local-, и в gRPC-режиме (ml_client.py L6–7). await MlOutboxQueue.PushAsync(_learningStore, text, label, delta, ct); } @@ -251,7 +223,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient return fresh; } - // Последние известные данные (при сбое refresh останутся они — python refresh_status L132–135). _statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot stale); MlServiceStatusDto previous = stale?.Service ?? NotReadyServiceStatus; @@ -275,7 +246,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient } } - // Маппит ответ Status в контрактный статус модели (поля 1:1 с MlServiceStatusDto). // reply: Ответ ml-service. // Возвращает: DTO статуса модели. private static MlServiceStatusDto MapStatus(StatusReply reply) @@ -290,7 +260,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient Accuracy: reply.Eval?.Accuracy ?? 0.0)); } - // Маппит ответ Predict в контрактный результат (поля 1:1 с MlPredictResultDto). // reply: Ответ ml-service. // Возвращает: DTO предсказания. private static MlPredictResultDto MapPredict(PredictReply reply) @@ -320,7 +289,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient Margin: decision.Margin); } - // CallOptions вызова: metadata tenant-id/service-token + deadline + токен отмены (Ruling 1). // tenantId: Id тенанта (формат N). // deadline: Лимит времени вызова. // ct: Токен отмены вызова. @@ -342,7 +310,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient ? "ML-сервис недоступен" : "ML-сервис не ответил — повторите попытку через несколько секунд"; - // Фиксированный ответ неготовой/недоступной модели: «не уверен» (Ruling 5, ml.proto L21–23). private static MlPredictResultDto NotReadyPrediction => new( Take: false, Label: null, @@ -360,7 +327,6 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient 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) diff --git a/src/core/Deal.Infrastructure/Integrations/Services/GrpcTelegramClient.cs b/src/core/Deal.Infrastructure/Integrations/Services/GrpcTelegramClient.cs index f9c13e4..a76c4eb 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/GrpcTelegramClient.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/GrpcTelegramClient.cs @@ -10,44 +10,27 @@ using Microsoft.Extensions.Logging; namespace Deal.Infrastructure.Integrations.Services; /// -/// gRPC-адаптер порта к автономному telegram-service (Ruling 6/7, план Task 14). +/// gRPC-адаптер порта к автономному telegram-service. /// -/// -/// Регистрируется вместо 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). + /// Deadline локальных команд статуса/зеркала — 10 с. /// public const int ShortDeadlineSeconds = 10; /// - /// Deadline сетевых команд Telegram — 60 с (README L67: паузы анти-бана внутри сервиса). + /// Deadline сетевых команд Telegram — 60 с. /// public const int CommandDeadlineSeconds = 60; /// - /// Deadline тяжёлых команд каталога/backfill — 120 с (README L68: iter_dialogs 500, backfill). + /// Deadline тяжёлых команд каталога/backfill — 120 с. /// 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). @@ -415,10 +398,8 @@ public sealed class GrpcTelegramClient : ITelegramGateway ?? 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. @@ -440,7 +421,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway // Нормализует транспортные сбои в RpcException «Telegram не подключён»; RpcException домена — как есть. // Отмена по токену вызывающего пробрасывается без нормализации (не сбой сервиса). Доменные // RPC-ошибки (INVALID_ARGUMENT/FAILED_PRECONDITION/…) несут канонический detail — их трогать нельзя: - // текст причины 1:1 уходит в {detail} эндпоинтов (Ruling 7/8). // exception: Исключение вызова. // tenantId: Id тенанта (лог). // operation: Имя RPC (лог-аудит). @@ -457,7 +437,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway } // Доменная 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))) @@ -469,7 +448,6 @@ public sealed class GrpcTelegramClient : ITelegramGateway 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) diff --git a/src/core/Deal.Infrastructure/Integrations/Services/LocalAiClassifier.cs b/src/core/Deal.Infrastructure/Integrations/Services/LocalAiClassifier.cs index 7c82496..09bf317 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/LocalAiClassifier.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/LocalAiClassifier.cs @@ -7,31 +7,14 @@ 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)); } diff --git a/src/core/Deal.Infrastructure/Integrations/Services/LocalAiTools.cs b/src/core/Deal.Infrastructure/Integrations/Services/LocalAiTools.cs index 3866794..33e42fc 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/LocalAiTools.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/LocalAiTools.cs @@ -4,16 +4,8 @@ using Deal.Contracts.Integrations.Models; namespace Deal.Infrastructure.Integrations.Services; /// -/// Локальная реализация без внешнего ИИ-сервиса (Ruling 9, план Task 15). +/// Локальная реализация без внешнего ИИ-сервиса. /// -/// -/// Регистрируется при Services:Ai:UseLocal=true (default, Ruling 6). Методы НЕ поддерживаются — на -/// этапе 6 локальной генерации ключей/оценки fit нет: Discovery-воркер сам выбирает эвристику (при -/// aiEnabled=false или сбое, python discovery_eval L186–194), а generate-keywords-эндпоинт (Task 19) ловит -/// исключение и отдаёт мягкую ошибку {keywords: [], error} (Ruling 11). NotSupportedException — явный сигнал -/// «вызов порта в локальном режиме — ошибка сценария», чтобы будущий потребитель (Discovery) не получил -/// молча пустые ключи/ложный fit. Scoped-зависимостей нет (экземпляр лёгкий, как LocalAiClassifier на дефолты). -/// public sealed class LocalAiTools : IAiTools { // Сообщение исключения методов (локальный режим = ai-service не подключён). diff --git a/src/core/Deal.Infrastructure/Integrations/Services/LocalColumnSuggester.cs b/src/core/Deal.Infrastructure/Integrations/Services/LocalColumnSuggester.cs index 0fb9ec3..fa9befb 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/LocalColumnSuggester.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/LocalColumnSuggester.cs @@ -15,51 +15,30 @@ using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRule 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). +/// KV-хранилище настроек тенанта. /// Сервис контейнеров: список существующих и создание 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"; /// @@ -80,7 +59,6 @@ ContainersService containersService) : IColumnSuggester Cooldown: false); } - // Существующие (suggested=false) колонки: похожие темы не предлагаем (suggest.py L105, L138–139). IReadOnlyList containers = await containersService.ListAsync(ContainerSpaces.Dashboard, ct); IReadOnlyList existingNames = containers .Where(container => !container.Suggested) @@ -96,7 +74,6 @@ ContainersService containersService) : IColumnSuggester 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); } @@ -107,7 +84,6 @@ ContainersService containersService) : IColumnSuggester /// 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 @@ -131,22 +107,17 @@ ContainersService containersService) : IColumnSuggester 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); @@ -195,7 +166,6 @@ ContainersService containersService) : IColumnSuggester if (placed == 0) { - // Ничего не легло — пустое предложение не нужно (suggest.py L152–156). await containersService.DeleteAsync(container.Id, ct); continue; } @@ -207,8 +177,6 @@ ContainersService containersService) : IColumnSuggester } // Сработал ли кулдаун: с последнего успешного предложения прошло меньше 20 минут. - // Повреждённое/отсутствующее значение lastSuggestAt — кулдауна нет (как прототип: значение - // пишется только после успеха, L160–161; битый KV — дефолт «никогда»). // ct: Токен отмены. // Возвращает: True — повторный вызов слишком рано (ответ {ok:false, reason, cooldown:true}). private async Task WithinCooldownAsync(CancellationToken ct) @@ -236,7 +204,6 @@ ContainersService containersService) : IColumnSuggester } } - // Записывает метку успешного предложения (suggest.py L160: set_setting(KEY, time.time())). // ct: Токен отмены. private Task WriteLastSuggestAtAsync(CancellationToken ct) => settings.SetAsync( diff --git a/src/core/Deal.Infrastructure/Integrations/Services/LocalMlClient.cs b/src/core/Deal.Infrastructure/Integrations/Services/LocalMlClient.cs index 8f16bf1..6ef2d57 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/LocalMlClient.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/LocalMlClient.cs @@ -8,36 +8,19 @@ using Deal.Modules.Settings.Application.Models; namespace Deal.Infrastructure.Integrations.Services; /// -/// Локальная реализация без внешнего ML-сервиса (Ruling 4, план Task 5 L266–286). +/// Локальная реализация без внешнего ML-сервиса. /// -/// -/// Этап 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, @@ -48,9 +31,6 @@ public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learnin 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); @@ -70,7 +50,6 @@ public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learnin /// public Task PredictAsync(string text, CancellationToken ct) { - // Неготовая модель ничего не решает (Ruling 5 L79–80) — текст не влияет на ответ. return Task.FromResult(new MlPredictResultDto( Take: false, Label: null, @@ -85,8 +64,6 @@ public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learnin /// 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); } @@ -98,13 +75,10 @@ public sealed class LocalMlClient(ISettingsStore store, IMlLearningStore learnin 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) diff --git a/src/core/Deal.Infrastructure/Integrations/Services/LocalTelegramGateway.cs b/src/core/Deal.Infrastructure/Integrations/Services/LocalTelegramGateway.cs index 602e792..5c95cc4 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/LocalTelegramGateway.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/LocalTelegramGateway.cs @@ -4,18 +4,8 @@ using Deal.Contracts.Integrations.Models; namespace Deal.Infrastructure.Integrations.Services; /// -/// Локальная заглушка без telegram-service (Ruling 6, план Task 13/14). +/// Локальная заглушка без telegram-service. /// -/// -/// Регистрируется как дефолт dev (до появления gRPC-клиента GrpcTelegramClient под флагом -/// Services:Telegram:UseLocal=false — Ruling 6): реальный telegram-service в dev не поднят, поэтому гейт -/// нейтрален — статус «idle/не подключён» (1:1 форма «сервис недоступен → idle-форма», Ruling 8), команды — -/// no-op, выборки пусты. На этапе 6 (Task 14 curl-приёмка) эндпоинты тестируются фейк-реализацией гейта в -/// тестах (не этой заглушкой); заглушка гарантирует разрешимость графа DI до подключения сервиса. -/// Команды подключения (StartPhone/StartQr/SendCode/SendPassword/Logout) и discovery-операции (Search/Info/ -/// ReadForEval/Join/Leave) без сервиса не имеют смысла — их ветки эндпоинтов/воркера отдают ошибку -/// «Telegram не подключён» по статусу подключения (Ruling 7), сам гейт их не вызывает. -/// public sealed class LocalTelegramGateway : ITelegramGateway { // Фаза idle-формы (аккаунт не подключён — сервиса нет). @@ -24,7 +14,6 @@ public sealed class LocalTelegramGateway : ITelegramGateway /// public Task StatusAsync(CancellationToken ct) { - // «Сервис недоступен → idle-форма» (Ruling 8): connected=false, live-поля пусты. return Task.FromResult(new TelegramAccountStatusDto(IdlePhase, false, false, string.Empty, null, null)); } diff --git a/src/core/Deal.Infrastructure/Integrations/Services/MlOutboxQueue.cs b/src/core/Deal.Infrastructure/Integrations/Services/MlOutboxQueue.cs index 7fcb151..4ffa670 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/MlOutboxQueue.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/MlOutboxQueue.cs @@ -4,28 +4,21 @@ 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, @@ -49,7 +42,6 @@ internal static class MlOutboxQueue ct); } - // Генерирует id строки outbox: префикс mle_ + 12 случайных hex-символов (прототип store.uid). // Возвращает: Короткий id записи очереди. private static string NewOutboxId() => KanbanIdPrefixes.MlOutbox + Convert.ToHexString(RandomNumberGenerator.GetBytes(OutboxIdRandomBytes)).ToLowerInvariant(); @@ -57,7 +49,6 @@ internal static class MlOutboxQueue // Обрезает текст до MaxLearningTextLength символов, не разбивая суррогатную пару на конце. // text: Текст (уже trim-нут). // Возвращает: Первые 6000 символов (или весь текст, если короче). - // .NET-срез идёт по UTF-16-единицам и может разбить суррогатную пару; Python-срез прототипа // (text[:6000]) режет по code points — хвостовой high-surrogate убираем, чтобы в БД не ушла «битая» пара. private static string TruncateText(string text) { diff --git a/src/core/Deal.Infrastructure/Integrations/Services/MlStatusCache.cs b/src/core/Deal.Infrastructure/Integrations/Services/MlStatusCache.cs index c9ab671..4970db6 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/MlStatusCache.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/MlStatusCache.cs @@ -4,18 +4,12 @@ using Deal.Contracts.Integrations.Models; namespace Deal.Infrastructure.Integrations.Services; /// -/// Кэш статуса ML-сервиса на тенанта (python ml_client L30–31 + refresh_status L127–135; Ruling 6). +/// Кэш статуса ML-сервиса на тенанта. /// -/// -/// Кэш живёт 15 секунд и хранит последний известный статус + флаг reachable: обновление происходит -/// при вызове GrpcMlClient.StatusAsync, когда запись устарела/отсутствует; при сбое сервиса строка -/// остаётся со старыми данными и reachable=false (python L132–135). Singleton: кэш переживает scope -/// запросов (в /api/ml/status и фоновых циклах тенант один и тот же), ключ — id тенанта (формат N). -/// public sealed class MlStatusCache { /// - /// Время жизни кэша статуса сервиса — 15 с (refresh_status python L30–31). + /// Время жизни кэша статуса сервиса — 15 с. /// public const int CacheTtlSeconds = 15; @@ -33,7 +27,7 @@ public sealed class MlStatusCache private readonly Func _utcNow; /// - /// Создаёт кэш с системными часами (DateTimeOffset.UtcNow). + /// Создаёт кэш с системными часами /// public MlStatusCache() : this(() => DateTimeOffset.UtcNow) @@ -41,7 +35,7 @@ public sealed class MlStatusCache } /// - /// Создаёт кэш с заданными часами (тесты TTL 15 с). + /// Создаёт кэш с заданными часами /// /// Источник текущего времени (UTC). public MlStatusCache(Func utcNow) @@ -51,7 +45,7 @@ public sealed class MlStatusCache } /// - /// Возвращает свежую запись кэша (возраст ≤ ). + /// Возвращает свежую запись кэша /// /// Id тенанта (формат N). /// Свежая запись (если есть). @@ -73,7 +67,7 @@ public sealed class MlStatusCache } /// - /// Возвращает последнюю запись независимо от возраста (для «старые данные при сбое», python L134). + /// Возвращает последнюю запись независимо от возраста. /// /// Id тенанта (формат N). /// Последняя запись (если есть). @@ -81,7 +75,7 @@ public sealed class MlStatusCache public bool TryGet(string tenantId, out Snapshot snapshot) => _entries.TryGetValue(tenantId, out snapshot!); /// - /// Сохраняет запись статуса (момент обновления — сейчас). + /// Сохраняет запись статуса /// /// Id тенанта (формат N). /// Статус модели. @@ -95,7 +89,7 @@ public sealed class MlStatusCache } /// - /// Помечает запись устаревшей (сброс модели, python reset_model L123 — refresh после сброса). + /// Помечает запись устаревшей. /// /// Id тенанта (формат N). public void Invalidate(string tenantId) => _entries.TryRemove(tenantId, out _); diff --git a/src/core/Deal.Infrastructure/Integrations/Services/ServiceHealthProbe.cs b/src/core/Deal.Infrastructure/Integrations/Services/ServiceHealthProbe.cs index ce8a162..4b2eb94 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/ServiceHealthProbe.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/ServiceHealthProbe.cs @@ -7,41 +7,30 @@ 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). +/// Health-проба grpc.health.v1 автономных сервисов /// -/// -/// Каждый вызов строит свой короткоживущий канал к 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 с на сервис). + /// Дедлайн health-RPC, секунд. /// public const int HealthTimeoutSeconds = 3; private readonly MtlsCertificates? _mtlsCertificates; /// - /// Создаёт пробу; mTLS-каналы — при переданных сертификатах (иначе plaintext, dev). + /// Создаёт пробу; mTLS-каналы — при переданных сертификатах /// - /// Сертификаты mTLS (Ruling 6, Task 13): null — plaintext-канал. + /// Сертификаты mTLS: null — plaintext-канал. public ServiceHealthProbe(MtlsCertificates? mtlsCertificates = null) { _mtlsCertificates = mtlsCertificates; } /// - /// Проверяет health-контракт gRPC-сервиса по базовому адресу (grpc.health.v1, сервис ""). + /// Проверяет health-контракт gRPC-сервиса по базовому адресу /// /// Базовый адрес сервиса (http://host:port; пустой/пробельный — ошибка аргумента). - /// Токен отмены вызывающего. /// Результат пробы (см. ). public async Task ProbeAsync(string endpoint, CancellationToken ct) { diff --git a/src/core/Deal.Infrastructure/Integrations/Services/TokenUsageRecorder.cs b/src/core/Deal.Infrastructure/Integrations/Services/TokenUsageRecorder.cs index ce7d531..5033fa0 100644 --- a/src/core/Deal.Infrastructure/Integrations/Services/TokenUsageRecorder.cs +++ b/src/core/Deal.Infrastructure/Integrations/Services/TokenUsageRecorder.cs @@ -13,36 +13,16 @@ 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). +/// Recorder расхода токенов /// -/// -/// Точка вызова — та же, что у этапа 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; @@ -56,7 +36,7 @@ public sealed class TokenUsageRecorder /// KV-хранилище настроек тенанта (ключ aiTokenUsage, lifetime-счётчик). /// Хранилище лимитов бюджета (public.tenant_limits, списание периода). /// Контекст текущего тенанта (AsyncLocal; tenantId списания/события). - /// Сервис истории расхода (public.token_usage_events, этап 10). + /// Сервис истории расхода. public TokenUsageRecorder( ISettingsStore store, ITenantLimitStore tenantLimits, @@ -74,13 +54,11 @@ public sealed class TokenUsageRecorder } /// - /// Списывает usage ответа ai-service: (1) инкремент бюджета периода в tenant_limits, (2) lifetime-сумму - /// в KV aiTokenUsage, (3) событие истории (kind=ai). usage null — no-op (успешный RPC без оценки токенов). + /// Списывает usage ответа ai-service /// /// Оценка токенов ответа (Usage ai.proto; reply без usage — нули; null — no-op). /// Id активного провайдера (deepseek/openai/anthropic/…; событие истории). /// Модель провайдера (событие истории). - /// Токен отмены. public async Task AddAsync( Usage? usage, string provider, @@ -98,7 +76,6 @@ public sealed class TokenUsageRecorder } await AddToLifetimeAsync(usage, ct); - // Прикладная метрика (этап 12, пакет A): счётчик вызовов/токенов ИИ — та же точка, что и событие // token_usage_events (без tenantId в метках). DealMetrics.RecordAiUsage(usage.Prompt, usage.Completion); await RecordEventAsync( @@ -112,13 +89,11 @@ public sealed class TokenUsageRecorder } /// - /// Записывает событие локального ML-вызова (kind=ml) с оценкой токенов по длине входного текста - /// (≈chars/4, конвенция ai.proto): бюджет/lifetime aiTokenUsage ML не затрагивает. + /// Записывает событие локального ML-вызова /// /// Входной текст предсказания (оценка токенов запроса; null — 0). /// Провайдер/источник события (для локальной ML-модели — "local"). /// Модель/вид локального ML-вызова (событие истории). - /// Токен отмены. /// Оценка токенов (для тестов/наблюдаемости). public async Task AddEstimatedAsync( string? text, @@ -127,7 +102,6 @@ public sealed class TokenUsageRecorder CancellationToken ct) { long promptTokens = EstimateTokens(text); - // Прикладная метрика (этап 12, пакет A): вызов локального ML + оценка токенов (та же точка, // что и событие token_usage_events, kind=ml). DealMetrics.RecordMlUsage(promptTokens); await RecordEventAsync( @@ -142,7 +116,7 @@ public sealed class TokenUsageRecorder } /// - /// Оценка токенов по символам (≈chars/4; конвенция проекта, ai.proto Ruling 5). + /// Оценка токенов по символам. /// /// Текст (null/пустой — 0). /// Оценка токенов (неотрицательная). @@ -196,7 +170,6 @@ public sealed class TokenUsageRecorder return id; } - // Прибавляет usage к накопленному значению aiTokenUsage (lifetime-счётчик, формат этапа 6). // usage: Оценка токенов ответа. // ct: Токен отмены. private async Task AddToLifetimeAsync(Usage usage, CancellationToken ct) diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/MinioStorageOptionsExtensions.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/MinioStorageOptionsExtensions.cs index c25da36..d394f73 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/MinioStorageOptionsExtensions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/MinioStorageOptionsExtensions.cs @@ -3,12 +3,12 @@ using Deal.Infrastructure.Integrations.Storage.Options; namespace Deal.Infrastructure.Integrations.Storage.Extensions; /// -/// Расширения (выбор MinIO-адаптера по заполненности секции). +/// Расширения /// internal static class MinioStorageOptionsExtensions { /// - /// True — секция Minio заполнена настолько, что возможен Minio-адаптер (Ruling 4: Endpoint + креды). + /// True — секция Minio заполнена настолько, что возможен Minio-адаптер. /// /// Настройки MinIO из секции Storage:Minio. /// True — заданы Endpoint, AccessKey и SecretKey. diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/StringExtensions.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/StringExtensions.cs index 52b773f..3d988a1 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/StringExtensions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Extensions/StringExtensions.cs @@ -6,7 +6,7 @@ namespace Deal.Infrastructure.Integrations.Storage.Extensions; internal static class StringExtensions { /// - /// Разбирает строковое значение как булев флаг конфигурации: «true» (без учёта регистра) или «1». + /// Разбирает строковое значение как булев флаг конфигурации /// /// Сырое значение настройки. /// True — значение распознано как включённое. diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Options/LocalStorageOptions.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Options/LocalStorageOptions.cs index f860495..9b1face 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Options/LocalStorageOptions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Options/LocalStorageOptions.cs @@ -3,18 +3,12 @@ using Deal.Infrastructure.Integrations.Storage.Services; namespace Deal.Infrastructure.Integrations.Storage.Options; /// -/// Локальный режим файлового хранилища — секция Storage:Local (Ruling 4, Task 6). +/// Локальный режим файлового хранилища — секция Storage:Local. /// -/// -/// Root — каталог вложений: относительный путь резолвится от ContentRoot приложения, абсолютный — как есть -/// (см. ). Пустая секция → дефолт -/// data/attachments под ContentRoot (fallback прототипа object_store.py L54–79: FILES_DIR = -/// DATA_DIR/attachments). Режим Local — dev/curl/unit по умолчанию: выбирается, когда MinIO не сконфигурирован. -/// public sealed class LocalStorageOptions { /// - /// Каталог вложений (относительно ContentRoot либо абсолютный); пусто — data/attachments. + /// Каталог вложений /// public string? Root { get; set; } } diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Options/MinioStorageOptions.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Options/MinioStorageOptions.cs index 61e212f..6b2b651 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Options/MinioStorageOptions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Options/MinioStorageOptions.cs @@ -3,28 +3,17 @@ using Deal.Infrastructure.Integrations.Storage.Services; namespace Deal.Infrastructure.Integrations.Storage.Options; /// -/// MinIO-режим файлового хранилища — секция Storage:Minio (Ruling 4, Task 6). +/// MinIO-режим файлового хранилища — секция Storage:Minio. /// -/// -/// Источник — секция Storage:Minio (appsettings.json + env Storage__Minio__Endpoint, -/// Storage__Minio__AccessKey, Storage__Minio__SecretKey, Storage__Minio__Bucket, -/// Storage__Minio__Secure; 1:1 с Ruling 4 «креды Storage:Minio … из appsettings/env Storage__Minio__*»). -/// Если секция не задана, регистратор заполняет её из env-алиасов DEAL_MINIO_ENDPOINT/ -/// DEAL_MINIO_ACCESS_KEY/DEAL_MINIO_SECRET_KEY/DEAL_MINIO_BUCKET/DEAL_MINIO_SECURE -/// (аналог LEADRADAR_MINIO_* config.py прототипа). Адаптер регистрируется, -/// только когда Endpoint и AccessKey/SecretKey заполнены (Ruling 4: иначе LocalFileStorage — «заглушка, -/// если MinIO недоступен»). Бакет — единственный (объекты всех карточек в одном бакете, как в прототипе; -/// мульти-аренда объектного хранилища — этап 7 SaaS), по умолчанию deal-files. -/// public sealed class MinioStorageOptions { /// - /// Имя бакета по умолчанию (Ruling 4; python MINIO_BUCKET дефолт из config). + /// Имя бакета по умолчанию. /// public const string DefaultBucketName = "deal-files"; /// - /// Хост:порт MinIO (например, localhost:9000 или play.min.io). + /// Хост:порт MinIO /// public string? Endpoint { get; set; } @@ -44,7 +33,7 @@ public sealed class MinioStorageOptions public string Bucket { get; set; } = DefaultBucketName; /// - /// True — HTTPS (WithSSL); dev-compose deal-minio — false (http). + /// True — HTTPS /// public bool Secure { get; set; } } diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Options/StorageOptions.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Options/StorageOptions.cs index 60545dd..4f22a92 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Options/StorageOptions.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Options/StorageOptions.cs @@ -3,25 +3,17 @@ using Deal.Infrastructure.Integrations.Storage.Services; namespace Deal.Infrastructure.Integrations.Storage.Options; /// -/// Настройки файлового хранилища — секция Storage конфигурации (Ruling 4, Task 6). +/// Настройки файлового хранилища — секция Storage конфигурации. /// -/// -/// Источник — секция Storage (appsettings.json + env Storage__Local__Root, -/// Storage__Minio__Endpoint и т.д.; см. ) — плюс env-алиасы -/// DEAL_MINIO_* (аналог LEADRADAR_MINIO_* прототипа), которые заполняют секцию Minio, если она не -/// задана (см. ). Читается регистратором вручную -/// (секция маленькая; Binder в Infrastructure не тянем). LocalFileStorage — dev/unit по умолчанию; -/// MinioFileStorage регистрируется, только когда Minio сконфигурирован (Ruling 4). -/// public sealed class StorageOptions { /// - /// Настройки локального режима (root-каталог относительно ContentRoot). + /// Настройки локального режима /// public LocalStorageOptions Local { get; set; } = new(); /// - /// Настройки MinIO-режима (endpoint/креды/бакет). + /// Настройки MinIO-режима /// public MinioStorageOptions Minio { get; set; } = new(); } diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs index 571d1b3..9f57e1c 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs @@ -8,30 +8,17 @@ using Microsoft.Extensions.Logging; namespace Deal.Infrastructure.Integrations.Storage.Services; /// -/// DI-регистрация файлового хранилища: выбор Local/MinIO по конфигурации (Ruling 4, Task 6). +/// DI-регистрация файлового хранилища /// -/// -/// читает секцию 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). + /// Дефолтный каталог вложений локального режима относительно ContentRoot. /// public const string DefaultAttachmentsRelativePath = "data/attachments"; @@ -49,7 +36,7 @@ public static class FileStorageRegistrar private const string MinioEnvironmentSecureVariableName = "DEAL_MINIO_SECURE"; /// - /// Регистрирует IFileStorage — LocalFileStorage или MinioFileStorage по конфигурации (Ruling 4). + /// Регистрирует IFileStorage — LocalFileStorage или MinioFileStorage по конфигурации. /// /// Коллекция сервисов. /// Конфигурация приложения (секция Storage + env-алиасы DEAL_MINIO_*). @@ -64,7 +51,6 @@ public static class FileStorageRegistrar StorageOptions options = ReadOptions(configuration); - // MinIO-режим: только когда секция/алиасы заполнены (Ruling 4: «заглушка-адаптер, если MinIO // недоступен» — dev/curl/unit по умолчанию работают на LocalFileStorage без MinIO). if (options.Minio.IsConfigured()) { @@ -152,7 +138,6 @@ public static class FileStorageRegistrar return string.IsNullOrWhiteSpace(fromEnvironment) ? null : fromEnvironment; } - // Env-алиас DEAL_MINIO_* для ключа секции Storage:Minio (аналог LEADRADAR_MINIO_*); Local-ключи алиасов не имеют. private static string? EnvironmentAliasFor(string sectionKey) { return sectionKey switch diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Services/LocalFileStorage.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Services/LocalFileStorage.cs index abb1bcf..b5e9afc 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Services/LocalFileStorage.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Services/LocalFileStorage.cs @@ -4,21 +4,8 @@ using Deal.Contracts.Integrations.Models; namespace Deal.Infrastructure.Integrations.Storage.Services; /// -/// Локальное файловое хранилище вложений — каталог на диске (Ruling 4, Task 6; 1:1 object_store.py L54–79). +/// Локальное файловое хранилище вложений — каталог на диске. /// -/// -/// Dev/curl/unit-режим по умолчанию: используется, когда MinIO не сконфигурирован (Ruling 4 — «заглушка- -/// адаптер, если MinIO недоступен»). Root — абсолютный каталог (по умолчанию data/attachments под -/// ContentRoot, резолвит ). Путь из objectKey строится безопасно: -/// ключ делится на сегменты по /\ — защита не зависит от ОС), сегменты ./.. -/// запрещены, итоговый полный путь обязан лежать внутри root (object_store.py L54–79 — «не даём выйти за -/// FILES_DIR»). Put — mkdir родителя + запись потока с позиции 0 (Ruling T6: перемотаемый поток сбрасывается -/// в 0 — в отличие от MinIO-адаптера локальный поток не буферизуется: длина тут не нужна); Get — FileStream|null; -/// Stat — FileInfo-дескриптор (размер; contentType пуст — см. ниже); Delete — удаление файла. ContentType не хранится (как -/// прототип: локально пишутся только байты) — дескриптор StatAsync несёт пустой MIME, и download-эндпоинт (Task 9) -/// отвечает фиксированным application/octet-stream (Ruling 4/T6). -/// Потокобезопасен (состояние — только root); регистрируется singleton. -/// public sealed class LocalFileStorage : IFileStorage { // Размер буфера чтения при скачивании (async FileStream). @@ -39,7 +26,7 @@ public sealed class LocalFileStorage : IFileStorage } /// - /// Описание режима для стартового лога Api (приёмка Task 6: LocalFileStorage + путь data/attachments). + /// Описание режима для стартового лога Api. /// /// Строка вида LocalFileStorage (root: …). public override string ToString() => $"LocalFileStorage (root: {_rootPath})"; @@ -53,7 +40,6 @@ public sealed class LocalFileStorage : IFileStorage { ArgumentNullException.ThrowIfNull(content); - // Контракт порта (Ruling T6): Put читает ВСЁ содержимое с позиции 0 — поток-источник (multipart) // может быть прочитан эндпоинтом раньше; перемотаемые потоки сбрасываем (неперемотаемые читаются // с текущей позиции, как есть). Выравнивание с Minio-адаптером PutAsync. if (content.CanSeek && content.Position != 0) @@ -80,7 +66,6 @@ public sealed class LocalFileStorage : IFileStorage return Task.FromResult(null); } - // FileStream отдаётся вызывающему «как есть» (владелец — вызывающий, он же закрывает; python — BytesIO). FileStream stream = new(path, FileMode.Open, FileAccess.Read, FileShare.Read, FileBufferSize, FileOptions.Asynchronous); return Task.FromResult(stream); } @@ -94,8 +79,6 @@ public sealed class LocalFileStorage : IFileStorage return Task.FromResult(null); } - // ContentType локально не хранится (put пишет только байты, как прототип) — дескриптор несёт пустой - // MIME (см. FileMeta); download-эндпоинт (Task 9) отвечает application/octet-stream (Ruling 4/T6). FileInfo info = new(path); return Task.FromResult(new FileMeta(objectKey, info.Length, string.Empty)); } @@ -112,7 +95,6 @@ public sealed class LocalFileStorage : IFileStorage return Task.CompletedTask; } - // Безопасно резолвит objectKey в путь внутри root (object_store.py _local_path L54–79). // objectKey: Ключ объекта (сегменты по '/', без «.»/«..»). // Возвращает: Полный путь файла под root. // Исключение ArgumentException: objectKey пуст либо содержит обходные сегменты «.»/«..». diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Services/MinioFileStorage.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Services/MinioFileStorage.cs index 25a7936..6730772 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Services/MinioFileStorage.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Services/MinioFileStorage.cs @@ -10,24 +10,10 @@ 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 /// -/// -/// Регистрируется, только когда 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; @@ -35,7 +21,6 @@ public sealed class MinioFileStorage : IFileStorage private readonly string _bucket; private readonly ILogger _logger; - // Семафор ленивой проверки/создания бакета (гонка первых put, object_store.py L44–51). private readonly SemaphoreSlim _bucketCheckGate = new(1, 1); private bool _bucketChecked; @@ -76,7 +61,7 @@ public sealed class MinioFileStorage : IFileStorage } /// - /// Описание режима для стартового лога Api (endpoint/бакет, без секретов). + /// Описание режима для стартового лога Api /// /// Строка вида MinioFileStorage (endpoint: …; bucket: …). public override string ToString() => $"MinioFileStorage (endpoint: {_endpoint}; bucket: {_bucket})"; @@ -92,7 +77,6 @@ public sealed class MinioFileStorage : IFileStorage await EnsureBucketAsync(ct); - // Прототип держит байты файла в памяти (put L67–73); MinIO-пути нужна известная длина объекта // (Content-Length), поэтому поток буферизуется — Local-адаптер буферизации не требует. if (content.CanSeek && content.Position != 0) { @@ -137,8 +121,6 @@ public sealed class MinioFileStorage : IFileStorage } catch (Exception) { - // Любая иная ошибка (сеть/MinIO недоступен и т.п.): частично заполненный буфер не течёт (Ruling T6), - // ошибка уходит вызывающему (эндпоинт Task 9 мапит её в 404 «Файл не найден в MinIO»). buffer.Dispose(); throw; } @@ -152,8 +134,6 @@ public sealed class MinioFileStorage : IFileStorage { 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), @@ -178,8 +158,6 @@ public sealed class MinioFileStorage : IFileStorage } catch (MinioException exception) { - // 1:1 object_store.py remove L96–108: сбой MinIO (недоступен, бакет не создан) гасим warning-логом — - // метаданные карточки (FilesJson) чистит сервис в любом случае (Task 7). _logger.LogWarning( exception, "Не удалось удалить объект MinIO «{ObjectKey}» из бакета «{Bucket}»: {Message}", @@ -189,7 +167,6 @@ public sealed class MinioFileStorage : IFileStorage } } - // Ленивая проверка/создание бакета при первом put (object_store.py L26–51). // ct: Токен отмены. private async Task EnsureBucketAsync(CancellationToken ct) { @@ -217,7 +194,6 @@ public sealed class MinioFileStorage : IFileStorage catch (MinioException exception) { // Бакет не проверить/создать (MinIO недоступен и т.п.): put продолжит и упадёт с понятной - // ошибкой; 1:1 object_store.py L47–51 (python логирует warning и не бросает на проверке). _logger.LogWarning( exception, "Не удалось проверить/создать бакет MinIO «{Bucket}»: {Message}", diff --git a/src/core/Deal.Infrastructure/Migrations/TenantSchemaMigrator.cs b/src/core/Deal.Infrastructure/Migrations/TenantSchemaMigrator.cs index 3a03712..f557430 100644 --- a/src/core/Deal.Infrastructure/Migrations/TenantSchemaMigrator.cs +++ b/src/core/Deal.Infrastructure/Migrations/TenantSchemaMigrator.cs @@ -1,12 +1,12 @@ namespace Deal.Infrastructure.Migrations; /// -/// Миграции схем тенантов. Чистые функции формирования SQL. +/// Миграции схем тенантов. /// public static class TenantSchemaMigrator { /// - /// SQL создания схемы тенанта. Имя экранируется (не интерполируется из ввода). + /// SQL создания схемы тенанта. /// public static string CreateSchemaSql(string schemaName) { diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs index 5ea7276..74f5dba 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/AuditLogConfiguration.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация записи аудита: таблица audit_log в схеме public (append-only). +/// EF-конфигурация записи аудита /// public sealed class AuditLogConfiguration : IEntityTypeConfiguration { diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/CardConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/CardConfiguration.cs index 5429ebb..fa572d5 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/CardConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/CardConfiguration.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация карточки канбана: таблица Cards (модель без схемы). +/// EF-конфигурация карточки канбана /// public sealed class CardConfiguration : IEntityTypeConfiguration { @@ -37,7 +37,6 @@ public sealed class CardConfiguration : IEntityTypeConfiguration 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\",''))", diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/CardMoveConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/CardMoveConfiguration.cs index 55df9e7..301794c 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/CardMoveConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/CardMoveConfiguration.cs @@ -5,9 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация журнала действий над карточками: таблица CardMoves (модель без схемы). +/// EF-конфигурация журнала действий над карточками /// -/// Без внешних ключей: журнал живёт дольше карточки (прототип _hard_delete его не чистит). public sealed class CardMoveConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/ContainerConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/ContainerConfiguration.cs index 4a4d6dc..b8efa15 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/ContainerConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/ContainerConfiguration.cs @@ -5,13 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация единого контейнера: таблица Containers (модель без схемы). +/// EF-конфигурация единого контейнера /// -/// -/// Аддитивный слой этапа 9: единый реестр контейнеров (колонки/стадии/зоны) вместо прежних board-строк. -/// переключение хранилища/сервисов — следующими задачами (T3/T4). SearchTsv — полнотекстовый индекс -/// для поиска контейнеров (title/description), как Cards.SearchTsv. -/// public sealed class ContainerConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/DedupEntryConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/DedupEntryConfiguration.cs index 2265263..79a8c26 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/DedupEntryConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/DedupEntryConfiguration.cs @@ -5,12 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация дедуп-хэшей пайплайна: таблица DedupEntries (модель без схемы). +/// EF-конфигурация дедуп-хэшей пайплайна /// -/// -/// Без внешних ключей: LeadId — «мягкая» ссылка на Cards; при жёстком удалении карточки строки чистит -/// приложение (Ruling 3), чтобы «сирота» не блокировала повторное создание карточки. -/// public sealed class DedupEntryConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/DialogConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/DialogConfiguration.cs index ee90660..fb81da2 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/DialogConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/DialogConfiguration.cs @@ -5,9 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация каталога диалогов: таблица Dialogs (модель без схемы; Ruling 7). +/// EF-конфигурация каталога диалогов /// -/// Индексов нет — каталог читается целиком (список вкладки ≤500 диалогов) и по PK. public sealed class DialogConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) @@ -16,7 +15,6 @@ public sealed class DialogConfiguration : IEntityTypeConfiguration 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 4c2d7bf..e4087db 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscBlacklistConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscBlacklistConfiguration.cs @@ -5,13 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация чёрного списка Discovery: таблица DiscBlacklist (модель без схемы; Ruling 9). +/// EF-конфигурация чёрного списка Discovery /// -/// -/// 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) diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscCandidateConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscCandidateConfiguration.cs index 21ab964..5a7f66a 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscCandidateConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscCandidateConfiguration.cs @@ -5,13 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация кандидата Discovery: таблица DiscCandidates (модель без схемы; Ruling 9). +/// EF-конфигурация кандидата Discovery /// -/// -/// 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) diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscLogConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscLogConfiguration.cs index 633ff30..2979532 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscLogConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscLogConfiguration.cs @@ -5,12 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация лога Discovery: таблица DiscLog (модель без схемы; Ruling 9). +/// EF-конфигурация лога Discovery /// -/// -/// 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) diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscTaskConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscTaskConfiguration.cs index caec09b..342d52b 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/DiscTaskConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/DiscTaskConfiguration.cs @@ -5,12 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация задачи поиска Discovery: таблица DiscTasks (модель без схемы; Ruling 9). +/// EF-конфигурация задачи поиска Discovery /// -/// -/// 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) diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/GlobalSettingConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/GlobalSettingConfiguration.cs index 1439a2e..678b8b2 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/GlobalSettingConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/GlobalSettingConfiguration.cs @@ -5,8 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация глобальной (системной) настройки оператора: таблица global_settings -/// в схеме public (ТЗ §4.1/§8.1). +/// EF-конфигурация глобальной /// public sealed class GlobalSettingConfiguration : IEntityTypeConfiguration { diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/InviteConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/InviteConfiguration.cs index ce175a4..fe410a0 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/InviteConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/InviteConfiguration.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация приглашения: таблица invites в схеме public. +/// EF-конфигурация приглашения /// public sealed class InviteConfiguration : IEntityTypeConfiguration { diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/LeadCommentConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/LeadCommentConfiguration.cs index 8be71ce..10c8da2 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/LeadCommentConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/LeadCommentConfiguration.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация комментария карточки: таблица LeadComments (модель без схемы). +/// EF-конфигурация комментария карточки /// public sealed class LeadCommentConfiguration : IEntityTypeConfiguration { @@ -15,7 +15,6 @@ public sealed class LeadCommentConfiguration : IEntityTypeConfiguration x.Id); - // Комментарии карточки читаются вместе с ней; удаление карточки каскадно чистит комментарии (Ruling 10). builder.HasOne() .WithMany() .HasForeignKey(x => x.CardId) diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/MlOutboxConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/MlOutboxConfiguration.cs index b1d35a2..f627805 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/MlOutboxConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/MlOutboxConfiguration.cs @@ -5,9 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация очереди обучающих сигналов ML: таблица MlOutbox (модель без схемы). +/// EF-конфигурация очереди обучающих сигналов ML /// -/// Без внешних ключей: очередь не зависит от карточек и чистится отдельно (ResetAsync). public sealed class MlOutboxConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorConfiguration.cs index 9e01703..e403fde 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorConfiguration.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация оператора: таблица operators в схеме public. +/// EF-конфигурация оператора /// public sealed class OperatorConfiguration : IEntityTypeConfiguration { diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorSessionConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorSessionConfiguration.cs index 1e54805..9596122 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorSessionConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/OperatorSessionConfiguration.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация сессии оператора: таблица operator_sessions в схеме public. +/// EF-конфигурация сессии оператора /// public sealed class OperatorSessionConfiguration : IEntityTypeConfiguration { diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/QueueItemConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/QueueItemConfiguration.cs index 910e8e0..ec7d9be 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/QueueItemConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/QueueItemConfiguration.cs @@ -5,9 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация очереди входящих пайплайна: таблица QueueItems (модель без схемы). +/// EF-конфигурация очереди входящих пайплайна /// -/// Без внешних ключей: очередь не зависит от карточек и чистится воркером/очистками. public sealed class QueueItemConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) @@ -16,7 +15,6 @@ public sealed class QueueItemConfiguration : IEntityTypeConfiguration x.Id); - // Текст сообщения — text; лимит 6000 символов применяет сервис при приёме (Ruling 2), не БД. builder.Property(x => x.Text).HasColumnType("text"); // Выборка pump'а идёт по статусу и времени постановки (status='new', лимит 12). diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/RateLimitCounterConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/RateLimitCounterConfiguration.cs index 887840a..bfd302e 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/RateLimitCounterConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/RateLimitCounterConfiguration.cs @@ -5,8 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация счётчика фиксированного окна: таблица rate_limit_counters в схеме public -/// (этап 12, пакет B — распределённый rate-limit и учёт попыток входа). +/// EF-конфигурация счётчика фиксированного окна /// public sealed class RateLimitCounterConfiguration : IEntityTypeConfiguration { diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/RejectedItemConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/RejectedItemConfiguration.cs index f13425e..44bf94d 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/RejectedItemConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/RejectedItemConfiguration.cs @@ -5,12 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация отсева пайплайна: таблица RejectedItems (модель без схемы). +/// EF-конфигурация отсева пайплайна /// -/// -/// Без внешних ключей — отсев живёт дольше карточки. Тексты Text/Reason/Kw — text (лимиты 6000/500/200 -/// символов применяет сервис). SearchTsv — вычисляемая STORED-колонка tsvector (Ruling 6). -/// public sealed class RejectedItemConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) @@ -23,14 +19,12 @@ public sealed class RejectedItemConfiguration : IEntityTypeConfiguration 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 8bbfa21..7ebfd6f 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/SessionConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/SessionConfiguration.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация сессии: таблица sessions в схеме public. +/// EF-конфигурация сессии /// public sealed class SessionConfiguration : IEntityTypeConfiguration { diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/TenantConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/TenantConfiguration.cs index 75c176f..6d789ac 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/TenantConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/TenantConfiguration.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация тенанта: таблица tenants в схеме public. +/// EF-конфигурация тенанта /// public sealed class TenantConfiguration : IEntityTypeConfiguration { diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/TenantLimitConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/TenantLimitConfiguration.cs index 974d465..f089cf0 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/TenantLimitConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/TenantLimitConfiguration.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация лимита тенанта: таблица tenant_limits в схеме public. +/// EF-конфигурация лимита тенанта /// public sealed class TenantLimitConfiguration : IEntityTypeConfiguration { diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/TenantSettingConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/TenantSettingConfiguration.cs index 276f8a5..c1df3a6 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/TenantSettingConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/TenantSettingConfiguration.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация настройки тенанта: таблица settings в схеме тенанта (модель без схемы). +/// EF-конфигурация настройки тенанта /// public sealed class TenantSettingConfiguration : IEntityTypeConfiguration { diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/TgMessageConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/TgMessageConfiguration.cs index a436bd9..5e8ceb1 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/TgMessageConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/TgMessageConfiguration.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация превью-сообщений: таблица TgMessages (модель без схемы; Ruling 7, db.py L67–74). +/// EF-конфигурация превью-сообщений /// public sealed class TgMessageConfiguration : IEntityTypeConfiguration { @@ -15,7 +15,6 @@ public sealed class TgMessageConfiguration : IEntityTypeConfiguration 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 96d1d5e..cf07742 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/TokenUsageEventConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/TokenUsageEventConfiguration.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация события расхода токенов: таблица token_usage_events в схеме public (append-only). +/// EF-конфигурация события расхода токенов /// public sealed class TokenUsageEventConfiguration : IEntityTypeConfiguration { diff --git a/src/core/Deal.Infrastructure/Persistence/Configurations/UserConfiguration.cs b/src/core/Deal.Infrastructure/Persistence/Configurations/UserConfiguration.cs index cb0c20c..a58d63b 100644 --- a/src/core/Deal.Infrastructure/Persistence/Configurations/UserConfiguration.cs +++ b/src/core/Deal.Infrastructure/Persistence/Configurations/UserConfiguration.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Deal.Infrastructure.Persistence.Configurations; /// -/// EF-конфигурация пользователя: таблица users в схеме public. +/// EF-конфигурация пользователя /// public sealed class UserConfiguration : IEntityTypeConfiguration { diff --git a/src/core/Deal.Infrastructure/Persistence/DealDbContext.cs b/src/core/Deal.Infrastructure/Persistence/DealDbContext.cs index 575a8b5..a45692d 100644 --- a/src/core/Deal.Infrastructure/Persistence/DealDbContext.cs +++ b/src/core/Deal.Infrastructure/Persistence/DealDbContext.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence; /// -/// Системный DbContext: схема public (тенанты, пользователи, сессии, операторы, инвайты, лимиты, аудит). +/// Системный DbContext /// public sealed class DealDbContext(DbContextOptions options) : DbContext(options) { @@ -26,18 +26,17 @@ public sealed class DealDbContext(DbContextOptions options) : DbC 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(); diff --git a/src/core/Deal.Infrastructure/Persistence/DealDbDesignTimeFactory.cs b/src/core/Deal.Infrastructure/Persistence/DealDbDesignTimeFactory.cs index 6cb40ab..0f7895c 100644 --- a/src/core/Deal.Infrastructure/Persistence/DealDbDesignTimeFactory.cs +++ b/src/core/Deal.Infrastructure/Persistence/DealDbDesignTimeFactory.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore.Design; namespace Deal.Infrastructure.Persistence; /// -/// Фабрика для dotnet-ef (миграции). Читает строку подключения из env. +/// Фабрика для dotnet-ef /// public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory { diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/AuditLogEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/AuditLogEntity.cs index 3a73192..5360008 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/AuditLogEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/AuditLogEntity.cs @@ -1,7 +1,7 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Запись аудита (append-only) в системной схеме public. +/// Запись аудита /// public sealed class AuditLogEntity { @@ -26,7 +26,7 @@ public sealed class AuditLogEntity public string? Ip { get; set; } /// - /// Детали события в JSON (без секретов). + /// Детали события в JSON /// public string? DetailJson { get; set; } } diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/CardEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/CardEntity.cs index d6859b0..2bfcc5d 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/CardEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/CardEntity.cs @@ -3,33 +3,32 @@ using NpgsqlTypes; namespace Deal.Infrastructure.Persistence.Entities; /// -/// Карточка канбана: таблица Cards в схеме тенанта. Соответствует таблице leads прототипа. +/// Карточка канбана /// public sealed class CardEntity { /// - /// Короткий id карточки (префикс c_), первичный ключ. + /// Короткий id карточки /// 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; } @@ -39,32 +38,32 @@ public sealed class CardEntity public bool IsVacancyKnown { get; set; } /// - /// Заголовок карточки (очищенный, до 140 символов — режет сервис). + /// Заголовок карточки /// public string Title { get; set; } = string.Empty; /// - /// Краткое содержание карточки (очищенное, до 2000 символов — режет сервис). + /// Краткое содержание карточки /// public string Summary { get; set; } = string.Empty; /// - /// Стек/направления, сериализованные в JSON (text). + /// Стек/направления, сериализованные в JSON /// 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; @@ -79,17 +78,17 @@ public sealed class CardEntity public double? ConvTo { get; set; } /// - /// Валюта сконвертированного бюджета (целевая валюта тенанта); пусто — конверсия не сделана. + /// Валюта сконвертированного бюджета /// public string ConvCur { get; set; } = string.Empty; /// - /// Контактная строка «как в сообщении» (fallback, если ContactsJson пуст). + /// Контактная строка «как в сообщении» /// public string Contact { get; set; } = string.Empty; /// - /// Квалифицированные контакты, сериализованные в JSON (text). + /// Квалифицированные контакты, сериализованные в JSON /// public string ContactsJson { get; set; } = "[]"; @@ -104,22 +103,22 @@ public sealed class CardEntity 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 диалога исходного сообщения (для «открыть исходник»). + /// Id диалога исходного сообщения /// public string SourceDialogId { get; set; } = string.Empty; @@ -129,52 +128,52 @@ public sealed class CardEntity public long? SourceMsgId { get; set; } /// - /// Предыдущая колонка (для возврата из архива/корзины). + /// Предыдущая колонка /// public string PrevCol { get; set; } = "inbox"; /// - /// Время помещения в архив (для правила «архив очищается через N дней»), либо null. + /// Время помещения в архив /// public DateTimeOffset? ArchivedAt { get; set; } /// - /// Совпавшие критерии правил при попадании в колонку, сериализованные в JSON (text). + /// Совпавшие критерии правил при попадании в колонку, сериализованные в JSON /// public string MatchHitsJson { get; set; } = "[]"; /// - /// Ссылки карточки, сериализованные в JSON (text; элементы {id,name,url}). + /// Ссылки карточки, сериализованные в JSON /// public string LinksJson { get; set; } = "[]"; /// - /// Файлы карточки, сериализованные в JSON (text; элементы {id,name,size,kind,label,objectKey}). + /// Файлы карточки, сериализованные в JSON /// public string FilesJson { get; set; } = "[]"; /// - /// История движения карточки, сериализованная в JSON (text; элементы {id,at,type|stage}). + /// История движения карточки, сериализованная в JSON /// public string HistoryJson { get; set; } = "[]"; /// - /// Текст технического задания по карточке (заметка-задание). + /// Текст технического задания по карточке /// public string TzText { get; set; } = string.Empty; /// - /// Время напоминания об отложенной карточке, либо null (напоминание не задано/сброшено). + /// Время напоминания об отложенной карточке, либо null /// public DateTimeOffset? ReminderAt { get; set; } /// - /// Признак «напоминание уже выстрелило» (повторно не срабатывает до переноса/переустановки). + /// Признак «напоминание уже выстрелило» /// public bool ReminderFired { get; set; } /// - /// Полнотекстовый вектор (tsvector, конфигурация russian) для поиска карточек — вычисляемая STORED-колонка БД. + /// Полнотекстовый вектор /// public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty; @@ -184,7 +183,7 @@ public sealed class CardEntity 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 2dcbf84..d22e121 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/CardMoveEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/CardMoveEntity.cs @@ -1,36 +1,32 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Запись журнала действий над карточкой: таблица CardMoves в схеме тенанта. Соответствует таблице learning_log прототипа. +/// Запись журнала действий над карточкой /// -/// -/// Журнал живёт дольше карточки (прототип _hard_delete его не чистит), поэтому ссылки на -/// карточку внешним ключом не связаны — только значение LeadId. -/// public sealed class CardMoveEntity { /// - /// Короткий id записи журнала (префикс lm_), первичный ключ. + /// Короткий id записи журнала /// public string Id { get; set; } = string.Empty; /// - /// Id карточки (Cards.Id), над которой выполнено действие. Без FK — журнал хранится и после удаления карточки. + /// Id карточки (Cards.Id), над которой выполнено действие. /// public string LeadId { get; set; } = string.Empty; /// - /// Действие: move|trash|restore|comment (счётчик learning = число записей). + /// Действие: move|trash|restore|comment /// public string Action { get; set; } = string.Empty; /// - /// Колонка-источник переноса, либо null (комментарий). + /// Колонка-источник переноса, либо null /// public string? FromCol { get; set; } /// - /// Колонка-назначение переноса, либо null (комментарий). + /// Колонка-назначение переноса, либо null /// public string? ToCol { get; set; } diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/ContainerEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/ContainerEntity.cs index 8f7e082..b4428b9 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/ContainerEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/ContainerEntity.cs @@ -3,30 +3,22 @@ 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). + /// Короткий id контейнера /// public string Id { get; set; } = string.Empty; /// - /// Имя для отображения («WPF», «В работе», «Архив»). + /// Имя для отображения /// public string Name { get; set; } = string.Empty; /// - /// Описание контейнера (для пользователя и подсказки ИИ/ML). + /// Описание контейнера /// public string Description { get; set; } = string.Empty; @@ -36,47 +28,47 @@ public sealed class ContainerEntity public string Color { get; set; } = "#818cf8"; /// - /// Позиция в пространстве (ORDER BY space, position). + /// Позиция в пространстве /// public int Position { get; set; } /// - /// Вид контейнера: board (колонка-фильтр) | stage (стадия) | service (inbox/archive/trash) | terminal (finished/rejected). + /// Вид контейнера: board /// public string Kind { get; set; } = "board"; /// - /// Пространство: dashboard | selected (вид дашборда, к которому принадлежит контейнер). + /// Пространство: 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; diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/DedupEntryEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/DedupEntryEntity.cs index e637bb2..ff92a3c 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/DedupEntryEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/DedupEntryEntity.cs @@ -1,21 +1,17 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Дедуп-хэш текста сообщения: таблица DedupEntries в схеме тенанта. Соответствует таблице dedup прототипа. +/// Дедуп-хэш текста сообщения /// -/// -/// Защита от повторного заведения карточки (одинаковый текст дважды). LeadId — «мягкая» ссылка на Cards без FK: -/// чистку строки при жёстком удалении карточки выполняет приложение (Ruling 3 этапа 4). -/// public sealed class DedupEntryEntity { /// - /// Хэш нормализованного текста (SHA1 hex, без префикса), первичный ключ. + /// Хэш нормализованного текста /// public string Hash { get; set; } = string.Empty; /// - /// Id созданной карточки (l_...), либо null — хэш занят в обработке (claim). + /// Id созданной карточки /// public string? LeadId { get; set; } diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/DialogEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/DialogEntity.cs index b5626bd..f6f2778 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/DialogEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/DialogEntity.cs @@ -1,22 +1,17 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Диалог/канал каталога тенанта: таблица Dialogs в схеме тенанта (Ruling 7, db.py L76–86). +/// Диалог/канал каталога тенанта /// -/// -/// Зеркало каталога диалогов аккаунта Telegram: владелец — модуль Deal.Modules.Telegram (Task 13). Kind хранит -/// EN-канон контракта (channel|group|forum|chat) — 1:1 с entries SyncDialogs/refresh (proto DialogEntry). -/// Без FK — каталог независим от сообщений/карточек. -/// public sealed class DialogEntity { /// - /// Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»), первичный ключ. + /// Подписанный id диалога /// public string Id { get; set; } = string.Empty; /// - /// Отображаемое имя диалога (title/first_name). + /// Отображаемое имя диалога /// public string Name { get; set; } = string.Empty; @@ -26,37 +21,37 @@ public sealed class DialogEntity public string Handle { get; set; } = string.Empty; /// - /// Тип источника: channel|group|forum|chat (EN-канон telegram.proto). + /// Тип источника: channel|group|forum|chat /// public string Kind { get; set; } = string.Empty; /// - /// Цвет источника из палитры DIALOG_HUES (hex «#rrggbb»); дефолт «#666» (db.py L81). + /// Цвет источника из палитры DIALOG_HUES /// public string Hue { get; set; } = "#666"; /// - /// Признак мониторинга: сообщения диалога → PushMessage в очередь пайплайна (db.py L82). + /// Признак мониторинга /// public bool Monitor { get; set; } /// - /// Текст последнего принятого сообщения (обрезается до 200, python L272). + /// Текст последнего принятого сообщения. /// public string LastText { get; set; } = string.Empty; /// - /// Момент последнего принятого сообщения (UTC); null — сообщений ещё не было. + /// Момент последнего принятого сообщения /// public DateTimeOffset? LastAt { get; set; } /// - /// Признак «канал разобран» (первый backfill завершён; python ALTER backfilled, L284). + /// Признак «канал разобран». /// public bool Backfilled { get; set; } /// - /// Момент последнего изменения строки (UTC). + /// Момент последнего изменения строки /// public DateTimeOffset UpdatedAt { get; set; } } diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/DiscBlacklistEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/DiscBlacklistEntity.cs index fcdb158..cb59dd5 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/DiscBlacklistEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/DiscBlacklistEntity.cs @@ -1,14 +1,8 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Чёрный список Discovery: таблица DiscBlacklist в схеме тенанта (db.py L180–185, Ruling 9). +/// Чёрный список Discovery /// -/// -/// Владелец — модуль Deal.Modules.Discovery (Task 17). Список общий для всех задач: источники из него -/// пропускаются поиском (add_candidate) и повторной проверкой перед авто-вступлением (воркер). Снимается -/// вручную или при ручном join. Повторное добавление обновляет Name/Reason и сохраняет CreatedAt -/// (ON CONFLICT DO UPDATE — python L572–575). -/// public sealed class DiscBlacklistEntity { /// @@ -17,17 +11,17 @@ public sealed class DiscBlacklistEntity public string DialogId { get; set; } = string.Empty; /// - /// Имя источника (пусто → DialogId). + /// Имя источника /// public string Name { get; set; } = string.Empty; /// - /// Причина добавления («отклонено вручную», метка воркера). + /// Причина добавления /// public string Reason { get; set; } = string.Empty; /// - /// Момент первого добавления (UTC; при перезаписи сохраняется). + /// Момент первого добавления /// public DateTimeOffset CreatedAt { get; set; } } diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/DiscCandidateEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/DiscCandidateEntity.cs index 6146596..8c88eae 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/DiscCandidateEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/DiscCandidateEntity.cs @@ -1,18 +1,12 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Кандидат задачи Discovery: таблица DiscCandidates в схеме тенанта (db.py L159–176, Ruling 9). +/// Кандидат задачи Discovery /// -/// -/// Владелец — модуль Deal.Modules.Discovery (Task 17). Первичный ключ — DialogId (источник может быть кандидатом -/// только одной задачи/одного статуса — python). Marks/Topics — JSON-колонки (text): marks — строки-метки оценки, -/// topics — элементы {topicId,title,fitCount,total,fitRatio,passed} для форумов. Status: new|review|joined|rejected. -/// Без FK — DiscTasks/Dialogs удаляются/живут независимо (каталог кандидата может пережить задачу до delete_task). -/// public sealed class DiscCandidateEntity { /// - /// Подписанный id источника (каналы «-100…», группы «-…»), первичный ключ. + /// Подписанный id источника /// public string DialogId { get; set; } = string.Empty; @@ -22,7 +16,7 @@ public sealed class DiscCandidateEntity public string TaskId { get; set; } = string.Empty; /// - /// Отображаемое имя источника (пусто → DialogId). + /// Отображаемое имя источника /// public string Name { get; set; } = string.Empty; @@ -37,12 +31,12 @@ public sealed class DiscCandidateEntity public string Kind { get; set; } = "channel"; /// - /// Цвет источника из палитры DIALOG_HUES (hex «#rrggbb»); дефолт «#666». + /// Цвет источника из палитры DIALOG_HUES /// public string Hue { get; set; } = "#666"; /// - /// Число участников источника; null — неизвестно (до discovery_info). + /// Число участников источника; null — неизвестно /// public int? Participants { get; set; } @@ -52,42 +46,42 @@ public sealed class DiscCandidateEntity public bool? LangRu { get; set; } /// - /// Метки оценки, сериализованные в JSON (text; дефолт «[]»). + /// Метки оценки, сериализованные в JSON /// public string MarksJson { get; set; } = "[]"; /// - /// Оценка тем форума, сериализованная в JSON (text; дефолт «[]»). + /// Оценка тем форума, сериализованная в JSON /// public string TopicsJson { get; set; } = "[]"; /// - /// Доля подходящих сообщений оценки (0..1); null — контент не оценён. + /// Доля подходящих сообщений оценки /// public double? FitRatio { get; set; } /// - /// Статус кандидата: new|review|joined|rejected. + /// Статус кандидата /// public string Status { get; set; } = "new"; /// - /// Вступили автоматически (воркером); false — вручную. + /// Вступили автоматически /// public bool AutoJoined { get; set; } /// - /// Неудачные авто-вступления подряд (3 → кандидат удаляется, Task 18). + /// Неудачные авто-вступления подряд. /// public int JoinFailures { get; set; } /// - /// Момент добавления кандидата (UTC). + /// Момент добавления кандидата /// public DateTimeOffset CreatedAt { get; set; } /// - /// Момент последнего изменения (UTC). + /// Момент последнего изменения /// public DateTimeOffset UpdatedAt { get; set; } } diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/DiscLogEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/DiscLogEntity.cs index a66dfae..17d17a8 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/DiscLogEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/DiscLogEntity.cs @@ -1,17 +1,12 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Лог событий задачи Discovery: таблица DiscLog в схеме тенанта (db.py L189–195, Ruling 9). +/// Лог событий задачи Discovery /// -/// -/// Владелец — модуль Deal.Modules.Discovery (Task 17). Event — каталог модуля: search|skip|review|join_auto| -/// join_manual|leave|reject|flood|error|done. Чтение — последние события задачи (ORDER BY created_at DESC), -/// поэтому создан индекс (TaskId, CreatedAt) — python idx_disc_log_task L196. -/// public sealed class DiscLogEntity { /// - /// Короткий id записи (префикс dl_), первичный ключ. + /// Короткий id записи /// public string Id { get; set; } = string.Empty; @@ -26,12 +21,12 @@ public sealed class DiscLogEntity public string Event { get; set; } = string.Empty; /// - /// Текст/детали события (русская строка 1:1 с прототипом). + /// Текст/детали события. /// public string Text { get; set; } = string.Empty; /// - /// Момент события (UTC). + /// Момент события /// public DateTimeOffset CreatedAt { get; set; } } diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/DiscTaskEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/DiscTaskEntity.cs index 347d412..311d9fb 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/DiscTaskEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/DiscTaskEntity.cs @@ -1,56 +1,52 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Задача поиска Discovery: таблица DiscTasks в схеме тенанта (db.py L136–156, Ruling 9). +/// Задача поиска Discovery /// -/// -/// Владелец — модуль Deal.Modules.Discovery (Task 17). Keywords хранит JSON-массив (text); search_*/счётчики — -/// живой прогресс по задаче (воркер Task 18). Status: draft|running|paused|done|failed (каталог модуля). -/// public sealed class DiscTaskEntity { /// - /// Короткий id задачи (префикс dt_), первичный ключ. + /// Короткий id задачи /// public string Id { get; set; } = string.Empty; /// - /// Название задачи (обязательное, Trim). + /// Название задачи /// public string Name { get; set; } = string.Empty; /// - /// Описание ниши/цели (источник для ИИ-генерации ключей). + /// Описание ниши/цели /// public string Description { get; set; } = string.Empty; /// - /// Ключевые слова поиска, сериализованные в JSON (text; дефолт «[]»). + /// Ключевые слова поиска, сериализованные в JSON /// public string KeywordsJson { get; set; } = "[]"; /// - /// Минимальное число участников источника (0 — не фильтровать). + /// Минимальное число участников источника /// public int MinSubscribers { get; set; } /// - /// Язык источников: ru|any. + /// Язык источников /// public string Lang { get; set; } = "ru"; /// - /// Порог подходящих сообщений оценки, % (1..100; дефолт 40 — discEvalThreshold). + /// Порог подходящих сообщений оценки, % /// public int Threshold { get; set; } = 40; /// - /// Размер выборки сообщений при оценке (дефолт 10 — discEvalSample). + /// Размер выборки сообщений при оценке /// public int SampleSize { get; set; } = 10; /// - /// План авто-вступлений (1..discJoinLimit; занимает суточный бюджет). + /// План авто-вступлений /// public int PlanJoins { get; set; } = 1; @@ -65,7 +61,7 @@ public sealed class DiscTaskEntity public string Status { get; set; } = "draft"; /// - /// Индекс текущего ключа поиска (прогресс прохода по keywords). + /// Индекс текущего ключа поиска /// public int SearchIdx { get; set; } @@ -95,12 +91,12 @@ public sealed class DiscTaskEntity public int Rejected { get; set; } /// - /// Момент создания задачи (UTC). + /// Момент создания задачи /// public DateTimeOffset CreatedAt { get; set; } /// - /// Момент последнего изменения (UTC). + /// Момент последнего изменения /// public DateTimeOffset UpdatedAt { get; set; } } diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs index 2ae60f0..e4f0a70 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs @@ -1,17 +1,12 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Глобальная (системная) настройка оператора: таблица public.global_settings. +/// Глобальная (системная) настройка оператора /// -/// -/// Единое KV-хранилище всего SaaS-контура (ТЗ §4.1/§8.1): значения задаёт оператор, видят все -/// тенанты. Секреты хранятся зашифрованными (префикс enc:), формат значения определяет ключ -/// (). -/// public sealed class GlobalSettingEntity { /// - /// Ключ глобальной настройки (PK). + /// Ключ глобальной настройки /// public string Key { get; set; } = string.Empty; @@ -21,7 +16,7 @@ public sealed class GlobalSettingEntity public string Value { get; set; } = string.Empty; /// - /// Время последнего изменения (UTC). + /// Время последнего изменения /// public DateTimeOffset UpdatedAt { get; set; } } diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/InviteEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/InviteEntity.cs index ed7fe6b..a6d9d23 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/InviteEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/InviteEntity.cs @@ -1,17 +1,17 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Приглашение на регистрацию (invite) в системной схеме public. +/// Приглашение на регистрацию /// public sealed class InviteEntity { /// - /// Одноразовый код приглашения (url-safe, 16 симв.) — первичный ключ. + /// Одноразовый код приглашения /// public string Code { get; set; } = string.Empty; /// - /// Email приглашённого, нормализованный (нижний регистр); уникален среди активных. + /// Email приглашённого, нормализованный /// public string Email { get; set; } = string.Empty; @@ -25,7 +25,7 @@ public sealed class InviteEntity public DateTimeOffset ExpiresAt { get; set; } /// - /// Момент активации (null, пока инвайт не использован). + /// Момент активации /// public DateTimeOffset? ActivatedAt { get; set; } diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/LeadCommentEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/LeadCommentEntity.cs index e62b71c..d99c2b8 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/LeadCommentEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/LeadCommentEntity.cs @@ -1,12 +1,12 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Комментарий карточки: таблица LeadComments в схеме тенанта. Нормализация массива comments строки leads прототипа. +/// Комментарий карточки /// public sealed class LeadCommentEntity { /// - /// Короткий id комментария (префикс cm_), первичный ключ. + /// Короткий id комментария /// public string Id { get; set; } = string.Empty; @@ -16,7 +16,7 @@ public sealed class LeadCommentEntity public string CardId { get; set; } = string.Empty; /// - /// Автор комментария (в прототипе — «Вы»), отдаётся как by. + /// Автор комментария, отдаётся как by. /// public string By { get; set; } = string.Empty; @@ -26,7 +26,7 @@ public sealed class LeadCommentEntity public string Text { get; set; } = string.Empty; /// - /// Время добавления комментария (человеческую метку time считает маппинг). + /// Время добавления комментария /// public DateTimeOffset CreatedAt { get; set; } } diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/MlOutboxEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/MlOutboxEntity.cs index 1f29f02..3a02468 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/MlOutboxEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/MlOutboxEntity.cs @@ -1,31 +1,27 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Строка очереди обучающих сигналов ML: таблица MlOutbox в схеме тенанта. Соответствует таблице ml_outbox прототипа. +/// Строка очереди обучающих сигналов ML /// -/// -/// Действия пользователя всегда пишутся сюда синхронно; фоновый воркер отправляет строки в ML-сервис -/// (этап 3 — только накопление; отправка — этап 6). Без FK — очередь не зависит от карточек. -/// public sealed class MlOutboxEntity { /// - /// Короткий id записи outbox (префикс mle_), первичный ключ. + /// Короткий id записи outbox /// public string Id { get; set; } = string.Empty; /// - /// Текст обучающего примера (обрезается до 6000 символов при записи). + /// Текст обучающего примера /// public string Text { get; set; } = string.Empty; /// - /// Метка обучения: id доски (b_...), spam либо t:hire|t:order. + /// Метка обучения: id доски /// public string Label { get; set; } = string.Empty; /// - /// Весовой коэффициент сигнала (1.0 — учить, −1.0 — снять метку). + /// Весовой коэффициент сигнала /// public double Delta { get; set; } = 1.0; diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/OperatorEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/OperatorEntity.cs index e5383dd..73c28cb 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/OperatorEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/OperatorEntity.cs @@ -8,7 +8,7 @@ public sealed class OperatorEntity public Guid Id { get; set; } = Guid.NewGuid(); /// - /// Логин оператора: уникальный, хранится в нижнем регистре. + /// Логин оператора /// public string Login { get; set; } = string.Empty; diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/OperatorSessionEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/OperatorSessionEntity.cs index 77b2b5c..aa01357 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/OperatorSessionEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/OperatorSessionEntity.cs @@ -1,12 +1,12 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Сессия оператора в системной схеме public. Хранится SHA-256-хеш токена. +/// Сессия оператора в системной схеме public. /// public sealed class OperatorSessionEntity { /// - /// SHA-256-хеш токена сессии оператора (первичный ключ). + /// SHA-256-хеш токена сессии оператора /// public string TokenHash { get; set; } = string.Empty; diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/QueueItemEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/QueueItemEntity.cs index 9e01af0..9e8b1f2 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/QueueItemEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/QueueItemEntity.cs @@ -1,22 +1,17 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Строка очереди входящих пайплайна: таблица QueueItems в схеме тенанта. Соответствует таблице pipeline_msg прототипа. +/// Строка очереди входящих пайплайна /// -/// -/// Все сообщения из групп попадают сюда и разбираются фоновым воркером; не прошедшие фильтры удаляются сразу -/// (не копятся). force=true — сообщение возвращено из отсева: фильтры-отсев для него игнорируются (уходит на ML/ИИ). -/// Без FK — очередь не зависит от карточек/диалогов. -/// public sealed class QueueItemEntity { /// - /// Короткий id строки очереди (префикс p_), первичный ключ. + /// Короткий id строки очереди /// public string Id { get; set; } = string.Empty; /// - /// Id диалога-источника (для «открыть исходник» и дубль-гварда по msgId при приёме). + /// Id диалога-источника /// public string DialogId { get; set; } = string.Empty; @@ -31,22 +26,22 @@ public sealed class QueueItemEntity public string ChannelHandle { get; set; } = string.Empty; /// - /// Цвет канала-источника (hex). + /// Цвет канала-источника /// public string ChannelHue { get; set; } = "#666"; /// - /// Текст сообщения (обрезается до 6000 символов при приёме — режет сервис). + /// Текст сообщения /// public string Text { get; set; } = string.Empty; /// - /// Id исходного сообщения в Telegram, либо null (защита от двойного события Telethon). + /// Id исходного сообщения в Telegram, либо null /// public long? MsgId { get; set; } /// - /// Время получения исходного сообщения (stale-проверка правила автоархива). + /// Время получения исходного сообщения /// public DateTimeOffset MsgAt { get; set; } @@ -56,7 +51,7 @@ public sealed class QueueItemEntity public string Status { get; set; } = "new"; /// - /// Признак возврата из отсева: фильтры-отсев для строки игнорируются. + /// Признак возврата из отсева /// public bool Force { get; set; } diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/RateLimitCounterEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/RateLimitCounterEntity.cs index 44b2fa0..22f2648 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/RateLimitCounterEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/RateLimitCounterEntity.cs @@ -1,27 +1,22 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Счётчик фиксированного окна в системной схеме public (этап 12, пакет B). +/// Счётчик фиксированного окна в системной схеме public. /// -/// -/// Используется распределённым rate limiting (auth/api/gRPC-ингресс) и guard'ом попыток входа: значение -/// общего счётчика видно всем инстансам core (ранее — память одного процесса). Строка живёт до -/// (windowStart + длина окна), после чего удаляется фоновой уборкой. -/// public sealed class RateLimitCounterEntity { /// - /// Уникальный ключ счётчика (префикс политики/типа + партиция: IP, tenant-id или ip|login). + /// Уникальный ключ счётчика /// public string Key { get; set; } = string.Empty; /// - /// Начало текущего фиксированного окна (UTC; выровнено по длине окна). + /// Начало текущего фиксированного окна /// public DateTimeOffset WindowStart { get; set; } /// - /// Момент, после которого строка считается устаревшей (windowStart + длина окна). + /// Момент, после которого строка считается устаревшей /// public DateTimeOffset ExpiresAt { get; set; } diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/RejectedItemEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/RejectedItemEntity.cs index 925188b..6fdce60 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/RejectedItemEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/RejectedItemEntity.cs @@ -3,24 +3,17 @@ 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_), первичный ключ. + /// Короткий id записи /// - /// Детерминированный r_<dialog>_<msgId> при наличии dialog+msgId, иначе r_+hex (upsert по id). public string Id { get; set; } = string.Empty; /// - /// Id диалога-источника (для повторного возврата в очередь); пусто — диалог неизвестен. + /// Id диалога-источника /// public string DialogId { get; set; } = string.Empty; @@ -40,12 +33,12 @@ public sealed class RejectedItemEntity public string ChannelHandle { get; set; } = string.Empty; /// - /// Цвет канала-источника (hex). + /// Цвет канала-источника /// public string ChannelHue { get; set; } = "#666"; /// - /// Текст сообщения (обрезается до 6000 символов при записи — режет сервис). + /// Текст сообщения /// public string Text { get; set; } = string.Empty; @@ -55,27 +48,27 @@ public sealed class RejectedItemEntity 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; } @@ -90,12 +83,12 @@ public sealed class RejectedItemEntity 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 9f91dbf..4470362 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/SessionEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/SessionEntity.cs @@ -1,12 +1,12 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Сессия пользователя в системной схеме public. Хранится SHA-256-хеш токена. +/// Сессия пользователя в системной схеме public. /// public sealed class SessionEntity { /// - /// SHA-256-хеш токена сессии (первичный ключ). + /// SHA-256-хеш токена сессии /// public string TokenHash { get; set; } = string.Empty; @@ -22,8 +22,7 @@ public sealed class SessionEntity public DateTimeOffset CreatedAt { get; set; } /// - /// Маркер impersonation (план Task 7): оператор, создавший сессию; null — обычная сессия. - /// Нужен для аудита impersonation_stopped при logout (AuthService/LogoutAsync). + /// Маркер impersonation /// public Guid? ImpersonatedByOperatorId { get; set; } } diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/TenantLimitEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/TenantLimitEntity.cs index 14f903a..ded8a6d 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/TenantLimitEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/TenantLimitEntity.cs @@ -6,7 +6,7 @@ namespace Deal.Infrastructure.Persistence.Entities; public sealed class TenantLimitEntity { /// - /// Тенант, которому принадлежит лимит (первичный ключ). + /// Тенант, которому принадлежит лимит /// public Guid TenantId { get; set; } @@ -21,7 +21,7 @@ public sealed class TenantLimitEntity public string Period { get; set; } = "month"; /// - /// Начало текущего периода (отсчёт от него — срок и ленивый reset). + /// Начало текущего периода /// public DateTimeOffset PeriodStart { get; set; } @@ -31,12 +31,12 @@ public sealed class TenantLimitEntity public long UsedTokens { get; set; } /// - /// Флаг: тост о расходе 80% бюджета уже отправлен (один на период). + /// Флаг: тост о расходе 80% бюджета уже отправлен /// public bool Warned80 { get; set; } /// - /// Флаг: тост об исчерпании бюджета уже отправлен (один на период). + /// Флаг: тост об исчерпании бюджета уже отправлен /// public bool NotifiedExhausted { get; set; } diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/TenantSettingEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/TenantSettingEntity.cs index 6f4e7d5..b761d3c 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/TenantSettingEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/TenantSettingEntity.cs @@ -1,7 +1,7 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Настройка тенанта: таблица settings в схеме тенанта. +/// Настройка тенанта /// public sealed class TenantSettingEntity { diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/TgMessageEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/TgMessageEntity.cs index c3438eb..574656f 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/TgMessageEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/TgMessageEntity.cs @@ -1,17 +1,12 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Строка превью сообщения диалога: таблица TgMessages в схеме тенанта (Ruling 7, db.py L67–74). +/// Строка превью сообщения диалога /// -/// -/// Превью сообщений принятых PushMessage/разборов — фолбэк вкладки «Каналы» (preview) и «последнее сообщение» -/// каталога. Id — «m_<dialog>_<msg>» (python L604). LeadId — мягкая ссылка на карточку по сообщению -/// (без FK — карточки живут в другой таблице и могут удаляться). -/// public sealed class TgMessageEntity { /// - /// Id строки превью («m_<dialog>_<msg>»), первичный ключ. + /// Id строки превью /// public string Id { get; set; } = string.Empty; @@ -21,17 +16,17 @@ public sealed class TgMessageEntity public string DialogId { get; set; } = string.Empty; /// - /// Текст сообщения (обрезается до 4000 при записи, python L604). + /// Текст сообщения. /// public string Text { get; set; } = string.Empty; /// - /// Время сообщения (UTC; у python — epoch-ms). + /// Время сообщения. /// public DateTimeOffset MsgAt { get; set; } /// - /// Id карточки, созданной по сообщению (мягкая ссылка), либо null. + /// Id карточки, созданной по сообщению /// public string? LeadId { get; set; } } diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/TokenUsageEventEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/TokenUsageEventEntity.cs index e2c91e0..13e64ad 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/TokenUsageEventEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/TokenUsageEventEntity.cs @@ -1,21 +1,21 @@ namespace Deal.Infrastructure.Persistence.Entities; /// -/// Событие расхода токенов (time-series) в системной схеме public (этап 10, T2). +/// Событие расхода токенов /// public sealed class TokenUsageEventEntity { public long Id { get; set; } /// - /// Тенант события (Guid строки public.tenants). + /// Тенант события /// public Guid TenantId { get; set; } public DateTimeOffset At { get; set; } /// - /// Провайдер/источник: deepseek/openai/anthropic/local/ml. + /// Провайдер/источник /// public string Provider { get; set; } = string.Empty; @@ -25,7 +25,7 @@ public sealed class TokenUsageEventEntity public string Model { get; set; } = string.Empty; /// - /// Вид вызова: ai|ml (TokenUsageEventKinds). + /// Вид вызова: ai|ml /// public string Kind { get; set; } = string.Empty; @@ -36,7 +36,7 @@ public sealed class TokenUsageEventEntity public long TotalTokens { get; set; } /// - /// Детали события в JSON (без секретов). + /// Детали события в JSON /// public string? DetailJson { get; set; } } diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/UserEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/UserEntity.cs index 3099f26..a8521b6 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/UserEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/UserEntity.cs @@ -8,7 +8,7 @@ public sealed class UserEntity public Guid Id { get; set; } = Guid.NewGuid(); /// - /// Логин пользователя: уникальный, хранится в нижнем регистре. + /// Логин пользователя /// public string Login { get; set; } = string.Empty; diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs index ee3c403..5c4d912 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs @@ -7,14 +7,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// EF-адаптер хранилища аудита: таблица public.audit_log (append-only, Ruling 4). +/// EF-адаптер хранилища аудита /// -/// -/// Маппинг 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 { /// @@ -113,7 +107,6 @@ public sealed class AuditLogStore(DealDbContext dbContext) : IAuditLogStore return query; } - // Клампит размер выборки в 1..MaxQueryLimit (Ruling 4: limit ≤500). // limit: Запрошенный размер. // Возвращает: Клампированное значение. private static int ClampLimit(int limit) => Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit)); diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs index 44be182..75be645 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs @@ -6,9 +6,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// EF-адаптер хранилища аутентификации: таблицы public.users и public.sessions. +/// EF-адаптер хранилища аутентификации /// -/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). public sealed class AuthStore(DealDbContext dbContext) : IAuthStore { /// @@ -58,7 +57,6 @@ public sealed class AuthStore(DealDbContext dbContext) : IAuthStore /// public async Task> ListUsersByTenantIdAsync(Guid tenantId, CancellationToken ct) { - // Порядок по CreatedAt — «первый пользователь тенанта» для impersonation без login (Task 7) детерминирован. var entities = await dbContext.Users .AsNoTracking() .Where(u => u.TenantId == tenantId) diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Blacklist.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Blacklist.cs index b7b5568..bd9becc 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Blacklist.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Blacklist.cs @@ -5,8 +5,7 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// Чёрный список Discovery — partial-часть (C32: выделено из общего -/// файла, поведение не менялось): upsert/удаление/чтение/список DiscBlacklist (ON CONFLICT DO UPDATE). +/// Чёрный список Discovery — partial-часть /// public sealed partial class DiscoveryStore { @@ -31,7 +30,6 @@ public sealed partial class DiscoveryStore } else { - // add_blacklist L572–575 (ON CONFLICT DO UPDATE): name/reason обновляются, CreatedAt сохраняется. row.Name = name; row.Reason = reason; } diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Candidates.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Candidates.cs index fd3f799..afc2777 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Candidates.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Candidates.cs @@ -5,9 +5,7 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// Кандидаты Discovery — partial-часть (C32: выделено из общего файла, -/// поведение не менялось): список/чтение кандидатов (DiscCandidates), мониторинг диалога и чёрный список, -/// создание/патч/статусы joined/rejected и счётчик сбоев вступлений. +/// Кандидаты Discovery — partial-часть /// public sealed partial class DiscoveryStore { @@ -124,7 +122,6 @@ public sealed partial class DiscoveryStore return false; } - // mark_joined L531–534: status=joined + auto_joined. row.Status = "joined"; row.AutoJoined = autoJoined; row.UpdatedAt = DateTimeOffset.UtcNow; @@ -139,7 +136,6 @@ public sealed partial class DiscoveryStore .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); if (row is null || row.Status != "review") { - // воркер L404–416: счётчик и удаление трогаем только у живой записи в статусе review. return null; } diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Logs.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Logs.cs index fe8cf35..288b1ee 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Logs.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Logs.cs @@ -5,8 +5,7 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// Лог Discovery — partial-часть (C32: выделено из общего файла, -/// поведение не менялось): запись события, счётчик событий с начала суток (квота вступлений) и лог задачи. +/// Лог Discovery — partial-часть /// public sealed partial class DiscoveryStore { @@ -35,7 +34,6 @@ public sealed partial class DiscoveryStore 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); } diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Tasks.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Tasks.cs index 52a9479..f72473c 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Tasks.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Tasks.cs @@ -5,9 +5,7 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// Задачи Discovery — partial-часть (C32: выделено из общего файла, -/// поведение не менялось): CRUD задач (DiscTasks), состояние running/paused/done, атомарные инкременты -/// счётчиков и сдвиг поиска, сумма плана (discovery.py db.py; Ruling 9, Task 17). +/// Задачи Discovery — partial-часть /// public sealed partial class DiscoveryStore { @@ -82,7 +80,6 @@ public sealed partial class DiscoveryStore 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); @@ -106,7 +103,6 @@ public sealed partial class DiscoveryStore row.Status = "running"; if (resetProgress) { - // start_task L333–339: повторный прогон завершённой/упавшей — свежий проход по ключам. row.SearchIdx = 0; row.SearchDone = false; row.Found = 0; @@ -146,7 +142,6 @@ public sealed partial class DiscoveryStore return false; } - // воркер _finish_done L126–136: план вступлений выполнен — status=done, бюджет планов освобождается. row.Status = "done"; row.UpdatedAt = DateTimeOffset.UtcNow; await _dbContext.SaveChangesAsync(ct); @@ -167,7 +162,6 @@ public sealed partial class DiscoveryStore return row is not null; } - // bump_counter L359–368: приращение счётчика + bump UpdatedAt (python читает и пишет абсолютное значение). switch (field) { case DiscoveryCounterField.Found: @@ -205,7 +199,6 @@ public sealed partial class DiscoveryStore return false; } - // advance_search L371–380: новый индекс и флаг завершения прохода (значения считает сервис). row.SearchIdx = nextIndex; row.SearchDone = searchDone; row.UpdatedAt = DateTimeOffset.UtcNow; diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs index 2861f6c..464dc7c 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs @@ -6,25 +6,14 @@ using Deal.Modules.Discovery.Application.Models; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// EF-адаптер хранилища Discovery: таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog схемы тенанта (Ruling 9, Task 17). +/// EF-адаптер хранилища Discovery /// -/// -/// Реализация порта на (эталон 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-контекст БД). + /// Создаёт EF-адаптер хранилища Discovery /// /// Scoped-контекст тенанта запроса (search_path). public DiscoveryStore(TenantDbContext dbContext) @@ -32,13 +21,11 @@ public sealed partial class DiscoveryStore : IDiscoveryStore _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( @@ -63,7 +50,6 @@ public sealed partial class DiscoveryStore : IDiscoveryStore row.UpdatedAt.ToUnixTimeMilliseconds()); } - // Маппит строку кандидата в DTO (candidate_view L140–158; marks/topics — JSON-списки). private static DiscoveryCandidateDto ToCandidateDto(DiscCandidateEntity row) { return new DiscoveryCandidateDto( @@ -85,19 +71,16 @@ public sealed partial class DiscoveryStore : IDiscoveryStore 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) @@ -146,7 +129,6 @@ public sealed partial class DiscoveryStore : IDiscoveryStore } } - // Применяет патч кандидата к строке (set_candidate L468–492; marks/topics — JSON-замена). private static void ApplyCandidatePatch(DiscCandidateEntity row, DiscoveryCandidatePatch patch) { if (patch.Name is not null) @@ -200,7 +182,6 @@ public sealed partial class DiscoveryStore : IDiscoveryStore } } - // Разбирает JSON-массив колонки (повреждённая строка/не-массив → пустой список, python _loads L73–77). private static IReadOnlyList FromJson(string json) { try diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/GlobalSettingsStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/GlobalSettingsStore.cs index f60750a..022fea3 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/GlobalSettingsStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/GlobalSettingsStore.cs @@ -6,14 +6,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// EF-адаптер KV-хранилища глобальных (системных) настроек оператора: таблица public.global_settings. +/// EF-адаптер KV-хранилища глобальных /// -/// -/// Маппинг 1:1 со строкой таблицы: key/value/updated_at (сущность GlobalSettingEntity, системный -/// DealDbContext схемы public). Порт оперирует готовыми JSON-строками (сериализацию выполняет -/// владелец ключа), поэтому адаптер хранит значение как текст без интерпретации. -/// пишет updated_at = UTC-now. -/// public sealed class GlobalSettingsStore(DealDbContext dbContext) : IGlobalSettingsStore { /// diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/InviteStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/InviteStore.cs index df5734e..1ab9003 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/InviteStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/InviteStore.cs @@ -7,14 +7,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// EF-адаптер хранилища приглашений: таблица public.invites (Ruling 2 этапа 7). +/// EF-адаптер хранилища приглашений /// -/// -/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). «expired» здесь -/// не вычисляется: ленивое проставление статуса выполняет при чтении/проверке, -/// адаптер лишь меняет статус по коду (). List упорядочен CreatedAt DESC — -/// свежие приглашения сверху в операторском списке. -/// public sealed class InviteStore(DealDbContext dbContext) : IInviteStore { /// @@ -78,7 +72,6 @@ public sealed class InviteStore(DealDbContext dbContext) : IInviteStore 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) diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs index e27f07a..9dde964 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs @@ -5,9 +5,7 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// Карточки канбана — partial-часть (C32: выделено из общего файла, -/// поведение не менялось): чтение списка/поиск/одна карточка, создание из снимка, -/// смена колонки/seen, жёсткое удаление, очистка колонки, счётчики. +/// Карточки канбана — partial-часть /// public sealed partial class KanbanStore { @@ -17,7 +15,6 @@ public sealed partial class KanbanStore IQueryable queryable = _dbContext.Cards.AsNoTracking(); if (query.Col is null) { - // Все колонки дашборда, кроме контейнеров-стадий «Выбранных» (этап 9): карточка, взятая // в работу, живёт в том же пространстве «Выбранных», а не на дашборде. queryable = queryable.Where(card => !SelectedStageIds.Contains(card.Col)); } @@ -44,8 +41,6 @@ public sealed partial class KanbanStore 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. @@ -138,12 +133,10 @@ public sealed partial class KanbanStore /// 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; } @@ -162,7 +155,6 @@ public sealed partial class KanbanStore /// 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) @@ -197,7 +189,6 @@ public sealed partial class KanbanStore string? col, CancellationToken ct) { - // Три режима leads.py mark_seen L250–256: одна карточка | колонка | все карточки. IQueryable queryable = _dbContext.Cards; if (cardId is not null) { @@ -214,9 +205,6 @@ public sealed partial class KanbanStore /// 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 @@ -231,8 +219,6 @@ public sealed partial class KanbanStore /// 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 @@ -248,7 +234,6 @@ public sealed partial class KanbanStore /// 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)) diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Comments.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Comments.cs index 1dc6af4..0bca7ea 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Comments.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Comments.cs @@ -6,9 +6,7 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// Комментарии и журнал действий — partial-часть (C32: выделено из общего -/// файла, поведение не менялось): LeadComments (список/добавление), CardMoves (запись/счётчик learning) -/// и few-shot-примеры разметки для ИИ (leads.py add_comment/_log_learning/_learning_examples). +/// Комментарии и журнал действий — partial-часть /// public sealed partial class KanbanStore { @@ -32,7 +30,6 @@ public sealed partial class KanbanStore string text, CancellationToken ct) { - // CreatedAt — UTC-now (leads.py add_comment L259–265). Целостность ссылки на карточку держит FK: // комментарий без карточки не запишется (404-семантику несуществующей карточки отдаёт сервис, // прочитав карточку перед добавлением, — адаптеру возвращать нечего: порт void). _dbContext.LeadComments.Add(new LeadCommentEntity @@ -49,8 +46,6 @@ public sealed partial class KanbanStore /// 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, @@ -69,7 +64,6 @@ public sealed partial class KanbanStore /// 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() diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Containers.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Containers.cs index 5bbe5f0..d5f9e3d 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Containers.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Containers.cs @@ -6,9 +6,7 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// Контейнеры карточек — partial-часть (этап 9, T4): единый реестр -/// колонок/стадий/зон на таблице Containers: список/чтение/создание/полное обновление/удаление с -/// переносом карточек в «Неразобранное»/перестановка позиций. +/// Контейнеры карточек — partial-часть /// public sealed partial class KanbanStore { diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Selected.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Selected.cs index 155637c..3a1d8eb 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Selected.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Selected.cs @@ -5,10 +5,7 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// Операции карточек пространства «Выбранные» — partial-часть (этап 9: -/// после слияния домена карточка — одна строка таблицы Cards, стадия — её контейнер): список стадий -/// (UpdatedAt DESC), правка полей, ссылки/файлы (jsonb-append/filter), перенос по стадии с историей и -/// сбросом напоминания, напоминания (set/clear/due/fired/очистка протухших), очистка стадии. +/// Операции карточек пространства «Выбранные» — partial-часть /// public sealed partial class KanbanStore { @@ -38,7 +35,6 @@ public sealed partial class KanbanStore CancellationToken ct) { // Точечная правка по присутствующим полям патча: null-поле не меняется, JSON-поля заменяются - // целиком, в конце — bump UpdatedAt (patch_card projects.py L159–187). CardEntity? entity = await _dbContext.Cards .SingleOrDefaultAsync(card => card.Id == cardId, ct); if (entity is null) @@ -78,7 +74,6 @@ public sealed partial class KanbanStore CardLinkDto link, CancellationToken ct) { - // Один UPDATE — jsonb-append в конец массива links (add_link L133–143 + bump UpdatedAt). return await ExecuteJsonMutationAsync( $""" UPDATE "Cards" @@ -96,7 +91,6 @@ public sealed partial class KanbanStore string linkId, CancellationToken ct) { - // Один UPDATE — jsonb-фильтрация массива по id элемента (remove_link L146–150 + bump UpdatedAt). return await ExecuteJsonMutationAsync( $""" UPDATE "Cards" @@ -118,7 +112,6 @@ public sealed partial class KanbanStore CardFileDto file, CancellationToken ct) { - // Один UPDATE — jsonb-append в конец массива files (files.py add_file L57–75 + bump UpdatedAt). return await ExecuteJsonMutationAsync( $""" UPDATE "Cards" @@ -136,7 +129,6 @@ public sealed partial class KanbanStore string fileId, CancellationToken ct) { - // Один UPDATE — jsonb-фильтрация массива files по id записи (files.py remove_file L86–94 + bump). return await ExecuteJsonMutationAsync( $""" UPDATE "Cards" @@ -161,7 +153,6 @@ public sealed partial class KanbanStore CancellationToken ct) { // Смена контейнера-стадии: Col=containerId, сброс напоминания, updated_at=atMs, история + запись - // (move_stage projects.py L202–216; Ruling 7). Нетрекинговое чтение + ExecuteUpdate. CardEntity? entity = await _dbContext.Cards .AsNoTracking() .SingleOrDefaultAsync(card => card.Id == cardId, ct); @@ -215,7 +206,6 @@ public sealed partial class KanbanStore /// public async Task ClearStageAsync(string containerId, CancellationToken ct) { - // Число удалённых — затронутые строки ExecuteDelete (clear_stage projects.py L223–231). Комментарии // снимает каскад FK LeadComments → Cards. return await _dbContext.Cards .Where(card => card.Col == containerId) @@ -225,7 +215,6 @@ public sealed partial class KanbanStore /// public async Task> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct) { - // Наступившие напоминания hold-карточек в порядке наступления (check_reminders projects.py L270–275). return await _dbContext.Cards .AsNoTracking() .Where(card => card.Col == HoldStage @@ -253,7 +242,6 @@ public sealed partial class KanbanStore /// public async Task ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct) { - // Протухшие напоминания при выключенной настройке: fired не важен — чистим все (L266–269, Ruling 3). return await _dbContext.Cards .Where(card => card.ReminderAt != null && card.ReminderAt <= now) .ExecuteUpdateAsync(setters => setters @@ -269,7 +257,6 @@ public sealed partial class KanbanStore return affected == 1; } - // Накладывает поля патча на сущность: null-поле пропускается (не меняется), JSON-поля — полная замена (patch_card L159–187). private static void ApplyPatch(CardEntity entity, CardPatch patch) { if (patch.Title is not null) diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs index 94c8f81..a2b182d 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs @@ -7,9 +7,6 @@ namespace Deal.Infrastructure.Persistence.Repositories; /// /// Правила хранения, конверсии и вход эвристики suggest — partial-часть -/// (C32: выделено из общего файла, поведение не менялось): кандидаты тика (автоархив/очистка архива и -/// корзины), batch-архивация и жёсткое удаление пачки, пересчёт бюджетных конверсий и «Неразобранное» -/// с исходным текстом для ИИ-предложений (tick_storage/recompute_conversions/suggest, Rulings 3/7/8). /// public sealed partial class KanbanStore { @@ -17,7 +14,6 @@ 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) @@ -38,9 +34,7 @@ public sealed partial class KanbanStore 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; @@ -59,7 +53,6 @@ public sealed partial class KanbanStore /// 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 @@ -72,7 +65,6 @@ public sealed partial class KanbanStore /// 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) @@ -83,8 +75,6 @@ public sealed partial class KanbanStore /// public async Task PurgeAsync(IReadOnlyList cardIds, CancellationToken ct) { - // Жёсткое удаление пачки (очистки тика и clear-col, Ruling 8): комментарии чистит каскад БД, - // строки дедупа удаляемых карточек — здесь же в транзакции (Ruling 3, _hard_delete L229). if (cardIds.Count == 0) { return 0; @@ -104,7 +94,6 @@ public sealed partial class KanbanStore /// public async Task> ListCardsForConversionAsync(CancellationToken ct) { - // Карточки с бюджетом вне служебных колонок (recompute_conversions L106–130, Ruling 7): // пересчёт читает только бюджетные/conv-поля, комментарии не нужны. List entities = await _dbContext.Cards .AsNoTracking() @@ -123,7 +112,6 @@ public sealed partial class KanbanStore string convCur, CancellationToken ct) { - // Пишутся только conv-поля карточки (Ruling 7); convCur пуст — конверсия снята. await _dbContext.Cards .Where(card => card.Id == cardId) .ExecuteUpdateAsync(setters => setters @@ -136,7 +124,6 @@ public sealed partial class KanbanStore /// 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) diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs index 4afced7..de6fcfa 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs @@ -9,32 +9,14 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// EF-адаптер хранилища карточек и контейнеров (Ruling 1/12): таблицы Cards/Containers/LeadComments/CardMoves схемы тенанта. +/// EF-адаптер хранилища карточек и контейнеров /// -/// -/// Маппинг 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-контекст БД). + /// Создаёт EF-адаптер хранилища канбана /// /// Контекст схемы тенанта (Containers/Cards/LeadComments/CardMoves/DedupEntries). public KanbanStore(TenantDbContext dbContext) @@ -42,30 +24,23 @@ public sealed partial class KanbanStore : ICardStore _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, @@ -226,13 +201,10 @@ public sealed partial class KanbanStore : ICardStore 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()); @@ -252,7 +224,6 @@ public sealed partial class KanbanStore : ICardStore return $"{days} дн"; } - // Разбирает JSON-массив в список; пустая/битая строка — пустой список (как json.loads в прототипе). private static IReadOnlyList ToJsonList(string json) { if (string.IsNullOrWhiteSpace(json)) diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/MlLearningStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/MlLearningStore.cs index 1c26786..41b559e 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/MlLearningStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/MlLearningStore.cs @@ -6,15 +6,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// EF-адаптер хранилища обучения ML (Ruling 4, Task 5): очередь MlOutbox + журнал CardMoves схемы тенанта. +/// EF-адаптер хранилища обучения ML /// -/// -/// Реализация порта на (эталон -/// 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 { @@ -49,8 +42,6 @@ public sealed class MlLearningStore(TenantDbContext dbContext) : IMlLearningStor /// 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) @@ -66,7 +57,6 @@ public sealed class MlLearningStore(TenantDbContext dbContext) : IMlLearningStor 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 360700e..55caf51 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/OperatorAuthStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/OperatorAuthStore.cs @@ -6,9 +6,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// EF-адаптер хранилища аутентификации оператора: таблицы public.operators и public.operator_sessions (Ruling 1). +/// EF-адаптер хранилища аутентификации оператора /// -/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). public sealed class OperatorAuthStore(DealDbContext dbContext) : IOperatorAuthStore { /// diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs index 9b9da79..c6e95ee 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs @@ -7,23 +7,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// EF-адаптер хранилища пайплайна (Rulings 1/10): таблицы QueueItems/RejectedItems/DedupEntries схемы тенанта. +/// EF-адаптер хранилища пайплайна /// -/// -/// Маппинг 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) ─────────────────────────────────────────────── @@ -34,7 +19,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore 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); @@ -88,7 +72,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore /// public async Task RemoveAsync(string id, CancellationToken ct) { - // Безвозвратное удаление строки очереди на любом этапе отсева (pipeline.py _drop_row L809–813). await dbContext.QueueItems .Where(item => item.Id == id) .ExecuteDeleteAsync(ct); @@ -99,7 +82,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore /// public async Task UpsertAsync(RejectRecord record, CancellationToken ct) { - // Пустой/пробельный текст — no-op (processing.record L73–74). if (string.IsNullOrWhiteSpace(record.Text)) { return; @@ -109,9 +91,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore 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( $""" @@ -160,9 +139,7 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore 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( $""" @@ -177,9 +154,7 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore 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() @@ -217,7 +192,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore /// public async Task DeleteAsync(string id, CancellationToken ct) { - // Удаление одной записи отсева безвозвратно (processing.delete_one L196–198). await dbContext.RejectedItems .Where(item => item.Id == id) .ExecuteDeleteAsync(ct); @@ -226,14 +200,12 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore /// 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); @@ -246,7 +218,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore DateTimeOffset returnedAt, CancellationToken ct) { - // Возврат из отсева: аудит-поля записи, строка не удаляется (return_to_queue L156–159). await dbContext.RejectedItems .Where(item => item.Id == id) .ExecuteUpdateAsync(setters => setters @@ -265,10 +236,8 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore /// 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") @@ -282,7 +251,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore /// 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) @@ -295,7 +263,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore 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); @@ -304,7 +271,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore /// public async Task DeleteByCardAsync(string cardId, CancellationToken ct) { - // Чистка «мягких» ссылок при жёстком удалении карточки (Ruling 3). В KanbanStore-удалениях // дублируется тем же DELETE (см. KanbanStore.DeleteForeverAsync/PurgeAsync/ClearColAsync) — // чтобы Kanban-адаптер не зависел от порта Pipeline; этот метод — для потребителей модуля. await dbContext.DedupEntries @@ -314,7 +280,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore // ── Маппинг (вручную: порт не видит EF-сущности) ─────────────────────── - // Строка QueueItems → DTO очереди (времена — epoch-ms наружу, Ruling 1). private static QueueItemDto ToQueueItemDto(QueueItemEntity entity) => new() { Id = entity.Id, @@ -349,7 +314,6 @@ public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore }; } - // Строка RejectedItems → DTO отсева (подписи этапа/источника — словари модуля Pipeline). private static RejectedItemDto ToRejectedItemDto(RejectedItemEntity entity) => new() { Id = entity.Id, diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs index adf6dc0..aee05ca 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs @@ -8,17 +8,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// EF-адаптер распределённого счётчика фиксированного окна: таблица public.rate_limit_counters -/// (этап 12, пакет B). +/// EF-адаптер распределённого счётчика фиксированного окна /// -/// -/// Атомарность инкремента на Postgres обеспечивает один INSERT … ON CONFLICT DO UPDATE … RETURNING -/// (гонки параллельных запросов одного ключа не теряют счёт) — именно ради этого хранилище вынесено из -/// памяти процесса. InMemory-провайдер (юнит-тесты) raw SQL не исполняет: тот же результат даёт -/// read-modify-write EF в одном SaveChanges (эталон TenantLimitStore). Смена WindowStart сбрасывает -/// счётчик — семантика фиксированного окна. Уборка устаревших строк — -/// (фоновый цикл); на Npgsql удаление — ExecuteDelete, на InMemory — выгрузка и RemoveRange. -/// public sealed class RateLimitCounterStore(DealDbContext dbContext) : IRateLimitCounterStore { // Атомарный upsert счётчика окна на Postgres (INSERT … ON CONFLICT … RETURNING). diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs index 0cc450c..aab51a1 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs @@ -6,15 +6,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// EF-адаптер KV-хранилища настроек тенанта: таблица settings (Ruling 1). +/// EF-адаптер KV-хранилища настроек тенанта /// -/// -/// Маппинг 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 { /// diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/TelegramStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/TelegramStore.cs index 6926927..b5ec200 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/TelegramStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/TelegramStore.cs @@ -7,15 +7,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// EF-адаптер хранилища каталога диалогов: таблицы Dialogs/TgMessages схемы тенанта (Ruling 7, Task 13). +/// EF-адаптер хранилища каталога диалогов /// -/// -/// Реализация порта на (эталон 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 { @@ -27,11 +20,9 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore { 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)) @@ -50,7 +41,6 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore } else { - // Новый диалог: монитор — по настройке autoMonitorNew (python L489), остальное — дефолты строки. dbContext.Dialogs.Add(new DialogEntity { Id = entry.Id, @@ -64,7 +54,6 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore } } - // Диалоги, которых больше нет в каталоге Telegram (вышел/удалил), удаляются (python L491–500). List stale = await dbContext.Dialogs .Where(dialog => !ids.Contains(dialog.Id)) .ToListAsync(ct); @@ -77,7 +66,6 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore /// public async Task> ListAsync(CancellationToken ct) { - // Список вкладки «Каналы»: включённые первыми, далее по имени (python list_dialogs L522). List rows = await dbContext.Dialogs .OrderByDescending(dialog => dialog.Monitor) .ThenBy(dialog => dialog.Name) @@ -144,7 +132,6 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore /// 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); @@ -159,7 +146,6 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore 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); @@ -203,7 +189,6 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore bool exists = await dbContext.TgMessages.AnyAsync(message => message.Id == messageId, ct); if (exists) { - // INSERT OR IGNORE python L602–605: первое вхождение сообщения не перезаписывается. return; } @@ -224,7 +209,6 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore DateTimeOffset at, CancellationToken ct) { - // python _on_message L270–273: last_text/last_at/updated_at одним моментом приёма. return dbContext.Dialogs .Where(dialog => dialog.Id == dialogId) .ExecuteUpdateAsync( @@ -250,7 +234,6 @@ public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore 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) diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/TenantLimitStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/TenantLimitStore.cs index aeb90ac..5768446 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/TenantLimitStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/TenantLimitStore.cs @@ -7,20 +7,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// EF-адаптер хранилища лимитов ИИ-бюджета: таблица public.tenant_limits (Ruling 3 этапа 7, Task 8). +/// EF-адаптер хранилища лимитов ИИ-бюджета /// -/// -/// Маппинг 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; @@ -29,7 +17,7 @@ public sealed class TenantLimitStore : ITenantLimitStore private readonly Func _utcNow; /// - /// Создаёт адаптер с дефолт-бюджетом модуля (TokenBudgetDefaults) и системными часами. + /// Создаёт адаптер с дефолт-бюджетом модуля /// /// Системный контекст (public-схема). public TenantLimitStore(DealDbContext dbContext) @@ -38,17 +26,17 @@ public sealed class TenantLimitStore : ITenantLimitStore } /// - /// Создаёт адаптер с дефолт-бюджетом из конфигурации (Api/AddDealPersistence) и системными часами. + /// Создаёт адаптер с дефолт-бюджетом из конфигурации /// /// Системный контекст (public-схема). - /// Дефолт-параметры лениво создаваемой строки (Ruling 3). + /// Дефолт-параметры лениво создаваемой строки. public TenantLimitStore(DealDbContext dbContext, TokenLimitDefaults defaults) : this(dbContext, defaults, new TokenBudgetService(), () => DateTimeOffset.UtcNow) { } /// - /// Создаёт адаптер с явными зависимостями (тесты подменяют часы для проверки ленивого reset). + /// Создаёт адаптер с явными зависимостями /// /// Системный контекст (public-схема). /// Дефолт-параметры лениво создаваемой строки. @@ -176,7 +164,6 @@ public sealed class TenantLimitStore : ITenantLimitStore return true; } - // Читает строку лимита; при отсутствии лениво создаёт с дефолт-бюджетом и сохраняет (Ruling 3). // tenantId: Тенант. // defaults: Дефолт-параметры создаваемой строки. // ct: Токен отмены. @@ -209,7 +196,6 @@ public sealed class TenantLimitStore : ITenantLimitStore return created; } - // Ленивый reset (Ruling 3): период истёк — обнулить счётчик и флаги, PeriodStart=now, сохранить. // entity: Отслеживаемая строка лимита. // ct: Токен отмены. private async Task ResetIfPeriodExpiredAsync(TenantLimitEntity entity, CancellationToken ct) @@ -263,14 +249,12 @@ public sealed class TenantLimitStore : ITenantLimitStore 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) diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/TenantRepository.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/TenantRepository.cs index 9bd5956..8e61f63 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/TenantRepository.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/TenantRepository.cs @@ -6,9 +6,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// EF-адаптер реестра тенантов: таблица public.tenants. +/// EF-адаптер реестра тенантов /// -/// Маппинг DTO ↔ сущности выполняется вручную (порт модуля не видит EF-сущности, Ruling 1). public sealed class TenantRepository(DealDbContext dbContext) : ITenantRepository { /// diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/TokenUsageEventStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/TokenUsageEventStore.cs index ef1435f..1b0ba0c 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/TokenUsageEventStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/TokenUsageEventStore.cs @@ -7,14 +7,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; /// -/// EF-адаптер истории расхода токенов: таблица public.token_usage_events (append-only + агрегаты, этап 10, T2). +/// EF-адаптер истории расхода токенов /// -/// -/// Маппинг 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 { /// diff --git a/src/core/Deal.Infrastructure/Persistence/TenantDbContext.cs b/src/core/Deal.Infrastructure/Persistence/TenantDbContext.cs index ada1283..47e3506 100644 --- a/src/core/Deal.Infrastructure/Persistence/TenantDbContext.cs +++ b/src/core/Deal.Infrastructure/Persistence/TenantDbContext.cs @@ -5,82 +5,82 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence; /// -/// Бессхемный DbContext тенанта: таблицы живут в схеме тенанта через search_path. +/// Бессхемный DbContext тенанта /// 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). + /// Журнал действий над карточками для обучения ML /// public DbSet CardMoves => Set(); /// - /// Очередь обучающих сигналов ML (таблица MlOutbox). + /// Очередь обучающих сигналов ML /// 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). + /// Каталог диалогов/каналов Telegram. /// public DbSet Dialogs => Set(); /// - /// Превью-сообщения диалогов (таблица TgMessages; модуль Telegram, Ruling 7). + /// Превью-сообщения диалогов. /// public DbSet TgMessages => Set(); /// - /// Задачи поиска Discovery (таблица DiscTasks; модуль Discovery, Ruling 9). + /// Задачи поиска Discovery. /// public DbSet DiscTasks => Set(); /// - /// Кандидаты задач Discovery (таблица DiscCandidates; модуль Discovery, Ruling 9). + /// Кандидаты задач Discovery. /// public DbSet DiscCandidates => Set(); /// - /// Чёрный список Discovery (таблица DiscBlacklist; модуль Discovery, Ruling 9). + /// Чёрный список Discovery. /// public DbSet DiscBlacklist => Set(); /// - /// Лог событий задач Discovery (таблица DiscLog; модуль Discovery, Ruling 9). + /// Лог событий задач Discovery. /// public DbSet DiscLog => Set(); diff --git a/src/core/Deal.Infrastructure/Persistence/TenantDbDesignTimeFactory.cs b/src/core/Deal.Infrastructure/Persistence/TenantDbDesignTimeFactory.cs index 766f93e..3476857 100644 --- a/src/core/Deal.Infrastructure/Persistence/TenantDbDesignTimeFactory.cs +++ b/src/core/Deal.Infrastructure/Persistence/TenantDbDesignTimeFactory.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore.Design; namespace Deal.Infrastructure.Persistence; /// -/// Фабрика для dotnet-ef (миграции TenantDbContext). История миграций — без схемы. +/// Фабрика для dotnet-ef /// public sealed class TenantDbDesignTimeFactory : IDesignTimeDbContextFactory { diff --git a/src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs b/src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs index 9353eca..176032b 100644 --- a/src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs +++ b/src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs @@ -5,13 +5,8 @@ using Deal.Modules.Settings.Application.Abstractions; namespace Deal.Infrastructure.Security; /// -/// AES-256-GCM-шифр секретов (Ruling 2): nonce 12 байт, tag 16 байт, ключ 32 байта. +/// AES-256-GCM-шифр секретов /// -/// -/// Формат значения: enc: + Base64(nonce ‖ шифротекст ‖ tag). Экземпляр AesGcm создаётся -/// на каждую операцию: операции шифрования секретов редкие, а отсутствие разделяемого -/// криптографического состояния делает singleton-использование потокобезопасным. -/// public sealed class AesGcmSecretCipher : ISecretCipher { // Префикс зашифрованного значения (маркер формата в хранилище). diff --git a/src/core/Deal.Infrastructure/Security/EncryptionKeyProvider.cs b/src/core/Deal.Infrastructure/Security/EncryptionKeyProvider.cs index a67883b..6d5ab32 100644 --- a/src/core/Deal.Infrastructure/Security/EncryptionKeyProvider.cs +++ b/src/core/Deal.Infrastructure/Security/EncryptionKeyProvider.cs @@ -3,17 +3,8 @@ using System.Security.Cryptography; namespace Deal.Infrastructure.Security; /// -/// Источник ключа шифрования секретов (Ruling 2): env-ключ либо файл data/encryption.key. +/// Источник ключа шифрования секретов /// -/// -/// Порядок разрешения — как в crypto._get_fernet (crypto.py L22–42): -/// -/// env DEAL_ENCRYPTION_KEY — 32 байта в urlsafe-Base64; невалидный ключ → исключение при старте; -/// иначе файл data/encryption.key относительно ContentRoot (путь переопределяется env -/// DEAL_ENCRYPTION_KEY_FILE); при первом старте файл генерируется (32 случайных байта, urlsafe-Base64). -/// -/// Ключ кэшируется после первого разрешения. Для продакшена задавайте env-ключ, а не файл. -/// public sealed class EncryptionKeyProvider { // Имя env-переменной с ключом (32 байта, urlsafe-Base64). @@ -44,7 +35,7 @@ public sealed class EncryptionKeyProvider } /// - /// Возвращает ключ AES-256 (32 байта), разрешая его один раз и кэшируя результат. + /// Возвращает ключ AES-256 /// /// Ключ шифрования. /// Env-ключ невалиден либо файл-ключ невозможно использовать. diff --git a/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs b/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs index fcdd981..475c2d4 100644 --- a/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs +++ b/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs @@ -23,55 +23,41 @@ using Microsoft.Extensions.Logging; namespace Deal.Infrastructure; /// -/// DI-регистрация адаптеров персистентности Deal.Infrastructure (паттерн «port & adapter», Ruling 1). +/// DI-регистрация адаптеров персистентности Deal.Infrastructure. /// -/// -/// Регистрируются только EF-адаптеры портов модулей. Сервисы самих модулей регистрируют -/// модульные регистраторы (AddTenantsModule в Deal.Api). Адаптеры — scoped, потому что живут -/// на scoped-контекстах EF (DealDbContext), которыми владеет запрос. -/// public static class ServiceCollectionExtensions { /// - /// Регистрирует EF-адаптеры портов модулей: IAuthStore, IOperatorAuthStore, ITenantRepository, IAuditLogStore, IInviteStore, ITenantLimitStore, ISettingsStore, IGlobalSettingsStore, ICardStore, IPipelineStore, IMlLearningStore, ITenantProvisioner. + /// Регистрирует EF-адаптеры портов модулей /// /// Коллекция сервисов. - /// Дефолт-бюджет лениво создаваемых строк tenant_limits (Ruling 3; null — константа TokenBudgetDefaults). - /// Передаётся из конфигурации/env DEAL_DEFAULT_AI_BUDGET в Program.cs (Task 8). + /// Дефолт-бюджет лениво создаваемых строк tenant_limits. Передаётся из конфигурации/env DEAL_DEFAULT_AI_BUDGET в Program.cs. /// Коллекция сервисов для цепочки вызовов. public static IServiceCollection AddDealPersistence(this IServiceCollection services, TokenLimitDefaults? tenantLimitDefaults = null) { services.AddScoped(); - // Хранилище аутентификации оператора (Ruling 1 этапа 7): таблицы operators/operator_sessions // схемы public — отдельный порт от IAuthStore (оператор ≠ пользователь тенанта). services.AddScoped(); services.AddScoped(); - // Хранилище аудита (Ruling 4): таблица public.audit_log — append-only, порт без Update/Delete. services.AddScoped(); - // Хранилище истории расхода токенов (этап 10, T2): таблица public.token_usage_events — append-only // + агрегаты для аналитики; порт без Update/Delete. services.AddScoped(); - // Хранилище приглашений (Ruling 2): таблица public.invites — создание/чтение/отзыв оператором (Task 5), - // статусные переходы меняет прикладной слой (InvitesService), активацию выполнит /api/join (Task 6). services.AddScoped(); - // Хранилище лимитов ИИ-бюджета (Ruling 3, Task 8): таблица public.tenant_limits. Фабрика передаёт // дефолт-бюджет из конфигурации (Program.cs) — ленивый GetOrCreate новой строки использует его; // DealDbContext уже зарегистрирован в Api (AddDbContext до AddDealPersistence). services.AddScoped(provider => new TenantLimitStore( provider.GetRequiredService(), tenantLimitDefaults ?? TokenBudgetDefaults.Default)); - // Хранилище счётчиков фиксированного окна (этап 12, пакет B): таблица public.rate_limit_counters — // общее хранилище распределённого rate limiting (auth/api/gRPC) и учёта попыток входа. Scoped // (DealDbContext); лимитер резолвит его в собственном scope на каждое приобретение. services.AddScoped(); - // KV-хранилище настроек тенанта (Ruling 1): таблица settings в схеме тенанта, // контекст — scoped TenantDbContext запроса (см. AddDbContext в Deal.Api). services.AddScoped(); @@ -80,33 +66,24 @@ public static class ServiceCollectionExtensions // задаёт оператор, ядро читает их для команд входа (TelegramKeysService). services.AddScoped(); - // Хранилище карточек и контейнеров (Ruling 12): таблицы Cards/Containers/LeadComments/CardMoves в схеме тенанта. services.AddScoped(); - // Хранилище пайплайна (Ruling 10): таблицы QueueItems/RejectedItems/DedupEntries в схеме тенанта. // KanbanStore при жёстком удалении карточки чистит DedupEntries напрямую тем же TenantDbContext - // (Ruling 3) — IPipelineStore для этого не привлекается, цикла Kanban → Pipeline нет. services.AddScoped(); - // Хранилище обучения ML (Ruling 4, Task 5): очередь MlOutbox + счётчик журнала CardMoves. services.AddScoped(); - // Хранилище каталога диалогов Telegram (Ruling 7, Task 13): таблицы Dialogs/TgMessages в схеме тенанта. services.AddScoped(); - // Хранилище Discovery (Ruling 9, Task 17): таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog в схеме // тенанта. Проверки «уже мониторится» читают таблицу Dialogs (владелец — Telegram) тем же TenantDbContext. services.AddScoped(); - // Провижининг схем тенантов (Ruling 3). TenantProvisioningService зависит от // ConnectionStringProvider — он регистрируется в Deal.Api (Program.cs) как singleton. services.AddScoped(); - // Пакетная (maintenance) миграция схем всех тенантов (этап 12, пакет C): ограниченный // параллелизм + логирование прогресса поверх ITenantRepository и ITenantProvisioner. services.AddScoped(); - // Единая точка перехода карточки между контейнерами (R4 этапа 9): выбор маршрута «стадия // Выбранных vs дашборд-контейнер» живёт в адаптере, а не в эндпоинте. Scoped — // композирует scoped-сервис карточек в рамках tenant-запроса. services.AddScoped(); @@ -114,35 +91,13 @@ public static class ServiceCollectionExtensions } /// - /// Регистрирует адаптеры внешних интеграций (Rulings 4/5/6/9): IMlClient, IAiClassifier, IAiTools, IColumnSuggester, ITelegramGateway. + /// Регистрирует адаптеры внешних интеграций /// /// Коллекция сервисов. - /// Конфигурация секции Services:Ml — выбор реализации IMlClient (Ruling 6). - /// Конфигурация секции Services:Ai — выбор реализации IAiClassifier/IAiTools (Ruling 6). - /// Конфигурация секции Services:Telegram — выбор реализации ITelegramGateway (Ruling 6). + /// Конфигурация секции Services:Ml — выбор реализации IMlClient. + /// Конфигурация секции Services:Ai — выбор реализации IAiClassifier/IAiTools. + /// Конфигурация секции Services:Telegram — выбор реализации ITelegramGateway. /// Коллекция сервисов для цепочки вызовов. - /// - /// IMlClient: при UseLocal=true (default) — Local-заглушка LocalMlClient (фолбэк этапов 2–5); при - /// UseLocal=false — gRPC-адаптер GrpcMlClient (ml.proto, Ruling 1/4) + синглтон-транспорт - /// MlGrpcConnection и кэш статуса MlStatusCache; тот же адаптер реализует IMlTrainClient для фонового - /// MlOutboxFlushScheduler (регистрируется в Deal.Api под тем же флагом). PushAsync в обоих режимах пишет - /// в MlOutbox (Ruling 6 — обучение всегда локально), выгрузку батчами делает флашер. IAiClassifier: при - /// Services:Ai:UseLocal=true — LocalAiClassifier (детерминированный разбор ядра, Ruling 5 этапа 4); - /// при false — GrpcAiClassifier (ai.proto; Ruling 5/6) + синглтон AiGrpcConnection (fail-fast, как - /// MlGrpcConnection) + scoped-сервисы конфига провайдера/учёта токенов. Бюджетный гейт (Task 9, Ruling 3): - /// в gRPC-режиме порты наружу отдаются декораторами BudgetedAiClassifier/BudgetedAiTools поверх gRPC-адаптеров - /// (порядок Grpc → Budgeted), Local-реализации регистрируются как бесплатный fallback гейта. IAiTools: - /// LocalAiTools (методы не поддерживаются — NotSupportedException, Ruling 9) либо GrpcAiTools за тем же флагом. - /// IColumnSuggester — - /// детерминированная Local-эвристика LocalColumnSuggester (Self-Review плана L525–527: на gRPC сознательно - /// не заменяется). ITelegramGateway: при Services:Telegram:UseLocal=true (default) — LocalTelegramGateway - /// (нейтральный no-op/idle, фолбэк до Task 14); при false — GrpcTelegramClient (telegram.proto, Ruling 7) + - /// синглтон-транспорт TelegramGrpcConnection (fail-fast). - /// Scoped: адаптеры читают KV-настройки тенанта (ISettingsStore → scoped TenantDbContext запроса) и - /// пишут в таблицы схемы тенанта, поэтому не могут жить дольше scope. HTTP-адаптеры других - /// интеграций (IAiConnectionChecker/IRatesSource) регистрируются в Deal.Api через AddHttpClient — - /// см. Program.cs (Tasks 6/8). - /// public static IServiceCollection AddDealIntegrations( this IServiceCollection services, MlServiceOptions mlOptions, @@ -150,24 +105,18 @@ public static class ServiceCollectionExtensions TelegramServiceOptions telegramOptions, MtlsCertificates? mtlsCertificates = null) { - // Сертификаты mTLS-каналов (Ruling 6, Task 13): null (флаг DEAL_MTLS_ENABLED выключен) — каналы // остаются plaintext + service-token (dev); при включённом флаге каждый транспорт подписывает запрос // клиентским сертификатом и проверяет CA сервера (fail-fast на загрузку — в MtlsCertificates.Load). - // Recorder расхода токенов (Ruling 3 этапа 7; история — этап 10, T2): пишет бюджет/lifetime/историю // событий. Регистрируется независимо от AI-режима — им пользуется и gRPC ML-клиент (событие kind=ml). services.AddScoped(); - // ML-клиент (Ruling 6, план Task 16): выбор на старте — Local-заглушка либо gRPC-адаптер ml-service. if (mlOptions.UseLocal) { - // Обучение копится в MlOutbox (этап 3); отправка в ML-сервис не выполняется (сервиса нет в dev) — - // очередь остаётся накопленной, как в прототипе при недоступном сервисе (python L56–82). services.AddScoped(); } else { // gRPC-клиент ml-service: транспорт создаётся сразу (fail-fast: пустой endpoint/токен останавливают - // старт — Ruling 2/13), кэш статуса 15 с переживает scope запросов. Scoped GrpcMlClient читает // KV/таблицы тенанта (как LocalMlClient) и ходит в сервис по metadata tenant-id/service-token. services.AddSingleton(new MlGrpcConnection(mlOptions, mtlsCertificates)); services.AddSingleton(); @@ -176,14 +125,11 @@ public static class ServiceCollectionExtensions services.AddScoped(provider => provider.GetRequiredService()); } - // ИИ-предложения колонок/ключей (Ruling 3, Task 14): порт Contracts → детерминированная эвристика. // LocalColumnSuggester читает карточки через ICardStore, считает группы ядром SuggestHeuristics // (модуль Kanban) и создаёт доски suggested=true через ContainersService; scoped — его зависимости - // живут в рамках tenant-запроса (KanbanStore/TenantDbContext). На этапе 6 адаптер заменяется // gRPC-клиентом ai-service с тем же контрактом. services.AddScoped(); - // ИИ-классификатор входящих (Ruling 5/6, Task 15): порт Contracts → детерминированная Local-реализация // (UseLocal=true) либо gRPC-адаптер ai-service (UseLocal=false). LocalAiClassifier разбирает сообщение // ядром LocalFieldsParser модуля Pipeline (маркерная гипотеза типа: is_vacancy_known=false, board=null — // «смысловые колонки до ИИ не назначаем») и всегда пропускает ИИ-фильтр {pass:true, skipped:true}; @@ -191,8 +137,6 @@ public static class ServiceCollectionExtensions // GrpcAiClassifier строит промпты/контекст классификации (AiClassifyContextBuilder модуля Pipeline, // регистрируется AddPipelineModule), ходит в ai-service с ProviderConfig из настроек (расшифровка apiKey) // и копит usage в KV aiTokenUsage; недоступность/ok=false → AiUnavailableException — воркер падает в - // локальный разбор (aiFail, как raw={} python L1108–1114). Выбор на старте, рантайм-логики нет (Ruling 6). - // Списывание в tenant_limits + lifetime-KV aiTokenUsage выполняет TokenUsageRecorder (Ruling 3, Task 8). if (aiOptions.UseLocal) { services.AddScoped(); @@ -201,20 +145,15 @@ public static class ServiceCollectionExtensions else { // gRPC-клиент ai-service: транспорт создаётся сразу (fail-fast: пустой endpoint/токен останавливают - // старт — Ruling 2/13), как MlGrpcConnection. Scoped-адаптеры читают настройки/данные тенанта и ходят // в сервис по metadata tenant-id/service-token; usage ответов списывает TokenUsageRecorder - // (tenant_limits + lifetime-KV aiTokenUsage, Ruling 3 этапа 7). services.AddSingleton(new AiGrpcConnection(aiOptions, mtlsCertificates)); services.AddScoped(); services.AddScoped(); - // Локальные адаптеры как fallback бюджетного гейта (Ruling 3, Task 9): даже в gRPC-режиме декоратор при // исчерпанном бюджете/приостановке уводит вызов на детерминированный бесплатный локальный разбор // (LocalAiClassifier; LocalFieldsParser регистрирует AddPipelineModule в Program.cs). Инструменты локального - // fallback не имеют — при запрете BudgetedAiTools бросает AiUnavailableException/мягкую ошибку (Ruling 3). services.AddScoped(); // Декораторы бюджетного гейта (порядок Grpc → Budgeted → наружу): перед каждым платным вызовом // GetStateAsync (ITenantLimitStore, scoped) — исчерпано/приостановлено → Local-классификатор либо - // исключение/мягкая ошибка инструментов; списание usage остаётся внутри gRPC-адаптеров (Task 8). services.AddScoped(provider => new BudgetedAiClassifier( provider.GetRequiredService(), provider.GetRequiredService(), @@ -229,7 +168,6 @@ public static class ServiceCollectionExtensions provider.GetRequiredService>())); } - // Гейт telegram-service (Ruling 6/7, план Task 14): порт Contracts. По умолчанию (UseLocal=true) — // локальная заглушка dev LocalTelegramGateway (нейтральный no-op/idle — сервис не поднят); при // UseLocal=false — gRPC-клиент GrpcTelegramClient (telegram.proto) + синглтон-транспорт // TelegramGrpcConnection (fail-fast, как MlGrpcConnection). Scoped-адаптер: tenant-id для metadata @@ -249,17 +187,11 @@ public static class ServiceCollectionExtensions } /// - /// Регистрирует сервисы шифрования секретов: ISecretCipher → AesGcmSecretCipher (Ruling 2). + /// Регистрирует сервисы шифрования секретов /// /// Коллекция сервисов. /// ContentRoot приложения — каталог по умолчанию для файла-ключа data/encryption.key. /// Коллекция сервисов для цепочки вызовов. - /// - /// Ключ разрешается один раз при вызове: невалидный DEAL_ENCRYPTION_KEY → исключение при старте - /// (план Task 1). EncryptionKeyProvider в контейнер не регистрируется — после разрешения ключа - /// он рантайм-сервисам не нужен. ISecretCipher — singleton: реализация без разделяемого - /// состояния (AesGcm создаётся на операцию), поэтому потокобезопасна. - /// public static IServiceCollection AddDealSecurity(this IServiceCollection services, string contentRootPath) { EncryptionKeyProvider keyProvider = new(contentRootPath); diff --git a/src/core/Deal.Infrastructure/Services/CardMover.cs b/src/core/Deal.Infrastructure/Services/CardMover.cs index 923d5c5..c5c1a7b 100644 --- a/src/core/Deal.Infrastructure/Services/CardMover.cs +++ b/src/core/Deal.Infrastructure/Services/CardMover.cs @@ -7,17 +7,8 @@ using Deal.Modules.Kanban.Application.Services; namespace Deal.Infrastructure.Services; /// -/// Единая точка перехода карточки между контейнерами — реализация порта -/// (R4 этапа 9). +/// Единая точка перехода карточки между контейнерами — реализация порта . /// -/// -/// Маршрутизация цели живёт в домене: цель-стадия пространства «Выбранные» -/// () переходит через — -/// запись истории движения и сброс напоминания; остальные цели (доски/служебные зоны) — штатный перенос -/// дашборда (журнал CardMoves, matchHits, обучение ML). -/// Адаптер только нормализует результаты обоих маршрутов к ; снимок карточки -/// эндпоинт перечитывает единым чтением. Scoped: зависимости живут в рамках tenant-запроса. -/// /// Сервис карточек — единый домен перехода (стадии и контейнеры дашборда). public sealed class CardMover(CardsService cardsService) : ICardMover { @@ -30,7 +21,6 @@ public sealed class CardMover(CardsService cardsService) : ICardMover { // Контекст перехода (инициатор/обучение) учтён внутри маршрутов: дашборд-перенос обучает ML по // цели пользователя, переход по стадии пишет историю и сбрасывает напоминание. Отдельного - // ветвления по ctx сейчас нет — оно появится с политиками контейнеров (R4). CardResultDto result = CardsDefaultContainers.Contains(toContainerId) ? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct) : await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct); diff --git a/src/core/Deal.Infrastructure/Services/FtsMaintenance.cs b/src/core/Deal.Infrastructure/Services/FtsMaintenance.cs index 1ac3a3d..8756607 100644 --- a/src/core/Deal.Infrastructure/Services/FtsMaintenance.cs +++ b/src/core/Deal.Infrastructure/Services/FtsMaintenance.cs @@ -5,39 +5,23 @@ using Microsoft.Extensions.Logging; namespace Deal.Infrastructure.Services; /// -/// Обслуживание FTS-индексов схемы тенанта — POST /api/admin/fts/rebuild (Ruling 6, план Task 10; -/// fts.py rebuild L48–67). +/// Обслуживание FTS-индексов схемы тенанта — POST /api/admin/fts/rebuild. /// -/// -/// SearchTsv — генерируемые STORED-колонки Cards/RejectedItems (миграция TenantPipeline, Ruling 6): tsvector -/// авто-актуален при записи строк, «пересборка индекса раз в сутки» как в DuckDB-прототипе не нужна. -/// — реальная идемпотентная обслуживающая операция: CREATE INDEX IF NOT EXISTS -/// обоих GIN-индексов (самовосстановление, если индекс отсутствует) + ANALYZE обеих таблиц (актуальная -/// статистика для планировщика Postgres). Имена индексов/таблиц — внутренние константы (не пользовательский -/// ввод), поэтому SQL без параметров (как fts.py L51–52). Успех — true ({ok:true, ready:true} ответа); -/// сбой (нет DDL-прав и т.п.) логируется и возвращает false — {ok:false, ready:false}, как rebuild() python -/// (frontend rebuildFts store.js L1883–1889 показывает ошибку по ready). -/// public sealed class FtsMaintenance(TenantDbContext dbContext, ILogger logger) { - // Самовосстановление GIN-индекса карточек (имя — из миграции TenantPipeline, Ruling 6). private const string CardsIndexSql = "CREATE INDEX IF NOT EXISTS \"IX_Cards_SearchTsv\" ON \"Cards\" USING gin (\"SearchTsv\")"; - // Самовосстановление GIN-индекса отсева (имя — из миграции TenantPipeline, Ruling 6). private const string RejectedIndexSql = "CREATE INDEX IF NOT EXISTS \"IX_RejectedItems_SearchTsv\" ON \"RejectedItems\" USING gin (\"SearchTsv\")"; - // Актуализация статистики таблицы карточек для планировщика (fts.py rebuild L58–60). private const string AnalyzeCardsSql = "ANALYZE \"Cards\""; - // Актуализация статистики таблицы отсева для планировщика (fts.py rebuild L58–60). private const string AnalyzeRejectedSql = "ANALYZE \"RejectedItems\""; /// - /// Пересобирает FTS-индексы текущей схемы тенанта: CREATE INDEX IF NOT EXISTS + ANALYZE. + /// Пересобирает FTS-индексы текущей схемы тенанта /// - /// Токен отмены. /// true — индексы на месте и статистика собрана; false — сбой (эндпоинт отвечает {ok:false, ready:false}). public async Task RebuildAsync(CancellationToken ct) { diff --git a/src/core/Deal.Infrastructure/Tenancy/DefaultContainerProvisioner.cs b/src/core/Deal.Infrastructure/Tenancy/DefaultContainerProvisioner.cs index 71a8b30..cf89276 100644 --- a/src/core/Deal.Infrastructure/Tenancy/DefaultContainerProvisioner.cs +++ b/src/core/Deal.Infrastructure/Tenancy/DefaultContainerProvisioner.cs @@ -8,14 +8,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Tenancy; /// -/// Провижининг контейнеров тенанта по умолчанию из реестров модуля Cards (этап 9, T4). +/// Провижининг контейнеров тенанта по умолчанию из реестров модуля Cards. /// -/// -/// Единственный источник состава — (стадии «Выбранных») и -/// (служебные зоны inbox/archive/trash). Идемпотентен: контейнеры, уже существующие -/// в схеме тенанта, не трогаются — повторный bootstrap безопасен. Политики: архив хранится 90 дней, -/// корзина — 7, терминальные стадии без возврата; значения дней совпадают с дефолтами настроек хранения. -/// public static class DefaultContainerProvisioner { // Цвет служебной зоны «Неразобранное». @@ -44,7 +38,6 @@ public static class DefaultContainerProvisioner /// /// Контекст схемы тенанта. /// Текущее UTC-время (CreatedAt новых строк). - /// Токен отмены. public static async Task EnsureAsync( TenantDbContext db, DateTimeOffset now, diff --git a/src/core/Deal.Infrastructure/Tenancy/TenantMigrationSummary.cs b/src/core/Deal.Infrastructure/Tenancy/TenantMigrationSummary.cs index 3707160..fab00a0 100644 --- a/src/core/Deal.Infrastructure/Tenancy/TenantMigrationSummary.cs +++ b/src/core/Deal.Infrastructure/Tenancy/TenantMigrationSummary.cs @@ -1,7 +1,7 @@ namespace Deal.Infrastructure.Tenancy; /// -/// Итог пакетной миграции схем всех тенантов (maintenance-операция, этап 12, пакет C). +/// Итог пакетной миграции схем всех тенантов. /// /// Сколько схем тенантов обработано (число тенантов в реестре). /// Сколько схем успешно провижинено/мигрировано. diff --git a/src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs b/src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs index b41bbb6..3d6e1b3 100644 --- a/src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs +++ b/src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs @@ -10,15 +10,10 @@ using Npgsql; namespace Deal.Infrastructure.Tenancy; /// -/// Провижининг схемы тенанта (Ruling 3): создание схемы tenant_<id> и применение tenant-миграций. +/// Провижининг схемы тенанта /// -/// -/// Реализация порта ITenantProvisioner модуля Tenants. Идемпотентен: CREATE SCHEMA IF NOT EXISTS -/// и Database.Migrate повторно ничего не меняют — повторный bootstrap при старте безопасен. -/// public sealed class TenantProvisioningService(ConnectionStringProvider connectionStringProvider) : ITenantProvisioner { - // Имя таблицы истории tenant-миграций (схема задаётся при применении — Ruling 3). private const string TenantMigrationsHistoryTable = "__TenantMigrationsHistory"; // Семафор на схему тенанта: сериализует провижининг одного тенанта при параллельных вызовах. @@ -75,7 +70,6 @@ public sealed class TenantProvisioningService(ConnectionStringProvider connectio await using var db = new TenantDbContext(options); await db.Database.MigrateAsync(ct); - // Контейнеры по умолчанию — из реестров модуля Cards (этап 9); идемпотентно. await DefaultContainerProvisioner.EnsureAsync(db, DateTimeOffset.UtcNow, ct); } } diff --git a/src/core/Deal.Infrastructure/Tenancy/TenantSchemaMigrationService.cs b/src/core/Deal.Infrastructure/Tenancy/TenantSchemaMigrationService.cs index feb3796..b7119fd 100644 --- a/src/core/Deal.Infrastructure/Tenancy/TenantSchemaMigrationService.cs +++ b/src/core/Deal.Infrastructure/Tenancy/TenantSchemaMigrationService.cs @@ -8,25 +8,15 @@ using Microsoft.Extensions.Logging; namespace Deal.Infrastructure.Tenancy; /// -/// Пакетная (maintenance) миграция схем ВСЕХ существующих тенантов реестра (этап 12, пакет C) с -/// ограниченным параллелизмом и логированием прогресса — для провижининга SaaS на сотни/тысячи схем. +/// Пакетная (maintenance) миграция схем ВСЕХ существующих тенантов реестра с ограниченным параллелизмом и логированием прогресса — для провижининга SaaS на сотни/тысячи схем. /// -/// -/// Идемпотентность обеспечивает сам провижининг: CREATE SCHEMA IF NOT EXISTS + EF Core -/// MigrateAsync, который сверяется с таблицей истории __TenantMigrationsHistory схемы тенанта -/// и применяет ТОЛЬКО неприменённые миграции (повторный прогон ничего не меняет). Поэтому пакетный прогон -/// безопасен и при повторном запуске, и параллельно со стартовым bootstrap. Сбой одной схемы не прерывает -/// остальные: ошибка логируется, схема попадает в , а -/// операция завершается итоговой сводкой. Степень параллелизма клампится разумными границами, чтобы не -/// перегрузить Postgres при большом реестре. -/// public sealed class TenantSchemaMigrationService( ITenantRepository tenantRepository, ITenantProvisioner tenantProvisioner, ILogger logger) { /// - /// Параллелизм пакетной миграции по умолчанию (схем одновременно). + /// Параллелизм пакетной миграции по умолчанию /// public const int DefaultMaxParallelism = 4; @@ -39,16 +29,14 @@ public sealed class TenantSchemaMigrationService( /// /// Мигрирует схемы всех тенантов реестра с параллелизмом по умолчанию. /// - /// Токен отмены. /// Итоговая сводка пакетной миграции. public Task MigrateAllAsync(CancellationToken ct) => MigrateAllAsync(DefaultMaxParallelism, ct); /// - /// Мигрирует схемы всех тенантов реестра с заданным параллелизмом (значение клампится). + /// Мигрирует схемы всех тенантов реестра с заданным параллелизмом /// /// Желаемое число схем, мигрируемых одновременно. - /// Токен отмены. /// Итоговая сводка пакетной миграции. public async Task MigrateAllAsync(int maxParallelism, CancellationToken ct) { diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IAiSource.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IAiSource.cs index 0e0bec9..f611765 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IAiSource.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IAiSource.cs @@ -1,13 +1,12 @@ namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Источник «ИИ»: карточка создана/сгенерирована ИИ (агент, генератор, разборщик). +/// Источник «ИИ»: карточка создана/сгенерирована ИИ /// -/// Может быть составной частью : первоисточник данных + ИИ-обработка. public interface IAiSource : ISource { /// - /// Id провайдера ИИ (deepseek/openai/ollama/…). + /// Id провайдера ИИ /// public string ProviderId { get; } @@ -17,17 +16,17 @@ public interface IAiSource : ISource public string Model { get; } /// - /// Id ИИ-агента (если источник — агент, ищущий/обрабатывающий данные); null — не агент. + /// Id ИИ-агента /// public string? AgentId { get; } /// - /// Ссылка на промпт/шаблон (если применимо); null — нет. + /// Ссылка на промпт/шаблон /// public string? PromptRef { get; } /// - /// API, через который работал агент (для составных сценариев); null — нет. + /// API, через который работал агент /// public IApiSource? ViaApi { get; } } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IApiSource.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IApiSource.cs index a2963b2..54d0708 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IApiSource.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IApiSource.cs @@ -1,17 +1,17 @@ namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Источник «внешний API»: карточка пришла из интеграции по API. +/// Источник «внешний API» /// public interface IApiSource : ISource { /// - /// Ключ интеграции/провайдера (конфигурация подключения живёт в настройках тенанта). + /// Ключ интеграции/провайдера /// public string ProviderId { get; } /// - /// Адрес эндпоинта (или иной указатель в рамках провайдера); null — не применимо. + /// Адрес эндпоинта /// public string? Endpoint { get; } } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IAttributedCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IAttributedCard.cs index 9d90b03..012af15 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IAttributedCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IAttributedCard.cs @@ -3,13 +3,12 @@ using Deal.Modules.Cards.Application.Models; namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Модуль «атрибуты»: настраиваемые пользователем характеристики (стек, грейд, локация, сроки…). +/// Модуль «атрибуты» /// -/// Справочник атрибутов живёт в настройках тенанта; карточка хранит только значения по ключам. public interface IAttributedCard { /// - /// Значения атрибутов карточки (пусто — не заполнены). + /// Значения атрибутов карточки /// public IReadOnlyList Attributes { get; } } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IBudgetedCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IBudgetedCard.cs index a2a879b..0c5d866 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IBudgetedCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IBudgetedCard.cs @@ -3,7 +3,7 @@ using Deal.Modules.Cards.Application.Models; namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Модуль «бюджет/цена»: деньги заявки (валюта исходная + сконвертированная). +/// Модуль «бюджет/цена» /// public interface IBudgetedCard { diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ICard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ICard.cs index 19668e9..5b6d219 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ICard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ICard.cs @@ -3,29 +3,20 @@ namespace Deal.Modules.Cards.Application.Abstractions; /// /// Ядро карточки: единственное, что есть у любой карточки во всех дашбордах. /// -/// -/// Никаких «полей заявки» в ядре: стек/бюджет/контакты и прочее — опциональные модули-роли -/// (см. IContentCard, IBudgetedCard и др.), которые реализует агрегат карточки -/// по мере наполнения. — откуда карточка пришла (см. иерархию ISource); -/// у карточки, созданной пайплайном из сообщения канала, источник составной: -/// ICompositeSource { Origin: ITelegramSource, Pipeline: [IAiSource/IMl] }. -/// Типизированный доступ к конкретному источнику — дискриминация через иерархию ISource -/// (паттерн-матчинг), отдельный generic-интерфейс не нужен. -/// public interface ICard { /// - /// Короткий id карточки (единый префикс карточек, напр. c_). + /// Короткий id карточки /// public string Id { get; } /// - /// Заголовок карточки (очищенный). + /// Заголовок карточки /// public string Title { get; } /// - /// Источник: откуда карточка пришла (полиморфный, не enum). + /// Источник: откуда карточка пришла /// public ISource Source { get; } } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ICardMover.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ICardMover.cs index 15b9565..904ae3f 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ICardMover.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ICardMover.cs @@ -6,13 +6,6 @@ namespace Deal.Modules.Cards.Application.Abstractions; /// /// Единый механизм перехода карточки между контейнерами. /// -/// -/// Заменяет маршрутизацию перехода по эндпоинтам: одна точка знает и контейнеры-стадии пространства -/// «Выбранные», и контейнеры дашборда (доски/служебные зоны). Валидацию «откуда → куда можно» выполняют -/// политики контейнеров и «ворота» между пространствами (например, дашборд → «Выбранные» — только через -/// действие «взять в работу»; «Выбранные» → дашборд — запрещено). Побочные эффекты (запись истории для -/// стадий, сброс напоминания, журнал/обучение ML для дашборда) выполняет реализация перехода. -/// public interface ICardMover { /// @@ -21,7 +14,6 @@ public interface ICardMover /// Id карточки. /// Id контейнера назначения (стадия «Выбранных» либо дашборд-контейнер). /// Контекст перехода (инициатор, причина, обучение). - /// Токен отмены. /// Результат: Error (400-текст отказа) | Exists=false (карточки нет, 404) | успех (Exists=true). public Task MoveAsync( string cardId, diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ICommentableCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ICommentableCard.cs index 1d67be9..e2932ab 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ICommentableCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ICommentableCard.cs @@ -8,7 +8,7 @@ namespace Deal.Modules.Cards.Application.Abstractions; public interface ICommentableCard { /// - /// Комментарии карточки (пусто — комментариев нет). + /// Комментарии карточки /// public IReadOnlyList Comments { get; } } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ICompositeSource.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ICompositeSource.cs index 1b35045..7ad7333 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ICompositeSource.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ICompositeSource.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Составной источник: цепочка «где взято → как обработано/доставлено». +/// Составной источник /// -/// -/// У карточки из пайплайна источник составной: — первоисточник контента -/// (например, ITelegramSource), — кто/что его обрабатывал -/// (например, ИИ-классификатор). Позволяет строить «третьи» источники без правки ядра. -/// public interface ICompositeSource : ISource { /// @@ -16,7 +11,7 @@ public interface ICompositeSource : ISource public ISource Origin { get; } /// - /// Цепочка обработки (пусто — обработки не было). + /// Цепочка обработки /// public IReadOnlyList Pipeline { get; } } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IContactCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IContactCard.cs index 928e06f..625725a 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IContactCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IContactCard.cs @@ -8,12 +8,12 @@ namespace Deal.Modules.Cards.Application.Abstractions; public interface IContactCard { /// - /// Квалифицированные контакты (пусто — контактов нет). + /// Квалифицированные контакты /// public IReadOnlyList Contacts { get; } /// - /// Контактная строка «как в исходных данных» (fallback, если Contacts пуст). + /// Контактная строка «как в исходных данных» /// public string ContactText { get; } } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IContainer.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IContainer.cs index cb098e9..7d00cb8 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IContainer.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IContainer.cs @@ -1,44 +1,37 @@ namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Контейнер карточки: колонка дашборда, стадия «Выбранных» или служебная зона. +/// Контейнер карточки /// -/// -/// Общая база колонок/стадий/зон: пользовательские колонки (kind=board, создаёт пользователь/ИИ), -/// стадии (kind=stage, предзаданный каталог), «Неразобранное», архив, корзина, терминальные зоны. -/// Поведение контейнера — через (роль, не enum-свойства): правила попадания, -/// допустимость возврата, автоочистка, терминальность. Пространство (дашборд/«Выбранные»/третий вид) — -/// свойство SpaceId: карточке безразличен вид, у неё только ContainerId. -/// public interface IContainer { /// - /// Короткий id контейнера (доски b_…, стадии planned…, служебные inbox/archive/trash). + /// Короткий id контейнера /// public string Id { get; } /// - /// Имя для отображения («WPF», «В работе», «Архив»). + /// Имя для отображения /// public string Name { get; } /// - /// Цвет контейнера (hex). + /// Цвет контейнера /// public string Color { get; } /// - /// Позиция в пространстве (порядок показа слева направо). + /// Позиция в пространстве /// public int Order { get; } /// - /// Id пространства (доски), которому принадлежит контейнер. + /// Id пространства /// public string SpaceId { get; } /// - /// Политика контейнера: что можно/нельзя и что происходит (роль). + /// Политика контейнера /// public IContainerPolicy Policy { get; } } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerPolicy.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerPolicy.cs index 9d57694..c614f7b 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerPolicy.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerPolicy.cs @@ -1,33 +1,27 @@ namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Политика контейнера: правила размещения карточек и жизненного цикла зоны. +/// Политика контейнера /// -/// -/// Роль контейнера (не enum): пользовательская колонка-фильтр, стадия, «Неразобранное», архив/корзина, -/// терминальная зона — каждая реализует политику по-своему. Политика отвечает на два вопроса: -/// (1) можно ли карточке попасть сюда (правила фильтрации) и (2) что происходит с карточкой здесь -/// (возврат, автоочистка, терминальность, напоминания). -/// public interface IContainerPolicy { /// - /// Правила попадания карточки в контейнер (null — фильтра нет, карточки кладутся вручную/ИИ). + /// Правила попадания карточки в контейнер /// public IContainerRules? Rules { get; } /// - /// Можно ли вернуть карточку из контейнера на доску пространства (архив/корзина — да; терминальные — нет). + /// Можно ли вернуть карточку из контейнера на доску пространства /// public bool CanRestore { get; } /// - /// Терминальная зона: карточка завершила жизненный путь, ручная очистка без возврата. + /// Терминальная зона /// public bool IsTerminal { get; } /// - /// Автоочистка контейнера: срок хранения карточек (null — автоочистки нет). + /// Автоочистка контейнера /// public TimeSpan? Retention { get; } } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerRules.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerRules.cs index d0a3442..ac5abf0 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerRules.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IContainerRules.cs @@ -3,38 +3,32 @@ using Deal.Modules.Cards.Application.Models; namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Правила попадания карточки в контейнер (набор опциональных фильтров). +/// Правила попадания карточки в контейнер /// -/// -/// Соответствует «правилам колонки» (§6.3): направление/стек/ключевые слова/грейд/уровень/цена/бюджет/ -/// локация/тип и отрицательные исключения. Все группы опциональны; режим All/Any решает, как группы -/// сочетаются. Пустые правила — контейнер без фильтра (карточки кладутся вручную или ИИ/ML по контексту). -/// Проверка — по содержимому карточки (атрибуты, бюджет, текст), чистая функция. -/// public interface IContainerRules { /// - /// Режим сочетания групп: all — должны совпасть все включённые группы; any — хотя бы одна. + /// Режим сочетания групп /// public string Mode { get; } /// - /// Ключевые слова/фразы (пусто — группа не участвует). + /// Ключевые слова/фразы /// public IReadOnlyList Keywords { get; } /// - /// Ключевые технологии/стек-атрибуты (пусто — не участвует). + /// Ключевые технологии/стек-атрибуты /// public IReadOnlyList Stack { get; } /// - /// Направление/тема (пусто — не участвует). + /// Направление/тема /// public IReadOnlyList Directions { get; } /// - /// Грейд/уровень (пусто — не участвует). + /// Грейд/уровень /// public IReadOnlyList Grades { get; } @@ -49,22 +43,22 @@ public interface IContainerRules public IReadOnlyList Locations { get; } /// - /// Тип заявки: вакансия/фриланс/объявление (пусто — не участвует). + /// Тип заявки: вакансия/фриланс/объявление /// public IReadOnlyList Types { get; } /// - /// Бюджетный диапазон (null — не участвует). + /// Бюджетный диапазон /// public CardBudget? Budget { get; } /// - /// Диапазон цены (отдельная группа §6.3; null — не участвует). + /// Диапазон цены /// public CardBudget? Prices { get; } /// - /// Исключения: карточка не попадает, если в тексте/атрибутах есть хотя бы одно (veto). + /// Исключения: карточка не попадает, если в тексте/атрибутах есть хотя бы одно /// public IReadOnlyList Exclude { get; } } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IContentCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IContentCard.cs index 7233a0a..a1f681f 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IContentCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IContentCard.cs @@ -1,16 +1,12 @@ namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Модуль «содержимое»: структурированный блок «О заявке» карточки. +/// Модуль «содержимое» /// -/// -/// Единая структура у всех карточек (Компания → Формат → О задаче → Требования → Будет плюсом → Условия); -/// у карточки без разобранного содержимого модуль пуст (Summary = ""). -/// public interface IContentCard { /// - /// Блок «О заявке» (строки с метками, ≤2000). + /// Блок «О заявке» /// public string Summary { get; } } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IFileCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IFileCard.cs index 99fcda2..77086b4 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IFileCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IFileCard.cs @@ -3,12 +3,12 @@ using Deal.Modules.Cards.Application.Models; namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Модуль «файлы» карточки (медиа/документы; объекты — в S3/MinIO). +/// Модуль «файлы» карточки /// public interface IFileCard { /// - /// Метаданные файлов карточки (пусто — файлов нет). + /// Метаданные файлов карточки /// public IReadOnlyList Files { get; } } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IFileSource.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IFileSource.cs index 1fc9451..b9b4668 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IFileSource.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IFileSource.cs @@ -1,12 +1,12 @@ namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Источник «файл» (карточка создана из загруженного/импортированного файла). +/// Источник «файл» /// public interface IFileSource : ISource { /// - /// Ключ объекта в хранилище (MinIO/локальная папка). + /// Ключ объекта в хранилище /// public string ObjectKey { get; } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ILinkCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ILinkCard.cs index d557c2d..b5b3368 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ILinkCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ILinkCard.cs @@ -8,7 +8,7 @@ namespace Deal.Modules.Cards.Application.Abstractions; public interface ILinkCard { /// - /// Прикреплённые ссылки (пусто — ссылок нет). + /// Прикреплённые ссылки /// public IReadOnlyList Links { get; } } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ILocalSource.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ILocalSource.cs index b4eea05..3afa2f5 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ILocalSource.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ILocalSource.cs @@ -1,9 +1,8 @@ namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Источник «создано вручную/локально» (карточка без внешнего первоисточника). +/// Источник «создано вручную/локально» /// -/// Покрывает ручное создание карточки пользователем (в UI «Выбранных» и будущих дашбордов). public interface ILocalSource : ISource { /// diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ILocatedCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ILocatedCard.cs index 695e25e..2a07eca 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ILocatedCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ILocatedCard.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Модуль «размещение»: контейнер карточки и флаги состояния на доске. +/// Модуль «размещение» /// -/// -/// Контейнер (колонка/стадия/зона) — единственное «место» карточки; смена контейнера = переход карточки -/// (см. ). «Архив/корзина/отклонено/выполнено» — такие же контейнеры со своими -/// политиками, а не отдельные сущности. PrevContainerId — для возврата из архива/корзины. -/// public interface ILocatedCard { /// @@ -16,12 +11,12 @@ public interface ILocatedCard public string ContainerId { get; } /// - /// Предыдущий контейнер (для возврата); null — возврат неприменим. + /// Предыдущий контейнер /// public string? PrevContainerId { get; } /// - /// Признак «новое» (подсветка на доске; снимается просмотром/переходом). + /// Признак «новое» /// public bool IsNew { get; } } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IRemindableCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IRemindableCard.cs index 29a30e7..8b12e63 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IRemindableCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IRemindableCard.cs @@ -3,7 +3,7 @@ using Deal.Modules.Cards.Application.Models; namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Модуль «напоминание»: отложенная карточка (контейнер hold). +/// Модуль «напоминание» /// public interface IRemindableCard { diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IRowSource.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IRowSource.cs index 32b6ede..3ab11b2 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IRowSource.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IRowSource.cs @@ -1,17 +1,17 @@ namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Источник «импорт данных»: карточка создана из строки/колонки таблицы (csv/sheet/БД). +/// Источник «импорт данных» /// public interface IRowSource : ISource { /// - /// Id таблицы/импорта (внешний идентификатор источника данных). + /// Id таблицы/импорта /// public string TableId { get; } /// - /// Id строки в таблице (первичный ключ строки-источника). + /// Id строки в таблице /// public string RowId { get; } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ISource.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ISource.cs index ae96d8d..f9b83c4 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ISource.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ISource.cs @@ -1,28 +1,22 @@ namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Источник карточки: всё, откуда карточка может прийти. +/// Источник карточки /// -/// -/// Полиморфная иерархия вместо enum-свойства «тип источника»: конкретные варианты несут свои поля -/// (Telegram — диалог/сообщение/тема, Web — url, AI — провайдер/модель и т.д.), а общий контракт — -/// только подпись, ссылку на оригинал и сырое содержимое. Составной источник () -/// описывает цепочку «первоисточник → обработка» (например, сообщение Telegram, разобранное ИИ). -/// public interface ISource { /// - /// Подпись источника в UI: «@freelance», «hh.ru», «файл leads.csv»… + /// Подпись источника в UI /// public string DisplayName { get; } /// - /// Ссылка на оригинал: t.me/…, https://…, objectKey; null — оригинала нет (локальное создание). + /// Ссылка на оригинал /// public string? OriginRef { get; } /// - /// Сырое содержимое (текст/JSON), если хранится; null — не хранится. + /// Сырое содержимое /// public string? RawPayload { get; } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ITelegramSource.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ITelegramSource.cs index 330679b..70fd318 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ITelegramSource.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ITelegramSource.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Источник «Telegram»: канал/группа/чат/форум, конкретное сообщение. +/// Источник «Telegram» /// -/// -/// Покрывает все варианты источника Telegram: канал (DialogId=peer, TopicId=null), группа с темами -/// (TopicId задан), личный чат. Ссылка на оригинал строится из PeerHandle/PeerId и MessageId -/// (см. контракт tgSourceUrl текущего фронта). -/// public interface ITelegramSource : ISource { /// @@ -21,7 +16,7 @@ public interface ITelegramSource : ISource public long MessageId { get; } /// - /// Handle канала (без «@»); null — диалог без username (приватный). + /// Handle канала /// public string? PeerHandle { get; } @@ -31,7 +26,7 @@ public interface ITelegramSource : ISource public string PeerName { get; } /// - /// Id темы форума (для групп с темами); null — темы нет. + /// Id темы форума /// public string? TopicId { get; } } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/ITraceableCard.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/ITraceableCard.cs index 96340ad..581a9b2 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/ITraceableCard.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/ITraceableCard.cs @@ -3,12 +3,12 @@ using Deal.Modules.Cards.Application.Models; namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Модуль «история движения» карточки (создание и смены контейнера). +/// Модуль «история движения» карточки /// public interface ITraceableCard { /// - /// История движения, свежие записи — в конце списка (пусто — истории нет). + /// История движения, свежие записи — в конце списка /// public IReadOnlyList History { get; } } diff --git a/src/core/Deal.Modules.Cards/Application/Abstractions/IWebSource.cs b/src/core/Deal.Modules.Cards/Application/Abstractions/IWebSource.cs index 8d3717b..98f699a 100644 --- a/src/core/Deal.Modules.Cards/Application/Abstractions/IWebSource.cs +++ b/src/core/Deal.Modules.Cards/Application/Abstractions/IWebSource.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Cards.Application.Abstractions; /// -/// Источник «ссылка на сайт/объявление» (карточка создана из веб-страницы). +/// Источник «ссылка на сайт/объявление» /// public interface IWebSource : ISource { diff --git a/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs b/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs index 15e2fcd..a3e907f 100644 --- a/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs +++ b/src/core/Deal.Modules.Cards/Application/Dtos/CardMoveResultDto.cs @@ -5,11 +5,6 @@ namespace Deal.Modules.Cards.Application.Dtos; /// /// Результат перехода карточки единым механизмом . /// -/// -/// Error — 400-текст отказа (несуществующий контейнер/исходная колонка, запрет политики); Exists=false — -/// карточки нет (404). При успехе оба поля пусты/true — снимок карточки эндпоинт перечитывает единым -/// чтением (CardDto), чтобы наружу всегда уходила одна форма. -/// /// Текст 400-ошибки либо null. /// True — карточка найдена и переход выполнен (либо перенос был no-op). public sealed record CardMoveResultDto(string? Error, bool Exists); diff --git a/src/core/Deal.Modules.Cards/Application/Models/Card.cs b/src/core/Deal.Modules.Cards/Application/Models/Card.cs index 89af7ed..33881ea 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/Card.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/Card.cs @@ -3,15 +3,8 @@ using Deal.Modules.Cards.Application.Abstractions; namespace Deal.Modules.Cards.Application.Models; /// -/// Агрегат карточки: ядро + опциональные модули (единая сущность всех дашбордов). +/// Агрегат карточки /// -/// -/// Один класс на карточку (не иерархия видов): вид определяется контейнером и наполненностью модулей, -/// а не типом. Модули — это секции данных (роли интерфейсов), пустые, пока карточка «входящая», -/// наполняются, когда карточка «в работе» (файлы/ссылки/ТЗ/история/напоминания). Класс неизменяемый -/// (init/свойства только для чтения) — изменения применяются сервисами через порт и возвращают новый -/// снимок, как единая CardDto (wire 1:1 сохраняется). -/// public sealed class Card : ICard, IContentCard, diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardAttribute.cs b/src/core/Deal.Modules.Cards/Application/Models/CardAttribute.cs index 6377f4f..0518fa4 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardAttribute.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardAttribute.cs @@ -1,27 +1,22 @@ namespace Deal.Modules.Cards.Application.Models; /// -/// Настраиваемый атрибут карточки: имя, значение и (опционально) единица измерения. +/// Настраиваемый атрибут карточки /// -/// -/// Стек/грейд/локация/сроки/площадь и прочее — не «зашитые» поля карточки, а атрибуты из справочника -/// тенанта (пользователь настраивает в UI, «стек» — лишь частый атрибут). Один атрибут = одна запись -/// «ключ → значение»; ключом служит строковый id из справочника атрибутов. -/// public sealed record CardAttribute { /// - /// Id атрибута (ключ справочника: stack, grade, location, …). + /// Id атрибута (ключ справочника /// public string Key { get; init; } = string.Empty; /// - /// Значение атрибута (например, «Python», «Middle», «Москва»). + /// Значение атрибута /// public string Value { get; init; } = string.Empty; /// - /// Единица измерения (₽/₽/час/м²/…); null — безразмерный. + /// Единица измерения /// public string? Unit { get; init; } } diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardBudget.cs b/src/core/Deal.Modules.Cards/Application/Models/CardBudget.cs index c82404d..7121b40 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardBudget.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardBudget.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Cards.Application.Models; /// -/// Бюджет/цена карточки в исходной валюте и (опционально) сконвертированный в целевую. +/// Бюджет/цена карточки в исходной валюте и /// public sealed record CardBudget { diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardContact.cs b/src/core/Deal.Modules.Cards/Application/Models/CardContact.cs index e31516b..693f8b6 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardContact.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardContact.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Cards.Application.Models; /// -/// Контакт заказчика: квалифицированная запись (тип + значение). +/// Контакт заказчика /// public sealed record CardContact { diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardFile.cs b/src/core/Deal.Modules.Cards/Application/Models/CardFile.cs index 45d02ea..bf4267a 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardFile.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardFile.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Cards.Application.Models; /// -/// Метаданные прикреплённого файла карточки (объект хранится в S3/MinIO или локальной папке). +/// Метаданные прикреплённого файла карточки /// public sealed record CardFile { @@ -21,7 +21,7 @@ public sealed record CardFile public long SizeBytes { get; init; } /// - /// Тип контента: image/video/audio/archive/document/other (определяет детектор). + /// Тип контента: image/video/audio/archive/document/other /// public string Kind { get; init; } = "other"; diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardHistoryEntry.cs b/src/core/Deal.Modules.Cards/Application/Models/CardHistoryEntry.cs index 2c777aa..7b83ec1 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardHistoryEntry.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardHistoryEntry.cs @@ -16,7 +16,7 @@ public sealed record CardHistoryEntry public long AtMs { get; init; } /// - /// Тип события: created|createdLocal|id контейнера назначения (движение). + /// Тип события: created|createdLocal|id контейнера назначения /// public string Type { get; init; } = string.Empty; } diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardIds.cs b/src/core/Deal.Modules.Cards/Application/Models/CardIds.cs index 82c4ec8..1ce0218 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardIds.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardIds.cs @@ -1,34 +1,27 @@ namespace Deal.Modules.Cards.Application.Models; /// -/// Реестр коротких id модуля Cards и служебных контейнеров (единый владелец карточки). +/// Реестр коротких id модуля Cards и служебных контейнеров /// -/// -/// Единый префикс карточек c_ приходит на смену l_ (канбан) и pr_ (проекты) — при -/// слиянии таблиц у карточки один id, не зависящий от пространства. Служебные контейнеры -/// (inbox/archive/trash) — фиксированные зоны любого пространства-дашборда. -/// Контейнеры-доски (b_…) создаёт пользователь/ИИ; контейнеры-стадии — предзаданный каталог -/// (см. CardsDefaultContainers). Id генерирует модуль (PrefixId), хранилище получает готовые. -/// public static class CardIds { /// - /// Префикс id карточки (единый для всех дашбордов). + /// Префикс id карточки /// public const string CardPrefix = "c_"; /// - /// Контейнер «Неразобранное»: новые карточки пайплайна до раскладки по колонкам. + /// Контейнер «Неразобранное» /// public const string Inbox = "inbox"; /// - /// Контейнер «Архив»: карточки старше срока архивации (автоочистка по политике). + /// Контейнер «Архив» /// public const string Archive = "archive"; /// - /// Контейнер «Корзина»: удалённые карточки (автоочистка по политике). + /// Контейнер «Корзина» /// public const string Trash = "trash"; } diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardLink.cs b/src/core/Deal.Modules.Cards/Application/Models/CardLink.cs index a0b21ee..f4e13b5 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardLink.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardLink.cs @@ -11,12 +11,12 @@ public sealed record CardLink public string Id { get; init; } = string.Empty; /// - /// Подпись ссылки (пусто — url как подпись). + /// Подпись ссылки /// public string Name { get; init; } = string.Empty; /// - /// Адрес ссылки (http/https). + /// Адрес ссылки /// public string Url { get; init; } = string.Empty; } diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardReminder.cs b/src/core/Deal.Modules.Cards/Application/Models/CardReminder.cs index cdd10ec..5af93f0 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardReminder.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardReminder.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Cards.Application.Models; /// -/// Напоминание об «Отложено» (карточка в контейнере hold). +/// Напоминание об «Отложено» /// public sealed record CardReminder { @@ -11,7 +11,7 @@ public sealed record CardReminder public long AtMs { get; init; } /// - /// Признак «напоминание выстрелило» (повторно не срабатывает до переноса/переустановки). + /// Признак «напоминание выстрелило» /// public bool Fired { get; init; } } diff --git a/src/core/Deal.Modules.Cards/Application/Models/CardsDefaultContainers.cs b/src/core/Deal.Modules.Cards/Application/Models/CardsDefaultContainers.cs index cc07973..09ea0cc 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/CardsDefaultContainers.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/CardsDefaultContainers.cs @@ -1,19 +1,12 @@ namespace Deal.Modules.Cards.Application.Models; /// -/// Каталог контейнеров по умолчанию пространства «Выбранные» (стадии) и служебных зон. +/// Каталог контейнеров по умолчанию пространства «Выбранные» /// -/// -/// Приходит на смену ProjectStages модуля Projects: стадии — это контейнеры kind=stage предзаданного -/// каталога (терминальные — finished/rejected). Хранилище провижинит их тенанту как строки Containers; -/// каталог — единственный источник имён/цветов/порядка (как ProjectStages.All). Служебные зоны -/// (inbox/archive/trash) — фиксированные контейнеры CardIds. Доски (kind=board) каталогу не принадлежат: -/// их создаёт пользователь/ИИ. -/// public static class CardsDefaultContainers { /// - /// Id стадии «Запланировано» (цель перехода «взять в работу»). + /// Id стадии «Запланировано» /// public const string Planned = "planned"; @@ -43,7 +36,7 @@ public static class CardsDefaultContainers public const string Ready = "ready"; /// - /// Id стадии «Отложено» (напоминания сбрасываются переносом). + /// Id стадии «Отложено» /// public const string Hold = "hold"; @@ -53,12 +46,12 @@ public static class CardsDefaultContainers public const string Finished = "finished"; /// - /// Id терминальной стадии «Отклонено» (очистка вручную, без возврата). + /// Id терминальной стадии «Отклонено» /// public const string Rejected = "rejected"; /// - /// Все контейнеры по умолчанию (стадии пространства «Выбранные»), порядок канбана. + /// Все контейнеры по умолчанию /// public static IReadOnlyList All { get; } = new List { @@ -74,12 +67,12 @@ public static class CardsDefaultContainers }; /// - /// Id всех контейнеров-стадий «Выбранных» (для фильтра пространства в выборках хранилища). + /// Id всех контейнеров-стадий «Выбранных» /// public static IReadOnlyList Ids { get; } = All.Select(container => container.Id).ToArray(); /// - /// Проверка: контейнер с таким id есть в каталоге (валидация move/ручного создания). + /// Проверка: контейнер с таким id есть в каталоге /// /// Проверяемый id. /// True — контейнер известен каталогу. diff --git a/src/core/Deal.Modules.Cards/Application/Models/DefaultContainer.cs b/src/core/Deal.Modules.Cards/Application/Models/DefaultContainer.cs index d949100..8b1f159 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/DefaultContainer.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/DefaultContainer.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Cards.Application.Models; /// -/// Описание контейнера по умолчанию (запись каталога CardsDefaultContainers). +/// Описание контейнера по умолчанию /// public sealed record DefaultContainer { diff --git a/src/core/Deal.Modules.Cards/Application/Models/TransitionContext.cs b/src/core/Deal.Modules.Cards/Application/Models/TransitionContext.cs index 3bec7e3..47b5853 100644 --- a/src/core/Deal.Modules.Cards/Application/Models/TransitionContext.cs +++ b/src/core/Deal.Modules.Cards/Application/Models/TransitionContext.cs @@ -3,15 +3,12 @@ using Deal.Modules.Cards.Application.Abstractions; namespace Deal.Modules.Cards.Application.Models; /// -/// Контекст перехода карточки: кто инициировал и что делать с обучением. +/// Контекст перехода карточки /// -/// Переход — единый механизм (); контекст отличает действие пользователя -/// (обучать ML), системы (автоархив/очистка) и ИИ/ML (классификация). Reason — для возвратов из отсева -/// и ручных исключений, чтобы ML/ИИ учились на решении. public sealed record TransitionContext { /// - /// Инициатор перехода: user|system|ai|ml. + /// Инициатор перехода /// public required string Actor { get; init; } @@ -21,7 +18,7 @@ public sealed record TransitionContext public string? Reason { get; init; } /// - /// Обучать ML на этом действии (действия пользователя — да; системные — нет). + /// Обучать ML на этом действии /// public bool Learn { get; init; } } diff --git a/src/core/Deal.Modules.Cards/CardsModuleMarker.cs b/src/core/Deal.Modules.Cards/CardsModuleMarker.cs index 96a7513..87e0b19 100644 --- a/src/core/Deal.Modules.Cards/CardsModuleMarker.cs +++ b/src/core/Deal.Modules.Cards/CardsModuleMarker.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Cards; /// -/// Маркер модуля Cards: используется для DI-сканирования и тестов. +/// Маркер модуля Cards /// public sealed class CardsModuleMarker { diff --git a/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryPacer.cs b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryPacer.cs index 0357b7a..b49110d 100644 --- a/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryPacer.cs +++ b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryPacer.cs @@ -3,18 +3,12 @@ using Deal.Modules.Discovery.Application.Services; namespace Deal.Modules.Discovery.Application.Abstractions; /// -/// Порт «пейсера» Discovery: паузы между сетевыми действиями (план Task 18, Ruling 10). +/// Порт «пейсера» Discovery /// -/// -/// Рандомные паузы вынесены за интерфейс, чтобы воркер тестировался без реальных ожиданий: тесты ставят фейк -/// с мгновенным возвратом, продовая реализация () читает discJoinDelayMin/Max из -/// настроек тенанта (50–70 с) и спит случайное время в интервале (1:1 ban_guard.wait_join_delay L44–56). -/// public interface IDiscoveryPacer { /// - /// Пауза перед авто-вступлением: случайное число секунд из discJoinDelayMin..Max. + /// Пауза перед авто-вступлением /// - /// Токен отмены (остановка хоста прерывает ожидание). public Task WaitJoinDelayAsync(CancellationToken ct); } diff --git a/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoverySearchErrorCounter.cs b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoverySearchErrorCounter.cs index 0d81cf3..19a8278 100644 --- a/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoverySearchErrorCounter.cs +++ b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoverySearchErrorCounter.cs @@ -1,15 +1,8 @@ namespace Deal.Modules.Discovery.Application.Abstractions; /// -/// Счётчик ошибок поиска одного ключа по задачам (discovery_worker._search_errors L82, план Task 18). +/// Счётчик ошибок поиска одного ключа по задачам. /// -/// -/// В прототипе счётчик — глобальный dict памяти процесса (при рестарте сбрасывается). В ядре воркер Discovery -/// разрешается scoped-зависимостями на каждый тик тенанта, поэтому счётчик вынесен в singleton: после трёх -/// ошибок подряд одного ключа (3) ключ пропускается advance_search — битый ключ не должен зацикливать поиск. -/// Ключ — id задачи (dt_…): id уникальны глобально (случайная 12-hex часть), коллизий между тенантами нет. -/// Реализация хранит записи с TTL (эвикция просроченных, quality review) — удалённые задачи не копят строки. -/// public interface IDiscoverySearchErrorCounter { /// @@ -20,7 +13,7 @@ public interface IDiscoverySearchErrorCounter public int Next(string taskId); /// - /// Сбрасывает счётчик задачи (успешный поиск/ключ пропущен). + /// Сбрасывает счётчик задачи /// /// Id задачи поиска. public void Reset(string taskId); diff --git a/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryStore.cs b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryStore.cs index 6d8078b..5f7f85d 100644 --- a/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryStore.cs +++ b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryStore.cs @@ -3,50 +3,35 @@ using Deal.Modules.Discovery.Application.Models; namespace Deal.Modules.Discovery.Application.Abstractions; /// -/// Порт хранилища Discovery: таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog схемы тенанта (Ruling 9, Task 17). +/// Порт хранилища Discovery /// -/// -/// Порт объявлен в модуле 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. /// /// 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). + /// Точечная правка полей по присутствующим в патче + bump UpdatedAt. /// /// Id задачи (dt_...). /// Изменяемые поля (null — поле не меняется; keywords — полная замена JSON-массива). - /// Токен отмены. /// True — строка обновлена; false — задачи нет (404-семантика сервиса). public Task PatchTaskAsync( string taskId, @@ -54,20 +39,17 @@ public interface IDiscoveryStore CancellationToken ct); /// - /// Удаляет задачу вместе с её кандидатами и логом (delete_task L314–318; чёрный список общий — не трогается). + /// Удаляет задачу вместе с её кандидатами и логом. /// /// Id задачи (dt_...). - /// Токен отмены. /// True — задача была и удалена; false — строки нет. public Task DeleteTaskAsync(string taskId, CancellationToken ct); /// - /// Переводит задачу в running (start_task L321–346). + /// Переводит задачу в running. /// /// Id задачи (dt_...). - /// True — повторный старт терминальной (done/failed): search_idx=0, search_done=false, - /// счётчики found/evaluated/joined/rejected=0 (L333–339); False — draft/paused: только статус (L342–344). - /// Токен отмены. + /// True — повторный старт терминальной (done/failed): search_idx=0, search_done=false, счётчики found/evaluated/joined/rejected=0; False — draft/paused: только статус. /// True — задача обновлена; false — строки нет. public Task SetTaskRunningAsync( string taskId, @@ -75,28 +57,25 @@ public interface IDiscoveryStore CancellationToken ct); /// - /// Переводит задачу в paused (pause_task L349–356; прогресс поиска сохраняется). + /// Переводит задачу в paused. /// /// Id задачи (dt_...). - /// Токен отмены. /// True — задача обновлена; false — строки нет. public Task SetTaskPausedAsync(string taskId, CancellationToken ct); /// - /// Переводит задачу в done (воркер _finish_done L126–136: план вступлений выполнен; bump UpdatedAt). + /// Переводит задачу в done. /// /// 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). - /// Токен отмены. + /// Приращение (вызывающий передаёт ≥1; ≤0 — no-op, как max(0, n)). /// True — задача обновлена; false — строки нет. public Task BumpTaskCounterAsync( string taskId, @@ -105,12 +84,11 @@ public interface IDiscoveryStore 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, @@ -119,21 +97,18 @@ public interface IDiscoveryStore CancellationToken ct); /// - /// Сумма plan_joins активных задач (python _active_plan_sum L85–91: status NOT IN done/failed). + /// Сумма plan_joins активных задач. /// /// Id задачи, исключаемой из суммы (patch-рост плана); null — все активные. - /// Токен отмены. /// Занятый бюджет авто-вступлений (0 — активных задач нет). public Task SumActivePlanAsync(string? excludeTaskId, CancellationToken ct); - // ── Кандидаты (disc_candidates; L385–520) ── /// - /// Кандидаты задачи, старые первыми (list_candidates L385–397); status — фильтр. + /// Кандидаты задачи, старые первыми; status — фильтр. /// /// Id задачи (dt_...). /// Статус-фильтр (см. ); null — все статусы. - /// Токен отмены. /// Кандидаты задачи (marks/topics — типизированными списками); пусто — кандидатов нет. public Task> ListCandidatesAsync( string taskId, @@ -141,20 +116,17 @@ public interface IDiscoveryStore CancellationToken ct); /// - /// Кандидат по dialog_id (python _get_candidate L400–402; ключ — источник, кандидат один). + /// Кандидат по dialog_id. /// /// Подписанный 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). + /// Считает события лога по типу с CreatedAt ≥ sinceUtc. /// /// Событие (см. ; воркер считает «join_auto»). /// Нижняя граница CreatedAt (UTC; бан-гард передаёт начало текущих суток). - /// Токен отмены. /// Число событий с начала суток (0 — событий нет). public Task CountLogEventAsync( string logEvent, @@ -162,50 +134,43 @@ public interface IDiscoveryStore CancellationToken ct); /// - /// Увеличивает join_failures кандидата, если запись жива и в статусе review (воркер L411–416; bump UpdatedAt). + /// Увеличивает join_failures кандидата, если запись жива и в статусе review. /// /// Подписанный 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; повторный вызов безопасен). + /// Удаляет кандидата по dialog_id. /// /// Подписанный 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, @@ -213,11 +178,10 @@ public interface IDiscoveryStore CancellationToken ct); /// - /// Переводит кандидата в new/review (set_candidate_status L497–515: только эти статусы; bump UpdatedAt). + /// Переводит кандидата в new/review. /// /// Подписанный id источника. /// Новый статус: new|review (валидирует сервис). - /// Токен отмены. /// True — строка обновлена; false — кандидата нет. public Task SetCandidateStatusAsync( string dialogId, @@ -225,11 +189,10 @@ public interface IDiscoveryStore CancellationToken ct); /// - /// Ставит кандидату joined + auto_joined (mark_joined L531–534; bump UpdatedAt). + /// Ставит кандидату joined + auto_joined. /// /// Подписанный id источника. /// True — авто-вступление воркера; false — ручное (join из UI). - /// Токен отмены. /// True — строка обновлена; false — кандидата нет. public Task SetCandidateJoinedAsync( string dialogId, @@ -237,22 +200,19 @@ public interface IDiscoveryStore CancellationToken ct); /// - /// Ставит кандидату rejected (mark_rejected L555–558; bump UpdatedAt; счётчик/лог/чёрный список — сервис). + /// Ставит кандидату rejected. /// /// Подписанный 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, @@ -260,37 +220,32 @@ public interface IDiscoveryStore CancellationToken ct); /// - /// Снимает источник с чёрного списка (remove_blacklist L581–582; нет строки — no-op). + /// Снимает источник с чёрного списка. /// /// Подписанный id источника. - /// Токен отмены. public Task RemoveBlacklistAsync(string dialogId, CancellationToken ct); /// - /// Запись чёрного списка по dialog_id (echo add_blacklist L577–578). + /// Запись чёрного списка по dialog_id. /// /// Подписанный 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, @@ -299,11 +254,10 @@ public interface IDiscoveryStore CancellationToken ct); /// - /// Последние события задачи, новые сверху (task_log L601–608: ORDER BY created_at DESC LIMIT). + /// Последние события задачи, новые сверху. /// /// Id задачи поиска. /// Сколько последних записей (кламп 1..500 выполняет сервис; дефолт 100). - /// Токен отмены. /// События задачи от новых к старым; пусто — лога нет. public Task> ListTaskLogAsync( string taskId, diff --git a/src/core/Deal.Modules.Discovery/Application/Exceptions/DiscoveryValidationException.cs b/src/core/Deal.Modules.Discovery/Application/Exceptions/DiscoveryValidationException.cs index 596f633..059905d 100644 --- a/src/core/Deal.Modules.Discovery/Application/Exceptions/DiscoveryValidationException.cs +++ b/src/core/Deal.Modules.Discovery/Application/Exceptions/DiscoveryValidationException.cs @@ -1,18 +1,12 @@ namespace Deal.Modules.Discovery.Application.Exceptions; /// -/// Доменная ошибка запроса Discovery — 400-семантика (аналог ValueError discovery.py). +/// Доменная ошибка запроса Discovery — 400-семантика. /// -/// -/// Прототип бросает ValueError с русским текстом причины, а роутеры переводят его в HTTP 400 {detail} -/// (discovery_routes.py L8–9). В .NET сервисы модуля бросают это исключение с тем же текстом; эндпоинты -/// (Task 19) ловят его и отвечают 400. 404-семантика (KeyError прототипа) остаётся null-результатом методов -/// (конвенция модулей этапов 1–5) — см. xml-doc сервисов. -/// public sealed class DiscoveryValidationException : Exception { /// - /// Создаёт ошибку с текстом 400-детали (1:1 текст ValueError прототипа). + /// Создаёт ошибку с текстом 400-детали. /// /// Текст причины для {detail} ответа. public DiscoveryValidationException(string detail) diff --git a/src/core/Deal.Modules.Discovery/Application/Extensions/DiscoveryTaskPatchExtensions.cs b/src/core/Deal.Modules.Discovery/Application/Extensions/DiscoveryTaskPatchExtensions.cs index 1b9c88c..1893972 100644 --- a/src/core/Deal.Modules.Discovery/Application/Extensions/DiscoveryTaskPatchExtensions.cs +++ b/src/core/Deal.Modules.Discovery/Application/Extensions/DiscoveryTaskPatchExtensions.cs @@ -5,7 +5,7 @@ namespace Deal.Modules.Discovery.Application.Extensions; internal static class DiscoveryTaskPatchExtensions { /// - /// Содержит ли патч хотя бы одно изменяемое поле (python L289 «if not cols»). + /// Содержит ли патч хотя бы одно изменяемое поле. /// /// Нормализованный патч. /// True — есть поле к записи. diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryBlacklistDto.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryBlacklistDto.cs index a614270..8a1af71 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryBlacklistDto.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryBlacklistDto.cs @@ -1,14 +1,8 @@ namespace Deal.Modules.Discovery.Application.Models; /// -/// Запись чёрного списка Discovery — элемент GET /api/discovery/blacklist (api-map §3.8 L213). +/// Запись чёрного списка Discovery — элемент GET /api/discovery/blacklist. /// -/// -/// Поля 1:1 с _blacklist_view discovery.py L161–167 и строкой disc_blacklist (db.py L180–185). Список общий для -/// всех задач (не привязан к DiscTasks): источники из него пропускаются поиском (add_candidate) и повторной -/// проверкой перед авто-вступлением (воркер). Снимается вручную (DELETE …/blacklist/{dialogId}) или при ручном -/// join. Повторное добавление того же источника обновляет Name/Reason и сохраняет CreatedAt (ON CONFLICT L572–575). -/// /// Подписанный id источника (первичный ключ). /// Имя источника (пусто → DialogId). /// Причина добавления («отклонено вручную», метка воркера и т.п.). diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateDto.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateDto.cs index 568ea21..2f034cb 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateDto.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateDto.cs @@ -1,14 +1,8 @@ namespace Deal.Modules.Discovery.Application.Models; /// -/// Кандидат задачи Discovery — элемент GET …/candidates и ответ join/reject (api-map §4.8 L355). +/// Кандидат задачи Discovery — элемент GET …/candidates и ответ join/reject. /// -/// -/// Поля 1:1 с _candidate_view discovery.py L140–158 и строкой disc_candidates (db.py L159–176). marks/topics — -/// JSON-колонки, наружу всегда списки (marks — строки-метки, topics — элементы для -/// форумов). Статус — ; переводы в -/// joined/rejected — только через mark_joined/mark_rejected. -/// /// Подписанный id источника (первичный ключ кандидата; как Dialogs.Id). /// Id задачи поиска, которой принадлежит кандидат. /// Отображаемое имя источника (пусто → DialogId). @@ -22,7 +16,7 @@ namespace Deal.Modules.Discovery.Application.Models; /// Доля подходящих сообщений оценки (0..1); null — контент не оценён. /// Статус кандидата (см. ). /// Вступили автоматически (воркером); false — вручную (join из UI). -/// Неудачные авто-вступления подряд (3 → кандидат удаляется, Task 18). +/// Неудачные авто-вступления подряд. /// Время добавления, epoch-ms. /// Время последнего изменения, epoch-ms. public sealed record DiscoveryCandidateDto( diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateKinds.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateKinds.cs index 0cd59d8..25040ce 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateKinds.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateKinds.cs @@ -1,12 +1,8 @@ namespace Deal.Modules.Discovery.Application.Models; /// -/// Типы источников-кандидатов Discovery (колонка DiscCandidates.Kind; api-map §4.8, discovery_worker _kind_code). +/// Типы источников-кандидатов Discovery. /// -/// -/// EN-канон контракта 1:1 с кандидатами Telegram: channel — канал, group — группа, forum — форум (группа с -/// темами; оценка идёт по темам, discovery_worker L299–307). Личные чаты/боты поиском не предлагаются. -/// public static class DiscoveryCandidateKinds { /// diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidatePatch.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidatePatch.cs index 0ae45cf..ffdfab0 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidatePatch.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidatePatch.cs @@ -1,15 +1,8 @@ namespace Deal.Modules.Discovery.Application.Models; /// -/// Патч кандидата (set_candidate discovery.py L456–494; поля — в нотации кандидата). +/// Патч кандидата. /// -/// -/// null — поле не меняется; не-null значение записывается (marks/topics — полной заменой JSON-массива, -/// пустой список очищает). Пустые после Trim name/username/kind/hue НЕ затирают текущее значение -/// (python L480–482: «or row[col]» — оценка воркера не должна стирать имя фолбэком). participants:null из -/// патча в python очищает колонку; в .NET null означает «не менять» — очистка не нужна (участников всегда -/// присылает discovery_info либо поле не трогается). -/// public sealed record DiscoveryCandidatePatch { /// @@ -18,12 +11,12 @@ public sealed record DiscoveryCandidatePatch public string? Name { get; init; } /// - /// Новый username (после Trim; пустой — не менять). + /// Новый username /// public string? Username { get; init; } /// - /// Новый тип источника (после Trim; пустой — не менять). + /// Новый тип источника /// public string? Kind { get; init; } @@ -33,32 +26,32 @@ public sealed record DiscoveryCandidatePatch public string? Hue { get; init; } /// - /// Новое число участников (null — не менять). + /// Новое число участников /// public int? Participants { get; init; } /// - /// Новый признак языка (true/false — меняет; null — не менять). + /// Новый признак языка /// public bool? LangRu { get; init; } /// - /// Новые метки оценки (полная замена; null — не менять). + /// Новые метки оценки /// public IReadOnlyList? Marks { get; init; } /// - /// Новые темы форума (полная замена; null — не менять). + /// Новые темы форума /// public IReadOnlyList? Topics { get; init; } /// - /// Новая доля подходящих сообщений (null — не менять). + /// Новая доля подходящих сообщений /// public double? FitRatio { get; init; } /// - /// Новый флаг авто-вступления (у mark_joined ставится отдельным методом; патч — для воркера). + /// Новый флаг авто-вступления /// public bool? AutoJoined { get; init; } } diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateRow.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateRow.cs index 32cb7ea..dcdf44d 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateRow.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateRow.cs @@ -1,17 +1,12 @@ namespace Deal.Modules.Discovery.Application.Models; /// -/// Полная запись нового кандидата задачи (INSERT disc_candidates; discovery.py L435–450). +/// Полная запись нового кандидата задачи. /// -/// -/// Write-модель: содержит только заполняемые при добавлении поля. Служебные значения INSERT прототипа -/// L436–449 проставляет хранилище: Participants=NULL, LangRu=NULL, Marks=«[]», Topics=«[]», FitRatio=NULL, -/// Status=new, AutoJoined=false, JoinFailures=0; CreatedAt/UpdatedAt — UTC-now. -/// public sealed record DiscoveryCandidateRow { /// - /// Подписанный id источника (первичный ключ DiscCandidates). + /// Подписанный id источника /// public string DialogId { get; init; } = string.Empty; @@ -21,12 +16,12 @@ public sealed record DiscoveryCandidateRow public string TaskId { get; init; } = string.Empty; /// - /// Имя источника (пустое → DialogId нормализует сервис). + /// Имя источника /// public string Name { get; init; } = string.Empty; /// - /// Username источника (пуст, если нет публичного username). + /// Username источника /// public string Username { get; init; } = string.Empty; @@ -36,7 +31,7 @@ public sealed record DiscoveryCandidateRow public string Kind { get; init; } = "channel"; /// - /// Цвет источника (дефолт «#666»). + /// Цвет источника /// public string Hue { get; init; } = "#666"; } diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateStatuses.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateStatuses.cs index de4f430..6b4e8de 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateStatuses.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateStatuses.cs @@ -1,15 +1,8 @@ namespace Deal.Modules.Discovery.Application.Models; /// -/// Статусы кандидата Discovery (колонка DiscCandidates.Status; discovery.py L171, api-map §4.8). +/// Статусы кандидата Discovery. /// -/// -/// Цепочка 1:1 с прототипом: new — найден поиском, ждёт оценки; review — оценён, ждёт решения (воркер/человек); -/// joined — вступили (только mark_joined: счётчик задачи + лог join_auto/join_manual); rejected — отклонён -/// (только mark_rejected: счётчик задачи + лог + чёрный список). Переводы в joined/rejected минуя mark_* — -/// запрещены (discovery.py L16–18). Активные статусы (python _ACTIVE_CANDIDATE L35) запрещают повторное -/// добавление источника в задачу. -/// public static class DiscoveryCandidateStatuses { /// @@ -23,17 +16,17 @@ public static class DiscoveryCandidateStatuses public const string Review = "review"; /// - /// Вступили в источник (терминал: счётчик joined задачи). + /// Вступили в источник /// public const string Joined = "joined"; /// - /// Отклонён и помещен в чёрный список (терминал: счётчик rejected). + /// Отклонён и помещен в чёрный список /// public const string Rejected = "rejected"; /// - /// Статусы, при которых повторное добавление источника запрещено (python _ACTIVE_CANDIDATE L35). + /// Статусы, при которых повторное добавление источника запрещено. /// /// Статус кандидата. /// True — new/review/joined (add_candidate пропускает источник с логом skip). @@ -43,7 +36,7 @@ public static class DiscoveryCandidateStatuses } /// - /// Разрешён ли перевод через set_candidate_status (python L503–504: только new/review). + /// Разрешён ли перевод через set_candidate_status. /// /// Запрашиваемый статус. /// True — new/review (joined/rejected выставляются только через mark_joined/mark_rejected). diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCounterField.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCounterField.cs index 874ad21..2bdf534 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCounterField.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCounterField.cs @@ -1,32 +1,27 @@ namespace Deal.Modules.Discovery.Application.Models; /// -/// Счётчики прогресса задачи поиска (колонки DiscTasks Found/Evaluated/Joined/Rejected; discovery.py L359–368). +/// Счётчики прогресса задачи поиска. /// -/// -/// Соответствие колонкам db.py L150–153: found — найдено кандидатов поиском, evaluated — оценено/пропущено, -/// joined — вступили (терминал плана: joined ≥ planJoins → done), rejected — отклонено. bump_counter прототипа -/// принимает имя колонки строкой; в .NET поле типизировано перечислением (валидация L361–362 — на этапе компиляции). -/// public enum DiscoveryCounterField { /// - /// Счётчик «found»: найденные источники (add_candidate). + /// Счётчик «found» /// Found, /// - /// Счётчик «evaluated»: оценённые/пропущенные кандидаты (воркер). + /// Счётчик «evaluated» /// Evaluated, /// - /// Счётчик «joined»: вступившие источники (mark_joined). + /// Счётчик «joined» /// Joined, /// - /// Счётчик «rejected»: отклонённые источники (mark_rejected). + /// Счётчик «rejected» /// Rejected, } diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryEvalSample.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryEvalSample.cs index ea57335..3d56279 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryEvalSample.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryEvalSample.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Discovery.Application.Models; /// -/// Агрегат оценки выборки сообщений (python evaluate_sample L197–226: fit_count/total/fit_ratio/per_message). +/// Агрегат оценки выборки сообщений. /// /// Сколько сообщений подошли под задачу. /// Всего сообщений выборки. diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryIdPrefixes.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryIdPrefixes.cs index 1da49e4..2adaf6e 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryIdPrefixes.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryIdPrefixes.cs @@ -3,37 +3,30 @@ using System.Security.Cryptography; namespace Deal.Modules.Discovery.Application.Models; /// -/// Префиксы коротких id модуля Discovery и их генерация (прототип store.uid в discovery.py). +/// Префиксы коротких id модуля Discovery и их генерация. /// -/// -/// Префиксы 1:1 с discovery.py L30–32 (_ID_TASK = "dt_", _ID_LOG = "dl_") и api-map §1: -/// dt_ — задача поиска (DiscTasks), dl_ — запись лога задачи (DiscLog). Случайная часть — -/// 12 hex-символов (6 байт CSPRNG), как общий генератор PrefixId модуля Kanban (uuid4().hex[:12] прототипа); -/// модуль Discovery зависит только от ST + Contracts (план Task 17), поэтому генератор вынесен сюда, а не в -/// Kanban. Id генерирует модуль и передаёт в хранилище готовыми (порт id не создаёт). -/// public static class DiscoveryIdPrefixes { // Размер случайной части в байтах: 6 байт → 12 hex-символов (uuid4().hex[:12]). private const int RandomHexBytes = 6; /// - /// Префикс id задачи поиска (таблица DiscTasks; discovery.py store.uid("dt_")). + /// Префикс id задачи поиска /// public const string Task = "dt_"; /// - /// Префикс id записи лога задачи (таблица DiscLog; discovery.py store.uid("dl_")). + /// Префикс id записи лога задачи /// public const string Log = "dl_"; /// - /// Новый id задачи поиска: dt_ + 12 случайных hex-символов. + /// Новый id задачи поиска /// public static string NewTaskId() => New(Task); /// - /// Новый id записи лога: dl_ + 12 случайных hex-символов. + /// Новый id записи лога /// public static string NewLogId() => New(Log); diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogDto.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogDto.cs index c2c4407..c59cd03 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogDto.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogDto.cs @@ -1,17 +1,12 @@ namespace Deal.Modules.Discovery.Application.Models; /// -/// Запись лога задачи Discovery — элемент GET /api/discovery/tasks/{id}/log (api-map §3.8 L215). +/// Запись лога задачи Discovery — элемент GET /api/discovery/tasks/{id}/log. /// -/// -/// Поля 1:1 с _log_view discovery.py L170–177 и строкой disc_log (db.py L189–195). Событие — каталог -/// (search|skip|review|join_auto|join_manual| -/// reject|flood|error|done|…). Список — последние события задачи, новые сверху (ORDER BY created_at DESC). -/// /// Короткий id записи (префикс dl_). /// Id задачи поиска. /// Событие (см. ). -/// Текст/детали события (русская строка 1:1 с прототипом). +/// Текст/детали события. /// Время события, epoch-ms. public sealed record DiscoveryLogDto( string Id, diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogEvents.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogEvents.cs index 8deb0b3..524f90f 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogEvents.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogEvents.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Discovery.Application.Models; /// -/// События лога задачи Discovery (колонка DiscLog.Event; discovery.py L187–188, api-map §3.8). +/// События лога задачи Discovery. /// -/// -/// Каталог 1:1 с комментарием db.py L187–188: search (завершён проход по ключам), skip (источник пропущен), -/// review (кандидат переведён в review), join_auto (авто-вступление воркером), join_manual (ручное вступление), -/// leave, reject (отклонён), flood (флуд-стоп), error (сбой шага), done (план задачи выполнен). -/// public static class DiscoveryLogEvents { /// @@ -16,32 +11,32 @@ public static class DiscoveryLogEvents public const string Search = "search"; /// - /// Источник пропущен (мониторится/в чёрном списке/уже кандидат/мало участников/…). + /// Источник пропущен /// public const string Skip = "skip"; /// - /// Кандидат переведён в review (set_candidate_status). + /// Кандидат переведён в review /// public const string Review = "review"; /// - /// Авто-вступление воркера (mark_joined(auto:true), счётчик суточной квоты). + /// Авто-вступление воркера /// public const string JoinAuto = "join_auto"; /// - /// Ручное вступление (mark_joined(auto:false), вне квот). + /// Ручное вступление /// public const string JoinManual = "join_manual"; /// - /// Выход из источника (зарезервировано прототипом). + /// Выход из источника. /// public const string Leave = "leave"; /// - /// Кандидат отклонён (mark_rejected → чёрный список). + /// Кандидат отклонён /// public const string Reject = "reject"; @@ -51,12 +46,12 @@ public static class DiscoveryLogEvents public const string Flood = "flood"; /// - /// Сбой шага воркера/поиска (не роняет задачу). + /// Сбой шага воркера/поиска /// public const string Error = "error"; /// - /// План авто-вступлений задачи выполнен (воркер). + /// План авто-вступлений задачи выполнен /// public const string Done = "done"; } diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryMessageFit.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryMessageFit.cs index de7235e..568c4ec 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryMessageFit.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryMessageFit.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Discovery.Application.Models; /// -/// Вердикт фита одного сообщения (python evaluate_message L174–194). +/// Вердикт фита одного сообщения. /// /// True — сообщение относится к сфере/задаче. /// Краткая причина вердикта. diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskDraft.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskDraft.cs index a99845c..496a16a 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskDraft.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskDraft.cs @@ -1,28 +1,22 @@ namespace Deal.Modules.Discovery.Application.Models; /// -/// Создание задачи поиска — вход POST /api/discovery/tasks (DiscoveryTaskDraft: поля с дефолтами сервиса). +/// Создание задачи поиска — вход POST /api/discovery/tasks /// -/// -/// 1:1 с TaskCreate discovery_routes.py L50–60 и нормализацией create_task discovery.py L234–282: name — строка -/// (обязательная, Trim); description/keywords/minSubscribers/lang/autoJoin имеют дефолты в сервисе; -/// threshold/sampleSize/planJoins — null → дефолты (threshold/sampleSize — из настроек discEvalThreshold/ -/// discEvalSample, planJoins — 1). plan_joins дополнительно проходит план-бюджет (DiscoveryPlanGuard). -/// public sealed record DiscoveryTaskDraft { /// - /// Название задачи (после Trim непустое — иначе 400 «Укажите название задачи»). + /// Название задачи /// public string Name { get; init; } = string.Empty; /// - /// Описание ниши/цели (может быть пустым). + /// Описание ниши/цели /// public string Description { get; init; } = string.Empty; /// - /// Ключевые слова поиска; null → пустой список (start до добавления ключей — 400). + /// Ключевые слова поиска; null → пустой список /// public IReadOnlyList? Keywords { get; init; } @@ -32,22 +26,22 @@ public sealed record DiscoveryTaskDraft 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; } diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskDto.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskDto.cs index 4c411e1..3846b7c 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskDto.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskDto.cs @@ -1,16 +1,11 @@ namespace Deal.Modules.Discovery.Application.Models; /// -/// Задача поиска Discovery — элемент GET /api/discovery/tasks и ответ всех мутаций (api-map §4.8 L353). +/// Задача поиска Discovery — элемент GET /api/discovery/tasks и ответ всех мутаций. /// -/// -/// Поля 1:1 с _task_view discovery.py L116–137 и строкой disc_tasks (db.py L136–156). JSON-поле keywords -/// наружу всегда список строк; статус — . -/// createdAt/updatedAt — epoch-ms (времена хранятся UTC, на границе переводятся в ms — конвенция проекта). -/// /// Короткий id задачи (префикс dt_). /// Название задачи (обязательное, Trim). -/// Описание ниши/цели (источник для ИИ-генерации ключей, Task 19). +/// Описание ниши/цели. /// Ключевые слова поиска (JSON-массив; пусто — start возвращает 400). /// Минимальное число участников (0 — не фильтровать). /// Язык источников: ru|any. diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskPatch.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskPatch.cs index 6635e00..cf74f8e 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskPatch.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskPatch.cs @@ -1,58 +1,52 @@ namespace Deal.Modules.Discovery.Application.Models; /// -/// Патч задачи поиска — вход PATCH /api/discovery/tasks/{id} (все поля optional; TaskPatch discovery_routes.py L62–71). +/// Патч задачи поиска — вход PATCH /api/discovery/tasks/{id}. /// -/// -/// null — поле не меняется; не-null значение записывается (JSON-список keywords — полной заменой, пустой -/// список очищает ключи). План-бюджет: увеличение PlanJoins относительно текущего значения проверяется -/// DiscoveryPlanGuard с исключением самой задачи (patch_task discovery.py L292–298); границы значений -/// (threshold/minSubscribers/sampleSize/lang) нормализует сервис как _validate_task_values L213–231. -/// public sealed record DiscoveryTaskPatch { /// - /// Новое название (после 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). + /// Новый язык: ru|any /// public string? Lang { get; init; } /// - /// Новый порог оценки, % (кламп 1..100). + /// Новый порог оценки, % /// public int? Threshold { get; init; } /// - /// Новый размер выборки (кламп ≥1). + /// Новый размер выборки /// public int? SampleSize { get; init; } /// - /// Новый план авто-вступлений (рост — с проверкой бюджета, см. xml-doc типа). + /// Новый план авто-вступлений /// public int? PlanJoins { get; init; } /// - /// Новый флаг авто-вступлений (false — выключить). + /// Новый флаг авто-вступлений /// public bool? AutoJoin { get; init; } } diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskRow.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskRow.cs index 20f5c61..ce0ba95 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskRow.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskRow.cs @@ -1,59 +1,52 @@ namespace Deal.Modules.Discovery.Application.Models; /// -/// Полная запись для создания задачи поиска (IProjectStore-стиль write-псевдоним строки DiscTasks). +/// Полная запись для создания задачи поиска /// -/// -/// Write-модель: содержит полное состояние новой задачи, включая готовый id (dt_..., генерирует модуль — -/// ). Служебные значения, вычисленные до -/// записи: Status=draft, SearchIdx=0, SearchDone=false, счётчики Found/Evaluated/Joined/Rejected=0 — -/// как в INSERT прототипа discovery.py L260–280. CreatedAt/UpdatedAt проставляет хранилище (UTC-now); -/// keywords адаптер сериализует в JSON при записи. -/// public sealed record DiscoveryTaskRow { /// - /// Готовый id задачи (префикс dt_), сгенерированный модулем. + /// Готовый id задачи /// public string Id { get; init; } = string.Empty; /// - /// Название задачи (после Trim; непустое — валидирует сервис). + /// Название задачи /// public string Name { get; init; } = string.Empty; /// - /// Описание ниши/цели (может быть пустым). + /// Описание ниши/цели /// public string Description { get; init; } = string.Empty; /// - /// Ключевые слова поиска (JSON-список строк, очищенный от пустых). + /// Ключевые слова поиска /// public IReadOnlyList Keywords { get; init; } = Array.Empty(); /// - /// Минимальное число участников (0 — не фильтровать). + /// Минимальное число участников /// public int MinSubscribers { get; init; } /// - /// Язык источников: ru|any (дефолт ru). + /// Язык источников /// public string Lang { get; init; } = "ru"; /// - /// Порог подходящих сообщений оценки, % (1..100). + /// Порог подходящих сообщений оценки, % /// public int Threshold { get; init; } /// - /// Размер выборки сообщений при оценке (≥1). + /// Размер выборки сообщений при оценке /// public int SampleSize { get; init; } /// - /// План авто-вступлений (1..discJoinLimit; занимает суточный бюджет). + /// План авто-вступлений /// public int PlanJoins { get; init; } diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskStatuses.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskStatuses.cs index d5d6854..e7f9abd 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskStatuses.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskStatuses.cs @@ -1,46 +1,40 @@ namespace Deal.Modules.Discovery.Application.Models; /// -/// Статусы задачи поиска Discovery (колонка DiscTasks.Status; discovery.py L147, api-map §4.8). +/// Статусы задачи поиска Discovery. /// -/// -/// draft — создана, поиск не запускался; running — поиск/оценка/вступления идут; paused — поставлена на паузу -/// вручную; done — план авто-вступлений выполнен (воркер, discovery_worker _finish_done); failed — упала/остановлена -/// (зарезервировано прототипом). done/failed — терминальные: не занимают бюджет plan_joins (python _DONE_TASK L37) -/// и при повторном start сбрасывают прогресс поиска (start_task L332–339). -/// public static class DiscoveryTaskStatuses { /// - /// Создана, поиск ещё не запускался (значение по умолчанию при INSERT). + /// Создана, поиск ещё не запускался /// public const string Draft = "draft"; /// - /// Поиск запущен (обрабатывается воркером). + /// Поиск запущен /// public const string Running = "running"; /// - /// Поставлена на паузу вручную (POST …/pause); прогресс поиска сохраняется. + /// Поставлена на паузу вручную /// public const string Paused = "paused"; /// - /// План авто-вступлений выполнен (терминальный; повторный start сбрасывает прогресс). + /// План авто-вступлений выполнен /// public const string Done = "done"; /// - /// Упала/остановлена (терминальный статус прототипа; повторный start сбрасывает прогресс). + /// Упала/остановлена. /// public const string Failed = "failed"; /// - /// Проверка терминального статуса: задача не занимает бюджет plan_joins (python _DONE_TASK L37). + /// Проверка терминального статуса /// /// Статус задачи. - /// True — done/failed (повторный start сбрасывает прогресс поиска, L332–339). + /// True — done/failed. public static bool IsFinished(string status) { return status is Done or Failed; diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTopicDto.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTopicDto.cs index d24160e..802653f 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTopicDto.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTopicDto.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Discovery.Application.Models; /// -/// Элемент topics кандидата-форума — результат оценки темы (api-map §4.8 L355, discovery_worker L336–344). +/// Элемент topics кандидата-форума — результат оценки темы. /// -/// -/// Форма 1:1 с python discovery_worker._evaluate_content L336–344: {topicId, title, fitCount, total, fitRatio, -/// passed}. Заполняется оценкой только для kind=forum (для каналов/групп topics пуст); passed — тема прошла -/// порог (eval passed L229–237). Хранится JSON-массивом в DiscCandidates.TopicsJson и наружу отдаётся как есть. -/// /// Id темы форума (из Telegram). /// Заголовок темы (пуст — тема без названия). /// Сколько сообщений темы подошли под описание задачи. diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTopicGroup.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTopicGroup.cs index ee07a76..93daa39 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTopicGroup.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTopicGroup.cs @@ -3,7 +3,7 @@ using Deal.Contracts.Integrations.Models; namespace Deal.Modules.Discovery.Application.Models; /// -/// Группа сообщений форума по теме (python group_by_topic L96–117). +/// Группа сообщений форума по теме. /// /// Ключ темы: id темы строкой либо «main» (topic_id=null). /// Сниппет первого непустого текста темы (≤60 символов; пуст — тема без текста). diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryWorkerOutcome.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryWorkerOutcome.cs index 742da64..d1d888a 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryWorkerOutcome.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryWorkerOutcome.cs @@ -3,14 +3,14 @@ using Deal.Modules.Discovery.Application.Services; namespace Deal.Modules.Discovery.Application.Models; /// -/// Результат одного тика discovery-воркера (python discovery_worker L5–6: {action, taskId}). +/// Результат одного тика discovery-воркера. /// /// Действие тика (константы …). /// Id задачи шага (null — действия с задачей не связано). public sealed record DiscoveryWorkerOutcome(string Action, string? TaskId) { /// - /// Результат «работы нет» (none). + /// Результат «работы нет» /// public static DiscoveryWorkerOutcome None { get; } = new(DiscoveryWorkerService.ActionNone, null); } diff --git a/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs b/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs index 2221831..09468ce 100644 --- a/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs +++ b/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs @@ -6,15 +6,8 @@ using Microsoft.Extensions.DependencyInjection; namespace Deal.Modules.Discovery.Application.Registrars; /// -/// DI-регистрация модуля Discovery. Паттерн «port & adapter» (Ruling 9, план Task 17). +/// DI-регистрация модуля Discovery. /// -/// -/// Регистрируются только сервисы модуля. Порт-адаптер IDiscoveryStore → DiscoveryStore реализован в -/// Deal.Infrastructure и регистрируется там (AddDealPersistence) — модуль не знает про EF. Зависимости модуля — -/// Deal.Modules.Settings (порт ISettingsStore: discJoinLimit/discEvalThreshold/discEvalSample) и Deal.Contracts; -/// реверс-зависимостей нет (Global Constraints). Вызывается из Program.cs Deal.Api (AddDiscoveryModule, -/// план Task 19 — эндпоинты; воркер Task 18 добавляет свои сервисы в тот же регистратор). -/// public static class DiscoveryModuleRegistrar { /// @@ -22,10 +15,6 @@ public static class DiscoveryModuleRegistrar /// /// Коллекция сервисов. /// Коллекция сервисов для цепочки вызовов. - /// - /// Все сервисы scoped: их зависимости (IDiscoveryStore → TenantDbContext схемы тенанта, ISettingsStore) - /// живут в рамках tenant-запроса/tenant-скоупа воркера (эталон TelegramModuleRegistrar). - /// public static IServiceCollection AddDiscoveryModule(this IServiceCollection services) { services.AddScoped(); @@ -34,9 +23,7 @@ public static class DiscoveryModuleRegistrar services.AddScoped(); services.AddScoped(); - // Task 18 (воркер/оценка/бан-гард): DiscoveryWorkerService разрешается в tenant-скоупе цикла // (DiscoveryWorkerScheduler); IDiscoverySearchErrorCounter — singleton (счётчик ошибок ключей живёт - // дольше scoped-воркера, как глобальный dict прототипа). DiscoveryBanGuard регистрируется фабрикой, // чтобы не резолвить опциональный параметр utcNow (дефолт — DateTimeOffset.UtcNow). services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs index ba26556..b778575 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs @@ -7,21 +7,8 @@ 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). +/// Бан-гард авто-вступлений Discovery /// -/// -/// Суточный лимит (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; @@ -33,8 +20,7 @@ public sealed class DiscoveryBanGuard /// /// Хранилище Discovery (счётчик DiscLog за сутки). /// KV-настройки тенанта (discJoinLimit/discFloodDay/discPaused). - /// Источник текущего времени (UTC; тесты передают фиксированные «часы», эталон - /// FakeDiscoveryStore). По умолчанию — . + /// Источник текущего времени (UTC; тесты передают фиксированные «часы», эталон FakeDiscoveryStore). По умолчанию — . public DiscoveryBanGuard( IDiscoveryStore store, ISettingsStore settings, @@ -48,9 +34,8 @@ public sealed class DiscoveryBanGuard } /// - /// Ручной стоп-кран авто-вступлений (setting discPaused; ban_guard.global_paused L69–71). + /// Ручной стоп-кран авто-вступлений. /// - /// Токен отмены. /// True — вступления на паузе (воркер не делает сетевых шагов). public async Task GlobalPausedAsync(CancellationToken ct) { @@ -59,19 +44,15 @@ public sealed class DiscoveryBanGuard } /// - /// Авто-вступления за текущие UTC-сутки (ban_guard.joins_today_auto L29–35). + /// Авто-вступления за текущие UTC-сутки. /// - /// События 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). + /// Была ли flood-блокировка в текущие UTC-сутки. /// - /// Токен отмены. /// True — фиксировалась сегодня (стоп до конца суток). public async Task FloodTodayAsync(CancellationToken ct) { @@ -80,10 +61,8 @@ public sealed class DiscoveryBanGuard } /// - /// Разрешено ли авто-вступление: лимит не исчерпан, нет flood на сегодня, нет стоп-крана - /// (ban_guard.can_auto_join L38–41). + /// Разрешено ли авто-вступление /// - /// Токен отмены. /// True — следующее авто-вступление допустимо. public async Task CanAutoJoinAsync(CancellationToken ct) { @@ -98,25 +77,19 @@ public sealed class DiscoveryBanGuard } /// - /// Фиксирует FloodWait: блокировка авто-вступлений до конца суток (ban_guard.note_flood L59–62). + /// Фиксирует FloodWait /// - /// Запись идемпотентна в течение суток: 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 фиксировался сегодня. diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBlacklistService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBlacklistService.cs index 3851a29..9bd875f 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBlacklistService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBlacklistService.cs @@ -4,24 +4,16 @@ using Deal.Modules.Discovery.Application.Models; namespace Deal.Modules.Discovery.Application.Services; /// -/// Сервис чёрного списка Discovery — добавление/снятие/список (1:1 add_blacklist/remove_blacklist/list_blacklist L568–589). +/// Сервис чёрного списка Discovery — добавление/снятие/список. /// -/// -/// Чистый сервис поверх (таблица 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, @@ -41,10 +33,9 @@ public sealed class DiscoveryBlacklistService(IDiscoveryStore store) } /// - /// Снимает источник с чёрного списка (remove_blacklist L581–582; нет строки — no-op). + /// Снимает источник с чёрного списка. /// /// Подписанный id источника. - /// Токен отмены. /// Завершается после удаления строки. public Task RemoveAsync(string dialogId, CancellationToken ct) { @@ -52,9 +43,8 @@ public sealed class DiscoveryBlacklistService(IDiscoveryStore store) } /// - /// Весь чёрный список, новые записи первыми (list_blacklist L585–589). + /// Весь чёрный список, новые записи первыми. /// - /// Токен отмены. /// Записи от новых к старым. public Task> ListAsync(CancellationToken ct) { diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryCandidatesService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryCandidatesService.cs index e2290aa..ddec8df 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryCandidatesService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryCandidatesService.cs @@ -6,55 +6,40 @@ 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). +/// Сервис кандидатов Discovery — add с исключениями, set_candidate, review-перевод, mark_joined/rejected, delete. /// -/// -/// Чистый оркестратор поверх (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). + /// 400 mark_rejected /// public const string RejectJoinedDetail = "Нельзя отклонить источник, в который уже вступили"; /// - /// 400 set_candidate_status: не new/review (set_candidate_status L503–504). + /// 400 set_candidate_status /// 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, @@ -65,10 +50,9 @@ public sealed class DiscoveryCandidatesService( } /// - /// Кандидат по dialog_id (echo _get_candidate L400–402; для эндпоинтов join/reject Task 19). + /// Кандидат по dialog_id. /// /// Подписанный id источника. - /// Токен отмены. /// Кандидат или null (404 «Кандидат не найден»). public Task GetAsync(string dialogId, CancellationToken ct) { @@ -76,20 +60,14 @@ public sealed class DiscoveryCandidatesService( } /// - /// Добавляет найденный источник как кандидата задачи (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, @@ -103,7 +81,6 @@ public sealed class DiscoveryCandidatesService( 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; } @@ -135,7 +112,6 @@ public sealed class DiscoveryCandidatesService( if (existing is not null) { - // Устаревшая rejected-запись (например, после remove_blacklist): перезаписываем новым кандидатом (L431–433). await store.DeleteCandidateAsync(dialogId, ct).ConfigureAwait(false); } @@ -154,13 +130,11 @@ public sealed class DiscoveryCandidatesService( } /// - /// Обновляет поля кандидата по результатам оценки (set_candidate L456–494; зовёт воркер Task 18). + /// Обновляет поля кандидата по результатам оценки. /// - /// Пустые после Trim name/username/kind/hue не затирают текущее значение (L480–482). - /// Id задачи (принадлежность кандидата проверяется — python L464–466). + /// Id задачи. /// Подписанный id источника. /// Изменяемые поля (null — не менять). - /// Токен отмены. /// Обновлённый кандидат либо null — задачи/кандидата нет (или кандидат другой задачи). public async Task SetAsync( string taskId, @@ -172,7 +146,6 @@ public sealed class DiscoveryCandidatesService( 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; } @@ -191,13 +164,10 @@ public sealed class DiscoveryCandidatesService( } /// - /// Переводит кандидата в new/review (set_candidate_status L497–515; review пишет лог). + /// Переводит кандидата в new/review. /// - /// joined/rejected переводятся только через / - /// (там счётчики/чёрный список/лог). В review воркер переводит оценённых кандидатов (finish_review, Task 18). /// Подписанный id источника. /// Новый статус: new|review. - /// Токен отмены. /// Обновлённый кандидат либо null (кандидата нет). /// Статус не new/review. public async Task SetStatusAsync( @@ -230,12 +200,11 @@ public sealed class DiscoveryCandidatesService( } /// - /// Вступили в источник: status=joined, счётчик joined задачи, лог join_auto/join_manual (mark_joined L523–538). + /// Вступили в источник /// /// Подписанный id источника. - /// True — авто-вступление воркера; false — ручное (join из UI, Task 19). - /// Токен отмены. - /// Кандидат в joined либо null (кандидата нет). Повторный вызов для joined — идемпотентен (L528–529). + /// True — авто-вступление воркера; false — ручное. + /// Кандидат в joined либо null (кандидата нет). Повторный вызов для joined — идемпотентен. public async Task MarkJoinedAsync( string dialogId, bool auto, @@ -260,12 +229,11 @@ public sealed class DiscoveryCandidatesService( } /// - /// Отклоняет кандидата: status=rejected, счётчик rejected, лог reject, чёрный список (mark_rejected L541–563). + /// Отклоняет кандидата /// /// Подписанный id источника. /// Причина отклонения (в лог/чёрный список; «отклонено вручную» — эндпоинт reject). - /// Токен отмены. - /// Кандидат в rejected либо null (кандидата нет). Повторный вызов для rejected — идемпотентен (L552–553). + /// Кандидат в rejected либо null (кандидата нет). Повторный вызов для rejected — идемпотентен. /// Источник уже joined — отклонить нельзя. public async Task MarkRejectedAsync( string dialogId, @@ -297,19 +265,16 @@ public sealed class DiscoveryCandidatesService( } /// - /// Удаляет кандидата (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) { @@ -328,7 +293,6 @@ public sealed class DiscoveryCandidatesService( }; } - // Строковое поле патча: Trim; пустой результат — «не менять» (python L480–482 «or row[col]»). // value: Значение патча (null — не менялось). // current: Текущее значение строки. // Возвращает: Обрезанное значение или null (поле не меняется). @@ -343,7 +307,6 @@ public sealed class DiscoveryCandidatesService( return trimmed.Length == 0 ? null : trimmed; } - // Обрезка строки с дефолтом при пустом результате (add_candidate L443–446). private static string TrimOr(string value, string fallback) { string trimmed = (value ?? string.Empty).Trim(); diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryEvaluator.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryEvaluator.cs index f26f0e2..2e2462b 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryEvaluator.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryEvaluator.cs @@ -8,48 +8,24 @@ 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; @@ -61,7 +37,7 @@ public sealed class DiscoveryEvaluator /// /// KV-настройки тенанта (mlEnabled/aiEnabled — ветки каскада). /// ML-порт (спам-отсев при mlEnabled; сбой — «не уверен»). - /// ИИ-порт (фит при aiEnabled; сбой — эвристика, Ruling 10). + /// ИИ-порт. public DiscoveryEvaluator( ISettingsStore settings, IMlClient mlClient, @@ -76,13 +52,10 @@ public sealed class DiscoveryEvaluator } /// - /// Последовательная оценка всех сообщений выборки (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, @@ -114,11 +87,10 @@ public sealed class DiscoveryEvaluator } /// - /// Оценка фита одного сообщения под задачу: каскад короткое → ML-спам → ИИ → эвристика (python L174–194). + /// Оценка фита одного сообщения под задачу /// /// Задача поиска (description/keywords). /// Текст сообщения. - /// Токен отмены. /// Вердикт: fit + причина + источник (heuristic|ml|ai). public async Task EvaluateMessageAsync( DiscoveryTaskDto task, @@ -159,7 +131,6 @@ public sealed class DiscoveryEvaluator } catch (Exception exception) when (exception is not OperationCanceledException) { - // Прогноз недоступен («не уверен», python ml_client.predict L101–107) — решает ИИ/эвристика. } } @@ -182,14 +153,12 @@ public sealed class DiscoveryEvaluator } 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); @@ -206,7 +175,7 @@ public sealed class DiscoveryEvaluator } /// - /// Вердикт «источник подходит»: выборка ≥3 сообщений и доля fit ≥ threshold, % (python passed L229–237). + /// Вердикт «источник подходит» /// /// Агрегат оценки выборки/темы. /// Порог задачи (1..100; дефолт discEvalThreshold). @@ -217,11 +186,8 @@ public sealed class DiscoveryEvaluator } /// - /// Группирует сообщения выборки по topic_id (null → «main») и сортирует группы по числу сообщений - /// (убыв.), порядок сообщений внутри группы — входной (python group_by_topic L96–117). + /// Группирует сообщения выборки по topic_id /// - /// Заголовок группы — сниппет первого непустого текста (python _topic_title L86–93, ≤60 символов, - /// whitespace схлопнут). Возвращаемые группы переиспользуют исходные DTO (без копий). /// Сообщения выборки (для форумов заполнен TopicId). /// Группы от большей к меньшей; пусто — выборки нет. public static IReadOnlyList GroupByTopic(IReadOnlyList messages) @@ -248,13 +214,11 @@ public sealed class DiscoveryEvaluator 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) @@ -269,14 +233,12 @@ public sealed class DiscoveryEvaluator 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); @@ -301,7 +263,6 @@ public sealed class DiscoveryEvaluator return builder.ToString(); } - // Первые max кодовых точек строки (python-срез без разрыва суррогатных пар). private static string SliceCodePoints(string text, int max) { if (text.Length <= max) diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLangDetector.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLangDetector.cs index 5bda7de..71000e9 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLangDetector.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLangDetector.cs @@ -1,24 +1,16 @@ namespace Deal.Modules.Discovery.Application.Services; /// -/// Детектор «русскости» выборки сообщений кандидата (1:1 discovery_eval.detect_lang_ru L62–83, план Task 18). +/// Детектор «русскости» выборки сообщений кандидата. /// -/// -/// Чистая функция без зависимостей: доля кириллических букв (базовый блок U+0400–U+04FF) среди всех букв -/// выборки. Пороги python L36–37: ≥0.15 → True (язык русский), ≤0.03 → False (не русский), иначе — None -/// («не подтверждён»: воркер не отсекает кандидата, а помечает меткой). Пустая выборка без букв тоже даёт -/// None. Вызывается только для задач с lang=ru (discovery_worker L280–289). -/// public static class DiscoveryLangDetector { - // Верхний порог доли кириллицы: ≥ него — язык русский (python _RU_RATIO_HI L36). private const double RuRatioHigh = 0.15; - // Нижний порог доли кириллицы: ≤ него — язык не русский (python _RU_RATIO_LO L37). private const double RuRatioLow = 0.03; /// - /// Определяет язык выборки по доле кириллицы (detect_lang_ru L62–83). + /// Определяет язык выборки по доле кириллицы. /// /// Тексты сообщений выборки (пустые/без букв — None). /// True — русский, False — не русский, null — неопределённо (между порогами/нет букв). diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLogService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLogService.cs index 6f246c9..2e69f1a 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLogService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLogService.cs @@ -4,32 +4,22 @@ using Deal.Modules.Discovery.Application.Models; namespace Deal.Modules.Discovery.Application.Services; /// -/// Сервис лога задач Discovery — запись событий и чтение истории (1:1 add_log/task_log discovery.py L594–608). +/// Сервис лога задач Discovery — запись событий и чтение истории. /// -/// -/// Чистый сервис поверх (таблица 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, @@ -41,10 +31,9 @@ public sealed class DiscoveryLogService(IDiscoveryStore store) } /// - /// Последние события задачи, новые сверху (task_log L601–608). + /// Последние события задачи, новые сверху. /// /// Id задачи поиска. - /// Токен отмены. /// События задачи от новых к старым (до 100; пусто — лога нет). public Task> TaskLogAsync(string taskId, CancellationToken ct) { @@ -52,11 +41,10 @@ public sealed class DiscoveryLogService(IDiscoveryStore store) } /// - /// Последние события задачи с выборкой (limit клампится 1..500, как python L603). + /// Последние события задачи с выборкой. /// /// Id задачи поиска. /// Запрошенный размер выборки. - /// Токен отмены. /// События задачи от новых к старым. public Task> TaskLogAsync( string taskId, diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPacer.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPacer.cs index eebc3c1..d758a3c 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPacer.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPacer.cs @@ -5,13 +5,8 @@ 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 { /// diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPlanGuard.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPlanGuard.cs index c91efc7..6a0a114 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPlanGuard.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPlanGuard.cs @@ -6,37 +6,29 @@ using Deal.Modules.Settings.Application.Models; namespace Deal.Modules.Discovery.Application.Services; /// -/// План-бюджет авто-вступлений Discovery: лимит и правило суммы (python L80–111, Ruling 9). +/// План-бюджет авто-вступлений Discovery /// -/// -/// Правило 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»). + /// 400: план меньше 1. /// public const string PlanTooSmallDetail = "plan_joins должен быть не меньше 1"; /// - /// 400: план больше суточного лимита (python L100). Формат: {plan}, {limit}. + /// 400: план больше суточного лимита. /// public const string PlanTooBigFormat = "plan_joins {0} больше суточного лимита авто-вступлений ({1})"; /// - /// 400: бюджет исчерпан (python L108–110). Формат: {used}, {limit}, {plan}. + /// 400: бюджет исчерпан. /// public const string BudgetExceededFormat = "Бюджет авто-вступлений исчерпан: задачи уже занимают {0} из {1} в сутки, ещё {2} не влезает"; /// - /// Верхняя граница plan_joins: суточный лимит авто-вступлений (python _plan_limit L80–82). + /// Верхняя граница plan_joins /// - /// Токен отмены. /// discJoinLimit настройки (дефолт 50) с нижней границей 1. public async Task PlanLimitAsync(CancellationToken ct) { @@ -46,11 +38,10 @@ public sealed class DiscoveryPlanGuard(IDiscoveryStore store, ISettingsStore set } /// - /// Проверяет план новой задачи: 1..discJoinLimit (python _assert_plan L94–100). + /// Проверяет план новой задачи /// /// Запрашиваемый план авто-вступлений. - /// Токен отмены. - /// План меньше 1 или больше суточного лимита (тексты python). + /// План меньше 1 или больше суточного лимита. public async Task AssertPlanAsync(int planJoins, CancellationToken ct) { if (planJoins < 1) @@ -66,12 +57,11 @@ public sealed class DiscoveryPlanGuard(IDiscoveryStore store, ISettingsStore set } /// - /// Проверяет бюджет: занято активными задачами + новая ≤ discJoinLimit (python _assert_budget L103–111). + /// Проверяет бюджет /// /// План создаваемой/итоговый план увеличиваемой задачи. /// Id задачи, исключаемой из занятого бюджета (patch-рост плана); null — создание. - /// Токен отмены. - /// Бюджет исчерпан (текст python L108–110). + /// Бюджет исчерпан. public async Task AssertBudgetAsync( int planJoins, string? excludeTaskId, diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoverySearchErrorCounter.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoverySearchErrorCounter.cs index de86e6f..233fa62 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoverySearchErrorCounter.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoverySearchErrorCounter.cs @@ -4,14 +4,8 @@ using Deal.Modules.Discovery.Application.Abstractions; namespace Deal.Modules.Discovery.Application.Services; /// -/// Потокобезопасная реализация (ConcurrentDictionary + TTL). +/// Потокобезопасная реализация /// -/// -/// Записи ошибок живут с последнего инкремента: удалённая/завершённая задача не -/// копит строку вечно (Security/quality review — эвикция), а серия «3 ошибки подряд» всё равно успевает -/// накопиться (тики поиска идут раз в 5 с, окно TTL на порядки шире). Эвикция ленивая — при Next/Reset -/// (фоновых таймеров нет, как у прототипа: dict памяти процесса); часы инъекцией (в проде — UtcNow). -/// public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter { /// @@ -20,7 +14,7 @@ public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter public const int EntryTtlSeconds = 3600; /// - /// Запись счётчика: число ошибок подряд + момент последнего инкремента (epoch-мс, UTC). + /// Запись счётчика /// /// Число ошибок подряд. /// Момент последнего инкремента. @@ -32,7 +26,7 @@ public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter private readonly Func _utcNow; /// - /// Создаёт счётчик с системными часами (DateTimeOffset.UtcNow). + /// Создаёт счётчик с системными часами /// public DiscoverySearchErrorCounter() : this(() => DateTimeOffset.UtcNow) @@ -40,7 +34,7 @@ public sealed class DiscoverySearchErrorCounter : IDiscoverySearchErrorCounter } /// - /// Создаёт счётчик с заданными часами (тесты TTL). + /// Создаёт счётчик с заданными часами /// /// Источник текущего времени (UTC). public DiscoverySearchErrorCounter(Func utcNow) diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs index cbabe41..75ad431 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs @@ -8,32 +8,23 @@ 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). +/// Сервис задач поиска Discovery — create/patch/delete/start/pause/advance/bump. /// -/// -/// Чистый оркестратор поверх портов (таблицы 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). + /// 400 create: пустое название после Trim. /// public const string NameRequiredDetail = "Укажите название задачи"; /// - /// 400 start: у задачи нет ключевых слов (start_task L330). + /// 400 start: у задачи нет ключевых слов. /// public const string NoKeywordsDetail = "Нет ключевых слов для поиска — добавьте их в задачу"; /// - /// Все задачи, старые первыми (list_tasks L189–191; воркер берёт самую старую running). + /// Все задачи, старые первыми. /// - /// Токен отмены. /// Задачи в порядке создания. public Task> ListAsync(CancellationToken ct) { @@ -41,10 +32,9 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu } /// - /// Одна задача по id (get_task L194–196). + /// Одна задача по id. /// /// Id задачи (dt_...). - /// Токен отмены. /// Задача или null (404 «Задача не найдена» у эндпоинта). public Task GetAsync(string taskId, CancellationToken ct) { @@ -52,16 +42,9 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu } /// - /// Создаёт задачу поиска (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) @@ -115,14 +98,10 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu } /// - /// Обновляет поля задачи (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( @@ -148,7 +127,6 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu if (!normalized.HasChanges()) { - // python L289–290: пустой патч — возврат текущей задачи без записи (updated_at не бампается). return current; } @@ -157,10 +135,9 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu } /// - /// Удаляет задачу вместе с кандидатами и логом (delete_task L314–318; чёрный список общий). + /// Удаляет задачу вместе с кандидатами и логом. /// /// Id задачи (dt_...). - /// Токен отмены. /// True — задача удалена; false — строки нет (404 у эндпоинта). public async Task DeleteAsync(string taskId, CancellationToken ct) { @@ -174,14 +151,11 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu } /// - /// Запускает поиск (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); @@ -201,10 +175,9 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu } /// - /// Ставит задачу на паузу (pause_task L349–356; прогресс поиска сохраняется). + /// Ставит задачу на паузу. /// /// Id задачи (dt_...). - /// Токен отмены. /// Задача в paused либо null (404). public async Task PauseAsync(string taskId, CancellationToken ct) { @@ -219,12 +192,11 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu } /// - /// Увеличивает счётчик прогресса задачи (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, @@ -236,10 +208,9 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu } /// - /// Продвигает индекс поиска (advance_search L371–380): search_idx+1; конец ключей → search_done. + /// Продвигает индекс поиска /// /// Id задачи (dt_...). - /// Токен отмены. /// True — задача обновлена; false — строки нет. public async Task AdvanceSearchAsync(string taskId, CancellationToken ct) { @@ -254,7 +225,6 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu 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) @@ -279,7 +249,6 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu }; } - // Очищает список ключей: Trim + без пустых (python L226–227). // keywords: Сырые ключи (null — пусто). // Возвращает: Список непустых ключей. private static IReadOnlyList CleanKeywords(IReadOnlyList? keywords) @@ -289,7 +258,6 @@ public sealed class DiscoveryTasksService(IDiscoveryStore store, DiscoveryPlanGu .ToList() ?? new List(); } - // Кламп значения в границы [min, max] (эталон clamp python). private static int Clamp( int value, int min, 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 20f7d5c..f5784e0 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Constants.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Constants.cs @@ -1,12 +1,10 @@ namespace Deal.Modules.Discovery.Application.Services; // Часть DiscoveryWorkerService: константы — действия тика, пороги/размеры воркера и метки кандидатов -// (1:1 python discovery_worker L5–6/L74–89). public sealed partial class DiscoveryWorkerService { - // ── Действия тика (python discovery_worker L5–6) ── /// - /// Тик: работы нет (паузы/нет задач/заняты шаги других задач). + /// Тик: работы нет /// public const string ActionNone = "none"; @@ -21,7 +19,7 @@ public sealed partial class DiscoveryWorkerService public const string ActionReview = "review"; /// - /// Тик: кандидат пропущен (мало участников/язык/мало подходящих/3 неудачи join). + /// Тик: кандидат пропущен /// public const string ActionSkip = "skip"; @@ -41,7 +39,7 @@ public sealed partial class DiscoveryWorkerService public const string ActionFlood = "flood"; /// - /// Тик: сбой шага (не роняет задачу/воркер). + /// Тик: сбой шага /// public const string ActionError = "error"; @@ -50,42 +48,36 @@ public sealed partial class DiscoveryWorkerService /// public const string ActionDone = "done"; - // ── Пороги/размеры воркера (python L74–89) ── - // Минимальный объём содержательной выборки для вердикта оценки (python _MIN_CONTENT L77). private const int MinContentMessages = 3; - // Ошибки поиска одного ключа подряд, после которых ключ пропускается (python L80). private const int SearchErrorsToSkip = 3; - // Неудачные авто-вступления подряд, после которых кандидат удаляется (python L417–419). private const int MaxJoinFailures = 3; - // Верхняя граница результатов глобального поиска (прототип discovery_search default 30). private const int SearchResultLimit = 30; - // ── Метки кандидата (marks; python discovery_worker L85–89) ── /// - /// Метка: число участников не подтверждено (minSubscribers задан, participants неизвестны). + /// Метка: число участников не подтверждено /// public const string MarkParticipantsNotConfirmed = "участники не подтверждены"; /// - /// Метка: язык не подтверждён (доля кириллицы между порогами). + /// Метка: язык не подтверждён /// public const string MarkLangNotConfirmed = "язык не подтверждён"; /// - /// Метка: канал, история недоступна без членства (контент не оценён). + /// Метка: канал, история недоступна без членства /// public const string MarkChannelNoHistory = "канал: история недоступна"; /// - /// Метка: закрытая группа — история скрыта, вступите сами (контент не оценён). + /// Метка: закрытая группа — история скрыта, вступите сами /// public const string MarkClosedGroup = "закрытая группа (история скрыта) — вступите сами"; /// - /// Метка: содержательных сообщений выборки меньше трёх (решает человек). + /// Метка: содержательных сообщений выборки меньше трёх /// public const string MarkFewMessages = "мало сообщений"; } 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 baeaf77..260df3c 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Evaluate.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Evaluate.cs @@ -4,10 +4,8 @@ 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: Токен отмены. @@ -21,7 +19,6 @@ public sealed partial class DiscoveryWorkerService string dialogId = candidate.DialogId; List marks = new(2); - // ── Инфо об источнике: kind/forum, участники, имя/username (python L234–249) ── TelegramChannelInfoDto info; try { @@ -50,7 +47,6 @@ public sealed partial class DiscoveryWorkerService if (resolved) { // Имя/username обновляем только при успешном резолве: при fallback discovery_info возвращает - // name=dialog_id и не должен затирать имя (python L244–248). patch = patch with { Name = string.IsNullOrWhiteSpace(info.Name) ? null : info.Name.Trim(), @@ -61,7 +57,6 @@ public sealed partial class DiscoveryWorkerService 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) @@ -81,7 +76,6 @@ public sealed partial class DiscoveryWorkerService } } - // ── Чтение истории для оценки (python L267–277) ── TelegramEvalReadDto read; try { @@ -97,7 +91,6 @@ public sealed partial class DiscoveryWorkerService IReadOnlyList messages = read.Messages ?? Array.Empty(); if (!read.Ok) { - // История недоступна без членства: контент не оцениваем, фильтры помечаем (python L269–276). marks.Add(kind == DiscoveryCandidateKinds.Channel ? MarkChannelNoHistory : MarkClosedGroup); if (task.Lang == "ru") { @@ -109,7 +102,6 @@ public sealed partial class DiscoveryWorkerService return new DiscoveryWorkerOutcome(ActionReview, taskId); } - // ── Язык (только для ru-задач; python L279–289) ── bool? langRu = null; if (task.Lang == "ru") { @@ -129,7 +121,6 @@ public sealed partial class DiscoveryWorkerService } } - // ── Объём выборки: меньше 3 содержательных — решает человек (python L291–296) ── if (messages.Count < MinContentMessages) { marks.Add(MarkFewMessages); @@ -138,7 +129,6 @@ public sealed partial class DiscoveryWorkerService return new DiscoveryWorkerOutcome(ActionReview, taskId); } - // ── Оценка содержания (форумы — по темам; python _evaluate_content L318–356) ── int fitCount; int total; double fitRatio; @@ -205,7 +195,6 @@ public sealed partial class DiscoveryWorkerService return new DiscoveryWorkerOutcome(ActionSkip, taskId); } - // Перевести кандидата в review с метками/оценкой (python _finish_review L154–172; статус пишет лог review). // taskId: Id задачи. // dialogId: Id источника. // marks: Метки (пустые отфильтровываются). 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 7b93d81..9ec51fd 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Helpers.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Helpers.cs @@ -8,7 +8,6 @@ namespace Deal.Modules.Discovery.Application.Services; 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; обычные ошибки его не несут). @@ -22,7 +21,6 @@ public sealed partial class DiscoveryWorkerService || 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). 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 8a7755c..16b1606 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Join.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Join.cs @@ -4,10 +4,8 @@ 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: Токен отмены. @@ -20,7 +18,6 @@ public sealed partial class DiscoveryWorkerService 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)) { @@ -33,18 +30,15 @@ public sealed partial class DiscoveryWorkerService } 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 @@ -68,7 +62,6 @@ public sealed partial class DiscoveryWorkerService { if (IsFlood(exception)) { - // FloodWait: стоп авто-вступлений до конца суток; кандидат остаётся review (python L399–402). await _banGuard.NoteFloodAsync(ct).ConfigureAwait(false); await _log.AddAsync( taskId, @@ -79,7 +72,6 @@ public sealed partial class DiscoveryWorkerService } // Между паузой и неудачным join кандидата могли отклонить/удалить: счётчик и удаление трогаем - // только у живой записи в статусе review (python L404–410). DiscoveryCandidateDto? rowNow = await _store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); if (rowNow is null || rowNow.Status != DiscoveryCandidateStatuses.Review) { @@ -109,7 +101,6 @@ public sealed partial class DiscoveryWorkerService } // Вступление состоялось: joined(auto) + каталог/зеркало (монитор on) + фоновый разбор последних - // сообщений (backfill; сбой не роняет шаг) + снятие чёрного списка (python L424–438). await _candidates.MarkJoinedAsync(dialogId, auto: true, ct).ConfigureAwait(false); try @@ -138,7 +129,6 @@ public sealed partial class DiscoveryWorkerService } catch (Exception exception) { - // Вступление уже состоялось — сбой backfill не роняет шаг (python L434–437: log.warning), // но фиксируется в DiscLog, иначе «немое» глотание скрывает регулярные сбои. await _log.AddAsync(taskId, DiscoveryLogEvents.Error, $"фоновый разбор {dialogId}: {exception.Message}", ct) .ConfigureAwait(false); 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 c55456b..bc3a26d 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Search.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Search.cs @@ -4,10 +4,8 @@ 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 — сбой (индекс ключа/счётчик ошибок по сценарию). @@ -18,7 +16,6 @@ public sealed partial class DiscoveryWorkerService 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); @@ -44,7 +41,6 @@ public sealed partial class DiscoveryWorkerService if (errors >= SearchErrorsToSkip) { // 3 ошибки подряд одного ключа: пропускаем (битый ключ не должен зацикливать поиск и - // блокировать оценку/вступления других задач; python L196–203). _searchErrors.Reset(taskId); await _log.AddAsync( taskId, @@ -67,7 +63,6 @@ public sealed partial class DiscoveryWorkerService 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; @@ -88,7 +83,6 @@ public sealed partial class DiscoveryWorkerService 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); diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs index d7b1f05..50ca516 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs @@ -5,35 +5,8 @@ using Deal.Modules.Discovery.Application.Models; namespace Deal.Modules.Discovery.Application.Services; /// -/// Воркер Discovery: поиск → оценка → авто-вступление, один шаг за тик (1:1 discovery_worker.py целиком, план Task 18, Ruling 10). +/// Воркер Discovery /// -/// -/// выполняет ОДНО действие для самой старой 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 { @@ -48,7 +21,7 @@ public sealed partial class DiscoveryWorkerService private readonly IDiscoverySearchErrorCounter _searchErrors; /// - /// Создаёт воркер Discovery (чистый оркестратор; все зависимости — порты/сервисы модуля). + /// Создаёт воркер Discovery /// /// Хранилище Discovery (микро-операции: done, join_failures, проверки «не состоим»). /// Сервис задач (список running, advance_search, bump счётчиков). @@ -90,16 +63,14 @@ public sealed partial class DiscoveryWorkerService } /// - /// Один шаг discovery-воркера (см. doc-комментарий типа и discovery_worker.tick L444–484). + /// Один шаг discovery-воркера. /// - /// Токен отмены. /// Результат действия: действие + 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; } @@ -111,7 +82,6 @@ public sealed partial class DiscoveryWorkerService } // 1. План достигнут — закрываем задачу (важно до поиска/оценки/join: задачу с выполненным планом - // нельзя продолжать обрабатывать; python L458–461). foreach (DiscoveryTaskDto task in running) { if (task.Joined >= task.PlanJoins) @@ -121,7 +91,6 @@ public sealed partial class DiscoveryWorkerService } } - // 2. Поиск: следующая running-задача с незавершённым проходом по ключам (python L463–466). foreach (DiscoveryTaskDto task in running) { if (!task.SearchDone) @@ -130,7 +99,6 @@ public sealed partial class DiscoveryWorkerService } } - // 3. Оценка: первый кандидат status='new' (самая старая задача — первой; python L468–472). foreach (DiscoveryTaskDto task in running) { IReadOnlyList newCandidates = @@ -141,7 +109,6 @@ public sealed partial class DiscoveryWorkerService } } - // 4. Авто-вступление: отдельный проход, приоритет ниже оценки (python L474–482). foreach (DiscoveryTaskDto task in running) { if (!task.AutoJoin) @@ -158,7 +125,6 @@ public sealed partial class DiscoveryWorkerService if (!await _banGuard.CanAutoJoinAsync(ct).ConfigureAwait(false)) { - // Суточный лимит/flood/пауза — join никому нельзя (python L480–481). return DiscoveryWorkerOutcome.None; } @@ -168,7 +134,6 @@ public sealed partial class DiscoveryWorkerService 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); diff --git a/src/core/Deal.Modules.Discovery/DiscoveryModuleMarker.cs b/src/core/Deal.Modules.Discovery/DiscoveryModuleMarker.cs index bec551e..26d6a46 100644 --- a/src/core/Deal.Modules.Discovery/DiscoveryModuleMarker.cs +++ b/src/core/Deal.Modules.Discovery/DiscoveryModuleMarker.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Discovery; /// -/// Маркер модуля Discovery: используется для DI-сканирования и тестов. +/// Маркер модуля Discovery /// public sealed class DiscoveryModuleMarker { diff --git a/src/core/Deal.Modules.Kanban/Application/Abstractions/ICardStore.cs b/src/core/Deal.Modules.Kanban/Application/Abstractions/ICardStore.cs index 06b0e8d..9deb563 100644 --- a/src/core/Deal.Modules.Kanban/Application/Abstractions/ICardStore.cs +++ b/src/core/Deal.Modules.Kanban/Application/Abstractions/ICardStore.cs @@ -3,72 +3,50 @@ 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 /// /// 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, @@ -77,50 +55,42 @@ public interface ICardStore // ── Карточки ────────────────────────────────────────────────────────── /// - /// Карточки колонки или всех колонок дашборда, ORDER BY received_at DESC (list_leads L151–156). + /// Карточки колонки или всех колонок дашборда, ORDER BY received_at DESC. /// /// Фильтр: Col — конкретная колонка либо null (все колонки дашборда). - /// Токен отмены. /// Полные карточки (DTO): JSON-поля разобраны, комментарии приложены, time посчитан. public Task> ListCardsAsync(CardsQuery query, CancellationToken ct); /// - /// Карточки пространства «Выбранные», ORDER BY updated_at DESC (list_cards projects.py L58–63). + /// Карточки пространства «Выбранные», ORDER BY updated_at DESC. /// /// Фильтр по контейнеру-стадии (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. + /// Ограничение результата. + /// Полные карточки (комментарии приложены, 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 /// /// 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, @@ -128,18 +98,16 @@ public interface ICardStore CancellationToken ct); /// - /// Создаёт карточку из готового снимка (пайплайн этапа 4, ручное создание; CreatedAt — UTC-now). + /// Создаёт карточку из готового снимка. /// /// Полное состояние новой карточки (id сгенерирован модулем, см. CardSnapshot). - /// Токен отмены. public Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct); /// - /// Точечная правка полей по присутствующим в патче + bump UpdatedAt (patch_card projects.py L159–187). + /// Точечная правка полей по присутствующим в патче + bump UpdatedAt. /// /// Id карточки (c_...). /// Изменяемые поля (null — поле не меняется; JSON-поля — полная замена). - /// Токен отмены. /// True — строка обновлена; false — карточки нет (404-семантика сервиса). public Task PatchCardAsync( string cardId, @@ -147,11 +115,10 @@ public interface ICardStore CancellationToken ct); /// - /// Атомарно дописывает ссылку в JSON-массив links ОДНИМ UPDATE (jsonb-append) + bump UpdatedAt. + /// Атомарно дописывает ссылку в JSON-массив links ОДНИМ UPDATE /// /// Id карточки (c_...). /// Готовая ссылка {id,name,url} (id сгенерирован модулем). - /// Токен отмены. /// True — строка обновлена; false — карточки нет (404-семантика сервиса). public Task AddLinkAsync( string cardId, @@ -159,11 +126,10 @@ public interface ICardStore CancellationToken ct); /// - /// Атомарно убирает из JSON-массива links элемент с указанным id ОДНИМ UPDATE (jsonb-фильтрация) + bump UpdatedAt. + /// Атомарно убирает из JSON-массива links элемент с указанным id ОДНИМ UPDATE /// /// Id карточки (c_...). /// Id удаляемой ссылки (pl_...). - /// Токен отмены. /// True — строка обновлена; false — карточки нет (404-семантика сервиса). public Task RemoveLinkAsync( string cardId, @@ -171,11 +137,10 @@ public interface ICardStore CancellationToken ct); /// - /// Атомарно дописывает метаданные файла в JSON-массив files ОДНИМ UPDATE (jsonb-append) + bump UpdatedAt. + /// Атомарно дописывает метаданные файла в JSON-массив files ОДНИМ UPDATE /// /// Id карточки (c_...). /// Готовые метаданные {id,name,size,kind,label,objectKey} (id сгенерирован модулем). - /// Токен отмены. /// True — строка обновлена; false — карточки нет (404-семантика сервиса). public Task AddFileAsync( string cardId, @@ -183,11 +148,10 @@ public interface ICardStore CancellationToken ct); /// - /// Атомарно убирает из JSON-массива files элемент с указанным id ОДНИМ UPDATE (jsonb-фильтрация) + bump UpdatedAt. + /// Атомарно убирает из JSON-массива files элемент с указанным id ОДНИМ UPDATE /// /// Id карточки (c_...). /// Id удаляемой записи файла (pf_...). - /// Токен отмены. /// True — строка обновлена; false — карточки нет (404-семантика сервиса). public Task RemoveFileAsync( string cardId, @@ -195,14 +159,12 @@ public interface ICardStore 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, @@ -212,123 +174,106 @@ public interface ICardStore 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). + /// Наступившие напоминания стадии hold /// /// Текущий момент (UTC) для сравнения с ReminderAt. - /// Токен отмены. /// Due-строки {id,title,containerId} в порядке наступления; пусто — сработавших нет. public Task> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct); /// - /// Помечает due-карточки сработавшими: ReminderFired=true по списку id (check_reminders projects.py L277–278). + /// Помечает due-карточки сработавшими /// /// 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, @@ -339,51 +284,37 @@ public interface ICardStore // ── Журнал действий (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). + /// Число записей журнала = счётчик learning. /// - /// Токен отмены. /// Количество строк CardMoves. public Task CountMovesAsync(CancellationToken ct); /// - /// Свежие примеры разметки пользователя для few-shot ИИ-классификации (pipeline.py _learning_examples L201–215). + /// Свежие примеры разметки пользователя для few-shot ИИ-классификации. /// - /// - /// 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). + /// Архивирует пачку карточек ОДНИМ UPDATE /// /// Id карточек-кандидатов (список из ListArchiveCandidatesAsync). /// Момент архивации (один «now» тика). - /// Токен отмены. /// Сколько карточек реально архивировано (0 — кандидатов не было/уже не в рабочих колонках). public Task ArchiveAsync( IReadOnlyList cardIds, @@ -391,47 +322,40 @@ public interface ICardStore 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. + /// Карточки для пересчёта ConvFrom/ConvTo/ConvCur /// - /// Токен отмены. /// Карточки с бюджетом (используются Budget/бюджетные поля; служебные колонки исключены). public Task> ListCardsForConversionAsync(CancellationToken ct); /// - /// Записывает пересчитанную конверсию бюджета карточки (обновляет только conv-поля). + /// Записывает пересчитанную конверсию бюджета карточки /// /// Id карточки. /// Сконвертированная нижняя граница, либо null. /// Сконвертированная верхняя граница, либо null. /// Валюта конверсии (целевая валюта тенанта); пусто — конверсия снята. - /// Токен отмены. public Task UpdateConversionAsync( string cardId, double? convFrom, @@ -439,12 +363,10 @@ public interface ICardStore string convCur, CancellationToken ct); - // ── Эвристика ИИ-предложений (Ruling 3, Task 14) ─────────────────────── /// - /// Карточки «Неразобранного» с исходным текстом — вход эвристики suggest (suggest.py, Ruling 3). + /// Карточки «Неразобранного» с исходным текстом — вход эвристики suggest. /// - /// Токен отмены. /// Карточки 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 8b0a71f..c624595 100644 --- a/src/core/Deal.Modules.Kanban/Application/Abstractions/IMlLearningStore.cs +++ b/src/core/Deal.Modules.Kanban/Application/Abstractions/IMlLearningStore.cs @@ -3,41 +3,29 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Kanban.Application.Abstractions; /// -/// Порт хранилища обучения ML: очередь MlOutbox + счётчик журнала CardMoves (Ruling 4, Task 5). +/// Порт хранилища обучения ML /// -/// -/// Объявлен в модуле 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). + /// Число записей журнала обучения = счётчик learning /// - /// Токен отмены. /// Количество строк журнала CardMoves. public Task CountLearningAsync(CancellationToken ct); /// - /// Число строк очереди обучения = счётчик outbox: count(MlOutbox) (ml_client.outbox_len L52–53). + /// Число строк очереди обучения = счётчик outbox /// - /// Токен отмены. /// Количество строк очереди 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, @@ -46,26 +34,21 @@ public interface IMlLearningStore 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. /// /// Id строк, успешно отправленных в ML-сервис. - /// Токен отмены. /// Задача завершается после удаления строк. public Task DeleteOutboxAsync(IReadOnlyCollection ids, CancellationToken ct); } diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/AmountParser.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/AmountParser.cs index c27f8eb..48bffe0 100644 --- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/AmountParser.cs +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/AmountParser.cs @@ -4,58 +4,38 @@ using System.Text.RegularExpressions; namespace Deal.Modules.Kanban.Application.ColumnRules; /// -/// Парсер сумм из текста сообщения (rules.py extract_amounts L93–144, _norm_amount L62–73, _cur_from_tail L76–90). +/// Парсер сумм из текста сообщения. /// -/// -/// Сохраняет смысл суммы: «от A до B»/«A–B» → from/to, «до B» → только верхняя граница, одна сумма/«от A» -/// → from=to. Валюту ищем сразу после суммы (символ «$ € ₽ ₮ £ ¥» или слово «usd eur rub … руб долл бакс») -/// либо перед ней для «$1 200»; суффикс «к/К» в числе — тысячи («2к» → 2000, «1.5к$» → 1500 USD). -/// Суммы БЕЗ валюты игнорируются (прототип L98); повторное срабатывание конструкций на одном тексте -/// отсекается занятыми диапазонами (_free/_add L104–112) — порядок проходов 1:1 с прототипом. -/// Семантика 1:1, включая особенности: «usdt» словом после числа распознаётся как USD (первым срабатывает -/// startswith «usd», rules.py L82), «евро»/«рубли» словами не распознаются (в _CUR_WORDS их нет). -/// public static class AmountParser { - // Цифры суммы: число с разделителями тысяч и опциональным суффиксом «к/К» (rules.py _AMT L58). private const string Amount = @"\d[\d\s\u00a0]*(?:[.,]\d+)?[кkКK]?"; - // Символы валют сразу после суммы (rules.py _CUR_SYMBOLS L15, порядок 1:1). private static readonly (string Symbol, string Code)[] CurrencySymbols = { ("$", "USD"), ("€", "EUR"), ("₽", "RUB"), ("₮", "USDT"), ("£", "GBP"), ("¥", "CNY"), }; - // Слова валют сразу после суммы (rules.py _CUR_WORDS L16, порядок 1:1 — важен для «usdt»). private static readonly (string Word, string Code)[] CurrencyWords = { ("usd", "USD"), ("eur", "EUR"), ("rub", "RUB"), ("usdt", "USDT"), ("gbp", "GBP"), ("cny", "CNY"), ("руб", "RUB"), ("долл", "USD"), ("бакс", "USD"), }; - // Проход 1: словесный диапазон «от A до B» (rules.py L115). private static readonly Regex WordRangeRe = new(@"\bот\s+(" + Amount + @")\s+до\s+(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - // Проход 2: «до B» без пары «от…» — только верхняя граница (rules.py L121). private static readonly Regex ToOnlyRe = new(@"\bдо\s+(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - // Проход 3: «от A» без верхней границы — считаем одной суммой (rules.py L127). private static readonly Regex FromOnlyRe = new(@"\bот\s+(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - // Проход 4: числовой диапазон «A–B» / «A - B» / «A до B» (rules.py L133). private static readonly Regex NumericRangeRe = new("(" + Amount + @")\s*(?:[-–—]|\s+до\s+)\s*(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - // Проход 5: одиночные суммы с валютой (rules.py L139). private static readonly Regex AnyAmountRe = new(Amount, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); /// - /// Разбирает суммы в тексте (rules.py extract_amounts L93–144). + /// Разбирает суммы в тексте. /// /// Текст сообщения (сырой, как сохранил источник). - /// - /// Список распознанных сумм/диапазонов с кодом валюты; пустой список — сумм с валютой нет. - /// Порядок — по проходам парсера (1:1 с прототипом), а не по позиции в тексте. - /// + /// Список распознанных сумм/диапазонов с кодом валюты; пустой список — сумм с валютой нет. Порядок — по проходам парсера, а не по позиции в тексте. public static IReadOnlyList Parse(string? text) { string t = text ?? string.Empty; @@ -67,7 +47,6 @@ public static class AmountParser var result = new List(); var occupied = new List<(int Start, int End)>(); - // Свободен ли диапазон (не пересекается с уже занятым конструкцией выше) — прототип _free L106–107. static bool IsFree( List<(int Start, int End)> spans, int start, @@ -160,7 +139,6 @@ public static class AmountParser } // Число из строки суммы: пробелы и неразрывные пробелы убираем, «,» — десятичный разделитель, - // суффикс «к/К» — множитель 1000 (прототип _norm_amount L62–73). // raw: Сырая цифровая часть из regex (может содержать «к»/«К» на конце). // Возвращает: Значение суммы или null при ошибке парсинга. private static double? NormalizeAmount(string raw) @@ -183,13 +161,11 @@ public static class AmountParser } // Код валюты рядом с суммой: символ/слово сразу после позиции либо символ за ≤3 символа до неё - // («$1 200», прототип _cur_from_tail L76–90). // text: Весь текст сообщения. // position: Позиция сразу после конца суммы (m.end). // Возвращает: Код валюты или null, если валюты рядом нет. private static string? CurFromTail(string text, int position) { - // Окно до 12 символов после суммы (прототип: text[m_start:m_start+12].lower().strip()). int tailLength = Math.Min(12, text.Length - position); string tail = text.Substring(Math.Max(0, position), Math.Max(0, tailLength)).ToLowerInvariant().Trim(); @@ -209,7 +185,6 @@ public static class AmountParser } } - // Валюта перед числом («$1 200»): символ за ≤3 символа до конца суммы (прототип L86–89). int headStart = Math.Max(0, position - 3); string head = text.Substring(headStart, position - headStart).Trim(); foreach ((string symbol, string code) in CurrencySymbols) diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/AmountRange.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/AmountRange.cs index bec07bd..55e8473 100644 --- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/AmountRange.cs +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/AmountRange.cs @@ -1,14 +1,8 @@ namespace Deal.Modules.Kanban.Application.ColumnRules; /// -/// Одна распознанная сумма/диапазон из текста сообщения (rules.py extract_amounts L93–144). +/// Одна распознанная сумма/диапазон из текста сообщения. /// -/// -/// Элемент результата : «от A до B»/«A–B» → from=A, to=B; «до B» -/// (только верхняя граница) → from=null, to=B; одна сумма/«от A» → from=to=A. — -/// код валюты (USD/RUB/…) из символа или слова сразу после суммы (для «$1 200» — символа перед ней); -/// суммы без валюты парсер игнорирует (прототип, rules.py L98). -/// /// Нижняя граница (null — «до B» без нижней). /// Верхняя граница (null не возвращается: «от A» даёт from=to=A). /// Код валюты (непустой). diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetInRange.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetInRange.cs index 8e052a7..24c2ff2 100644 --- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetInRange.cs +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetInRange.cs @@ -4,27 +4,17 @@ using Deal.Modules.Settings.Application.Services; namespace Deal.Modules.Kanban.Application.ColumnRules; /// -/// Проверка попадания распознанных сумм в бюджетный диапазон правил колонки (rules.py _amount_in_range L147–173). +/// Проверка попадания распознанных сумм в бюджетный диапазон правил колонки. /// -/// -/// «Интерфейс курсов» — чистый: сравнение в одной валюте не требует курсов, при разных валютах суммы -/// конвертируются по словарю «код → курс к рублю» через чистую -/// (Settings, rates.py L86–103): USDT приравнивается к USD, отсутствующая валюта → сумма пропускается. -/// Курсы — параметр функции: чтение кэша ratesCache (Ruling 7) остаётся за вызывающим (CardsService и -/// др.), модуль и функция не ходят в БД. Границы из null → диапазон без ограничений (True), как прототип. -/// public static class BudgetInRange { /// - /// Попадает ли хотя бы одна распознанная сумма в диапазон правил (rules.py L147–173). + /// Попадает ли хотя бы одна распознанная сумма в диапазон правил. /// /// Суммы из текста (). - /// Бюджет правил колонки (валюта пуста → USD, как в прототипе L157). + /// Бюджет правил колонки. /// Курсы к рублю «код → курс» либо null (конвертация невозможна). - /// - /// True — есть сумма, которая после конвертации (при необходимости) ≥ from и ≤ to; обе границы null → True; - /// пустые amounts или непереводимая валюта → False. - /// + /// True — есть сумма, которая после конвертации (при необходимости) ≥ from и ≤ to; обе границы null → True; пустые amounts или непереводимая валюта → False. public static bool IsInRange( IReadOnlyList amounts, BudgetRangeDto budget, diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetRangeDtoExtensions.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetRangeDtoExtensions.cs index bc564c6..28a279a 100644 --- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetRangeDtoExtensions.cs +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetRangeDtoExtensions.cs @@ -8,8 +8,7 @@ namespace Deal.Modules.Kanban.Application.ColumnRules; internal static class BudgetRangeDtoExtensions { /// - /// Активна ли бюджетная группа: объект есть и не «пустой» (прототип bool(budget) — {} выключен, - /// {cur} без границ включён; см. ColumnMatcher.HasActiveRules). + /// Активна ли бюджетная группа /// /// Поле budget/prices правил (может быть null). /// True — группа бюджета участвует в матчинге. diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/ColumnExclusions.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/ColumnExclusions.cs index 56f5e52..a885060 100644 --- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/ColumnExclusions.cs +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/ColumnExclusions.cs @@ -3,19 +3,12 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Kanban.Application.ColumnRules; /// -/// Слова-исключения колонки: veto, применяется ДО положительных правил (rules.py excluded_terms L230–244, is_excluded L247–248). +/// Слова-исключения колонки /// -/// -/// Исключения — вето колонки: если в содержании сообщения (без ссылок и служебных хвостов, -/// ) встречается любое из них, карточка в колонку не попадает, -/// даже если все положительные условия совпали. Ветo учитывает ТОЛЬКО размещение (страховка -/// ContainerAccepts/route — rules.py board_accepts L251–268); сам матчинг положительных критериев -/// (MatchText/Hits) exclude не видит, как в прототипе. -/// public static class ColumnExclusions { /// - /// Какие слова-исключения колонки есть в тексте (rules.py excluded_terms L230–244). + /// Какие слова-исключения колонки есть в тексте. /// /// Правила колонки (null → пустой список). /// Текст сообщения. @@ -42,7 +35,7 @@ public static class ColumnExclusions } /// - /// Есть ли в тексте слово-исключение колонки (rules.py is_excluded L247–248). + /// Есть ли в тексте слово-исключение колонки. /// /// Правила колонки. /// Текст сообщения. diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/ColumnMatcher.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/ColumnMatcher.cs index 09a3aec..94187be 100644 --- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/ColumnMatcher.cs +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/ColumnMatcher.cs @@ -3,26 +3,16 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Kanban.Application.ColumnRules; /// -/// Матчинг текста сообщения по правилам колонки: направление/слова/стек/грейд/бюджет (rules.py match_text L176–209, -/// score_text L212–227, has_active_rules L322–338). +/// Матчинг текста сообщения по правилам колонки /// -/// -/// Колонка — набор опциональных фильтров; пустая группа не участвует. Режим all — совпасть должны -/// ВСЕ включённые группы, any — хотя бы одна (пустые группы не считаются совпавшими «сами по себе», -/// иначе колонка с одним фильтром ловила бы всё). Все термины ищутся подстрокой в тексте, приведённом к -/// нижнему регистру и очищенном от ссылок (); бюджет -/// проверяется по суммам исходного текста через + . -/// Исключения (exclude) здесь НЕ участвуют — это вето размещения (ColumnExclusions/ColumnRules.ContainerAccepts). -/// public static class ColumnMatcher { /// - /// Соответствует ли текст правилам колонки (rules.py match_text L176–209). + /// Соответствует ли текст правилам колонки. /// /// Правила колонки (null/пустые → False: пустая колонка не матчит). /// Текст сообщения/карточки. - /// Курсы для конвертации бюджета (см. ); null — если бюджет в - /// другой валюте, суммы не конвертируются и группа бюджета не совпадает. + /// Курсы для конвертации бюджета (см. ); null — если бюджет в другой валюте, суммы не конвертируются и группа бюджета не совпадает. /// True — текст прошёл правила в режиме all/any. public static bool MatchText( ContainerRulesDto? rules, @@ -93,13 +83,8 @@ public static class ColumnMatcher } /// - /// Число совпавших ТЕРМОВ правил (для выбора лучшей колонки; rules.py score_text L212–227). + /// Число совпавших ТЕРМОВ правил. /// - /// - /// В отличие от групп MatchText, здесь считается каждый совпавший терм: direction/keywords/stack — по - /// терму, grade — по каждому расширенному синониму (тег с двумя найденными алиасами даёт +2). Бюджет и - /// exclude в счёт не входят (как в прототипе — score нужен для сравнения колонок с совпавшими словами). - /// /// Правила колонки. /// Текст сообщения. /// Суммарный балл (0 — текст пуст или совпадений нет). @@ -144,13 +129,8 @@ public static class ColumnMatcher } /// - /// Есть ли в правилах хотя бы одна реально работающая группа фильтров (rules.py has_active_rules L322–338). + /// Есть ли в правилах хотя бы одна реально работающая группа фильтров. /// - /// - /// Нужно для ML и страховки: колонка с активными правилами раскладывается только самими правилами - /// (детерминированно); колонка без активных правил принимает любой текст. «Бюджет» активен только при - /// заданной границе (from/to), одна валюта без границ активной группой не считается. - /// /// Правила колонки (null → False). /// True — есть непустой терм группы или бюджет с границей. public static bool HasActiveRules(ContainerRulesDto? rules) @@ -212,7 +192,6 @@ public static class ColumnMatcher return count; } - // Приводит термы группы к нижнему регистру и убирает пустые (прототип match_text L185–188). // terms: Термы как сохранены. // Возвращает: Непустые термы в нижнем регистре. private static IReadOnlyList NormalizeTerms(IReadOnlyList? terms) diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/ColumnRules.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/ColumnRules.cs index ac3f20c..1799518 100644 --- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/ColumnRules.cs +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/ColumnRules.cs @@ -3,31 +3,17 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Kanban.Application.ColumnRules; /// -/// Чистые правила колонок — единая точка входа модуля (Ruling 2, Task 3; rules.py board_accepts L251–268, -/// hits_for_board L311–319, has_active_rules L322–338, describe L341–368). +/// Чистые правила колонок — единая точка входа модуля. /// -/// -/// Все функции — чистые (без EF/HTTP/хранилища): правила передаются объектом , -/// текст — строкой, курсы для бюджетной конвертации — словарём (загрузка кэша ratesCache за вызывающим, -/// Ruling 7). Соответствие прототипу: = hits_for_board (для колонок без активных -/// правил и null-правил — пустой список), = board_accepts (вето исключений → -/// колонка без активных правил принимает любой текст → иначе MatchText), = describe. -/// Для служебных колонок (inbox/archive/trash, Ruling 2) правила не вычисляются: сервис карточек -/// вызывает ComputeHits только для колонок-досок (правила null/отсутствуют → []). -/// public static class ColumnRules { /// - /// Пропускает ли колонка этот текст (страховка «ИИ/ML не кладут в отфильтрованную колонку», - /// rules.py board_accepts L251–268). + /// Пропускает ли колонка этот текст. /// /// Правила колонки; null = «правил нет» (принимает любой текст). /// Текст сообщения/карточки. /// Курсы для конвертации бюджета (см. ). - /// - /// False — сработало слово-исключение (veto) либо текст не прошёл активные правила; - /// True — правил нет/неактивны или текст прошёл правила. - /// + /// False — сработало слово-исключение (veto) либо текст не прошёл активные правила; True — правил нет/неактивны или текст прошёл правила. public static bool ContainerAccepts( ContainerRulesDto? rules, string? text, @@ -47,7 +33,7 @@ public static class ColumnRules } /// - /// Совпал ли текст с правилами колонки (обёртка над ). + /// Совпал ли текст с правилами колонки /// /// Правила колонки. /// Текст сообщения. @@ -62,7 +48,7 @@ public static class ColumnRules } /// - /// Есть ли в правилах активная группа фильтров (rules.py has_active_rules L322–338). + /// Есть ли в правилах активная группа фильтров. /// /// Правила колонки. /// True — правила реально фильтруют (см. ). @@ -72,15 +58,12 @@ public static class ColumnRules } /// - /// Совпавшие критерии правил для размещения карточки в колонку-доску (rules.py hits_for_board L311–319). + /// Совпавшие критерии правил для размещения карточки в колонку-доску. /// /// Правила колонки-доски; null — доски нет/правил нет. - /// Текст карточки для правил (source_msg или title — выбор за вызывающим, leads.py L167). + /// Текст карточки для правил. /// Курсы для конвертации бюджета. - /// - /// Список совпавших критериев (MatchHitDto label/term/word); для пустых/null правил и колонок без - /// активных правил — [] (Ruling 2). Исключения (veto) в список не входят (прототип hits L271–296). - /// + /// Список совпавших критериев (MatchHitDto label/term/word); для пустых/null правил и колонок без активных правил — []. Исключения (veto) в список не входят. public static IReadOnlyList ComputeHits( ContainerRulesDto? rules, string? text, @@ -95,7 +78,7 @@ public static class ColumnRules } /// - /// Русская расшифровка правил для UI/note (обёртка над ). + /// Русская расшифровка правил для UI/note /// /// Правила колонки. /// «все условия · стек: …» или «без правил (решает ИИ/ML)». diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/ContentNormalizer.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/ContentNormalizer.cs index 149986a..d03219c 100644 --- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/ContentNormalizer.cs +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/ContentNormalizer.cs @@ -3,25 +3,16 @@ using System.Text.RegularExpressions; namespace Deal.Modules.Kanban.Application.ColumnRules; /// -/// Нормализация текста сообщения перед матчингом правил колонок (rules.py content_text L51–54). +/// Нормализация текста сообщения перед матчингом правил колонок. /// -/// -/// Вырезает markdown-ссылки [текст](url) и голые URL: иначе фильтр ловит слова из трекерных -/// хвостов и служебных строк адресов (например, «desktop» в utm_medium=member_desktop) — в колонку -/// попадает мусор, не имеющий отношения к содержанию сообщения. Нормализатор вызывается внутри -/// matcher/hits/exclude-функций (как в прототипе) — вызывающий код передаёт «сырой» текст карточки. -/// Регистр не меняется: lower-преобразование выполняют функции сравнения (прототип content_text L51–54). -/// public static class ContentNormalizer { - // Markdown-ссылка [текст](url) (правила прототипа _MD_URL_RE L47). private static readonly Regex MarkdownLink = new(@"\[[^\]]*\]\([^)\s]+\)"); - // Голый URL http(s)://… или www.… (правила прототипа _RAW_URL_RE L48). private static readonly Regex RawUrl = new(@"https?://[^\s<>""']+|www\.[^\s<>""']+"); /// - /// Возвращает текст с вырезанными ссылками (markdown- и голыми), прототип content_text L51–54. + /// Возвращает текст с вырезанными ссылками /// /// Исходный текст (null → пустая строка). /// Текст, где на месте ссылок — пробелы (слова ссылок не участвуют в поиске). diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/GradeAliases.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/GradeAliases.cs index f3ac567..4ec8bb2 100644 --- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/GradeAliases.cs +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/GradeAliases.cs @@ -1,17 +1,10 @@ namespace Deal.Modules.Kanban.Application.ColumnRules; /// -/// Синонимы грейдов/уровней правил колонки (rules.py _GRADE_ALIASES L20–27, _grade_terms L30–40). +/// Синонимы грейдов/уровней правил колонки. /// -/// -/// Тег из правил расширяется синонимами, чтобы «middle» находил и «mid», и «мидл», а «сеньор» — -/// senior и т.п. Неизвестный тег участвует как есть (в нижнем регистре) — это позволяет хранить в -/// группе grade произвольные слова (например, «middle+» ищется буквально). Списки — 1:1 с прототипом; -/// используются ColumnMatcher (группа grade) и MatchHitBuilder (word-совпадение грейда). -/// public static class GradeAliases { - // Карта «тег → синонимы» в нижнем регистре (rules.py L20–27). private static readonly IReadOnlyDictionary Aliases = new Dictionary { ["junior"] = new[] { "junior", "джун", "джуниор" }, @@ -23,13 +16,10 @@ public static class GradeAliases }; /// - /// Расширяет теги правил синонимами (прототип _grade_terms L30–40). + /// Расширяет теги правил синонимами. /// /// Теги группы grade (как сохранил пользователь/фронт). - /// - /// Список термов для поиска: для известного тега — его синонимы, для неизвестного — сам тег в - /// нижнем регистре; пустые теги пропускаются. - /// + /// Список термов для поиска: для известного тега — его синонимы, для неизвестного — сам тег в нижнем регистре; пустые теги пропускаются. public static IReadOnlyList ExpandTerms(IEnumerable? tags) { var result = new List(); diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/MatchHitBuilder.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/MatchHitBuilder.cs index 89f5396..7d694e5 100644 --- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/MatchHitBuilder.cs +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/MatchHitBuilder.cs @@ -3,19 +3,10 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Kanban.Application.ColumnRules; /// -/// Список совпавших критериев правил — «почему карточка в колонке» (rules.py hits L271–296, _budget_label L299–308). +/// Список совпавших критериев правил — «почему карточка в колонке». /// -/// -/// Для каждой группы правил колонки находит совпавшие термины и возвращает их в формате MatchHitDto -/// (label/term/word?): direction → «Направление», keywords → «Слова», stack → «Стек», grade → «Грейд/уровень» -/// (term — тег как сохранён, word — фактически найденный синоним), budget → «Бюджет» (term — человекочитаемый -/// диапазон «от X до Y CUR»). Ключевые слова ищутся по всему тексту сообщения (включая стек, требования и -/// «будет плюсом»). Грейд — только первый найденный синоним тега; exclude в hits не входит (это вето -/// размещения), пустые правила → пустой список. -/// public static class MatchHitBuilder { - // Метка группы «направление» (как в UI и прототипе). private const string DirectionLabel = "Направление"; // Метка группы «ключевые слова». @@ -43,13 +34,12 @@ public static class MatchHitBuilder private const string PricesLabel = "Цена"; /// - /// Совпавшие критерии фильтра колонки (rules.py hits L271–296). + /// Совпавшие критерии фильтра колонки. /// /// Правила колонки (null → пустой список). /// Текст сообщения. /// Курсы для конвертации бюджета (см. ). - /// Список совпадений по группам; порядок — direction → keywords → stack → grade → levels → - /// locations → types → budget → prices. + /// Список совпадений по группам; порядок — direction → keywords → stack → grade → levels → locations → types → budget → prices. public static IReadOnlyList BuildHits( ContainerRulesDto? rules, string? text, @@ -69,14 +59,12 @@ public static class MatchHitBuilder result.AddRange(MatchedTermHits(rules.Stack, StackLabel, lower)); result.AddRange(MatchedTermHits(rules.Locations, LocationsLabel, lower)); - // Грейд и уровень: по тегу — первый найденный синоним (rules.py L288–292). AddAliasHits(result, rules.Grade, GradeAliases.ExpandTerms, GradeLabel, lower); AddAliasHits(result, rules.Levels, GradeAliases.ExpandTerms, LevelsLabel, lower); // Тип: синонимы «vacancy/freelance/announcement» (TypeAliases). AddAliasHits(result, rules.Types, TypeAliases.ExpandTerms, TypesLabel, lower); - // Бюджет/цена: один hit с человекочитаемым диапазоном, если сумма в границах (rules.py L293–295). AddRangeHit(result, rules.Budget, BudgetLabel, text, rates); AddRangeHit(result, rules.Prices, PricesLabel, text, rates); @@ -142,7 +130,6 @@ public static class MatchHitBuilder } } - // Совпавшие термины текстовой группы (direction/keywords/stack) — прототип L283–287. // rawTerms: Термы как сохранены. // label: Метка группы. // lower: Текст в нижнем регистре. @@ -167,7 +154,6 @@ public static class MatchHitBuilder } } - // Человекочитаемый диапазон бюджета для term-а hit'а (rules.py _budget_label L299–308). // budget: Бюджет правил. // Возвращает: «от X до Y CUR» / «до X CUR» / «от X CUR» / «бюджет». private static string DescribeRange(BudgetRangeDto budget) @@ -191,7 +177,6 @@ public static class MatchHitBuilder return "бюджет"; } - // Формат числа как python %g (до 6 значащих цифр, без «.0»): «1000», «1500.5». // value: Число. // Возвращает: Строка по инвариантной культуре. private static string FormatAmount(double value) diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/RulesDescriber.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/RulesDescriber.cs index 71b3135..93dd4be 100644 --- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/RulesDescriber.cs +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/RulesDescriber.cs @@ -4,17 +4,12 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Kanban.Application.ColumnRules; /// -/// Человекочитаемое описание правил колонки для note/подсказки (rules.py describe L341–368). +/// Человекочитаемое описание правил колонки для note/подсказки. /// -/// -/// Формат 1:1 с прототипом: «все условия · направление: …; стек: …; слова: …; грейд: …; исключено: …; -/// бюджет: …» (режим any → «любое из условий · …»). Списки обрезаются (direction ≤6, остальные ≤8), бюджет -/// печатается границами «lo–hi CUR» с удалением пробелов у тире. Правил нет → «без правил (решает ИИ/ML)». -/// public static class RulesDescriber { /// - /// Описывает правила колонки (rules.py describe L341–368). + /// Описывает правила колонки. /// /// Правила колонки (null/пустые → «без правил (решает ИИ/ML)»). /// Строка-расшифровка для UI/промпта. @@ -80,19 +75,15 @@ public static class RulesDescriber return mode + " · " + string.Join("; ", parts); } - // Текст колонки без правил (прототип L366). private const string NoRulesText = "без правил (решает ИИ/ML)"; - // Префикс режима «все условия» (прототип L367). private const string AllModePrefix = "все условия"; // Префикс режима «любое из условий». private const string AnyModePrefix = "любое из условий"; - // Лимит термов направления в описании (прототип L350: direction[:6]). private const int DirectionLimit = 6; - // Лимит термов остальных групп (прототип L352–359: stack/kw/grade/exclude[:8]). private const int TermsLimit = 8; // Добавляет в описание диапазон группы (бюджет/цена), если задана хотя бы одна граница. @@ -115,10 +106,8 @@ public static class RulesDescriber parts.Add($"{title}: {lo}–{hi} {cur}".Replace("– ", "–").Replace(" –", "–")); } - // Соединяет первые N термов группы через «, » (прототип describe). // terms: Термы группы. // limit: Максимум термов. - // Возвращает: Строка «t1, t2, …». private static string JoinLimited(IReadOnlyList terms, int limit) { int count = Math.Min(terms.Count, limit); diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/TermListExtensions.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/TermListExtensions.cs index 908b580..b53ec76 100644 --- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/TermListExtensions.cs +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/TermListExtensions.cs @@ -6,7 +6,7 @@ namespace Deal.Modules.Kanban.Application.ColumnRules; internal static class TermListExtensions { /// - /// Есть ли в списке непустой (после trim) терм. + /// Есть ли в списке непустой /// /// Список термов группы (может быть null). /// True — хотя бы один терм непустой. diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/TypeAliases.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/TypeAliases.cs index d6772e1..3d082ad 100644 --- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/TypeAliases.cs +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/TypeAliases.cs @@ -1,14 +1,8 @@ namespace Deal.Modules.Kanban.Application.ColumnRules; /// -/// Синонимы типов заявки для группы types правил колонки и глобальных исключений (§6.3). +/// Синонимы типов заявки для группы types правил колонки и глобальных исключений /// -/// -/// Тег из правил расширяется синонимами, чтобы «vacancy» находил «вакансия»/«найм», «freelance» — -/// «заказ»/«проект», «announcement» — «объявление». Неизвестный тег участвует как есть (в нижнем -/// регистре) — можно хранить произвольные слова («срочно»). Используется -/// (группа types) и MatchHitBuilder (word-совпадение типа). -/// public static class TypeAliases { // Карта «тег типа → синонимы» в нижнем регистре. @@ -23,10 +17,7 @@ public static class TypeAliases /// Расширяет теги типов синонимами. /// /// Теги группы types (как сохранил пользователь/фронт). - /// - /// Список термов для поиска: для известного тега — его синонимы, для неизвестного — сам тег в - /// нижнем регистре; пустые теги пропускаются. - /// + /// Список термов для поиска: для известного тега — его синонимы, для неизвестного — сам тег в нижнем регистре; пустые теги пропускаются. public static IReadOnlyList ExpandTerms(IEnumerable? tags) { var result = new List(); diff --git a/src/core/Deal.Modules.Kanban/Application/Extensions/CharExtensions.cs b/src/core/Deal.Modules.Kanban/Application/Extensions/CharExtensions.cs index ad16e23..c0807bb 100644 --- a/src/core/Deal.Modules.Kanban/Application/Extensions/CharExtensions.cs +++ b/src/core/Deal.Modules.Kanban/Application/Extensions/CharExtensions.cs @@ -6,7 +6,7 @@ namespace Deal.Modules.Kanban.Application.Extensions; internal static class CharExtensions { /// - /// Буква кода валюты: латиница A–Z или кириллица А–Я (regex прототипа [^A-ZА-Я], ai.py L286). + /// Буква кода валюты /// /// Символ (строка уже в верхнем регистре). /// True — буква, участвующая в распознавании валюты. diff --git a/src/core/Deal.Modules.Kanban/Application/Models/AddCommentResultDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/AddCommentResultDto.cs index cb5d61b..8725def 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/AddCommentResultDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/AddCommentResultDto.cs @@ -1,15 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Результат добавления комментария — CardsService.AddCommentAsync (add_comment L259–265, dashboard_routes L238–242). +/// Результат добавления комментария — CardsService.AddCommentAsync. /// -/// -/// Три исхода для эндпоинта (Task 8): — текст 400 «Пустой комментарий»; -/// == null при == null — карточки нет (эндпоинт отвечает 404 -/// «Карточка не найдена»); иначе — полный список комментариев карточки после -/// добавления (ответ {comments: [...]} — фронт затирает массив карточки ответом, store.js addComment -/// L924–933). Автор нового комментария — «Вы» (by), текст — после Trim, метка времени — «только что». -/// /// Текст 400 (пустой комментарий) либо null. /// Список комментариев после добавления либо null (400/карточки нет). public sealed record AddCommentResultDto(string? Error, IReadOnlyList? Comments); diff --git a/src/core/Deal.Modules.Kanban/Application/Models/AiMarkupExampleDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/AiMarkupExampleDto.cs index 507336a..815f2e8 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/AiMarkupExampleDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/AiMarkupExampleDto.cs @@ -1,16 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Пример разметки пользователя для few-shot ИИ-классификации — результат ICardStore.GetAiMarkupExamplesAsync -/// (pipeline.py _learning_examples L201–215). +/// Пример разметки пользователя для few-shot ИИ-классификации — результат ICardStore.GetAiMarkupExamplesAsync. /// -/// -/// Источник — журнал CardMoves (learning_log прототипа): действия move/restore над карточками с исходным -/// текстом (Cards.SourceMsg), кроме переносов в служебные колонки trash/archive; свежие первыми (ORDER BY -/// created_at DESC), максимум режется до 500 кодовых точек вызывающим -/// (контекст-билдер классификации). Форма «текст → колонка» 1:1 с python: примеры подставляются в -/// user-контекст Classify как «текст: … → колонка: …». -/// /// Исходный текст карточки (source_msg сообщения-источника). /// Колонка-назначение переноса (id доски, не служебная). public sealed record AiMarkupExampleDto(string Text, string Board); diff --git a/src/core/Deal.Modules.Kanban/Application/Models/BudgetRangeDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/BudgetRangeDto.cs index 6b8316d..15b7c00 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/BudgetRangeDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/BudgetRangeDto.cs @@ -1,11 +1,6 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Бюджетный диапазон в правилах доски — поле budget объекта rules (§4.2 L274). +/// Бюджетный диапазон в правилах доски — поле budget объекта rules. /// -/// -/// Границы могут отсутствовать по отдельности («до X» → from=null, «от X» → to=null), валюта — код -/// (USD/RUB/…) либо символ исходного сообщения. Наружу сериализуется в camelCase: from/to/cur. -/// Используется правилами колонок (Task 3, BudgetInRange) — конвертация валюты при сравнении. -/// public sealed record BudgetRangeDto(double? From, double? To, string Cur); diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardBudgetDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardBudgetDto.cs index 6b98ad5..ca105b9 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardBudgetDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardBudgetDto.cs @@ -1,12 +1,6 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Бюджет карточки — поля budget/converted карточки (§4.1 L240–241, pipeline.py lead_to_dict L565–574). +/// Бюджет карточки — поля budget/converted карточки. /// -/// -/// budget — бюджет как в исходном сообщении (валюта — BudgetCur карточки), converted — -/// пересчитанный в целевую валюту (Ruling 7, ConversionRecomputer). Пустой BudgetCur («суммы нет») -/// представляется null-объектом budget на карточке. Границы могут отсутствовать по отдельности. -/// Наружу сериализуется в camelCase: from/to/cur. -/// public sealed record CardBudgetDto(double? From, double? To, string Cur); diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardChannelDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardChannelDto.cs index e0dfc3f..bf5d381 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardChannelDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardChannelDto.cs @@ -1,12 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Канал-источник карточки — объект ch (§4.1 L244, pipeline.py L577). +/// Канал-источник карточки — объект ch. /// -/// -/// Имя/хендл/цвет канала или диалога, из которого пришло исходное сообщение. Наружу сериализуется -/// в camelCase под ключом ch (JsonPropertyName — свойство называется Channel): ch.name/ch.handle/ch.hue. -/// public sealed record CardChannelDto( string Name, string Handle, diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardColumnCountDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardColumnCountDto.cs index 6286873..57c7728 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardColumnCountDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardColumnCountDto.cs @@ -1,10 +1,6 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Счётчик колонки в ответе counts — значение словаря «col → {count, new}» (§4.1 L257, leads.py counts L268–279). +/// Счётчик колонки в ответе counts — значение словаря «col → {count, new}». /// -/// -/// Строка GROUP BY по Cards (col, is_new): count — всего карточек в колонке, new — из них «новых». -/// Наружу сериализуется в camelCase: {count, new}. -/// public sealed record CardColumnCountDto(int Count, int New); diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardColumnUpdateDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardColumnUpdateDto.cs index 1a5ad29..1bde66c 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardColumnUpdateDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardColumnUpdateDto.cs @@ -1,16 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Перенос карточки в другую колонку/состояние — параметр ICardStore.UpdateColumnAsync (leads.py _move L163–174, restore L204–222). +/// Перенос карточки в другую колонку/состояние — параметр ICardStore.UpdateColumnAsync. /// -/// -/// Единая операция для move/trash/restore/автоархива: обновляет col, is_new, prev_col, archived_at, -/// match_hits у карточки. : null — НЕ менять (автоархив тика не трогает prev_col, -/// Ruling 8); иначе записывается значение (при move — прежняя колонка, при restore — «inbox», Ruling 10). -/// : значение пишется как есть, null — обнуляется (возврат из архива/корзины). -/// всегда записывается целиком: для inbox/archive/trash и досок без правил — -/// пустой список (Ruling 2), пересчёт — в модуле (ColumnRules, Task 3) до вызова. -/// public sealed record CardColumnUpdateDto( string CardId, string Col, diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardCommentDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardCommentDto.cs index 882f409..5b8b5a5 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardCommentDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardCommentDto.cs @@ -1,14 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Комментарий карточки — элемент массива comments (§4.1 L252, lead_to_dict). +/// Комментарий карточки — элемент массива comments. /// -/// -/// Нормализация комментария таблицы LeadComments: by — автор («Вы» — свои комментарии), -/// time — человеческая метка, вычисляемая от CreatedAt при маппинге (Ruling 10, как -/// time карточки). Сразу после добавления комментарий отдаётся с меткой «только что» -/// (leads.py add_comment L259–265). Наружу сериализуется в camelCase: id/by/text/time. -/// public sealed record CardCommentDto( string Id, string By, diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardContactDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardContactDto.cs index 9ded067..2028a30 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardContactDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardContactDto.cs @@ -1,12 +1,6 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Контакт карточки — элемент массива contacts (§4.1 L243). +/// Контакт карточки — элемент массива contacts. /// -/// -/// Квалифицированный контакт (прототип qualify_contact/build_contacts): type — -/// tg|phone|whatsapp|email|linkedin|site|other, value — нормализованное значение. Строковый -/// contact карточки — «быстрый» контакт = value основного (primary_contact, pipeline.py L424–430). -/// Наружу сериализуется в camelCase: type/value. -/// public sealed record CardContactDto(string Type, string Value); diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardCountsDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardCountsDto.cs index 84a7e8f..c4ee02a 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardCountsDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardCountsDto.cs @@ -1,16 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Ответ GET /api/cards/counts (§4.1 L257, leads.py counts L268–279). +/// Ответ GET /api/cards/counts. /// -/// -/// — сумма «новых» по всем колонкам (top-level new); — -/// счётчики по каждой колонке с карточками: ключ — col (inbox/archive/trash/b_...). learning/ml/ai -/// наполняет CardsService из IMlClient.StatusAsync (Task 5) — фронт читает именно их (store.js L586–592). -/// Сериализация: new/learning/ml/ai — фиксированные поля, per-колоночные счётчики живут в словаре -/// (плоская wire-форма «{new, <col>: {…}, learning, ml, ai}» собирается на уровне эндпоинта/сервиса -/// из этих частей — см. Task 7/8). -/// public sealed record CardCountsDto { /// @@ -19,23 +11,23 @@ public sealed record CardCountsDto public int New { get; init; } /// - /// Счётчики колонок: col → {count, new} (только колонки с карточками). + /// Счётчики колонок /// public IReadOnlyDictionary Columns { get; init; } = new Dictionary(); /// - /// Число обучающих действий (записей CardMoves), счётчик learning. + /// Число обучающих действий /// public int Learning { get; init; } /// - /// Обработано ML-решений (KV mlDecisions; на этапе 3 — 0, Ruling 4). + /// Обработано ML-решений. /// public int Ml { get; init; } /// - /// Обработано ИИ-решений (KV aiDecisions; на этапе 3 — 0, Ruling 4). + /// Обработано ИИ-решений. /// public int Ai { get; init; } } diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardDto.cs index 36f5598..b90a2e9 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardDto.cs @@ -3,39 +3,32 @@ using System.Text.Json.Serialization; namespace Deal.Modules.Kanban.Application.Models; /// -/// Карточка — единая сущность всех дашбордов: элемент GET /api/cards, ответ мутаций и payload SSE new_card. +/// Карточка — единая сущность всех дашбордов /// -/// -/// Единая сущность карточки (этап 9): карточка — один агрегат. Поля wire: id/containerId/col/ -/// isNew/local/title/summary/source/sourceMsg/sourceDialogId/sourceMsgId/stack/budget/converted/contact/ -/// contacts/channel/matchHits/comments/links/files/history/tzText/reminder/prevCol/isVacancy/isVacancyKnown/ -/// time/receivedAt/createdAt/updatedAt. — human-метка от ReceivedAt; времена — epoch-ms. -/// — внутреннее имя колонки карточки, наружу отдаётся и как алиас . -/// public sealed record CardDto { /// - /// Короткий id карточки (префикс c_). + /// Короткий id карточки /// public string Id { get; init; } = string.Empty; /// - /// Колонка карточки (служебная зона/стадия/доска b_...). + /// Колонка карточки /// public string Col { get; init; } = string.Empty; /// - /// Id контейнера (алиас ; каноничное имя wire). + /// Id контейнера /// public string ContainerId => Col; /// - /// Флаг «новое» (точка на карточке; снимается mark-seen/переносом). + /// Флаг «новое» /// public bool IsNew { get; init; } /// - /// Признак «карточка создана локально» (без внешнего первоисточника). + /// Признак «карточка создана локально» /// public bool Local { get; init; } @@ -50,27 +43,27 @@ public sealed record CardDto public bool IsVacancyKnown { get; init; } /// - /// Заголовок карточки (очищенный, до 140 символов). + /// Заголовок карточки /// public string Title { get; init; } = string.Empty; /// - /// Блок «О заявке» (до 2000 символов). + /// Блок «О заявке» /// public string Summary { get; init; } = string.Empty; /// - /// Источник (производная проекция): kind/displayName/originRef/receivedAt. + /// Источник (производная проекция) /// public CardSourceDto Source { get; init; } = new(string.Empty, string.Empty, string.Empty, 0); /// - /// Стек/направления (JSON-массив строк). + /// Стек/направления /// public IReadOnlyList Stack { get; init; } = Array.Empty(); /// - /// Бюджет по исходному сообщению; null — сумма не указана (BudgetCur пуст). + /// Бюджет по исходному сообщению; null — сумма не указана /// public CardBudgetDto? Budget { get; init; } @@ -80,7 +73,7 @@ public sealed record CardDto public CardBudgetDto? Converted { get; init; } /// - /// «Быстрый» контакт: значение основного контакта. + /// «Быстрый» контакт /// public string Contact { get; init; } = string.Empty; @@ -90,28 +83,28 @@ public sealed record CardDto public IReadOnlyList Contacts { get; init; } = Array.Empty(); /// - /// Канал-источник (name/handle/hue). + /// Канал-источник /// public CardChannelDto Channel { get; init; } = new(string.Empty, string.Empty, string.Empty); /// - /// Человеческая метка возраста: «только что»/«N мин»/«N ч»/«N дн». + /// Человеческая метка возраста /// public string Time { get; init; } = string.Empty; /// - /// Время получения исходного сообщения, epoch-ms (выходит под ключом receivedAt). + /// Время получения исходного сообщения, epoch-ms /// [property: JsonPropertyName("receivedAt")] public long ReceivedAtMs { get; init; } /// - /// Исходное сообщение (для переобучения ML и поиска, до 4000 символов). + /// Исходное сообщение /// public string SourceMsg { get; init; } = string.Empty; /// - /// Id диалога исходного сообщения (для «открыть исходник»). + /// Id диалога исходного сообщения /// public string SourceDialogId { get; init; } = string.Empty; @@ -121,7 +114,7 @@ public sealed record CardDto public long? SourceMsgId { get; init; } /// - /// Предыдущая колонка (для возврата из архива/корзины). + /// Предыдущая колонка /// public string PrevCol { get; init; } = string.Empty; @@ -131,7 +124,7 @@ public sealed record CardDto public IReadOnlyList MatchHits { get; init; } = Array.Empty(); /// - /// Комментарии карточки (таблица LeadComments). + /// Комментарии карточки /// public IReadOnlyList Comments { get; init; } = Array.Empty(); @@ -161,13 +154,13 @@ public sealed record CardDto public CardReminderDto? Reminder { get; init; } /// - /// Время создания, epoch-ms (выходит под ключом createdAt). + /// Время создания, epoch-ms /// [property: JsonPropertyName("createdAt")] public long CreatedAtMs { get; init; } /// - /// Время последнего изменения, epoch-ms (выходит под ключом updatedAt). + /// Время последнего изменения, epoch-ms /// [property: JsonPropertyName("updatedAt")] public long UpdatedAtMs { get; init; } diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardFileDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardFileDto.cs index a03d1ff..2406e5a 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardFileDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardFileDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Файл карточки — элемент массива files (этап 9, T6; форма 1:1 с прежней проектной карточкой). +/// Файл карточки — элемент массива files. /// /// Короткий id записи файла (pf_...). /// Имя файла. diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardFileKind.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardFileKind.cs index b1b1a06..9932b79 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardFileKind.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardFileKind.cs @@ -3,13 +3,8 @@ using Deal.Modules.Kanban.Application.Services; namespace Deal.Modules.Kanban.Application.Models; /// -/// Результат — kind/label вложения (1:1 files.py detect L31–45). +/// Результат — kind/label вложения. /// -/// -/// kind — категория файла для wire-поля CardFileDto.kind и иконок фронта; label — человекочитаемая -/// метка (wire-поле CardFileDto.label). Значения 1:1 с прототипом (files.py KIND_BY_EXT/KIND_LABELS): -/// image/video/audio/archive/document/other и «Изображение»/«Видео»/«Аудио»/«Архив»/«Документ»/«Файл». -/// /// Категория файла: image|video|audio|archive|document|other. /// Человекочитаемая метка («Изображение», «Документ», «Файл» …). public sealed record CardFileKind( diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardHistoryDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardHistoryDto.cs index c15df66..9069e66 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardHistoryDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardHistoryDto.cs @@ -3,12 +3,8 @@ using System.Text.Json.Serialization; namespace Deal.Modules.Kanban.Application.Models; /// -/// Запись истории движения карточки — элемент массива history (этап 9, T6). +/// Запись истории движения карточки — элемент массива history. /// -/// -/// Ровно один из ключей type/stage: второй опускается сериализацией. Type — создание -/// (created/createdLocal), Stage — новая стадия при переносе. At — epoch-ms. -/// /// Короткий id записи (h_...). /// Время события, epoch-ms. /// Тип создания; null у move-записей. diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardLinkDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardLinkDto.cs index 2f3d7bb..5deec82 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardLinkDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardLinkDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Ссылка карточки — элемент массива links (этап 9, T6; форма 1:1 с прежней проектной карточкой). +/// Ссылка карточки — элемент массива links. /// /// Короткий id ссылки (pl_...). /// Название ссылки. diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardLocalCreateDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardLocalCreateDto.cs index c70dbd9..bee98d7 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardLocalCreateDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardLocalCreateDto.cs @@ -1,21 +1,15 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Начальные поля ручного («локального») создания карточки — тело POST /api/cards (Ruling 6). +/// Начальные поля ручного /// -/// -/// Wire-форма тела: {title, summary, stack?, budget?, contact, tzText?, containerId?/stage?} — подмножество -/// плюс . Дефолты — как в прототипе (pydantic): пустые -/// строки; отсутствующие stack/containerId — null (стек пуст, контейнер — planned). Наружу — camelCase. -/// -/// Заголовок карточки (при создании Trim(); пустой допустим — 1:1 прототип). +/// Заголовок карточки (при создании Trim; пустой допустим). /// Краткое содержание карточки. /// Стек/направления (null — пусто). /// Бюджет (from/to/cur); null — бюджета нет. /// Контактная строка карточки. /// Текст технического задания. -/// Желаемый контейнер-стадия (id каталога ); -/// null или неизвестная — карточка создаётся в planned (Ruling 6). +/// Желаемый контейнер-стадия (id каталога ); null или неизвестная — карточка создаётся в planned. public sealed record CardLocalCreateDto( string Title = "", string Summary = "", diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardMoveDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardMoveDto.cs index 082b3d9..95ea67b 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardMoveDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardMoveDto.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Запись журнала действий над карточкой — параметр ICardStore.AddMoveAsync (таблица CardMoves, leads.py _log_learning L40–44). +/// Запись журнала действий над карточкой — параметр ICardStore.AddMoveAsync. /// -/// -/// Каждое действие пользователя (move/trash/restore/comment) пишет строку журнала; счётчик learning = -/// число записей CardMoves (Ruling 4, Task 5). Id (lm_...) генерирует модуль (Ruling 12); -/// CreatedAt проставляет хранилище (UTC-now). Журнал живёт дольше карточки — FK нет (Ruling 1). -/// public sealed record CardMoveDto( string Id, string LeadId, diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardPatch.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardPatch.cs index 16fa605..1207524 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardPatch.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardPatch.cs @@ -1,15 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Частичная правка карточки (ICardStore.PatchCardAsync; 1:1 patch_card projects.py L159–187). +/// Частичная правка карточки. /// -/// -/// Значение null у поля означает «поле не меняется» (конвенция ContainerPatchDto); пустая строка или -/// пустой массив — осмысленное значение и применяется. JSON-поля (stack/comments/links/files) заменяются -/// ЦЕЛИКОМ, не сливаясь с текущими значениями. budget — объект {from,to,cur} либо null = «не менять»; -/// явная очистка бюджета телом PATCH передаётся объектом с пустой Cur (BudgetCur пуст = «бюджета нет»; -/// хранилище пишет from/to=null и cur="", наружу бюджет снова null). Наружу сериализуется в camelCase. -/// /// Новый заголовок. /// Новое краткое содержание. /// Новая контактная строка. diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardReclassificationDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardReclassificationDto.cs index e3284ca..8ece0c3 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardReclassificationDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardReclassificationDto.cs @@ -3,17 +3,9 @@ namespace Deal.Modules.Kanban.Application.Models; /// /// Результат ручной переклассификации карточки — параметр ICardStore.ApplyReclassificationAsync. /// -/// -/// Одно обновление полей классификации (leads.py reclassify_lead L346–367): колонка, тип (найм/заказ), -/// заголовок/суть/стек, бюджет и его конверсия, контакты и matchHits. Пишется как есть (полная замена): -/// бюджет без валюты ( = null) очищает поля, как и отсутствие конверсии. -/// на ИИ-пути = true (тип подтверждён по контексту), на локальном — маркерная -/// гипотеза. PrevCol/ArchivedAt не трогаются — переклассификация не меняет историю возврата. -/// JSON-поля (Stack/Contacts/MatchHits) сериализует адаптер при записи. -/// /// Id карточки (c_...). /// Новая колонка (доска либо inbox — страховку ContainerAccepts уже применил вызывающий). -/// Флаг «новое» (переклассификация подсвечивает карточку — python is_new = TRUE). +/// Флаг «новое». /// Признак найма/заказа после переклассификации. /// Тип подтверждён классификатором (иначе — маркерная гипотеза). /// Новый заголовок (очищенный, ≤140). diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardReminderDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardReminderDto.cs index 8d22938..fc09455 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardReminderDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardReminderDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Напоминание карточки — объект reminder (этап 9, T6). +/// Напоминание карточки — объект reminder. /// /// Время напоминания, epoch-ms. public sealed record CardReminderDto(long At); diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardReminderDueDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardReminderDueDto.cs index 43e04da..d061661 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardReminderDueDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardReminderDueDto.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Строка «выстрелившего» напоминания — мини-DTO выборки ICardStore.ListDueRemindersAsync (Ruling 3). +/// Строка «выстрелившего» напоминания — мини-DTO выборки ICardStore.ListDueRemindersAsync. /// -/// -/// Возвращается проверкой напоминаний (CardsService.CheckDueRemindersAsync), публикуется в SSE-событии -/// reminder_due ({id,title,containerId}, api-map §2) и в ответе POST /api/admin/tick (reminders). -/// id — карточки, containerId всегда hold на момент срабатывания. Сериализуется в camelCase. -/// /// Id карточки (c_...). /// Заголовок карточки (для уведомления). /// Контейнер карточки (всегда hold на момент срабатывания). diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardResultDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardResultDto.cs index 256c303..136ec0a 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardResultDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardResultDto.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Результат мутации карточки, отвечающей карточкой: тонкий record-результат (перенос, ссылки, напоминания). +/// Результат мутации карточки, отвечающей карточкой /// -/// -/// непуст — 400-текст фиксированной строки; == null при Error == null — -/// карточки нет (404-семантику даёт null, эндпоинт отвечает «Карточка не найдена»); Card непуст — успех. -/// Единая форма результатов мутаций карточки (перенос, ссылки, напоминания). -/// /// Текст 400 (фиксированная строка) либо null. /// Карточка после мутации либо null (400/карточки нет). public sealed record CardResultDto(string? Error, CardDto? Card); diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardSnapshot.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardSnapshot.cs index 0c77047..0fb3f75 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardSnapshot.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardSnapshot.cs @@ -1,34 +1,27 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// «Сырая» запись для создания карточки (ICardStore.AddCardAsync) — пайплайн и ручное создание. +/// «Сырая» запись для создания карточки /// -/// -/// Write-модель: содержит полное состояние новой карточки (псевдоним строки Cards, Ruling 1), включая -/// готовый id (c_..., генерирует модуль) и служебные значения, вычисленные до записи: -/// matchHits (Ruling 2), conv-поля (BudgetNormalizer), prevCol=inbox, историю/локальный признак. -/// CreatedAt проставляет хранилище (UTC-now), а human-метку time/маппинг — слой чтения (CardDto). -/// JSON-массивы (Stack/Contacts/MatchHits/History) сериализует адаптер при записи. -/// public sealed record CardSnapshot { /// - /// Готовый id карточки (префикс c_), сгенерированный модулем. + /// Готовый id карточки /// public string Id { get; init; } = string.Empty; /// - /// Контейнер размещения: inbox (пайплайн) либо стадия/доска (ручное создание). + /// Контейнер размещения /// public string Col { get; init; } = string.Empty; /// - /// Новая карточка (точка «новое»); при ручном создании — false. + /// Новая карточка /// public bool IsNew { get; init; } = true; /// - /// Признак «карточка создана локально» (без внешнего первоисточника). + /// Признак «карточка создана локально» /// public bool Local { get; init; } @@ -38,17 +31,17 @@ public sealed record CardSnapshot public bool IsVacancy { get; init; } /// - /// Тип подтверждён ИИ (на этапе 3/демо — как в исходных данных). + /// Тип подтверждён ИИ. /// public bool IsVacancyKnown { get; init; } /// - /// Заголовок карточки (очищенный, ≤140). + /// Заголовок карточки /// public string Title { get; init; } = string.Empty; /// - /// Блок «О заявке» (очищенный, ≤2000). + /// Блок «О заявке» /// public string Summary { get; init; } = string.Empty; @@ -58,12 +51,12 @@ public sealed record CardSnapshot public IReadOnlyList Stack { get; init; } = Array.Empty(); /// - /// Нижняя граница бюджета (валюта — ), либо null. + /// Нижняя граница бюджета /// public double? BudgetFrom { get; init; } /// - /// Верхняя граница бюджета (валюта — ), либо null. + /// Верхняя граница бюджета /// public double? BudgetTo { get; init; } @@ -73,12 +66,12 @@ public sealed record CardSnapshot public string BudgetCur { get; init; } = string.Empty; /// - /// Сконвертированная нижняя граница (целевая валюта — ), либо null. + /// Сконвертированная нижняя граница /// public double? ConvFrom { get; init; } /// - /// Сконвертированная верхняя граница (целевая валюта — ), либо null. + /// Сконвертированная верхняя граница /// public double? ConvTo { get; init; } @@ -88,7 +81,7 @@ public sealed record CardSnapshot public string ConvCur { get; init; } = string.Empty; /// - /// «Быстрый» контакт (value основного контакта, primary_contact). + /// «Быстрый» контакт /// public string Contact { get; init; } = string.Empty; @@ -108,17 +101,17 @@ public sealed record CardSnapshot public string ChannelHandle { get; init; } = string.Empty; /// - /// Цвет канала-источника (hex). + /// Цвет канала-источника /// public string ChannelHue { get; init; } = string.Empty; /// - /// Время получения исходного сообщения (сортировка DESC, автоархив). + /// Время получения исходного сообщения /// public DateTimeOffset ReceivedAt { get; init; } /// - /// Исходное сообщение (≤4000; для ML-обучения и поиска). + /// Исходное сообщение /// public string SourceMsg { get; init; } = string.Empty; @@ -133,32 +126,32 @@ public sealed record CardSnapshot public long? SourceMsgId { get; init; } /// - /// Предыдущая колонка (при создании — inbox). + /// Предыдущая колонка /// public string PrevCol { get; init; } = string.Empty; /// - /// Время помещения в архив (для правил очистки), либо null. + /// Время помещения в архив /// public DateTimeOffset? ArchivedAt { get; init; } /// - /// Совпавшие критерии правил (для inbox/досок без правил — пусто). + /// Совпавшие критерии правил /// public IReadOnlyList MatchHits { get; init; } = Array.Empty(); /// - /// Текст технического задания (при создании может быть пустым). + /// Текст технического задания /// public string TzText { get; init; } = string.Empty; /// - /// Стартовые комментарии карточки (ручное создание — пусто; пишутся в таблицу LeadComments). + /// Стартовые комментарии карточки /// public IReadOnlyList Comments { get; init; } = Array.Empty(); /// - /// История движения: при ручном создании — одна запись (createdLocal); пайплайн — пусто. + /// История движения /// public IReadOnlyList History { get; init; } = Array.Empty(); } diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardSourceDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardSourceDto.cs index 5332e61..97f171e 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardSourceDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardSourceDto.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Источник карточки на wire — объект source карточки (этап 9, T6). +/// Источник карточки на wire — объект source карточки. /// -/// -/// Производная проекция от строки карточки (точная полиморфная иерархия ISource — домен): -/// kind — вид источника, displayName — имя канала/источника, originRef — ссылка на первоисточник -/// (id диалога/файла), receivedAt — момент получения (epoch-ms). -/// /// Вид источника: local/telegram/web/file/row/api/ai/composite/other. /// Имя источника (канал/диалог). /// Ссылка на первоисточник (id диалога/файла). diff --git a/src/core/Deal.Modules.Kanban/Application/Models/CardsQuery.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardsQuery.cs index 4073120..b4ebee9 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/CardsQuery.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardsQuery.cs @@ -1,11 +1,6 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Запрос списка карточек — параметр ICardStore.ListCardsAsync (фильтр по колонке, leads.py list_leads L151–156). +/// Запрос списка карточек — параметр ICardStore.ListCardsAsync. /// -/// -/// — null означает «все колонки дашборда» (без контейнеров-стадий «Выбранных»); -/// иначе — карточки одной колонки. Сортировка всегда received_at DESC (задача адаптера). -/// Валидацию значения колонки (inbox/archive/trash/существующая доска) выполняет сервис до вызова. -/// public sealed record CardsQuery(string? Col); diff --git a/src/core/Deal.Modules.Kanban/Application/Models/ClearColResultDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/ClearColResultDto.cs index 1884c82..9e25597 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/ClearColResultDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/ClearColResultDto.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Результат очистки служебной колонки — CardsService.ClearColAsync (clear_col L237–247, dashboard_routes L228–235). +/// Результат очистки служебной колонки — CardsService.ClearColAsync. /// -/// -/// Колонка не trash/archive → = текст 400 «Очищать можно только корзину или архив»; -/// иначе — сколько карточек удалено навсегда (0 — колонка пуста). Ответ эндпоинта -/// — {ok: true, cleared}; при ошибке — {detail} с текстом Error. -/// /// Текст 400 (колонка не служебная trash/archive) либо null. /// Число удалённых карточек (валидная колонка); 0 при ошибке. public sealed record ClearColResultDto(string? Error, int Cleared); diff --git a/src/core/Deal.Modules.Kanban/Application/Models/ColumnStateDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/ColumnStateDto.cs index 7bfe7eb..b3b1253 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/ColumnStateDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/ColumnStateDto.cs @@ -1,17 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Состояние одной колонки в KV-настройке colState — значение словаря «col → состояние» (leads.py L138–146). +/// Состояние одной колонки в KV-настройке colState — значение словаря «col → состояние». /// -/// -/// Wire-форма значения — JSON-объект `{"collapsed": bool}` (и опционально `{"width": "sm"|"md"|"lg"}`, -/// api-map §3.2 L76–77): в colState фронт хранит только свёрнутость служебных колонок -/// (inbox/archive/trash) — ширину/свёрнутость ДОСОК держат поля Boards (Ruling 10). null у поля — -/// «значение не задано»: при PATCH не меняет текущее, при записи не сериализуется -/// (DefaultIgnoreCondition.WhenWritingNull, как прототип model_dump(exclude_none=True)). -/// Неизвестные ключи внутри значения колонки типизированная модель не сохраняет (в реальных -/// потоках фронта их нет — колонки пишутся только этим PATCH). -/// /// Свёрнута ли колонка в виджет (null — не задано). /// Ширина колонки sm|md|lg (null — не задано). public sealed record ColumnStateDto(bool? Collapsed, string? Width); diff --git a/src/core/Deal.Modules.Kanban/Application/Models/ContainerCountsDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/ContainerCountsDto.cs index 4fd3f5d..c35321d 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/ContainerCountsDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/ContainerCountsDto.cs @@ -1,12 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Счётчики карточек контейнера — поле counts ответа /api/containers (этап 9, T4). +/// Счётчики карточек контейнера — поле counts ответа /api/containers. /// -/// -/// Вычисляется чтением: строка GROUP BY по Cards (container → count, new). В БД не хранится. -/// Наружу сериализуется в camelCase: {total, new}. -/// /// Всего карточек в контейнере. /// Из них «новых» (is_new = true). public sealed record ContainerCountsDto(int Total, int New); diff --git a/src/core/Deal.Modules.Kanban/Application/Models/ContainerCreateDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/ContainerCreateDto.cs index fcd260d..5768b09 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/ContainerCreateDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/ContainerCreateDto.cs @@ -1,15 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Вход создания контейнера — параметр ContainersService.CreateAsync (POST /api/containers). +/// Вход создания контейнера — параметр ContainersService.CreateAsync /// -/// -/// — обязательный (пробельное/пустое значение сервис заменяет на «Новая колонка»); -/// — null/пустая строка означают «взять цвет палитры по позиции». -/// / задают пространство и вид контейнера (по умолчанию — -/// пользовательская колонка дашборда board/dashboard). Правила — необязательны; -/// / использует эвристика ИИ-предложений. Wire — camelCase. -/// /// Имя колонки (пустое → «Новая колонка»). /// Описание колонки. /// Цвет (hex) либо null — из палитры. diff --git a/src/core/Deal.Modules.Kanban/Application/Models/ContainerDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/ContainerDto.cs index 6e3b150..5c4b43c 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/ContainerDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/ContainerDto.cs @@ -1,43 +1,37 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Единый контейнер карточек: колонка дашборда, стадия «Выбранных» или служебная зона. +/// Единый контейнер карточек /// -/// -/// Приходит на смену BoardDto: таблица Containers — единственный реестр колонок/стадий/зон (этап 9, T4). -/// Поля wire (camelCase): id/name/description/color/order/space/kind/collapsed/suggested/note/rules/policy/counts. -/// — null означает «правил нет»; описывает поведение зоны; -/// заполняется при чтении (счётчики карточек контейнера) и не хранится в БД. -/// public sealed record ContainerDto { /// - /// Короткий id контейнера (доски b_…, стадии planned…, служебные inbox/archive/trash). + /// Короткий id контейнера /// public string Id { get; init; } = string.Empty; /// - /// Имя для отображения («WPF», «В работе», «Архив»). + /// Имя для отображения /// public string Name { get; init; } = string.Empty; /// - /// Описание контейнера (пользователю и для подсказки ИИ/ML). + /// Описание контейнера /// public string Description { get; init; } = string.Empty; /// - /// Цвет контейнера (hex). + /// Цвет контейнера /// public string Color { get; init; } = string.Empty; /// - /// Позиция в пространстве (порядок показа; wire-имя — order). + /// Позиция в пространстве /// public int Order { get; init; } /// - /// Id пространства: dashboard | selected. + /// Id пространства /// public string Space { get; init; } = ContainerSpaces.Dashboard; @@ -47,32 +41,32 @@ public sealed record ContainerDto public string Kind { get; init; } = ContainerKinds.Board; /// - /// Свёрнутость колонки на дашборде (состояние UI). + /// Свёрнутость колонки на дашборде /// public bool Collapsed { get; init; } /// - /// Признак ИИ-предложения: контейнер ждёт решения пользователя. + /// Признак ИИ-предложения /// public bool Suggested { get; init; } /// - /// Заметка контейнера (например, сгенерированное обоснование ИИ). + /// Заметка контейнера /// public string Note { get; init; } = string.Empty; /// - /// Правила попадания карточки; null — фильтра нет (карточки кладутся вручную/ИИ). + /// Правила попадания карточки; null — фильтра нет /// public ContainerRulesDto? Rules { get; init; } /// - /// Политика контейнера: возврат, терминальность, автоочистка. + /// Политика контейнера /// public ContainerPolicyDto Policy { get; init; } = new(); /// - /// Счётчики карточек контейнера (total/new); заполняются чтением, в БД не хранятся. + /// Счётчики карточек контейнера /// public ContainerCountsDto Counts { get; init; } = new(0, 0); } diff --git a/src/core/Deal.Modules.Kanban/Application/Models/ContainerKinds.cs b/src/core/Deal.Modules.Kanban/Application/Models/ContainerKinds.cs index a5e0e3d..3af5d96 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/ContainerKinds.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/ContainerKinds.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Реестр видов контейнеров (этап 9, T4): роль колонки в пространстве. +/// Реестр видов контейнеров /// -/// -/// board — пользовательская колонка-фильтр (создаёт пользователь/ИИ); stage — стадия -/// «Выбранных»; service — служебная зона (inbox/archive/trash); terminal — терминальная -/// стадия (finished/rejected). Вид контейнера влияет на показ и допустимые переходы. -/// public static class ContainerKinds { /// @@ -21,12 +16,12 @@ public static class ContainerKinds public const string Stage = "stage"; /// - /// Служебная зона (неразобранное/архив/корзина). + /// Служебная зона /// public const string Service = "service"; /// - /// Терминальная стадия (выполнено/отклонено). + /// Терминальная стадия /// public const string Terminal = "terminal"; } diff --git a/src/core/Deal.Modules.Kanban/Application/Models/ContainerPatchDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/ContainerPatchDto.cs index 806442f..b8edf36 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/ContainerPatchDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/ContainerPatchDto.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Патч контейнера — допустимые изменения PATCH /api/containers/{id} (этап 9, T4). +/// Патч контейнера — допустимые изменения PATCH /api/containers/{id}. /// -/// -/// Значение null у поля означает «поле не меняется» (в PATCH-теле отсутствует либо явно null). -/// JSON-объекты rules/policy заменяются целиком, не сливаясь с текущими. -/// Наружу и из wire сериализуется в camelCase. -/// /// Новое имя (null — не менять). /// Новое описание (null — не менять). /// Новый цвет (null — не менять). diff --git a/src/core/Deal.Modules.Kanban/Application/Models/ContainerPolicyDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/ContainerPolicyDto.cs index 8525ee5..41d1f9e 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/ContainerPolicyDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/ContainerPolicyDto.cs @@ -1,22 +1,17 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Политика контейнера: правила жизненного цикла зоны (этап 9, T4). +/// Политика контейнера /// -/// -/// Роль контейнера (не enum): можно ли вернуть карточку на доску пространства, терминальна ли зона, -/// задана ли автоочистка. Хранится JSON-объектом в Containers.PolicyJson (camelCase); значения по -/// умолчанию — «обычная колонка» (возврат разрешён, не терминальна, без автоочистки). -/// public sealed record ContainerPolicyDto { /// - /// Можно ли вернуть карточку из контейнера на доску пространства (архив/корзина — да). + /// Можно ли вернуть карточку из контейнера на доску пространства /// public bool CanRestore { get; init; } = true; /// - /// Терминальная зона: завершение жизненного пути, только ручная очистка без возврата. + /// Терминальная зона /// public bool IsTerminal { get; init; } diff --git a/src/core/Deal.Modules.Kanban/Application/Models/ContainerRulesDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/ContainerRulesDto.cs index a296789..0921f71 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/ContainerRulesDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/ContainerRulesDto.cs @@ -1,18 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Правила фильтра колонки — объект rules доски (§4.2 L270–275, §6.3, BoardRulesDialog.vue). +/// Правила фильтра колонки — объект rules доски. /// -/// -/// Форма совпадает с формой диалога правил фронта: mode — «all» (все группы обязательны) либо -/// «any» (любая из групп); direction/keywords/stack/grade/levels/locations/types/exclude — группы термов; -/// budget/prices — опциональные диапазоны. Правила маршрутизируют входящие карточки (Ruling 2, Task 3). -/// Пустые списки и отсутствующие ключи в JSON (хранится как text, формат {} — нет правил) приводит -/// к маппингу. Наружу сериализуется в camelCase: -/// mode/direction/keywords/stack/grade/exclude/budget + levels/locations/types/prices (этап 12, §6.3). -/// Новые группы добавлены в конец с дефолтами — существующие позиционные вызовы и сохранённый RulesJson -/// обратно совместимы. -/// public sealed record ContainerRulesDto( string Mode, IReadOnlyList Direction, diff --git a/src/core/Deal.Modules.Kanban/Application/Models/ContainerSpaces.cs b/src/core/Deal.Modules.Kanban/Application/Models/ContainerSpaces.cs index 16403e1..d7adbf6 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/ContainerSpaces.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/ContainerSpaces.cs @@ -1,22 +1,17 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Реестр пространств контейнеров (этап 9, T4): где показывается карточка. +/// Реестр пространств контейнеров /// -/// -/// Пространство — свойство контейнера, не карточки. Дашборд (dashboard) — служебные зоны и -/// пользовательские колонки; «Выбранные» (selected) — каталог стадий. Карточка живёт в одном -/// пространстве: её контейнер однозначно определяет вид. -/// public static class ContainerSpaces { /// - /// Пространство дашборда (служебные зоны и пользовательские колонки-доски). + /// Пространство дашборда /// public const string Dashboard = "dashboard"; /// - /// Пространство «Выбранные» (стадии работы над карточкой). + /// Пространство «Выбранные» /// public const string Selected = "selected"; } diff --git a/src/core/Deal.Modules.Kanban/Application/Models/KanbanColumns.cs b/src/core/Deal.Modules.Kanban/Application/Models/KanbanColumns.cs index 34f7868..d96dca3 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/KanbanColumns.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/KanbanColumns.cs @@ -1,28 +1,22 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Реестр служебных (не-доски) колонок канбана (Ruling 1, constants.py SERVICE_COLS). +/// Реестр служебных /// -/// -/// Значения — фиксированные строки JSON/колонки Cards.Col: inbox|archive|trash. -/// Доски (b_...) в реестр не входят: их id хранятся в таблице Containers, а существование -/// колонки-доски приложение валидирует по хранилищу (KanbanStore.GetContainerAsync). -/// Прототип: backend/app/constants.py — SERVICE_COLS; api-map §4.4 L304. -/// public static class KanbanColumns { /// - /// «Неразобранное» — приёмная колонка: карточки без доски и канбан-возвраты. + /// «Неразобранное» — приёмная колонка /// public const string Inbox = "inbox"; /// - /// Архив: карточки, ушедшие по правилам хранения (тик) или вручную. + /// Архив: карточки, ушедшие по правилам хранения /// public const string Archive = "archive"; /// - /// Корзина: удалённые пользователем карточки (до очистки по сроку). + /// Корзина: удалённые пользователем карточки /// public const string Trash = "trash"; } diff --git a/src/core/Deal.Modules.Kanban/Application/Models/KanbanIdPrefixes.cs b/src/core/Deal.Modules.Kanban/Application/Models/KanbanIdPrefixes.cs index cdf884c..ca39cc2 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/KanbanIdPrefixes.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/KanbanIdPrefixes.cs @@ -3,55 +3,47 @@ using Deal.Modules.Cards.Application.Models; namespace Deal.Modules.Kanban.Application.Models; /// -/// Реестр префиксов коротких id модуля Kanban (Ruling 12, прототип store.uid). +/// Реестр префиксов коротких id модуля Kanban. /// -/// -/// Id-генерация — в модуле (не GUID: прототип и фронт требуют коротких ключей в JSON); -/// хранилище (Task 4) получает уже готовые id и только сохраняет их. -/// Префиксы соответствуют владельцам данных карточки: Contеinerы=b_, Cards=c_, -/// LeadComments=cm_, CardMoves=lm_, MlOutbox=mle_; элементы JSON-массивов -/// карточки — ссылки pl_, файлы pf_, записи истории h_. -/// Генератор (PrefixId + случайный hex) добавляется задачей 7. -/// public static class KanbanIdPrefixes { /// - /// Префикс id контейнера-доски (kind=board единого реестра Containers). + /// Префикс id контейнера-доски /// public const string Board = "b_"; /// - /// Префикс id карточки (единый для всех дашбордов; таблица Cards). + /// Префикс id карточки /// public const string Card = CardIds.CardPrefix; /// - /// Префикс id комментария карточки (таблица LeadComments). + /// Префикс id комментария карточки /// public const string Comment = "cm_"; /// - /// Префикс id записи журнала действий (таблица CardMoves = learning_log). + /// Префикс id записи журнала действий /// public const string CardMove = "lm_"; /// - /// Префикс id ссылки карточки (элемент массива links). + /// Префикс id ссылки карточки /// public const string Link = "pl_"; /// - /// Префикс id файла карточки (элемент массива files). + /// Префикс id файла карточки /// public const string File = "pf_"; /// - /// Префикс id записи истории движения (элемент массива history; projects.py store.uid("h_")). + /// Префикс id записи истории движения /// public const string History = "h_"; /// - /// Префикс id строки очереди обучения (таблица MlOutbox). + /// Префикс id строки очереди обучения /// public const string MlOutbox = "mle_"; } diff --git a/src/core/Deal.Modules.Kanban/Application/Models/MatchHitDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/MatchHitDto.cs index 093d171..21c67c2 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/MatchHitDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/MatchHitDto.cs @@ -1,14 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Совпадение критерия правил — элемент массива matchHits (§4.1 L251, rules.py hits L271–296). +/// Совпадение критерия правил — элемент массива matchHits. /// -/// -/// «Почему карточка в колонке»: label — группа критерия («Направление»/«Слова»/«Стек»/«Грейд/уровень»/ -/// «Бюджет», Ruling 2), term — совпавший терм, word — опциональное слово (для грейдов/уровней). -/// Для служебных колонок (inbox/archive/trash) и досок без активных правил — пустой список. -/// Наружу сериализуется в camelCase: label/term/word. -/// public sealed record MatchHitDto( string Label, string Term, diff --git a/src/core/Deal.Modules.Kanban/Application/Models/MlOutboxEntryDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/MlOutboxEntryDto.cs index ed62bf2..e69b647 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/MlOutboxEntryDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/MlOutboxEntryDto.cs @@ -1,14 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Строка очереди обучения ML (таблица MlOutbox) — срез для выгрузки батча (план Task 16, Ruling 6). +/// Строка очереди обучения ML /// -/// -/// Возвращается хранилищем через в порядке -/// created_at (1:1 с выборкой python flush_outbox L66–69) и уходит в ml-service батчем -/// TrainBatch (поля text/label/delta — ровно колонки ml_outbox; id нужен вызывающему для удаления -/// строк только после успешной отправки). -/// /// Id строки очереди (mle_...). /// Текст обучающего примера (trim-нут, ≤6000 символов). /// Метка обучения: id доски, spam либо t:hire|t:order. diff --git a/src/core/Deal.Modules.Kanban/Application/Models/PrefixId.cs b/src/core/Deal.Modules.Kanban/Application/Models/PrefixId.cs index 619582d..9f2c305 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/PrefixId.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/PrefixId.cs @@ -3,14 +3,8 @@ using System.Security.Cryptography; namespace Deal.Modules.Kanban.Application.Models; /// -/// Генератор коротких префиксных id модуля Kanban (Ruling 12, прототип store.uid = prefix + uuid4().hex[:12]). +/// Генератор коротких префиксных id модуля Kanban /// -/// -/// Не GUID: прототип и фронт требуют коротких ключей в JSON (id досок/карточек попадают в wire как -/// ключи). Случайная часть — 12 hex-символов (6 байт CSPRNG), 1:1 с uuid4().hex[:12] прототипа. -/// Префиксы — (b_/c_/cm_/lm_/mle_/pl_/pf_/h_): модуль генерирует id и передаёт в -/// хранилище готовыми (порт id не создаёт). Использование: CardsService, эвристика suggest. -/// public static class PrefixId { // Размер случайной части в байтах: 6 байт → 12 hex-символов (uuid4().hex[:12]). diff --git a/src/core/Deal.Modules.Kanban/Application/Models/StorageTickStatsDto.cs b/src/core/Deal.Modules.Kanban/Application/Models/StorageTickStatsDto.cs index 6dbf25e..d2c36ee 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/StorageTickStatsDto.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/StorageTickStatsDto.cs @@ -1,14 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// Статистика тика правил хранения — поле storage ответа POST /api/admin/tick (Ruling 8, leads.py tick_storage L454–493). +/// Статистика тика правил хранения — поле storage ответа POST /api/admin/tick. /// -/// -/// archived — карточки, ушедшие в архив по autoArchive; purgedArchive — очищено из архива по сроку -/// (archiveClearDays); purgedTrash — очищено из корзины (trashClearDays); purgedRejected — отсев -/// пайплайна (этап 4; в этапе 3 всегда 0). Тексты SSE-тостов по статистике — 1:1 (Ruling 8). -/// Наружу сериализуется в camelCase: archived/purgedArchive/purgedTrash/purgedRejected. -/// public sealed record StorageTickStatsDto( int Archived, int PurgedArchive, diff --git a/src/core/Deal.Modules.Kanban/Application/Models/SuggestedColumnPlan.cs b/src/core/Deal.Modules.Kanban/Application/Models/SuggestedColumnPlan.cs index 9d80d90..4c0fd03 100644 --- a/src/core/Deal.Modules.Kanban/Application/Models/SuggestedColumnPlan.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/SuggestedColumnPlan.cs @@ -1,18 +1,8 @@ namespace Deal.Modules.Kanban.Application.Models; /// -/// План одной колонки-предложения — элемент выхода SuggestHeuristics (Ruling 3, Task 14). +/// План одной колонки-предложения — элемент выхода SuggestHeuristics. /// -/// -/// Чистая структура модуля Kanban: тема-слово в нижнем регистре (), готовое имя -/// колонки ( — Word с заглавной буквы), id карточек «Неразобранного», собранных -/// в группу (≥2, в порядке выдачи окна анализа), и note-обоснование ( — -/// «Эвристика (этап 3): …; реальные предложения ИИ — этап 6»). Адаптер -/// LocalColumnSuggester (Infrastructure) превращает план в доску suggested=true -/// (RulesJson {mode:"any", keywords:[Word]}) и раскладывает карточки. Правила с одним ключевым словом -/// достаточны: группа собирается именно по этому слову, и оно же маршрутизирует будущие входящие -/// (после принятия колонки пользователем). -/// /// Тема-слово группы (нижний регистр; станет keywords-правилом колонки). /// Имя колонки-предложения (Word с заглавной буквы, ≤40 символов). /// Id карточек группы (в порядке выдачи окна, ≥2, без пересечений между планами). diff --git a/src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs b/src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs index e6072c4..35f65ab 100644 --- a/src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs +++ b/src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs @@ -5,15 +5,8 @@ using Microsoft.Extensions.DependencyInjection; namespace Deal.Modules.Kanban.Application.Registrars; /// -/// DI-регистрация модуля Kanban. Паттерн «port & adapter» (Ruling 12). +/// DI-регистрация модуля Kanban. /// -/// -/// Регистрируются только сервисы модуля. Порт-адаптеры (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 { /// @@ -21,20 +14,12 @@ public static class KanbanModuleRegistrar /// /// Коллекция сервисов. /// Коллекция сервисов для цепочки вызовов. - /// - /// Здесь появляются 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 386fd92..1acba93 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/BudgetNormalizer.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/BudgetNormalizer.cs @@ -5,24 +5,12 @@ 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", @@ -34,14 +22,10 @@ public static class BudgetNormalizer }; /// - /// Нормализует бюджет к форме хранения (ai.py clean_budget L316–326). + /// Нормализует бюджет к форме хранения. /// /// Бюджет из ИИ/локального разбора (null — бюджета нет). - /// - /// Нормализованный бюджет CardBudgetDto(from/to/cur) либо null: бюджет отсутствует, валюта не - /// распознана или обе границы отсутствуют/равны нулю (чистый dict → None в прототипе). - /// from=0 трактуется как отсутствие нижней границы; to без from у «одной суммы» приравнивается к from. - /// + /// Нормализованный бюджет CardBudgetDto(from/to/cur) либо null: бюджет отсутствует, валюта не распознана или обе границы отсутствуют/равны нулю. from=0 трактуется как отсутствие нижней границы; to без from у «одной суммы» приравнивается к from. public static CardBudgetDto? Normalize(BudgetRangeDto? budget) { if (budget is null) @@ -71,17 +55,13 @@ public static class BudgetNormalizer } /// - /// Пересчёт бюджета в целевую валюту «один раз при поступлении» (ai.py budget_to_target L342–352). + /// Пересчёт бюджета в целевую валюту «один раз при поступлении». /// /// Нормализованный бюджет (валюта — код, см. ). - /// Настройка conversionOn (Ruling 7); false → конверсия снята. - /// Настройка targetCurrency (пустая → RUB, как в прототипе). + /// Настройка conversionOn; false → конверсия снята. + /// Настройка targetCurrency. /// Курсы к рублю «код → курс» (кэш ratesCache либо мок-курсы). - /// - /// Сконвертированный бюджет CardBudgetDto(convFrom, convTo, targetCurrency), либо null — конверсия - /// выключена/бюджета нет/валюта не задана (в прототипе это convCur=""). При валюте, отсутствующей в - /// курсах, границы null, но целевая валюта сохраняется (как budget_to_target L351–357). - /// + /// Сконвертированный бюджет CardBudgetDto(convFrom, convTo, targetCurrency), либо null — конверсия выключена/бюджета нет/валюта не задана. При валюте, отсутствующей в курсах, границы null, но целевая валюта сохраняется. public static CardBudgetDto? ToTarget( CardBudgetDto? budget, bool conversionOn, @@ -120,7 +100,6 @@ public static class BudgetNormalizer 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) @@ -133,7 +112,6 @@ public static class BudgetNormalizer return value.Value; } - // Приводит название/символ валюты из ИИ к коду (ai.py _norm_currency L279–291). // raw: Валюта как пришла («рублей», «$», «usd», …). // Возвращает: Код валюты (USD/RUB/…) или null, если не распознана. private static string? NormalizeCurrency(string? raw) 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 00e9d7d..5726d13 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs @@ -4,20 +4,10 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Kanban.Application.Services; /// -/// Файлы карточки — partial-часть (этап 9: тот же домен карточки): -/// оркестрация файлового хранилища и метаданных FilesJson (files.py L57–94). +/// Файлы карточки — partial-часть /// -/// -/// Вложения живут в двух местах — объект файла в файловом хранилище (порт ) и -/// метаданные JSON-массивом FilesJson карточки (запись {id,name,size,kind,label,objectKey}). -/// add_file — объект сохраняется первым, затем метаданные дописываются атомарным jsonb-append; get_file_entry — -/// запись по id для download; remove_file — объект удаляется из хранилища (при непустом objectKey), запись -/// убирается из массива атомарной jsonb-фильтрацией. 404-семантика — null-результатом методов. Id записей — -/// PrefixId с префиксом pf_; objectKey строит сервис. -/// public sealed partial class CardsService { - // Первый сегмент objectKey — каталог вложений карточек (object_store.py L65: «projects/{card}/{ms}_{name}»). private const string ObjectRootSegment = "projects"; // Замена недопустимых символов имени в objectKey (path-разделители/кавычки заменяются). @@ -26,25 +16,16 @@ public sealed partial class CardsService // Символы имени, заменяемые в objectKey: path-разделители («/», «\») и кавычки «"». private const string KeyNameUnsafeCharacters = "/\\\""; - // Имя файла по умолчанию при пустом имени из multipart (прототип: «f.filename or "file"»). private const string DefaultAttachmentName = "file"; /// - /// Добавляет файл карточке: объект в хранилище + метаданные в конец массива files (files.py add_file L57–75). + /// Добавляет файл карточке /// - /// - /// Порядок 1:1 с прототипом: карточки нет → null (404) ДО записи объекта. Kind — через - /// по contentType и расширению; id записи (pf_) генерируется ДО - /// ключа и входит в него: objectKey = projects/{cardId}/{fileId}_{unixMs}_{safeName} — две загрузки в одну - /// миллисекунду не перезапишут объект. Имя в ключе санитизируется, в метаданных остаётся как прислано. - /// Карточка исчезла между чтением и записью — объект удаляется (сирота не нужен) и возвращается null (404). - /// /// Id карточки (c_...). /// Имя файла как прислано (в objectKey санитизируется); пустое → «file». /// MIME-тип загрузки (может быть null/пустым — детект по расширению). /// Поток содержимого файла (читается хранилищем с позиции 0). /// Длина содержимого в байтах (пишется в метаданные записи). - /// Токен отмены. /// Метаданные добавленного файла или null — карточки нет (404). public async Task AddFileAsync( string cardId, @@ -88,15 +69,10 @@ public sealed partial class CardsService } /// - /// Запись файла по id для download-эндпоинта (files.py get_file_entry L78–83). + /// Запись файла по id для download-эндпоинта. /// - /// - /// Возвращает ТОЛЬКО метаданные (включая objectKey/name): поток объекта для ответа резолвит эндпоинт через - /// . Карточки нет или записи с таким id нет → null (оба случая — 404). - /// /// Id карточки (c_...). /// Id записи файла (pf_...). - /// Токен отмены. /// Метаданные записи файла либо null (карточка/запись не найдены). public async Task GetFileEntryAsync( string cardId, @@ -113,16 +89,10 @@ public sealed partial class CardsService } /// - /// Удаляет файл карточки: объект из хранилища + запись из массива files (files.py remove_file L86–94). + /// Удаляет файл карточки /// - /// - /// Карточки нет → null (404). Объект удаляется только когда запись найдена и у неё непустой objectKey. - /// Запись убирается из FilesJson атомарной jsonb-фильтрацией; записи с указанным id нет — список остаётся - /// прежним, ошибки НЕТ. - /// /// Id карточки (c_...). /// Id удаляемой записи файла (pf_...). - /// Токен отмены. /// Карточка после удаления (без записи) либо null — карточки нет (404-семантика). public async Task RemoveFileAsync( string cardId, @@ -150,7 +120,6 @@ public sealed partial class CardsService ?? throw new InvalidOperationException("Карточка не прочиталась после удаления файла: " + cardId); } - // Строит objectKey файла: projects/{cardId}/{fileId}_{unixMs}_{safeName} (object_store.py put L65). // cardId: Id карточки (c_...). // fileId: Id записи файла (pf_...; уникальный суффикс ключа). // name: Имя файла как прислано (в ключ идёт санитизированная часть). diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Helpers.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Helpers.cs index 7f6305f..14de2f4 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Helpers.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Helpers.cs @@ -9,27 +9,21 @@ using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRule namespace Deal.Modules.Kanban.Application.Services; /// -/// Приватные помощники — partial-часть (C32: выделено из общего файла, -/// поведение не менялось): текст обучающего примера, перенос колонки с журналом, hits правил доски и -/// колонка возврата (leads.py L163–174, L209, L311–319; Ruling 2/4). +/// Приватные помощники — partial-часть /// 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( @@ -49,7 +43,6 @@ public sealed partial class CardsService 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). @@ -70,7 +63,6 @@ public sealed partial class CardsService toCol), ct); } - // Совпавшие критерии правил доски (hits_for_board L311–319 через ColumnRules, Ruling 2). // boardId: Id доски (b_...). // text: Текст карточки для правил (source_msg или title). // ct: Токен отмены. @@ -88,7 +80,6 @@ public sealed partial class CardsService // Совпавшие критерии правил колонки-доски (ColumnRules.ComputeHits с курсами для бюджета). // Резолв имени — через алиас KanbanColumnRules: одноимённые класс и namespace ColumnRules в одном модуле. - // rules: Правила доски; null («правил нет») → пусто (Ruling 2). // text: Текст карточки для правил. // ct: Токен отмены. // Возвращает: Совпавшие критерии (label/term[/word]); нет активных правил → пусто. @@ -103,7 +94,6 @@ public sealed partial class CardsService return KanbanColumnRules.ComputeHits(rules, text, rates); } - // Колонка возврата карточки: prev_col, если inbox или существующая доска, иначе inbox (restore_lead L209). // prevCol: Сохранённая prev_col карточки. // ct: Токен отмены. // Возвращает: Колонка возврата (inbox/доска). 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 7b12b3f..a360fb9 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs @@ -6,19 +6,15 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Kanban.Application.Services; /// -/// Публичные операции карточек — partial-часть (C32: выделено из общего -/// файла по темам, поведение не менялось): чтение списка/карточки, переносы/корзина/возврат/удаление/ -/// очистка колонки, комментарии, mark-seen, счётчики и поиск (leads.py L151–279, L509–551). +/// Публичные операции карточек — partial-часть /// public sealed partial class CardsService { - // ── Чтение (list_leads/get_lead L151–160) ─────────────────────────────── /// - /// Карточки колонки или всех колонок дашборда, received_at DESC (list_leads L151–156). + /// Карточки колонки или всех колонок дашборда, received_at DESC. /// /// Колонка-фильтр (inbox/archive/trash/доска); null — все колонки дашборда. - /// Токен отмены. /// Полные карточки (маппинг/комментарии/time — адаптер); пусто — карточек нет. public Task> ListCardsAsync(string? col, CancellationToken ct) { @@ -26,35 +22,21 @@ public sealed partial class CardsService } /// - /// Одна карточка по id (get_lead L159–160; GET /api/cards/{id}). + /// Одна карточка по 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, @@ -77,8 +59,6 @@ public sealed partial class CardsService 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); @@ -104,15 +84,9 @@ public sealed partial class CardsService } /// - /// Перенос карточки в корзину (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) { @@ -120,16 +94,10 @@ public sealed partial class CardsService } /// - /// Перенос карточки в корзину с управлением обучением ML (trash_lead L194–201). + /// Перенос карточки в корзину с управлением обучением ML. /// - /// - /// = 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, @@ -158,16 +126,9 @@ public sealed partial class CardsService } /// - /// Возврат карточки из архива/корзины на канбан (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) { @@ -201,11 +162,9 @@ public sealed partial class CardsService } /// - /// Полное удаление карточки (delete_forever L225–234): Cards + комментарии (FK cascade), - /// журнал CardMoves/MlOutbox не трогаются (Ruling 10). + /// Полное удаление карточки /// /// Id карточки (c_...). - /// Токен отмены. /// False — карточки нет (404 «Карточка не найдена»); True — удалена. public async Task DeleteForeverAsync(string cardId, CancellationToken ct) { @@ -219,11 +178,9 @@ public sealed partial class CardsService } /// - /// Полная ручная очистка служебной колонки trash/archive (clear_col L237–247). + /// Полная ручная очистка служебной колонки trash/archive. /// - /// Другая колонка (inbox/доска/…) → 400 (как ValueError L239–240). /// Очищаемая колонка: trash | archive. - /// Токен отмены. /// Результат: Error (400) либо Cleared — сколько карточек удалено навсегда. public async Task ClearColAsync(string col, CancellationToken ct) { @@ -236,20 +193,12 @@ public sealed partial class CardsService 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, @@ -273,16 +222,12 @@ public sealed partial class CardsService 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, @@ -294,18 +239,10 @@ public sealed partial class CardsService 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) { @@ -321,21 +258,11 @@ public sealed partial class CardsService }; } - // ── Поиск (search L509–551, LIKE-вариант Ruling 6) ────────────────────── /// - /// Поиск карточек: FTS по Cards.SearchTsv + LIKE-дополнение (search L509–551, Ruling 6/Task 12). + /// Поиск карточек: FTS по Cards.SearchTsv + LIKE-дополнение. /// - /// - /// 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) { 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 a9a9de9..c871a8f 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Reminders.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Reminders.cs @@ -4,44 +4,25 @@ using Deal.Modules.Settings.Application.Models; namespace Deal.Modules.Kanban.Application.Services; /// -/// Напоминания «Отложено» — partial-часть (этап 9: тот же домен карточки): -/// set/clear/snooze/check (projects.py L236–282), Ruling 3. +/// Напоминания «Отложено» — partial-часть /// -/// -/// Семантика 1:1 с прототипом (Ruling 3): set проверяет выключатель (400-результат «Напоминания об отложенных -/// выключены в настройках») и НЕ проверяет ни стадию карточки (фронт шлёт напоминание только для hold), ни -/// время at (прошлое допустимо — «выстреливает» таким at на ближайшей проверке); clear и snooze выключатель НЕ -/// проверяют; snooze = now + 24 ч. CheckDueRemindersAsync (check_reminders L264–282): выключено → только -/// очистка протухших и пустой список; включено → due-строки hold-карточек помечаются fired и возвращаются -/// списком {id,title,containerId} — SSE-события по ним публикует Api-слой, не сервис. Порядок проверок set — -/// карточка раньше выключателя (404 раньше 400). -/// public sealed partial class CardsService { /// - /// 400 set: напоминания выключены в настройках (set_reminder projects.py L237–238; Ruling 3). + /// 400 set: напоминания выключены в настройках. /// public const string RemindersDisabledDetail = "Напоминания об отложенных выключены в настройках"; - // Ключ публичной настройки-выключателя напоминаний (Ruling 3). private const string RemindersEnabledKey = SettingsKeys.RemindersEnabled; - // Шаг «напомнить позже» (snooze): +24 часа в epoch-мс (snooze projects.py L257–261). private const long ReminderSnoozeMs = 86_400_000; /// - /// Устанавливает напоминание карточке (POST /api/cards/{cardId}/reminder; set_reminder L236–243). + /// Устанавливает напоминание карточке. /// - /// - /// Порядок 1:1 с прототипом: карточки нет → 404-результат ДО проверки выключателя; напоминания выключены → - /// 400 . Стадия карточки НЕ проверяется, at НЕ валидируется. Хранилище - /// пишет reminder_at + reminder_fired=false + bump UpdatedAt; ответ — полная карточка после записи. - /// /// Id карточки (c_...). /// Время напоминания, epoch-ms. - /// Токен отмены. - /// Результат: Error (400) | Card=null без Error (404) | - /// Card — карточка с напоминанием. + /// Результат: Error (400) | Card=null без Error (404) | Card — карточка с напоминанием. public async Task SetReminderAsync( string cardId, long atMs, @@ -65,14 +46,9 @@ public sealed partial class CardsService } /// - /// Снимает напоминание карточки (DELETE /api/cards/{cardId}/reminder; clear_reminder L246–247). + /// Снимает напоминание карточки. /// - /// - /// Выключатель НЕ проверяется. Карточки нет → false (404); напоминания у карточки нет — успех без изменений. - /// Хранилище пишет reminder_at=NULL + reminder_fired=false, UpdatedAt НЕ бампит. - /// /// Id карточки (c_...). - /// Токен отмены. /// True — карточка есть и напоминание снято; false — карточки нет (404). public async Task ClearReminderAsync(string cardId, CancellationToken ct) { @@ -87,14 +63,9 @@ public sealed partial class CardsService } /// - /// «Напомнить позже»: перенос напоминания на now + 24 ч (POST /api/cards/{cardId}/reminder/snooze). + /// «Напомнить позже» /// - /// - /// Выключатель НЕ проверяется (snooze зовётся из баннера reminder_due независимо от настройки). Карточки - /// нет → false (404). Хранилище пишет reminder_at=now+24ч, reminder_fired=false, UpdatedAt бампит. - /// /// Id карточки (c_...). - /// Токен отмены. /// True — карточка есть и напоминание отложено; false — карточки нет (404). public async Task SnoozeReminderAsync(string cardId, CancellationToken ct) { @@ -110,14 +81,8 @@ public sealed partial class CardsService } /// - /// Проверка наступивших напоминаний «Отложено» (check_reminders projects.py L264–282). + /// Проверка наступивших напоминаний «Отложено». /// - /// - /// Выключено → ТОЛЬКО очистка протухших напоминаний (reminder_at ≤ now, без учёта stage/fired) и пустой - /// результат; включено → строки stage='hold' AND reminder_at ≤ now AND reminder_fired=false помечаются - /// fired и возвращаются списком {id,title,containerId}. Публикацию SSE reminder_due выполняет Api-слой. - /// - /// Токен отмены. /// «Выстрелившие» напоминания (после пометки fired); пусто — сработавших нет/напоминания выключены. public async Task> CheckDueRemindersAsync(CancellationToken ct) { @@ -137,7 +102,6 @@ public sealed partial class CardsService return due; } - // Читает выключатель напоминаний «remindersEnabled» (типизированный снимок настроек, Ruling 3). // ct: Токен отмены. // Возвращает: Значение настройки; отсутствие/повреждённый JSON → дефолт SettingsDefaults.RemindersEnabled. private async Task ReadRemindersEnabledAsync(CancellationToken ct) 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 0618e6a..6030090 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs @@ -6,35 +6,28 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Kanban.Application.Services; /// -/// Операции пространства «Выбранные» — partial-часть (этап 9: тот же домен -/// карточки): ручное создание, патч полей тела, ссылки, перенос по контейнерам-стадиям с историей и сбросом -/// напоминания, «взять в работу», очистка «Отклонено» (projects.py L103–231). +/// Операции пространства «Выбранные» — partial-часть /// public sealed partial class CardsService { /// - /// 400 перенос по стадии: стадии нет в каталоге (move_stage projects.py L203–204). + /// 400 перенос по стадии /// public const string UnknownStageDetail = "Неизвестная стадия"; - // Стадия размещения новой карточки и fallback ручного создания (Rulings 5/6). private const string PlannedStage = CardsDefaultContainers.Planned; - // Очищаемая терминальная стадия (clear_stage projects.py L225). private const string RejectedStage = CardsDefaultContainers.Rejected; - // Текст комментария-«взял в работу» на карточке (take L149). private const string TakenCommentText = "Взял в работу."; /// - /// 400 ссылка: пустой url после Trim (projects_routes.py L137–138). + /// 400 ссылка: пустой url после Trim. /// public const string EmptyLinkDetail = "Пустая ссылка"; - // Схема по умолчанию ссылки, присланной без схемы (add_link L139–140: «https://» + url). private const string HttpsUrlScheme = "https://"; - // Схема http: ссылки с ней оставляются как есть (add_link L139 — проверка префикса http/https). private const string HttpUrlScheme = "http://"; // Ключ тела PATCH: заголовок (JSON-строка). @@ -55,14 +48,12 @@ public sealed partial class CardsService // Ключ тела PATCH: бюджет (JSON-объект {from,to,cur} либо null/не-объект — очистка). private const string PatchKeyBudget = "budget"; - // Представление «бюджета нет» для патча: пустая Cur = очистка (patch_card L174–179). private static readonly CardBudgetDto ClearedBudget = new(From: null, To: null, Cur: string.Empty); /// - /// Карточки пространства «Выбранные»: список ORDER BY updated_at DESC с фильтром контейнера-стадии. + /// Карточки пространства «Выбранные» /// /// Фильтр по контейнеру-стадии; null — все стадии «Выбранных». - /// Токен отмены. /// Полные карточки в порядке UpdatedAt DESC; пусто — карточек нет. public Task> ListSelectedCardsAsync(string? containerId, CancellationToken ct) { @@ -70,15 +61,9 @@ public sealed partial class CardsService } /// - /// Ручное создание «локальной» карточки без внешнего источника (POST /api/cards; create_local_card L103–124). + /// Ручное создание «локальной» карточки без внешнего источника. /// - /// - /// local=true («создано локально»); title — Trim(); контейнер — переданный, если есть в каталоге - /// , иначе planned; история — одна запись type="createdLocal". - /// Пустой заголовок допустим (фронт шлёт {title:''}, 1:1 прототип). - /// /// Начальные поля карточки (тело POST /api/cards, см. ). - /// Токен отмены. /// Созданная карточка (полное чтение после записи). public async Task CreateLocalCardAsync(CardLocalCreateDto draft, CancellationToken ct) { @@ -108,17 +93,9 @@ public sealed partial class CardsService } /// - /// «Взять в работу»: карточка переходит в контейнер-стадию planned, сохраняя свой id (POST - /// /api/cards/take; Rulings 4/5 этапа 9 — никакого клонирования во вторую сущность). + /// «Взять в работу» /// - /// - /// Поток: (1) карточка читается () — null → null-результат (404); - /// (2) если карточка уже в контейнере-стадии — возвращается без изменений (идемпотентность Ruling 5); - /// (3) иначе карточка переносится в стадию planned той же строкой (: - /// контейнер, запись истории, сброс напоминания), дописывается комментарий «Взял в работу.». - /// /// Id карточки (c_...). - /// Токен отмены. /// Карточка в стадии planned; null — карточки нет (404). public async Task TakeCardAsync(string cardId, CancellationToken ct) { @@ -130,7 +107,6 @@ public sealed partial class CardsService if (CardsDefaultContainers.Contains(card.Col)) { - // Карточка уже в пространстве «Выбранные» — повторный take идемпотентен (Ruling 5). return card; } @@ -150,19 +126,10 @@ public sealed partial class CardsService } /// - /// Точечная правка полей карточки по телу PATCH — presence-aware (PATCH /api/cards/{cardId}; - /// 1:1 с patch_card projects.py L159–187). + /// Точечная правка полей карточки по телу PATCH — presence-aware. /// - /// - /// Тело — произвольный JSON-объект: учитывается ПРИСУТСТВИЕ ключа, а не только значение, поэтому явный - /// null очищаемых полей не теряется типизированным биндингом. Патчатся ключи title/summary/contact/ - /// tzText (JSON-строка, пустая строка — очистка текста), stack (JSON-массив строк; null/не-массив → - /// пустой стек) и budget (JSON-объект {from,to,cur}; null/не-объект → очистка бюджета). Неизвестные - /// ключи игнорируются. Правки в историю НЕ пишутся (Ruling 7); хранилище бампает UpdatedAt. - /// /// Id карточки (c_...). /// Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется). - /// Токен отмены. /// Обновлённая карточка или null — карточки нет (404). public async Task PatchCardAsync( string cardId, @@ -176,18 +143,11 @@ public sealed partial class CardsService } /// - /// Добавляет ссылку карточке: append в JSON-массив links (POST /api/cards/{cardId}/links; add_link L133–143). + /// Добавляет ссылку карточке /// - /// - /// Порядок 1:1 с прототипом: карточки нет → 404-результат ДО валидации url. url Trim'ится; пустой → 400 - /// . Без схемы http:// или https:// → префикс https://. Новая запись: - /// {id pl_, name: name.Trim() или url — пустое имя → ссылка называется url, url}. Запись — атомарным - /// jsonb-append хранилища. - /// /// Id карточки (c_...). /// Название ссылки; пустое после Trim → name = url. /// URL ссылки (без схемы — добавится https://). - /// Токен отмены. /// Результат: Error (400 «Пустая ссылка») | Card=null без Error (404) | Card — карточка со ссылкой. public async Task AddLinkAsync( string cardId, @@ -229,15 +189,10 @@ public sealed partial class CardsService } /// - /// Удаляет ссылку карточки по id: фильтрация JSON-массива links (DELETE /api/cards/{cardId}/links/{linkId}). + /// Удаляет ссылку карточки по id /// - /// - /// Карточки нет → 404-результат. Ссылка с указанным id не найдена — список остаётся прежним, ошибки НЕТ. - /// Запись — атомарной jsonb-фильтрацией хранилища. - /// /// Id карточки (c_...). /// Id удаляемой ссылки (pl_...). - /// Токен отмены. /// Результат: Card=null без Error (404) | Card — карточка без ссылки. public async Task RemoveLinkAsync( string cardId, @@ -255,19 +210,11 @@ public sealed partial class CardsService } /// - /// Перенос карточки по контейнерам-стадиям «Выбранных»: запись истории + сброс напоминания - /// (1:1 move_stage projects.py L202–216, Rulings 3/7). + /// Перенос карточки по контейнерам-стадиям «Выбранных» /// - /// - /// Контейнер валидируется каталогом (400 «Неизвестная стадия»). - /// При успехе хранилище пишет одним UPDATE: col, reminder_at=NULL/reminder_fired=false, updated_at=время - /// переноса, history + запись {id h_, at, stage:<новый>}. - /// /// Id карточки (c_...). /// Новый контейнер-стадия — id каталога . - /// Токен отмены. - /// Результат: Error «Неизвестная стадия» (400) | Card=null без Error (карточки нет, 404) | - /// Card — карточка после переноса. + /// Результат: Error «Неизвестная стадия» (400) | Card=null без Error (карточки нет, 404) | Card — карточка после переноса. public async Task MoveStageCardAsync( string cardId, string containerId, @@ -292,9 +239,8 @@ public sealed partial class CardsService } /// - /// Полная ручная очистка терминальной стадии «Отклонено» — hard-delete строк (POST /api/cards/clear-rejected). + /// Полная ручная очистка терминальной стадии «Отклонено» — hard-delete строк /// - /// Токен отмены. /// Сколько карточек удалено (0 — стадия пуста). public Task ClearRejectedAsync(CancellationToken ct) { @@ -302,7 +248,6 @@ public sealed partial class CardsService } // Переводит тело PATCH в точечный патч хранилища: ключ присутствует → поле меняется; - // неизвестные ключи отбрасываются (1:1 pydantic PatchBody + patch_card L159–187). // body: Тело PATCH (ключ → значение). // Возвращает: Патч со значениями присутствующих ключей; остальные поля null («не менять»). private static CardPatch ResolvePatch(IReadOnlyDictionary body) diff --git a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs index 1c27297..f67aab1 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs @@ -7,24 +7,8 @@ using Deal.Modules.Settings.Application.Abstractions; 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. @@ -37,7 +21,7 @@ public sealed partial class CardsService private readonly IFileStorage _storage; /// - /// Создаёт сервис карточек над портами модуля (поле-захват DI-зависимостей). + /// Создаёт сервис карточек над портами модуля /// /// Единый порт хранилища карточек/контейнеров/журнала тенанта. /// KV-хранилище настроек тенанта (курсы, напоминания). @@ -55,65 +39,51 @@ public sealed partial class CardsService _storage = storage; } - // ── Фиксированные строки прототипа (400-детали; leads.py L183–184, L239–240, dashboard_routes L241) ── /// - /// 400 move: целевой контейнер не существует (или это «Неразобранное» — оно допустимо). + /// 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). + /// 400 clear-col: колонка не trash/archive. /// public const string ClearColInvalidDetail = "Очищать можно только корзину или архив"; /// - /// 400 комментарий: пустой текст после Trim (dashboard_routes L240–241). + /// 400 комментарий /// 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. + /// Human-метка времени свежего комментария. /// 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 57f2230..8340991 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs @@ -8,20 +8,14 @@ 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 = "Новая колонка"; @@ -40,10 +34,9 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings) // ── Контейнеры ─────────────────────────────────────────────────────── /// - /// Контейнеры пространства в порядке показа, со счётчиками карточек (этап 9, T4). + /// Контейнеры пространства в порядке показа, со счётчиками карточек. /// /// Пространство (dashboard/selected) либо null — все контейнеры. - /// Токен отмены. /// Контейнеры с заполненными counts; пусто — контейнеров нет. public async Task> ListAsync(string? space, CancellationToken ct) { @@ -63,7 +56,6 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings) /// Один контейнер со счётчиками; null — контейнера нет. /// /// Id контейнера. - /// Токен отмены. /// Контейнер со счётчиками либо null. public async Task GetAsync(string containerId, CancellationToken ct) { @@ -81,10 +73,9 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings) } /// - /// Создаёт контейнер с дефолтами: order = MAX+1, цвет палитры, имя «Новая колонка» при пустом. + /// Создаёт контейнер с дефолтами /// /// Вход создания (имя обязательно; цвет/правила/пространство/вид — опциональны). - /// Токен отмены. /// Созданный контейнер (id b_ + 12 hex). public async Task CreateAsync(ContainerCreateDto create, CancellationToken ct) { @@ -112,11 +103,10 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings) } /// - /// Частичное обновление контейнера: меняются только не-null поля патча; rules/policy заменяются целиком. + /// Частичное обновление контейнера /// /// Id контейнера. /// Изменения; null-поле означает «не менять». - /// Токен отмены. /// Контейнер после патча; null — контейнера нет (404 «Контейнер не найден»). public async Task PatchAsync( string containerId, @@ -135,10 +125,9 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings) } /// - /// Принимает ИИ-предложение: снимает флаг suggested у контейнера. + /// Принимает ИИ-предложение /// /// Id контейнера-предложения. - /// Токен отмены. /// Контейнер после принятия; null — контейнера нет (404). public Task AcceptSuggestedAsync(string containerId, CancellationToken ct) { @@ -157,7 +146,6 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings) /// Удаляет контейнер, перенося его карточки в «Неразобранное» новыми. /// /// Id удаляемого контейнера. - /// Токен отмены. /// Сколько карточек перенесено в inbox (ответ {ok, movedToInbox}). public Task DeleteAsync(string containerId, CancellationToken ct) { @@ -165,11 +153,10 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings) } /// - /// Переставляет контейнеры пространства: позиции 0..N-1 в порядке списка. + /// Переставляет контейнеры пространства /// /// Пространство переставляемых контейнеров. /// Id контейнеров в новом порядке. - /// Токен отмены. public Task ReorderAsync( string space, IReadOnlyList containerIds, @@ -181,9 +168,8 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings) // ── Состояние колонок (colState, KV через ISettingsStore) ──────────── /// - /// Весь объект colState: словарь «контейнер → состояние». + /// Весь объект colState /// - /// Токен отмены. /// Состояния всех колонок; пусто — настройка не сохранена/повреждена. public async Task> GetColStateAsync(CancellationToken ct) { @@ -191,11 +177,10 @@ public sealed class ContainersService(ICardStore store, ISettingsStore settings) } /// - /// PATCH состояния одной колонки: merge патча в текущее значение и запись всего объекта. + /// PATCH состояния одной колонки /// /// Id контейнера. /// Изменяемые поля состояния. - /// Токен отмены. /// Состояние колонки после merge. public async Task PatchColStateAsync( string colId, diff --git a/src/core/Deal.Modules.Kanban/Application/Services/ConversionRecomputer.cs b/src/core/Deal.Modules.Kanban/Application/Services/ConversionRecomputer.cs index 30616dd..51e4a19 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/ConversionRecomputer.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/ConversionRecomputer.cs @@ -7,21 +7,8 @@ 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 @@ -38,7 +25,6 @@ public sealed class ConversionRecomputer(ISettingsStore settings, ICardStore sto /// /// Полный пересчёт конверсий кандидатов по текущим настройкам и кэшу курсов. /// - /// Токен отмены. /// Сколько карточек обновлено (0 — конверсия выключена / нет кэша курсов / нет кандидатов). public async Task RecomputeAsync(CancellationToken ct) { @@ -68,7 +54,6 @@ public sealed class ConversionRecomputer(ISettingsStore settings, ICardStore sto continue; // хранилище уже фильтрует budget_cur != '' (null Budget), страховка DTO } - // Нижняя граница; верхняя — как есть, при отсутствии — равна нижней (одна сумма/«от X», L121–122). double? convFrom = RatesService.ConvertAmount(budget.From, budget.Cur, targetCurrency, cache.Rates); if (convFrom is null) { diff --git a/src/core/Deal.Modules.Kanban/Application/Services/FileKindDetector.cs b/src/core/Deal.Modules.Kanban/Application/Services/FileKindDetector.cs index 8a17b06..576f173 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/FileKindDetector.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/FileKindDetector.cs @@ -3,47 +3,37 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Kanban.Application.Services; /// -/// Чистый детектор типа вложения карточки (Ruling 4; 1:1 files.py detect L31–45). +/// Чистый детектор типа вложения карточки. /// -/// -/// Правила 1:1 с прототипом (files.py L13–28, L31–45): MIME-префикс image/|video/|audio/ → -/// соответствующий kind; иначе — расширение имени файла по наборам KIND_BY_EXT (archive/document); -/// неопознанное → other («Файл»). kind/label — wire-значения CardFileDto и иконок -/// фронта: image/«Изображение», video/«Видео», audio/«Аудио», archive/«Архив», document/«Документ», -/// other/«Файл». MIME сравнивается без учёта регистра (HTTP content-type регистронезависим), расширение -/// приводится к нижнему регистру (как .lower() в python). Детектор чистый и детерминированный: -/// «магия» (содержимое файла) не читается — тип даёт браузерный content-type и имя файла. -/// Потребитель — CardsService при добавлении файла. -/// public static class FileKindDetector { /// - /// Категория «изображение» (files.py KIND_BY_EXT: png/jpg/jpeg/gif/webp/svg/bmp/avif/heic). + /// Категория «изображение». /// public const string ImageKind = "image"; /// - /// Категория «видео» (mp4/mov/avi/mkv/webm/m4v). + /// Категория «видео» /// public const string VideoKind = "video"; /// - /// Категория «аудио» (mp3/wav/ogg/m4a/flac/aac). + /// Категория «аудио» /// public const string AudioKind = "audio"; /// - /// Категория «архив» (zip/rar/7z/tar/gz/bz2). + /// Категория «архив» /// public const string ArchiveKind = "archive"; /// - /// Категория «документ» (pdf/doc/docx/xls/xlsx/csv/txt/md/rtf/ppt/pptx/odt/ods). + /// Категория «документ» /// public const string DocumentKind = "document"; /// - /// Категория «другое» — неопознанный MIME и расширение («Файл»). + /// Категория «другое» — неопознанный MIME и расширение /// public const string OtherKind = "other"; @@ -54,7 +44,6 @@ public static class FileKindDetector private const string DocumentLabel = "Документ"; private const string OtherLabel = "Файл"; - // Расширения по категориям (1:1 files.py KIND_BY_EXT L13–19); наборы не пересекаются, порядок обхода не важен. private static readonly IReadOnlyDictionary> ExtensionsByKind = new Dictionary>(StringComparer.Ordinal) { [ImageKind] = new HashSet(StringComparer.Ordinal) { "png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif", "heic" }, @@ -64,7 +53,6 @@ public static class FileKindDetector [DocumentKind] = new HashSet(StringComparer.Ordinal) { "pdf", "doc", "docx", "xls", "xlsx", "csv", "txt", "md", "rtf", "ppt", "pptx", "odt", "ods" }, }; - // Метки категорий (1:1 files.py KIND_LABELS L21–28). private static readonly IReadOnlyDictionary LabelsByKind = new Dictionary(StringComparer.Ordinal) { [ImageKind] = ImageLabel, @@ -76,7 +64,7 @@ public static class FileKindDetector }; /// - /// Определяет категорию вложения по MIME и расширению имени (files.py detect L31–45). + /// Определяет категорию вложения по MIME и расширению имени. /// /// Имя файла как прислано (расширение — часть после последней точки, в нижнем регистре). /// MIME-тип из загрузки (может быть null/пустым — тогда только расширение). @@ -115,7 +103,6 @@ public static class FileKindDetector return new CardFileKind(kind, LabelsByKind[kind]); } - // Расширение имени файла после последней точки, в нижнем регистре (1:1 python: (name.split(".")[-1]).lower()). // name: Имя файла. // Возвращает: Расширение без точки; пустая строка, если точки в имени нет (или имя заканчивается точкой). private static string GetExtension(string name) diff --git a/src/core/Deal.Modules.Kanban/Application/Services/StorageTickService.cs b/src/core/Deal.Modules.Kanban/Application/Services/StorageTickService.cs index 5b2209d..4b40589 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/StorageTickService.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/StorageTickService.cs @@ -6,31 +6,15 @@ 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). +/// KV-хранилище настроек тенанта. public sealed class StorageTickService(ICardStore store, ISettingsStore settings) { /// - /// Один тик правил хранения тенанта (Ruling 8; leads.py tick_storage L454–493). + /// Один тик правил хранения тенанта. /// - /// Токен отмены. /// Статистика тика: сколько карточек архивировано/очищено из архива и корзины (purgedRejected=0). public async Task TickAsync(CancellationToken ct) { @@ -41,33 +25,26 @@ public sealed class StorageTickService(ICardStore store, ISettingsStore settings 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 — кандидатов не было). diff --git a/src/core/Deal.Modules.Kanban/Application/Services/SuggestHeuristics.cs b/src/core/Deal.Modules.Kanban/Application/Services/SuggestHeuristics.cs index 12115c1..ad92020 100644 --- a/src/core/Deal.Modules.Kanban/Application/Services/SuggestHeuristics.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/SuggestHeuristics.cs @@ -4,70 +4,56 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Kanban.Application.Services; /// -/// Чистое ядро ИИ-предложений колонок/ключей — план Task 14 L467–471, Ruling 3. +/// Чистое ядро ИИ-предложений колонок/ключей — план. /// -/// -/// Детерминированная эвристика этапа 3 (реальные предложения ИИ — этап 6): без EF/HTTP/хранилища. -/// Тема колонки — повторяющееся слово-тема по source_msg карточек «Неразобранного»: текст -/// токенизируется на слова (только буквы, ≥3 символов, нижний регистр, минус стоп-слова), слово, -/// встречающееся в ≥2 карточках окна, становится кандидатом в колонку. Группы собираются жадным -/// алгоритмом: самый частотный кандидат (при равенстве — более длинное слово, затем лексикографически) -/// забирает свои карточки, следующие кандидаты получают только неразобранные (как used_msg прототипа, -/// suggest.py L129–143). Выход — до планов, каждый с ≥2 карточками; похожие на -/// существующие доски слова пропускаются (suggest.py _similar_exists L55–61). Suggest-keywords — те же -/// частотные слова по выборке карточек (≤60, ≤40 симв.) — прототип suggest_domain_keywords L166–193. -/// Все сравнения/порядки — Ordinal: одинаковый вход даёт одинаковый выход (Acceptance Task 14). -/// public static class SuggestHeuristics { - // ── Пороги анализа (прототип suggest.py L48–52 и правила промпта L24–27) ── /// - /// Минимум карточек в «Неразобранном» для анализа (suggest.py MIN_INBOX L48). + /// Минимум карточек в «Неразобранном» для анализа. /// public const int MinInbox = 6; /// - /// Минимум карточек в одной колонке-предложении (MIN_INBOX_GROUP L49, «группируй… минимум 2 сообщения»). + /// Минимум карточек в одной колонке-предложении. /// public const int MinInboxGroup = 2; /// - /// Сколько сообщений берём в анализ — окно по received_at DESC (MAX_TEXT L50, LIMIT 12). + /// Сколько сообщений берём в анализ — окно по received_at DESC. /// public const int MaxText = 12; /// - /// Колонок-предложений за один прогон максимум (прототип cols[:4] L132, «2–4 колонки максимум»). + /// Колонок-предложений за один прогон максимум. /// public const int MaxColumns = 4; /// - /// Минимальная длина слова-темы: слова короче 3 букв не несут темы (план Task 14 L468). + /// Минимальная длина слова-темы /// public const int MinWordLength = 3; /// - /// Максимальная длина слова-темы/ключа: 1:1 с прототипом (имя колонки ≤40, L136; ключ ≤40, L191). + /// Максимальная длина слова-темы/ключа /// public const int MaxKeywordLength = 40; /// - /// Максимум ключей-маркеров suggest-keywords (прототип keywords[:60] L193). + /// Максимум ключей-маркеров suggest-keywords. /// public const int MaxKeywordsTotal = 60; /// - /// Окно карточек для suggest-keywords: свежие 40 с текстом (suggest.py L172–176, LIMIT 40). + /// Окно карточек для suggest-keywords /// public const int KeywordsSampleLimit = 40; /// - /// Минимум карточек для suggest-keywords: «нужно хотя бы 3» (suggest.py L178). + /// Минимум карточек для suggest-keywords /// public const int MinKeywordsSample = 3; - // Сколько карточек группы учитывается в note-обосновании (текст note, план L469–470). private const string ColumnNoteFormat = "Эвристика (этап 3): слово-тема «{0}» встречается у {1} карточек; реальные предложения ИИ — этап 6"; @@ -102,22 +88,13 @@ public static class SuggestHeuristics }; /// - /// Планы колонок-предложений по карточкам «Неразобранного» (suggest_from_inbox L76–163). + /// Планы колонок-предложений по карточкам «Неразобранного». /// - /// - /// Анализируется окно из самых свежих карточек (received_at DESC — как SQL - /// L96–99); карточек в окне меньше → пусто (причину «мало карточек…» называет - /// адаптер). Слова существующих НЕ-suggested досок исключаются из кандидатов (похожесть имени, - /// suggest.py L55–61). Каждая карточка попадает не более чем в одну группу (used_msg L129–143): - /// кандидаты перебираются от самого частотного, группе достаются только ещё не разобранные карточки. - /// Выход полностью детерминирован: сортировки Ordinal + порядок входа окна. - /// /// Карточки «Неразобранного» с непустым source_msg (ICardStore.ListInboxWithSourceAsync). /// Имена существующих (suggested=false) досок — похожие темы не предлагаются. /// Планы колонок (≤4, каждая ≥2 карточки); пусто — мало карточек/нечего сгруппировать. public static IReadOnlyList PlanColumns(IReadOnlyList inbox, IReadOnlyList existingBoardNames) { - // Окно анализа: свежие карточки с текстом, как выборка SQL L96–99 (сортировка — страховка // детерминизма: адаптер уже отдаёт received_at DESC, но вход не должен влиять на выход). List window = inbox .Where(card => card.SourceMsg.Trim().Length > 0) @@ -147,7 +124,6 @@ public static class SuggestHeuristics } } - // Имена существующих досок — один раз в нижнем регистре (похожесть L55–61). List existingLowered = existingBoardNames .Select(name => name.ToLowerInvariant()) .ToList(); @@ -162,8 +138,6 @@ public static class SuggestHeuristics .ThenBy(term => term, StringComparer.Ordinal) .ToList(); - // Жадная сборка групп: каждый кандидат забирает только неразобранные карточки (suggest.py - // used_msg L129–143); группа без ≥2 свободных карточек пропускается. var usedCardIds = new HashSet(StringComparer.Ordinal); var plans = new List(); foreach (string term in candidates) @@ -191,15 +165,8 @@ public static class SuggestHeuristics } /// - /// Частотные слова-маркеры по текстам карточек (suggest_domain_keywords L166–193, эвристика Ruling 3). + /// Частотные слова-маркеры по текстам карточек. /// - /// - /// Маркер — слово (те же правила токенизации, что для колонок), встречающееся в ≥2 текстах выборки. - /// Выход — до слов, длина каждого ≤; - /// порядок — частота по убыванию, при равенстве более длинное, затем лексикографически - /// (детерминизм: одинаковый вход → одинаковый выход). Выборку (свежие 40 вне trash/archive) и - /// проверку «мало карточек» выполняет адаптер LocalColumnSuggester. - /// /// Тексты source_msg карточек выборки (непустые, свежие ≤40). /// Слова-маркеры (≤60); пусто — нет слов, встречающихся в ≥2 текстах. public static IReadOnlyList SuggestDomainKeywords(IReadOnlyList texts) @@ -267,7 +234,6 @@ public static class SuggestHeuristics } // Похоже ли слово-тема на существующую доску: равенство или вхождение имени в слово/наоборот - // (suggest.py _similar_exists L55–61, регистронезависимо). // term: Слово-кандидат (нижний регистр). // existingLowered: Имена существующих досок в нижнем регистре. // Возвращает: True — тема уже покрыта существующей колонкой (кандидат пропускается). @@ -284,7 +250,6 @@ public static class SuggestHeuristics return false; } - // Имя колонки из слова-темы: первая буква заглавная (python → Python, такси → Такси). // term: Слово-тема в нижнем регистре (непустое). // Возвращает: Слово с заглавной первой буквой. private static string Capitalize(string term) => char.ToUpperInvariant(term[0]) + term[1..]; diff --git a/src/core/Deal.Modules.Kanban/KanbanModuleMarker.cs b/src/core/Deal.Modules.Kanban/KanbanModuleMarker.cs index 5a1b913..115a482 100644 --- a/src/core/Deal.Modules.Kanban/KanbanModuleMarker.cs +++ b/src/core/Deal.Modules.Kanban/KanbanModuleMarker.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Kanban; /// -/// Маркер модуля Kanban: используется для DI-сканирования и тестов. +/// Маркер модуля Kanban /// public sealed class KanbanModuleMarker { diff --git a/src/core/Deal.Modules.Pipeline/Application/Abstractions/IPipelineStore.cs b/src/core/Deal.Modules.Pipeline/Application/Abstractions/IPipelineStore.cs index 5334d80..a290249 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Abstractions/IPipelineStore.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Abstractions/IPipelineStore.cs @@ -3,30 +3,17 @@ using Deal.Modules.Pipeline.Application.Models; namespace Deal.Modules.Pipeline.Application.Abstractions; /// -/// Порт хранилища пайплайна (таблицы QueueItems/RejectedItems/DedupEntries тенанта), Ruling 1. +/// Порт хранилища пайплайна /// -/// -/// Объявлен в модуле Pipeline (чистый: без EF/HTTP); реализация — EF-адаптер PipelineStore в -/// Deal.Infrastructure (Task 3, регистрация в AddDealPersistence). Порт оперирует DTO модуля; маппинг -/// DTO ↔ строки (включая DateTimeOffset ↔ epoch-ms наружу и подписи stageLabel/sourceLabel через -/// ) выполняет адаптер вручную (эталон KanbanStore.cs). Набор методов — -/// ровно тот, что нужен задачам 3/5/8/9/11 (YAGNI): очередь (приём/списки/статусы/удаление), отсев -/// (upsert/страницы/поиск-кандидаты/очистки/возврат) и дедуп-заявки (проверка/claim/связь с карточкой). -/// Id строк очереди (p_) генерирует модуль и передаёт готовыми (Ruling 2); id отсева — детерминированный -/// из RejectRecord либо случайный r_+hex в адаптере (processing.record L77, Ruling 1). -/// Чтения — AsNoTracking; сортировки/лимиты — обязанность адаптера. Статусы очереди: new (ждёт правил/ -/// дедупа/ML) и filtered (прошла «new»-проход, ждёт ИИ; счётчик «ai»). -/// public interface IPipelineStore { // ── Очередь (QueueItems) ─────────────────────────────────────────────── /// - /// Есть ли строка очереди с тем же сообщением диалога — дубль-гвард приёма (Ruling 2, enqueue L70–77). + /// Есть ли строка очереди с тем же сообщением диалога — дубль-гвард приёма. /// /// Id диалога-источника. /// Id исходного сообщения; null — проверка не выполняется (вернёт false). - /// Токен отмены. /// true — строка с DialogId+MsgId уже в очереди (Telethon-дубль не пишем). public Task ExistsDuplicateAsync( string dialogId, @@ -34,18 +21,16 @@ public interface IPipelineStore CancellationToken ct); /// - /// Добавляет строку очереди (вставка без гвардов — сервис приёма уже проверил дубль и обрезал текст). + /// Добавляет строку очереди /// /// Полная строка: id p_, статус new, QueuedAtMs=CreatedAt=UpdatedAt (задаёт модуль). - /// Токен отмены. public Task AddAsync(QueueItemDto item, CancellationToken ct); /// - /// Строки очереди в порядке постановки (CreatedAt ASC), необязательно фильтр по статусу (воркер, Ruling 8). + /// Строки очереди в порядке постановки /// - /// Статус new|filtered, либо null — все статусы (GET /api/pipeline/queue, L218–241). + /// Статус new|filtered, либо null — все статусы. /// Максимум строк (лимиты сервиса: 12/4 у воркера, ≤500 у списка). - /// Токен отмены. /// Строки очереди (включая внутренний для воркера). public Task> ListAsync( string? status, @@ -53,64 +38,53 @@ public interface IPipelineStore CancellationToken ct); /// - /// Сколько строк очереди со статусом (для счётчиков queue_counts: new/ai=filtered, L207–215). + /// Сколько строк очереди со статусом. /// /// Статус new|filtered. - /// Токен отмены. /// Число строк со статусом. public Task CountByStatusAsync(string status, CancellationToken ct); /// - /// Меняет статус строки очереди (new → filtered после «new»-прохода воркера; UpdatedAt = now). + /// Меняет статус строки очереди /// /// Id строки (p_...). /// Новый статус new|filtered. - /// Токен отмены. public Task SetStatusAsync( string id, string status, CancellationToken ct); /// - /// Удаляет строку очереди безвозвратно (отсев на любом этапе; сброс dedup-claim — DeleteClaimAsync). + /// Удаляет строку очереди безвозвратно /// /// Id строки (p_...). - /// Токен отмены. public Task RemoveAsync(string id, CancellationToken ct); // ── Отсев (RejectedItems) ────────────────────────────────────────────── /// - /// Пишет запись отсева: детерминированный id по dialog+msgId либо случайный r_+hex; повторное - /// отбрасывание того же сообщения обновляет запись (upsert ON CONFLICT, processing.record L66–101). - /// Пустой/пробельный текст — no-op. + /// Пишет запись отсева /// /// Команда записи отсева (текст/канал/время + source/stage/reason/kw). - /// Токен отмены. public Task UpsertAsync(RejectRecord record, CancellationToken ct); /// - /// Страница отсева без поиска: ORDER BY RejectedAt DESC, LIMIT/OFFSET (processing.list_rejected L278–284). + /// Страница отсева без поиска /// /// Сдвиг от начала (0 — первая страница). /// Размер страницы (≤500; валидирует сервис). - /// Токен отмены. - /// Записи страницы (полные DTO с подписями этапа/источника). + /// Записи страницы. public Task> ListPageAsync( int offset, int limit, CancellationToken ct); /// - /// Кандидаты поиска по отсеву ПОЛНЫМИ СТРОКАМИ: FTS-совпадения (SearchTsv @@ plainto_tsquery, - /// rank DESC, ≤limitFts) + LIKE-дополнение по lower(text)/reason/kw/ch_name (≤limitLike), без дублей — - /// объединение Ruling 6 (processing L252–270); итог — общий список страниц (total считает сервис). - /// Записи читаются сразу в выборках кандидатов — без N+1 «id → GetAsync» страниц поиска (Ruling 6). + /// Кандидаты поиска по отсеву ПОЛНЫМИ СТРОКАМИ /// /// Поисковый запрос (сервис отдаёт нормализованный lower). /// Лимит FTS-кандидатов. /// Лимит LIKE-дополнения. - /// Токен отмены. /// Упорядоченный список полных записей-кандидатов (FTS-ранжированные первыми, затем LIKE-дополнение). public Task> SearchAsync( string q, @@ -119,49 +93,43 @@ public interface IPipelineStore CancellationToken ct); /// - /// Всего записей отсева (rejected_count; счётчик вкладки «Обработка»). + /// Всего записей отсева /// - /// Токен отмены. /// Число записей. public Task CountAsync(CancellationToken ct); /// - /// Одна запись отсева по id (для возврата/удаления и страниц поиска). + /// Одна запись отсева по id /// /// Id записи (r_...). - /// Токен отмены. /// Запись (полный DTO) или null, если строки нет. public Task GetAsync(string id, CancellationToken ct); /// - /// Удаляет одну запись отсева безвозвратно (DELETE /rejected/{id}, delete_one L196–198). + /// Удаляет одну запись отсева безвозвратно. /// /// Id записи (r_...). - /// Токен отмены. public Task DeleteAsync(string id, CancellationToken ct); /// - /// Полная очистка отсева (POST /rejected/clear; clear_all L103–110). + /// Полная очистка отсева. /// - /// Токен отмены. /// Сколько записей удалено. public Task ClearAsync(CancellationToken ct); /// - /// Автоочистка: удаляет записи с RejectedAt старше olderThan (purge_expired L104–117, Ruling 8). + /// Автоочистка: удаляет записи с RejectedAt старше olderThan. /// /// Граница срока хранения (UTC): удаляются записи RejectedAt < olderThan. - /// Токен отмены. /// Сколько записей удалено. public Task PurgeExpiredAsync(DateTimeOffset olderThan, CancellationToken ct); /// - /// Помечает запись отсева возвращённой: returned=true + returnedAt + returnReason (return_to_queue L164–174). + /// Помечает запись отсева возвращённой /// /// Id записи (r_...). /// Причина возврата (уже обрезана сервисом до 500). /// Момент возврата (UTC). - /// Токен отмены. public Task MarkReturnedAsync( string id, string reason, @@ -171,48 +139,38 @@ public interface IPipelineStore // ── Дедуп (DedupEntries) ────────────────────────────────────────────── /// - /// Есть ли запись дедупа с хэшем (нормализованный текст уже в системе — отсев dup, Ruling 8). + /// Есть ли запись дедупа с хэшем. /// /// SHA1-hex нормализованного текста (без префикса). - /// Токен отмены. /// true — запись существует. public Task ExistsAsync(string hash, CancellationToken ct); /// - /// Заявляет хэш за обрабатываемым сообщением: INSERT (hash, LeadId=null, CreatedAt=now) - /// ON CONFLICT DO NOTHING — параллельные дубли не проходят (Ruling 8, L947–949). Атомарность claim'а - /// позволяет воркеру проверить результат: false — хэш уже заявлен другим проходом pump, карточку не создаём. + /// Заявляет хэш за обрабатываемым сообщением /// /// SHA1-hex нормализованного текста. - /// Токен отмены. /// True — заявка занята этим вызовом (строка INSERT'нута); false — хэш уже заявлен (ON CONFLICT). public Task ClaimAsync(string hash, CancellationToken ct); /// - /// Снимает незанятую заявку дедупа: DELETE WHERE Hash=? AND LeadId IS NULL (не трогает строки, - /// уже связанные с карточкой; _drop_row L812–814, Ruling 8). + /// Снимает незанятую заявку дедупа /// /// SHA1-hex нормализованного текста. - /// Токен отмены. public Task DeleteClaimAsync(string hash, CancellationToken ct); /// - /// Связывает заявку дедупа с созданной карточкой: UPDATE DedupEntries SET LeadId=? WHERE Hash=? - /// (порядок AddCard → LinkDedup, Ruling 4; L512–513). + /// Связывает заявку дедупа с созданной карточкой /// /// SHA1-hex нормализованного текста. /// Id созданной карточки (c_...). - /// Токен отмены. public Task LinkAsync( string hash, string cardId, CancellationToken ct); /// - /// Чистит «мягкие» ссылки карточки при её жёстком удалении (Ruling 3: DELETE DedupEntries - /// WHERE LeadId=? — KanbanStore зовёт при DeleteForever/Purge/ClearCol через адаптер, без цикла модулей). + /// Чистит «мягкие» ссылки карточки при её жёстком удалении. /// /// Id удаляемой карточки (c_...). - /// Токен отмены. public Task DeleteByCardAsync(string cardId, CancellationToken ct); } diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/GlobalExcludeSettings.cs b/src/core/Deal.Modules.Pipeline/Application/Models/GlobalExcludeSettings.cs index 0f65e89..bd3e0be 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/GlobalExcludeSettings.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/GlobalExcludeSettings.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Pipeline.Application.Models; /// -/// Снимок глобальных исключений тенанта (стоп-уровень до ML/ИИ, §5.14/§8). +/// Снимок глобальных исключений тенанта /// /// Ключевые слова/фразы/технологии (пусто — группа выключена). /// Локации/языки (пусто — группа выключена). diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/GlobalExclusionResult.cs b/src/core/Deal.Modules.Pipeline/Application/Models/GlobalExclusionResult.cs index f2d1219..79cd911 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/GlobalExclusionResult.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/GlobalExclusionResult.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Pipeline.Application.Models; /// -/// Сработавшее глобальное исключение: какое правило, причина и конкретный терм (§5.14). +/// Сработавшее глобальное исключение /// /// Этап/правило исключения (константы GlobalExclusionRules). /// Причина отсева для UI (какое исключение сработало). diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/LocalParsedFields.cs b/src/core/Deal.Modules.Pipeline/Application/Models/LocalParsedFields.cs index f4c04fa..cb4f768 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/LocalParsedFields.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/LocalParsedFields.cs @@ -3,20 +3,10 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Pipeline.Application.Models; /// -/// Результат локального разбора сообщения без ИИ — структура карточки (pipeline.py _local_fields L718–798). +/// Результат локального разбора сообщения без ИИ — структура карточки. /// -/// -/// Заголовок/суть/стек/грейд/бюджет/контакты/признак найма извлекаются детерминированно: по меткам -/// «Стек:/Грейд:/Контакты:/Бюджет:» (_field_of L686–698) с fallback-поиском по тексту. Семантика полей -/// 1:1 с прототипом: — первая распознанная сумма (формы хранения, валюта-код); -/// — сырые кандидаты контактов, склеенные «; » и ограниченные 200 символами (python L782: -/// финальную квалификацию делает build_contacts при сборке карточки). Тип заявки — маркерная гипотеза -/// ( по hireMarkers); признак «тип подтверждён» для локального пути всегда false, -/// колонка — inbox (python L796–797: is_vacancy_known=False, board=None — смысловые колонки до ИИ не назначаем). -/// /// Заголовок карточки — первая содержательная строка, очищенная (≤140). -/// Суть «О задаче» — содержательные строки после заголовка без меток-полей (python -/// _local_summary L294–314; ≤600). +/// Суть «О задаче» — содержательные строки после заголовка без меток-полей. /// Стек/направления из меток или текста (≤12, без стоп-слов). /// Грейды/уровни из метки «Грейд:» или текста (≤4, термины levelTerms в нижнем регистре). /// Первая распознанная сумма/диапазон (из метки «Бюджет:» или текста); null — суммы нет. diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/MlApplyResult.cs b/src/core/Deal.Modules.Pipeline/Application/Models/MlApplyResult.cs index f299ad1..78e99f0 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/MlApplyResult.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/MlApplyResult.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Pipeline.Application.Models; /// -/// Результат ручного решения по сообщению (тело ответа POST /api/ml/apply, §8). +/// Результат ручного решения по сообщению /// /// Текст ошибки 400 (неизвестная доска/действие); null — решение применено. /// True — решение принято. diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidateDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidateDto.cs index dc77193..729dd3d 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidateDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidateDto.cs @@ -1,19 +1,12 @@ namespace Deal.Modules.Pipeline.Application.Models; /// -/// Сообщение-кандидат для ручной проверки ML (элемент items ответа POST /api/ml/candidates, §8). +/// Сообщение-кандидат для ручной проверки ML /// -/// -/// id — id исходного сообщения (msgId), его же принимает POST /api/ml/apply. -/// описывает текущее состояние сообщения: card (уже карточка), rejected (в отсеве) либо -/// queued (ещё в очереди обработки). Для карточки заполнены (колонка), для отсева — -/// /. Наружу сериализуется camelCase: id/dialogId/text/time/lead/ -/// verdict/col/stage/reason/pred. -/// public sealed record MlCandidateDto { /// - /// Id исходного сообщения в Telegram (для POST /api/ml/apply). + /// Id исходного сообщения в Telegram /// public long Id { get; init; } @@ -23,7 +16,7 @@ public sealed record MlCandidateDto public string DialogId { get; init; } = string.Empty; /// - /// Текст сообщения (обрезан до 600 символов, как прототип). + /// Текст сообщения. /// public string Text { get; init; } = string.Empty; @@ -38,12 +31,12 @@ public sealed record MlCandidateDto public bool Lead { get; init; } /// - /// Текущий вердикт: card | rejected | queued. + /// Текущий вердикт /// public string Verdict { get; init; } = string.Empty; /// - /// Колонка карточки (для verdict=card). + /// Колонка карточки /// public string? Col { get; init; } @@ -53,7 +46,7 @@ public sealed record MlCandidateDto public string? Stage { get; init; } /// - /// Причина отсева (для verdict=rejected). + /// Причина отсева /// public string? Reason { get; init; } diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidatePredictionDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidatePredictionDto.cs index 2b24604..7671c24 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidatePredictionDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/MlCandidatePredictionDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Pipeline.Application.Models; /// -/// Мнение ML по сообщению-кандидату (поле pred элемента POST /api/ml/candidates, §8). +/// Мнение ML по сообщению-кандидату /// /// Модель «взяла бы» сообщение (готова и уверена). /// Метка решения (id доски либо spam); null — модель не уверена/не готова. diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/ParsedCardContent.cs b/src/core/Deal.Modules.Pipeline/Application/Models/ParsedCardContent.cs index 5742be4..d53fa21 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/ParsedCardContent.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/ParsedCardContent.cs @@ -1,23 +1,15 @@ namespace Deal.Modules.Pipeline.Application.Models; /// -/// Структурированное содержимое блока «О заявке» карточки (поля классификации cardPrompt L116–123; -/// python raw-словарь compose_summary L225–284). +/// Структурированное содержимое блока «О заявке» карточки. /// -/// -/// Карточка всегда собирается из одних и тех же блоков: Компания → Формат → О задаче → Требования → -/// Будет плюсом → Условия (Ruling 4, 1:1 с cardPrompt и compose_summary). Заполняет структурированный разбор -/// (ИИ-классификатор этапа 6); локальный разбор (без ИИ) заполняет только — тогда -/// композиция идёт «суть как есть» либо путём «О задаче: …» из текста. Пустые/отсутствующие поля блок не дают. -/// /// Кто ищет/разместил: компания, бренд, агентство, частное лицо, заказчик. /// Формат работы: удалённо/офис/гибрид, город/страна, график. /// Что за задача/роль → для кого → что нужно сделать. /// Реальные требования/обязанности (пункты списков). /// Что отмечено как «будет плюсом»/«приветствуется»/«желательно». /// Условия одной строкой: оплата/ЗП/вилка, сроки, объём, тип занятости. -/// Неструктурированная суть разбора (legacy): возвращается как есть, если не похожа на -/// служебный футер (python L266–268); иначе блок «О задаче: …» из текста. +/// Неструктурированная суть разбора (legacy): возвращается как есть, если не похожа на служебный футер; иначе блок «О задаче: …» из текста. public sealed record ParsedCardContent( string? Company = null, string? Format = null, diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineChannelDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineChannelDto.cs index bef161b..9f8aaca 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineChannelDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineChannelDto.cs @@ -1,14 +1,9 @@ namespace Deal.Modules.Pipeline.Application.Models; /// -/// Канал-источник сообщения пайплайна — объект ch строк очереди/отсева (§4.5 L308/L310). +/// Канал-источник сообщения пайплайна — объект ch строк очереди/отсева. /// -/// -/// Имя/хендл/цвет диалога, из которого пришло входящее сообщение. Общий для очереди и отсева (в прототипе — -/// один inline-объект ch в list_queue L218–241 / list_rejected L246–312); в JSON выходит под ключом -/// ch: ch.name/ch.handle/ch.hue. -/// /// Имя канала/диалога. -/// Handle канала/диалога (t.me/<handle>). -/// Цвет канала (hex; дефолт #666, Ruling 1). +/// Handle канала/диалога (t.me/<handle>). +/// Цвет канала. public sealed record PipelineChannelDto(string Name, string Handle, string Hue); diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineIdPrefixes.cs b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineIdPrefixes.cs index fc66c7b..55ba68c 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineIdPrefixes.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineIdPrefixes.cs @@ -1,24 +1,17 @@ namespace Deal.Modules.Pipeline.Application.Models; /// -/// Реестр префиксов коротких id модуля Pipeline (Ruling 10; прототип store.uid). +/// Реестр префиксов коротких id модуля Pipeline. /// -/// -/// Очередь — p_ (строка QueueItems, id генерирует модуль при приёме, Ruling 2); отсев — r_ -/// (детерминированный r_<dialog>_<msgId> при наличии dialog+msgId, иначе r_+hex, -/// Ruling 1). Хэш-ключ дедупа — SHA1-hex БЕЗ префикса (первичный ключ DedupEntries.Hash; считает DedupHasher, -/// Task 4). Случайную часть id даёт общий генератор PrefixId модуля Kanban (переиспользуем публичный -/// чистый помощник владельца, без дублирования и без выноса в SharedKernel — модуль Kanban уже в зависимостях). -/// public static class PipelineIdPrefixes { /// - /// Префикс id строки очереди (таблица QueueItems; pipeline.py store.uid("p_")). + /// Префикс id строки очереди /// public const string Queue = "p_"; /// - /// Префикс id записи отсева (таблица RejectedItems; processing.record L77). + /// Префикс id записи отсева. /// public const string Rejected = "r_"; } diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineIngestResultDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineIngestResultDto.cs index 71430cb..a3e86b8 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineIngestResultDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineIngestResultDto.cs @@ -1,14 +1,8 @@ namespace Deal.Modules.Pipeline.Application.Models; /// -/// Результат приёма сообщения — ответ PipelineIngestService.EnqueueAsync (Ruling 2, pipeline.enqueue L53–85). +/// Результат приёма сообщения — ответ PipelineIngestService.EnqueueAsync. /// -/// -/// отсутствует (null), когда сообщение НЕ поставлено в очередь: no-op (пустой текст/нет -/// dialogId) либо дубль dialogId+msgId уже в очереди. отличает дубль-гвард Telethon -/// (Ruling 2, enqueue L74–80) от остальных no-op. Прототип enqueue возвращает None — результат нужен -/// демо-ingest (Task 9): ответ {ok:true, id, queue:{new,ai,total}}. -/// /// Id строки очереди (p_...) либо null — сообщение не принято. /// true — то же сообщение диалога (dialogId+msgId) уже в очереди, вставки не было. public sealed record PipelineIngestResultDto(string? Id, bool Duplicate); diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/PipelinePumpResult.cs b/src/core/Deal.Modules.Pipeline/Application/Models/PipelinePumpResult.cs index 1dcca97..db508d4 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/PipelinePumpResult.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/PipelinePumpResult.cs @@ -3,63 +3,57 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Pipeline.Application.Models; /// -/// Результат одного прохода воркера pump — поле pipeline ответа POST /api/admin/tick (Ruling 8, прототип _pump_unlocked L922–924). +/// Результат одного прохода воркера pump — поле pipeline ответа POST /api/admin/tick. /// -/// -/// Счётчики 1:1 со словарём прототипа (staged/rulesStored/mlStored/mlDrop/typeDrop/aiStored/aiDrop/aiFail/ -/// noBudget); wire-ключи admin/tick — те же (camelCase). — карточки, созданные за -/// проход (для SSE new_card из Api, Ruling 8/9). Счётчики решений для KV (mlDecisions/aiDecisions) воркер -/// инкрементирует сам как mlStored+mlDrop и aiStored+aiDrop (Ruling 5) — отдельных полей не нужно. -/// public sealed record PipelinePumpResult { /// - /// Сообщений прошли «new»-проход (правила/дедуп/ML) и переведены в статус filtered. + /// Сообщений прошли «new»-проход /// public int Staged { get; init; } /// - /// Отсевов по правилам этапа 1 (length|stop|resume|type; source=stop). + /// Отсевов по правилам /// public int RulesStored { get; init; } /// - /// Карточек создано ML-веткой (решения ML: колонка/тип; на этапе 4 ML «спит» — 0). + /// Карточек создано ML-веткой. /// public int MlStored { get; init; } /// - /// Отсевов решением ML (spam_ml/type). + /// Отсевов решением ML /// public int MlDrop { get; init; } /// - /// Отсевов «тип не под режим» по решению ML (stage=type, source=ml). + /// Отсевов «тип не под режим» по решению ML /// public int TypeDrop { get; init; } /// - /// Карточек создано ИИ-веткой (классификация/локальный разбор). + /// Карточек создано ИИ-веткой /// public int AiStored { get; init; } /// - /// Отсевов решением ИИ (spam_ai/filter_ai). + /// Отсевов решением ИИ /// public int AiDrop { get; init; } /// - /// Сообщений, где ИИ не дал разбора — собран локальный разбор (aiFail). + /// Сообщений, где ИИ не дал разбора — собран локальный разбор /// public int AiFail { get; init; } /// - /// Отсевов глобальным фильтром «без суммы» (stage=budget, source=stop). + /// Отсевов глобальным фильтром «без суммы» /// public int NoBudget { get; init; } /// - /// Карточки, созданные за проход (порядок создания; полный CardDto для SSE new_card). + /// Карточки, созданные за проход /// public IReadOnlyList CreatedCards { get; init; } = Array.Empty(); } diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineQueueStatuses.cs b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineQueueStatuses.cs index f2bd4f9..8b7ba72 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineQueueStatuses.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineQueueStatuses.cs @@ -1,23 +1,17 @@ namespace Deal.Modules.Pipeline.Application.Models; /// -/// Статусы строк очереди — колонка QueueItems.Status (pipeline.py ST_NEW/ST_AI L42–44). +/// Статусы строк очереди — колонка QueueItems.Status. /// -/// -/// new — сообщение ждёт «new»-прохода воркера (правила/дедуп/ML, Ruling 8); filtered — прошло -/// «new»-проход и ждёт ИИ-классификации (в счётчиках очереди — bucket «ai», processing.queue_counts L207–215). -/// Значения 1:1 с wire-статусами api-map §4.5 L308 ("new"|"filtered"); строки QueueItems пишет приём -/// (Ruling 2: статус new), переводит в filtered воркер (Ruling 8). -/// public static class PipelineQueueStatuses { /// - /// Статус «ждёт правил/дедупа/ML» (pipeline.py ST_NEW L43). + /// Статус «ждёт правил/дедупа/ML». /// public const string New = "new"; /// - /// Статус «прошла этап 1, ждёт ИИ» (pipeline.py ST_AI L44; в счётчиках — «ai»). + /// Статус «прошла, ждёт ИИ». /// public const string Filtered = "filtered"; } diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineRejectConstants.cs b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineRejectConstants.cs index 7a96226..ab3444d 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineRejectConstants.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineRejectConstants.cs @@ -1,24 +1,17 @@ namespace Deal.Modules.Pipeline.Application.Models; /// -/// Константы отсева: подписи этапов/источников и срок хранения (processing.py L26–46, Rulings 1/9). +/// Константы отсева /// -/// -/// stageLabel — человекочитаемая подпись этапа для UI («почему отсеяно»); sourceLabel — «чьё» решение: -/// правила/ML/ИИ/система. Источники stale|dup оба подписываются «система» (Ruling 9: «системный отсев» сверх -/// stale/dup в прототипе отсутствует — roadmap-смысл покрыт этими источниками). Неизвестные значения подписи -/// возвращаются как есть (пустое — «отсев»/«система»), 1:1 с prototype stage_label/source_label L40–45. -/// — автоочистка отсева раз в 3 суток (processing.RETENTION_DAYS, Ruling 8). -/// public static class PipelineRejectConstants { /// - /// Срок жизни записи отсева в сутках (после — безвозвратная автоочистка PurgeExpiredAsync). + /// Срок жизни записи отсева в сутках /// public const int RetentionDays = 3; /// - /// Словарь «этап → подпись» (stage → stageLabel), 1:1 с _STAGE_LABELS processing.py L26–34. + /// Словарь « → подпись» /// public static readonly IReadOnlyDictionary StageLabels = new Dictionary(StringComparer.Ordinal) @@ -40,7 +33,7 @@ public static class PipelineRejectConstants }; /// - /// Словарь «источник → подпись» (source → sourceLabel), 1:1 с _SOURCE_LABELS processing.py L36–41. + /// Словарь «источник → подпись» /// public static readonly IReadOnlyDictionary SourceLabels = new Dictionary(StringComparer.Ordinal) @@ -53,7 +46,7 @@ public static class PipelineRejectConstants }; /// - /// Подпись этапа отсева: словарь, иначе сам stage; пустой stage — «отсев» (stage_label L40–42). + /// Подпись отсева /// /// Этап отсева. /// Подпись для UI. @@ -68,7 +61,7 @@ public static class PipelineRejectConstants } /// - /// Подпись источника решения: словарь, иначе сам source; пустой source — «система» (source_label L44–45). + /// Подпись источника решения /// /// Источник решения. /// Подпись для UI. diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineStatsDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineStatsDto.cs index 830eef5..59acfef 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/PipelineStatsDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineStatsDto.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Pipeline.Application.Models; /// -/// Сводка вкладки «Обработка» — тело GET /api/pipeline/stats (api-map §3.6 L179, processing.stats L315–320). +/// Сводка вкладки «Обработка» — тело GET /api/pipeline/stats. /// -/// -/// Ответ 1:1 с прототипом: {queue: {new, ai, total}, rejected: int}. — счётчики -/// очереди (new/ai/total), — число записей в отсеве. Фронт держит вкладку на поллинге -/// (reloadAll), SSE pipeline_stats не публикуется (Ruling 9). -/// /// Счётчики очереди по статусам (ключ «queue» ответа). /// Число записей в отсеве (ключ «rejected»). public sealed record PipelineStatsDto(QueueCountsDto Queue, int Rejected); diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/QueueCountsDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/QueueCountsDto.cs index 1d847c3..d31de31 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/QueueCountsDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/QueueCountsDto.cs @@ -1,26 +1,22 @@ namespace Deal.Modules.Pipeline.Application.Models; /// -/// Счётчики очереди — counts ответа GET /api/pipeline/queue и queue ответа GET /api/pipeline/stats (§3.6 L178–181, processing.queue_counts L207–215). +/// Счётчики очереди — counts ответа GET /api/pipeline/queue и queue ответа GET /api/pipeline/stats. /// -/// -/// new — строки со статусом new; ai — строки со статусом filtered (имя bucket'а прототипа — -/// очередь «на ИИ»); total = new + ai. Wire-ключи: new/ai/total (camelCase). -/// public sealed record QueueCountsDto { /// - /// Строк со статусом new (ждут правил/дедупа/ML). + /// Строк со статусом new /// public int New { get; init; } /// - /// Строк со статусом filtered (прошли «new»-проход, ждут ИИ). + /// Строк со статусом filtered /// public int Ai { get; init; } /// - /// Всего строк в очереди (new + ai). + /// Всего строк в очереди /// public int Total { get; init; } } diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/QueueItemDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/QueueItemDto.cs index 3ce2a7f..c52088d 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/QueueItemDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/QueueItemDto.cs @@ -3,19 +3,12 @@ using System.Text.Json.Serialization; namespace Deal.Modules.Pipeline.Application.Models; /// -/// Элемент очереди входящих — item ответа GET /api/pipeline/queue (§4.5 L308, processing.list_queue L218–241). +/// Элемент очереди входящих — item ответа GET /api/pipeline/queue. /// -/// -/// Поля 1:1 с §4.5 (фронт читает: id/text/status/ch/msgAt; dialogId/msgId/queuedAt — справочные). Status — -/// new (ждёт правил/дедупа/ML) | filtered (прошла «new»-проход, ждёт ИИ; в счётчиках — bucket -/// «ai»). Времена наружу epoch-ms; маппинг с DateTimeOffset-строкой (Ruling 1) выполняет адаптер хранилища. -/// — внутренний флаг строки «возвращено из отсева» (Ruling 2/8), в JSON не выходит -/// ([JsonIgnore]): прототипная запись pipeline_msg несёт force, но wire очередь её не отдаёт. -/// public sealed record QueueItemDto { /// - /// Короткий id строки очереди (префикс p_, Ruling 10). + /// Короткий id строки очереди. /// public string Id { get; init; } = string.Empty; @@ -25,40 +18,40 @@ public sealed record QueueItemDto public string DialogId { get; init; } = string.Empty; /// - /// Id исходного сообщения в Telegram (дубль-гвард приёма), либо null. + /// Id исходного сообщения в Telegram /// public long? MsgId { get; init; } /// - /// Текст сообщения (обрезан при приёме до 6000 символов, Ruling 2). + /// Текст сообщения. /// public string Text { get; init; } = string.Empty; /// - /// Статус строки: new | filtered (значения — как pipeline.py ST_NEW/ST_AI). + /// Статус строки: new | filtered. /// public string Status { get; init; } = string.Empty; /// - /// Канал-источник (выходит под ключом ch). + /// Канал-источник /// [property: JsonPropertyName("ch")] public PipelineChannelDto Channel { get; init; } = new(string.Empty, string.Empty, string.Empty); /// - /// Время исходного сообщения, epoch-ms (выходит под ключом msgAt). + /// Время исходного сообщения, epoch-ms /// [property: JsonPropertyName("msgAt")] public long MsgAtMs { get; init; } /// - /// Время постановки в очередь (= CreatedAt строки), epoch-ms (выходит под ключом queuedAt). + /// Время постановки в очередь /// [property: JsonPropertyName("queuedAt")] public long QueuedAtMs { get; init; } /// - /// Внутренний флаг «возвращено пользователем из отсева»: правила/устарело/ML для строки игнорируются (Ruling 2/8). + /// Внутренний флаг «возвращено пользователем из отсева» /// [JsonIgnore] public bool Force { get; init; } diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/QueuedMessage.cs b/src/core/Deal.Modules.Pipeline/Application/Models/QueuedMessage.cs index 614cc09..a3f7036 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/QueuedMessage.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/QueuedMessage.cs @@ -1,19 +1,12 @@ namespace Deal.Modules.Pipeline.Application.Models; /// -/// Команда приёма входящего сообщения — аргумент PipelineIngestService.EnqueueAsync (Ruling 2, прототип pipeline.enqueue L53–85). +/// Команда приёма входящего сообщения — аргумент PipelineIngestService.EnqueueAsync. /// -/// -/// Канальные поля плоские (как сигнатура enqueue: dialog_id/ch_name/ch_handle/ch_hue/msg_id/text/msg_at/force): -/// сервис приёма кладёт их в строку QueueItems (адаптер пишет колонки ChannelName/ChannelHandle/ChannelHue). -/// Пустой текст или нет dialogId → no-op (Ruling 2); текст обрезается до 6000; дубль-гвард dialog+msgId делает -/// сервис приёма до записи. может отсутствовать (демо-ingest не шлёт) — приём подставит now. -/// — возврат из отсева (Ruling 2/10): для строки игнорируются правила/устарело/ML-решения. -/// public sealed record QueuedMessage { /// - /// Id диалога-источника сообщения (обязателен, иначе no-op). + /// Id диалога-источника сообщения /// public string DialogId { get; init; } = string.Empty; @@ -28,17 +21,17 @@ public sealed record QueuedMessage public string ChannelHandle { get; init; } = string.Empty; /// - /// Цвет канала-источника (hex; null — дефолт #666). + /// Цвет канала-источника /// public string ChannelHue { get; init; } = string.Empty; /// - /// Id исходного сообщения в Telegram (дубль-гвард), либо null. + /// Id исходного сообщения в Telegram /// public long? MsgId { get; init; } /// - /// Текст сообщения (обрезается при приёме до 6000). + /// Текст сообщения /// public string Text { get; init; } = string.Empty; @@ -48,7 +41,7 @@ public sealed record QueuedMessage public long? MsgAtMs { get; init; } /// - /// Флаг «возвращено из отсева»: правила/устарело/ML для сообщения игнорируются (Ruling 2). + /// Флаг «возвращено из отсева» /// public bool Force { get; init; } } diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/ReclassifyResultDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/ReclassifyResultDto.cs index 30c7b0d..64664e3 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/ReclassifyResultDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/ReclassifyResultDto.cs @@ -1,21 +1,8 @@ namespace Deal.Modules.Pipeline.Application.Models; /// -/// Итог ручной переклассификации «Неразобранного»/одной карточки (POST /api/cards/reclassify|{id}/reclassify). +/// Итог ручной переклассификации «Неразобранного»/одной карточки /// -/// -/// Расширяет совместимый контракт-заглушку этапа 3 (started/busy/attempted) счётчиками исхода: -/// -/// — переклассификация выполнена (target непуст); false — нечего или занято; -/// — другой проход уже выполняется (single-flight, как фоновая задача прототипа); -/// — сколько карточек отобрано (batch — inbox, либо ids∩inbox); -/// — успешно обработано (moved + kept + trashed); -/// — ушло в доску, — осталось в inbox, — спам/не прошло фильтр; -/// — пропущено без обработки (нет исходного текста); -/// — хотя бы одна карточка разобрана через порт ИИ (иначе — локальный детерминированный разбор); -/// — понятная причина, когда проход не выполнен/нечего обрабатывать; иначе null. -/// -/// /// Переклассификация выполнена (target непуст). /// Проход уже выполняется другим запросом. /// Сколько карточек отобрано. diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/RejectRecord.cs b/src/core/Deal.Modules.Pipeline/Application/Models/RejectRecord.cs index cdf573d..b10ae95 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/RejectRecord.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/RejectRecord.cs @@ -1,20 +1,12 @@ namespace Deal.Modules.Pipeline.Application.Models; /// -/// Команда записи отсева — аргумент IPipelineStore.UpsertAsync (processing.record L66–101). +/// Команда записи отсева — аргумент IPipelineStore.UpsertAsync. /// -/// -/// Содержит всё, что нужно строке RejectedItems: текст/канал/время исходного сообщения + решение -/// (source/stage/reason/kw). Id строки детерминирован при наличии dialog+msgId (Ruling 1: r_<dialog>_<msgId>) -/// — см. ; иначе хранилище генерирует случайный r_+hex (как processing.record L77). -/// Повторное отбрасывание того же сообщения (dialog+msgId) обновляет запись, а не копит дубликаты (upsert -/// ON CONFLICT, Task 3). Пустой текст — no-op в хранилище. Ограничения длин (текст ≤6000, reason ≤500, -/// kw ≤200) соблюдает слой сервиса (Ruling 1/10). -/// public sealed record RejectRecord { /// - /// Id диалога-источника сообщения (пусто — детерминированного id нет). + /// Id диалога-источника сообщения /// public string DialogId { get; init; } = string.Empty; @@ -24,7 +16,7 @@ public sealed record RejectRecord public long? MsgId { get; init; } /// - /// Текст отсеянного сообщения (пустой — запись не создаётся). + /// Текст отсеянного сообщения /// public string Text { get; init; } = string.Empty; @@ -39,7 +31,7 @@ public sealed record RejectRecord public string ChannelHandle { get; init; } = string.Empty; /// - /// Цвет канала-источника (hex; дефолт #666). + /// Цвет канала-источника /// public string ChannelHue { get; init; } = string.Empty; @@ -49,27 +41,27 @@ public sealed record RejectRecord public long MsgAtMs { get; init; } /// - /// Источник решения: stop|ml|ai|stale|dup (Ruling 1; «система» = stale|dup). + /// Источник решения /// public string Source { get; init; } = string.Empty; /// - /// Этап отсева: length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup (api-map §4.5). + /// Этап отсева: length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup /// public string Stage { get; init; } = string.Empty; /// - /// Причина отсева (текст 1:1 с прототипом). + /// Причина отсева. /// public string Reason { get; init; } = string.Empty; /// - /// Конкретное слово/фраза стоп-списка, сработавшая правилом (пусто — не правило). + /// Конкретное слово/фраза стоп-списка, сработавшая правилом /// public string Kw { get; init; } = string.Empty; /// - /// Детерминированный id записи: r_<dialog>_<msgId> при наличии dialog+msgId, иначе null (хранилище берёт случайный). + /// Детерминированный id записи /// public string? DeterministicId => DialogId.Length > 0 && MsgId is not null ? $"r_{DialogId}_{MsgId}" : null; diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/RejectReturnResultDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/RejectReturnResultDto.cs index 682b68b..e91a618 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/RejectReturnResultDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/RejectReturnResultDto.cs @@ -3,14 +3,8 @@ using System.Text.Json.Serialization; namespace Deal.Modules.Pipeline.Application.Models; /// -/// Результат возврата отсеянного сообщения в обработку — POST /api/pipeline/rejected/{id}/return (processing.return_to_queue L128–193). +/// Результат возврата отсеянного сообщения в обработку — POST /api/pipeline/rejected/{id}/return. /// -/// -/// — текст 400 (уже возвращено / повтор-dup / нет текста), при успехе null. Успех 1:1 с -/// прототипом: {id, returned: true, returnedAt: ms} — id записи отсева (запись НЕ удаляется, помечается -/// returned + причина, аудит Ruling 10). Случай «записи нет» сервис возвращает null — эндпоинт отвечает 404 -/// «Запись не найдена» (текст 404 — слой эндпоинтов, паттерн CardsService → CardsEndpoints). -/// /// Текст 400 либо null — успех. /// Id записи отсева (успех; эхо возвращаемой записи). /// true — запись возвращена в обработку (успех). diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/RejectedItemDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/RejectedItemDto.cs index 7c17b6c..7819c9c 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/RejectedItemDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/RejectedItemDto.cs @@ -3,20 +3,12 @@ using System.Text.Json.Serialization; namespace Deal.Modules.Pipeline.Application.Models; /// -/// Элемент отсева — item ответа GET /api/pipeline/rejected (§4.5 L310–313, processing.list_rejected L246–312). +/// Элемент отсева — item ответа GET /api/pipeline/rejected. /// -/// -/// Поля 1:1 с §4.5: stage/stageLabel/reason/kw/source/sourceLabel и т.д. stage — этап отсева -/// (length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup), source — «чьё» решение -/// (stop|ml|ai|stale|dup). Подписи stageLabel/sourceLabel считает слой маппинга через -/// (prototype stage_label/source_label L40–45). returned/returnedAt/ -/// returnReason — аудит возврата из отсева (processing.return_to_queue L128–193; «Возврат» в UI неактивен при -/// source=dup или returned). Времена наружу epoch-ms (адаптер мапит DateTimeOffset-строку, Ruling 1). -/// public sealed record RejectedItemDto { /// - /// Id записи отсева (детерминированный r_<dialog>_<msgId> либо r_+hex, Ruling 1). + /// Id записи отсева. /// public string Id { get; init; } = string.Empty; @@ -41,61 +33,61 @@ public sealed record RejectedItemDto public string Stage { get; init; } = string.Empty; /// - /// Человекочитаемая подпись этапа для UI («короткое сообщение», «повтор», …). + /// Человекочитаемая подпись для UI /// public string StageLabel { get; init; } = string.Empty; /// - /// Причина отсева (текст 1:1 с прототипом, ≤500). + /// Причина отсева. /// public string Reason { get; init; } = string.Empty; /// - /// Конкретное слово/фраза стоп-списка, сработавшая правилом (≤200; пусто — не правило). + /// Конкретное слово/фраза стоп-списка, сработавшая правилом /// public string Kw { get; init; } = string.Empty; /// - /// Источник решения: stop|ml|ai|stale|dup. + /// Источник решения /// public string Source { get; init; } = string.Empty; /// - /// Подпись источника («правила»/«ML»/«ИИ»/«система», см. ). + /// Подпись источника /// public string SourceLabel { get; init; } = string.Empty; /// - /// Канал-источник (выходит под ключом ch). + /// Канал-источник /// [property: JsonPropertyName("ch")] public PipelineChannelDto Channel { get; init; } = new(string.Empty, string.Empty, string.Empty); /// - /// Время исходного сообщения, epoch-ms (выходит под ключом msgAt). + /// Время исходного сообщения, epoch-ms /// [property: JsonPropertyName("msgAt")] public long MsgAtMs { get; init; } /// - /// Время отсева, epoch-ms (выходит под ключом rejectedAt; сортировка списка DESC). + /// Время отсева, epoch-ms /// [property: JsonPropertyName("rejectedAt")] public long RejectedAtMs { get; init; } /// - /// Флаг «возвращено в обработку» (запись остаётся для аудита, не удаляется). + /// Флаг «возвращено в обработку» /// public bool Returned { get; init; } /// - /// Время возврата, epoch-ms (выходит под ключом returnedAt); null — не возвращалась. + /// Время возврата, epoch-ms /// [property: JsonPropertyName("returnedAt")] public long? ReturnedAtMs { get; init; } /// - /// Причина возврата, указанная пользователем (≤500; пусто — не задана). + /// Причина возврата, указанная пользователем /// public string ReturnReason { get; init; } = string.Empty; } diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/RejectedPageDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/RejectedPageDto.cs index d884404..c32b152 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/RejectedPageDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/RejectedPageDto.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Pipeline.Application.Models; /// -/// Страница отсева — тело GET /api/pipeline/rejected (api-map §3.6 L180, processing.list_rejected L246–312). +/// Страница отсева — тело GET /api/pipeline/rejected. /// -/// -/// Ответ 1:1 с прототипом: {items, total, offset, limit}. — число записей по условию: -/// без поиска — весь отсев (rejected_count), с поиском — размер объединения FTS- и LIKE-кандидатов (Ruling 6). -/// offset/limit — эхо запроса после clamp (offset ≥ 0, limit 1..500). -/// /// Записи страницы (полные item'ы отсева §4.5, порядок RejectedAt DESC). /// Всего записей по условию поиска. /// Сдвиг от начала страницы (эхо запроса). diff --git a/src/core/Deal.Modules.Pipeline/Application/Parse/AmountRangeBudgetFallback.cs b/src/core/Deal.Modules.Pipeline/Application/Parse/AmountRangeBudgetFallback.cs index dbe187f..a0feb45 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Parse/AmountRangeBudgetFallback.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Parse/AmountRangeBudgetFallback.cs @@ -4,24 +4,15 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Pipeline.Application.Parse; /// -/// Fallback бюджета карточки, когда разбор не выделил бюджет отдельным полем: первая сумма с валютой из -/// исходника или из структурированной «О заявке» (pipeline.py L459–468). +/// Fallback бюджета карточки, когда разбор не выделил бюджет отдельным полем /// -/// -/// ИИ не всегда выделяет бюджет отдельным полем, но сумма с валютой есть в тексте сообщения либо ушла в блок -/// «Условия» структурированной сути — показываем её на карточке. Тот же источник, что и фильтр «не создавать -/// карточку без суммы» (Ruling 4: AmountParser модуля Kanban, извлечение — только суммы с валютой). -/// Порядок источников 1:1 с прототипом: сначала текст сообщения, затем summary; берётся первый распознанный -/// диапазон. Возвращается форма хранения (валюта-код) — дальнейшую нормализацию делает вызывающий -/// (BudgetNormalizer.Normalize, CardComposer). -/// public static class AmountRangeBudgetFallback { /// - /// Ищет бюджет-«заглушку»: первую сумму с валютой среди источников (python L463–468). + /// Ищет бюджет-«заглушку» /// /// Текст исходного сообщения (первый источник). - /// Структурированная «О заявке» карточки (второй источник, python L463). + /// Структурированная «О заявке» карточки. /// Бюджет {from, to, cur} первой найденной суммы либо null — сумм с валютой нет. public static BudgetRangeDto? Extract(string? text, string? summary) { diff --git a/src/core/Deal.Modules.Pipeline/Application/Parse/CodePointExtensions.cs b/src/core/Deal.Modules.Pipeline/Application/Parse/CodePointExtensions.cs index b9fc474..fb6dfd5 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Parse/CodePointExtensions.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Parse/CodePointExtensions.cs @@ -6,7 +6,7 @@ namespace Deal.Modules.Pipeline.Application.Parse; internal static class CodePointExtensions { /// - /// Входит ли кодовая точка в эмодзи-диапазоны прототипа (pipeline.py _EMOJI_RE L137–145). + /// Входит ли кодовая точка в эмодзи-диапазоны. /// /// Кодовая точка (BMP или доп. плоскость). /// True — декоративный символ, подлежащий удалению. diff --git a/src/core/Deal.Modules.Pipeline/Application/Parse/ContactsQualifier.cs b/src/core/Deal.Modules.Pipeline/Application/Parse/ContactsQualifier.cs index 773507c..41e6d64 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Parse/ContactsQualifier.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Parse/ContactsQualifier.cs @@ -4,93 +4,65 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Pipeline.Application.Parse; /// -/// Квалификация контактов из разбора/текста сообщения (pipeline.py L344–430, _norm_phone L661–663, -/// _contacts_from L666–679). +/// Квалификация контактов из разбора/текста сообщения. /// -/// -/// Тип контакта — tg|phone|email|linkedin|whatsapp|site, формат записи — карточки -/// Kanban ({type, value}, Ruling 4). Отбрасываются боты (@…bot), сервисные t.me-ссылки -/// (joinchat/+/s/c/…), «постовые» сайты (teletype, google-формы, youtube и т.п.). собирает -/// до 6 записей с дедупликацией по значению (casefold); при отсутствии контактов в разборе пытается вытащить -/// кандидатов из текста (_contacts_from: @username, email, телефон). — основной -/// контакт карточки для быстрого действия (tg → phone → whatsapp → email → linkedin → site, python L424–430). -/// public static class ContactsQualifier { /// - /// Максимум записей контактов карточки (python build_contacts L419: ≤6, Ruling 4). + /// Максимум записей контактов карточки. /// public const int MaxContacts = 6; - // Максимум кандидатов из текста за один вызов (python _contacts_from L679: [:4]). internal const int MaxTextCandidates = 4; - // Максимум символов сырого контакта (python qualify_contact L358: len > 300 → None). private const int MaxContactLength = 300; - // Минимальная длина никнейма telegram (python L362/L367: {4,32}). private const int MinTgNameLength = 4; - // Максимальная длина никнейма telegram (python L362/L367: {4,32}). private const int MaxTgNameLength = 32; - // Ограничение нормализованного телефона (python _norm_phone L663: [:18]). internal const int MaxNormalizedPhoneLength = 18; - // Подсказка бота в конце ника: @…bot отбрасывается (python _TG_BOT_HINTS L345). private const string TgBotSuffix = "bot"; - // Сервисные t.me-ссылки: не контакты людей (python _TG_SERVICE_NAMES L346). private static readonly IReadOnlySet TgServiceNames = new HashSet(StringComparer.Ordinal) { "joinchat", "share", "s", "c", "addstickers", "addtheme", "proxy", "bg", "login", }; - // «Постовые» сайты-агрегаторы: не контакты (python _SKIP_SITE_HOSTS L347). private static readonly IReadOnlySet SkipSiteHosts = new HashSet(StringComparer.Ordinal) { "teletype.in", "forms.gle", "docs.google.com", "youtube.com", "youtu.be", "clck.ru", }; - // Ник в telegram: @имя (python L362). private static readonly Regex TelegramNameRe = new(@"\A[A-Za-z0-9_]{4,32}\z", RegexOptions.CultureInvariant); - // t.me-ссылка на профиль (python L366). private static readonly Regex TelegramLinkRe = new( @"\Ahttps?://(?:www\.)?t\.me/([A-Za-z0-9_]{4,32})/?\z", RegexOptions.CultureInvariant); - // E-mail (python L372). private static readonly Regex EmailRe = new( @"\A[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\z", RegexOptions.CultureInvariant); - // Телефон целиком: цифры/пробелы/дефисы/скобки, опциональный «+» (python L375). private static readonly Regex PhoneRe = new(@"\A\+?[\d\s\-()]{6,20}\z", RegexOptions.CultureInvariant); - // Ссылка LinkedIn на профиль (python L377). private static readonly Regex LinkedinRe = new(@"linkedin\.com/in/", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - // Ссылка WhatsApp (python L379). private static readonly Regex WhatsappRe = new(@"wa\.me|api\.whatsapp\.com", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - // Хост сайта из URL (python L382). private static readonly Regex SiteHostRe = new(@"https?://(?:www\.)?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - // Никнеймы в тексте: @имя (python _CONTACT_RE L601). private static readonly Regex AtNameRe = new(@"@[A-Za-z0-9_]{3,}", RegexOptions.CultureInvariant); - // E-mail в тексте (python _EMAIL_RE L602). private static readonly Regex EmailInTextRe = new( @"[A-Za-z0-9._%+\-]+@[A-Za-z0-9\-]+(?:\.[A-Za-z0-9\-]+)+", RegexOptions.CultureInvariant); - // Телефон в тексте (python _PHONE_RE L603). private static readonly Regex PhoneInTextRe = new( @"(?:\+7|8|7)[\s\-()]*\d{3}[\s\-()]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}", RegexOptions.CultureInvariant); - // Приоритеты основного контакта: tg → phone → whatsapp → email → linkedin → site (python L426). private static readonly IReadOnlyDictionary PrimaryOrder = new Dictionary(StringComparer.Ordinal) { ["tg"] = 0, @@ -101,17 +73,13 @@ public static class ContactsQualifier ["site"] = 5, }; - // Приоритет неизвестного типа (не встречается — fallback на всякий случай, python L429: 9). private const int UnknownTypePriority = 9; /// - /// Классифицирует один сырой контакт → {type, value} или null (python qualify_contact L350–386). + /// Классифицирует один сырой контакт → {type, value} или null. /// /// Сырое значение контакта («@user», «https://t.me/x», телефон, email, ссылка). - /// - /// Квалифицированный контакт либо null — пусто/мусор/бот/сервисная ссылка/ - /// «постовый» сайт (python: None). - /// + /// Квалифицированный контакт либо null — пусто/мусор/бот/сервисная ссылка/ «постовый» сайт. public static CardContactDto? Qualify(string? raw) { string s = (raw ?? string.Empty).Trim(); @@ -183,7 +151,7 @@ public static class ContactsQualifier } /// - /// Собирает квалифицированные контакты из разбора/текста (python build_contacts L389–421). + /// Собирает квалифицированные контакты из разбора/текста. /// /// Сырые контакты разбора: строка со значениями через ;/|/перенос. /// Исходный текст сообщения — кандидаты, если в разборе контактов нет. @@ -193,8 +161,6 @@ public static class ContactsQualifier var candidates = new List(); if (contacts is not null) { - // python build_contacts L396–397: строка разбивается по разделителям; пустая строка даёт [''] — - // «кандидаты есть», текст НЕ извлекаем (1:1, L406: if not cands and text). candidates.AddRange(Regex.Split(contacts, @"[;|\n]+", RegexOptions.CultureInvariant)); if (candidates.Count == 0) { @@ -206,8 +172,7 @@ public static class ContactsQualifier } /// - /// Собирает квалифицированные контакты из списка значений разбора (python build_contacts L396–405: - /// элементы строки могут нести разделители — разбиваются). + /// Собирает квалифицированные контакты из списка значений разбора. /// /// Список сырых значений контактов (пустой/null — извлечение из текста). /// Исходный текст сообщения — кандидаты, если список пуст. @@ -224,7 +189,6 @@ public static class ContactsQualifier continue; } - // python: элемент списка-строки разбивается разделителями (L398). candidates.AddRange(Regex.Split(value, @"[;|\n]+", RegexOptions.CultureInvariant)); } } @@ -233,7 +197,7 @@ public static class ContactsQualifier } /// - /// Основной контакт карточки — значение с наименьшим приоритетом (python primary_contact L424–430). + /// Основной контакт карточки — значение с наименьшим приоритетом. /// /// Квалифицированные контакты (см. ). /// Значение основного контакта или пустая строка, если контактов нет. @@ -260,7 +224,7 @@ public static class ContactsQualifier } /// - /// Извлекает кандидатов контактов из текста: @username, e-mail, телефон (python _contacts_from L666–679). + /// Извлекает кандидатов контактов из текста /// /// Текст сообщения или значение метки «Контакты: …». /// До сырых кандидатов в порядке появления (телефон — нормализован). @@ -274,7 +238,6 @@ public static class ContactsQualifier return result.Count > MaxTextCandidates ? result.Take(MaxTextCandidates).ToList() : result; } - // Нормализует телефон: убирает пробелы/неразрывные пробелы/дефисы/скобки (python _norm_phone L661–663). // phone: Телефон как встретился в тексте. // Возвращает: Нормализованный телефон (≤MaxNormalizedPhoneLength символов). internal static string NormalizePhone(string phone) @@ -289,7 +252,6 @@ public static class ContactsQualifier : normalized; } - // Приоритет типа контакта для Primary (python L426–428). // type: Тип контакта (tg/phone/whatsapp/email/linkedin/site). // Возвращает: Приоритет (меньше — важнее); неизвестный тип — UnknownTypePriority. private static int OrderOf(string type) @@ -297,7 +259,6 @@ public static class ContactsQualifier return PrimaryOrder.TryGetValue(type, out int order) ? order : UnknownTypePriority; } - // Квалифицирует кандидатов и собирает результат: дедуп по casefold-значению, лимит (python L408–420). // candidates: Сырые кандидаты. // text: Текст сообщения для извлечения кандидатов, если список пуст. // Возвращает: Квалифицированные контакты без дублей (≤MaxContacts). @@ -334,7 +295,6 @@ public static class ContactsQualifier return result; } - // Добавляет в список только отсутствующие значения (python «if m not in out» L669–678). // result: Список-накопитель. // values: Найденные совпадения. private static void AddUnique(List result, IEnumerable values) diff --git a/src/core/Deal.Modules.Pipeline/Application/Parse/DedupHasher.cs b/src/core/Deal.Modules.Pipeline/Application/Parse/DedupHasher.cs index 1fcc472..82f2709 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Parse/DedupHasher.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Parse/DedupHasher.cs @@ -4,20 +4,12 @@ using System.Text; namespace Deal.Modules.Pipeline.Application.Parse; /// -/// Хэш текста для дедупликации сообщений (ai.py normalize_dedup L261–267; Ruling 7: хэш-ключ -/// DedupEntries без префикса). +/// Хэш текста для дедупликации сообщений. /// -/// -/// Нормализация 1:1 с прототипом: текст приводится к нижнему регистру, удаляются все символы, кроме -/// «словесных» (буквы, включая кириллицу, цифры, подчёркивание — python [^\\wа-яё]+; регистр и -/// пунктуация/пробелы на хэш не влияют — «Тест!» ≡ «тест», «Привет мир» ≡ «Привет мир»). Итог — SHA1-hex -/// нормализованной строки в UTF-8 (прототип использует hashlib.sha1; Task 2/3: без префикса, колонка -/// Hash). Ссылки/служебный текст НЕ вырезаются отдельно — их буквы входят в нормализованную строку (1:1). -/// public static class DedupHasher { /// - /// Хэширует текст сообщения для проверки «сообщение уже в системе» (python normalize_dedup L261–267). + /// Хэширует текст сообщения для проверки «сообщение уже в системе». /// /// Текст сообщения; null/пустой — как пустая строка. /// SHA1-hex (32 символа) нормализованного текста; детерминирован для равных по регистру/пунктуации текстов. @@ -28,8 +20,6 @@ public static class DedupHasher return Convert.ToHexString(hash).ToLowerInvariant(); } - // Нормализация текста для дедупа: только буквы/цифры/подчёркивание (python [^\wа-яё]+, - // L266) затем lowercase — по кодовым точкам, как python-casefold (суррогатные пары целиком). // text: Исходный текст. // Возвращает: Нормализованная строка без регистра, пробелов и пунктуации. private static string Normalize(string text) diff --git a/src/core/Deal.Modules.Pipeline/Application/Parse/LocalFieldsParser.cs b/src/core/Deal.Modules.Pipeline/Application/Parse/LocalFieldsParser.cs index 49a3234..2d6d8d8 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Parse/LocalFieldsParser.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Parse/LocalFieldsParser.cs @@ -8,63 +8,41 @@ using Deal.Modules.Settings.Application.Models; namespace Deal.Modules.Pipeline.Application.Parse; /// -/// Локальный структуратор сообщения без ИИ (pipeline.py _local_fields L718–798, _field_of L686–698, -/// метки L591–596, маркеры/токены L597–612): заголовок, суть, стек, грейд, бюджет, контакты, признак вакансии. +/// Локальный структуратор сообщения без ИИ /// -/// -/// Карточка при локальном пути (aiEnabled=false / сбой ИИ) не должна выглядеть как сырое сообщение: по меткам -/// «Стек:/Грейд:/Контакты:/Бюджет:» (синонимы ) с fallback-извлечениями по тексту -/// заполняются поля. Маркеры найма (hireMarkers) и термины грейдов (levelTerms) — настройки -/// тенанта: читает дефолты , перекрытые сохранёнными -/// (нормализация trim+lowercase как в IncomingRules); чистое ядро — статический . -/// is_vacancy — маркерная гипотеза: is_vacancy_known=false, колонка не назначается (board=null, -/// python L796–797). Резюме-маркеры здесь не нужны — отсев резюме выполняет этап-1 фильтр (IncomingRules). -/// public sealed class LocalFieldsParser(ISettingsStore store) { - // Лимит заголовка (python L777: clean_short(…, 140)). private const int TitleLimit = 140; - // Лимит сырых контактов карточки (python L782: контакты[:200]). private const int ContactsLimit = 200; - // Максимум грейдов в результате (python L792: grade[:4]). internal const int MaxGrades = 4; - // Максимум токенов, выбираемых из значения метки «Стек:» (python _pick_stack L713: 10). private const int MaxPickTokens = 10; - // Максимум поля стека в результате (python L791: stack[:12], Ruling 4). private const int MaxStackResult = 12; - // Строка метки: «Метка: значение» / «Метка| значение» (python _LABEL_RE L599). private static readonly Regex LabelRe = new( @"^[\s*>#_~]*([А-Яа-яЁёA-Za-z][А-Яа-яЁёA-Za-z0-9 /+\-]{1,36}?)\s*[:|]\s*(.+)$", RegexOptions.CultureInvariant); - // Токены значения: слова/названия с цифрами, «#», «.» внутри (python _TOKEN_RE L600). private static readonly Regex TokenRe = new( @"(?:[A-Za-zА-Яа-яЁё0-9][A-Za-zА-Яа-яЁё0-9#.+\-]*|\.[A-Za-zА-Яа-яЁё][A-Za-zА-Яа-яЁё0-9#.+\-]*)", RegexOptions.CultureInvariant); - // Fallback стека: метка «Стек: …» не в начале строки (однострочные объявления, python L755). private static readonly Regex InlineStackRe = new( @"\b(?:стек|технологии|скиллы|скилы|языки|язык|инструменты)\s*[:|]\s*([^\n]{2,120})", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.CultureInvariant); - // Метки-поля: категория → синонимы меток (python _FIELD_LABELS L591–596, порядок категорий 1:1). private static readonly IReadOnlyList<(string Category, IReadOnlySet Synonyms)> FieldLabels = BuildFieldLabels(); - // Знаки пунктуации, обрезаемые у слова при fallback-поиске грейда (python L761). private static readonly char[] WordTrimChars = ",;.:«»\"'()".ToCharArray(); /// - /// Разбирает текст локальным структуратором по настройкам тенанта (python _local_fields L718–798 + - /// чтение hireMarkers/levelTerms L617–632). + /// Разбирает текст локальным структуратором по настройкам тенанта. /// /// Текст сообщения. - /// Токен отмены. /// Локальные поля карточки (маркерная гипотеза типа, известность=false). public async Task ParseAsync(string? text, CancellationToken ct) { @@ -73,8 +51,7 @@ public sealed class LocalFieldsParser(ISettingsStore store) } /// - /// Разбирает текст по ПЕРЕДАННОМУ типизированному снимку настроек (снимок прохода воркера читает - /// таблицу настроек ОДИН раз на pump, а не на каждое сообщение — см. PipelineWorkerService.LoadRunSettingsAsync). + /// Разбирает текст по ПЕРЕДАННОМУ типизированному снимку настроек /// /// Текст сообщения. /// Типизированный снимок настроек тенанта на проход pump. @@ -89,7 +66,7 @@ public sealed class LocalFieldsParser(ISettingsStore store) } /// - /// Чистое ядро локального разбора (1:1 _local_fields L718–798). + /// Чистое ядро локального разбора. /// /// Текст сообщения; null — пустая строка (как text or ""). /// Маркеры найма (как сохранены или дефолты; нормализуются внутри). @@ -153,7 +130,6 @@ public sealed class LocalFieldsParser(ISettingsStore store) } } - // Fallback-извлечения по всему тексту (если объявление без меток, python L750–766). if (contacts.Count == 0) { contacts.AddRange(ContactsQualifier.ExtractFromText(body)); @@ -205,7 +181,6 @@ public sealed class LocalFieldsParser(ISettingsStore store) bool isVacancy = ContainsAny(lower, hire); string contactsText = MessageTextCleaner.SliceCodePoints(string.Join("; ", contacts), ContactsLimit); - // Дедуп стека без учёта регистра (python L784–787), лимиты стека/грейда (L791–792). var stack = new List(); foreach (string token in stackTokens) { @@ -238,7 +213,6 @@ public sealed class LocalFieldsParser(ISettingsStore store) return new LocalParsedFields(title, summary, resultStack, resultGrades, budget, contactsText, isVacancy); } - // Разбирает строку «Метка: значение» → (категория, значение) или null (python _field_of L686–698). // line: Строка текста (очищенная). // Возвращает: Категория поля и значение метки; null — это не метка-поле. internal static (string Category, string Value)? FieldOf(string line) @@ -267,7 +241,6 @@ public sealed class LocalFieldsParser(ISettingsStore store) return null; } - // Слова из значения метки стека: значимые токены без стоп-слов (python _pick_stack L701–715). // value: Значение метки («Стек: Java, Kotlin» → «Java, Kotlin»). // Возвращает: Токены-технологии (до MaxPickTokens). private static IReadOnlyList PickStackTokens(string value) @@ -291,7 +264,6 @@ public sealed class LocalFieldsParser(ISettingsStore store) return tokens; } - // Токены строки по токенизатору прототипа (python _TOKEN_RE.findall L600). // value: Строка значения. // Возвращает: Все совпадения токенов. private static IEnumerable TokensOf(string value) @@ -302,7 +274,6 @@ public sealed class LocalFieldsParser(ISettingsStore store) } } - // Первая распознанная сумма в тексте (python L769–775: extract_amounts, первый элемент). // source: Текст (значение метки «Бюджет:» либо всё тело). // Возвращает: Бюджет формы хранения (валюта-код) или null — суммы с валютой нет. private static BudgetRangeDto? FirstAmount(string source) @@ -317,7 +288,6 @@ public sealed class LocalFieldsParser(ISettingsStore store) return new BudgetRangeDto(first.From, first.To, first.Cur); } - // Нормализует маркеры для сравнения: trim + lowercase, пустые отбрасываются (как IncomingRules L338–351). // markers: Список маркеров как сохранён (или дефолт); null — пусто. // Возвращает: Нормализованный набор (порядок исходного, без дублей). private static IReadOnlySet NormalizeMarkers(IReadOnlyCollection? markers) @@ -340,7 +310,6 @@ public sealed class LocalFieldsParser(ISettingsStore store) return result; } - // Содержит ли текст хотя бы один маркер (python «any(mk in lower …)», L781). // lower: Текст в нижнем регистре. // markers: Нормализованные маркеры. // Возвращает: True — найден хотя бы один маркер. @@ -357,7 +326,6 @@ public sealed class LocalFieldsParser(ISettingsStore store) return false; } - // Категория поля «Стек:». Константа-строка (свитч по категориям меток, python-дикт L591–596). private const string FieldCategoryStack = "stack"; // Категория поля «Грейд:». Константа-строка (свитч по категориям меток). @@ -369,7 +337,6 @@ public sealed class LocalFieldsParser(ISettingsStore store) // Категория поля «Бюджет:». Константа-строка (свитч по категориям меток). private const string FieldCategoryBudget = "budget"; - // Собирает каталог меток-полей (python _FIELD_LABELS L591–596, порядок категорий 1:1: словарь-дикт). // Возвращает: Список: категория → синонимы меток (нижний регистр). private static IReadOnlyList<(string Category, IReadOnlySet Synonyms)> BuildFieldLabels() { diff --git a/src/core/Deal.Modules.Pipeline/Application/Parse/MessageListNormalizer.cs b/src/core/Deal.Modules.Pipeline/Application/Parse/MessageListNormalizer.cs index 1f9f345..bd8a98c 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Parse/MessageListNormalizer.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Parse/MessageListNormalizer.cs @@ -3,35 +3,23 @@ using System.Text.RegularExpressions; namespace Deal.Modules.Pipeline.Application.Parse; /// -/// Нормализация списков из ответов разбора: разделители, мусорные элементы, лимиты (pipeline.py -/// normalize_list L317–329, normalize_stack L332–341; стоп-слова стека L604–610). +/// Нормализация списков из ответов разбора /// -/// -/// ИИ/локальный разбор возвращает список, иногда строку («Java, Kotlin») — строку разбиваем по -/// ;/|/переносам (запятая НЕ разделитель — 1:1 с прототипом); элементы чистятся от обрамляющих -/// *`#, запятых и мусора, элементы короче 2 символов отбрасываются, дубликаты схлопываются. -/// дополнительно отсеивает одиночные буквы и ограничивает стек 12 элементами. -/// Стоп-слова () — общеупотребительные слова, не являющиеся технологией/услугой; -/// используются локальным разбором метки «Стек: …» (python _STOP_STACK L604–610). -/// public static class MessageListNormalizer { /// - /// Максимум элементов стека (python normalize_stack L340, Ruling 4: ≤12). + /// Максимум элементов стека. /// public const int MaxStackItems = 12; - // Разделители элементов строкового списка (python normalize_list L322). private static readonly Regex ListSeparatorsRe = new(@"[;|\n]+", RegexOptions.CultureInvariant); - // Пробел перед висящей пунктуацией в конце элемента (python L326). private static readonly Regex TrailingPunctSpaceRe = new(@"\s+([.,])\s*$", RegexOptions.CultureInvariant); - // Стоп-слова стека: не технологии/услуги, а связки и общие слова объявлений (python _STOP_STACK L604–610). private static readonly IReadOnlySet StopWordsSet = BuildStopWords(); /// - /// Нормализует строковый список/одиночную строку (python normalize_list L317–329). + /// Нормализует строковый список/одиночную строку. /// /// Строка-список («Java; Kotlin») или null. /// Элементы списка (очищенные, без дублей и мусора). @@ -46,8 +34,7 @@ public static class MessageListNormalizer } /// - /// Нормализует список элементов (python normalize_list над list L320–329: элементы НЕ разбиваются - /// по разделителям — это сделал разбор). + /// Нормализует список элементов. /// /// Список элементов или null. /// Очищенные элементы без дублей и мусора. @@ -57,8 +44,7 @@ public static class MessageListNormalizer } /// - /// Стек из строкового значения (python normalize_stack L332–341): элементы короче 2 символов — не - /// технология, максимум . + /// Стек из строкового значения /// /// Строка-список стека или null. /// Стек (≤12 элементов). @@ -68,7 +54,7 @@ public static class MessageListNormalizer } /// - /// Стек из списка элементов (python normalize_stack L332–341). + /// Стек из списка элементов. /// /// Список технологий/направлений или null. /// Стек (≤12 элементов). @@ -78,13 +64,11 @@ public static class MessageListNormalizer } /// - /// Стоп-слова стека (python _STOP_STACK L604–610): общеупотребительные слова, не являющиеся - /// технологией/услугой — локальный разбор метки «Стек: …» их отбрасывает (_pick_stack L701–715). + /// Стоп-слова стека /// public static IReadOnlySet StackStopWords => StopWordsSet; // Очищает части списка: обрезка, снятие обрамляющих *`#, висящих запятых/точек, - // отбрасывание пустых/односимвольных и дублей (python L324–328). // parts: Сырые части (после разбиения или элементы списка). // Возвращает: Очищенный список. private static IReadOnlyList CleanParts(IEnumerable parts) @@ -104,7 +88,6 @@ public static class MessageListNormalizer return result; } - // Отбирает стек: одиночные буквы/мусор — не технология (python L335–337), лимит 12 (L339–340). // items: Нормализованные элементы списка. // Возвращает: Стек ≤12 элементов длиной ≥2. private static IReadOnlyList TakeStack(IReadOnlyList items) @@ -127,7 +110,6 @@ public static class MessageListNormalizer return stack; } - // Собирает набор стоп-слов стека (python _STOP_STACK L604–610). // Возвращает: Набор слов в нижнем регистре. private static IReadOnlySet BuildStopWords() { diff --git a/src/core/Deal.Modules.Pipeline/Application/Parse/MessageTextCleaner.cs b/src/core/Deal.Modules.Pipeline/Application/Parse/MessageTextCleaner.cs index a55c9c4..e57fb4c 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Parse/MessageTextCleaner.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Parse/MessageTextCleaner.cs @@ -4,82 +4,54 @@ using System.Text.RegularExpressions; namespace Deal.Modules.Pipeline.Application.Parse; /// -/// Чистка текстовых полей сообщения/карточки от markdown-разметки, ссылок и служебных символов -/// (pipeline.py clean_short L148–156 / clean_block L158–193, регэкспы/эмодзи L131–145). +/// Чистка текстовых полей сообщения/карточки от markdown-разметки, ссылок и служебных символов. /// -/// -/// 1:1 с прототипом: markdown-ссылки [текст](url) → текст, **__`~~-пары снимаются, -/// ||спойлер|| и голые URL удаляются, «C#»/«F#» защищаются от вырезания решётки (письмо + #), -/// эмодзи-диапазоны, маркеры списков в начале строк и лишние переносы/пробелы схлопываются. -/// дополнительно переводит переносы в пробелы (заголовок); сохраняет структуру строк -/// («О заявке» карточки). — обрезка краёв строки (python _clean_line L682–683). -/// public static class MessageTextCleaner { - // Защитный символ, временно заменяющий решётку после буквы («C#», прототип L172/L176). private const string HashGuard = "\u2063"; - // Символ zero-width space (фото-превью Telegram), удаляется (прототип L173). private const string ZeroWidthSpace = "\u200b"; - // Неразрывный пробел, заменяется обычным (прототип L174). private const string NonBreakingSpace = "\u00a0"; - // Markdown-ссылка [текст](url) → текст ссылки (прототип _MD_LINK_RE L131). private static readonly Regex MarkdownLinkRe = new(@"\[([^\]]*)\]\([^)\s]+\)", RegexOptions.CultureInvariant); - // Жирный **текст** (прототип _MD_BOLD L598). private static readonly Regex MarkdownBoldRe = new(@"\*\*(.+?)\*\*", RegexOptions.CultureInvariant); - // Жирный __текст__ (прототип _MD_BOLD2_RE L132). private static readonly Regex MarkdownBold2Re = new(@"__([^_\n]+?)__", RegexOptions.CultureInvariant); - // Код `текст` (прототип _MD_CODE_RE L133). private static readonly Regex MarkdownCodeRe = new(@"`([^`\n]+?)`", RegexOptions.CultureInvariant); - // Зачёркнутый ~~текст~~ (прототип _MD_STRIKE_RE L134). private static readonly Regex MarkdownStrikeRe = new(@"~~([^~\n]+?)~~", RegexOptions.CultureInvariant); - // Голая URL-ссылка (прототип _BARE_URL_RE L135). private static readonly Regex BareUrlRe = new(@"https?://[^\s<>""']+", RegexOptions.CultureInvariant); - // Защита решётки в составе названия: «C#»/«F#» (прототип L172). private static readonly Regex HashProtectRe = new(@"\b([A-Za-zА-Яа-яЁё])\#", RegexOptions.CultureInvariant); - // Служебные символы markdown в тексте (прототип L175). private static readonly Regex MarkdownSymbolsRe = new("[*`#>~]+", RegexOptions.CultureInvariant); - // Маркеры списков/декоративные буллеты в начале строк (прототип L179). private static readonly Regex LineStartMarkersRe = new(@"(?m)^[\s>#*\-–—•▪▫●○‣]+\s*", RegexOptions.CultureInvariant); - // Схлопывание пробелов и табуляций (прототип L180). private static readonly Regex SpaceCollapseRe = new(@"[ \t]+", RegexOptions.CultureInvariant); - // Пробелы/табуляции после переноса строки (прототип L181). private static readonly Regex LineIndentRe = new(@"\n[ \t]+", RegexOptions.CultureInvariant); - // Подряд идущие переносы строк → один (прототип L182). private static readonly Regex MultiNewlineRe = new(@"\n{2,}", RegexOptions.CultureInvariant); - // Переносы строк в пробелы для clean_short (прототип L155). private static readonly Regex NewlinesToSpaceRe = new(@"\n+", RegexOptions.CultureInvariant); - // Служебные символы на краях строки (python _MD_EDGES L597). private static readonly Regex LineEdgesRe = new(@"^[\s*>#_~]+|[\s*>#_~]+$", RegexOptions.CultureInvariant); - // Символы, обрезаемые с краёв готового блока (прототип L183). private static readonly char[] EdgeTrimChars = " \t\n\r-–—·•|:;,".ToCharArray(); // Одноразовые кодовые точки-разделители для ручного прохода символов. private const int EmptyCodePoint = -1; /// - /// Чистит текстовое поле в одну строку (заголовок, суть без структуры): как , - /// но переносы строк схлопываются в пробелы (python clean_short L148–156). + /// Чистит текстовое поле в одну строку /// /// Сырой текст (markdown/ссылки/эмодзи); null → пустая строка (как str(text or "")). - /// Максимум кодовых точек результата; обрезка по границе переноса/пробела с многоточием - /// (L184–192); null/0 — без обрезки. + /// Максимум кодовых точек результата; обрезка по границе переноса/пробела с многоточием; null/0 — без обрезки. /// Очищенный однострочный текст. public static string CleanShort(string? text, int? limit = null) { @@ -87,18 +59,14 @@ public static class MessageTextCleaner } /// - /// Чистит блок текста с сохранением переносов строк (python clean_block L158–193): применяет все - /// шаги прототипа в том же порядке (markdown → URL → защита «C#» → эмодзи → маркеры списков → пробелы → - /// обрезка краёв → лимит по границе). + /// Чистит блок текста с сохранением переносов строк /// /// Сырой текст; null → пустая строка. - /// Максимум кодовых точек результата; обрезка по последнему переносу/пробелу ближе - /// середины лимита, иначе жёсткая по лимиту; в конец добавляется «…». null/0 — без обрезки. + /// Максимум кодовых точек результата; обрезка по последнему переносу/пробелу ближе середины лимита, иначе жёсткая по лимиту; в конец добавляется «…». null/0 — без обрезки. /// Очищенный текст с сохранённой структурой строк. public static string CleanBlock(string? text, int? limit = null) { string s = text ?? string.Empty; - // Порядок 1:1 с прототипом (L163–183). s = MarkdownLinkRe.Replace(s, m => m.Groups[1].Value.Trim()); s = MarkdownBoldRe.Replace(s, "$1"); s = MarkdownBold2Re.Replace(s, "$1"); @@ -126,17 +94,15 @@ public static class MessageTextCleaner } /// - /// Обрезает строку по краевым служебным символам markdown (python _clean_line L682–683): - /// убирает [\s>*#_~] с краёв и тримит. + /// Обрезает строку по краевым служебным символам markdown /// - /// Строка текста (может быть null — как None в python). + /// Строка текста. /// Строка без краевого мусора (пустая, если мусора было больше). public static string CleanLine(string? line) { return LineEdgesRe.Replace(line ?? string.Empty, string.Empty).Trim(); } - // Количество кодовых точек в строке (python len() — позиции суррогатных пар считаются одной). // value: Строка (не null). // Возвращает: Число кодовых точек. internal static int CountCodePoints(string value) @@ -154,7 +120,6 @@ public static class MessageTextCleaner return count; } - // Первые max кодовых точек строки (python-срез s[:max] без разрыва // суррогатных пар). // value: Строка. // max: Максимум кодовых точек; ≤0 или ≥ длины — строка как есть. @@ -191,7 +156,6 @@ public static class MessageTextCleaner return builder.ToString(); } - // Убирает декоративные эмодзи/символы-маркеры (python _EMOJI_RE L137–145): доп. пиктограммы // 1F000–1FAFF (включая региональные флаги 1F1E6–1F1FF), разные символы 2600–27BF, стрелки 2B00–2BFF // и variation selector FE0F. // value: Текст после снятия markdown-разметки. @@ -249,7 +213,6 @@ public static class MessageTextCleaner } // Обрезка по границе последнего переноса/пробела в первых limit кодовых точках - // (прототип L184–192): выбирается перенос (или пробел), если он после середины лимита; иначе режем жёстко. // value: Текст длиннее лимита. // limit: Лимит кодовых точек. // Возвращает: Усечённый текст с многоточием в конце. diff --git a/src/core/Deal.Modules.Pipeline/Application/Parse/StringExtensions.cs b/src/core/Deal.Modules.Pipeline/Application/Parse/StringExtensions.cs index b55db15..9d0b237 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Parse/StringExtensions.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Parse/StringExtensions.cs @@ -5,7 +5,6 @@ namespace Deal.Modules.Pipeline.Application.Parse; /// internal static class StringExtensions { - // Служебные строки/фразы футеров агрегаторов: «суть» с ними — не структура, а шум (python L288–291). private static readonly string[] FooterHintsArray = { "откликнуться через", "runello", "больше вакансий", "teletype", "при отклике укажите", @@ -13,7 +12,7 @@ internal static class StringExtensions }; /// - /// Содержит ли текст служебный футер-хинт (python L267/L275: сравнение с casefold-текстом). + /// Содержит ли текст служебный футер-хинт. /// /// Текст (в любом регистре; null трактуется как пустая строка). /// True — текст похож на футер агрегатора/служебную строку. diff --git a/src/core/Deal.Modules.Pipeline/Application/Parse/SummaryComposer.cs b/src/core/Deal.Modules.Pipeline/Application/Parse/SummaryComposer.cs index 34d8df6..34ba04a 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Parse/SummaryComposer.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Parse/SummaryComposer.cs @@ -3,48 +3,32 @@ using Deal.Modules.Pipeline.Application.Models; namespace Deal.Modules.Pipeline.Application.Parse; /// -/// Сборка блока «О заявке» карточки (pipeline.py compose_summary L225–284, _local_summary L294–314, -/// футер-хинты L288–291). +/// Сборка блока «О заявке» карточки. /// -/// -/// Карточка всегда собирается из одних и тех же блоков — Компания → Формат → О задаче → Требования → -/// Будет плюсом → Условия (1:1 с cardPrompt); недостающие блоки пропускаются. Если структурированных полей нет -/// ( от локального/старого разбора) — суть сохраняется как есть, но -/// отбрасывается, когда похожа на служебный футер агрегаторов (); тогда «О задаче» -/// собирается из содержательных строк текста (, с префиксом «О задаче: »). -/// public static class SummaryComposer { - // Лимит пунктов «Требования» в блоке (python L255: req[:14]). private const int MaxRequirementsItems = 14; - // Лимит пунктов «Будет плюсом» в блоке (python L257: plus[:10]). private const int MaxPlusItems = 10; - // Максимум содержательных строк локальной сути (python _local_summary L308: 4). private const int MaxSummaryParts = 4; - // Минимальная длина сути без fallback на весь текст (python L311: < 40 → clean_short(body, 360)). private const int MinSummaryLength = 40; - // Лимит fallback-сути из всего текста (python L313: clean_short(body, 360)). private const int SummaryFallbackLimit = 360; - // Лимит итоговой сути (python L314: out[:600]). private const int MaxSummaryLength = 600; // Сколько первых строк списка пропускает локальная суть по умолчанию — первая строка это заголовок - // (python _local_summary skip_first=1 L294). private const int DefaultSkipFirst = 1; - // Однострочные слова-заглушки, не несущие сути (python L305). private static readonly IReadOnlySet TrivialWords = new HashSet(StringComparer.Ordinal) { "вакансия", "вакансию", "фриланс", }; /// - /// Собирает «О заявке» из структурированных полей либо текста (python compose_summary L225–284). + /// Собирает «О заявке» из структурированных полей либо текста. /// /// Структура разбора: блоки Компания→…→Условия и/или legacy-суть; null — пустая структура. /// Исходный текст сообщения (источник локального пути «О задаче: …»). @@ -96,14 +80,12 @@ public static class SummaryComposer } // Структурированных полей нет. «summary» разбора часто является копией исходника/шумом — если в нём есть - // футеры/хэштеги-мусор, не используем его (python L264–268). string legacy = MessageTextCleaner.CleanShort(source.Summary); if (legacy.Length > 0 && !legacy.ContainsFooterHint()) { return legacy; } - // Локальный путь без ИИ: «О задаче» из содержательных строк (без хэштег-строк и футеров, python L271–283). var keep = new List(); foreach (string rawLine in (text ?? string.Empty).Split('\n')) { @@ -139,8 +121,7 @@ public static class SummaryComposer } /// - /// Суть карточки при локальном разборе: содержательные строки после заголовка, без меток-полей и - /// мусора (python _local_summary L294–314). + /// Суть карточки при локальном разборе /// /// Весь очищенный текст (источник fallback-сути). /// Очищенные непустые строки текста (первая — заголовок). @@ -176,7 +157,6 @@ public static class SummaryComposer string summary = string.Join(" ", parts); if (MessageTextCleaner.CountCodePoints(summary) < MinSummaryLength) { - // Мало содержательных строк — берём очищенное начало всего текста (python L311–313). summary = MessageTextCleaner.CleanShort(body ?? string.Empty, SummaryFallbackLimit); } @@ -184,7 +164,6 @@ public static class SummaryComposer } // Нормализует пункты блока: элементы списка чистятся как в normalize_list(list), затем каждый — - // clean_short (python _items L235–241). // values: Сырые пункты разбора (может быть null). // Возвращает: Очищенные непустые пункты. private static IReadOnlyList ContentItems(IReadOnlyList? values) diff --git a/src/core/Deal.Modules.Pipeline/Application/Registrars/PipelineModuleRegistrar.cs b/src/core/Deal.Modules.Pipeline/Application/Registrars/PipelineModuleRegistrar.cs index 114dad4..a344c24 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Registrars/PipelineModuleRegistrar.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Registrars/PipelineModuleRegistrar.cs @@ -5,21 +5,8 @@ using Microsoft.Extensions.DependencyInjection; namespace Deal.Modules.Pipeline.Application.Registrars; /// -/// DI-регистрация модуля Pipeline. Паттерн «port & adapter» (Ruling 10). +/// DI-регистрация модуля Pipeline. /// -/// -/// Регистрируются сервисы модуля: 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 { /// @@ -27,33 +14,21 @@ public static class PipelineModuleRegistrar /// /// Коллекция сервисов. /// Коллекция сервисов для цепочки вызовов. - /// - /// Время жизни — 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(); diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/AiCardLearning.cs b/src/core/Deal.Modules.Pipeline/Application/Services/AiCardLearning.cs index 828a2f8..505628b 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/AiCardLearning.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/AiCardLearning.cs @@ -8,19 +8,12 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Pipeline.Application.Services; /// -/// Обучающие сигналы ML по карточке ИИ-пути (pipeline.py L1155–1180): колонка-доска и тип заявки. +/// Обучающие сигналы ML по карточке ИИ-пути /// -/// -/// Общий источник для воркера pump () и ручной переклассификации -/// (): оба «докладывают» ML те же сигналы гипотезы ИИ с весом -/// , поэтому логика вынесена из воркера без дублей. -/// Свободная колонка — не служебная (inbox/trash/archive), не ИИ-предложение и без активных правил: -/// именно такие доски ML может назначать сама (собираем аналогичные примеры). -/// public static class AiCardLearning { /// - /// Пушит обучающие сигналы ML: доска (свободная колонка) и тип (при известном типе). + /// Пушит обучающие сигналы ML /// /// Порт канбана: чтение правил/признака предложения доски. /// Клиент ML (PushAsync — обучающий сигнал). @@ -28,7 +21,6 @@ public static class AiCardLearning /// Разбор, на котором собрана карточка (тип/спам из классификатора). /// Текст сообщения (обучающий пример — как source_msg карточки). /// Вес сигнала (гипотеза ИИ — ). - /// Токен отмены. public static async Task PushSignalsAsync( ICardStore kanjStore, IMlClient mlClient, diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/AiCardMapper.cs b/src/core/Deal.Modules.Pipeline/Application/Services/AiCardMapper.cs index 05154d2..8585a79 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/AiCardMapper.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/AiCardMapper.cs @@ -7,29 +7,13 @@ 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. diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/AiClassifyContextBuilder.cs b/src/core/Deal.Modules.Pipeline/Application/Services/AiClassifyContextBuilder.cs index 78519ce..79e1c60 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/AiClassifyContextBuilder.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/AiClassifyContextBuilder.cs @@ -10,67 +10,43 @@ 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) { @@ -79,10 +55,9 @@ public sealed class AiClassifyContextBuilder(ISettingsStore settings, ICardStore } /// - /// Заполненный system-промпт классификации: aiPrompt + cardPrompt (python L252–257). + /// Заполненный system-промпт классификации /// - /// Токен отмены. - /// Текст system_prompt ClassifyRequest (cardPrompt приклеен, если непуст). + /// Текст system_prompt ClassifyRequest. public async Task BuildClassifySystemPromptAsync(CancellationToken ct) { // Промпты/«Сфера и ключи» — одним типизированным снимком (C30): один GetAllAsync вместо 6 GetAsync. @@ -98,10 +73,9 @@ public sealed class AiClassifyContextBuilder(ISettingsStore settings, ICardStore } /// - /// user-контекст классификации «Доски + примеры разметки + Сообщение» (python L226–251). + /// user-контекст классификации «Доски + примеры разметки + Сообщение». /// /// Текст сообщения (режется до ). - /// Токен отмены. /// Текст user_context ClassifyRequest. public async Task BuildClassifyUserContextAsync(string text, CancellationToken ct) { @@ -134,7 +108,6 @@ public sealed class AiClassifyContextBuilder(ISettingsStore settings, ICardStore return context.ToString(); } - // Карта досок классификации: non-suggested с критериями/ключами и описанием (python L226–243). // ct: Токен отмены. // Возвращает: Список строк «- id: имя (критерии) — описание»; пусто — фраза «колонок нет». private async Task BuildBoardMapAsync(CancellationToken ct) @@ -154,7 +127,6 @@ public sealed class AiClassifyContextBuilder(ISettingsStore settings, ICardStore return lines.Count == 0 ? NoBoardsLine : string.Join("\n", lines); } - // Строка колонки: «- id: имя (критерии)» + « — описание» (python L230–242). // container: Колонка (non-suggested, kind=board). // Возвращает: Однострочное описание колонки для промпта. private static string BuildBoardLine(ContainerDto container) @@ -174,8 +146,6 @@ public sealed class AiClassifyContextBuilder(ISettingsStore settings, ICardStore return line; } - // Заполненный промпт: значение настройки (или дефолт) + PromptFiller (python fill_prompt L63–77). - // key: Ключ промпта (aiPrompt/aiFilterPrompt/cardPrompt). // defaultValue: Дефолт из SettingsDefaults (когда переопределения нет). // settingsSnapshot: Типизированный снимок настроек тенанта (промпты/«Сфера и ключи»). // Возвращает: Текст промпта с подставленными {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 a1a2170..20cf0da 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/AiRawCardMapper.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/AiRawCardMapper.cs @@ -9,37 +9,18 @@ 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). +/// Строгий маппинг JSON-ответа ИИ-классификатора в контрактный разбор карточки. /// -/// -/// Потребитель — 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"; // Регулярное выражение числа бюджета: число + необязательные «к»/валюта-суффиксы. @@ -47,10 +28,10 @@ public static class AiRawCardMapper new(@"\A" + BudgetAmountPattern + @"(?:руб|р|₽)?\z", System.Text.RegularExpressions.RegexOptions.CultureInvariant); /// - /// Маппит JSON-ответ модели в контрактный разбор карточки (поля 1:1 с _store_lead). + /// Маппит JSON-ответ модели в контрактный разбор карточки. /// /// Сырой JSON-ответ модели (ClassifyReply.Json; типовую схему задаёт промпт). - /// Исходный текст сообщения (fallback заголовка/контактов, python L455/L406). + /// Исходный текст сообщения. /// Разбор: структурированный блок «О заявке»/суть, стек, бюджет, контакты, тип/спам/доска. /// Ответ не объект/не разбирается — «ИИ не дал разбора» (ветка aiFail воркера). public static AiParsedCardDto Map(string json, string text) @@ -92,7 +73,6 @@ public static class AiRawCardMapper Board: board); } - // Заголовок/строковые поля: значение как строка (python str(raw.get(k) or "")). // root: Корневой объект ответа модели. // field: Имя поля. // Возвращает: Значение строкой (отсутствие/null → пустая строка). @@ -106,7 +86,6 @@ public static class AiRawCardMapper return node is JsonValue value ? ValueToString(value) : string.Empty; } - // Строковое поле блока «О заявке»: null/отсутствие/пусто → null (python: блока нет). // root: Корневой объект ответа модели. // field: Имя поля. // Возвращает: Значение или null. @@ -116,7 +95,6 @@ public static class AiRawCardMapper return value.Length == 0 ? null : value; } - // Булево поле: true/1/строковые «да»-подобные → true, иначе false (python bool(raw.get(...))). // root: Корневой объект ответа модели. // field: Имя поля. // Возвращает: True/False; отсутствие/неразбираемое → false. @@ -151,7 +129,6 @@ public static class AiRawCardMapper return false; } - // Список пунктов (requirements/plus): python compose_summary _items L235–241 — normalize_list + чистка. // root: Корневой объект ответа модели. // field: Имя поля. // Возвращает: Очищенные пункты; отсутствие/null → null (блока нет), пустой список → пустой список. @@ -180,7 +157,6 @@ public static class AiRawCardMapper return result; } - // Стек из ответа модели (python normalize_stack L332–341: список или строка, ≤12, ≥2 символов). // root: Корневой объект ответа модели. // Возвращает: Нормализованный стек. private static IReadOnlyList ReadStack(JsonObject root) @@ -197,7 +173,6 @@ public static class AiRawCardMapper : Array.Empty(); } - // Бюджет из ответа модели (ai.py clean_budget L316–339 через BudgetNormalizer). // root: Корневой объект ответа модели. // Возвращает: Нормализованный бюджет контракта или null (нет объекта/валюты/границ). private static AiBudgetDto? ReadBudget(JsonObject root) @@ -221,7 +196,6 @@ public static class AiRawCardMapper : new AiBudgetDto(normalized.From, normalized.To, normalized.Cur); } - // Граница бюджета (ai.py _budget_num L294–313): число, «2к» → 2000, «2000₽» → 2000. // budget: Объект бюджета ответа модели. // field: Имя границы (from/to). // Возвращает: Число или null — поле отсутствует/пусто/не разбирается. @@ -268,7 +242,6 @@ public static class AiRawCardMapper return result == 0 ? null : result; } - // Контакты из ответа модели (python build_contacts L389–421: строка/список/значения-объекты). // root: Корневой объект ответа модели. // text: Исходный текст сообщения (fallback-кандидаты, если контактов в ответе нет). // Возвращает: Квалифицированные контакты (≤6, дедуп по casefold-значению). @@ -324,11 +297,9 @@ public static class AiRawCardMapper // Значение 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) diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/CardComposer.cs b/src/core/Deal.Modules.Pipeline/Application/Services/CardComposer.cs index e07d8b8..41a71d0 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/CardComposer.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/CardComposer.cs @@ -13,56 +13,24 @@ 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). +/// Сборка снимка карточки из разобранного сообщения для записи через Kanban. /// -/// -/// Чистый класс модуля 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, @@ -109,7 +77,6 @@ public sealed class CardComposer(ICardStore kanjStore, ISettingsStore settings) converted = BudgetNormalizer.ToTarget(budget, conversionOn, targetCurrency, rates); } - // Колонка и «почему карточка здесь»: страховка L449–450 (доски нет/не прошла правила → inbox). string col = CardIds.Inbox; IReadOnlyList matchHits = Array.Empty(); if (boardCandidate is not null) @@ -162,7 +129,6 @@ public sealed class CardComposer(ICardStore kanjStore, ISettingsStore settings) } // Бюджет карточки: нормализация из разбора, иначе fallback первой суммы по исходнику/«О заявке» - // (python L453/L459–468; формы хранения — CardBudgetDto). // parsedBudget: Бюджет разбора (форма контракта; null — разбор не выделил сумму). // text: Текст исходного сообщения (первый источник fallback). // summary: Блок «О заявке» (второй источник fallback — сумма часто уходит в «Условия»). @@ -181,7 +147,6 @@ public sealed class CardComposer(ICardStore kanjStore, ISettingsStore settings) return fallback is null ? null : BudgetNormalizer.Normalize(fallback); } - // Маппинг разбора классификатора в структуру блока «О заявке» (поля 1:1 с cardPrompt L116–123). // parsed: Разбор (пустые/отсутствующие поля блок не дают — SummaryComposer). // Возвращает: Структура для SummaryComposer.Compose. private static ParsedCardContent ToParsedContent(AiParsedCardDto parsed) => new( diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs b/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs index c55a73d..d1fb123 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs @@ -12,25 +12,8 @@ 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-режиме — детерминированный). @@ -50,24 +33,21 @@ public sealed class CardReclassifier( 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) { @@ -100,10 +80,9 @@ public sealed class CardReclassifier( } /// - /// Переклассификация одной карточки (любой колонки; обычно — «Неразобранное»). + /// Переклассификация одной карточки /// /// Карточка (уже прочитана вызывающим — 404 остаётся за эндпоинтом). - /// Токен отмены. /// Итог прохода либо busy, если проход уже идёт. public async Task ReclassifyCardAsync(CardDto card, CancellationToken ct) { @@ -165,13 +144,11 @@ public sealed class CardReclassifier( } catch (Exception) { - // Классификатор недоступен/сбой — локальный разбор (как raw={} python L1112–1114). parsed = null; } if (parsed is not null) { - // Успешная классификация ИИ подтверждает тип по контексту (python L1108–1111). parsed = parsed with { IsVacancyKnown = true }; pass.AiUsed = true; } @@ -218,7 +195,6 @@ public sealed class CardReclassifier( await AiCardLearning.PushSignalsAsync(store, mlClient, snapshot.Col, parsed, text, MlLearningLabels.AiPushWeight, ct); } - // Отправляет карточку в корзину и обучает ML «спаму» с весом гипотезы ИИ (python L311–318). // card: Карточка. // text: Исходный текст (обучающий пример). // pass: Накопители прохода. @@ -277,7 +253,6 @@ public sealed class CardReclassifier( string cur) => cur.Length == 0 ? null : new CardBudgetDto(from, to, cur); - // Контакт карточки: новый из разбора, иначе — валидный старый (python L327–331). // card: Карточка до переклассификации (старый контакт). // computed: Контакт, собранный из нового разбора. // Возвращает: Значение основного контакта либо пустая строка. @@ -292,7 +267,6 @@ public sealed class CardReclassifier( return oldContact.Length > 0 && ContactsQualifier.Qualify(oldContact) is not null ? oldContact : string.Empty; } - // Отбор карточек inbox по опциональному списку id (python L394–399). // inbox: Карточки «Неразобранного» (порядок хранилища). // ids: Опциональный фильтр id. // Возвращает: Целевые карточки в порядке хранилища. @@ -378,7 +352,7 @@ public sealed class CardReclassifier( public int Trashed { get; set; } /// - /// Сколько карточек пропущено (нет исходного текста). + /// Сколько карточек пропущено /// public int Skipped { 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 83c2dee..3c48c6f 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/GlobalExclusionRules.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/GlobalExclusionRules.cs @@ -4,17 +4,8 @@ using Deal.Modules.Pipeline.Application.Models; namespace Deal.Modules.Pipeline.Application.Services; /// -/// Глобальные исключения тенанта — стоп-фильтр ДО ML/ИИ (§5.14/§8, «стоп на уровне фильтров»). +/// Глобальные исключения тенанта — стоп-фильтр ДО ML/ИИ /// -/// -/// Чистая функция над снимком настроек () и текстом сообщения. Порядок -/// проверок — от самого дешёвого/явного к косвенному: ключевые слова → локация/язык → тип → бюджет. -/// Первое сработавшее исключение возвращается как (причина называет -/// конкретное исключение); иначе null — сообщение идёт дальше по пайплайну. Бюджет сравнивается по -/// числовым суммам текста () БЕЗ конвертации валют (у настройки нет валюты; -/// сравнение чисел предсказуемо и не зависит от кэша курсов). Так исключения экономят токены: отсев -/// происходит до ML-прогноза и ИИ-вызова. -/// public static class GlobalExclusionRules { /// @@ -33,7 +24,7 @@ public static class GlobalExclusionRules public const string KindType = "exclude_type"; /// - /// Этап/правило: исключение по бюджету (диапазон). + /// Этап/правило: исключение по бюджету /// public const string KindBudget = "exclude_budget"; @@ -48,7 +39,7 @@ public static class GlobalExclusionRules private const string ReasonBudgetFormat = "глобальное исключение: бюджет {0}"; /// - /// Проверяет текст на срабатывание глобальных исключений (первое найденное). + /// Проверяет текст на срабатывание глобальных исключений /// /// Текст сообщения. /// Снимок глобальных исключений тенанта. diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs b/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs index e4c04e0..7fb2fa0 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs @@ -10,24 +10,8 @@ using Deal.Modules.Pipeline.Application.Models; namespace Deal.Modules.Pipeline.Application.Services; /// -/// Ручная проверка/разметка ML на сообщениях канала (§8 ML: «проверка на сообщении/канале»). +/// Ручная проверка/разметка 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, @@ -36,58 +20,57 @@ public sealed class MlReviewService( 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). + /// Действие: спам — учим ML и /// public const string ActionSpam = "spam"; /// - /// Префикс действия «в колонку»: board:<id> (ml_routes.py L157). + /// Префикс действия «в колонку» /// public const string ActionBoardPrefix = "board:"; /// - /// 400 apply: неизвестная доска-цель (ml_routes.py L159–160). + /// 400 apply: неизвестная доска-цель. /// public const string UnknownBoardDetail = "Неизвестная доска"; /// - /// 400 apply: неизвестное действие (ml_routes.py L169–170). + /// 400 apply: неизвестное действие. /// public const string UnknownActionDetail = "Неизвестное действие"; @@ -100,7 +83,6 @@ public sealed class MlReviewService( // Источник решения при ручной разметке. private const string ManualSource = "ml"; - // Вес обучающего сигнала ручной разметки — действие пользователя (ml_client.py USER_WEIGHT 1.0). private const double UserPushWeight = 1.0; /// @@ -108,7 +90,6 @@ public sealed class MlReviewService( /// /// Id канала/диалога; пусто — выборка по всем источникам тенанта. /// Сколько последних сообщений вернуть (кламп 1.., дефолт вызывающего). - /// Токен отмены. /// Кандидаты (свежие первыми): текст, текущий вердикт и мнение ML по каждому. public async Task> CandidatesAsync( string? dialogId, @@ -169,12 +150,11 @@ public sealed class MlReviewService( } /// - /// Применяет ручное решение по сообщению: обучение ML + перенос/корзина/отсев. + /// Применяет ручное решение по сообщению /// /// Id канала/диалога сообщения. /// Id исходного сообщения. /// Действие: skip | spam | board:<id>. - /// Токен отмены. /// Результат решения; null — исходное сообщение не найдено (404-семантика эндпоинта). public async Task ApplyAsync( string dialogId, @@ -287,7 +267,6 @@ public sealed class MlReviewService( 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); } diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineCardWriter.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineCardWriter.cs index 46f728e..106130d 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineCardWriter.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineCardWriter.cs @@ -7,22 +7,8 @@ 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 → чтение после записи). +/// Создание карточки пайплайна через публичный интерфейс Kanban. /// -/// -/// Тонкая обёртка создания: генерирует 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) { /// @@ -30,9 +16,8 @@ public sealed class PipelineCardWriter(ICardStore kanjStore, IPipelineStore pipe /// /// Разбор сообщения (ИИ/локальный путь; колонка разбора пройдёт ContainerAccepts-страховку). /// Строка очереди с сообщением-источником (метаданные канала/текст/время). - /// SHA1-hex хэша текста (заявка дедупа уже создана воркером, Ruling 8). - /// Токен отмены. - /// Полная карточка (чтение после записи — §4.1, как lead_to_dict после INSERT, L514). + /// SHA1-hex хэша текста. + /// Полная карточка. /// Карточка не прочиталась сразу после создания. public async Task CreateCardAsync( AiParsedCardDto parsed, diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineIngestService.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineIngestService.cs index c94ef4d..c6ea022 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineIngestService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineIngestService.cs @@ -6,32 +6,19 @@ using Deal.Modules.Pipeline.Application.Parse; namespace Deal.Modules.Pipeline.Application.Services; /// -/// Приём входящих сообщений пайплайна — постановка сырого сообщения в очередь QueueItems (Ruling 2, pipeline.py enqueue L53–85). +/// Приём входящих сообщений пайплайна — постановка сырого сообщения в очередь QueueItems. /// -/// -/// Единственная точка входа сообщений в пайплайн этапа 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). - /// Токен отмены. + /// Сырое сообщение: диалог, канальные поля, текст, время; Force — возврат из отсева. /// Результат: 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)) { @@ -40,7 +27,6 @@ public sealed class PipelineIngestService(IPipelineStore store) long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - // Дубль-гвард приёма: то же сообщение диалога уже в очереди/обработке — Telethon-повтор не пишем (L74–80). if (message.MsgId is not null && await store.ExistsDuplicateAsync(message.DialogId, message.MsgId, ct)) { diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineProcessingService.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineProcessingService.cs index b6a111d..5a9a88f 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineProcessingService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineProcessingService.cs @@ -8,48 +8,29 @@ 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). + /// 400 return: запись уже возвращалась в обработку. /// public const string AlreadyReturnedDetail = "Сообщение уже возвращено в обработку"; /// - /// 400 return: повтор по дедупу — карточка с текстом уже в системе (return_to_queue L141–142, Ruling 10). + /// 400 return: повтор по дедупу — карточка с текстом уже в системе. /// public const string DuplicateReturnDetail = "Повтор: карточка с таким текстом уже есть в системе — возвращать нечего"; /// - /// 400 return: в записи нет текста сообщения (return_to_queue L146–147, Ruling 10). + /// 400 return: в записи нет текста сообщения. /// public const string EmptyReturnTextDetail = "В записи нет текста сообщения"; - // ── Лимиты страниц (processing.DEFAULT_LIMIT/MAX_LIMIT L48–49) ─────────── /// /// Размер страницы по умолчанию для списков очереди/отсева. @@ -57,19 +38,16 @@ public sealed class PipelineProcessingService( 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", @@ -77,32 +55,20 @@ public sealed class PipelineProcessingService( "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) { @@ -120,13 +86,11 @@ public sealed class PipelineProcessingService( }, ct); } - // ── Очередь (list_queue L218–241 / queue_counts L207–215) ──────────────── /// - /// Сырые сообщения очереди в порядке постановки (CreatedAt ASC), все статусы (list_queue L218–241). + /// Сырые сообщения очереди в порядке постановки /// - /// Максимум строк; clamp 1..500 (L219) — запрашивается больше — вернётся не больше 500. - /// Токен отмены. + /// Максимум строк; clamp 1..500 — запрашивается больше — вернётся не больше 500. /// Строки очереди (item'ы §4.5; внутренний Force в JSON не выходит). public async Task> ListQueueAsync(int limit, CancellationToken ct) { @@ -135,9 +99,8 @@ public sealed class PipelineProcessingService( } /// - /// Счётчики очереди по статусам: new / filtered (bucket «ai») / total (queue_counts L207–215). + /// Счётчики очереди по статусам /// - /// Токен отмены. /// new/ai/total (форма counts ответа /queue и queue ответа /stats). public async Task QueueCountsAsync(CancellationToken ct) { @@ -147,31 +110,21 @@ public sealed class PipelineProcessingService( } /// - /// Число записей в отсеве (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, @@ -190,30 +143,18 @@ public sealed class PipelineProcessingService( 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, @@ -244,7 +185,6 @@ public sealed class PipelineProcessingService( if (SpamStages.Contains(row.Stage)) { - // Реальное действие пользователя: этот текст НЕ спам — снимаем у ML вес спама (L149–153). await mlClient.PushAsync(text, MlLearningLabels.Spam, SpamUnlearnDelta, ct); } @@ -255,12 +195,10 @@ public sealed class PipelineProcessingService( 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, @@ -276,7 +214,6 @@ public sealed class PipelineProcessingService( else { // Старые записи (до сохранения dialog_id/msg_id): текст сохранился — возвращаем без ссылки - // на исходное сообщение прямым INSERT (return_to_queue L174–192, force=true). var item = new QueueItemDto { Id = PrefixId.New(PipelineIdPrefixes.Queue), @@ -295,23 +232,20 @@ public sealed class PipelineProcessingService( 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) { @@ -319,13 +253,8 @@ public sealed class PipelineProcessingService( } /// - /// Автоочистка отсева: записи старше 3 суток от RejectedAt удаляются безвозвратно (purge_expired L104–117, Ruling 8). + /// Автоочистка отсева /// - /// - /// Срок — (3 суток). Вызывается из /admin/tick и - /// фонового StorageTickScheduler (Ruling 9/11) — модуль сам циклы не заводит. - /// - /// Токен отмены. /// Сколько записей удалено. public Task PurgeExpiredAsync(CancellationToken ct) { @@ -333,13 +262,11 @@ public sealed class PipelineProcessingService( return store.PurgeExpiredAsync(olderThan, ct); } - // ── Сводка (stats L315–320) ────────────────────────────────────────────── /// - /// Сводка вкладки «Обработка» — форма GET /api/pipeline/stats: {queue: {new, ai, total}, rejected}. + /// Сводка вкладки «Обработка» — форма GET /api/pipeline/stats /// - /// Токен отмены. - /// Счётчики очереди и число записей отсева (поллинг вкладки, SSE pipeline_stats не публикуем — Ruling 9). + /// Счётчики очереди и число записей отсева. public async Task StatsAsync(CancellationToken ct) { QueueCountsDto queue = await QueueCountsAsync(ct); 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 ca0b906..4ef47dd 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Checks.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Checks.cs @@ -6,15 +6,12 @@ using Deal.Modules.Pipeline.Application.Parse; namespace Deal.Modules.Pipeline.Application.Services; /// -/// Проверки воркера — partial-часть (C32: выделено из общего файла, -/// поведение не менялось): срок актуальности (IsStale), фильтр «без суммы» (_skip_no_budget), слияние -/// ML-терминов в стек и сбой-безопасные вызовы ML/ИИ-фильтра. +/// Проверки воркера — partial-часть /// public sealed partial class PipelineWorkerService { // ── Проверки воркера ─────────────────────────────────────────────────── - // Срок актуальности сообщения = срок до автоархива (python _stale_max_age L816–826): автоархив // включён и msg_at старше archiveAfterDays суток → устарело. msg_at=0/пусто — не устарело. Флаги — из // снимка прохода (читаются один раз на pump, не на каждое сообщение). // row: Строка очереди (время исходного сообщения). @@ -42,11 +39,9 @@ public sealed partial class PipelineWorkerService 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). @@ -77,7 +72,6 @@ public sealed partial class PipelineWorkerService return Task.FromResult(AmountParser.Parse(text).Count == 0); } - // Докладывает узнанные ML термины в стек карточки (python L997–1011): непустые, ≥2 символов, без // ведущей «~», не стоп-слова стека, не дубликаты — до MaxMlTermsAdded терминов. // terms: Термины класса из ответа ML (dec.terms). // stack: Стек локального разбора (к нему добавляем; лимит 12 доберёт CardComposer). @@ -116,7 +110,6 @@ public sealed partial class PipelineWorkerService return merged; } - // Предсказание ML с защитой от сбоя: недоступность сервиса → «не уверен» (решит ИИ; ml_client.predict L101–107). // text: Текст сообщения. // ct: Токен отмены. // Возвращает: Решение модели либо фиксированный «не готов/не уверен». @@ -132,7 +125,6 @@ public sealed partial class PipelineWorkerService } } - // ИИ-фильтр с защитой от сбоя: сбой фильтра — «пропустить» (python L1102–1106). // text: Текст сообщения. // ct: Токен отмены. // Возвращает: Решение фильтра либо пропуск при сбое. 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 f5573ad..d83c6af 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Decisions.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Decisions.cs @@ -4,16 +4,13 @@ using Deal.Modules.Settings.Application.Models; namespace Deal.Modules.Pipeline.Application.Services; /// -/// KV-счётчики решений — partial-часть (C32: выделено из общего -/// файла, поведение не менялось): mlDecisions/aiDecisions после pump (track_decisions L153–157, Ruling 5). +/// KV-счётчики решений — partial-часть /// 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) 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 75b2054..c88cd50 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Learning.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Learning.cs @@ -5,9 +5,7 @@ using Deal.Modules.Pipeline.Application.Models; namespace Deal.Modules.Pipeline.Application.Services; /// -/// Карточка и обучение ML — partial-часть (C32: выделено из общего -/// файла, поведение не менялось): создание карточки через PipelineCardWriter и обучающие push-сигналы по -/// карточке ИИ-пути (Ruling 5, L1155–1180). +/// Карточка и обучение ML — partial-часть /// public sealed partial class PipelineWorkerService { @@ -15,8 +13,6 @@ public sealed partial class PipelineWorkerService // Создаёт карточку (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: Строка очереди (метаданные канала/текст/время). @@ -47,10 +43,8 @@ public sealed partial class PipelineWorkerService return card; } - // Обучающие сигналы ML по карточке ИИ-пути (L1155–1180): колонка-доска (не inbox/служебная, // без активных правил и не-suggested) и тип (t:hire/t:order при is_vacancy_known). // «Не знаю» (inbox) и служебные колонки не учим; доска с активными правилами/ИИ-предложение — тоже - // (ML в своём пути назначает только такие же свободные колонки — собираем аналогичные примеры). // card: Созданная карточка (реальная колонка после ContainerAccepts-страховки). // parsed: Разбор, на котором собрана карточка (тип/спам из классификатора). // text: Текст сообщения (обучающий пример — как source_msg карточки). 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 0e12ea6..c75c634 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Pump.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Pump.cs @@ -9,29 +9,18 @@ using Deal.Modules.Settings.Application.Models; namespace Deal.Modules.Pipeline.Application.Services; /// -/// Проход pump — partial-часть (C32: выделено из общего файла, -/// поведение не менялось): PumpOnceAsync и «new»/«filtered»-проходы батчей (pump_once L890–1183, Ruling 8). +/// Проход pump — partial-часть /// 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); @@ -42,12 +31,10 @@ public sealed partial class PipelineWorkerService return state.ToResult(); } - // ── «new»-проход: правила → дедуп → ML (python L923–1065) ────────────── // Батч сообщений статуса new: устарело/правила/повтор отсекаются, остальные — ML или в filtered. // Настройки (сроки/флаги ML/ИИ/бюджета) — из снимка прохода: читаются один раз, не на каждое сообщение. // state: Накопители результата pump (счётчики/карточки). - // run: Снимок настроек воркера на проход (Ruling 8). // ct: Токен отмены. private async Task PumpNewPassAsync( PumpState state, @@ -59,14 +46,12 @@ public sealed partial class PipelineWorkerService { 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); @@ -88,7 +73,6 @@ public sealed partial class PipelineWorkerService } } - // Дедуп по нормализованному тексту: повтор — отсев «система»; иначе заявляем хэш (L940–951). string digest = DedupHasher.Hash(row.Text); if (await _store.ExistsAsync(digest, ct)) { @@ -99,7 +83,6 @@ public sealed partial class PipelineWorkerService // 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); @@ -109,13 +92,11 @@ public sealed partial class PipelineWorkerService 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) @@ -161,13 +142,11 @@ public sealed partial class PipelineWorkerService } } - // 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) @@ -183,7 +162,6 @@ public sealed partial class PipelineWorkerService } } - // ИИ выключен, но тип ML знает — карточка сама (inbox; L1043–1061). if (!run.AiEnabled) { LocalParsedFields fields = _fieldsParser.Parse(row.Text, run.Settings); @@ -207,17 +185,14 @@ public sealed partial class PipelineWorkerService } } - // 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, @@ -229,7 +204,6 @@ public sealed partial class PipelineWorkerService { bool force = row.Force; - // Устарело: как в «new»-проходе, только не force (L1073–1076). if (!force && IsStaleAsync(row, run)) { await DropStaleAsync(row, run, ct); @@ -239,7 +213,6 @@ public sealed partial class PipelineWorkerService string text = row.Text; string digest = DedupHasher.Hash(text); - // ИИ выключен: классификацию/фильтр не зовём — карточку собирает локальный разбор (L1079–1096). if (!run.AiEnabled) { // Карточку собирает локальный разбор (без вызова порта ИИ). @@ -256,7 +229,6 @@ public sealed partial class PipelineWorkerService continue; } - // ИИ-фильтр (этап 2): возврат из отсева фильтр не пересматривает; выключен/сбой — пропуск (L1097–1106). AiFilterResultDto filter; if (force) { @@ -278,11 +250,9 @@ public sealed partial class PipelineWorkerService } catch (Exception) { - // Классификатор недоступен/сбой — как raw={} в прототипе (L1112–1114): локальный разбор, aiFail. parsed = null; } - // python L1108–1111: успешная классификация ИИ — тип определён по контексту (не маркерной // эвристикой). Стемп ставится ДО создания карточки (карточка получает is_vacancy_known=true) и // переживает force-отмену вердикта «спам»; локальный разбор aiFail/aiEnabled=false стемпа не имеет. if (parsed is not null) @@ -291,7 +261,6 @@ public sealed partial class PipelineWorkerService } } - // Вердикт «спам»: не прошёл фильтр либо классификатор пометил мусором (L1116–1121). Возврат (force) // отменяет вердикт — пользователь уже подтвердил релевантность, карточку создаём без обучения «спаму». bool isSpam = !filter.Pass || (parsed?.IsSpam ?? false); if (force && filter.Pass && parsed?.IsSpam == true) @@ -302,7 +271,6 @@ public sealed partial class PipelineWorkerService 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) @@ -316,12 +284,10 @@ public sealed partial class PipelineWorkerService 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++; 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 c830868..60c2111 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Rejections.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Rejections.cs @@ -4,19 +4,15 @@ using Deal.Modules.Pipeline.Application.Parse; namespace Deal.Modules.Pipeline.Application.Services; /// -/// Отсев и удаление строк — partial-часть (C32: выделено из общего -/// файла, поведение не менялось): запись отсева (RejectRowAsync), удаление строки очереди со снятием claim -/// и отсев «устарело» (_drop_row/_drop_stale, Ruling 8). +/// Отсев и удаление строк — partial-часть /// 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( @@ -43,7 +39,6 @@ public sealed partial class PipelineWorkerService }, ct); } - // Удаляет строку очереди (отсев на любом этапе; python _drop_row L809–814). // row: Строка очереди. // digest: Хэш текста (для снятия незанятого dedup-claim). // withDedup: Снять claim дедупа (true — сообщение можно обработать заново после отсева). @@ -62,7 +57,6 @@ public sealed partial class PipelineWorkerService await _store.RemoveAsync(row.Id, ct); } - // Отсев «устарело»: запись + удаление строки, карточка НЕ создаётся (python _drop_stale L836–844). // row: Строка очереди со старым msg_at. // run: Снимок настроек воркера (срок до автоархива уже прочитан). // 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 389626e..d871067 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Settings.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Settings.cs @@ -5,12 +5,10 @@ using Deal.Modules.Settings.Application.Models; namespace Deal.Modules.Pipeline.Application.Services; /// -/// Снимок настроек прохода — partial-часть (C32: выделено из общего -/// файла, поведение не менялось): WorkerRunSettings + один GetAllAsync на pump и резолв дефолтов. +/// Снимок настроек прохода — partial-часть /// public sealed partial class PipelineWorkerService { - // ── Снимок настроек на проход pump (Ruling 8/C30: один GetAllAsync вместо чтений на каждое сообщение) ── // Снимок настроек воркера на один проход pump: значения прочитаны ОДИН раз (LoadRunSettingsAsync) // и передаются во все проверки сообщений батча и в LocalFieldsParser — раньше настройки читались на каждое @@ -21,7 +19,6 @@ public sealed partial class PipelineWorkerService // ArchiveAfterDays: Срок до автоархива, сутки (stale/причина отсева). // MlEnabled: ML-слот включён (не false — прогноз/решения). // AiEnabled: ИИ-слот включён (классификация/фильтр). - // AiFilterEnabled: ИИ-фильтр включён (этап 2). // BudgetRequiredHire: Фильтр «без суммы» для найма. // BudgetRequiredOrder: Фильтр «без суммы» для разовых заказов. // WantedType: Тип заявок «что собираем» (both|vacancy|freelance). @@ -76,7 +73,6 @@ public sealed partial class PipelineWorkerService BudgetTo: to > 0 ? to : null); } - // wantedType: тип заявок (both|vacancy|freelance) в нижнем регистре (python L1030); пустая/отсут- // ствующая/повреждённая строка → дефолт «both» (даёт GetString с дефолтом + нормализация). // value: Значение настройки wantedType (JSON-строка либо дефолт из SettingsDefaults). // Возвращает: Значение в нижнем регистре (пустое хранимое значение остаётся пустым — как wantedType=""). 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 1fbab52..8cf309e 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.State.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.State.cs @@ -4,63 +4,61 @@ using Deal.Modules.Pipeline.Application.Models; namespace Deal.Modules.Pipeline.Application.Services; /// -/// Накопители результата pump — partial-часть (C32: выделено из -/// общего файла, поведение не менялось): приватный PumpState (счётчики словаря python L921 + карточки). +/// Накопители результата pump — partial-часть /// public sealed partial class PipelineWorkerService { // ── Накопители результата pump (счётчики и карточки одного прохода) ── - // Мутабельные накопители одного прохода pump: счётчики словаря python L921 + созданные карточки. private sealed class PumpState { /// - /// Прошли «new»-проход и переведены в filtered (L952). + /// Прошли «new»-проход и переведены в filtered. /// public int Staged { get; set; } /// - /// Карточек создано ML-веткой (mlStored). + /// Карточек создано ML-веткой /// public int MlStored { get; set; } /// - /// Отсевов решением ML «спам» (mlDrop). + /// Отсевов решением ML «спам» /// public int MlDrop { get; set; } /// - /// Отсевов «тип не под режим» по решению ML (typeDrop). + /// Отсевов «тип не под режим» по решению ML /// 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() diff --git a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.cs index db98a73..93ac90f 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.cs @@ -10,36 +10,8 @@ using Deal.Modules.Settings.Application.Models; namespace Deal.Modules.Pipeline.Application.Services; /// -/// Воркер разбора очереди входящих — один проход pump (Ruling 8; прототип _pump_unlocked L920–1183, порядок строго 1:1). +/// Воркер разбора очереди входящих — один проход pump. /// -/// -/// Чистый оркестратор модуля (без 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; @@ -53,16 +25,16 @@ public sealed partial class PipelineWorkerService private readonly LocalFieldsParser _fieldsParser; /// - /// Создаёт воркер pump над портами модуля Pipeline (DI-зависимости прохода). + /// Создаёт воркер pump над портами модуля Pipeline /// /// Хранилище очереди/отсева/дедупа (порт IPipelineStore). - /// KV-настройки тенанта (флаги/сроки воркера, Ruling 8). + /// KV-настройки тенанта. /// Этап-1 правила фильтра входящих (длина/стоп-фразы/резюме/тип, Settings). /// Порт канбана: доски (проверка allowed-колонок ML/обучения) и чтение карточки. /// Порт ML-сервиса: predict (решения «решил сам») и push (обучение). - /// Порт ИИ: фильтр и классификация (этап 4 — LocalAiClassifier). + /// Порт ИИ: фильтр и классификация. /// Запись отсева (RejectAsync) и обслуживание вкладки «Обработка». - /// Создание карточки через публичный интерфейс Kanban + связь дедупа (Ruling 3/4). + /// Создание карточки через публичный интерфейс Kanban + связь дедупа. /// Локальный структуратор (aiEnabled=false / сбой ИИ / локальные поля ML-ветки). public PipelineWorkerService( IPipelineStore store, @@ -85,7 +57,6 @@ public sealed partial class PipelineWorkerService _cardWriter = cardWriter; _fieldsParser = fieldsParser; } - // ── Лимиты батчей pump (прототип pump_once new_limit=12 / ai_limit=4, L890) ── // Лимит «new»-прохода: сколько сообщений за проход проходят правила/дедуп/ML. private const int NewBatchLimit = 12; @@ -93,12 +64,9 @@ public sealed partial class PipelineWorkerService // Лимит «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» (решения модели). @@ -122,7 +90,6 @@ public sealed partial class PipelineWorkerService // Этап отсева «ИИ-фильтр». private const string StageFilterAi = "filter_ai"; - // Этап отсева «повтор» / «устарело» / «нет суммы» (словарь подписей Ruling 1). private const string StageDup = "dup"; private const string StageStale = "stale"; @@ -131,9 +98,7 @@ public sealed partial class PipelineWorkerService 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) ── @@ -144,39 +109,27 @@ public sealed partial class PipelineWorkerService // 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, @@ -187,7 +140,6 @@ public sealed partial class PipelineWorkerService 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 5c12ada..2cb2959 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Services/ReclassifyGate.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/ReclassifyGate.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Pipeline.Application.Services; /// -/// Single-flight-замок ручной переклассификации: одна переклассификация за раз (как фоновая задача прототипа). +/// Single-flight-замок ручной переклассификации /// -/// -/// Регистрируется singleton (состояние общее для всех tenant-запросов процесса). Переклассификация выполняется -/// синхронно в запросе; если проход уже идёт, второй вызов получает busy без ожидания блокировки — -/// не блокирует поток. -/// public sealed class ReclassifyGate : IDisposable { private readonly SemaphoreSlim _gate = new(initialCount: 1, maxCount: 1); diff --git a/src/core/Deal.Modules.Pipeline/PipelineModuleMarker.cs b/src/core/Deal.Modules.Pipeline/PipelineModuleMarker.cs index 486bac7..f181f0a 100644 --- a/src/core/Deal.Modules.Pipeline/PipelineModuleMarker.cs +++ b/src/core/Deal.Modules.Pipeline/PipelineModuleMarker.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Pipeline; /// -/// Маркер модуля Pipeline: используется для DI-сканирования и тестов. +/// Маркер модуля Pipeline /// public sealed class PipelineModuleMarker { diff --git a/src/core/Deal.Modules.Settings/Application/Abstractions/IAiConnectionChecker.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/IAiConnectionChecker.cs index 430f523..fdebcd9 100644 --- a/src/core/Deal.Modules.Settings/Application/Abstractions/IAiConnectionChecker.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/IAiConnectionChecker.cs @@ -3,22 +3,14 @@ using Deal.Modules.Settings.Application.Models; namespace Deal.Modules.Settings.Application.Abstractions; /// -/// Порт проверки подключения к выбранному AI-провайдеру (Ruling 4/7). +/// Порт проверки подключения к выбранному AI-провайдеру. /// -/// -/// Модульный порт Settings-слоя: потребляется только POST /api/ai/check (Settings-экран); -/// IAiFacade на этапе 2 не заводится (Ruling 4 — классификация/ИИ-фильтр — этап 6). -/// Проверка — реальный HTTP БЕЗ LLM-вызовов: GET {base}/models (OpenAI-совместимые) или -/// GET {base}/v1/models (Anthropic), 1:1 с settings_routes.py L195–219 (Ruling 7). -/// Реализация — Deal.Infrastructure/Integrations/AiConnectionChecker (HttpClient). -/// public interface IAiConnectionChecker { /// /// Проверяет соединение с провайдером по активной конфигурации тенанта. /// /// Данные проверки: id провайдера из каталога, эффективный base/model, расшифрованный ключ. - /// Токен отмены запроса. - /// Результат: {ok, message} + статус провайдера (Ruling 7). + /// Результат: {ok, message} + статус провайдера. public Task CheckAsync(AiCheckRequest request, CancellationToken ct); } diff --git a/src/core/Deal.Modules.Settings/Application/Abstractions/IGlobalSettingsStore.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/IGlobalSettingsStore.cs index f4a8212..63967d7 100644 --- a/src/core/Deal.Modules.Settings/Application/Abstractions/IGlobalSettingsStore.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/IGlobalSettingsStore.cs @@ -3,21 +3,14 @@ using Deal.Modules.Settings.Application.Models; namespace Deal.Modules.Settings.Application.Abstractions; /// -/// Порт KV-хранилища глобальных (системных) настроек оператора: таблица public.global_settings. +/// Порт KV-хранилища глобальных /// -/// -/// В отличие от (схема отдельного тенанта) это единое хранилище всего -/// SaaS-контура: значения задаёт оператор, видят все тенанты. Секреты хранятся зашифрованными -/// (префикс enc: — Ruling 2), JSON-сериализацию значения выполняет вызывающий (как и в -/// ). Реализация — EF-адаптер GlobalSettingsStore в Deal.Infrastructure. -/// public interface IGlobalSettingsStore { /// - /// Читает одно глобальное значение по ключу (см. ). + /// Читает одно глобальное значение по ключу /// /// Ключ глобальной настройки. - /// Токен отмены. /// Значение или null, если строка отсутствует (настройка не задана оператором). public Task GetAsync(string key, CancellationToken ct); @@ -26,7 +19,6 @@ public interface IGlobalSettingsStore /// /// Ключ глобальной настройки. /// Значение, сериализованное в JSON. - /// Токен отмены. public Task SetAsync( string key, string valueJson, diff --git a/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesChangedListener.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesChangedListener.cs index 29f2b15..dc6593a 100644 --- a/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesChangedListener.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesChangedListener.cs @@ -3,24 +3,13 @@ using Deal.Modules.Settings.Application.Services; namespace Deal.Modules.Settings.Application.Abstractions; /// -/// Порт модуля Settings: уведомление об изменении курсов/настроек конверсии (Ruling 7, план Task 12). +/// Порт модуля Settings /// -/// -/// Объявляется в модуле Settings (модуль не знает Kanban); реализация — ConversionRecomputer в модуле -/// Kanban, регистрация AddScoped<IRatesChangedListener, ConversionRecomputer>() в -/// AddKanbanModule() (Ruling 12). Вызывают сервисы Settings ПОСЛЕ успешного изменения состояния: -/// (1) — после записи кэша ratesCache (покрывает и фоновый -/// RatesRefreshScheduler — rates.py refresh_rates L62–74); (2) — -/// если в теле PATCH /settings присутствовали targetCurrency/conversionOn -/// (settings_routes.py L186–192). Список слушателей может быть пустым (модуль Kanban не подключён) — no-op. -/// public interface IRatesChangedListener { /// /// Курсы/настройки конверсии изменились — пересчитать конверсии бюджетов карточек. /// - /// True — полный пересчёт всех кандидатов (оба текущих триггера). Параметр - /// зарезервирован для будущих частичных событий (пересчёт одной карточки). - /// Токен отмены. + /// True — полный пересчёт всех кандидатов (оба текущих триггера). Параметр зарезервирован для будущих частичных событий (пересчёт одной карточки). public Task OnRatesChangedAsync(bool fullRecompute, CancellationToken ct); } diff --git a/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesSource.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesSource.cs index 274084f..09ca697 100644 --- a/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesSource.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesSource.cs @@ -3,21 +3,13 @@ using Deal.Modules.Settings.Application.Services; namespace Deal.Modules.Settings.Application.Abstractions; /// -/// Порт источника курсов валют к рублю (Ruling 6, Task 8). +/// Порт источника курсов валют к рублю. /// -/// -/// Модуль Settings определяет только контракт; HTTP-адаптер (ЦБ РФ) живёт в Deal.Infrastructure -/// (CbrRateSource). Метод возвращает словарь «код валюты → курс к RUB» (включая RUB:1) -/// либо null при любом сбое источника (HTTP-код ≠ 200, нераспознанное тело, сетевая ошибка). -/// Абстракция асинхронна — реальный источник ходит по сети; мок-режим обходит порт целиком -/// (RatesService сам сохраняет константу ). -/// public interface IRatesSource { /// /// Запрашивает актуальные курсы к рублю. /// - /// Токен отмены. /// Словарь «код валюты (RUB/USD/EUR/…) → курс к RUB» или null при сбое. public Task?> FetchAsync(CancellationToken ct); } diff --git a/src/core/Deal.Modules.Settings/Application/Abstractions/ISecretCipher.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/ISecretCipher.cs index 7a1aa68..1c8a5c1 100644 --- a/src/core/Deal.Modules.Settings/Application/Abstractions/ISecretCipher.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/ISecretCipher.cs @@ -1,14 +1,8 @@ namespace Deal.Modules.Settings.Application.Abstractions; /// -/// Порт симметричного шифрования секретов тенанта (ключи AI/Telegram), Ruling 2. +/// Порт симметричного шифрования секретов тенанта /// -/// -/// Формат зашифрованного значения: enc: + Base64(nonce ‖ шифротекст ‖ tag). -/// Реализация (AES-256-GCM) живёт в Deal.Infrastructure/Security; модуль не знает -/// криптографических деталей. Расшифровка повреждённого или чужого значения возвращает -/// пустую строку без исключений (семантика crypto.decrypt_text, crypto.py L52–61). -/// public interface ISecretCipher { /// @@ -22,9 +16,6 @@ public interface ISecretCipher /// Расшифровывает значение формата enc: + Base64(nonce ‖ шифротекст ‖ tag). /// /// Значение из хранилища. - /// - /// Открытый текст. Пустая строка (без исключений) для пустого входа, значения без префикса - /// enc: и повреждённого/зашифрованного чужим ключом значения. - /// + /// Открытый текст. Пустая строка (без исключений) для пустого входа, значения без префикса enc: и повреждённого/зашифрованного чужим ключом значения. public string Decrypt(string cipherText); } diff --git a/src/core/Deal.Modules.Settings/Application/Abstractions/ISettingsStore.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/ISettingsStore.cs index de33e92..303d4c7 100644 --- a/src/core/Deal.Modules.Settings/Application/Abstractions/ISettingsStore.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/ISettingsStore.cs @@ -3,30 +3,20 @@ using Deal.Modules.Settings.Application.Models; namespace Deal.Modules.Settings.Application.Abstractions; /// -/// Порт KV-хранилища настроек тенанта (таблица settings), Ruling 1. +/// Порт KV-хранилища настроек тенанта /// -/// -/// Порт оперирует JSON-строками значений — 1:1 с колонками таблицы -/// (key/value_json/updated_at, сущность TenantSettingEntity). Модуль хранит только -/// переопределения: публичные и внутренние (ratesCache, mlDecisions, -/// aiDecisions) ключи лежат в одном хранилище. Сериализацию значений в JSON -/// выполняет вызывающий (SettingsService и сервисы модуля) — адаптер (Task 4) и модуль -/// не зависят от конкретного типа значения. Реализация — EF-адаптер в Deal.Infrastructure. -/// public interface ISettingsStore { /// /// Читает одно значение по ключу. /// /// Ключ настройки (см. ). - /// Токен отмены. /// Значение или null, если строка отсутствует (значит — дефолт из кода). public Task GetAsync(string key, CancellationToken ct); /// - /// Читает все сохранённые значения (только переопределения, без дефолтов). + /// Читает все сохранённые значения /// - /// Токен отмены. /// Все строки настроек тенанта. public Task> GetAllAsync(CancellationToken ct); @@ -35,16 +25,14 @@ public interface ISettingsStore /// /// Ключ настройки. /// Значение, сериализованное в JSON. - /// Токен отмены. public Task SetAsync( string key, string valueJson, CancellationToken ct); /// - /// Удаляет значение по ключу (нет строки — no-op). + /// Удаляет значение по ключу /// /// Ключ настройки. - /// Токен отмены. public Task RemoveAsync(string key, CancellationToken ct); } diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiCheckRequest.cs b/src/core/Deal.Modules.Settings/Application/Models/AiCheckRequest.cs index 4d62a8e..e970229 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/AiCheckRequest.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/AiCheckRequest.cs @@ -1,15 +1,8 @@ namespace Deal.Modules.Settings.Application.Models; /// -/// Данные проверки подключения AI-провайдера (порт , Ruling 7). +/// Данные проверки подключения AI-провайдера. /// -/// -/// Собирается HTTP-слоем (AiCheckEndpoint) из активной конфигурации провайдера тенанта — -/// настройки aiProvider + aiConfigs с расшифрованным и меты -/// каталога (локальность, стиль API). Ключ в DTO — открытым текстом: -/// DTO живёт только в рамках одного запроса проверки и наружу не сериализуется (в ответ — -/// только флаг keySet и маска keyMasked). -/// /// Идентификатор провайдера (id из каталога AiProviders, ключ в aiConfigs). /// Эффективный базовый URL: переопределение aiConfigs или дефолт каталога. /// Активная модель: переопределение aiConfigs или первая модель каталога. diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiCheckResultDto.cs b/src/core/Deal.Modules.Settings/Application/Models/AiCheckResultDto.cs index 52e08d3..ede2c56 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/AiCheckResultDto.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/AiCheckResultDto.cs @@ -1,23 +1,17 @@ namespace Deal.Modules.Settings.Application.Models; /// -/// Результат проверки подключения AI-провайдера — тело POST /api/ai/check (api-map §4.10, Ruling 7). +/// Результат проверки подключения AI-провайдера — тело POST /api/ai/check. /// -/// -/// 1:1 с прототипом: {ok, message} + статус провайдера (provider,name,base,model,local,keySet, -/// keyMaskedai.py provider_status L36–58). Сериализуется в camelCase: -/// ok/message/provider/name/base/model/local/keySet/keyMasked. Ключ наружу не попадает — только -/// флаг и маска . -/// /// True — подключение успешно (включая локальные серверы). -/// Сообщение для UI (фиксированные строки прототипа, Ruling 7). +/// Сообщение для UI. /// Активный провайдер (id из каталога). /// Имя провайдера из каталога. /// Эффективный базовый URL. /// Активная модель. /// True — провайдер локальный. /// True — API-ключ задан (расшифрованный непустой). -/// Маска ключа (ai.py mask_key L53–58): пусто, «x…» (len ≤ 8) или «1234…5678». +/// Маска ключа: пусто, «x…» (len ≤ 8) или «1234…5678». public sealed record AiCheckResultDto( bool Ok, string Message, diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiConfigPublicDto.cs b/src/core/Deal.Modules.Settings/Application/Models/AiConfigPublicDto.cs index 022a57a..829a820 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/AiConfigPublicDto.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/AiConfigPublicDto.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Settings.Application.Models; /// -/// Публичная форма конфигурации AI-провайдера в aiConfigs (Ruling 3, api-map §4.6). +/// Публичная форма конфигурации AI-провайдера в aiConfigs. /// -/// -/// Секрет наружу не отдаётся: вместо ключа — флаг и маска -/// (первые 4 + «…» + последние 4 символа). Сериализуется в camelCase: -/// baseUrl/model/keySet/keyMasked. -/// /// Базовый URL API провайдера. /// Активная модель. /// True — API-ключ задан (после расшифровки не пустой). diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiConfigSetting.cs b/src/core/Deal.Modules.Settings/Application/Models/AiConfigSetting.cs index 1b20621..507aded 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/AiConfigSetting.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/AiConfigSetting.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Settings.Application.Models; /// -/// Сохранённая конфигурация AI-провайдера в настройке aiConfigs (включает секрет). +/// Сохранённая конфигурация AI-провайдера в настройке aiConfigs /// -/// -/// Внутренняя форма значения (json: {"apiKey":…, "baseUrl":…, "model":…}), живёт в БД; -/// наружу не отдаётся — публичная форма это (маскирование, Ruling 3). -/// Сериализация/десериализация — System.Text.Json c camelCase-политикой (совпадает с прототипом). -/// /// API-ключ: пустая строка или зашифрованное значение с префиксом enc:. /// Базовый URL API (переопределение дефолта провайдера). /// Активная модель. diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiProviderDefinition.cs b/src/core/Deal.Modules.Settings/Application/Models/AiProviderDefinition.cs index 9d20ac8..8e093dd 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/AiProviderDefinition.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/AiProviderDefinition.cs @@ -1,12 +1,8 @@ namespace Deal.Modules.Settings.Application.Models; /// -/// Описание AI-провайдера — зеркало constants.AI_PROVIDERS (constants.py L170–186), Ruling 3. +/// Описание AI-провайдера — зеркало constants.AI_PROVIDERS. /// -/// -/// Список статический: модуль Settings — единый источник провайдеров для настроек -/// и проверки подключения. Поле внутреннее: наружу не отдаётся. -/// /// Идентификатор провайдера (совпадает с ключом в настройке aiConfigs). /// Человекочитаемое имя. /// Базовый URL API по умолчанию. diff --git a/src/core/Deal.Modules.Settings/Application/Models/AiProviders.cs b/src/core/Deal.Modules.Settings/Application/Models/AiProviders.cs index a4d076d..bbb6db2 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/AiProviders.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/AiProviders.cs @@ -1,17 +1,12 @@ namespace Deal.Modules.Settings.Application.Models; /// -/// Статический список AI-провайдеров (зеркало constants.AI_PROVIDERS, constants.py L170–186). +/// Статический список AI-провайдеров. /// -/// -/// Семь провайдеров: deepseek/openai/openrouter/anthropic/ollama/lmstudio/custom. -/// Список отдаётся в public-снимок настроек (поле providers, Ruling 3) -/// и используется при валидации PATCH (aiProvider/aiConfigs). -/// public static class AiProviders { /// - /// Все провайдеры в фиксированном порядке (как в прототипе). + /// Все провайдеры в фиксированном порядке. /// public static readonly IReadOnlyList All = new List { diff --git a/src/core/Deal.Modules.Settings/Application/Models/DefaultPrompts.cs b/src/core/Deal.Modules.Settings/Application/Models/DefaultPrompts.cs index cec195d..72b6dcb 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/DefaultPrompts.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/DefaultPrompts.cs @@ -1,19 +1,12 @@ namespace Deal.Modules.Settings.Application.Models; /// -/// Дефолтные тексты ИИ-промптов (копия из src/frontend/src/data.js L94–141). +/// Дефолтные тексты ИИ-промптов. /// -/// -/// Фронт — высший авторитет форм (план L47): тексты копируются из data.js, а не из -/// constants.py. Плейсхолдеры {domain}/{keywords} остаются в тексте — -/// подстановка выполняется при вызове ИИ (PromptFiller, Task 7). Значения хранятся -/// в static readonly, а не const: тексты многострочные и требуют -/// нормализации переводов строк на этапе инициализации. -/// public static class DefaultPrompts { /// - /// Промпт классификатора входящих сообщений (data.js DEFAULT_AI_PROMPT, L94–114). + /// Промпт классификатора входящих сообщений. /// public static readonly string DefaultAiPrompt = Normalize( """ @@ -41,7 +34,7 @@ public static class DefaultPrompts """); /// - /// Промпт структуры карточки — блок «О заявке» (data.js DEFAULT_AI_CARD_PROMPT, L116–123). + /// Промпт структуры карточки — блок «О заявке». /// public static readonly string DefaultCardPrompt = Normalize( """ @@ -56,7 +49,7 @@ public static class DefaultPrompts """); /// - /// Промпт стража входящих (ИИ-фильтр) (data.js DEFAULT_AI_FILTER_PROMPT, L125–141). + /// Промпт стража входящих /// public static readonly string DefaultAiFilterPrompt = Normalize( """ diff --git a/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs b/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs index 7fd50bc..f85259b 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs @@ -1,17 +1,12 @@ namespace Deal.Modules.Settings.Application.Models; /// -/// Каталог ключей глобальных (системных) настроек оператора (таблица public.global_settings). +/// Каталог ключей глобальных /// -/// -/// Глобальные настройки задаёт оператор SaaS-контура, они едины для всех тенантов (ТЗ §4.1/§8.1). -/// Ключи пишутся в БД в оригинальном camelCase; JSON-форма значения — на стороне владельца ключа. -/// public static class GlobalSettingsKeys { /// - /// Ключ «telegramKeys»: ключи приложения Telegram (api_id/api_hash), задаёт оператор глобально - /// (ТЗ §4.1/§8.1). Формат значения — JSON {"apiId":…, "apiHash":"enc:…"}. + /// Ключ «telegramKeys» /// public const string TelegramKeys = "telegramKeys"; } diff --git a/src/core/Deal.Modules.Settings/Application/Models/IncomingRules.cs b/src/core/Deal.Modules.Settings/Application/Models/IncomingRules.cs index 9fb198b..b64fdee 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/IncomingRules.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/IncomingRules.cs @@ -3,59 +3,40 @@ using Deal.Modules.Settings.Application.Abstractions; namespace Deal.Modules.Settings.Application.Models; /// -/// Этап-1 правила фильтра входящих — чистая реализация stage1_plain (pipeline.py L94–124). +/// Этап-1 правила фильтра входящих — чистая реализация stage1_plain. /// -/// -/// Детерминированные правила без ИИ поверх настроек тенанта (): -/// минимальная длина (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). + /// kind: текст короче minLen. /// public const string KindLength = "length"; /// - /// kind: найдена стоп-фраза (pipeline.py L108). + /// kind: найдена стоп-фраза. /// public const string KindStop = "stop"; /// - /// kind: текст опознан как резюме соискателя (pipeline.py L114). + /// kind: текст опознан как резюме соискателя. /// public const string KindResume = "resume"; /// - /// kind: тип текста не совпал с wantedType (pipeline.py L121/L123). + /// kind: тип текста не совпал с wantedType. /// public const string KindType = "type"; /// - /// kind на проходе — пустая строка (как в python-результате stage1_plain). + /// kind на проходе — пустая строка. /// public const string KindPass = ""; - // Маркер резюме с контекстным guard («…вакансия…, присылайте резюме», pipeline.py L655). private const string ResumeMarkerGuardWord = "резюме"; // wantedType: собираем и вакансии, и разовые заказы (дефолт). @@ -67,38 +48,28 @@ public sealed class IncomingRules(ISettingsStore store) // 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). + /// Проверяет текст -1 фильтра по настройкам тенанта. /// - /// Текст сообщения; null/пустой трактуется как «короче minLen» (как (text or "").strip()). - /// Токен отмены. - /// Вердикт этапа 1: {pass, reason, stage:1, kind, kw}. + /// Текст сообщения; null/пустой трактуется как «короче minLen» (как (text or "").strip). + /// Вердикт: {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) { @@ -113,7 +84,6 @@ public sealed class IncomingRules(ISettingsStore store) string lower = trimmed.ToLowerInvariant(); - // Стоп-фразы: подстрочное вхождение casefold-фразы; kw — фраза как сохранена (pipeline.py L106–108). foreach (string phrase in settings.StopPhrases) { if (string.IsNullOrEmpty(phrase)) @@ -127,7 +97,6 @@ public sealed class IncomingRules(ISettingsStore store) } } - // Отсев резюме соискателей (blockResumes): до ИИ и правил колонок (pipeline.py L109–114). if (settings.BlockResumes) { string? marker = FindResumeMarker(lower, settings.ResumeMarkers, settings.HireMarkers); @@ -137,7 +106,6 @@ public sealed class IncomingRules(ISettingsStore store) } } - // Тип заявок «только вакансии»/«только разовые» — по маркерам найма (pipeline.py L115–123). string wantedType = settings.WantedType; if (wantedType == WantedTypeVacancy || wantedType == WantedTypeFreelance) { @@ -153,18 +121,14 @@ public sealed class IncomingRules(ISettingsStore store) } } - // Проход: 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, @@ -211,8 +175,6 @@ public sealed class IncomingRules(ISettingsStore store) // Собирает вердикт-отсев: stage всегда 1, причина фиксированная. // kind: Правило, сработавшее на тексте (length|stop|resume|type). // reason: Причина для UI. - // Kw: Фраза/маркер, по которому текст отсечён (для мониторинга); иначе "" (как python). - // Возвращает: Результат этапа 1: pass=false. private static IncomingRulesResult Fail( string kind, string reason, @@ -221,7 +183,6 @@ public sealed class IncomingRules(ISettingsStore store) return new IncomingRulesResult(Pass: false, Reason: reason, Stage, kind, Kw); } - // Читает эффективные настройки этапа-1 из типизированного снимка (Ruling 1, C30). // settings: Снимок настроек тенанта (дефолты, перекрытые сохранёнными). // Возвращает: Снимок значений для чистой проверки. private static RulesSettings ReadSettings(TenantSettingsSnapshot settings) @@ -247,7 +208,6 @@ public sealed class IncomingRules(ISettingsStore store) return wanted.Length > 0 ? wanted : WantedTypeBoth; } - // Нормализует маркеры для сравнения: trim + lowercase, пустые отбрасываются (pipeline.py L619–641). // markers: Список маркеров как сохранён (или дефолт). // Возвращает: Нормализованный список в порядке исходного (детерминированно). private static IReadOnlyList NormalizeMarkers(IReadOnlyList markers) @@ -265,7 +225,6 @@ public sealed class IncomingRules(ISettingsStore store) return result; } - // Снимок эффективных настроек этапа-1 для чистой проверки текста. // MinLen: Минимальная длина текста (minLen). // StopPhrases: Стоп-фразы как сохранены (kw отдаётся исходным списком). // BlockResumes: True — резюме соискателей отсекаем. diff --git a/src/core/Deal.Modules.Settings/Application/Models/IncomingRulesResult.cs b/src/core/Deal.Modules.Settings/Application/Models/IncomingRulesResult.cs index 039cecd..6758c30 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/IncomingRulesResult.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/IncomingRulesResult.cs @@ -1,21 +1,12 @@ namespace Deal.Modules.Settings.Application.Models; /// -/// Результат этапа-1 правил фильтра входящих — 1:1 со stage1_plain (pipeline.py L94–124). +/// Результат -1 правил фильтра входящих — со stage1_plain. /// -/// -/// Этап 1 детерминирован (без ИИ): минимальная длина, стоп-фразы, отсев резюме, тип заявки. -/// Форма результата — {pass, reason, stage, kind, kw} (план Task 10 L373–375, pipeline.py L97–99): -/// kind — какое правило сработало (length|stop|resume|type; на проходе — пустая строка), -/// kw — конкретная фраза/маркер, по которому текст отсечён (для мониторинга и этапа 4). -/// При pass=true =null, и — пустые (как python). -/// Поля сериализуются в camelCase (pass/reason/stage/kind/kw); HTTP-слой тестера отдаёт наружу -/// только pass/reason (api-map §4.10 L364) — kind/kw — внутренние для пайплайна этапа 4. -/// -/// True — текст прошёл этап 1 (дальше этап 2/ИИ); False — отсечён правилом. -/// Причина для UI (фиксированные строки прототипа) или null на проходе. -/// Номер этапа пайплайна — всегда 1 (константа ). -/// Какое правило сработало: length|stop|resume|type; на проходе — "" (pipeline.py L97–99). +/// True — текст прошёл; False — отсечён правилом. +/// Причина для UI или null на проходе. +/// Номер пайплайна — всегда 1 (константа ). +/// Какое правило сработало: length|stop|resume|type; на проходе — "". /// Конкретная стоп-фраза/маркер резюме (для мониторинга отсева); иначе "". public sealed record IncomingRulesResult( bool Pass, diff --git a/src/core/Deal.Modules.Settings/Application/Models/MyPromptDto.cs b/src/core/Deal.Modules.Settings/Application/Models/MyPromptDto.cs index 99cfa8c..b2937b6 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/MyPromptDto.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/MyPromptDto.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Settings.Application.Models; /// -/// Элемент «Моих промптов» (myPrompts, api-map §4.7). +/// Элемент «Моих промптов» /// -/// -/// Наружу — camelCase: id/name/description/prompt. Идентификатор генерирует фронт -/// (префикс pp_) или бэк при отсутствии. Ограничения: name ≤ 80, description ≤ 300, -/// prompt ≤ 8000 символов, элементов ≤ 100 (Task 3). -/// /// Идентификатор промпта (начинается с pp_). /// Название (как показывается пользователю). /// Короткое описание (необязательное). diff --git a/src/core/Deal.Modules.Settings/Application/Models/ProviderPublicDto.cs b/src/core/Deal.Modules.Settings/Application/Models/ProviderPublicDto.cs index 30bd77e..a1326cb 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/ProviderPublicDto.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/ProviderPublicDto.cs @@ -1,12 +1,8 @@ namespace Deal.Modules.Settings.Application.Models; /// -/// Провайдер ИИ в public-снимке настроек (поле providers, Ruling 3, api-map §4.6). +/// Провайдер ИИ в public-снимке настроек. /// -/// -/// Статический список из без внутреннего -/// поля api_style. Сериализуется в camelCase: id/name/base/local/models. -/// /// Идентификатор провайдера. /// Человекочитаемое имя. /// Базовый URL API. diff --git a/src/core/Deal.Modules.Settings/Application/Models/PublicSettingsDto.cs b/src/core/Deal.Modules.Settings/Application/Models/PublicSettingsDto.cs index d90bd66..59ba100 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/PublicSettingsDto.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/PublicSettingsDto.cs @@ -1,14 +1,8 @@ namespace Deal.Modules.Settings.Application.Models; /// -/// Публичный снимок настроек тенанта — тело ответа GET/PATCH /api/settings (api-map §4.6). +/// Публичный снимок настроек тенанта — тело ответа GET/PATCH /api/settings /// -/// -/// Снимок = дефолты SettingsDefaults, перекрытые сохранёнными значениями (Ruling 1); -/// секреты заменены масками/флагами (Ruling 3). Наружу сериализуется в camelCase -/// (политика ASP.NET Core по умолчанию), поэтому свойства названы в PascalCase. -/// Неизвестные/внутренние ключи в снимок не попадают. -/// public sealed record PublicSettingsDto { /// @@ -17,7 +11,7 @@ public sealed record PublicSettingsDto public bool AutoArchive { get; init; } /// - /// Дней до авто-архивации карточки (кламп 1..30). + /// Дней до авто-архивации карточки /// public int ArchiveAfterDays { get; init; } @@ -32,12 +26,12 @@ public sealed record PublicSettingsDto public int TrashClearDays { get; init; } /// - /// Минимальная длина сообщения для этапа-1 фильтра (кламп 10..500). + /// Минимальная длина сообщения для -1 фильтра /// public int MinLen { get; init; } /// - /// Стоп-фразы этапа-1 фильтра. + /// Стоп-фразы -1 фильтра. /// public IReadOnlyList StopPhrases { get; init; } = Array.Empty(); @@ -47,7 +41,7 @@ public sealed record PublicSettingsDto public bool MlEnabled { get; init; } /// - /// Полный выключатель ИИ (false — только фильтр + ML). + /// Полный выключатель ИИ /// public bool AiEnabled { get; init; } @@ -57,7 +51,7 @@ public sealed record PublicSettingsDto public bool AiFilterEnabled { get; init; } /// - /// Промпт классификатора входящих (плейсхолдеры {domain}/{keywords}). + /// Промпт классификатора входящих /// public string AiPrompt { get; init; } = string.Empty; @@ -67,12 +61,12 @@ public sealed record PublicSettingsDto public string AiFilterPrompt { get; init; } = string.Empty; /// - /// Промпт структуры карточки (блок «О заявке»). + /// Промпт структуры карточки /// public string CardPrompt { get; init; } = string.Empty; /// - /// Тип собираемых заявок: both | vacancy | freelance. + /// Тип собираемых заявок /// public string WantedType { get; init; } = string.Empty; @@ -97,7 +91,7 @@ public sealed record PublicSettingsDto public string OrderLabel { get; init; } = string.Empty; /// - /// Описание сферы (подставляется в {domain}). + /// Описание сферы /// public string DomainDescription { get; init; } = string.Empty; @@ -107,7 +101,7 @@ public sealed record PublicSettingsDto public IReadOnlyList DomainKeywords { get; init; } = Array.Empty(); /// - /// Маркеры найма (локальный разбор). + /// Маркеры найма /// public IReadOnlyList HireMarkers { get; init; } = Array.Empty(); @@ -122,32 +116,32 @@ public sealed record PublicSettingsDto public IReadOnlyList ResumeMarkers { get; init; } = Array.Empty(); /// - /// Отсекать резюме соискателей на этапе 1. + /// Отсекать резюме соискателей на. /// public bool BlockResumes { get; init; } /// - /// Глобальные исключения по ключевым словам/фразам/технологиям (стоп до ML/ИИ). + /// Глобальные исключения по ключевым словам/фразам/технологиям /// public IReadOnlyList ExcludeKeywords { get; init; } = Array.Empty(); /// - /// Глобальные исключения по локации/языку (стоп до ML/ИИ). + /// Глобальные исключения по локации/языку /// public IReadOnlyList ExcludeLocations { get; init; } = Array.Empty(); /// - /// Глобальные исключения по типу заявки (vacancy|freelance|announcement). + /// Глобальные исключения по типу заявки /// public IReadOnlyList ExcludeTypes { get; init; } = Array.Empty(); /// - /// Нижняя граница глобального исключения по бюджету (0 — не задана). + /// Нижняя граница глобального исключения по бюджету /// public int ExcludeBudgetFrom { get; init; } /// - /// Верхняя граница глобального исключения по бюджету (0 — не задана). + /// Верхняя граница глобального исключения по бюджету /// public int ExcludeBudgetTo { get; init; } @@ -157,7 +151,7 @@ public sealed record PublicSettingsDto public IReadOnlyList MyPrompts { get; init; } = Array.Empty(); /// - /// Общие напоминания включены (Ruling 10). + /// Общие напоминания включены. /// public bool RemindersEnabled { get; init; } @@ -172,7 +166,7 @@ public sealed record PublicSettingsDto public string TargetCurrency { get; init; } = string.Empty; /// - /// Источник курсов: cbr | mock. + /// Источник курсов /// public string RateSource { get; init; } = string.Empty; @@ -182,27 +176,27 @@ public sealed record PublicSettingsDto public bool AutoMonitorNew { get; init; } /// - /// Суточный лимит авто-вступлений Discovery (кламп 1..200). + /// Суточный лимит авто-вступлений Discovery /// public int DiscJoinLimit { get; init; } /// - /// Нижняя граница паузы между авто-вступлениями, сек (5..600). + /// Нижняя граница паузы между авто-вступлениями, сек /// public int DiscJoinDelayMin { get; init; } /// - /// Верхняя граница паузы между авто-вступлениями, сек (5..600). + /// Верхняя граница паузы между авто-вступлениями, сек /// public int DiscJoinDelayMax { get; init; } /// - /// Размер выборки сообщений при оценке канала (3..30). + /// Размер выборки сообщений при оценке канала /// public int DiscEvalSample { get; init; } /// - /// Процент подходящих сообщений для оценки канала (1..100). + /// Процент подходящих сообщений для оценки канала /// public int DiscEvalThreshold { get; init; } @@ -212,24 +206,24 @@ public sealed record PublicSettingsDto public bool DiscPaused { get; init; } /// - /// Состояние колонок канбана (произвольный объект, Ruling 9). + /// Состояние колонок канбана. /// public IReadOnlyDictionary ColState { get; init; } = new Dictionary(); /// - /// Активный AI-провайдер (id из Providers). + /// Активный AI-провайдер /// public string AiProvider { get; init; } = string.Empty; /// - /// Публичные конфигурации AI-провайдеров (id → маска/флаги). + /// Публичные конфигурации AI-провайдеров /// public IReadOnlyDictionary AiConfigs { get; init; } = new Dictionary(); /// - /// Статический список AI-провайдеров (Ruling 3). + /// Статический список AI-провайдеров. /// public IReadOnlyList Providers { get; init; } = Array.Empty(); } diff --git a/src/core/Deal.Modules.Settings/Application/Models/RatesCacheValue.cs b/src/core/Deal.Modules.Settings/Application/Models/RatesCacheValue.cs index 15a0698..5ddfdb9 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/RatesCacheValue.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/RatesCacheValue.cs @@ -1,14 +1,8 @@ namespace Deal.Modules.Settings.Application.Models; /// -/// Значение внутреннего KV-ключа ratesCache (Ruling 6, Task 8). +/// Значение внутреннего KV-ключа ratesCache. /// -/// -/// Форма хранения — {rates, source, updatedAtMs} (Ruling 6 L83): JSON-сериализованный объект этого -/// типа в колонке value_json таблицы settings. Ключ внутренний (Ruling 1): в GET/PATCH /api/settings не -/// участвует, владелец — RatesService. всегда нормализован (mock|cbr), -/// даже если в настройке rateSource лежит произвольная строка (не-mock → запрос ЦБ, запись source cbr). -/// /// Курсы к рублю: «код валюты → курс» (включая RUB:1). /// Источник сохранённых курсов: mock | cbr. /// Момент сохранения кэша (Unix-ms, UTC). diff --git a/src/core/Deal.Modules.Settings/Application/Models/RatesDto.cs b/src/core/Deal.Modules.Settings/Application/Models/RatesDto.cs index 9d44946..2a0d6e4 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/RatesDto.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/RatesDto.cs @@ -3,15 +3,8 @@ using System.Text.Json.Serialization; namespace Deal.Modules.Settings.Application.Models; /// -/// Курсы валют — тело ответа GET /api/rates и поле rates POST /api/rates/refresh (api-map §3.4 L149–150). +/// Курсы валют — тело ответа GET /api/rates и поле rates POST /api/rates/refresh. /// -/// -/// 1:1 с прототипом get_rates() (rates.py L23–31): {base:"RUB", rates:{CODE:курс}, source:"cbr"|"mock", -/// updatedAt:ms|null}. Поле сериализуется как updatedAt (JsonPropertyName): -/// фронт читает r.rates.updatedAt (applyRates, store.js L407–410), план Task 8 L330. -/// — всегда RUB (курсы даются к рублю). При отсутствии кэша ответ = мок-курсы со source "mock" -/// и updatedAt null (Ruling 6). -/// /// Базовая валюта (RUB). /// Курсы к базовой: «код валюты → сколько базовой за 1 единицу». /// Источник кэша: mock | cbr. diff --git a/src/core/Deal.Modules.Settings/Application/Models/SettingKind.cs b/src/core/Deal.Modules.Settings/Application/Models/SettingKind.cs index 90f0fdf..24c365c 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/SettingKind.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/SettingKind.cs @@ -1,14 +1,8 @@ namespace Deal.Modules.Settings.Application.Models; /// -/// Категория ключа настроек тенанта: определяет тип значения и обработку в PATCH (Ruling 1). +/// Категория ключа настроек тенанта /// -/// -/// Значение в хранилище всегда JSON-сериализовано; категория задаёт ожидаемую -/// форму JSON (число/булево/строка/массив/объект) и используется SettingsService -/// при сборке public-снимка и применении PATCH (клампы и валидация — Task 3). -/// Перечень ключей каждой категории — каталог (1:1 api-map §4.6). -/// public enum SettingKind { /// @@ -27,27 +21,27 @@ public enum SettingKind String, /// - /// Список строк (например, stopPhrases, hireMarkers). + /// Список строк /// List, /// - /// Произвольный JSON-объект (например, colState). + /// Произвольный JSON-объект /// Dict, /// - /// Личная библиотека промптов пользователя (ключ myPrompts). + /// Личная библиотека промптов пользователя /// MyPrompts, /// - /// Конфигурации AI-провайдеров с ключами (ключ aiConfigs). + /// Конфигурации AI-провайдеров с ключами /// AiConfigs, /// - /// Внутренний (непубличный) ключ: ratesCache, mlDecisions, aiDecisions. + /// Внутренний (непубличный) ключ /// Internal, } diff --git a/src/core/Deal.Modules.Settings/Application/Models/SettingValue.cs b/src/core/Deal.Modules.Settings/Application/Models/SettingValue.cs index 9a30d47..9f0780f 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/SettingValue.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/SettingValue.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Settings.Application.Models; /// -/// Значение настройки из KV-хранилища (строка таблицы settings: key/value_json/updated_at). +/// Значение настройки из KV-хранилища /// -/// -/// — значение, уже сериализованное в JSON (Ruling 1): формат значения -/// зависит от категории ключа (). Модуль хранит только переопределения; -/// дефолты живут в SettingsDefaults. -/// /// Ключ настройки (имя из каталога SettingsKeys). /// Значение, сериализованное в JSON. /// Момент последней записи (UTC). diff --git a/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs b/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs index 4de4a50..e4c9781 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs @@ -1,48 +1,41 @@ 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»: авто-архивация включена. + /// Дефолт «autoArchive» /// public const bool AutoArchive = true; /// - /// Дефолт «archiveAfterDays»: 14 дней (кламп 1..30). + /// Дефолт «archiveAfterDays» /// public const int ArchiveAfterDays = 14; /// - /// Дефолт «archiveClearDays»: 90 дней хранения в архиве. + /// Дефолт «archiveClearDays» /// public const int ArchiveClearDays = 90; /// - /// Дефолт «trashClearDays»: 7 дней хранения в корзине. + /// Дефолт «trashClearDays» /// public const int TrashClearDays = 7; // ── Фильтры входящих ── /// - /// Дефолт «minLen»: минимальная длина сообщения — 24 (constants.py L57, кламп 10..500). + /// Дефолт «minLen» /// public const int MinLen = 24; /// - /// Дефолтные стоп-фразы (constants.py L55). + /// Дефолтные стоп-фразы. /// public static readonly IReadOnlyList StopPhrases = new[] { @@ -53,49 +46,49 @@ public static class SettingsDefaults }; /// - /// Дефолт «mlEnabled»: локальный ML-слой включён. + /// Дефолт «mlEnabled» /// public const bool MlEnabled = true; /// - /// Дефолт «aiEnabled»: полный выключатель ИИ — включён. + /// Дефолт «aiEnabled» /// public const bool AiEnabled = true; /// - /// Дефолт «aiFilterEnabled»: ИИ-фильтр входящих включён. + /// Дефолт «aiFilterEnabled» /// public const bool AiFilterEnabled = true; /// - /// Дефолт «aiPrompt» — текст из DefaultPrompts (data.js L94–114). + /// Дефолт «aiPrompt» — текст из DefaultPrompts. /// public static readonly string AiPrompt = DefaultPrompts.DefaultAiPrompt; /// - /// Дефолт «aiFilterPrompt» — текст из DefaultPrompts (data.js L125–141). + /// Дефолт «aiFilterPrompt» — текст из DefaultPrompts. /// public static readonly string AiFilterPrompt = DefaultPrompts.DefaultAiFilterPrompt; /// - /// Дефолт «cardPrompt» — текст из DefaultPrompts (data.js L116–123). + /// Дефолт « » — текст из DefaultPrompts. /// public static readonly string CardPrompt = DefaultPrompts.DefaultCardPrompt; // ── Сфера и ключи ── /// - /// Дефолт «domainDescription»: описание сферы пустое. + /// Дефолт «domainDescription» /// public const string DomainDescription = ""; /// - /// Дефолт «domainKeywords»: пустой список. + /// Дефолт «domainKeywords» /// public static readonly IReadOnlyList DomainKeywords = Array.Empty(); /// - /// Дефолтные маркеры найма (constants.py L144–149). + /// Дефолтные маркеры найма. /// public static readonly IReadOnlyList HireMarkers = new[] { @@ -106,7 +99,7 @@ public static class SettingsDefaults }; /// - /// Дефолтные термины грейдов/уровней (constants.py L151–154). + /// Дефолтные термины грейдов/уровней. /// public static readonly IReadOnlyList LevelTerms = new[] { @@ -115,7 +108,7 @@ public static class SettingsDefaults }; /// - /// Дефолтные маркеры резюме соискателей (constants.py L163–167). + /// Дефолтные маркеры резюме соискателей. /// public static readonly IReadOnlyList ResumeMarkers = new[] { @@ -125,146 +118,145 @@ public static class SettingsDefaults }; /// - /// Дефолт «blockResumes»: резюме соискателей отсекаем. + /// Дефолт «blockResumes» /// public const bool BlockResumes = true; // ── Глобальные исключения (стоп-уровень, §5.14) ── /// - /// Дефолт «excludeKeywords»: глобальных исключений по словам нет. + /// Дефолт «excludeKeywords» /// public static readonly IReadOnlyList ExcludeKeywords = Array.Empty(); /// - /// Дефолт «excludeLocations»: глобальных исключений по локации нет. + /// Дефолт «excludeLocations» /// public static readonly IReadOnlyList ExcludeLocations = Array.Empty(); /// - /// Дефолт «excludeTypes»: глобальных исключений по типу нет. + /// Дефолт «excludeTypes» /// public static readonly IReadOnlyList ExcludeTypes = Array.Empty(); /// - /// Дефолт «excludeBudgetFrom»: нижняя граница исключения по бюджету не задана (0). + /// Дефолт «excludeBudgetFrom» /// public const int ExcludeBudgetFrom = 0; /// - /// Дефолт «excludeBudgetTo»: верхняя граница исключения по бюджету не задана (0). + /// Дефолт «excludeBudgetTo» /// public const int ExcludeBudgetTo = 0; /// - /// Дефолт «wantedType»: собираем и вакансии, и заказы. + /// Дефолт «wantedType» /// public const string WantedType = "both"; /// - /// Дефолт «budgetRequiredHire»: без суммы карточку найма создаём. + /// Дефолт «budgetRequiredHire» /// public const bool BudgetRequiredHire = false; /// - /// Дефолт «budgetRequiredOrder»: без суммы карточку заказа создаём. + /// Дефолт «budgetRequiredOrder» /// public const bool BudgetRequiredOrder = false; /// - /// Дефолт «hireLabel»: подпись найма. + /// Дефолт «hireLabel» /// public const string HireLabel = "вакансия"; /// - /// Дефолт «orderLabel»: подпись заказа. + /// Дефолт «orderLabel» /// public const string OrderLabel = "фриланс"; // ── Мои промпты / напоминания ── /// - /// Дефолт «myPrompts»: личная библиотека пуста. + /// Дефолт «myPrompts» /// public static readonly IReadOnlyList MyPrompts = Array.Empty(); /// - /// Дефолт «remindersEnabled»: общие напоминания включены. + /// Дефолт «remindersEnabled» /// public const bool RemindersEnabled = true; // ── Валюта и курсы ── /// - /// Дефолт «conversionOn»: конвертация бюджетов включена. + /// Дефолт «conversionOn» /// public const bool ConversionOn = true; /// - /// Дефолт «targetCurrency»: рубль. + /// Дефолт «targetCurrency» /// public const string TargetCurrency = "RUB"; /// - /// Дефолт «rateSource»: ЦБ РФ (cbr | mock). + /// Дефолт «rateSource» /// public const string RateSource = "cbr"; /// - /// Дефолт «colState»: состояние колонок пустое (Ruling 9). + /// Дефолт «colState» /// public static readonly IReadOnlyDictionary ColState = new Dictionary(); // ── ИИ ── /// - /// Дефолт «aiProvider»: deepseek. + /// Дефолт «aiProvider» /// public const string AiProvider = "deepseek"; /// - /// Дефолт «aiConfigs»: конфиг на каждого провайдера (пустой ключ, base, первая модель). + /// Дефолт «aiConfigs» /// public static readonly IReadOnlyDictionary AiConfigs = BuildDefaultAiConfigs(); // ── Telegram / Discovery ── /// - /// Дефолт «autoMonitorNew»: авто-мониторинг новых чатов включён. + /// Дефолт «autoMonitorNew» /// public const bool AutoMonitorNew = true; /// - /// Дефолт «discJoinLimit»: суточный лимит авто-вступлений — 50 (1..200). + /// Дефолт «discJoinLimit» /// public const int DiscJoinLimit = 50; /// - /// Дефолт «discJoinDelayMin»: 50 сек (5..600). + /// Дефолт «discJoinDelayMin» /// public const int DiscJoinDelayMin = 50; /// - /// Дефолт «discJoinDelayMax»: 70 сек (5..600). + /// Дефолт «discJoinDelayMax» /// public const int DiscJoinDelayMax = 70; /// - /// Дефолт «discEvalSample»: 10 сообщений выборки (3..30). + /// Дефолт «discEvalSample» /// public const int DiscEvalSample = 10; /// - /// Дефолт «discEvalThreshold»: 40% подходящих сообщений (1..100). + /// Дефолт «discEvalThreshold» /// public const int DiscEvalThreshold = 40; /// - /// Дефолт «discPaused»: авто-вступления не на паузе (рантайм, в DEFAULT_SETTINGS нет). + /// Дефолт «discPaused» /// public const bool DiscPaused = false; - // Собирает дефолтный «aiConfigs» из списка провайдеров (семантика constants.py L231–234). // Возвращает: Словарь: id провайдера → конфиг с пустым ключом, базовым URL и первой моделью. private static IReadOnlyDictionary BuildDefaultAiConfigs() { diff --git a/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs b/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs index de1b96a..f47dd32 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs @@ -1,290 +1,280 @@ namespace Deal.Modules.Settings.Application.Models; /// -/// Каталог ключей настроек тенанта (Ruling 1, api-map §4.6). +/// Каталог ключей настроек тенанта. /// -/// -/// Публичные ключи — те, что участвуют в GET/PATCH /api/settings; каталог -/// «ключ → категория» повторяет 1:1 список §4.6. Внутренние ключи -/// (, , ) -/// хранятся в той же таблице через ISettingsStore, но в GET/PATCH не участвуют. -/// Ключи в БД пишутся в оригинальном camelCase (как в api-map), поэтому значения -/// констант — строки в нижнем/верблюжьем регистре. -/// public static class SettingsKeys { // ── Внутренние (непубличные) ключи: в GET/PATCH /settings не участвуют ── /// - /// Кэш курсов валют (внутренний, владелец — RatesService, Task 8). + /// Кэш курсов валют. /// public const string RatesCache = "ratesCache"; /// - /// Счётчик решений ML (внутренний, владелец — этап 3/4). + /// Счётчик решений ML. /// public const string MlDecisions = "mlDecisions"; /// - /// Счётчик решений ИИ (внутренний, владелец — этап 6). + /// Счётчик решений ИИ. /// public const string AiDecisions = "aiDecisions"; /// - /// Метка последнего успешного ИИ-предложения колонок, epoch-сек (внутренний, владелец — Task 14 - /// LocalColumnSuggester; прототип — KEY lastSuggestAt suggest.py L52). + /// Метка последнего успешного ИИ-предложения колонок, epoch-сек. /// public const string LastSuggestAt = "lastSuggestAt"; /// - /// Последний статус Telegram-аккаунта (внутренний, владелец — gRPC-ингресс, Ruling 7; JSON). + /// Последний статус Telegram-аккаунта. /// public const string TgStatus = "tgStatus"; /// - /// Аккаунт Telegram «@username» (внутренний, владелец — gRPC-ингресс, Ruling 7; JSON-строка). + /// Аккаунт Telegram «@username». /// public const string TgAccount = "tgAccount"; /// - /// Накопленный учёт токенов ИИ-вызовов (внутренний, владелец — Task 15; Ruling 5). + /// Накопленный учёт токенов ИИ-вызовов. /// public const string AiTokenUsage = "aiTokenUsage"; /// - /// День суточного лимита авто-вступлений Discovery «flood» (внутренний, владелец — Task 18; Ruling 10). + /// День суточного лимита авто-вступлений Discovery «flood». /// public const string DiscFloodDay = "discFloodDay"; // ── Int: целочисленные настройки ── /// - /// Ключ «archiveAfterDays»: дней до авто-архивации карточки (1..30). + /// Ключ «archiveAfterDays» /// public const string ArchiveAfterDays = "archiveAfterDays"; /// - /// Ключ «archiveClearDays»: дней хранения в архиве до очистки. + /// Ключ «archiveClearDays» /// public const string ArchiveClearDays = "archiveClearDays"; /// - /// Ключ «trashClearDays»: дней хранения в корзине до очистки. + /// Ключ «trashClearDays» /// public const string TrashClearDays = "trashClearDays"; /// - /// Ключ «minLen»: минимальная длина сообщения для этапа-1 фильтра (10..500). + /// Ключ «minLen»: минимальная длина сообщения для -1 фильтра /// public const string MinLen = "minLen"; /// - /// Ключ «discJoinLimit»: суточный лимит авто-вступлений Discovery (1..200). + /// Ключ «discJoinLimit» /// public const string DiscJoinLimit = "discJoinLimit"; /// - /// Ключ «discJoinDelayMin»: нижняя граница паузы между авто-вступлениями, сек (5..600). + /// Ключ «discJoinDelayMin» /// public const string DiscJoinDelayMin = "discJoinDelayMin"; /// - /// Ключ «discJoinDelayMax»: верхняя граница паузы между авто-вступлениями, сек (5..600). + /// Ключ «discJoinDelayMax» /// public const string DiscJoinDelayMax = "discJoinDelayMax"; /// - /// Ключ «discEvalSample»: размер выборки сообщений при оценке канала (3..30). + /// Ключ «discEvalSample» /// public const string DiscEvalSample = "discEvalSample"; /// - /// Ключ «discEvalThreshold»: процент подходящих сообщений для оценки (1..100). + /// Ключ «discEvalThreshold» /// public const string DiscEvalThreshold = "discEvalThreshold"; /// - /// Ключ «excludeBudgetFrom»: нижняя граница глобального исключения по бюджету (§5.14; 0 — не задана). + /// Ключ «excludeBudgetFrom» /// public const string ExcludeBudgetFrom = "excludeBudgetFrom"; /// - /// Ключ «excludeBudgetTo»: верхняя граница глобального исключения по бюджету (§5.14; 0 — не задана). + /// Ключ «excludeBudgetTo» /// public const string ExcludeBudgetTo = "excludeBudgetTo"; // ── Bool: булевы настройки ── /// - /// Ключ «autoArchive»: авто-архивация «Выполнено»/«Отклонено». + /// Ключ «autoArchive» /// public const string AutoArchive = "autoArchive"; /// - /// Ключ «aiEnabled»: полный выключатель ИИ. + /// Ключ «aiEnabled» /// public const string AiEnabled = "aiEnabled"; /// - /// Ключ «aiFilterEnabled»: включён ИИ-фильтр входящих (страж). + /// Ключ «aiFilterEnabled» /// public const string AiFilterEnabled = "aiFilterEnabled"; /// - /// Ключ «conversionOn»: конвертация бюджетов карточек в целевую валюту. + /// Ключ «conversionOn» /// public const string ConversionOn = "conversionOn"; /// - /// Ключ «remindersEnabled»: общие напоминания (Ruling 10). + /// Ключ «remindersEnabled» /// public const string RemindersEnabled = "remindersEnabled"; /// - /// Ключ «mlEnabled»: локальный ML-слой. + /// Ключ «mlEnabled» /// public const string MlEnabled = "mlEnabled"; /// - /// Ключ «blockResumes»: отсекать резюме соискателей на этапе 1. + /// Ключ «blockResumes» /// public const string BlockResumes = "blockResumes"; /// - /// Ключ «budgetRequiredHire»: без суммы не создавать карточку найма. + /// Ключ «budgetRequiredHire» /// public const string BudgetRequiredHire = "budgetRequiredHire"; /// - /// Ключ «budgetRequiredOrder»: без суммы не создавать карточку заказа. + /// Ключ «budgetRequiredOrder» /// public const string BudgetRequiredOrder = "budgetRequiredOrder"; /// - /// Ключ «autoMonitorNew»: авто-мониторинг новых чатов/каналов. + /// Ключ «autoMonitorNew» /// public const string AutoMonitorNew = "autoMonitorNew"; /// - /// Ключ «discPaused»: стоп-кран авто-вступлений Discovery (рантайм, дефолт false). + /// Ключ «discPaused» /// public const string DiscPaused = "discPaused"; // ── String: строковые настройки ── /// - /// Ключ «targetCurrency»: целевая валюта конвертации (RUB/USD/…). + /// Ключ «targetCurrency» /// public const string TargetCurrency = "targetCurrency"; /// - /// Ключ «rateSource»: источник курсов (cbr|mock). + /// Ключ «rateSource» /// public const string RateSource = "rateSource"; /// - /// Ключ «aiProvider»: активный AI-провайдер (id из AiProviders). + /// Ключ «aiProvider» /// public const string AiProvider = "aiProvider"; /// - /// Ключ «aiPrompt»: промпт классификатора входящих. + /// Ключ «aiPrompt» /// public const string AiPrompt = "aiPrompt"; /// - /// Ключ «aiFilterPrompt»: промпт стража входящих (ИИ-фильтр). + /// Ключ «aiFilterPrompt» /// public const string AiFilterPrompt = "aiFilterPrompt"; /// - /// Ключ «cardPrompt»: промпт структуры карточки (блок «О заявке»). + /// Ключ « »: промпт структуры карточки /// public const string CardPrompt = "cardPrompt"; /// - /// Ключ «domainDescription»: описание сферы пользователя (подставляется в {domain}). + /// Ключ «domainDescription» /// public const string DomainDescription = "domainDescription"; /// - /// Ключ «wantedType»: тип собираемых заявок (both|vacancy|freelance). + /// Ключ «wantedType» /// public const string WantedType = "wantedType"; /// - /// Ключ «hireLabel»: подпись найма на карточке. + /// Ключ «hireLabel» /// public const string HireLabel = "hireLabel"; /// - /// Ключ «orderLabel»: подпись разового заказа на карточке. + /// Ключ «orderLabel» /// public const string OrderLabel = "orderLabel"; // ── List: списки строк ── /// - /// Ключ «stopPhrases»: стоп-фразы этапа-1 фильтра. + /// Ключ «stopPhrases» /// public const string StopPhrases = "stopPhrases"; /// - /// Ключ «domainKeywords»: общие слова-маркеры заявок сферы. + /// Ключ «domainKeywords» /// public const string DomainKeywords = "domainKeywords"; /// - /// Ключ «hireMarkers»: маркеры найма для локального разбора. + /// Ключ «hireMarkers» /// public const string HireMarkers = "hireMarkers"; /// - /// Ключ «levelTerms»: термины грейдов/уровней. + /// Ключ «levelTerms» /// public const string LevelTerms = "levelTerms"; /// - /// Ключ «resumeMarkers»: маркеры резюме соискателей. + /// Ключ «resumeMarkers» /// public const string ResumeMarkers = "resumeMarkers"; /// - /// Ключ «excludeKeywords»: глобальные исключения по ключевым словам/фразам/технологиям (§5.14). + /// Ключ «excludeKeywords» /// public const string ExcludeKeywords = "excludeKeywords"; /// - /// Ключ «excludeLocations»: глобальные исключения по локации/языку (§5.14). + /// Ключ «excludeLocations» /// public const string ExcludeLocations = "excludeLocations"; /// - /// Ключ «excludeTypes»: глобальные исключения по типу (vacancy|freelance|announcement, §5.14). + /// Ключ «excludeTypes» /// public const string ExcludeTypes = "excludeTypes"; // ── Dict / special ── /// - /// Ключ «colState»: состояние колонок канбана (произвольный объект, Ruling 9). + /// Ключ «colState» /// public const string ColState = "colState"; /// - /// Ключ «myPrompts»: личная библиотека промптов пользователя (special). + /// Ключ «myPrompts» /// public const string MyPrompts = "myPrompts"; /// - /// Ключ «aiConfigs»: конфигурации AI-провайдеров, включая ключи (special, секрет). + /// Ключ «aiConfigs» /// public const string AiConfigs = "aiConfigs"; /// - /// Каталог публичных ключей: имя ключа → категория (1:1 api-map §4.6). + /// Каталог публичных ключей /// - /// Внутренние ключи ( и др.) в каталог не входят (Ruling 1). public static readonly IReadOnlyDictionary PublicKeys = new Dictionary { // Int diff --git a/src/core/Deal.Modules.Settings/Application/Models/TenantSettingsSnapshot.cs b/src/core/Deal.Modules.Settings/Application/Models/TenantSettingsSnapshot.cs index 83af369..f23f8a6 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/TenantSettingsSnapshot.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/TenantSettingsSnapshot.cs @@ -5,26 +5,10 @@ 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, @@ -45,7 +29,6 @@ public sealed class TenantSettingsSnapshot /// Читает ВСЕ переопределения тенанта одним запросом и строит снимок. /// /// KV-хранилище настроек тенанта (таблица settings). - /// Токен отмены. /// Снимок (значения резолвятся лениво методами Get* с дефолтами SettingsDefaults). public static async Task LoadAsync(ISettingsStore store, CancellationToken ct) { @@ -72,7 +55,7 @@ public sealed class TenantSettingsSnapshot } /// - /// Булева настройка: только JSON true/false (план Task 3), иначе дефолт. + /// Булева настройка /// /// Ключ настройки (bool-категория каталога ). /// Дефолт из . @@ -94,8 +77,7 @@ public sealed class TenantSettingsSnapshot } /// - /// Целочисленная настройка: JSON-число или числовая строка (python int(...), как - /// SettingsService.TryReadInt); 0 — валидное значение. Отсутствие/повреждение/нечисловое → дефолт. + /// Целочисленная настройка /// /// Ключ настройки (int-категория каталога ). /// Дефолт из . @@ -108,8 +90,7 @@ public sealed class TenantSettingsSnapshot } /// - /// Длинная целочисленная настройка (счётчики вне int32, например discFloodDay): JSON-число или - /// числовая строка; отсутствие/повреждение/нечисловое → дефолт. + /// Длинная целочисленная настройка /// /// Ключ настройки (числовой JSON). /// Дефолт (0 для внутренних счётчиков). @@ -120,8 +101,7 @@ public sealed class TenantSettingsSnapshot } /// - /// Строковая настройка: только JSON-строка (пустая строка — валидное значение); отсутствие/ - /// повреждение/другой JSON-вид → дефолт. Нормализация (регистр/trim) — за калл-сайтом. + /// Строковая настройка /// /// Ключ настройки (string-категория каталога ). /// Дефолт из . @@ -138,9 +118,7 @@ public sealed class TenantSettingsSnapshot } /// - /// Список-настройка строк: JSON-массив → элементы-строки (пустой сохранённый массив остаётся - /// пустым — это не дефолт); одиночная JSON-строка → список из одного (python: raw = [raw]); - /// отсутствие/повреждение/другой JSON-вид → дефолт. + /// Список-настройка строк /// /// Ключ настройки (list-категория каталога ). /// Дефолт из . @@ -174,10 +152,9 @@ public sealed class TenantSettingsSnapshot } /// - /// Внутренний кэш курсов ratesCache (Ruling 6): типизированное значение или null. + /// Внутренний кэш курсов ratesCache /// - /// Кэш {rates, source, updatedAtMs} либо null — строки нет / JSON повреждён / rates не задан - /// (дефолт — мок-курсы, решает вызывающий, как RatesService.LoadCacheAsync). + /// Кэш {rates, source, updatedAtMs} либо null — строки нет / JSON повреждён / rates не задан (дефолт — мок-курсы, решает вызывающий, как RatesService.LoadCacheAsync). public RatesCacheValue? TryGetRatesCache() { JsonElement? value = ReadValue(SettingsKeys.RatesCache); diff --git a/src/core/Deal.Modules.Settings/Application/Registrars/SettingsModuleRegistrar.cs b/src/core/Deal.Modules.Settings/Application/Registrars/SettingsModuleRegistrar.cs index c24e2ee..14d6eba 100644 --- a/src/core/Deal.Modules.Settings/Application/Registrars/SettingsModuleRegistrar.cs +++ b/src/core/Deal.Modules.Settings/Application/Registrars/SettingsModuleRegistrar.cs @@ -5,13 +5,8 @@ using Microsoft.Extensions.DependencyInjection; namespace Deal.Modules.Settings.Application.Registrars; /// -/// DI-регистрация модуля Settings. Паттерн «port & adapter» (Ruling 1). +/// DI-регистрация модуля Settings. /// -/// -/// Регистрируются только сервисы модуля. Порт-адаптеры (ISettingsStore, ISecretCipher) -/// реализованы в Deal.Infrastructure и регистрируются там (AddDealPersistence/AddDealSecurity) — -/// модуль не знает про EF и шифрование. Регистрация расширяется по мере появления сервисов модуля. -/// public static class SettingsModuleRegistrar { /// @@ -19,12 +14,6 @@ public static class SettingsModuleRegistrar /// /// Коллекция сервисов. /// Коллекция сервисов для цепочки вызовов. - /// - /// Время жизни: SettingsService/RatesService/IncomingRules — scoped, потому что их зависимость ISettingsStore - /// реализована на EF-контексте со scoped-жизнью, и сервисы должны жить в рамках запроса. - /// Порт IRatesSource (Task 8) регистрируется HTTP-адаптером в Deal.Api (AddHttpClient) — - /// модуль не знает про сеть. - /// public static IServiceCollection AddSettingsModule(this IServiceCollection services) { services.AddScoped(); diff --git a/src/core/Deal.Modules.Settings/Application/Services/MockRates.cs b/src/core/Deal.Modules.Settings/Application/Services/MockRates.cs index b009ba0..2559859 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/MockRates.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/MockRates.cs @@ -1,17 +1,12 @@ namespace Deal.Modules.Settings.Application.Services; /// -/// Константы курсов валют: мок-курсы и интервал обновления кэша (Ruling 6). +/// Константы курсов валют /// -/// -/// Источник значений — constants.MOCK_RATES (constants.py L41–50) и интервал -/// _FETCH_INTERVAL_MS (rates.py L20). Используется RatesService (Task 8): -/// мок-курсы — дефолт кэша ratesCache при пустом хранилище. -/// public static class MockRates { /// - /// Мок-курсы к рублю (источник «mock»), как в constants.py L41–50. + /// Мок-курсы к рублю /// public static readonly IReadOnlyDictionary Values = new Dictionary { @@ -26,7 +21,7 @@ public static class MockRates }; /// - /// Интервал обновления кэша курсов: 6 часов (≤4 запроса в сутки, rates.py L20). + /// Интервал обновления кэша курсов /// public static readonly TimeSpan RatesFetchInterval = TimeSpan.FromHours(6); } diff --git a/src/core/Deal.Modules.Settings/Application/Services/PromptFiller.cs b/src/core/Deal.Modules.Settings/Application/Services/PromptFiller.cs index 95e8ed6..b592351 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/PromptFiller.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/PromptFiller.cs @@ -1,29 +1,23 @@ namespace Deal.Modules.Settings.Application.Services; /// -/// Подстановка плейсхолдеров {domain}/{keywords} в текст промпта (чистая функция). +/// Подстановка плейсхолдеров {domain}/{keywords} в текст промпта /// -/// -/// Аналог backend/app/services/ai.py L63–77 (fill_prompt): значения сферы -/// (domainDescription) и ключевых слов (domainKeywords) читает вызывающая сторона -/// из настроек — здесь только подстановка без хранилища. Используется этапом 6 при ИИ-вызовах -/// (план Task 7 L300–301). -/// public static class PromptFiller { /// - /// Фраза-фолбэк сферы, когда domainDescription не задан (ai.py L71). + /// Фраза-фолбэк сферы, когда domainDescription не задан. /// public const string FallbackDomain = "Универсально: заявка = конкретный запрос на услугу/товар/работу или найм человека."; /// - /// Подсказка вместо списка ключевых слов, когда domainKeywords пуст (ai.py L76). + /// Подсказка вместо списка ключевых слов, когда domainKeywords пуст. /// public const string NoKeywordsHint = "(не заданы — определяй по тексту)"; /// - /// Максимум ключевых слов в подстановке (ai.py L76: kws[:60]). + /// Максимум ключевых слов в подстановке. /// public const int MaxKeywords = 60; diff --git a/src/core/Deal.Modules.Settings/Application/Services/RatesService.cs b/src/core/Deal.Modules.Settings/Application/Services/RatesService.cs index 36ff2e5..e6bbf07 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/RatesService.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/RatesService.cs @@ -5,31 +5,16 @@ using Deal.Modules.Settings.Application.Models; namespace Deal.Modules.Settings.Application.Services; /// -/// Курсы валют: кэш в tenant-настройке ratesCache, источник по rateSource (Ruling 6, Task 8). +/// Курсы валют: кэш в tenant-настройке ratesCache, источник по rateSource. /// -/// -/// Референс — 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. +/// Слушатели смены курсов; пусто — no-op. public sealed class RatesService( ISettingsStore store, IRatesSource ratesSource, IEnumerable listeners) { - // Базовая валюта ответа (курсы всегда к рублю, как в прототипе). private const string BaseCurrency = "RUB"; // Source кэша для мок-курсов. @@ -41,7 +26,6 @@ public sealed class RatesService( // Значение настройки rateSource, включающее мок-режим. private const string MockRateSource = "mock"; - // Опции JSON: camelCase для записей в ratesCache (1:1 с формой Ruling 6). private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -49,9 +33,8 @@ public sealed class RatesService( }; /// - /// Текущее состояние кэша курсов (тело GET /api/rates). + /// Текущее состояние кэша курсов /// - /// Токен отмены. /// DTO: кэш из ratesCache или дефолт — мок-курсы, source "mock", updatedAt null. public async Task GetAsync(CancellationToken ct) { @@ -65,9 +48,8 @@ public sealed class RatesService( } /// - /// Обновляет кэш курсов по текущей настройке rateSource (POST /rates/refresh, фон). + /// Обновляет кэш курсов по текущей настройке rateSource /// - /// Токен отмены. /// True — кэш обновлён (мок-режим всегда успешен); False — сбой источника, кэш не тронут. public async Task RefreshAsync(CancellationToken ct) { @@ -80,7 +62,6 @@ public sealed class RatesService( return true; } - // Не-mock источник: запрос к ЦБ; сбой порта (null) — кэш не трогаем (rates.py L69–72). Dictionary? rates = await ratesSource.FetchAsync(ct); if (rates is null) { @@ -92,9 +73,7 @@ public sealed class RatesService( return true; } - // Оповещает слушателей о смене курсов ПОСЛЕ успешной записи кэша (Ruling 7, план Task 12). // ct: Токен отмены. - // Вызов синхронный в рамках refresh (как recompute_conversions после save_rates в rates.py L62–74); // пустой список — no-op. При сбое источника (кэш не записан) слушатели НЕ вызываются. private async Task NotifyRatesChangedAsync(CancellationToken ct) { @@ -105,15 +84,9 @@ public sealed class RatesService( } /// - /// Нужно ли обновить кэш: нет кэша / сменился источник / прошло ≥6 часов (rates.py L77–83). + /// Нужно ли обновить кэш /// - /// Токен отмены. - /// True — GET /api/rates должен запустить фоновое обновление (Ruling 6). - /// - /// Сравнивается НОРМАЛИЗОВАННЫЙ источник: значение настройки приводится к mock|cbr (не-mock → cbr), - /// поэтому произвольная строка в rateSource (ручное вмешательство в БД) не вызывает обновление - /// на каждом GET. Расхождение «кэш mock / настройка cbr» (и обратное) трактуется как смена источника. - /// + /// True — GET /api/rates должен запустить фоновое обновление. public async Task ShouldFetchAsync(CancellationToken ct) { TenantSettingsSnapshot settings = await TenantSettingsSnapshot.LoadAsync(store, ct); @@ -134,18 +107,13 @@ public sealed class RatesService( } /// - /// Конвертирует сумму из одной валюты в другую по курсам к рублю (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, @@ -200,10 +168,8 @@ public sealed class RatesService( // Нормализует значение настройки источника к известным 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 082f536..9bd1bc2 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchMyPrompts.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchMyPrompts.cs @@ -8,7 +8,6 @@ namespace Deal.Modules.Settings.Application.Services; // и чтение полей элемента: 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) 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 bbb41b3..3cc21aa 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs @@ -7,7 +7,6 @@ namespace Deal.Modules.Settings.Application.Services; // int/string-ключи и списки строк (ApplyIntKey/ApplyStringKey/SerializeStringList). public sealed partial class SettingsService { - // Клампы пары/одиночных концов интервала авто-вступлений (референс L78–109). // body: Тело PATCH (не мутируется). // overrides: Сохранённые переопределения (для «другого конца» интервала). // Возвращает: Ключ → уже склампированное значение; ключи в результате пропускаются общим циклом. @@ -123,7 +122,6 @@ public sealed partial class SettingsService writes[key] = JsonSerializer.Serialize(text); } - // Сериализует список строк для хранения: элементы приводятся к строке, срез 200 (L137–139). // array: JSON-массив из тела PATCH. // Возвращает: JSON-строка массива строк. private static string SerializeStringList(JsonElement array) 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 4c0fe6c..91c5a2d 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchSecrets.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchSecrets.cs @@ -7,7 +7,6 @@ namespace Deal.Modules.Settings.Application.Services; // apiKey), включая удаление переопределения, вернувшегося к дефолту (EqualsDefault). public sealed partial class SettingsService { - // Применяет ключ aiConfigs (L161–175): только существующие провайдеры; apiKey ≥8 → шифруется. // value: JSON-объект {id провайдера → конфиг} из тела PATCH. // overrides: Сохранённые переопределения. // writes: Накопитель записей (key → valueJson). @@ -30,7 +29,6 @@ public sealed partial class SettingsService foreach (JsonProperty provider in value.EnumerateObject()) { - // Неизвестный провайдер (нет в каталоге) — пропуск, как `pid not in cfg` прототипа. if (!effective.ContainsKey(provider.Name) || provider.Value.ValueKind != JsonValueKind.Object) { continue; 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 65cc140..b6435d9 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PublicForms.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PublicForms.cs @@ -2,10 +2,8 @@ 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) @@ -19,7 +17,6 @@ public sealed partial class SettingsService } // Маска СЕКРЕТА (apiKey ИИ): всегда скрывает, кроме пустого — короткие значения не раскрываются - // (Security review: echo-маска ключа наружу). Длина ≤ 8 → «x…», иначе «1234…5678» (как ai.py mask_key). // value: Открытый секрет (не null). // Возвращает: Маскированная строка. private static string MaskSecret(string value) 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 f2719cd..61169bf 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs @@ -4,11 +4,9 @@ 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) @@ -32,7 +30,6 @@ public sealed partial class SettingsService return result; } - // Читает целое: JSON-число или числовая строка (семантика int(...) прототипа). // element: JSON-значение. // value: Прочитанное число (диапазон long сжимается до int). // Возвращает: True — значение прочитано; иначе False (нечисловое — пропуск ключа). @@ -66,7 +63,6 @@ public sealed partial class SettingsService return true; } - // Читает булево: только JSON True/False (строки не конвертируются — план Task 3). // element: JSON-значение. // value: Прочитанное значение. // Возвращает: True — значение вида true/false. @@ -82,7 +78,6 @@ public sealed partial class SettingsService return false; } - // Читает скаляр как строку: строка/число/булево (семантика str(...) прототипа). // element: JSON-значение. // text: Текст; null — если значение не скаляр (объект/массив/null). // Возвращает: True — значение скалярное. diff --git a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs index 372358b..be6799b 100644 --- a/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs @@ -5,30 +5,17 @@ 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. +/// Шифр секретов. +/// Слушатели смены настроек конверсии; пусто — 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»): строка, содержащая его, @@ -66,7 +53,6 @@ public sealed partial class SettingsService( // Размер случайного суффикса id промпта в байтах (8 hex-символов). private const int PromptIdRandomBytes = 4; - // Клампы целочисленных ключей: имя ключа → диапазон [min..max] (L80–127). private static readonly IReadOnlyDictionary IntClampRanges = new Dictionary { @@ -83,14 +69,12 @@ public sealed partial class SettingsService( 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, @@ -98,9 +82,8 @@ public sealed partial class SettingsService( }; /// - /// Публичный снимок настроек: дефолты, перекрытые сохранёнными значениями, секреты — маски. + /// Публичный снимок настроек /// - /// Токен отмены. /// Полный public-снимок §4.6 (тело GET/PATCH /api/settings). public async Task GetPublicAsync(CancellationToken ct) { @@ -109,25 +92,16 @@ public sealed partial class SettingsService( } /// - /// Частичное обновление настроек: применяет только переданные поля, клампы и валидацию 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); @@ -155,7 +129,6 @@ public sealed partial class SettingsService( break; case SettingKind.Bool: - // Только JSON-булево; строки не «питон-булеватся» (план Task 3). if (TryReadBool(value, out bool flag)) { writes[key] = JsonSerializer.Serialize(flag); @@ -176,7 +149,6 @@ public sealed partial class SettingsService( break; case SettingKind.Dict: - // colState — passthrough: произвольный JSON-объект сохраняется как есть (Ruling 9). if (value.ValueKind == JsonValueKind.Object) { writes[key] = value.GetRawText(); @@ -208,8 +180,6 @@ public sealed partial class SettingsService( await store.RemoveAsync(key, ct); } - // Пересчёт конверсий при смене целевой валюты/выключателя конверсии (settings_routes.py L190–191, - // Ruling 7): слушатели вызываются ПОСЛЕ сохранения, чтобы пересчёт читал уже новые настройки // (реализация порта — ConversionRecomputer модуля Kanban; пустой список — no-op). if (AffectsConversion(body)) { @@ -219,10 +189,8 @@ public sealed partial class SettingsService( 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); @@ -237,9 +205,7 @@ public sealed partial class SettingsService( 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) diff --git a/src/core/Deal.Modules.Settings/SettingsModuleMarker.cs b/src/core/Deal.Modules.Settings/SettingsModuleMarker.cs index 0753a0d..8395dbe 100644 --- a/src/core/Deal.Modules.Settings/SettingsModuleMarker.cs +++ b/src/core/Deal.Modules.Settings/SettingsModuleMarker.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Settings; /// -/// Маркер модуля Settings: используется для DI-сканирования и тестов. +/// Маркер модуля Settings /// public sealed class SettingsModuleMarker { diff --git a/src/core/Deal.Modules.Telegram/Application/DialogsService.cs b/src/core/Deal.Modules.Telegram/Application/DialogsService.cs index 9d190bd..1bd52f8 100644 --- a/src/core/Deal.Modules.Telegram/Application/DialogsService.cs +++ b/src/core/Deal.Modules.Telegram/Application/DialogsService.cs @@ -8,22 +8,8 @@ using Microsoft.Extensions.Logging; namespace Deal.Modules.Telegram.Application; /// -/// Сервис каталога диалогов/каналов тенанта — владелец зеркала мониторинга ядра (Ruling 7, Task 13). +/// Сервис каталога диалогов/каналов тенанта — владелец зеркала мониторинга ядра. /// -/// -/// Чистый оркестратор поверх портов (таблицы Dialogs/TgMessages схемы тенанта), -/// (настройка autoMonitorNew) и (команды наружу — -/// зеркало telegram-service). Семантика 1:1 с python telegram.py: _persist_dialogs L468–503 (SyncFromTelegram), -/// list_dialogs L521–534 (List), set_monitor L536–546 (SetMonitor), set_monitor_all L548–567 (SetMonitorAll), -/// backfill_dialog L349–390 (ReadRecent по включённым), _on_message L270–274 (SavePreview). -/// -/// Фоновый «первый разбор» (backfill) при включении мониторинга в ядре НЕ запускается здесь: у сервиса -/// scoped-зависимости (EF-контекст схемы тенанта) живут в рамках запроса, а Backfill длится секунды -/// (паузы анти-бана). Модуль отдаёт признаки «не разобран» (/ -/// ), фоновый спуск RPC делает Api-слой (Task 14, Ruling 8) — -/// как python-_spawn из роутеров. -/// -/// /// Хранилище каталога (Dialogs/TgMessages схемы тенанта). /// KV-настройки тенанта (autoMonitorNew). /// Порт-гейт к telegram-service (SetMonitor/SetMonitorAll/Backfill наружу). @@ -34,35 +20,27 @@ public sealed class DialogsService( ITelegramGateway gateway, ILogger logger) { - // Потолок текста превью-строки TgMessages (python L604: m.text[:4000]). private const int PreviewTextMaxLength = 4000; - // Потолок текста «последнего сообщения» диалога (python L272: msg.text.strip()[:200]). private const int DialogLastTextMaxLength = 200; /// - /// Список диалогов каталога для вкладки «Каналы» (list_dialogs L521–534). + /// Список диалогов каталога для вкладки «Каналы». /// - /// Токен отмены. /// Диалоги (включённые мониторингом первыми) в форме §4.8. public Task> ListAsync(CancellationToken ct) => store.ListAsync(ct); /// - /// Id диалогов с включённым мониторингом (зеркало ядра; ответ SyncDialogs ингресса, Ruling 7). + /// Id диалогов с включённым мониторингом. /// - /// Токен отмены. /// Id мониторящихся диалогов каталога. public Task> ListMonitoredIdsAsync(CancellationToken ct) => store.ListMonitoredIdsAsync(ct); /// - /// Применяет каталог диалогов telegram-service: upsert + удаление отсутствующих (1:1 _persist_dialogs). + /// Применяет каталог диалогов telegram-service /// - /// Авто-мониторинг новых диалогов управляется настройкой autoMonitorNew (дефолт true — новый чат - /// появляется включённым); у существующих монитор пользователя не трогаем. Вызывается из gRPC-ингресса - /// (SyncDialogs) и эндпоинта refresh (Task 14) — общий путь актуализации зеркала. /// Актуальный каталог диалогов аккаунта (id/name/handle/kind/hue). - /// Токен отмены. /// Число применённых записей (= entries.Count; 0 — пустой каталог). public async Task SyncFromTelegramAsync(IReadOnlyCollection entries, CancellationToken ct) { @@ -72,13 +50,10 @@ public sealed class DialogsService( } /// - /// Включает/выключает мониторинг диалога и синхронизирует зеркало telegram-service (set_monitor L536–546). + /// Включает/выключает мониторинг диалога и синхронизирует зеркало telegram-service. /// - /// Диалога нет в каталоге — no-op (как UPDATE без строк python): флаг не меняется, зеркало сервиса - /// не трогаем (иначе сервис начал бы мониторить источник, которого нет в ядре). /// Id диалога каталога. /// True — мониторить, false — выключить. - /// Токен отмены. /// Результат: зеркальное enabled + признак «нужен первый фоновый Backfill». public async Task SetMonitorAsync( string dialogId, @@ -97,10 +72,9 @@ public sealed class DialogsService( } /// - /// Включает/выключает мониторинг всех диалогов (set_monitor_all L548–567) + зеркало сервиса. + /// Включает/выключает мониторинг всех диалогов + зеркало сервиса. /// /// True — мониторить все диалоги каталога, false — снять со всех. - /// Токен отмены. /// Число диалогов каталога + список неразобранных при включении (фоновый Backfill — Api-слой). public async Task SetMonitorAllAsync(bool enabled, CancellationToken ct) { @@ -114,32 +88,21 @@ public sealed class DialogsService( } /// - /// Помечает диалог разобранным — первый backfill завершён (backfill_dialog L387). + /// Помечает диалог разобранным — первый backfill завершён. /// - /// Зовёт Api-слой после успешного фонового Backfill RPC (Task 14): флаг Backfilled=false убирает - /// повторный первый разбор при следующем включении мониторинга. /// Id диалога. - /// Токен отмены. /// Завершается после обновления строки (нет строки — no-op). public Task MarkBackfilledAsync(string dialogId, CancellationToken ct) => store.SetBackfilledAsync(dialogId, ct); /// - /// Добавляет источник после discovery-вступления: строка каталога (монитор on) + зеркало сервиса - /// (python add_dialog_monitored L850–873; эндпоинт ручного join Task 19, решение T18). + /// Добавляет источник после discovery-вступления /// - /// - /// Пишет/обновляет локальную строку Dialogs (upsert как python ON CONFLICT: name/handle/kind/hue, - /// monitor=TRUE, backfilled=FALSE — последние сообщения подхватит фоновый первый разбор Api-слоя) и - /// включает монитор-зеркало telegram-service (SetMonitor(id, true), как авто-join воркера, Task 18). - /// Сбой зеркала не роняет вступление: строка уже в каталоге, зеркало догонит ближайшая SyncDialogs-синхронизация. - /// /// Подписанный id источника. /// Имя источника (пустое → dialogId). /// Username источника (пусто — нет публичного username). - /// Тип источника (channel/group/forum; пустое допустимо — python kind or «»). + /// Тип источника. /// Цвет источника (пустое → дефолт «#666»). - /// Токен отмены. /// Завершается после записи каталога и команды зеркалу. public async Task AddDiscoveredMonitoredAsync( string dialogId, @@ -175,17 +138,8 @@ public sealed class DialogsService( } /// - /// «Перечитать»: последние сообщения всех включённых каналов (backfill_monitored L569–581). + /// «Перечитать»: последние сообщения всех включённых каналов. /// - /// - /// Для каждого мониторящегося диалога вызывает c force=true — догонялка - /// последних ~10 сообщений потоком PushMessage даже для уже разобранных; успешный разбор помечает диалог - /// разобранным (как python L387). Падение одного канала (сервис недоступен/диалог удалён) не отменяет - /// остальные — перечитывание продолжается со следующего диалога (замечание ревью T13; python _backfill_dialogs - /// L345–347 логирует и идёт дальше). Выполняется последовательно и занимает время (анти-бан telegram-service) — - /// вызывать из фонового скоупа (эндпоинт backfill-all Task 14 запускает её как python-_spawn). - /// - /// Токен отмены. /// Сколько включённых каналов отправлено на перечитывание (0 — мониторящихся нет). public async Task ReadRecentAsync(CancellationToken ct) { @@ -198,7 +152,6 @@ public sealed class DialogsService( } catch (Exception exception) when (exception is not OperationCanceledException || !ct.IsCancellationRequested) { - // Частичное падение одного канала (замечание ревью T13): разбор продолжается со следующего; // канал остаётся неразобранным — следующий «Перечитать»/первое включение попробует снова. logger.LogWarning(exception, "Перечитывание канала {DialogId} не удалось", dialogId); } @@ -208,18 +161,10 @@ public sealed class DialogsService( } /// - /// Разбор последних ~10 сообщений одного диалога (backfill_dialog L349–390). + /// Разбор последних ~10 сообщений одного диалога. /// - /// - /// 1:1 python: диалога нет в каталоге или уже разобран (без force) — выход с 0 без RPC; после успешного - /// разбора диалог помечается разобранным (L387) — флаг ставится СТРОГО после успеха (замечание ревью T13), - /// сбой RPC оставляет диалог неразобранным (следующее включение/«Перечитать» повторит). Вызывается - /// эндпоинтом {id}/backfill (Task 14) и фоновыми спусками Api-слоя при первом включении мониторинга - /// (Ruling 8: модуль отдаёт BackfillNeeded, RPC-спуск — Api). - /// /// Id диалога каталога. /// True — перечитать, даже если диалог уже разобран (кнопка «Перечитать»). - /// Токен отмены. /// Сколько сообщений отправлено в ядро потоком PushMessage (0 — выхода нет/сообщений нет). public async Task BackfillOneAsync( string dialogId, @@ -229,7 +174,6 @@ public sealed class DialogsService( bool? backfilled = await store.GetBackfilledAsync(dialogId, ct).ConfigureAwait(false); if (backfilled is null || (!force && backfilled.Value)) { - // python L360–362: диалога нет в каталоге или (без force) уже разобран — без RPC, 0 сообщений. return 0; } @@ -239,18 +183,10 @@ public sealed class DialogsService( } /// - /// Последние сообщения диалога для превью (dialog_messages L583–620). + /// Последние сообщения диалога для превью. /// - /// - /// Свежие сообщения приходят из telegram-service (ReadRecentAsync); признак lead и фолбэк на БД добавляет - /// ядро (Ruling 7): у свежего сообщения lead берётся из строки TgMessages «m_<dialog>_<msg>» - /// (если сообщение уже принималось PushMessage-ингрессом), фолбэк при пустом/недоступном свежем списке — - /// строки БД диалога (ListMessagesAsync, как python L614–619). Падение гейта — не ошибка запроса: превью - /// показывает то, что есть в БД (python L612–613 лог + фолбэк). - /// /// Id диалога. /// Сколько последних сообщений (1..50; api-map /dialogs/preview limit 1..50). - /// Токен отмены. /// Сообщения от новых к старым в форме §4.11 ({id, text, time, lead}). public async Task> PreviewAsync( string dialogId, @@ -264,7 +200,6 @@ public sealed class DialogsService( } catch (Exception exception) when (exception is not OperationCanceledException || !ct.IsCancellationRequested) { - // Сервис недоступен/сбой чтения — превью из БД (python L612–613: лог + фолбэк), не ошибка запроса. logger.LogWarning(exception, "Чтение свежих сообщений диалога {DialogId} не удалось — превью из БД", dialogId); fresh = []; } @@ -275,7 +210,6 @@ public sealed class DialogsService( } // lead свежих сообщений — по строке TgMessages «m__» (признак уже принятого/карточки): - // python L594–600 делает запрос по id на каждое сообщение; здесь — один список БД диалога (L186–190). Dictionary existingRows = (await store.ListMessagesAsync(dialogId, limit, ct).ConfigureAwait(false)) .ToDictionary(message => message.Id, StringComparer.Ordinal); var items = new List(fresh.Count); @@ -290,19 +224,12 @@ public sealed class DialogsService( } /// - /// Сохраняет превью принятого сообщения: строка TgMessages + «последнее сообщение» каталога (Ruling 7). + /// Сохраняет превью принятого сообщения /// - /// - /// 1:1 _on_message L270–274 (last_text/last_at каталога) + Ruling 7 «PushMessage пишет превью в TgMessages». - /// Пустой текст — no-op (как python L259: пустые сообщения в обработку не идут). Без msg_id строка превью - /// не пишется (нет стабильного id «m_<dialog>_<msg>» — дубль-гварда), «последнее сообщение» - /// обновляется. Вызывается gRPC-ингрессом PushMessage; сбой превью не влияет на приём сообщения. - /// /// Id диалога-источника. /// Id сообщения в Telegram (null — только «последнее сообщение» каталога). /// Текст сообщения (непустой). /// Время исходного сообщения (UTC; null — сейчас). - /// Токен отмены. /// Завершается после обновления каталога/TgMessages (дубли превью — no-op). public async Task SavePreviewAsync( string dialogId, @@ -328,7 +255,6 @@ public sealed class DialogsService( await store.SavePreviewAsync(previewId, dialogId, Truncate(text, PreviewTextMaxLength), at, ct).ConfigureAwait(false); } - // Обрезает строку до maxLength символов (python-слайсы [:N]). // value: Исходная строка. // maxLength: Потолок длины. // Возвращает: Строка не длиннее maxLength. diff --git a/src/core/Deal.Modules.Telegram/Application/ITelegramStore.cs b/src/core/Deal.Modules.Telegram/Application/ITelegramStore.cs index e57da5b..728ad9f 100644 --- a/src/core/Deal.Modules.Telegram/Application/ITelegramStore.cs +++ b/src/core/Deal.Modules.Telegram/Application/ITelegramStore.cs @@ -4,28 +4,15 @@ using Deal.Modules.Telegram.Application.Models; namespace Deal.Modules.Telegram.Application; /// -/// Порт хранилища каталога диалогов тенанта: таблицы Dialogs/TgMessages схемы тенанта (Ruling 7, Task 13). +/// Порт хранилища каталога диалогов тенанта /// -/// -/// Порт объявлен в модуле Telegram (чистый, без EF) и реализуется EF-адаптером TelegramStore -/// (Deal.Infrastructure, регистрация в AddDealPersistence) — 1:1 с таблицами dialogs/messages прототипа -/// (db.py L67–86) и операциями telegram.py: _persist_dialogs L468–503, list_dialogs L521–534, -/// set_monitor L536–546, set_monitor_all L548–567, backfill L387, dialog_messages L583–620. -/// Синхронизация каталога (upsert новых + обновление метаданных + удаление отсутствующих) — единый -/// метод (как _persist_dialogs); авто-мониторинг новых решает вызывающий -/// (DialogsService читает настройку autoMonitorNew и передаёт флаг). -/// public interface ITelegramStore { /// - /// Применяет каталог диалогов telegram-service (1:1 _persist_dialogs L468–503). + /// Применяет каталог диалогов telegram-service. /// - /// Новые диалоги добавляются с монитором ; переименования/смена - /// типа обновляются (монитор пользователя не трогаем); диалоги, которых больше нет в каталоге (вышел/ - /// удалил), удаляются. Пустой каталог — no-op (как python L477–478: «если не entries — возврат 0»). /// Актуальный каталог (id/name/handle/kind/hue). /// Мониторить ли новые диалоги (настройка autoMonitorNew, дефолт true). - /// Токен отмены. /// Число применённых записей каталога (= entries.Count; 0 — пустой каталог). public Task SyncFromTelegramAsync( IReadOnlyCollection entries, @@ -33,25 +20,22 @@ public interface ITelegramStore CancellationToken ct); /// - /// Список диалогов каталога для GET /api/tg/dialogs (list_dialogs L521–534, ORDER BY monitor DESC, name). + /// Список диалогов каталога для GET /api/tg/dialogs. /// - /// Токен отмены. /// Диалоги каталога (включённые мониторингом — первыми, далее по имени). public Task> ListAsync(CancellationToken ct); /// - /// Id диалогов с включённым мониторингом (зеркало ядра; ответ SyncDialogs ингресса, Ruling 7). + /// Id диалогов с включённым мониторингом. /// - /// Токен отмены. /// Id строк Dialogs, где Monitor=true. public Task> ListMonitoredIdsAsync(CancellationToken ct); /// - /// Включает/выключает мониторинг диалога (set_monitor L536–537: UPDATE monitor + updated_at). + /// Включает/выключает мониторинг диалога. /// /// Id диалога каталога. /// True — мониторить, false — выключить. - /// Токен отмены. /// Завершается после обновления строки (нет строки — no-op). public Task SetMonitorAsync( string dialogId, @@ -59,48 +43,40 @@ public interface ITelegramStore CancellationToken ct); /// - /// Включает/выключает мониторинг всех диалогов каталога (set_monitor_all L554–563). + /// Включает/выключает мониторинг всех диалогов каталога. /// /// True — мониторить все, false — снять мониторинг со всех. - /// Токен отмены. /// Сколько диалогов в каталоге (api-map /monitor-all → count). public Task SetMonitorAllAsync(bool enabled, CancellationToken ct); /// - /// Флаг разобранности диалога (SELECT backfilled; null — диалога нет в каталоге). + /// Флаг разобранности диалога /// /// Id диалога. - /// Токен отмены. /// Backfilled диалога либо null, если строки нет. public Task GetBackfilledAsync(string dialogId, CancellationToken ct); /// - /// Id неразобранных диалогов каталога (для первого фонового Backfill, set_monitor_all L555). + /// Id неразобранных диалогов каталога. /// - /// Токен отмены. /// Id строк Dialogs с Backfilled=false. public Task> ListNotBackfilledIdsAsync(CancellationToken ct); /// - /// Помечает диалог разобранным (backfill_dialog L387: UPDATE backfilled = TRUE). + /// Помечает диалог разобранным. /// /// Id диалога. - /// Токен отмены. /// Завершается после обновления строки (нет строки — no-op). public Task SetBackfilledAsync(string dialogId, CancellationToken ct); /// - /// Пишет/обновляет строку каталога после discovery-вступления (python add_dialog_monitored L850–873). + /// Пишет/обновляет строку каталога после discovery-вступления. /// - /// Upsert 1:1 с python INSERT/ON CONFLICT: метаданные (name/handle/kind/hue) перезаписываются, - /// монитор принудительно TRUE, разбор — FALSE (последние сообщения подхватит первый фоновый Backfill - /// Api-слоя). Значения нормализует вызывающий (DialogsService): пустое имя → dialogId, hue → «#666». /// Подписанный id источника (первичный ключ). /// Имя источника (уже с фолбэком на dialogId). /// Username источника (пуст, если нет публичного username). - /// Тип источника (channel/group/forum/chat; пусто допустимо — как python kind or «»). + /// Тип источника. /// Цвет источника (уже с дефолтом «#666»). - /// Токен отмены. /// Завершается после записи. public Task UpsertDiscoveredMonitoredAsync( string dialogId, @@ -111,15 +87,12 @@ public interface ITelegramStore CancellationToken ct); /// - /// Пишет строку превью сообщения в TgMessages (INSERT OR IGNORE, python L602–605). + /// Пишет строку превью сообщения в TgMessages. /// - /// Id строки — готовый «m_<dialog>_<msg>» (собирает вызывающий); дубль не перезаписывает - /// текст (python INSERT OR IGNORE — превью хранит первое вхождение сообщения). /// Id строки превью («m_<dialog>_<msg>»). /// Id диалога-источника. - /// Текст сообщения (вызывающий обрезает до 4000, python L604). + /// Текст сообщения. /// Время сообщения (UTC). - /// Токен отмены. /// Завершается после вставки (дубль — no-op). public Task SavePreviewAsync( string messageId, @@ -129,13 +102,11 @@ public interface ITelegramStore CancellationToken ct); /// - /// Обновляет «последнее сообщение» диалога в каталоге (_on_message L270–273). + /// Обновляет «последнее сообщение» диалога в каталоге. /// - /// 1:1 python: last_text (обрезанный до 200), last_at = момент приёма, updated_at = тот же момент. /// Id диалога. - /// Текст последнего сообщения (вызывающий обрезает до 200, python L272). + /// Текст последнего сообщения. /// Момент приёма сообщения (UTC). - /// Токен отмены. /// Завершается после обновления строки (нет строки — no-op). public Task TouchDialogLastAsync( string dialogId, @@ -144,11 +115,10 @@ public interface ITelegramStore CancellationToken ct); /// - /// Фолбэк превью из БД (dialog_messages L614–619): SELECT * FROM messages WHERE dialog_id ORDER BY msg_at DESC LIMIT. + /// Фолбэк превью из БД /// /// Id диалога. /// Сколько последних сообщений (≤50). - /// Токен отмены. /// Сообщения TgMessages диалога от новых к старым (lead — по LeadId). public Task> ListMessagesAsync( string dialogId, diff --git a/src/core/Deal.Modules.Telegram/Application/Models/TelegramDialogDto.cs b/src/core/Deal.Modules.Telegram/Application/Models/TelegramDialogDto.cs index 3c28584..c78ffc6 100644 --- a/src/core/Deal.Modules.Telegram/Application/Models/TelegramDialogDto.cs +++ b/src/core/Deal.Modules.Telegram/Application/Models/TelegramDialogDto.cs @@ -1,14 +1,8 @@ namespace Deal.Modules.Telegram.Application.Models; /// -/// Диалог/канал каталога тенанта — элемент GET /api/tg/dialogs (api-map §4.8 L349). +/// Диалог/канал каталога тенанта — элемент GET /api/tg/dialogs. /// -/// -/// Поля 1:1 с list_dialogs python L521–534 (фронт читает id/name/handle/type/hue/on; не -/// читает, но сохраняется 1:1). — тип источника в EN-каноне каталога сервиса -/// (channel|group|forum|chat); русская форма («канал»/«группа»/«чат») — приведение на границе эндпоинта -/// (заметка Task 1, обратный маппинг EN→RU в Task 14). -/// /// Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»). /// Отображаемое имя диалога. /// Username (handle) источника; пуст, если нет публичного username. diff --git a/src/core/Deal.Modules.Telegram/Application/Models/TelegramDialogLastDto.cs b/src/core/Deal.Modules.Telegram/Application/Models/TelegramDialogLastDto.cs index 72c84b1..b1c1758 100644 --- a/src/core/Deal.Modules.Telegram/Application/Models/TelegramDialogLastDto.cs +++ b/src/core/Deal.Modules.Telegram/Application/Models/TelegramDialogLastDto.cs @@ -1,8 +1,8 @@ namespace Deal.Modules.Telegram.Application.Models; /// -/// Последнее сообщение диалога — поле last элемента списка каналов (list_dialogs L531). +/// Последнее сообщение диалога — поле last элемента списка каналов. /// -/// Текст последнего принятого сообщения (обрезается до 200 символов, python L272). +/// Текст последнего принятого сообщения. /// Время последнего сообщения, epoch-ms (null — сообщений ещё не было). public sealed record TelegramDialogLastDto(string Text, long? TimeMs); diff --git a/src/core/Deal.Modules.Telegram/Application/Models/TelegramMessageDto.cs b/src/core/Deal.Modules.Telegram/Application/Models/TelegramMessageDto.cs index e0ef5a7..d5b605e 100644 --- a/src/core/Deal.Modules.Telegram/Application/Models/TelegramMessageDto.cs +++ b/src/core/Deal.Modules.Telegram/Application/Models/TelegramMessageDto.cs @@ -3,12 +3,8 @@ using System.Text.Json.Serialization; namespace Deal.Modules.Telegram.Application.Models; /// -/// Сообщение превью диалога — элемент POST /api/tg/dialogs/preview (api-map §4.8 L351). +/// Сообщение превью диалога — элемент POST /api/tg/dialogs/preview. /// -/// -/// id: из Telegram — int (как строка); фолбэк из БД (TgMessages) — строки «m_<dialog>_<msg>». -/// lead — есть ли карточка по этому сообщению (в строке TgMessages проставлен LeadId). -/// /// Id сообщения (строка; «m_<dialog>_<msg>» для фолбэка БД). /// Текст сообщения. /// Время сообщения, epoch-ms (JSON «time»). diff --git a/src/core/Deal.Modules.Telegram/Application/Models/TelegramMonitorAllDto.cs b/src/core/Deal.Modules.Telegram/Application/Models/TelegramMonitorAllDto.cs index bcb6e83..b0cf9d0 100644 --- a/src/core/Deal.Modules.Telegram/Application/Models/TelegramMonitorAllDto.cs +++ b/src/core/Deal.Modules.Telegram/Application/Models/TelegramMonitorAllDto.cs @@ -1,12 +1,8 @@ namespace Deal.Modules.Telegram.Application.Models; /// -/// Результат включения/выключения мониторинга всех диалогов — ответ SetMonitorAllAsync (L548–567). +/// Результат включения/выключения мониторинга всех диалогов — ответ SetMonitorAllAsync. /// -/// -/// Первое включение неразобранных каналов в python разбирается последовательно в фоне (_backfill_dialogs); -/// здесь ядро отдаёт их списком (), а фоновый спуск делает Api-слой (Task 14). -/// /// Сколько диалогов в каталоге тенанта (api-map /monitor-all → count). /// Id неразобранных диалогов при enabled=true (пусто при выключении). public sealed record TelegramMonitorAllDto(int Count, IReadOnlyList BackfillNeededIds); diff --git a/src/core/Deal.Modules.Telegram/Application/Models/TelegramMonitorToggleDto.cs b/src/core/Deal.Modules.Telegram/Application/Models/TelegramMonitorToggleDto.cs index 6f1124c..11aa709 100644 --- a/src/core/Deal.Modules.Telegram/Application/Models/TelegramMonitorToggleDto.cs +++ b/src/core/Deal.Modules.Telegram/Application/Models/TelegramMonitorToggleDto.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Telegram.Application.Models; /// -/// Результат переключения мониторинга одного диалога — ответ SetMonitorAsync (set_monitor python L536–546). +/// Результат переключения мониторинга одного диалога — ответ SetMonitorAsync. /// -/// -/// — диалог до включения не был разобран (Backfilled=false): эндпоинт мониторинга -/// (Task 14, Ruling 8) по нему запускает фоновый Backfill(force=false) — первый разбор последних сообщений -/// канала (в ядре фоновый спуск живёт в Api-слое, как python-_spawn). -/// /// Зеркальное значение включения (для ответов эндпоинтов {ok, enabled}). /// True — включение первого раза: диалог не был разобран (нужен фоновый Backfill). public sealed record TelegramMonitorToggleDto(bool Enabled, bool BackfillNeeded); diff --git a/src/core/Deal.Modules.Telegram/Application/Models/TgStatusDto.cs b/src/core/Deal.Modules.Telegram/Application/Models/TgStatusDto.cs index 9b2e2f2..060c964 100644 --- a/src/core/Deal.Modules.Telegram/Application/Models/TgStatusDto.cs +++ b/src/core/Deal.Modules.Telegram/Application/Models/TgStatusDto.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Telegram.Application.Models; /// -/// Статус вкладки Telegram — GET /api/tg/status и payload SSE system_status (api-map §4.9 L357–359). +/// Статус вкладки Telegram — GET /api/tg/status и payload SSE system_status. /// -/// -/// Собирает TgStatusService (Task 14, Ruling 8): live-поля (phase/connected/listener/error/qrUrl) из гейта -/// (сервис недоступен → idle-форма), account из KV tgAccount, monitored = count(Dialogs WHERE Monitor), -/// keysSet из настроек тенанта. Форма 1:1 с status() python L110–118 + счётчик/ключи ядра. -/// /// Фаза входа: idle|phone|code|password|qr|ready. /// Клиент Telegram подключён и авторизован. /// Жив ли realtime-listener (поток новых сообщений → PushMessage). diff --git a/src/core/Deal.Modules.Telegram/Application/TelegramModuleRegistrar.cs b/src/core/Deal.Modules.Telegram/Application/TelegramModuleRegistrar.cs index 47b7931..40761c1 100644 --- a/src/core/Deal.Modules.Telegram/Application/TelegramModuleRegistrar.cs +++ b/src/core/Deal.Modules.Telegram/Application/TelegramModuleRegistrar.cs @@ -3,15 +3,8 @@ using Microsoft.Extensions.DependencyInjection; namespace Deal.Modules.Telegram.Application; /// -/// DI-регистрация модуля Telegram. Паттерн «port & adapter» (Ruling 7). +/// DI-регистрация модуля Telegram. /// -/// -/// Регистрируются только сервисы модуля. Порт-адаптеры реализованы в Deal.Infrastructure и регистрируются там: -/// ITelegramStore → TelegramStore (AddDealPersistence), ITelegramGateway → LocalTelegramGateway / -/// gRPC-клиент (AddDealIntegrations, Ruling 6) — модуль не знает про EF и gRPC. Зависимости модуля — -/// Deal.Modules.Settings (порт ISettingsStore: autoMonitorNew) и Deal.Contracts (ITelegramGateway/DTO); -/// реверс-зависимостей нет (Global Constraints). -/// public static class TelegramModuleRegistrar { /// @@ -19,11 +12,6 @@ public static class TelegramModuleRegistrar /// /// Коллекция сервисов. /// Коллекция сервисов для цепочки вызовов. - /// - /// DialogsService — scoped: его зависимости (ITelegramStore → TenantDbContext схемы тенанта) живут в рамках - /// tenant-запроса/tenant-скоупа ингресса. Вызывается из Program.cs (AddTelegramModule, план Task 13) после - /// AddDealPersistence. - /// public static IServiceCollection AddTelegramModule(this IServiceCollection services) { services.AddScoped(); diff --git a/src/core/Deal.Modules.Telegram/TelegramModuleMarker.cs b/src/core/Deal.Modules.Telegram/TelegramModuleMarker.cs index ed531c4..75d4cee 100644 --- a/src/core/Deal.Modules.Telegram/TelegramModuleMarker.cs +++ b/src/core/Deal.Modules.Telegram/TelegramModuleMarker.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Telegram; /// -/// Маркер модуля Telegram: используется для DI-сканирования и тестов. +/// Маркер модуля Telegram /// public sealed class TelegramModuleMarker { diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuditLogStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuditLogStore.cs index d439b88..51df87f 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuditLogStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuditLogStore.cs @@ -4,47 +4,34 @@ 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). + /// Удаляет записи старше retention-границы /// /// Граница: удаляются записи со строгим At < cutoff (UTC). - /// Токен отмены. /// Число удалённых записей. public Task PurgeOlderThanAsync(DateTimeOffset cutoff, CancellationToken ct); } diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuthStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuthStore.cs index 6a4dee7..30e629c 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuthStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuthStore.cs @@ -3,31 +3,27 @@ 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); @@ -35,15 +31,13 @@ public interface IAuthStore /// Ищет пользователя по идентификатору. /// /// Идентификатор пользователя. - /// Токен отмены. /// Идентичность пользователя (без хэша пароля) или null. public Task FindUserByIdAsync(Guid userId, CancellationToken ct); /// - /// Возвращает пользователей тенанта (операторские список/детали и impersonation, план Task 7). + /// Возвращает пользователей тенанта. /// /// Идентификатор тенанта. - /// Токен отмены. /// Пользователи тенанта, упорядоченные по времени создания (пусто — пользователей нет). public Task> ListUsersByTenantIdAsync(Guid tenantId, CancellationToken ct); @@ -51,21 +45,18 @@ public interface IAuthStore /// Сохраняет новую сессию. /// /// Сессия для сохранения. - /// Токен отмены. public Task CreateSessionAsync(SessionDto session, CancellationToken ct); /// - /// Удаляет сессию по SHA-256-хэшу токена (нет сессии — no-op). + /// Удаляет сессию по SHA-256-хэшу токена /// /// SHA-256-хэш raw-токена. - /// Токен отмены. public Task DeleteSessionAsync(string tokenHash, CancellationToken ct); /// - /// Удаляет все сессии пользователя (смена пароля). + /// Удаляет все сессии пользователя /// /// Идентификатор пользователя. - /// Токен отмены. public Task DeleteSessionsByUserIdAsync(Guid userId, CancellationToken ct); /// @@ -73,7 +64,6 @@ public interface IAuthStore /// /// Идентификатор пользователя. /// Новая encoded-строка хэша. - /// Токен отмены. public Task UpdatePasswordHashAsync( Guid userId, string passwordHash, @@ -82,6 +72,5 @@ public interface IAuthStore /// /// Удаляет все сессии со сроком жизни не позднее текущего момента. /// - /// Токен отмены. 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 005d00f..f5f3cd8 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/IInviteStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IInviteStore.cs @@ -4,44 +4,35 @@ 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. + /// Меняет статус приглашения по коду; для активации обновляет и ActivatedAt. /// /// Код приглашения. /// Новый статус (константа ). /// Момент активации при статусе activated; иначе null. - /// Токен отмены. /// true, если приглашение с таким кодом найдено и статус изменён; false — кода нет. public Task UpdateStatusAsync( string code, @@ -50,14 +41,10 @@ public interface IInviteStore 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, @@ -65,10 +52,9 @@ public interface IInviteStore CancellationToken ct); /// - /// Активное (pending) приглашение на email — антидубль создания (частичная уникальность, Ruling 2). + /// Активное (pending) приглашение на email — антидубль создания. /// /// Нормализованный email. - /// Токен отмены. /// Pending-приглашение на email (включая протухшее, но ещё не помеченное expired — его переводит сервис) или null. public Task FindActiveByEmailAsync(string email, CancellationToken ct); } diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/IOperatorAuthStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IOperatorAuthStore.cs index 8dc2f4c..1b2f7d0 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/IOperatorAuthStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IOperatorAuthStore.cs @@ -3,35 +3,27 @@ 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); @@ -39,19 +31,16 @@ public interface IOperatorAuthStore /// Сохраняет новую сессию оператора. /// /// Сессия для сохранения. - /// Токен отмены. public Task CreateSessionAsync(OperatorSessionDto session, CancellationToken ct); /// - /// Удаляет сессию по SHA-256-хэшу токена (нет сессии — no-op). + /// Удаляет сессию по SHA-256-хэшу токена /// /// SHA-256-хэш raw-токена. - /// Токен отмены. public Task DeleteSessionAsync(string tokenHash, CancellationToken ct); /// /// Удаляет все сессии со сроком жизни не позднее текущего момента. /// - /// Токен отмены. public Task DeleteExpiredSessionsAsync(CancellationToken ct); } diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/IPasswordHasher.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IPasswordHasher.cs index d5b1846..de36a5f 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/IPasswordHasher.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IPasswordHasher.cs @@ -1,13 +1,12 @@ namespace Deal.Modules.Tenants.Application.Abstractions; /// -/// Порт хэширования паролей (проверка учётных данных, смена пароля). +/// Порт хэширования паролей /// -/// Реализация по умолчанию — Argon2id (Ruling 5); хранилищам передаётся только encoded-строка. public interface IPasswordHasher { /// - /// Вычисляет encoded-строку хэша пароля (со случайной солью и параметрами). + /// Вычисляет encoded-строку хэша пароля /// /// Пароль в открытом виде. /// Encoded-строка для хранения в БД. diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/IRateLimitCounterStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IRateLimitCounterStore.cs index 4fadb52..72a961c 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/IRateLimitCounterStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IRateLimitCounterStore.cs @@ -1,28 +1,17 @@ namespace Deal.Modules.Tenants.Application.Abstractions; /// -/// Порт распределённого счётчика фиксированного окна (этап 12, пакет B): таблица -/// public.rate_limit_counters. Реализация — EF-адаптер RateLimitCounterStore в Infrastructure. +/// Порт распределённого счётчика фиксированного окна /// -/// -/// Один счётчик = строка (Key, WindowStart, ExpiresAt, Count). Ключ уникален; смена окна (WindowStart -/// изменился) сбрасывает счётчик к переданному amount — семантика фиксированного окна, совпадающая с -/// in-memory FixedWindowRateLimiter, но общая для нескольких инстансов (ранее — память одного -/// процесса, Ruling 5). Окна выровнены по границам длины окна (расчёт — на стороне вызывающего). -/// Счётчики используются и политиками rate limiting (auth/api/gRPC-ингресс), и guard'ом попыток входа: -/// уборка старых окон — из фонового цикла (лениво/по TTL, ExpiresAt). -/// public interface IRateLimitCounterStore { /// - /// Атомарно увеличивает счётчик окна: при смене WindowStart значение сбрасывается к - /// , иначе прибавляется; возвращает значение после операции. + /// Атомарно увеличивает счётчик окна /// /// Уникальный ключ счётчика (политика/тип + партиция). /// Начало текущего фиксированного окна (UTC). /// Конец окна (UTC) — срок уборки строки (ExpiresAt). /// Величина приращения (обычно 1; должно быть ≥0). - /// Токен отмены. /// Значение счётчика в текущем окне после операции. public Task IncrementAsync( string key, @@ -32,11 +21,10 @@ public interface IRateLimitCounterStore CancellationToken ct); /// - /// Читает счётчик текущего окна (0 — строки нет или окно сменилось). + /// Читает счётчик текущего окна /// /// Уникальный ключ счётчика. /// Начало текущего фиксированного окна (UTC). - /// Токен отмены. /// Значение счётчика в окне (0, если окно иное/строки нет). public Task GetCountAsync( string key, @@ -44,17 +32,15 @@ public interface IRateLimitCounterStore CancellationToken ct); /// - /// Удаляет счётчик ключа (успешный вход сбрасывает попытки, Ruling 5). + /// Удаляет счётчик ключа. /// /// Уникальный ключ счётчика. - /// Токен отмены. public Task ResetAsync(string key, CancellationToken ct); /// - /// Удаляет строки завершившихся окон (ExpiresAt < now) — уборка роста таблицы. + /// Удаляет строки завершившихся окон /// /// Текущий момент (UTC). - /// Токен отмены. /// Число удалённых строк. public Task DeleteExpiredAsync(DateTimeOffset now, CancellationToken ct); } diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantLimitStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantLimitStore.cs index 75c6271..6ed0a8b 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantLimitStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantLimitStore.cs @@ -4,29 +4,14 @@ 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( @@ -35,21 +20,17 @@ public interface ITenantLimitStore 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, @@ -57,13 +38,11 @@ public interface ITenantLimitStore CancellationToken ct); /// - /// Меняет бюджет/период тенанта (операторский PATCH, Task 10) и сбрасывает флаги Warned80/ - /// NotifiedExhausted (Ruling 3: один тост на период на порог; смена бюджета открывает новые пороги). + /// Меняет бюджет/период тенанта и сбрасывает флаги Warned80/ NotifiedExhausted. /// /// Тенант. /// Новый бюджет периода (≥0; 0 — ИИ запрещён). /// Новый тип периода (). - /// Токен отмены. /// Состояние бюджета после изменения. public Task UpdateBudgetAsync( Guid tenantId, @@ -72,31 +51,23 @@ public interface ITenantLimitStore CancellationToken ct); /// - /// Атомарно выставляет флаг Warned80, если порог 80% достигнут и флаг ещё не стоял (Task 9: - /// SSE-алерт один раз на период на порог). + /// Атомарно выставляет флаг Warned80, если порог 80% достигнут и флаг ещё не стоял. /// /// Тенант. - /// Токен отмены. /// True — флаг только что установлен (нужно публиковать тост); false — уже стоял/порог не достигнут. public Task TryMarkWarnedAsync(Guid tenantId, CancellationToken ct); /// - /// Атомарно выставляет флаг NotifiedExhausted, если бюджет исчерпан и флаг ещё не стоял (Task 9: - /// SSE-алерт один раз на период на порог). + /// Атомарно выставляет флаг NotifiedExhausted, если бюджет исчерпан и флаг ещё не стоял. /// /// Тенант. - /// Токен отмены. /// 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 6d73c7b..5b750d1 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantProvisioner.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantProvisioner.cs @@ -3,15 +3,13 @@ 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 d55d150..9eca60d 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantRepository.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantRepository.cs @@ -3,7 +3,7 @@ using Deal.Modules.Tenants.Application.Models; namespace Deal.Modules.Tenants.Application.Abstractions; /// -/// Порт реестра тенантов (реализация — EF-адаптер в Infrastructure, таблица public.tenants). +/// Порт реестра тенантов /// public interface ITenantRepository { @@ -11,7 +11,6 @@ public interface ITenantRepository /// Ищет тенанта по идентификатору. /// /// Идентификатор тенанта. - /// Токен отмены. /// Запись тенанта или null. public Task FindByIdAsync(Guid id, CancellationToken ct); @@ -19,22 +18,19 @@ public interface ITenantRepository /// Сохраняет нового тенанта. /// /// Запись тенанта. - /// Токен отмены. public Task CreateAsync(TenantRecordDto tenant, CancellationToken ct); /// /// Возвращает список всех тенантов. /// - /// Токен отмены. /// Список тенантов. public Task> ListAsync(CancellationToken ct); /// - /// Устанавливает статус тенанта (операторская приостановка/возобновление, план Task 7). + /// Устанавливает статус тенанта. /// /// Идентификатор тенанта. /// Новый статус — константа TenantStatuses. - /// Токен отмены. /// true, если тенант существовал и статус обновлён; false — записи нет (404 на HTTP-слое). public Task UpdateStatusAsync( Guid id, diff --git a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITokenUsageEventStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITokenUsageEventStore.cs index 8d1d414..79bc046 100644 --- a/src/core/Deal.Modules.Tenants/Application/Abstractions/ITokenUsageEventStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITokenUsageEventStore.cs @@ -4,27 +4,20 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Modules.Tenants.Application.Abstractions; /// -/// Порт хранилища истории расхода токенов: таблица public.token_usage_events (этап 10, T2; -/// реализация — EF-адаптер TokenUsageEventStore в Infrastructure). +/// Порт хранилища истории расхода токенов /// -/// -/// Append-only запись (только Add) и агрегация (group by day/tenant/provider/model с диапазоном дат). -/// Update/Delete отсутствуют. Запись — через (At=UTC-now проставляет он). -/// public interface ITokenUsageEventStore { /// - /// Добавляет событие расхода токенов (Id генерирует БД; At приходит готовым от сервиса). + /// Добавляет событие расхода токенов /// /// Событие для сохранения. - /// Токен отмены. public Task AppendAsync(TokenUsageEventDto record, CancellationToken ct); /// - /// Агрегаты по фильтру и способу группировки (суммы токенов и число событий). + /// Агрегаты по фильтру и способу группировки /// /// Фильтр/группировка. - /// Токен отмены. /// Строки агрегатов (пусто — событий нет). public Task> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct); } diff --git a/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs b/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs index f7a461a..125359c 100644 --- a/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs +++ b/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Расширения записей аудита: классификация событий входа. +/// Расширения записей аудита /// internal static class AuditRecordDtoExtensions { @@ -20,14 +20,14 @@ internal static class AuditRecordDtoExtensions }; /// - /// Неудачный вход (тенант/оператор)? + /// Неудачный вход /// /// Запись аудита. /// True — событие из FailedLoginEvents. public static bool IsFailedLogin(this AuditRecordDto record) => FailedLoginEvents.Contains(record.EventType); /// - /// Успешный вход (тенант/оператор)? + /// Успешный вход /// /// Запись аудита. /// True — событие из SuccessfulLoginEvents. diff --git a/src/core/Deal.Modules.Tenants/Application/Extensions/InviteDtoExtensions.cs b/src/core/Deal.Modules.Tenants/Application/Extensions/InviteDtoExtensions.cs index 60d4895..55effca 100644 --- a/src/core/Deal.Modules.Tenants/Application/Extensions/InviteDtoExtensions.cs +++ b/src/core/Deal.Modules.Tenants/Application/Extensions/InviteDtoExtensions.cs @@ -5,7 +5,7 @@ namespace Deal.Modules.Tenants.Application.Extensions; internal static class InviteDtoExtensions { /// - /// Истёк ли срок действия приглашения (сравнение по UTC-now). + /// Истёк ли срок действия приглашения /// /// Приглашение. /// True — срок действия уже прошёл. diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsActivityDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsActivityDto.cs index 881d711..c963edf 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsActivityDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsActivityDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Лента действий тенантов/операторов (GET /api/operator/analytics/activity; этап 10, T3). +/// Лента действий тенантов/операторов. /// /// Записи аудита, новые сверху (в пределах страницы offset/limit). /// Полное число записей по фильтру (без учёта limit/offset). diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsOverviewDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsOverviewDto.cs index 52f2d92..7b6f9e1 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsOverviewDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsOverviewDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Сводка операторской аналитики (GET /api/operator/analytics/overview; этап 10, T3). +/// Сводка операторской аналитики. /// /// Всего тенантов. /// Активных тенантов (статус active). diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsTokensDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsTokensDto.cs index f3bed43..9525c5f 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsTokensDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/AnalyticsTokensDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Агрегаты расхода токенов (GET /api/operator/analytics/tokens; этап 10, T3). +/// Агрегаты расхода токенов. /// /// Способ группировки: day|tenant|provider|model. /// Начало периода (включительно; null — без границы). diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AuditActorTypes.cs b/src/core/Deal.Modules.Tenants/Application/Models/AuditActorTypes.cs index fef47ef..4fa2962 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/AuditActorTypes.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/AuditActorTypes.cs @@ -1,17 +1,17 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Типы акторов аудита (колонка public.audit_log.ActorType; Ruling 4 этапа 7). +/// Типы акторов аудита. /// public static class AuditActorTypes { /// - /// Действие пользователя тенанта (вход, действия в своём тенанте). + /// Действие пользователя тенанта /// public const string Tenant = "tenant"; /// - /// Действие оператора SaaS-контура (инвайты, создание/приостановка тенантов, impersonation). + /// Действие оператора SaaS-контура /// public const string Operator = "operator"; diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs b/src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs index 24dae2e..b90dd50 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs @@ -1,164 +1,157 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Каталог типов событий аудита (колонка public.audit_log.EventType; Ruling 4 этапа 7). +/// Каталог типов событий аудита. /// -/// Значения — строковые константы 1:1 с каталогом Ruling 4; хранятся в БД как текст, поэтому -/// переименование константы = изменение значения (менять только вместе с миграцией данных). public static class AuditEvents { /// - /// Успешный вход пользователя тенанта (tenant_login_ok). + /// Успешный вход пользователя тенанта /// public const string TenantLoginOk = "tenant_login_ok"; /// - /// Неудачный вход пользователя тенанта — неверный пароль или заблокированный вход (tenant_login_failed). + /// Неудачный вход пользователя тенанта — неверный пароль или заблокированный вход /// public const string TenantLoginFailed = "tenant_login_failed"; /// - /// Успешный вход оператора (operator_login_ok). + /// Успешный вход оператора /// public const string OperatorLoginOk = "operator_login_ok"; /// - /// Неудачный вход оператора (operator_login_failed). + /// Неудачный вход оператора /// public const string OperatorLoginFailed = "operator_login_failed"; /// - /// Оператор создал приглашение (invite_created). + /// Оператор создал приглашение /// public const string InviteCreated = "invite_created"; /// - /// Оператор отозвал приглашение (invite_revoked). + /// Оператор отозвал приглашение /// public const string InviteRevoked = "invite_revoked"; /// - /// Приглашение активировано (invite_activated). + /// Приглашение активировано /// public const string InviteActivated = "invite_activated"; /// - /// Оператор создал тенанта (tenant_created). + /// Оператор создал тенанта /// public const string TenantCreated = "tenant_created"; /// - /// Статус тенанта изменён оператором: active/suspended (tenant_status_changed). + /// Статус тенанта изменён оператором /// public const string TenantStatusChanged = "tenant_status_changed"; /// - /// Оператор изменил лимиты тенанта (tenant_limit_changed). + /// Оператор изменил лимиты тенанта /// public const string TenantLimitChanged = "tenant_limit_changed"; /// - /// Оператор начал impersonation пользователя тенанта (impersonation_started). + /// Оператор начал impersonation пользователя тенанта /// public const string ImpersonationStarted = "impersonation_started"; /// - /// Сессия impersonation завершена выходом пользователя (logout) — impersonation_stopped - /// (ревью Task 7: полный аудит start/stop; актор — оператор по маркеру сессии). + /// Сессия impersonation завершена выходом пользователя /// public const string ImpersonationStopped = "impersonation_stopped"; /// - /// Выход пользователя тенанта (tenant_logout; этап 10, T1; актор — tenant). + /// Выход пользователя тенанта. /// public const string TenantLogout = "tenant_logout"; /// - /// Выход оператора (operator_logout; этап 10, T1; актор — operator). + /// Выход оператора. /// public const string OperatorLogout = "operator_logout"; /// - /// Активация инвайта через публичный POST /api/join (invite_joined; этап 10, T1; актор — новый - /// пользователь тенанта). Отдельное значение от (легаси-каталог этапа 7). + /// Активация инвайта через публичный POST /api/join. /// public const string InviteJoined = "invite_joined"; /// - /// Пользователь тенанта создал карточку (card_created; этап 10, T1). + /// Пользователь тенанта создал карточку. /// public const string CardCreated = "card_created"; /// - /// Пользователь перенёс карточку между контейнерами (card_moved; этап 10, T1). + /// Пользователь перенёс карточку между контейнерами. /// public const string CardMoved = "card_moved"; /// - /// Пользователь отправил карточку в корзину (card_trashed; этап 10, T1). + /// Пользователь отправил карточку в корзину. /// public const string CardTrashed = "card_trashed"; /// - /// Пользователь вернул карточку из корзины/архива (card_restored; этап 10, T1). + /// Пользователь вернул карточку из корзины/архива. /// public const string CardRestored = "card_restored"; /// - /// Пользователь удалил карточку навсегда (card_deleted; этап 10, T1). + /// Пользователь удалил карточку навсегда. /// public const string CardDeleted = "card_deleted"; /// - /// Пользователь добавил комментарий к карточке (card_comment_added; этап 10, T1). + /// Пользователь добавил комментарий к карточке. /// public const string CardCommentAdded = "card_comment_added"; /// - /// Пользователь переклассифицировал карточку(и) через ИИ/локальный разбор - /// (card_reclassified; этап 12, пакет D; актор — tenant). + /// Пользователь переклассифицировал карточку(и) через ИИ/локальный разбор. /// public const string CardReclassified = "card_reclassified"; /// - /// Пользователь создал контейнер/колонку (container_created; этап 10, T1). + /// Пользователь создал контейнер/колонку. /// public const string ContainerCreated = "container_created"; /// - /// Пользователь изменил контейнер/колонку (container_updated; этап 10, T1). + /// Пользователь изменил контейнер/колонку. /// public const string ContainerUpdated = "container_updated"; /// - /// Пользователь удалил контейнер/колонку (container_deleted; этап 10, T1). + /// Пользователь удалил контейнер/колонку. /// public const string ContainerDeleted = "container_deleted"; /// - /// Пользователь сохранил настройки тенанта (settings_updated; этап 10, T1). + /// Пользователь сохранил настройки тенанта. /// public const string SettingsUpdated = "settings_updated"; /// - /// Пользователь включил мониторинг канала Telegram (channel_enabled; этап 10, T1). + /// Пользователь включил мониторинг канала Telegram. /// public const string ChannelEnabled = "channel_enabled"; /// - /// Канал Telegram добавлен в каталог тенанта (channel_created; этап 10, T1; резерв каталога: - /// пользовательской ручки создания канала пока нет — каталог наполняется синхронизацией Telegram). + /// Канал Telegram добавлен в каталог тенанта. /// public const string ChannelCreated = "channel_created"; /// - /// Аккаунт Telegram привязан (фаза ready после входа; telegram_linked; этап 10, T1). + /// Аккаунт Telegram привязан. /// public const string TelegramLinked = "telegram_linked"; /// - /// Оператор изменил глобальные ключи Telegram api_id/api_hash (telegram_keys_changed; - /// ТЗ §4.1/§8.1; актор — operator; секреты в деталях не пишутся). + /// Оператор изменил глобальные ключи Telegram api_id/api_hash /// public const string TelegramKeysChanged = "telegram_keys_changed"; } diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AuditQueryDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/AuditQueryDto.cs index 167dee9..2e8b58b 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/AuditQueryDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/AuditQueryDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Фильтр выборки аудита (GET /api/operator/audit, аналитика действий; Ruling 4 этапа 7; этап 10, T3). +/// Фильтр выборки аудита. /// /// Тип события (равенство; null — без фильтра). /// Тип актора (равенство; null — без фильтра). diff --git a/src/core/Deal.Modules.Tenants/Application/Models/AuditRecordDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/AuditRecordDto.cs index c602986..814ac2f 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/AuditRecordDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/AuditRecordDto.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Запись аудита (строка public.audit_log; Ruling 4 этапа 7). +/// Запись аудита. /// -/// -/// Append-only: запись — только через (никаких Update/Delete в порту). -/// At для новых записей проставляет (UTC-now); Id генерирует БД -/// (identity) — при создании передаётся 0. DetailJson — JSON без секретов (пароли/токены не пишутся). -/// /// Тип события — константа каталога AuditEvents. /// Тип актора — константа AuditActorTypes (operator|tenant|system). /// Идентификатор актора; null, если актор неизвестен (неудачный вход). diff --git a/src/core/Deal.Modules.Tenants/Application/Models/BudgetStateDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/BudgetStateDto.cs index 4e980ad..ebd0aed 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/BudgetStateDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/BudgetStateDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Состояние ИИ-бюджета тенанта на момент чтения (Task 8; читают recorder-гейт Task 9 и оператор Task 10). +/// Состояние ИИ-бюджета тенанта на момент чтения. /// /// Тенант, которому принадлежит лимит. /// Бюджет текущего периода в токенах. @@ -9,8 +9,7 @@ namespace Deal.Modules.Tenants.Application.Models; /// Начало текущего периода (после ленивого reset — момент reset'а). /// Использовано токенов с начала периода. /// Статус тенанта (константа TenantStatuses: active|suspended). -/// True — ИИ-вызовы разрешены: тенант активен и бюджет не исчерпан (Ruling 3: suspended -/// замораживает ИИ; исчерпание бюджета уводит гейт в Local-fallback, Task 9). +/// True — ИИ-вызовы разрешены: тенант активен и бюджет не исчерпан. /// Флаг: тост о расходе 80% бюджета уже отправлен (один на период). /// Флаг: тост об исчерпании бюджета уже отправлен (один на период). public sealed record BudgetStateDto( diff --git a/src/core/Deal.Modules.Tenants/Application/Models/ChangePasswordResultDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/ChangePasswordResultDto.cs index c35f22d..1c68d43 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/ChangePasswordResultDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/ChangePasswordResultDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Результат смены пароля. При успехе старые сессии удалены и выдан новый токен. +/// Результат смены пароля. /// /// true при успехе; false при ошибке. /// Код ошибки (см. , ) или null при успехе. @@ -9,12 +9,12 @@ namespace Deal.Modules.Tenants.Application.Models; public sealed record ChangePasswordResultDto(bool Ok, string? Error, string? NewToken) { /// - /// Ошибка: текущий пароль неверен (или пользователь не найден). + /// Ошибка: текущий пароль неверен /// public const string ErrorOldPassword = "oldPassword"; /// - /// Ошибка: новый пароль короче минимальной длины (4 символа). + /// Ошибка: новый пароль короче минимальной длины /// public const string ErrorTooShort = "tooShort"; } diff --git a/src/core/Deal.Modules.Tenants/Application/Models/ImpersonationResultDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/ImpersonationResultDto.cs index 4e3d9d1..ad0e08e 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/ImpersonationResultDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/ImpersonationResultDto.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Результат impersonation (POST /api/operator/tenants/{id}/impersonate, план Task 7). +/// Результат impersonation. /// -/// -/// Паттерн JoinResultDto: Ok=true — выдана tenant-сессия целевого пользователя (raw-токен наружу, в хранилище — -/// его SHA-256-хэш с маркером оператора, см. ); Ok=false — -/// код ошибки (текст на HTTP-слое). Пароль пользователя НЕ меняется (Ruling: impersonation = временный вход). -/// /// true — сессия impersonation создана. /// Код ошибки при Ok=false (см. константы); null при успехе. /// Raw-токен tenant-сессии (значение куки deal_session); null при Ok=false. @@ -25,17 +20,17 @@ public sealed record ImpersonationResultDto( Guid? TenantId) { /// - /// Код ошибки: тенант не найден (404 на HTTP-слое). + /// Код ошибки: тенант не найден /// public const string ErrorTenantNotFound = "tenant_not_found"; /// - /// Код ошибки: в тенанте нет пользователей, а login не указан (400 на HTTP-слое). + /// Код ошибки: в тенанте нет пользователей, а login не указан /// public const string ErrorTenantHasNoUsers = "tenant_has_no_users"; /// - /// Код ошибки: пользователь с таким login не найден в тенанте (404 на HTTP-слое). + /// Код ошибки: пользователь с таким login не найден в тенанте /// public const string ErrorUserNotFound = "user_not_found"; diff --git a/src/core/Deal.Modules.Tenants/Application/Models/InviteCreateResultDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/InviteCreateResultDto.cs index d95ef2d..d5cd249 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/InviteCreateResultDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/InviteCreateResultDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Результат создания приглашения оператором; тексты HTTP-ошибок фиксирует слой эндпоинтов (Task 5). +/// Результат создания приглашения оператором; тексты HTTP-ошибок фиксирует слой эндпоинтов. /// /// true — приглашение создано и сохранено (см. ); false — ошибка. /// Код ошибки ( / ) или null при успехе. @@ -9,12 +9,12 @@ namespace Deal.Modules.Tenants.Application.Models; public sealed record InviteCreateResultDto(bool Ok, string? Error, InviteDto? Invite) { /// - /// Ошибка: email пустой/пробельный или не прошёл проверку формата (). + /// Ошибка: email пустой/пробельный или не прошёл проверку формата /// public const string ErrorInvalidEmail = "invalidEmail"; /// - /// Ошибка: на email уже есть активное (pending, не истёкшее) приглашение — антидубль Ruling 2. + /// Ошибка: на email уже есть активное /// public const string ErrorDuplicateActive = "duplicateActive"; } diff --git a/src/core/Deal.Modules.Tenants/Application/Models/InviteDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/InviteDto.cs index 44ecfde..5ffd22d 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/InviteDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/InviteDto.cs @@ -1,14 +1,8 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Приглашение (строка public.invites; Ruling 2 этапа 7). +/// Приглашение. /// -/// -/// Статусы — константы . Статус «expired» не хранится в БД до первого чтения: -/// он вычисляется лениво ( / ) -/// и сохраняется. Email нормализован (нижний регистр), TenantId null означает «при активации создать нового -/// тенанта» (Task 6), иначе пользователь добавляется в существующий тенант. -/// /// Одноразовый код приглашения (url-safe, 16 симв.) — первичный ключ. /// Email приглашённого, нормализованный (нижний регистр). /// Целевой тенант; null — при активации создаётся новый тенант. diff --git a/src/core/Deal.Modules.Tenants/Application/Models/InviteRevokeResultDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/InviteRevokeResultDto.cs index 0cb5336..f4955db 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/InviteRevokeResultDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/InviteRevokeResultDto.cs @@ -1,12 +1,11 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Результат отзыва приглашения оператором; тексты HTTP-ошибок фиксирует слой эндпоинтов (Task 5). +/// Результат отзыва приглашения оператором; тексты HTTP-ошибок фиксирует слой эндпоинтов. /// /// true — приглашение было pending и переведено в revoked. /// Код ошибки ( / ) или null при успехе. -/// Состояние приглашения: при Ok — отозванное (для аудита); при ErrorNotPending — фактическое -/// (уже отозвано/использовано/истекло); при ErrorNotFound — null. +/// Состояние приглашения: при Ok — отозванное (для аудита); при ErrorNotPending — фактическое (уже отозвано/использовано/истекло); при ErrorNotFound — null. public sealed record InviteRevokeResultDto(bool Ok, string? Error, InviteDto? Invite) { /// @@ -15,7 +14,7 @@ public sealed record InviteRevokeResultDto(bool Ok, string? Error, InviteDto? In public const string ErrorNotFound = "notFound"; /// - /// Ошибка: приглашение не в статусе pending — отозвать нельзя (Ruling 2: отзыв только ожидающего). + /// Ошибка: приглашение не в статусе pending — отозвать нельзя. /// public const string ErrorNotPending = "notPending"; } diff --git a/src/core/Deal.Modules.Tenants/Application/Models/InviteStatuses.cs b/src/core/Deal.Modules.Tenants/Application/Models/InviteStatuses.cs index ba51652..8e12e28 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/InviteStatuses.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/InviteStatuses.cs @@ -3,14 +3,8 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Modules.Tenants.Application.Models; /// -/// Статусы приглашения (колонка public.invites.Status; Ruling 2 этапа 7). +/// Статусы приглашения. /// -/// -/// Значения — строковые константы 1:1 с InviteConfiguration (Task 1); хранятся в БД как текст, -/// поэтому переименование константы = изменение значения (менять только вместе с миграцией данных). -/// «Активным» (доступным для активации через /api/join, Task 6) считается только pending; частичный -/// unique-индекс invites.Email действует именно по статусу pending (InviteConfiguration, Task 1). -/// public static class InviteStatuses { /// @@ -19,17 +13,17 @@ public static class InviteStatuses public const string Pending = "pending"; /// - /// Активировано через /api/join: пользователь создан, код больше недействителен (Task 6). + /// Активировано через /api/join /// public const string Activated = "activated"; /// - /// Отозвано оператором; email освобождается для нового приглашения (перевод только из pending). + /// Отозвано оператором; email освобождается для нового приглашения /// public const string Revoked = "revoked"; /// - /// Срок действия истёк; проставляется лениво при чтении/проверке (). + /// Срок действия истёк; проставляется лениво при чтении/проверке /// public const string Expired = "expired"; } diff --git a/src/core/Deal.Modules.Tenants/Application/Models/JoinResultDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/JoinResultDto.cs index 0facfc2..4dcd6f3 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/JoinResultDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/JoinResultDto.cs @@ -1,29 +1,27 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Результат активации приглашения через /api/join; тексты HTTP-ошибок фиксирует слой эндпоинтов (Task 6). +/// Результат активации приглашения через /api/join; тексты HTTP-ошибок фиксирует слой эндпоинтов. /// /// true — пользователь создан, тенант (при необходимости) создан и приглашение активировано. -/// Код ошибки (/// -/// /// -/// ) или null при успехе. +/// Код ошибки (/// /// ) или null при успехе. /// Нормализованный email (логин пользователя) при Ok=true; иначе null. /// Идентификатор созданного пользователя при Ok=true (для аудита invite_activated); иначе null. /// Тенант пользователя при Ok=true (существующий из инвайта или созданный); иначе null. public sealed record JoinResultDto(bool Ok, string? Error, string? Login, Guid? UserId, Guid? TenantId) { /// - /// Ошибка: приглашение с таким кодом не найдено (или код пустой). + /// Ошибка: приглашение с таким кодом не найдено /// public const string ErrorNotFound = "notFound"; /// - /// Ошибка: срок действия приглашения истёк (статус pending, но ExpiresAt уже прошёл). + /// Ошибка: срок действия приглашения истёк /// public const string ErrorExpired = "expired"; /// - /// Ошибка: приглашение уже активировано (повторная активация тем же кодом). + /// Ошибка: приглашение уже активировано /// public const string ErrorUsed = "used"; @@ -33,12 +31,12 @@ public sealed record JoinResultDto(bool Ok, string? Error, string? Login, Guid? public const string ErrorRevoked = "revoked"; /// - /// Ошибка: email запроса не совпадает с email приглашения (Ruling 2). + /// Ошибка: email запроса не совпадает с email приглашения. /// public const string ErrorEmailMismatch = "emailMismatch"; /// - /// Ошибка: пользователь с таким email (login) уже зарегистрирован глобально (users.login unique). + /// Ошибка: пользователь с таким email /// public const string ErrorEmailTaken = "emailTaken"; @@ -48,12 +46,12 @@ public sealed record JoinResultDto(bool Ok, string? Error, string? Login, Guid? public const string ErrorPasswordTooShort = "passwordTooShort"; /// - /// Ошибка: целевой тенант инвайта не существует (удалён/«битый» инвайт; Security review). + /// Ошибка: целевой тенант инвайта не существует /// public const string ErrorTenantNotFound = "tenantNotFound"; /// - /// Ошибка: целевой тенант инвайта приостановлен (join на suspended-тенант запрещён; Security review). + /// Ошибка: целевой тенант инвайта приостановлен /// public const string ErrorTenantSuspended = "tenantSuspended"; } diff --git a/src/core/Deal.Modules.Tenants/Application/Models/LoginResultDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/LoginResultDto.cs index 569ca77..6a9559c 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/LoginResultDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/LoginResultDto.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Результат логина: пустые Login/Token означают «Неверный логин или пароль» (401 на HTTP-слое). +/// Результат логина /// -/// -/// Коды ошибок — в константах типа: отличает «учётка заблокирована -/// приостановкой тенанта» от «неверные учётные данные» (Login/Token пусты, но UserId/TenantId заполнены — -/// HTTP-слой пишет tenant_login_failed с tenantId, ревью Task 4/7). -/// /// Логин пользователя (в нижнем регистре) при успехе; иначе null. /// Raw-токен сессии при успехе; иначе null. /// Идентификатор пользователя при успехе или при заблокированном входе (для аудита); иначе null. @@ -21,7 +16,7 @@ public sealed record LoginResultDto( string? Error = null) { /// - /// Код ошибки: тенант пользователя приостановлен — вход заблокирован (план Task 7, Ruling 10(5)). + /// Код ошибки: тенант пользователя приостановлен — вход заблокирован /// public const string ErrorTenantSuspended = "tenant_suspended"; } diff --git a/src/core/Deal.Modules.Tenants/Application/Models/LogoutResultDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/LogoutResultDto.cs index 74f0bdd..8ca6310 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/LogoutResultDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/LogoutResultDto.cs @@ -1,13 +1,8 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Сведения о завершённой сессии impersonation — возвращает AuthService.LogoutAsync (Task 7). +/// Сведения о завершённой сессии impersonation — возвращает AuthService.LogoutAsync. /// -/// -/// Заполнен только когда удалённая сессия была создана оператором (маркер ); -/// обычный logout возвращает null. HTTP-слой пишет по нему аудит impersonation_stopped (актор — оператор, начавший -/// impersonation; тенант и login пользователя — поля записи/DetailJson). -/// /// Идентификатор оператора, создавшего сессию (актор события stopped). /// Логин пользователя тенанта (денормализован в сессию). /// Идентификатор пользователя тенанта. diff --git a/src/core/Deal.Modules.Tenants/Application/Models/OperatorIdentityDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/OperatorIdentityDto.cs index 872951e..e955678 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/OperatorIdentityDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/OperatorIdentityDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Идентичность оператора — результат разрешения операторской сессии (без секретов). +/// Идентичность оператора — результат разрешения операторской сессии /// /// Идентификатор оператора. /// Логин в нижнем регистре. diff --git a/src/core/Deal.Modules.Tenants/Application/Models/OperatorLoginResultDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/OperatorLoginResultDto.cs index 194ed8e..d5b52d5 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/OperatorLoginResultDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/OperatorLoginResultDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Результат логина оператора: пустые Login/Token означают «Неверный логин или пароль оператора» (401 на HTTP-слое). +/// Результат логина оператора /// /// Логин оператора (в нижнем регистре) при успехе; иначе null. /// Raw-токен сессии при успехе; иначе null. diff --git a/src/core/Deal.Modules.Tenants/Application/Models/OperatorSessionDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/OperatorSessionDto.cs index bc7381f..51cffd6 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/OperatorSessionDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/OperatorSessionDto.cs @@ -1,9 +1,8 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Сессия оператора как её видит хранилище: SHA-256-хэш токена и срок жизни (12 часов). +/// Сессия оператора как её видит хранилище /// -/// Отдельная от пользовательских сессий сущность (таблица operator_sessions, Ruling 1 этапа 7). /// SHA-256-хэш raw-токена (первичный ключ таблицы operator_sessions). /// Идентификатор оператора. /// Денормализованный логин (для чтения без join). diff --git a/src/core/Deal.Modules.Tenants/Application/Models/SessionDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/SessionDto.cs index 507c94d..aab3d38 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/SessionDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/SessionDto.cs @@ -1,14 +1,13 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Сессия пользователя как её видит хранилище: SHA-256-хэш токена и срок жизни. +/// Сессия пользователя как её видит хранилище /// /// SHA-256-хэш raw-токена (первичный ключ таблицы sessions). /// Идентификатор пользователя. /// Денормализованный логин (для чтения без join). /// Момент истечения сессии (30 дней от создания). -/// Идентификатор оператора, создавшего сессию impersonation (Task 7); -/// null — обычная сессия пользователя. Маркер нужен для аудита impersonation_stopped при logout. +/// Идентификатор оператора, создавшего сессию impersonation; null — обычная сессия пользователя. Маркер нужен для аудита impersonation_stopped при logout. public sealed record SessionDto( string TokenHash, Guid UserId, diff --git a/src/core/Deal.Modules.Tenants/Application/Models/StoredOperatorDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/StoredOperatorDto.cs index d9aaf1d..25b3efc 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/StoredOperatorDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/StoredOperatorDto.cs @@ -1,9 +1,8 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Оператор SaaS-контура из хранилища вместе с хэшем пароля (нужен аутентификации). +/// Оператор SaaS-контура из хранилища вместе с хэшем пароля /// -/// Оператор ≠ пользователь тенанта: не принадлежит ни одному тенанту (Ruling 1 этапа 7). /// Идентификатор оператора. /// Логин в нижнем регистре (уникален). /// Статус учётной записи ("active" и т.п.). diff --git a/src/core/Deal.Modules.Tenants/Application/Models/StoredUserDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/StoredUserDto.cs index 734a288..9892cc3 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/StoredUserDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/StoredUserDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Пользователь из хранилища вместе с хэшем пароля (нужен сервисам аутентификации). +/// Пользователь из хранилища вместе с хэшем пароля /// /// Идентификатор пользователя. /// Логин в нижнем регистре. diff --git a/src/core/Deal.Modules.Tenants/Application/Models/SuspiciousActivityDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/SuspiciousActivityDto.cs index 1a3dd1b..469c590 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/SuspiciousActivityDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/SuspiciousActivityDto.cs @@ -3,21 +3,15 @@ namespace Deal.Modules.Tenants.Application.Models; /// /// Сводка детектора подозрительной активности — тело ответа GET /api/operator/analytics/suspicious. /// -/// -/// Анализ идёт по логам аудита (public.audit_log) за окно [From, To]. — сколько -/// записей реально разобрано (ограничено лимитом выборки), — окно содержало больше -/// записей, чем разобрано (находки неполные). упорядочены: сперва high, затем по убыванию -/// Count и субъекту. -/// public sealed record SuspiciousActivityDto { /// - /// Начало окна анализа (включительно, UTC). + /// Начало окна анализа /// public DateTimeOffset From { get; init; } /// - /// Конец окна анализа (включительно, UTC). + /// Конец окна анализа /// public DateTimeOffset To { get; init; } @@ -27,12 +21,12 @@ public sealed record SuspiciousActivityDto public int Scanned { get; init; } /// - /// True — в окне было больше записей, чем предел выборки (анализ частичный). + /// True — в окне было больше записей, чем предел выборки /// public bool Truncated { get; init; } /// - /// Найденные подозрительные паттерны (пусто — ничего не сработало). + /// Найденные подозрительные паттерны /// public IReadOnlyList Items { get; init; } = Array.Empty(); } diff --git a/src/core/Deal.Modules.Tenants/Application/Models/SuspiciousFindingDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/SuspiciousFindingDto.cs index d8e1cdc..a8a949c 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/SuspiciousFindingDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/SuspiciousFindingDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Одна находка детектора подозрительной активности (элемент ответа GET /api/operator/analytics/suspicious). +/// Одна находка детектора подозрительной активности /// /// Правило, сработавшее на логах безопасности (константы SuspiciousActivityService). /// Уровень: high | medium (константы сервиса). diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TenantCreateResultDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/TenantCreateResultDto.cs index 6207e4c..83553a5 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TenantCreateResultDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TenantCreateResultDto.cs @@ -1,14 +1,8 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Результат операторского создания тенанта (POST /api/operator/tenants, план Task 7/Ruling 11). +/// Результат операторского создания тенанта. /// -/// -/// Паттерн JoinResultDto: Ok=false с кодом ошибки — бизнес-отказ без побочных эффектов; Ok=true — тенант -/// создан (Status active) и схема провижинена. При заданном email сразу создаётся пользователь-владелец -/// с одноразовым паролем (наружу возвращается один раз; в БД — только Argon2id- -/// хэш; в аудит/логи не пишется — Ruling «секреты не логируются»). Тексты ошибок — HTTP-слой. -/// /// true — тенант создан (и, при email, пользователь-владелец). /// Код ошибки при Ok=false (см. константы); null при успехе. /// Созданный тенант (Status active); null при Ok=false. @@ -24,17 +18,17 @@ public sealed record TenantCreateResultDto( string? InitialPassword = null) { /// - /// Код ошибки: имя тенанта пустое/пробельное (400 на HTTP-слое). + /// Код ошибки: имя тенанта пустое/пробельное /// public const string ErrorNameRequired = "name_required"; /// - /// Код ошибки: email имеет некорректный формат (400 на HTTP-слое). + /// Код ошибки: email имеет некорректный формат /// public const string ErrorInvalidEmail = "invalid_email"; /// - /// Код ошибки: пользователь с таким email уже зарегистрирован (users.login unique, Ruling 2). + /// Код ошибки: пользователь с таким email уже зарегистрирован. /// public const string ErrorEmailTaken = "email_taken"; diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TenantDetailDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/TenantDetailDto.cs index c54e952..f36a5d0 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TenantDetailDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TenantDetailDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Детали тенанта для оператора (GET /api/operator/tenants/{id}, план Task 7): реестр + пользователи. +/// Детали тенанта для оператора /// /// Идентификатор тенанта. /// Человекочитаемое имя тенанта. diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TenantLimitDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/TenantLimitDto.cs index c8f68ab..166432f 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TenantLimitDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TenantLimitDto.cs @@ -1,15 +1,15 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Строка лимита ИИ-бюджета тенанта (таблица public.tenant_limits, Ruling 3 этапа 7). +/// Строка лимита ИИ-бюджета тенанта. /// /// Тенант, которому принадлежит лимит (первичный ключ). /// Бюджет текущего периода в токенах. /// Тип периода (константа TenantLimitPeriods: month|day). /// Начало текущего периода (отсчёт окна для ленивого reset). /// Использовано токенов с начала периода. -/// Флаг: тост о расходе 80% бюджета уже отправлен (один на период, Task 9). -/// Флаг: тост об исчерпании бюджета уже отправлен (один на период, Task 9). +/// Флаг: тост о расходе 80% бюджета уже отправлен. +/// Флаг: тост об исчерпании бюджета уже отправлен. public sealed record TenantLimitDto( Guid TenantId, long BudgetTokens, diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TenantLimitPeriods.cs b/src/core/Deal.Modules.Tenants/Application/Models/TenantLimitPeriods.cs index cc80705..2d0f1dd 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TenantLimitPeriods.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TenantLimitPeriods.cs @@ -1,23 +1,17 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Типы периода бюджета тенанта (колонка public.tenant_limits.Period; Ruling 3 этапа 7). +/// Типы периода бюджета тенанта. /// -/// -/// Значения — строковые константы 1:1 со значениями в БД (TenantLimitConfiguration, дефолт "month"); -/// переименование константы = изменение значения (менять только вместе с миграцией данных). Период -/// определяет конец окна для ленивого reset (Task 8): месяц — календарный (+1 месяц от PeriodStart), -/// день — +1 сутки; reset срабатывает при чтении/записи, если сейчас ≥ конца периода (Ruling 3). -/// public static class TenantLimitPeriods { /// - /// Месячный период (календарный месяц от PeriodStart, дефолт строки tenant_limits). + /// Месячный период /// public const string Month = "month"; /// - /// Суточный период (24 часа от PeriodStart). + /// Суточный период /// public const string Day = "day"; } diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TenantListItemDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/TenantListItemDto.cs index 7d7870e..2e7081e 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TenantListItemDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TenantListItemDto.cs @@ -1,9 +1,8 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Строка списка тенантов для оператора (GET /api/operator/tenants, план Task 7): реестр + счётчик пользователей. +/// Строка списка тенантов для оператора /// -/// Поля лимитов (бюджет/использовано) добавляет Task 8 — здесь счётчик только пользователей (план Task 7). /// Идентификатор тенанта. /// Человекочитаемое имя тенанта. /// Статус тенанта (константа TenantStatuses). diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TenantRecordDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/TenantRecordDto.cs index 65553c5..dcfc124 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TenantRecordDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TenantRecordDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Запись реестра тенантов (таблица public.tenants). +/// Запись реестра тенантов /// /// Идентификатор тенанта (Guid). /// Человекочитаемое имя тенанта. diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TenantStatusChangeResultDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/TenantStatusChangeResultDto.cs index aaf9ed0..8ce19d0 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TenantStatusChangeResultDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TenantStatusChangeResultDto.cs @@ -1,12 +1,8 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Результат смены статуса тенанта оператором (POST /api/operator/tenants/{id}/suspend|unsuspend, план Task 7). +/// Результат смены статуса тенанта оператором. /// -/// -/// Паттерн JoinResultDto: Ok=false с кодом ошибки — бизнес-отказ без побочных эффектов; Ok=true — запрос -/// применён (или уже был в этом статусе — Changed=false, аудит не пишется). Код ошибки — . -/// /// true — тенант найден и действие применимо (возможно, без изменения — см. ). /// Код ошибки при Ok=false (см. константы); null при успехе. /// true — статус реально изменён (пишется аудит tenant_status_changed); false — уже был таким. @@ -18,7 +14,7 @@ public sealed record TenantStatusChangeResultDto( TenantRecordDto? Tenant) { /// - /// Код ошибки: тенант не найден (404 на HTTP-слое). + /// Код ошибки: тенант не найден /// public const string ErrorNotFound = "tenant_not_found"; } diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TenantStatuses.cs b/src/core/Deal.Modules.Tenants/Application/Models/TenantStatuses.cs index c1610cc..37f65ec 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TenantStatuses.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TenantStatuses.cs @@ -3,26 +3,17 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Modules.Tenants.Application.Models; /// -/// Статусы тенанта (колонка public.tenants.Status; Ruling 1/10 этапа 7). +/// Статусы тенанта. /// -/// -/// Значения — строковые константы 1:1 со значениями в БД (TenantConfiguration/дефолт сущности — "active"); -/// хранятся как текст, поэтому переименование константы = изменение значения (менять только вместе с -/// миграцией данных). Приостановка (suspended) блокирует вход пользователя (AuthService, Task 7) и -/// замораживает ИИ-расход (бюджетный гейт, Task 9); с этапа 12 (пакет B) она ещё и обесценивает активные -/// сессии немедленно — проверяет статус тенанта на каждом -/// запросе (ранее сессии доживали до expiry, Ruling 10(5) уточнён). -/// public static class TenantStatuses { /// - /// Тенант активен: вход пользователей разрешён, лимиты списываются (Ruling 10(5)). + /// Тенант активен: вход пользователей разрешён, лимиты списываются /// public const string Active = "active"; /// - /// Тенант приостановлен оператором: вход и активные сессии заблокированы (403 на HTTP-слое / - /// сессия не разрешается), ИИ-гейт запрещён (Task 9; этап 12, пакет B — мгновенный разлогин). + /// Тенант приостановлен оператором /// public const string Suspended = "suspended"; } diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TokenBudgetDefaults.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenBudgetDefaults.cs index 0928e63..171d9f2 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenBudgetDefaults.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenBudgetDefaults.cs @@ -1,29 +1,22 @@ 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/TokenLimitDefaults.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenLimitDefaults.cs index d4e31e2..8bd1887 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenLimitDefaults.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenLimitDefaults.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Дефолт-параметры новой строки tenant_limits (Ruling 3 этапа 7). +/// Дефолт-параметры новой строки tenant_limits. /// /// Стартовый бюджет периода в токенах (лениво создаваемая строка). /// Тип периода (константа TenantLimitPeriods: month|day). diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageAggregateDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageAggregateDto.cs index b14ed36..f6777fd 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageAggregateDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageAggregateDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Строка агрегата расхода токенов (группа по дню/тенанту/провайдеру/модели; этап 10, T2/T3). +/// Строка агрегата расхода токенов. /// /// Ключ группы: ГГГГ-ММ-ДД (day), Guid (tenant), id провайдера, модель. /// Сумма токенов запроса в группе. diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventDto.cs index 6cb4ff3..f353dec 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventDto.cs @@ -1,14 +1,8 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Событие расхода токенов (строка public.token_usage_events; этап 10, T2) — time-series для аналитики. +/// Событие расхода токенов — time-series для аналитики. /// -/// -/// Append-only: запись — только через . At для новых -/// событий проставляет TokenUsageEventService (UTC-now). Агрегат public.tenant_limits.UsedTokens -/// остаётся источником истины бюджетного гейта; эта таблица — история (кто/сколько/когда/провайдер/модель). -/// DetailJson — JSON без секретов (промпты/api-ключи не пишутся). -/// /// Тенант события (Guid строки public.tenants). /// Время события (UTC; проставляет сервис). /// Провайдер/источник (deepseek/openai/anthropic/local/ml). diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventKinds.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventKinds.cs index fb4f0e0..a344dfe 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventKinds.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventKinds.cs @@ -1,17 +1,17 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Виды вызова расхода токенов (колонка public.token_usage_events.Kind; этап 10, T2). +/// Виды вызова расхода токенов. /// public static class TokenUsageEventKinds { /// - /// Платный вызов LLM через ai-service (учитывается в бюджете tenant_limits). + /// Платный вызов LLM через ai-service /// public const string Ai = "ai"; /// - /// Локальный вызов ml-service/ML (бесплатный; бюджет не расходует, только история). + /// Локальный вызов ml-service/ML /// public const string Ml = "ml"; } diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventQueryDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventQueryDto.cs index c0bfdcd..fe57405 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventQueryDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventQueryDto.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Фильтр агрегации расхода токенов (этап 10, T2/T3). +/// Фильтр агрегации расхода токенов. /// /// Тенант (равенство; null — все тенанты). /// Провайдер (равенство; null — без фильтра). diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageGroupBys.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageGroupBys.cs index 2b998dd..15392b9 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageGroupBys.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageGroupBys.cs @@ -1,27 +1,27 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Способы группировки агрегатов расхода токенов (query groupBy эндпоинта аналитики; этап 10, T3). +/// Способы группировки агрегатов расхода токенов. /// public static class TokenUsageGroupBys { /// - /// Серия по суткам UTC (ключ — ГГГГ-ММ-ДД). + /// Серия по суткам UTC /// public const string Day = "day"; /// - /// Агрегат по тенантам (ключ — Guid тенанта). + /// Агрегат по тенантам /// public const string Tenant = "tenant"; /// - /// Агрегат по провайдерам (ключ — id провайдера). + /// Агрегат по провайдерам /// public const string Provider = "provider"; /// - /// Агрегат по моделям (ключ — модель). + /// Агрегат по моделям /// public const string Model = "model"; } diff --git a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageSources.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageSources.cs index 14cf4cf..3064778 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageSources.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageSources.cs @@ -1,21 +1,17 @@ namespace Deal.Modules.Tenants.Application.Models; /// -/// Источники расхода токенов (значения колонок public.token_usage_events.Provider/Model; этап 10, T2). +/// Источники расхода токенов. /// -/// -/// AI-вызовы пишут provider/model из конфига активного провайдера тенанта (aiConfigs: deepseek/openai/…). -/// Локальный ML-вызов не имеет провайдера-LLM: provider — , model — . -/// public static class TokenUsageSources { /// - /// Локальный источник (ML-модель ядра/ml-service). + /// Локальный источник /// public const string Local = "local"; /// - /// Модель локального ML-вызова (наивный байес ml-service). + /// Модель локального ML-вызова /// public const string Ml = "ml"; } diff --git a/src/core/Deal.Modules.Tenants/Application/Models/UserIdentityDto.cs b/src/core/Deal.Modules.Tenants/Application/Models/UserIdentityDto.cs index e549a53..ac59a00 100644 --- a/src/core/Deal.Modules.Tenants/Application/Models/UserIdentityDto.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/UserIdentityDto.cs @@ -1,7 +1,7 @@ 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 ee6a6e5..7bf15c6 100644 --- a/src/core/Deal.Modules.Tenants/Application/Registrars/TenantModuleRegistrar.cs +++ b/src/core/Deal.Modules.Tenants/Application/Registrars/TenantModuleRegistrar.cs @@ -5,14 +5,8 @@ using Microsoft.Extensions.DependencyInjection; namespace Deal.Modules.Tenants.Application.Registrars; /// -/// DI-регистрация модуля Tenants. Паттерн «port & adapter» (Ruling 1). +/// DI-регистрация модуля Tenants. /// -/// -/// Регистрируются только сервисы и реализации внутри модуля. Порт-адаптеры -/// (IAuthStore, IOperatorAuthStore, ITenantRepository, IAuditLogStore, IInviteStore, ITenantProvisioner) -/// реализованы в Deal.Infrastructure и регистрируются там (AddDealPersistence, см. Program.cs) — модуль не -/// знает про EF. -/// public static class TenantModuleRegistrar { /// @@ -20,13 +14,6 @@ public static class TenantModuleRegistrar /// /// Коллекция сервисов. /// Коллекция сервисов для цепочки вызовов. - /// - /// Время жизни: 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(); diff --git a/src/core/Deal.Modules.Tenants/Application/Services/AnalyticsService.cs b/src/core/Deal.Modules.Tenants/Application/Services/AnalyticsService.cs index 59a8da9..199daf6 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/AnalyticsService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/AnalyticsService.cs @@ -4,34 +4,28 @@ using Deal.Modules.Tenants.Application.Models; namespace Deal.Modules.Tenants.Application.Services; /// -/// Прикладной сервис операторской аналитики (этап 10, T3): сводка, агрегаты токенов и лента действий. +/// Прикладной сервис операторской аналитики /// -/// -/// Read-only: ничего не пишет. Источники — реестр тенантов (), аудит -/// () и история расхода токенов (). Все ручки — -/// под операторской сессией (HTTP-слой); периоды задаются границами from/to (включительно). -/// public sealed class AnalyticsService( ITenantRepository tenants, AuditService audit, TokenUsageEventService tokenUsage) { /// - /// Размер страницы ленты действий по умолчанию (как аудит-лента оператора). + /// Размер страницы ленты действий по умолчанию /// public const int DefaultActivityLimit = AuditService.DefaultQueryLimit; /// - /// Верхняя граница размера страницы ленты действий (как аудит-лента оператора). + /// Верхняя граница размера страницы ленты действий /// public const int MaxActivityLimit = AuditService.MaxQueryLimit; /// - /// Сводка: тенанты (всего/активных), расход токенов, события, входы/выходы/неудачные входы за период. + /// Сводка: тенанты /// /// Начало периода (включительно; null — без границы). /// Конец периода (включительно; null — без границы). - /// Токен отмены. /// Сводка аналитики. public async Task OverviewAsync( DateTimeOffset? from, @@ -74,13 +68,12 @@ public sealed class AnalyticsService( } /// - /// Агрегаты расхода токенов по группировке и фильтрам (серия/витрина). + /// Агрегаты расхода токенов по группировке и фильтрам /// /// Группировка day|tenant|provider|model (валидирует HTTP-слой). /// Тенант (равенство; null — все тенанты). /// Начало периода (включительно; null — без границы). /// Конец периода (включительно; null — без границы). - /// Токен отмены. /// Строки агрегатов и итог. public async Task TokensAsync( string groupBy, @@ -101,7 +94,7 @@ public sealed class AnalyticsService( } /// - /// Лента действий (аудит) с фильтрами и пагинацией offset/limit. + /// Лента действий /// /// Тип события (равенство; null — без фильтра). /// Тип актора operator|tenant|system (равенство; null — без фильтра). @@ -111,7 +104,6 @@ public sealed class AnalyticsService( /// Верхняя граница At (включительно; null — без границы). /// Размер страницы (дефолт 100, кламп 1..500). /// Смещение страницы (≥0). - /// Токен отмены. /// Страница записей и полное число по фильтру. public async Task ActivityAsync( string? eventType, @@ -135,7 +127,7 @@ public sealed class AnalyticsService( } /// - /// Нормализует limit ленты действий: дефолт , кламп 1... + /// Нормализует limit ленты действий /// /// Запрошенный размер (null — не задан). /// Значение для фильтра. diff --git a/src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs b/src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs index d27f835..86f96ab 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs @@ -6,24 +6,17 @@ 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). + /// Размер выборки по умолчанию при отсутствии limit в запросе /// public const int DefaultQueryLimit = 100; @@ -31,44 +24,40 @@ public sealed class AuditService(IAuditLogStore store) 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). + /// Сериализует детали события в JSON. /// - /// Объект деталей (обычно анонимный: { login = ... }). + /// Объект деталей (обычно анонимный: { login =... }). /// JSON-строка деталей. public static string ToDetailJson(object? details) => JsonSerializer.Serialize(details, DetailJsonOptions); /// - /// Актор «пользователь тенанта» по разрешённой сессии: (ActorType, ActorId, TenantId). + /// Актор «пользователь тенанта» по разрешённой сессии /// /// Идентичность пользователя тенанта. /// Кортеж актора для полей записи аудита. @@ -76,7 +65,7 @@ public sealed class AuditService(IAuditLogStore store) (AuditActorTypes.Tenant, user.Id, user.TenantId); /// - /// Актор «оператор» по разрешённой операторской сессии: (ActorType, ActorId, TenantId=null). + /// Актор «оператор» по разрешённой операторской сессии /// /// Идентичность оператора. /// Кортеж актора для полей записи аудита. diff --git a/src/core/Deal.Modules.Tenants/Application/Services/AuthService.cs b/src/core/Deal.Modules.Tenants/Application/Services/AuthService.cs index 6713fa9..9c1d29d 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/AuthService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/AuthService.cs @@ -4,36 +4,20 @@ 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; @@ -42,16 +26,11 @@ public sealed class AuthService( private const string UserActiveStatus = "active"; /// - /// Вход: при успехе создаёт сессию и возвращает её raw-токен. Вход suspended-тенанта заблокирован (Ruling 10(5)). + /// Вход: при успехе создаёт сессию и возвращает её raw-токен. /// /// Логин (регистр и пробелы не важны — нормализуется). /// Пароль в открытом виде. - /// Токен отмены. - /// - /// При успехе — Login и Token (UserId/TenantId для аудита). Иначе Login/Token null: Error может отличать - /// заблокированный вход приостановленного тенанта (, - /// UserId/TenantId заполнены) от «неверные учётные данные» (Error null, UserId/TenantId пусты). - /// + /// При успехе — Login и Token (UserId/TenantId для аудита). Иначе Login/Token null: Error может отличать заблокированный вход приостановленного тенанта (, UserId/TenantId заполнены) от «неверные учётные данные» (Error null, UserId/TenantId пусты). public async Task LoginAsync( string login, string password, @@ -74,7 +53,6 @@ public sealed class AuthService( 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, @@ -88,14 +66,10 @@ public sealed class AuthService( } /// - /// Выход: удаляет сессию по raw-токену (no-op без токена). + /// Выход: удаляет сессию по raw-токену /// /// Raw-токен из куки (может отсутствовать — no-op). - /// Токен отмены. - /// - /// Не null, если удалённая сессия была impersonation — сведения для аудита impersonation_stopped - /// (актор — оператор по маркеру сессии). Обычный logout и no-op возвращают null. - /// + /// Не null, если удалённая сессия была impersonation — сведения для аудита impersonation_stopped (актор — оператор по маркеру сессии). Обычный logout и no-op возвращают null. public async Task LogoutAsync(string? rawToken, CancellationToken ct) { if (string.IsNullOrWhiteSpace(rawToken)) @@ -130,11 +104,7 @@ public sealed class AuthService( /// Логин пользователя. /// Текущий пароль. /// Новый пароль (минимум 8 символов). - /// Токен отмены. - /// - /// При успехе — Ok=true и NewToken (raw-токен свежей сессии). Иначе Ok=false и код ошибки: - /// или . - /// + /// При успехе — Ok=true и NewToken (raw-токен свежей сессии). Иначе Ok=false и код ошибки: или . public async Task ChangePasswordAsync( string login, string oldPassword, @@ -153,7 +123,6 @@ public sealed class AuthService( 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); @@ -162,19 +131,11 @@ public sealed class AuthService( } /// - /// Impersonation: tenant-сессия целевого пользователя от имени оператора (план Task 7). + /// Impersonation: tenant-сессия целевого пользователя от имени оператора. /// - /// - /// Пароль пользователя не меняется и не требуется: сессия выпускается оператором напрямую с маркером - /// (для аудита stopped при logout). login опционален: - /// не задан — берётся первый пользователь тенанта (по CreatedAt); задан — пользователь обязан - /// принадлежать тенанту. Приостановленный тенант не блокирует impersonation (операторский доступ, - /// аудируется; ИИ-расход всё равно заморожен гейтом Task 9). - /// /// Идентификатор тенанта. /// Логин пользователя (null/пустой — первый пользователь тенанта). /// Идентификатор оператора, начинающего impersonation (маркер сессии). - /// Токен отмены. /// Результат: Ok=true — SessionToken (raw-токен для куки deal_session) и срок жизни; иначе код ошибки. public async Task ImpersonateAsync( Guid tenantId, @@ -215,10 +176,9 @@ public sealed class AuthService( } /// - /// Разрешение сессии по raw-токену: возвращает пользователя или null (нет/протухла). + /// Разрешение сессии по raw-токену /// /// Raw-токен из куки. - /// Токен отмены. /// Идентичность пользователя или null. public async Task ResolveSessionAsync(string? rawToken, CancellationToken ct) { @@ -242,9 +202,7 @@ public sealed class AuthService( 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); diff --git a/src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs b/src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs index ae4e434..6cb8fb1 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs @@ -4,14 +4,8 @@ using Isopoh.Cryptography.Argon2; namespace Deal.Modules.Tenants.Application.Services; /// -/// Реализация на Argon2id (Ruling 5). +/// Реализация на Argon2id. /// -/// -/// Используются дефолты пакета 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 { /// diff --git a/src/core/Deal.Modules.Tenants/Application/Services/InviteCodeGenerator.cs b/src/core/Deal.Modules.Tenants/Application/Services/InviteCodeGenerator.cs index de45231..68baf15 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/InviteCodeGenerator.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/InviteCodeGenerator.cs @@ -4,18 +4,12 @@ using Deal.SharedKernel.Utilities; 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; @@ -23,7 +17,7 @@ public static class InviteCodeGenerator 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 4f94a4a..4f53895 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/InvitesService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/InvitesService.cs @@ -6,25 +6,15 @@ 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; // Проверяемый формат: один '@', непустые локальная часть и домен с точкой, без пробелов. @@ -35,12 +25,11 @@ public sealed partial class InvitesService(IInviteStore inviteStore) private static partial Regex EmailFormatRegex(); /// - /// Создаёт приглашение оператором: нормализация/валидация email, антидубль активного, срок +72 часа. + /// Создаёт приглашение оператором /// /// Идентификатор оператора (CreatedById приглашения). /// Email приглашённого (регистр/пробелы не важны — нормализуется). - /// Целевой тенант; null — при активации будет создан новый тенант (Task 6). - /// Токен отмены. + /// Целевой тенант; null — при активации будет создан новый тенант. /// Ok=true + созданное приглашение, либо код ошибки (текст — HTTP-слой). public async Task CreateInviteAsync( Guid operatorId, @@ -54,7 +43,6 @@ public sealed partial class InvitesService(IInviteStore inviteStore) return new InviteCreateResultDto(Ok: false, Error: InviteCreateResultDto.ErrorInvalidEmail, Invite: null); } - // Антидубль «одно активное приглашение на email» (Ruling 2): pending блокирует новое. Если найденное // pending уже истекло (статус ещё не переведён), сначала помечаем expired — иначе partial unique-индекс // по pending не пустит новую строку; затем создаём новое приглашение. Отозванный/активированный/ // истёкший email свободен (глобальную уникальность регистрации держит unique-индекс users.Login). @@ -86,10 +74,9 @@ public sealed partial class InvitesService(IInviteStore inviteStore) } /// - /// Отзывает приглашение: только pending переводится в revoked; иные статусы не трогаем (Ruling 2). + /// Отзывает приглашение /// /// Код приглашения. - /// Токен отмены. /// Ok=true + отозванное приглашение, либо код ошибки (текст — HTTP-слой). public async Task RevokeAsync(string code, CancellationToken ct) { @@ -115,9 +102,8 @@ public sealed partial class InvitesService(IInviteStore inviteStore) } /// - /// Список приглашений для оператора (новые сверху) с ленивой пометкой истёкших (status → expired сохраняется). + /// Список приглашений для оператора /// - /// Токен отмены. /// Приглашения в порядке CreatedAt DESC; протухшие pending приходят со статусом expired. public async Task> ListAsync(CancellationToken ct) { @@ -140,10 +126,9 @@ public sealed partial class InvitesService(IInviteStore inviteStore) } /// - /// Читает приглашение по коду, вычисляя статус expired при чтении (проверку кода использует /api/join, Task 6). + /// Читает приглашение по коду, вычисляя статус expired при чтении. /// /// Код приглашения. - /// Токен отмены. /// Приглашение (протухшее pending — со статусом expired и сохранённым переходом) или null. public async Task GetByCodeAsync(string code, CancellationToken ct) { @@ -158,26 +143,22 @@ public sealed partial class InvitesService(IInviteStore inviteStore) } /// - /// Активирует приглашение CAS-переходом (Task 6): атомарный pending → activated, ActivatedAt = сейчас. - /// Прокси над — реальное условие на статус исполняет хранилище - /// (условный UPDATE), сервис лишь фиксирует момент активации. Возвращает false, если к моменту обновления - /// приглашение уже не pending (параллельно отозвано/активировано) — вызывающий (JoinService) перечитает статус. + /// Активирует приглашение CAS-переходом /// /// Код приглашения. - /// Токен отмены. /// true, если активация выполнена; false — строка не в статусе pending. public Task TryActivateAsync(string code, CancellationToken ct) => inviteStore.TryActivateAsync(code, DateTimeOffset.UtcNow, ct); /// - /// Нормализация email: обрезка пробелов и нижний регистр (единая форма хранения/сравнения). + /// Нормализация email /// /// Входной email (может быть null). /// Нормализованный email (пустая строка, если вход был пустым). public static string NormalizeEmail(string? email) => (email ?? string.Empty).Trim().ToLowerInvariant(); /// - /// Проверка формата email (санити-уровень): непустой, ≤ , один '@', домен с точкой, без пробелов. + /// Проверка формата email /// /// Входной email (регистр/пробелы не важны). /// true, если email выглядит корректно. diff --git a/src/core/Deal.Modules.Tenants/Application/Services/JoinService.cs b/src/core/Deal.Modules.Tenants/Application/Services/JoinService.cs index 415e0d3..5e282aa 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/JoinService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/JoinService.cs @@ -4,22 +4,8 @@ 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. +/// Прикладной сервис активации инвайта через публичную ручку POST /api/join /// -/// -/// Порядок операций и 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, @@ -31,13 +17,12 @@ public sealed class JoinService( private const string UserActiveStatus = "active"; /// - /// Активирует инвайт: валидация, CAS-резервирование, создание тенанта (при необходимости) и пользователя. + /// Активирует инвайт /// /// Код приглашения (пробелы по краям не важны). /// Email активирующего; обязан совпасть с email приглашения (регистр/пробелы не важны). /// Имя тенанта при создании нового (TenantId инвайта пуст); null/пустое — имя = email. /// Пароль пользователя (открытым текстом; минимум ). - /// Токен отмены. /// При успехе — Ok=true, Login (нормализованный email), UserId и TenantId; иначе код ошибки (см. ). public async Task ActivateAsync( string? code, @@ -49,7 +34,6 @@ public sealed class JoinService( 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) @@ -62,7 +46,6 @@ public sealed class JoinService( return Failed(ErrorFromStatus(invite.Status)); } - // 2. Email обязан совпасть с приглашением (Ruling 2); пустой/иной email не проходит — инвайт не расходуется. if (normalizedEmail != invite.Email) { return Failed(JoinResultDto.ErrorEmailMismatch); @@ -74,7 +57,6 @@ public sealed class JoinService( 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) diff --git a/src/core/Deal.Modules.Tenants/Application/Services/OperatorAuthService.cs b/src/core/Deal.Modules.Tenants/Application/Services/OperatorAuthService.cs index 4d0d5fb..de2a72c 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/OperatorAuthService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/OperatorAuthService.cs @@ -4,19 +4,12 @@ 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; @@ -28,7 +21,6 @@ public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IP /// /// Логин (регистр и пробелы не важны — нормализуется). /// Пароль в открытом виде. - /// Токен отмены. /// При успехе — Login и Token; иначе оба null (текст «Неверный логин или пароль оператора» фиксирует endpoint). public async Task LoginAsync( string login, @@ -52,10 +44,9 @@ public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IP } /// - /// Выход оператора: удаляет сессию по raw-токену, если он передан. + /// Выход оператора /// /// Raw-токен из куки (может отсутствовать — no-op). - /// Токен отмены. public async Task LogoutAsync(string? rawToken, CancellationToken ct) { if (string.IsNullOrWhiteSpace(rawToken)) @@ -67,10 +58,9 @@ public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IP } /// - /// Разрешение операторской сессии по raw-токену: возвращает оператора или null (нет/протухла/оператор не активен). + /// Разрешение операторской сессии по raw-токену /// /// Raw-токен из куки deal_operator_session. - /// Токен отмены. /// Идентичность активного оператора или null. public async Task ResolveSessionAsync(string? rawToken, CancellationToken ct) { @@ -85,7 +75,6 @@ public sealed class OperatorAuthService(IOperatorAuthStore operatorAuthStore, IP 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) diff --git a/src/core/Deal.Modules.Tenants/Application/Services/OperatorBootstrapService.cs b/src/core/Deal.Modules.Tenants/Application/Services/OperatorBootstrapService.cs index 2347050..5a7f2db 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/OperatorBootstrapService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/OperatorBootstrapService.cs @@ -4,49 +4,38 @@ using Deal.Modules.Tenants.Application.Models; namespace Deal.Modules.Tenants.Application.Services; /// -/// Bootstrap оператора при старте (Ruling 1 этапа 7): идемпотентный seed из env DEAL_OPERATOR_*. +/// Bootstrap оператора при старте /// -/// -/// Шаг вызывается хостом при старте (встраивается в 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). + /// Дефолтный логин в Development при отсутствии env-кред. /// public const string DefaultOperatorLogin = "operator"; /// - /// Дефолтный пароль в Development при отсутствии env-кред (Ruling 1). + /// Дефолтный пароль в Development при отсутствии env-кред. /// public const string DefaultOperatorPassword = "operator"; private const string ActiveStatus = "active"; /// - /// Гарантирует наличие оператора: создаёт, если его ещё нет (идемпотентно). + /// Гарантирует наличие оператора /// /// Логин из env () или null/пусто, если не задан. /// Пароль из env () или null/пусто, если не задан. - /// - /// true в Development: при отсутствии кред берутся дефолты operator/operator; - /// false (Production) при отсутствии кред — шаг пропускается, хост логирует warning. - /// - /// Токен отмены. + /// true в Development: при отсутствии кред берутся дефолты operator/operator; false (Production) при отсутствии кред — шаг пропускается, хост логирует warning. /// Логин оператора, присутствующего после шага (созданного или уже существовавшего); null — шаг пропущен. public async Task EnsureOperatorAsync( string? login, diff --git a/src/core/Deal.Modules.Tenants/Application/Services/SessionTokens.cs b/src/core/Deal.Modules.Tenants/Application/Services/SessionTokens.cs index d230926..f956690 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/SessionTokens.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/SessionTokens.cs @@ -6,26 +6,21 @@ using Deal.SharedKernel.Utilities; namespace Deal.Modules.Tenants.Application.Services; /// -/// Токены сессий: генерация raw-токена и его SHA-256-хэша для хранения (Ruling 6). +/// Токены сессий: генерация raw-токена и его SHA-256-хэша для хранения. /// -/// -/// В БД и куке никогда не фигурирует одно и то же: наружу (в куку) отдаётся raw-токен, -/// в хранилище пишется — SHA-256-хэш raw-токена. -/// Генерация url-safe токена — общий (Security review, C36). -/// public static class SessionTokens { // Случайные байты raw-токена (32 → 43 символа Base64Url). private const int RawTokenByteLength = 32; /// - /// Новый raw-токен: 32 случайных байта в Base64Url (без padding, без '+' и '/'). + /// Новый raw-токен /// /// Строка токена длиной 43 символа. public static string NewToken() => UrlSafeToken.New(RawTokenByteLength); /// - /// SHA-256-хэш raw-токена в нижнем регистре (hex) — значение для поиска в БД. + /// SHA-256-хэш raw-токена в нижнем регистре /// /// Raw-токен из (или от клиента). /// 64 hex-символа. diff --git a/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs b/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs index 79c71ff..c836159 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs @@ -5,33 +5,17 @@ using Deal.Modules.Tenants.Application.Models; namespace Deal.Modules.Tenants.Application.Services; /// -/// Детектор подозрительной активности по логам безопасности (§10.5): правила поверх аудита. +/// Детектор подозрительной активности по логам безопасности /// -/// -/// -/// Источник — append-only public.audit_log (), читается за окно [From, To] -/// (по умолчанию — последние часов, не более -/// записей). Реализованные правила: -/// -/// failed_logins_per_ip — всплеск неудачных входов с одного IP; -/// failed_logins_per_login — всплеск неудачных входов по одному логину; -/// many_ips_per_actor — успешные входы одного актора с множества IP (угон сессии/перебор); -/// auth_failures_per_tenant — повторные неудачные входы по тенанту (косвенно 401/429-серия). -/// -/// Пороговые значения — именованные константы; анализ — чистая функция над прочитанными записями, поэтому -/// покрывается юнит-тестами на фейковом хранилище. События 401/429 как таковые в аудит не пишутся (429 — -/// следствие серии неудач) — правило по тенанту агрегирует фактические неудачные входы. -/// -/// public sealed class SuspiciousActivityService { /// - /// Размер окна анализа по умолчанию, часов (сутки). + /// Размер окна анализа по умолчанию, часов /// public const int DefaultWindowHours = 24; /// - /// Предел разбираемых записей за окно (совпадает с лимитом выборки аудита). + /// Предел разбираемых записей за окно /// public const int MaxScanRecords = AuditService.MaxQueryLimit; @@ -79,12 +63,12 @@ public sealed class SuspiciousActivityService public const string KindAuthFailuresPerTenant = "auth_failures_per_tenant"; /// - /// Уровень находки: высокий (серьёзная аномалия). + /// Уровень находки /// public const string SeverityHigh = "high"; /// - /// Уровень находки: средний (стоит посмотреть). + /// Уровень находки /// public const string SeverityMedium = "medium"; @@ -92,7 +76,7 @@ public sealed class SuspiciousActivityService private readonly Func _clock; /// - /// Создаёт детектор с системными часами UTC-«сейчас» (боевая регистрация). + /// Создаёт детектор с системными часами UTC-«сейчас» /// /// Хранилище аудита (append-only лента). public SuspiciousActivityService(IAuditLogStore store) @@ -101,7 +85,7 @@ public sealed class SuspiciousActivityService } /// - /// Создаёт детектор с инъекцией часов (тесты фиксируют окно). + /// Создаёт детектор с инъекцией часов /// /// Хранилище аудита (append-only лента). /// Источник «сейчас». @@ -118,7 +102,6 @@ public sealed class SuspiciousActivityService /// /// Начало окна (включительно); null — «сейчас минус часов». /// Конец окна (включительно); null — «сейчас». - /// Токен отмены. /// Сводка: окно, число разобранных записей, признак усечения и список находок. public async Task AnalyzeAsync( DateTimeOffset? from, diff --git a/src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs b/src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs index eae1f82..9f2023f 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs @@ -6,19 +6,8 @@ using Deal.SharedKernel.Utilities; 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, @@ -29,17 +18,11 @@ public sealed class TenantAdminService( private const int InitialPasswordRandomByteCount = 12; /// - /// Создаёт тенанта оператором (план Task 7): строка реестра (Status active) + провижининг схемы - /// (); при email — сразу пользователь-владелец с одноразовым паролем. + /// Создаёт тенанта оператором /// /// Имя тенанта (обязательно; обрезается). - /// Email владельца (опционально): создаёт пользователя-владельца сразу, иначе владелец - /// заводится инвайтом (Ruling 2). - /// Токен отмены. - /// - /// Ok=true — тенант создан (Tenant); при email дополнительно OwnerUserId/OwnerLogin/InitialPassword. - /// Ok=false — код ошибки (имя пусто, email невалиден/уже зарегистрирован); текст — HTTP-слой. - /// + /// Email владельца (опционально): создаёт пользователя-владельца сразу, иначе владелец заводится инвайтом. + /// Ok=true — тенант создан (Tenant); при email дополнительно OwnerUserId/OwnerLogin/InitialPassword. Ok=false — код ошибки (имя пусто, email невалиден/уже зарегистрирован); текст — HTTP-слой. public async Task CreateAsync( string? name, string? email, @@ -60,8 +43,6 @@ public sealed class TenantAdminService( 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) { @@ -102,9 +83,8 @@ public sealed class TenantAdminService( } /// - /// Список тенантов со счётчиками пользователей (реестр + счётчики, план Task 7). + /// Список тенантов со счётчиками пользователей. /// - /// Токен отмены. /// Тенанты в порядке создания (реестр) с числом пользователей каждого. public async Task> ListAsync(CancellationToken ct) { @@ -120,10 +100,9 @@ public sealed class TenantAdminService( } /// - /// Детали тенанта с пользователями (GET /api/operator/tenants/{id}). + /// Детали тенанта с пользователями /// /// Идентификатор тенанта. - /// Токен отмены. /// Детали и пользователи тенанта (по CreatedAt) или null, если тенанта нет. public async Task GetAsync(Guid id, CancellationToken ct) { @@ -138,11 +117,10 @@ public sealed class TenantAdminService( } /// - /// Меняет статус тенанта (POST …/suspend → suspended, …/unsuspend → active; аудит пишет HTTP-слой). + /// Меняет статус тенанта /// /// Идентификатор тенанта. /// Новый статус — константа TenantStatuses. - /// Токен отмены. /// Результат: тенант не найден (404) или применение с признаком реального изменения (Changed). public async Task ChangeStatusAsync( Guid id, diff --git a/src/core/Deal.Modules.Tenants/Application/Services/TenantService.cs b/src/core/Deal.Modules.Tenants/Application/Services/TenantService.cs index 261410b..2a2794c 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/TenantService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/TenantService.cs @@ -5,27 +5,24 @@ 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>). + /// Идентификатор созданного тенанта (он же имя схемы tenant_<id>). public Task CreateTenantAsync(string name, CancellationToken ct) => CreateTenantAsync(name, Guid.NewGuid(), ct); /// - /// Создаёт тенанта с явным id и провижинит его схему (bootstrap дефолтного тенанта, Ruling 8). + /// Создаёт тенанта с явным id и провижинит его схему. /// /// Имя тенанта. /// Идентификатор тенанта (определяет имя схемы). - /// Токен отмены. - /// Идентификатор созданного тенанта (он же имя схемы tenant_<id>). + /// Идентификатор созданного тенанта (он же имя схемы tenant_<id>). public async Task CreateTenantAsync( string name, Guid id, @@ -47,7 +44,6 @@ public sealed class TenantService(ITenantRepository tenantRepository, ITenantPro /// /// Возвращает список всех тенантов. /// - /// Токен отмены. /// Список тенантов. 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 2134fa2..0d8dc17 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/TokenBudgetService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/TokenBudgetService.cs @@ -3,20 +3,12 @@ using Deal.Modules.Tenants.Application.Models; namespace Deal.Modules.Tenants.Application.Services; /// -/// Период-математика лимитов ИИ-бюджета (Task 8, Ruling 3 этапа 7): конец окна периода для ленивого -/// reset и пороги 80/100% для флагов Warned80/NotifiedExhausted. +/// Период-математика лимитов ИИ-бюджета /// -/// -/// Класс без состояния (детерминированные функции от аргументов) — безопасен для scoped/singleton-регистрации -/// (Task 9) и для прямого создания в адаптерах/тестах. Reset ленивый (Ruling 3): при чтении/записи, если -/// сейчас ≥ конца периода (PeriodStart+месяц/сутки), UsedTokens и флаги обнуляются и PeriodStart=now; -/// отдельного фонового цикла нет. Порог 80% считается целочисленно: budget - budget/5 = floor(0.8·budget) -/// — без double и переполнения long. -/// public sealed class TokenBudgetService { /// - /// Проверяет, что период истёк (ленивый reset, Ruling 3): сейчас ≥ конца окна от PeriodStart. + /// Проверяет, что период истёк /// /// Начало текущего периода. /// Тип периода (; любое иное значение трактуется как месяц). @@ -34,7 +26,7 @@ public sealed class TokenBudgetService } /// - /// Проверяет порог 80% бюджета (Ruling 3: тост «ИИ-бюджет израсходован на 80%», флаг Warned80). + /// Проверяет порог 80% бюджета. /// /// Использовано токенов с начала периода. /// Бюджет периода. @@ -45,7 +37,7 @@ public sealed class TokenBudgetService } /// - /// Проверяет исчерпание бюджета (Ruling 3: «ИИ-бюджет исчерпан», флаг NotifiedExhausted; гейт Task 9). + /// Проверяет исчерпание бюджета. /// /// Использовано токенов с начала периода. /// Бюджет периода. @@ -56,7 +48,7 @@ public sealed class TokenBudgetService } /// - /// Остаток бюджета до исчерпания (≥0; операторская сводка usage, Task 10). + /// Остаток бюджета до исчерпания. /// /// Использовано токенов с начала периода. /// Бюджет периода. diff --git a/src/core/Deal.Modules.Tenants/Application/Services/TokenUsageEventService.cs b/src/core/Deal.Modules.Tenants/Application/Services/TokenUsageEventService.cs index 7d1334f..9a3377d 100644 --- a/src/core/Deal.Modules.Tenants/Application/Services/TokenUsageEventService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/TokenUsageEventService.cs @@ -4,29 +4,23 @@ using Deal.Modules.Tenants.Application.Models; namespace Deal.Modules.Tenants.Application.Services; /// -/// Прикладной сервис истории расхода токенов (этап 10, T2): единая точка записи и чтения агрегатов. +/// Прикладной сервис истории расхода токенов /// -/// -/// Запись — append-only через ; At=UTC-now проставляется здесь -/// (как у ). Чтение агрегатов — для операторской аналитики (read-only). -/// public sealed class TokenUsageEventService(ITokenUsageEventStore store) { /// - /// Записывает событие расхода токенов (append-only; At = сейчас, UTC). + /// Записывает событие расхода токенов /// /// Событие (At перезаписывается сервисом). - /// Токен отмены. public async Task AppendAsync(TokenUsageEventDto record, CancellationToken ct) { await store.AppendAsync(record with { At = DateTimeOffset.UtcNow }, ct); } /// - /// Агрегаты расхода по фильтру и группировке (прокси порта; чтение — аналитика оператора). + /// Агрегаты расхода по фильтру и группировке /// /// Фильтр/группировка. - /// Токен отмены. /// Строки агрегатов. public Task> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct) => store.AggregateAsync(query, ct); diff --git a/src/core/Deal.Modules.Tenants/TenantsModuleMarker.cs b/src/core/Deal.Modules.Tenants/TenantsModuleMarker.cs index e294895..4f80bb8 100644 --- a/src/core/Deal.Modules.Tenants/TenantsModuleMarker.cs +++ b/src/core/Deal.Modules.Tenants/TenantsModuleMarker.cs @@ -1,7 +1,7 @@ namespace Deal.Modules.Tenants; /// -/// Маркер модуля Tenants: используется для DI-сканирования и тестов. +/// Маркер модуля Tenants /// public sealed class TenantsModuleMarker { diff --git a/src/core/Deal.SharedKernel/Observability/DealMetrics.cs b/src/core/Deal.SharedKernel/Observability/DealMetrics.cs index 3e6fba9..0d480eb 100644 --- a/src/core/Deal.SharedKernel/Observability/DealMetrics.cs +++ b/src/core/Deal.SharedKernel/Observability/DealMetrics.cs @@ -4,31 +4,17 @@ using System.Diagnostics.Metrics; namespace Deal.SharedKernel.Observability; /// -/// Прикладные метрики Deal (этап 12, пакет A): счётчики вызовов/токенов AI и ML, событий аудита и -/// gauge'и очередей/активных сессий. Экспортируются процессом-владельцем meter'а -/// (AddMeter()) через эндпоинт /metrics в формате Prometheus. +/// Прикладные метрики Deal /// -/// -/// -/// Инструменты статические: прикладные точки инкремента (TokenUsageRecorder, AuditService) живут в -/// разных модулях/слоях и не должны тянуть DI-обёртку метрик — один общий meter на процесс. Метки -/// сознательно низкокардинальные: никаких tenantId/userId/cardId (правило «без персональных данных в -/// метках»); типы токенов (prompt/completion), тип/актор события аудита — ограниченные множества. -/// -/// -/// Gauge-значения (глубина очередей, активные сессии) обновляет фоновый сборщик ядра -/// (Deal.Api/Observability/DealMetricsCollector) — callback ObservableGauge читает последнее значение. -/// -/// public static class DealMetrics { /// - /// Имя meter'а прикладных метрик (общий префикс метрик deal.*). + /// Имя meter'а прикладных метрик /// public const string MeterName = "Deal"; /// - /// Имя метки вида токенов (значения — /). + /// Имя метки вида токенов /// public const string TokenTypeTagName = "type"; @@ -43,12 +29,12 @@ public static class DealMetrics public const string TokenTypeCompletion = "completion"; /// - /// Имя метки типа события аудита (значения — константы AuditEvents). + /// Имя метки типа события аудита /// public const string AuditEventTagName = "event"; /// - /// Имя метки типа актора события аудита (tenant/operator/system). + /// Имя метки типа актора события аудита /// public const string AuditActorTagName = "actor"; @@ -56,37 +42,37 @@ public static class DealMetrics private static readonly Meter Meter = new(MeterName); /// - /// Успешные вызовы платного ИИ (инкремент — TokenUsageRecorder.AddAsync). + /// Успешные вызовы платного ИИ /// public static readonly Counter AiCalls = Meter.CreateCounter("deal.ai.calls", description: "Успешные вызовы платного ИИ (ai-service)."); /// - /// Токены платного ИИ по видам (метка : prompt/completion). + /// Токены платного ИИ по видам /// public static readonly Counter AiTokens = Meter.CreateCounter("deal.ai.tokens", description: "Токены платных ИИ-вызовов (prompt/completion)."); /// - /// Вызовы локального ML (инкремент — TokenUsageRecorder.AddEstimatedAsync). + /// Вызовы локального ML /// public static readonly Counter MlCalls = Meter.CreateCounter("deal.ml.calls", description: "Вызовы локального ML-предсказания."); /// - /// Оценка токенов локальных ML-вызовов (метка : prompt). + /// Оценка токенов локальных ML-вызовов /// public static readonly Counter MlTokens = Meter.CreateCounter("deal.ml.tokens", description: "Оценка токенов локальных ML-вызовов."); /// - /// События аудита по типам и акторам (метки /). + /// События аудита по типам и акторам /// public static readonly Counter AuditEvents = Meter.CreateCounter("deal.audit.events", description: "Записи аудита по типам и акторам."); /// - /// Суммарная глубина очереди пайплайна (new+filtered) по всем тенантам. + /// Суммарная глубина очереди пайплайна /// public static readonly ObservableGauge PipelineQueueDepth = Meter.CreateObservableGauge( @@ -95,7 +81,7 @@ public static class DealMetrics description: "Суммарная глубина очереди пайплайна (new+filtered) по всем тенантам."); /// - /// Суммарная глубина очереди обучения ML (MlOutbox) по всем тенантам. + /// Суммарная глубина очереди обучения ML /// public static readonly ObservableGauge MlOutboxDepth = Meter.CreateObservableGauge( @@ -104,7 +90,7 @@ public static class DealMetrics description: "Суммарная глубина очереди обучения ML (MlOutbox) по всем тенантам."); /// - /// Число активных сессий (пользователи тенантов + операторы, срок действия не истёк). + /// Число активных сессий /// public static readonly ObservableGauge ActiveSessions = Meter.CreateObservableGauge( @@ -117,7 +103,7 @@ public static class DealMetrics private static long _activeSessions; /// - /// Записывает успешный платный ИИ-вызов: счётчик вызовов + токены запроса/ответа. + /// Записывает успешный платный ИИ-вызов /// /// Токены запроса (неотрицательные). /// Токены ответа (неотрицательные). @@ -136,7 +122,7 @@ public static class DealMetrics } /// - /// Записывает вызов локального ML: счётчик вызовов + оценка токенов запроса. + /// Записывает вызов локального ML /// /// Оценка токенов входного текста (неотрицательная). public static void RecordMlUsage(long promptTokens) @@ -149,7 +135,7 @@ public static class DealMetrics } /// - /// Записывает событие аудита по типу и актору (низкокардинальные метки). + /// Записывает событие аудита по типу и актору /// /// Тип события (константа AuditEvents). /// Тип актора (tenant/operator/system). @@ -161,19 +147,19 @@ public static class DealMetrics } /// - /// Публикует глубину очереди пайплайна (вызывает фоновый сборщик ядра). + /// Публикует глубину очереди пайплайна /// /// Суммарная глубина (new+filtered) по всем тенантам. public static void SetPipelineQueueDepth(long depth) => Interlocked.Exchange(ref _pipelineQueueDepth, depth); /// - /// Публикует глубину очереди обучения ML (вызывает фоновый сборщик ядра). + /// Публикует глубину очереди обучения ML /// /// Суммарная глубина MlOutbox по всем тенантам. public static void SetMlOutboxDepth(long depth) => Interlocked.Exchange(ref _mlOutboxDepth, depth); /// - /// Публикует число активных сессий (вызывает фоновый сборщик ядра). + /// Публикует число активных сессий /// /// Активные непросроченные сессии пользователей и операторов. public static void SetActiveSessions(long count) => Interlocked.Exchange(ref _activeSessions, count); diff --git a/src/core/Deal.SharedKernel/SharedKernelMarker.cs b/src/core/Deal.SharedKernel/SharedKernelMarker.cs index 399175c..7488bbf 100644 --- a/src/core/Deal.SharedKernel/SharedKernelMarker.cs +++ b/src/core/Deal.SharedKernel/SharedKernelMarker.cs @@ -3,7 +3,7 @@ using Deal.SharedKernel.Utilities; namespace Deal.SharedKernel; /// -/// Маркер слоя SharedKernel: используется для DI-сканирования и тестов. +/// Маркер слоя SharedKernel /// public sealed class SharedKernelMarker { diff --git a/src/core/Deal.SharedKernel/Tenants/Abstractions/ITenantContext.cs b/src/core/Deal.SharedKernel/Tenants/Abstractions/ITenantContext.cs index e228890..e4e8174 100644 --- a/src/core/Deal.SharedKernel/Tenants/Abstractions/ITenantContext.cs +++ b/src/core/Deal.SharedKernel/Tenants/Abstractions/ITenantContext.cs @@ -5,11 +5,6 @@ namespace Deal.SharedKernel.Tenants.Abstractions; /// /// Контекст текущего тенанта запроса. /// -/// -/// Значение хранится в AsyncLocal и распространяется на весь запрос. Middleware сессии -/// (Deal.Api) устанавливает тенанта по данным сессии и сбрасывает в finally — см. -/// . -/// public interface ITenantContext { public TenantId? TenantId { get; } @@ -17,7 +12,7 @@ public interface ITenantContext public bool HasTenant { get; } /// - /// Имя схемы текущего тенанта или null для системного контекста (public). + /// Имя схемы текущего тенанта или null для системного контекста /// public string? SchemaName { get; } @@ -28,7 +23,7 @@ public interface ITenantContext public void SetTenant(TenantId tenantId); /// - /// Сбрасывает контекст в системный (public) — вызывается по завершении запроса. + /// Сбрасывает контекст в системный /// public void Reset(); } diff --git a/src/core/Deal.SharedKernel/Tenants/Models/TenantId.cs b/src/core/Deal.SharedKernel/Tenants/Models/TenantId.cs index 948b1c3..9b7e22c 100644 --- a/src/core/Deal.SharedKernel/Tenants/Models/TenantId.cs +++ b/src/core/Deal.SharedKernel/Tenants/Models/TenantId.cs @@ -3,14 +3,12 @@ using Deal.SharedKernel.Utilities; namespace Deal.SharedKernel.Tenants.Models; /// -/// Идентификатор тенанта. Инвариант: ровно 32 hex-символа (Guid в формате "N" без дефисов) — значение -/// подставляется в Search Path строки подключения и в DDL-идентификаторы схемы, поэтому произвольная -/// строка здесь недопустима (connection-string/DDL-инъекция). +/// Идентификатор тенанта. /// public readonly record struct TenantId { /// - /// Значение идентификатора: 32 hex-символа (формат Guid "N"). + /// Значение идентификатора /// public string Value { get; } @@ -37,7 +35,7 @@ public readonly record struct TenantId public static TenantId FromGuid(Guid id) => new(id.ToString("N")); /// - /// True — строка является валидным значением TenantId (32 hex-символа). + /// True — строка является валидным значением TenantId /// /// Проверяемая строка. /// True — значение пригодно для TenantId. diff --git a/src/core/Deal.SharedKernel/Utilities/UrlSafeToken.cs b/src/core/Deal.SharedKernel/Utilities/UrlSafeToken.cs index 018e1f2..767f7a3 100644 --- a/src/core/Deal.SharedKernel/Utilities/UrlSafeToken.cs +++ b/src/core/Deal.SharedKernel/Utilities/UrlSafeToken.cs @@ -5,14 +5,12 @@ using Deal.SharedKernel.Utilities; namespace Deal.SharedKernel.Utilities; /// -/// Криптостойкие url-safe токены (Base64Url без padding) для одноразовых секретов: raw-токены сессий, -/// коды приглашений, одноразовые пароли. Единая реализация — раньше генерация дублировалась в -/// SessionTokens/InviteCodeGenerator/TenantAdminService (Security review, C36). +/// Криптостойкие url-safe токены /// public static class UrlSafeToken { /// - /// Новый токен: случайных байт в Base64Url (без '=' padding, '+' и '/'). + /// Новый токен: случайных байт в Base64Url /// /// Число случайных байт (≥ 1). /// Строка длиной ceil(byteCount·4/3) без padding. diff --git a/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs index 3120757..2c24114 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/DataRetentionSchedulerTests.cs @@ -9,16 +9,8 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Deal.Tests.Unit.Api; /// -/// Тесты логики прохода (этап 12, пакет B): авто-очистка -/// audit_log по retention, сброс накопительных полей tenant_limits прошедших периодов и уборка -/// завершившихся окон распределённых счётчиков. Тайминги цикла не тестируются — итерация через публичный -/// . +/// Тесты логики прохода /// -/// -/// Скоупы/DI — реальный ServiceCollection с фейками хранилищ (, -/// , ), зеркалящими семантику -/// EF-адаптеров. Срок хранения аудита — . -/// public sealed class DataRetentionSchedulerTests { // Тенант сценария (строка лимита). @@ -64,7 +56,7 @@ public sealed class DataRetentionSchedulerTests } /// - /// Повторный проход на тех же данных — идемпотентен (чистить больше нечего). + /// Повторный проход на тех же данных — идемпотентен /// [Fact] public async Task RunCycle_IsIdempotent() diff --git a/src/core/tests/Deal.Tests.Unit/Api/DiscoveryEndpointsHelpersTests.cs b/src/core/tests/Deal.Tests.Unit/Api/DiscoveryEndpointsHelpersTests.cs index c581cc0..a88aa16 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/DiscoveryEndpointsHelpersTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/DiscoveryEndpointsHelpersTests.cs @@ -3,13 +3,12 @@ using Deal.Api.Endpoints; namespace Deal.Tests.Unit.Api; /// -/// Тесты чистых хелперов эндпоинтов /api/discovery (план Task 19, Ruling 11): очистка ключей -/// ответа ИИ (python _clean_keywords discovery_routes.py L111–128). +/// Тесты чистых хелперов эндпоинтов /api/discovery /// public sealed class DiscoveryEndpointsHelpersTests { /// - /// Null/пустой ответ — пустой список (python L115: raw or []). + /// Null/пустой ответ — пустой список. /// [Fact] public void CleanKeywords_NullOrEmpty_ReturnsEmptyList() @@ -19,7 +18,7 @@ public sealed class DiscoveryEndpointsHelpersTests } /// - /// Строки тримятся; пустые/пробельные и не-строки отбрасываются (python L117–120). + /// Строки тримятся; пустые/пробельные и не-строки отбрасываются. /// [Fact] public void CleanKeywords_TrimsAndDropsEmpty() @@ -31,7 +30,7 @@ public sealed class DiscoveryEndpointsHelpersTests } /// - /// Ключ длиннее 60 символов отбрасывается (python L120: len(keyword) > _KEYWORD_LENGTH_LIMIT). + /// Ключ длиннее 60 символов отбрасывается /// [Fact] public void CleanKeywords_DropsOverlongKeyword() @@ -44,7 +43,7 @@ public sealed class DiscoveryEndpointsHelpersTests } /// - /// Повторы (casefold) схлопываются, сохраняется первое вхождение (python L121–126). + /// Повторы (casefold) схлопываются, сохраняется первое вхождение. /// [Fact] public void CleanKeywords_DeduplicatesCaseInsensitiveKeepingFirst() @@ -55,7 +54,7 @@ public sealed class DiscoveryEndpointsHelpersTests } /// - /// Страховочный потолок — 30 ключей (python L127–128: break на лимите). + /// Страховочный потолок — 30 ключей. /// [Fact] public void CleanKeywords_CapsAtThirty() diff --git a/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs index 9d0eda8..bbfec38 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs @@ -21,17 +21,8 @@ using Microsoft.Extensions.Logging; namespace Deal.Tests.Unit.Api; /// -/// Тесты DiscoveryWorkerScheduler — фоновый цикл воркера Discovery (план Task 18, Ruling 10; эталон -/// PipelineWorkerSchedulerTests): каждые 5 с обход ВСЕХ тенантов реестра, на каждый — собственный scope с -/// ITenantContext и один тик DiscoveryWorkerService. +/// Тесты DiscoveryWorkerScheduler — фоновый цикл воркера Discovery /// -/// -/// Тайминги цикла (Timer 5 с, первый проход, stop) не тестируются — тестируется тело прохода RunCycleAsync -/// (как PipelineWorkerSchedulerTests). Провайдер собирает РЕАЛЬНЫЕ сервисы модуля Discovery (AddDiscoveryModule) -/// на тенант-фейках (FakeDiscoveryStore/FakeSettingsStore/FakeDiscoveryGateway по ITenantContext): воркер ходит -/// тем же путём, что и в проде (SetTenant → scoped-резолв → TickOnce). ML/ИИ в настройках тенантов выключены — -/// оценка идёт эвристикой по ключам. -/// public sealed class DiscoveryWorkerSchedulerTests { // Тенант A теста. diff --git a/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs b/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs index 36ade9e..ec88020 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs @@ -15,14 +15,8 @@ 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 лимитом не режется. +/// In-proc gRPC-тесты IngressRateLimitInterceptor /// -/// -/// Хост — TelegramIngressTestHost с Enabled-опциями (добавляет интерцептор, общий singleton-лимитер -/// и grpc.health.v1, как в Program.cs). Все вызовы несут service-token сценария. -/// public sealed class IngressRateLimitInterceptorTests { // Токен сценариев теста. @@ -46,8 +40,7 @@ public sealed class IngressRateLimitInterceptorTests // ─── Окно интерцептора ───────────────────────────────────────────────── /// - /// 3-й вызов тенанта в минуту (окно 2/мин) — RPC RESOURCE_EXHAUSTED до метода сервиса; - /// другой тенант имеет собственное окно и проходит (партиция по tenant-id). + /// 3-й вызов тенанта в минуту /// [Fact] public async Task PushMessage_ExceedingTenantWindow_ThirdRejectedOtherTenantPasses() @@ -75,7 +68,7 @@ public sealed class IngressRateLimitInterceptorTests } /// - /// Health (grpc.health.v1) освобождён от лимита: при исчерпанном окне ингресса health отвечает SERVING. + /// Health (grpc.health.v1) освобождён от лимита /// [Fact] public async Task HealthCheck_IsNotRateLimited_WhenIngressWindowExhausted() @@ -91,7 +84,6 @@ public sealed class IngressRateLimitInterceptorTests 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 }, diff --git a/src/core/tests/Deal.Tests.Unit/Api/LoginAttemptGuardTests.cs b/src/core/tests/Deal.Tests.Unit/Api/LoginAttemptGuardTests.cs index cdf2b9a..2c83f0e 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/LoginAttemptGuardTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/LoginAttemptGuardTests.cs @@ -5,15 +5,8 @@ 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/тесты) гвард выключен. +/// Unit-тесты LoginAttemptGuard /// -/// -/// Хранилище — (семантика public.rate_limit_counters); часы — -/// инъекцией Func<DateTimeOffset>: границы окна тестируются на фиксированном «сейчас» без ожидания. -/// public sealed class LoginAttemptGuardTests { // IP клиента сценариев. @@ -31,7 +24,7 @@ public sealed class LoginAttemptGuardTests // ─── Порог блокировки ────────────────────────────────────────────────── /// - /// 5 неудач подряд в окне — следующий вход ключа блокирован (Ruling 5: ≥5 → блок). + /// 5 неудач подряд в окне — следующий вход ключа блокирован. /// [Fact] public async Task FiveFailuresWithinWindow_BlockNextAttempt() @@ -48,7 +41,7 @@ public sealed class LoginAttemptGuardTests } /// - /// 4 неудачи — порог не достигнут: попытка ещё разрешена. + /// 4 неудачи — порог не достигнут /// [Fact] public async Task FourFailuresWithinWindow_AreNotBlocked() @@ -64,10 +57,9 @@ public sealed class LoginAttemptGuardTests Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None)); } - // ─── Сброс при успехе (Ruling 5) ─────────────────────────────────────── /// - /// Успешный вход сбрасывает счётчик: после части неудач и Reset ключ снова проходит полные 5. + /// Успешный вход сбрасывает счётчик /// [Fact] public async Task SuccessReset_ClearsFailures_AndWindowStartsAnew() @@ -95,7 +87,7 @@ public sealed class LoginAttemptGuardTests } /// - /// Уже заблокированный ключ разблокируется успешным входом (сброс снимает блок). + /// Уже заблокированный ключ разблокируется успешным входом /// [Fact] public async Task SuccessReset_UnblocksBlockedKey() @@ -168,7 +160,7 @@ public sealed class LoginAttemptGuardTests // ─── Флаг Enabled (dev/тесты) ────────────────────────────────────────── /// - /// RateLimit:Enabled=false (дефолт dev/тестов) — гвард выключен: неудачи не копятся, блокировок нет. + /// RateLimit:Enabled=false /// [Fact] public async Task DisabledByDefaultInDev_DoesNotCountOrBlock() @@ -184,7 +176,7 @@ public sealed class LoginAttemptGuardTests } /// - /// Пустой/пробельный логин ключа не имеет — блокировке не подлежит (нет ключа ip|""). + /// Пустой/пробельный логин ключа не имеет — блокировке не подлежит /// [Fact] public async Task EmptyOrWhitespaceLogin_IsNeverBlocked() diff --git a/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs index 7cd6d89..d5d13c7 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/MlOutboxFlushSchedulerTests.cs @@ -23,18 +23,8 @@ 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 на реальном канале к фейк-серверу), удаление строк -/// ТОЛЬКО после успеха; при недоступности сервиса строки остаются (ретрай на следующем цикле). +/// Тесты MlOutboxFlushScheduler — логика прохода цикла выгрузки обучения ML /// -/// -/// Тайминги цикла (Timer 10 с) не тестируются — тестируется итерация через публичный -/// . DI-провайдер поднимается на реальном ServiceCollection: -/// ITenantRepository — фейк, IMlLearningStore/ISettingsStore выбираются по текущему ITenantContext (как -/// реальные адаптеры, строящие TenantDbContext от схемы тенанта); IMlTrainClient — реальный GrpcMlClient -/// к in-proc фейк-ml-service (проверяются metadata/маппинг TrainBatch «по проводу»). Сеть наружу не используется. -/// [Collection("MlGrpcTests")] public sealed class MlOutboxFlushSchedulerTests { @@ -141,7 +131,6 @@ public sealed class MlOutboxFlushSchedulerTests 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)); }); @@ -175,7 +164,6 @@ public sealed class MlOutboxFlushSchedulerTests // 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(); diff --git a/src/core/tests/Deal.Tests.Unit/Api/OperatorAuditEndpointsHelpersTests.cs b/src/core/tests/Deal.Tests.Unit/Api/OperatorAuditEndpointsHelpersTests.cs index d433177..40b09c6 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/OperatorAuditEndpointsHelpersTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/OperatorAuditEndpointsHelpersTests.cs @@ -4,7 +4,7 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit.Api; /// -/// Тесты хелпера фильтров эндпоинта GET /api/operator/audit (Task 4, Ruling 4): нормализация limit. +/// Тесты хелпера фильтров эндпоинта GET /api/operator/audit /// public sealed class OperatorAuditEndpointsHelpersTests { diff --git a/src/core/tests/Deal.Tests.Unit/Api/OperatorBootstrapHostedServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Api/OperatorBootstrapHostedServiceTests.cs index 6daa436..f406f27 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/OperatorBootstrapHostedServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/OperatorBootstrapHostedServiceTests.cs @@ -11,14 +11,8 @@ 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). +/// Тесты hosted-шага bootstrap оператора /// -/// -/// Сам шаг (создание/идемпотентность/нормализация) покрыт OperatorBootstrapServiceTests (Task 2); здесь -/// проверяется встраивание в старт: чтение env, dev-дефолт, пропуск в Production и тексты предупреждений. -/// Секреты (пароли) в логах не появляются — проверяется на сценарии с явными кредами. -/// public sealed class OperatorBootstrapHostedServiceTests { private const string DevelopmentEnvironmentName = "Development"; @@ -49,7 +43,6 @@ public sealed class OperatorBootstrapHostedServiceTests 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); diff --git a/src/core/tests/Deal.Tests.Unit/Api/OperatorCookieOptionsTests.cs b/src/core/tests/Deal.Tests.Unit/Api/OperatorCookieOptionsTests.cs index 969f0d4..9ecd5af 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/OperatorCookieOptionsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/OperatorCookieOptionsTests.cs @@ -4,8 +4,7 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit.Api; /// -/// Юнит-тесты : имя операторской куки не пересекается с тенантной -/// deal_session, а срок жизни по умолчанию ссылается на единый источник — 12 часов (Ruling 1 этапа 7). +/// Юнит-тесты /// public sealed class OperatorCookieOptionsTests { diff --git a/src/core/tests/Deal.Tests.Unit/Api/OperatorLimitsEndpointsHelpersTests.cs b/src/core/tests/Deal.Tests.Unit/Api/OperatorLimitsEndpointsHelpersTests.cs index 5f9a20a..257c275 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/OperatorLimitsEndpointsHelpersTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/OperatorLimitsEndpointsHelpersTests.cs @@ -3,7 +3,7 @@ using Deal.Api.Endpoints; namespace Deal.Tests.Unit.Api; /// -/// Тесты хелперов операторских ручек лимитов (план Task 10): процент расхода бюджета (0..100). +/// Тесты хелперов операторских ручек лимитов /// public sealed class OperatorLimitsEndpointsHelpersTests { @@ -34,7 +34,6 @@ public sealed class OperatorLimitsEndpointsHelpersTests [InlineData(5, 0)] public void CalculatePercent_ZeroBudget_Returns100(long used, long budget) { - // Бюджет 0 = «ИИ запрещён» (Ruling 3): лимит трактуется исчерпанным — показываем 100%. Assert.Equal(100, OperatorLimitsEndpoints.CalculatePercent(used, budget)); } } diff --git a/src/core/tests/Deal.Tests.Unit/Api/PipelinePumpGateTests.cs b/src/core/tests/Deal.Tests.Unit/Api/PipelinePumpGateTests.cs index ce1450c..0bfb25a 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/PipelinePumpGateTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/PipelinePumpGateTests.cs @@ -3,16 +3,8 @@ using Deal.Api.Services; namespace Deal.Tests.Unit.Api; /// -/// Тесты PipelinePumpGate — общий воркер-гейт pump тенанта (план Task 11, Ruling 8; аналог -/// asyncio.Lock pipeline.py L38–42): admin/tick и фоновый цикл не разбирают очередь одного тенанта -/// одновременно, разные тенанты не связаны. +/// Тесты PipelinePumpGate — общий воркер-гейт pump тенанта /// -/// -/// Семантика — как прототип pump_once L901–902: занятый pump тенанта → повторный вход (TryEnter) даёт -/// false, вызывающий пропускает проход (очередь дождётся следующего срабатывания). Exit освобождает -/// гейт; гейт потокобезопасен (ConcurrentDictionary + атомарный TryAdd), параллельные входы одного -/// тенанта выигрывает ровно один. -/// public sealed class PipelinePumpGateTests { // Тенант A теста. diff --git a/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs b/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs index 533598b..49ae001 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs @@ -17,13 +17,8 @@ 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"); diff --git a/src/core/tests/Deal.Tests.Unit/Api/TelegramEndpointsMappingTests.cs b/src/core/tests/Deal.Tests.Unit/Api/TelegramEndpointsMappingTests.cs index 9b2dee1..e0c5c48 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/TelegramEndpointsMappingTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/TelegramEndpointsMappingTests.cs @@ -4,13 +4,12 @@ using Grpc.Core; namespace Deal.Tests.Unit.Api; /// -/// Тесты чистых хелперов эндпоинтов /api/tg (план Task 14): RU-маппинг типа диалога на границе -/// (заметка Task 1, api-map §4.8 L349) и текст причины ошибки гейта для {detail} (Ruling 7/8). +/// Тесты чистых хелперов эндпоинтов /api/tg /// public sealed class TelegramEndpointsMappingTests { /// - /// EN-канон каталога → русская подпись вкладки «Каналы» (channel/group/forum/chat, python L461–466). + /// EN-канон каталога → русская подпись вкладки «Каналы». /// [Theory] [InlineData("channel", "канал")] @@ -23,7 +22,7 @@ public sealed class TelegramEndpointsMappingTests } /// - /// Неизвестная подпись (например, уже русская из discovery-вступлений) проходит как есть. + /// Неизвестная подпись /// [Theory] [InlineData("канал")] @@ -35,7 +34,7 @@ public sealed class TelegramEndpointsMappingTests } /// - /// Доменная RPC-ошибка отдаёт канонический detail telegram-service (текст причины 1:1). + /// Доменная RPC-ошибка отдаёт канонический detail telegram-service. /// [Fact] public void GatewayErrorText_RpcDomainError_ReturnsServiceDetail() @@ -46,7 +45,7 @@ public sealed class TelegramEndpointsMappingTests } /// - /// Транспортный сбой/неизвестное исключение → «Telegram не подключён» (Ruling 7). + /// Транспортный сбой/неизвестное исключение → «Telegram не подключён». /// [Theory] [InlineData(StatusCode.Unavailable)] // сервис недоступен (detail пуст) diff --git a/src/core/tests/Deal.Tests.Unit/Api/TelegramKeysServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Api/TelegramKeysServiceTests.cs index 209fefa..d09d566 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/TelegramKeysServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/TelegramKeysServiceTests.cs @@ -6,13 +6,8 @@ using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Api; /// -/// Тесты сервиса глобальных ключей Telegram (ТЗ §4.1/§8.1): шифрование/маскирование, валидация, -/// чтение расшифрованного снимка ядром. +/// Тесты сервиса глобальных ключей Telegram /// -/// -/// Прогон на фейках (FakeGlobalSettingsStore + FakeSecretCipher): проверяется, что apiHash уходит в -/// хранилище только в enc:-форме, наружу отдаётся маска, а apiId — открыт (не секрет). -/// public sealed class TelegramKeysServiceTests { private readonly FakeGlobalSettingsStore _store = new(); diff --git a/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs index 049e247..5b03bdd 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs @@ -12,18 +12,12 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Deal.Tests.Unit.Api; /// -/// Тесты сборки статуса вкладки Telegram — GET /api/tg/status (Ruling 8, api-map §4.9; план Task 14). +/// Тесты сборки статуса вкладки Telegram — GET /api/tg/status. /// -/// -/// Сервис чистый: 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() @@ -43,7 +37,7 @@ public sealed class TgStatusServiceTests } /// - /// Сервис недоступен (RPC-отказ) → idle live-поля, но account/счётчик/keysSet ядро докладывает само. + /// Сервис недоступен /// [Fact] public async Task GetAsync_GatewayUnavailable_ReturnsIdleFormWithKvAccountCountAndKeys() @@ -69,7 +63,7 @@ public sealed class TgStatusServiceTests } /// - /// Готовый аккаунт: live-поля гейта, account — из KV (источник истины, не поле гейта), счётчики/ключи. + /// Готовый аккаунт /// [Fact] public async Task GetAsync_ReadyGateway_ComposesLiveFieldsWithKvAccountMonitoredAndKeys() @@ -92,7 +86,7 @@ public sealed class TgStatusServiceTests } /// - /// Фаза qr несёт qrUrl из гейта (фронт рисует QR/поллит статус до ready, store.js tg-флоу). + /// Фаза qr несёт qrUrl из гейта /// [Fact] public async Task GetAsync_QrPhase_ReturnsQrUrlFromGateway() diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs index 83eb953..86c6008 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiClassifierTests.cs @@ -11,16 +11,8 @@ 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. @@ -53,7 +45,6 @@ public sealed class BudgetedAiClassifierTests [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; @@ -78,7 +69,6 @@ public sealed class BudgetedAiClassifierTests 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); @@ -87,7 +77,6 @@ public sealed class BudgetedAiClassifierTests [Fact] public async Task Classify_LargeBudgetLimit_CallsPaidAndReturnsItsResult() { - // Acceptance Task 9 «лимит большой → gRPC-фейк вызван»: строки лимита нет — ленивый дефолт // (10 000 000/месяц, расход 0) → Allowed=true → вызов уходит на платного исполнителя. Context ctx = Create(); ctx.Paid.ClassifyResult = PaidParsedLead; @@ -101,7 +90,6 @@ public sealed class BudgetedAiClassifierTests [Fact] public async Task Classify_SuspendedTenant_UsesLocalParseAndDoesNotCallPaid() { - // Acceptance Task 9 «suspended → Local»: приостановка замораживает ИИ (Ruling 3/10(5)) — Allowed=false // даже при неисчерпанном бюджете. Context ctx = Create( limits => limits.Preload( diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs index 2344c0c..61d4c8a 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs @@ -10,16 +10,8 @@ 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. @@ -34,8 +26,6 @@ public sealed class BudgetedAiToolsTests [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)); @@ -51,7 +41,6 @@ public sealed class BudgetedAiToolsTests [Fact] public async Task EvaluateFit_ZeroBudgetLimit_ThrowsAiUnavailable() { - // Лимит 0 запрещает ИИ с нулевого расхода (Ruling 3) — как и исчерпание. Context ctx = Create( limits => limits.Preload(TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0)); @@ -93,7 +82,6 @@ public sealed class BudgetedAiToolsTests [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)); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/CardComposerTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/CardComposerTests.cs index d30fd69..610f9ef 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/CardComposerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/CardComposerTests.cs @@ -10,17 +10,8 @@ using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Contracts; /// -/// Тесты композитора карточки CardComposer (план Task 7 L403–406, Ruling 4; python _store_lead L433–514). +/// Тесты композитора карточки CardComposer. /// -/// -/// Композитор чистый (порты 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, контакты, источник) ─────── @@ -46,7 +37,6 @@ public sealed class CardComposerTests 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); @@ -74,7 +64,6 @@ public sealed class CardComposerTests 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); @@ -111,7 +100,6 @@ public sealed class CardComposerTests Assert.Equal("Ищем Java-разработчика в команду на проект", snapshot.Title); // clean_short(text, 140) } - // ─── Колонка: назначенная доска (ContainerAccepts-страховка L449–450) ───────────────────── [Fact] public async Task BuildAsync_BoardWithoutRules_PlacesCardIntoBoardWithEmptyMatchHits() @@ -125,7 +113,6 @@ public sealed class CardComposerTests cardId: "l_test4", CancellationToken.None); - // Доска без правил принимает любой текст (rules.py L266–268); совпадений критериев нет. Assert.Equal("b_py", snapshot.Col); Assert.Empty(snapshot.MatchHits); } @@ -142,7 +129,6 @@ public sealed class CardComposerTests cardId: "l_test5", CancellationToken.None); - // Страховка: ИИ/ML не кладут в отфильтрованную колонку текст, не прошедший правила (L449–450). Assert.Equal(KanbanColumns.Inbox, snapshot.Col); Assert.Empty(snapshot.MatchHits); } @@ -168,7 +154,6 @@ public sealed class CardComposerTests [Fact] public async Task BuildAsync_MissingBoard_FallsBackToInbox() { - // Доска разбора удалена после классификации — как отсутствующая строка board_accepts (rules.py L260–262). (CardComposer composer, _, _) = Create(); CardSnapshot snapshot = await composer.BuildAsync( @@ -194,7 +179,6 @@ public sealed class CardComposerTests 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"), @@ -233,7 +217,6 @@ public sealed class CardComposerTests 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); @@ -251,7 +234,6 @@ public sealed class CardComposerTests cardId: "l_test11", CancellationToken.None); - // Дефолт цвета канала #666 (Ruling 1) — как у строк очереди/отсева при пустом hue. Assert.Equal("#666", 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 f923d21..bbd0308 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs @@ -7,19 +7,10 @@ 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 — поиск). +/// Тесты CardsService — карточки /// -/// -/// Служебные фейки: (карточки/журнал/комментарии), -/// (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() @@ -69,7 +60,6 @@ public sealed class CardsServiceTests Assert.Null(card); } - // ─── Перенос (move_lead L177–191, _move L163–174) ────────────────────── [Fact] public async Task Move_ToBoardWithRules_UpdatesColumnAndComputesMatchHits() @@ -148,7 +138,6 @@ public sealed class CardsServiceTests { (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); @@ -177,7 +166,6 @@ public sealed class CardsServiceTests 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); @@ -230,7 +218,6 @@ public sealed class CardsServiceTests 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); @@ -280,7 +267,6 @@ public sealed class CardsServiceTests 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); @@ -299,7 +285,6 @@ public sealed class CardsServiceTests 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); @@ -307,7 +292,6 @@ public sealed class CardsServiceTests Assert.Empty(ml.Pushed); } - // ─── Корзина (trash_lead L194–201) ───────────────────────────────────── [Fact] public async Task Trash_FromInbox_MovesToTrashWithJournalAndSpamPush() @@ -370,7 +354,6 @@ public sealed class CardsServiceTests Assert.Empty(ml.Pushed); } - // ─── Возврат (restore_lead L204–222) ─────────────────────────────────── [Fact] public async Task Restore_FromTrash_ToPrevColBoard_UnlearnsSpam() @@ -417,7 +400,6 @@ public sealed class CardsServiceTests 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); @@ -453,7 +435,6 @@ public sealed class CardsServiceTests Assert.Null(back); // эндпоинт отвечает 404 «Карточка не найдена» } - // ─── Удаление навсегда (delete_forever L225–234) ─────────────────────── [Fact] public async Task DeleteForever_RemovesCardAndComments_KeepsJournal() @@ -480,7 +461,6 @@ public sealed class CardsServiceTests 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); @@ -496,7 +476,6 @@ public sealed class CardsServiceTests Assert.False(deleted); // эндпоинт отвечает 404 «Карточка не найдена» } - // ─── Очистка колонки (clear_col L237–247) ────────────────────────────── [Fact] public async Task ClearCol_Trash_ReturnsClearedCount() @@ -548,7 +527,6 @@ public sealed class CardsServiceTests Assert.Equal(CardsService.ClearColInvalidDetail, result.Error); } - // ─── Пометить прочитанным (mark_seen L250–256) ───────────────────────── [Fact] public async Task MarkSeen_ById_OnlyThatCard() @@ -589,7 +567,6 @@ public sealed class CardsServiceTests Assert.All(store.CardDtos, card => Assert.False(card.IsNew)); } - // ─── Комментарии (add_comment L259–265) ──────────────────────────────── [Fact] public async Task AddComment_EmptyOrWhitespaceText_Returns400Text() @@ -637,7 +614,6 @@ public sealed class CardsServiceTests Assert.Null(result.Comments); // эндпоинт отвечает 404 «Карточка не найдена» } - // ─── Счётчики (counts L268–279) ──────────────────────────────────────── [Fact] public async Task Counts_ColumnsNewAndMlStats() @@ -672,7 +648,6 @@ public sealed class CardsServiceTests Assert.Equal(0, counts.Learning); } - // ─── Поиск (search L509–551; FTS + LIKE — адаптер, Ruling 6/Task 12) ──────────── [Fact] public async Task Search_QueryShorterThanTwoChars_ReturnsEmptyAndDoesNotCallStore() @@ -696,7 +671,6 @@ public sealed class CardsServiceTests 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); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/DialogsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/DialogsServiceTests.cs index 3a94c3e..c906643 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/DialogsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/DialogsServiceTests.cs @@ -9,23 +9,14 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Deal.Tests.Unit.Contracts; /// -/// Тесты DialogsService — каталог диалогов/каналов и зеркало мониторинга ядра (Ruling 7, план Task 13). +/// Тесты DialogsService — каталог диалогов/каналов и зеркало мониторинга ядра. /// -/// -/// Сервис чистый: оркестрирует 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). + /// Новые диалоги при дефолтной autoMonitorNew=true появляются включёнными. /// [Fact] public async Task Sync_NewDialogsAutoMonitorNewDefaultTrue_TurnMonitoringOn() @@ -42,7 +33,7 @@ public sealed class DialogsServiceTests } /// - /// autoMonitorNew=false: новые диалоги появляются отключёнными (настройка читается из KV). + /// autoMonitorNew=false /// [Fact] public async Task Sync_AutoMonitorNewFalse_AddsNewDialogsUnmonitored() @@ -59,7 +50,7 @@ public sealed class DialogsServiceTests } /// - /// Существующий диалог обновляет имя/тип/handle, монитор пользователя не трогается (python L487–489). + /// Существующий диалог обновляет имя/тип/handle, монитор пользователя не трогается. /// [Fact] public async Task Sync_ExistingDialog_UpdatesMetadataAndKeepsUserMonitor() @@ -80,7 +71,7 @@ public sealed class DialogsServiceTests } /// - /// Диалог, которого больше нет в каталоге (вышел/удалил), удаляется (python L491–500). + /// Диалог, которого больше нет в каталоге /// [Fact] public async Task Sync_DialogLeftCatalog_IsDeleted() @@ -97,7 +88,7 @@ public sealed class DialogsServiceTests } /// - /// Пустой каталог — no-op (python L477–478: возврат 0 без записи), существующие строки целы. + /// Пустой каталог — no-op, существующие строки целы. /// [Fact] public async Task Sync_EmptyCatalog_ReturnsZeroAndKeepsRows() @@ -114,7 +105,7 @@ public sealed class DialogsServiceTests // ─── SetMonitor: флаг + RPC в telegram-service + «нужен первый разбор» ── /// - /// Первое включение неразобранного диалога: флаг on, RPC SetMonitor(id,true), BackfillNeeded=true. + /// Первое включение неразобранного диалога /// [Fact] public async Task SetMonitor_EnableFirstTimeNotBackfilled_SetsFlagCallsGatewayAndNeedsBackfill() @@ -131,7 +122,7 @@ public sealed class DialogsServiceTests } /// - /// Включение уже разобранного диалога: флаг on + RPC, BackfillNeeded=false (python L544: row backfilled). + /// Включение уже разобранного диалога /// [Fact] public async Task SetMonitor_EnableAlreadyBackfilled_NoBackfillNeeded() @@ -147,7 +138,7 @@ public sealed class DialogsServiceTests } /// - /// Выключение: флаг off + RPC SetMonitor(id,false), разбор не нужен (python L536–540). + /// Выключение: флаг off + RPC SetMonitor(id,false), разбор не нужен. /// [Fact] public async Task SetMonitor_Disable_TurnsOffAndNotifiesMirror() @@ -164,7 +155,7 @@ public sealed class DialogsServiceTests } /// - /// Диалога нет в каталоге — no-op без RPC (UPDATE без строк python не меняет зеркало сервиса). + /// Диалога нет в каталоге — no-op без RPC. /// [Fact] public async Task SetMonitor_UnknownDialog_NoOpWithoutGatewayCall() @@ -182,7 +173,7 @@ public sealed class DialogsServiceTests // ─── SetMonitorAll: все диалоги + зеркало + список неразобранных ── /// - /// Включение всех: count строк, RPC SetMonitorAll(true), неразобранные — списком (python L548–567). + /// Включение всех: count строк, RPC SetMonitorAll(true), неразобранные — списком. /// [Fact] public async Task SetMonitorAll_Enable_ReturnsCountAndNotBackfilledIdsAndNotifiesMirror() @@ -201,7 +192,7 @@ public sealed class DialogsServiceTests } /// - /// Выключение всех: мониторинг снят со всех, неразобранные пусты (backfill при выключении не нужен). + /// Выключение всех /// [Fact] public async Task SetMonitorAll_Disable_TurnsAllOffAndEmptyBackfillList() @@ -219,7 +210,7 @@ public sealed class DialogsServiceTests } /// - /// MarkBackfilled ставит флаг разобранности (backfill_dialog L387). + /// MarkBackfilled ставит флаг разобранности. /// [Fact] public async Task MarkBackfilled_SetsFlagOnDialog() @@ -232,7 +223,6 @@ public sealed class DialogsServiceTests Assert.True(store.Dialogs[0].Backfilled); } - // ─── BackfillOne / ReadRecent: частичный сбой не отменяет остальные (замечание ревью T13) ── /// /// «Перечитать» при падении одного канала продолжает остальные; флаг разбора — только после успеха. @@ -256,7 +246,7 @@ public sealed class DialogsServiceTests } /// - /// BackfillOne: нет строки/уже разобран без force — 0 без RPC; успех помечает разобранным (L360–387). + /// BackfillOne: нет строки/уже разобран без force — 0 без RPC; успех помечает разобранным. /// [Fact] public async Task BackfillOne_UnknownOrAlreadyBackfilledWithoutForce_ReturnsZeroWithoutRpc() @@ -277,7 +267,7 @@ public sealed class DialogsServiceTests } /// - /// BackfillOne c force=true обходит флаг разобранности («Перечитать» кнопкой, python L352–353). + /// BackfillOne c force=true обходит флаг разобранности. /// [Fact] public async Task BackfillOne_ForceTrue_BackfillsAlreadyBackfilledDialogAndMarksAgain() @@ -295,7 +285,7 @@ public sealed class DialogsServiceTests // ─── ReadRecent («Перечитать»): только включённые каналы, force=true ── /// - /// Перечитываются только включённые диалоги: Backfill(force=true) каждому, флаг разбора после успеха. + /// Перечитываются только включённые диалоги /// [Fact] public async Task ReadRecent_OnlyMonitoredDialogs_BackfillsEachMonitoredWithForce() @@ -314,7 +304,7 @@ public sealed class DialogsServiceTests } /// - /// Включённых нет — «Перечитать» возвращает 0 без вызовов гейта (python L577–579). + /// Включённых нет — «Перечитать» возвращает 0 без вызовов гейта. /// [Fact] public async Task ReadRecent_NoMonitoredDialogs_ReturnsZeroWithoutGatewayCalls() @@ -331,7 +321,7 @@ public sealed class DialogsServiceTests // ─── List ────────────────────────────────────────────────────────────── /// - /// List возвращает каталог: включённые первыми, далее по имени, с «последним сообщением» (L521–534). + /// List возвращает каталог /// [Fact] public async Task List_ReturnsMonitoredFirstByNameOrderWithLastMessage() @@ -357,7 +347,7 @@ public sealed class DialogsServiceTests // ─── SavePreview: превью TgMessages + «последнее сообщение» каталога ── /// - /// SavePreview пишет строку превью (m_<dialog>_<msg>, текст ≤4000) и обновляет last каталога (≤200). + /// SavePreview пишет строку превью /// [Fact] public async Task SavePreview_PersistsPreviewRowAndTouchesDialogLast() @@ -380,7 +370,7 @@ public sealed class DialogsServiceTests } /// - /// Пустой/пробельный текст — no-op (python L259: пустые сообщения не обрабатываются). + /// Пустой/пробельный текст — no-op. /// [Fact] public async Task SavePreview_EmptyText_DoesNotTouchStore() @@ -396,7 +386,7 @@ public sealed class DialogsServiceTests } /// - /// Без msg_id строка превью не пишется (нет стабильного id), «последнее сообщение» обновляется. + /// Без msg_id строка превью не пишется /// [Fact] public async Task SavePreview_NoMsgId_UpdatesLastOnly() @@ -412,7 +402,7 @@ public sealed class DialogsServiceTests } /// - /// Дубль превью того же сообщения не перезаписывает первую строку (INSERT OR IGNORE python L602–605). + /// Дубль превью того же сообщения не перезаписывает первую строку. /// [Fact] public async Task SavePreview_DuplicateMessageId_KeepsFirstRow() @@ -429,10 +419,9 @@ public sealed class DialogsServiceTests 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() @@ -453,8 +442,7 @@ public sealed class DialogsServiceTests } /// - /// Существующая строка (пользователь снял монитор, канал разобран) — upsert как python ON CONFLICT: - /// метаданные обновлены, монитор принудительно on, backfilled сброшен. + /// Существующая строка /// [Fact] public async Task AddDiscoveredMonitored_ExistingDialog_UpdatesMetadataAndForcesMonitorOn() @@ -475,7 +463,7 @@ public sealed class DialogsServiceTests } /// - /// Пустые имя/hue нормализуются как python L866–869: name → dialogId, hue → «#666». + /// Пустые имя/hue нормализуются как /// [Fact] public async Task AddDiscoveredMonitored_EmptyNameAndHue_FallBackToDialogIdAndDefaultHue() @@ -492,7 +480,7 @@ public sealed class DialogsServiceTests } /// - /// Сбой зеркала (telegram-service недоступен) не роняет вступление: строка каталога уже записана. + /// Сбой зеркала /// [Fact] public async Task AddDiscoveredMonitored_GatewayMirrorFails_RowStillWritten() diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryEvaluatorTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryEvaluatorTests.cs index e9ce7b0..dc8067b 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryEvaluatorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryEvaluatorTests.cs @@ -7,15 +7,8 @@ using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Contracts; /// -/// Тесты DiscoveryEvaluator — каскад оценки сообщения и агрегаты выборки (план Task 18, 1:1 -/// discovery_eval.py целиком): короткие → нет, ML-спам, ИИ (успех/сбой → эвристика), эвристика по ключам, -/// вердикт passed (объём+порог), группировка форумов по темам. +/// Тесты DiscoveryEvaluator — каскад оценки сообщения и агрегаты выборки /// -/// -/// Сервис чистый: KV-флаги — через (по умолчанию дефолты SettingsDefaults: -/// mlEnabled/aiEnabled = true), ML — , ИИ — . Тесты выключают -/// ветки, чтобы проверять каскад изолированно (эталон тестов воркера Pipeline). -/// public sealed class DiscoveryEvaluatorTests { [Fact] @@ -80,7 +73,6 @@ public sealed class DiscoveryEvaluatorTests [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( @@ -133,7 +125,6 @@ public sealed class DiscoveryEvaluatorTests [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)); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs index 7529582..13347e8 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs @@ -9,17 +9,8 @@ using Grpc.Core; namespace Deal.Tests.Unit.Contracts; /// -/// Тесты DiscoveryWorkerService — один шаг за тик (план Task 18, 1:1 discovery_worker.py целиком, -/// Ruling 10): поиск → кандидаты; оценка → review (фильтры/язык/содержание); авто-вступление с паузами/ -/// квотами/бан-гардом; план выполнен → done; идемпотентность/изоляция. +/// Тесты DiscoveryWorkerService — один шаг за тик /// -/// -/// Воркер чистый: гейт — (поиск/инфо/чтение/join настраиваются сценарием), -/// ML/ИИ — фейки с выключенными ветками (mlEnabled/aiEnabled=false: оценка идёт эвристикой по ключам; -/// сценарии веток каскада покрыты DiscoveryEvaluatorTests). Пауза перед join — -/// (мгновенная, с хуком изменений за «паузу»). FloodWait — настоящий -/// RESOURCE_EXHAUSTED с detail-префиксом «flood:» (как бросает GrpcTelegramClient, контракт telegram.proto). -/// public sealed class DiscoveryWorkerServiceTests { // ─── Поиск ───────────────────────────────────────────────────────────── @@ -219,7 +210,6 @@ public sealed class DiscoveryWorkerServiceTests 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); @@ -549,7 +539,6 @@ public sealed class DiscoveryWorkerServiceTests 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 c6760c0..504666f 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiClassifier.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiClassifier.cs @@ -4,47 +4,37 @@ using Deal.Contracts.Integrations.Models; namespace Deal.Tests.Unit.Contracts; /// -/// In-memory реализация для unit-тестов воркера pump (план Task 8 L423: -/// доска назначена → ContainerAccepts-страховка; is_spam → отсев spam_ai; пустой разбор/сбой → локальный, aiFail). +/// In-memory реализация для unit-тестов воркера pump. /// -/// -/// По умолчанию ведёт себя как детерминированный LocalAiClassifier (Ruling 5): фильтр всегда -/// {pass:true, skipped:true}. Сценарий задаёт (перекрыть фильтр/заблокировать), -/// (разбор карточки) и флаги сбоев / -/// — ветка «ИИ недоступен/сбой» прототипа L1102–1114. = null при вызове -/// ClassifyAsync бросает — тест сразу поймает неожиданное обращение. -/// Счётчики вызовов (/) показывают, какие ветки воркера реально -/// ходили в порт (выключатели aiEnabled/aiFilterEnabled обрабатывает воркер, а не порт — Ruling 5). -/// public sealed class FakeAiClassifier : IAiClassifier { /// - /// Ответ FilterAsync по умолчанию — как LocalAiClassifier: пропуск (фильтра нет, Ruling 5). + /// Ответ FilterAsync по умолчанию — как LocalAiClassifier /// public AiFilterResultDto FilterResult { get; set; } = new(Pass: true, Reason: null, Skipped: true); /// - /// Ответ ClassifyAsync; null — ClassifyAsync бросает NotSupportedException (вызова не ждали). + /// Ответ ClassifyAsync; null — ClassifyAsync бросает NotSupportedException /// public AiParsedCardDto? ClassifyResult { get; set; } /// - /// Сбой FilterAsync (ветка «фильтр недоступен» L1102–1106 — воркер пропускает). + /// Сбой FilterAsync. /// public bool FilterThrows { get; set; } /// - /// Сбой ClassifyAsync (ветка «классификация упала» L1112–1114 — локальный разбор, aiFail). + /// Сбой ClassifyAsync. /// public bool ClassifyThrows { get; set; } /// - /// Сколько раз вызван FilterAsync (0 — воркер не звал фильтр: force/выключен). + /// Сколько раз вызван FilterAsync /// public int FilterCalls { get; private set; } /// - /// Сколько раз вызван ClassifyAsync (0 — воркер не звал классификатор: заблокировано фильтром). + /// Сколько раз вызван ClassifyAsync /// public int ClassifyCalls { get; private set; } diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiTools.cs b/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiTools.cs index fc42d03..4f40835 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiTools.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiTools.cs @@ -4,27 +4,22 @@ using Deal.Contracts.Integrations.Models; namespace Deal.Tests.Unit.Contracts; /// -/// Фейковая реализация для тестов Discovery-воркера/оценки (план Task 18). +/// Фейковая реализация для тестов Discovery-воркера/оценки. /// -/// -/// По умолчанию EvaluateFitAsync бросает (как Local-реализация — локальный -/// режим не поддерживает инструменты, Ruling 6): сценарий задаёт либо явный -/// (сбой ИИ — воркер падает в эвристику). GenerateKeywordsAsync в тестах воркера не используется — бросает. -/// public sealed class FakeAiTools : IAiTools { /// - /// Ответ EvaluateFitAsync; null — вызов бросит (вызова не ждали). + /// Ответ EvaluateFitAsync; null — вызов бросит /// public AiEvaluateFitResultDto? Fit { get; set; } /// - /// Явный сбой EvaluateFitAsync (сеть/недоступность); null — обычный сценарий. + /// Явный сбой EvaluateFitAsync /// public Exception? Error { get; set; } /// - /// Сколько раз вызван EvaluateFitAsync (0 — ИИ не звали: выключен/короткое сообщение/ML-спам). + /// Сколько раз вызван EvaluateFitAsync /// public int EvaluateFitCalls { get; private set; } diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeDiscoveryGateway.cs b/src/core/tests/Deal.Tests.Unit/Contracts/FakeDiscoveryGateway.cs index 2299b1b..af97334 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeDiscoveryGateway.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/FakeDiscoveryGateway.cs @@ -4,44 +4,37 @@ using Deal.Contracts.Integrations.Models; namespace Deal.Tests.Unit.Contracts; /// -/// Фейковый для тестов Discovery-воркера (план Task 18): поиск/инфо/чтение -/// настраиваются сценарием, join — по умолчанию успешен (исключение задаётся), вызовы записываются. +/// Фейковый для тестов Discovery-воркера /// -/// -/// Неиспользуемые команды вкладки Telegram (Status/QR/каталог/превью и т.п.) бросают -/// — тест сразу поймает неожиданное обращение (эталон FakeMlClient). -/// FloodWait имитируется исключением с detail-префиксом «flood:» (контракт telegram.proto L820–826) — тесты -/// бросают настоящий , как реальный гейт. -/// public sealed class FakeDiscoveryGateway : ITelegramGateway { /// - /// Результат глобального поиска (возвращается на любой запрос); пусто — результатов нет. + /// Результат глобального поиска /// public IReadOnlyList SearchResults { get; set; } = []; /// - /// Исключение SearchAsync (flood/сбой); null — вернуть . + /// Исключение SearchAsync /// public Exception? SearchError { get; set; } /// - /// Инфо об источниках по dialogId; отсутствующий — fallback как Local (kind пуст, participants null). + /// Инфо об источниках по dialogId; отсутствующий — fallback как Local /// public Dictionary InfoByDialog { get; } = new(StringComparer.Ordinal); /// - /// Выборки сообщений по dialogId; отсутствующий — ok=false "no_history" (как Local/недоступная история). + /// Выборки сообщений по dialogId; отсутствующий — ok=false "no_history" /// public Dictionary ReadsByDialog { get; } = new(StringComparer.Ordinal); /// - /// Исключение JoinAsync (flood/прочий сбой); null — вступление успешно. + /// Исключение JoinAsync /// public Exception? JoinError { get; set; } /// - /// Сколько сообщений «разобрал» Backfill (ответ BackfillAsync). + /// Сколько сообщений «разобрал» Backfill /// public int BackfillResult { get; set; } = 3; @@ -51,12 +44,12 @@ public sealed class FakeDiscoveryGateway : ITelegramGateway public List SearchedKeywords { get; } = []; /// - /// Запросы Info (dialogId) в порядке вызовов. + /// Запросы Info /// public List InfoRequests { get; } = []; /// - /// Запросы ReadForEval (dialogId, limit) в порядке вызовов. + /// Запросы ReadForEval /// public List<(string DialogId, int Limit)> ReadRequests { get; } = []; @@ -66,12 +59,12 @@ public sealed class FakeDiscoveryGateway : ITelegramGateway public List JoinedUsernames { get; } = []; /// - /// Вызовы SetMonitor (dialogId, enabled) в порядке вызовов. + /// Вызовы SetMonitor /// public List<(string DialogId, bool Enabled)> SetMonitorCalls { get; } = []; /// - /// Вызовы Backfill (dialogId, force) в порядке вызовов. + /// Вызовы Backfill /// public List<(string DialogId, bool Force)> BackfillCalls { get; } = []; diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeFileStorage.cs b/src/core/tests/Deal.Tests.Unit/Contracts/FakeFileStorage.cs index f4cd6bc..8824bee 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeFileStorage.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/FakeFileStorage.cs @@ -4,19 +4,8 @@ using Deal.Contracts.Integrations.Models; namespace Deal.Tests.Unit.Contracts; /// -/// In-memory реализация для unit-тестов файлов карточки (CardsService.Files). +/// In-memory реализация для unit-тестов файлов карточки /// -/// -/// Поведение 1:1 с контрактом порта (Ruling 4/T6): Put сохраняет содержимое потока С ПОЗИЦИИ 0 (перемотаемый -/// поток сбрасывается в начало — выравнивание адаптеров Local/MinIO из Ruling T6) и возвращает objectKey; -/// Get отсутствующего объекта → null (порт: null = «объекта нет»); Delete удаляет объект (повторный и -/// удаление отсутствующего — успех, как адаптеры); Stat — FileMeta по сохранённому объекту (размер + MIME -/// как при put; null — объекта нет) — семантика Minio-адаптера (Ruling T6: StatObject). Объекты хранятся -/// байтовым словарём по objectKey; тесты -/// смотрят // — проверки -/// «объект записан/удалён», как в CardsServiceFilesTests (приёмка: «add на несуществующей карточке -/// не пишет объект», «remove — объект удалён»). -/// public sealed class FakeFileStorage : IFileStorage { private readonly Dictionary _objects = new(StringComparer.Ordinal); @@ -24,17 +13,17 @@ public sealed class FakeFileStorage : IFileStorage private readonly List _deletedKeys = []; /// - /// objectKey всех объектов, сохранённых на данный момент (копия на момент обращения). + /// objectKey всех объектов, сохранённых на данный момент /// public IReadOnlyList StoredObjectKeys => _objects.Keys.ToList(); /// - /// objectKey всех удалений в порядке вызовов DeleteAsync (копия на момент обращения). + /// objectKey всех удалений в порядке вызовов DeleteAsync /// public IReadOnlyList DeletedKeys => _deletedKeys.ToList(); /// - /// Содержимое сохранённого объекта по ключу либо null — объекта нет (проверки round-trip). + /// Содержимое сохранённого объекта по ключу либо null — объекта нет /// /// Ключ объекта (opaque). public byte[]? ContentOf(string objectKey) diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeMlClient.cs b/src/core/tests/Deal.Tests.Unit/Contracts/FakeMlClient.cs index 0392417..38dde69 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeMlClient.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/FakeMlClient.cs @@ -4,40 +4,29 @@ using Deal.Contracts.Integrations.Models; namespace Deal.Tests.Unit.Contracts; /// -/// In-memory реализация для unit-тестов CardsService (план Task 7) и воркера pump -/// (Task 8: ML-ветки «решил сам», Ruling 5). +/// In-memory реализация для unit-тестов CardsService и воркера pump. /// -/// -/// CardsService использует только два члена контракта: PushAsync (обучающий сигнал move/trash/restore, -/// Ruling 4) и StatusAsync (счётчики learning/ml/ai для counts, план Task 7 L321). -/// задаётся сценарием (по умолчанию — пустая статистика), показывает, что именно ушло -/// в клиент (text/label/delta, в порядке вызовов). — ответ PredictAsync воркера -/// (неготовая модель по умолчанию сценария не задана): PredictAsync/ResetAsync бросают -/// , если сценарий их не настроил, — тест сразу поймает неожиданное -/// обращение (эталон CardsService: predict/reset сервисы не вызывают). -/// public sealed class FakeMlClient : IMlClient { private readonly List<(string Text, string Label, double Delta)> _pushed = []; /// - /// Ответ StatusAsync (сценарий задаёт счётчики learning/ml/ai — LocalMlClient.snapshot). + /// Ответ StatusAsync /// public MlStatusResponseDto Status { get; set; } = DefaultStatus(); /// - /// Ответ PredictAsync воркера pump (Task 8): готовность/уверенность/метка/термины/тип задаёт - /// сценарий; null — PredictAsync бросает NotSupportedException (вызова не ждали). + /// Ответ PredictAsync воркера pump /// public MlPredictResultDto? Predict { get; set; } /// - /// Сколько раз вызван PredictAsync (0 — воркер не звал ML: выключен/force/сообщение отсеяно ранее). + /// Сколько раз вызван PredictAsync /// public int PredictCalls { get; private set; } /// - /// Обучающие сигналы, отправленные через (копия на момент обращения). + /// Обучающие сигналы, отправленные через /// public IReadOnlyList<(string Text, string Label, double Delta)> Pushed => _pushed.ToList(); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramGateway.cs b/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramGateway.cs index 384b4db..f23086c 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramGateway.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramGateway.cs @@ -5,67 +5,58 @@ using Grpc.Core; namespace Deal.Tests.Unit.Contracts; /// -/// In-memory реализация для юнит-тестов модуля Telegram/ингресса (Task 13/14). +/// In-memory реализация для юнит-тестов модуля Telegram/ингресса. /// -/// -/// Записывает команды наружу (мониторинг/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) в порядке вызова. + /// Вызовы SetMonitor /// public List<(string DialogId, bool Enabled)> SetMonitorCalls { get; } = []; /// - /// Вызовы SetMonitorAll (enabled) в порядке вызова. + /// Вызовы SetMonitorAll /// public List SetMonitorAllCalls { get; } = []; /// - /// Вызовы Backfill (dialogId, force) в порядке вызова. + /// Вызовы Backfill /// public List<(string DialogId, bool Force)> BackfillCalls { get; } = []; /// - /// Сколько сообщений «разобрал» Backfill (ответ {processed}). + /// Сколько сообщений «разобрал» Backfill /// public int BackfillResult { get; set; } = 3; /// - /// Каталог, который возвращает RefreshDialogsAsync/SearchAsync (пуст — каталога нет). + /// Каталог, который возвращает RefreshDialogsAsync/SearchAsync /// public IReadOnlyList Catalog { get; set; } = []; /// - /// Статус, который возвращает StatusAsync (по умолчанию idle/не подключён). + /// Статус, который возвращает StatusAsync /// public TelegramAccountStatusDto Status { get; set; } = new(IdlePhase, Connected: false, Listener: false, Account: string.Empty, Error: null, QrUrl: null); /// - /// Заданное исключение StatusAsync (сценарий «сервис недоступен»); null — вернуть . + /// Заданное исключение StatusAsync /// public RpcException? StatusFailure { get; set; } /// - /// Диалоги, чей BackfillAsync падает (частичный сбой перечитывания); остальные — успешны. + /// Диалоги, чей BackfillAsync падает /// public HashSet BackfillFailures { get; } = new(StringComparer.Ordinal); /// - /// Диалоги, чей SetMonitorAsync падает (сбой зеркала после discovery-вступления); остальные — успешны. + /// Диалоги, чей SetMonitorAsync падает /// public HashSet SetMonitorFailures { get; } = new(StringComparer.Ordinal); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramStore.cs b/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramStore.cs index 3fc5df1..747078c 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/FakeTelegramStore.cs @@ -6,32 +6,25 @@ using Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Contracts; /// -/// In-memory реализация для юнит-тестов DialogsService/ингресса (Task 13). +/// In-memory реализация для юнит-тестов DialogsService/ингресса. /// -/// -/// Повторяет семантику EF-адаптера TelegramStore (таблицы Dialogs/TgMessages): upsert каталога (ON CONFLICT — -/// обновляются только метаданные, монитор пользователя не трогается), удаление отсутствующих, список -/// «monitor DESC, name», UPDATE monitor/backfilled, превью INSERT OR IGNORE, последнее сообщение диалога. -/// Строки можно посеять напрямую (/) — сценарий «в БД уже есть -/// строки»; / — для проверок теста. -/// public sealed class FakeTelegramStore : ITelegramStore { private readonly Dictionary _dialogs = new(StringComparer.Ordinal); private readonly Dictionary _messages = new(StringComparer.Ordinal); /// - /// Строки каталога фейка (копия, в порядке добавления) — проверки тестов. + /// Строки каталога фейка /// public IReadOnlyList Dialogs => _dialogs.Values.ToList(); /// - /// Строки превью фейка (копия, в порядке добавления) — проверки тестов. + /// Строки превью фейка /// public IReadOnlyList Messages => _messages.Values.ToList(); /// - /// Кладёт строку каталога напрямую (сценарий «в каталоге уже есть диалог»). + /// Кладёт строку каталога напрямую /// /// Строка как если бы была сохранена в БД. public void Seed(FakeTelegramDialogRow row) @@ -40,7 +33,7 @@ public sealed class FakeTelegramStore : ITelegramStore } /// - /// Кладёт строку превью напрямую (сценарий «сообщение уже сохранено»). + /// Кладёт строку превью напрямую /// /// Строка как если бы была сохранена в БД. public void SeedMessage(FakeTelegramMessageRow row) @@ -82,7 +75,6 @@ public sealed class FakeTelegramStore : ITelegramStore } } - // Диалоги, которых больше нет в каталоге, удаляются (1:1 _persist_dialogs L491–500). HashSet seen = entries.Select(entry => entry.Id).ToHashSet(StringComparer.Ordinal); foreach (string id in _dialogs.Keys.Where(id => !seen.Contains(id)).ToList()) { @@ -183,7 +175,6 @@ public sealed class FakeTelegramStore : ITelegramStore string hue, CancellationToken ct) { - // 1:1 python add_dialog_monitored L850–873 (INSERT/ON CONFLICT): метаданные, monitor=TRUE, backfilled=FALSE. if (_dialogs.TryGetValue(dialogId, out FakeTelegramDialogRow? existing)) { _dialogs[dialogId] = existing with @@ -253,7 +244,6 @@ public sealed class FakeTelegramStore : ITelegramStore return Task.FromResult(items); } - // Маппит строку каталога в DTO списка каналов (1:1 ToDialogDto адаптера). // row: Строка Dialogs фейка. // Возвращает: Форма §4.8: type = kind, on = monitor, last {text, time}. private static TelegramDialogDto ToDialogDto(FakeTelegramDialogRow row) diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs index f92c961..48c6ab5 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs @@ -17,11 +17,7 @@ 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). +/// Тесты gRPC-адаптера IAiTools к ai-service /// [Collection("MlGrpcTests")] public sealed class GrpcAiToolsTests @@ -29,7 +25,6 @@ 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] @@ -70,7 +65,6 @@ public sealed class GrpcAiToolsTests 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); @@ -118,7 +112,6 @@ public sealed class GrpcAiToolsTests service.FitUnavailable = true; GrpcAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher()); - // Сбой ИИ-оценки не роняет оценку кандидата — воркер Discovery падает в эвристику (Ruling 10). await Assert.ThrowsAsync( () => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None)); }); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs index 00c6ffa..bd9bfb7 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcMlClientTests.cs @@ -19,16 +19,8 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Deal.Tests.Unit.Contracts; /// -/// Тесты gRPC-адаптера IMlClient/IMlTrainClient к ml-service (план Task 16, Ruling 6; Acceptance L449). +/// Тесты gRPC-адаптера IMlClient/IMlTrainClient к ml-service. /// -/// -/// Сценарии гоняются против 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 { @@ -60,7 +52,6 @@ public sealed class GrpcMlClientTests 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"]); @@ -75,7 +66,6 @@ public sealed class GrpcMlClientTests 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)); }); @@ -91,7 +81,6 @@ public sealed class GrpcMlClientTests 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); @@ -152,7 +141,6 @@ public sealed class GrpcMlClientTests 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); @@ -219,7 +207,6 @@ public sealed class GrpcMlClientTests 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); }); @@ -237,7 +224,6 @@ public sealed class GrpcMlClientTests 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)); @@ -262,7 +248,6 @@ public sealed class GrpcMlClientTests }); } - // ─── PushAsync: всегда запись в MlOutbox (Ruling 6) ───────────────────── [Fact] public async Task PushAsync_WritesOutboxRow() diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcTelegramClientTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcTelegramClientTests.cs index b223163..a97bbae 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcTelegramClientTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcTelegramClientTests.cs @@ -14,19 +14,13 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Deal.Tests.Unit.Contracts; /// -/// In-proc тесты gRPC-адаптера гейта GrpcTelegramClient «по проводу» (план Task 14, Ruling 1/2/7). +/// In-proc тесты gRPC-адаптера гейта GrpcTelegramClient «по проводу». /// -/// -/// Клиент ходит в фейк-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). + /// StatusAsync маппит live-поля telegram-service /// [Fact] public async Task StatusAsync_MapsLiveStatusFields() @@ -54,7 +48,7 @@ public sealed class GrpcTelegramClientTests } /// - /// Доменный RPC-отказ сервиса пробрасывается как есть: RpcException c каноническим detail (Ruling 1/7). + /// Доменный RPC-отказ сервиса пробрасывается как есть /// [Fact] public async Task StatusAsync_ServiceDomainFailure_PropagatesRpcExceptionWithDetail() @@ -75,7 +69,7 @@ public sealed class GrpcTelegramClientTests } /// - /// StartPhone уходит с phone/api_id/api_hash и возвращает фазу ("code"). + /// StartPhone уходит с phone/api_id/api_hash и возвращает фазу /// [Fact] public async Task StartPhoneAsync_SendsPhoneAndKeysAndReturnsPhase() @@ -96,7 +90,7 @@ public sealed class GrpcTelegramClientTests } /// - /// StartQr уходит с ключами и возвращает фазу + qrUrl (пустой qrUrl при phase != qr). + /// StartQr уходит с ключами и возвращает фазу + qrUrl /// [Fact] public async Task StartQrAsync_SendsKeysAndReturnsPhaseWithQrUrl() @@ -117,7 +111,7 @@ public sealed class GrpcTelegramClientTests } /// - /// RefreshDialogs маппит каталог (username → handle; kind/hue 1:1). + /// RefreshDialogs маппит каталог. /// [Fact] public async Task RefreshDialogsAsync_MapsCatalogEntries() @@ -138,7 +132,7 @@ public sealed class GrpcTelegramClientTests } /// - /// Backfill уходит с dialog_id/force и возвращает processed (сколько отправлено PushMessage). + /// Backfill уходит с dialog_id/force и возвращает processed /// [Fact] public async Task BackfillAsync_SendsDialogAndForceAndReturnsProcessed() @@ -158,7 +152,7 @@ public sealed class GrpcTelegramClientTests } /// - /// ReadRecent уходит с dialog_id/limit и маппит сообщения превью (id строкой/time epoch-ms). + /// ReadRecent уходит с dialog_id/limit и маппит сообщения превью /// [Fact] public async Task ReadRecentAsync_SendsDialogAndLimitAndMapsMessages() @@ -181,7 +175,7 @@ public sealed class GrpcTelegramClientTests } /// - /// Вызов вне tenant-контекста — ошибка конфигурации (metadata tenant-id невозможен, Ruling 1). + /// Вызов вне tenant-контекста — ошибка конфигурации. /// [Fact] public async Task Call_WithoutTenantContext_ThrowsInvalidOperation() diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs index 6d46890..22d537f 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs @@ -23,22 +23,8 @@ using Microsoft.Extensions.DependencyInjection; namespace Deal.Tests.Unit.Contracts; /// -/// Тесты выбора реализации портов интеграций по флагам Services:Ml|Ai|Telegram:UseLocal (Ruling 6, планы Task -/// 15/16/14): +/// Тесты выбора реализации портов интеграций по флагам Services:Ml|Ai|Telegram:UseLocal /// -/// -/// ML: UseLocal=true (default) → LocalMlClient (фолбэк этапов 2–5), IMlTrainClient не регистрируется -/// (Local-режиму ml-service не нужен — флашера нет); UseLocal=false → GrpcMlClient (тот же экземпляр -/// реализует IMlClient и IMlTrainClient для флашера) + транспорт MlGrpcConnection (создаётся сразу, fail-fast). -/// AI: Services:Ai:UseLocal=true → LocalAiClassifier/LocalAiTools (детерминированный разбор/инструменты -/// не поддерживаются); false → декораторы бюджетного гейта BudgetedAiClassifier/BudgetedAiTools поверх -/// GrpcAiClassifier (IAiClassifier) и GrpcAiTools (IAiTools) + транспорт AiGrpcConnection (создаётся сразу, -/// fail-fast при пустом endpoint/DEAL_SERVICE_TOKEN; Task 9, Ruling 3 — Local-классификатор регистрируется как -/// бесплатный fallback гейта). -/// Telegram: Services:Telegram:UseLocal=true (default) → LocalTelegramGateway (нейтральная заглушка); -/// false → GrpcTelegramClient (ITelegramGateway) + транспорт TelegramGrpcConnection (создаётся сразу, -/// fail-fast при пустом endpoint/DEAL_SERVICE_TOKEN). Выбор — на старте, рантайм-переключения нет. -/// [Collection("MlGrpcTests")] public sealed class IntegrationsDiTests { @@ -74,7 +60,6 @@ public sealed class IntegrationsDiTests // gRPC-адаптеры: один scoped-экземпляр GrpcMlClient реализует IMlClient и IMlTrainClient (для // флашера); наружу IAiClassifier → BudgetedAiClassifier (внутри GrpcAiClassifier), IAiTools → - // BudgetedAiTools (внутри GrpcAiTools) — бюджетный гейт Task 9; ITelegramGateway → GrpcTelegramClient. IMlClient mlClient = scope.ServiceProvider.GetRequiredService(); IMlTrainClient trainClient = scope.ServiceProvider.GetRequiredService(); Assert.IsType(mlClient); @@ -98,7 +83,6 @@ public sealed class IntegrationsDiTests [Fact] public void AddDealIntegrations_UseLocalFalseMl_WithoutServiceToken_ThrowsAtStartup() { - // Fail-closed (Ruling 2/13): пустой DEAL_SERVICE_TOKEN при gRPC-режиме ML — ошибка конфигурации на старте. string? originalToken = Environment.GetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey); Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, null); try @@ -116,7 +100,6 @@ public sealed class IntegrationsDiTests [Fact] public void AddDealIntegrations_UseLocalFalseAi_WithoutServiceToken_ThrowsAtStartup() { - // Fail-closed (Ruling 2/13): пустой DEAL_SERVICE_TOKEN при gRPC-режиме AI — ошибка конфигурации на старте. string? originalToken = Environment.GetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey); Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, null); try @@ -134,7 +117,6 @@ public sealed class IntegrationsDiTests [Fact] public void AddDealIntegrations_UseLocalFalseTelegram_WithoutServiceToken_ThrowsAtStartup() { - // Fail-closed (Ruling 2/13): пустой DEAL_SERVICE_TOKEN при gRPC-режиме Telegram — ошибка конфигурации. string? originalToken = Environment.GetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey); Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, null); try @@ -168,10 +150,8 @@ public sealed class IntegrationsDiTests services.AddScoped(_ => new FakeSecretCipher()); services.AddScoped(_ => new FakeKanjStore()); services.AddScoped(_ => new FakeMlLearningStore()); - // Хранилище лимитов (Task 8): scoped фейк — recorder списывает usage при UseLocal=false (как в Program.cs // ITenantLimitStore регистрируется AddDealPersistence поверх DealDbContext — здесь не нужен). services.AddScoped(_ => new FakeTenantLimitStore()); - // История расхода токенов (этап 10, T2): recorder пишет события — сервис модуля на фейке хранилища. services.AddScoped(_ => new TokenUsageEventService(new FakeTokenUsageEventStore())); services.AddScoped(); services.AddScoped(); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiClassifierTests.cs index 9372530..a5f7a31 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiClassifierTests.cs @@ -7,15 +7,8 @@ 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-хранилище (дефолты маркеров) + адаптер. @@ -36,8 +29,6 @@ public sealed class LocalAiClassifierTests 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); @@ -84,7 +75,6 @@ public sealed class LocalAiClassifierTests AiParsedCardDto result = await classifier.ClassifyAsync(text, default); - // «до 2к$»: только верхняя граница, суффикс «к» — тысячи (rules.py _AMT L58). Assert.Equal(new AiBudgetDto(From: null, To: 2000, Cur: "USD"), result.Budget); } diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiToolsTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiToolsTests.cs index 582dc8f..9928e86 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiToolsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/LocalAiToolsTests.cs @@ -3,9 +3,7 @@ using Deal.Infrastructure.Integrations.Services; namespace Deal.Tests.Unit.Contracts; /// -/// Тесты Local-реализации порта ИИ-инструментов (Ruling 9, план Task 15): -/// методы не поддерживаются (NotSupportedException) — Discovery-воркер сам выбирает эвристику, а -/// generate-keywords-эндпоинт (Task 19) превращает исключение в мягкую ошибку {keywords: [], error}. +/// Тесты Local-реализации порта ИИ-инструментов /// public sealed class LocalAiToolsTests { diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs index 7a65f34..02aeb68 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs @@ -9,19 +9,10 @@ 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 → чтение после записи). +/// Тесты обёртки создания карточки PipelineCardWriter. /// -/// -/// Тонкая обёртка: 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() @@ -45,7 +36,6 @@ public sealed class PipelineCardWriterTests 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)); } diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs index 41f3faa..07b62b6 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs @@ -24,19 +24,8 @@ 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»). +/// Сквозной тест воркера pump с gRPC-классификатором 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 { @@ -86,13 +75,11 @@ public sealed class PipelineWorkerGrpcAiTests 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)); }); @@ -111,7 +98,6 @@ public sealed class PipelineWorkerGrpcAiTests 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); @@ -123,7 +109,6 @@ public sealed class PipelineWorkerGrpcAiTests [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) => @@ -155,7 +140,6 @@ public sealed class PipelineWorkerGrpcAiTests Assert.Equal(0, service.ClassifyCalls); Assert.Empty(service.RequestTenantIds); - // Карточка создана из Local-разбора (детерминированный разбор ядра, Ruling 3): ИИ-путь pump, // aiFail не засчитан (классификатор вернул разбор, а не упал). Расход не списывался. Assert.Equal(1, result.AiStored); Assert.Equal(0, result.AiFail); @@ -181,7 +165,6 @@ public sealed class PipelineWorkerGrpcAiTests // port: Порт хоста-фейка ai-service. // limits: Фейк лимитов (списание usage/гейт); null — собственный экземпляр. // budgeted: True — классификатор обёрнут декоратором BudgetedAiClassifier - // (гейт Task 9; fallback — реальный LocalAiClassifier). // Возвращает: Воркер и фейки для проверок. private static Context CreateContext( int port, diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/TelegramGatewayPortTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/TelegramGatewayPortTests.cs index 957e1d9..1984084 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/TelegramGatewayPortTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/TelegramGatewayPortTests.cs @@ -3,17 +3,10 @@ using Deal.Contracts.Integrations.Abstractions; namespace Deal.Tests.Unit.Contracts; /// -/// Порт-контракт гейта telegram-service: набор методов 1:1 со списком Ruling 7 (план Task 13). +/// Порт-контракт гейта telegram-service /// -/// -/// Контракт фиксирует 16 команд ядра наружу (подключение/каталог/мониторинг/backfill/превью/discovery) — -/// gRPC-клиент GrpcTelegramClient (следующая задача) реализует ровно этот порт; эндпоинты /api/tg (Task 14) -/// и Discovery-воркер (Task 18) зависят только от него. Тест — «заморозка» контракта: добавление/удаление -/// метода ломает сборку вызовов или этот тест, а не молча расходится с proto. -/// public sealed class TelegramGatewayPortTests { - // Ожидаемый набор методов порта (1:1 список Ruling 7). private static readonly string[] ExpectedMethods = [ "StatusAsync", diff --git a/src/core/tests/Deal.Tests.Unit/Grpc/RecordingAiService.cs b/src/core/tests/Deal.Tests.Unit/Grpc/RecordingAiService.cs index c4a0722..b27986f 100644 --- a/src/core/tests/Deal.Tests.Unit/Grpc/RecordingAiService.cs +++ b/src/core/tests/Deal.Tests.Unit/Grpc/RecordingAiService.cs @@ -4,41 +4,32 @@ using Grpc.Core; namespace Deal.Tests.Unit.Grpc; /// -/// In-proc фейк ai-service для тестов gRPC-адаптеров ядра (план Task 15): Recording-сервер AiService. +/// In-proc фейк ai-service для тестов gRPC-адаптеров ядра /// -/// -/// Реализует 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 нулевой). + /// Ответ Filter по умолчанию /// public FilterReply FilterReply { get; set; } = new() { Pass = true }; /// - /// Ответ Classify по умолчанию (ok=false — «разбора нет»). + /// Ответ Classify по умолчанию /// public ClassifyReply ClassifyReply { get; set; } = new(); /// - /// Ответ GenerateKeywords по умолчанию (пустой список). + /// Ответ GenerateKeywords по умолчанию /// public GenerateKeywordsReply GenerateKeywordsReply { get; set; } = new(); /// - /// Ответ EvaluateFit по умолчанию (fit=true). + /// Ответ EvaluateFit по умолчанию /// public EvaluateFitReply EvaluateFitReply { get; set; } = new() { Fit = true }; /// - /// Сбоить ли Filter статусом UNAVAILABLE (недоступность ai-service/провайдера). + /// Сбоить ли Filter статусом UNAVAILABLE /// public bool FilterUnavailable { get; set; } @@ -78,12 +69,12 @@ public sealed class RecordingAiService : AiService.AiServiceBase public int FitCalls { get; private set; } /// - /// Последний запрос Filter (проверка промпта/текста/ProviderConfig). + /// Последний запрос Filter /// public FilterRequest? LastFilter { get; private set; } /// - /// Последний запрос Classify (проверка system_prompt/user_context/ProviderConfig). + /// Последний запрос Classify /// public ClassifyRequest? LastClassify { get; private set; } @@ -98,12 +89,12 @@ public sealed class RecordingAiService : AiService.AiServiceBase public EvaluateFitRequest? LastFit { get; private set; } /// - /// tenant-id из metadata вызовов (в порядке обращений). + /// tenant-id из metadata вызовов /// public List RequestTenantIds { get; } = []; /// - /// service-token из metadata вызовов (в порядке обращений). + /// service-token из metadata вызовов /// public List RequestTokens { get; } = []; @@ -163,7 +154,6 @@ public sealed class RecordingAiService : AiService.AiServiceBase return Task.FromResult(EvaluateFitReply); } - // Записывает tenant-id/service-token вызова (Ruling 1) для проверок адаптера. // context: Контекст вызова (metadata запроса). private void RecordMetadata(ServerCallContext context) { @@ -171,7 +161,6 @@ public sealed class RecordingAiService : AiService.AiServiceBase 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 b2b0e00..c0e794b 100644 --- a/src/core/tests/Deal.Tests.Unit/Grpc/RecordingMlService.cs +++ b/src/core/tests/Deal.Tests.Unit/Grpc/RecordingMlService.cs @@ -4,35 +4,27 @@ using Grpc.Core; namespace Deal.Tests.Unit.Grpc; /// -/// In-proc фейк ml-service для тестов gRPC-клиента ядра (план Task 16): Recording-сервер MlService. +/// In-proc фейк ml-service для тестов gRPC-клиента ядра /// -/// -/// Реализует 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 — «не уверен», как у неготовой модели). + /// Ответ Predict по умолчанию /// public PredictReply PredictReply { get; set; } = new(); /// - /// Ответ Status по умолчанию (модель не готова). + /// Ответ Status по умолчанию /// public StatusReply StatusReply { get; set; } = new(); /// - /// Ответ Reset по умолчанию (успех). + /// Ответ Reset по умолчанию /// public ResetReply ResetReply { get; set; } = new() { Ok = true }; /// - /// Сбоить ли Predict статусом UNAVAILABLE (недоступность ml-service). + /// Сбоить ли Predict статусом UNAVAILABLE /// public bool PredictUnavailable { get; set; } @@ -72,17 +64,17 @@ public sealed class RecordingMlService : MlService.MlServiceBase public int TrainCalls { get; private set; } /// - /// Полученные батчи обучения (порции флашера, в порядке вызовов). + /// Полученные батчи обучения /// public List TrainBatches { get; } = []; /// - /// tenant-id из metadata вызовов (в порядке обращений). + /// tenant-id из metadata вызовов /// public List RequestTenantIds { get; } = []; /// - /// service-token из metadata вызовов (в порядке обращений). + /// service-token из metadata вызовов /// public List RequestTokens { get; } = []; @@ -139,7 +131,6 @@ public sealed class RecordingMlService : MlService.MlServiceBase return Task.FromResult(new TrainBatchReply { Learned = request.Items.Count }); } - // Записывает tenant-id/service-token вызова (Ruling 1) для проверок клиента. // context: Контекст вызова (metadata запроса). private void RecordMetadata(ServerCallContext context) { @@ -147,7 +138,6 @@ public sealed class RecordingMlService : MlService.MlServiceBase 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 1f48e77..778d40a 100644 --- a/src/core/tests/Deal.Tests.Unit/Grpc/RecordingTelegramService.cs +++ b/src/core/tests/Deal.Tests.Unit/Grpc/RecordingTelegramService.cs @@ -4,48 +4,42 @@ using Grpc.Core; namespace Deal.Tests.Unit.Grpc; /// -/// Фейк-сервер TelegramService (telegram.proto) для in-proc тестов GrpcTelegramClient (план Task 14). +/// Фейк-сервер TelegramService /// -/// -/// Реализует сценариевые 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). + /// Последний запрос StartPhone /// public StartPhoneRequest? LastStartPhone { get; private set; } /// - /// Последний запрос StartQr (apiId/apiHash). + /// Последний запрос StartQr /// public StartQrRequest? LastStartQr { get; private set; } /// - /// Запросы Backfill (dialogId/force) в порядке вызова. + /// Запросы Backfill /// public List BackfillRequests { get; } = []; /// - /// Запросы ReadRecent (dialogId/limit) в порядке вызова. + /// Запросы ReadRecent /// public List ReadRecentRequests { get; } = []; /// - /// Каталог ответа RefreshDialogs (DialogEntry). + /// Каталог ответа RefreshDialogs /// public List Catalog { get; } = []; /// - /// Сколько сообщений «разобрал» Backfill (ответ processed). + /// Сколько сообщений «разобрал» Backfill /// public int BackfillProcessed { get; set; } = 5; /// - /// Доменный сбой следующего вызова (RpcException с detail telegram-service); null — без сбоя. + /// Доменный сбой следующего вызова /// public RpcException? DomainFailure { get; set; } @@ -55,7 +49,7 @@ public sealed class RecordingTelegramService : TelegramService.TelegramServiceBa public string StatusPhase { get; set; } = "idle"; /// - /// Аккаунт ответа GetStatus (account). + /// Аккаунт ответа GetStatus /// public string StatusAccount { get; set; } = string.Empty; @@ -70,12 +64,12 @@ public sealed class RecordingTelegramService : TelegramService.TelegramServiceBa public bool StatusListener { get; set; } /// - /// qrUrl ответа GetStatus/StartQr (пуст — поле не заполнено). + /// qrUrl ответа GetStatus/StartQr /// public string QrUrl { get; set; } = string.Empty; /// - /// error ответа GetStatus (пуст — поле не заполнено). + /// error ответа GetStatus /// public string StatusError { get; set; } = string.Empty; diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/AuditLogStoreTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/AuditLogStoreTests.cs index 21b9652..b0fd7c9 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/AuditLogStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/AuditLogStoreTests.cs @@ -7,15 +7,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Tests.Unit.Infrastructure; /// -/// Юнит-тесты EF-адаптера на InMemory-провайдере: маппинг DTO↔сущности, -/// фильтры выборки, сортировка At DESC, limit ≤500 (Task 4, Ruling 4). +/// Юнит-тесты EF-адаптера на InMemory-провайдере /// -/// -/// Покрываются 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] @@ -110,7 +103,7 @@ public sealed class AuditLogStoreTests } /// - /// Авто-очистка (этап 12, пакет B): удаляет только записи старше границы, свежие остаются. + /// Авто-очистка: удаляет только записи старше границы, свежие остаются. /// [Fact] public async Task PurgeOlderThanAsync_RemovesOnlyRecordsOlderThanCutoff() diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/AuthStoreTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/AuthStoreTests.cs index 73db465..b3722a8 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/AuthStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/AuthStoreTests.cs @@ -6,13 +6,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Tests.Unit.Infrastructure; /// -/// Юнит-тесты EF-адаптера на InMemory-провайдере: список пользователей тенанта -/// (Task 7) и маркер impersonation в сессии (план Task 7). +/// Юнит-тесты EF-адаптера на InMemory-провайдере /// -/// -/// Покрываются round-trip'ы новых маппингов DTO↔сущности (порт модуля не видит EF-сущности, Ruling 1). -/// Удаления (ExecuteDeleteAsync) InMemory-провайдер не поддерживает — не тестируются (Postgres, ⚠ Manual). -/// public sealed class AuthStoreTests { [Fact] diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs index 6dea6eb..9b336d1 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs @@ -10,15 +10,8 @@ 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 }; diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/GlobalSettingsStoreTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/GlobalSettingsStoreTests.cs index b7af223..bfb50d5 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/GlobalSettingsStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/GlobalSettingsStoreTests.cs @@ -6,12 +6,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Tests.Unit.Infrastructure; /// -/// Юнит-тесты EF-адаптера на InMemory-провайдере (ТЗ §4.1/§8.1): -/// чтение отсутствующего ключа, upsert (создание/обновление), сохранение строки как есть. +/// Юнит-тесты EF-адаптера на InMemory-провайдере /// -/// -/// Проверяется семантика адаптера; реальный Postgres (схема public) — в dev-приёмке. -/// public sealed class GlobalSettingsStoreTests { [Fact] diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/InviteStoreTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/InviteStoreTests.cs index 3b26fbd..f227764 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/InviteStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/InviteStoreTests.cs @@ -7,14 +7,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Tests.Unit.Infrastructure; /// -/// Юнит-тесты EF-адаптера на InMemory-провайдере: маппинг DTO↔сущности и статусные операции. +/// Юнит-тесты EF-адаптера на InMemory-провайдере /// -/// -/// Покрываются create/find/list/update round-trip'ы (порт модуля не видит EF-сущности, Ruling 1; маппинг ручной). -/// Частичный unique-индекс invites.Email по pending и FK на оператора InMemory-провайдер не исполняет — -/// их поведение завязано на реляционный Postgres (проверка ⚠ Manual). «expired» адаптер не вычисляет — -/// это зона InvitesService. -/// public sealed class InviteStoreTests { [Fact] diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/MtlsOptionsTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/MtlsOptionsTests.cs index 968844e..f18c071 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/MtlsOptionsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/MtlsOptionsTests.cs @@ -4,18 +4,12 @@ using Microsoft.Extensions.Configuration; namespace Deal.Tests.Unit.Infrastructure; /// -/// Тесты конфиг-парсинга mTLS (план Task 13, Ruling 6): env DEAL_MTLS_* — флаг и пути/пароли; -/// dev-дефолт Enabled=false (режим plaintext + service-token не меняется). +/// Тесты конфиг-парсинга mTLS /// -/// -/// MtlsOptions.FromConfiguration читает только IConfiguration (env-провайдер) — файлы не трогаются -/// (загрузка сертификатов — MtlsCertificatesTests). Значения env: «1»/«true» включают флаг, «0»/«false»/ -/// отсутствие — выключают; пути обрезаются. -/// public sealed class MtlsOptionsTests { /// - /// Пустой конфиг — флаг выключен и все пути пустые (dev-дефолт, plaintext не меняется). + /// Пустой конфиг — флаг выключен и все пути пустые /// [Fact] public void FromConfiguration_WithoutEnvKeys_ReturnsDisabledDefaults() @@ -47,7 +41,7 @@ public sealed class MtlsOptionsTests } /// - /// «0»/«false»/прочие значения/отсутствие флага — mTLS выключен (dev). + /// «0»/«false»/прочие значения/отсутствие флага — mTLS выключен /// [Theory] [InlineData("0")] diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/OperatorAuthStoreTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/OperatorAuthStoreTests.cs index a94ce54..fc4882f 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/OperatorAuthStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/OperatorAuthStoreTests.cs @@ -6,13 +6,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Tests.Unit.Infrastructure; /// -/// Юнит-тесты EF-адаптера на InMemory-провайдере: маппинг DTO↔сущности (минимально). +/// Юнит-тесты EF-адаптера на InMemory-провайдере /// -/// -/// Покрываются create/find round-trip'ы оператора и сессии — все поля маппятся вручную (порт модуля не -/// видит EF-сущности, Ruling 1). Удаления (ExecuteDeleteAsync) InMemory-провайдер не поддерживает и здесь -/// не тестируются — их поведение завязано на реляционный провайдер (Postgres, проверка ⚠ Manual). -/// public sealed class OperatorAuthStoreTests { private const string OperatorPasswordHash = "fake-argon2-encoded-hash"; diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/RateLimitCounterStoreTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/RateLimitCounterStoreTests.cs index 5042173..4c3d0dd 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/RateLimitCounterStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/RateLimitCounterStoreTests.cs @@ -5,14 +5,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Tests.Unit.Infrastructure; /// -/// Юнит-тесты EF-адаптера на InMemory-провайдере (этап 12, -/// пакет B): семантика фиксированного окна — инкремент/накопление в одном окне, сброс при смене окна, -/// чтение текущего окна, удаление счётчика (успешный вход) и уборка завершившихся окон по ExpiresAt. +/// Юнит-тесты EF-адаптера на InMemory-провайдере /// -/// -/// На Postgres инкремент идёт атомарным upsert'ом (ExecuteSql) — здесь проверяется семантическая ветка -/// InMemory/read-modify-write; SQL-ветка проверяется на живом Postgres (см. task-b-report). -/// public sealed class RateLimitCounterStoreTests { // Фиксированное «сейчас» тестов (UTC). diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/SecretCipherTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/SecretCipherTests.cs index 6bc8e53..afa88c3 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/SecretCipherTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/SecretCipherTests.cs @@ -4,7 +4,7 @@ using Deal.Modules.Settings.Application.Abstractions; namespace Deal.Tests.Unit.Infrastructure; /// -/// Тесты AES-256-GCM-шифра секретов: roundtrip, nonce, устойчивость к повреждению (Task 1, Ruling 2). +/// Тесты AES-256-GCM-шифра секретов /// public sealed class SecretCipherTests { diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantLimitStoreTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantLimitStoreTests.cs index 6a5a195..8a929d6 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantLimitStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantLimitStoreTests.cs @@ -8,16 +8,8 @@ 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. +/// Юнит-тесты EF-адаптера на InMemory-провайдере /// -/// -/// Маппинг 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. @@ -32,7 +24,6 @@ public sealed class TenantLimitStoreTests 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); @@ -73,7 +64,6 @@ public sealed class TenantLimitStoreTests 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); @@ -84,8 +74,6 @@ public sealed class TenantLimitStoreTests [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); @@ -118,7 +106,6 @@ public sealed class TenantLimitStoreTests [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(); @@ -137,7 +124,6 @@ public sealed class TenantLimitStoreTests [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); @@ -204,7 +190,6 @@ public sealed class TenantLimitStoreTests 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); @@ -231,7 +216,7 @@ public sealed class TenantLimitStoreTests } /// - /// Авто-очистка (этап 12, пакет B): строки с завершившимся периодом сбрасываются, текущие — нет. + /// Авто-очистка: строки с завершившимся периодом сбрасываются, текущие — нет. /// [Fact] public async Task ResetExpiredPeriodsAsync_ResetsOnlyExpiredPeriods() diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantRepositoryTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantRepositoryTests.cs index 31bbd33..2e29dd5 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantRepositoryTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantRepositoryTests.cs @@ -6,12 +6,8 @@ using Microsoft.EntityFrameworkCore; namespace Deal.Tests.Unit.Infrastructure; /// -/// Юнит-тесты EF-адаптера на InMemory-провайдере: смена статуса (Task 7). +/// Юнит-тесты EF-адаптера на InMemory-провайдере /// -/// -/// CreateAsync/FindByIdAsync/ListAsync покрыты косвенно (Task 1–6); здесь — новый маппинг UpdateStatusAsync -/// (suspend/unsuspend оператора): существующий тенант обновляется (true), неизвестный — false без записи. -/// public sealed class TenantRepositoryTests { [Fact] diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigrationServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigrationServiceTests.cs index 3715471..8e8256b 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigrationServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSchemaMigrationServiceTests.cs @@ -7,14 +7,8 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Deal.Tests.Unit.Infrastructure; /// -/// Тесты пакетной миграции схем тенантов (этап 12, пакет C): провижининг всех схем реестра, отказ одной -/// схемы не прерывает остальные, пустой реестр — корректная пустая сводка. +/// Тесты пакетной миграции схем тенантов /// -/// -/// Реальный TenantProvisioningService требует Postgres, поэтому проверяется координация сервиса на -/// фейках (, ), зеркалящих порты. -/// Идемпотентность повторного прогона обеспечивает EF MigrateAsync (см. TenantProvisioningService). -/// public sealed class TenantSchemaMigrationServiceTests { // Тенант сценария A. diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSettingEntityTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSettingEntityTests.cs index 98b032c..1413d17 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSettingEntityTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/TenantSettingEntityTests.cs @@ -3,13 +3,8 @@ using Deal.Infrastructure.Persistence.Entities; namespace Deal.Tests.Unit.Infrastructure; /// -/// Лёгкий тест сущности настройки тенанта (EF-адаптер SettingsStore работает с TenantSettingEntity). +/// Лёгкий тест сущности настройки тенанта /// -/// -/// Unit-тест SettingsStore требует EF-провайдера/БД; конвенция проекта — реальный Postgres -/// в интеграционных проверках (dev-check), поэтому здесь проверяется только сущность-носитель -/// (как TenantEntityTests). Полный сценарий адаптера — dev-проверка на deal-postgres (отчёт Task 4). -/// public sealed class TenantSettingEntityTests { [Fact] 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 007c0d1..31615c4 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Cards/CardsDomainTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Cards/CardsDomainTests.cs @@ -5,7 +5,7 @@ using Deal.Modules.Cards.Application.Models; namespace Deal.Tests.Unit.Modules.Cards; /// -/// Тесты каркаса единой карточки (T1): маркер модуля, реестры id/контейнеров, агрегат Card. +/// Тесты каркаса единой карточки /// public sealed class CardsDomainTests { 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 2bd8617..c224c64 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Cards/FakeKanjStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Cards/FakeKanjStore.cs @@ -7,26 +7,6 @@ namespace Deal.Tests.Unit.Modules.Cards; /// /// In-memory реализация для unit-тестов сервисов единого домена карточки. /// -/// -/// Реализованы операции досок (список/чтение/создание/обновление/удаление/reorder) и операции карточек, -/// которые используют ContainersService/CardsService/StorageTickService: чтение списка/одной, смена колонки (1:1 с -/// KanbanStore.UpdateColumnAsync: PrevCol=null — не менять, ArchivedAt пишется как есть — null обнуляет), -/// mark-seen, удаление навсегда (карточка уходит, журнал CardMoves не трогается — как каскад БД у комментариев), -/// clear-col, счётчики по колонкам, комментарии (живут на карточке, как приложенный массив адаптера), -/// журнал CardMoves и кандидаты правил хранения тика (Ruling 8): автоархив (доски+inbox по ReceivedAt), -/// очистка архива (col='archive' по ArchivedAt) и корзины (col='trash' по ReceivedAt), пачечное удаление PurgeAsync. -/// Выборка «Неразобранного» для эвристики ИИ-предложений (Task 14) реализована 1:1 с KanbanStore -/// (ListInboxWithSourceAsync: inbox + непустой source_msg, received_at DESC). -/// ReceivedAt карточки — из (epoch-ms, как маппинг адаптера); ArchivedAt в CardDto -/// не выходит (Ruling 10) — хранится рядом словарём (см. ). -/// Перенос карточек доски в inbox при удалении доски — 1:1 с KanbanStore.DeleteContainerAsync -/// (col=inbox, is_new=TRUE, prev_col='inbox'). Методы пересчёта конверсий (Ruling 7) повторяют SQL -/// KanbanStore: кандидаты ListCardsForConversionAsync (Budget != null и колонка не archive/trash), -/// UpdateConversionAsync пишет только conv-поля (convCur пуст → Converted=null). Демо-операции Task 13 -/// повторяют KanbanStore: AddCardAsync (снимок → строка 1:1 с ToCardEntity/ToCardDto), самая старая -/// карточка досок GetOldestBoardCardAsync и сдвиг времени UpdateReceivedAtAsync. Все методы порта -/// реализованы (сценарии задач 6–14); поведение 1:1 с EF-адаптером KanbanStore описано у методов. -/// public class FakeKanjStore : ICardStore { private readonly List _boards = []; @@ -34,10 +14,8 @@ public class FakeKanjStore : ICardStore private readonly List _moves = []; private readonly List<(string Query, int Limit)> _searchCalls = []; - // Id карточек, чьи напоминания уже «выстрелили» (аналог колонки CardEntity.ReminderFired). private readonly HashSet _firedById = new(StringComparer.Ordinal); - // Метки архивации карточек (аналог колонки Cards.ArchivedAt; CardDto её не несёт, Ruling 10). private readonly Dictionary _archivedAtById = new(StringComparer.Ordinal); /// @@ -46,33 +24,33 @@ public class FakeKanjStore : ICardStore public IReadOnlyList Boards => _boards.ToList(); /// - /// Карточки фейка как тройки (проверка переноса в inbox при удалении доски — BoardsServiceTests). + /// Карточки фейка как тройки /// public IReadOnlyList<(string CardId, string Col, bool IsNew)> Cards => _cards.Select(card => (card.Id, card.Col, card.IsNew)).ToList(); /// - /// Полные карточки фейка (копия на момент обращения) — проверки CardsServiceTests. + /// Полные карточки фейка /// public IReadOnlyList CardDtos => _cards.ToList(); /// - /// Флаг «сбой записи карточки»: AddCardAsync бросает (сценарий ошибки создания — тесты PipelineCardWriter). + /// Флаг «сбой записи карточки» /// public bool FailAddCard { get; set; } /// - /// Записи журнала CardMoves (копия на момент обращения) — проверка журналирования действий. + /// Записи журнала CardMoves /// public IReadOnlyList Moves => _moves.ToList(); /// - /// Вызовы SearchCardsAsync как пары (q, limit) в порядке вызовов — проверка делегирования CardsService. + /// Вызовы SearchCardsAsync как пары /// public IReadOnlyList<(string Query, int Limit)> SearchCalls => _searchCalls.ToList(); /// - /// Кладёт доску напрямую (сценарий «в БД уже есть строки» — произвольные позиции/флаги). + /// Кладёт доску напрямую /// /// Доска как если бы была сохранена в БД. public void SeedBoard(ContainerDto board) @@ -81,7 +59,7 @@ public class FakeKanjStore : ICardStore } /// - /// Кладёт карточку в колонку (сценарий «в доске есть карточки» для delete-тестов, Task 6). + /// Кладёт карточку в колонку. /// /// Id карточки. /// Колонка (inbox/доска). @@ -91,7 +69,7 @@ public class FakeKanjStore : ICardStore } /// - /// Кладёт полную карточку (сценарий «строка Cards в БД» для CardsServiceTests, Task 7). + /// Кладёт полную карточку. /// /// Карточка как если бы была сохранена в БД (комментарии — приложенным массивом). public void SeedCard(CardDto card) @@ -136,7 +114,6 @@ public class FakeKanjStore : ICardStore /// public Task DeleteContainerAsync(string containerId, CancellationToken ct) { - // 1:1 с KanbanStore.DeleteContainerAsync: карточки колонки → inbox (is_new=TRUE, prev_col=inbox). List moved = _cards.Where(card => card.Col == containerId).ToList(); for (int i = 0; i < _cards.Count; i++) { @@ -174,7 +151,6 @@ public class FakeKanjStore : ICardStore /// public Task> ListCardsAsync(CardsQuery query, CancellationToken ct) { - // 1:1 с KanbanStore.ListCardsAsync: фильтр колонки либо все колонки дашборда, ORDER BY received_at DESC. IEnumerable result = query.Col is null ? _cards : _cards.Where(card => card.Col == query.Col); @@ -188,7 +164,6 @@ public class FakeKanjStore : ICardStore int limit, CancellationToken ct) { - // 1:1 с KanbanStore.SearchCardsAsync для unit-сценариев: FTS-часть (tsvector/морфология) в фейке // недостижима — реализуется LIKE-дополнение (lower title/summary/contact/source_msg), порядок // ReceivedAt DESC, лимит. Вызов записывается (SearchCalls) — тесты сервиса видят // делегирование с q/лимитом. @@ -222,7 +197,6 @@ public class FakeKanjStore : ICardStore long msgId, CancellationToken ct) { - // 1:1 с KanbanStore.GetCardBySourceAsync: свежайшая карточка по (dialogId, msgId); пустой dialogId — null. if (string.IsNullOrEmpty(dialogId)) { return Task.FromResult(null); @@ -243,7 +217,6 @@ public class FakeKanjStore : ICardStore throw new InvalidOperationException("Тестовый сбой записи карточки (FailAddCard)."); } - // 1:1 с KanbanStore.ToCardEntity/ToCardDto: снимок → строка Cards → DTO чтения (human-метка времени // свежей карточки — «только что», как HumanAge(now)); стартовые комментарии/история — из снимка. _cards.Add(new CardDto { @@ -284,7 +257,6 @@ public class FakeKanjStore : ICardStore /// public Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct) { - // 1:1 с KanbanStore.UpdateColumnAsync: PrevCol=null — не менять; ArchivedAt пишется как есть (null — // обнуляет, возврат из архива/корзины); нет карточки — no-op (валидирует сервис). ArchivedAt наружу // не выходит (его нет в CardDto) — храним рядом в archivedAtById. int index = _cards.FindIndex(card => card.Id == update.CardId); @@ -315,7 +287,6 @@ public class FakeKanjStore : ICardStore /// public Task ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct) { - // 1:1 с KanbanStore.ApplyReclassificationAsync: полная замена полей классификации; PrevCol/ArchivedAt // не трогаются; нет карточки — false (404-семантика сервиса). int index = _cards.FindIndex(card => card.Id == update.CardId); if (index < 0) @@ -439,8 +410,6 @@ public class FakeKanjStore : ICardStore /// public Task> GetAiMarkupExamplesAsync(int limit, CancellationToken ct) { - // 1:1 с KanbanStore.GetAiMarkupExamplesAsync: действия move/restore, цель не служебная, исходник непустой; - // журнал фейка не хранит CreatedAt — «свежесть» = порядок вставки (python ORDER BY created_at DESC). var examples = new List(); for (int index = _moves.Count - 1; index >= 0 && examples.Count < limit; index--) { @@ -467,12 +436,10 @@ public class FakeKanjStore : ICardStore return Task.FromResult>(examples); } - // ── Правила хранения (тик, Ruling 8; StorageTickServiceTests) ───────────── /// public Task> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) { - // 1:1 с KanbanStore.ListArchiveCandidatesAsync: карточки досок и inbox, received_at < границы (tick_storage L462–467). var boardIds = new HashSet(_boards.Select(board => board.Id), StringComparer.Ordinal); IReadOnlyList result = _cards .Where(card => (card.Col == KanbanColumns.Inbox || boardIds.Contains(card.Col)) @@ -488,7 +455,6 @@ public class FakeKanjStore : ICardStore DateTimeOffset archivedAt, CancellationToken ct) { - // 1:1 с KanbanStore.ArchiveAsync: один batch-перенос пачки в архив (col=archive, is_new=false, // archived_at=archivedAt, matchHits пусто; prev_col не трогается). Возврат — число архивированных. if (cardIds.Count == 0) { @@ -520,7 +486,6 @@ public class FakeKanjStore : ICardStore /// public Task> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct) { - // 1:1 с KanbanStore: col='archive' с непустой archived_at старше границы (tick_storage L475–478). IReadOnlyList result = _cards .Where(card => card.Col == KanbanColumns.Archive && _archivedAtById.TryGetValue(card.Id, out DateTimeOffset archivedAt) @@ -533,7 +498,6 @@ public class FakeKanjStore : ICardStore /// public Task> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) { - // 1:1 с KanbanStore: col='trash' с received_at старше границы (tick_storage L480–483). IReadOnlyList result = _cards .Where(card => card.Col == KanbanColumns.Trash && ReceivedAtOf(card) < receivedBeforeUtc) .Select(card => card.Id) @@ -561,14 +525,14 @@ public class FakeKanjStore : ICardStore } /// - /// Задаёт метку архивации карточки (сценарий «строка уже в архиве с archived_at», StorageTickServiceTests). + /// Задаёт метку архивации карточки /// /// Id карточки. /// Метка архивации (когда карточка ушла в архив). public void SetArchivedAt(string cardId, DateTimeOffset archivedAt) => _archivedAtById[cardId] = archivedAt; /// - /// Метка архивации карточки — проверка archived_at после автоархива тика (Ruling 8). + /// Метка архивации карточки — проверка archived_at после автоархива тика. /// /// Id карточки. /// Метка архивации либо null — карточки нет/не архивирована/метка сброшена. @@ -582,13 +546,10 @@ public class FakeKanjStore : ICardStore // Возвращает: ReceivedAt карточки как UTC-момент. private static DateTimeOffset ReceivedAtOf(CardDto card) => DateTimeOffset.FromUnixTimeMilliseconds(card.ReceivedAtMs); - // ── Конверсии (Ruling 7; ConversionRecomputerTests) ────────────────────── /// public Task> ListCardsForConversionAsync(CancellationToken ct) { - // 1:1 с KanbanStore.ListCardsForConversionAsync: бюджет задан (BudgetCur != '' → Budget не null) - // и колонка не archive/trash (recompute_conversions L115–118). IReadOnlyList result = _cards .Where(card => card.Budget is not null && card.Col != KanbanColumns.Archive @@ -606,7 +567,6 @@ public class FakeKanjStore : ICardStore string convCur, CancellationToken ct) { - // 1:1 с KanbanStore.UpdateConversionAsync: пишутся только conv-поля; нет карточки — no-op. // convCur пуст → конверсия снята (Converted = null, как маппинг адаптера ConvCur == ""). int index = _cards.FindIndex(card => card.Id == cardId); if (index >= 0) @@ -622,14 +582,11 @@ public class FakeKanjStore : ICardStore return Task.CompletedTask; } - // ── Эвристика ИИ-предложений (Ruling 3, Task 14; LocalColumnSuggesterTests) ── - // ── Операции пространства «Выбранные» (этап 9) ────────────────────────── /// public Task> ListSelectedCardsAsync(string? containerId, CancellationToken ct) { - // 1:1 с KanbanStore.ListSelectedCardsAsync: без фильтра — только стадии «Выбранных», ORDER BY UpdatedAt DESC. IEnumerable query = containerId is null ? _cards.Where(card => CardsDefaultContainers.Contains(card.Col)) : _cards.Where(card => card.Col == containerId); @@ -895,8 +852,6 @@ public class FakeKanjStore : ICardStore /// public Task> ListInboxWithSourceAsync(CancellationToken ct) { - // 1:1 с KanbanStore.ListInboxWithSourceAsync: «Неразобранное» с непустым source_msg, - // ORDER BY received_at DESC (эвристика анализирует только source_msg — Ruling 3). IReadOnlyList result = _cards .Where(card => card.Col == KanbanColumns.Inbox && card.SourceMsg != string.Empty) .OrderByDescending(card => card.ReceivedAtMs) 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 1ccd87e..5a07a7c 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Cards/LocalSourceStub.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Cards/LocalSourceStub.cs @@ -2,7 +2,6 @@ using Deal.Modules.Cards.Application.Abstractions; namespace Deal.Tests.Unit.Modules.Cards; -// Тестовая реализация локального источника (каркас T1; боевая — в T2/T3). internal sealed class LocalSourceStub(string authorId) : ILocalSource { public string? AuthorId { get; } = authorId; 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 9aedfd9..bd47a15 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Cards/TelegramSourceStub.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Cards/TelegramSourceStub.cs @@ -2,7 +2,6 @@ using Deal.Modules.Cards.Application.Abstractions; namespace Deal.Tests.Unit.Modules.Cards; -// Тестовая реализация Telegram-источника (каркас T1; боевая — в T2/T3). internal sealed class TelegramSourceStub(string dialogId, long messageId, string? peerHandle, string peerName, string? topicId) : ITelegramSource { 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 4d7abc0..b5cb23d 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryBanGuardTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryBanGuardTests.cs @@ -6,14 +6,8 @@ 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). +/// Тесты DiscoveryBanGuard — суточный лимит авто-вступлений, flood-день, стоп-кран. /// -/// -/// Гард чистый: считает DiscLog (event=join_auto) за UTC-сутки через и читает -/// KV-настройки через . «Часы» фиксированы и совпадают у стора и гарда (эталон -/// FakeDiscoveryStore: конструктор принимает источник времени) — границы суток детерминированы. -/// public sealed class DiscoveryBanGuardTests { // Фиксированный «сейчас» теста (UTC-полдень; границы суток далеко от краёв). @@ -36,7 +30,6 @@ public sealed class DiscoveryBanGuardTests { (DiscoveryBanGuard guard, FakeDiscoveryStore store, _) = Create(); - // Суточный лимит 50 исчерпан (50 авто-вступлений сегодня) — вступление нельзя (python L38–41). for (int i = 0; i < 50; i++) { store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i)); 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 f3658e1..f3aa523 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryBlacklistServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryBlacklistServiceTests.cs @@ -4,14 +4,8 @@ using Deal.Modules.Discovery.Application.Services; namespace Deal.Tests.Unit.Modules.Discovery; /// -/// Тесты DiscoveryBlacklistService — добавление с upsert-перезаписью, снятие, список (план Task 17, 1:1 L568–589). +/// Тесты DiscoveryBlacklistService — добавление с upsert-перезаписью, снятие, список. /// -/// -/// Сервис чистый: оркестрирует (DiscBlacklist). Проверяется семантика python: -/// add_blacklist L568–578 (пустое имя → DialogId; ON CONFLICT DO UPDATE name/reason при сохранённом CreatedAt), -/// remove_blacklist L581–582, list_blacklist L585–589 (новые первыми). «Перезапись» существующей записи (та же -/// семантика, что пишет mark_rejected повторно вступившего-и-вышедшего источника) — без смены CreatedAt. -/// public sealed class DiscoveryBlacklistServiceTests { [Fact] @@ -36,7 +30,6 @@ public sealed class DiscoveryBlacklistServiceTests // Первая запись источника — CreatedAt фиксируется (t=1000). DiscoveryBlacklistDto first = await service.AddAsync("-1001", "Первое имя", "отклонено вручную", CancellationToken.None); - // Повторная запись того же источника (перезапись) — имя/причина обновляются, CreatedAt сохраняется (L572–575). DiscoveryBlacklistDto overwritten = await service.AddAsync("-1001", "Второе имя", "отклонён повторно", CancellationToken.None); Assert.Equal("Второе имя", overwritten.Name); 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 6504a17..d533437 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryCandidatesServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryCandidatesServiceTests.cs @@ -5,15 +5,8 @@ using Deal.Modules.Discovery.Application.Services; namespace Deal.Tests.Unit.Modules.Discovery; /// -/// Тесты DiscoveryCandidatesService — add с исключениями, set_candidate, review, mark_joined/rejected (план Task 17, 1:1 discovery.py L385–563). +/// Тесты DiscoveryCandidatesService — add с исключениями, set_candidate, review, mark_joined/rejected. /// -/// -/// Сервис чистый: оркестрирует (DiscCandidates + счётчики DiscTasks + мониторинг -/// Dialogs), и (те же фейки). Логи/счётчики -/// проверяются по свойствам фейка: skip — возврат null + запись лога (python L405–430), mark_joined — joined/ -/// autoJoined + счётчик joined + лог join_auto/join_manual (L523–538), mark_rejected — rejected + счётчик rejected + -/// лог reject + чёрный список (L541–563). -/// public sealed class DiscoveryCandidatesServiceTests { [Fact] @@ -112,7 +105,6 @@ public sealed class DiscoveryCandidatesServiceTests DiscoveryCandidateDto? candidate = await service.AddAsync( "dt_1", "-1001", "Канал", "", DiscoveryCandidateKinds.Channel, "", CancellationToken.None); - // Устаревшая rejected-запись перезаписывается новым кандидатом (python L431–433), found +1. Assert.NotNull(candidate); Assert.Equal(DiscoveryCandidateStatuses.New, candidate!.Status); DiscoveryCandidateDto stored = Assert.Single(store.Candidates); @@ -175,7 +167,6 @@ public sealed class DiscoveryCandidatesServiceTests DiscoveryCandidateDto? candidate = await service.MarkJoinedAsync("-1001", auto: true, CancellationToken.None); - // Повторный вызов для joined — без счётчика и лога (python L528–529). Assert.NotNull(candidate); Assert.Equal(DiscoveryCandidateStatuses.Joined, candidate!.Status); Assert.Equal(0, store.Tasks.Single().Joined); @@ -224,7 +215,6 @@ public sealed class DiscoveryCandidatesServiceTests DiscoveryCandidateDto? candidate = await service.MarkRejectedAsync( "-1001", "снова", CancellationToken.None); - // Повторный вызов для rejected — без счётчика/лога/перезаписи чёрного списка (python L552–553). Assert.NotNull(candidate); Assert.Equal(0, store.Tasks.Single().Rejected); Assert.Empty(store.Log); 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 fadca97..c0e297b 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryLangDetectorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryLangDetectorTests.cs @@ -3,8 +3,7 @@ using Deal.Modules.Discovery.Application.Services; namespace Deal.Tests.Unit.Modules.Discovery; /// -/// Тесты DiscoveryLangDetector — доля кириллицы в выборке (1:1 discovery_eval.detect_lang_ru L62–83, -/// план Task 18). +/// Тесты DiscoveryLangDetector — доля кириллицы в выборке. /// public sealed class DiscoveryLangDetectorTests { 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 f684097..6db3fbe 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryLogServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryLogServiceTests.cs @@ -4,12 +4,8 @@ using Deal.Modules.Discovery.Application.Services; namespace Deal.Tests.Unit.Modules.Discovery; /// -/// Тесты DiscoveryLogService — запись событий и чтение истории (план Task 17, 1:1 add_log/task_log L594–608). +/// Тесты DiscoveryLogService — запись событий и чтение истории. /// -/// -/// Сервис чистый: пишет в (DiscLog) с id dl_ (генератор модуля) и читает -/// последние события задачи — новые сверху (ORDER BY created_at DESC), как GET …/tasks/{id}/log. -/// public sealed class DiscoveryLogServiceTests { [Fact] 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 109caea..1c5f64c 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoverySearchErrorCounterTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoverySearchErrorCounterTests.cs @@ -3,13 +3,8 @@ using Deal.Modules.Discovery.Application.Services; namespace Deal.Tests.Unit.Modules.Discovery; /// -/// Тесты — инкремент/сброс ошибок ключа поиска и TTL-эвикция -/// (Task 18 + quality review: singleton без эвикции копил строки удалённых задач). +/// Тесты — инкремент/сброс ошибок ключа поиска и TTL-эвикция. /// -/// -/// Счётчик чистый: часы инъекцией (фейковый источник) — TTL проверяется без ожидания 1 часа. Поведение ядра -/// (3 ошибки подряд → пропуск ключа) покрыто DiscoveryWorkerServiceTests — здесь контракт самого счётчика. -/// public sealed class DiscoverySearchErrorCounterTests { [Fact] 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 a28ebd3..0a9dcec 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryTasksServiceTests.cs @@ -6,14 +6,8 @@ using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Modules.Discovery; /// -/// Тесты DiscoveryTasksService — создание/патч/удаление/start/pause и план-бюджет (план Task 17, 1:1 discovery.py L234–381). +/// Тесты DiscoveryTasksService — создание/патч/удаление/start/pause и план-бюджет. /// -/// -/// Сервис чистый: оркестрирует (семантика 1:1 с DiscoveryStore), читает дефолты -/// через (отсутствие строки → SettingsDefaults: discJoinLimit=50, discEvalThreshold=40, -/// discEvalSample=10) и план-бюджет через . 400-семантика — исключение -/// с текстом python; 404 — null (текст у эндпоинта Task 19). -/// public sealed class DiscoveryTasksServiceTests { [Fact] @@ -61,7 +55,6 @@ public sealed class DiscoveryTasksServiceTests 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); } 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 59582db..286009d 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/FakeDiscoveryPacer.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/FakeDiscoveryPacer.cs @@ -3,19 +3,17 @@ using Deal.Modules.Discovery.Application.Abstractions; namespace Deal.Tests.Unit.Modules.Discovery; /// -/// Фейковый для тестов воркера Discovery (план Task 18): паузы мгновенные, -/// вызовы считаются. позволяет сценарию менять состояние во время «паузы» (проверка -/// повторной перепроверки кандидата/задачи после wait_join_delay, python L379–394). +/// Фейковый для тестов воркера Discovery /// public sealed class FakeDiscoveryPacer : IDiscoveryPacer { /// - /// Действие, выполняемое в момент паузы (имитация изменений за 50–70 с ожидания); null — нет. + /// Действие, выполняемое в момент паузы /// public Action? OnDelay { get; set; } /// - /// Сколько раз запрошена пауза перед авто-вступлением (0 — join без паузы не делается). + /// Сколько раз запрошена пауза перед авто-вступлением /// public int DelayCalls { get; private set; } 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 589e4bc..f5662ef 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/FakeDiscoveryStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/FakeDiscoveryStore.cs @@ -4,17 +4,8 @@ using Deal.Modules.Discovery.Application.Models; namespace Deal.Tests.Unit.Modules.Discovery; /// -/// In-memory реализация для unit-тестов Discovery (план Task 17). +/// In-memory реализация для unit-тестов Discovery. /// -/// -/// Поведение 1:1 с EF-адаптером DiscoveryStore.cs (Task 17): задачи/кандидаты/чёрный список/лог — DTO как после -/// маппинга адаптера (marks/topics типизированными списками, времена epoch-ms); CreateTask/CreateCandidate -/// проставляют служебные дефолты INSERT python (draft/new/счётчики 0) и CreatedAt/UpdatedAt по «часам» фейка -/// (адаптер — UTC-now); мутации бампают UpdatedAt. Мониторинг Dialogs (проверка add_candidate «уже мониторится») -/// моделируется множеством — тесты кладут источники через . -/// Удаление задачи каскадит кандидатов и лог (delete_task), чёрный список — общий (не трогается). Upsert чёрного -/// списка сохраняет CreatedAt (ON CONFLICT python). -/// public sealed class FakeDiscoveryStore : IDiscoveryStore { private readonly List _tasks = []; @@ -25,7 +16,7 @@ public sealed class FakeDiscoveryStore : IDiscoveryStore private readonly Func _clock; /// - /// Создаёт фейк с «часами» по умолчанию (UTC-now) либо заданными (детерминизм bump-тестов). + /// Создаёт фейк с «часами» по умолчанию /// /// Источник текущего времени (по умолчанию ). public FakeDiscoveryStore(Func? clock = null) @@ -34,27 +25,27 @@ public sealed class FakeDiscoveryStore : IDiscoveryStore } /// - /// Задачи фейка (копия на момент обращения) — проверки DiscoveryTasksServiceTests. + /// Задачи фейка /// public IReadOnlyList Tasks => _tasks.ToList(); /// - /// Кандидаты фейка (копия) — проверки DiscoveryCandidatesServiceTests. + /// Кандидаты фейка /// public IReadOnlyList Candidates => _candidates.ToList(); /// - /// Чёрный список фейка (копия) — проверки blacklist-сервиса/кандидатов. + /// Чёрный список фейка /// public IReadOnlyList Blacklist => _blacklist.ToList(); /// - /// Лог фейка в порядке записи (копия) — проверки логов skip/join/reject/review. + /// Лог фейка в порядке записи /// public IReadOnlyList Log => _log.ToList(); /// - /// Кладёт задачу напрямую (сценарий «строка DiscTasks в БД»). + /// Кладёт задачу напрямую /// /// Задача как если бы была прочитана адаптером. public void SeedTask(DiscoveryTaskDto task) @@ -63,7 +54,7 @@ public sealed class FakeDiscoveryStore : IDiscoveryStore } /// - /// Кладёт кандидата напрямую (сценарий «строка DiscCandidates в БД»). + /// Кладёт кандидата напрямую /// /// Кандидат как если бы был прочитан адаптером. public void SeedCandidate(DiscoveryCandidateDto candidate) @@ -72,7 +63,7 @@ public sealed class FakeDiscoveryStore : IDiscoveryStore } /// - /// Помечает источник «уже мониторится» — есть в каталоге Dialogs (add_candidate L418). + /// Помечает источник «уже мониторится» — есть в каталоге Dialogs. /// /// Подписанный id источника. public void SeedMonitored(string dialogId) @@ -81,7 +72,7 @@ public sealed class FakeDiscoveryStore : IDiscoveryStore } /// - /// Кладёт событие лога напрямую (сценарий «строка DiscLog в БД»; счётчик квоты бан-гарда). + /// Кладёт событие лога напрямую /// /// Событие (join_auto/…). /// Момент события (UTC). @@ -205,7 +196,6 @@ public sealed class FakeDiscoveryStore : IDiscoveryStore return false; } - // delete_task L314–318: задача + кандидаты + лог; чёрный список общий — не трогаем. _tasks.RemoveAll(task => task.Id == taskId); _candidates.RemoveAll(candidate => candidate.TaskId == taskId); _log.RemoveAll(entry => entry.TaskId == taskId); @@ -559,7 +549,6 @@ public sealed class FakeDiscoveryStore : IDiscoveryStore } else { - // add_blacklist ON CONFLICT L572–575: name/reason обновляются, CreatedAt сохраняется. _blacklist[_blacklist.IndexOf(existing)] = existing with { Name = name, Reason = reason }; } 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 e575a94..ed8a39d 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AiClassifyContextBuilderTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AiClassifyContextBuilderTests.cs @@ -8,15 +8,8 @@ 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-разработка"; @@ -50,7 +43,6 @@ public sealed class AiClassifyContextBuilderTests string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None); - // Дефолты сферы 1:1 с ai.py fill_prompt L69–76 (PromptFiller). Assert.Equal( $"[{PromptFiller.FallbackDomain}] {PromptFiller.NoKeywordsHint}", prompt); @@ -68,7 +60,6 @@ public sealed class AiClassifyContextBuilderTests string prompt = await builder.BuildClassifySystemPromptAsync(CancellationToken.None); - // python L255–257: prompt + "\n\n" + card (когда cardPrompt непуст). Assert.Equal("Классифицируй IT-разработка.\n\nВерни блок «О заявке» бот, сайт.", prompt); } @@ -120,7 +111,6 @@ public sealed class AiClassifyContextBuilderTests 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); @@ -144,7 +134,6 @@ public sealed class AiClassifyContextBuilderTests 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", 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 b0c65dc..1bc1907 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AmountParserTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/AmountParserTests.cs @@ -3,13 +3,8 @@ using Deal.Modules.Kanban.Application.ColumnRules; namespace Deal.Tests.Unit.Modules.Kanban; /// -/// Тесты парсера сумм — AmountParser (план Task 3 L234–235, L244–246; rules.py extract_amounts L93–144). +/// Тесты парсера сумм — AmountParser. /// -/// -/// Кейсы Acceptance и прототипа: «от…до», «до…», диапазоны «A–B», суффикс «2к», символы и слова валют, -/// суммы без валюты игнорируются. Семантика 1:1 с rules.py, включая особенности («usdt» словом → USD — -/// первый startswith «usd» L82; «евро» слова в прототипе нет). Распознаются ТОЛЬКО суммы с валютой. -/// public sealed class AmountParserTests { // Парсит текст и возвращает единственную сумму (тест-хелпер). @@ -38,14 +33,12 @@ public sealed class AmountParserTests [Fact] public void Parse_FromOnly_ReturnsSingleAmount() { - // «от A» без верхней границы — одна сумма (from=to, rules.py L127–131). Assert.Equal(new AmountRange(1000, 1000, "EUR"), Single("Ставка от 1000 €")); } [Fact] public void Parse_WordRangeWithKSuffix_MultipliesByThousand() { - // «2к»/«1.5к» — тысячи (rules.py L58, L66–68). Assert.Equal(new AmountRange(1500, 3000, "USD"), Single("ЗП от 1.5к до 3к $")); } @@ -66,8 +59,6 @@ public sealed class AmountParserTests [Fact] public void Parse_DollarSymbolBeforeRange_IsNotDetected() { - // Особенность прототипа: символ ПЕРЕД числом виден только в окне ≤3 символов до КОНЦА суммы - // (rules.py L86–89), где стоят цифры, — «$50–100» валюту не даёт (как и в rules.py). Assert.Empty(AmountParser.Parse("Вилка $50–100")); } @@ -96,14 +87,12 @@ public sealed class AmountParserTests [Fact] public void Parse_WordUsdtAfterNumber_IsParsedAsUsd() { - // Особенность прототипа: первый startswith «usd» побеждает (rules.py L82) — «usdt» → USD. Assert.Equal(new AmountRange(2000, 2000, "USD"), Single("Бюджет 2000 usdt")); } [Fact] public void Parse_SymbolTugrik_MapsToUsdt() { - // Символ ₮ проверяется раньше слов и даёт именно USDT (rules.py L15). Assert.Equal(new AmountRange(2000, 2000, "USDT"), Single("Бюджет 2000 ₮")); } @@ -120,7 +109,6 @@ public sealed class AmountParserTests [Fact] public void Parse_WordRangeAndSingleAmount_NoDuplicates() { - // Конструкции на одном тексте не дублируются (занятые диапазоны, _free L106–112). IReadOnlyList amounts = AmountParser.Parse("от 1000 до 2000 $ и ещё 3000 €"); Assert.Equal(2, amounts.Count); @@ -133,7 +121,6 @@ public sealed class AmountParserTests [Fact] public void Parse_AmountWithoutCurrency_ReturnsEmpty() { - // Суммы без валюты игнорируются (rules.py L98) — и «от…до», и одиночные. Assert.Empty(AmountParser.Parse("от 1000 до 2000")); Assert.Empty(AmountParser.Parse("Зарплата 1000")); } 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 cfbe5ac..2d03213 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/BudgetNormalizerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/BudgetNormalizerTests.cs @@ -4,17 +4,10 @@ using Deal.Modules.Kanban.Application.Services; namespace Deal.Tests.Unit.Modules.Kanban; /// -/// Тесты нормализации бюджета — BudgetNormalizer (план Task 3 L241–246, Ruling 7; ai.py clean_budget L316–326, -/// budget_to_target L342–352, _norm_currency L279–291). +/// Тесты нормализации бюджета — BudgetNormalizer. /// -/// -/// Чистый класс: Normalize приводит бюджет к форме хранения (одна сумма → from=to, «до X» → from=null, -/// from=0 → null, алиасы валют → коды); ToTarget пересчитывает в целевую валюту при conversionOn по курсам -/// (USDT=USD через RatesService.ConvertAmount, rates.py L86–103). Курсы в тестах — мок-курсы прототипа. -/// public sealed class BudgetNormalizerTests { - // Курсы как в constants.py MOCK_RATES (rates.py L86–91: USDT=USD). private static readonly IReadOnlyDictionary MockRates = new Dictionary { ["RUB"] = 1.0, @@ -23,7 +16,6 @@ public sealed class BudgetNormalizerTests ["USDT"] = 92.5, }; - // ─── Normalize = clean_budget (ai.py L316–326) ───────────────────────── [Fact] public void Normalize_SingleSum_SetsToEqualsFrom() @@ -52,7 +44,6 @@ public sealed class BudgetNormalizerTests [Fact] public void Normalize_FromZero_TreatsAsNoLowerBound() { - // «от 0 до 100» == «до 100» (from=0 → null, ai.py L322–323). CardBudgetDto? result = BudgetNormalizer.Normalize(new BudgetRangeDto(From: 0, To: 100, Cur: "RUB")); Assert.Equal(new CardBudgetDto(From: null, To: 100, Cur: "RUB"), result); @@ -61,7 +52,6 @@ public sealed class BudgetNormalizerTests [Fact] public void Normalize_BothBoundsZero_ReturnsNull() { - // Число 0 → None в _budget_num (L313): обе границы отсутствуют — бюджета нет. Assert.Null(BudgetNormalizer.Normalize(new BudgetRangeDto(From: 0, To: 0, Cur: "USD"))); } @@ -80,7 +70,6 @@ public sealed class BudgetNormalizerTests [Fact] public void Normalize_UnknownCurrency_ReturnsNull() { - // «₴»/«гривна» нет в алиасах прототипа (ai.py L271–276) — валюта не распознана. Assert.Null(BudgetNormalizer.Normalize(new BudgetRangeDto(From: 100, To: 200, Cur: "₴"))); } @@ -100,7 +89,6 @@ public sealed class BudgetNormalizerTests Assert.Equal(new CardBudgetDto(100, 100, "EUR"), result); } - // ─── ToTarget = budget_to_target (ai.py L342–352) ────────────────────── [Fact] public void ToTarget_ConversionDisabled_ReturnsNull() @@ -128,7 +116,6 @@ public sealed class BudgetNormalizerTests targetCurrency: "RUB", MockRates); - // 1000×92.5=92 500, 2000×92.5=185 000 (округление до 2 знаков, rates.py L103). Assert.Equal(new CardBudgetDto(92500, 185000, "RUB"), result); } @@ -147,7 +134,6 @@ public sealed class BudgetNormalizerTests [Fact] public void ToTarget_UsdtBudget_UsesUsdRate() { - // Курсов USDT в словаре нет — работает подстановка USDT=USD (rates.py L86–91). var ratesWithoutUsdt = new Dictionary { ["RUB"] = 1.0, ["USD"] = 92.5 }; CardBudgetDto? result = BudgetNormalizer.ToTarget( @@ -162,7 +148,6 @@ public sealed class BudgetNormalizerTests [Fact] public void ToTarget_CurrencyMissingInRates_KeepsTargetCurrencyWithNullBounds() { - // Как в прототипе: курс не нашёлся → convFrom/convTo None, но convCur = target (L351–357). CardBudgetDto? result = BudgetNormalizer.ToTarget( new CardBudgetDto(1000, 2000, "KZT"), conversionOn: true, 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 0b86e49..de9f5c5 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/CardsServiceRemindersTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/CardsServiceRemindersTests.cs @@ -8,23 +8,12 @@ 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() @@ -60,7 +49,6 @@ public sealed class CardsServiceRemindersTests [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); @@ -72,7 +60,6 @@ public sealed class CardsServiceRemindersTests [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; @@ -84,7 +71,6 @@ public sealed class CardsServiceRemindersTests Assert.Equal(atMs, result.Card!.Reminder!.At); } - // ─── Clear (clear_reminder L246–247) ─────────────────────────────────── [Fact] public async Task Clear_WithReminder_ClearsItAndReturnsTrue() @@ -111,7 +97,6 @@ public sealed class CardsServiceRemindersTests [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) }); @@ -121,7 +106,6 @@ public sealed class CardsServiceRemindersTests Assert.Null(Assert.Single(store.CardDtos).Reminder); } - // ─── Snooze (snooze L257–261) ────────────────────────────────────────── [Fact] public async Task Snooze_MovesReminderToNowPlus24h() @@ -151,7 +135,6 @@ public sealed class CardsServiceRemindersTests [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) }); @@ -161,7 +144,6 @@ public sealed class CardsServiceRemindersTests Assert.NotNull(Assert.Single(store.CardDtos).Reminder); } - // ─── CheckDueRemindersAsync (check_reminders L264–282) ────────────────── [Fact] public async Task CheckDue_Disabled_ClearsExpiredAndReturnsEmpty() 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 835351c..5756620 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/ColumnRulesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/ColumnRulesTests.cs @@ -4,18 +4,10 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Tests.Unit.Modules.Kanban; /// -/// Тесты чистых правил колонок — ColumnRules (план Task 3 L231–250, Ruling 2; rules.py match_text L176–209, -/// excluded_terms/is_excluded L230–248, board_accepts L251–268, hits L271–296, hits_for_board L311–319, -/// has_active_rules L322–338, describe L341–368). +/// Тесты чистых правил колонок — ColumnRules. /// -/// -/// Покрывает кейсы Acceptance: keywords any/all и регистр, exclude-veto, grade-алиасы («mid»→middle), -/// budget-диапазон с конвертацией (USDT=USD), пустые правила → [], ContainerAccepts-кейсы, содержание hits и -/// describe. Курсы для конвертации — мок-курсы Settings (MockRates.Values), как в прототипе rates.py. -/// public sealed class ColumnRulesTests { - // Мок-курсы прототипа (USD 92.5 и т.д.) — для бюджетной конвертации. private static readonly IReadOnlyDictionary MockRates = new Dictionary { ["RUB"] = 1.0, @@ -54,7 +46,6 @@ public sealed class ColumnRulesTests budget); } - // ─── Совпадения keywords: режимы any/all и регистр (match_text L176–209) ── [Fact] public void Matches_KeywordsModeAll_SingleGroupHit_ReturnsTrue() @@ -118,11 +109,9 @@ public sealed class ColumnRulesTests [Fact] public void Matches_EmptyRules_ReturnsFalse() { - // Пустая колонка не матчит (rules.py: any(enabled) ложно → False), хотя и принимает всё через ContainerAccepts. Assert.False(ColumnRules.Matches(Rules(), "Любой текст")); } - // ─── Ссылки не участвуют в матчинге (content_text L51–54) ───────────── [Fact] public void Matches_UrlTailWord_DoesNotHit() @@ -141,7 +130,6 @@ public sealed class ColumnRulesTests Assert.False(ColumnRules.Matches(rules, "Подробнее [тут](https://example.com/vacancy/42)")); } - // ─── Исключения (veto): excluded_terms/is_excluded/board_accepts L230–268 ─ [Fact] public void ExcludedTerms_TermPresent_ReturnsIt() @@ -166,7 +154,6 @@ public sealed class ColumnRulesTests [Fact] public void BoardAccepts_NoRules_AcceptsAnyText() { - // Колонка без правил наполняется ИИ/ML/пользователем — принимает любой текст (L266–267). Assert.True(ColumnRules.ContainerAccepts(null, "Любой текст")); Assert.True(ColumnRules.ContainerAccepts(Rules(), "Любой текст")); } @@ -188,7 +175,6 @@ public sealed class ColumnRulesTests Assert.True(ColumnRules.ContainerAccepts(rules, "Нужен бот для телеграма")); } - // ─── Активность правил: has_active_rules L322–338 ───────────────────── [Fact] public void HasActiveRules_EmptyRules_ReturnsFalse() @@ -208,13 +194,11 @@ public sealed class ColumnRulesTests [Fact] public void HasActiveRules_BudgetWithoutBounds_ReturnsFalse() { - // Одна валюта без границ активной группой не считается (прототип L333–337). ContainerRulesDto rules = Rules(budget: new BudgetRangeDto(From: null, To: null, Cur: "USD")); Assert.False(ColumnRules.HasActiveRules(rules)); } - // ─── Грейды: алиасы «mid»→middle и т.п. (L20–27, hits L288–292) ─────── [Fact] public void Matches_GradeAliasMid_TextWithMid_ReturnsTrue() @@ -282,11 +266,9 @@ public sealed class ColumnRulesTests { ContainerRulesDto rules = Rules(budget: new BudgetRangeDto(From: 1000, To: 2000, Cur: "USD")); - // USDT приравнивается к USD (rates.py L86–91, mock-курсы равны) — 1000–2000 ₮ в диапазоне. Assert.True(ColumnRules.Matches(rules, "Бюджет 1000–2000 ₮", MockRates)); } - // ─── ComputeHits: пустые правила → [] и состав hits (hits_for_board L311–319) ─ [Fact] public void ComputeHits_NoActiveRules_ReturnsEmpty() @@ -321,7 +303,6 @@ public sealed class ColumnRulesTests { ContainerRulesDto rules = Rules(grade: new[] { "middle" }); - // Текст содержит «mid», но не «middle» — word показывает именно найденный синоним (L288–292). IReadOnlyList hits = ColumnRules.ComputeHits(rules, "Нужен mid python разработчик"); MatchHitDto hit = Assert.Single(hits); @@ -350,7 +331,6 @@ public sealed class ColumnRulesTests Assert.Empty(ColumnRules.ComputeHits(rules, "Зарплата 1000$")); } - // ─── Describe: русская расшифровка для UI (describe L341–368) ────────── [Fact] public void Describe_NoRules_ReturnsDefaultText() 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 d76f12a..43fc9f0 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakeMlLearningStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakeMlLearningStore.cs @@ -4,27 +4,19 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Tests.Unit.Modules.Kanban; /// -/// In-memory реализация для unit-тестов ML-интеграции (Tasks 5/16). +/// In-memory реализация для unit-тестов ML-интеграции. /// -/// -/// Как и EF-адаптер (MlLearningStore), ведёт очередь MlOutbox отдельно от журнала CardMoves: -/// счётчик learning задаётся независимо () — так тест reset'а может -/// проверить, что очищается только очередь. Очередь упорядочена по моменту добавления (аналог -/// created_at): возвращает первые строки (порции флашера), -/// удаляет отправленные. показывает, что именно -/// ушло в хранилище (id/text/label/delta). -/// public sealed class FakeMlLearningStore : IMlLearningStore { private readonly List<(string Id, string Text, string Label, double Delta)> _rows = []; /// - /// Число записей журнала обучения (CardMoves) — задаётся сценарием, reset его не трогает. + /// Число записей журнала обучения /// public int LearningCount { get; set; } /// - /// Строки очереди (копия на момент обращения), в порядке добавления = created_at. + /// Строки очереди /// public IReadOnlyList<(string Id, string Text, string Label, double Delta)> AddedRows => _rows.ToList(); @@ -47,7 +39,7 @@ public sealed class FakeMlLearningStore : IMlLearningStore } /// - /// Пишет строку очереди напрямую (сценарии флашера: посев готовых строк в порядке created_at). + /// Пишет строку очереди напрямую /// /// Id строки (mle_...). /// Текст обучающего примера. 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 8c7f4b8..05c6345 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakePipelineStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakePipelineStore.cs @@ -5,22 +5,8 @@ using Deal.Modules.Pipeline.Application.Models; namespace Deal.Tests.Unit.Modules.Kanban; /// -/// In-memory реализация для unit-тестов PipelineIngestService/PipelineProcessingService (план Task 5). +/// In-memory реализация для unit-тестов PipelineIngestService/PipelineProcessingService. /// -/// -/// Повторяет семантику EF-адаптера PipelineStore (Task 3): очередь хранит QueueItemDto как есть, список — -/// CreatedAt=QueuedAtMs ASC со статус-фильтром; отсев — RejectedItemDto с upsert'ом по id: повторное -/// отбрасывание того же сообщения обновляет поля записи, аудит возврата (returned/returnedAt/returnReason) -/// переживает (ON CONFLICT … DO UPDATE прототипа, record L79–101); пустой текст — no-op; детерминированный id -/// r_<dialog>_<msgId> из , иначе случайный r_+hex. -/// Подписи stageLabel/sourceLabel считаются словарями (как маппинг -/// адаптера). реализует LIKE-половину поиска (FTS в unit-сценариях не нужен): -/// регистронезависимое вхождение q в text/reason/kw/ch_name, порядок RejectedAt DESC, лимит limitLike, -/// возврат — ПОЛНЫЕ строки (порт без N+1 «id → GetAsync», Ruling 6). ClaimAsync атомарен: false, когда хэш -/// уже заявлен (dedup.TryAdd). -/// Записи можно посеять напрямую (/) — сценарий «в БД уже -/// есть строки»; коллекции / — для проверок теста. -/// public class FakePipelineStore : IPipelineStore { private readonly List _queue = []; @@ -28,17 +14,17 @@ public class FakePipelineStore : IPipelineStore private readonly Dictionary _dedup = new(StringComparer.Ordinal); /// - /// Строки очереди фейка (копия на момент обращения) — проверки тестов приёма/возврата. + /// Строки очереди фейка /// public IReadOnlyList Queue => _queue.ToList(); /// - /// Записи отсева фейка (копия на момент обращения) — проверки тестов отсева/возврата. + /// Записи отсева фейка /// public IReadOnlyList Rejected => _rejected.ToList(); /// - /// Кладёт строку очереди напрямую (сценарий «в очереди уже есть сообщение» — дубль-гвард). + /// Кладёт строку очереди напрямую /// /// Строка как если бы была сохранена в БД. public void SeedQueue(QueueItemDto item) @@ -47,7 +33,7 @@ public class FakePipelineStore : IPipelineStore } /// - /// Кладёт запись отсева напрямую (сценарий «в отсеве уже есть запись» — возврат/удаление). + /// Кладёт запись отсева напрямую /// /// Запись как если бы была сохранена в БД. public void SeedRejected(RejectedItemDto item) @@ -122,7 +108,6 @@ public class FakePipelineStore : IPipelineStore /// public Task UpsertAsync(RejectRecord record, CancellationToken ct) { - // Пустой/пробельный текст — no-op (как адаптер: processing.record L73–74). if (string.IsNullOrWhiteSpace(record.Text)) { return Task.CompletedTask; @@ -131,8 +116,6 @@ public class FakePipelineStore : IPipelineStore string id = record.DeterministicId ?? PrefixId.New(PipelineIdPrefixes.Rejected); long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - // Повторное отбрасывание того же сообщения (dialog+msgId) — upsert по id: поля прототипа - // обновляются, аудит возврата переживает (как PipelineStore.UpsertAsync, ON CONFLICT L109–119). int index = _rejected.FindIndex(item => item.Id == id); RejectedItemDto row = ToItem(record, id, nowMs); if (index >= 0) @@ -175,8 +158,6 @@ public class FakePipelineStore : IPipelineStore int limitLike, CancellationToken ct) { - // FTS-кандидатов в unit нет (Ruling 6) — LIKE-половина адаптера (PipelineStore L166–180). Повторяем SQL - // 1:1: lower(поле) LIKE %q%, где q уже нормализован сервисом (trim+lowercase): поле приводим к нижнему // регистру, q НЕ приводим — если сервис забыл lower, совпадений не будет (тест ловит нормализацию). string query = q.Trim(); IReadOnlyList rows = _rejected @@ -249,7 +230,7 @@ public class FakePipelineStore : IPipelineStore // ── Дедуп (DedupEntries) ─────────────────────────────────────────────── /// - /// Id карточки, связанной с хэшем дедупа (LeadId), либо null — строки нет/заявка не связана. + /// Id карточки, связанной с хэшем дедупа /// /// SHA1-hex нормализованного текста. public string? DedupLeadId(string hash) @@ -314,7 +295,6 @@ public class FakePipelineStore : IPipelineStore return field.ToLowerInvariant().Contains(query, StringComparison.Ordinal); } - // Команда записи → строка отсева (RejectedAt=now; подписи этапа/источника по словарям). // record: Команда записи отсева. // id: Id записи (детерминированный либо r_+hex). // rejectedAtMs: Момент отсева (epoch-ms). 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 87a2074..32ca6f8 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FileKindDetectorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FileKindDetectorTests.cs @@ -4,17 +4,10 @@ using Deal.Modules.Kanban.Application.Services; namespace Deal.Tests.Unit.Modules.Kanban; /// -/// Тесты FileKindDetector — категории вложений по MIME/расширению (план Task 6 L333–334; files.py KIND_BY_EXT/KIND_LABELS L13–28, detect L31–45; Ruling 4). +/// Тесты FileKindDetector — категории вложений по MIME/расширению. /// -/// -/// Наборы расширений сверяются 1:1 с files.py: image (png/jpg/jpeg/gif/webp/svg/bmp/avif/heic), video -/// (mp4/mov/avi/mkv/webm/m4v), audio (mp3/wav/ogg/m4a/flac/aac), archive (zip/rar/7z/tar/gz/bz2), document -/// (pdf/doc/docx/xls/xlsx/csv/txt/md/rtf/ppt/pptx/odt/ods). MIME-префикс image/|video/|audio/ имеет -/// приоритет над расширением; сравнение регистронезависимо; неизвестное → other/«Файл». -/// public sealed class FileKindDetectorTests { - // ─── Расширения по наборам KIND_BY_EXT (L13–19) ───────────────────────── [Fact] public void Detect_ImageExtensions_ReturnsImageKind() @@ -61,7 +54,6 @@ public sealed class FileKindDetectorTests } } - // ─── MIME-префиксы поверх расширения (detect L34–39) ──────────────────── [Fact] public void Detect_ImageMimeOverridesUnknownExtension_ReturnsImageKind() @@ -76,7 +68,6 @@ public sealed class FileKindDetectorTests AssertDetected("payload.bin", mime: "audio/mpeg", kind: "audio", label: "Аудио"); } - // ─── Неизвестное → other/«Файл» (detect L32–33) ───────────────────────── [Fact] public void Detect_UnknownExtensionWithoutMime_ReturnsOtherWithFileLabel() @@ -86,7 +77,6 @@ public sealed class FileKindDetectorTests AssertDetected("trailing-dot.", mime: null, kind: "other", label: "Файл"); } - // ─── Регистронезависимость (HTTP content-type; files.py .lower()) ────── [Fact] public void Detect_MixedCaseExtensionAndMime_IsCaseInsensitive() 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 6fde342..62dc1a4 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs @@ -9,7 +9,7 @@ using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Modules.Kanban; /// -/// Тесты ручной проверки/разметки ML (§8): кандидаты канала/выборки и применение решения. +/// Тесты ручной проверки/разметки ML /// public sealed class MlReviewServiceTests { 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 fac5119..74be848 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/StorageTickServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/StorageTickServiceTests.cs @@ -7,19 +7,10 @@ using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Modules.Kanban; /// -/// Тесты StorageTickService — тик правил хранения (Ruling 8, план Task 10 L395; leads.py tick_storage L454–493). +/// Тесты StorageTickService — тик правил хранения. /// -/// -/// Сервис чистый: оркестрирует кандидатов и удаления 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() @@ -114,7 +105,6 @@ public sealed class StorageTickServiceTests Assert.Equal(KanbanColumns.Archive, ColOf(store, "l_old")); } - // ─── Очистка архива по сроку (tick_storage L475–478) ──────────────────── [Fact] public async Task Tick_PurgesOnlyArchiveCardsWithArchivedAtOlderThanArchiveClearDays() @@ -154,7 +144,6 @@ public sealed class StorageTickServiceTests Assert.Contains(store.CardDtos, card => card.Id == "l_fresh"); } - // ─── Очистка корзины по сроку (tick_storage L480–483) ─────────────────── [Fact] public async Task Tick_PurgesOnlyTrashCardsWithReceivedAtOlderThanTrashClearDays() @@ -233,7 +222,6 @@ public sealed class StorageTickServiceTests 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 351cc9f..22a325e 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/SuggestHeuristicsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/SuggestHeuristicsTests.cs @@ -4,14 +4,8 @@ using Deal.Modules.Kanban.Application.Services; namespace Deal.Tests.Unit.Modules.Kanban; /// -/// Тесты чистого ядра ИИ-предложений (Ruling 3, план Task 14 L467–471). +/// Тесты чистого ядра ИИ-предложений . /// -/// -/// Проверяются: порог MIN_INBOX (карточек меньше 6 → пусто), частотные слова-темы (повтор слова в ≥2 -/// карточках окна), стоп-слова («нужен»/«ищу» не становятся темами), размер группы (≥2), лимит 4 колонок, -/// похожесть с существующими досками (suggest.py _similar_exists L55–61), окно MAX_TEXT=12 (анализ только -/// свежих карточек) и детерминированность (одинаковый вход в любом порядке → одинаковый выход). -/// public sealed class SuggestHeuristicsTests { // Карточка «Неразобранного» с текстом (helper; время — порядок окна, по умолчанию 0). @@ -66,7 +60,6 @@ public sealed class SuggestHeuristicsTests IReadOnlyList plans = SuggestHeuristics.PlanColumns(inbox, Array.Empty()); - // Порядок кандидатов: python (3 карточки, длина 6) раньше такси (3 карточки, длина 5). SuggestedColumnPlan python = Assert.Single(plans, plan => plan.Word == "python"); Assert.Equal("Python", python.Name); Assert.Equal(["l_py1", "l_py2", "l_py3"], python.CardIds); @@ -143,7 +136,6 @@ public sealed class SuggestHeuristicsTests [Fact] public void PlanColumns_MoreThanFourThemes_LimitsToFourColumns() { - // 5 тем по 2 карточки: лимит колонок — 4 (прототип cols[:4]); порядок равных кандидатов — // лексикографический: crm, mvp, php, sql (vue остаётся за лимитом). List inbox = [ @@ -164,7 +156,6 @@ public sealed class SuggestHeuristicsTests Assert.Equal(["crm", "mvp", "php", "sql"], plans.Select(plan => plan.Word)); } - // ─── Похожесть с существующими досками (suggest.py L55–61) ───────────── [Fact] public void PlanColumns_SimilarExistingBoard_SkipsTheWord() @@ -179,8 +170,6 @@ public sealed class SuggestHeuristicsTests InboxCard("l_6", "нужен python"), ]; - // Существующая доска «Python разработка» содержит слово-тему python → кандидат пропускается, - // других тем у карточек нет → планов нет (suggest.py L158–159). IReadOnlyList plans = SuggestHeuristics.PlanColumns(inbox, new[] { "Python разработка" }); @@ -192,8 +181,6 @@ public sealed class SuggestHeuristicsTests [Fact] public void PlanColumns_OnlyNewestMaxTextCards_Analyzed() { - // Старые 6 карточек (общая тема python) вне окна MAX_TEXT=12: среди свежих 12 тем нет → - // планов нет (если бы анализировались все карточки, python дал бы группу). var inbox = new List(); for (int i = 1; i <= 6; i++) { @@ -211,7 +198,6 @@ public sealed class SuggestHeuristicsTests Assert.Empty(plans); } - // ─── Детерминированность (Acceptance Task 14) ────────────────────────── [Fact] public void PlanColumns_SameInput_ReturnsSameOutput() @@ -256,7 +242,6 @@ public sealed class SuggestHeuristicsTests [Fact] public void SuggestDomainKeywords_WordCountsPerTextNotPerOccurrence() { - // «python» дважды в ОДНОМ тексте и один раз во втором → частота 2 (тексты), маркер есть. IReadOnlyList keywords = SuggestHeuristics.SuggestDomainKeywords( [ "нужен python python срочно", @@ -308,7 +293,6 @@ public sealed class SuggestHeuristicsTests [Fact] public void SuggestDomainKeywords_WordLongerThanLimit_IsNotMarker() { - // Слово длиннее 40 символов не проходит токенизацию (лимит ключа suggest.py L191) — маркеров нет. const string longWord = "супердлинныймаркердляпроверкиограничениядлиныслова"; Assert.True(longWord.Length > SuggestHeuristics.MaxKeywordLength); 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 7c138fa..e1d1471 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/GlobalExclusionRulesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/GlobalExclusionRulesTests.cs @@ -4,7 +4,7 @@ using Deal.Modules.Pipeline.Application.Services; namespace Deal.Tests.Unit.Modules.Pipeline; /// -/// Тесты глобальных исключений (§5.14): слова/локации/типы/бюджет — чистая проверка до ML/ИИ. +/// Тесты глобальных исключений /// public sealed class GlobalExclusionRulesTests { 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 edc054c..44d33d5 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs @@ -5,13 +5,8 @@ using Deal.Tests.Unit.Modules.Kanban; namespace Deal.Tests.Unit.Modules.Pipeline; /// -/// Тесты приёма входящих сообщений — PipelineIngestService (план Task 5 L353–354, Ruling 2; pipeline.py enqueue L53–85). +/// Тесты приёма входящих сообщений — PipelineIngestService. /// -/// -/// Кейсы Acceptance Task 5: trim текста; no-op пустого текста и отсутствующего dialogId; дубль-гвард -/// dialogId+msgId (Telethon-повтор не пишется); текст[:6000]; id p_; статус new; msgAt отсутствует → now. -/// Разбор/дедуп по тексту — этап воркера (Task 8), на приёме не проверяются (1:1 с прототипом). -/// public sealed class PipelineIngestServiceTests { // ─── Приём: нормализация и строка очереди ─────────────────────────────── 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 c9ddc8a..c1f875b 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs @@ -6,19 +6,10 @@ 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/очистки). +/// Тесты обслуживания пайплайна — PipelineProcessingService. /// -/// -/// Кейсы 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() @@ -70,7 +61,6 @@ public sealed class PipelineProcessingServiceTests Assert.Equal("стоп-фраза «реклама»", row.Reason); } - // ─── Очередь и счётчики (L201–241) ────────────────────────────────────── [Fact] public async Task QueueCountsAsync_CountsNewAndFiltered() @@ -119,7 +109,6 @@ public sealed class PipelineProcessingServiceTests 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() @@ -172,7 +161,6 @@ public sealed class PipelineProcessingServiceTests Assert.Equal(3, page.Total); // total = размер объединения кандидатов (не весь отсев) } - // ─── Отсев: возврат в обработку (return_to_queue L128–193) ────────────── [Fact] public async Task ReturnAsync_RecordNotFound_ReturnsNull() @@ -340,7 +328,6 @@ public sealed class PipelineProcessingServiceTests Assert.True(queued.MsgAtMs > 0); // msg_at нет — now (row.get("msg_at") or now) } - // ─── Отсев: удаление и очистки (L104–125, L196–198) ───────────────────── [Fact] public async Task DeleteAsync_RemovesSingleRecord() 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 923f93c..75ce9b5 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeGlobalSettingsStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeGlobalSettingsStore.cs @@ -6,22 +6,17 @@ namespace Deal.Tests.Unit.Modules.Settings; /// /// In-memory реализация для юнит-тестов глобальных настроек. /// -/// -/// Как EF-адаптер GlobalSettingsStore, оперирует готовыми JSON-строками (сериализацию выполняет -/// владелец ключа). / позволяют тестам проверять, -/// что именно (и в каком виде — например, enc:) ушло в хранилище. -/// public sealed class FakeGlobalSettingsStore : IGlobalSettingsStore { private readonly Dictionary _rows = new(StringComparer.Ordinal); /// - /// Ключи сохранённых строк (копия на момент обращения). + /// Ключи сохранённых строк /// public IReadOnlyCollection Keys => _rows.Keys.ToList(); /// - /// Кладёт готовую строку (сценарий «значение уже сохранено в БД»). + /// Кладёт готовую строку /// /// Ключ настройки. /// Значение, сериализованное в JSON. 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 6f69445..3ab105f 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeRatesListener.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeRatesListener.cs @@ -3,10 +3,8 @@ using Deal.Modules.Settings.Application.Abstractions; namespace Deal.Tests.Unit.Modules.Settings; /// -/// In-memory реализация для тестов триггеров пересчёта (Task 12). +/// In-memory реализация для тестов триггеров пересчёта. /// -/// Записывает вызовы события (значение fullRecompute) в порядке прихода — тесты проверяют факт -/// и кратность оповещения из RatesService/SettingsService. public sealed class FakeRatesListener : IRatesChangedListener { /// 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 81aa4fa..783b869 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeRatesSource.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeRatesSource.cs @@ -3,20 +3,15 @@ using Deal.Modules.Settings.Application.Abstractions; namespace Deal.Tests.Unit.Modules.Settings; /// -/// Фейковый источник курсов для тестов RatesService (Task 8, Ruling 6). +/// Фейковый источник курсов для тестов RatesService. /// -/// -/// Повторяет контракт порта : результат — словарь курсов к рублю либо null -/// (сбой источника, как CbrRateSource). Счётчик позволяет тестам проверять, что -/// мок-режим НЕ обращается к порту (сети), а cbr-режим — обращается ровно один раз за refresh. -/// public sealed class FakeRatesSource : IRatesSource { private readonly Dictionary? _result; private readonly Exception? _failure; /// - /// Создаёт источник с фиксированным результатом (null — сбой). + /// Создаёт источник с фиксированным результатом /// /// Курсы к рублю или null, если источник «недоступен». public FakeRatesSource(Dictionary? result) @@ -25,7 +20,7 @@ public sealed class FakeRatesSource : IRatesSource } /// - /// Создаёт источник, бросающий исключение (ветка неожиданного сбоя адаптера). + /// Создаёт источник, бросающий исключение /// /// Исключение, которое бросит FetchAsync. public FakeRatesSource(Exception failure) 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 3afa5cf..a126026 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeSettingsStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Settings/FakeSettingsStore.cs @@ -4,24 +4,19 @@ using Deal.Modules.Settings.Application.Models; namespace Deal.Tests.Unit.Modules.Settings; /// -/// In-memory реализация для юнит-тестов SettingsService (Task 3). +/// In-memory реализация для юнит-тестов SettingsService. /// -/// -/// Как и EF-адаптер (Task 4), оперирует готовыми JSON-строками: сериализацию значений выполняет -/// сервис. Метод /свойство позволяют тестам проверять, -/// что именно (и в каком виде — например, enc:) ушло в хранилище. -/// public sealed class FakeSettingsStore : ISettingsStore { private readonly Dictionary _rows = new(StringComparer.Ordinal); /// - /// Ключи сохранённых строк (копия на момент обращения). + /// Ключи сохранённых строк /// public IReadOnlyCollection Keys => _rows.Keys.ToList(); /// - /// Кладёт готовую строку (сценарий «значение уже сохранено в БД»). + /// Кладёт готовую строку /// /// Ключ настройки. /// Значение, сериализованное в JSON. 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 f39f886..9e53bd5 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Settings/PromptDefaultsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Settings/PromptDefaultsTests.cs @@ -4,22 +4,14 @@ using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit.Modules.Settings; /// -/// Тесты границы промптов с фронтом: дефолтные тексты 1:1 с ресурсами фронта и подстановка PromptFiller (Task 7). +/// Тесты границы промптов с фронтом /// -/// -/// Фронт — высший авторитет форм (план L47): DefaultPrompts копируются из ресурсов локали фронта -/// src/frontend/src/i18n/locales/ru.data.js (этап 11 перенёс их из src/frontend/src/data.js), -/// поэтому сверка читает этот файл из репозитория (построчно, включая отсутствие хвостового \n) -/// и падает при любом расхождении. -/// Подстановка плейсхолдеров сверена с backend/app/services/ai.py L63–77 (fill_prompt). -/// public sealed class PromptDefaultsTests { private const string DataJsRelativePath = "src/frontend/src/i18n/locales/ru.data.js"; private static readonly string DataJsSource = ReadDataJs(); - // ─── 1:1 с data.js ─────────────────────────────────────────────────────── [Fact] public void DefaultAiPrompt_MatchDataJs_LineByLine() @@ -48,7 +40,6 @@ public sealed class PromptDefaultsTests DefaultPrompts.DefaultAiFilterPrompt); } - // ─── Маркеры из data.js (план Task 7 L295–299) ────────────────────────── [Fact] public void DefaultAiPrompt_ContainsClassifierMarkers() @@ -72,7 +63,6 @@ public sealed class PromptDefaultsTests Assert.Contains("{keywords}", DefaultPrompts.DefaultAiFilterPrompt); } - // ─── PromptFiller: подстановка {domain}/{keywords} (аналог ai.fill_prompt) ─ [Fact] public void Fill_EmptyDomain_UsesFallbackPhrase() 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 e7a93f6..9158bec 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Settings/SettingsCatalogTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Settings/SettingsCatalogTests.cs @@ -4,12 +4,8 @@ using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit.Modules.Settings; /// -/// Тесты каталога ключей настроек, дефолтов и провайдеров (Task 2, Ruling 1/3). +/// Тесты каталога ключей настроек, дефолтов и провайдеров. /// -/// -/// Эталон каталога — план Task 2 (L152–161), 1:1 с api-map §4.6 и PATCH-списком L340. -/// Ожидаемые ключи заданы строками (wire-имена), чтобы тест ловил опечатки в константах SettingsKeys. -/// public sealed class SettingsCatalogTests { // Ожидаемый каталог публичных ключей (43 шт.): имя → категория. 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 a27480d..1c66d6e 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditEventsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditEventsTests.cs @@ -3,12 +3,8 @@ using Deal.Modules.Tenants.Application.Models; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// Стабильность каталога типов событий аудита (Ruling 4 этапа 7; расширение этапа 10, T1). +/// Стабильность каталога типов событий аудита. /// -/// -/// Значения — строки БД (public.audit_log.EventType), поэтому переименование константы = изменение формата -/// исторических данных. Тест фиксирует точные значения новых событий этапа 10. -/// public sealed class AuditEventsTests { [Theory] 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 a64fe65..f50aad6 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuditServiceTests.cs @@ -5,8 +5,7 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// Юнит-тесты AuditService (Task 4, Ruling 4): append-only запись (At=UTC-now), чтение/счёт по фильтрам, -/// JSON-детали и хелперы акторов. Append-only на уровне порта проверяется рефлексией: ни Update, ни Delete. +/// Юнит-тесты AuditService /// public sealed class AuditServiceTests { @@ -152,8 +151,6 @@ public sealed class AuditServiceTests .OrderBy(name => name) .ToArray(); - // Порт содержит только запись/чтение и retention-очистку (этап 12, пакет B): никаких Update/Delete - // прикладного уровня и Remove (Ruling 4: append-only — Purge удаляет только устаревшее по retention). Assert.Equal(["AppendAsync", "CountAsync", "PurgeOlderThanAsync", "QueryAsync"], methodNames); } 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 b3ea63f..f1d3b1f 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuthServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/AuthServiceTests.cs @@ -102,7 +102,6 @@ public sealed class AuthServiceTests Assert.Null(result.Error); Assert.False(string.IsNullOrWhiteSpace(result.NewToken)); - // Порядок операций семантики прототипа: удалить все сессии → обновить хэш → свежая сессия. Assert.Equal( new[] { @@ -163,7 +162,6 @@ public sealed class AuthServiceTests SessionTokens.HashToken(rawToken), UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30))); await _tenantStore.UpdateStatusAsync(UserTenantId, TenantStatuses.Suspended, CancellationToken.None); - // Активная сессия suspended-тенанта не разрешается немедленно (этап 12, пакет B). Assert.Null(await _service.ResolveSessionAsync(rawToken, CancellationToken.None)); // Строка сессии не удалена: resume возвращает доступ тем же токеном. @@ -204,8 +202,6 @@ public sealed class AuthServiceTests [Fact] public async Task LoginAsync_WhenTenantSuspended_ReturnsSuspendedErrorWithoutSession() { - // Task 7/Ruling 10(5): приостановленный тенант блокирует вход (пароль верный) — Error=tenant_suspended - // с UserId/TenantId (HTTP-слой пишет tenant_login_failed с tenantId, ревью Task 4), сессия не создаётся. var passwordHasher = new FakePasswordHasher(); var suspendedStore = new FakeAuthStore(); suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword))); 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 228c6db..ee8c6cc 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FailingTenantProvisioner.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FailingTenantProvisioner.cs @@ -4,9 +4,7 @@ using Deal.SharedKernel.Tenants.Models; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// In-memory реализация для тестов пакетной миграции (этап 12, пакет C): -/// фиксирует успешные схемы и «роняет» провижининг заранее заданных схем — проверка отказоустойчивости -/// (сбой одной схемы не прерывает остальные). +/// In-memory реализация для тестов пакетной миграции /// public sealed class FailingTenantProvisioner : ITenantProvisioner { @@ -17,7 +15,7 @@ public sealed class FailingTenantProvisioner : ITenantProvisioner /// /// Создаёт провижинер, роняющий провижининг указанных схем. /// - /// Имена схем (tenant_<id>), провижининг которых бросает исключение. + /// Имена схем (tenant_<id>), провижининг которых бросает исключение. public FailingTenantProvisioner(params string[] failingSchemaNames) { _failingSchemaNames = new HashSet(failingSchemaNames, StringComparer.Ordinal); 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 8916883..28ce2be 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuditLogStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuditLogStore.cs @@ -5,20 +5,14 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// In-memory реализация для юнит/HTTP-тестов аудита (Task 4). +/// In-memory реализация для юнит/HTTP-тестов аудита. /// -/// -/// Повторяет семантику EF-адаптера: выборка по фильтрам (EventType/ActorType/TenantId/At-range), сортировка -/// At DESC, limit клампится 1..500, CountAsync считает по фильтру без учёта limit. Id проставляется как identity -/// (1..N) — эмуляция БД. Update/Delete отсутствуют (порт append-only, Ruling 4) — компиляция фейка против -/// интерфейса и есть проверка отсутствия таких методов. -/// public sealed class FakeAuditLogStore : IAuditLogStore { private readonly List _records = []; /// - /// Записи хранилища в порядке добавления (самые новые — последние). + /// Записи хранилища в порядке добавления /// public IReadOnlyList Records => _records; 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 dbca917..ab8348a 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuthStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeAuthStore.cs @@ -6,11 +6,6 @@ namespace Deal.Tests.Unit.Modules.Tenants; /// /// In-memory реализация для юнит-тестов AuthService. /// -/// -/// В отличие от EF-адаптера (AuthStore) не фильтрует протухшие сессии при поиске по токену — -/// это позволяет проверить, что AuthService сам учитывает ExpiresAt. -/// Мутирующие вызовы пишутся в для проверки последовательности операций. -/// public sealed class FakeAuthStore : IAuthStore { private readonly List _users = []; @@ -33,13 +28,13 @@ public sealed class FakeAuthStore : IAuthStore public IReadOnlyList Calls => _calls; /// - /// Добавляет пользователя (как заранее созданного в БД). + /// Добавляет пользователя /// /// Пользователь. public void AddUser(StoredUserDto user) => _users.Add(user); /// - /// Добавляет сессию (как заранее созданную в БД). + /// Добавляет сессию /// /// Сессия. public void AddSession(SessionDto session) => _sessions.Add(session); 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 5b851a2..a9b79f4 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeInviteStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeInviteStore.cs @@ -4,15 +4,8 @@ using Deal.Modules.Tenants.Application.Models; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// In-memory реализация для юнит/HTTP-тестов приглашений (Task 5). +/// In-memory реализация для юнит/HTTP-тестов приглашений. /// -/// -/// Повторяет семантику EF-адаптера: List — CreatedAt DESC; UpdateStatus меняет строку по коду атомарно -/// (false, если кода нет); FindActiveByEmail — только pending (без учёта ExpiresAt, как адаптер: протухшее -/// pending-приглашение переводит в expired сервис). «expired» хранилище не вычисляет — статус меняет -/// InvitesService при чтении/проверке. Мутирующие вызовы пишутся в . Не запечатан: -/// JoinFlowTests (Task 6) наследует его для детерминированной симуляции CAS-гонки (ревью T5). -/// public class FakeInviteStore : IInviteStore { private readonly List _invites = []; @@ -29,7 +22,7 @@ public class FakeInviteStore : IInviteStore public IReadOnlyList Calls => _calls; /// - /// Добавляет приглашение (как заранее созданное в БД). + /// Добавляет приглашение /// /// Приглашение. public void AddInvite(InviteDto invite) => _invites.Add(invite); @@ -74,7 +67,6 @@ public class FakeInviteStore : IInviteStore DateTimeOffset activatedAt, CancellationToken ct) { - // CAS (Task 6): переход pending → activated только если строка всё ещё pending; иначе (уже отозвана/ // активирована/истекла) — false без изменений, как условный UPDATE EF-адаптера. int index = _invites.FindIndex(i => i.Code == code && i.Status == InviteStatuses.Pending); if (index < 0) 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 317540b..91b350d 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeOperatorAuthStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeOperatorAuthStore.cs @@ -6,11 +6,6 @@ namespace Deal.Tests.Unit.Modules.Tenants; /// /// In-memory реализация для юнит-тестов OperatorAuthService. /// -/// -/// В отличие от EF-адаптера не фильтрует протухшие сессии при поиске по токену — -/// это позволяет проверить, что OperatorAuthService сам учитывает ExpiresAt. -/// Мутирующие вызовы пишутся в для проверки последовательности операций. -/// public sealed class FakeOperatorAuthStore : IOperatorAuthStore { private readonly List _operators = []; @@ -33,13 +28,13 @@ public sealed class FakeOperatorAuthStore : IOperatorAuthStore public IReadOnlyList Calls => _calls; /// - /// Добавляет оператора (как заранее созданного в БД). + /// Добавляет оператора /// /// Оператор. public void AddOperator(StoredOperatorDto operatorRecord) => _operators.Add(operatorRecord); /// - /// Добавляет сессию (как заранее созданную в БД). + /// Добавляет сессию /// /// Сессия. public void AddSession(OperatorSessionDto session) => _sessions.Add(session); 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 bc4e2b7..b4f3685 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakePasswordHasher.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakePasswordHasher.cs @@ -3,9 +3,8 @@ using Deal.Modules.Tenants.Application.Abstractions; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// Детерминированный «хэшер» для тестов AuthService (взаимно-однозначное кодирование). +/// Детерминированный «хэшер» для тестов AuthService /// -/// Не является криптографическим; быстрый, чтобы не гонять Argon2 в каждом тесте сервиса. public sealed class FakePasswordHasher : IPasswordHasher { private const string Prefix = "fake-hash:"; 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 d5957c7..250b121 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeRateLimitCounterStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeRateLimitCounterStore.cs @@ -3,10 +3,7 @@ using Deal.Modules.Tenants.Application.Abstractions; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// In-memory реализация для юнит/HTTP-тестов (этап 12, пакет B): -/// повторяет семантику EF-адаптера RateLimitCounterStore (фиксированное окно, сброс при смене WindowStart, -/// уборка по ExpiresAt) без БД. Экземпляр — общий singleton в тестовых хостах (как public.rate_limit_counters -/// в проде: счётчики видны всем «инстансам»). +/// In-memory реализация для юнит/HTTP-тестов /// public sealed class FakeRateLimitCounterStore : IRateLimitCounterStore { @@ -70,7 +67,7 @@ public sealed class FakeRateLimitCounterStore : IRateLimitCounterStore } /// - /// Число заведённых счётчиков (диагностика сценариев). + /// Число заведённых счётчиков /// public int Count => _rows.Count; } 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 58d668b..f69bfe0 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantLimitStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantLimitStore.cs @@ -5,15 +5,8 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// In-memory реализация для юнит-тестов recorder'а (Task 8) и -/// бюджетного гейта/алертов (Task 9): поведение повторяет EF-адаптер TenantLimitStore (ленивый GetOrCreate с -/// дефолт-бюджетом, ленивый reset периода, инкремент списания без установки флагов, TryMark*) без БД. +/// In-memory реализация для юнит-тестов recorder'а и бюджетного гейта/алертов /// -/// -/// Статус тенанта для BudgetStateDto — единый на фейк (, default active); при -/// необходимости per-tenant статусов строка добавляется Preload со своим статусом. Часы подменяемы -/// (конструктор), как в — для проверки reset на фиксированном «сейчас». -/// public sealed class FakeTenantLimitStore : ITenantLimitStore { private sealed class Row @@ -46,7 +39,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore } /// - /// Создаёт фейк с заданными часами и статусом тенанта (тесты reset/гейта). + /// Создаёт фейк с заданными часами и статусом тенанта /// /// Источник текущего времени (UTC). /// Статус тенанта для всех строк (константа ). @@ -58,12 +51,12 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore } /// - /// Статус тенанта для создаваемых строк (константа ). + /// Статус тенанта для создаваемых строк /// public string TenantStatus { get; set; } /// - /// Кладёт готовую строку лимита (сценарий «уже есть расход/флаги»). + /// Кладёт готовую строку лимита /// /// Тенант. /// Бюджет периода. @@ -96,7 +89,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore } /// - /// Расход строки тенанта (0 — строки нет/расхода не было). + /// Расход строки тенанта /// /// Тенант. /// UsedTokens строки или 0. @@ -104,7 +97,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore => _rows.TryGetValue(tenantId, out Row? row) ? row.UsedTokens : 0; /// - /// Бюджет строки тенанта (0 — строки нет). + /// Бюджет строки тенанта /// /// Тенант. /// BudgetTokens строки или 0. @@ -112,7 +105,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore => _rows.TryGetValue(tenantId, out Row? row) ? row.BudgetTokens : 0; /// - /// Есть ли строка лимита тенанта (без ленивого создания). + /// Есть ли строка лимита тенанта /// /// Тенант. /// True — строка существует. @@ -146,7 +139,6 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore ResetIfPeriodExpired(row); if (tokens > 0) { - // Инкремент без установки флагов (review-fix Task 9): флаги порогов выставляет только TryMark* — // иначе планировщик алертов не увидел бы переход порога (TryMark* = false после списания). row.UsedTokens += tokens; } 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 6c95bfe..d064d27 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantProvisioner.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantProvisioner.cs @@ -4,20 +4,15 @@ using Deal.SharedKernel.Tenants.Models; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// In-memory реализация для unit/HTTP-тестов join-потока (Task 6): записывает вызовы. +/// In-memory реализация для unit/HTTP-тестов join-потока /// -/// -/// Реальный провижининг (TenantProvisioningService) требует Postgres и схем tenant-миграций — в unit-тестах -/// он заменяется этим фейком, который фиксирует, какие схемы «провижинены» (ассерт теста: при активации нового -/// тенанта вызван ровно один раз; при присоединении к существующему — ни разу). -/// public sealed class FakeTenantProvisioner : ITenantProvisioner { private readonly List _provisionedSchemaNames = []; private readonly object _gate = new(); /// - /// Имена провижиненных схем (tenant_<id>) в порядке вызовов. + /// Имена провижиненных схем /// public IReadOnlyList ProvisionedSchemaNames { @@ -33,7 +28,6 @@ public sealed class FakeTenantProvisioner : ITenantProvisioner /// public Task ProvisionAsync(TenantId tenantId, CancellationToken ct) { - // Пакетная миграция тенантов вызывает провижининг параллельно (этап 12, пакет C) — запись под lock. lock (_gate) { _provisionedSchemaNames.Add(tenantId.SchemaName); 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 cb32dd0..8467002 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantRegistry.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantRegistry.cs @@ -4,14 +4,8 @@ using Deal.Modules.Tenants.Application.Models; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// In-memory реализация для тестов gRPC-ингресса (план Task 12). +/// In-memory реализация для тестов gRPC-ингресса. /// -/// -/// В отличие от (реестр для циклов, FindByIdAsync бросает) поддерживает -/// поиск по id: ингресс подтверждает принадлежность каждого RPC записью public.tenants (ResolveTenantAsync). -/// Список тенантов фиксируется в конструкторе; CreateAsync не используется — бросает, чтобы тест сразу -/// поймал неожиданное обращение (как FakeTenantRepository). -/// public sealed class FakeTenantRegistry : ITenantRepository { private readonly IReadOnlyList _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 beee8eb..dbafc09 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantRepository.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantRepository.cs @@ -4,13 +4,8 @@ using Deal.Modules.Tenants.Application.Models; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// In-memory реализация для unit-тестов (реестр тенантов, таблица public.tenants). +/// In-memory реализация для unit-тестов /// -/// -/// Список тенантов фиксируется в конструкторе — сценарий «в реестре уже есть тенанты» для фонового -/// StorageTickScheduler (Task 11). FindByIdAsync/CreateAsync не используются циклом — бросают -/// , чтобы тест сразу поймал неожиданное обращение (как FakeKanjStore). -/// public sealed class FakeTenantRepository : ITenantRepository { private readonly IReadOnlyList _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 fb7a144..2a491b5 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTenantStore.cs @@ -4,14 +4,8 @@ using Deal.Modules.Tenants.Application.Models; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// In-memory реализация с поддержкой создания тенантов (join-поток, Task 6). +/// In-memory реализация с поддержкой создания тенантов. /// -/// -/// В отличие от / (только чтение фиксированного -/// списка) сохраняет созданные записи: CreateAsync/FindByIdAsync/ListAsync работают над общим списком. Используется -/// unit/HTTP-тестами /api/join (JoinService создаёт нового тенанта при пустом TenantId инвайта) и пригодится -/// Task 7 (операторские тенанты). -/// public sealed class FakeTenantStore : ITenantRepository { private readonly List _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 8dfe9e9..6394a0b 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTokenUsageEventStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/FakeTokenUsageEventStore.cs @@ -4,13 +4,8 @@ using Deal.Modules.Tenants.Application.Models; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// In-memory реализация для юнит/HTTP-тестов (этап 10, T2). +/// In-memory реализация для юнит/HTTP-тестов. /// -/// -/// Повторяет семантику EF-адаптера: append-only запись и агрегация по day/tenant/provider/model с фильтрами -/// TenantId/Provider/Model/Kind/At-range. Порядок: day — по возрастанию даты, остальные — по убыванию total. -/// Update/Delete отсутствуют (порт append-only). -/// public sealed class FakeTokenUsageEventStore : ITokenUsageEventStore { private readonly List _records = []; 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 487adda..f63d115 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/InviteCodeGeneratorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/InviteCodeGeneratorTests.cs @@ -3,7 +3,7 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// Юнит-тесты генератора кодов приглашений (Ruling 2: случайный url-safe, 16 симв., без префикса). +/// Юнит-тесты генератора кодов приглашений. /// public sealed class InviteCodeGeneratorTests { 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 41432ba..5b0d09a 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/InvitesServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/InvitesServiceTests.cs @@ -4,9 +4,8 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// Юнит-тесты InvitesService на fake-хранилище: создание (код/срок/email/антидубль), отзыв, expiry при чтении, список. +/// Юнит-тесты InvitesService на fake-хранилище /// -/// Статусы проверяются константами (1:1 со значениями БД, Ruling 2). public sealed class InvitesServiceTests { private const string FirstEmail = "new-user@example.com"; @@ -166,7 +165,6 @@ public sealed class InvitesServiceTests [Fact] public async Task GetByCodeAsync_WhenPendingInviteExpired_MarksItExpiredAndReturnsExpiredStatus() { - // Ленивая пометка при чтении (план Task 5): протухший pending возвращается как expired и переход сохраняется. var store = new FakeInviteStore(); store.AddInvite(NewInvite(code: "old-code", email: FirstEmail, expiresAt: DateTimeOffset.UtcNow.AddHours(-1), createdAt: DateTimeOffset.UtcNow.AddDays(-3))); var service = new InvitesService(store); 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 7b8aa88..c9f88b0 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/JoinFlowTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/JoinFlowTests.cs @@ -4,15 +4,8 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// Юнит-тесты JoinService (активация инвайта, Task 6) на фейк-хранилищах: успех с новым/существующим -/// тенантом, коды ошибок (код/email/дубль/протух/revoked/пароль) и CAS-семантика перехода статуса. +/// Юнит-тесты JoinService на фейк-хранилищах /// -/// -/// JoinService координирует порты модуля (IInviteStore через InvitesService, ITenantRepository/ITenantProvisioner -/// через TenantService, IAuthStore) — фейки повторяют семантику EF-адаптеров: FakeInviteStore.TryActivateAsync — -/// условный переход pending→activated (CAS), FakeTenantProvisioner фиксирует провижининг, FakeAuthStore хранит -/// пользователей. Аудит invite_activated пишет HTTP-слой — он покрыт в JoinEndpointHttpTests. -/// public sealed class JoinFlowTests { private const string Email = "new-user@example.com"; @@ -161,7 +154,6 @@ public sealed class JoinFlowTests [Fact] public async Task ActivateAsync_WithExpiredPendingInvite_ReturnsExpiredAndPersistsTransition() { - // Протухшее pending: GetByCodeAsync лениво переводит в expired (Task 5) — join отвечает «срок истёк». var inviteStore = new FakeInviteStore(); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, expiresAt: DateTimeOffset.UtcNow.AddHours(-1))); var tenantStore = new FakeTenantStore(); @@ -216,7 +208,6 @@ public sealed class JoinFlowTests Assert.False(result.Ok); Assert.Equal(JoinResultDto.ErrorEmailMismatch, result.Error); - // Инвайт не расходуется чужим email (Ruling 2): остаётся pending, побочных эффектов нет. Assert.Equal(InviteStatuses.Pending, Assert.Single(inviteStore.Invites).Status); Assert.Empty(tenantStore.Tenants); Assert.Empty(authStore.Users); @@ -270,7 +261,6 @@ public sealed class JoinFlowTests [Fact] public async Task ActivateAsync_SecondActivationWithSameCode_ReturnsUsedWithoutDuplicates() { - // CAS-семантика (ревью T5): повторная активация того же кода не создаёт второго пользователя/тенанта — // вторая попытка видит статус activated (переход pending→activated выполнился ровно один раз). var inviteStore = new FakeInviteStore(); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); @@ -293,7 +283,6 @@ public sealed class JoinFlowTests [Fact] public async Task ActivateAsync_WhenParallelRevokeWinsBeforeCas_ReturnsRevokedWithoutSideEffects() { - // Гонка (ревью T5): между чтением pending и CAS-резервированием оператор отозвал инвайт — CAS не проходит, // проигравшая активация не создаёт пользователя/тенанта и не перезаписывает revoked. var inviteStore = new RacingInviteStore(statusBeforeActivate: InviteStatuses.Revoked); inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null)); 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 e28d881..8eff488 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorAuthServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorAuthServiceTests.cs @@ -6,7 +6,6 @@ namespace Deal.Tests.Unit.Modules.Tenants; /// /// Юнит-тесты OperatorAuthService на fake-хранилище и детерминированном «хэшере». /// -/// Проверяются login (включая нормализацию и 12-часовой срок сессии), resolve и logout. public sealed class OperatorAuthServiceTests { private const string OperatorLogin = "operator"; @@ -117,7 +116,6 @@ public sealed class OperatorAuthServiceTests [Fact] public async Task ResolveSessionAsync_WhenOperatorIsNotActive_ReturnsNull() { - // Ревью Task 2: ResolveSession проверяет Status оператора — приостановленный не разрешается. const string suspendedLogin = "suspended-operator"; var passwordHasher = new FakePasswordHasher(); _store.AddOperator(new StoredOperatorDto(Guid.NewGuid(), suspendedLogin, "suspended", passwordHasher.Hash("x"))); 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 d0535d3..a7dee55 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorBootstrapServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/OperatorBootstrapServiceTests.cs @@ -4,7 +4,7 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// Юнит-тесты bootstrap оператора (Ruling 1 этапа 7): dev-дефолт, prod-skip, идемпотентность. +/// Юнит-тесты bootstrap оператора /// public sealed class OperatorBootstrapServiceTests { 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 89ce83c..54de23c 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/PasswordHasherTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/PasswordHasherTests.cs @@ -4,7 +4,7 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// Тесты Argon2id-хэшера паролей (Ruling 5, DefaultPasswordHasher). +/// Тесты Argon2id-хэшера паролей. /// public sealed class PasswordHasherTests { 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 8a4548c..07a53de 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SessionTokensTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SessionTokensTests.cs @@ -3,7 +3,7 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// Тесты токенов сессий: Base64Url-генерация и детерминированный SHA-256 (Ruling 6). +/// Тесты токенов сессий /// public sealed class SessionTokensTests { 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 85ad737..b578e5e 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SuspiciousActivityServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/SuspiciousActivityServiceTests.cs @@ -4,9 +4,8 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// Юнит-тесты детектора подозрительной активности (§10.5) на фейковом хранилище аудита. +/// Юнит-тесты детектора подозрительной активности /// -/// Часы фиксированы, поэтому окно анализа детерминировано; записи кладутся с At = «сейчас − минуты». public sealed class SuspiciousActivityServiceTests { private static readonly DateTimeOffset Now = new(2026, 9, 10, 12, 0, 0, TimeSpan.Zero); 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 479bf83..ec8c018 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TenantAdminServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TenantAdminServiceTests.cs @@ -4,15 +4,8 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// Юнит-тесты (план Task 7): create (тенант + владелец), список со -/// счётчиками, детали, смена статуса. +/// Юнит-тесты /// -/// -/// Тесты сервиса на фейк-хранилищах (FakeTenantStore/FakeAuthStore/FakeTenantProvisioner): создание тенанта -/// (провижининг схемы — фейк, реальный TenantProvisioningService ⚠ Manual), чтение реестра + пользователей и -/// статусные переходы suspend/unsuspend. Аудит пишет HTTP-слой (покрытие — OperatorTenantsEndpointsHttpTests); -/// impersonation — AuthServiceTests. -/// public sealed class TenantAdminServiceTests { private static readonly Guid FirstTenantId = Guid.NewGuid(); @@ -75,7 +68,6 @@ public sealed class TenantAdminServiceTests authStore.AddUser(NewUser(Guid.NewGuid(), "owner@example.com")); var service = NewService(tenantStore, authStore); - // Предпроверка до создания тенанта: занятый email не создаёт тенанта без владельца (Ruling 2, как JoinService). TenantCreateResultDto result = await service.CreateAsync("Тенант", "owner@example.com", CancellationToken.None); Assert.False(result.Ok); 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 5501e08..4cff186 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TokenBudgetServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TokenBudgetServiceTests.cs @@ -4,14 +4,8 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// Тесты период-математики лимитов (Task 8, Ruling 3 этапа 7): конец окна месяца/дня для ленивого -/// reset, пороги 80/100%, остаток и дефолты модуля (TokenBudgetDefaults). +/// Тесты период-математики лимитов /// -/// -/// Время — DateTimeOffset (UTC) с фиксированными моментами; месяц календарный от PeriodStart (+1 месяц), -/// день — +1 сутки. Reset считается «истёк», когда сейчас ≥ конца окна (Ruling 3) — граничные случаи -/// покрываются ровно на границе. -/// public sealed class TokenBudgetServiceTests { private static readonly DateTimeOffset Now = new(2026, 9, 1, 12, 0, 0, TimeSpan.Zero); @@ -23,7 +17,6 @@ public sealed class TokenBudgetServiceTests [Fact] public void IsPeriodExpired_Month_AtOneMonthBoundary() { - // Период стартовал 1 августа в 12:00 → окно до 1 сентября в 12:00 (календарный месяц, Ruling 3). DateTimeOffset start = new(2026, 8, 1, 12, 0, 0, TimeSpan.Zero); Assert.False(_service.IsPeriodExpired(start, TenantLimitPeriods.Month, new DateTimeOffset(2026, 8, 31, 23, 59, 59, TimeSpan.Zero))); @@ -77,7 +70,6 @@ public sealed class TokenBudgetServiceTests [Fact] public void IsExhausted_ZeroBudget_AlwaysExhausted() { - // Лимит 0 запрещает ИИ (Task 9: «лимит 0 → Local-ветка»): тратить нечего даже при нулевом расходе. Assert.True(_service.IsExhausted(usedTokens: 0, budgetTokens: 0)); Assert.True(_service.IsExhausted(usedTokens: 5, budgetTokens: 0)); } 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 f7a430d..9a48815 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TokenUsageEventServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Tenants/TokenUsageEventServiceTests.cs @@ -4,7 +4,7 @@ using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit.Modules.Tenants; /// -/// Тесты сервиса истории расхода токенов (этап 10, T2): единая точка записи (At=UTC-now) и чтения агрегатов. +/// Тесты сервиса истории расхода токенов /// public sealed class TokenUsageEventServiceTests { diff --git a/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs b/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs index 9ac3021..d7e501a 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs @@ -18,23 +18,8 @@ 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. +/// Тесты AdminTickOrchestrator — состав POST /api/admin/tick /// -/// -/// Оркестратор — 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). @@ -68,7 +53,6 @@ public sealed class AdminTickOrchestratorTests 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); @@ -76,7 +60,6 @@ public sealed class AdminTickOrchestratorTests 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"]); @@ -94,7 +77,6 @@ public sealed class AdminTickOrchestratorTests 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") @@ -119,7 +101,6 @@ public sealed class AdminTickOrchestratorTests 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); @@ -139,9 +120,6 @@ public sealed class AdminTickOrchestratorTests 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); @@ -156,7 +134,6 @@ public sealed class AdminTickOrchestratorTests ctx.PumpGate.Exit(TenantA); } - // ─── Тик: due-напоминания «Отложено» → reminders ответа + SSE reminder_due (план Task 11, Ruling 8) ── [Fact] public async Task Tick_DueReminder_ReturnsItInRemindersAndPublishesReminderDue() @@ -175,7 +152,6 @@ public sealed class AdminTickOrchestratorTests 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()); @@ -190,7 +166,6 @@ public sealed class AdminTickOrchestratorTests 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)); diff --git a/src/core/tests/Deal.Tests.Unit/Support/AiConnectionCheckerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/AiConnectionCheckerTests.cs index dc5afdd..4735871 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/AiConnectionCheckerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/AiConnectionCheckerTests.cs @@ -5,15 +5,8 @@ using Deal.Modules.Settings.Application.Models; namespace Deal.Tests.Unit.Support; /// -/// Тесты AiConnectionChecker — ветки ответа POST /api/ai/check (Task 6, Ruling 7). +/// Тесты AiConnectionChecker — ветки ответа POST /api/ai/check. /// -/// -/// Референс — settings_routes.py L195–219 (ai_check) и план Task 6 L265–285. HTTP замокан -/// фейковым (запись запросов; ответ/сетевая ошибка — по -/// сценарию). Проверяются фиксированные сообщения веток (нет ключа, локальный провайдер, -/// HTTP <400, 401/403, иной HTTP, сетевая ошибка), статус-поля ответа, маска ключа, -/// URL/заголовки OpenAI-совместимых и Anthropic, SSRF-гейты (allowlist провайдера, схема base URL). -/// public sealed class AiConnectionCheckerTests { // ─── Короткие ветки (без HTTP) ────────────────────────────────────────── @@ -117,7 +110,6 @@ public sealed class AiConnectionCheckerTests Assert.Equal("https://api.deepseek.com/", result.Base); Assert.Equal("deepseek-v4-flash", result.Model); - // rstrip("/") как в прототипе: base с хвостовым слэшем → ровно {base}/models. HttpRequestMessage sent = Assert.Single(handler.Requests); Assert.Equal("https://api.deepseek.com/models", sent.RequestUri!.ToString()); Assert.Equal("Bearer sk-1234567890ab", sent.Headers.Authorization!.ToString()); diff --git a/src/core/tests/Deal.Tests.Unit/Support/AiGrpcTestHost.cs b/src/core/tests/Deal.Tests.Unit/Support/AiGrpcTestHost.cs index 799cbec..6e18b90 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/AiGrpcTestHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/AiGrpcTestHost.cs @@ -8,9 +8,7 @@ using Microsoft.Extensions.DependencyInjection; namespace Deal.Tests.Unit.Support; -// Общий харнесс интеграционных тестов gRPC-адаптеров ядра к ai-service (план Task 15, Ruling 1/2; // эталон MlGrpcTestHost). -// Поднимает в процессе теста Kestrel HTTP/2 (plaintext — Ruling 2) на эфемерном порту с фейком // RecordingAiService (серверная сторона ai.proto) и передаёт сценарию порт + сервис-фейк: // адаптеры (GrpcAiClassifier/GrpcAiTools) строятся на реальном канале к этому порту, поэтому проверяются // metadata tenant-id/service-token, deadline и маппинг DTO↔proto «по проводу». DEAL_SERVICE_TOKEN задаётся @@ -19,7 +17,7 @@ namespace Deal.Tests.Unit.Support; internal static class AiGrpcTestHost { /// - /// Токен сценариев теста (тот же env-ключ, что у MlGrpcConnection/AiGrpcConnection). + /// Токен сценариев теста /// public const string DefaultToken = "deal-ai-test-token"; diff --git a/src/core/tests/Deal.Tests.Unit/Support/AiRawCardMapperTests.cs b/src/core/tests/Deal.Tests.Unit/Support/AiRawCardMapperTests.cs index 44effeb..f60585f 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/AiRawCardMapperTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/AiRawCardMapperTests.cs @@ -5,15 +5,8 @@ using Deal.Modules.Pipeline.Application.Services; namespace Deal.Tests.Unit.Support; /// -/// Тесты строгого маппинга JSON-ответа ИИ-классификатора в разбор карточки -/// (план Task 15, Ruling 5; python _store_lead L433–514 — normalize_stack/clean_budget/build_contacts). +/// Тесты строгого маппинга JSON-ответа ИИ-классификатора в разбор карточки . /// -/// -/// Сценарии Acceptance: полный ответ → все поля DTO (заголовок/блок «О заявке»/стек/бюджет/контакты/тип/спам/ -/// доска); бюджет «2к»/валюты-алиасы (clean_budget L316–339); контакты (build_contacts L389–421 — боты и -/// служебные ссылки отбрасываются); стек строкой и списком (normalize_stack L332–341); «голый» минимальный -/// ответ → дефолты (не-спам, board=null); не-JSON/не-объект → JsonException (ветка «разбора нет» воркера). -/// public sealed class AiRawCardMapperTests { [Fact] @@ -70,7 +63,6 @@ public sealed class AiRawCardMapperTests AiParsedCardDto parsed = AiRawCardMapper.Map(json, "текст"); - // clean_budget L316–339: суффикс «к» → тысячи; «cur» — алиас поля валюты; «₽» → RUB. Assert.Equal(new AiBudgetDto(2000, 5000, "RUB"), parsed.Budget); } @@ -85,7 +77,6 @@ public sealed class AiRawCardMapperTests AiParsedCardDto parsed = AiRawCardMapper.Map(json, "текст"); - // Одна граница («до X») → from=null, to=X (python L334–338: t = f при отсутствии to). Assert.Equal(new AiBudgetDto(From: null, To: 1500, Cur: "USD"), parsed.Budget); } @@ -97,7 +88,6 @@ public sealed class AiRawCardMapperTests const string badCurrency = """{ "title": "Заказ", "stack": [], "is_spam": false, "budget": { "from": 10, "to": 20, "currency": "деняг" } }"""; - // Бюджета в ответе нет / валюта не распознана → null (не-число карточку не даёт; python L325–329). Assert.Null(AiRawCardMapper.Map(noBudget, "текст").Budget); Assert.Null(AiRawCardMapper.Map(badCurrency, "текст").Budget); } @@ -118,7 +108,6 @@ public sealed class AiRawCardMapperTests AiParsedCardDto parsed = AiRawCardMapper.Map(json, "текст"); - // build_contacts L389–421: дубль по значению схлопнут, бот и сервисная t.me-ссылка отброшены. Assert.Equal(new[] { new AiContactDto("tg", "@ivan_dev") }, parsed.Contacts); } @@ -130,7 +119,6 @@ public sealed class AiRawCardMapperTests AiParsedCardDto parsed = AiRawCardMapper.Map(json, "пишите @petrov_dev или на dev@q.ru"); - // build_contacts L406–407: контактов в ответе нет → кандидаты из текста. Assert.Contains(new AiContactDto("tg", "@petrov_dev"), parsed.Contacts); Assert.Contains(new AiContactDto("email", "dev@q.ru"), parsed.Contacts); } @@ -143,8 +131,6 @@ public sealed class AiRawCardMapperTests AiParsedCardDto parsed = AiRawCardMapper.Map(json, "текст"); - // normalize_list L317–329: строка разбивается по «;»/«|»/переносам (запятая — не разделитель); - // «C#» на краю теряет решётку (strip('*`#') L325) → «C» длиной 1 отбрасывается (L327) — квирк python 1:1. Assert.Equal(new[] { "Java", "Kotlin" }, parsed.Stack); } @@ -169,7 +155,6 @@ public sealed class AiRawCardMapperTests AiParsedCardDto parsed = AiRawCardMapper.Map(json, "Нужен Python-разработчик на проект"); - // python L455: clean_short(title,140) or clean_short(text,140) — заголовок из исходника. Assert.Equal("Нужен Python-разработчик на проект", parsed.Title); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs index a35f238..603ab18 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/BudgetAlertSchedulerTests.cs @@ -10,17 +10,8 @@ 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 теста (канал подписки). @@ -29,16 +20,13 @@ public sealed class BudgetAlertSchedulerTests // Тенант 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); @@ -52,7 +40,6 @@ public sealed class BudgetAlertSchedulerTests 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") }, @@ -106,7 +93,6 @@ public sealed class BudgetAlertSchedulerTests [Fact] public async Task RunCycle_NaturalSpendCrossing80Then100_PublishesOneToastPerThreshold() { - // Review-fix Task 9: флаги порогов выставляет только TryMark* — естественный расход (AddUsage) пересекает // 80% → ближайший проход публикует РОВНО один тост; повторный проход — без тоста; пересечение 100% (ещё // расход) → ещё ровно один тост (100%), 80% не дублируется. FakeTenantLimitStore limits = new(); @@ -117,7 +103,6 @@ public sealed class BudgetAlertSchedulerTests 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); diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs index a0be59b..4335984 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs @@ -13,14 +13,8 @@ using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; /// -/// Тесты ручной переклассификации карточек — CardReclassifier (этап 12, пакет D; -/// прототип leads.py reclassify_lead L292–389 + reclassify_inbox L392–417). +/// Тесты ручной переклассификации карточек — CardReclassifier. /// -/// -/// Проверяется поведение без ИИ (детерминированный локальный разбор — «несанкционированный» сбой без кредов не -/// роняет проход), ИИ-путь (доска + обучающие сигналы ML), отсев спама/непройденного фильтра в корзину, -/// single-flight-замок и счётчики исхода. Всё — на in-memory фейках (FakeKanjStore/FakeMlClient/FakeAiClassifier). -/// public sealed class CardReclassifierTests { // Контекст теста: сервис поверх in-memory фейков. diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs index e691f2c..0ff70a5 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs @@ -8,19 +8,10 @@ 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() @@ -104,7 +95,6 @@ public sealed class CardsServiceFilesTests 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/«Файл» @@ -147,7 +137,6 @@ public sealed class CardsServiceFilesTests Assert.Equal(content, storage.ContentOf(entry.ObjectKey)); } - // ─── GetEntry (files.py get_file_entry L78–83; download-эндпоинт) ──────── [Fact] public async Task GetEntry_ExistingFile_ReturnsEntryMeta() @@ -184,7 +173,6 @@ public sealed class CardsServiceFilesTests Assert.Null(entry); // файла нет в метаданных карточки — 404-семантика } - // ─── Remove (files.py remove_file L86–94) ─────────────────────────────── [Fact] public async Task Remove_Existing_DeletesObjectRemovesMetaAndReturnsCard() @@ -232,7 +220,6 @@ public sealed class CardsServiceFilesTests 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); diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs index 9095bb5..b0246d5 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs @@ -8,19 +8,10 @@ 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() @@ -58,7 +49,6 @@ public sealed class CardsServiceSelectedTests Assert.Null(card); } - // ─── Ручное создание (create_local_card L103–124, Ruling 6) ───────────── [Fact] public async Task CreateLocal_WithStage_TrimsTitleAndWritesCreatedLocalHistory() @@ -108,7 +98,6 @@ public sealed class CardsServiceSelectedTests Assert.Equal("planned", card.Col); } - // ─── «Взять в работу» (take_lead_to_projects L127–156, Ruling 5) ──────── [Fact] public async Task TakeCard_CardMissing_ReturnsNullAndCreatesNothing() @@ -137,7 +126,6 @@ public sealed class CardsServiceSelectedTests 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); @@ -175,7 +163,6 @@ public sealed class CardsServiceSelectedTests Assert.Single(store.CardDtos); } - // ─── Правка полей (patch_card L159–187; presence-aware тело PATCH) ────── [Fact] public async Task Patch_PresentKeys_UpdateFieldsAndBumpUpdatedAt() @@ -217,7 +204,6 @@ public sealed class CardsServiceSelectedTests 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); @@ -233,7 +219,6 @@ public sealed class CardsServiceSelectedTests 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); } @@ -265,7 +250,6 @@ public sealed class CardsServiceSelectedTests PatchBody(("title", null), ("summary", "Новое описание")), CancellationToken.None); - // Текстовые поля прототип НЕ очищает null-ом (str(None) — баг); JSON-null трактуется как отсутствие // ключа — title не тронут, summary обновлён. Assert.NotNull(card); Assert.Equal("Старый", card!.Title); @@ -285,7 +269,6 @@ public sealed class CardsServiceSelectedTests Assert.Null(card); } - // ─── Move + история + сброс напоминания (move_stage L202–216) ────────── [Fact] public async Task Move_TwoMoves_AppendHistoryResetReminderAndBumpUpdatedAt() @@ -310,7 +293,6 @@ public sealed class CardsServiceSelectedTests 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" }, @@ -352,7 +334,6 @@ public sealed class CardsServiceSelectedTests Assert.Null(result.Card); // 404 «Карточка не найдена» — текст у эндпоинта } - // ─── Очистка «Отклонено» (clear_stage L223–231, Ruling 9) ─────────────── [Fact] public async Task ClearRejected_RemovesOnlyRejectedAndReturnsCount() @@ -379,7 +360,6 @@ public sealed class CardsServiceSelectedTests Assert.Equal(0, cleared); } - // ─── Комментарии (add_comment L194–199, Ruling 11) ───────────────────── [Fact] public async Task AddComment_Valid_AppendsTrimmedCommentWithWireForm() @@ -441,7 +421,6 @@ public sealed class CardsServiceSelectedTests Assert.Empty(store.CardDtos); } - // ─── Ссылки (add_link/remove_link L133–150) ──────────────────────────── [Fact] public async Task AddLink_NoScheme_PrefixesHttpsAndDefaultsNameToUrl() diff --git a/src/core/tests/Deal.Tests.Unit/Support/CbrRateSourceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CbrRateSourceTests.cs index 1b4a49b..7b2ba0b 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CbrRateSourceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CbrRateSourceTests.cs @@ -6,14 +6,8 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Deal.Tests.Unit.Support; /// -/// Тесты CbrRateSource — парсинг daily_json.js и ветки сбоя (Task 8, Ruling 6; rates.py L43–59). +/// Тесты CbrRateSource — парсинг daily_json.js и ветки сбоя. /// -/// -/// HTTP замокан фейковым (запись запросов; ответ/сетевая ошибка — -/// по сценарию). Проверяются: парсинг образца (включая Nominal > 1 — 100 KZT за 19 ₽), добавление -/// RUB:1, фиксированный URL (SSRF-allowlist), ветки сбоя (HTTP-код ≠ 2xx, не-JSON, нет Valute, -/// повреждённая запись, сетевая ошибка) → null. Исключения логируются через NullLogger. -/// public sealed class CbrRateSourceTests { // ─── Парсинг образца ответа ЦБ ────────────────────────────────────────── @@ -102,7 +96,6 @@ public sealed class CbrRateSourceTests [Fact] public async Task FetchAsync_BrokenCurrencyEntry_ReturnsNull() { - // Нераспознанная запись (Value — не число) роняет весь fetch, как исключение python в цикле. string payload = """{"Valute":{"USD":{"Nominal":1,"Value":"abc"},"EUR":{"Nominal":1,"Value":99.9}}}"""; StubHttpMessageHandler handler = CreateJsonHandler(payload); CbrRateSource source = CreateSource(handler); @@ -115,7 +108,6 @@ public sealed class CbrRateSourceTests [Fact] public async Task FetchAsync_ZeroNominal_TreatedAsOne() { - // python: int(Nominal) or 1 — нулевой номинал трактуется как 1. string payload = """{"Valute":{"USD":{"Nominal":0,"Value":92.5}}}"""; StubHttpMessageHandler handler = CreateJsonHandler(payload); CbrRateSource source = CreateSource(handler); diff --git a/src/core/tests/Deal.Tests.Unit/Support/ColumnRulesNewGroupsTests.cs b/src/core/tests/Deal.Tests.Unit/Support/ColumnRulesNewGroupsTests.cs index c33a178..21f1374 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/ColumnRulesNewGroupsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/ColumnRulesNewGroupsTests.cs @@ -5,12 +5,8 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Tests.Unit.Support; /// -/// Тесты новых групп фильтров колонки (§6.3, этап 12): levels/locations/types/prices. +/// Тесты новых групп фильтров колонки /// -/// -/// Проверяются матчинг (в т.ч. алиасы уровней и типов, диапазон цены), has-active-rules, содержание -/// matchHits (label/term/word), описание правил и обратная совместимость сохранённого JSON без новых групп. -/// public sealed class ColumnRulesNewGroupsTests { // Правила только с новыми группами (остальные пусты). diff --git a/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs index 16c8197..8ef8600 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/ContainersServiceTests.cs @@ -7,14 +7,8 @@ using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; /// -/// Тесты ContainersService — единый реестр контейнеров и colState (этап 9, T4). +/// Тесты ContainersService — единый реестр контейнеров и colState. /// -/// -/// Служебные фейки: (контейнеры/карточки, перенос карточек в inbox при -/// удалении) и (colState — KV-ключ). Палитра/дефолты: "#818cf8", -/// "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"; имя по умолчанию -/// «Новая колонка»; пространство dashboard/вид board. -/// public sealed class ContainersServiceTests { // ─── Создание контейнера ────────────────────────────────────────────── diff --git a/src/core/tests/Deal.Tests.Unit/Support/ConversionRecomputerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/ConversionRecomputerTests.cs index adbaa6a..b764165 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/ConversionRecomputerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/ConversionRecomputerTests.cs @@ -10,16 +10,8 @@ using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; /// -/// Тесты ConversionRecomputer — пересчёт конверсий карточек (Ruling 7, план Task 12 L424–431). +/// Тесты ConversionRecomputer — пересчёт конверсий карточек. /// -/// -/// Референс — rates.py recompute_conversions (L106–130) и _resolve_rate (L86–91). Сервис -/// читает conversionOn/targetCurrency/ratesCache через FakeSettingsStore, кандидатов и запись conv-полей -/// делает FakeKanjStore (методы порта повторяют SQL KanbanStore: бюджет задан и колонка не -/// archive/trash). Кэш курсов задаётся готовым JSON {rates, source, updatedAtMs}; отсутствие -/// строки кэша → пересчёт не выполняется (дефолт-мок НЕ подставляется — по прототипу, где курс читается -/// из строки rates таблицы напрямую). -/// public sealed class ConversionRecomputerTests { // ─── Полный пересчёт по кэшу курсов ────────────────────────────────────── @@ -34,7 +26,6 @@ public sealed class ConversionRecomputerTests int updated = await service.RecomputeAsync(CancellationToken.None); - // 100 USD → RUB: 100 * 92.5 / 1 = 9250; 50..200 USD → 4625..18500 (rates.py L121–122). Assert.Equal(2, updated); AssertConverted(store, "l_board", convFrom: 9250, convTo: 9250, "RUB"); AssertConverted(store, "l_inbox", convFrom: 4625, convTo: 18500, "RUB"); @@ -73,7 +64,6 @@ public sealed class ConversionRecomputerTests { (ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); // Собственный курс USDT (90) отличается от USD (100): приравнивание USDT=USD даёт 100 * 100 = 10000, - // а не 9000 (rates.py L86–91 — у ЦБ нет тикера USDT). var rates = new Dictionary { ["RUB"] = 1.0, ["USD"] = 100.0, ["USDT"] = 90.0 }; settings.Preload(SettingsKeys.RatesCache, CacheJson(rates)); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USDT")); @@ -89,7 +79,6 @@ public sealed class ConversionRecomputerTests [Fact] public async Task Recompute_BudgetWithoutUpperBound_UsesLowerBoundForConvTo() { - // «От X»/одна сумма: верхней границы нет — conv_to = конверсии нижней (rates.py L122). (ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values)); store.SeedCard(Card("l_board", "b_1", from: 100, to: null, cur: "USD")); @@ -129,7 +118,6 @@ public sealed class ConversionRecomputerTests int updated = await service.RecomputeAsync(CancellationToken.None); - // Служебные колонки не трогаем (recompute_conversions L115–118; фильтрует хранилище) — conv пуст. Assert.Equal(1, updated); AssertConverted(store, "l_board", convFrom: 9250, convTo: 9250, "RUB"); Assert.Null(ConvertedOf(store, "l_archive")); @@ -141,7 +129,6 @@ public sealed class ConversionRecomputerTests [Fact] public async Task Recompute_MissingCurrencyInRates_SkipsCardKeepingOldConversion() { - // Курсы есть, но валюты бюджета (XXX) в них нет: cf = null → строку НЕ трогаем (rates.py L123–124) // — старые conv-поля сохраняются как были (не обнуляются). (ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); settings.Preload(SettingsKeys.RatesCache, CacheJson(new Dictionary { ["RUB"] = 1.0, ["USD"] = 92.5 })); @@ -162,7 +149,6 @@ public sealed class ConversionRecomputerTests [Fact] public async Task Recompute_NoRatesCache_ReturnsZeroAndKeepsCardsUntouched() { - // Кэша ratesCache нет — конвертировать нечем; дефолт-мок НЕ подставляется (по прототипу курс // читается из строки rates таблицы напрямую), карточки не трогаются. (ConversionRecomputer service, FakeKanjStore store, _) = Create(); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); @@ -245,7 +231,6 @@ public sealed class ConversionRecomputerTests Budget = new CardBudgetDto(from, to, cur), }; - // JSON значения ratesCache {rates, source, updatedAtMs} с источником mock (форма Ruling 6). // rates: Курсы к рублю. private static string CacheJson(IReadOnlyDictionary rates) { diff --git a/src/core/tests/Deal.Tests.Unit/Support/FakeSecretCipher.cs b/src/core/tests/Deal.Tests.Unit/Support/FakeSecretCipher.cs index 00446af..8e2399b 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/FakeSecretCipher.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/FakeSecretCipher.cs @@ -4,13 +4,8 @@ using Deal.Modules.Settings.Application.Abstractions; namespace Deal.Tests.Unit.Support; /// -/// Детерминированный шифр для тестов SettingsService: «enc:» + Base64(UTF-8 текст). +/// Детерминированный шифр для тестов SettingsService /// -/// -/// Повторяет контракт (Ruling 2, Task 1): пустая строка шифруется в пустую; -/// Decrypt без префикса enc: и повреждённого токена возвращает "" (решение T1 — legacy-данных нет). -/// Base64-проверка делает фейк честным для сценария «сбойный токен → пустая строка» (изоляция). -/// public sealed class FakeSecretCipher : ISecretCipher { private const string EncryptedPrefix = "enc:"; diff --git a/src/core/tests/Deal.Tests.Unit/Support/FakeTelegramDialogRow.cs b/src/core/tests/Deal.Tests.Unit/Support/FakeTelegramDialogRow.cs index fa9e7b0..c3e9242 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/FakeTelegramDialogRow.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/FakeTelegramDialogRow.cs @@ -1,7 +1,7 @@ namespace Deal.Tests.Unit.Support; /// -/// Срез строки каталога Dialogs фейка (зеркало DialogEntity). +/// Срез строки каталога Dialogs фейка /// /// Подписанный id диалога. /// Отображаемое имя диалога. diff --git a/src/core/tests/Deal.Tests.Unit/Support/FakeTelegramMessageRow.cs b/src/core/tests/Deal.Tests.Unit/Support/FakeTelegramMessageRow.cs index d38ede1..751a8f0 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/FakeTelegramMessageRow.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/FakeTelegramMessageRow.cs @@ -1,7 +1,7 @@ namespace Deal.Tests.Unit.Support; /// -/// Срез строки превью TgMessages фейка (зеркало TgMessageEntity). +/// Срез строки превью TgMessages фейка /// /// Id строки превью («m_<dialog>_<msg>»). /// Id диалога-источника. diff --git a/src/core/tests/Deal.Tests.Unit/Support/ForwardedHeadersHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/ForwardedHeadersHttpTests.cs index 3e22512..3c422d4 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/ForwardedHeadersHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/ForwardedHeadersHttpTests.cs @@ -9,19 +9,8 @@ using Microsoft.AspNetCore.HttpOverrides; namespace Deal.Tests.Unit.Support; /// -/// Тесты обработки прокси-заголовков (план Task 12; замечание ревью T4/T11): за Caddy (compose-prod, -/// Task 14) RemoteIpAddress всех запросов — адрес прокси, и audit-IP/rate-limit-по-IP схлопываются в один -/// бакет. UseForwardedHeaders (X-Forwarded-For/X-Forwarded-Proto) должен доверять только клиентам из -/// ForwardedHeaders:KnownProxies/KnownNetworks. Здесь — unit на -/// (списки доверия, fail-fast на невалидных значениях) и HTTP-сценарии: доверенный loopback-прокси -/// применяет заголовки, клиент вне списков — игнорирует. +/// Тесты обработки прокси-заголовков /// -/// -/// HTTP-часть повторяет схему Program.cs: UseForwardedHeaders первым middleware конвейера (до CORS/ -/// сессий/rate-limiter, читающих RemoteIpAddress). Тест-клиент соединяется с Kestrel по 127.0.0.1 — -/// это адрес «доверенного прокси» сценария; X-Forwarded-For несёт адрес конечного клиента (TEST-NET-3, -/// RFC 5737), X-Forwarded-Proto — https. -/// public sealed class ForwardedHeadersHttpTests { // Конечный клиент за прокси (TEST-NET-3, RFC 5737 — не маршрутизируется). @@ -34,8 +23,6 @@ public sealed class ForwardedHeadersHttpTests /// /// Из ForwardedHeadersConfig строятся ровно перечисленные KnownProxies/KnownNetworks - /// (дефолты конструктора очищены — доверие только из конфига); включены XFF + X-Forwarded-Proto, - /// один hop (Caddy). /// [Fact] public void BuildOptions_KnownProxiesAndNetworks_AreApplied() @@ -60,9 +47,7 @@ public sealed class ForwardedHeadersHttpTests } /// - /// Пустые списки конфига не передаются middleware как пустые: у ForwardedHeadersMiddleware - /// пустота KnownProxies/KnownIPNetworks означает «доверять любому клиенту», поэтому фолбэк — loopback - /// (dev-прокси на хосте; appsettings-дефолт тот же). Оператор, перечисливший Caddy, замещает фолбэк. + /// Пустые списки конфига не передаются middleware как пустые /// [Fact] public void BuildOptions_EmptyTrustLists_FallBackToLoopback() @@ -74,8 +59,7 @@ public sealed class ForwardedHeadersHttpTests } /// - /// Невалидный IP в KnownProxies — ошибка запуска (fail-fast: опечатка в доверии не должна - /// молча отключать обработку прокси-заголовков). + /// Невалидный IP в KnownProxies — ошибка запуска /// [Fact] public void BuildOptions_InvalidProxyIp_Throws() @@ -88,7 +72,7 @@ public sealed class ForwardedHeadersHttpTests } /// - /// Невалидный CIDR в KnownNetworks (не число/вне диапазона) — ошибка запуска. + /// Невалидный CIDR в KnownNetworks /// [Fact] public void BuildOptions_InvalidKnownNetwork_Throws() @@ -102,8 +86,7 @@ public sealed class ForwardedHeadersHttpTests // ─── HTTP: доверенный прокси применяет заголовки ───────────────────────── /// - /// Клиент из KnownProxies (loopback — адрес прокси сценария): X-Forwarded-For/Proto - /// применяются — приложение видит IP конечного клиента и https (audit/rate-limit в PROD за Caddy). + /// Клиент из KnownProxies /// [Fact] public async Task KnownProxy_XForwardedForAndProto_AreApplied() @@ -119,7 +102,7 @@ public sealed class ForwardedHeadersHttpTests } /// - /// Доверие подсетью (KnownNetworks, CIDR) работает: клиент из 127.0.0.0/8 — прокси. + /// Доверие подсетью /// [Fact] public async Task KnownNetwork_CoversProxy_ForwardedHeadersApplied() @@ -135,9 +118,7 @@ public sealed class ForwardedHeadersHttpTests } /// - /// Клиент вне KnownProxies/KnownNetworks: заголовки игнорируются (спуфинг XFF невозможен) — - /// приложение видит реальный адрес соединения и http. Доверенный в конфиге адрес — «не наш» клиент - /// (loopback), поэтому фолбэк не применяется. + /// Клиент вне KnownProxies/KnownNetworks /// [Fact] public async Task UnknownClient_ForwardedHeadersAreIgnored() diff --git a/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs index 4cbabb3..112c51c 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs @@ -21,23 +21,14 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Deal.Tests.Unit.Support; /// -/// Тесты gRPC-адаптера IAiClassifier к ai-service (план Task 15, Ruling 5/6; Acceptance L433–434). +/// Тесты gRPC-адаптера IAiClassifier к ai-service. /// -/// -/// Сценарии гоняются против 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-разработка"; @@ -66,7 +57,6 @@ public sealed class GrpcAiClassifierTests 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); @@ -81,7 +71,6 @@ public sealed class GrpcAiClassifierTests 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); @@ -98,7 +87,6 @@ public sealed class GrpcAiClassifierTests (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)); }); @@ -115,7 +103,6 @@ public sealed class GrpcAiClassifierTests await classifier.FilterAsync(text, CancellationToken.None); - // python filter_incoming L193: text[:4000] — лимит текста фильтра (ai.proto FilterRequest). Assert.Equal(4000, service.LastFilter!.Text.Length); }); } @@ -165,7 +152,6 @@ public sealed class GrpcAiClassifierTests 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); @@ -182,7 +168,6 @@ public sealed class GrpcAiClassifierTests 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); @@ -226,7 +211,6 @@ public sealed class GrpcAiClassifierTests (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)); }); @@ -280,7 +264,6 @@ public sealed class GrpcAiClassifierTests 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); }); @@ -325,7 +308,6 @@ public sealed class GrpcAiClassifierTests // Возвращает: JSON для Preload. private static string Json(object value) => JsonSerializer.Serialize(value); - // Проверяет накопленный usage в tenant-KV aiTokenUsage (Ruling 5). // settings: KV-хранилище тенанта. // prompt: Ожидаемые токены запроса. // completion: Ожидаемые токены ответа. diff --git a/src/core/tests/Deal.Tests.Unit/Support/IncomingRulesTests.cs b/src/core/tests/Deal.Tests.Unit/Support/IncomingRulesTests.cs index 817459b..856e2ac 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/IncomingRulesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/IncomingRulesTests.cs @@ -5,17 +5,8 @@ using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; /// -/// Тесты этап-1 правил фильтра входящих — IncomingRules (план Task 10 L370–383, pipeline.py L94–124). +/// Тесты -1 правил фильтра входящих — IncomingRules. /// -/// -/// Референс — stage1_plain (pipeline.py L94–124) + _resume_reason (L644–658): длина → -/// стоп-фразы → резюме (blockResumes + маркеры, guard «…вакансия…, присылайте резюме») → тип заявки -/// (wantedType + hireMarkers). Эффективные настройки — дефолты , перекрытые -/// сохранёнными в (Ruling 1). Сценарии-тексты подобраны так, что каждый -/// проверяет ровно одно правило: стоп-фразы по умолчанию (включая «резюме»/«ищу работу») НЕ должны -/// маскировать resume/type-ветки — там стоп-фразы переопределяются пустым списком (как сделал бы -/// пользователь в «Обработке сообщений»). -/// public sealed class IncomingRulesTests { // Создаёт правила поверх in-memory KV-хранилища. @@ -28,7 +19,6 @@ public sealed class IncomingRulesTests // Возвращает: JSON для Preload. private static string Json(object value) => JsonSerializer.Serialize(value); - // ─── Длина (minLen): pipeline.py L101–104 ────────────────────────────── [Fact] public async Task CheckAsync_ShortTextBelowDefaultMinLen_FailsWithKindLength() @@ -72,7 +62,6 @@ public sealed class IncomingRulesTests public async Task CheckAsync_LongCleanTextWithDefaults_Passes() { // Дефолты (stopPhrases «взаимный пиар/резюме/ищу работу/набор в команду», blockResumes, - // wantedType=both) не задевают текст — проход (план Task 10 L388–389). IncomingRulesResult result = await CreateRules(new FakeSettingsStore()).CheckAsync( "Заработок на крипте 300% в месяц! Подпишись на канал и получи бесплатный курс по трейдингу", CancellationToken.None); @@ -83,7 +72,6 @@ public sealed class IncomingRulesTests Assert.Equal(string.Empty, result.Kw); } - // ─── Стоп-фразы (stopPhrases): pipeline.py L106–108 ─────────────────── [Fact] public async Task CheckAsync_StopPhraseHit_ReturnsPhraseAsKwAndReason() @@ -113,12 +101,10 @@ public sealed class IncomingRulesTests "ПРЕДЛАГАЮ ВЗАИМНЫЙ ПИАР ДЛЯ ПРОДВИЖЕНИЯ ВАШЕГО КАНАЛА ПО ФИНАНСАМ И КРИПТЕ", CancellationToken.None); - // kw — фраза КАК СОХРАНЕНА (python возвращает phrase из настроек, pipeline.py L108). Assert.Equal(IncomingRules.KindStop, result.Kind); Assert.Equal("Взаимный Пиар", result.Kw); } - // ─── Отсев резюме (blockResumes + resumeMarkers): pipeline.py L109–114 ─ [Fact] public async Task CheckAsync_ResumeMarker_BlockResumesOn_BlocksWithMarker() @@ -145,7 +131,6 @@ public sealed class IncomingRulesTests store.Preload(SettingsKeys.BlockResumes, Json(false)); IncomingRules rules = CreateRules(store); - // blockResumes выключен — резюме не отсекается этапом 1 (настройка «отсев резюме»). IncomingRulesResult result = await rules.CheckAsync( "Ищу работу python backend разработчик с опытом 5 лет, удалённая занятость, фриланс", CancellationToken.None); @@ -179,7 +164,6 @@ public sealed class IncomingRulesTests IncomingRules rules = CreateRules(store); // «…вакансия…, присылайте резюме» — объявление работодателя: hire-маркер ДО «резюме» - // (pipeline._resume_reason L644–654, план Task 10 L372–373) — этап 1 пропускает. IncomingRulesResult result = await rules.CheckAsync( "Открыта вакансия senior python разработчика в офис, зарплата по итогам собеседования, " + "присылайте резюме на почту hr@example.com, рассматриваем удалённо и в гибриде", @@ -189,7 +173,6 @@ public sealed class IncomingRulesTests Assert.Null(result.Reason); } - // ─── Тип заявки (wantedType + hireMarkers): pipeline.py L115–123 ────── [Fact] public async Task CheckAsync_WantedTypeFreelance_VacancyLikeText_BlockedAsType() diff --git a/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs index 1e4098e..5c60f18 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/JoinEndpointHttpTests.cs @@ -14,15 +14,8 @@ using Microsoft.Extensions.DependencyInjection; namespace Deal.Tests.Unit.Support; /// -/// HTTP-тесты POST /api/join (Task 6, Ruling 2/11): эквивалент curl-сценария активации на in-process Kestrel. +/// HTTP-тесты POST /api/join /// -/// -/// Ручка публичная (без сессии) — успех {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"; @@ -59,7 +52,6 @@ public sealed class JoinEndpointHttpTests 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")); }); @@ -75,7 +67,6 @@ public sealed class JoinEndpointHttpTests 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); diff --git a/src/core/tests/Deal.Tests.Unit/Support/LocalColumnSuggesterTests.cs b/src/core/tests/Deal.Tests.Unit/Support/LocalColumnSuggesterTests.cs index b657739..eff3b9b 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/LocalColumnSuggesterTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/LocalColumnSuggesterTests.cs @@ -10,17 +10,8 @@ using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; /// -/// Тесты адаптера ИИ-предложений (Ruling 3, план Task 14 L472–475). +/// Тесты адаптера ИИ-предложений . /// -/// -/// Проверяются сценарии suggest-columns: кулдаун повторов (KV lastSuggestAt, 20 минут), «мало карточек в -/// «Неразобранном» (нужно от 6)», создание досок suggested=true (rules {mode:any, keywords:[…]}, note, -/// разложенные карточки с matchHits/prev_col=inbox/is_new=true), похожесть с существующими досками -/// («похожие колонки уже есть или нечего сгруппировать») и запись метки после успеха. Suggest-keywords: -/// «мало карточек… (нужно хотя бы 3)» с исключением trash/archive и успех {ok, keywords}. Контрактные -/// DTO-ответы (wire-форма 1:1 с api-map L120–121) — в SuggestResultDtosTests; HTTP-ветки 401/200 — -/// curl-приёмка (паттерн этапа: эндпоинты тонкие, сценарии у адаптера). -/// public sealed class LocalColumnSuggesterTests { // Создаёт контекст теста: in-memory хранилища + адаптер поверх сервиса досок модуля. @@ -33,7 +24,6 @@ public sealed class LocalColumnSuggesterTests return (store, settings, suggester); } - // Кладёт карточку в «Неразобранное» (seed; is_new=true, как демо-карточки Task 13). // id: Id карточки. // text: Исходное сообщение. // store: Хранилище канбана. @@ -164,7 +154,6 @@ public sealed class LocalColumnSuggesterTests ContainerDto taxi = Assert.Single(store.Boards, board => board.Name == "Такси"); Assert.True(taxi.Suggested); - // Карточки разложены по своим колонкам: is_new=true, prev_col=inbox, matchHits по правилам (Ruling 2). CardDto pyCard = store.CardDtos.Single(card => card.Id == "l_p1"); Assert.Equal(python.Id, pyCard.Col); Assert.True(pyCard.IsNew); @@ -175,7 +164,6 @@ public sealed class LocalColumnSuggesterTests Assert.Equal(["l_p1", "l_p2", "l_p3"], store.CardDtos.Where(card => card.Col == python.Id).Select(card => card.Id)); Assert.Equal(["l_t1", "l_t2", "l_t3"], store.CardDtos.Where(card => card.Col == taxi.Id).Select(card => card.Id)); - // Метка успеха записана (кулдаун следующего вызова, suggest.py L160). string? stored = settings.GetStoredJson(SettingsKeys.LastSuggestAt); long lastSuggestAt = long.Parse(stored!, CultureInfo.InvariantCulture); Assert.InRange(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - lastSuggestAt, 0, 5); @@ -241,7 +229,6 @@ public sealed class LocalColumnSuggesterTests (FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext(); SeedInbox("l_1", "нужен python", store); SeedInbox("l_2", "нужен vue", store); - // В trash/archive — повторяющиеся темы, но выборка их не учитывает (suggest.py L172–173): // текстов вне мусора только 2 → «мало карточек». store.SeedCard(new CardDto { Id = "l_t1", Col = KanbanColumns.Trash, SourceMsg = "ищу такси" }); store.SeedCard(new CardDto { Id = "l_t2", Col = KanbanColumns.Trash, SourceMsg = "ищу такси" }); diff --git a/src/core/tests/Deal.Tests.Unit/Support/LocalFileStorageTests.cs b/src/core/tests/Deal.Tests.Unit/Support/LocalFileStorageTests.cs index b44cb85..a3d39ea 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/LocalFileStorageTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/LocalFileStorageTests.cs @@ -5,14 +5,8 @@ using Deal.Infrastructure.Integrations.Storage.Services; namespace Deal.Tests.Unit.Support; /// -/// Тесты LocalFileStorage — put/get/delete round-trip во временном каталоге (план Task 6 L334–335; object_store.py L54–108; Ruling 4). +/// Тесты LocalFileStorage — put/get/delete round-trip во временном каталоге. /// -/// -/// Каждый тест работает в отдельной временной папке (удаляется в Dispose). Проверяются: round-trip -/// содержимого и вложенных каталогов из objectKey, null на отсутствующем объекте, delete (включая повторный -/// delete без ошибки) и защита от выхода за root: objectKey с сегментами «..»/«.» (в т.ч. через «\») — -/// ArgumentException, файлы вне root не создаются/не удаляются. -/// public sealed class LocalFileStorageTests : IDisposable { // Байты образца содержимого для round-trip. @@ -46,7 +40,6 @@ public sealed class LocalFileStorageTests : IDisposable } } - // ─── Put/Get round-trip (put/get L61–93) ──────────────────────────────── [Fact] public async Task Put_ThenGet_ReturnsSameContentAndObjectKey() @@ -78,7 +71,6 @@ public sealed class LocalFileStorageTests : IDisposable [Fact] public async Task Put_StreamAtNonZeroPosition_StoresWholeContentFromStart() { - // Ruling T6 (выравнивание адаптеров): Put читает содержимое С ПОЗИЦИИ 0 — перемотаемый поток сбрасывается // в начало, в файл пишется весь SampleContent, а не «хвост» от текущей позиции (как в Minio-адаптере). using MemoryStream content = new(SampleContent) { Position = SampleContent.Length / 2 }; @@ -99,7 +91,6 @@ public sealed class LocalFileStorageTests : IDisposable Assert.Null(stream); } - // ─── Stat (FileInfo; Ruling T6 — download-заголовки Task 9) ──────────────── [Fact] public async Task Stat_AfterPut_ReturnsSizeAndEmptyContentType() @@ -130,7 +121,6 @@ public sealed class LocalFileStorageTests : IDisposable () => _storage.StatAsync("../secret.bin", CancellationToken.None)); } - // ─── Delete (remove L96–108) ───────────────────────────────────────────── [Fact] public async Task Delete_RemovesObject_AndIsIdempotent() @@ -141,11 +131,9 @@ public sealed class LocalFileStorageTests : IDisposable await _storage.DeleteAsync(objectKey, CancellationToken.None); Assert.Null(await _storage.GetAsync(objectKey, CancellationToken.None)); - // Повторный delete отсутствующего объекта — успех без действий (как remove L103–108). await _storage.DeleteAsync(objectKey, CancellationToken.None); } - // ─── Защита от выхода за root (object_store.py _local_path L54–59) ────── [Fact] public async Task Put_ObjectKeyWithParentTraversal_ThrowsAndWritesNothingOutsideRoot() diff --git a/src/core/tests/Deal.Tests.Unit/Support/LocalMlClientTests.cs b/src/core/tests/Deal.Tests.Unit/Support/LocalMlClientTests.cs index 38375c6..7e9019a 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/LocalMlClientTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/LocalMlClientTests.cs @@ -9,15 +9,8 @@ using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; /// -/// Локальная реализация IMlClient без внешнего ML-сервиса (Ruling 4/5, план Task 5 L266–286). +/// Локальная реализация IMlClient без внешнего ML-сервиса. /// -/// -/// Проверяются формы ответов и поведение 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). @@ -26,10 +19,8 @@ public sealed class LocalMlClientTests 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-хранилища и хранилища обучения. @@ -39,7 +30,6 @@ public sealed class LocalMlClientTests private static LocalMlClient CreateClient(FakeSettingsStore store, FakeMlLearningStore? learning = null) => new(store, learning ?? new FakeMlLearningStore()); - // ─── StatusAsync: детерминированная форма (Ruling 5) ─────────────────── [Fact] public async Task StatusAsync_NoSettings_ReturnsDeterministicNotReadyStatus() @@ -52,7 +42,6 @@ public sealed class LocalMlClientTests 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); @@ -94,7 +83,6 @@ public sealed class LocalMlClientTests MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); - // Семантика прототипа «mlEnabled !== false» (ml_routes.py L71): false — только JSON-false. Assert.False(status.Enabled); } @@ -144,15 +132,12 @@ public sealed class LocalMlClientTests 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() @@ -172,7 +157,6 @@ public sealed class LocalMlClientTests Assert.Null(result.Type); } - // ─── PushAsync: очередь обучения MlOutbox (Ruling 4, ml_client.push L40–49) ── [Fact] public async Task PushAsync_WritesOutboxRow_WithMleIdAndTrimmedFields() @@ -202,7 +186,6 @@ public sealed class LocalMlClientTests await client.PushAsync(text, label, 1.0, CancellationToken.None); - // Прототип L44–45: пустые text/label после trim — тихий no-op, строка не пишется. Assert.Empty(learning.AddedRows); } @@ -242,7 +225,6 @@ public sealed class LocalMlClientTests 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); @@ -250,7 +232,6 @@ public sealed class LocalMlClientTests Assert.Equal(-1.0, row.Delta); } - // ─── ResetAsync: {ok:true}, чистит только очередь (ml_client.py reset_model L110–124) ── [Fact] public async Task ResetAsync_ReturnsOk() @@ -293,7 +274,6 @@ public sealed class LocalMlClientTests 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() @@ -340,7 +320,6 @@ public sealed class LocalMlClientTests 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," @@ -355,7 +334,6 @@ public sealed class LocalMlClientTests 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 564d73a..1bb8200 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/LoginAttemptEndpointHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/LoginAttemptEndpointHttpTests.cs @@ -9,10 +9,7 @@ using Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Support; /// -/// HTTP-тесты защиты входа на ручке POST /api/auth/login (план Task 11, Ruling 5): эндпоинт вызывает -/// LoginAttemptGuard до AuthService — после LoginAttemptsMax неудач ключа ip|login следующие попытки -/// получают 429 «Слишком много попыток входа…»; успешный вход сбрасывает счётчик (гвард включён -/// опциями RateLimit:Enabled=true — в dev по умолчанию выключен). +/// HTTP-тесты защиты входа на ручке POST /api/auth/login /// public sealed class LoginAttemptEndpointHttpTests { @@ -31,7 +28,7 @@ public sealed class LoginAttemptEndpointHttpTests // ─── Блокировка после 5 неудач ───────────────────────────────────────── /// - /// 5 неудачных входов → 6-я попытка (даже с верным паролем) — 429 с текстом Ruling 5. + /// 5 неудачных входов → 6-я попытка /// [Fact] public async Task Login_AfterFiveWrongAttempts_Returns429WithBlockedDetail() @@ -50,7 +47,6 @@ public sealed class LoginAttemptEndpointHttpTests Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode); } - // Ключ ip|login заблокирован: гвард отвечает 429 ДО проверки учётных данных (Ruling 5). using HttpResponseMessage blocked = await PostJsonAsync( client, $"{baseAddress}/api/auth/login", new { login = Login, password = Password }); Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode); @@ -63,8 +59,7 @@ public sealed class LoginAttemptEndpointHttpTests } /// - /// Успешный вход сбрасывает счётчик: 4 неудачи + успех → ещё 2 неудачи не блокируют - /// (без сброса вторая из них была бы 429 — 5-й и 6-й сбой в окне). + /// Успешный вход сбрасывает счётчик /// [Fact] public async Task Login_SuccessResetsCounter_FurtherFailuresNotBlocked() @@ -103,7 +98,6 @@ public sealed class LoginAttemptEndpointHttpTests // ─── Хелперы ───────────────────────────────────────────────────────── - // Включённые опции гварда (дефолты 5 неудач / окно 15 минут, Ruling 5). private static RateLimitOptions EnabledRateLimitOptions() => new() { Enabled = true }; diff --git a/src/core/tests/Deal.Tests.Unit/Support/MessageParseCoreTests.cs b/src/core/tests/Deal.Tests.Unit/Support/MessageParseCoreTests.cs index e463465..5e9f24a 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/MessageParseCoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/MessageParseCoreTests.cs @@ -8,15 +8,8 @@ using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; /// -/// Тесты чистого ядра разбора сообщения — MessageParseCore (план Task 4 L327–349, Ruling 7; -/// pipeline.py cleaners/контакты/локальные поля, ai.py normalize_dedup). +/// Тесты чистого ядра разбора сообщения — MessageParseCore. /// -/// -/// Кейсы Acceptance: markdown/URL/эмодзи-чистка, «C#» не режется, обрезка по границе; normalize_list; -/// квалификация контактов по типам и сборка из текста (≤6, дедуп); dedup-хэш детерминирован и инвариантен -/// к регистру/пунктуации («Тест!» ≡ «тест»); «О заявке»-блоки Компания→…→Условия в порядке и локальный путь -/// «О задаче: …»; локальные поля с метками и без (fallback; is_vacancy по hire-маркерам, known=false). -/// public sealed class MessageParseCoreTests { // ─── MessageTextCleaner: markdown / ссылки / эмодзи / «C#» ───────────────── @@ -84,7 +77,6 @@ public sealed class MessageParseCoreTests [Fact] public void CleanShort_LongText_CutsByWordBoundaryWithEllipsis() { - // Лимит 10: последний пробел в отрезанном куске на позиции 8 > 10/2 — режем по нему (python L184–192). string text = "Один два три четыре"; string cleaned = MessageTextCleaner.CleanShort(text, 10); @@ -122,14 +114,12 @@ public sealed class MessageParseCoreTests [Fact] public void NormalizeList_CommaList_IsNotSplit() { - // Запятая НЕ разделитель (python L322: только ; | перенос) — «Java, Kotlin» один элемент. Assert.Equal(new[] { "Java, Kotlin" }, MessageListNormalizer.NormalizeList("Java, Kotlin")); } [Fact] public void NormalizeList_CleansItemsAndDropsTrash() { - // Обрамляющие **`# снимаются (у «C#» срезается хвостовая «#» — особенность strip('*`#') python L325, // остаётся «C» — одиночная буква отбрасывается), висящие запятые/одиночные буквы/дубли — тоже. Assert.Equal( new[] { "Java", "Kotlin" }, @@ -139,7 +129,6 @@ public sealed class MessageParseCoreTests [Fact] public void NormalizeList_FromEnumerable_CleansItemsButDoesNotSplit() { - // python normalize_list(list): элементы НЕ разбиваются по разделителям — только чистятся (L320–329). Assert.Equal( new[] { "Java, Kotlin", "Kotlin" }, MessageListNormalizer.NormalizeList(new[] { "**Java, Kotlin**", "Kotlin,", "C", "Kotlin" })); @@ -180,7 +169,6 @@ public sealed class MessageParseCoreTests public void Qualify_TelegramLink_ReturnsTg() { Assert.Equal(new CardContactDto("tg", "@ivanov"), ContactsQualifier.Qualify("https://t.me/ivanov")); - // Сервисная t.me-ссылка (joinchat/share/…) — не контакт человека (python L369–371). Assert.Null(ContactsQualifier.Qualify("https://t.me/joinchat")); Assert.Null(ContactsQualifier.Qualify("https://t.me/addstickers")); } @@ -310,7 +298,6 @@ public sealed class MessageParseCoreTests [Fact] public void Hash_CaseAndPunctuation_DoNotAffect() { - // «Тест!» ≡ «тест», пробелы/пунктуация/регистр схлопываются (python [^\wа-яё]+ + casefold). Assert.Equal(DedupHasher.Hash("Тест!"), DedupHasher.Hash("тест")); Assert.Equal(DedupHasher.Hash("Привет, мир!!"), DedupHasher.Hash("привет мир")); Assert.Equal(DedupHasher.Hash("С# разработчик"), DedupHasher.Hash("с# разработчик")); @@ -468,7 +455,6 @@ public sealed class MessageParseCoreTests [Fact] public void Extract_TextFirst_ThenSummary() { - // Первый источник — текст (python L463–468); во втором — тоже сумма, но текст в приоритете. Assert.Equal( new BudgetRangeDto(1200, 1200, "USD"), AmountRangeBudgetFallback.Extract("Оплата 1200 $, срок месяц", "Бюджет 1500 €")); diff --git a/src/core/tests/Deal.Tests.Unit/Support/MlGrpcTestHost.cs b/src/core/tests/Deal.Tests.Unit/Support/MlGrpcTestHost.cs index 4e310ed..225d946 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/MlGrpcTestHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/MlGrpcTestHost.cs @@ -7,8 +7,6 @@ using Microsoft.Extensions.DependencyInjection; namespace Deal.Tests.Unit.Support; -// Общий харнесс интеграционных тестов gRPC-клиента ядра к ml-service (план Task 16, Ruling 1/2). -// Поднимает в процессе теста Kestrel HTTP/2 (plaintext — Ruling 2) на эфемерном порту с фейком // RecordingMlService (серверная сторона ml.proto) и передаёт сценарию порт + сервис-фейк: // клиент (GrpcMlClient/флашер) строится на реальном канале к этому порту, поэтому проверяются metadata // tenant-id/service-token, deadline и маппинг DTO↔proto «по проводу». DEAL_SERVICE_TOKEN задаётся env на @@ -17,7 +15,7 @@ namespace Deal.Tests.Unit.Support; internal static class MlGrpcTestHost { /// - /// Env-ключ service-token (зеркало MlGrpcConnection.ServiceTokenEnvKey). + /// Env-ключ service-token /// public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; diff --git a/src/core/tests/Deal.Tests.Unit/Support/MlGrpcTestsCollection.cs b/src/core/tests/Deal.Tests.Unit/Support/MlGrpcTestsCollection.cs index 6521716..8172356 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/MlGrpcTestsCollection.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/MlGrpcTestsCollection.cs @@ -1,8 +1,7 @@ namespace Deal.Tests.Unit.Support; /// -/// Коллекция тестов gRPC-клиента ml-service (план Task 16): сериализует сценарии, меняющие env -/// DEAL_SERVICE_TOKEN (как TelegramIngressServiceTests) — внутри коллекции гонок по env нет. +/// Коллекция тестов gRPC-клиента ml-service /// [CollectionDefinition("MlGrpcTests", DisableParallelization = true)] public sealed class MlGrpcTestsCollection diff --git a/src/core/tests/Deal.Tests.Unit/Support/MtlsCertificatesTests.cs b/src/core/tests/Deal.Tests.Unit/Support/MtlsCertificatesTests.cs index 4568bee..c63d77d 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/MtlsCertificatesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/MtlsCertificatesTests.cs @@ -7,10 +7,7 @@ using Deal.Infrastructure.Integrations.Options; namespace Deal.Tests.Unit.Support; /// -/// Тесты загрузки/проверки сертификатов mTLS (план Task 13, Ruling 6): выбор режима (флаг → null, -/// набор сертификатов), fail-fast на пустые/битые пути/пароли, проверка второй стороны цепочкой на нашу CA, -/// клиентский хендлер канала. Сетевых mTLS-рукопожатий нет (⚠ Manual) — сертификаты фиктивные, генерируются -/// в памяти через CertificateRequest (по плану Task 13) и пишутся во временный каталог теста. +/// Тесты загрузки/проверки сертификатов mTLS /// public sealed class MtlsCertificatesTests { @@ -24,7 +21,7 @@ public sealed class MtlsCertificatesTests private const string ClientAuthEkuOid = "1.3.6.1.5.5.7.3.2"; /// - /// Флаг выключен — Load возвращает null (выбор режима: plaintext + service-token, dev). + /// Флаг выключен — Load возвращает null /// [Fact] public void Load_Disabled_ReturnsNull() @@ -117,8 +114,7 @@ public sealed class MtlsCertificatesTests } /// - /// Серверная проверка: клиентский сертификат, подписанный нашей CA (chain-ошибки стандартного - /// хранилища) — принят (пересбор цепочки на CaPem). + /// Серверная проверка /// [Fact] public void ValidateClientCertificate_SignedByOurCa_ChainErrorsAccepted() @@ -134,7 +130,7 @@ public sealed class MtlsCertificatesTests } /// - /// Серверная проверка: сертификат чужой CA (и при chain-ошибках) — отказ (не «свой»). + /// Серверная проверка /// [Fact] public void ValidateClientCertificate_SignedByForeignCa_Rejected() @@ -151,7 +147,7 @@ public sealed class MtlsCertificatesTests } /// - /// Серверная проверка: отсутствие сертификата/иные ошибки — отказ (RequireCertificate). + /// Серверная проверка /// [Fact] public void ValidateClientCertificate_NullOrNameMismatch_Rejected() @@ -183,8 +179,7 @@ public sealed class MtlsCertificatesTests } /// - /// Выбор режима: при выключенном флаге Load не читает файлы вовсе (plaintext-путь dev не требует - /// сертификатов — битые пути игнорируются, режим не меняется). + /// Выбор режима: при выключенном флаге Load не читает файлы вовсе /// [Fact] public void Load_DisabledDoesNotTouchFiles() @@ -305,7 +300,7 @@ public sealed class MtlsCertificatesTests public string DirectoryPath { get; } /// - /// Путь к клиентскому PFX (для проверок второй стороны). + /// Путь к клиентскому PFX /// public string ClientPfxPath => Path.Combine(DirectoryPath, "client.pfx"); diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs index c5bb6a6..7ac7dce 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAnalyticsEndpointsHttpTests.cs @@ -8,14 +8,8 @@ using Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Support; /// -/// HTTP-тесты операторской аналитики (этап 10, T3): /api/operator/analytics/{overview,tokens,activity} -/// и расширенный фильтр аудита (actorId/offset). In-process Kestrel (OperatorAuthHttpHost), фейки хранилищ. +/// HTTP-тесты операторской аналитики /// -/// -/// Проверяются: 401 без операторской сессии; сводка (тенанты, токены, события, входы/выходы); агрегаты токенов -/// по провайдеру + итог и 400 на неизвестную группировку; лента действий с фильтром actorId и пагинацией -/// offset/limit; actorId/offset у GET /api/operator/audit. Живая curl/psql-приёмка — ⚠ Manual (нужен Postgres). -/// public sealed class OperatorAnalyticsEndpointsHttpTests { private const string OperatorLogin = "operator"; diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuditEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuditEndpointsHttpTests.cs index 93d3cd4..c9bb124 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuditEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuditEndpointsHttpTests.cs @@ -7,14 +7,8 @@ using Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Support; /// -/// HTTP-тесты аудита (Task 4, Ruling 4): запись событий входов из login-эндпоинтов и чтение ленты -/// оператором (GET /api/operator/audit) через in-process Kestrel (OperatorAuthHttpHost) на фейк-хранилищах. +/// HTTP-тесты аудита /// -/// -/// Проверяются: 401 без операторской сессии; события operator_login_ok/operator_login_failed и -/// tenant_login_ok/tenant_login_failed с полями (актор, IP, login в DetailJson, без пароля); чтение — items -/// новыми сверху + total; query-фильтры actorType/eventType. Живая curl/psql-приёмка — ⚠ Manual (нужен Postgres). -/// public sealed class OperatorAuditEndpointsHttpTests { private const string OperatorLogin = "operator"; diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthEndpointsHttpTests.cs index 6c1f0e5..06eba7f 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthEndpointsHttpTests.cs @@ -8,14 +8,8 @@ using Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Support; /// -/// HTTP-тесты операторского auth-контура (Task 3): OperatorSessionMiddleware + OperatorAuthEndpoints -/// на фейк-хранилищах через in-process Kestrel (OperatorAuthHttpHost). +/// HTTP-тесты операторского auth-контура /// -/// -/// Проверяются login (кука deal_operator_session, 12 ч/httpOnly/SameSite=Lax), logout, me (401 без сессии), -/// гейт по статусу оператора и изоляция операторской/тенантной кук (Ruling 1). Тексты ошибок — 401 -/// {"detail":"…"} как в AuthEndpoints. Живая curl-приёмка на :5080 — ⚠ Manual (нужен Postgres). -/// public sealed class OperatorAuthEndpointsHttpTests { private const string OperatorLogin = "operator"; @@ -41,7 +35,6 @@ public sealed class OperatorAuthEndpointsHttpTests Assert.True(body.GetProperty("ok").GetBoolean()); Assert.Equal(OperatorLogin, body.GetProperty("login").GetString()); - // Кука выставлена с атрибутами Ruling 1: 12 ч (max-age=43200), httpOnly, SameSite=Lax. string? setCookie = response.Headers.TryGetValues("Set-Cookie", out var values) ? values.SingleOrDefault(v => v.StartsWith(OperatorCookieName, StringComparison.OrdinalIgnoreCase)) : null; @@ -137,7 +130,6 @@ public sealed class OperatorAuthEndpointsHttpTests [Fact] public async Task OperatorWithNonActiveStatus_LoginSucceeds_ButMeReturns401() { - // Ревью Task 2: ResolveSession проверяет Status оператора — неактивный не получает сессию. FakeOperatorAuthStore operatorStore = NewOperatorStore(active: false); await OperatorAuthHttpHost.RunAsync( @@ -159,7 +151,6 @@ public sealed class OperatorAuthEndpointsHttpTests [Fact] public async Task TenantAndOperatorCookies_DoNotResolveAcrossAuthGroups() { - // Изоляция сессий (Ruling 1): deal_session не проходит на /api/operator/auth/me и наоборот. await OperatorAuthHttpHost.RunAsync( NewOperatorStore(), NewUserStore(), diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs index cff92c8..7014bd0 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs @@ -24,18 +24,13 @@ using TenantCookieOptions = Deal.Api.Configuration.CookieOptions; namespace Deal.Tests.Unit.Support; -// Харнесс HTTP-тестов операторского контура (Task 3): in-process Kestrel с SessionMiddleware, // OperatorSessionMiddleware и обеими auth-группами (/api/auth и /api/operator/auth) на фейк-хранилищах. // Эталон in-process-хостов тестов (MlGrpcTestHost/TelegramIngressTestHost). Поднимается HTTP/1.1 // (основной эндпоинт core, как :5080) на эфемерном loopback-порту; пароли — FakePasswordHasher // (детерминированный, без Argon2). Сценарий сам создаёт HttpClient'ы (у каждого — свой CookieContainer), // что позволяет проверять изоляцию кук deal_session/deal_operator_session в одном сценарии. -// Аудит (Task 4): AuditService регистрирует модуль (AddTenantsModule), IAuditLogStore — фейк // (FakeAuditLogStore), если сценарий не передал собственный; GET /api/operator/audit также // смонтирован (MapOperatorAuditEndpoints), чтобы HTTP-сценарии покрывали чтение ленты оператором. -// Реестр тенантов (Task 6/7): ITenantRepository — FakeTenantStore (пустой, если сценарий -// не передал собственный); смонтированы операторские ручки тенантов (MapOperatorTenantsEndpoints, Task 7). -// Лимиты/health (Task 10): ITenantLimitStore — FakeTenantLimitStore; смонтированы // MapOperatorLimitsEndpoints и MapOperatorHealthEndpoints; для health-ручки зарегистрированы DealDbContext // (Npgsql к dev-Postgres :5433 — SELECT 1 ручки сам переживает недоступность, см. OperatorHealthEndpoints), // опции Services:Ml|Ai|Telegram (по умолчанию UseLocal=true — Local-режим dev) и ServiceHealthProbe. @@ -68,7 +63,7 @@ internal static class OperatorAuthHttpHost (baseAddress, operators, users, _, _, _, _, _) => scenario(baseAddress, operators, users)); /// - /// Прогоняет сценарий (в т.ч. с приглашениями): дополнительно передаёт фейк-хранилища инвайтов и аудита. + /// Прогоняет сценарий /// /// Фейк-хранилище оператора (IOperatorAuthStore). /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. @@ -91,7 +86,7 @@ internal static class OperatorAuthHttpHost (baseAddress, operators, users, _, invites, audit, _, _) => scenario(baseAddress, operators, users, invites, audit)); /// - /// Прогоняет сценарий (в т.ч. с тенантами/инвайтами): дополнительно передаёт фейк-хранилища реестра. + /// Прогоняет сценарий /// /// Фейк-хранилище оператора (IOperatorAuthStore). /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. @@ -116,7 +111,7 @@ internal static class OperatorAuthHttpHost (baseAddress, operators, users, tenants, invites, audit, _, _) => scenario(baseAddress, operators, users, tenants, invites, audit)); /// - /// Прогоняет сценарий (в т.ч. с лимитами/health, Task 10): дополнительно передаёт фейк лимитов. + /// Прогоняет сценарий /// /// Фейк-хранилище оператора (IOperatorAuthStore). /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. @@ -125,8 +120,7 @@ internal static class OperatorAuthHttpHost /// Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри. /// Фейк-хранилище реестра тенантов (ITenantRepository); null — создаётся пустое внутри. /// Фейк-хранилище лимитов (ITenantLimitStore); null — создаётся пустое внутри. - /// Опции rate limiting (план Task 11): null — выключенные по умолчанию - /// (LoginAttemptGuard no-op, существующие сценарии не режутся); Enabled-опции — сценарии защиты входа. + /// Опции rate limiting: null — выключенные по умолчанию (LoginAttemptGuard no-op, существующие сценарии не режутся); Enabled-опции — сценарии защиты входа. public static async Task RunAsync( FakeOperatorAuthStore operatorStore, FakeAuthStore userStore, @@ -149,7 +143,7 @@ internal static class OperatorAuthHttpHost tokenUsageStore); /// - /// Прогоняет сценарий с глобальными настройками (ТЗ §4.1/§8.1): передаёт фейк public.global_settings. + /// Прогоняет сценарий с глобальными настройками /// /// Фейк-хранилище оператора (IOperatorAuthStore). /// Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth. @@ -212,18 +206,14 @@ internal static class OperatorAuthHttpHost builder.Services.AddSingleton(new FakeSecretCipher()); // Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys). builder.Services.AddScoped(); - // История расхода токенов (этап 10, T2/T3): аналитика токенов читает фейк-хранилище событий. builder.Services.AddSingleton(effectiveTokenUsageStore); // Провижининг схем тенантов — фейк (реальный TenantProvisioningService требует Postgres; нужен - // TenantService для операторского create (POST /api/operator/tenants, Task 7)). builder.Services.AddSingleton(); - // Пакетная миграция схем тенантов (этап 12, пакет C): реальный сервис координации поверх фейковых // реестра/провижинера — проверяются авторизация и форма сводки ручки maintenance. builder.Services.AddScoped(); // Опции кук — по умолчанию (deal_session/deal_operator_session, без конфиг-секции в тесте). builder.Services.AddOptions(); builder.Services.AddOptions(); - // Здоровье ядра (Task 10): DealDbContext на dev-Postgres :5433 — ручка сама переживает недоступность // (SELECT 1 в try/catch с таймаутом, см. OperatorHealthEndpoints); опции сервисов — dev-default // (UseLocal=true → services помечаются local); проба grpc.health.v1 stateless-синглтон. builder.Services.AddDbContext(options => options.UseNpgsql(TestDatabaseConnectionString)); @@ -233,7 +223,6 @@ internal static class OperatorAuthHttpHost builder.Services.AddSingleton(); // Глубины очередей/сессий для health (§10.2): тот же сборщик, что и метрики в продакшене. builder.Services.AddSingleton(); - // Защита входа (план Task 11, Ruling 5; этап 12, пакет B): гвард регистрируется всегда (эндпоинты /login принимают // его параметром DI); активен только при Enabled в переданных опциях (по умолчанию — выключен). // Счётчики попыток входа — общее фейк-хранилище (public.rate_limit_counters в проде). builder.Services.AddSingleton(effectiveRateLimitOptions); @@ -241,13 +230,11 @@ internal static class OperatorAuthHttpHost builder.Services.AddScoped(); WebApplication app = builder.Build(); - // Порядок как в Program.cs (Ruling 1): SessionMiddleware → OperatorSessionMiddleware → эндпоинты. app.UseMiddleware(); app.UseMiddleware(); app.MapAuthEndpoints(); app.MapOperatorAuthEndpoints(); app.MapOperatorAuditEndpoints(); - // Аналитика (этап 10, T3): overview/tokens/activity — read-only, операторская сессия. app.MapOperatorAnalyticsEndpoints(); app.MapOperatorInvitesEndpoints(); app.MapOperatorTenantsEndpoints(); diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs index eaae28c..236344d 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorHealthEndpointsHttpTests.cs @@ -7,15 +7,8 @@ using Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Support; /// -/// HTTP-тесты операторского health: GET /api/operator/health (план Task 10, Ruling 3/6/9/11). +/// HTTP-тесты операторского health /// -/// -/// Прогон на in-process Kestrel (OperatorAuthHttpHost) в dev-конфигурации по умолчанию (UseLocal=true для -/// ml/ai/telegram — Local-режим). Проверяются форма ответа {ok, core:{db}, services:[…]}, пометка сервисов -/// local (reachable=false) и 401 без операторской сессии. Проверка БД (SELECT 1 к dev-Postgres :5433) в тесте -/// ходит на реальный порт: при выключенном docker core.db=down (ручка жива, 200); при поднятом deal-postgres — -/// ok. Реальный gRPC-health сервисов (UseLocal=false, поднятый стек) — ⚠ Manual, как в задачах 6–9. -/// public sealed class OperatorHealthEndpointsHttpTests { private const string OperatorLogin = "operator"; @@ -69,7 +62,6 @@ public sealed class OperatorHealthEndpointsHttpTests }); } - // Форма записи сервиса в Local-режиме (план Task 10: {reachable:false, mode:"local"} + status/local). private static void AssertServiceLocal(JsonElement service) { Assert.Equal("local", service.GetProperty("mode").GetString()); diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorInvitesEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorInvitesEndpointsHttpTests.cs index e2ffd2c..e6d9447 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorInvitesEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorInvitesEndpointsHttpTests.cs @@ -8,14 +8,8 @@ using Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Support; /// -/// HTTP-тесты операторских ручек приглашений (Task 5, Ruling 2/11): create → list → revoke и 401 без оператора. +/// HTTP-тесты операторских ручек приглашений /// -/// -/// Эквивалент curl-минимума плана (create → list → revoke, 401 без оператора) на in-process Kestrel -/// (OperatorAuthHttpHost) с фейк-хранилищами: проверяются форма ответа create ({code,email,tenantId,expiresAt, -/// status}), список, отзыв и аудит invite_created/invite_revoked (email+code в DetailJson). Живая curl-приёмка -/// на :5080 — ⚠ Manual (нужен Postgres). -/// public sealed class OperatorInvitesEndpointsHttpTests { private const string OperatorLogin = "operator"; @@ -102,7 +96,6 @@ public sealed class OperatorInvitesEndpointsHttpTests Assert.Equal(code, stored.Code); Assert.Equal(operatorStore.Operators.Single().Id, stored.CreatedById); - // Аудит создания: invite_created с email+code (Ruling 4). AuditRecordDto createdAudit = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.InviteCreated); Assert.Equal(AuditActorTypes.Operator, createdAudit.ActorType); Assert.Equal(operatorStore.Operators.Single().Id, createdAudit.ActorId); @@ -199,7 +192,6 @@ public sealed class OperatorInvitesEndpointsHttpTests $"{baseAddress}/api/operator/invites/{code}/revoke", content: null); Assert.Equal(HttpStatusCode.OK, revoke.StatusCode); - // Отозванное приглашение освобождает email (Ruling 2; план Task 5: revoked позволяет новый). using HttpResponseMessage second = await PostJsonAsync( client, $"{baseAddress}/api/operator/invites", new { email = InvitedEmail }); Assert.Equal(HttpStatusCode.OK, second.StatusCode); diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs index 3a8f57f..1a7c597 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorLimitsEndpointsHttpTests.cs @@ -7,16 +7,8 @@ using Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Support; /// -/// HTTP-тесты операторских ручек лимитов ИИ-бюджета (план Task 10, Ruling 3/4/11): сводка по всем -/// тенантам и просмотр/смена лимита (GET/PATCH /api/operator/tenants/{id}/limit). +/// HTTP-тесты операторских ручек лимитов ИИ-бюджета /// -/// -/// Прогон на in-process Kestrel (OperatorAuthHttpHost) с фейк-хранилищами: реестр тенантов и лимиты — фейки -/// (FakeTenantStore/FakeTenantLimitStore), аудит — настоящий сервис модуля на фейк-сторе. Проверяются формы -/// ответов, сброс флагов Warned80/NotifiedExhausted при смене бюджета, аудит tenant_limit_changed (только при -/// реальном изменении), 401 без операторской сессии и 400/404 на невалидные тела. Живая curl-приёмка на :5080 -/// (с psql-проверкой строки public.tenant_limits) — ⚠ Manual (нужен Postgres). -/// public sealed class OperatorLimitsEndpointsHttpTests { private const string OperatorLogin = "operator"; @@ -274,7 +266,6 @@ public sealed class OperatorLimitsEndpointsHttpTests HttpClient operatorClient = CreateClient(baseAddress); await LoginOperatorAsync(operatorClient, baseAddress); - // Бюджет 0 — «ИИ запрещён» (Ruling 3): допустимое значение, расход 8 000 000 виден как 100%. using HttpResponseMessage patch = await PatchJsonAsync( operatorClient, LimitUrl(baseAddress, FirstTenantId), new { budget = 0 }); Assert.Equal(HttpStatusCode.OK, patch.StatusCode); diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorMaintenanceEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorMaintenanceEndpointsHttpTests.cs index 9268111..a5a8a58 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorMaintenanceEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorMaintenanceEndpointsHttpTests.cs @@ -8,13 +8,7 @@ namespace Deal.Tests.Unit.Support; /// /// HTTP-тесты операторской maintenance-ручки пакетной миграции схем тенантов -/// (этап 12, пакет C): POST /api/operator/maintenance/tenants/migrate. /// -/// -/// Прогон на in-process Kestrel (OperatorAuthHttpHost): реальный TenantSchemaMigrationService поверх -/// фейковых реестра () и провижинера (). -/// Реальный провижининг схем (Postgres) — ⚠ Manual; здесь проверяются авторизация и форма сводки. -/// public sealed class OperatorMaintenanceEndpointsHttpTests { private const string OperatorLogin = "operator"; diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs index 55fca01..f559f74 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorSettingsEndpointsHttpTests.cs @@ -9,15 +9,8 @@ using Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Support; /// -/// HTTP-тесты операторских ручек глобальных настроек Telegram (ТЗ §4.1/§8.1): -/// GET/PUT /api/operator/settings/telegram-keys. +/// HTTP-тесты операторских ручек глобальных настроек Telegram /// -/// -/// Прогон на 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"; diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorTenantsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorTenantsEndpointsHttpTests.cs index 29f0c45..17578c9 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorTenantsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorTenantsEndpointsHttpTests.cs @@ -8,15 +8,8 @@ using Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Support; /// -/// HTTP-тесты операторских ручек тенантов (план Task 7, Ruling 1/4/10/11): эквивалент curl-минимума -/// list → suspend → login заблокирован → unsuspend → login ok и impersonate → deal_session работает на /me. +/// HTTP-тесты операторских ручек тенантов /// -/// -/// Прогон на in-process Kestrel (OperatorAuthHttpHost) с фейк-хранилищами: реестр тенантов, пользователи и -/// аудит — настоящие сервисы модуля. Проверяются формы ответов, 401 без операторской сессии и аудит -/// (tenant_status_changed, tenant_login_failed с tenantId для suspended-тенанта, impersonation_started/stopped). -/// Живая curl-приёмка на :5080 — ⚠ Manual (нужен Postgres). -/// public sealed class OperatorTenantsEndpointsHttpTests { private const string OperatorLogin = "operator"; @@ -218,7 +211,6 @@ public sealed class OperatorTenantsEndpointsHttpTests HttpClient client = CreateClient(baseAddress); await LoginOperatorAsync(client, baseAddress); - // GET /api/operator/tenants: реестр + счётчик пользователей (поля лимитов — Task 8). using HttpResponseMessage list = await client.GetAsync($"{baseAddress}/api/operator/tenants"); Assert.Equal(HttpStatusCode.OK, list.StatusCode); JsonElement item = (await ReadJsonAsync(list)).GetProperty("items").EnumerateArray().Single(); @@ -380,8 +372,6 @@ public sealed class OperatorTenantsEndpointsHttpTests Assert.Equal(TenantId, detail.RootElement.GetProperty("tenantId").GetGuid()); } - // 2. Токен работает как deal_session на /api/auth/me (Acceptance Task 7), при этом операторский - // контур для него закрыт: tenant-сессия ≠ операторская (Ruling 1, изоляция кук). HttpClient userClient = CreateClient(baseAddress); using (HttpResponseMessage me = await GetWithTenantCookieAsync(userClient, baseAddress, sessionToken, "/api/auth/me")) { diff --git a/src/core/tests/Deal.Tests.Unit/Support/OriginGuardHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/OriginGuardHttpTests.cs index 6cdfd02..cb607fc 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OriginGuardHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OriginGuardHttpTests.cs @@ -10,17 +10,8 @@ using Microsoft.Extensions.DependencyInjection; namespace Deal.Tests.Unit.Support; /// -/// HTTP-тесты Origin-проверки мутаций (план Task 12, Ruling 10(2)): не-GET/HEAD/OPTIONS запросы /api -/// с заголовком Origin обязаны иметь Origin == собственному origin запроса (схема+Host) либо входящий -/// в Security:AllowedOrigins; чужой Origin → 403 {detail}. Без Origin (curl/сервер-сервер) и -/// не-мутации (GET/OPTIONS) не проверяются. +/// HTTP-тесты Origin-проверки мутаций /// -/// -/// Хост повторяет схему Program.cs: SecurityOptions регистрируется инстансом, middleware — через -/// UseMiddleware (как после UseRateLimiter в Ruling 5: Session → Operator → RateLimiter → OriginGuard). -/// Allowlist пуст в большинстве сценариев — правило «Origin == свой origin» (Ruling 10(2): при пустом -/// списке правило = Host-запросу; здесь — схема+Host, что для браузерного Origin эквивалентно). -/// public sealed class OriginGuardHttpTests { // Путь мутирующего эндпоинта сценария (POST /api/...). @@ -35,7 +26,7 @@ public sealed class OriginGuardHttpTests // ─── Чужой Origin → 403 ────────────────────────────────────────────────── /// - /// Мутация /api с Origin чужого сайта → 403 {detail} (Ruling 10(2); acceptance curl). + /// Мутация /api с Origin чужого сайта → 403 {detail} /// [Fact] public async Task Post_ForeignOrigin_Returns403WithRejectedDetail() @@ -56,7 +47,7 @@ public sealed class OriginGuardHttpTests // ─── Свой Origin (схема + Host) → ok ───────────────────────────────────── /// - /// Мутация с Origin == собственному origin запроса (схема+Host) проходит (браузерный вызов). + /// Мутация с Origin == собственному origin запроса /// [Fact] public async Task Post_SelfOrigin_IsAllowed() @@ -74,7 +65,7 @@ public sealed class OriginGuardHttpTests // ─── Allowlist из конфига ──────────────────────────────────────────────── /// - /// Origin из Security:AllowedOrigins проходит; чужой на том же хосте — 403 (allowlist не «всё»). + /// Origin из Security:AllowedOrigins проходит; чужой на том же хосте — 403 /// [Fact] public async Task Post_AllowlistedOrigin_IsAllowed_ButForeignStillRejected() @@ -100,8 +91,7 @@ public sealed class OriginGuardHttpTests // ─── Без Origin (curl/сервер-сервер) → ok ──────────────────────────────── /// - /// Мутация без заголовка Origin (curl, сервер-сервер) проходит — Origin проверяется - /// только когда он есть (Ruling 10(2); acceptance curl «без Origin → ok»). + /// Мутация без заголовка Origin /// [Fact] public async Task Post_WithoutOrigin_IsAllowed() @@ -118,7 +108,7 @@ public sealed class OriginGuardHttpTests // ─── Не-мутации не проверяются ─────────────────────────────────────────── /// - /// GET с чужим Origin не проверяется (не мутация; CORS-заголовки — зона CORS-middleware). + /// GET с чужим Origin не проверяется /// [Fact] public async Task Get_WithForeignOrigin_IsNotChecked() @@ -134,7 +124,7 @@ public sealed class OriginGuardHttpTests } /// - /// OPTIONS с чужим Origin не проверяется (CORS-preflight проходит к CORS-middleware). + /// OPTIONS с чужим Origin не проверяется /// [Fact] public async Task Options_WithForeignOrigin_IsNotChecked() diff --git a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs index 1880c5c..10f0275 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs @@ -27,19 +27,8 @@ 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 по созданным -/// карточкам. +/// Тесты PipelineWorkerScheduler — фоновый цикл разбора очереди входящих /// -/// -/// Тайминги цикла (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 теста (канал подписки). @@ -80,7 +69,6 @@ public sealed class PipelineWorkerSchedulerTests 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)); @@ -95,7 +83,6 @@ public sealed class PipelineWorkerSchedulerTests 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); @@ -134,7 +121,6 @@ public sealed class PipelineWorkerSchedulerTests 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); diff --git a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs index 123ff09..a5662f8 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs @@ -13,27 +13,12 @@ using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; /// -/// Тесты воркера pump — PipelineWorkerService (план Task 8 L423–431, Ruling 8; прототип _pump_unlocked L920–1183). +/// Тесты воркера pump — PipelineWorkerService. /// -/// -/// Референс — путь сообщения 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 фейков хранилищ/портов. @@ -72,12 +57,12 @@ public sealed class PipelineWorkerServiceTests private sealed class RacingClaimPipelineStore(bool claimResult = false) : FakePipelineStore { /// - /// Сколько раз вызван ClaimAsync (проверка: конфликт обрабатывается, а не молча пропускается). + /// Сколько раз вызван ClaimAsync /// public int ClaimCalls { get; private set; } /// - /// Сколько раз вызван DeleteClaimAsync (должен остаться 0 — заявка не наша). + /// Сколько раз вызван DeleteClaimAsync /// public int DeleteClaimCalls { get; private set; } @@ -130,7 +115,6 @@ public sealed class PipelineWorkerServiceTests Force = force, }; - // Неготовая модель: «не уверена» (LocalMlClient ready:false → predict, Ruling 5). // Возвращает: Фиксированный «не готов/не уверен». private static MlPredictResultDto NotReadyPrediction() => new( Take: false, @@ -292,7 +276,6 @@ public sealed class PipelineWorkerServiceTests 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); @@ -316,7 +299,6 @@ public sealed class PipelineWorkerServiceTests 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); @@ -366,7 +348,6 @@ public sealed class PipelineWorkerServiceTests 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); @@ -381,7 +362,6 @@ public sealed class PipelineWorkerServiceTests 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)); } @@ -406,7 +386,6 @@ public sealed class PipelineWorkerServiceTests 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)); } @@ -497,7 +476,6 @@ public sealed class PipelineWorkerServiceTests 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); @@ -535,7 +513,6 @@ public sealed class PipelineWorkerServiceTests 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); @@ -551,7 +528,6 @@ public sealed class PipelineWorkerServiceTests public async Task Pump_AiBoardNotAcceptedByRules_FallsBackToInbox() { Context ctx = CreateContext(); - // Колонка с активными правилами «nestjs»: текст про python её не проходит — страховка L449–450. ctx.KanjStore.SeedBoard(new ContainerDto { Id = "b_js", @@ -566,7 +542,6 @@ public sealed class PipelineWorkerServiceTests // ИИ/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)); } @@ -590,7 +565,6 @@ public sealed class PipelineWorkerServiceTests 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)); } @@ -607,7 +581,6 @@ public sealed class PipelineWorkerServiceTests 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); @@ -648,7 +621,6 @@ public sealed class PipelineWorkerServiceTests ctx.AiClassifier.ClassifyResult = Parsed("Разработчик", isVacancy: true); ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); - // Как в прототипе: сбой создания карточки роняет проход (обработчик фонового цикла логирует и // продолжит следующим тиком) — сообщение не отсеивается и не теряется. await Assert.ThrowsAsync(() => ctx.Worker.PumpOnceAsync(default)); @@ -671,8 +643,6 @@ public sealed class PipelineWorkerServiceTests 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); @@ -687,11 +657,9 @@ public sealed class PipelineWorkerServiceTests 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)); @@ -703,7 +671,6 @@ public sealed class PipelineWorkerServiceTests 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); diff --git a/src/core/tests/Deal.Tests.Unit/Support/RateLimitHttpTests.cs b/src/core/tests/Deal.Tests.Unit/Support/RateLimitHttpTests.cs index 6518618..da19e29 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/RateLimitHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/RateLimitHttpTests.cs @@ -14,16 +14,8 @@ using Microsoft.Extensions.DependencyInjection; namespace Deal.Tests.Unit.Support; /// -/// HTTP-тесты политик встроенного rate limiter (план Task 11, Ruling 5): превышение окна политики — -/// 429 {detail}; при RateLimit:Enabled=false лимитов нет (dev-прогон не режет curl-приёмки); -/// ключ политики "api" — тенант сессии (CurrentUser) либо IP анонима (партиции изолированы). +/// HTTP-тесты политик встроенного rate limiter /// -/// -/// Хост повторяет схему Program.cs: регистрация политик/глобального лимитера только при Enabled, -/// middleware UseRateLimiter — после «сессионного» слоя (в тесте — маркер CurrentUser по query -/// tenant, эталон SessionMiddleware), эндпоинты: /rate/auth — именованная политика "auth", -/// /rate/api — именованная "api", /rate/global — без политики (глобальный лимитер API-партиции). -/// public sealed class RateLimitHttpTests { // Путь эндпоинта под именованной политикой "auth" (эталон ручки входа). @@ -47,7 +39,7 @@ public sealed class RateLimitHttpTests // ─── Превышение окна → 429 ───────────────────────────────────────────── /// - /// Политика "auth" (окно на IP): после разрешённых запросов следующий — 429 {detail}. + /// Политика "auth" /// [Fact] public async Task AuthPolicy_ExceedingWindow_Returns429WithRejectedDetail() @@ -72,7 +64,7 @@ public sealed class RateLimitHttpTests } /// - /// Политика "api" (аноним → окно на IP): превышение — 429 {detail} (глобальный лимитер — тот же ответ). + /// Политика "api" /// [Fact] public async Task ApiPolicy_AnonymousExceedingWindow_Returns429WithRejectedDetail() @@ -99,8 +91,7 @@ public sealed class RateLimitHttpTests // ─── Ключ политики "api": тенант vs IP анонима ───────────────────────── /// - /// Партиции изолированы: исчерпанный IP-бакет анонима не режет запросы тенанта; у разных - /// тенантов собственные окна (SessionMiddleware кладёт CurrentUser до UseRateLimiter, Ruling 5). + /// Партиции изолированы /// [Fact] public async Task ApiPolicy_TenantBucket_IsSeparateFromAnonymousIpAndOtherTenants() @@ -140,8 +131,7 @@ public sealed class RateLimitHttpTests // ─── Флаг Enabled=false (dev/тесты) ───────────────────────────────────── /// - /// RateLimit:Enabled=false — лимитеры/middleware не регистрируются: запросы не режутся - /// (acceptance: dev-прогон не режет curl-приёмки, Ruling 5). + /// RateLimit:Enabled=false — лимитеры/middleware не регистрируются /// [Fact] public async Task RateLimiterDisabled_RequestsAreNotLimited() @@ -172,7 +162,6 @@ public sealed class RateLimitHttpTests if (options.Enabled) { // Счётчики окон — общее фейк-хранилище (public.rate_limit_counters в проде): лимиты видны - // всем запросам хоста, как в распределённом окружении (этап 12, пакет B). builder.Services.AddSingleton(new FakeRateLimitCounterStore()); builder.Services.AddDealRateLimiter(options); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/RatesServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/RatesServiceTests.cs index 9e5dc59..f12309f 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/RatesServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/RatesServiceTests.cs @@ -7,14 +7,8 @@ using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; /// -/// Тесты RatesService: кэш ratesCache, refresh-семантика, ShouldFetch, ConvertAmount (Task 8, Ruling 6). +/// Тесты RatesService /// -/// -/// Референс — rates.py целиком и план Task 8 L308–331. Фейковое хранилище держит JSON-строки, -/// поэтому тесты проверяют «что ушло в ratesCache» ({rates, source, updatedAtMs} — Ruling 6) и что -/// мок-режим не ходит в источник (FakeRatesSource.Calls). Время кэша задаётся через preload JSON — -/// тесты не спят. Интервал — (6 ч). -/// public sealed class RatesServiceTests { private const long FixedTimestampMs = 1_752_000_000_000; @@ -142,7 +136,6 @@ public sealed class RatesServiceTests [Fact] public async Task RefreshAsync_UnknownRateSource_TreatedAsCbr() { - // Не-mock значение настройки (в т.ч. произвольное) → запрос к ЦБ (семантика прототипа). _store.Preload(SettingsKeys.RateSource, "\"garbage\""); FakeRatesSource source = new(new Dictionary { ["RUB"] = 1.0 }); RatesService service = CreateService(source); @@ -154,7 +147,6 @@ public sealed class RatesServiceTests Assert.Contains("\"source\":\"cbr\"", _store.GetStoredJson(SettingsKeys.RatesCache)!); } - // ─── Триггер пересчёта: RefreshAsync оповещает IRatesChangedListener (Ruling 7, Task 12) ─── [Fact] public async Task RefreshAsync_MockSuccess_NotifiesListenersAfterCacheWrite() @@ -189,7 +181,6 @@ public sealed class RatesServiceTests [Fact] public async Task RefreshAsync_CbrFailure_DoesNotNotifyListeners() { - // Сбой источника: кэш не записан — пересчёт не нужен (rates.py L69–74), слушатели не вызываются. _store.Preload(SettingsKeys.RateSource, "\"cbr\""); FakeRatesListener listener = new(); RatesService service = CreateService(new FakeRatesSource(result: null), new[] { listener }); @@ -265,7 +256,6 @@ public sealed class RatesServiceTests [Fact] public async Task ShouldFetchAsync_ExactlyInterval_ReturnsTrue() { - // Граница «протухания» — включительно (rates.py L83: >= интервала). long boundary = NowMs() - (long)MockRates.RatesFetchInterval.TotalMilliseconds; _store.Preload(SettingsKeys.RatesCache, CacheJson(nowMs: boundary, source: "cbr", "USD", 92.5)); _store.Preload(SettingsKeys.RateSource, "\"cbr\""); @@ -319,7 +309,6 @@ public sealed class RatesServiceTests [Fact] public void ConvertAmount_UsdtToRub_EqualsUsdToRub() { - // USDT приравнивается к USD (rates.py L86–91) — курс 92.5, а не собственный тикер. double? usdt = RatesService.ConvertAmount(100, "USDT", "RUB", MockRates.Values); double? usd = RatesService.ConvertAmount(100, "USD", "RUB", MockRates.Values); @@ -372,7 +361,6 @@ public sealed class RatesServiceTests Assert.Equal(108.0, result); } - // ─── Формат ответа эндпоинтов (wire 1:1 с прототипом) ─────────────────── [Fact] public void RatesDto_SerializesToPrototypeWireFormat() @@ -389,7 +377,6 @@ public sealed class RatesServiceTests Assert.True(root.TryGetProperty("base", out JsonElement baseElement) && baseElement.GetString() == "RUB"); Assert.True(root.TryGetProperty("rates", out JsonElement ratesElement) && ratesElement.ValueKind == JsonValueKind.Object); Assert.True(root.TryGetProperty("source", out JsonElement sourceElement) && sourceElement.GetString() == "mock"); - // Ключ — "updatedAt" (не "updatedAtMs"): фронт читает r.updatedAt (applyRates, store.js L408). Assert.True(root.TryGetProperty("updatedAt", out JsonElement updatedAt) && updatedAt.GetInt64() == 1_752_000_000_000); } @@ -420,7 +407,6 @@ public sealed class RatesServiceTests return CreateService(source, Array.Empty()); } - // Сервис на пустом хранилище с заданным источником и слушателями пересчёта (Task 12). // source: Фейк-источник курсов. // listeners: Слушатели смены курсов. private RatesService CreateService(FakeRatesSource source, IReadOnlyList listeners) diff --git a/src/core/tests/Deal.Tests.Unit/Support/ServiceHealthProbeTests.cs b/src/core/tests/Deal.Tests.Unit/Support/ServiceHealthProbeTests.cs index 280801b..5025948 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/ServiceHealthProbeTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/ServiceHealthProbeTests.cs @@ -10,15 +10,8 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; namespace Deal.Tests.Unit.Support; /// -/// Тесты gRPC-health-пробы автономных сервисов (план Task 10, Ruling 3/6): in-proc health-сервер фейк — -/// SERVING → ok; NOT_SERVING (проверка unhealthy) → сервис жив, но не готов; недоступный порт/дедлайн → down. +/// Тесты gRPC-health-пробы автономных сервисов /// -/// -/// Хост теста — in-process Kestrel HTTP/2 (эталон AiGrpcTestHost) со стандартным grpc.health.v1 -/// (MapGrpcHealthChecksService + проверка "ready"), на который смотрит ServiceHealthProbe — проверяется -/// классификация по «проводу». Реальный gRPC-health сервисов ml/ai/telegram (UseLocal=false, стек поднят) — -/// ⚠ Manual, как в задачах 6–9 (docker off). -/// public sealed class ServiceHealthProbeTests { [Fact] @@ -66,7 +59,6 @@ public sealed class ServiceHealthProbeTests public async Task Probe_SlowServer_ExceedsDeadline_ReturnsNotReachable() { // Проверка висит дольше дедлайна пробы (3 с, ServiceHealthProbe.HealthTimeoutSeconds) — сервис жив, - // но не ответил за отведённое время: операторский health не должен ждать дольше таймаута (Ruling 3/9). await RunWithHealthServerAsync( async () => { diff --git a/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs index 22b1499..63f1232 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs @@ -7,13 +7,8 @@ using Deal.Tests.Unit.Modules.Settings; namespace Deal.Tests.Unit.Support; /// -/// Тесты SettingsService: public-снимок и PATCH-семантика 1:1 (Task 3, Rulings 1/3/9). +/// Тесты SettingsService /// -/// -/// Референс поведения — settings_routes.py L75–192; план Task 3 L184–220 (кладки/валидация). -/// Фейковое хранилище держит JSON-строки, поэтому тесты могут проверять «что ушло в БД» -/// (шифрование enc:, отсутствие лишних ключей, passthrough colState). -/// public sealed class SettingsServiceTests { private readonly FakeSettingsStore _store = new(); @@ -32,7 +27,6 @@ public sealed class SettingsServiceTests { PublicSettingsDto snapshot = await _service.GetPublicAsync(CancellationToken.None); - // Дефолты из SettingsDefaults (Task 5-приёмка). Assert.True(snapshot.AutoArchive); Assert.Equal(14, snapshot.ArchiveAfterDays); Assert.Equal(24, snapshot.MinLen); @@ -96,7 +90,6 @@ public sealed class SettingsServiceTests [Fact] public async Task GetPublicAsync_MalformedEncryptedSecret_ReturnsMaskEmpty() { - // Сбойная расшифровка не роняет снимок: keySet=false, keyMasked="" (изоляция, T1). _store.Preload( SettingsKeys.AiConfigs, JsonSerializer.Serialize(new { deepseek = new { apiKey = "enc:не-base64!", baseUrl = "https://x", model = "m" } })); @@ -239,7 +232,6 @@ public sealed class SettingsServiceTests [Fact] public async Task ApplyPatchAsync_DelayPair_ClampsAndSwapsWhenMinAboveMax() { - // Референс settings_routes.py L82–93: сначала клампы 5..600, затем swap. PublicSettingsDto snapshot = await PatchAsync(new { discJoinDelayMin = 700, discJoinDelayMax = 5 }); Assert.Equal(5, snapshot.DiscJoinDelayMin); @@ -293,7 +285,6 @@ public sealed class SettingsServiceTests [Fact] public async Task ApplyPatchAsync_Bool_AcceptsOnlyJsonBoolean() { - // Строка «false» не «питон-булеватся» — ключ пропускается (план Task 3). PublicSettingsDto snapshot = await PatchAsync(new { aiEnabled = "false" }); Assert.True(snapshot.AiEnabled); @@ -563,7 +554,6 @@ public sealed class SettingsServiceTests Assert.Equal("1…", deepseek.KeyMasked); } - // ─── PATCH: colState (passthrough, Ruling 9) ───────────────────────────── [Fact] public async Task ApplyPatchAsync_ColState_PassedThroughAsIs() @@ -597,7 +587,6 @@ public sealed class SettingsServiceTests Assert.Empty(_store.Keys); } - // ─── Триггер пересчёта: PATCH targetCurrency/conversionOn → IRatesChangedListener (Ruling 7, Task 12) ─── [Fact] public async Task ApplyPatchAsync_TargetCurrencyInBody_NotifiesListenersAfterSave() @@ -639,7 +628,6 @@ public sealed class SettingsServiceTests [Fact] public async Task ApplyPatchAsync_TargetCurrencyJsonNull_DoesNotNotifyListeners() { - // Семантика прототипа body.get(...) is not None: JSON-null — не смена настройки (L190–191). FakeRatesListener listener = new(); SettingsService local = new(_store, _cipher, new[] { listener }); diff --git a/src/core/tests/Deal.Tests.Unit/Support/SseBrokerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/SseBrokerTests.cs index 03ab783..28a359b 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/SseBrokerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/SseBrokerTests.cs @@ -4,15 +4,8 @@ using Deal.Api.Events; namespace Deal.Tests.Unit.Support; /// -/// Тесты SSE-брокера (план Task 9 L362–378, Ruling 5): очередь подписчика ≤200 с вытеснением -/// старых, публикация без подписчиков не падает, разные тенанты изолированы; frame протокола SSE. +/// Тесты SSE-брокера /// -/// -/// Семантика 1:1 с прототипом sse.py: per-tenant канал (L14–18), подписка = bounded-очередь -/// maxsize=200 (L19–23), переполнение — вытеснение старых get_nowait+put_nowait (L36–44), -/// publish без подписчиков — no-op (L29–45). Каналы System.Threading.Channels (DropOldest) -/// дают ту же семантику детерминированно. -/// public sealed class SseBrokerTests { // Тенант A теста (канал подписки). @@ -106,7 +99,6 @@ public sealed class SseBrokerTests { var sseEvent = new SseEvent("toast", """{"text":"Привет","icon":"check"}"""); - // Формат 1:1 с sse.py L30: event: <тип>\ndata: \n\n. Assert.Equal("event: toast\ndata: {\"text\":\"Привет\",\"icon\":\"check\"}\n\n", sseEvent.RenderFrame()); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs index e2ff852..8232ca8 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs @@ -26,22 +26,8 @@ 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). +/// Тесты StorageTickScheduler — логика прохода цикла правил хранения /// -/// -/// Тайминги цикла (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 теста (канал подписки). @@ -185,9 +171,7 @@ public sealed class StorageTickSchedulerTests 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)); @@ -199,7 +183,6 @@ public sealed class StorageTickSchedulerTests 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)); @@ -220,7 +203,6 @@ public sealed class StorageTickSchedulerTests 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); @@ -238,8 +220,6 @@ public sealed class StorageTickSchedulerTests [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(); @@ -345,14 +325,11 @@ public sealed class StorageTickSchedulerTests 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(); @@ -406,7 +383,6 @@ public sealed class StorageTickSchedulerTests // id: Id карточки (c_...). // title: Заголовок (ушёл в SSE reminder_due). // reminderAtMs: Время напоминания, epoch-ms (прошлое — «выстрелит» на проходе). - // stage: Контейнер карточки (по умолчанию hold — её напоминания проверяет тик, Ruling 3). // Возвращает: Карточка как DTO хранилища. private static CardDto HoldCard( string id, diff --git a/src/core/tests/Deal.Tests.Unit/Support/StorageToastPublisherTests.cs b/src/core/tests/Deal.Tests.Unit/Support/StorageToastPublisherTests.cs index 57eff4b..55c6bf0 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/StorageToastPublisherTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/StorageToastPublisherTests.cs @@ -5,15 +5,8 @@ using Deal.Modules.Kanban.Application.Models; namespace Deal.Tests.Unit.Support; /// -/// Тесты StorageToastPublisher — общий хелпер SSE-тостов статистики тика для POST /api/admin/tick -/// (AdminTickOrchestrator, план Task 10) и фонового StorageTickScheduler (план Task 11; notify_tick_stats L496–504). +/// Тесты StorageToastPublisher — общий хелпер SSE-тостов статистики тика для POST /api/admin/tick и фонового StorageTickScheduler. /// -/// -/// Публикация только по ненулевым счётчикам, тексты/иконки 1:1 с прототипом («Автоархив: N карточек»/clock, -/// «Архив очищен: N (90 дн.)»/trash, «Корзина очищена: N (7 дн.)»/trash, «Отсев очищен: N записей (3 дн.)»/trash — -/// ветка purgedRejected плана Task 10, счётчик наполняет оркестратор тика очисткой отсева). Публикация в канал -/// тенанта; без подписчиков — no-op (Ruling 5). -/// public sealed class StorageToastPublisherTests { // Тенант теста (канал подписки). diff --git a/src/core/tests/Deal.Tests.Unit/Support/StubHttpMessageHandler.cs b/src/core/tests/Deal.Tests.Unit/Support/StubHttpMessageHandler.cs index 93fb7e2..0ae73f6 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/StubHttpMessageHandler.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/StubHttpMessageHandler.cs @@ -1,14 +1,8 @@ namespace Deal.Tests.Unit.Support; /// -/// Фейковый HttpMessageHandler для юнит-тестов HTTP-адаптеров (запись запросов). +/// Фейковый HttpMessageHandler для юнит-тестов HTTP-адаптеров /// -/// -/// Отвечает через делегат: сценарий может вернуть HttpResponseMessage или бросить -/// исключение (сетевая ошибка — через Task.FromException). Каждый отправленный запрос сохраняется -/// в для проверок URL/заголовков; ветки без HTTP (локальный провайдер, -/// нет ключа, SSRF-гейты) проверяются пустотой списка. -/// public sealed class StubHttpMessageHandler : HttpMessageHandler { private readonly Func> _responder; @@ -23,7 +17,7 @@ public sealed class StubHttpMessageHandler : HttpMessageHandler } /// - /// Отправленные запросы (по одному на фактический HTTP-вызов). + /// Отправленные запросы /// public List Requests { get; } = new(); diff --git a/src/core/tests/Deal.Tests.Unit/Support/SuggestResultDtosTests.cs b/src/core/tests/Deal.Tests.Unit/Support/SuggestResultDtosTests.cs index cbfe505..4dfdb6c 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/SuggestResultDtosTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/SuggestResultDtosTests.cs @@ -4,15 +4,8 @@ using Deal.Contracts.Integrations.Models; namespace Deal.Tests.Unit.Support; /// -/// Wire-форма ответов ИИ-предложений: сериализация DTO 1:1 с api-map §3.2 L120–121 (camelCase, -/// отсутствующие ключи опускаются — как dict прототипа suggest.py). +/// Wire-форма ответов ИИ-предложений /// -/// -/// Проверяются формы: suggest-columns — {ok:true, created:N} (без reason/cooldown при успехе), -/// {ok:false, reason} (без created/cooldown), кулдаун — {ok:false, reason, cooldown:true}; -/// suggest-keywords — {ok:true, keywords:[…]} и {ok:false, reason}. HTTP-ветки эндпоинтов -/// (401 без сессии, 200 при ok:false) проверяются curl-приёмкой — эндпоинты тонкие, ответы — этот DTO. -/// public sealed class SuggestResultDtosTests { // Опции сериализации, как у минимальных API (JsonSerializerDefaults.Web → camelCase). diff --git a/src/core/tests/Deal.Tests.Unit/Support/TelegramGrpcTestHost.cs b/src/core/tests/Deal.Tests.Unit/Support/TelegramGrpcTestHost.cs index 525f3f8..8b19a5b 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TelegramGrpcTestHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TelegramGrpcTestHost.cs @@ -7,8 +7,6 @@ using Microsoft.Extensions.DependencyInjection; namespace Deal.Tests.Unit.Support; -// Общий харнесс in-proc тестов gRPC-клиента ядра к telegram-service (план Task 14, Ruling 1/2). -// Поднимает в процессе теста Kestrel HTTP/2 (plaintext — Ruling 2) на эфемерном порту с фейком // RecordingTelegramService (серверная сторона telegram.proto) и передаёт сценарию порт + сервис-фейк: // клиент (GrpcTelegramClient) строится на реальном канале к этому порту, поэтому проверяются metadata // tenant-id/service-token, deadline и маппинг DTO↔proto «по проводу». DEAL_SERVICE_TOKEN задаётся env на время @@ -17,7 +15,7 @@ namespace Deal.Tests.Unit.Support; internal static class TelegramGrpcTestHost { /// - /// Env-ключ service-token (зеркало TelegramGrpcConnection.ServiceTokenEnvKey). + /// Env-ключ service-token /// public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; diff --git a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs index 0af96eb..fbd2b51 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressServiceTests.cs @@ -19,18 +19,7 @@ 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. +/// In-proc gRPC-тесты входящего потока telegram-service → ядро. /// public sealed class TelegramIngressServiceTests { @@ -46,8 +35,7 @@ public sealed class TelegramIngressServiceTests // ─── PushMessage: приём, дубль, несуществующий тенант ────────────────── /// - /// PushMessage кладёт строку очереди тенанта (эмуляция входящего сообщения — сквозная проверка без - /// Telegram, план Task 12): accepted=true, строка в очереди фейка с канальными полями и msgId. + /// PushMessage кладёт строку очереди тенанта /// [Fact] public async Task PushMessage_ValidTenant_EnqueuesQueueRowAndAccepts() @@ -76,7 +64,7 @@ public sealed class TelegramIngressServiceTests } /// - /// Повтор PushMessage того же dialogId+msgId — duplicate=true, очередь не растёт (гвард EnqueueAsync). + /// Повтор PushMessage того же dialogId+msgId — duplicate=true, очередь не растёт /// [Fact] public async Task PushMessage_SameDialogAndMsgIdTwice_SecondIsDuplicateAndQueueNotGrown() @@ -100,8 +88,7 @@ public sealed class TelegramIngressServiceTests } /// - /// PushMessage для несуществующего тенанта не падает (нет записи в реестре/схемы → ошибка ловится, - /// reply not-accepted, план Task 12): RPC завершается штатно, очередь не растёт, исключения нет. + /// PushMessage для несуществующего тенанта не падает /// [Fact] public async Task PushMessage_UnknownTenant_NotAcceptedWithoutRpcError() @@ -125,7 +112,7 @@ public sealed class TelegramIngressServiceTests // ─── Интерцептор service-token и metadata tenant-id ───────────────────── /// - /// Запрос без metadata «service-token» → UNAUTHENTICATED (Ruling 1). + /// Запрос без metadata «service-token» → UNAUTHENTICATED. /// [Fact] public async Task PushMessage_WithoutToken_IsUnauthenticated() @@ -142,7 +129,7 @@ public sealed class TelegramIngressServiceTests } /// - /// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1). + /// Запрос с неверным токеном → UNAUTHENTICATED. /// [Fact] public async Task PushMessage_WithWrongToken_IsUnauthenticated() @@ -184,7 +171,7 @@ public sealed class TelegramIngressServiceTests } /// - /// Metadata tenant-id отсутствует → UNAUTHENTICATED (README src/contracts: tenant-id обязателен). + /// Metadata tenant-id отсутствует → UNAUTHENTICATED /// [Fact] public async Task PushMessage_WithoutTenantIdMetadata_IsUnauthenticated() @@ -204,8 +191,6 @@ public sealed class TelegramIngressServiceTests /// /// SyncDialogs применяет каталог тенанта к таблице Dialogs и отвечает актуальным списком monitored id - /// (план Task 13, Ruling 7): по умолчанию autoMonitorNew=true — новые диалоги каталога появляются - /// включёнными, ответ содержит их id (зеркало telegram-service обновится по reply). /// [Fact] public async Task SyncDialogs_KnownTenant_AppliesCatalogAndReturnsMonitoredIds() @@ -232,7 +217,6 @@ public sealed class TelegramIngressServiceTests /// /// SyncDialogs при autoMonitorNew=false добавляет новые диалоги отключёнными — зеркало мониторинга пусто - /// (Ruling 7): ответ monitored_ids пуст, каталог применён (строки Dialogs есть). /// [Fact] public async Task SyncDialogs_AutoMonitorNewDisabled_ReturnsEmptyMonitoredMirror() @@ -263,9 +247,7 @@ public sealed class TelegramIngressServiceTests } /// - /// ReportStatus: KV tgStatus/tgAccount на каждый репорт, SSE system_status на каждый репорт и тосты - /// только на переходах connected (false→true «подключён», true→false «отключён») — как сервис шлёт - /// статус heartbeat'ом, без гарда переходов тосты дублировались бы (Ruling 7). + /// ReportStatus: KV tgStatus/tgAccount на каждый репорт, SSE system_status на каждый репорт и тосты только на переходах connected /// [Fact] public async Task ReportStatus_PersistsStatusAndPublishesTransitionToasts() @@ -312,7 +294,7 @@ public sealed class TelegramIngressServiceTests } /// - /// ReportStatus для несуществующего тенанта не падает — ok=false, KV не тронут (план Task 12). + /// ReportStatus для несуществующего тенанта не падает — ok=false, KV не тронут. /// [Fact] public async Task ReportStatus_UnknownTenant_NotSavedWithoutRpcError() @@ -342,10 +324,8 @@ public sealed class TelegramIngressServiceTests // Тип 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). diff --git a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs index ab91e8c..0e55a4d 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs @@ -22,8 +22,6 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; namespace Deal.Tests.Unit.Support; -// Общий харнесс интеграционных тестов gRPC-ингресса Deal.Api (план Task 12): поднимает хост в процессе -// теста на эфемерном порту (Kestrel HTTP/2, plaintext — Ruling 2) с тем же набором регистраций, что // Program.cs (AddGrpc + IngressServiceTokenInterceptor + TelegramIngressService), и tenant-фейками вместо // БД: реестр (FakeTenantRegistry) и tenant-scoped адаптеры (IPipelineStore/ISettingsStore) выбирает // сценарий. DEAL_SERVICE_TOKEN задаётся env на время сценария (интерцептор читает IConfiguration @@ -31,7 +29,7 @@ namespace Deal.Tests.Unit.Support; internal static class TelegramIngressTestHost { /// - /// Env-ключ ожидаемого service-token (зеркало IngressServiceTokenInterceptor). + /// Env-ключ ожидаемого service-token /// public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; @@ -41,17 +39,17 @@ internal static class TelegramIngressTestHost public const string DefaultToken = "deal-test-token"; /// - /// Deadline RPC-вызовов теста (сек). + /// Deadline RPC-вызовов теста /// public const int RpcDeadlineSeconds = 10; /// - /// Ключ gRPC-metadata с service-token (зеркало IngressServiceTokenInterceptor). + /// Ключ gRPC-metadata с service-token /// public const string ServiceTokenMetadataKey = IngressServiceTokenInterceptor.ServiceTokenMetadataKey; /// - /// Ключ gRPC-metadata с tenant-id (зеркало TelegramIngressService). + /// Ключ gRPC-metadata с tenant-id /// public const string TenantIdMetadataKey = TelegramIngressService.TenantIdMetadataKey; @@ -61,9 +59,7 @@ internal static class TelegramIngressTestHost /// Значение env DEAL_SERVICE_TOKEN (null — убрать переменную). /// Регистрация tenant-фейков сценария (реестр/хранилища/брокер). /// Сценарий с gRPC-каналом к хосту. - /// Опции rate limiting (план Task 11): null — без интерцептора и health - /// (существующие сценарии); Enabled-опции — хост добавляет IngressRateLimitInterceptor (+ grpc.health.v1 - /// для проверки освобождения health от лимита). + /// Опции rate limiting: null — без интерцептора и health (существующие сценарии); Enabled-опции — хост добавляет IngressRateLimitInterceptor (+ grpc.health.v1 для проверки освобождения health от лимита). public static async Task RunAsync( string? serviceToken, Action configureServices, @@ -94,14 +90,12 @@ internal static class TelegramIngressTestHost grpc.Interceptors.Add(); if (rateLimitOptions is { Enabled: true }) { - // Лимит по tenant-id (план Task 11): общий singleton-лимитер + интерцептор, как в Program.cs. grpc.Interceptors.Add(); } }); if (rateLimitOptions is { Enabled: true }) { // Счётчики окон — общий фейк-хранилище (public.rate_limit_counters в проде); лимитер — - // store-backed, как в Program.cs (этап 12, пакет B). builder.Services.AddSingleton(new FakeRateLimitCounterStore()); builder.Services.AddSingleton(provider => IngressRateLimitInterceptor.CreateLimiter( @@ -113,7 +107,6 @@ internal static class TelegramIngressTestHost .AddCheck("ready", () => HealthCheckResult.Healthy("хост готов")); } - // Модуль Telegram (план Task 13): DialogsService резолвится ингрессом (SyncDialogs/PushMessage- // превью). Фейки tenant-адаптеров по умолчанию — сценарий переопределяет их в configureServices // (как ISettingsStore/IPipelineStore ниже): последняя регистрация побеждает. builder.Services.AddScoped(_ => new FakeSettingsStore()); @@ -153,7 +146,7 @@ internal static class TelegramIngressTestHost } /// - /// Строит metadata вызова: service-token (+ tenant-id, если задан). + /// Строит metadata вызова /// /// Значение заголовка service-token. /// Id тенанта (null — без заголовка tenant-id). diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestPort.cs b/src/core/tests/Deal.Tests.Unit/Support/TestPort.cs index d980542..1b29d68 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TestPort.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TestPort.cs @@ -4,7 +4,6 @@ using System.Net.Sockets; namespace Deal.Tests.Unit.Support; -// Единый аллокатор свободных TCP-портов для интеграционных тестов ядра (этап 12, остаток 1). // Заменяет размноженные копии FreeTcpPort() из тест-хостов (AiGrpcTestHost, OperatorAuthHttpHost, // ForwardedHeadersHttpTests и т. п.). Наивная связка «биндинг 127.0.0.1:0 → освобождение» даёт редкую // гонку при параллельном прогоне: ОС может выдать один и тот же освобождённый эфемерный порт двум тестам, и diff --git a/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs b/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs index 92cd010..d5a5040 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TokenUsageRecorderTests.cs @@ -12,10 +12,7 @@ using Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Support; /// -/// Тесты recorder'а расхода токенов (Ruling 3 этапа 7, Task 8; история — этап 10, T2): успешный usage -/// ответа ai-service списывается (1) с бюджета периода tenant_limits, (2) в lifetime-KV aiTokenUsage и -/// (3) пишется событием в token_usage_events; ML-вызов пишет только событие (kind=ml, оценка ≈chars/4). -/// Всё — на фейках, без сети/БД. +/// Тесты recorder'а расхода токенов /// public sealed class TokenUsageRecorderTests { @@ -36,7 +33,6 @@ public sealed class TokenUsageRecorderTests Assert.Equal(540, limits.UsedTokens(TenantGuid)); Assert.Equal(TokenBudgetDefaults.DefaultBudgetTokens, limits.BudgetTokens(TenantGuid)); - // (2) Lifetime-KV: полный объект {prompt, completion, total} (формат этапа 6). Assert.Equal("{\"prompt\":500,\"completion\":40,\"total\":540}", settings.GetStoredJson(SettingsKeys.AiTokenUsage)); // (3) История: одно событие ai с провайдером/моделью и токенами. diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/RpcCallLoggingInterceptor.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/RpcCallLoggingInterceptor.cs index 6d14672..7e2c70e 100644 --- a/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/RpcCallLoggingInterceptor.cs +++ b/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/RpcCallLoggingInterceptor.cs @@ -9,19 +9,8 @@ using Deal.Grpc.Hosting.Services; namespace Deal.Grpc.Hosting.Interceptors; /// -/// Access-лог RPC Deal-сервисов (Ruling 7, план Task 14; общий шаблон трёх сервисов — C31): каждый -/// вызов (кроме gRPC-health) — одна структурированная строка «метод → статус за N мс». +/// Access-лог RPC Deal-сервисов /// -/// -/// Регистрируется ПЕРВЫМ в цепочке AddGrpc (до ServiceTokenInterceptor): логируются и отклонённые -/// вызовы (401) — access-лог должен видеть отказы. Значения запросов не логируются (в RPC — тексты/ -/// промпты/ключи), секреты не пишутся (Ruling 13). gRPC-health (docker healthcheck ~5 с) пропускается — -/// иначе лог был бы зашумлён инфраструктурными пробами. -/// Access-лог ведётся для всех видов RPC (unary/клиентский/серверный/дуплексный стриминг): каждый -/// handler-метод исполняется через общий . «Прочие» сбои реализации (не -/// отмена и не RpcException) логируются как Unknown и переводятся в RpcException — мимо лога они -/// больше не уходят (замечание code-review). -/// public sealed class RpcCallLoggingInterceptor : Interceptor { // Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness). @@ -35,7 +24,7 @@ public sealed class RpcCallLoggingInterceptor : Interceptor /// /// Создаёт интерцептор access-лога gRPC-вызовов. /// - /// Логгер (Serilog, Ruling 7). + /// Логгер. public RpcCallLoggingInterceptor(ILogger logger) { ArgumentNullException.ThrowIfNull(logger); @@ -43,7 +32,7 @@ public sealed class RpcCallLoggingInterceptor : Interceptor } /// - /// Логирует unary-RPC: время вызова и итоговый gRPC-статус (успех либо статус исключения). + /// Логирует unary-RPC /// public override Task UnaryServerHandler( TRequest request, @@ -52,7 +41,7 @@ public sealed class RpcCallLoggingInterceptor : Interceptor => LogAsync(context, () => continuation(request, context)); /// - /// Логирует client-streaming-RPC: access-строка пишется после завершения потока/вызова. + /// Логирует client-streaming-RPC /// public override Task ClientStreamingServerHandler( IAsyncStreamReader requestStream, @@ -61,7 +50,7 @@ public sealed class RpcCallLoggingInterceptor : Interceptor => LogAsync(context, () => continuation(requestStream, context)); /// - /// Логирует server-streaming-RPC: access-строка пишется после завершения потока/вызова. + /// Логирует server-streaming-RPC /// public override Task ServerStreamingServerHandler( TRequest request, @@ -71,7 +60,7 @@ public sealed class RpcCallLoggingInterceptor : Interceptor => LogAsync(context, () => continuation(request, responseStream, context)); /// - /// Логирует дуплексный RPC: access-строка пишется после завершения потока/вызова. + /// Логирует дуплексный RPC /// public override Task DuplexStreamingServerHandler( IAsyncStreamReader requestStream, diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/ServiceTokenInterceptor.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/ServiceTokenInterceptor.cs index a19cefe..8682823 100644 --- a/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/ServiceTokenInterceptor.cs +++ b/src/grpc-hosting/Deal.Grpc.Hosting/Interceptors/ServiceTokenInterceptor.cs @@ -10,44 +10,26 @@ using Deal.Grpc.Hosting.Services; namespace Deal.Grpc.Hosting.Interceptors; /// -/// Серверный интерцептор service-token (Ruling 1; общий шаблон трёх Deal-сервисов — C31). -/// -/// Каждый RPC Deal-сервиса обязан нести gRPC-metadata «service-token», равный ожидаемому значению -/// из env DEAL_SERVICE_TOKEN (общий токен сервисов в compose, Ruling 12). Отсутствие или -/// несовпадение токена — отказ UNAUTHENTICATED до вызова метода сервиса. Стандартный -/// grpc.health.v1.Health токеном НЕ проверяется: это liveness инфраструктуры (docker healthcheck, -/// Ruling 12), данных тенантов он не отдаёт. -/// -/// Fail-closed (замечание ревью Task 2 учтено): если DEAL_SERVICE_TOKEN не задан/пуст — любой -/// Deal-RPC отклоняется всегда. Явный гард обязателен: сравнение строк без него пропустило бы -/// запрос с пустым значением metadata («» == «»), а env-провайдер конфигурации возвращает пустую -/// строку вместо null для незаданного ключа. -/// -/// Проверка выполняется для ВСЕХ видов RPC (unary/клиентский/серверный/дуплексный стриминг): -/// метод вызывается из каждого handler-а (замечание code-review). +/// Серверный интерцептор service-token. /// public sealed class ServiceTokenInterceptor : Interceptor { /// - /// Ключ gRPC-metadata с токеном сервиса (контракт — README src/contracts). + /// Ключ gRPC-metadata с токеном сервиса /// public const string ServiceTokenMetadataKey = "service-token"; // Префикс методов стандартного gRPC-health, освобождённых от проверки токена. private const string HealthMethodPrefix = "/grpc.health.v1.Health/"; - // Env-ключ ожидаемого токена (только env; ключи/секреты не логируются — Ruling 13). private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; - // Деталь отказа — общий текст для трёх сервисов этапа (шаблон T2/T3/T4). private const string RejectionDetail = "service-token отсутствует или неверен"; private readonly byte[] _expectedTokenBytes; /// - /// Создаёт интерцептор. Ожидаемый токен читается из конфигурации (env DEAL_SERVICE_TOKEN) - /// в момент старта хоста; смена токена требует рестарта (как остальной env-конфиг). Токен - /// хранится в UTF-8-байтах для constant-time сравнения (). + /// Создаёт интерцептор. /// /// Конфигурация хоста (env-провайдер WebApplicationBuilder). public ServiceTokenInterceptor(IConfiguration configuration) diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Models/MtlsCertificates.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Models/MtlsCertificates.cs index 331069f..96ab05b 100644 --- a/src/grpc-hosting/Deal.Grpc.Hosting/Models/MtlsCertificates.cs +++ b/src/grpc-hosting/Deal.Grpc.Hosting/Models/MtlsCertificates.cs @@ -8,21 +8,8 @@ using Deal.Grpc.Hosting.Services; namespace Deal.Grpc.Hosting.Models; /// -/// Загруженный набор сертификатов mTLS внутреннего gRPC (Ruling 6, план Task 13; общий шаблон — C31). +/// Загруженный набор сертификатов mTLS внутреннего gRPC. /// -/// -/// Создаётся один раз на старте процесса, когда =true, из файлов -/// deploy/certs (генерация — scripts/mtls-certs.sh); при выключенном флаге возвращает -/// null — процесс остаётся на plaintext + service-token (Ruling 2 этапа 6). Экземпляр живёт до конца -/// процесса: сертификаты держат Kestrel (серверный) и исходящие каналы процессов с клиентской ролью -/// (клиентский), поэтому IDisposable сознательно нет — преждевременный Dispose сломал бы живые -/// соединения. Fail-fast: при включённом флаге любой пустой/битый путь или пароль — -/// на старте. -/// -/// Проверка второй стороны — цепочка на нашу CA (CustomRootTrust, без revocation): dev-CA не в системном -/// хранилище, поэтому стандартная проверка доверия дала бы RemoteCertificateChainErrors и без кастомного -/// билда цепочки каждое соединение отвергалось бы. -/// public sealed class MtlsCertificates { // Роль в сообщениях об ошибках: CA-сертификат (проверка второй стороны). @@ -45,23 +32,22 @@ public sealed class MtlsCertificates } /// - /// CA-сертификат из CaPem: корень доверия для проверки второй стороны. + /// CA-сертификат из CaPem /// public X509Certificate2 CaCertificate { get; } /// - /// Серверный сертификат процесса из PFX (подпись своего Kestrel-gRPC-эндпоинта). + /// Серверный сертификат процесса из PFX /// public X509Certificate2 ServerCertificate { get; } /// - /// Клиентский сертификат из PFX (подпись исходящих каналов, общий deal-client). + /// Клиентский сертификат из PFX /// public X509Certificate2 ClientCertificate { get; } /// - /// Загружает сертификаты из : null при выключенном флаге (режим plaintext), - /// иначе — CA + серверный + клиентский с fail-fast на битые пути/пароли. + /// Загружает сертификаты из /// /// Опции mTLS (env DEAL_MTLS_*). /// Набор сертификатов либо null (флаг выключен). @@ -81,9 +67,7 @@ public sealed class MtlsCertificates } /// - /// Серверная проверка клиентского сертификата для Kestrel (ClientCertificateValidation): сертификат - /// обязан быть подписан нашей CA (цепочка до CaPem). Стандартные ошибки цепочки (наша CA вне системного - /// хранилища) пересобираются кастомным билдом; иные ошибки (нет сертификата/недоступен) — отказ. + /// Серверная проверка клиентского сертификата для Kestrel /// /// Клиентский сертификат из рукопожатия (null — RequireCertificate не выполнен). /// Цепочка стандартной проверки (игнорируется — пересобирается на нашу CA). @@ -112,8 +96,7 @@ public sealed class MtlsCertificates } /// - /// Создаёт HTTP/2-хендлер исходящего канала: клиентский сертификат + проверка CA сервера - /// (используют процессы с исходящими gRPC-каналами — общий шаблон). + /// Создаёт HTTP/2-хендлер исходящего канала /// /// Новый SocketsHttpHandler (владелец — создатель; канал GrpcChannel закроет его вместе с собой). public SocketsHttpHandler CreateClientHttpHandler() diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Options/MtlsOptions.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Options/MtlsOptions.cs index 25cea26..7ed5d98 100644 --- a/src/grpc-hosting/Deal.Grpc.Hosting/Options/MtlsOptions.cs +++ b/src/grpc-hosting/Deal.Grpc.Hosting/Options/MtlsOptions.cs @@ -6,26 +6,17 @@ using Deal.Grpc.Hosting.Services; namespace Deal.Grpc.Hosting.Options; /// -/// Конфигурация mTLS-транспорта внутреннего gRPC (Ruling 6, план Task 13; общий шаблон — C31). +/// Конфигурация mTLS-транспорта внутреннего gRPC. /// -/// -/// Только 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). Env-схема общая для процессов Deal (Ruling 6): серверный PFX — для своего -/// Kestrel-gRPC; клиентский PFX задаётся единообразно и используется процессами с исходящими -/// каналами (общий deal-client); CA — для проверки второй стороны. -/// public sealed class MtlsOptions { /// - /// Env-ключ флага: 1/true включает mTLS (как DEAL_DEMO=1). + /// Env-ключ флага: 1/true включает mTLS /// public const string EnabledEnvKey = "DEAL_MTLS_ENABLED"; /// - /// Env-ключ пути к PFX серверного сертификата процесса (Kestrel-gRPC). + /// Env-ключ пути к PFX серверного сертификата процесса /// public const string ServerCertPfxEnvKey = "DEAL_MTLS_SERVER_CERT_PFX"; @@ -35,7 +26,7 @@ public sealed class MtlsOptions public const string ServerCertPasswordEnvKey = "DEAL_MTLS_SERVER_CERT_PASSWORD"; /// - /// Env-ключ пути к PFX клиентского сертификата (общий deal-client исходящих каналов). + /// Env-ключ пути к PFX клиентского сертификата /// public const string ClientCertPfxEnvKey = "DEAL_MTLS_CLIENT_CERT_PFX"; @@ -45,7 +36,7 @@ public sealed class MtlsOptions public const string ClientCertPasswordEnvKey = "DEAL_MTLS_CLIENT_CERT_PASSWORD"; /// - /// Env-ключ пути к PEM dev-CA (проверка сертификата второй стороны). + /// Env-ключ пути к PEM dev-CA /// public const string CaPemEnvKey = "DEAL_MTLS_CA_PEM"; @@ -55,32 +46,32 @@ public sealed class MtlsOptions public bool Enabled { get; init; } /// - /// Путь к PFX серверного сертификата процесса (см. ). + /// Путь к PFX серверного сертификата процесса /// public string ServerCertPfx { get; init; } = string.Empty; /// - /// Пароль серверного PFX (см. ). + /// Пароль серверного PFX /// public string ServerCertPassword { get; init; } = string.Empty; /// - /// Путь к PFX клиентского сертификата (см. ). + /// Путь к PFX клиентского сертификата /// public string ClientCertPfx { get; init; } = string.Empty; /// - /// Пароль клиентского PFX (см. ). + /// Пароль клиентского PFX /// public string ClientCertPassword { get; init; } = string.Empty; /// - /// Путь к PEM-файлу dev-CA (см. ). + /// Путь к PEM-файлу dev-CA /// public string CaPem { get; init; } = string.Empty; /// - /// Читает опции из конфигурации хоста (env-ключи DEAL_MTLS_*, только env — Ruling 13). + /// Читает опции из конфигурации хоста. /// /// Конфигурация хоста (env-провайдер WebApplicationBuilder). /// Опции mTLS (флаг выключен — остальные поля пустые). @@ -99,7 +90,7 @@ public sealed class MtlsOptions } /// - /// Разбирает значение флага DEAL_MTLS_ENABLED: «1»/«true» (без учёта регистра) — включено. + /// Разбирает значение флага DEAL_MTLS_ENABLED /// /// Сырое значение env (null/пусто — выключено). public static bool IsEnabled(string? rawValue) diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealLogging.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealLogging.cs index bf4aa4f..62a652f 100644 --- a/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealLogging.cs +++ b/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealLogging.cs @@ -11,23 +11,8 @@ using Deal.Grpc.Hosting.Options; namespace Deal.Grpc.Hosting.Services; /// -/// Serilog-конфигурация процесса Deal-сервиса (Ruling 7/9, план Task 14; общий шаблон — C31). -/// -/// Консоль — JSON в prod-стиле (CompactJsonFormatter: одна JSON-строка на событие, поля @t/@mt/@l — -/// парсинг Loki/Promtail) либо текст в Development; плюс rolling-файл data/logs/deal-<процесс>.json -/// под ContentRoot (/app в контейнере). Уровень/каталог переопределяются env: DEAL_LOG_LEVEL, -/// DEAL_LOGS_DIR. +/// Serilog-конфигурация процесса Deal-сервиса. /// -/// -/// Конфигурация кодом, а не секцией appsettings: у сервиса appsettings.json нет (весь конфиг — env, -/// Ruling 13), поэтому единый код-набор с env-переопределениями не расходится между процессами. -/// Секреты не логируются (Ruling 13); OTel/метрики в этапе 7 не добавляются (Ruling 7) — стек: -/// Serilog-логи → docker-логи → Promtail → Loki → Grafana. -/// -/// Вызов — из Program.cs процесса (entry point): DealLogging.Configure(builder, "имя_процесса") -/// ДО builder.Build(). Интеграционные тесты поднимают хост через *ServiceHost.Create БЕЗ этого -/// вызова (логирование — забота production-точки входа), поэтому тесты не пишут файлы-логи. -/// public static class DealLogging { // Env-ключ минимального уровня Serilog (Debug/Information/Warning/Error; дефолт Information). @@ -56,8 +41,7 @@ public static class DealLogging private const LogEventLevel DefaultMinimumLevel = LogEventLevel.Information; /// - /// Подключает Serilog к хосту (builder.Host.UseSerilog). Регистрация отложенная: конфигурация - /// логгера применяется при builder.Build(), когда среда/конфигурация (env) уже собраны. + /// Подключает Serilog к хосту /// /// Билдер WebApplication процесса (до Build). /// Имя процесса для имени файла-лога (telegram/ai/ml/…). diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealMetricsHosting.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealMetricsHosting.cs index 053e04b..58c5fb7 100644 --- a/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealMetricsHosting.cs +++ b/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealMetricsHosting.cs @@ -10,33 +10,17 @@ using Deal.Grpc.Hosting.Options; namespace Deal.Grpc.Hosting.Services; /// -/// Общая настройка метрик Deal-сервисов (этап 12, пакет A): OpenTelemetry → экспортёр Prometheus, -/// эндпоинт /metrics в отдельном HTTP/1.1 Kestrel-эндпоинте (порт 9464 по умолчанию). +/// Общая настройка метрик Deal-сервисов /// -/// -/// -/// gRPC-сервисы слушают HTTP/2 (см. ), а -/// Prometheus scrape'ит обычным HTTP/1.1-запросом GET — поэтому метрики вынесены на отдельный -/// Kestrel-эндпоинт с : тот же процесс, тот же DI, но отдельный порт. -/// Порт не публикуется наружу — scrape идёт внутри compose-сети от сервиса prometheus. -/// -/// -/// Сервисы вызывают ровно две строки: — на этапе сборки хоста (до -/// builder.Build(), обычно из configureBuilder-хука Program.cs), и -/// — после сборки. Инструментация (входящие ASP.NET Core/gRPC, -/// исходящие HTTP/gRPC) даёт метрики RPS/латентности/ошибок без ручного кода; прикладные метрики -/// (токены, аудит, очереди) добавляет ядро своим meter'ом . -/// -/// public static class DealMetricsHosting { /// - /// Имя meter'а прикладных метрик Deal (общий префикс с ядром: deal.*). + /// Имя meter'а прикладных метрик Deal /// public const string MeterName = "Deal"; /// - /// Порт эндпоинта /metrics по умолчанию (конвенция OpenTelemetry Prometheus). + /// Порт эндпоинта /metrics по умолчанию /// public const int DefaultMetricsPort = 9464; @@ -44,9 +28,7 @@ public static class DealMetricsHosting private const string MetricsPortEnvKey = "METRICS_PORT"; /// - /// Порт эндпоинта метрик: env METRICS_PORT (заданное нечисловое значение игнорируется), - /// иначе . Локальный запуск нескольких процессов на хосте без - /// compose требует разных значений (в compose порты контейнеров изолированы). + /// Порт эндпоинта метрик /// /// Дефолтный порт (обычно ). /// Порт HTTP/1.1-эндпоинта метрик. @@ -56,8 +38,7 @@ public static class DealMetricsHosting : defaultPort; /// - /// Регистрирует OTel-метрики и Kestrel-эндпоинт метрик (HTTP/1.1, 0.0.0.0:). - /// Вызывать до builder.Build(). + /// Регистрирует OTel-метрики и Kestrel-эндпоинт метрик /// /// Билдер хоста сервиса. /// Порт HTTP/1.1-эндпоинта метрик. @@ -86,7 +67,7 @@ public static class DealMetricsHosting } /// - /// Мапит эндпоинт /metrics (формат Prometheus). Вызывать после builder.Build(). + /// Мапит эндпоинт /metrics /// /// Собранное приложение сервиса. public static void MapDealMetrics(WebApplication app) diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Services/GrpcHostEnvironment.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Services/GrpcHostEnvironment.cs index bc6346d..0b0df34 100644 --- a/src/grpc-hosting/Deal.Grpc.Hosting/Services/GrpcHostEnvironment.cs +++ b/src/grpc-hosting/Deal.Grpc.Hosting/Services/GrpcHostEnvironment.cs @@ -5,9 +5,7 @@ using Deal.Grpc.Hosting.Options; namespace Deal.Grpc.Hosting.Services; /// -/// Общие стартовые проверки/разбор env для Program.cs Deal-сервисов (C31): порт Kestrel -/// (GRPC_PORT → PORT → дефолт), окружение ASP.NET Core и fail-closed mTLS в Production -/// (замечание code-review: отсутствие/опечатка DEAL_MTLS_ENABLED не должны давать «тихого» plaintext). +/// Общие стартовые проверки/разбор env для Program.cs Deal-сервисов /// public static class GrpcHostEnvironment { @@ -28,8 +26,7 @@ public static class GrpcHostEnvironment "Production требует mTLS: задайте DEAL_MTLS_ENABLED=1 и env DEAL_MTLS_* (сертификаты deploy/certs, генерация — scripts/mtls-certs.sh)"; /// - /// Порт Kestrel процесса: env GRPC_PORT (контейнер), затем PORT (общий env хостинг-платформ), - /// иначе дефолт сервиса (Ruling 12, compose.dev.yml). + /// Порт Kestrel процесса /// /// Дефолтный порт сервиса. public static int ResolveGrpcPort(int defaultPort) @@ -38,15 +35,14 @@ public static class GrpcHostEnvironment ?? defaultPort; /// - /// Парсит порт из env-строки; пустое/нечисловое значение — null (перебор следующего источника). + /// Парсит порт из env-строки; пустое/нечисловое значение — null /// /// Сырое значение env. public static int? ParsePort(string? rawValue) => int.TryParse(rawValue, out int parsedPort) ? parsedPort : null; /// - /// True — окружение Production (ASPNETCORE_ENVIRONMENT; незаданный env Production-ом не считается — - /// dev-локальный запуск без переменной остаётся на plaintext, как раньше). + /// True — окружение Production /// public static bool IsProductionEnvironment() => string.Equals( @@ -55,9 +51,7 @@ public static class GrpcHostEnvironment StringComparison.OrdinalIgnoreCase); /// - /// Fail-closed-гард транспорта: при ASPNETCORE_ENVIRONMENT=Production и выключенном mTLS — - /// отказ на старте с понятным текстом (Development и прочие не-prod окружения: plaintext - /// + service-token допустимы, Ruling 2). Проверять после создания хоста (env уже собраны). + /// Fail-closed-гард транспорта /// /// Опции mTLS процесса (из env DEAL_MTLS_*). /// Production без mTLS. diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Services/GrpcServer.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Services/GrpcServer.cs index 68d4d2a..7c40694 100644 --- a/src/grpc-hosting/Deal.Grpc.Hosting/Services/GrpcServer.cs +++ b/src/grpc-hosting/Deal.Grpc.Hosting/Services/GrpcServer.cs @@ -12,9 +12,7 @@ using Deal.Grpc.Hosting.Options; namespace Deal.Grpc.Hosting.Services; /// -/// Общие серверные блоки gRPC-хостов Deal-сервисов (C31): mTLS-набор, Kestrel HTTP/2-эндпоинт, -/// AddGrpc с интерцепторами и gRPC-health. Host-фабрики сервисов (TelegramServiceHost/AiServiceHost/ -/// MlServiceHost) собирают эти блоки здесь один раз, затем регистрируют свою доменную логику. +/// Общие серверные блоки gRPC-хостов Deal-сервисов /// public static class GrpcServer { @@ -22,16 +20,12 @@ public static class GrpcServer private const string ReadyHealthCheckName = "ready"; /// - /// Потолок входящего gRPC-сообщения (серверный лимит на границе, замечание code-review; 4 МБ — - /// запросы контрактов сервисов помещаются с запасом). + /// Потолок входящего gRPC-сообщения /// public const int DefaultMaxReceiveMessageSize = 4 * 1024 * 1024; /// - /// Загружает сертификаты mTLS из env (DEAL_MTLS_*, Ruling 6/Task 13) и регистрирует набор - /// в DI: null при выключенном флаге (plaintext + service-token, dev); при включённом — - /// fail-fast на битые пути/пароли. Возвращённый экземпляр используют Kestrel и (в процессах - /// с клиентской ролью) исходящие каналы. + /// Загружает сертификаты mTLS из env и регистрирует набор в DI /// /// Билдер хоста (конфигурация env + DI). /// Набор сертификатов либо null (mTLS выключен). @@ -50,9 +44,7 @@ public static class GrpcServer } /// - /// Настраивает единственный Kestrel-эндпоинт HTTP/2 на 0.0.0.0:grpcPort: plaintext (dev, Ruling 2) - /// либо mTLS при переданном наборе сертификатов (серверный сертификат + требование клиентского - /// с проверкой через нашу CA, Ruling 6). + /// Настраивает единственный Kestrel-эндпоинт HTTP/2 на 0.0.0.0:grpcPort /// /// Билдер хоста (WebHost для ConfigureKestrel). /// TCP-порт Kestrel. @@ -82,10 +74,7 @@ public static class GrpcServer } /// - /// Регистрирует AddGrpc с общей серверной обвязкой: access-лог ПЕРВЫМ (логирует и отклонённые - /// вызовы), затем проверка service-token (Ruling 1) на каждом Deal-RPC; grpc.health.v1.Health - /// освобождён от токена и access-лога (см. ServiceTokenInterceptor/RpcCallLoggingInterceptor). - /// Плюс потолок входящего сообщения . + /// Регистрирует AddGrpc с общей серверной обвязкой /// /// DI сервисов хоста. public static IServiceCollection AddDealGrpcServer(this IServiceCollection services) @@ -101,8 +90,7 @@ public static class GrpcServer } /// - /// Регистрирует стандартный gRPC-health (Grpc.HealthCheck): healthcheck контейнера (Ruling 12) - /// с явной проверкой ready — без неё health-сервис отвечает UNKNOWN, а не SERVING. + /// Регистрирует стандартный gRPC-health /// /// DI сервисов хоста. /// Текст готовности проверки (имя хоста в логах healthcheck). diff --git a/src/ml-service/Deal.Ml.Tests/Grpc/MlRpcTests.cs b/src/ml-service/Deal.Ml.Tests/Grpc/MlRpcTests.cs index 5b99a89..21fe9c8 100644 --- a/src/ml-service/Deal.Ml.Tests/Grpc/MlRpcTests.cs +++ b/src/ml-service/Deal.Ml.Tests/Grpc/MlRpcTests.cs @@ -5,14 +5,12 @@ 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 неготовой модели — «не уверен», не ошибка). Без сети. +/// In-proc gRPC-тесты MlService /// public sealed class MlRpcTests { /// - /// Свежий тенант: Status — пустой ответ, Predict — фиксированный «не уверен» 1:1. + /// Свежий тенант: Status — пустой ответ, Predict — фиксированный «не уверен». /// [Fact] public async Task FreshTenant_StatusAndPredictReturnNotReadyShape() @@ -46,7 +44,7 @@ public sealed class MlRpcTests } /// - /// TrainBatch батчем из 3 → learned=3; модель остаётся неготовой (ниже MIN_TOTAL). + /// TrainBatch батчем из 3 → learned=3; модель остаётся неготовой /// [Fact] public async Task TrainBatch_ThreeItems_LearnsThree() @@ -111,7 +109,7 @@ public sealed class MlRpcTests } /// - /// Reset обнуляет модель тенанта: Status снова пуст, Predict «не уверен». + /// Reset обнуляет модель тенанта /// [Fact] public async Task Reset_AfterTraining_EmptiesModel() @@ -144,7 +142,7 @@ public sealed class MlRpcTests } /// - /// Изоляция тенантов: обучение одного не влияет на модель другого (пул per-tenant). + /// Изоляция тенантов /// [Fact] public async Task Tenants_AreIsolated() @@ -174,7 +172,7 @@ public sealed class MlRpcTests } /// - /// Отсутствующий tenant-id в metadata → UNAUTHENTICATED (Ruling 1; шаблон T5). + /// Отсутствующий tenant-id в metadata → UNAUTHENTICATED. /// [Fact] public async Task Status_WithoutTenantId_IsUnauthenticated() @@ -192,7 +190,7 @@ public sealed class MlRpcTests } /// - /// Tenant-id с недопустимыми символами пути → INVALID_ARGUMENT (защита каталога). + /// Tenant-id с недопустимыми символами пути → INVALID_ARGUMENT /// [Fact] public async Task Status_WithPathTraversalTenantId_IsInvalidArgument() @@ -211,7 +209,7 @@ public sealed class MlRpcTests } /// - /// Пустые text/label в батче пропускаются: learned = применённые, ответ не падает. + /// Пустые text/label в батче пропускаются /// [Fact] public async Task TrainBatch_EmptyItems_SkippedQuietly() @@ -231,7 +229,7 @@ public sealed class MlRpcTests } /// - /// Серверный лимит батча (ml.proto: ≤100 примеров): 101 → INVALID_ARGUMENT до обучения. + /// Серверный лимит батча /// [Fact] public async Task TrainBatch_OverBatchLimit_IsInvalidArgument() @@ -253,7 +251,7 @@ public sealed class MlRpcTests } /// - /// Серверный лимит длины текста примера (source_msg ≤4000): превышение → INVALID_ARGUMENT. + /// Серверный лимит длины текста примера /// [Fact] public async Task TrainBatch_TooLongExampleText_IsInvalidArgument() diff --git a/src/ml-service/Deal.Ml.Tests/Grpc/MlServiceHostTests.cs b/src/ml-service/Deal.Ml.Tests/Grpc/MlServiceHostTests.cs index fa4e050..00f3fad 100644 --- a/src/ml-service/Deal.Ml.Tests/Grpc/MlServiceHostTests.cs +++ b/src/ml-service/Deal.Ml.Tests/Grpc/MlServiceHostTests.cs @@ -9,18 +9,7 @@ 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 исполняет методы класса последовательно). +/// Интеграционные тесты хоста ml-service. /// public sealed class MlServiceHostTests { @@ -46,8 +35,7 @@ public sealed class MlServiceHostTests private const int RpcDeadlineSeconds = 10; /// - /// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура - /// и health-сервис работают (Ruling 12; health освобождён от service-token). + /// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура и health-сервис работают. /// [Fact] public async Task HealthCheck_ReturnsServing() @@ -66,7 +54,7 @@ public sealed class MlServiceHostTests } /// - /// Запрос без metadata «service-token» → UNAUTHENTICATED (Ruling 1). + /// Запрос без metadata «service-token» → UNAUTHENTICATED. /// [Fact] public async Task Status_WithoutToken_IsUnauthenticated() @@ -78,7 +66,7 @@ public sealed class MlServiceHostTests } /// - /// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1). + /// Запрос с неверным токеном → UNAUTHENTICATED. /// [Fact] public async Task Status_WithWrongToken_IsUnauthenticated() @@ -90,8 +78,7 @@ public sealed class MlServiceHostTests } /// - /// Верный токен проходит интерцептор к методу Status (задачи 5–6): кодогенерация и маппинг - /// сервиса работают, свежая модель тенанта отвечает готовым ответом (ready=false), а не UNIMPLEMENTED. + /// Верный токен проходит интерцептор к методу Status /// [Fact] public async Task Status_WithValidToken_ReturnsEmptyStatus() @@ -116,9 +103,7 @@ public sealed class MlServiceHostTests } /// - /// Fail-closed (замечание ревью Task 2): DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, - /// в т.ч. запрос с «пустым» значением metadata (без гарда сравнение «» == «» пропустило бы его); - /// health при этом продолжает отвечать SERVING (инфраструктурный liveness не ломается). + /// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, в т.ч. /// [Fact] public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing() diff --git a/src/ml-service/Deal.Ml.Tests/Grpc/MlTestHost.cs b/src/ml-service/Deal.Ml.Tests/Grpc/MlTestHost.cs index 88a78cd..4c9ad86 100644 --- a/src/ml-service/Deal.Ml.Tests/Grpc/MlTestHost.cs +++ b/src/ml-service/Deal.Ml.Tests/Grpc/MlTestHost.cs @@ -11,29 +11,28 @@ 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). + /// Env-ключ ожидаемого service-token /// public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; /// - /// Env-ключ каталога моделей (зеркало MlOptions.DataDirEnvVarName). + /// Env-ключ каталога моделей /// public const string DataDirEnvKey = MlOptions.DataDirEnvVarName; /// - /// Ключ gRPC-metadata с service-token (зеркало ServiceTokenInterceptor). + /// Ключ gRPC-metadata с service-token /// public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey; /// - /// Ключ gRPC-metadata с tenant-id (зеркало MlServiceImpl.TenantIdMetadataKey). + /// Ключ gRPC-metadata с tenant-id /// public const string TenantIdMetadataKey = MlServiceImpl.TenantIdMetadataKey; @@ -48,7 +47,7 @@ internal static class MlTestHost public const string DefaultTenantId = "tenant-test"; /// - /// Deadline RPC-вызовов теста (сек). + /// Deadline RPC-вызовов теста /// public const int RpcDeadlineSeconds = 15; @@ -107,7 +106,7 @@ internal static class MlTestHost } /// - /// Строит metadata вызова: service-token (+ tenant-id, если задан). + /// Строит metadata вызова /// /// Значение заголовка service-token. /// Id тенанта (null — без заголовка tenant-id). @@ -128,7 +127,7 @@ internal static class MlTestHost } /// - /// CallOptions RPC: metadata + deadline (рекомендации README src/contracts L62–73). + /// CallOptions RPC /// /// Metadata вызова. public static CallOptions CallOptions(Metadata metadata) diff --git a/src/ml-service/Deal.Ml.Tests/Ml/LearningData.cs b/src/ml-service/Deal.Ml.Tests/Ml/LearningData.cs index 37c622d..2a71517 100644 --- a/src/ml-service/Deal.Ml.Tests/Ml/LearningData.cs +++ b/src/ml-service/Deal.Ml.Tests/Ml/LearningData.cs @@ -2,14 +2,12 @@ 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_… как в ядре). + /// Id колонки канбана «разработка/найм» /// public const string ColumnDev = "b_col_dev"; @@ -29,25 +27,25 @@ internal static class LearningData 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 = "резюме ищу подработку, разошлю отклик по вакансиям, рассылка кадровым агентствам"; /// - /// Пример обучения с заданным весом сигнала (пользователь/ИИ/правила). + /// Пример обучения с заданным весом сигнала /// /// Метка класса. /// Текст примера. @@ -58,16 +56,14 @@ internal static class LearningData double delta) => new(text, label, delta); /// - /// Пример обучения с дельтой 1.0 (действие пользователя). + /// Пример обучения с дельтой 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() { diff --git a/src/ml-service/Deal.Ml.Tests/Ml/MlTokenizerTests.cs b/src/ml-service/Deal.Ml.Tests/Ml/MlTokenizerTests.cs index fc0edef..8bb1e40 100644 --- a/src/ml-service/Deal.Ml.Tests/Ml/MlTokenizerTests.cs +++ b/src/ml-service/Deal.Ml.Tests/Ml/MlTokenizerTests.cs @@ -3,9 +3,7 @@ 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 { @@ -34,7 +32,7 @@ public sealed class MlTokenizerTests } /// - /// Слова короче 3 символов отбрасываются (пустой результат для «а б вг»). + /// Слова короче 3 символов отбрасываются /// [Fact] public void Tokenize_DropsShortWords() @@ -43,7 +41,7 @@ public sealed class MlTokenizerTests } /// - /// Кириллица/ё обрабатываются как в python: lower + префикс первых 4 символов. + /// Кириллица/ё обрабатываются как в /// [Fact] public void Tokenize_CyrillicWithYo() @@ -54,7 +52,7 @@ public sealed class MlTokenizerTests } /// - /// Разрешённый алфавит включает @ + . # — email остаётся одним термином (1:1 python). + /// Разрешённый алфавит включает @ +. /// [Fact] public void Tokenize_KeepsEmailAsSingleTerm() diff --git a/src/ml-service/Deal.Ml.Tests/Ml/ModelPersistenceTests.cs b/src/ml-service/Deal.Ml.Tests/Ml/ModelPersistenceTests.cs index 974a137..1f19a27 100644 --- a/src/ml-service/Deal.Ml.Tests/Ml/ModelPersistenceTests.cs +++ b/src/ml-service/Deal.Ml.Tests/Ml/ModelPersistenceTests.cs @@ -4,15 +4,12 @@ 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() @@ -54,9 +51,7 @@ public sealed class ModelPersistenceTests } /// - /// Окно самооценки: журнал хранит не больше EVAL_KEEP (200) строк (проверка в файле), статус - /// считает последние EVAL_WINDOW (50) решений. Сценарий — 250 дополнительных «реальных» - /// действий поверх канонического батча. + /// Окно самооценки /// [Fact] public void EvalLog_PrunesToKeepAndWindowIsFifty() @@ -92,7 +87,7 @@ public sealed class ModelPersistenceTests } /// - /// Прунинг переживает перезапуск: reload держит последние EVAL_KEEP и окно 50. + /// Прунинг переживает перезапуск /// [Fact] public void EvalLog_PrunePersistedAcrossPoolRestart() diff --git a/src/ml-service/Deal.Ml.Tests/Ml/ModelPoolTests.cs b/src/ml-service/Deal.Ml.Tests/Ml/ModelPoolTests.cs index decb1cf..ad0b26f 100644 --- a/src/ml-service/Deal.Ml.Tests/Ml/ModelPoolTests.cs +++ b/src/ml-service/Deal.Ml.Tests/Ml/ModelPoolTests.cs @@ -3,8 +3,7 @@ using Deal.Ml.Model; namespace Deal.Ml.Tests.Ml; /// -/// Тесты пула моделей (план Task 5, Ruling 4): ленивое создание по тенанту (один инстанс на -/// тенанта), файл data/ml/<tenantId>.sqlite, защита tenant-id от выхода из каталога. +/// Тесты пула моделей /// public sealed class ModelPoolTests { @@ -27,7 +26,7 @@ public sealed class ModelPoolTests } /// - /// Имя файла модели — <tenantId>.sqlite в каталоге данных (Ruling 4). + /// Имя файла модели — <tenantId>.sqlite в каталоге данных. /// [Fact] public void GetOrCreate_DbPathIsTenantFileUnderDataDir() @@ -44,7 +43,7 @@ public sealed class ModelPoolTests } /// - /// Некорректный tenant-id (путь/«..») не может вывести файл за каталог данных. + /// Некорректный tenant-id /// [Fact] public void GetOrCreate_InvalidTenantId_Throws() diff --git a/src/ml-service/Deal.Ml.Tests/Ml/TenantModelLearningTests.cs b/src/ml-service/Deal.Ml.Tests/Ml/TenantModelLearningTests.cs index d37f3ff..1834403 100644 --- a/src/ml-service/Deal.Ml.Tests/Ml/TenantModelLearningTests.cs +++ b/src/ml-service/Deal.Ml.Tests/Ml/TenantModelLearningTests.cs @@ -3,14 +3,12 @@ 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). + /// Пустая модель: predict — фиксированный «не уверен» /// [Fact] public void EmptyModel_PredictReturnsNotReadyShape() @@ -41,8 +39,7 @@ public sealed class TenantModelLearningTests } /// - /// Пока суммарно примеров < MIN_TOTAL (20), модель не готова и ничего не решает - /// (ready=false, «не уверен») — не может ошибочно удалить заявку как спам. + /// Пока суммарно примеров < MIN_TOTAL /// [Fact] public void BelowMinTotal_ModelNotReadyAndDoesNotDecide() @@ -65,8 +62,7 @@ public sealed class TenantModelLearningTests } /// - /// Порог ready (суммарно ≥ 20, spam ≥ 4, не-спам ≥ 6): после добавления t:hire модель - /// «включается» и начинает уверенно решать (приёмка Task 5: «нужен middle python…» → колонка). + /// Порог ready (суммарно ≥ 20, spam ≥ 4, не-спам ≥ 6) /// [Fact] public void ReachingReadyThreshold_StartsDeciding() @@ -90,9 +86,7 @@ public sealed class TenantModelLearningTests } /// - /// Полный сценарий приёмки 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() @@ -102,7 +96,6 @@ public sealed class TenantModelLearningTests 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()); @@ -151,7 +144,6 @@ public sealed class TenantModelLearningTests 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); @@ -166,7 +158,7 @@ public sealed class TenantModelLearningTests } /// - /// MIN_HITS=2: одно совпадение у победителя ещё не «взятие» (take=false, hits=1), двух — уже да. + /// MIN_HITS=2: одно совпадение у победителя ещё не «взятие» /// [Fact] public void MinHitsThreshold_NeedsTwoMatchedTerms() @@ -192,7 +184,7 @@ public sealed class TenantModelLearningTests } /// - /// Незнакомый текст готовой модели: «не уверен», но ready=true и margin пуст. + /// Незнакомый текст готовой модели /// [Fact] public void UnknownText_NotTakenButReady() @@ -214,7 +206,7 @@ public sealed class TenantModelLearningTests } /// - /// Адаптивный margin: 0.9 на старте, 0.7 после ≥ 60 суммарных примеров (L42–55). + /// Адаптивный margin /// [Fact] public void AdaptiveMargin_DropsAfter60Examples() @@ -234,7 +226,7 @@ public sealed class TenantModelLearningTests } /// - /// delta < 0 «разучивает»: вес класса и его термины уменьшаются до удаления. + /// delta < 0 «разучивает» /// [Fact] public void NegativeDelta_UnlearnsUntilClassRemoved() @@ -260,7 +252,7 @@ public sealed class TenantModelLearningTests } /// - /// Дробные веса ИИ-сигналов (delta 0.4): класс копится, но не кратен 1 (гипотезы ИИ). + /// Дробные веса ИИ-сигналов /// [Fact] public void FractionalDelta_TrainsAiHypotheses() @@ -279,7 +271,7 @@ public sealed class TenantModelLearningTests } /// - /// Пустые/пробельные text или label пропускаются тихо (1:1 _upsert_one L112–114). + /// Пустые/пробельные text или label пропускаются тихо. /// [Fact] public void LearnBatch_SkipsEmptyItems() @@ -305,7 +297,7 @@ public sealed class TenantModelLearningTests } /// - /// Reset очищает модель и пересоздаёт файл при следующем обучении (план Task 6). + /// Reset очищает модель и пересоздаёт файл при следующем обучении. /// [Fact] public void Reset_ClearsWeightsAndFileRecreatedOnNextLearn() diff --git a/src/ml-service/Deal.Ml/Extensions/LabelExtensions.cs b/src/ml-service/Deal.Ml/Extensions/LabelExtensions.cs index ef550e8..a29bea6 100644 --- a/src/ml-service/Deal.Ml/Extensions/LabelExtensions.cs +++ b/src/ml-service/Deal.Ml/Extensions/LabelExtensions.cs @@ -8,10 +8,10 @@ namespace Deal.Ml.Extensions; internal static class LabelExtensions { /// - /// Метка внутреннего типа заявки (префикс t:). + /// Метка внутреннего типа заявки /// /// Метка класса. - /// True — метка является внутренним типом заявки (префикс t:). + /// True — метка является внутренним типом заявки (префикс t). public static bool IsTypeLabel(this string label) { return label.StartsWith(ModelConstants.TypeLabelPrefix, StringComparison.Ordinal); diff --git a/src/ml-service/Deal.Ml/MlServiceHost.cs b/src/ml-service/Deal.Ml/MlServiceHost.cs index 01dfa71..5254c5f 100644 --- a/src/ml-service/Deal.Ml/MlServiceHost.cs +++ b/src/ml-service/Deal.Ml/MlServiceHost.cs @@ -7,38 +7,17 @@ using Deal.Ml.Model; namespace Deal.Ml; /// -/// Собирает WebApplication gRPC-хоста ml-service (план Task 3/5/6; L240–248 + движок и RPC). -/// -/// Продакшн-точка входа вызывает из Program.cs (порт из env GRPC_PORT/PORT); -/// интеграционные тесты (Deal.Ml.Tests) — из своего процесса на эфемерном порту, поэтому -/// конфигурация хоста живёт здесь один раз и не дублируется в тестах. -/// Транспорт/AddGrpc/health — общая серверная обвязка (Deal.Grpc.Hosting, -/// C31): mTLS (env DEAL_MTLS_*, Ruling 6/Task 13), Kestrel HTTP/2, интерцепторы service-token и -/// access-лога, gRPC-health; здесь — только регистрации логики ml-service. -/// Регистрации логики (план Task 5/6, Ruling 4): каталог файлов моделей (MlOptions — env -/// DEAL_ML_DATA_DIR, volume /data/ml в compose) и пул инкрементальных наивно-байесовских моделей -/// per-tenant (ModelPool: lazy-загрузка SQLite-файла data/ml/<tenantId>.sqlite, lock на модель). -/// gRPC-сервис поверх пула — (Predict/Status/Reset/TrainBatch). +/// Собирает WebApplication gRPC-хоста ml-service. /// public static class MlServiceHost { /// - /// Создаёт (не запускает) хост: общая обвязка GrpcServer (Kestrel HTTP/2 на 0.0.0.0:grpcPort, - /// dev — plaintext + service-token, Ruling 2; при DEAL_MTLS_ENABLED=1 — HTTPS с серверным - /// сертификатом и требованием клиентского, Ruling 6/Task 13), затем пул моделей и маппинг - /// . + /// Создаёт (не запускает) хост /// /// TCP-порт Kestrel. /// Аргументы командной строки (Program.cs); в тестах не нужны. - /// - /// Опциональный хук DI для тестов (подмена зависимостей фейками; для ml-логики обычно не нужен — - /// харнессы тестов направляют каталог моделей env DEAL_ML_DATA_DIR во временную папку). - /// - /// - /// Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog - /// (DealLogging.Configure, Ruling 7/Task 14). Тесты хост поднимают БЕЗ этого хука — логирование - /// файлов/консоли тестам не нужно. - /// + /// Опциональный хук DI для тестов (подмена зависимостей фейками; для ml-логики обычно не нужен — харнессы тестов направляют каталог моделей env DEAL_ML_DATA_DIR во временную папку). + /// Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog. Тесты хост поднимают БЕЗ этого хука — логирование файлов/консоли тестам не нужно. /// Собранный хост; запуск — StartAsync/RunAsync у вызывающего. public static WebApplication Create( int grpcPort, @@ -50,14 +29,11 @@ public static class MlServiceHost // Общая серверная обвязка (Deal.Grpc.Hosting, C31): mTLS env DEAL_MTLS_* — загрузка // сертификатов сразу с fail-fast (compose-prod монтирует deploy/certs, scripts/mtls-certs.sh); - // Kestrel HTTP/2 (dev — plaintext + обязательный service-token, Ruling 2); AddGrpc - // (access-лог первым, затем service-token, потолок сообщения) и gRPC-health (Ruling 12). MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder); GrpcServer.ConfigureKestrelHttp2Endpoint(builder, grpcPort, mtlsCertificates); builder.Services.AddDealGrpcServer(); builder.Services.AddReadyHealthCheck("хост ml-service готов"); - // Движок инкрементальной модели (план Task 5/6, Ruling 4): каталог SQLite-файлов тенантов // (env DEAL_ML_DATA_DIR; по умолчанию data/ml под ContentRoot) + пул моделей per-tenant. MlOptions mlOptions = MlOptions.FromConfiguration(builder.Configuration, builder.Environment); builder.Services.AddSingleton(mlOptions); diff --git a/src/ml-service/Deal.Ml/MlServiceImpl.cs b/src/ml-service/Deal.Ml/MlServiceImpl.cs index b1b10ae..d792fe3 100644 --- a/src/ml-service/Deal.Ml/MlServiceImpl.cs +++ b/src/ml-service/Deal.Ml/MlServiceImpl.cs @@ -5,30 +5,22 @@ using Grpc.Core; namespace Deal.Ml; /// -/// Реализация серверной стороны Deal.Grpc.Ml.MlService — команды ядра в ml-service -/// (ml.proto, контракты Task 1; Ruling 1/4/6). План Task 6: Predict/Status/Reset/TrainBatch поверх -/// (движок Task 5). tenantId — только из gRPC-metadata, полю не доверяем -/// (Ruling 1); модели нет — она создаётся лениво: Predict без опыта отвечает «не уверен», а не -/// ошибкой (README src/contracts L21–23). Формы ответов 1:1 с model.py predict/status/reset и -/// mlservice/server.py (эталон). +/// Реализация серверной стороны Deal.Grpc.Ml.MlService — команды ядра в ml-service. /// public sealed class MlServiceImpl : MlService.MlServiceBase { /// - /// Ключ gRPC-metadata с id тенанта (единственный источник принадлежности — Ruling 1). + /// Ключ gRPC-metadata с id тенанта. /// public const string TenantIdMetadataKey = "tenant-id"; - // Деталь отказа: tenant-id отсутствует в metadata (UNAUTHENTICATED, шаблон T5). private const string TenantIdMissingDetail = "tenant-id отсутствует в metadata"; // Деталь отказа: tenant-id некорректен как имя файла модели (INVALID_ARGUMENT). private const string InvalidTenantIdDetail = "Некорректный tenant-id"; - // Деталь отказа: хранилище модели недоступно (UNAVAILABLE — безопасный повтор, Ruling 1). private const string StorageUnavailableDetail = "Хранилище модели недоступно — повторите запрос позже"; - // Потолок примеров батча обучения (контракт ml.proto: ядро шлёт ≤100 за цикл — Ruling 6). private const int MaxTrainBatchItems = 100; // Потолок длины текста примера/предсказания (source_msg ядро обрезает до 4000). @@ -49,7 +41,6 @@ public sealed class MlServiceImpl : MlService.MlServiceBase // Деталь отказа: текст предсказания длиннее лимита (INVALID_ARGUMENT). private const string PredictTextTooLongDetail = "Слишком длинный текст сообщения"; - // Фиксированный текст мягкой ошибки сброса (детали сбоя/пути — только в лог, Ruling 13). private const string ResetFailedDetail = "Не удалось сбросить модель — повторите попытку позже"; private readonly ModelPool _pool; @@ -59,7 +50,7 @@ public sealed class MlServiceImpl : MlService.MlServiceBase /// Создаёт сервис команд ядра поверх пула моделей. /// /// Пул моделей тенантов (ленивое создание/загрузка). - /// Логгер аудита (Ruling 13). + /// Логгер аудита. public MlServiceImpl(ModelPool pool, ILogger logger) { _pool = pool; @@ -67,8 +58,7 @@ public sealed class MlServiceImpl : MlService.MlServiceBase } /// - /// Predict — решение по тексту сообщения (model.py predict L184–293): take/label/scores/hits/ - /// ready/margin/terms/type. Неготовая или пустая модель отвечает «не уверен» — не ошибка. + /// Predict — решение по тексту сообщения /// public override Task Predict(PredictRequest request, ServerCallContext context) { @@ -91,8 +81,7 @@ public sealed class MlServiceImpl : MlService.MlServiceBase } /// - /// Status — статус модели тенанта (model.py status L325–345): ready/classes/learned/eval; - /// модель создаётся лениво по первому обращению (отсутствие опыта — не ошибка). + /// Status — статус модели тенанта /// public override Task Status(StatusRequest request, ServerCallContext context) { @@ -114,9 +103,7 @@ public sealed class MlServiceImpl : MlService.MlServiceBase } /// - /// Reset — полный сброс модели тенанта (model.py reset L348–354): очистка классов, терминов и - /// журнала самооценки + пересоздание файла. Ok=true при успехе; сбой — мягкая ошибка - /// Ok=false + error (ядро чистит свою ml_outbox только при успехе, Ruling 6). + /// Reset — полный сброс модели тенанта /// public override Task Reset(ResetRequest request, ServerCallContext context) { @@ -136,8 +123,7 @@ public sealed class MlServiceImpl : MlService.MlServiceBase } /// - /// TrainBatch — пакетное обучение (model.py learn_batch L147–173): одна транзакция на батч, - /// ответ — число применённых примеров (пустые text/label пропускаются; ядро шлёт ≤100, Ruling 6). + /// TrainBatch — пакетное обучение /// public override Task TrainBatch(TrainBatchRequest request, ServerCallContext context) { @@ -196,7 +182,6 @@ public sealed class MlServiceImpl : MlService.MlServiceBase } } - // Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, Ruling 1). // context: Контекст вызова. private static string RequireTenantId(ServerCallContext context) { @@ -223,7 +208,6 @@ public sealed class MlServiceImpl : MlService.MlServiceBase } } - // Собирает PredictReply из результата модели (1:1 ml.proto / MlPredictResultDto). // result: Результат предсказания. private static PredictReply ToPredictReply(MlPredictResult result) { @@ -265,7 +249,6 @@ public sealed class MlServiceImpl : MlService.MlServiceBase return reply; } - // Собирает StatusReply из результата модели (1:1 ml.proto / MlServiceStatusDto). // result: Результат статуса. private static StatusReply ToStatusReply(MlStatusResult result) { diff --git a/src/ml-service/Deal.Ml/Model/EvalEntry.cs b/src/ml-service/Deal.Ml/Model/EvalEntry.cs index 7333044..9e984ab 100644 --- a/src/ml-service/Deal.Ml/Model/EvalEntry.cs +++ b/src/ml-service/Deal.Ml/Model/EvalEntry.cs @@ -1,9 +1,7 @@ namespace Deal.Ml.Model; /// -/// Строка журнала самооценки модели (млservice model.py eval_log, L296–322): каждое реальное -/// действие пользователя (delta=1, не t:*) сверяется с текущим предсказанием. Хранится в -/// SQLite (eval_log) и в памяти тенантной модели (окно EVAL_KEEP/EvalWindowSize). +/// Строка журнала самооценки модели /// /// Момент решения (epoch-ms UTC). /// Метка действия пользователя («правильный ответ»). diff --git a/src/ml-service/Deal.Ml/Model/LearnItem.cs b/src/ml-service/Deal.Ml/Model/LearnItem.cs index 20dfbd1..4967a14 100644 --- a/src/ml-service/Deal.Ml/Model/LearnItem.cs +++ b/src/ml-service/Deal.Ml/Model/LearnItem.cs @@ -1,9 +1,7 @@ namespace Deal.Ml.Model; /// -/// Один обучающий пример тенанта (1:1 TrainExample ml.proto и строка ml_outbox ядра: -/// text/label/delta). delta — вес сигнала: 1.0 — действие пользователя; −1.0 — снять метку; -/// 0.4/0.6 — гипотезы ИИ/правил (Ruling 4; ml_client.py L26–28). +/// Один обучающий пример тенанта. /// /// Текст примера (source_msg карточки или title). /// Метка: id колонки (b_…), spam либо тип t:hire/t:order. diff --git a/src/ml-service/Deal.Ml/Model/MlEvalInfo.cs b/src/ml-service/Deal.Ml/Model/MlEvalInfo.cs index 48546b0..990ba9c 100644 --- a/src/ml-service/Deal.Ml/Model/MlEvalInfo.cs +++ b/src/ml-service/Deal.Ml/Model/MlEvalInfo.cs @@ -1,7 +1,7 @@ namespace Deal.Ml.Model; /// -/// Окно самооценки модели (model.py status L329–339; 1:1 ModelEval ml.proto и MlEvalDto ядра). +/// Окно самооценки модели. /// /// Решений в окне (последние EVAL_WINDOW подтверждённых решений). /// Из них совпавших с действием пользователя. diff --git a/src/ml-service/Deal.Ml/Model/MlOptions.cs b/src/ml-service/Deal.Ml/Model/MlOptions.cs index 1661486..bf757d0 100644 --- a/src/ml-service/Deal.Ml/Model/MlOptions.cs +++ b/src/ml-service/Deal.Ml/Model/MlOptions.cs @@ -1,23 +1,17 @@ namespace Deal.Ml.Model; /// -/// Конфигурация хранения моделей ml-service (план Task 5, Ruling 4/12). -/// -/// Каждый тенант держит собственный SQLite-файл весов -/// <dataDir>/<tenantId>.sqlite. Каталог задаётся env -/// DEAL_ML_DATA_DIR (в compose — volume /data/ml, Ruling 12); по умолчанию — -/// data/ml относительно ContentRoot хоста. Два источника — только env и корень -/// хоста (как TgOptions telegram-service, шаблон T5). +/// Конфигурация хранения моделей ml-service. /// public sealed class MlOptions { /// - /// Env-ключ каталога файлов моделей (volume /data/ml в compose.dev.yml). + /// Env-ключ каталога файлов моделей /// public const string DataDirEnvVarName = "DEAL_ML_DATA_DIR"; /// - /// Относительный каталог моделей по умолчанию (под ContentRoot хоста). + /// Относительный каталог моделей по умолчанию /// public const string DefaultDataDirRelative = "data/ml"; @@ -27,13 +21,12 @@ public sealed class MlOptions } /// - /// Абсолютный путь к каталогу файлов моделей data/ml/<tenantId>.sqlite. + /// Абсолютный путь к каталогу файлов моделей data/ml/<tenantId>.sqlite. /// public string DataDirectory { get; } /// - /// Создаёт опции с уже известным каталогом (unit-тесты пула/хранилища; прод-путь — - /// ). + /// Создаёт опции с уже известным каталогом /// /// Каталог файлов моделей. public static MlOptions Create(string dataDirectory) @@ -47,7 +40,7 @@ public sealed class MlOptions } /// - /// Читает конфигурацию из env и корня хоста (DEAL_ML_DATA_DIR или data/ml). + /// Читает конфигурацию из env и корня хоста /// /// Конфигурация хоста (env-провайдер WebApplicationBuilder). /// Окружение хоста (ContentRootPath для каталога по умолчанию). diff --git a/src/ml-service/Deal.Ml/Model/MlPredictResult.cs b/src/ml-service/Deal.Ml/Model/MlPredictResult.cs index 7bda992..cd0aafe 100644 --- a/src/ml-service/Deal.Ml/Model/MlPredictResult.cs +++ b/src/ml-service/Deal.Ml/Model/MlPredictResult.cs @@ -1,10 +1,7 @@ namespace Deal.Ml.Model; /// -/// Результат предсказания модели (model.py predict L184–293; 1:1 PredictReply ml.proto и -/// MlPredictResultDto ядра). Неготовая/пустая модель отвечает фиксированным «не уверен»: -/// Take=false, Label=null, Scores пусто, Hits=0, Ready по состоянию, Margin=null, Terms пусто, -/// Type=null (README src/contracts L21–23). +/// Результат предсказания модели. /// /// True — модель уверена и решение можно использовать без ИИ. /// Класс решения: id колонки канбана (b_…) или spam (null — не уверена). diff --git a/src/ml-service/Deal.Ml/Model/MlStatusResult.cs b/src/ml-service/Deal.Ml/Model/MlStatusResult.cs index 410386e..00b9f02 100644 --- a/src/ml-service/Deal.Ml/Model/MlStatusResult.cs +++ b/src/ml-service/Deal.Ml/Model/MlStatusResult.cs @@ -1,10 +1,9 @@ namespace Deal.Ml.Model; /// -/// Статус модели тенанта (model.py status L325–345; 1:1 StatusReply ml.proto и MlServiceStatusDto -/// ядра): готовность, веса классов, всего примеров и окно самооценки. +/// Статус модели тенанта /// -/// Модель готова принимать решения (пороги Ruling 4). +/// Модель готова принимать решения. /// Классы модели: «label → вес» (round 2, по убыванию). /// Всего примеров, на которых модель обучалась (сумма по классам, int). /// Самооценка по последним подтверждённым решениям. diff --git a/src/ml-service/Deal.Ml/Model/MlTokenizer.cs b/src/ml-service/Deal.Ml/Model/MlTokenizer.cs index d556612..390d005 100644 --- a/src/ml-service/Deal.Ml/Model/MlTokenizer.cs +++ b/src/ml-service/Deal.Ml/Model/MlTokenizer.cs @@ -3,13 +3,7 @@ using System.Text.RegularExpressions; namespace Deal.Ml.Model; /// -/// Разбиение текста на термины модели (план Task 5; 1:1 mlservice/model.py tokenize L78–87). -/// -/// Ссылки и markdown-ссылки удаляются до токенизации (иначе модель учит мусор из URL — utm, -/// source, campaign — и режет по нему заявки). Термин — серия [a-zа-яё0-9@+.#]+ (в обеих -/// регистрах, затем lowercase); слова короче 3 символов отбрасываются, слова длиной ≥ 6 -/// дополнительно дают «хвостовой» термин «~» + первые 4 символа. Порядок токенов сохраняется -/// (повторы слова дают повторы термина — так же, как python-executemany в learn). +/// Разбиение текста на термины модели. /// public static class MlTokenizer { @@ -28,8 +22,7 @@ public static class MlTokenizer RegexOptions.Compiled | RegexOptions.CultureInvariant); /// - /// Токенизирует текст в термины модели: удаляет ссылки, приводит к lowercase, отбрасывает - /// короткие слова и добавляет «~prefix» для длинных (1:1 tokenize L78–87). + /// Токенизирует текст в термины модели /// /// Текст сообщения/карточки (null — пустой). /// Список терминов в порядке появления (включая повторы). diff --git a/src/ml-service/Deal.Ml/Model/MlTypeDecision.cs b/src/ml-service/Deal.Ml/Model/MlTypeDecision.cs index d3844c3..6ef7368 100644 --- a/src/ml-service/Deal.Ml/Model/MlTypeDecision.cs +++ b/src/ml-service/Deal.Ml/Model/MlTypeDecision.cs @@ -1,9 +1,7 @@ namespace Deal.Ml.Model; /// -/// Решение ML о типе заявки (model.py predict L233–238; 1:1 TypeDecision ml.proto и -/// MlTypeDecisionDto ядра). Возникает только когда в модели есть оба внутренних класса -/// t:hire/t:order с достаточным опытом и отрывом по адаптивному порогу. +/// Решение ML о типе заявки. /// /// True — модель уверена в типе. /// Тип для UI: hire | order. diff --git a/src/ml-service/Deal.Ml/Model/ModelConstants.cs b/src/ml-service/Deal.Ml/Model/ModelConstants.cs index c601191..3d274cd 100644 --- a/src/ml-service/Deal.Ml/Model/ModelConstants.cs +++ b/src/ml-service/Deal.Ml/Model/ModelConstants.cs @@ -1,64 +1,62 @@ namespace Deal.Ml.Model; /// -/// Пороги и константы наивно-байесовской модели по терминам (Ruling 4; 1:1 -/// mlservice/model.py L20–31, L42–55, L178–181). Именованные константы вместо магических -/// чисел (код-стайл этапа); значения НЕ входят в .proto-контракт (README src/contracts L164–166). +/// Пороги и константы наивно-байесовской модели по терминам. /// public static class ModelConstants { /// - /// Суммарно примеров по всем классам, чтобы модель «включилась» (MIN_TOTAL). + /// Суммарно примеров по всем классам, чтобы модель «включилась» /// public const double MinTotalExamples = 20.0; /// - /// Минимум примеров у класса-победителя (не-спам; MIN_WINNER). + /// Минимум примеров у класса-победителя /// public const double MinWinnerExamples = 6.0; /// - /// Минимум примеров у класса-победителя «spam» (MIN_WINNER_SPAM). + /// Минимум примеров у класса-победителя «spam» /// public const double MinWinnerSpamExamples = 4.0; /// - /// Минимум различных терминов, встреченных у победителя (MIN_HITS). + /// Минимум различных терминов, встреченных у победителя /// public const int MinHits = 2; /// - /// Минимум примеров класса типа t:*, чтобы ML выдавал тип (MIN_TYPE_WINNER). + /// Минимум примеров класса типа t:*, чтобы ML выдавал тип /// public const double MinTypeWinnerExamples = 4.0; /// - /// Метка класса «спам» (специальная роль — порог 4 и «корзина» карточки). + /// Метка класса «спам» /// public const string SpamLabel = "spam"; /// - /// Префикс внутренних классов типа заявки (t:hire/t:order отсекаются из колонок). + /// Префикс внутренних классов типа заявки /// public const string TypeLabelPrefix = "t:"; /// - /// Внутренний класс типа заявки «найм» (в UI — hire). + /// Внутренний класс типа заявки «найм» /// public const string TypeClassHire = "t:hire"; /// - /// Внутренний класс типа заявки «разовая сделка» (в UI — order). + /// Внутренний класс типа заявки «разовая сделка» /// public const string TypeClassOrder = "t:order"; /// - /// Множитель prior при ранжировании классов: score + 3·prior (predict L226–256). + /// Множитель prior при ранжировании классов /// public const double PriorWeight = 3.0; /// - /// ln-отрыв от второго класса на старте (MARGIN; адаптив 0.35/0.5/0.7 — см. ниже). + /// ln-отрыв от второго класса на старте /// public const double InitialMargin = 0.9; @@ -93,47 +91,47 @@ public static class ModelConstants public const double MarginAfter400Examples = 0.35; /// - /// Окно самооценки, которое отдаётся в /status (EVAL_WINDOW). + /// Окно самооценки, которое отдаётся в /status /// public const int EvalWindowSize = 50; /// - /// Сколько последних решений самооценки хранится в БД модели (EVAL_KEEP). + /// Сколько последних решений самооценки хранится в БД модели /// public const int EvalKeepCount = 200; /// - /// Сколько лучших весов классов отдаётся в predict (scores ≤ 5). + /// Сколько лучших весов классов отдаётся в predict /// public const int MaxScoresInReply = 5; /// - /// Сколько узнанных терминов отдаётся в predict (terms ≤ 8). + /// Сколько узнанных терминов отдаётся в predict /// public const int MaxMatchedTerms = 8; /// - /// Точность округления весов в scores (python round(…, 3)). + /// Точность округления весов в scores /// public const int ScoresPrecision = 3; /// - /// Точность округления весов классов в status (python round(…, 2)). + /// Точность округления весов классов в status /// public const int ClassesPrecision = 2; /// - /// Точность округления отступа margin (python round(…, 2)). + /// Точность округления отступа margin /// public const int MarginPrecision = 2; /// - /// Точность округления доли верных в eval (python round(…, 3)). + /// Точность округления доли верных в eval /// public const int AccuracyPrecision = 3; /// - /// Минимальная длина токена, попадающего в модель (L83–86). + /// Минимальная длина токена, попадающего в модель. /// public const int MinTokenLength = 3; @@ -143,12 +141,12 @@ public static class ModelConstants public const int MinTokenLengthForPrefix = 6; /// - /// Длина префикса хвостового токена (w[:4]). + /// Длина префикса хвостового токена /// public const int PrefixLength = 4; /// - /// Префикс хвостового токена («~pyth» для «python») — не участвует в terms-подсказках. + /// Префикс хвостового токена — не участвует в terms-подсказках. /// public const string TokenPrefixMarker = "~"; } diff --git a/src/ml-service/Deal.Ml/Model/ModelPool.cs b/src/ml-service/Deal.Ml/Model/ModelPool.cs index 6285fb4..664ef06 100644 --- a/src/ml-service/Deal.Ml/Model/ModelPool.cs +++ b/src/ml-service/Deal.Ml/Model/ModelPool.cs @@ -4,14 +4,10 @@ using Deal.Ml.Storage; namespace Deal.Ml.Model; /// -/// Пул моделей тенантов (план Task 5/6, Ruling 4): ConcurrentDictionary tenantId → TenantModel, -/// модель создаётся лениво по первому обращению (Predict без опыта даёт «не готов» — не ошибка), -/// у каждой модели свой lock (predict/learn сериализованы на тенанта). Файл весов — -/// data/ml/<tenantId>.sqlite. Никакой бизнес-логики и БД тенантов (Ruling 1) — только файлы моделей. +/// Пул моделей тенантов /// public sealed class ModelPool : IDisposable { - // Расширение файла модели (data/ml/<tenantId>.sqlite, Ruling 4). private const string DbFileExtension = ".sqlite"; // Верхняя граница длины tenant-id (защита пути; реальные id заметно короче). @@ -23,7 +19,7 @@ public sealed class ModelPool : IDisposable private readonly ConcurrentDictionary _models = new(StringComparer.Ordinal); /// - /// Каталог файлов моделей (data/ml; диагностика/тесты). + /// Каталог файлов моделей /// public string DataDirectory => _options.DataDirectory; @@ -37,8 +33,7 @@ public sealed class ModelPool : IDisposable } /// - /// Возвращает модель тенанта, создавая её лениво (первое обращение грузит веса из файла). - /// Tenant-id — только из gRPC-metadata (Ruling 1); некорректный id (путь вне каталога) — ошибка. + /// Возвращает модель тенанта, создавая её лениво /// /// Id тенанта. /// Модель тенанта (в пуле до Reset/Dispose). diff --git a/src/ml-service/Deal.Ml/Model/ModelState.cs b/src/ml-service/Deal.Ml/Model/ModelState.cs index 264b0bf..5ac6767 100644 --- a/src/ml-service/Deal.Ml/Model/ModelState.cs +++ b/src/ml-service/Deal.Ml/Model/ModelState.cs @@ -1,33 +1,27 @@ namespace Deal.Ml.Model; /// -/// Состояние модели тенанта в памяти: веса классов и терминов + журнал самооценки. -/// Зеркалит SQLite-файл data/ml/<tenantId>.sqlite (план Task 5, ModelState.cs): изменения -/// применяются транзакцией в MlDb, затем повторяются в этом состоянии в том же порядке. -/// -/// Инварианты 1:1 с БД (model.py): содержит только классы с n > 0; -/// хранит термины с count > 0 и может содержать метки, у которых -/// класс удалён (python оставляет строки terms при удалении класса — «фантомные» веса). +/// Состояние модели тенанта в памяти /// public sealed class ModelState { /// - /// Веса классов: label → n (только n > 0; аналог таблицы classes). + /// Веса классов: label → n. /// public Dictionary Classes { get; } = new(StringComparer.Ordinal); /// - /// Веса терминов по классам: label → (term → count; только count > 0; таблица terms). + /// Веса терминов по классам /// public Dictionary> TermsByLabel { get; } = new(StringComparer.Ordinal); /// - /// Журнал самооценки в порядке накопления (последние EVAL_KEEP, таблица eval_log). + /// Журнал самооценки в порядке накопления /// public List EvalLog { get; } = []; /// - /// Очищает состояние (reset модели; 1:1 model.py reset L348–354). + /// Очищает состояние. /// public void Clear() { diff --git a/src/ml-service/Deal.Ml/Model/OnlineNaiveBayes.cs b/src/ml-service/Deal.Ml/Model/OnlineNaiveBayes.cs index 924c408..0fa6469 100644 --- a/src/ml-service/Deal.Ml/Model/OnlineNaiveBayes.cs +++ b/src/ml-service/Deal.Ml/Model/OnlineNaiveBayes.cs @@ -3,16 +3,12 @@ using Deal.Ml.Extensions; namespace Deal.Ml.Model; /// -/// Инкрементальная наивно-байесовская модель по терминам (план Task 5; Ruling 4). Чистая -/// математика predict/status/ready/margin поверх состояния — 1:1 с -/// mlservice/model.py L184–345: score термина, prior, адаптивный отрыв, type-решение, окно -/// самооценки. Обучение/сохранение — в + Storage.MlDb. +/// Инкрементальная наивно-байесовская модель по терминам. /// public static class OnlineNaiveBayes { /// - /// Готовность модели: суммарно ≥ MIN_TOTAL примеров, у «spam» ≥ MIN_WINNER_SPAM, у остальных - /// классов вместе ≥ MIN_WINNER (model.py ready L96–102). + /// Готовность модели /// /// Состояние модели. public static bool Ready(ModelState state) @@ -29,8 +25,7 @@ public static class OnlineNaiveBayes } /// - /// Адаптивный отрыв от второго класса (model.py _adaptive_margin L42–55): чем больше примеров - /// модель видела, тем ниже порог — ML постепенно заменяет ИИ на типовых сообщениях. + /// Адаптивный отрыв от второго класса /// /// Суммарно примеров по всем классам. public static double AdaptiveMargin(double total) @@ -54,14 +49,13 @@ public static class OnlineNaiveBayes } /// - /// Суммарно примеров по всем классам (n > 0). + /// Суммарно примеров по всем классам /// /// Состояние модели. public static double Total(ModelState state) => state.Classes.Values.Sum(); /// - /// Предсказание по тексту (model.py predict L184–293): веса классов по узнанным терминам, - /// решение «взяла/не взяла» по порогам, type-решение t:hire/t:order, термины-подсказки. + /// Предсказание по тексту /// /// Состояние модели. /// Текст сообщения. @@ -69,14 +63,12 @@ public static class OnlineNaiveBayes { string[] tokens = MlTokenizer.Tokenize(text); - // Нет классов или нет терминов — «не уверен» (ready по факту; L186–188). if (state.Classes.Count == 0 || tokens.Length == 0) { return NotReady(Ready(state)); } // Модель «включается» только с опытом: пока примеров мало, она ничего не решает - // и не может ошибочно удалить заявку как спам (L191–192). if (!Ready(state)) { return NotReady(ready: false); @@ -169,8 +161,7 @@ public static class OnlineNaiveBayes } /// - /// Статус модели (model.py status L325–345): готовность, веса классов (round 2, по убыванию), - /// всего примеров и окно самооценки по последним подтверждённым решениям. + /// Статус модели: готовность, веса классов /// /// Состояние модели. public static MlStatusResult Status(ModelState state) @@ -186,12 +177,10 @@ public static class OnlineNaiveBayes return new MlStatusResult(ready, classes, learned, EvalWindow(state)); } - // Вес термина в score класса (predict L207–208): count < 1 → 1.0, иначе 1+(w−1)/(w+1). // weight: Вес (count) термина в классе. private static double TermScore(double weight) => weight < 1.0 ? 1.0 : 1.0 + (weight - 1.0) / (weight + 1.0); - // Решение о типе заявки по классам t:hire/t:order (predict L217–238): лучший тип со score>0, // опытом ≥ MIN_TYPE_WINNER и отрывом best_total − second_total ≥ margin. // state: Состояние модели. // scores: Веса классов по тексту (label → score). @@ -237,7 +226,6 @@ public static class OnlineNaiveBayes Margin: Round(margin, ModelConstants.MarginPrecision)); } - // Термины, которые модель «узнала» в тексте у класса-победителя (predict L266–282): подсказка // для структурирования карточки без ИИ. Хвостовые «~»-термины исключаются; до 8 по весу. // state: Состояние модели. // label: Класс-победитель (не spam). @@ -274,7 +262,6 @@ public static class OnlineNaiveBayes .ToArray(); } - // Окно самооценки (model.py status L329–339): последние EVAL_WINDOW подтверждённых решений. // state: Состояние модели. private static MlEvalInfo EvalWindow(ModelState state) { @@ -287,7 +274,6 @@ public static class OnlineNaiveBayes return new MlEvalInfo(count, correct, accuracy); } - // Термины класса или null, если класса нет/нет терминов (аналог SQL WHERE count > 0). // state: Состояние модели. // label: Метка класса. private static Dictionary? TryGetTerms(ModelState state, string label) @@ -320,7 +306,6 @@ public static class OnlineNaiveBayes Terms: Array.Empty(), Type: typeDecision); - // Округление как python round (banker's rounding). // value: Значение. // digits: Число знаков после запятой. private static double Round(double value, int digits) => Math.Round(value, digits); diff --git a/src/ml-service/Deal.Ml/Model/TenantModel.cs b/src/ml-service/Deal.Ml/Model/TenantModel.cs index fc8c189..ecfb2c9 100644 --- a/src/ml-service/Deal.Ml/Model/TenantModel.cs +++ b/src/ml-service/Deal.Ml/Model/TenantModel.cs @@ -3,11 +3,7 @@ using Deal.Ml.Storage; namespace Deal.Ml.Model; /// -/// Модель одного тенанта: состояние в памяти + SQLite-файл весов (план Task 5, Ruling 4). -/// Создаётся пулом лениво по первому обращению и живёт в пуле, пока не вызван reset/Dispose. -/// Predict/learn/status/reset сериализованы на тенанта собственным lock (predict и learn не -/// перемешиваются; пул соединений per-tenant — один MlDb на модель). Математика 1:1 с -/// mlservice/model.py вынесена в , персистентность — . +/// Модель одного тенанта /// public sealed class TenantModel : IDisposable { @@ -18,10 +14,10 @@ public sealed class TenantModel : IDisposable private bool _loaded; /// - /// Создаёт модель тенанта над своим SQLite-файлом (файл не трогается до первого RPC). + /// Создаёт модель тенанта над своим SQLite-файлом /// /// Id тенанта (владелец модели). - /// Хранилище весов модели (файл data/ml/<tenantId>.sqlite). + /// Хранилище весов модели (файл data/ml/<tenantId>.sqlite). public TenantModel(string tenantId, MlDb db) { _tenantId = tenantId; @@ -29,12 +25,12 @@ public sealed class TenantModel : IDisposable } /// - /// Путь к SQLite-файлу модели (диагностика/тесты). + /// Путь к SQLite-файлу модели /// public string DatabasePath => _db.DatabasePath; /// - /// Предсказание по тексту (model.py predict L184–293): отсутствие опыта — «не уверен», не ошибка. + /// Предсказание по тексту /// /// Текст сообщения. public MlPredictResult Predict(string text) @@ -47,7 +43,7 @@ public sealed class TenantModel : IDisposable } /// - /// Статус модели (model.py status L325–345). + /// Статус модели. /// public MlStatusResult Status() { @@ -59,9 +55,7 @@ public sealed class TenantModel : IDisposable } /// - /// Пакетное обучение (model.py learn_batch L147–173): самооценка по реальным действиям - /// пользователя до применения батча, одна транзакция на батч. Возвращает число применённых - /// примеров (пустые text/label пропускаются — тихий no-op, 1:1 _upsert_one L112–114). + /// Пакетное обучение /// /// Обучающие примеры (text/label/delta). public int LearnBatch(IReadOnlyList items) @@ -96,7 +90,6 @@ public sealed class TenantModel : IDisposable } // Сначала персистентность (одна транзакция; при сбое память не менялась), затем — - // повтор изменений в памяти в том же порядке (1:1 с upsert-семантикой python). _db.ApplyLearnBatch(applied, evalRows); foreach ((string label, double delta, string[] tokens) in applied) { @@ -109,9 +102,7 @@ public sealed class TenantModel : IDisposable } /// - /// Полный сброс модели (model.py reset L348–354; план Task 6 — пересоздание файла): файл - /// удаляется и пересоздаётся пустым при следующем обращении. Ошибка хранилища пробрасывается — - /// RPC Reset отвечает мягким ok=false + error (контракт ml.proto). + /// Полный сброс модели /// public void Reset() { @@ -126,8 +117,6 @@ public sealed class TenantModel : IDisposable /// public void Dispose() => _db.Dispose(); - // Самооценка перед обучением на реальном действии пользователя (model.py _maybe_eval - // L296–322): delta=1.0, не тип t:*, модель уже включена и уверенно взяла решение — // сверяем его с действием и пишем строку журнала. Гипотезы ИИ (delta<1) и «разучивание» // (delta<0) не оцениваются. // label: Метка действия пользователя. @@ -158,9 +147,7 @@ public sealed class TenantModel : IDisposable string.Equals(prediction.Label, label, StringComparison.Ordinal)); } - // Повтор изменения одного примера в памяти (1:1 _upsert_one L105–131): класс и термины // обновляются до применения удаления «обнулённых» строк при delta < 0. Удалённый класс - // может оставить «фантомные» термины с count > 0 (python хранит строки terms). // label: Метка класса. // delta: Вес сигнала. // tokens: Термины текста (повторы — повторы инкрементов). @@ -207,7 +194,6 @@ public sealed class TenantModel : IDisposable } } - // Добавляет строки самооценки в память и оставляет последние EVAL_KEEP (L319–322). // rows: Новые строки (уже в порядке накопления). private void AppendEvalToMemory(IReadOnlyList rows) { @@ -223,7 +209,6 @@ public sealed class TenantModel : IDisposable } } - // Ленивая загрузка модели из файла (первое обращение к тенанту; Ruling 4). private void EnsureLoaded() { if (_loaded) diff --git a/src/ml-service/Deal.Ml/Program.cs b/src/ml-service/Deal.Ml/Program.cs index c3e5036..abf43b5 100644 --- a/src/ml-service/Deal.Ml/Program.cs +++ b/src/ml-service/Deal.Ml/Program.cs @@ -1,14 +1,9 @@ -// ml-service — точка входа gRPC-хоста (план Task 3, L240–248; Ruling 1/2/4/12). // // Kestrel HTTP/2 на порту 5103 (env GRPC_PORT, затем PORT) + AddGrpc с интерцепторами service-token // и access-лога + стандартный gRPC-health (grpc.health.v1.Health). Транспорт: dev — plaintext -// (Ruling 2); mTLS (TLS + клиентский сертификат) — при DEAL_MTLS_ENABLED=1 (Ruling 6, план Task 13; -// сертификаты deploy/certs — scripts/mtls-certs.sh, env передаёт compose-prod Task 14); fail-closed: // Production без mTLS не стартует (GrpcHostEnvironment.RequireMtlsInProduction). // Серверная обвязка (Kestrel/AddGrpc/health) — общий Deal.Grpc.Hosting (C31): хост-фабрика // MlServiceHost.Create используется и интеграционными тестами (Deal.Ml.Tests), которые поднимают -// его в своём процессе на эфемерном порту. Реальная логика (план Task 5/6): инкрементальный -// наивный Байес по терминам per-tenant (файлы data/ml/.sqlite, Ruling 4) — движок/пул // в Model/, хранилище в Storage/, gRPC Predict/Status/Reset/TrainBatch — MlServiceImpl. using Deal.Grpc.Hosting.Interceptors; @@ -17,17 +12,13 @@ using Deal.Grpc.Hosting.Options; using Deal.Grpc.Hosting.Services; using Deal.Ml; -// Порт по умолчанию — 5103 (Ruling 12, compose.dev.yml); переопределяется env GRPC_PORT (контейнер) // или PORT (общий конвенциональный env хостинг-платформ) — см. GrpcHostEnvironment.ResolveGrpcPort. const int defaultGrpcPort = 5103; -// Имя процесса для rolling-файла логов (Ruling 7, Task 14): data/logs/deal-ml-<дата>.json. const string mlProcessName = "ml"; int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort); -// Порт эндпоинта метрик /metrics (HTTP/1.1, отдельно от gRPC HTTP/2; этап 12, пакет A). int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); -// Serilog (Ruling 7, план Task 14): JSON-консоль + rolling-файл data/logs/deal-ml-*.json — // конфигурируется production-точкой входа через configureBuilder-хук хоста (тесты хост поднимают // без Serilog, DealLogging.Configure в MlServiceHost/Create вызывается только здесь). Метрики // (OTel → Prometheus, /metrics) — тем же хуком до builder.Build(). @@ -39,10 +30,8 @@ WebApplication app = MlServiceHost.Create( DealMetricsHosting.AddDealMetrics(builder, metricsPort); }); -// Эндпоинт метрик /metrics (HTTP/1.1 на отдельном порту): формат Prometheus (этап 12, пакет A). DealMetricsHosting.MapDealMetrics(app); -// Режим транспорта — из тех же env, что читал хост (Ruling 6, Task 13): mTLS при DEAL_MTLS_ENABLED=1. MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration); // Fail-closed (замечание code-review): отсутствие/опечатка DEAL_MTLS_ENABLED не должны давать diff --git a/src/ml-service/Deal.Ml/Storage/MlDb.cs b/src/ml-service/Deal.Ml/Storage/MlDb.cs index c50490e..86a5e0b 100644 --- a/src/ml-service/Deal.Ml/Storage/MlDb.cs +++ b/src/ml-service/Deal.Ml/Storage/MlDb.cs @@ -4,13 +4,7 @@ using Microsoft.Data.Sqlite; namespace Deal.Ml.Storage; /// -/// SQLite-хранилище весов модели одного тенанта (план Task 5; Ruling 4; 1:1 db-схемы -/// mlservice/model.py L64–75). Файл data/ml/<tenantId>.sqlite, таблицы -/// classes(label,n,updated_at)/terms(label,term,count)/eval_log(created_at,expected,predicted, -/// correct). Одно долгоживущее соединение на экземпляр (пул соединений per-tenant: один -/// TenantModel ↔ один MlDb); все операции вызываются под lock модели тенанта -/// (), поэтому дополнительная синхронизация не нужна. -/// Запись — транзакциями, термины пишутся батчем per-пример (1:1 learn_batch L147–173). +/// SQLite-хранилище весов модели одного тенанта. /// public sealed class MlDb : IDisposable { @@ -34,14 +28,14 @@ public sealed class MlDb : IDisposable private SqliteConnection? _connection; /// - /// Путь к SQLite-файлу модели (диагностика, тесты перезапуска пула). + /// Путь к SQLite-файлу модели /// public string DatabasePath => _filePath; /// - /// Создаёт хранилище модели для файла по пути (файл/каталог создаются лениво). + /// Создаёт хранилище модели для файла по пути /// - /// Путь к SQLite-файлу модели (<dataDir>/<tenantId>.sqlite). + /// Путь к SQLite-файлу модели (<dataDir>/<tenantId>.sqlite). public MlDb(string filePath) { if (string.IsNullOrWhiteSpace(filePath)) @@ -53,8 +47,7 @@ public sealed class MlDb : IDisposable } /// - /// Открывает соединение и создаёт схему при первом обращении (model.py _db L58–75). - /// Файл пересоздаётся автоматически после . + /// Открывает соединение и создаёт схему при первом обращении. /// public void EnsureCreated() { @@ -81,9 +74,7 @@ public sealed class MlDb : IDisposable } /// - /// Читает полное состояние модели из файла (lazy-load модели тенанта): классы n > 0, - /// термины count > 0 (в т.ч. «фантомные» метки удалённых классов — 1:1 с python) и - /// последние строк журнала самооценки. + /// Читает полное состояние модели из файла /// public ModelState LoadState() { @@ -139,10 +130,7 @@ public sealed class MlDb : IDisposable } /// - /// Применяет батч обучения одной транзакцией (model.py learn_batch L147–173): upsert классов, - /// пакетная вставка терминов per-пример, удаление «обнулённых» строк при разучивании (delta < 0), - /// журнал самооценки + его прунинг до . Вызывается под - /// lock модели тенанта. При сбое — ROLLBACK и проброс исключения (состояние памяти не менялось). + /// Применяет батч обучения одной транзакцией /// /// Применяемые примеры: label, delta и термины текста (в порядке появления). /// Новые строки самооценки (решения до применения батча). @@ -178,7 +166,7 @@ public sealed class MlDb : IDisposable } /// - /// Пересоздаёт файл модели (reset, план Task 6): закрывает соединение и удаляет файл. + /// Пересоздаёт файл модели /// public void DeleteFile() { @@ -189,12 +177,10 @@ public sealed class MlDb : IDisposable /// public void Dispose() => DisposeConnection(); - // Применяет один пример внутри транзакции (1:1 model.py _upsert_one L105–131): upsert класса, // пакетные upsert терминов; при delta < 0 — удаление строк count ≤ 0 и классов n ≤ 0. // transaction: Транзакция батча. // label: Метка класса. // delta: Вес сигнала (знак — учить/разучивать). - // tokens: Термины текста (повторы слова — повторы строк, как python-executemany). private void ApplyExample( SqliteTransaction transaction, string label, @@ -274,7 +260,6 @@ public sealed class MlDb : IDisposable } } - // Оставляет ровно последние EVAL_KEEP строк журнала (аналог model.py L319–322). // Python удаляет по created_at и при одинаковых миллисекундах может оставить больше EVAL_KEEP; // здесь прунинг детерминирован по порядку вставки (rowid) — память модели хранит те же самые // последние EVAL_KEEP строк, поэтому состояние памяти и файла не расходится после перезапуска. diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/CoreIngressClientTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/CoreIngressClientTests.cs index 55c1693..e3c9f39 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/CoreIngressClientTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/CoreIngressClientTests.cs @@ -8,10 +8,7 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Deal.Telegram.Tests.Grpc; /// -/// Тесты исходящего gRPC-канала в ядро (план Task 10 Acceptance: «PushMessage-клиент к in-proc -/// fake-серверу ингресса»). Клиент CoreIngressClient ходит по реальному gRPC (HTTP/2) на фейк-сервер -/// IngressService в процессе теста; проверяются metadata tenant-id/service-token (Ruling 1), тело -/// запроса/ответ и перевод недоступности ядра в SessionException UNAVAILABLE. +/// Тесты исходящего gRPC-канала в ядро. /// public sealed class CoreIngressClientTests { @@ -54,7 +51,7 @@ public sealed class CoreIngressClientTests } /// - /// SyncDialogs: entries уходят; ответ ядра (monitored ids) возвращается списком. + /// SyncDialogs: entries уходят; ответ ядра /// [Fact] public async Task SyncDialogs_SendsEntries_AndReturnsMonitoredIds() @@ -83,7 +80,7 @@ public sealed class CoreIngressClientTests } /// - /// Ядро недоступно → SessionException UNAVAILABLE «Ядро недоступно…» (лог + повторный sweep). + /// Ядро недоступно → SessionException UNAVAILABLE «Ядро недоступно…» /// [Fact] public async Task PushMessage_UnreachableCore_ThrowsSessionException() diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/DialogRpcTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/DialogRpcTests.cs index 6d8cd8e..531f8d7 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/DialogRpcTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/DialogRpcTests.cs @@ -11,10 +11,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; namespace Deal.Telegram.Tests.Grpc; /// -/// RPC-тесты каталога/мониторинга поверх реального gRPC-хоста (план Task 10: RefreshDialogs/ -/// SetMonitor/SetMonitorAll/Backfill/ReadRecent). Сеть Telegram не используется: ready-сессия — -/// телефонный вход на фейк-клиенте (как Task 9), канал в ядро — in-proc фейк-сервер IngressService -/// (проводка реального gRPC, metadata tenant/service-token), анти-бан-паузы — фейк-пейсер. +/// RPC-тесты каталога/мониторинга поверх реального gRPC-хоста. /// public sealed class DialogRpcTests { @@ -26,7 +23,7 @@ public sealed class DialogRpcTests private const int PollTimeoutMilliseconds = 5000; /// - /// RefreshDialogs ready-сессии: актуальный каталог уходит в ответ и синком в ядро. + /// RefreshDialogs ready-сессии /// [Fact] public async Task RefreshDialogs_ReturnsEntries_AndSyncsToCore() @@ -70,7 +67,7 @@ public sealed class DialogRpcTests } /// - /// SetMonitor включает мониторинг диалога (ответ ok/enabled) после refresh каталога. + /// SetMonitor включает мониторинг диалога /// [Fact] public async Task SetMonitor_EnablesAndDisables() @@ -96,7 +93,7 @@ public sealed class DialogRpcTests } /// - /// SetMonitorAll: count = размер каталога; повторный off снимает мониторинг (ok=true). + /// SetMonitorAll: count = размер каталога; повторный off снимает мониторинг /// [Fact] public async Task SetMonitorAll_ReturnsCatalogCount() @@ -125,7 +122,7 @@ public sealed class DialogRpcTests } /// - /// Backfill: последние сообщения уходят в ядро (in-proc ингресс) + read-ack на сессии. + /// Backfill: последние сообщения уходят в ядро /// [Fact] public async Task Backfill_PushesRecentMessages_AndMarksRead() @@ -153,7 +150,7 @@ public sealed class DialogRpcTests } /// - /// Backfill без готовой сессии → «Telegram не подключён» (FAILED_PRECONDITION). + /// Backfill без готовой сессии → «Telegram не подключён» /// [Fact] public async Task Backfill_NoSession_FailedPrecondition() @@ -170,7 +167,7 @@ public sealed class DialogRpcTests } /// - /// ReadRecent: свежие сообщения превью (от новых к старым) + read-ack после просмотра. + /// ReadRecent: свежие сообщения превью /// [Fact] public async Task ReadRecent_ReturnsFreshPreview_NewestFirst() @@ -200,7 +197,7 @@ public sealed class DialogRpcTests } /// - /// ReadRecent пустого диалога: пустой превью без падения (фолбэк на БД делает ядро). + /// ReadRecent пустого диалога /// [Fact] public async Task ReadRecent_EmptyDialog_ReturnsEmptyPreview() diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryProtoMapperTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryProtoMapperTests.cs index aea4f4f..61f1967 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryProtoMapperTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryProtoMapperTests.cs @@ -6,14 +6,12 @@ using Deal.Telegram.Telegram; namespace Deal.Telegram.Tests.Grpc; /// -/// Unit-тесты маппера ответов discovery (план Task 11 Acceptance: «формат ответов — тесты на чистых -/// мапперах»): нейтральные результаты → ChannelInfo/EvalMessage/ReadForEvalReply контракта. TL-слой не -/// участвует (без сети и фейков сессии) — только чистые мапперы нейтральных типов. +/// Unit-тесты маппера ответов discovery /// public sealed class DiscoveryProtoMapperTests { /// - /// Инфо источника → ChannelInfo: все поля, hue сервиса, optional participants/is_forum. + /// Инфо источника → ChannelInfo /// [Fact] public void ToChannelInfo_MapsAllFields() @@ -32,7 +30,7 @@ public sealed class DiscoveryProtoMapperTests } /// - /// Инфо по умолчанию (сущность недоступна): name=id, kind пуст, participants не выставлен. + /// Инфо по умолчанию /// [Fact] public void ToChannelInfo_UnknownInfo_LeavesParticipantsUnset() @@ -47,7 +45,7 @@ public sealed class DiscoveryProtoMapperTests } /// - /// Форум: kind канона + is_forum=true (ядро трактует kind как forum, Ruling 10). + /// Форум: kind канона + is_forum=true. /// [Fact] public void ToChannelInfo_Forum_SetsIsForum() @@ -61,7 +59,7 @@ public sealed class DiscoveryProtoMapperTests } /// - /// Сообщение темы форума → EvalMessage: поля и topic_id/topic_title. + /// Сообщение темы форума → EvalMessage /// [Fact] public void ToEvalMessage_MapsTopicFields() @@ -78,7 +76,7 @@ public sealed class DiscoveryProtoMapperTests } /// - /// Сообщение обычной ленты → EvalMessage: topic-поля не выставлены (optional пуст). + /// Сообщение обычной ленты → EvalMessage /// [Fact] public void ToEvalMessage_PlainFeed_LeavesTopicUnset() @@ -118,7 +116,7 @@ public sealed class DiscoveryProtoMapperTests } /// - /// История недоступна → ReadForEvalReply ok:false + error="no_history" (не ошибка RPC). + /// История недоступна → ReadForEvalReply ok:false + error="no_history" /// [Fact] public void ToReadForEvalReply_NoHistory_OkFalseWithError() diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryRpcTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryRpcTests.cs index bb9c4b2..8f46479 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryRpcTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/DiscoveryRpcTests.cs @@ -10,9 +10,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; namespace Deal.Telegram.Tests.Grpc; /// -/// RPC-тесты discovery-операций поверх реального gRPC-хоста (план Task 11: Search/GetInfo/ReadForEval/ -/// Join/Leave). Сеть Telegram не используется: ready-сессия — телефонный вход на фейк-клиенте, данные -/// discovery задаёт тест на фейке; анти-бан-пауза поиска — фейк-пейсер (без реальных задержек). +/// RPC-тесты discovery-операций поверх реального gRPC-хоста. /// public sealed class DiscoveryRpcTests { @@ -25,7 +23,7 @@ public sealed class DiscoveryRpcTests private const int PollTimeoutMilliseconds = 5000; /// - /// Search ready-сессии: найденные источники в entries ответа + анти-бан-пауза 2–4 с. + /// Search ready-сессии /// [Fact] public async Task Search_ReturnsFoundEntries_AndWaits() @@ -72,7 +70,7 @@ public sealed class DiscoveryRpcTests } /// - /// GetInfo: имя/username/kind/participants/is_forum из фейка (маппинг ChannelInfo). + /// GetInfo: имя/username/kind/participants/is_forum из фейка /// [Fact] public async Task GetInfo_ReturnsChannelInfo() @@ -98,7 +96,7 @@ public sealed class DiscoveryRpcTests } /// - /// ReadForEval: выборка ok:true с сообщениями (в т.ч. темами форума). + /// ReadForEval: выборка ok:true с сообщениями /// [Fact] public async Task ReadForEval_ReturnsMessages_Ok() @@ -131,7 +129,7 @@ public sealed class DiscoveryRpcTests } /// - /// ReadForEval недоступной истории: ok:false + error=no_history (это НЕ ошибка RPC). + /// ReadForEval недоступной истории /// [Fact] public async Task ReadForEval_HistoryUnavailable_OkFalseNoHistory() @@ -153,7 +151,7 @@ public sealed class DiscoveryRpcTests } /// - /// Join: вступление по username (нормализованному) → ok:true; пауз нет (внешний анти-бан — ядро). + /// Join: вступление по username /// [Fact] public async Task Join_JoinsByNormalizedUsername_Ok() @@ -173,7 +171,7 @@ public sealed class DiscoveryRpcTests } /// - /// Join при FloodWait → RESOURCE_EXHAUSTED с detail-префиксом "flood" (контракт). + /// Join при FloodWait → RESOURCE_EXHAUSTED с detail-префиксом "flood" /// [Fact] public async Task Join_FloodWait_ResourceExhausted() @@ -195,7 +193,7 @@ public sealed class DiscoveryRpcTests } /// - /// Join без username → INVALID_ARGUMENT (текст 1:1 прототипа). + /// Join без username → INVALID_ARGUMENT. /// [Fact] public async Task Join_EmptyUsername_InvalidArgument() @@ -213,7 +211,7 @@ public sealed class DiscoveryRpcTests } /// - /// Серверная граница длины Search.query: длиннее лимита → INVALID_ARGUMENT без сессии/поиска. + /// Серверная граница длины Search.query /// [Fact] public async Task Search_TooLongQuery_InvalidArgument() @@ -231,7 +229,7 @@ public sealed class DiscoveryRpcTests } /// - /// Серверная граница длины Join.username (лимит Telegram 32): длиннее → INVALID_ARGUMENT. + /// Серверная граница длины Join.username /// [Fact] public async Task Join_TooLongUsername_InvalidArgument() @@ -249,7 +247,7 @@ public sealed class DiscoveryRpcTests } /// - /// Изоляция тенантов: поиск идёт по сессии своего тенанта (metadata tenant-id, Ruling 1). + /// Изоляция тенантов /// [Fact] public async Task TenantIsolation_SearchOnOwnTenantSession() diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/FakeIngressServer.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/FakeIngressServer.cs index 8e67777..151f92c 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/FakeIngressServer.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/FakeIngressServer.cs @@ -10,7 +10,6 @@ using Microsoft.Extensions.DependencyInjection; namespace Deal.Telegram.Tests.Grpc; // Фейковая реализация IngressService (сервер ядра) для in-proc проверки клиента ингресса -// (план Task 10: «PushMessage-клиент к in-proc fake-серверу ингресса»). Записывает вызовы и metadata // (tenant-id/service-token), отвечает по протоколу без логики ядра. internal sealed class RecordingIngressService : IngressService.IngressServiceBase { @@ -25,17 +24,17 @@ internal sealed class RecordingIngressService : IngressService.IngressServiceBas public List Syncs { get; } = []; /// - /// Значения tenant-id полученных вызовов (в порядке вызовов). + /// Значения tenant-id полученных вызовов /// public List Tenants { get; } = []; /// - /// Значения service-token полученных вызовов (в порядке вызовов). + /// Значения service-token полученных вызовов /// public List Tokens { get; } = []; /// - /// Monitored-набор ответа SyncDialogs (по умолчанию пуст). + /// Monitored-набор ответа SyncDialogs /// public List MonitoredIds { get; set; } = []; @@ -57,7 +56,6 @@ internal sealed class RecordingIngressService : IngressService.IngressServiceBas return Task.FromResult(reply); } - // Записывает metadata вызова (tenant-id/service-token, Ruling 1). // context: Контекст вызова gRPC. private void Record(ServerCallContext context) { @@ -71,7 +69,7 @@ internal sealed class RecordingIngressService : IngressService.IngressServiceBas internal static class FakeIngressServer { /// - /// Создаёт сервер (остановку — через возвращённый App) и возвращает endpoint + реализацию. + /// Создаёт сервер /// public static async Task<(string Endpoint, RecordingIngressService Server, WebApplication App)> StartAsync() { diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/RealtimeListenerTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/RealtimeListenerTests.cs index ff93f4b..d410484 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/RealtimeListenerTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/RealtimeListenerTests.cs @@ -9,10 +9,7 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Deal.Telegram.Tests.Grpc; /// -/// Unit-тесты realtime-listener'а (план Task 10 Acceptance: фильтр мониторинга; «новые сообщения → -/// mark-as-read → PushMessage в core»). Listener подписывается на события ready-сессии (проброс -/// клиента TenantSession), фильтрует по зеркалу DialogCatalog и отправляет в ядро; read-ack — -/// только после успешного push. +/// Unit-тесты realtime-listener'а. /// public sealed class RealtimeListenerTests { @@ -21,7 +18,7 @@ public sealed class RealtimeListenerTests private const string OtherDialogId = "+79990001122"; /// - /// Мониторящееся сообщение: PushMessage в ядро + mark-as-read (ТЗ: «сразу прочитанным»). + /// Мониторящееся сообщение /// [Fact] public async Task Listener_MonitoredMessage_PushesAndMarksRead() @@ -50,7 +47,7 @@ public sealed class RealtimeListenerTests } /// - /// Немониторящийся диалог фильтруется: без push и без read-ack. + /// Немониторящийся диалог фильтруется /// [Fact] public async Task Listener_UnmonitoredMessage_IsFilteredOut() @@ -76,7 +73,7 @@ public sealed class RealtimeListenerTests } /// - /// Сбой PushMessage не роняет realtime: сообщение не помечается прочитанным (догонит sweep). + /// Сбой PushMessage не роняет realtime /// [Fact] public async Task Listener_PushFails_NoMarkRead() @@ -102,7 +99,7 @@ public sealed class RealtimeListenerTests } /// - /// Stop отписывает listener: события больше не обрабатываются. + /// Stop отписывает listener /// [Fact] public async Task Listener_Stop_Unsubscribes() diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramServiceHostTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramServiceHostTests.cs index 82e8e65..7fb1e2d 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramServiceHostTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramServiceHostTests.cs @@ -6,14 +6,7 @@ using Grpc.Net.Client; namespace Deal.Telegram.Tests.Grpc; /// -/// Интеграционные тесты каркаса telegram-service (план Task 2, Acceptance + задача сессий). -/// -/// Хост поднимается в процессе теста (Kestrel HTTP/2, эфемерный порт) через TelegramServiceHost.Create — -/// ту же сборку хоста, что использует Program.cs. Проверки: gRPC-health → SERVING; ServiceTokenInterceptor -/// (Ruling 1): запрос без токена/с неверным токеном → UNAUTHENTICATED; верный токен доходит до метода -/// (GetStatus без сессии тенанта → FAILED_PRECONDITION «Telegram не подключён», контракт README); -/// при незаданном DEAL_SERVICE_TOKEN — fail-closed. Окружение сессий (ключ/каталог) выставляет -/// TelegramTestHost — тот же хост, что и у тестов сессий. +/// Интеграционные тесты каркаса telegram-service. /// public sealed class TelegramServiceHostTests { @@ -21,8 +14,7 @@ public sealed class TelegramServiceHostTests private const string ValidToken = TelegramTestHost.DefaultToken; /// - /// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура - /// и health-сервис работают (Ruling 12; health освобождён от service-token). + /// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура и health-сервис работают. /// [Fact] public async Task HealthCheck_ReturnsServing() @@ -41,7 +33,7 @@ public sealed class TelegramServiceHostTests } /// - /// Запрос без metadata «service-token» → UNAUTHENTICATED (Ruling 1). + /// Запрос без metadata «service-token» → UNAUTHENTICATED. /// [Fact] public async Task GetStatus_WithoutToken_IsUnauthenticated() @@ -50,7 +42,7 @@ public sealed class TelegramServiceHostTests } /// - /// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1). + /// Запрос с неверным токеном → UNAUTHENTICATED. /// [Fact] public async Task GetStatus_WithWrongToken_IsUnauthenticated() @@ -59,9 +51,7 @@ public sealed class TelegramServiceHostTests } /// - /// Верный токен проходит интерцептор к методу: GetStatus для тенанта без сессии отвечает - /// FAILED_PRECONDITION «Telegram не подключён» (контракт telegram.proto/README — нет сессии → отказ), - /// т.е. маппинг сервиса и проверка принадлежности работают (задача сессий). + /// Верный токен проходит интерцептор к методу /// [Fact] public async Task GetStatus_WithValidToken_NoSession_IsFailedPrecondition() @@ -83,8 +73,7 @@ public sealed class TelegramServiceHostTests } /// - /// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется даже с «каким-то» токеном; - /// health при этом продолжает отвечать SERVING (инфраструктурный liveness не ломается). + /// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется даже с «каким-то» токеном; health при этом продолжает отвечать SERVING /// [Fact] public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing() @@ -104,9 +93,7 @@ public sealed class TelegramServiceHostTests } /// - /// Fail-closed-гард: env DEAL_SERVICE_TOKEN не задан, а клиент прислал ПУСТОЙ metadata «service-token» — - /// без гарда «» == «» прошло бы сравнение и запрос дошёл бы до метода. Гард отклоняет запрос - /// UNAUTHENTICATED; health при этом остаётся SERVING. + /// Fail-closed-гард /// [Fact] public async Task EmptyServiceToken_WithUnsetEnvToken_IsUnauthenticated_HealthServing() diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramSessionRpcTests.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramSessionRpcTests.cs index b06e128..e5cda7d 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramSessionRpcTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramSessionRpcTests.cs @@ -8,9 +8,7 @@ using Microsoft.Extensions.DependencyInjection; namespace Deal.Telegram.Tests.Grpc; /// -/// RPC-тесты подключения аккаунта поверх реального gRPC-хоста (план Task 9 Acceptance: ветки RPC — -/// нет ключей → отказ; tenant без сессии; состояние QR) с фейковой фабрикой клиентов: сеть не -/// трогается, QR-«сканирование» эмулируется. Хост — TelegramServiceHost.Create (как в проде). +/// RPC-тесты подключения аккаунта поверх реального gRPC-хоста с фейковой фабрикой клиентов /// public sealed class TelegramSessionRpcTests { @@ -40,7 +38,7 @@ public sealed class TelegramSessionRpcTests } /// - /// Отсутствующий tenant-id в metadata → UNAUTHENTICATED (принадлежность по metadata, Ruling 1). + /// Отсутствующий tenant-id в metadata → UNAUTHENTICATED. /// [Fact] public async Task GetStatus_WithoutTenantId_Unauthenticated() @@ -99,7 +97,7 @@ public sealed class TelegramSessionRpcTests } /// - /// QR-сканирование (эмуляция) → фаза "ready" с account; сессия сохранена. + /// QR-сканирование /// [Fact] public async Task StartQr_ScanCompleted_ReadyWithAccount() @@ -122,7 +120,7 @@ public sealed class TelegramSessionRpcTests } /// - /// SendPassword вне фазы "password" (сейчас "qr") → FAILED_PRECONDITION. + /// SendPassword вне фазы "password" /// [Fact] public async Task SendPassword_WhileQrPhase_FailedPrecondition() diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramTestHost.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramTestHost.cs index d76c984..4730996 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramTestHost.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/TelegramTestHost.cs @@ -21,32 +21,32 @@ namespace Deal.Telegram.Tests.Grpc; internal static class TelegramTestHost { /// - /// Env-ключ ожидаемого service-token (зеркало ServiceTokenInterceptor). + /// Env-ключ ожидаемого service-token /// public const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN"; /// - /// Env-ключ адреса ингресса ядра (зеркало CoreIngressOptions). + /// Env-ключ адреса ингресса ядра /// public const string IngressEndpointEnvKey = CoreIngressOptions.IngressEndpointEnvVarName; /// - /// Env-ключ ключа шифрования сессий (зеркало TgOptions). + /// Env-ключ ключа шифрования сессий /// public const string SessionKeyEnvKey = TgOptions.SessionKeyEnvVarName; /// - /// Env-ключ каталога сессий (зеркало TgOptions). + /// Env-ключ каталога сессий /// public const string SessionDirEnvKey = TgOptions.SessionDirEnvVarName; /// - /// Ключ gRPC-metadata с service-token (зеркало ServiceTokenInterceptor). + /// Ключ gRPC-metadata с service-token /// public const string ServiceTokenMetadataKey = ServiceTokenInterceptor.ServiceTokenMetadataKey; /// - /// Ключ gRPC-metadata с tenant-id (зеркало TelegramServiceImpl). + /// Ключ gRPC-metadata с tenant-id /// public const string TenantIdMetadataKey = TelegramServiceImpl.TenantIdMetadataKey; @@ -56,7 +56,7 @@ internal static class TelegramTestHost public const string DefaultToken = "deal-test-token"; /// - /// Тестовый ключ шифрования сессий: base64 от байтов 0..31 (валидные 32 байта AES-256). + /// Тестовый ключ шифрования сессий /// public const string SessionKeyBase64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="; @@ -66,7 +66,7 @@ internal static class TelegramTestHost public const string DefaultTenantId = "tenant-test"; /// - /// Deadline RPC-вызовов теста (сек). + /// Deadline RPC-вызовов теста /// public const int RpcDeadlineSeconds = 10; @@ -146,7 +146,7 @@ internal static class TelegramTestHost } /// - /// Строит metadata вызова: service-token (+ tenant-id, если задан). + /// Строит metadata вызова /// /// Значение заголовка service-token. /// Id тенанта (null — без заголовка tenant-id). diff --git a/src/telegram-service/Deal.Telegram.Tests/Grpc/TestDoubles.cs b/src/telegram-service/Deal.Telegram.Tests/Grpc/TestDoubles.cs index f1bf51e..b8bdcc9 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Grpc/TestDoubles.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Grpc/TestDoubles.cs @@ -6,37 +6,36 @@ namespace Deal.Telegram.Tests.Grpc; // Фейк исходящего канала в ядро (ICoreIngressClient) для unit-тестов служб каталога: записывает // PushMessage/SyncDialogs без сети; SyncDialogs возвращает настраиваемый monitored-набор (роль ядра, -// Ruling 7). Проводка реального gRPC-канала проверяется отдельно — CoreIngressClientTests против // in-proc фейк-сервера ингресса (FakeIngressServer). internal sealed class FakeIngress : ICoreIngressClient { /// - /// Отправленные PushMessage (tenant + запрос), в порядке вызовов. + /// Отправленные PushMessage /// public List<(string TenantId, PushMessageRequest Message)> Pushes { get; } = []; /// - /// Синхронизации каталога (tenant + entries), в порядке вызовов. + /// Синхронизации каталога /// public List<(string TenantId, IReadOnlyList Entries)> Syncs { get; } = []; /// - /// Monitored-набор, который фейк возвращает ответом SyncDialogs (как ядро). + /// Monitored-набор, который фейк возвращает ответом SyncDialogs /// public List MonitoredIdsToReturn { get; set; } = []; /// - /// Ошибка PushMessageAsync (null — успех). + /// Ошибка PushMessageAsync /// public Exception? PushError { get; set; } /// - /// Ошибка SyncDialogsAsync (null — успех). + /// Ошибка SyncDialogsAsync /// public Exception? SyncError { get; set; } /// - /// Текст последнего PushMessage (для быстрых проверок). + /// Текст последнего PushMessage /// public string? LastPushText => Pushes.Count == 0 ? null : Pushes[^1].Message.Text; @@ -77,11 +76,10 @@ internal sealed class FakeIngress : ICoreIngressClient } // Фейк анти-бан-пейсера: паузы не ждёт, а записывает запрошенные диапазоны (сек) — проверка -// «паузы 1.5–3 с/сообщение, 3–6 с/диалог» без реальных задержек (fake clock плана Task 10). internal sealed class RecordingPacer : IBackfillPacer { /// - /// Запрошенные диапазоны пауз (min/max, сек), в порядке вызовов. + /// Запрошенные диапазоны пауз /// public List<(double MinSeconds, double MaxSeconds)> Waits { get; } = []; diff --git a/src/telegram-service/Deal.Telegram.Tests/Support/SessionStorageTests.cs b/src/telegram-service/Deal.Telegram.Tests/Support/SessionStorageTests.cs index 167095f..a571a91 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Support/SessionStorageTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Support/SessionStorageTests.cs @@ -6,8 +6,7 @@ using Grpc.Core; namespace Deal.Telegram.Tests.Support; /// -/// Тесты файлового хранилища сессий и AES-GCM-обёртки (план Task 9 Acceptance: roundtrip/шифрование/ -/// атомарность/изоляция тенантов). Без сети: только шифр и файлы в temp-каталогах. +/// Тесты файлового хранилища сессий и AES-GCM-обёртки. /// public sealed class SessionStorageTests { @@ -63,7 +62,7 @@ public sealed class SessionStorageTests } /// - /// Шифр: значение не в формате enc: — Decrypt возвращает null (не бросает). + /// Шифр: значение не в формате enc: — Decrypt возвращает null /// [Theory] [InlineData("")] @@ -84,7 +83,7 @@ public sealed class SessionStorageTests } /// - /// Шифр: чужой ключ не расшифровывает (тег не сходится → CryptographicException). + /// Шифр: чужой ключ не расшифровывает /// [Fact] public void Cipher_Decrypt_WrongKey_ThrowsCryptographic() @@ -106,7 +105,7 @@ public sealed class SessionStorageTests } /// - /// Store: Save → Load возвращает тот же StoredSession (круглый roundtrip). + /// Store: Save → Load возвращает тот же StoredSession /// [Fact] public async Task Store_SaveLoad_Roundtrip() @@ -132,7 +131,7 @@ public sealed class SessionStorageTests } /// - /// Store: файл создаётся как enc:-обёртка (at-rest шифрование; открытого JSON в файле нет). + /// Store: файл создаётся как enc:-обёртка /// [Fact] public async Task Store_Save_WritesEncryptedFileWithoutPlaintext() @@ -158,7 +157,7 @@ public sealed class SessionStorageTests } /// - /// Store: повторное сохранение атомарно перезаписывает (последнее значение выигрывает). + /// Store: повторное сохранение атомарно перезаписывает /// [Fact] public async Task Store_SaveTwice_LastWriteWins() @@ -185,7 +184,7 @@ public sealed class SessionStorageTests } /// - /// Store: файла нет → Load возвращает null (без ошибки). + /// Store: файла нет → Load возвращает null /// [Fact] public async Task Store_Load_MissingFile_ReturnsNull() @@ -203,7 +202,7 @@ public sealed class SessionStorageTests } /// - /// Store: чужой ключ → файл нечитаем → Load возвращает null (не падает). + /// Store: чужой ключ → файл нечитаем → Load возвращает null /// [Fact] public async Task Store_Load_WrongKey_ReturnsNull() @@ -224,7 +223,7 @@ public sealed class SessionStorageTests } /// - /// Store: битый файл (не формат/мусор) → Load возвращает null, файл остаётся на диске. + /// Store: битый файл /// [Fact] public async Task Store_Load_CorruptFile_ReturnsNull_FileKept() @@ -247,7 +246,7 @@ public sealed class SessionStorageTests } /// - /// Store: изоляция тенантов — файлы разных тенантов не пересекаются (1:1, Ruling 3). + /// Store: изоляция тенантов — файлы разных тенантов не пересекаются. /// [Fact] public async Task Store_TenantIsolation_SeparateFiles() @@ -294,7 +293,7 @@ public sealed class SessionStorageTests } /// - /// Store: ListTenantIds перечисляет только *.session текущего каталога (для auto_resume). + /// Store: ListTenantIds перечисляет только *.session текущего каталога /// [Fact] public async Task Store_ListTenantIds_ReturnsSessionFilesOnly() @@ -321,7 +320,7 @@ public sealed class SessionStorageTests } /// - /// Store: некорректный tenant-id (путь) отклоняется SessionException INVALID_ARGUMENT. + /// Store: некорректный tenant-id /// [Fact] public async Task Store_InvalidTenantId_ThrowsSessionException() diff --git a/src/telegram-service/Deal.Telegram.Tests/Support/TenantSessionTests.cs b/src/telegram-service/Deal.Telegram.Tests/Support/TenantSessionTests.cs index 01c94a4..d34aa4a 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Support/TenantSessionTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Support/TenantSessionTests.cs @@ -7,9 +7,7 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Deal.Telegram.Tests.Support; /// -/// Тесты фазовой машины сессии тенанта (план Task 9 Acceptance: фазовые переходы на fake-клиенте -/// ISessionClient; изоляция тенантов; отсутствие сессии → отказ). Без сети — клиент фейковый, -/// хранилище реальное (temp-каталог): проверяется и сохранение сессии после авторизации. +/// Тесты фазовой машины сессии тенанта. /// public sealed class TenantSessionTests { @@ -87,7 +85,7 @@ public sealed class TenantSessionTests } /// - /// SendPassword вне фазы "password" → FAILED_PRECONDITION (фаза "code" после StartPhone). + /// SendPassword вне фазы "password" → FAILED_PRECONDITION /// [Fact] public async Task SendPassword_WhenPhaseIsCode_ThrowsPasswordNotRequested() @@ -110,7 +108,7 @@ public sealed class TenantSessionTests } /// - /// SendCode с неверным кодом → INVALID_ARGUMENT «Неверный код», фаза остаётся "code" (повтор). + /// SendCode с неверным кодом → INVALID_ARGUMENT «Неверный код», фаза остаётся "code" /// [Fact] public async Task SendCode_InvalidCode_KeepsCodePhase_WithError() @@ -243,7 +241,7 @@ public sealed class TenantSessionTests } /// - /// TryResumeAsync (auto_resume): сохранённая авторизованная сессия → "ready" + account. + /// TryResumeAsync /// [Fact] public async Task Resume_StoredAuthorizedSession_BecomesReady() @@ -273,7 +271,7 @@ public sealed class TenantSessionTests } /// - /// StartQr на уже авторизованной (возобновлённой) сессии → "ready", QR-URL пуст. + /// StartQr на уже авторизованной /// [Fact] public async Task StartQr_WhenAlreadyAuthorized_ReturnsReady_NoUrl() @@ -332,7 +330,7 @@ public sealed class TenantSessionTests } /// - /// QR-отмены (переключение на phone-вход) не ломают новую фазу: после CancelQrFlow через StartPhone — code. + /// QR-отмены (переключение на phone-вход) не ломают новую фазу /// [Fact] public async Task StartPhone_AfterActiveQr_SwitchesToCodePhase() @@ -353,7 +351,7 @@ public sealed class TenantSessionTests } /// - /// Отмена RPC StartQr до первого URL отменяет и фоновый QR-вход (не «скрытая» авторизация). + /// Отмена RPC StartQr до первого URL отменяет и фоновый QR-вход /// [Fact] public async Task StartQr_CancelledBeforeFirstUrl_CancelsBackgroundQrLogin() @@ -383,7 +381,7 @@ public sealed class TenantSessionTests } /// - /// Heartbeat-переподключение ограничено собственным таймаутом: зависший connect не блокирует цикл. + /// Heartbeat-переподключение ограничено собственным таймаутом /// [Fact] public async Task TryReconnect_HangingConnect_TimesOutByAttemptTimeout() @@ -495,7 +493,7 @@ public sealed class TenantSessionTests public FakeClientFactory Factory => _factory; /// - /// Создаёт контекст: temp-каталог, хранилище, фейковая фабрика, сессия тенанта. + /// Создаёт контекст /// /// Таймаут попытки переподключения сессии (null — значение по умолчанию). public static Task CreateAsync(TimeSpan? reconnectTimeout = null) diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/BackfillServiceTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/BackfillServiceTests.cs index f3813c8..6ff054e 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/BackfillServiceTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/BackfillServiceTests.cs @@ -8,10 +8,7 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Deal.Telegram.Tests.Telegram; /// -/// Unit-тесты backfill диалога (план Task 10 Acceptance: паузы fake-пейсером, processed, mark-as-read -/// вызван, PushMessage отправлен; без сети — фейк-клиент и фейк-канал в ядро). Сценарии 1:1 -/// backfill_dialog прототипа L349–390: от старых к новым, пауза 1.5–3 с/сообщение, 3–6 с между -/// диалогами тенанта, read-ack в конце, сбой отправки — без read-ack. +/// Unit-тесты backfill диалога. /// public sealed class BackfillServiceTests { @@ -45,7 +42,6 @@ public sealed class BackfillServiceTests Assert.Equal(ChannelHandle, ingress.Pushes[0].Message.ChannelHandle); Assert.Equal(DialogHue.Compute(DialogId, ChannelName), ingress.Pushes[0].Message.ChannelHue); Assert.Equal("новое сообщение", ingress.Pushes[2].Message.Text); - // Паузы анти-бана: по одной на сообщение, диапазон 1.5–3 с (Ruling 3). Assert.Equal(3, pacer.Waits.Count); Assert.All(pacer.Waits, wait => { @@ -62,7 +58,7 @@ public sealed class BackfillServiceTests } /// - /// Backfill пустого диалога: 0 сообщений, без пауз; read-ack всё равно выполняется. + /// Backfill пустого диалога /// [Fact] public async Task Backfill_NoMessages_ProcessedZero_StillMarksRead() @@ -88,7 +84,7 @@ public sealed class BackfillServiceTests } /// - /// Второй backfill тенанта (другой диалог) ждёт 3–6 с между диалогами (python L347). + /// Второй backfill тенанта /// [Fact] public async Task Backfill_SecondDialog_WaitsDialogSpacing() @@ -118,7 +114,7 @@ public sealed class BackfillServiceTests } /// - /// Сбой PushMessage прерывает backfill без read-ack (непрочитанное догонит sweep). + /// Сбой PushMessage прерывает backfill без read-ack /// [Fact] public async Task Backfill_PushFails_Throws_WithoutMarkRead() @@ -142,7 +138,7 @@ public sealed class BackfillServiceTests } /// - /// Backfill без готовой сессии тенанта → «Telegram не подключён» (FAILED_PRECONDITION). + /// Backfill без готовой сессии тенанта → «Telegram не подключён» /// [Fact] public async Task Backfill_NoReadySession_FailedPrecondition() diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogCatalogTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogCatalogTests.cs index 37b8594..843e088 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogCatalogTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogCatalogTests.cs @@ -3,9 +3,7 @@ using Deal.Telegram.Dialogs; namespace Deal.Telegram.Tests.Telegram; /// -/// Unit-тесты зеркала каталога/мониторинга диалогов (план Task 10: DialogCatalog; Ruling 7 — ядро -/// владеет списком мониторинга, сервис держит зеркало в памяти). Проверяются SetMonitor/SetMonitorAll/ -/// актуализация ответом SyncDialogs (ReplaceMonitored), изоляция тенантов и очистка после Logout. +/// Unit-тесты зеркала каталога/мониторинга диалогов. /// public sealed class DialogCatalogTests { @@ -33,7 +31,7 @@ public sealed class DialogCatalogTests } /// - /// Мониторинг изолирован по тенантам (1 аккаунт/тенант — чужие решения не видны). + /// Мониторинг изолирован по тенантам /// [Fact] public void Monitored_IsIsolatedPerTenant() @@ -68,7 +66,7 @@ public sealed class DialogCatalogTests } /// - /// SetMonitorAll(true) сохраняет мониторинг записей вне каталога (каталог может отставать). + /// SetMonitorAll(true) сохраняет мониторинг записей вне каталога /// [Fact] public void SetAllMonitored_TrueKeepsExistingMonitoredOutsideKnown() @@ -82,7 +80,7 @@ public sealed class DialogCatalogTests } /// - /// Актуализация ответом SyncDialogs (ReplaceMonitored) — авторитетный monitored-набор. + /// Актуализация ответом SyncDialogs /// [Fact] public void ReplaceMonitored_OverridesMirror_WithCoreReply() @@ -100,7 +98,7 @@ public sealed class DialogCatalogTests } /// - /// Reset (Logout) очищает каталог и мониторинг тенанта (прототип L203: `_monitored.clear()`). + /// Reset (Logout) очищает каталог и мониторинг тенанта (`.clear`). /// [Fact] public void Reset_ClearsTenantState() @@ -116,7 +114,7 @@ public sealed class DialogCatalogTests } /// - /// Каталог пустого тенанта: счётчик 0 и «не мониторится» без создания состояния. + /// Каталог пустого тенанта /// [Fact] public void UnknownTenant_ReportsEmptyState() diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogHueTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogHueTests.cs index 3f9e880..6cbbd2a 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogHueTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/DialogHueTests.cs @@ -3,14 +3,12 @@ using Deal.Telegram.Dialogs; namespace Deal.Telegram.Tests.Telegram; /// -/// Unit-тесты цвета диалога (план Task 10: DialogHue — 1:1 dialog_hue прототипа L876–880 и палитры -/// constants.py DIALOG_HUES). Ожидаемые значения посчитаны python-прототипом (те же алгоритм/палитра) — -/// паритет цветов источников между реализациями. +/// Unit-тесты цвета диалога. /// public sealed class DialogHueTests { /// - /// Цвет по паре (id, имя) совпадает с python-прототипом. + /// Цвет по паре /// [Theory] [InlineData("-1001234567890", "IT Канал", "#a78bfa")] @@ -26,7 +24,7 @@ public sealed class DialogHueTests } /// - /// Цвет детерминирован: повторный вызов возвращает то же значение. + /// Цвет детерминирован /// [Fact] public void Compute_IsDeterministic() @@ -37,7 +35,7 @@ public sealed class DialogHueTests } /// - /// Разные источники распределяются по палитре (цвета коллизируют не все вместе). + /// Разные источники распределяются по палитре /// [Fact] public void Compute_DifferentSources_DistributeAcrossPalette() @@ -50,7 +48,6 @@ public sealed class DialogHueTests DialogHue.Compute("-5", "Работа"), }; - // Ожидание посчитано python-прототипом: 4 источника дают 3 разных цвета палитры. Assert.Equal(3, hues.Distinct().Count()); } } diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/DiscoveryOpsTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/DiscoveryOpsTests.cs index b1cf1ca..49443ce 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/DiscoveryOpsTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/DiscoveryOpsTests.cs @@ -8,10 +8,7 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Deal.Telegram.Tests.Telegram; /// -/// Unit-тесты discovery-операций (план Task 11): поиск с паузой 2–4 с и дедупом/обрезкой, join по -/// username (нормализация; пустой → INVALID_ARGUMENT; FloodWait → RESOURCE_EXHAUSTED с префиксом "flood"), -/// паузы в join нет (внешний анти-бан — воркер ядра, Ruling 10), изоляция тенантов (операции только на -/// сессии своего тенанта; нет сессии → «Telegram не подключён»). Без сети: фейк-клиент и фейк-пейсер. +/// Unit-тесты discovery-операций /// public sealed class DiscoveryOpsTests { @@ -22,7 +19,7 @@ public sealed class DiscoveryOpsTests private const string UserC = "+777333"; /// - /// Поиск: дедуп по id + обрезка до лимита (порядок сохранён) и пауза анти-бана 2–4 с. + /// Поиск: дедуп по id + обрезка до лимита /// [Fact] public async Task Search_DedupesAndCapsResults_PausesTwoToFourSeconds() @@ -44,7 +41,6 @@ public sealed class DiscoveryOpsTests Assert.Equal([ChannelA, ChannelB, UserC], result.Select(item => item.Id).ToArray()); Assert.Equal(("python", 3), Assert.Single(harness.Client.SearchQueries)); - // Пауза между поисковыми запросами: 2–4 с (Ruling 3, ban_guard.search_pause). Assert.Equal((DiscoveryOps.SearchPauseMinSeconds, DiscoveryOps.SearchPauseMaxSeconds), Assert.Single(pacer.Waits)); } finally @@ -54,7 +50,7 @@ public sealed class DiscoveryOpsTests } /// - /// Поиск без лимита (0) → дефолт прототипа 30 (SearchRequest limit=30, L624). + /// Поиск без лимита /// [Fact] public async Task Search_LimitZero_UsesDefaultThirty() @@ -76,7 +72,7 @@ public sealed class DiscoveryOpsTests } /// - /// Поиск без готовой сессии → «Telegram не подключён» (FAILED_PRECONDITION). + /// Поиск без готовой сессии → «Telegram не подключён» /// [Fact] public async Task Search_NoSession_FailedPrecondition() @@ -99,7 +95,7 @@ public sealed class DiscoveryOpsTests } /// - /// GetInfo: возвращает инфо сессии как есть (без пауз и сетевых обёрток). + /// GetInfo: возвращает инфо сессии как есть /// [Fact] public async Task GetInfo_ReturnsSourceInfo() @@ -124,7 +120,7 @@ public sealed class DiscoveryOpsTests } /// - /// ReadForEval: passthrough результата чтения сессии (ok:false no_history — не ошибка). + /// ReadForEval: passthrough результата чтения сессии /// [Fact] public async Task ReadForEval_ReturnsSessionResult() @@ -149,7 +145,7 @@ public sealed class DiscoveryOpsTests } /// - /// Join: нормализация username («@»/пробелы) и отсутствие пауз (внешний анти-бан — ядро). + /// Join: нормализация username /// [Fact] public async Task Join_NormalizesUsername_WithoutPause() @@ -172,7 +168,7 @@ public sealed class DiscoveryOpsTests } /// - /// Join пустого username → INVALID_ARGUMENT «Не указан username для вступления» (L828). + /// Join пустого username → INVALID_ARGUMENT «Не указан username для вступления». /// [Fact] public async Task Join_EmptyUsername_InvalidArgument() @@ -196,7 +192,7 @@ public sealed class DiscoveryOpsTests } /// - /// Join при FloodWait → RESOURCE_EXHAUSTED с detail-префиксом "flood" (флуд-гард, контракт). + /// Join при FloodWait → RESOURCE_EXHAUSTED с detail-префиксом "flood" /// [Fact] public async Task Join_FloodWait_ResourceExhaustedFlood() @@ -220,7 +216,7 @@ public sealed class DiscoveryOpsTests } /// - /// Leave: passthrough id на сессию (без пауз). + /// Leave: passthrough id на сессию /// [Fact] public async Task Leave_PassesDialogId() @@ -241,7 +237,7 @@ public sealed class DiscoveryOpsTests } /// - /// Изоляция тенантов: у каждого тенанта свои результаты (сессия 1 акк/тенант, Ruling 1). + /// Изоляция тенантов /// [Fact] public async Task TenantIsolation_SearchUsesOnlyOwnSession() diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/FakeSessionClient.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/FakeSessionClient.cs index d2c2557..59f153c 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/FakeSessionClient.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/FakeSessionClient.cs @@ -2,7 +2,6 @@ using Deal.Telegram.Telegram; namespace Deal.Telegram.Tests.Telegram; -// Фейковый клиент Telegram для unit/in-proc тестов (план Task 9: фазовые переходы на fake-клиенте // абстракции ISessionClient; без сети). Поведение задаётся скриптом: ошибки/результаты кода/пароля, // QR-вход ждёт явного «сканирования» (CompleteQrScanAsync) — тест управляет моментом авторизации. internal sealed class FakeSessionClient : ISessionClient @@ -16,7 +15,7 @@ internal sealed class FakeSessionClient : ISessionClient private bool _authorized; /// - /// Создаёт фейк под ключи приложения (как реальный клиент). + /// Создаёт фейк под ключи приложения /// /// api_id приложения. /// api_hash приложения. @@ -33,48 +32,47 @@ internal sealed class FakeSessionClient : ISessionClient } /// - /// Сколько раз вызван ConnectAsync (проверка попыток переподключения в тестах). + /// Сколько раз вызван ConnectAsync /// public int ConnectAttempts { get; private set; } /// - /// Непустой — ConnectAsync ждёт завершения этой задачи (эмуляция «зависшего» connect; - /// отмена токена прерывает ожидание). null — мгновенный успех. + /// Непустой — ConnectAsync ждёт завершения этой задачи /// public TaskCompletionSource? ConnectGate { get; set; } /// - /// True — первый URL QR не выдаётся сразу (эмуляция ожидания QR до сканирования/отмены). + /// True — первый URL QR не выдаётся сразу /// public bool DelayFirstQrUrl { get; set; } /// - /// True — фоновый QR-вход (StartQrAsync) остановлен отменой до сканирования. + /// True — фоновый QR-вход /// public bool QrLoginCancelled { get; private set; } /// - /// Маркерные байты сессии (детект сохранения файла в тестах). + /// Маркерные байты сессии /// public static byte[] SessionMarker { get; } = [1, 3, 3, 7, 42]; /// - /// Исключение, которое бросит RequestCodeAsync (null — успех). + /// Исключение, которое бросит RequestCodeAsync /// public Exception? RequestCodeError { get; set; } /// - /// Исключение SubmitCodeAsync (null — успех). + /// Исключение SubmitCodeAsync /// public Exception? CodeError { get; set; } /// - /// Исключение SubmitPasswordAsync (null — успех). + /// Исключение SubmitPasswordAsync /// public Exception? PasswordError { get; set; } /// - /// Результат SubmitCodeAsync: "password" — нужен 2FA; null — авторизация завершена. + /// Результат SubmitCodeAsync /// public string? CodeResult { get; set; } @@ -89,17 +87,17 @@ internal sealed class FakeSessionClient : ISessionClient public bool LoggedOut { get; private set; } /// - /// Запрошенные номера (RequestCodeAsync). + /// Запрошенные номера /// public List RequestedPhones { get; } = []; /// - /// Отправленные коды (SubmitCodeAsync). + /// Отправленные коды /// public List SubmittedCodes { get; } = []; /// - /// Отправленные пароли (SubmitPasswordAsync). + /// Отправленные пароли /// public List SubmittedPasswords { get; } = []; @@ -109,7 +107,7 @@ internal sealed class FakeSessionClient : ISessionClient public bool QrStarted { get; private set; } /// - /// Завершает «сканирование» QR — авторизует фейк (как подтверждение на телефоне). + /// Завершает «сканирование» QR — авторизует фейк /// public void CompleteQrScan() => _qrScanTcs.TrySetResult(true); @@ -229,12 +227,12 @@ internal sealed class FakeSessionClient : ISessionClient public event Func? MessageReceived; /// - /// Список диалогов, который фейк возвращает из GetDialogsAsync (по умолчанию пуст). + /// Список диалогов, который фейк возвращает из GetDialogsAsync /// public List Dialogs { get; } = []; /// - /// Сообщения диалогов (ключ — подписанный id), которые возвращает GetMessagesAsync. + /// Сообщения диалогов /// public Dictionary> Messages { get; } = new(StringComparer.Ordinal); @@ -244,22 +242,22 @@ internal sealed class FakeSessionClient : ISessionClient public int DialogListCallCount { get; private set; } /// - /// Диалоги, которые GetMessagesAsync/MarkReadAsync получали (в порядке вызовов). + /// Диалоги, которые GetMessagesAsync/MarkReadAsync получали /// public List ReadDialogs { get; } = []; /// - /// Диалоги, помеченные прочитанными (MarkReadAsync), в порядке вызовов. + /// Диалоги, помеченные прочитанными /// public List MarkedReadDialogs { get; } = []; /// - /// Исключение GetDialogsAsync (null — успех). + /// Исключение GetDialogsAsync /// public Exception? DialogsError { get; set; } /// - /// Исключение GetMessagesAsync (null — успех). + /// Исключение GetMessagesAsync /// public Exception? MessagesError { get; set; } @@ -300,25 +298,24 @@ internal sealed class FakeSessionClient : ISessionClient return Task.CompletedTask; } - // --- Discovery (план Task 11): данные/ошибки операций задаёт тест, сети нет --- /// - /// Результат поиска, который фейк возвращает из SearchAsync (chats затем users). + /// Результат поиска, который фейк возвращает из SearchAsync /// public List SearchResults { get; } = []; /// - /// Поисковые запросы (query/limit), в порядке вызовов. + /// Поисковые запросы /// public List<(string Query, int Limit)> SearchQueries { get; } = []; /// - /// Исключение SearchAsync (null — успех). + /// Исключение SearchAsync /// public Exception? SearchError { get; set; } /// - /// Инфо источников (ключ — подписанный id), возвращаемое GetInfoAsync. + /// Инфо источников /// public Dictionary SourceInfos { get; } = new(StringComparer.Ordinal); @@ -328,7 +325,7 @@ internal sealed class FakeSessionClient : ISessionClient public List InfoRequests { get; } = []; /// - /// Результаты чтения выборки (ключ — подписанный id), возвращаемые ReadForEvalAsync. + /// Результаты чтения выборки /// public Dictionary ReadForEvalResults { get; } = new(StringComparer.Ordinal); @@ -338,12 +335,12 @@ internal sealed class FakeSessionClient : ISessionClient public List ReadRequests { get; } = []; /// - /// Username вступлений (после нормализации), в порядке вызовов. + /// Username вступлений /// public List JoinedUsernames { get; } = []; /// - /// Исключение JoinAsync (null — успех; flood эмулируется SessionException RESOURCE_EXHAUSTED). + /// Исключение JoinAsync /// public Exception? JoinError { get; set; } @@ -353,7 +350,7 @@ internal sealed class FakeSessionClient : ISessionClient public List LeaveCalls { get; } = []; /// - /// Исключение LeaveAsync (null — успех). + /// Исключение LeaveAsync /// public Exception? LeaveError { get; set; } @@ -420,7 +417,7 @@ internal sealed class FakeSessionClient : ISessionClient } /// - /// Поднимает событие нового сообщения (эмуляция realtime-события Telegram). + /// Поднимает событие нового сообщения /// /// Входящее сообщение диалога. public async Task RaiseMessageAsync(TelegramMessage message) @@ -450,12 +447,12 @@ internal sealed class FakeSessionClient : ISessionClient internal sealed class FakeClientFactory : ITelegramClientFactory { /// - /// Все созданные фабрикой клиенты (в порядке создания). + /// Все созданные фабрикой клиенты /// public List CreatedClients { get; } = []; /// - /// Колбэк настройки клиента перед возвратом (null — клиент по умолчанию). + /// Колбэк настройки клиента перед возвратом /// public Action? OnClientCreated { get; set; } diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/FarmHarness.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/FarmHarness.cs index 15660e5..464c9a7 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/FarmHarness.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/FarmHarness.cs @@ -33,7 +33,7 @@ internal sealed class FarmHarness : IAsyncDisposable public SessionStore Store { get; } /// - /// Фейковая фабрика клиентов (CreatedClients — ready-клиент тенанта). + /// Фейковая фабрика клиентов /// public FakeClientFactory Factory { get; } @@ -43,12 +43,12 @@ internal sealed class FarmHarness : IAsyncDisposable public SessionFarm Farm { get; } /// - /// Ready-фейк-клиент тенанта (настройка диалогов/сообщений тестом). + /// Ready-фейк-клиент тенанта /// public FakeSessionClient Client => Factory.CreatedClients.Single(); /// - /// Создаёт пул с ready-сессией тенанта (телефонный вход без сети). + /// Создаёт пул с ready-сессией тенанта /// /// Id тенанта. public static async Task CreateWithReadySessionAsync(string tenantId) @@ -68,7 +68,7 @@ internal sealed class FarmHarness : IAsyncDisposable } /// - /// Останавливает пул (сохранение/освобождение сессий) и удаляет temp-каталог. + /// Останавливает пул /// public async ValueTask DisposeAsync() { diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/LruCacheTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/LruCacheTests.cs index d9ff345..d629f5a 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/LruCacheTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/LruCacheTests.cs @@ -3,8 +3,7 @@ using Deal.Telegram.Caching; namespace Deal.Telegram.Tests.Telegram; /// -/// Unit-тесты LRU-кэша (этап 12, пакет C): вытеснение least-recently-used при переполнении, -/// обновление недавности при чтении/записи и проверка наличия без вытеснения. +/// Unit-тесты LRU-кэша /// public sealed class LruCacheTests { @@ -56,7 +55,7 @@ public sealed class LruCacheTests } /// - /// Промах возвращает false и значение по умолчанию (не бросает). + /// Промах возвращает false и значение по умолчанию /// [Fact] public void TryGetValue_MissingKey_ReturnsFalseAndDefault() @@ -69,7 +68,7 @@ public sealed class LruCacheTests } /// - /// ContainsKey не вытесняет: проверка «уже есть» не спасает старый элемент от вытеснения. + /// ContainsKey не вытесняет /// [Fact] public void ContainsKey_DoesNotRefreshRecency() @@ -87,7 +86,7 @@ public sealed class LruCacheTests } /// - /// Нулевая/отрицательная ёмкость — ошибка конфигурации кэша (fail-fast, без магических значений). + /// Нулевая/отрицательная ёмкость — ошибка конфигурации кэша /// [Theory] [InlineData(0)] diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/RealtimeSweepTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/RealtimeSweepTests.cs index abe011a..5f27439 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/RealtimeSweepTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/RealtimeSweepTests.cs @@ -8,9 +8,7 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Deal.Telegram.Tests.Telegram; /// -/// Unit-тесты догона realtime (план Task 10 Acceptance: фильтр мониторинга; RealtimeSweep L392–456). -/// Цикл тенанта: список диалогов → SyncDialogs (ядро отвечает monitored) → по мониторящимся диалогам -/// с unread_count > 0 PushMessage от старых к новым → read-ack. Сбой синка — зеркало прежнее. +/// Unit-тесты догона realtime. /// public sealed class RealtimeSweepTests { @@ -60,7 +58,7 @@ public sealed class RealtimeSweepTests } /// - /// Диалог с unread=0 или не мониторящийся не трогается (нет чтения и push). + /// Диалог с unread=0 или не мониторящийся не трогается /// [Fact] public async Task Sweep_SkipsReadAndUnmonitoredDialogs() @@ -88,7 +86,7 @@ public sealed class RealtimeSweepTests } /// - /// Сбой SyncDialogs не роняет цикл: зеркало прежнее, догон в следующий цикл. + /// Сбой SyncDialogs не роняет цикл /// [Fact] public async Task Sweep_SyncFails_KeepsOldMirror_AndSkipsPushes() @@ -118,7 +116,7 @@ public sealed class RealtimeSweepTests } /// - /// Пустой список диалогов (не ready/нет каталога) — no-op без сетевых вызовов ядра. + /// Пустой список диалогов /// [Fact] public async Task Sweep_NoDialogs_NoSyncCalls() diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/SessionHarness.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/SessionHarness.cs index 09620b7..78828b6 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/SessionHarness.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/SessionHarness.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.Logging.Abstractions; namespace Deal.Telegram.Tests.Telegram; -// Харнесс ready-сессии для unit-тестов каталога (план Task 10): реальное хранилище в temp-каталоге, // фейковая фабрика клиентов и сессия, возобновлённая из «сохранённой авторизованной сессии» // (TryResumeAsync → фаза ready, без сети и QR-флоу). Фейк-клиент после создания настраивается тестом. internal sealed class SessionHarness : IAsyncDisposable @@ -41,7 +40,7 @@ internal sealed class SessionHarness : IAsyncDisposable public SessionStore Store { get; } /// - /// Фейковая фабрика клиентов (CreatedClients — созданный ready-клиент). + /// Фейковая фабрика клиентов /// public FakeClientFactory Factory { get; } @@ -51,12 +50,12 @@ internal sealed class SessionHarness : IAsyncDisposable public TenantSession Session { get; } /// - /// Единственный фейк-клиент сессии (настройка диалогов/сообщений тестом). + /// Единственный фейк-клиент сессии /// public FakeSessionClient Client => Factory.CreatedClients.Single(); /// - /// Создаёт ready-сессию (auto_resume сохранённой авторизованной сессии). + /// Создаёт ready-сессию /// /// Id тенанта. public static async Task CreateReadyAsync(string tenantId) diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/TestSessionFactory.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/TestSessionFactory.cs index f22ffa1..c420aeb 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/TestSessionFactory.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/TestSessionFactory.cs @@ -8,7 +8,7 @@ namespace Deal.Telegram.Tests.Telegram; internal static class TestSessionFactory { /// - /// Тестовый ключ AES-256 (байты 1..32). + /// Тестовый ключ AES-256 /// public static byte[] TestKey { get; } = Enumerable.Range(1, 32).Select(index => (byte)index).ToArray(); @@ -35,7 +35,7 @@ internal static class TestSessionFactory => new(Options(sessionsDirectory, key)); /// - /// Реальное файловое хранилище над temp-каталогом (NullLogger). + /// Реальное файловое хранилище над temp-каталогом /// /// Каталог сессий. /// Ключ шифрования. @@ -43,7 +43,7 @@ internal static class TestSessionFactory => new(Options(sessionsDirectory, key), Cipher(sessionsDirectory, key), NullLogger.Instance); /// - /// Удаляет temp-каталог после сценария (ошибки удаления игнорируются). + /// Удаляет temp-каталог после сценария /// /// Каталог сессий. public static void Cleanup(string sessionsDirectory) diff --git a/src/telegram-service/Deal.Telegram.Tests/Telegram/TlMessageMapperTests.cs b/src/telegram-service/Deal.Telegram.Tests/Telegram/TlMessageMapperTests.cs index fa17054..6438d2f 100644 --- a/src/telegram-service/Deal.Telegram.Tests/Telegram/TlMessageMapperTests.cs +++ b/src/telegram-service/Deal.Telegram.Tests/Telegram/TlMessageMapperTests.cs @@ -4,11 +4,7 @@ using TL; namespace Deal.Telegram.Tests.Telegram; /// -/// Unit-тесты веток realtime-разбора на фейковых TL-объектах (без сети; план Task 10, ревью-фикс: -/// каналы/супергруппы идут UpdateNewChannelMessage, короткие — UpdateShortMessage/UpdateShortChatMessage). -/// Библиотека нормализует все варианты в UpdateNewMessage (UpdateNewChannelMessage — его подкласс, -/// короткие синтезируются списком UpdateList), поэтому юниты гоняют классификатор NewMessageFrom и -/// маппер TlMessageMapper на настоящих TL-типах. Живая проверка каналов — Manual (см. отчёт). +/// Unit-тесты веток realtime-разбора на фейковых TL-объектах. /// public sealed class TlMessageMapperTests { @@ -17,7 +13,7 @@ public sealed class TlMessageMapperTests private const long UserId = 333; /// - /// UpdateNewMessage (обычный чат) — извлекается сообщение. + /// UpdateNewMessage /// [Fact] public void NewMessageFrom_UpdateNewMessage_ReturnsMessage() @@ -29,7 +25,7 @@ public sealed class TlMessageMapperTests } /// - /// UpdateNewChannelMessage (каналы/супергруппы) — извлекается (подкласс UpdateNewMessage). + /// UpdateNewChannelMessage /// [Fact] public void NewMessageFrom_UpdateNewChannelMessage_ReturnsMessage() @@ -41,7 +37,7 @@ public sealed class TlMessageMapperTests } /// - /// Обновления «не нового сообщения» (edit/delete/…) — null (игнор). + /// Обновления «не нового сообщения» /// [Fact] public void NewMessageFrom_OtherUpdates_ReturnsNull() @@ -81,7 +77,7 @@ public sealed class TlMessageMapperTests } /// - /// Короткий групповой UpdateShortChatMessage — синтез UpdateNewMessage в чат (базовая группа). + /// Короткий групповой UpdateShortChatMessage — синтез UpdateNewMessage в чат /// [Fact] public void NewMessageFrom_UpdateShortChatMessage_SynthesizesIncomingMessage() @@ -108,7 +104,7 @@ public sealed class TlMessageMapperTests } /// - /// Маппинг сообщения канала: подписанный id «-100…», имя/username из сущности, текст. + /// Маппинг сообщения канала /// [Fact] public void ToMessage_ChannelMessage_MapsSignedIdAndChannelFields() @@ -128,7 +124,7 @@ public sealed class TlMessageMapperTests } /// - /// Исходящее (out_) сообщение не поднимается как входящее (incoming-семантика python). + /// Исходящее (out_) сообщение не поднимается как входящее. /// [Fact] public void ToMessage_OutgoingMessage_ReturnsNull() @@ -139,7 +135,7 @@ public sealed class TlMessageMapperTests } /// - /// Пустой/служебный текст не отдаётся в downstream (как python `if not text: continue`). + /// Пустой/служебный текст не отдаётся в downstream. /// [Fact] public void ToMessage_EmptyOrServiceMessage_ReturnsNull() @@ -149,7 +145,7 @@ public sealed class TlMessageMapperTests } /// - /// Сообщение личного чата: подписанный id «+…» и имя пользователя. + /// Сообщение личного чата /// [Fact] public void ToMessage_PrivateMessage_MapsUserDialog() diff --git a/src/telegram-service/Deal.Telegram/Caching/LruCache.cs b/src/telegram-service/Deal.Telegram/Caching/LruCache.cs index 9aec518..bd48544 100644 --- a/src/telegram-service/Deal.Telegram/Caching/LruCache.cs +++ b/src/telegram-service/Deal.Telegram/Caching/LruCache.cs @@ -3,17 +3,10 @@ using System.Diagnostics.CodeAnalysis; namespace Deal.Telegram.Caching; /// -/// Кэш с ограниченной ёмкостью и вытеснением least-recently-used (LRU): при переполнении удаляется -/// элемент, к которому дольше всего не обращались. Нужен там, где раньше жил неограниченный -/// и память росла с числом сущностей (долгоживущий процесс сервиса). +/// Кэш с ограниченной ёмкостью и вытеснением least-recently-used /// -/// -/// НЕ потокобезопасен: вызывающий обязан сериализовать доступ (в telegram-service кэши -/// WTelegramSessionClient защищены общим _entityCacheGate). Вытеснение — забота производительности, -/// а не корректности: потерянное значение всегда можно получить повторным запросом к Telegram. -/// -/// Тип ключа (ссылочный или значимый). -/// Тип значения. +/// Тип ключа (ссылочный или значимый). +/// Тип значения. public sealed class LruCache where TKey : notnull { @@ -66,15 +59,14 @@ public sealed class LruCache } /// - /// Проверяет наличие ключа, не меняя недавность (лёгкая проверка без вытеснения). + /// Проверяет наличие ключа, не меняя недавность /// /// Ключ. /// True — ключ есть в кэше. public bool ContainsKey(TKey key) => _map.ContainsKey(key); /// - /// Записывает значение по ключу (вставка или обновление) и вытесняет самый давний элемент при - /// переполнении. Обновление существующего ключа делает его самым недавним. + /// Записывает значение по ключу /// /// Ключ. /// Значение. diff --git a/src/telegram-service/Deal.Telegram/Core/CoreIngressClient.cs b/src/telegram-service/Deal.Telegram/Core/CoreIngressClient.cs index c5b5f76..8f15a3a 100644 --- a/src/telegram-service/Deal.Telegram/Core/CoreIngressClient.cs +++ b/src/telegram-service/Deal.Telegram/Core/CoreIngressClient.cs @@ -10,22 +10,17 @@ using Grpc.Net.Client; namespace Deal.Telegram.Core; /// -/// Исходящий gRPC-канал в ядро: клиент Deal.Grpc.Telegram.IngressService (план Task 10, Core/ -/// CoreIngressClient.cs; Ruling 1/7). Каждый RPC несёт metadata tenant-id + service-token (Ruling 1); -/// принадлежность сообщений тенанту — только по metadata (ядро не доверяет полю). Сбой связи → -/// UNAVAILABLE «Ядро недоступно…» с логом: буфер недоставленных -/// сообщений не ведётся — неподтверждённые сообщения не помечаются прочитанными и догоняются -/// realtime_sweep (упущенное после рестарта/разрыва, Ruling 7/план Task 10). +/// Исходящий gRPC-канал в ядро /// public sealed class CoreIngressClient : ICoreIngressClient { /// - /// Ключ gRPC-metadata с id тенанта (зеркало интерцепторов, Ruling 1). + /// Ключ gRPC-metadata с id тенанта. /// public const string TenantIdMetadataKey = "tenant-id"; /// - /// Ключ gRPC-metadata с service-token (зеркало интерцепторов, Ruling 1). + /// Ключ gRPC-metadata с service-token. /// public const string ServiceTokenMetadataKey = "service-token"; @@ -41,7 +36,7 @@ public sealed class CoreIngressClient : ICoreIngressClient /// /// Конфигурация (адрес из env, service-token). /// Логгер. - /// Сертификаты mTLS (Ruling 6, Task 13): null — plaintext-канал (dev). + /// Сертификаты mTLS: null — plaintext-канал (dev). public CoreIngressClient( CoreIngressOptions options, ILogger logger, @@ -96,8 +91,6 @@ public sealed class CoreIngressClient : ICoreIngressClient { if (_client is null) { - // mTLS (Ruling 6, Task 13): при включённом флаге канал подписывает запрос клиентским - // сертификатом и проверяет CA ядра; dev — plaintext-канал (Ruling 2 этапа 6). if (_mtlsCertificates is not null) { _channel = GrpcChannel.ForAddress( @@ -116,7 +109,6 @@ public sealed class CoreIngressClient : ICoreIngressClient } } - // Metadata вызова (tenant-id + service-token) и deadline (Ruling 1). // tenantId: Id тенанта. private CallOptions CallOptions(string tenantId) { @@ -129,7 +121,6 @@ public sealed class CoreIngressClient : ICoreIngressClient } // Переводит сбой вызова в SessionException (UNAVAILABLE) со структурированным логом. - // action: Действие (PushMessage/SyncDialogs) для лога аудита (Ruling 13). // exception: Исключение вызова. private SessionException Fail(string action, Exception exception) { diff --git a/src/telegram-service/Deal.Telegram/Core/CoreIngressOptions.cs b/src/telegram-service/Deal.Telegram/Core/CoreIngressOptions.cs index 7ffe164..b7da7a4 100644 --- a/src/telegram-service/Deal.Telegram/Core/CoreIngressOptions.cs +++ b/src/telegram-service/Deal.Telegram/Core/CoreIngressOptions.cs @@ -1,37 +1,32 @@ namespace Deal.Telegram.Core; /// -/// Конфигурация исходящего канала в ядро (план Task 10; Ruling 2/12/13: только env). -/// -/// Адрес gRPC-ингресса ядра — env `SERVICES__CORE__INGRESS` (Ruling 12: в dev/compose -/// http://host.docker.internal:5082; на хосте — http://localhost:5082); service-token — тот же общий -/// env `DEAL_SERVICE_TOKEN`, что проверяет интерцептор ядра (Ruling 1: каждый RPC несёт tenant-id и -/// service-token). Ключи/токены только env — не читаются из appsettings (Ruling 13). +/// Конфигурация исходящего канала в ядро. /// public sealed class CoreIngressOptions { /// - /// Env-ключ адреса gRPC-ингресса ядра (Ruling 12). + /// Env-ключ адреса gRPC-ингресса ядра. /// public const string IngressEndpointEnvVarName = "SERVICES__CORE__INGRESS"; /// - /// Ключ конфигурации адреса ингресса (env `__` → `:` провайдером env). + /// Ключ конфигурации адреса ингресса /// public const string IngressEndpointConfigKey = "Services:Core:Ingress"; /// - /// Env-ключ service-token (общий токен сервисов, Ruling 12). + /// Env-ключ service-token. /// public const string ServiceTokenEnvVarName = "DEAL_SERVICE_TOKEN"; /// - /// Адрес ингресса ядра по умолчанию (core dev на хосте, Ruling 12). + /// Адрес ингресса ядра по умолчанию. /// public const string DefaultIngressEndpoint = "http://localhost:5082"; /// - /// Таймаут одного RPC в ядро (сек). + /// Таймаут одного RPC в ядро /// public const int RpcTimeoutSeconds = 15; @@ -42,17 +37,17 @@ public sealed class CoreIngressOptions } /// - /// Адрес gRPC-ингресса ядра (например, http://localhost:5082). + /// Адрес gRPC-ингресса ядра /// public string IngressEndpoint { get; } /// - /// Service-token для metadata каждого RPC (может быть пустым — ядро откажет). + /// Service-token для metadata каждого RPC /// public string ServiceToken { get; } /// - /// Создаёт опции с явным адресом и токеном (unit-тесты канала). + /// Создаёт опции с явным адресом и токеном /// /// Адрес ингресса ядра. /// Service-token (пустой — вызовы будут отвергнуты ядром). @@ -67,8 +62,7 @@ public sealed class CoreIngressOptions } /// - /// Читает конфигурацию из env/конфигурации хоста. Адрес не задан — значение по умолчанию - /// (localhost-ядро dev); токен — как в env (может быть пуст). + /// Читает конфигурацию из env/конфигурации хоста. /// /// Конфигурация хоста (env-провайдер WebApplicationBuilder). /// Опции исходящего канала в ядро. diff --git a/src/telegram-service/Deal.Telegram/Core/ICoreIngressClient.cs b/src/telegram-service/Deal.Telegram/Core/ICoreIngressClient.cs index 66f76db..76207a4 100644 --- a/src/telegram-service/Deal.Telegram/Core/ICoreIngressClient.cs +++ b/src/telegram-service/Deal.Telegram/Core/ICoreIngressClient.cs @@ -3,21 +3,14 @@ using Deal.Grpc.Telegram; namespace Deal.Telegram.Core; /// -/// Исходящий канал в ядро (IngressService telegram.proto; план Task 10 Core/CoreIngressClient.cs, Ruling 7). -/// -/// telegram-service — клиент Ingress ядра (:5082, `SERVICES__CORE__INGRESS`): сырые сообщения -/// мониторящихся диалогов (PushMessage), синхронизация каталога с ответом monitored-набора -/// (SyncDialogs). Интерфейс — seam: реальная реализация ходит по gRPC, тесты подставляют фейк/в-proc -/// сервер ингресса (план: «PushMessage-клиент к in-proc fake-серверу ингресса»). +/// Исходящий канал в ядро. /// public interface ICoreIngressClient { /// - /// Отправляет сообщение диалога в ядро (Ingress.PushMessage → очередь пайплайна, Ruling 7). - /// Сбой связи — (UNAVAILABLE «Ядро недоступно…»); - /// недоставленное сообщение не помечается прочитанным и догоняется realtime_sweep. + /// Отправляет сообщение диалога в ядро. /// - /// Id тенанта (metadata tenant-id, Ruling 1). + /// Id тенанта. /// Сообщение в контракте PushMessageRequest. /// Отмена вызова. /// Ответ ядра (accepted/duplicate — дубль dialog+msgId в очереди не растёт). @@ -27,11 +20,9 @@ public interface ICoreIngressClient CancellationToken cancellationToken); /// - /// Синхронизирует каталог с ядром (Ingress.SyncDialogs → SyncFromTelegram, Ruling 7). Ядро применяет - /// entries (авто-мониторинг по autoMonitorNew, обновление, удаление отсутствующих) и отвечает - /// актуальным списком monitored id — зеркало сервиса обновляется ответом. + /// Синхронизирует каталог с ядром. /// - /// Id тенанта (metadata tenant-id, Ruling 1). + /// Id тенанта. /// Актуальный каталог диалогов (как refresh_dialogs). /// Отмена вызова. /// Список id диалогов с включённым мониторингом (по версии ядра). diff --git a/src/telegram-service/Deal.Telegram/Dialogs/BackfillService.cs b/src/telegram-service/Deal.Telegram/Dialogs/BackfillService.cs index a33fc2f..3ee111c 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/BackfillService.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/BackfillService.cs @@ -7,42 +7,32 @@ using Deal.Telegram.Telegram; namespace Deal.Telegram.Dialogs; /// -/// Backfill диалога: перечитывание последних ~10 сообщений и отправка их в ядро потоком PushMessage -/// (план Task 10, Dialogs/BackfillService.cs; прототип backfill_dialog/_backfill_dialogs L331–390). -/// -/// 1:1 с прототипом: сообщения читаются от старых к новым (reversed — как реальный поток), между -/// отправками — анти-бан-пауза 1.5–3 с/сообщение (Ruling 3, BACKFILL_PER_MESSAGE L35); между -/// диалогами одного тенанта — пауза 3–6 с (BACKFILL_PER_DIALOG L36, python sleep между диалогами -/// L347). В конце — read-ack (снять «новое» в Telegram). Ошибка середины потока прерывает backfill -/// без read-ack: сообщения, не дошедшие до ядра, останутся непрочитанными и будут догнаны -/// realtime_sweep; дубли уже доставленных в ядро не растут (дубль-гвард dialog+msgId, Ruling 7). -/// Параметр force («Перечитать» по кнопке) прототип использует против своего флага backfilled; -/// в разделении флаг живёт в БД ядра, поэтому RPC исполняется всегда — дубли гасит ядро. +/// Backfill диалога /// public sealed class BackfillService { /// - /// Сколько последних сообщений читает backfill (прототип: limit=10, L371). + /// Сколько последних сообщений читает backfill. /// public const int MessagesLimit = 10; /// - /// Нижняя граница анти-бан-паузы между сообщениями (BACKFILL_PER_MESSAGE, L35). + /// Нижняя граница анти-бан-паузы между сообщениями. /// public const double MinPerMessageDelaySeconds = 1.5; /// - /// Верхняя граница анти-бан-паузы между сообщениями (BACKFILL_PER_MESSAGE, L35). + /// Верхняя граница анти-бан-паузы между сообщениями. /// public const double MaxPerMessageDelaySeconds = 3.0; /// - /// Нижняя граница паузы между диалогами одного тенанта (BACKFILL_PER_DIALOG, L36). + /// Нижняя граница паузы между диалогами одного тенанта. /// public const double MinPerDialogDelaySeconds = 3.0; /// - /// Верхняя граница паузы между диалогами одного тенанта (BACKFILL_PER_DIALOG, L36). + /// Верхняя граница паузы между диалогами одного тенанта. /// public const double MaxPerDialogDelaySeconds = 6.0; @@ -51,10 +41,8 @@ public sealed class BackfillService private readonly IBackfillPacer _pacer; private readonly ILogger _logger; - // Гард повторного входа: (тенант, диалог) уже перечитывается (python `_backfilling` L99). private readonly ConcurrentDictionary<(string TenantId, string DialogId), byte> _running = new(); - // Сериализация backfill'ов одного тенанта (python: один asyncio-loop на все диалоги). private readonly ConcurrentDictionary _tenantGates = new(StringComparer.Ordinal); // Момент завершения последнего backfill тенанта (пауза 3–6 с между диалогами). @@ -82,7 +70,7 @@ public sealed class BackfillService } /// - /// Перечитывает последние сообщения диалога в ядро (Backfill RPC; кнопка «Перечитать»). + /// Перечитывает последние сообщения диалога в ядро /// /// Id тенанта. /// Подписанный id диалога. @@ -98,7 +86,6 @@ public sealed class BackfillService var key = (tenantId, dialogId); if (!_running.TryAdd(key, 0)) { - // Прототип L355–356: повторный вход в уже перечитываемый диалог → 0. _logger.LogInformation("Аудит: backfill {TenantId} {DialogId} → пропущен (уже перечитывается)", tenantId, dialogId); return 0; } @@ -135,7 +122,6 @@ public sealed class BackfillService } } - // Пауза 3–6 с между backfill'ами разных диалогов одного тенанта (python L347). // tenantId: Id тенанта. // cancellationToken: Отмена операции. private async Task EnsureDialogSpacingAsync(string tenantId, CancellationToken cancellationToken) @@ -184,7 +170,6 @@ public sealed class BackfillService int processed = 0; foreach (TelegramMessage message in messages.Reverse()) { - // От старых к новым — как реальный поток (прототип L372: `for m in reversed(msgs)`). PushMessageReply reply = await _ingress .PushMessageAsync(tenantId, DialogProtoMapper.ToPushRequest(message), cancellationToken) .ConfigureAwait(false); @@ -193,11 +178,9 @@ public sealed class BackfillService processed++; } - // Анти-бан между сообщениями (прототип L381; Ruling 3). await _pacer.WaitAsync(MinPerMessageDelaySeconds, MaxPerMessageDelaySeconds, cancellationToken).ConfigureAwait(false); } - // «Перечитали» — снимаем «новое» в Telegram (прототип L383–386). При ошибке выше read-ack // не выполняется: неотправленное останется непрочитанным и догонится realtime_sweep. await _sessionFarm.MarkReadAsync(tenantId, dialogId, cancellationToken).ConfigureAwait(false); return processed; diff --git a/src/telegram-service/Deal.Telegram/Dialogs/DialogCatalog.cs b/src/telegram-service/Deal.Telegram/Dialogs/DialogCatalog.cs index 1b6ed35..0766c12 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/DialogCatalog.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/DialogCatalog.cs @@ -3,15 +3,7 @@ using System.Collections.ObjectModel; namespace Deal.Telegram.Dialogs; /// -/// Зеркало каталога диалогов тенанта в памяти telegram-service (план Task 10, Dialogs/DialogCatalog.cs; -/// Ruling 7: «сервис держит зеркало мониторинга в памяти», ядро — владелец списка мониторинга в своей БД). -/// -/// Тенант хранит два набора id диалогов: полный каталог (из refresh_dialogs/realtime_sweep, L468–519) -/// и подмножество с включённым мониторингом. Мониторинг актуализируется тремя путями: -/// * командой SetMonitor/SetMonitorAll ядра (обновление одной записи / всех сразу); -/// * ответом Ingress.SyncDialogs (ядро применило entries с autoMonitorNew — L244–246 «_reload_monitored»); -/// * очисткой после Logout/отключения аккаунта (прототип L203: `_monitored.clear()`). -/// Все операции потокобезопасны; зеркало чисто в памяти — потерю при рестарте догоняет realtime_sweep. +/// Зеркало каталога диалогов тенанта в памяти telegram-service. /// public sealed class DialogCatalog { @@ -20,8 +12,7 @@ public sealed class DialogCatalog private readonly Dictionary> _monitoredByTenant = new(StringComparer.Ordinal); /// - /// Обновляет полный каталог диалогов тенанта (entries refresh/realtime_sweep). Мониторинг при этом - /// не трогается — актуальный monitored-набор приходит ответом SyncDialogs (или командами SetMonitor). + /// Обновляет полный каталог диалогов тенанта /// /// Id тенанта. /// Актуальные id каталога (entries списка диалогов). @@ -36,9 +27,7 @@ public sealed class DialogCatalog } /// - /// Включает/выключает мониторинг одного диалога (SetMonitor RPC, set_monitor L536–546). Команда - /// приходит от ядра после обновления его БД; зеркало повторяет решение без собственной проверки - /// «известности» — запись может появиться раньше каталога (включение после рестарта сервиса). + /// Включает/выключает мониторинг одного диалога. /// /// Id тенанта. /// Id диалога. @@ -66,8 +55,7 @@ public sealed class DialogCatalog } /// - /// Заменяет monitored-набор ответом SyncDialogs (актуальный список ядра после SyncFromTelegram — - /// авто-мониторинг новых/удаление отсутствующих, Ruling 7). Это авторитетный источник зеркала. + /// Заменяет monitored-набор ответом SyncDialogs. /// /// Id тенанта. /// Id диалогов с включённым мониторингом. @@ -82,9 +70,7 @@ public sealed class DialogCatalog } /// - /// Включает/выключает мониторинг всех диалогов каталога (SetMonitorAll RPC, set_monitor_all - /// L548–567). При включении учитываются и уже мониторящиеся записи (каталог может отставать от БД - /// ядра после рестарта — «монитор» из ответа SyncDialogs приедет следующим циклом realtime_sweep). + /// Включает/выключает мониторинг всех диалогов каталога. /// /// Id тенанта. /// Мониторить все (true) или снять мониторинг со всех (false). @@ -111,7 +97,7 @@ public sealed class DialogCatalog } /// - /// Проверяет, мониторится ли диалог (фильтр realtime-событий L262 и догона sweep L429). + /// Проверяет, мониторится ли диалог. /// /// Id тенанта. /// Id диалога. @@ -130,7 +116,7 @@ public sealed class DialogCatalog } /// - /// Сколько диалогов знает каталог тенанта (ответ monitor-all, count каталога). + /// Сколько диалогов знает каталог тенанта /// /// Id тенанта. /// Размер каталога; 0 — каталог ещё не синхронизирован (нет refresh/sweep). @@ -148,7 +134,7 @@ public sealed class DialogCatalog } /// - /// Возвращает копию мониторящихся id тенанта (для логов/тестов). + /// Возвращает копию мониторящихся id тенанта /// /// Id тенанта. /// Набор мониторящихся id (пустой, если тенанта нет). @@ -168,7 +154,7 @@ public sealed class DialogCatalog } /// - /// Сбрасывает состояние тенанта (Logout/отключение аккаунта — прототип L203). + /// Сбрасывает состояние тенанта. /// /// Id тенанта. public void Reset(string tenantId) @@ -185,7 +171,6 @@ public sealed class DialogCatalog } } - // Полный каталог тенанта (создаёт пустой при первом обращении). Вызывается под _gate. // tenantId: Id тенанта. private HashSet KnownSet(string tenantId) { @@ -198,7 +183,6 @@ public sealed class DialogCatalog return known; } - // Monitored-набор тенанта (создаёт пустой при первом обращении). Вызывается под _gate. // tenantId: Id тенанта. private HashSet MonitoredSet(string tenantId) { diff --git a/src/telegram-service/Deal.Telegram/Dialogs/DialogHue.cs b/src/telegram-service/Deal.Telegram/Dialogs/DialogHue.cs index 3f75be4..15a8219 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/DialogHue.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/DialogHue.cs @@ -3,13 +3,10 @@ using System.Text; namespace Deal.Telegram.Dialogs; /// -/// Детерминированный цвет диалога из палитры DIALOG_HUES (1:1 с dialog_hue python-прототипа -/// telegram.py L876–880 + backend/app/constants.py DIALOG_HUES; Ruling 7: «hue считает сервис»). -/// Палитра и хэш идентичны прототипу, чтобы цвета источников совпадали между реализациями. +/// Детерминированный цвет диалога из палитры DIALOG_HUES. /// public static class DialogHue { - // Палитра диалоговых цветов (hex) — 1:1 constants.py DIALOG_HUES (8 цветов). private static readonly string[] Palette = [ "#3b82f6", @@ -23,12 +20,10 @@ public static class DialogHue ]; /// - /// Цвет источника по id диалога и имени: хэш по кодовым точкам (dialog_id + name) по модулю длины - /// палитры — 1:1 dialog_hue прототипа (порядок символов и множитель 31 сохранены; EnumerateRunes - /// повторяет итерацию по Unicode-кодовым точкам python `for ch in ...`). + /// Цвет источника по id диалога и имени /// /// Подписанный id диалога. - /// Отображаемое имя (title/first_name); пустое — как в прототипе. + /// Отображаемое имя (title/first_name); пустое — как в. /// Цвет палитры, hex "#rrggbb". public static string Compute(string dialogId, string name) { diff --git a/src/telegram-service/Deal.Telegram/Dialogs/DialogProtoMapper.cs b/src/telegram-service/Deal.Telegram/Dialogs/DialogProtoMapper.cs index 014e39a..bd1d0b2 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/DialogProtoMapper.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/DialogProtoMapper.cs @@ -5,15 +5,12 @@ using Deal.Telegram.Telegram; namespace Deal.Telegram.Dialogs; /// -/// Маппер нейтральных результатов сессии в protobuf-контракт (telegram.proto; план Task 10). -/// -/// DialogEntry уходит в RefreshDialogsReply и SyncDialogsRequest (hue считает сервис по палитре — -/// Ruling 7); PushMessageRequest — в Ingress.PushMessage (канальные поля плоские, Ruling 7). +/// Маппер нейтральных результатов сессии в protobuf-контракт. /// public static class DialogProtoMapper { /// - /// Нейтральный диалог → DialogEntry контракта (id/name/username/kind/hue). + /// Нейтральный диалог → DialogEntry контракта /// /// Диалог из списка сессии. /// Запись каталога контракта с цветом палитры. @@ -28,7 +25,7 @@ public static class DialogProtoMapper }; /// - /// Нейтральное сообщение → PreviewMessage превью (id строкой, время epoch-ms). + /// Нейтральное сообщение → PreviewMessage превью /// /// Сообщение диалога (непустой текст). /// Сообщение превью ReadRecentReply (от новых к старым). @@ -41,7 +38,7 @@ public static class DialogProtoMapper }; /// - /// Нейтральное сообщение → PushMessageRequest ингресса (1:1 QueuedMessage, Ruling 7). + /// Нейтральное сообщение → PushMessageRequest ингресса. /// /// Сообщение диалога (непустой текст). /// Запрос Ingress.PushMessage с канальными полями и дубль-гвардом msg_id. diff --git a/src/telegram-service/Deal.Telegram/Dialogs/IBackfillPacer.cs b/src/telegram-service/Deal.Telegram/Dialogs/IBackfillPacer.cs index a79aaa3..8dcb404 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/IBackfillPacer.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/IBackfillPacer.cs @@ -1,17 +1,12 @@ namespace Deal.Telegram.Dialogs; /// -/// Seam анти-бан-пауз между сетевыми операциями каталога (Ruling 3: «Внутренний анти-бан сервиса -/// (паузы между сетевыми операциями одной сессии): backfill 1.5–3 с/сообщение и 3–6 с/диалог»). -/// -/// Реальная реализация () спит случайное время в диапазоне как -/// `random.uniform` прототипа (BACKFILL_PER_MESSAGE/BACKFILL_PER_DIALOG, telegram.py L35–36); -/// тесты подставляют фейк-пейсер, записывающий запрошенные диапазоны (fake clock, план Task 10). +/// Seam анти-бан-пауз между сетевыми операциями каталога /// public interface IBackfillPacer { /// - /// Ждёт случайное время в диапазоне [minSeconds, maxSeconds] (анти-бан). + /// Ждёт случайное время в диапазоне [minSeconds, maxSeconds] /// /// Нижняя граница паузы, секунды (≥ 0). /// Верхняя граница паузы, секунды (≥ minSeconds). diff --git a/src/telegram-service/Deal.Telegram/Dialogs/RandomBackfillPacer.cs b/src/telegram-service/Deal.Telegram/Dialogs/RandomBackfillPacer.cs index 5f23d87..614fc77 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/RandomBackfillPacer.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/RandomBackfillPacer.cs @@ -1,8 +1,7 @@ namespace Deal.Telegram.Dialogs; /// -/// Реальная реализация : случайная пауза в диапазоне (Random.Shared) — -/// эквивалент `await asyncio.sleep(random.uniform(min, max))` прототипа (telegram.py L381/L347). +/// Реальная реализация /// public sealed class RandomBackfillPacer : IBackfillPacer { diff --git a/src/telegram-service/Deal.Telegram/Dialogs/RealtimeListener.cs b/src/telegram-service/Deal.Telegram/Dialogs/RealtimeListener.cs index d54aad7..ee17863 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/RealtimeListener.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/RealtimeListener.cs @@ -6,13 +6,7 @@ using Deal.Telegram.Telegram; namespace Deal.Telegram.Dialogs; /// -/// Realtime-listener мониторящихся диалогов одного тенанта (план Task 10, Dialogs/RealtimeListener.cs; -/// прототип _on_message L255–283). Подписывается на события -/// (входящие текстовые сообщения аккаунта), фильтрует по зеркалу мониторинга -/// и отправляет сообщение в ядро Ingress.PushMessage; после успешной отправки — mark-as-read -/// (send_read_acknowledge, ТЗ: «сразу помечаются прочитанными», Ruling 3). -/// Упущенное при сбое/рестарте догоняет realtime_sweep (read-ack после сбоя не выполняется). -/// Жизненный цикл экземпляра — за (по одной сессии ready). +/// Realtime-listener мониторящихся диалогов одного тенанта. /// public sealed class RealtimeListener { @@ -25,7 +19,7 @@ public sealed class RealtimeListener /// Создаёт listener сессии. /// /// Ready-сессия тенанта (события сообщений её клиента). - /// Зеркало мониторинга (фильтр «мониторится ли диалог», Ruling 7). + /// Зеркало мониторинга. /// Канал в ядро (PushMessage). /// Логгер. public RealtimeListener( @@ -41,7 +35,7 @@ public sealed class RealtimeListener } /// - /// Подписывает listener на события сессии (вызывается при готовности сессии). + /// Подписывает listener на события сессии /// public void Start() { @@ -50,7 +44,7 @@ public sealed class RealtimeListener } /// - /// Отписывает listener (сессия ушла из ready/остановка хоста). + /// Отписывает listener /// public void Stop() { @@ -87,7 +81,6 @@ public sealed class RealtimeListener } catch (Exception exception) when (exception is not OperationCanceledException) { - // Без read-ack: непрочитанное сообщение подберёт realtime_sweep (Ruling 7/план Task 10). _logger.LogWarning( exception, "Realtime {TenantId} {DialogId} msg {MessageId}: не доставлено в ядро — догонит sweep", diff --git a/src/telegram-service/Deal.Telegram/Dialogs/RealtimeSweep.cs b/src/telegram-service/Deal.Telegram/Dialogs/RealtimeSweep.cs index 883a14a..e7d31fb 100644 --- a/src/telegram-service/Deal.Telegram/Dialogs/RealtimeSweep.cs +++ b/src/telegram-service/Deal.Telegram/Dialogs/RealtimeSweep.cs @@ -6,32 +6,27 @@ using Deal.Telegram.Telegram; namespace Deal.Telegram.Dialogs; /// -/// Страховочная догонялка realtime (план Task 10, Dialogs/RealtimeSweep.cs; прототип realtime_sweep -/// L392–456, цикл 30 с). Для каждой ready-сессии: список диалогов → синхронизация каталога с ядром -/// (SyncDialogs: ядро применяет entries с autoMonitorNew и отвечает актуальным monitored — зеркало -/// восстанавливается после рестарта сервиса) → по мониторящимся диалогам с unread_count > 0 читает -/// до 10 непрочитанных (от старых к новым), отправляет в ядро PushMessage и снимает «новое» -/// (read-ack). Потерянные realtime-события (рестарт/разрыв/сбой PushMessage) догоняются здесь. +/// Страховочная догонялка realtime. /// public sealed class RealtimeSweep { /// - /// Период цикла догона (сек; прототип вызывается планировщиком ~30 с). + /// Период цикла догона. /// public const int SweepPeriodSeconds = 30; /// - /// Верхняя граница списка диалогов за цикл (как refresh_dialogs L510: limit=500). + /// Верхняя граница списка диалогов за цикл. /// public const int DialogsLimit = 500; /// - /// Запас сообщений сверх unread_count при чтении (прототип L435: min(unread + 2, 10)). + /// Запас сообщений сверх unread_count при чтении /// public const int UnreadSlackMessages = 2; /// - /// Потолок сообщений одного диалога за цикл (прототип L435: 10). + /// Потолок сообщений одного диалога за цикл. /// public const int MaxMessagesPerDialog = 10; @@ -60,7 +55,7 @@ public sealed class RealtimeSweep } /// - /// Один цикл догона: все ready-сессии по очереди (сбой тенанта не останавливает остальных). + /// Один цикл догона /// /// Отмена цикла. public async Task SweepAllAsync(CancellationToken cancellationToken) @@ -105,7 +100,6 @@ public sealed class RealtimeSweep return; } - // Синхронизация каталога: зеркало мониторинга восстанавливается ответом ядра (L399–426). List dialogIds = dialogs.Select(dialog => dialog.Id).ToList(); _catalog.ReplaceKnown(tenantId, dialogIds); List entries = dialogs.Select(DialogProtoMapper.ToEntry).ToList(); @@ -136,7 +130,6 @@ public sealed class RealtimeSweep } } - // Догон одного диалога: чтение непрочитанных → PushMessage → read-ack (L427–456). // tenantId: Id тенанта. // dialog: Диалог с unread_count > 0. // cancellationToken: Отмена операции. @@ -173,7 +166,6 @@ public sealed class RealtimeSweep added); } - // Снимаем «новое» в Telegram (прототип L454). При ошибке выше — без read-ack: следующий // цикл увидит unread снова и дочитает то, что не ушло в ядро. await _sessionFarm.MarkReadAsync(tenantId, dialog.Id, cancellationToken).ConfigureAwait(false); } diff --git a/src/telegram-service/Deal.Telegram/Discovery/DiscoveryOps.cs b/src/telegram-service/Deal.Telegram/Discovery/DiscoveryOps.cs index a482b71..42642d0 100644 --- a/src/telegram-service/Deal.Telegram/Discovery/DiscoveryOps.cs +++ b/src/telegram-service/Deal.Telegram/Discovery/DiscoveryOps.cs @@ -6,35 +6,27 @@ using Grpc.Core; namespace Deal.Telegram.Discovery; /// -/// Discovery-операции telegram-service поверх пула сессий тенантов (план Task 11; 1:1 discovery_* методов -/// python-прототипа telegram.py L622–873). Каждая операция исполняется на сессии своего тенанта -/// (SessionFarm → TenantSession, Ruling 1): поиск (Search), инфо об источнике (GetInfo), чтение выборки -/// для оценки (ReadForEval), вступление (Join) и выход (Leave). -/// -/// Внутренний анти-бан сервиса (Ruling 3) — пауза 2–4 с после поискового запроса (ban_guard.search_pause -/// L78–80). Внешний анти-бан вступления (суточный лимит 50/тенант, паузы 50–70 с, flood-день, стоп-кран) — -/// владение core-воркера Discovery (Ruling 10): здесь Join — ручная операция вне квот (прототип L818–823), -/// FloodWait переводится в RESOURCE_EXHAUSTED (detail с префиксом "flood") — базовый флуд-гард сессии. +/// Discovery-операции telegram-service поверх пула сессий тенантов. /// public sealed class DiscoveryOps { /// - /// Верхняя граница поиска по умолчанию, если core не передал (прототип: 30, L624). + /// Верхняя граница поиска по умолчанию, если core не передал. /// public const int SearchDefaultLimit = 30; /// - /// Нижняя граница анти-бан-паузы после поиска (ban_guard.search_pause, 2 с). + /// Нижняя граница анти-бан-паузы после поиска /// public const double SearchPauseMinSeconds = 2.0; /// - /// Верхняя граница анти-бан-паузы после поиска (ban_guard.search_pause, 4 с). + /// Верхняя граница анти-бан-паузы после поиска /// public const double SearchPauseMaxSeconds = 4.0; /// - /// Размер выборки чтения по умолчанию, если core не передал (ReadForEvalRequest limit ≥ 1). + /// Размер выборки чтения по умолчанию, если core не передал /// public const int ReadForEvalDefaultLimit = 30; @@ -59,13 +51,11 @@ public sealed class DiscoveryOps } /// - /// Глобальный поиск источников по ключу (discovery_search L624–664). Выполняет поиск на сессии тенанта, - /// затем держит анти-бан-паузу 2–4 с (Ruling 3) и возвращает результат без дублей и не длиннее лимита. - /// Личные чаты/боты (kind=chat) не отсеиваются — это делает ядро (Ruling 10). + /// Глобальный поиск источников по ключу. /// /// Id тенанта. /// Поисковый запрос (ключ задачи discovery). - /// Верхняя граница результата (≤ 0 — прототип-дефолт 30). + /// Верхняя граница результата. /// Отмена операции. /// Найденные источники (каналы/группы/личные) без дублей, не длиннее limit. public async Task> SearchAsync( @@ -79,13 +69,12 @@ public sealed class DiscoveryOps .SearchAsync(tenantId, query, effectiveLimit, cancellationToken) .ConfigureAwait(false); - // Пауза между поисковыми запросами (анти-бан; Ruling 3, ban_guard.search_pause L78–80). await _pacer.WaitAsync(SearchPauseMinSeconds, SearchPauseMaxSeconds, cancellationToken).ConfigureAwait(false); return DedupeAndCap(found, effectiveLimit); } /// - /// Инфо об источнике для оценки кандидата (discovery_info L666–716). + /// Инфо об источнике для оценки кандидата. /// /// Id тенанта. /// Подписанный id источника («-100…»/«-…»/«+…»). @@ -98,7 +87,7 @@ public sealed class DiscoveryOps => _sessionFarm.GetInfoAsync(tenantId, dialogId, cancellationToken); /// - /// Выборка последних сообщений источника для оценки (discovery_read L718–800). + /// Выборка последних сообщений источника для оценки. /// /// Id тенанта. /// Подписанный id источника. @@ -113,9 +102,7 @@ public sealed class DiscoveryOps => _sessionFarm.ReadForEvalAsync(tenantId, dialogId, limit > 0 ? limit : ReadForEvalDefaultLimit, cancellationToken); /// - /// Вступить в канал/группу по username (discovery_join L818–839; ручной join из API — вне квот, паузу - /// перед авто-join делает воркер ядра, Ruling 10). Пустой username → INVALID_ARGUMENT; FloodWait → - /// SessionException RESOURCE_EXHAUSTED с префиксом "flood" (флуд-гард, контракт telegram.proto). + /// Вступить в канал/группу по username. /// /// Id тенанта. /// Username источника («@»/пробелы нормализуются). @@ -136,7 +123,7 @@ public sealed class DiscoveryOps } /// - /// Выйти из канала/группы (discovery_leave L841–848). + /// Выйти из канала/группы. /// /// Id тенанта. /// Подписанный id диалога. @@ -148,14 +135,13 @@ public sealed class DiscoveryOps => _sessionFarm.LeaveAsync(tenantId, dialogId, cancellationToken); /// - /// Нормализует username вступления 1:1 прототипа L826 (strip + lstrip "@"): срезает пробелы и ведущие «@». + /// Нормализует username вступления /// /// Username из запроса (может быть пуст/null). /// Нормализованный username (пустой — вступать не по чему). public static string NormalizeUsername(string? username) => (username ?? string.Empty).Trim().TrimStart('@'); - // Убирает дубли id и обрезает результат до лимита, сохраняя порядок (1:1 L643–663). // found: Результат поиска сессии (chats затем users). // limit: Верхняя граница числа записей. private static IReadOnlyList DedupeAndCap(IReadOnlyList found, int limit) diff --git a/src/telegram-service/Deal.Telegram/Discovery/DiscoveryProtoMapper.cs b/src/telegram-service/Deal.Telegram/Discovery/DiscoveryProtoMapper.cs index 231229a..ee8831c 100644 --- a/src/telegram-service/Deal.Telegram/Discovery/DiscoveryProtoMapper.cs +++ b/src/telegram-service/Deal.Telegram/Discovery/DiscoveryProtoMapper.cs @@ -5,16 +5,12 @@ using Deal.Telegram.Telegram; namespace Deal.Telegram.Discovery; /// -/// Маппер нейтральных результатов discovery в protobuf-контракт (telegram.proto; план Task 11). -/// -/// Чистый и без зависимостей от TL-слоя — единое место разбора, которое используют RPC-реализации -/// (TelegramServiceImpl) и unit-тесты формата ответов (записи поиска — как в каталоге, -/// ChannelInfo/ReadForEvalReply — здесь). hue считает сервис (Ruling 7), не TL-слой. +/// Маппер нейтральных результатов discovery в protobuf-контракт. /// public static class DiscoveryProtoMapper { /// - /// Нейтральное инфо источника → ChannelInfo контракта (participants/is_forum, hue). + /// Нейтральное инфо источника → ChannelInfo контракта /// /// Инфо из сессии (по умолчанию — только id/name). public static ChannelInfo ToChannelInfo(TelegramSourceInfo info) @@ -38,7 +34,7 @@ public static class DiscoveryProtoMapper } /// - /// Сообщение выборки → EvalMessage контракта (темы форума — optional-поля). + /// Сообщение выборки → EvalMessage контракта /// /// Сообщение discovery-read (непустой текст). public static EvalMessage ToEvalMessage(DiscoveryMessage message) @@ -64,7 +60,7 @@ public static class DiscoveryProtoMapper } /// - /// Результат чтения выборки → ReadForEvalReply контракта (ok/error/messages). + /// Результат чтения выборки → ReadForEvalReply контракта /// /// Результат из сессии (ok=false → error="no_history"). public static ReadForEvalReply ToReadForEvalReply(DiscoveryReadResult result) diff --git a/src/telegram-service/Deal.Telegram/Extensions/ExceptionExtensions.cs b/src/telegram-service/Deal.Telegram/Extensions/ExceptionExtensions.cs index 2ef627d..58bfc3a 100644 --- a/src/telegram-service/Deal.Telegram/Extensions/ExceptionExtensions.cs +++ b/src/telegram-service/Deal.Telegram/Extensions/ExceptionExtensions.cs @@ -8,7 +8,7 @@ namespace Deal.Telegram.Extensions; internal static class ExceptionExtensions { /// - /// Истинно транспортные/сетевые причины — только они дают UNAVAILABLE (безопасный повтор). + /// Истинно транспортные/сетевые причины — только они дают UNAVAILABLE /// /// Исключение для классификации. /// True — исключение транспортного/сетевого характера. diff --git a/src/telegram-service/Deal.Telegram/Hosting/RealtimeMonitorService.cs b/src/telegram-service/Deal.Telegram/Hosting/RealtimeMonitorService.cs index ea13ba3..f428678 100644 --- a/src/telegram-service/Deal.Telegram/Hosting/RealtimeMonitorService.cs +++ b/src/telegram-service/Deal.Telegram/Hosting/RealtimeMonitorService.cs @@ -5,17 +5,12 @@ using Deal.Telegram.Sessions; namespace Deal.Telegram.Hosting; /// -/// Фоновый reconcile realtime-listener'ов (план Task 10; «подписка: новые сообщения → PushMessage»). -/// -/// Держит по одному на ready-сессию: сессия перешла в ready — listener -/// подписывается на её события (GetStatus.listener → true); сессия ушла из ready (Logout/ошибка) — -/// отписка. Период мал (2 с) — подписка появляется сразу после QR-сканирования/auto_resume; упущенное -/// в окне между ready и подпиской догоняет realtime_sweep (без read-ack сообщения остаются unread). +/// Фоновый reconcile realtime-listener'ов. /// public sealed class RealtimeMonitorService : BackgroundService { /// - /// Период reconcile подписок (сек). + /// Период reconcile подписок /// public const int ReconcileIntervalSeconds = 2; diff --git a/src/telegram-service/Deal.Telegram/Hosting/RealtimeSweepService.cs b/src/telegram-service/Deal.Telegram/Hosting/RealtimeSweepService.cs index 31a201a..d932c9f 100644 --- a/src/telegram-service/Deal.Telegram/Hosting/RealtimeSweepService.cs +++ b/src/telegram-service/Deal.Telegram/Hosting/RealtimeSweepService.cs @@ -3,10 +3,7 @@ using Deal.Telegram.Dialogs; namespace Deal.Telegram.Hosting; /// -/// Фоновый цикл догона realtime (план Task 10; эталон SessionHeartbeatService). Каждые 30 секунд -/// вызывает по ready-сессиям; первый проход — сразу после -/// старта (зеркало мониторинга восстанавливается после рестарта, упущенное догоняется — Ruling 7). -/// Сбои отдельного цикла не роняют хост. +/// Фоновый цикл догона realtime. /// public sealed class RealtimeSweepService : BackgroundService { diff --git a/src/telegram-service/Deal.Telegram/Hosting/SessionHeartbeatService.cs b/src/telegram-service/Deal.Telegram/Hosting/SessionHeartbeatService.cs index 0b0f382..094e3e6 100644 --- a/src/telegram-service/Deal.Telegram/Hosting/SessionHeartbeatService.cs +++ b/src/telegram-service/Deal.Telegram/Hosting/SessionHeartbeatService.cs @@ -3,16 +3,12 @@ using Deal.Telegram.Sessions; namespace Deal.Telegram.Hosting; /// -/// Фоновый цикл сессий telegram-service (план Task 9: «heartbeat/авто-возобновление фоновым циклом -/// 30 с»; heartbeat прототипа L318–327). На старте — auto_resume сохранённых сессий тенантов -/// (авторизованная сессия → ready), далее каждые 30 секунд — повторное подключение оборвавшихся -/// сессий и (при остановке хоста) сохранение живых сессий в файлы (Ruling 3). -/// Сетевых вызовов без сессий не делает; с фейковой фабрикой в тестах — no-op. +/// Фоновый цикл сессий telegram-service. /// public sealed class SessionHeartbeatService : BackgroundService { /// - /// Период цикла сердцебиения (сек; прототип heartbeat вызывается планировщиком). + /// Период цикла сердцебиения. /// public const int HeartbeatIntervalSeconds = 30; @@ -31,8 +27,7 @@ public sealed class SessionHeartbeatService : BackgroundService } /// - /// Первый проход — auto_resume файлов сессий (создаёт ready-сессии после рестарта контейнера); - /// затем периодический heartbeat оборвавшихся соединений. + /// Первый проход — auto_resume файлов сессий /// /// Токен остановки хоста. protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -65,7 +60,7 @@ public sealed class SessionHeartbeatService : BackgroundService } /// - /// При остановке хоста сохраняет живые сессии (перешифровка при остановке, Ruling 3). + /// При остановке хоста сохраняет живые сессии. /// /// Токен остановки. public override async Task StopAsync(CancellationToken cancellationToken) diff --git a/src/telegram-service/Deal.Telegram/Program.cs b/src/telegram-service/Deal.Telegram/Program.cs index 351646d..3e86b03 100644 --- a/src/telegram-service/Deal.Telegram/Program.cs +++ b/src/telegram-service/Deal.Telegram/Program.cs @@ -1,15 +1,9 @@ -// telegram-service — точка входа gRPC-хоста (план Task 2, L227–238; Ruling 1/2/12). // // Kestrel HTTP/2 на порту 5101 (env GRPC_PORT, затем PORT) + AddGrpc с интерцепторами service-token // и access-лога + стандартный gRPC-health (grpc.health.v1.Health). Транспорт: dev — plaintext -// (Ruling 2); mTLS (TLS + клиентский сертификат) — при DEAL_MTLS_ENABLED=1 (Ruling 6, план Task 13; -// сертификаты deploy/certs — scripts/mtls-certs.sh, env передаёт compose-prod Task 14); fail-closed: // Production без mTLS не стартует (GrpcHostEnvironment.RequireMtlsInProduction). // Серверная обвязка (Kestrel/AddGrpc/health) — общий Deal.Grpc.Hosting (C31): хост-фабрика // TelegramServiceHost.Create используется и интеграционными тестами (Deal.Telegram.Tests), которые -// поднимают его в своём процессе на эфемерном порту. Реализованы RPC сессий (Task 9), -// каталога/мониторинга (Task 10) и discovery-операции Search/GetInfo/ReadForEval/Join/Leave -// (Task 11, см. TelegramServiceImpl/DiscoveryOps). using Deal.Grpc.Hosting.Interceptors; using Deal.Grpc.Hosting.Models; @@ -17,17 +11,13 @@ using Deal.Grpc.Hosting.Options; using Deal.Grpc.Hosting.Services; using Deal.Telegram; -// Порт по умолчанию — 5101 (Ruling 12, compose.dev.yml); переопределяется env GRPC_PORT (контейнер) // или PORT (общий конвенциональный env хостинг-платформ) — см. GrpcHostEnvironment.ResolveGrpcPort. const int defaultGrpcPort = 5101; -// Имя процесса для rolling-файла логов (Ruling 7, Task 14): data/logs/deal-telegram-<дата>.json. const string telegramProcessName = "telegram"; int grpcPort = GrpcHostEnvironment.ResolveGrpcPort(defaultGrpcPort); -// Порт эндпоинта метрик /metrics (HTTP/1.1, отдельно от gRPC HTTP/2; этап 12, пакет A). int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); -// Serilog (Ruling 7, план Task 14): JSON-консоль + rolling-файл data/logs/deal-telegram-*.json — // конфигурируется production-точкой входа через configureBuilder-хук хоста (тесты хост поднимают // без Serilog, DealLogging.Configure в TelegramServiceHost/Create вызывается только здесь). Метрики // (OTel → Prometheus, /metrics) — тем же хуком до builder.Build(). @@ -39,10 +29,8 @@ WebApplication app = TelegramServiceHost.Create( DealMetricsHosting.AddDealMetrics(builder, metricsPort); }); -// Эндпоинт метрик /metrics (HTTP/1.1 на отдельном порту): формат Prometheus (этап 12, пакет A). DealMetricsHosting.MapDealMetrics(app); -// Режим транспорта — из тех же env, что читал хост (Ruling 6, Task 13): mTLS при DEAL_MTLS_ENABLED=1. MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(app.Configuration); // Fail-closed (замечание code-review): отсутствие/опечатка DEAL_MTLS_ENABLED не должны давать diff --git a/src/telegram-service/Deal.Telegram/Sessions/AuthPhase.cs b/src/telegram-service/Deal.Telegram/Sessions/AuthPhase.cs index 3d33b2c..7171989 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/AuthPhase.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/AuthPhase.cs @@ -1,38 +1,37 @@ namespace Deal.Telegram.Sessions; /// -/// Фаза входа/подключения аккаунта тенанта (1:1 с каноном telegram.proto: -/// idle|phone|code|password|qr|ready — status() прототипа L85, план Task 9). +/// Фаза входа/подключения аккаунта тенанта /// public enum AuthPhase { /// - /// Активного входа нет (клиент не создан, вход не начат или завершён Logout). + /// Активного входа нет /// Idle, /// - /// Фаза ввода номера телефона (в прототипе используется как промежуточная; код ещё не запрошен). + /// Фаза ввода номера телефона. /// Phone, /// - /// SMS-код отправлен, ожидается код (submit_code). + /// SMS-код отправлен, ожидается код /// Code, /// - /// Включена двухфакторная аутентификация — нужен облачный пароль (submit_password). + /// Включена двухфакторная аутентификация — нужен облачный пароль /// Password, /// - /// QR-вход запущен: ожидается сканирование, qr_url актуален. + /// QR-вход запущен /// Qr, /// - /// Аккаунт авторизован, сессия сохранена (клиент готов исполнять команды). + /// Аккаунт авторизован, сессия сохранена /// Ready, } diff --git a/src/telegram-service/Deal.Telegram/Sessions/SessionErrorMessages.cs b/src/telegram-service/Deal.Telegram/Sessions/SessionErrorMessages.cs index aba613e..48ea69f 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/SessionErrorMessages.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/SessionErrorMessages.cs @@ -1,60 +1,57 @@ namespace Deal.Telegram.Sessions; /// -/// Канонические тексты причин сессионных ошибок (detail RPC и статус-поле GetStatus.error). -/// Тексты 1:1 с прототипом backend/app/services/telegram.py и перечнем строк плана -/// (задачи: «Telegram не подключён», «Сначала сохраните Telegram api_id и api_hash в настройках», -/// «Неверный код», «Код истёк — запросите новый», «Неверный облачный пароль»). +/// Канонические тексты причин сессионных ошибок /// public static class SessionErrorMessages { /// - /// Ключи приложения не переданы ядром (INVALID_ARGUMENT). + /// Ключи приложения не переданы ядром /// public const string NoApiKeys = "Сначала сохраните Telegram api_id и api_hash в настройках"; /// - /// Аккаунт тенанта не подключён — нет сессии (FAILED_PRECONDITION). + /// Аккаунт тенанта не подключён — нет сессии /// public const string NotConnected = "Telegram не подключён"; /// - /// Неверный SMS-код входа (INVALID_ARGUMENT). + /// Неверный SMS-код входа /// public const string WrongCode = "Неверный код"; /// - /// SMS-код истёк, нужен новый (INVALID_ARGUMENT). + /// SMS-код истёк, нужен новый /// public const string CodeExpired = "Код истёк — запросите новый"; /// - /// Неверный облачный пароль 2FA (INVALID_ARGUMENT). + /// Неверный облачный пароль 2FA /// public const string WrongPassword = "Неверный облачный пароль"; /// - /// SendCode вызван вне фазы "code" (FAILED_PRECONDITION). + /// SendCode вызван вне фазы "code" /// public const string CodeNotRequested = "Код не запрашивался — начните вход по номеру телефона"; /// - /// SendPassword вызван вне фазы "password" (FAILED_PRECONDITION). + /// SendPassword вызван вне фазы "password" /// public const string PasswordNotRequested = "2FA-пароль не запрашивался — сначала отправьте код"; /// - /// Metadata tenant-id отсутствует или пуст (UNAUTHENTICATED). + /// Metadata tenant-id отсутствует или пуст /// public const string TenantIdMissing = "tenant-id отсутствует в metadata"; /// - /// Некорректный tenant-id (INVALID_ARGUMENT). + /// Некорректный tenant-id /// public const string InvalidTenantId = "Некорректный tenant-id в metadata"; /// - /// Telegram/сеть недоступны (UNAVAILABLE). + /// Telegram/сеть недоступны /// public const string TelegramUnavailable = "Telegram недоступен — повторите попытку позже"; @@ -64,47 +61,47 @@ public static class SessionErrorMessages public const string IngressUnavailable = "Ядро недоступно — повторите попытку позже"; /// - /// Диалог не найден в аккаунте/кэше сущностей сессии (INVALID_ARGUMENT). + /// Диалог не найден в аккаунте/кэше сущностей сессии /// public const string UnknownDialog = "Источник не найден в аккаунте — обновите список каналов"; /// - /// Пустой username вступления (INVALID_ARGUMENT; 1:1 ValueError discovery_join L828). + /// Пустой username вступления. /// public const string JoinUsernameMissing = "Не указан username для вступления"; /// - /// Поисковый запрос длиннее верхней границы (INVALID_ARGUMENT; защита границы сервиса). + /// Поисковый запрос длиннее верхней границы /// public const string SearchQueryTooLong = "Слишком длинный поисковый запрос"; /// - /// Username вступления длиннее лимита Telegram (INVALID_ARGUMENT). + /// Username вступления длиннее лимита Telegram /// public const string UsernameTooLong = "Слишком длинный username"; /// - /// Внутренняя ошибка сервиса (INTERNAL; сбой реализации, а не транспорт/сеть). + /// Внутренняя ошибка сервиса /// public const string InternalError = "Внутренняя ошибка сервиса — повторите попытку позже"; /// - /// По username найден не канал/группа (личный чат/бот) — вступить нельзя (INVALID_ARGUMENT). + /// По username найден не канал/группа /// public const string JoinTargetNotChannel = "По этому username найден не канал/группа — вступить нельзя"; /// - /// Некорректный (неподписанный) id диалога в запросе (INVALID_ARGUMENT). + /// Некорректный /// public const string InvalidDialogId = "Некорректный id источника"; /// - /// Телефон не зарегистрирован в Telegram (регистрация из сервиса не выполняется). + /// Телефон не зарегистрирован в Telegram /// public const string SignUpRequired = "Номер не зарегистрирован в Telegram — зарегистрируйте его в приложении Telegram"; /// - /// Ключ шифрования сессий не задан в env (служебная ошибка конфигурации). + /// Ключ шифрования сессий не задан в env /// public const string SessionKeyNotConfigured = "Ключ шифрования сессий не задан (DEAL_TELEGRAM_SESSION_KEY)"; } diff --git a/src/telegram-service/Deal.Telegram/Sessions/SessionException.cs b/src/telegram-service/Deal.Telegram/Sessions/SessionException.cs index 71bfddf..20ce87d 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/SessionException.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/SessionException.cs @@ -3,12 +3,7 @@ using Grpc.Core; namespace Deal.Telegram.Sessions; /// -/// Доменная ошибка сессий telegram-service (план Task 9; Ruling 1/3). -/// -/// Ошибки Telegram/логики подключения переводятся в gRPC-статусы ядром только через этот тип: -/// обработчики TelegramServiceImpl ловят его и возвращают RpcException с кодом -/// и detail = сообщению (текст причины 1:1 с прототипом, см. ). -/// Неизвестные/сетевые сбои адаптер WTelegramClient также оборачивает в этот тип (UNAVAILABLE). +/// Доменная ошибка сессий telegram-service. /// public sealed class SessionException : Exception { @@ -16,7 +11,7 @@ public sealed class SessionException : Exception /// Создаёт ошибку сессии с gRPC-кодом, в который она должна превратиться на границе. /// /// gRPC-статус ошибки (контракт telegram.proto, шапка файла). - /// Текст причины — detail RPC (1:1 с текстами прототипа). + /// Текст причины — detail RPC. /// Внутренняя причина (исключение Telegram/адаптера), если есть. public SessionException( StatusCode code, diff --git a/src/telegram-service/Deal.Telegram/Sessions/SessionFarm.cs b/src/telegram-service/Deal.Telegram/Sessions/SessionFarm.cs index 0ca8860..e062413 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/SessionFarm.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/SessionFarm.cs @@ -5,10 +5,7 @@ using Grpc.Core; namespace Deal.Telegram.Sessions; /// -/// Пул сессий тенантов «1 аккаунт на тенанта» (план Task 9, Sessions/SessionFarm.cs; Ruling 3, -/// архитектура §7.1). Сессия создаётся на первый вход/возобновление и переиспользуется (Logout -/// сбрасывает её в состояние «отключено» — из карты объект не удаляется, гонок вызова нет). -/// Все сетевые команды исполняются на сессии своего тенанта (Ruling 1); операций по чужим сессиям нет. +/// Пул сессий тенантов «1 аккаунт на тенанта». /// public sealed class SessionFarm { @@ -38,20 +35,20 @@ public sealed class SessionFarm } /// - /// Возвращает сессию тенанта, если она уже создана (иначе null — «Telegram не подключён»). + /// Возвращает сессию тенанта, если она уже создана /// /// Id тенанта. public TenantSession? FindSession(string tenantId) => _sessions.TryGetValue(tenantId, out TenantSession? session) ? session : null; /// - /// Все сессии пула (снимок; для realtime-циклов каталога — RealtimeMonitorService/Sweep). + /// Все сессии пула /// public IReadOnlyCollection Sessions => _sessions.Values.ToArray(); /// - /// Список диалогов аккаунта тенанта (только ready-сессия; план Task 10). + /// Список диалогов аккаунта тенанта. /// /// Id тенанта. /// Верхняя граница числа диалогов. @@ -63,7 +60,7 @@ public sealed class SessionFarm => RequireSession(tenantId).ListDialogsAsync(limit, cancellationToken); /// - /// Последние сообщения диалога тенанта (только ready-сессия; план Task 10). + /// Последние сообщения диалога тенанта. /// /// Id тенанта. /// Подписанный id диалога. @@ -77,7 +74,7 @@ public sealed class SessionFarm => RequireSession(tenantId).GetMessagesAsync(dialogId, limit, cancellationToken); /// - /// Помечает диалог тенанта прочитанным (только ready-сессия; план Task 10). + /// Помечает диалог тенанта прочитанным. /// /// Id тенанта. /// Подписанный id диалога. @@ -89,7 +86,7 @@ public sealed class SessionFarm => RequireSession(tenantId).MarkReadAsync(dialogId, cancellationToken); /// - /// Глобальный поиск каналов/групп по ключу (только ready-сессия; план Task 11). + /// Глобальный поиск каналов/групп по ключу. /// /// Id тенанта. /// Поисковый запрос (ключ задачи discovery). @@ -103,7 +100,7 @@ public sealed class SessionFarm => RequireSession(tenantId).SearchAsync(query, limit, cancellationToken); /// - /// Инфо об источнике для оценки кандидата (только ready-сессия; план Task 11). + /// Инфо об источнике для оценки кандидата. /// /// Id тенанта. /// Подписанный id источника. @@ -115,7 +112,7 @@ public sealed class SessionFarm => RequireSession(tenantId).GetInfoAsync(dialogId, cancellationToken); /// - /// Выборка сообщений источника для оценки (только ready-сессия; план Task 11). + /// Выборка сообщений источника для оценки. /// /// Id тенанта. /// Подписанный id источника. @@ -129,7 +126,7 @@ public sealed class SessionFarm => RequireSession(tenantId).ReadForEvalAsync(dialogId, limit, cancellationToken); /// - /// Вступить в канал/группу по username (только ready-сессия; план Task 11). + /// Вступить в канал/группу по username. /// /// Id тенанта. /// Username (без «@»; нормализует DiscoveryOps). @@ -141,7 +138,7 @@ public sealed class SessionFarm => RequireSession(tenantId).JoinAsync(username, cancellationToken); /// - /// Выйти из канала/группы (только ready-сессия; план Task 11). + /// Выйти из канала/группы. /// /// Id тенанта. /// Подписанный id диалога. @@ -153,7 +150,7 @@ public sealed class SessionFarm => RequireSession(tenantId).LeaveAsync(dialogId, cancellationToken); /// - /// Вход по телефону: сессия создаётся при первом обращении. + /// Вход по телефону /// /// Id тенанта. /// api_id приложения. @@ -207,7 +204,7 @@ public sealed class SessionFarm => RequireSession(tenantId).SendPasswordAsync(password, cancellationToken); /// - /// Отключение аккаунта: Auth_LogOut + удаление файла сессии тенанта. + /// Отключение аккаунта /// /// Id тенанта. /// Отмена операции. @@ -224,7 +221,7 @@ public sealed class SessionFarm } /// - /// Статус сессии тенанта; null — аккаунт не подключён (сессии нет). + /// Статус сессии тенанта; null — аккаунт не подключён /// /// Id тенанта. /// Отмена операции. @@ -235,8 +232,7 @@ public sealed class SessionFarm } /// - /// Авто-возобновление на старте (auto_resume L209–222): для каждого файла сессии на диске создаёт - /// клиент и при авторизации переводит тенанта в "ready". Сбои не роняют старт (внутри TryResumeAsync). + /// Авто-возобновление на старте /// /// Отмена операции. public async Task ResumeAllAsync(CancellationToken cancellationToken) @@ -270,7 +266,7 @@ public sealed class SessionFarm } /// - /// Сердцебиение (30 с): повторное подключение оборвавшихся "ready"-сессий (heartbeat L318–327). + /// Сердцебиение /// /// Отмена операции. public async Task HeartbeatTickAsync(CancellationToken cancellationToken) @@ -287,8 +283,7 @@ public sealed class SessionFarm } /// - /// Остановка хоста: сохраняет живые сессии (перешифровка при остановке, Ruling 3) и освобождает - /// клиенты. Ошибки отдельных сессий не останавливают остальные. + /// Остановка хоста /// /// Отмена операции. public async Task ShutdownAsync(CancellationToken cancellationToken) diff --git a/src/telegram-service/Deal.Telegram/Sessions/SessionFileCipher.cs b/src/telegram-service/Deal.Telegram/Sessions/SessionFileCipher.cs index 37c5c4d..1463e17 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/SessionFileCipher.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/SessionFileCipher.cs @@ -3,18 +3,12 @@ using System.Security.Cryptography; namespace Deal.Telegram.Sessions; /// -/// AES-256-GCM-обёртка файла сессии тенанта (план Task 9; Ruling 3). -/// -/// Дублирование подхода AesGcmSecretCipher этапа 2 в отдельном процессе: сервисы этапа не имеют -/// ссылок на core (Ruling — общий только .proto и NuGet), поэтому маленький шифр реализован локально. -/// Формат значения тот же, что в core: enc: + Base64(nonce ‖ шифротекст ‖ tag) -/// (nonce 12 байт, tag 16 байт, ключ 32 байта из env DEAL_TELEGRAM_SESSION_KEY). -/// Экземпляр AesGcm создаётся на операцию — разделяемого криптографического состояния нет. +/// AES-256-GCM-обёртка файла сессии тенанта. /// public sealed class SessionFileCipher { /// - /// Префикс зашифрованного значения (маркер формата в файле сессии). + /// Префикс зашифрованного значения /// public const string EncryptedPrefix = "enc:"; @@ -27,7 +21,7 @@ public sealed class SessionFileCipher private readonly byte[] _key; /// - /// Создаёт шифр с ключом из опций (env DEAL_TELEGRAM_SESSION_KEY). + /// Создаёт шифр с ключом из опций /// /// Опции хранения сессий. public SessionFileCipher(TgOptions options) @@ -36,8 +30,7 @@ public sealed class SessionFileCipher } /// - /// Шифрует содержимое файла сессии: случайный nonce + AES-GCM, возвращает значение - /// enc:+Base64(nonce ‖ шифротекст ‖ tag) — готовый текст файла data/sessions/<tenant>.session. + /// Шифрует содержимое файла сессии /// /// Открытое содержимое сессии (расшифрованная копия в памяти процесса). /// Зашифрованное значение для записи в файл. @@ -63,9 +56,7 @@ public sealed class SessionFileCipher } /// - /// Расшифровывает значение файла сессии. null — значение не в формате сервиса (не enc: или - /// не base64): файл чужой/повреждён и трактуется как отсутствующий. Несовпадение тега/чужой - /// ключ — (файл повреждён или ключ сменился). + /// Расшифровывает значение файла сессии. /// /// Содержимое файла сессии (enc: + base64). /// Открытые байты сессии либо null (не наш формат). diff --git a/src/telegram-service/Deal.Telegram/Sessions/SessionStore.cs b/src/telegram-service/Deal.Telegram/Sessions/SessionStore.cs index e0375c7..2ce0e0e 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/SessionStore.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/SessionStore.cs @@ -5,19 +5,12 @@ using Grpc.Core; namespace Deal.Telegram.Sessions; /// -/// Файловое хранилище сессий тенантов (план Task 9; Ruling 3) — аналог SessionManager/SessionStore -/// задачи: файлы data/sessions/<tenantId>.session, содержимое — AES-GCM-обёртка -/// () сериализованного . -/// -/// Запись — атомарная (временный файл в том же каталоге + File.Move), чтобы рестарт/сбой -/// не оставил битый файл сессии; все записи сериализованы одним семафором (файлы маленькие, -/// запись редкая). Нечитаемый/повреждённый файл трактуется как отсутствие сессии (лог-warning), -/// файл не удаляется — диагностика сохраняется. +/// Файловое хранилище сессий тенантов — SessionManager/SessionStore задачи /// public sealed class SessionStore { /// - /// Расширение файла сессии (data/sessions/<tenantId>.session). + /// Расширение файла сессии /// public const string SessionFileExtension = ".session"; @@ -43,10 +36,9 @@ public sealed class SessionStore } /// - /// Читает и расшифровывает сессию тенанта. Возвращает null, если файла нет или он нечитаем - /// (чужой формат/повреждён/другой ключ — лог-warning; файл сохраняется для диагностики). + /// Читает и расшифровывает сессию тенанта. /// - /// Id тенанта (принадлежность сессии; Ruling 3 — 1 аккаунт на тенанта). + /// Id тенанта. /// Отмена операции. public async Task LoadAsync(string tenantId, CancellationToken cancellationToken = default) { @@ -99,7 +91,7 @@ public sealed class SessionStore } /// - /// Шифрует и атомарно сохраняет сессию тенанта (создаёт каталог при первом сохранении). + /// Шифрует и атомарно сохраняет сессию тенанта /// /// Id тенанта. /// Открытое содержимое сессии для шифрования at-rest. @@ -129,7 +121,7 @@ public sealed class SessionStore } /// - /// Удаляет файл сессии тенанта (Logout). Отсутствие файла не считается ошибкой. + /// Удаляет файл сессии тенанта /// /// Id тенанта. /// Отмена операции. @@ -145,8 +137,7 @@ public sealed class SessionStore } /// - /// Перечисляет id тенантов, для которых на диске есть файл сессии (auto_resume на старте). - /// Каталога нет — пустой список (каталог создаётся лениво, при первом сохранении). + /// Перечисляет id тенантов, для которых на диске есть файл сессии /// public IEnumerable ListTenantIds() { diff --git a/src/telegram-service/Deal.Telegram/Sessions/StoredSession.cs b/src/telegram-service/Deal.Telegram/Sessions/StoredSession.cs index 8f75398..4d055c9 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/StoredSession.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/StoredSession.cs @@ -1,18 +1,12 @@ namespace Deal.Telegram.Sessions; /// -/// Открытое содержимое файла сессии тенанта (план Task 9; Ruling 3). -/// -/// Файл data/sessions/<tenant>.session хранит AES-GCM-обёртку (SessionFileCipher) сериализованного -/// . Вместе с байтами сессии WTelegramClient сохраняются api_id/api_hash -/// приложения, под которыми сессия создана: ядро передаёт ключи в теле StartQr/StartPhone (Ruling 3), -/// а при auto_resume после рестарта сервиса других источников ключей нет — они восстанавливаются из -/// зашифрованного файла (иначе «авторизованная сессия → ready» на старте невозможна). +/// Открытое содержимое файла сессии тенанта. /// public sealed record StoredSession { /// - /// Версия формата файла (для будущих изменений контейнера). + /// Версия формата файла /// public const int CurrentFormatVersion = 1; @@ -32,7 +26,7 @@ public sealed record StoredSession public string ApiHash { get; init; } = string.Empty; /// - /// Байты файла сессии WTelegramClient (внутренне уже зашифрованы библиотекой). + /// Байты файла сессии WTelegramClient /// public byte[] SessionBytes { get; init; } = []; } diff --git a/src/telegram-service/Deal.Telegram/Sessions/TenantSession.cs b/src/telegram-service/Deal.Telegram/Sessions/TenantSession.cs index f1dcbf7..6c841d2 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/TenantSession.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/TenantSession.cs @@ -4,15 +4,7 @@ using Grpc.Core; namespace Deal.Telegram.Sessions; /// -/// Сессия тенанта: id тенанта, клиент Telegram и состояние входа (план Task 9, Sessions/TenantSession.cs). -/// -/// Соответствует TelegramManager прототипа (telegram.py L82–222) для одного тенанта: 1 аккаунт на -/// тенанта (Ruling 3/архитектура §7.1), фазы idle|phone|code|password|qr|ready, error/qrUrl/account. -/// Все операции сериализованы per-tenant семафором (команды исполняются только -/// на сессии своего тенанта; Ruling 1). Клиент создаётся фабрикой под ключи приложения из запроса; -/// авторизованная сессия сохраняется в файл data/sessions/<tenant>.session (AES-GCM-обёртка). -/// QR-вход выполняется фоновой задачей: RPC возвращается после первого URL, сканирование/ошибки -/// обновляют состояние в фоне (как _wait_qr прототипа L302–312). +/// Сессия тенанта: id тенанта, клиент Telegram и состояние входа. /// public sealed class TenantSession : IAsyncDisposable { @@ -44,18 +36,17 @@ public sealed class TenantSession : IAsyncDisposable private volatile bool _listenerActive; /// - /// Realtime-listener сессии жив (включает RealtimeMonitorService при фазе Ready). + /// Realtime-listener сессии жив /// public AuthPhase Phase => _phase; /// - /// Событие входящего сообщения аккаунта (план Task 10; поднимается для всех текстовых сообщений - /// клиента). RealtimeListener службы подписывается на сессию и фильтрует по зеркалу мониторинга. + /// Событие входящего сообщения аккаунта. /// public event Func? MessageReceived; /// - /// Создаёт сессию тенанта (объект переиспользуется между входами/выходами). + /// Создаёт сессию тенанта /// /// Id тенанта (принадлежность сессии). /// Фабрика клиентов Telegram (реальная или фейк в тестах). @@ -77,12 +68,12 @@ public sealed class TenantSession : IAsyncDisposable } /// - /// Id тенанта, которому принадлежит сессия (команды только своей сессии). + /// Id тенанта, которому принадлежит сессия /// public string TenantId { get; } /// - /// Вход по номеру телефона: запросить SMS-код (start_phone L134–147). + /// Вход по номеру телефона /// /// api_id приложения (из тела запроса ядра). /// api_hash приложения. @@ -122,7 +113,6 @@ public sealed class TenantSession : IAsyncDisposable } catch (SessionException exception) { - // 1:1 start_phone L144–147: фаза idle + текст ошибки. _phase = AuthPhase.Idle; _error = exception.Message; throw; @@ -145,7 +135,7 @@ public sealed class TenantSession : IAsyncDisposable } /// - /// Начать QR-вход (qr_start L286–300): фаза "qr" + первый URL либо "ready", если уже вошли. + /// Начать QR-вход: фаза "qr" + первый URL либо "ready", если уже вошли. /// /// api_id приложения. /// api_hash приложения. @@ -168,14 +158,12 @@ public sealed class TenantSession : IAsyncDisposable await EnsureClientAsync(apiId, apiHash, cancellationToken).ConfigureAwait(false); if (_client!.IsAuthorized) { - // 1:1 qr_start L291–293: уже авторизованы — финализация, url пуст. await CompleteAuthorizationAsync(cancellationToken).ConfigureAwait(false); return Snapshot(); } if (_phase == AuthPhase.Qr && _qrWaitTask is { IsCompleted: false }) { - // Повторный вызов во время активного QR — вернуть текущий URL (python L294–295). return Snapshot(); } @@ -196,7 +184,6 @@ public sealed class TenantSession : IAsyncDisposable } catch (SessionException exception) { - // Ошибка до первого URL (сеть/Telegram): сброс к idle + текст ошибки, как _wait_qr L306–309. _phase = AuthPhase.Idle; _qrUrl = null; _error = exception.Message; @@ -210,7 +197,6 @@ public sealed class TenantSession : IAsyncDisposable _qrUrl = null; } - // Отмена ожидания до первого URL отменяет и фоновый QR-вход (_qrCts): иначе задача // LoginWithQRCode продолжала бы авторизацию «скрыто» после отмены RPC (замечание code-review). CancelQrFlow(); @@ -233,7 +219,7 @@ public sealed class TenantSession : IAsyncDisposable } /// - /// Отправить SMS-код (submit_code L149–166). Фазы вне "code" — FAILED_PRECONDITION. + /// Отправить SMS-код. Фазы вне "code" — FAILED_PRECONDITION. /// /// Код из SMS/Telegram-сообщения. /// Отмена операции. @@ -258,7 +244,6 @@ public sealed class TenantSession : IAsyncDisposable } catch (SessionException exception) { - // Неверный/истёкший код — фаза остаётся "code" (повтор ввода, как в прототипе). _error = exception.Message; throw; } @@ -279,7 +264,7 @@ public sealed class TenantSession : IAsyncDisposable } /// - /// Отправить облачный пароль 2FA (submit_password L168–176). Фазы вне "password" — FAILED_PRECONDITION. + /// Отправить облачный пароль 2FA. /// /// Облачный пароль. /// Отмена операции. @@ -303,7 +288,6 @@ public sealed class TenantSession : IAsyncDisposable } catch (SessionException exception) { - // Неверный пароль — фаза остаётся "password" (повтор ввода, как в прототипе). _error = exception.Message; throw; } @@ -318,7 +302,7 @@ public sealed class TenantSession : IAsyncDisposable } /// - /// Отключить аккаунт и удалить сессию тенанта (disconnect L189–207). Возвращает null — сессии больше нет. + /// Отключить аккаунт и удалить сессию тенанта. /// /// Отмена операции. public async Task LogoutAsync(CancellationToken cancellationToken) @@ -372,7 +356,7 @@ public sealed class TenantSession : IAsyncDisposable } /// - /// Снимок состояния для GetStatus; null — сессии тенанта нет (аккаунт не подключён). + /// Снимок состояния для GetStatus; null — сессии тенанта нет /// /// Отмена операции. public async Task GetSnapshotAsync(CancellationToken cancellationToken) @@ -388,12 +372,11 @@ public sealed class TenantSession : IAsyncDisposable } } - // --- Каталог и сообщения (план Task 10; исполняются на ready-сессии своего тенанта) --- /// - /// Список диалогов аккаунта (refresh_dialogs L505–519); фаза обязана быть ready. + /// Список диалогов аккаунта; фаза обязана быть ready. /// - /// Верхняя граница числа диалогов (прототип: 500). + /// Верхняя граница числа диалогов. /// Отмена операции. /// Диалоги аккаунта (нейтральный вид). public async Task> ListDialogsAsync(int limit, CancellationToken cancellationToken) @@ -411,7 +394,7 @@ public sealed class TenantSession : IAsyncDisposable } /// - /// Последние сообщения диалога (get_messages); фаза обязана быть ready. + /// Последние сообщения диалога /// /// Подписанный id диалога. /// Сколько последних сообщений запросить. @@ -435,7 +418,7 @@ public sealed class TenantSession : IAsyncDisposable } /// - /// Помечает диалог прочитанным (send_read_acknowledge); фаза обязана быть ready. + /// Помечает диалог прочитанным /// /// Подписанный id диалога. /// Отмена операции. @@ -453,15 +436,14 @@ public sealed class TenantSession : IAsyncDisposable } } - // --- Discovery (план Task 11; discovery_search/info/read/join/leave L622–873) --- /// - /// Глобальный поиск каналов/групп по ключу (discovery_search L624–664); фаза ready. + /// Глобальный поиск каналов/групп по ключу; фаза ready. /// /// Поисковый запрос (ключ задачи discovery). /// Верхняя граница результата. /// Отмена операции. - /// Найденные источники (нейтральный вид; личные чаты отсеивает ядро, Ruling 10). + /// Найденные источники. public async Task> SearchAsync( string query, int limit, @@ -480,7 +462,7 @@ public sealed class TenantSession : IAsyncDisposable } /// - /// Инфо об источнике для оценки кандидата (discovery_info L666–716); фаза ready. + /// Инфо об источнике для оценки кандидата; фаза ready. /// /// Подписанный id источника. /// Отмена операции. @@ -500,7 +482,7 @@ public sealed class TenantSession : IAsyncDisposable } /// - /// Выборка сообщений источника для оценки (discovery_read L718–800); фаза ready. + /// Выборка сообщений источника для оценки; фаза ready. /// /// Подписанный id источника. /// Размер выборки (limit ≤ 0 — пусто без сети). @@ -524,7 +506,7 @@ public sealed class TenantSession : IAsyncDisposable } /// - /// Вступить в канал/группу по username (discovery_join L818–839); фаза ready. + /// Вступить в канал/группу по username; фаза ready. /// /// Username (без «@»; нормализует DiscoveryOps). /// Отмена операции. @@ -543,7 +525,7 @@ public sealed class TenantSession : IAsyncDisposable } /// - /// Выйти из канала/группы (discovery_leave L841–848); фаза ready. + /// Выйти из канала/группы; фаза ready. /// /// Подписанный id диалога. /// Отмена операции. @@ -562,7 +544,7 @@ public sealed class TenantSession : IAsyncDisposable } /// - /// Ставит признак живого realtime-listener (для GetStatus.listener, L109). + /// Ставит признак живого realtime-listener. /// /// True — listener сессии подписан на события сообщений. public void SetListenerActive(bool active) @@ -578,7 +560,6 @@ public sealed class TenantSession : IAsyncDisposable return client; } - // Как refresh_dialogs L507–508: разорванное соединение ready-сессии поднимаем перед операцией. try { await client.ConnectAsync(cancellationToken).ConfigureAwait(false); @@ -642,9 +623,7 @@ public sealed class TenantSession : IAsyncDisposable => client.MessageReceived -= ForwardClientMessageAsync; /// - /// Авто-возобновление на старте (auto_resume L209–222)... - /// Авто-возобновление на старте (auto_resume L209–222): поднять клиент из сохранённой сессии; - /// авторизованная сессия → фаза "ready". Не бросает — сбои сети/сессии оставляют фазу idle. + /// Авто-возобновление на старте... /// /// Содержимое файла сессии тенанта. /// Отмена операции. @@ -691,7 +670,6 @@ public sealed class TenantSession : IAsyncDisposable } catch (Exception exception) when (exception is not OperationCanceledException) { - // 1:1 auto_resume L219–221: сбой не роняет старт — фаза idle, ошибка для статуса. _logger.LogWarning(exception, "auto_resume {TenantId} пропущен", TenantId); _phase = AuthPhase.Idle; _error = exception is SessionException sessionException ? sessionException.Message : null; @@ -705,8 +683,7 @@ public sealed class TenantSession : IAsyncDisposable } /// - /// Сердцебиение (heartbeat L318–327): для фазы "ready" при обрыве соединения — повторный connect. - /// Ошибки только логируются; статус-error не меняется (как в прототипе). + /// Сердцебиение: для фазы "ready" при обрыве соединения — повторный connect. /// /// Отмена операции. public async Task TryReconnectAsync(CancellationToken cancellationToken) @@ -722,7 +699,6 @@ public sealed class TenantSession : IAsyncDisposable try { // Собственный лимит попытки: linked-токен с CancelAfter на время попытки переподключения. - // Зависший connect не держит _gate (heartbeat остальных тенантов и shutdown не блокируются). using CancellationTokenSource attemptTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); attemptTimeout.CancelAfter(_reconnectAttemptTimeout); @@ -748,8 +724,7 @@ public sealed class TenantSession : IAsyncDisposable } /// - /// Остановка (хост гасится): сохраняет текущие байты сессии (перешифровка при остановке, Ruling 3), - /// отменяет QR и освобождает клиент. Ошибки не бросаются (фоновая остановка). + /// Остановка (хост гасится) /// /// Отмена операции. public async Task FlushAndDisposeAsync(CancellationToken cancellationToken) @@ -802,7 +777,6 @@ public sealed class TenantSession : IAsyncDisposable } } - // --- внутренние помощники (вызываются под _gate) --- // Проверяет, что сессия тенанта существует и не закрыта (иначе «Telegram не подключён»). private void EnsureLoginStarted() @@ -813,7 +787,6 @@ public sealed class TenantSession : IAsyncDisposable } } - // Ключи приложения обязательны (ядро передаёт их в теле; Ruling 3). private static void ValidateApiKeys(int apiId, string apiHash) { if (apiId <= 0 || string.IsNullOrWhiteSpace(apiHash)) @@ -865,7 +838,6 @@ public sealed class TenantSession : IAsyncDisposable AttachClientMessages(_client); } - // Финализация авторизации (_finalize L178–187): аккаунт в статус, фаза "ready", сессия сохранена. // Сбой get_me не отменяет готовность — сохраняем сессию без account (готовность важнее имени). // cancellationToken: Отмена операции. private async Task CompleteAuthorizationAsync(CancellationToken cancellationToken) @@ -1044,7 +1016,6 @@ public sealed class TenantSession : IAsyncDisposable } // Обработка ошибки QR после того, как URL уже был выдан (RPC вернулся): фаза idle + текст ошибки - // (1:1 _wait_qr L306–309). Ошибку до первого URL сбрасывает StartQrAsync (проброс через задачу). // exception: Ошибка QR-входа. private async Task FailQrUnderGateAsync(SessionException exception) { @@ -1064,7 +1035,6 @@ public sealed class TenantSession : IAsyncDisposable } } - // Строит снимок текущего состояния (без проверки _registered — вызывается под замком). private TenantSessionSnapshot Snapshot() => new( _phase, diff --git a/src/telegram-service/Deal.Telegram/Sessions/TenantSessionSnapshot.cs b/src/telegram-service/Deal.Telegram/Sessions/TenantSessionSnapshot.cs index 44fb284..ab471bd 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/TenantSessionSnapshot.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/TenantSessionSnapshot.cs @@ -1,9 +1,7 @@ namespace Deal.Telegram.Sessions; /// -/// Снимок состояния сессии тенанта для GetStatus/ответов RPC подключения (план Task 9). -/// Live-поля GetStatusReply: phase/connected/listener/account/error/qr_url (статус прототипа L110–118); -/// qrUrl заполнен только при phase == Qr (как в прототипе L118), monitored/keysSet ядро считает само. +/// Снимок состояния сессии тенанта для GetStatus/ответов RPC подключения. /// public sealed record TenantSessionSnapshot { @@ -12,7 +10,7 @@ public sealed record TenantSessionSnapshot /// /// Текущая фаза входа. /// Клиент Telegram соединён. - /// Жив ли realtime-listener (включается задачами каталога, Task 10). + /// Жив ли realtime-listener. /// Аккаунт "@username" авторизованного пользователя (иначе null). /// Текст последней ошибки (иначе null). /// URL QR-входа (только при phase == Qr; иначе null). @@ -38,27 +36,27 @@ public sealed record TenantSessionSnapshot public AuthPhase Phase { get; } /// - /// Клиент Telegram соединён (bool connected статуса прототипа). + /// Клиент Telegram соединён. /// public bool Connected { get; } /// - /// Realtime-listener жив (заполняется с Task 10; в задаче сессий — false). + /// Realtime-listener жив. /// public bool Listener { get; } /// - /// Аккаунт "@username" (для справки; источник истины — KV tgAccount ядра). + /// Аккаунт "@username" /// public string? Account { get; } /// - /// Текст последней ошибки входа/соединения (null — ошибки нет). + /// Текст последней ошибки входа/соединения /// public string? Error { get; } /// - /// URL QR-входа (заполнен только при phase == Qr). + /// URL QR-входа /// public string? QrUrl { get; } } diff --git a/src/telegram-service/Deal.Telegram/Sessions/TgOptions.cs b/src/telegram-service/Deal.Telegram/Sessions/TgOptions.cs index c8e180d..aecee13 100644 --- a/src/telegram-service/Deal.Telegram/Sessions/TgOptions.cs +++ b/src/telegram-service/Deal.Telegram/Sessions/TgOptions.cs @@ -1,33 +1,27 @@ namespace Deal.Telegram.Sessions; /// -/// Конфигурация хранения сессий telegram-service (план Task 9 L316–318; Ruling 3/12/13). -/// -/// Два источника — только env и корень хоста: -/// * DEAL_TELEGRAM_SESSION_KEY — ключ AES-256-GCM обёртки файлов сессий (32 байта, base64); -/// * DEAL_TELEGRAM_SESSION_DIR — каталог файлов сессий (volume /data/sessions в compose, -/// Ruling 12); по умолчанию data/sessions относительно ContentRoot. -/// Ключ — только env (Ruling 13: ключи/секреты не логируются и не читаются из appsettings). +/// Конфигурация хранения сессий telegram-service. /// public sealed class TgOptions { /// - /// Env-ключ ключа шифрования сессий (32 байта base64; Ruling 3). + /// Env-ключ ключа шифрования сессий. /// public const string SessionKeyEnvVarName = "DEAL_TELEGRAM_SESSION_KEY"; /// - /// Env-ключ каталога сессий (опционально; по умолчанию data/sessions под ContentRoot). + /// Env-ключ каталога сессий /// public const string SessionDirEnvVarName = "DEAL_TELEGRAM_SESSION_DIR"; /// - /// Относительный каталог сессий по умолчанию (под ContentRoot хоста). + /// Относительный каталог сессий по умолчанию /// public const string DefaultSessionDirRelative = "data/sessions"; /// - /// Размер ключа AES-256 (байт). + /// Размер ключа AES-256 /// public const int KeySizeBytes = 32; @@ -38,18 +32,17 @@ public sealed class TgOptions } /// - /// Ключ AES-256-GCM обёртки файлов сессий (из env DEAL_TELEGRAM_SESSION_KEY). + /// Ключ AES-256-GCM обёртки файлов сессий /// public byte[] SessionKey { get; } /// - /// Абсолютный путь к каталогу файлов сессий data/sessions/<tenant>.session. + /// Абсолютный путь к каталогу файлов сессий data/sessions/<tenant>.session. /// public string SessionsDirectory { get; } /// - /// Создаёт опции с уже известными ключом и каталогом (unit-тесты хранилища; прод-путь — - /// ). Ключ обязан быть 32 байтами AES-256. + /// Создаёт опции с уже известными ключом и каталогом /// /// Ключ AES-256-GCM обёртки файлов сессий. /// Каталог файлов сессий. @@ -69,8 +62,7 @@ public sealed class TgOptions } /// - /// Читает конфигурацию из env. Отсутствующий/некорректный DEAL_TELEGRAM_SESSION_KEY — - /// ошибка конфигурации (fail-closed: файлы сессий не могут храниться в открытом виде). + /// Читает конфигурацию из env. /// /// Конфигурация хоста (env-провайдер WebApplicationBuilder). /// Окружение хоста (ContentRootPath для каталога по умолчанию). diff --git a/src/telegram-service/Deal.Telegram/Telegram/ClientFactory.cs b/src/telegram-service/Deal.Telegram/Telegram/ClientFactory.cs index dcf278c..87b548e 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/ClientFactory.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/ClientFactory.cs @@ -1,13 +1,7 @@ namespace Deal.Telegram.Telegram; /// -/// Фабрика реальных клиентов WTelegramClient (план Task 9; Ruling 3). -/// -/// Каждый вызов создаёт изолированный клиент для сессии тенанта: api_id/api_hash — из запроса -/// (их передаёт ядро из настроек tgKeys, Ruling 3), байты сессии — расшифрованная копия файла -/// data/sessions/<tenant>.session. Анти-бан-паузы между сетевыми операциями (Ruling 3: -/// backfill 1.5–3 с/сообщение, 3–6 с/диалог, поиск 2–4 с) добавляются на операции задач каталога -/// (Task 10–11), вход/QR пауз не требуют. +/// Фабрика реальных клиентов WTelegramClient. /// public sealed class ClientFactory : ITelegramClientFactory { diff --git a/src/telegram-service/Deal.Telegram/Telegram/DialogKinds.cs b/src/telegram-service/Deal.Telegram/Telegram/DialogKinds.cs index 8e6cec3..f7d58ef 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/DialogKinds.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/DialogKinds.cs @@ -1,29 +1,27 @@ namespace Deal.Telegram.Telegram; /// -/// Канон типов диалогов/источников контракта (шапка src/contracts/telegram.proto: DialogEntry.kind, -/// ChannelInfo.kind — channel|group|forum|chat). Значения строковые 1:1 с proto; python-прототип хранит -/// русские «канал»/«группа»/«чат», на границе контракта используется EN-канон (task-1-report). +/// Канон типов диалогов/источников контракта /// public static class DialogKinds { /// - /// Канал (broadcast): kind "channel". + /// Канал (broadcast) /// public const string Channel = "channel"; /// - /// Группа (базовая или супергруппа без тем): kind "group". + /// Группа (базовая или супергруппа без тем) /// public const string Group = "group"; /// - /// Супергруппа с темами (форум): kind "forum". + /// Супергруппа с темами /// public const string Forum = "forum"; /// - /// Личный чат (пользователь): kind "chat". + /// Личный чат (пользователь) /// public const string Chat = "chat"; } diff --git a/src/telegram-service/Deal.Telegram/Telegram/DiscoveryMessage.cs b/src/telegram-service/Deal.Telegram/Telegram/DiscoveryMessage.cs index 848a413..cacf934 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/DiscoveryMessage.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/DiscoveryMessage.cs @@ -1,12 +1,7 @@ namespace Deal.Telegram.Telegram; /// -/// Сообщение выборки discovery-read для оценки кандидата в нейтральном для TL-слоя виде (план -/// Task 11; 1:1 _discovery_message_item python-прототипа telegram.py L803–816). -/// -/// В отличие от (поток каталога) здесь не нужны канальные поля — выборка -/// идёт «внутри» уже известного диалога, а темы форума помечаются topic_id/topic_title (для обычных -/// источников оба пусты). Только непустые тексты: пустые/media/service отбрасывает TL-слой, как python. +/// Сообщение выборки discovery-read для оценки кандидата в нейтральном для TL-слоя виде. /// public sealed record DiscoveryMessage { @@ -38,7 +33,7 @@ public sealed record DiscoveryMessage public long Id { get; } /// - /// Текст сообщения (непустой). + /// Текст сообщения /// public string Text { get; } @@ -48,12 +43,12 @@ public sealed record DiscoveryMessage public long DateMs { get; } /// - /// Id темы форума (для обычных источников пуст). + /// Id темы форума /// public long? TopicId { get; } /// - /// Название темы форума (для обычных источников пусто). + /// Название темы форума /// public string? TopicTitle { get; } } diff --git a/src/telegram-service/Deal.Telegram/Telegram/DiscoveryReadResult.cs b/src/telegram-service/Deal.Telegram/Telegram/DiscoveryReadResult.cs index 4f024be..46af5f4 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/DiscoveryReadResult.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/DiscoveryReadResult.cs @@ -1,21 +1,17 @@ namespace Deal.Telegram.Telegram; /// -/// Результат чтения выборки источника для оценки кандидата (план Task 11; 1:1 discovery_read -/// python-прототипа telegram.py L718–760: {ok, error, messages}). -/// -/// ok=false — история недоступна (приватный/закрытый источник без членства), error="no_history"; -/// это НЕ ошибка сессии/RPC, а нормальный ответ контракта (ReadForEvalReply.ok=false). +/// Результат чтения выборки источника для оценки кандидата. /// public sealed record DiscoveryReadResult { /// - /// Код причины ok=false: история недоступна без членства (1:1 прототип L726). + /// Код причины ok=false /// public const string NoHistoryError = "no_history"; /// - /// Пустой успешный результат (limit ≤ 0 — выборка не запрашивалась, прототип L730–732). + /// Пустой успешный результат. /// public static DiscoveryReadResult Empty { get; } = new(true, null, []); @@ -36,7 +32,7 @@ public sealed record DiscoveryReadResult } /// - /// Создаёт результат «история недоступна» (ok=false, error=no_history, сообщений нет). + /// Создаёт результат «история недоступна» /// public static DiscoveryReadResult NoHistory() => new(false, NoHistoryError, []); @@ -47,12 +43,12 @@ public sealed record DiscoveryReadResult public bool Ok { get; } /// - /// Код причины при ok=false: "no_history"; иначе null. + /// Код причины при ok=false /// public string? Error { get; } /// - /// Сообщения выборки (для обычных источников topic_id/topic_title пусты). + /// Сообщения выборки /// public IReadOnlyList Messages { get; } } diff --git a/src/telegram-service/Deal.Telegram/Telegram/ISessionClient.cs b/src/telegram-service/Deal.Telegram/Telegram/ISessionClient.cs index f7be887..14a76db 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/ISessionClient.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/ISessionClient.cs @@ -1,20 +1,12 @@ namespace Deal.Telegram.Telegram; /// -/// Абстракция клиента Telegram для сессии тенанта (план Task 9: «абстракция ISessionClient»; план -/// Task 10: операции каталога/мониторинга на том же seam). -/// -/// Это seam между логикой фаз/состояния (TenantSession, SessionFarm) и WTelegramClient: -/// реальная реализация (WTelegramSessionClient) говорит с сетью Telegram, фейки в тестах — -/// нет. Контракт повторяет шаги веб-входа прототипа (start_phone/submit_code/submit_password/ -/// qr_start L134–312): код запрашивается по номеру, код/пароль отправляются отдельными вызовами, -/// QR-вход выполняется в фоне до авторизации с обновлением URL через колбэк. -/// Сетевые ошибки и ошибки домена реализация переводит в . +/// Абстракция клиента Telegram для сессии тенанта. /// public interface ISessionClient : IAsyncDisposable { /// - /// Авторизован ли клиент (в сессии есть пользователь Telegram). + /// Авторизован ли клиент /// public bool IsAuthorized { get; } @@ -24,7 +16,7 @@ public interface ISessionClient : IAsyncDisposable public bool IsConnected { get; } /// - /// api_id приложения, под которым создан клиент (для сохранения в файл сессии). + /// api_id приложения, под которым создан клиент /// public int ApiId { get; } @@ -34,29 +26,25 @@ public interface ISessionClient : IAsyncDisposable public string ApiHash { get; } /// - /// Последние байты сессии WTelegramClient (обновляются библиотекой в момент сохранения сессии). - /// Хранилище шифрует их в файл data/sessions/<tenant>.session (Ruling 3). + /// Последние байты сессии WTelegramClient /// public byte[]? SessionBytes { get; } /// - /// Устанавливает соединение с Telegram (идемпотентно для уже соединённого клиента). + /// Устанавливает соединение с Telegram /// /// Отмена операции. public Task ConnectAsync(CancellationToken cancellationToken); /// - /// Запрашивает SMS-код для номера (start_phone L134–147). После успеха клиент готов принять код. - /// Ошибки: нет соединения/недоступен Telegram, некорректный номер. + /// Запрашивает SMS-код для номера. /// /// Номер в международном формате (как ввёл пользователь). /// Отмена операции. public Task RequestCodeAsync(string phone, CancellationToken cancellationToken); /// - /// Отправляет SMS-код (submit_code L149–166). Возвращает следующий запрашиваемый шаг: - /// "password" — включён 2FA, нужен облачный пароль; null — авторизация завершена (готово). - /// Ошибки: «Неверный код», «Код истёк — запросите новый» (INVALID_ARGUMENT). + /// Отправляет SMS-код. Возвращает следующий запрашиваемый шаг /// /// Код из SMS/Telegram-сообщения. /// Отмена операции. @@ -64,51 +52,42 @@ public interface ISessionClient : IAsyncDisposable public Task SubmitCodeAsync(string code, CancellationToken cancellationToken); /// - /// Отправляет облачный пароль 2FA (submit_password L168–176). Возвращается после успешной - /// авторизации. Ошибка: «Неверный облачный пароль» (INVALID_ARGUMENT). + /// Отправляет облачный пароль 2FA. /// /// Облачный пароль. /// Отмена операции. public Task SubmitPasswordAsync(string password, CancellationToken cancellationToken); /// - /// Выполняет QR-вход (qr_start L286–300): метод возвращается после авторизации; новые URL - /// (в т.ч. после истечения токена) приходят через до завершения. - /// Отмена токена прерывает ожидание сканирования. + /// Выполняет QR-вход /// /// Колбэк нового URL QR-входа (tg://login?token=...). /// Отмена операции (прерывает ожидание сканирования). public Task StartQrAsync(Action onQrUrl, CancellationToken cancellationToken); /// - /// Полный выход: отзывает авторизацию на стороне Telegram (Auth_LogOut). + /// Полный выход: отзывает авторизацию на стороне Telegram /// /// Отмена операции. public Task LogOutAsync(CancellationToken cancellationToken); /// - /// Возвращает строку аккаунта для статуса: "@username" авторизованного пользователя либо - /// "@user", если username не задан (1:1 _finalize L180: f"@{me.username or 'user'}"). + /// Возвращает строку аккаунта для статуса /// /// Отмена операции. public Task GetAccountAsync(CancellationToken cancellationToken); - // --- Каталог и сообщения (план Task 10; Ruling 3/7; нейтральные типы Telegram/*) --- /// - /// Список диалогов аккаунта (refresh_dialogs L505–519 / iter_dialogs). Возвращает диалоги от - /// свежих к старым (как список Telegram), верхняя граница . - /// Ошибки сети/Telegram — . + /// Список диалогов аккаунта. /// - /// Верхняя граница числа диалогов (прототип: 500). + /// Верхняя граница числа диалогов. /// Отмена операции. /// Диалоги аккаунта в нейтральном виде. public Task> GetDialogsAsync(int limit, CancellationToken cancellationToken); /// - /// Последние сообщения диалога (get_messages прототипа L371/L435/L590): от новых к старым, - /// только непустые тексты (пустые/media/service отбрасывает реализация — как python). - /// Ошибки сети/неизвестный источник — . + /// Последние сообщения диалога /// /// Подписанный id диалога (каналы "-100…", группы "-…", личные "+…"). /// Сколько последних сообщений запросить. @@ -120,8 +99,7 @@ public interface ISessionClient : IAsyncDisposable CancellationToken cancellationToken); /// - /// Помечает весь диалог прочитанным (send_read_acknowledge прототипа L277/L383/L454/L609). - /// Ошибки сети/неизвестный источник — . + /// Помечает весь диалог прочитанным. /// /// Подписанный id диалога. /// Отмена операции. @@ -129,23 +107,16 @@ public interface ISessionClient : IAsyncDisposable public Task MarkReadAsync(string dialogId, CancellationToken cancellationToken); /// - /// Событие входящего текстового сообщения аккаунта (realtime; events.NewMessage прототипа - /// L255–283). Реализация поднимает событие для всех входящих сообщений с непустым текстом; - /// фильтр по зеркалу мониторинга делает служба каталога (Ruling 7). Подписчики исполняются - /// последовательно; исключение подписчика не роняет остальных и realtime-цикл. + /// Событие входящего текстового сообщения аккаунта. /// public event Func? MessageReceived; - // --- Discovery (план Task 11; discovery_search/info/read/join/leave L622–873; Ruling 3/7) --- /// - /// Глобальный поиск каналов/групп по ключу (contacts.search прототипа L624–664). Возвращает - /// сущности результата (чаты и пользователи) нейтральными записями от chats к users; id подписанные. - /// Личные чаты/ботов (kind=chat) отсеивает ядро (Ruling 10). Пауза анти-бана 2–4 с после поиска — - /// уровень службы (DiscoveryOps), не клиента. Ошибки — . + /// Глобальный поиск каналов/групп по ключу. /// /// Поисковый запрос (ключ задачи discovery). - /// Верхняя граница результата (прототип: default 30). + /// Верхняя граница результата. /// Отмена операции. /// Найденные источники в нейтральном виде (каналы/группы/личные). public Task> SearchAsync( @@ -154,10 +125,7 @@ public interface ISessionClient : IAsyncDisposable CancellationToken cancellationToken); /// - /// Инфо об источнике для оценки кандидата (discovery_info L666–716): имя/username/kind + участники - /// из полного чата (GetFullChannel/GetFullChat) и признак форума. Сбои определения не бросаются — - /// возвращается с тем, что удалось получить (прототип наружу - /// исключения не выпускает: participants пуст, недоступная сущность — поля по умолчанию). + /// Инфо об источнике для оценки кандидата /// /// Подписанный id источника («-100…»/«-…»/«+…»). /// Отмена операции. @@ -165,14 +133,10 @@ public interface ISessionClient : IAsyncDisposable public Task GetInfoAsync(string dialogId, CancellationToken cancellationToken); /// - /// Последние сообщения источника для оценки кандидата (discovery_read L718–800): форумы читаются - /// по активным темам (GetForumTopics + по каждой теме getReplies), обычные источники — лентой; - /// темы помечаются topic_id/topic_title. История недоступна (приватный/закрытый источник) — - /// результат ok=false/error="no_history" (это НЕ ошибка сессии, прототип L752–753). Только непустые - /// тексты. Ошибка тем форума — безопасный фолбэк на обычную ленту (прототип L743–748). + /// Последние сообщения источника для оценки кандидата /// /// Подписанный id источника. - /// Размер выборки (прототип: limit сообщений/тем; limit ≤ 0 — пусто без сети). + /// Размер выборки. /// Отмена операции. /// Результат чтения выборки (ok + сообщения либо no_history). public Task ReadForEvalAsync( @@ -181,10 +145,7 @@ public interface ISessionClient : IAsyncDisposable CancellationToken cancellationToken); /// - /// Вступить в канал/группу по username (discovery_join L818–839; ручной join вне квот — паузу перед - /// авто-join делает воркер ядра, Ruling 10). Username нормализует уровень службы (DiscoveryOps). - /// FloodWait Telegram → SessionException RESOURCE_EXHAUSTED (detail с префиксом "flood", контракт - /// telegram.proto; базовый флуд-гард — здесь, суточный стоп — в ядре). + /// Вступить в канал/группу по username. /// /// Username канала/группы (без «@»). /// Отмена операции. @@ -192,8 +153,7 @@ public interface ISessionClient : IAsyncDisposable public Task JoinAsync(string username, CancellationToken cancellationToken); /// - /// Выйти из канала/группы (discovery_leave L841–848). Неизвестный/недоступный источник — - /// SessionException (уровень контракта Leave: нет диалога/членства). + /// Выйти из канала/группы. /// /// Подписанный id диалога. /// Отмена операции. diff --git a/src/telegram-service/Deal.Telegram/Telegram/ITelegramClientFactory.cs b/src/telegram-service/Deal.Telegram/Telegram/ITelegramClientFactory.cs index 68d9d37..36e2907 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/ITelegramClientFactory.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/ITelegramClientFactory.cs @@ -1,11 +1,7 @@ namespace Deal.Telegram.Telegram; /// -/// Фабрика клиентов Telegram для сессий тенантов (план Task 9, Telegram/ClientFactory.cs). -/// -/// Создаёт для сессии тенанта по ключам приложения и байтам сохранённой -/// сессии (или пустой — новый вход). Интерфейс — seam для тестов: тесты регистрируют фейковую -/// фабрику, реальный сеть Telegram не трогает до первого вызова. +/// Фабрика клиентов Telegram для сессий тенантов. /// public interface ITelegramClientFactory { @@ -14,10 +10,7 @@ public interface ITelegramClientFactory /// /// api_id приложения Telegram (настройка tgKeys тенанта, из тела запроса). /// api_hash приложения Telegram. - /// - /// Байты сохранённой сессии WTelegramClient (расшифрованная копия файла data/sessions/<tenant>.session) - /// либо null — новая сессия (нет файла или ключи приложения сменились). - /// + /// Байты сохранённой сессии WTelegramClient (расшифрованная копия файла data/sessions/<tenant>.session) либо null — новая сессия (нет файла или ключи приложения сменились). /// Клиент, готовый к ConnectAsync/логину. public ISessionClient Create( int apiId, diff --git a/src/telegram-service/Deal.Telegram/Telegram/TelegramDialog.cs b/src/telegram-service/Deal.Telegram/Telegram/TelegramDialog.cs index 410bcb5..de7b290 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/TelegramDialog.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/TelegramDialog.cs @@ -1,13 +1,7 @@ namespace Deal.Telegram.Telegram; /// -/// Диалог (источник) аккаунта в нейтральном для TL-слоя виде (план Task 10; Ruling 3/7). -/// -/// Это результат «списка диалогов» сессии (refresh_dialogs прототипа L505–519): id в подписанном -/// каноне контракта («-100…» каналы, «-…» группы, «+…» личные), отображаемое имя, username, тип -/// канона channel|group|forum|chat и счётчики для догонялки непрочитанных (realtime_sweep L427–456). -/// TL-реализация () и фейки тестов возвращают именно этот тип; -/// службы каталога не зависят от библиотеки WTelegramClient. +/// Диалог (источник) аккаунта в нейтральном для TL-слоя виде. /// public sealed record TelegramDialog { @@ -37,12 +31,12 @@ public sealed record TelegramDialog } /// - /// Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»). + /// Подписанный id диалога /// public string Id { get; } /// - /// Отображаемое имя (title/first_name) или id, если имени нет. + /// Отображаемое имя /// public string Name { get; } @@ -52,12 +46,12 @@ public sealed record TelegramDialog public string Username { get; } /// - /// Тип канона контракта: channel|group|forum|chat. + /// Тип канона контракта /// public string Kind { get; } /// - /// Число непрочитанных сообщений диалога (для догона realtime_sweep). + /// Число непрочитанных сообщений диалога /// public int UnreadCount { get; } diff --git a/src/telegram-service/Deal.Telegram/Telegram/TelegramMessage.cs b/src/telegram-service/Deal.Telegram/Telegram/TelegramMessage.cs index 2c56da3..2d4e009 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/TelegramMessage.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/TelegramMessage.cs @@ -1,12 +1,7 @@ namespace Deal.Telegram.Telegram; /// -/// Текстовое сообщение диалога в нейтральном для TL-слоя виде (план Task 10; Ruling 3/7). -/// -/// Используется тремя путями сообщений: backfill («Перечитать» L349–390), realtime-listener -/// (L255–283) и realtime_sweep (L392–456). Пустые тексты и служебные сообщения (media/service) -/// TL-слой не отдаёт — только непустой текст. Канальные поля (имя/username) нужны для PushMessage -/// в ядро (PushMessageRequest.channel_name/channel_handle, Ruling 7): hue считает служба каталога. +/// Текстовое сообщение диалога в нейтральном для TL-слоя виде. /// public sealed record TelegramMessage { @@ -41,12 +36,12 @@ public sealed record TelegramMessage public string DialogId { get; } /// - /// Id сообщения в Telegram (дубль-гвард диалога ядра). + /// Id сообщения в Telegram /// public int Id { get; } /// - /// Текст сообщения (непустой). + /// Текст сообщения /// public string Text { get; } @@ -61,7 +56,7 @@ public sealed record TelegramMessage public string DialogName { get; } /// - /// Username диалога (пуст, если нет) — для PushMessage.channel_handle. + /// Username диалога /// public string DialogHandle { get; } } diff --git a/src/telegram-service/Deal.Telegram/Telegram/TelegramSourceInfo.cs b/src/telegram-service/Deal.Telegram/Telegram/TelegramSourceInfo.cs index 9d8df17..23651de 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/TelegramSourceInfo.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/TelegramSourceInfo.cs @@ -1,12 +1,7 @@ namespace Deal.Telegram.Telegram; /// -/// Инфо об источнике для оценки кандидата discovery в нейтральном для TL-слоя виде (план Task 11; -/// 1:1 результат discovery_info python-прототипа telegram.py L666–716). -/// -/// Поля повторяют словарь прототипа {id, name, username, kind, participants, is_forum}; hue прототип -/// не хранит — его считает маппер ответа (Ruling 7: цвет считает сервис). kind — EN-канон контракта -/// channel|group|forum|chat; пустая строка — тип определить не удалось (прототип L678: kind ""). +/// Инфо об источнике для оценки кандидата discovery в нейтральном для TL-слоя виде. /// public sealed record TelegramSourceInfo { @@ -36,12 +31,12 @@ public sealed record TelegramSourceInfo } /// - /// Подписанный id диалога (каналы «-100…», группы «-…», личные «+…»). + /// Подписанный id диалога /// public string Id { get; } /// - /// Отображаемое имя (title/first_name) или id, если имени нет. + /// Отображаемое имя /// public string Name { get; } @@ -51,17 +46,17 @@ public sealed record TelegramSourceInfo public string Username { get; } /// - /// Тип канона контракта: channel|group|forum|chat (пусто — не определён). + /// Тип канона контракта /// public string Kind { get; } /// - /// Число участников (full_chat); null — определить не удалось. + /// Число участников /// public int? Participants { get; } /// - /// True — мегагруппа с темами (форум; core трактует kind как forum, Ruling 10). + /// True — мегагруппа с темами. /// public bool IsForum { get; } } diff --git a/src/telegram-service/Deal.Telegram/Telegram/TlMessageMapper.cs b/src/telegram-service/Deal.Telegram/Telegram/TlMessageMapper.cs index d03543d..0d8f729 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/TlMessageMapper.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/TlMessageMapper.cs @@ -6,20 +6,12 @@ using TL; namespace Deal.Telegram.Telegram; /// -/// Чистый маппер TL-объектов каталога/сообщений в нейтральные типы сервиса (план Task 10; Ruling 3/7). -/// -/// Статический и без зависимостей от клиента — единое место разбора, используемое WTelegramSessionClient -/// (список диалогов, история, realtime-события) и unit-тестами на фейковых TL-объектах (без сети): -/// ветки типов обновлений, подписанные id, фильтр пустых/служебных/исходящих текстов. +/// Чистый маппер TL-объектов каталога/сообщений в нейтральные типы сервиса. /// public static class TlMessageMapper { /// - /// Извлекает сообщение из «нового сообщения» обновления. Канон обновлений библиотеки: - /// обычные чаты — UpdateNewMessage; каналы/супергруппы — UpdateNewChannelMessage (подкласс - /// UpdateNewMessage); короткие UpdateShortMessage/UpdateShortChatMessage синтезируются в - /// UpdateNewMessage списком UpdateList; собственные исходящие (UpdateShortSentMessage) менеджер - /// обновлений не поднимает. Прочие обновления (edit/delete/…) → null (не «новое сообщение»). + /// Извлекает сообщение из «нового сообщения» обновления. /// /// Одно нормализованное обновление. /// Сообщение нового входящего события или null. @@ -27,7 +19,7 @@ public static class TlMessageMapper => update is UpdateNewMessage { message: MessageBase message } ? message : null; /// - /// Превращает диалог списка в нейтральный (null — нет сущности). + /// Превращает диалог списка в нейтральный /// /// Диалог из ответа getDialogs. /// Сущности чатов контейнера (по raw id). @@ -49,9 +41,7 @@ public static class TlMessageMapper } /// - /// Превращает сообщение истории/обновления в нейтральное (null — не текст/служебное/своё исходящее). - /// Пустые тексты и media/service (MessageService/MessageEmpty) отбрасываются — как python - /// `if not m.text or not m.text.strip(): continue`; исходящие (out_) тоже (incoming-семантика). + /// Превращает сообщение истории/обновления в нейтральное /// /// Сообщение (MessageBase). /// Сущности чатов контейнера. @@ -74,12 +64,9 @@ public static class TlMessageMapper return new TelegramMessage(signedId, textMessage.id, textMessage.message, ToEpochMs(textMessage.Date), name, handle); } - // --- Discovery (план Task 11; contacts.search entity → TelegramDialog, сообщение выборки → DiscoveryMessage) --- /// - /// Сущность чата/канала результата contacts.SearchRequest → запись поиска (1:1 discovery_search - /// L644–661: id подписанный, name/username из сущности, kind EN-канона; счётчики пустые — у результата - /// поиска их нет). Используется TL-слоем поиска (Search) для chats результата. + /// Сущность чата/канала результата contacts.SearchRequest → запись поиска. /// /// Сущность канала/группы из chats результата поиска. public static TelegramDialog ToFoundChat(ChatBase chat) @@ -90,8 +77,7 @@ public static class TlMessageMapper } /// - /// Сущность пользователя результата contacts.SearchRequest → запись поиска (личный чат/бот; core - /// отсеивает kind=chat сам — Ruling 10). Поля и id — как у . + /// Сущность пользователя результата contacts.SearchRequest → запись поиска. /// /// Сущность пользователя из users результата поиска. public static TelegramDialog ToFoundUser(User user) @@ -102,13 +88,12 @@ public static class TlMessageMapper } /// - /// Сообщение выборки discovery-read → нейтральное (1:1 _discovery_message_item L803–816): только - /// непустые тексты (пустые/media/service отбрасываются); темы форума помечены topicId/topicTitle. + /// Сообщение выборки discovery-read → нейтральное /// /// Сообщение ленты/темы форума (MessageBase). /// Id темы форума (для обычных источников null). /// Название темы форума (для обычных источников null). - /// Текущее время (фолбэк даты сообщения без времени, как python `now`). + /// Текущее время. public static DiscoveryMessage? ToEvalMessage( MessageBase message, long? topicId, @@ -173,7 +158,7 @@ public static class TlMessageMapper } /// - /// Тип диалога канона контракта по сущности чата/канала (Ruling 3, kind-маппинг task-1). + /// Тип диалога канона контракта по сущности чата/канала. /// /// Сущность канала/группы. public static string KindOf(ChatBase chat) @@ -194,7 +179,6 @@ public static class TlMessageMapper _ => throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId), }; - // Дата сообщения → epoch-ms; без даты (default) — текущее время (python `date or now`). // date: Дата сообщения. // now: Текущее время (фолбэк). private static long ToEpochMsOrNow(DateTime date, DateTimeOffset now) @@ -217,7 +201,6 @@ public static class TlMessageMapper private static UserBase? FindUser(Peer peer, IReadOnlyDictionary users) => peer is PeerUser user && users.TryGetValue(user.user_id, out User? regularUser) ? regularUser : null; - // DateTime (UTC от Telegram) → epoch-ms (1:1 int(date.timestamp()*1000)). // date: Время сообщения. private static long ToEpochMs(DateTime date) => new DateTimeOffset(DateTime.SpecifyKind(date, DateTimeKind.Utc)).ToUnixTimeMilliseconds(); diff --git a/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs b/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs index aa3010d..b1d0567 100644 --- a/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs +++ b/src/telegram-service/Deal.Telegram/Telegram/WTelegramSessionClient.cs @@ -9,25 +9,10 @@ using RpcException = TL.RpcException; namespace Deal.Telegram.Telegram; #pragma warning disable CS0618 // Auth_SendCode/Auth_SignIn используются осознанно: ручной веб-вход 1:1 с прототипом -// (start_phone/submit_code/submit_password L134–176); Obsolete-метки библиотеки ведут на // LoginUserIfNeeded, который умеет только интерактивный конфиг-ввод, а не наш пошаговый API. /// -/// Реальная реализация поверх WTelegramClient (план Task 9; Ruling 3). -/// -/// Сессия библиотеки живёт в памяти процесса: байты сессии (внутренне зашифрованы WTelegramClient -/// ключом api_hash) подаются в конструктор и обновляются колбэком при каждом сохранении библиотекой; -/// at-rest файл data/sessions/<tenant>.session (AES-GCM-обёртка, Ruling 3) пишет SessionStore — -/// расшифрованного файла на диске нет ни в какой момент (Ruling 3: только в памяти процесса). -/// Шаги входа повторяют python-прототип на уровне TL-методов: -/// Auth_SendCode → (код) Auth_SignIn → 2FA: Account_GetPassword + Auth_CheckPassword; QR — -/// LoginWithQRCode с колбэком новых URL. Ошибки переводятся в . -/// Операции каталога (план Task 10) ходят TL-методами messages.getDialogs/getHistory и readHistory -/// (ReadHistory клиента — generic-хелпер channels/messages); discovery (план Task 11) — contacts.search, -/// getFullChannel/getFullChat (участники/forum), getForumTopics+getReplies (чтение форумов по темам) и -/// channels.joinChannel/leaveChannel; realtime-сообщения нормализует штатный -/// библиотеки (UpdateNewMessage для всех типов, включая каналы и короткие -/// UpdateShort*) и поднимаются событием . +/// Реальная реализация поверх WTelegramClient. /// public sealed class WTelegramSessionClient : ISessionClient { @@ -52,10 +37,8 @@ public sealed class WTelegramSessionClient : ISessionClient // LRU-кэш access_hash сущностей (ключ — подписанный id диалога; заполняется из ответов). private readonly LruCache _entityAccessHashes = new(AccessHashCacheCapacity); - // LRU-кэш сущностей чатов/каналов (ключ — raw id; для имён и forum-флага discovery, Task 11). private readonly LruCache _chatsById = new(ChatEntityCacheCapacity); - // LRU-кэш сущностей пользователей (ключ — raw id; для имён discovery, Task 11). private readonly LruCache _usersById = new(UserEntityCacheCapacity); // Защита кэшей сущностей (обновляются из потоков reactor/вызовов). @@ -234,7 +217,6 @@ public sealed class WTelegramSessionClient : ISessionClient return _client.DisposeAsync(); } - // --- Каталог и сообщения (план Task 10; Ruling 3/7; TL-методы getDialogs/getHistory/readHistory) --- /// public event Func? MessageReceived; @@ -288,11 +270,9 @@ public sealed class WTelegramSessionClient : ISessionClient { InputPeer peer = await ResolvePeerAsync(dialogId, cancellationToken).ConfigureAwait(false); // Generic-хелпер библиотеки: для канала — channels.readHistory, иначе — messages.readHistory; - // max_id=0 (default) — «снять новое» по всему диалогу (1:1 send_read_acknowledge прототипа). await RunTlCallAsync(() => _client.ReadHistory(peer), cancellationToken).ConfigureAwait(false); } - // --- Discovery (план Task 11; discovery_search/info/read/join/leave L622–873; Ruling 3/7) --- /// public async Task> SearchAsync( @@ -321,9 +301,7 @@ public sealed class WTelegramSessionClient : ISessionClient /// public async Task GetInfoAsync(string dialogId, CancellationToken cancellationToken) { - // discovery_info L666–716: определение никогда не бросает наружу — недоступная сущность/полный // чат дают инфо по умолчанию (name=id, kind пуст, participants пуст), сбой участников не роняет - // остальные поля (прототип: исключение только логируется). TelegramSourceInfo unknown = DefaultSourceInfo(dialogId); if (!TryParseSignedId(dialogId, out bool isChannel, out bool isChat, out bool isUser, out long rawId)) { @@ -361,7 +339,6 @@ public sealed class WTelegramSessionClient : ISessionClient int limit, CancellationToken cancellationToken) { - // discovery_read L718–760: limit ≤ 0 — пустой ok без сетевых вызовов (L730–732). if (limit <= 0) { return DiscoveryReadResult.Empty; @@ -379,7 +356,6 @@ public sealed class WTelegramSessionClient : ISessionClient } catch (SessionException) { - // Сущность не разрешилась (приватный/закрытый источник без членства) → no_history (L736–739). return DiscoveryReadResult.NoHistory(); } @@ -388,7 +364,6 @@ public sealed class WTelegramSessionClient : ISessionClient IReadOnlyList forumMessages = await ReadForumTopicsAsync(peer, limit, cancellationToken).ConfigureAwait(false); if (forumMessages.Count > 0) { - // Форум прочитан по темам (L746–747: непустой результат тем — ответ, без ленты). return new DiscoveryReadResult(true, null, forumMessages); } } @@ -403,7 +378,6 @@ public sealed class WTelegramSessionClient : ISessionClient } catch (SessionException) { - // История недоступна (приватный/закрытый) → ok=false no_history (L751–753), не ошибка RPC. return DiscoveryReadResult.NoHistory(); } } @@ -411,7 +385,6 @@ public sealed class WTelegramSessionClient : ISessionClient /// public async Task JoinAsync(string username, CancellationToken cancellationToken) { - // discovery_join L818–839: username → сущность → channels.JoinChannel (для мегагрупп/каналов). Contacts_ResolvedPeer resolved = await RunTlCallAsync(() => _client.Contacts_ResolveUsername(username), cancellationToken).ConfigureAwait(false); CacheEntities(resolved.chats.Values, resolved.users.Values); @@ -427,7 +400,6 @@ public sealed class WTelegramSessionClient : ISessionClient /// public async Task LeaveAsync(string dialogId, CancellationToken cancellationToken) { - // discovery_leave L841–848: channels.LeaveChannel по подписанному id (каналы/супергруппы). if (!TryParseSignedId(dialogId, out bool isChannel, out _, out _, out long rawId) || !isChannel) { throw new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.InvalidDialogId); @@ -438,7 +410,6 @@ public sealed class WTelegramSessionClient : ISessionClient } // Инфо о канале/супергруппе: entity из кэша/полного чата, участники — GetFullChannel best-effort - // (1:1 L686–715: entity недоступен → default; участники недоступны → остальные поля остаются). // dialogId: Подписанный id (для имени по умолчанию). // rawId: Raw id канала. // unknown: Инфо по умолчанию (сущность недоступна). @@ -457,12 +428,10 @@ public sealed class WTelegramSessionClient : ISessionClient if (cached is not null) { - // Сущность известна (поиск/каталог): имя/kind/forum — сразу, участники — best-effort (L700–715). int? participants = await TryFetchChannelParticipantsAsync(dialogId, rawId, cancellationToken).ConfigureAwait(false); return DescribeChannel(dialogId, cached, participants); } - // Неизвестная сущность: полный чат принесёт её; недоступен → default (как entity-not-found L686–690). InputChannel input = await ResolveInputChannelAsync(dialogId, rawId, cancellationToken).ConfigureAwait(false); Messages_ChatFull full = await RunTlCallAsync(() => _client.Channels_GetFullChannel(input), cancellationToken).ConfigureAwait(false); CacheEntities(full.chats.Values, full.users.Values); @@ -477,7 +446,6 @@ public sealed class WTelegramSessionClient : ISessionClient return DescribeChannel(dialogId, channel, fullParticipants); } - // Участники канала best-effort: сбой полного чата → null, имя/kind не роняются (L713–715). // dialogId: Подписанный id. // rawId: Raw id канала. // cancellationToken: Отмена операции. @@ -537,7 +505,6 @@ public sealed class WTelegramSessionClient : ISessionClient return DescribeGroup(dialogId, group, fullParticipants); } - // Список участников базовой группы best-effort: сбой → null (нет членства/приватная, L713–715). // rawId: Raw id группы. // cancellationToken: Отмена операции. private async Task TryFetchBasicParticipantsAsync(long rawId, CancellationToken cancellationToken) @@ -575,7 +542,6 @@ public sealed class WTelegramSessionClient : ISessionClient // Инфо о личном чате/боте: имя из кэша сущности (полного чата у людей нет). // dialogId: Подписанный id. // rawId: Raw id пользователя. - // unknown: Инфо по умолчанию (сущность неизвестна — как entity-not-found прототипа). private TelegramSourceInfo GetUserInfo( string dialogId, long rawId, @@ -602,7 +568,6 @@ public sealed class WTelegramSessionClient : ISessionClient return new TelegramSourceInfo(dialogId, name, username, DialogKinds.Chat, participants: null, isForum: false); } - // Выборка по активным темам форума: GetForumTopics + по каждой теме getReplies (L762–800). // peer: Peer форума. // limit: Размер выборки (раскладывается по темам). // cancellationToken: Отмена операции. @@ -615,7 +580,6 @@ public sealed class WTelegramSessionClient : ISessionClient var outMessages = new List(); try { - // channels/messages.getForumTopics (L771–775): до 5 активных тем, без смещения. Messages_ForumTopics forum = await RunTlCallAsync( () => _client.Messages_GetForumTopics(peer, offset_date: default, offset_id: 0, offset_topic: 0, limit: ForumTopicsLimit), cancellationToken).ConfigureAwait(false); @@ -627,14 +591,12 @@ public sealed class WTelegramSessionClient : ISessionClient return outMessages; } - // На тему минимум 3 сообщения, cap 10 (прототип L784); суммарно выборка может слегка превысить limit. int perTopic = Math.Min(Math.Max(3, (int)Math.Ceiling(limit / (double)topics.Length)), ForumMessagesPerTopicCap); DateTimeOffset now = DateTimeOffset.UtcNow; foreach (ForumTopic topic in topics) { try { - // get_messages(reply_to=topic.id) эквивалент: messages.getReplies (L792, Fix round 1). Messages_MessagesBase result = await RunTlCallAsync( () => _client.Messages_GetReplies(peer, topic.id, limit: perTopic), cancellationToken).ConfigureAwait(false); @@ -651,13 +613,11 @@ public sealed class WTelegramSessionClient : ISessionClient } catch (SessionException) { - // Тема не прочиталась — пропуск (прототип L793–795: continue). } } } catch (SessionException) { - // getForumTopics недоступен — безопасный фолбэк на обычную ленту (L777–779). outMessages.Clear(); } @@ -703,7 +663,6 @@ public sealed class WTelegramSessionClient : ISessionClient participants, isForum: (channel.flags & Channel.Flags.forum) != 0); - // Отображаемое имя сущности (title/first_name) или id (как get_display_name прототипа). // dialogId: Подписанный id (фолбэк имени). // chat: Сущность чата/канала. private static string DisplayName(string dialogId, ChatBase chat) @@ -712,7 +671,6 @@ public sealed class WTelegramSessionClient : ISessionClient return title.Length > 0 ? title : dialogId; } - // True — канал из кэша сущностей является форумом (темы; entity.forum прототипа L698). // rawId: Raw id канала. private bool IsForumChannel(long rawId) { @@ -724,15 +682,11 @@ public sealed class WTelegramSessionClient : ISessionClient } } - // Сколько активных тем форума запрашивает чтение выборки (getForumTopics limit=5, L773). private const int ForumTopicsLimit = 5; - // Потолок сообщений на тему форума (cap 10, прототип L784). private const int ForumMessagesPerTopicCap = 10; - // --- Realtime-события (план Task 10; прототип _on_message L255–283) --- - // Единый колбэк штатного UpdateManager (см. _updateManager): вызывается // последовательно на каждое обновление в правильном порядке (без пропусков/дублей по pts). // Все типы новых сообщений библиотека нормализует в TL.UpdateNewMessage: // * UpdateNewChannelMessage (каналы/супергруппы) — подкласс UpdateNewMessage; @@ -810,7 +764,6 @@ public sealed class WTelegramSessionClient : ISessionClient private void OnSessionSaved(byte[] sessionBytes) => Volatile.Write(ref _latestSessionBytes, sessionBytes); - // Запрашивает код с одним повтором при AUTH_RESTART (как LoginUserIfNeeded L1202–1205). private async Task SendCodeOnceAsync(string phone, CancellationToken cancellationToken) { try @@ -849,7 +802,6 @@ public sealed class WTelegramSessionClient : ISessionClient // Переводит RpcException Telegram в SessionException: FloodWait — RESOURCE_EXHAUSTED // (detail с префиксом "flood", контракт telegram.proto); 400-ошибки входных данных — - // INVALID_ARGUMENT (текст RPC как detail, как у прототипа: ошибка показывается как есть); // остальные серверные/сетевые сбои — UNAVAILABLE «Telegram недоступен…» (безопасный повтор). // exception: Исключение RPC Telegram. private static SessionException MapRpcException(RpcException exception) @@ -867,7 +819,6 @@ public sealed class WTelegramSessionClient : ISessionClient return new SessionException(StatusCode.Unavailable, SessionErrorMessages.TelegramUnavailable, exception); } - // --- Приватные помощники каталога/сообщений (план Task 10) --- // Исполняет TL-вызов с единым переводом ошибок (RpcException Telegram → SessionException; // прочие сбои — UNAVAILABLE «Telegram недоступен…»). diff --git a/src/telegram-service/Deal.Telegram/TelegramServiceHost.cs b/src/telegram-service/Deal.Telegram/TelegramServiceHost.cs index e94f219..18f5584 100644 --- a/src/telegram-service/Deal.Telegram/TelegramServiceHost.cs +++ b/src/telegram-service/Deal.Telegram/TelegramServiceHost.cs @@ -12,40 +12,17 @@ using Deal.Telegram.Telegram; namespace Deal.Telegram; /// -/// Собирает WebApplication gRPC-хоста telegram-service (план Task 2/9/10; L227–238 + задачи сессий и -/// диалогов/мониторинга). -/// -/// Продакшн-точка входа вызывает из Program.cs (порт из env GRPC_PORT/PORT); -/// интеграционные тесты (Deal.Telegram.Tests) — из своего процесса на эфемерном порту, поэтому -/// конфигурация хоста живёт здесь один раз и не дублируется в тестах. -/// Транспорт/AddGrpc/health — общая серверная обвязка (Deal.Grpc.Hosting, -/// C31): mTLS (env DEAL_MTLS_*, Ruling 6/Task 13), Kestrel HTTP/2, интерцепторы service-token и -/// access-лога, gRPC-health; здесь — только регистрации логики telegram-service. -/// Регистрации задачи сессий: хранение (TgOptions/SessionFileCipher/SessionStore), ферма сессий -/// (SessionFarm + ClientFactory), фоновый цикл auto_resume/heartbeat (SessionHeartbeatService); -/// обязательный env DEAL_TELEGRAM_SESSION_KEY проверяется при сборке хоста (fail-closed, Ruling 13). -/// Регистрации задачи диалогов/мониторинга (Task 10, Ruling 7): DialogCatalog, исходящий канал в ядро -/// (ICoreIngressClient/CoreIngressClient — SERVICES__CORE__INGRESS), BackfillService с анти-бан- -/// пейсером и фоновые циклы RealtimeSweepService/RealtimeMonitorService. +/// Собирает WebApplication gRPC-хоста telegram-service. /// public static class TelegramServiceHost { /// - /// Создаёт (не запускает) хост: общая обвязка GrpcServer (Kestrel HTTP/2 на 0.0.0.0:grpcPort, - /// dev — plaintext + service-token, Ruling 2; при DEAL_MTLS_ENABLED=1 — HTTPS с серверным - /// сертификатом и требованием клиентского, Ruling 6/Task 13), затем регистрации сессий и - /// диалогов/мониторинга и маппинг . + /// Создаёт (не запускает) хост /// /// TCP-порт Kestrel. /// Аргументы командной строки (Program.cs); в тестах не нужны. - /// - /// Опциональный хук DI для тестов (подмена зависимостей фейками, напр. ITelegramClientFactory). - /// - /// - /// Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog - /// (DealLogging.Configure, Ruling 7/Task 14). Тесты хост поднимают БЕЗ этого хука — логирование - /// файлов/консоли тестам не нужно. - /// + /// Опциональный хук DI для тестов (подмена зависимостей фейками, напр. ITelegramClientFactory). + /// Опциональный хук конфигурации билдера для production-точки входа (Program.cs): Serilog. Тесты хост поднимают БЕЗ этого хука — логирование файлов/консоли тестам не нужно. /// Собранный хост; запуск — StartAsync/RunAsync у вызывающего. public static WebApplication Create( int grpcPort, @@ -57,8 +34,6 @@ public static class TelegramServiceHost // Общая серверная обвязка (Deal.Grpc.Hosting, C31): mTLS env DEAL_MTLS_* — загрузка // сертификатов сразу с fail-fast (compose-prod монтирует deploy/certs, scripts/mtls-certs.sh); - // Kestrel HTTP/2 (dev — plaintext + обязательный service-token, Ruling 2); AddGrpc - // (access-лог первым, затем service-token, потолок сообщения) и gRPC-health (Ruling 12). // Один экземпляр mtlsCertificates используют и Kestrel ниже, и исходящий канал в ядро // (CoreIngressClient). MtlsCertificates? mtlsCertificates = GrpcServer.LoadMtlsCertificates(builder); @@ -66,7 +41,6 @@ public static class TelegramServiceHost builder.Services.AddDealGrpcServer(); builder.Services.AddReadyHealthCheck("хост telegram-service готов"); - // Сессии тенантов (задача «сессии и QR-подключение», план Task 9; Ruling 3): хранилище // файлов data/sessions/.session (AES-GCM, ключ из env), пул 1 аккаунт/тенант и // фоновый цикл auto_resume/heartbeat (30 с). DEAL_TELEGRAM_SESSION_KEY обязателен — // иначе хост не стартует (сессии не могут храниться в открытом виде). @@ -78,13 +52,6 @@ public static class TelegramServiceHost builder.Services.AddSingleton(); builder.Services.AddHostedService(); - /// Диалоги и мониторинг (задача «диалоги/backfill/мониторинг», план Task 10; Ruling 7): зеркало - /// каталога/мониторинга (DialogCatalog), исходящий канал в ядро (CoreIngressClient — адрес - /// SERVICES__CORE__INGRESS, Ruling 12), backfill с анти-бан-паузами и фоновые циклы: - /// догон непрочитанных (RealtimeSweepService, 30 с) и reconcile realtime-listener'ов - /// (RealtimeMonitorService). Discovery-операции (план Task 11): DiscoveryOps поверх SessionFarm - /// и общего анти-бан-пейсера (поиск 2–4 с, Ruling 3). configureServices (тесты) может подменить - /// ICoreIngressClient/пейсер. CoreIngressOptions ingressOptions = CoreIngressOptions.FromConfiguration(builder.Configuration); builder.Services.AddSingleton(ingressOptions); builder.Services.AddSingleton(provider => new CoreIngressClient( diff --git a/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs b/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs index 5880114..4aee187 100644 --- a/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs +++ b/src/telegram-service/Deal.Telegram/TelegramServiceImpl.cs @@ -10,31 +10,17 @@ using Grpc.Core; namespace Deal.Telegram; /// -/// Реализация серверной стороны Deal.Grpc.Telegram.TelegramService — команды core → -/// telegram-service (telegram.proto, контракты Task 1; Ruling 1/7). -/// -/// Реализованы RPC подключения аккаунта (задача «сессии и QR-подключение», план Task 9, Ruling 3): -/// GetStatus/StartPhone/StartQr/SendCode/SendPassword/Logout поверх -/// (1 аккаунт на тенанта; tenantId только из metadata, полю не доверяем — Ruling 1). Доменные ошибки -/// (SessionException) переводятся в RPC-статусы контракта (detail = текст 1:1, шапка telegram.proto). -/// RPC каталога/мониторинга (план Task 10, Ruling 7): RefreshDialogs/SetMonitor/SetMonitorAll/Backfill/ -/// ReadRecent поверх SessionFarm + DialogCatalog (зеркало мониторинга) + BackfillService. -/// RPC discovery (план Task 11): Search/GetInfo/ReadForEval/Join/Leave поверх SessionFarm через -/// DiscoveryOps (поиск с анти-бан-паузой 2–4 с, форумы по темам, join по username вне квот — паузу перед -/// авто-join делает воркер ядра, Ruling 10). IngressService здесь сервером не выставляется (его сервер — -/// Deal.Api, Ruling 7; сервис — клиент через CoreIngressClient). +/// Реализация серверной стороны Deal.Grpc.Telegram.TelegramService — команды core → telegram-service. /// public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase { /// - /// Ключ gRPC-metadata с id тенанта (единственный источник принадлежности — Ruling 1). + /// Ключ gRPC-metadata с id тенанта. /// public const string TenantIdMetadataKey = "tenant-id"; - // Верхняя граница списка диалогов refresh (как refresh_dialogs L510: limit=500). private const int DialogListLimit = 500; - // Лимит превью по умолчанию, если core не передал (dialog_messages прототипа, limit=24). private const int PreviewDefaultLimit = 24; // Максимальный лимит превью (контракт ReadRecentRequest: 1..50). @@ -78,10 +64,9 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase _logger = logger; } - // --- Подключение аккаунта и статус (Ruling 3, WTelegramClient) --- /// - /// GetStatus — статус и фаза входа аккаунта тенанта (status L103–119). + /// GetStatus — статус и фаза входа аккаунта тенанта. /// public override async Task GetStatus(GetStatusRequest request, ServerCallContext context) { @@ -115,7 +100,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase } /// - /// StartPhone — запросить код по номеру телефона (start_phone L134–147). + /// StartPhone — запросить код по номеру телефона. /// public override async Task StartPhone(StartPhoneRequest request, ServerCallContext context) { @@ -131,7 +116,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase } /// - /// StartQr — начать вход по QR (qr_start L286–300): фаза + qrUrl. + /// StartQr — начать вход по QR /// public override async Task StartQr(StartQrRequest request, ServerCallContext context) { @@ -153,7 +138,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase } /// - /// SendCode — отправить SMS-код входа (submit_code L149–166). + /// SendCode — отправить SMS-код входа. /// public override async Task SendCode(SendCodeRequest request, ServerCallContext context) { @@ -169,7 +154,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase } /// - /// SendPassword — облачный пароль 2FA (submit_password L168–176). + /// SendPassword — облачный пароль 2FA. /// public override async Task SendPassword(SendPasswordRequest request, ServerCallContext context) { @@ -185,7 +170,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase } /// - /// Logout — отключить аккаунт, удалить сессию тенанта (disconnect L189–207). + /// Logout — отключить аккаунт, удалить сессию тенанта. /// public override async Task Logout(LogoutRequest request, ServerCallContext context) { @@ -197,15 +182,13 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase "logout", context).ConfigureAwait(false); - // Отключение аккаунта: зеркало мониторинга очищается (прототип L203: `_monitored.clear()`). _catalog.Reset(tenantId); return new LogoutReply { Ok = true }; } - // --- Каталог и мониторинг (задача Task 10; Ruling 7) --- /// - /// RefreshDialogs — актуальный каталог диалогов аккаунта (refresh_dialogs L505–519). + /// RefreshDialogs — актуальный каталог диалогов аккаунта. /// public override async Task RefreshDialogs(RefreshDialogsRequest request, ServerCallContext context) { @@ -222,7 +205,6 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase List entries = dialogs.Select(DialogProtoMapper.ToEntry).ToList(); _catalog.ReplaceKnown(tenantId, dialogs.Select(dialog => dialog.Id).ToList()); - // Актуализация зеркала мониторинга ответом SyncDialogs (Ruling 7). Сбой ядра не роняет // ответ — зеркало догонит realtime_sweep следующим циклом. await SyncCatalogSilentlyAsync(tenantId, entries, ct).ConfigureAwait(false); @@ -236,7 +218,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase } /// - /// SetMonitor — включить/выключить мониторинг диалога (set_monitor L536–546). + /// SetMonitor — включить/выключить мониторинг диалога. /// public override Task SetMonitor(SetMonitorRequest request, ServerCallContext context) { @@ -253,7 +235,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase } /// - /// SetMonitorAll — мониторинг всех диалогов каталога (set_monitor_all L548–567). + /// SetMonitorAll — мониторинг всех диалогов каталога. /// public override Task SetMonitorAll(SetMonitorAllRequest request, ServerCallContext context) { @@ -269,7 +251,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase } /// - /// Backfill — перечитать последние сообщения диалога в ядро (backfill L568–582/«Перечитать»). + /// Backfill — перечитать последние сообщения диалога в ядро. /// public override async Task Backfill(BackfillRequest request, ServerCallContext context) { @@ -285,7 +267,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase } /// - /// ReadRecent — последние сообщения диалога для превью (dialog_messages L583–620). + /// ReadRecent — последние сообщения диалога для превью. /// public override async Task ReadRecent(ReadRecentRequest request, ServerCallContext context) { @@ -304,7 +286,6 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase var result = new ReadRecentReply(); result.Messages.AddRange(messages.Select(DialogProtoMapper.ToPreview)); - // Вручную вытащили сообщения — снимаем «новое» в Telegram (прототип L607–611). await _sessionFarm.MarkReadAsync(tenantId, request.DialogId, ct).ConfigureAwait(false); return result; }, @@ -313,10 +294,9 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase return reply; } - // --- Discovery-операции (задача Task 11; Ruling 3/7/10) --- /// - /// Search — глобальный поиск каналов/групп по ключу (discovery_search L624–664). + /// Search — глобальный поиск каналов/групп по ключу. /// public override async Task Search(SearchRequest request, ServerCallContext context) { @@ -341,7 +321,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase } /// - /// GetInfo — инфо об источнике для оценки кандидата (discovery_info L666–716). + /// GetInfo — инфо об источнике для оценки кандидата. /// public override async Task GetInfo(GetInfoRequest request, ServerCallContext context) { @@ -363,7 +343,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase } /// - /// ReadForEval — выборка сообщений источника для оценки (discovery_read L718–800). + /// ReadForEval — выборка сообщений источника для оценки. /// public override async Task ReadForEval(ReadForEvalRequest request, ServerCallContext context) { @@ -385,7 +365,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase } /// - /// Join — вступить в канал/группу по @username (discovery_join L818–839; вне квот, Ruling 10). + /// Join — вступить в канал/группу по @username. /// public override async Task Join(JoinRequest request, ServerCallContext context) { @@ -406,7 +386,7 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase } /// - /// Leave — выйти из канала/группы (discovery_leave L841–848). + /// Leave — выйти из канала/группы. /// public override async Task Leave(LeaveRequest request, ServerCallContext context) { @@ -425,7 +405,6 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase return new LeaveReply { Ok = true }; } - // Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, Ruling 1). // context: Контекст вызова. private static string RequireTenantId(ServerCallContext context) { @@ -469,7 +448,6 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase } // Best-effort-синхронизация зеркала мониторинга с ядром (SyncDialogs): сбой (ядро недоступно) - // логируется и не роняет команду — упущенное догоняет realtime_sweep (Ruling 7/план Task 10). // tenantId: Id тенанта. // entries: Актуальный каталог диалогов. // cancellationToken: Отмена операции. @@ -490,11 +468,9 @@ public sealed class TelegramServiceImpl : TelegramService.TelegramServiceBase } // Исполняет операцию сессии с единым переводом ошибок: SessionException → RPC-статус контракта, - // прочие — UNAVAILABLE «Telegram недоступен…» + структурированный лог (аудит команд, Ruling 13). // TResult: Тип результата операции. // tenantId: Id тенанта (для лога аудита). // operation: Операция сессии. - // action: Действие (имя метода прототипа, для лога). // context: Контекст вызова gRPC. private async Task ExecuteAsync( string tenantId,