Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63242775ee | ||
|
|
e81f1ebf30 | ||
|
|
babfbf8006 |
@@ -1,19 +0,0 @@
|
||||
# Dev-edge «Дейла» (compose.dev.yml, сервис frontend): SPA + /api на core.
|
||||
# Отличие от prod-Caddyfile: HTTP без TLS и без плейсхолдер-домена — для локального просмотра UI.
|
||||
# Статика — собранный SPA (Vite) в /srv, неизвестные пути отдают index.html (история браузера).
|
||||
|
||||
:80 {
|
||||
# API core: /api/* уходит на core:5080 без перезаписи (контракт /api неизменен).
|
||||
# SSE (/api/events), файлы и QR-SVG проходят reverse_proxy потоково.
|
||||
handle /api/* {
|
||||
reverse_proxy core:5080
|
||||
}
|
||||
|
||||
handle {
|
||||
# SPA/ассеты в dev не кэшируем: пересборка фронта должна подхватываться по F5.
|
||||
header Cache-Control "no-cache"
|
||||
root * /srv
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
@@ -254,18 +254,6 @@ services:
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
# Фронтенд (SPA) — сборка образа (Vite) и отдача через Caddy; /api → core:5080. UI — http://localhost:8080.
|
||||
frontend:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: src/frontend/Dockerfile
|
||||
container_name: deal-frontend
|
||||
ports:
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
core:
|
||||
condition: service_healthy
|
||||
|
||||
# Prometheus (профиль observability, этап 12/пакет A) — сбор /metrics всех 4 процессов (:9464)
|
||||
# внутри dev-сети. Подъём: docker compose -f deploy/compose.dev.yml --profile observability up -d.
|
||||
# Конфиг — общий deploy/observability/prometheus.yml (те же имена сервисов и таргеты). UI — 9090.
|
||||
@@ -344,56 +332,6 @@ services:
|
||||
- /:/host/root:ro
|
||||
pid: host
|
||||
|
||||
# Loki — хранилище логов (профиль observability), UI/API — :3100.
|
||||
loki:
|
||||
image: grafana/loki:3.4.2
|
||||
container_name: deal-loki
|
||||
profiles: ["observability"]
|
||||
command: -config.file=/etc/loki/loki.yml
|
||||
ports:
|
||||
- "3100:3100"
|
||||
volumes:
|
||||
- ./observability/loki.yml:/etc/loki/loki.yml:ro
|
||||
- deal_loki_data:/loki
|
||||
|
||||
# Promtail — сбор docker-логов deal-процессов в Loki (docker.sock, профиль observability).
|
||||
promtail:
|
||||
image: grafana/promtail:3.4.2
|
||||
container_name: deal-promtail
|
||||
profiles: ["observability"]
|
||||
command: -config.file=/etc/promtail/promtail.yml
|
||||
volumes:
|
||||
- ./observability/promtail.yml:/etc/promtail/promtail.yml:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- deal_promtail_data:/var/lib/promtail
|
||||
depends_on:
|
||||
loki:
|
||||
condition: service_started
|
||||
|
||||
# Grafana — UI логов/метрик/трейсов (профиль observability), локальный вход admin/admin.
|
||||
grafana:
|
||||
image: grafana/grafana:11.5.2
|
||||
container_name: deal-grafana
|
||||
profiles: ["observability"]
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: admin
|
||||
GF_SECURITY_ADMIN_PASSWORD: admin
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
GF_AUTH_ANONYMOUS_ENABLED: "false"
|
||||
ports:
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
- deal_grafana_data:/var/lib/grafana
|
||||
depends_on:
|
||||
loki:
|
||||
condition: service_started
|
||||
prometheus:
|
||||
condition: service_started
|
||||
tempo:
|
||||
condition: service_started
|
||||
|
||||
volumes:
|
||||
deal_pgdata:
|
||||
deal_minio_data:
|
||||
@@ -402,6 +340,3 @@ volumes:
|
||||
deal_api_data:
|
||||
deal_prometheus_data:
|
||||
deal_tempo_data:
|
||||
deal_loki_data:
|
||||
deal_promtail_data:
|
||||
deal_grafana_data:
|
||||
|
||||
@@ -202,7 +202,9 @@ public static class CardDetailsEndpoints
|
||||
return EndpointResults.BadRequest(result.Error);
|
||||
}
|
||||
|
||||
return await ReadCardAsync(context, cardId, ct);
|
||||
return result.Card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка.
|
||||
@@ -218,8 +220,10 @@ public static class CardDetailsEndpoints
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
await service.RemoveLinkAsync(cardId, linkId, ct);
|
||||
return await ReadCardAsync(context, cardId, ct);
|
||||
CardResultDto result = await service.RemoveLinkAsync(cardId, linkId, ct);
|
||||
return result.Card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files).
|
||||
@@ -236,7 +240,10 @@ public static class CardDetailsEndpoints
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
await cardsService.GetCardAsync(cardId, ct);
|
||||
if (await cardsService.GetCardAsync(cardId, ct) is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
IFormCollection form;
|
||||
try
|
||||
@@ -350,7 +357,9 @@ public static class CardDetailsEndpoints
|
||||
return EndpointResults.BadRequest(result.Error);
|
||||
}
|
||||
|
||||
return await ReadCardAsync(context, cardId, ct);
|
||||
return result.Card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: await ReadCardAsync(context, cardId, ct);
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка.
|
||||
@@ -365,8 +374,9 @@ public static class CardDetailsEndpoints
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
await service.ClearReminderAsync(cardId, ct);
|
||||
return await ReadCardAsync(context, cardId, ct);
|
||||
return await service.ClearReminderAsync(cardId, ct)
|
||||
? await ReadCardAsync(context, cardId, ct)
|
||||
: EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка.
|
||||
@@ -381,8 +391,9 @@ public static class CardDetailsEndpoints
|
||||
}
|
||||
|
||||
CardsService service = context.RequestServices.GetRequiredService<CardsService>();
|
||||
await service.SnoozeReminderAsync(cardId, ct);
|
||||
return await ReadCardAsync(context, cardId, ct);
|
||||
return await service.SnoozeReminderAsync(cardId, ct)
|
||||
? await ReadCardAsync(context, cardId, ct)
|
||||
: EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
// Читает карточку через единый сервис и возвращает её как ответ (404 — карточки нет).
|
||||
@@ -401,7 +412,11 @@ public static class CardDetailsEndpoints
|
||||
CancellationToken ct)
|
||||
{
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto card = await cardsService.GetCardAsync(cardId, ct);
|
||||
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
SourceContent content = await cardsService.ResolveSourceAsync(card, ct);
|
||||
return Results.Ok(content);
|
||||
@@ -413,8 +428,10 @@ public static class CardDetailsEndpoints
|
||||
CancellationToken ct)
|
||||
{
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto card = await cardsService.GetCardAsync(cardId, ct);
|
||||
return Results.Ok(card);
|
||||
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
|
||||
return card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: Results.Ok(card);
|
||||
}
|
||||
|
||||
// Имя файла для Content-Disposition без кавычек «"».
|
||||
|
||||
@@ -128,8 +128,10 @@ public static class CardsEndpoints
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto card = await cardsService.GetCardAsync(cardId, ct);
|
||||
return Results.Ok(card);
|
||||
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
|
||||
return card is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: Results.Ok(card);
|
||||
}
|
||||
|
||||
private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct)
|
||||
@@ -182,11 +184,18 @@ public static class CardsEndpoints
|
||||
return EndpointResults.BadRequest(outcome.Error);
|
||||
}
|
||||
|
||||
if (!outcome.Exists)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct);
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto unified = await cardsService.GetCardAsync(cardId, ct);
|
||||
return Results.Ok(unified);
|
||||
CardDto? unified = await cardsService.GetCardAsync(cardId, ct);
|
||||
return unified is null
|
||||
? EndpointResults.NotFound(CardNotFoundDetail)
|
||||
: Results.Ok(unified);
|
||||
}
|
||||
|
||||
private static async Task<IResult> TrashAsync(
|
||||
@@ -344,7 +353,11 @@ public static class CardsEndpoints
|
||||
}
|
||||
|
||||
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||
CardDto card = await cardsService.GetCardAsync(cardId, ct);
|
||||
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||
}
|
||||
|
||||
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
|
||||
ReclassifyResultDto result = await reclassifier.ReclassifyCardAsync(card, ct);
|
||||
|
||||
@@ -55,6 +55,9 @@ public static class DiscoveryEndpoints
|
||||
// Путь лога задачи (GET).
|
||||
private const string TaskLogPath = "/tasks/{task_id}/log";
|
||||
|
||||
// 404: кандидат не найден.
|
||||
private const string CandidateNotFoundDetail = "Кандидат не найден";
|
||||
|
||||
private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
|
||||
|
||||
private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов";
|
||||
@@ -278,7 +281,11 @@ public static class DiscoveryEndpoints
|
||||
}
|
||||
|
||||
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
|
||||
DiscoveryCandidateDto row = await candidates.GetAsync(dialog_id, ct);
|
||||
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CandidateNotFoundDetail);
|
||||
}
|
||||
|
||||
if (row.Status == DiscoveryCandidateStatuses.Joined)
|
||||
{
|
||||
@@ -307,8 +314,8 @@ public static class DiscoveryEndpoints
|
||||
|
||||
try
|
||||
{
|
||||
DiscoveryCandidateDto joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct);
|
||||
return Results.Ok(joined);
|
||||
DiscoveryCandidateDto? joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct);
|
||||
return joined is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(joined);
|
||||
}
|
||||
catch (DiscoveryValidationException exception)
|
||||
{
|
||||
@@ -327,7 +334,11 @@ public static class DiscoveryEndpoints
|
||||
}
|
||||
|
||||
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
|
||||
DiscoveryCandidateDto row = await candidates.GetAsync(dialog_id, ct);
|
||||
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return EndpointResults.NotFound(CandidateNotFoundDetail);
|
||||
}
|
||||
|
||||
if (row.Status == DiscoveryCandidateStatuses.Joined)
|
||||
{
|
||||
@@ -336,8 +347,8 @@ public static class DiscoveryEndpoints
|
||||
|
||||
try
|
||||
{
|
||||
DiscoveryCandidateDto rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct);
|
||||
return Results.Ok(rejected);
|
||||
DiscoveryCandidateDto? rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct);
|
||||
return rejected is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(rejected);
|
||||
}
|
||||
catch (DiscoveryValidationException exception)
|
||||
{
|
||||
|
||||
@@ -37,6 +37,8 @@ public static class MlEndpoints
|
||||
|
||||
private const string EnterTextDetail = "Введите текст";
|
||||
|
||||
private const string MessageNotFoundDetail = "Исходное сообщение не найдено";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/ml
|
||||
/// </summary>
|
||||
@@ -139,7 +141,12 @@ public static class MlEndpoints
|
||||
}
|
||||
|
||||
MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>();
|
||||
MlApplyResult result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct);
|
||||
MlApplyResult? result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct);
|
||||
if (result is null)
|
||||
{
|
||||
return EndpointResults.NotFound(MessageNotFoundDetail);
|
||||
}
|
||||
|
||||
if (result.Error is not null)
|
||||
{
|
||||
return EndpointResults.BadRequest(result.Error);
|
||||
|
||||
@@ -144,7 +144,12 @@ public static class OperatorTenantsEndpoints
|
||||
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
|
||||
}
|
||||
|
||||
TenantDetailDto tenant = await tenantAdminService.GetAsync(id, ct);
|
||||
TenantDetailDto? tenant = await tenantAdminService.GetAsync(id, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return EndpointResults.NotFound(TenantNotFoundDetail);
|
||||
}
|
||||
|
||||
return Results.Ok(tenant);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ public static class PipelineEndpoints
|
||||
// Путь возврата записи отсева в обработку (POST).
|
||||
private const string RejectedReturnPath = "/rejected/{rejId}/return";
|
||||
|
||||
private const string RejectedNotFoundDetail = "Запись не найдена";
|
||||
|
||||
private const int DefaultPageSize = 100;
|
||||
|
||||
/// <summary>
|
||||
@@ -138,7 +140,11 @@ public static class PipelineEndpoints
|
||||
}
|
||||
|
||||
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
|
||||
RejectReturnResultDto result = await processing.ReturnAsync(rejId, body.Reason ?? string.Empty, ct);
|
||||
RejectReturnResultDto? result = await processing.ReturnAsync(rejId, body.Reason ?? string.Empty, ct);
|
||||
if (result is null)
|
||||
{
|
||||
return EndpointResults.NotFound(RejectedNotFoundDetail);
|
||||
}
|
||||
|
||||
return result.Error is not null
|
||||
? EndpointResults.BadRequest(result.Error)
|
||||
|
||||
@@ -22,7 +22,6 @@ public sealed class TgStatusService(
|
||||
TelegramKeysService keys)
|
||||
{
|
||||
private const string IdlePhase = "idle";
|
||||
private const string ReadyPhase = "ready";
|
||||
|
||||
// Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра.
|
||||
private static readonly JsonSerializerOptions KvJsonOptions = new()
|
||||
@@ -38,19 +37,13 @@ public sealed class TgStatusService(
|
||||
public async Task<TgStatusDto> GetAsync(CancellationToken ct)
|
||||
{
|
||||
TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false);
|
||||
// «Подключён» для UI = авторизован (phase ready). Транспортный connected сервиса
|
||||
// означает лишь живость соединения и не гарантирует вход — в UI он даёт «зависание».
|
||||
bool authorized = string.Equals(live.Phase, ReadyPhase, StringComparison.Ordinal);
|
||||
// Живой account (имя из Telegram) приоритетнее KV: при QR-входе KV ещё не заполнен.
|
||||
string account = string.IsNullOrEmpty(live.Account)
|
||||
? await ReadAccountAsync(ct).ConfigureAwait(false)
|
||||
: live.Account;
|
||||
string account = await ReadAccountAsync(ct).ConfigureAwait(false);
|
||||
int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count;
|
||||
TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return new TgStatusDto(
|
||||
Phase: live.Phase,
|
||||
Connected: authorized,
|
||||
Connected: live.Connected,
|
||||
Listener: live.Listener,
|
||||
Account: account,
|
||||
Monitored: monitored,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using Deal.SharedKernel.Resilience;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace Deal.Infrastructure.Integrations.Resilience;
|
||||
|
||||
/// <summary>
|
||||
/// Повтор транзиентных gRPC-сбоев клиентов автономных сервисов.
|
||||
/// </summary>
|
||||
public static class GrpcRetry
|
||||
{
|
||||
/// <summary>
|
||||
/// Число повторов после первой попытки.
|
||||
/// </summary>
|
||||
public const int RetryCount = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Базовая задержка повтора (далее — экспоненциально с джиттером).
|
||||
/// </summary>
|
||||
public static readonly TimeSpan BaseDelay = TimeSpan.FromMilliseconds(200);
|
||||
|
||||
/// <summary>
|
||||
/// Выполняет gRPC-вызов с повтором транзиентных сбоев.
|
||||
/// </summary>
|
||||
/// <param name="operation">Вызов (принимает токен отмены).</param>
|
||||
/// <param name="cancellationToken">Токен отмены.</param>
|
||||
/// <returns>Ответ вызова.</returns>
|
||||
public static Task<TResult> ExecuteAsync<TResult>(
|
||||
Func<CancellationToken, Task<TResult>> operation,
|
||||
CancellationToken cancellationToken)
|
||||
=> ExecuteAsync(operation, DefaultDelayAsync, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Выполняет gRPC-вызов с повтором и заданной паузой между попытками.
|
||||
/// </summary>
|
||||
/// <param name="operation">Вызов (принимает токен отмены).</param>
|
||||
/// <param name="delayAsync">Пауза между попытками (в тестах — мгновенная).</param>
|
||||
/// <param name="cancellationToken">Токен отмены.</param>
|
||||
/// <returns>Ответ вызова.</returns>
|
||||
public static Task<TResult> ExecuteAsync<TResult>(
|
||||
Func<CancellationToken, Task<TResult>> operation,
|
||||
Func<TimeSpan, CancellationToken, Task> delayAsync,
|
||||
CancellationToken cancellationToken)
|
||||
=> RetryExecutor.ExecuteAsync(
|
||||
operation,
|
||||
RetryCount,
|
||||
BaseDelay,
|
||||
IsTransient,
|
||||
delayAsync,
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Признак транзиентного сбоя транспорта (недоступность/дедлайн).
|
||||
/// </summary>
|
||||
/// <param name="exception">Исключение вызова.</param>
|
||||
/// <returns>True — сбой имеет смысл повторить.</returns>
|
||||
public static bool IsTransient(Exception exception)
|
||||
=> exception is RpcException rpc
|
||||
&& rpc.StatusCode is StatusCode.Unavailable or StatusCode.DeadlineExceeded;
|
||||
|
||||
// Экспоненциальная задержка с джиттером 0.5–1.5× (сглаживает синхронные ретраи воркеров).
|
||||
private static Task DefaultDelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||
{
|
||||
double factor = 0.5 + Random.Shared.NextDouble();
|
||||
return Task.Delay(TimeSpan.FromMilliseconds(delay.TotalMilliseconds * factor), cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Grpc.Ai;
|
||||
using Deal.Infrastructure.Integrations.Exceptions;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Deal.Infrastructure.Integrations.Resilience;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
@@ -75,14 +76,16 @@ public sealed class GrpcAiClassifier : IAiClassifier
|
||||
string prompt = await _contextBuilder.BuildFilterPromptAsync(ct);
|
||||
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
|
||||
AiService.AiServiceClient client = _connection.CreateClient();
|
||||
FilterReply reply = await client.FilterAsync(
|
||||
new FilterRequest
|
||||
{
|
||||
Prompt = prompt,
|
||||
Text = SliceCodePoints(text, MaxFilterTextCodePoints), // python L193: text[:4000]
|
||||
ProviderConfig = providerConfig,
|
||||
},
|
||||
CallOptions(tenantId.Value, ct));
|
||||
FilterReply reply = await GrpcRetry.ExecuteAsync(
|
||||
token => client.FilterAsync(
|
||||
new FilterRequest
|
||||
{
|
||||
Prompt = prompt,
|
||||
Text = SliceCodePoints(text, MaxFilterTextCodePoints), // python L193: text[:4000]
|
||||
ProviderConfig = providerConfig,
|
||||
},
|
||||
CallOptions(tenantId.Value, token)).ResponseAsync,
|
||||
ct);
|
||||
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
|
||||
|
||||
return new AiFilterResultDto(
|
||||
@@ -113,14 +116,16 @@ public sealed class GrpcAiClassifier : IAiClassifier
|
||||
ClassifyReply reply;
|
||||
try
|
||||
{
|
||||
reply = await client.ClassifyAsync(
|
||||
new ClassifyRequest
|
||||
{
|
||||
SystemPrompt = systemPrompt,
|
||||
UserContext = userContext,
|
||||
ProviderConfig = providerConfig,
|
||||
},
|
||||
CallOptions(tenantId.Value, ct));
|
||||
reply = await GrpcRetry.ExecuteAsync(
|
||||
token => client.ClassifyAsync(
|
||||
new ClassifyRequest
|
||||
{
|
||||
SystemPrompt = systemPrompt,
|
||||
UserContext = userContext,
|
||||
ProviderConfig = providerConfig,
|
||||
},
|
||||
CallOptions(tenantId.Value, token)).ResponseAsync,
|
||||
ct);
|
||||
}
|
||||
catch (RpcException exception)
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Grpc.Ai;
|
||||
using Deal.Infrastructure.Integrations.Exceptions;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Deal.Infrastructure.Integrations.Resilience;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Grpc.Core;
|
||||
@@ -74,13 +75,15 @@ public sealed class GrpcAiTools : IAiTools
|
||||
{
|
||||
AiService.AiServiceClient client = _connection.CreateClient();
|
||||
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
|
||||
GenerateKeywordsReply reply = await client.GenerateKeywordsAsync(
|
||||
new GenerateKeywordsRequest
|
||||
{
|
||||
Description = SliceCodePoints(description ?? string.Empty, MaxDescriptionCodePoints),
|
||||
ProviderConfig = providerConfig,
|
||||
},
|
||||
CallOptions(tenantId.Value, ct));
|
||||
GenerateKeywordsReply reply = await GrpcRetry.ExecuteAsync(
|
||||
token => client.GenerateKeywordsAsync(
|
||||
new GenerateKeywordsRequest
|
||||
{
|
||||
Description = SliceCodePoints(description ?? string.Empty, MaxDescriptionCodePoints),
|
||||
ProviderConfig = providerConfig,
|
||||
},
|
||||
CallOptions(tenantId.Value, token)).ResponseAsync,
|
||||
ct);
|
||||
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
|
||||
return new AiGenerateKeywordsResultDto(
|
||||
Ok: true,
|
||||
@@ -125,7 +128,9 @@ public sealed class GrpcAiTools : IAiTools
|
||||
}
|
||||
}
|
||||
|
||||
EvaluateFitReply reply = await client.EvaluateFitAsync(request, CallOptions(tenantId.Value, ct));
|
||||
EvaluateFitReply reply = await GrpcRetry.ExecuteAsync(
|
||||
token => client.EvaluateFitAsync(request, CallOptions(tenantId.Value, token)).ResponseAsync,
|
||||
ct);
|
||||
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
|
||||
return new AiEvaluateFitResultDto(
|
||||
Fit: reply.Fit,
|
||||
|
||||
@@ -4,6 +4,7 @@ using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Grpc.Ml;
|
||||
using Deal.Infrastructure.Integrations.Abstractions;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Deal.Infrastructure.Integrations.Resilience;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
@@ -124,9 +125,11 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
try
|
||||
{
|
||||
MlService.MlServiceClient client = _connection.CreateClient();
|
||||
PredictReply reply = await client.PredictAsync(
|
||||
new PredictRequest { Text = text ?? string.Empty },
|
||||
CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), ct));
|
||||
PredictReply reply = await GrpcRetry.ExecuteAsync(
|
||||
token => client.PredictAsync(
|
||||
new PredictRequest { Text = text ?? string.Empty },
|
||||
CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), token)).ResponseAsync,
|
||||
ct);
|
||||
|
||||
await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct);
|
||||
return MapPredict(reply);
|
||||
@@ -220,9 +223,11 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
||||
try
|
||||
{
|
||||
MlService.MlServiceClient client = _connection.CreateClient();
|
||||
StatusReply reply = await client.StatusAsync(
|
||||
new StatusRequest(),
|
||||
CallOptions(tenantId.Value, TimeSpan.FromSeconds(StatusDeadlineSeconds), ct));
|
||||
StatusReply reply = await GrpcRetry.ExecuteAsync(
|
||||
token => client.StatusAsync(
|
||||
new StatusRequest(),
|
||||
CallOptions(tenantId.Value, TimeSpan.FromSeconds(StatusDeadlineSeconds), token)).ResponseAsync,
|
||||
ct);
|
||||
MlServiceStatusDto service = MapStatus(reply);
|
||||
_statusCache.Set(tenantId.Value, service, reachable: true);
|
||||
return _statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot updated)
|
||||
|
||||
@@ -22,6 +22,8 @@ public sealed class CardMover(CardsService cardsService) : ICardMover
|
||||
CardResultDto result = CardsDefaultContainers.Contains(toContainerId)
|
||||
? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct)
|
||||
: await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct);
|
||||
return new CardMoveResultDto(result.Error);
|
||||
return result.Error is not null
|
||||
? new CardMoveResultDto(result.Error, Exists: true)
|
||||
: new CardMoveResultDto(null, Exists: result.Card is not null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Deal.Modules.Cards.Application.Dtos;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.SharedKernel.Errors;
|
||||
|
||||
namespace Deal.Modules.Cards.Application.Abstractions;
|
||||
|
||||
@@ -15,8 +14,7 @@ public interface ICardMover
|
||||
/// <param name="cardId">Id карточки.</param>
|
||||
/// <param name="toContainerId">Id контейнера назначения (стадия «Выбранных» либо дашборд-контейнер).</param>
|
||||
/// <param name="ctx">Контекст перехода (инициатор, причина, обучение).</param>
|
||||
/// <returns>Результат: Error (400-текст отказа) либо null (успех).</returns>
|
||||
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
|
||||
/// <returns>Результат: Error (400-текст отказа) | Exists=false (карточки нет, 404) | успех (Exists=true).</returns>
|
||||
public Task<CardMoveResultDto> MoveAsync(
|
||||
string cardId,
|
||||
string toContainerId,
|
||||
|
||||
@@ -5,5 +5,6 @@ namespace Deal.Modules.Cards.Application.Dtos;
|
||||
/// <summary>
|
||||
/// Результат перехода карточки единым механизмом <see cref="ICardMover"/>.
|
||||
/// </summary>
|
||||
/// <param name="Error">Текст 400-ошибки либо null (успех).</param>
|
||||
public sealed record CardMoveResultDto(string? Error);
|
||||
/// <param name="Error">Текст 400-ошибки либо null.</param>
|
||||
/// <param name="Exists">True — карточка найдена и переход выполнен (либо перенос был no-op).</param>
|
||||
public sealed record CardMoveResultDto(string? Error, bool Exists);
|
||||
|
||||
+40
-46
@@ -2,7 +2,6 @@ using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Discovery.Application.Abstractions;
|
||||
using Deal.Modules.Discovery.Application.Exceptions;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Deal.SharedKernel.Errors;
|
||||
|
||||
namespace Deal.Modules.Discovery.Application.Services;
|
||||
|
||||
@@ -14,10 +13,6 @@ public sealed class DiscoveryCandidatesService(
|
||||
DiscoveryLogService log,
|
||||
DiscoveryBlacklistService blacklist)
|
||||
{
|
||||
private const string CandidateEntityName = "Кандидат";
|
||||
|
||||
private const string TaskEntityName = "Задача поиска";
|
||||
|
||||
/// <summary>
|
||||
/// 400 mark_rejected
|
||||
/// </summary>
|
||||
@@ -58,12 +53,10 @@ public sealed class DiscoveryCandidatesService(
|
||||
/// Кандидат по dialog_id.
|
||||
/// </summary>
|
||||
/// <param name="dialogId">Подписанный id источника.</param>
|
||||
/// <returns>Кандидат.</returns>
|
||||
/// <exception cref="NotFoundException">Кандидат не найден.</exception>
|
||||
public async Task<DiscoveryCandidateDto> GetAsync(string dialogId, CancellationToken ct)
|
||||
/// <returns>Кандидат или null (404 «Кандидат не найден»).</returns>
|
||||
public Task<DiscoveryCandidateDto?> GetAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
|
||||
?? throw new NotFoundException(CandidateEntityName, dialogId);
|
||||
return store.GetCandidateAsync(dialogId, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -85,8 +78,11 @@ public sealed class DiscoveryCandidatesService(
|
||||
string hue,
|
||||
CancellationToken ct)
|
||||
{
|
||||
DiscoveryTaskDto task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
|
||||
?? throw new NotFoundException(TaskEntityName, taskId);
|
||||
DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
|
||||
if (task is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (await store.IsDialogMonitoredAsync(dialogId, ct).ConfigureAwait(false))
|
||||
{
|
||||
@@ -129,8 +125,7 @@ public sealed class DiscoveryCandidatesService(
|
||||
};
|
||||
await store.CreateCandidateAsync(row, ct).ConfigureAwait(false);
|
||||
await store.BumpTaskCounterAsync(taskId, DiscoveryCounterField.Found, 1, ct).ConfigureAwait(false);
|
||||
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
|
||||
?? throw new NotFoundException(CandidateEntityName, dialogId);
|
||||
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -139,21 +134,18 @@ public sealed class DiscoveryCandidatesService(
|
||||
/// <param name="taskId">Id задачи.</param>
|
||||
/// <param name="dialogId">Подписанный id источника.</param>
|
||||
/// <param name="patch">Изменяемые поля (null — не менять).</param>
|
||||
/// <returns>Обновлённый кандидат.</returns>
|
||||
/// <exception cref="NotFoundException">Задача или кандидат не найдены.</exception>
|
||||
public async Task<DiscoveryCandidateDto> SetAsync(
|
||||
/// <returns>Обновлённый кандидат либо null — задачи/кандидата нет (или кандидат другой задачи).</returns>
|
||||
public async Task<DiscoveryCandidateDto?> SetAsync(
|
||||
string taskId,
|
||||
string dialogId,
|
||||
DiscoveryCandidatePatch patch,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_ = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
|
||||
?? throw new NotFoundException(TaskEntityName, taskId);
|
||||
DiscoveryCandidateDto current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
|
||||
?? throw new NotFoundException(CandidateEntityName, dialogId);
|
||||
if (current.TaskId != taskId)
|
||||
DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
|
||||
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
|
||||
if (task is null || current is null || current.TaskId != taskId)
|
||||
{
|
||||
throw new NotFoundException(CandidateEntityName, dialogId);
|
||||
return null;
|
||||
}
|
||||
|
||||
DiscoveryCandidatePatch normalized = NormalizePatch(patch, current);
|
||||
@@ -167,8 +159,7 @@ public sealed class DiscoveryCandidatesService(
|
||||
}
|
||||
|
||||
await store.PatchCandidateAsync(dialogId, normalized, ct).ConfigureAwait(false);
|
||||
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
|
||||
?? throw new NotFoundException(CandidateEntityName, dialogId);
|
||||
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -176,10 +167,9 @@ public sealed class DiscoveryCandidatesService(
|
||||
/// </summary>
|
||||
/// <param name="dialogId">Подписанный id источника.</param>
|
||||
/// <param name="status">Новый статус: new|review.</param>
|
||||
/// <returns>Обновлённый кандидат.</returns>
|
||||
/// <exception cref="NotFoundException">Кандидат не найден.</exception>
|
||||
/// <returns>Обновлённый кандидат либо null (кандидата нет).</returns>
|
||||
/// <exception cref="DiscoveryValidationException">Статус не new/review.</exception>
|
||||
public async Task<DiscoveryCandidateDto> SetStatusAsync(
|
||||
public async Task<DiscoveryCandidateDto?> SetStatusAsync(
|
||||
string dialogId,
|
||||
string status,
|
||||
CancellationToken ct)
|
||||
@@ -189,8 +179,11 @@ public sealed class DiscoveryCandidatesService(
|
||||
throw new DiscoveryValidationException(string.Format(TransitionNotAllowedFormat, status));
|
||||
}
|
||||
|
||||
DiscoveryCandidateDto current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
|
||||
?? throw new NotFoundException(CandidateEntityName, dialogId);
|
||||
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
|
||||
if (current is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await store.SetCandidateStatusAsync(dialogId, status, ct).ConfigureAwait(false);
|
||||
if (status == DiscoveryCandidateStatuses.Review)
|
||||
@@ -202,8 +195,7 @@ public sealed class DiscoveryCandidatesService(
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
|
||||
?? throw new NotFoundException(CandidateEntityName, dialogId);
|
||||
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -211,15 +203,17 @@ public sealed class DiscoveryCandidatesService(
|
||||
/// </summary>
|
||||
/// <param name="dialogId">Подписанный id источника.</param>
|
||||
/// <param name="auto">True — авто-вступление воркера; false — ручное.</param>
|
||||
/// <returns>Кандидат в joined. Повторный вызов для joined — идемпотентен.</returns>
|
||||
/// <exception cref="NotFoundException">Кандидат не найден.</exception>
|
||||
public async Task<DiscoveryCandidateDto> MarkJoinedAsync(
|
||||
/// <returns>Кандидат в joined либо null (кандидата нет). Повторный вызов для joined — идемпотентен.</returns>
|
||||
public async Task<DiscoveryCandidateDto?> MarkJoinedAsync(
|
||||
string dialogId,
|
||||
bool auto,
|
||||
CancellationToken ct)
|
||||
{
|
||||
DiscoveryCandidateDto current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
|
||||
?? throw new NotFoundException(CandidateEntityName, dialogId);
|
||||
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
|
||||
if (current is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (current.Status == DiscoveryCandidateStatuses.Joined)
|
||||
{
|
||||
@@ -230,8 +224,7 @@ public sealed class DiscoveryCandidatesService(
|
||||
await store.BumpTaskCounterAsync(current.TaskId, DiscoveryCounterField.Joined, 1, ct).ConfigureAwait(false);
|
||||
string logEvent = auto ? DiscoveryLogEvents.JoinAuto : DiscoveryLogEvents.JoinManual;
|
||||
await log.AddAsync(current.TaskId, logEvent, string.Format(JoinedLogFormat, dialogId), ct).ConfigureAwait(false);
|
||||
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
|
||||
?? throw new NotFoundException(CandidateEntityName, dialogId);
|
||||
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -239,16 +232,18 @@ public sealed class DiscoveryCandidatesService(
|
||||
/// </summary>
|
||||
/// <param name="dialogId">Подписанный id источника.</param>
|
||||
/// <param name="reason">Причина отклонения (в лог/чёрный список; «отклонено вручную» — эндпоинт reject).</param>
|
||||
/// <returns>Кандидат в rejected. Повторный вызов для rejected — идемпотентен.</returns>
|
||||
/// <exception cref="NotFoundException">Кандидат не найден.</exception>
|
||||
/// <returns>Кандидат в rejected либо null (кандидата нет). Повторный вызов для rejected — идемпотентен.</returns>
|
||||
/// <exception cref="DiscoveryValidationException">Источник уже joined — отклонить нельзя.</exception>
|
||||
public async Task<DiscoveryCandidateDto> MarkRejectedAsync(
|
||||
public async Task<DiscoveryCandidateDto?> MarkRejectedAsync(
|
||||
string dialogId,
|
||||
string reason,
|
||||
CancellationToken ct)
|
||||
{
|
||||
DiscoveryCandidateDto current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
|
||||
?? throw new NotFoundException(CandidateEntityName, dialogId);
|
||||
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
|
||||
if (current is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (current.Status == DiscoveryCandidateStatuses.Joined)
|
||||
{
|
||||
@@ -265,8 +260,7 @@ public sealed class DiscoveryCandidatesService(
|
||||
string text = string.IsNullOrEmpty(reason) ? string.Format(RejectedLogFormat, dialogId) : reason;
|
||||
await log.AddAsync(current.TaskId, DiscoveryLogEvents.Reject, text, ct).ConfigureAwait(false);
|
||||
await blacklist.AddAsync(dialogId, current.Name, reason ?? string.Empty, ct).ConfigureAwait(false);
|
||||
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
|
||||
?? throw new NotFoundException(CandidateEntityName, dialogId);
|
||||
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -27,16 +27,9 @@ public sealed partial class CardsService
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <returns>Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»).</returns>
|
||||
/// <summary>
|
||||
/// Одна карточка по id.
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <returns>Карточка.</returns>
|
||||
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
|
||||
public async Task<CardDto> GetCardAsync(string cardId, CancellationToken ct)
|
||||
public Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
return await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new NotFoundException(CardEntityName, cardId);
|
||||
return _store.GetCardAsync(cardId, ct);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +57,7 @@ public sealed partial class CardsService
|
||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
throw new NotFoundException(CardEntityName, cardId);
|
||||
return new CardResultDto(null, null);
|
||||
}
|
||||
|
||||
if (card.Col == CardIds.Archive || card.Col == CardIds.Trash)
|
||||
@@ -88,10 +81,7 @@ public sealed partial class CardsService
|
||||
await _mlClient.PushAsync(text, toCol, PushWeightUser, ct);
|
||||
}
|
||||
|
||||
return new CardResultDto(
|
||||
null,
|
||||
await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new NotFoundException(CardEntityName, cardId));
|
||||
return new CardResultDto(null, await _store.GetCardAsync(cardId, ct));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.SharedKernel.Errors;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.Services;
|
||||
|
||||
@@ -23,15 +22,17 @@ public sealed partial class CardsService
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="atMs">Время напоминания, epoch-ms.</param>
|
||||
/// <returns>Результат: Error <see cref="RemindersDisabledDetail"/> (400) | Card — карточка с напоминанием.</returns>
|
||||
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
|
||||
/// <returns>Результат: Error <see cref="RemindersDisabledDetail"/> (400) | Card=null без Error (404) | Card — карточка с напоминанием.</returns>
|
||||
public async Task<CardResultDto> SetReminderAsync(
|
||||
string cardId,
|
||||
long atMs,
|
||||
CancellationToken ct)
|
||||
{
|
||||
CardDto card = await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new NotFoundException(CardEntityName, cardId);
|
||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return new CardResultDto(null, null);
|
||||
}
|
||||
|
||||
if (!await ReadRemindersEnabledAsync(ct))
|
||||
{
|
||||
@@ -40,7 +41,7 @@ public sealed partial class CardsService
|
||||
|
||||
await _store.SetReminderAsync(cardId, atMs, ct);
|
||||
CardDto saved = await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new NotFoundException(CardEntityName, cardId);
|
||||
?? throw new InvalidOperationException("Карточка не прочиталась после установки напоминания: " + cardId);
|
||||
return new CardResultDto(null, saved);
|
||||
}
|
||||
|
||||
@@ -48,27 +49,35 @@ public sealed partial class CardsService
|
||||
/// Снимает напоминание карточки.
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
|
||||
public async Task ClearReminderAsync(string cardId, CancellationToken ct)
|
||||
/// <returns>True — карточка есть и напоминание снято; false — карточки нет (404).</returns>
|
||||
public async Task<bool> ClearReminderAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
_ = await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new NotFoundException(CardEntityName, cardId);
|
||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await _store.ClearReminderAsync(cardId, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// «Напомнить позже»
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
|
||||
public async Task SnoozeReminderAsync(string cardId, CancellationToken ct)
|
||||
/// <returns>True — карточка есть и напоминание отложено; false — карточки нет (404).</returns>
|
||||
public async Task<bool> SnoozeReminderAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
_ = await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new NotFoundException(CardEntityName, cardId);
|
||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long snoozedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + ReminderSnoozeMs;
|
||||
await _store.SetReminderAsync(cardId, snoozedAtMs, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -12,9 +12,6 @@ namespace Deal.Modules.Kanban.Application.Services;
|
||||
/// </summary>
|
||||
public sealed partial class CardsService
|
||||
{
|
||||
// Имя сущности для текста ошибки «не найдено».
|
||||
private const string CardLinkEntityName = "Ссылка карточки";
|
||||
|
||||
/// <summary>
|
||||
/// 400 перенос по стадии
|
||||
/// </summary>
|
||||
@@ -162,16 +159,18 @@ public sealed partial class CardsService
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="name">Название ссылки; пустое после Trim → name = url.</param>
|
||||
/// <param name="url">URL ссылки (без схемы — добавится https://).</param>
|
||||
/// <returns>Результат: Error (400 «Пустая ссылка») | Card — карточка со ссылкой.</returns>
|
||||
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
|
||||
/// <returns>Результат: Error (400 «Пустая ссылка») | Card=null без Error (404) | Card — карточка со ссылкой.</returns>
|
||||
public async Task<CardResultDto> AddLinkAsync(
|
||||
string cardId,
|
||||
string name,
|
||||
string url,
|
||||
CancellationToken ct)
|
||||
{
|
||||
CardDto card = await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new NotFoundException(CardEntityName, cardId);
|
||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return new CardResultDto(null, null);
|
||||
}
|
||||
|
||||
string normalizedUrl = (url ?? string.Empty).Trim();
|
||||
if (normalizedUrl.Length == 0)
|
||||
@@ -192,11 +191,11 @@ public sealed partial class CardsService
|
||||
normalizedUrl);
|
||||
if (!await _store.AddLinkAsync(cardId, link, ct))
|
||||
{
|
||||
throw new NotFoundException(CardEntityName, cardId);
|
||||
return new CardResultDto(null, null);
|
||||
}
|
||||
|
||||
CardDto saved = await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new NotFoundException(CardEntityName, cardId);
|
||||
?? throw new InvalidOperationException("Карточка не прочиталась после добавления ссылки: " + cardId);
|
||||
return new CardResultDto(null, saved);
|
||||
}
|
||||
|
||||
@@ -205,8 +204,7 @@ public sealed partial class CardsService
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="linkId">Id удаляемой ссылки (<c>pl_...</c>).</param>
|
||||
/// <returns>Карточка без ссылки.</returns>
|
||||
/// <exception cref="NotFoundException">Карточка или ссылка не найдены.</exception>
|
||||
/// <returns>Результат: Card=null без Error (404) | Card — карточка без ссылки.</returns>
|
||||
public async Task<CardResultDto> RemoveLinkAsync(
|
||||
string cardId,
|
||||
string linkId,
|
||||
@@ -214,11 +212,11 @@ public sealed partial class CardsService
|
||||
{
|
||||
if (!await _store.RemoveLinkAsync(cardId, linkId, ct))
|
||||
{
|
||||
throw new NotFoundException(CardLinkEntityName, linkId);
|
||||
return new CardResultDto(null, null);
|
||||
}
|
||||
|
||||
CardDto saved = await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new NotFoundException(CardEntityName, cardId);
|
||||
?? throw new InvalidOperationException("Карточка не прочиталась после удаления ссылки: " + cardId);
|
||||
return new CardResultDto(null, saved);
|
||||
}
|
||||
|
||||
@@ -227,8 +225,7 @@ public sealed partial class CardsService
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="containerId">Новый контейнер-стадия — id каталога <see cref="CardsDefaultContainers"/>.</param>
|
||||
/// <returns>Результат: Error «Неизвестная стадия» (400) | Card — карточка после переноса.</returns>
|
||||
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
|
||||
/// <returns>Результат: Error «Неизвестная стадия» (400) | Card=null без Error (карточки нет, 404) | Card — карточка после переноса.</returns>
|
||||
public async Task<CardResultDto> MoveStageCardAsync(
|
||||
string cardId,
|
||||
string containerId,
|
||||
@@ -244,11 +241,11 @@ public sealed partial class CardsService
|
||||
bool moved = await _store.MoveCardStageAsync(cardId, containerId, entry, nowMs, ct);
|
||||
if (!moved)
|
||||
{
|
||||
throw new NotFoundException(CardEntityName, cardId);
|
||||
return new CardResultDto(null, null);
|
||||
}
|
||||
|
||||
CardDto card = await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new NotFoundException(CardEntityName, cardId);
|
||||
?? throw new InvalidOperationException("Карточка не прочиталась после move: " + cardId);
|
||||
return new CardResultDto(null, card);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.SharedKernel.Errors;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
@@ -35,9 +34,6 @@ public sealed class MlReviewService(
|
||||
// Размер одного чтения из очереди/отсева при объединении кандидатов.
|
||||
private const int MaxScan = 500;
|
||||
|
||||
// Имя сущности для текста ошибки «не найдено».
|
||||
private const string MessageEntityName = "Исходное сообщение";
|
||||
|
||||
private const int TextPreviewLength = 600;
|
||||
|
||||
/// <summary>
|
||||
@@ -161,9 +157,8 @@ public sealed class MlReviewService(
|
||||
/// <param name="dialogId">Оригинал источника (OriginRef) записи.</param>
|
||||
/// <param name="msgId">Внешний id записи в источнике.</param>
|
||||
/// <param name="action">Действие: <c>skip</c> | <c>spam</c> | <c>board:<id></c>.</param>
|
||||
/// <returns>Результат решения.</returns>
|
||||
/// <exception cref="NotFoundException">Исходная запись не найдена.</exception>
|
||||
public async Task<MlApplyResult> ApplyAsync(
|
||||
/// <returns>Результат решения; null — исходная запись не найдена (404-семантика эндпоинта).</returns>
|
||||
public async Task<MlApplyResult?> ApplyAsync(
|
||||
string dialogId,
|
||||
long msgId,
|
||||
string? action,
|
||||
@@ -173,14 +168,17 @@ public sealed class MlReviewService(
|
||||
string dialog = (dialogId ?? string.Empty).Trim();
|
||||
string externalId = msgId.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
SourceRef source = await ResolveSourceAsync(dialog, externalId, ct)
|
||||
?? throw new NotFoundException(MessageEntityName, externalId);
|
||||
SourceRef? source = await ResolveSourceAsync(dialog, externalId, ct);
|
||||
if (source is null)
|
||||
{
|
||||
return null; // 404: исходная запись не найдена
|
||||
}
|
||||
|
||||
CardDto? card = await cardStore.GetCardBySourceAsync(source, ct);
|
||||
string? text = await FindTextAsync(dialog, externalId, card, ct);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
throw new NotFoundException(MessageEntityName, externalId);
|
||||
return null; // 404: исходная запись не найдена
|
||||
}
|
||||
|
||||
if (normalized == ActionSkip)
|
||||
|
||||
@@ -4,7 +4,6 @@ using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
using Deal.SharedKernel.Errors;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Services;
|
||||
|
||||
@@ -49,9 +48,6 @@ public sealed class PipelineProcessingService(
|
||||
|
||||
private const string DuplicateSource = "dup";
|
||||
|
||||
// Имя сущности для текста ошибки «не найдено».
|
||||
private const string RejectedEntityName = "Запись отсева";
|
||||
|
||||
private static readonly HashSet<string> SpamStages = new(StringComparer.Ordinal)
|
||||
{
|
||||
"spam_ml",
|
||||
@@ -158,15 +154,17 @@ public sealed class PipelineProcessingService(
|
||||
/// </summary>
|
||||
/// <param name="rejectedId">Id записи отсева (<c>r_...</c>).</param>
|
||||
/// <param name="reason">Причина возврата (trim, ≤500; пишется на запись для аудита).</param>
|
||||
/// <returns>Результат: Error (400) либо {id, returned:true, returnedAt}.</returns>
|
||||
/// <exception cref="NotFoundException">Запись отсева не найдена.</exception>
|
||||
public async Task<RejectReturnResultDto> ReturnAsync(
|
||||
/// <returns>null — записи нет (404); иначе результат: Error (400) либо {id, returned:true, returnedAt}.</returns>
|
||||
public async Task<RejectReturnResultDto?> ReturnAsync(
|
||||
string rejectedId,
|
||||
string reason,
|
||||
CancellationToken ct)
|
||||
{
|
||||
RejectedItemDto row = await store.GetAsync(rejectedId, ct)
|
||||
?? throw new NotFoundException(RejectedEntityName, rejectedId);
|
||||
RejectedItemDto? row = await store.GetAsync(rejectedId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (row.Returned)
|
||||
{
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel;
|
||||
using Deal.SharedKernel.Errors;
|
||||
using Deal.SharedKernel.Utilities;
|
||||
|
||||
namespace Deal.Modules.Tenants.Application.Services;
|
||||
@@ -18,9 +17,6 @@ public sealed class TenantAdminService(
|
||||
// Случайные байты одноразового пароля владельца: 12 → ровно 16 символов Base64Url (как InviteCodeGenerator).
|
||||
private const int InitialPasswordRandomByteCount = 12;
|
||||
|
||||
// Имя сущности для текста ошибки «не найдено» (пользователь = тенант с его окружением).
|
||||
private const string TenantEntityName = "Пользователь";
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт тенанта оператором
|
||||
/// </summary>
|
||||
@@ -107,12 +103,14 @@ public sealed class TenantAdminService(
|
||||
/// Детали тенанта с пользователями
|
||||
/// </summary>
|
||||
/// <param name="id">Идентификатор тенанта.</param>
|
||||
/// <returns>Детали и пользователи тенанта (по CreatedAt).</returns>
|
||||
/// <exception cref="NotFoundException">Тенант не найден.</exception>
|
||||
public async Task<TenantDetailDto> GetAsync(Guid id, CancellationToken ct)
|
||||
/// <returns>Детали и пользователи тенанта (по CreatedAt) или null, если тенанта нет.</returns>
|
||||
public async Task<TenantDetailDto?> GetAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
var tenant = await tenantRepository.FindByIdAsync(id, ct)
|
||||
?? throw new NotFoundException(TenantEntityName, id.ToString("N"));
|
||||
var tenant = await tenantRepository.FindByIdAsync(id, ct);
|
||||
if (tenant is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<UserIdentityDto> users = await authStore.ListUsersByTenantIdAsync(id, ct);
|
||||
return new TenantDetailDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace Deal.SharedKernel.Resilience;
|
||||
|
||||
/// <summary>
|
||||
/// Повтор операции при транзиентном сбое.
|
||||
/// </summary>
|
||||
public static class RetryExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// Выполняет операцию, повторяя её при транзиентном сбое с задержкой.
|
||||
/// </summary>
|
||||
/// <param name="operation">Операция (принимает токен отмены).</param>
|
||||
/// <param name="retryCount">Число повторов после первой попытки.</param>
|
||||
/// <param name="baseDelay">Базовая задержка; для повтора N — baseDelay * 2^N.</param>
|
||||
/// <param name="shouldRetry">Предикат транзиентности сбоя.</param>
|
||||
/// <param name="delayAsync">Пауза между попытками (в тестах — мгновенная).</param>
|
||||
/// <param name="cancellationToken">Токен отмены.</param>
|
||||
/// <returns>Результат первой успешной попытки.</returns>
|
||||
public static async Task<TResult> ExecuteAsync<TResult>(
|
||||
Func<CancellationToken, Task<TResult>> operation,
|
||||
int retryCount,
|
||||
TimeSpan baseDelay,
|
||||
Func<Exception, bool> shouldRetry,
|
||||
Func<TimeSpan, CancellationToken, Task> delayAsync,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(operation);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(retryCount);
|
||||
ArgumentNullException.ThrowIfNull(shouldRetry);
|
||||
ArgumentNullException.ThrowIfNull(delayAsync);
|
||||
|
||||
for (int attempt = 0; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await operation(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (attempt < retryCount
|
||||
&& shouldRetry(exception)
|
||||
&& !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await delayAsync(BackoffDelay(baseDelay, attempt), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Задержка повтора с экспоненциальным ростом от базовой.
|
||||
private static TimeSpan BackoffDelay(TimeSpan baseDelay, int attempt)
|
||||
=> TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt));
|
||||
}
|
||||
@@ -67,22 +67,21 @@ public sealed class TgStatusServiceTests
|
||||
/// Готовый аккаунт
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetAsync_ReadyGateway_PrefersLiveAccountOverKvAndComputesConnected()
|
||||
public async Task GetAsync_ReadyGateway_ComposesLiveFieldsWithKvAccountMonitoredAndKeys()
|
||||
{
|
||||
(TgStatusService service, TestTelegramStore store, TestSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
|
||||
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
|
||||
settings.Preload(SettingsKeys.TgAccount, "\"@realuser\"");
|
||||
PreloadKeys(globalSettings, "123456", "abcdefghijklmnop");
|
||||
// Транспортный Connected=false, но phase ready → UI должен считать аккаунт подключённым.
|
||||
gateway.Status = new TelegramAccountStatusDto(
|
||||
Phase: "ready", Connected: false, Listener: true, Account: "@liveuser", Error: null, QrUrl: null);
|
||||
Phase: "ready", Connected: true, Listener: true, Account: "gateway-account", Error: null, QrUrl: null);
|
||||
|
||||
TgStatusDto status = await service.GetAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal("ready", status.Phase);
|
||||
Assert.True(status.Connected); // connected = авторизация (phase ready), не транспорт
|
||||
Assert.True(status.Connected);
|
||||
Assert.True(status.Listener);
|
||||
Assert.Equal("@liveuser", status.Account); // живой account приоритетнее KV
|
||||
Assert.Equal("@realuser", status.Account); // KV tgAccount перекрывает справочное поле гейта (Ruling 8)
|
||||
Assert.Equal(1, status.Monitored);
|
||||
Assert.True(status.KeysSet);
|
||||
}
|
||||
|
||||
@@ -53,12 +53,13 @@ public sealed class CardsServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCard_Missing_ThrowsNotFound()
|
||||
public async Task GetCard_Missing_ReturnsNull()
|
||||
{
|
||||
(CardsService service, _, _, _) = Create();
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => service.GetCardAsync("l_missing", CancellationToken.None));
|
||||
CardDto? card = await service.GetCardAsync("l_missing", CancellationToken.None);
|
||||
|
||||
Assert.Null(card);
|
||||
}
|
||||
|
||||
|
||||
@@ -145,14 +146,15 @@ public sealed class CardsServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Move_CardMissing_ThrowsNotFound()
|
||||
public async Task Move_CardMissing_ReturnsNullLead()
|
||||
{
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedBoard(Board("b_py"));
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None));
|
||||
CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None);
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена»
|
||||
Assert.Empty(store.Moves);
|
||||
Assert.Empty(ml.Pushed);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using Deal.Infrastructure.Data;
|
||||
using Deal.Infrastructure.Integrations.Abstractions;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Deal.Infrastructure.Integrations.Options;
|
||||
using Deal.Infrastructure.Integrations.Resilience;
|
||||
using Deal.Infrastructure.Integrations.Services;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
@@ -91,7 +92,8 @@ public sealed class GrpcMlClientTests
|
||||
Assert.Null(result.Margin);
|
||||
Assert.Empty(result.Terms);
|
||||
Assert.Null(result.Type);
|
||||
Assert.Single(service.RequestTenantIds);
|
||||
// Недоступность транспорта повторяется — на сервер приходит первая попытка и повторы.
|
||||
Assert.Equal(GrpcRetry.RetryCount + 1, service.RequestTenantIds.Count);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -148,7 +150,8 @@ public sealed class GrpcMlClientTests
|
||||
Assert.False(down.Reachable);
|
||||
Assert.False(down.Service.Ready);
|
||||
Assert.False(down.Stats.Reachable);
|
||||
Assert.Equal(1, service.StatusCalls);
|
||||
// При недоступности транспорта идёт повтор — считаем все попытки.
|
||||
Assert.Equal(GrpcRetry.RetryCount + 1, service.StatusCalls);
|
||||
|
||||
// «Поднялся»: после TTL 15 с следующий StatusAsync обновляет кэш (ready=true, reachable=true).
|
||||
service.StatusUnavailable = false;
|
||||
@@ -165,7 +168,7 @@ public sealed class GrpcMlClientTests
|
||||
Assert.True(up.Reachable);
|
||||
Assert.True(up.Service.Ready);
|
||||
Assert.Equal(3, up.Service.Learned);
|
||||
Assert.Equal(2, service.StatusCalls);
|
||||
Assert.Equal(GrpcRetry.RetryCount + 2, service.StatusCalls);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using Deal.Grpc.Ai;
|
||||
using Deal.Infrastructure.Data;
|
||||
using Deal.Infrastructure.Integrations.Models;
|
||||
using Deal.Infrastructure.Integrations.Options;
|
||||
using Deal.Infrastructure.Integrations.Resilience;
|
||||
using Deal.Infrastructure.Integrations.Services;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
@@ -101,8 +102,9 @@ public sealed class PipelineWorkerGrpcAiTests
|
||||
Assert.Equal(1, result.AiFail);
|
||||
Assert.Equal(1, result.AiStored);
|
||||
Assert.Single(result.CreatedCards);
|
||||
Assert.Equal(1, service.FilterCalls);
|
||||
Assert.Equal(1, service.ClassifyCalls);
|
||||
// Недоступность транспорта повторяется — на сервер приходит первая попытка и повторы.
|
||||
Assert.Equal(GrpcRetry.RetryCount + 1, service.FilterCalls);
|
||||
Assert.Equal(GrpcRetry.RetryCount + 1, service.ClassifyCalls);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,10 @@ using Deal.Modules.Cards.Application.Dtos;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.SharedKernel.Errors;
|
||||
using Deal.Tests.Unit.Contracts;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
using Deal.Tests.Unit.Support;
|
||||
|
||||
namespace Deal.Tests.Unit.Infrastructure;
|
||||
|
||||
@@ -33,6 +32,7 @@ public sealed class CardMoverTests
|
||||
CardMoveResultDto result = await mover.MoveAsync("c_1", CardsDefaultContainers.Planned, UserMove, CancellationToken.None);
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.True(result.Exists);
|
||||
CardDto card = Assert.Single(store.CardDtos);
|
||||
Assert.Equal(CardsDefaultContainers.Planned, card.Col);
|
||||
Assert.Null(card.Reminder); // move по стадии сбрасывает напоминание
|
||||
@@ -50,6 +50,7 @@ public sealed class CardMoverTests
|
||||
CardMoveResultDto result = await mover.MoveAsync("c_1", "b_py", UserMove, CancellationToken.None);
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.True(result.Exists);
|
||||
Assert.Equal("b_py", Assert.Single(store.CardDtos).Col);
|
||||
Assert.Single(store.Moves); // журнал CardMoves дашборд-переноса
|
||||
}
|
||||
@@ -63,15 +64,18 @@ public sealed class CardMoverTests
|
||||
CardMoveResultDto result = await mover.MoveAsync("c_1", "b_ghost", UserMove, CancellationToken.None);
|
||||
|
||||
Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error);
|
||||
Assert.True(result.Exists); // ошибка важнее признака наличия
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Move_ToStage_CardMissing_ThrowsNotFound()
|
||||
public async Task Move_ToStage_CardMissing_ReportsNotFound()
|
||||
{
|
||||
(CardMover mover, _) = Create();
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => mover.MoveAsync("c_ghost", CardsDefaultContainers.Planned, UserMove, CancellationToken.None));
|
||||
CardMoveResultDto result = await mover.MoveAsync("c_ghost", CardsDefaultContainers.Planned, UserMove, CancellationToken.None);
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.False(result.Exists);
|
||||
}
|
||||
|
||||
private static (CardMover Mover, TestKanjStore Store) Create()
|
||||
|
||||
+19
-14
@@ -1,8 +1,7 @@
|
||||
using Deal.Modules.Discovery.Application.Exceptions;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Deal.Modules.Discovery.Application.Services;
|
||||
using Deal.SharedKernel.Errors;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Modules.Discovery.Application.Services;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Discovery;
|
||||
|
||||
@@ -115,13 +114,14 @@ public sealed class DiscoveryCandidatesServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_MissingTask_ThrowsNotFoundWithoutLog()
|
||||
public async Task Add_MissingTask_ReturnsNullWithoutLog()
|
||||
{
|
||||
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => service.AddAsync(
|
||||
"dt_missing", "-1001", "Канал", "", DiscoveryCandidateKinds.Channel, "", CancellationToken.None));
|
||||
DiscoveryCandidateDto? candidate = await service.AddAsync(
|
||||
"dt_missing", "-1001", "Канал", "", DiscoveryCandidateKinds.Channel, "", CancellationToken.None);
|
||||
|
||||
Assert.Null(candidate);
|
||||
Assert.Empty(store.Candidates);
|
||||
Assert.Empty(store.Log);
|
||||
}
|
||||
@@ -175,12 +175,13 @@ public sealed class DiscoveryCandidatesServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkJoined_MissingCandidate_ThrowsNotFound()
|
||||
public async Task MarkJoined_MissingCandidate_ReturnsNull()
|
||||
{
|
||||
(DiscoveryCandidatesService service, _) = Create();
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => service.MarkJoinedAsync("-1001", auto: true, CancellationToken.None));
|
||||
DiscoveryCandidateDto? candidate = await service.MarkJoinedAsync("-1001", auto: true, CancellationToken.None);
|
||||
|
||||
Assert.Null(candidate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -237,12 +238,14 @@ public sealed class DiscoveryCandidatesServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkRejected_MissingCandidate_ThrowsNotFound()
|
||||
public async Task MarkRejected_MissingCandidate_ReturnsNull()
|
||||
{
|
||||
(DiscoveryCandidatesService service, _) = Create();
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => service.MarkRejectedAsync("-1001", "причина", CancellationToken.None));
|
||||
DiscoveryCandidateDto? candidate = await service.MarkRejectedAsync(
|
||||
"-1001", "причина", CancellationToken.None);
|
||||
|
||||
Assert.Null(candidate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -312,15 +315,17 @@ public sealed class DiscoveryCandidatesServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Set_WrongTask_ThrowsNotFound()
|
||||
public async Task Set_WrongTask_ReturnsNull()
|
||||
{
|
||||
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
|
||||
store.SeedTask(Task("dt_1"));
|
||||
store.SeedTask(Task("dt_2"));
|
||||
store.SeedCandidate(Candidate("-1001", "dt_1"));
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => service.SetAsync(
|
||||
"dt_2", "-1001", new DiscoveryCandidatePatch { Participants = 10 }, CancellationToken.None));
|
||||
DiscoveryCandidateDto? candidate = await service.SetAsync(
|
||||
"dt_2", "-1001", new DiscoveryCandidatePatch { Participants = 10 }, CancellationToken.None);
|
||||
|
||||
Assert.Null(candidate); // кандидат другой задачи (python L465–466)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.SharedKernel.Errors;
|
||||
using Deal.Tests.Unit.Contracts;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
using Deal.Tests.Unit.Support;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Kanban;
|
||||
|
||||
@@ -48,12 +47,14 @@ public sealed class CardsServiceRemindersTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Set_MissingCard_ThrowsNotFoundEvenWhenDisabled()
|
||||
public async Task Set_MissingCard_Returns404EvenWhenDisabled()
|
||||
{
|
||||
(CardsService service, _, _) = Create(remindersEnabled: false);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => service.SetReminderAsync("c_missing", NowMs(), CancellationToken.None));
|
||||
CardResultDto result = await service.SetReminderAsync("c_missing", NowMs(), CancellationToken.None);
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена»
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -72,23 +73,25 @@ public sealed class CardsServiceRemindersTests
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_WithReminder_ClearsIt()
|
||||
public async Task Clear_WithReminder_ClearsItAndReturnsTrue()
|
||||
{
|
||||
(CardsService service, TestKanjStore store, _) = Create();
|
||||
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
|
||||
|
||||
await service.ClearReminderAsync("c_1", CancellationToken.None);
|
||||
bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None);
|
||||
|
||||
Assert.True(cleared);
|
||||
Assert.Null(Assert.Single(store.CardDtos).Reminder); // reminder_at=NULL, fired сброшен
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_MissingCard_ThrowsNotFound()
|
||||
public async Task Clear_MissingCard_ReturnsFalse()
|
||||
{
|
||||
(CardsService service, _, _) = Create();
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => service.ClearReminderAsync("c_missing", CancellationToken.None));
|
||||
bool cleared = await service.ClearReminderAsync("c_missing", CancellationToken.None);
|
||||
|
||||
Assert.False(cleared); // эндпоинт отвечает 404 «Карточка не найдена»
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -97,8 +100,9 @@ public sealed class CardsServiceRemindersTests
|
||||
(CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false);
|
||||
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() + DayMs) });
|
||||
|
||||
await service.ClearReminderAsync("c_1", CancellationToken.None);
|
||||
bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None);
|
||||
|
||||
Assert.True(cleared);
|
||||
Assert.Null(Assert.Single(store.CardDtos).Reminder);
|
||||
}
|
||||
|
||||
@@ -110,20 +114,22 @@ public sealed class CardsServiceRemindersTests
|
||||
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
|
||||
long beforeMs = NowMs();
|
||||
|
||||
await service.SnoozeReminderAsync("c_1", CancellationToken.None);
|
||||
bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None);
|
||||
|
||||
Assert.True(snoozed);
|
||||
long afterMs = NowMs();
|
||||
CardReminderDto reminder = Assert.Single(store.CardDtos).Reminder!;
|
||||
Assert.InRange(reminder.At, beforeMs + DayMs, afterMs + DayMs); // now + 24 ч
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snooze_MissingCard_ThrowsNotFound()
|
||||
public async Task Snooze_MissingCard_ReturnsFalse()
|
||||
{
|
||||
(CardsService service, _, _) = Create();
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => service.SnoozeReminderAsync("c_missing", CancellationToken.None));
|
||||
bool snoozed = await service.SnoozeReminderAsync("c_missing", CancellationToken.None);
|
||||
|
||||
Assert.False(snoozed); // эндпоинт отвечает 404 «Карточка не найдена»
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -132,8 +138,9 @@ public sealed class CardsServiceRemindersTests
|
||||
(CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false);
|
||||
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
|
||||
|
||||
await service.SnoozeReminderAsync("c_1", CancellationToken.None);
|
||||
bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None);
|
||||
|
||||
Assert.True(snoozed);
|
||||
Assert.NotNull(Assert.Single(store.CardDtos).Reminder);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.SharedKernel.Errors;
|
||||
using Deal.Tests.Unit.Contracts;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
using Deal.Tests.Unit.Support;
|
||||
@@ -242,11 +241,12 @@ public sealed class MlReviewServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_MessageNotFound_ThrowsNotFound()
|
||||
public async Task Apply_MessageNotFound_ReturnsNull()
|
||||
{
|
||||
MlReviewService service = Create(new TestPipelineStore(), new TestKanjStore(), new TestMlClient(), out _);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => service.ApplyAsync(Dialog, 999, MlReviewService.ActionSpam, CancellationToken.None));
|
||||
MlApplyResult? result = await service.ApplyAsync(Dialog, 999, MlReviewService.ActionSpam, CancellationToken.None);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ using System.Globalization;
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.SharedKernel.Errors;
|
||||
using Deal.Tests.Unit.Contracts;
|
||||
using Deal.Tests.Unit.Support;
|
||||
|
||||
@@ -166,13 +165,13 @@ public sealed class PipelineProcessingServiceTests
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnAsync_RecordNotFound_ThrowsNotFound()
|
||||
public async Task ReturnAsync_RecordNotFound_ReturnsNull()
|
||||
{
|
||||
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => service.ReturnAsync("r_missing", string.Empty, CancellationToken.None));
|
||||
RejectReturnResultDto? result = await service.ReturnAsync("r_missing", string.Empty, CancellationToken.None);
|
||||
|
||||
Assert.Null(result); // эндпоинт ответит 404 «Запись не найдена» (Ruling 10)
|
||||
Assert.Empty(store.Queue);
|
||||
Assert.Empty(ml.Pushed);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Deal.SharedKernel.Errors;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Tenants;
|
||||
|
||||
@@ -149,12 +148,11 @@ public sealed class TenantAdminServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ForUnknownTenant_ThrowsNotFound()
|
||||
public async Task GetAsync_ForUnknownTenant_ReturnsNull()
|
||||
{
|
||||
var service = NewService(new TestTenantStore(), new TestAuthStore());
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => service.GetAsync(Guid.NewGuid(), CancellationToken.None));
|
||||
Assert.Null(await service.GetAsync(Guid.NewGuid(), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -40,12 +40,13 @@ public sealed class CardsServiceSelectedTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_Missing_ThrowsNotFound()
|
||||
public async Task Get_Missing_ReturnsNull()
|
||||
{
|
||||
(CardsService service, _, _, _) = Create();
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => service.GetCardAsync("c_missing", CancellationToken.None));
|
||||
CardDto? card = await service.GetCardAsync("c_missing", CancellationToken.None);
|
||||
|
||||
Assert.Null(card);
|
||||
}
|
||||
|
||||
|
||||
@@ -318,12 +319,14 @@ public sealed class CardsServiceSelectedTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Move_CardMissing_ThrowsNotFound()
|
||||
public async Task Move_CardMissing_ReturnsNullCard()
|
||||
{
|
||||
(CardsService service, _, _, _) = Create();
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => service.MoveStageCardAsync("c_missing", "work", CancellationToken.None));
|
||||
CardResultDto result = await service.MoveStageCardAsync("c_missing", "work", CancellationToken.None);
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Null(result.Card); // 404 «Карточка не найдена» — текст у эндпоинта
|
||||
}
|
||||
|
||||
|
||||
@@ -462,13 +465,14 @@ public sealed class CardsServiceSelectedTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddLink_CardMissing_ThrowsBeforeUrlValidation()
|
||||
public async Task AddLink_CardMissing_ReturnsNullCardBeforeUrlValidation()
|
||||
{
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => service.AddLinkAsync("c_missing", string.Empty, "example.com", CancellationToken.None));
|
||||
CardResultDto result = await service.AddLinkAsync("c_missing", string.Empty, "example.com", CancellationToken.None);
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Null(result.Card); // 404-семантика: карточки нет раньше валидации url
|
||||
Assert.Empty(store.CardDtos);
|
||||
}
|
||||
|
||||
@@ -510,13 +514,14 @@ public sealed class CardsServiceSelectedTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveLink_CardMissing_ThrowsNotFound()
|
||||
public async Task RemoveLink_CardMissing_ReturnsNullCard()
|
||||
{
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(
|
||||
() => service.RemoveLinkAsync("c_missing", "pl_1", CancellationToken.None));
|
||||
CardResultDto result = await service.RemoveLinkAsync("c_missing", "pl_1", CancellationToken.None);
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена»
|
||||
Assert.Empty(store.CardDtos);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using Deal.Infrastructure.Integrations.Resilience;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Тесты <see cref="GrpcRetry"/> — повтор транзиентных gRPC-сбоев.
|
||||
/// </summary>
|
||||
public sealed class GrpcRetryTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(StatusCode.Unavailable)]
|
||||
[InlineData(StatusCode.DeadlineExceeded)]
|
||||
public void IsTransient_TransportFailures_True(StatusCode statusCode)
|
||||
{
|
||||
Assert.True(GrpcRetry.IsTransient(new RpcException(new Status(statusCode, "сбой"))));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(StatusCode.NotFound)]
|
||||
[InlineData(StatusCode.InvalidArgument)]
|
||||
[InlineData(StatusCode.Internal)]
|
||||
public void IsTransient_ApplicationFailures_False(StatusCode statusCode)
|
||||
{
|
||||
Assert.False(GrpcRetry.IsTransient(new RpcException(new Status(statusCode, "сбой"))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsTransient_NonRpcException_False()
|
||||
{
|
||||
Assert.False(GrpcRetry.IsTransient(new InvalidOperationException("сбой")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_TransientThenSuccess_Retries()
|
||||
{
|
||||
int calls = 0;
|
||||
|
||||
string result = await GrpcRetry.ExecuteAsync(
|
||||
_ =>
|
||||
{
|
||||
calls++;
|
||||
return calls < 2
|
||||
? Task.FromException<string>(new RpcException(new Status(StatusCode.Unavailable, "down")))
|
||||
: Task.FromResult("ok");
|
||||
},
|
||||
InstantDelayAsync,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("ok", result);
|
||||
Assert.Equal(2, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_TransientExhausted_ThrowsRpcException()
|
||||
{
|
||||
int calls = 0;
|
||||
|
||||
RpcException thrown = await Assert.ThrowsAsync<RpcException>(
|
||||
() => GrpcRetry.ExecuteAsync(
|
||||
_ =>
|
||||
{
|
||||
calls++;
|
||||
return Task.FromException<string>(new RpcException(new Status(StatusCode.Unavailable, "down")));
|
||||
},
|
||||
InstantDelayAsync,
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Equal(StatusCode.Unavailable, thrown.StatusCode);
|
||||
Assert.Equal(GrpcRetry.RetryCount + 1, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ApplicationFailure_NotRetried()
|
||||
{
|
||||
int calls = 0;
|
||||
|
||||
await Assert.ThrowsAsync<RpcException>(
|
||||
() => GrpcRetry.ExecuteAsync(
|
||||
_ =>
|
||||
{
|
||||
calls++;
|
||||
return Task.FromException<string>(new RpcException(new Status(StatusCode.InvalidArgument, "bad")));
|
||||
},
|
||||
InstantDelayAsync,
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
private static Task InstantDelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using Deal.SharedKernel.Resilience;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Тесты <see cref="RetryExecutor"/> — повтор транзиентных сбоев.
|
||||
/// </summary>
|
||||
public sealed class RetryExecutorTests
|
||||
{
|
||||
private const int RetryCount = 2;
|
||||
private static readonly TimeSpan BaseDelay = TimeSpan.FromMilliseconds(10);
|
||||
|
||||
[Fact]
|
||||
public async Task FirstAttemptSucceeds_NoRetry()
|
||||
{
|
||||
var delays = new List<TimeSpan>();
|
||||
int calls = 0;
|
||||
|
||||
string result = await ExecuteAsync(
|
||||
() =>
|
||||
{
|
||||
calls++;
|
||||
return Task.FromResult("ok");
|
||||
},
|
||||
shouldRetry: _ => true,
|
||||
delays);
|
||||
|
||||
Assert.Equal("ok", result);
|
||||
Assert.Equal(1, calls);
|
||||
Assert.Empty(delays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TransientFailureThenSuccess_RetriesUntilSuccess()
|
||||
{
|
||||
var delays = new List<TimeSpan>();
|
||||
int calls = 0;
|
||||
|
||||
string result = await ExecuteAsync(
|
||||
() =>
|
||||
{
|
||||
calls++;
|
||||
return calls < 3
|
||||
? throw new InvalidOperationException("транзиент")
|
||||
: Task.FromResult("ok");
|
||||
},
|
||||
shouldRetry: _ => true,
|
||||
delays);
|
||||
|
||||
Assert.Equal("ok", result);
|
||||
Assert.Equal(3, calls);
|
||||
Assert.Equal(2, delays.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetriesExhausted_ThrowsLastFailure()
|
||||
{
|
||||
var delays = new List<TimeSpan>();
|
||||
int calls = 0;
|
||||
|
||||
InvalidOperationException thrown = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => ExecuteAsync<string>(
|
||||
async () =>
|
||||
{
|
||||
calls++;
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException($"сбой {calls}");
|
||||
},
|
||||
shouldRetry: _ => true,
|
||||
delays));
|
||||
|
||||
Assert.Equal(3, calls); // первая попытка + 2 повтора
|
||||
Assert.Equal("сбой 3", thrown.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonTransientFailure_NotRetried()
|
||||
{
|
||||
var delays = new List<TimeSpan>();
|
||||
int calls = 0;
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => ExecuteAsync<string>(
|
||||
async () =>
|
||||
{
|
||||
calls++;
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("не транзиент");
|
||||
},
|
||||
shouldRetry: _ => false,
|
||||
delays));
|
||||
|
||||
Assert.Equal(1, calls);
|
||||
Assert.Empty(delays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_DoesNotRetry()
|
||||
{
|
||||
var delays = new List<TimeSpan>();
|
||||
int calls = 0;
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => ExecuteAsync<string>(
|
||||
async () =>
|
||||
{
|
||||
calls++;
|
||||
await Task.Yield();
|
||||
throw new InvalidOperationException("сбой");
|
||||
},
|
||||
shouldRetry: _ => true,
|
||||
delays,
|
||||
cts.Token));
|
||||
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backoff_GrowsExponentially()
|
||||
{
|
||||
var delays = new List<TimeSpan>();
|
||||
int calls = 0;
|
||||
|
||||
await ExecuteAsync(
|
||||
() =>
|
||||
{
|
||||
calls++;
|
||||
return calls < 3
|
||||
? throw new InvalidOperationException("транзиент")
|
||||
: Task.FromResult(1);
|
||||
},
|
||||
shouldRetry: _ => true,
|
||||
delays);
|
||||
|
||||
Assert.Equal(2, delays.Count);
|
||||
Assert.Equal(BaseDelay, delays[0]);
|
||||
Assert.Equal(BaseDelay * 2, delays[1]);
|
||||
}
|
||||
|
||||
private static Task<TResult> ExecuteAsync<TResult>(
|
||||
Func<Task<TResult>> operation,
|
||||
Func<Exception, bool> shouldRetry,
|
||||
List<TimeSpan> delays,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> RetryExecutor.ExecuteAsync(
|
||||
_ => operation(),
|
||||
RetryCount,
|
||||
BaseDelay,
|
||||
shouldRetry,
|
||||
(delay, _) =>
|
||||
{
|
||||
delays.Add(delay);
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
# Фронтенд Deal: сборка SPA (Vite) и отдача статики через Caddy.
|
||||
# Контекст сборки — корень репозитория (см. deploy/compose.dev.yml: build.context ..).
|
||||
# /api/* проксируется на core:5080 (см. deploy/caddy/Caddyfile.dev).
|
||||
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY src/frontend/package.json src/frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY src/frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM caddy:2.9.1
|
||||
COPY --from=build /app/dist /srv
|
||||
COPY deploy/caddy/Caddyfile.dev /etc/caddy/Caddyfile
|
||||
@@ -2,9 +2,10 @@
|
||||
// Вкладка «Telegram» настроек: подключение аккаунта (QR / номер / код /
|
||||
// облачный пароль) и авто-мониторинг новых чатов. Ключи приложения
|
||||
// (api_id/api_hash) задаёт оператор глобально — у тенанта их нет.
|
||||
// Пока идёт QR-вход, вкладка сама опрашивает статус и обновляет картинку.
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { state, connectTgStep, tgStart, disconnectTg, refreshTgStatus } from '../../store.js'
|
||||
// Опрос статуса при QR-входе живёт в SettingsView (watch на state.tgState),
|
||||
// чтобы поведение при переключении вкладок осталось прежним.
|
||||
import { ref } from 'vue'
|
||||
import { state, connectTgStep, tgStart, disconnectTg } from '../../store.js'
|
||||
import Icon from '../Icon.vue'
|
||||
import UiToggle from '../ui/ToggleSwitch.vue'
|
||||
|
||||
@@ -13,58 +14,6 @@ const code = ref('')
|
||||
const tgPass = ref('')
|
||||
const qrTick = ref(0)
|
||||
|
||||
// QR-ссылка приходит после старта: перезагружаем картинку, когда она появилась/сменилась.
|
||||
watch(
|
||||
() => state.tgQrUrl,
|
||||
() => {
|
||||
if (state.tgState === 'qr') qrTick.value++
|
||||
},
|
||||
)
|
||||
|
||||
// Таймер QR: пока идёт вход — периодически обновляем статус и саму картинку
|
||||
// (токен Telegram меняется); при подключении показываем учётку и останавливаемся.
|
||||
const QR_POLL_MS = 5000
|
||||
let qrTimer = null
|
||||
|
||||
function stopQrTimer() {
|
||||
if (qrTimer) {
|
||||
clearInterval(qrTimer)
|
||||
qrTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startQrTimer() {
|
||||
stopQrTimer()
|
||||
qrTimer = setInterval(async () => {
|
||||
await refreshTgStatus()
|
||||
// Продолжаем опрос, пока не авторизованы: переходной статус (phase ready до
|
||||
// подтверждения транспорта) не должен останавливать обновление.
|
||||
if (state.tgConnected) {
|
||||
stopQrTimer()
|
||||
return
|
||||
}
|
||||
if (state.tgState === 'idle') {
|
||||
stopQrTimer()
|
||||
return
|
||||
}
|
||||
qrTick.value++
|
||||
}, QR_POLL_MS)
|
||||
}
|
||||
|
||||
// Таймер стартует при входе в режим QR и сам останавливается при подключении/сбросе.
|
||||
watch(
|
||||
() => state.tgState,
|
||||
(s) => {
|
||||
if (s === 'qr') startQrTimer()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshTgStatus()
|
||||
if (state.tgState === 'qr') startQrTimer()
|
||||
})
|
||||
onBeforeUnmount(stopQrTimer)
|
||||
|
||||
async function submitCode() {
|
||||
await connectTgStep(code.value)
|
||||
if (state.tgState === 'done') code.value = ''
|
||||
|
||||
@@ -5,12 +5,11 @@ import { state, toast, errMsg, fmtMsgTime } from './core.js'
|
||||
|
||||
export function mapTgStatus(st) {
|
||||
if (!st) return
|
||||
const phase = st.phase || 'idle'
|
||||
// «Подключён» = авторизован (phase ready); транспортный connected ненадёжен для UI.
|
||||
state.tgConnected = phase === 'ready' || !!st.connected
|
||||
state.tgConnected = !!st.connected
|
||||
state.tgAccount = st.account || ''
|
||||
state.tgKeysSet = !!st.keysSet
|
||||
if (st.error) state.tgError = st.error
|
||||
const phase = st.phase || 'idle'
|
||||
if (phase === 'ready') {
|
||||
state.tgState = 'done'
|
||||
} else if (['phone', 'code', 'password', 'qr'].includes(phase)) {
|
||||
@@ -148,12 +147,10 @@ export async function refreshTgStatus() {
|
||||
export async function tgStart() {
|
||||
state.tgError = ''
|
||||
if (state.tgQrMode) {
|
||||
state.tgState = 'qr'
|
||||
try {
|
||||
// Сначала стартуем QR на бэке, затем показываем картинку: иначе <img> успевает запросить
|
||||
// /api/tg/qr-image до готовности QR и остаётся пустым.
|
||||
const r = await api.post('/api/tg/start-qr')
|
||||
state.tgQrUrl = r.qrUrl || ''
|
||||
state.tgState = 'qr'
|
||||
toast(t('settings.ssylka-dlya-vhoda-sgenerirovana-otkrojte'), { icon: 'send' })
|
||||
} catch (e) {
|
||||
state.tgError = errMsg(e)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { t } from '@/i18n/index.js'
|
||||
import { state } from '../store.js'
|
||||
import { onBeforeUnmount, watch } from 'vue'
|
||||
import { state, refreshTgStatus } from '../store.js'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import TelegramTab from '../components/settings/TelegramTab.vue'
|
||||
import AiTab from '../components/settings/AiTab.vue'
|
||||
@@ -25,6 +26,32 @@ const TABS = [
|
||||
{ id: 'appearance', name: t('settings.vneshnij-vid'), icon: 'palette' },
|
||||
{ id: 'profile', name: t('settings.profil'), icon: 'key' },
|
||||
]
|
||||
|
||||
// пока идёт QR-вход (вкладка Telegram) — опрашиваем статус, чтобы поймать
|
||||
// момент подтверждения. Watch живёт на уровне экрана настроек: при
|
||||
// переключении вкладок опрос не прерывается (как в исходном SettingsView).
|
||||
let qrTimer = null
|
||||
watch(
|
||||
() => state.tgState,
|
||||
(s) => {
|
||||
if (s === 'qr') {
|
||||
stopQrPoll()
|
||||
qrTimer = setInterval(async () => {
|
||||
await refreshTgStatus()
|
||||
if (state.tgState === 'done') stopQrPoll()
|
||||
}, 4000)
|
||||
} else {
|
||||
stopQrPoll()
|
||||
}
|
||||
},
|
||||
)
|
||||
function stopQrPoll() {
|
||||
if (qrTimer) {
|
||||
clearInterval(qrTimer)
|
||||
qrTimer = null
|
||||
}
|
||||
}
|
||||
onBeforeUnmount(stopQrPoll)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
Reference in New Issue
Block a user