Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9bfecf9cc | ||
|
|
13d7994511 | ||
|
|
a9f2b3a1ef | ||
|
|
a9bcd7c5f7 |
@@ -0,0 +1,19 @@
|
|||||||
|
# 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,6 +254,18 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
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)
|
# Prometheus (профиль observability, этап 12/пакет A) — сбор /metrics всех 4 процессов (:9464)
|
||||||
# внутри dev-сети. Подъём: docker compose -f deploy/compose.dev.yml --profile observability up -d.
|
# внутри dev-сети. Подъём: docker compose -f deploy/compose.dev.yml --profile observability up -d.
|
||||||
# Конфиг — общий deploy/observability/prometheus.yml (те же имена сервисов и таргеты). UI — 9090.
|
# Конфиг — общий deploy/observability/prometheus.yml (те же имена сервисов и таргеты). UI — 9090.
|
||||||
@@ -332,6 +344,56 @@ services:
|
|||||||
- /:/host/root:ro
|
- /:/host/root:ro
|
||||||
pid: host
|
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:
|
volumes:
|
||||||
deal_pgdata:
|
deal_pgdata:
|
||||||
deal_minio_data:
|
deal_minio_data:
|
||||||
@@ -340,3 +402,6 @@ volumes:
|
|||||||
deal_api_data:
|
deal_api_data:
|
||||||
deal_prometheus_data:
|
deal_prometheus_data:
|
||||||
deal_tempo_data:
|
deal_tempo_data:
|
||||||
|
deal_loki_data:
|
||||||
|
deal_promtail_data:
|
||||||
|
deal_grafana_data:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public sealed class TgStatusService(
|
|||||||
TelegramKeysService keys)
|
TelegramKeysService keys)
|
||||||
{
|
{
|
||||||
private const string IdlePhase = "idle";
|
private const string IdlePhase = "idle";
|
||||||
|
private const string ReadyPhase = "ready";
|
||||||
|
|
||||||
// Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра.
|
// Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра.
|
||||||
private static readonly JsonSerializerOptions KvJsonOptions = new()
|
private static readonly JsonSerializerOptions KvJsonOptions = new()
|
||||||
@@ -37,13 +38,19 @@ public sealed class TgStatusService(
|
|||||||
public async Task<TgStatusDto> GetAsync(CancellationToken ct)
|
public async Task<TgStatusDto> GetAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false);
|
TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false);
|
||||||
string account = await ReadAccountAsync(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;
|
||||||
int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count;
|
int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count;
|
||||||
TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false);
|
TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
return new TgStatusDto(
|
return new TgStatusDto(
|
||||||
Phase: live.Phase,
|
Phase: live.Phase,
|
||||||
Connected: live.Connected,
|
Connected: authorized,
|
||||||
Listener: live.Listener,
|
Listener: live.Listener,
|
||||||
Account: account,
|
Account: account,
|
||||||
Monitored: monitored,
|
Monitored: monitored,
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
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,7 +3,6 @@ using Deal.Contracts.Integrations.Models;
|
|||||||
using Deal.Grpc.Ai;
|
using Deal.Grpc.Ai;
|
||||||
using Deal.Infrastructure.Integrations.Exceptions;
|
using Deal.Infrastructure.Integrations.Exceptions;
|
||||||
using Deal.Infrastructure.Integrations.Models;
|
using Deal.Infrastructure.Integrations.Models;
|
||||||
using Deal.Infrastructure.Integrations.Resilience;
|
|
||||||
using Deal.Modules.Pipeline.Application.Services;
|
using Deal.Modules.Pipeline.Application.Services;
|
||||||
using Deal.SharedKernel.Tenants.Abstractions;
|
using Deal.SharedKernel.Tenants.Abstractions;
|
||||||
using Deal.SharedKernel.Tenants.Models;
|
using Deal.SharedKernel.Tenants.Models;
|
||||||
@@ -76,16 +75,14 @@ public sealed class GrpcAiClassifier : IAiClassifier
|
|||||||
string prompt = await _contextBuilder.BuildFilterPromptAsync(ct);
|
string prompt = await _contextBuilder.BuildFilterPromptAsync(ct);
|
||||||
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
|
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
|
||||||
AiService.AiServiceClient client = _connection.CreateClient();
|
AiService.AiServiceClient client = _connection.CreateClient();
|
||||||
FilterReply reply = await GrpcRetry.ExecuteAsync(
|
FilterReply reply = await client.FilterAsync(
|
||||||
token => client.FilterAsync(
|
new FilterRequest
|
||||||
new FilterRequest
|
{
|
||||||
{
|
Prompt = prompt,
|
||||||
Prompt = prompt,
|
Text = SliceCodePoints(text, MaxFilterTextCodePoints), // python L193: text[:4000]
|
||||||
Text = SliceCodePoints(text, MaxFilterTextCodePoints), // python L193: text[:4000]
|
ProviderConfig = providerConfig,
|
||||||
ProviderConfig = providerConfig,
|
},
|
||||||
},
|
CallOptions(tenantId.Value, ct));
|
||||||
CallOptions(tenantId.Value, token)).ResponseAsync,
|
|
||||||
ct);
|
|
||||||
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
|
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
|
||||||
|
|
||||||
return new AiFilterResultDto(
|
return new AiFilterResultDto(
|
||||||
@@ -116,16 +113,14 @@ public sealed class GrpcAiClassifier : IAiClassifier
|
|||||||
ClassifyReply reply;
|
ClassifyReply reply;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
reply = await GrpcRetry.ExecuteAsync(
|
reply = await client.ClassifyAsync(
|
||||||
token => client.ClassifyAsync(
|
new ClassifyRequest
|
||||||
new ClassifyRequest
|
{
|
||||||
{
|
SystemPrompt = systemPrompt,
|
||||||
SystemPrompt = systemPrompt,
|
UserContext = userContext,
|
||||||
UserContext = userContext,
|
ProviderConfig = providerConfig,
|
||||||
ProviderConfig = providerConfig,
|
},
|
||||||
},
|
CallOptions(tenantId.Value, ct));
|
||||||
CallOptions(tenantId.Value, token)).ResponseAsync,
|
|
||||||
ct);
|
|
||||||
}
|
}
|
||||||
catch (RpcException exception)
|
catch (RpcException exception)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using Deal.Contracts.Integrations.Models;
|
|||||||
using Deal.Grpc.Ai;
|
using Deal.Grpc.Ai;
|
||||||
using Deal.Infrastructure.Integrations.Exceptions;
|
using Deal.Infrastructure.Integrations.Exceptions;
|
||||||
using Deal.Infrastructure.Integrations.Models;
|
using Deal.Infrastructure.Integrations.Models;
|
||||||
using Deal.Infrastructure.Integrations.Resilience;
|
|
||||||
using Deal.SharedKernel.Tenants.Abstractions;
|
using Deal.SharedKernel.Tenants.Abstractions;
|
||||||
using Deal.SharedKernel.Tenants.Models;
|
using Deal.SharedKernel.Tenants.Models;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
@@ -75,15 +74,13 @@ public sealed class GrpcAiTools : IAiTools
|
|||||||
{
|
{
|
||||||
AiService.AiServiceClient client = _connection.CreateClient();
|
AiService.AiServiceClient client = _connection.CreateClient();
|
||||||
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
|
ProviderConfig providerConfig = await _providerConfigBuilder.BuildAsync(ct);
|
||||||
GenerateKeywordsReply reply = await GrpcRetry.ExecuteAsync(
|
GenerateKeywordsReply reply = await client.GenerateKeywordsAsync(
|
||||||
token => client.GenerateKeywordsAsync(
|
new GenerateKeywordsRequest
|
||||||
new GenerateKeywordsRequest
|
{
|
||||||
{
|
Description = SliceCodePoints(description ?? string.Empty, MaxDescriptionCodePoints),
|
||||||
Description = SliceCodePoints(description ?? string.Empty, MaxDescriptionCodePoints),
|
ProviderConfig = providerConfig,
|
||||||
ProviderConfig = providerConfig,
|
},
|
||||||
},
|
CallOptions(tenantId.Value, ct));
|
||||||
CallOptions(tenantId.Value, token)).ResponseAsync,
|
|
||||||
ct);
|
|
||||||
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
|
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
|
||||||
return new AiGenerateKeywordsResultDto(
|
return new AiGenerateKeywordsResultDto(
|
||||||
Ok: true,
|
Ok: true,
|
||||||
@@ -128,9 +125,7 @@ public sealed class GrpcAiTools : IAiTools
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
EvaluateFitReply reply = await GrpcRetry.ExecuteAsync(
|
EvaluateFitReply reply = await client.EvaluateFitAsync(request, CallOptions(tenantId.Value, ct));
|
||||||
token => client.EvaluateFitAsync(request, CallOptions(tenantId.Value, token)).ResponseAsync,
|
|
||||||
ct);
|
|
||||||
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
|
await _usageRecorder.AddAsync(reply.Usage, providerConfig.ProviderId, providerConfig.Model, ct);
|
||||||
return new AiEvaluateFitResultDto(
|
return new AiEvaluateFitResultDto(
|
||||||
Fit: reply.Fit,
|
Fit: reply.Fit,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using Deal.Contracts.Integrations.Models;
|
|||||||
using Deal.Grpc.Ml;
|
using Deal.Grpc.Ml;
|
||||||
using Deal.Infrastructure.Integrations.Abstractions;
|
using Deal.Infrastructure.Integrations.Abstractions;
|
||||||
using Deal.Infrastructure.Integrations.Models;
|
using Deal.Infrastructure.Integrations.Models;
|
||||||
using Deal.Infrastructure.Integrations.Resilience;
|
|
||||||
using Deal.Modules.Kanban.Application.Abstractions;
|
using Deal.Modules.Kanban.Application.Abstractions;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Deal.Modules.Settings.Application.Abstractions;
|
using Deal.Modules.Settings.Application.Abstractions;
|
||||||
@@ -125,11 +124,9 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
MlService.MlServiceClient client = _connection.CreateClient();
|
MlService.MlServiceClient client = _connection.CreateClient();
|
||||||
PredictReply reply = await GrpcRetry.ExecuteAsync(
|
PredictReply reply = await client.PredictAsync(
|
||||||
token => client.PredictAsync(
|
new PredictRequest { Text = text ?? string.Empty },
|
||||||
new PredictRequest { Text = text ?? string.Empty },
|
CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), ct));
|
||||||
CallOptions(tenantId.Value, TimeSpan.FromSeconds(PredictDeadlineSeconds), token)).ResponseAsync,
|
|
||||||
ct);
|
|
||||||
|
|
||||||
await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct);
|
await _usageRecorder.AddEstimatedAsync(text, TokenUsageSources.Local, TokenUsageSources.Ml, ct);
|
||||||
return MapPredict(reply);
|
return MapPredict(reply);
|
||||||
@@ -223,11 +220,9 @@ public sealed class GrpcMlClient : IMlClient, IMlTrainClient
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
MlService.MlServiceClient client = _connection.CreateClient();
|
MlService.MlServiceClient client = _connection.CreateClient();
|
||||||
StatusReply reply = await GrpcRetry.ExecuteAsync(
|
StatusReply reply = await client.StatusAsync(
|
||||||
token => client.StatusAsync(
|
new StatusRequest(),
|
||||||
new StatusRequest(),
|
CallOptions(tenantId.Value, TimeSpan.FromSeconds(StatusDeadlineSeconds), ct));
|
||||||
CallOptions(tenantId.Value, TimeSpan.FromSeconds(StatusDeadlineSeconds), token)).ResponseAsync,
|
|
||||||
ct);
|
|
||||||
MlServiceStatusDto service = MapStatus(reply);
|
MlServiceStatusDto service = MapStatus(reply);
|
||||||
_statusCache.Set(tenantId.Value, service, reachable: true);
|
_statusCache.Set(tenantId.Value, service, reachable: true);
|
||||||
return _statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot updated)
|
return _statusCache.TryGet(tenantId.Value, out MlStatusCache.Snapshot updated)
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
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,21 +67,22 @@ public sealed class TgStatusServiceTests
|
|||||||
/// Готовый аккаунт
|
/// Готовый аккаунт
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAsync_ReadyGateway_ComposesLiveFieldsWithKvAccountMonitoredAndKeys()
|
public async Task GetAsync_ReadyGateway_PrefersLiveAccountOverKvAndComputesConnected()
|
||||||
{
|
{
|
||||||
(TgStatusService service, TestTelegramStore store, TestSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
|
(TgStatusService service, TestTelegramStore store, TestSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
|
||||||
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
|
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
|
||||||
settings.Preload(SettingsKeys.TgAccount, "\"@realuser\"");
|
settings.Preload(SettingsKeys.TgAccount, "\"@realuser\"");
|
||||||
PreloadKeys(globalSettings, "123456", "abcdefghijklmnop");
|
PreloadKeys(globalSettings, "123456", "abcdefghijklmnop");
|
||||||
|
// Транспортный Connected=false, но phase ready → UI должен считать аккаунт подключённым.
|
||||||
gateway.Status = new TelegramAccountStatusDto(
|
gateway.Status = new TelegramAccountStatusDto(
|
||||||
Phase: "ready", Connected: true, Listener: true, Account: "gateway-account", Error: null, QrUrl: null);
|
Phase: "ready", Connected: false, Listener: true, Account: "@liveuser", Error: null, QrUrl: null);
|
||||||
|
|
||||||
TgStatusDto status = await service.GetAsync(CancellationToken.None);
|
TgStatusDto status = await service.GetAsync(CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal("ready", status.Phase);
|
Assert.Equal("ready", status.Phase);
|
||||||
Assert.True(status.Connected);
|
Assert.True(status.Connected); // connected = авторизация (phase ready), не транспорт
|
||||||
Assert.True(status.Listener);
|
Assert.True(status.Listener);
|
||||||
Assert.Equal("@realuser", status.Account); // KV tgAccount перекрывает справочное поле гейта (Ruling 8)
|
Assert.Equal("@liveuser", status.Account); // живой account приоритетнее KV
|
||||||
Assert.Equal(1, status.Monitored);
|
Assert.Equal(1, status.Monitored);
|
||||||
Assert.True(status.KeysSet);
|
Assert.True(status.KeysSet);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using Deal.Infrastructure.Data;
|
|||||||
using Deal.Infrastructure.Integrations.Abstractions;
|
using Deal.Infrastructure.Integrations.Abstractions;
|
||||||
using Deal.Infrastructure.Integrations.Models;
|
using Deal.Infrastructure.Integrations.Models;
|
||||||
using Deal.Infrastructure.Integrations.Options;
|
using Deal.Infrastructure.Integrations.Options;
|
||||||
using Deal.Infrastructure.Integrations.Resilience;
|
|
||||||
using Deal.Infrastructure.Integrations.Services;
|
using Deal.Infrastructure.Integrations.Services;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
@@ -92,8 +91,7 @@ public sealed class GrpcMlClientTests
|
|||||||
Assert.Null(result.Margin);
|
Assert.Null(result.Margin);
|
||||||
Assert.Empty(result.Terms);
|
Assert.Empty(result.Terms);
|
||||||
Assert.Null(result.Type);
|
Assert.Null(result.Type);
|
||||||
// Недоступность транспорта повторяется — на сервер приходит первая попытка и повторы.
|
Assert.Single(service.RequestTenantIds);
|
||||||
Assert.Equal(GrpcRetry.RetryCount + 1, service.RequestTenantIds.Count);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +148,7 @@ public sealed class GrpcMlClientTests
|
|||||||
Assert.False(down.Reachable);
|
Assert.False(down.Reachable);
|
||||||
Assert.False(down.Service.Ready);
|
Assert.False(down.Service.Ready);
|
||||||
Assert.False(down.Stats.Reachable);
|
Assert.False(down.Stats.Reachable);
|
||||||
// При недоступности транспорта идёт повтор — считаем все попытки.
|
Assert.Equal(1, service.StatusCalls);
|
||||||
Assert.Equal(GrpcRetry.RetryCount + 1, service.StatusCalls);
|
|
||||||
|
|
||||||
// «Поднялся»: после TTL 15 с следующий StatusAsync обновляет кэш (ready=true, reachable=true).
|
// «Поднялся»: после TTL 15 с следующий StatusAsync обновляет кэш (ready=true, reachable=true).
|
||||||
service.StatusUnavailable = false;
|
service.StatusUnavailable = false;
|
||||||
@@ -168,7 +165,7 @@ public sealed class GrpcMlClientTests
|
|||||||
Assert.True(up.Reachable);
|
Assert.True(up.Reachable);
|
||||||
Assert.True(up.Service.Ready);
|
Assert.True(up.Service.Ready);
|
||||||
Assert.Equal(3, up.Service.Learned);
|
Assert.Equal(3, up.Service.Learned);
|
||||||
Assert.Equal(GrpcRetry.RetryCount + 2, service.StatusCalls);
|
Assert.Equal(2, service.StatusCalls);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using Deal.Grpc.Ai;
|
|||||||
using Deal.Infrastructure.Data;
|
using Deal.Infrastructure.Data;
|
||||||
using Deal.Infrastructure.Integrations.Models;
|
using Deal.Infrastructure.Integrations.Models;
|
||||||
using Deal.Infrastructure.Integrations.Options;
|
using Deal.Infrastructure.Integrations.Options;
|
||||||
using Deal.Infrastructure.Integrations.Resilience;
|
|
||||||
using Deal.Infrastructure.Integrations.Services;
|
using Deal.Infrastructure.Integrations.Services;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Deal.Modules.Pipeline.Application.Models;
|
using Deal.Modules.Pipeline.Application.Models;
|
||||||
@@ -102,9 +101,8 @@ public sealed class PipelineWorkerGrpcAiTests
|
|||||||
Assert.Equal(1, result.AiFail);
|
Assert.Equal(1, result.AiFail);
|
||||||
Assert.Equal(1, result.AiStored);
|
Assert.Equal(1, result.AiStored);
|
||||||
Assert.Single(result.CreatedCards);
|
Assert.Single(result.CreatedCards);
|
||||||
// Недоступность транспорта повторяется — на сервер приходит первая попытка и повторы.
|
Assert.Equal(1, service.FilterCalls);
|
||||||
Assert.Equal(GrpcRetry.RetryCount + 1, service.FilterCalls);
|
Assert.Equal(1, service.ClassifyCalls);
|
||||||
Assert.Equal(GrpcRetry.RetryCount + 1, service.ClassifyCalls);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Фронтенд 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,10 +2,9 @@
|
|||||||
// Вкладка «Telegram» настроек: подключение аккаунта (QR / номер / код /
|
// Вкладка «Telegram» настроек: подключение аккаунта (QR / номер / код /
|
||||||
// облачный пароль) и авто-мониторинг новых чатов. Ключи приложения
|
// облачный пароль) и авто-мониторинг новых чатов. Ключи приложения
|
||||||
// (api_id/api_hash) задаёт оператор глобально — у тенанта их нет.
|
// (api_id/api_hash) задаёт оператор глобально — у тенанта их нет.
|
||||||
// Опрос статуса при QR-входе живёт в SettingsView (watch на state.tgState),
|
// Пока идёт QR-вход, вкладка сама опрашивает статус и обновляет картинку.
|
||||||
// чтобы поведение при переключении вкладок осталось прежним.
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { ref } from 'vue'
|
import { state, connectTgStep, tgStart, disconnectTg, refreshTgStatus } from '../../store.js'
|
||||||
import { state, connectTgStep, tgStart, disconnectTg } from '../../store.js'
|
|
||||||
import Icon from '../Icon.vue'
|
import Icon from '../Icon.vue'
|
||||||
import UiToggle from '../ui/ToggleSwitch.vue'
|
import UiToggle from '../ui/ToggleSwitch.vue'
|
||||||
|
|
||||||
@@ -14,6 +13,58 @@ const code = ref('')
|
|||||||
const tgPass = ref('')
|
const tgPass = ref('')
|
||||||
const qrTick = ref(0)
|
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() {
|
async function submitCode() {
|
||||||
await connectTgStep(code.value)
|
await connectTgStep(code.value)
|
||||||
if (state.tgState === 'done') code.value = ''
|
if (state.tgState === 'done') code.value = ''
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import { state, toast, errMsg, fmtMsgTime } from './core.js'
|
|||||||
|
|
||||||
export function mapTgStatus(st) {
|
export function mapTgStatus(st) {
|
||||||
if (!st) return
|
if (!st) return
|
||||||
state.tgConnected = !!st.connected
|
const phase = st.phase || 'idle'
|
||||||
|
// «Подключён» = авторизован (phase ready); транспортный connected ненадёжен для UI.
|
||||||
|
state.tgConnected = phase === 'ready' || !!st.connected
|
||||||
state.tgAccount = st.account || ''
|
state.tgAccount = st.account || ''
|
||||||
state.tgKeysSet = !!st.keysSet
|
state.tgKeysSet = !!st.keysSet
|
||||||
if (st.error) state.tgError = st.error
|
if (st.error) state.tgError = st.error
|
||||||
const phase = st.phase || 'idle'
|
|
||||||
if (phase === 'ready') {
|
if (phase === 'ready') {
|
||||||
state.tgState = 'done'
|
state.tgState = 'done'
|
||||||
} else if (['phone', 'code', 'password', 'qr'].includes(phase)) {
|
} else if (['phone', 'code', 'password', 'qr'].includes(phase)) {
|
||||||
@@ -147,10 +148,12 @@ export async function refreshTgStatus() {
|
|||||||
export async function tgStart() {
|
export async function tgStart() {
|
||||||
state.tgError = ''
|
state.tgError = ''
|
||||||
if (state.tgQrMode) {
|
if (state.tgQrMode) {
|
||||||
state.tgState = 'qr'
|
|
||||||
try {
|
try {
|
||||||
|
// Сначала стартуем QR на бэке, затем показываем картинку: иначе <img> успевает запросить
|
||||||
|
// /api/tg/qr-image до готовности QR и остаётся пустым.
|
||||||
const r = await api.post('/api/tg/start-qr')
|
const r = await api.post('/api/tg/start-qr')
|
||||||
state.tgQrUrl = r.qrUrl || ''
|
state.tgQrUrl = r.qrUrl || ''
|
||||||
|
state.tgState = 'qr'
|
||||||
toast(t('settings.ssylka-dlya-vhoda-sgenerirovana-otkrojte'), { icon: 'send' })
|
toast(t('settings.ssylka-dlya-vhoda-sgenerirovana-otkrojte'), { icon: 'send' })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state.tgError = errMsg(e)
|
state.tgError = errMsg(e)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { t } from '@/i18n/index.js'
|
import { t } from '@/i18n/index.js'
|
||||||
import { onBeforeUnmount, watch } from 'vue'
|
import { state } from '../store.js'
|
||||||
import { state, refreshTgStatus } from '../store.js'
|
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
import TelegramTab from '../components/settings/TelegramTab.vue'
|
import TelegramTab from '../components/settings/TelegramTab.vue'
|
||||||
import AiTab from '../components/settings/AiTab.vue'
|
import AiTab from '../components/settings/AiTab.vue'
|
||||||
@@ -26,32 +25,6 @@ const TABS = [
|
|||||||
{ id: 'appearance', name: t('settings.vneshnij-vid'), icon: 'palette' },
|
{ id: 'appearance', name: t('settings.vneshnij-vid'), icon: 'palette' },
|
||||||
{ id: 'profile', name: t('settings.profil'), icon: 'key' },
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
Reference in New Issue
Block a user