Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,10 @@
using Deal.SharedKernel.Utilities;
namespace Deal.SharedKernel;
/// <summary>
/// Маркер слоя SharedKernel
/// </summary>
public interface ISharedKernel
{
}
@@ -0,0 +1,218 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.Metrics;
namespace Deal.SharedKernel.Observability;
/// <summary>
/// Прикладные метрики Deal
/// </summary>
public static class DealMetrics
{
/// <summary>
/// Имя meter'а прикладных метрик
/// </summary>
public const string MeterName = "Deal";
/// <summary>
/// Имя метки вида токенов
/// </summary>
public const string TokenTypeTagName = "type";
/// <summary>
/// Значение метки вида токенов — токены запроса.
/// </summary>
public const string TokenTypePrompt = "prompt";
/// <summary>
/// Значение метки вида токенов — токены ответа.
/// </summary>
public const string TokenTypeCompletion = "completion";
/// <summary>
/// Имя метки типа события аудита
/// </summary>
public const string AuditEventTagName = "event";
/// <summary>
/// Имя метки типа актора события аудита
/// </summary>
public const string AuditActorTagName = "actor";
/// <summary>
/// Имя метки тенанта
/// </summary>
public const string TenantTagName = "tenant";
/// <summary>
/// Имя метки вида события безопасности
/// </summary>
public const string SecurityKindTagName = "kind";
/// <summary>
/// Имя метрики доли израсходованного ИИ-бюджета
/// </summary>
public const string AiBudgetUsedRatioName = "deal.ai.budget.used.ratio";
// Meter прикладных метрик (static — один на процесс, как того требует System.Diagnostics.Metrics).
private static readonly Meter Meter = new(MeterName);
/// <summary>
/// Успешные вызовы платного ИИ
/// </summary>
public static readonly Counter<long> AiCalls =
Meter.CreateCounter<long>("deal.ai.calls", description: "Успешные вызовы платного ИИ (ai-service).");
/// <summary>
/// Токены платного ИИ по видам
/// </summary>
public static readonly Counter<long> AiTokens =
Meter.CreateCounter<long>("deal.ai.tokens", description: "Токены платных ИИ-вызовов (prompt/completion).");
/// <summary>
/// Вызовы локального ML
/// </summary>
public static readonly Counter<long> MlCalls =
Meter.CreateCounter<long>("deal.ml.calls", description: "Вызовы локального ML-предсказания.");
/// <summary>
/// Оценка токенов локальных ML-вызовов
/// </summary>
public static readonly Counter<long> MlTokens =
Meter.CreateCounter<long>("deal.ml.tokens", description: "Оценка токенов локальных ML-вызовов.");
/// <summary>
/// События аудита по типам и акторам
/// </summary>
public static readonly Counter<long> AuditEvents =
Meter.CreateCounter<long>("deal.audit.events", description: "Записи аудита по типам и акторам.");
/// <summary>
/// События подозрительной активности по видам
/// </summary>
public static readonly Counter<long> SecurityEvents =
Meter.CreateCounter<long>("deal.security.suspicious", description: "События подозрительной активности по видам (rate_limit, login_blocked).");
/// <summary>
/// Суммарная глубина очереди пайплайна
/// </summary>
public static readonly ObservableGauge<long> PipelineQueueDepth =
Meter.CreateObservableGauge(
"deal.pipeline.queue.depth",
(Func<long>)(() => (long)Interlocked.Read(ref DealMetrics._pipelineQueueDepth)),
description: "Суммарная глубина очереди пайплайна (new+filtered) по всем тенантам.");
/// <summary>
/// Суммарная глубина очереди обучения ML
/// </summary>
public static readonly ObservableGauge<long> MlOutboxDepth =
Meter.CreateObservableGauge(
"deal.ml.outbox.depth",
(Func<long>)(() => (long)Interlocked.Read(ref DealMetrics._mlOutboxDepth)),
description: "Суммарная глубина очереди обучения ML (MlOutbox) по всем тенантам.");
/// <summary>
/// Число активных сессий
/// </summary>
public static readonly ObservableGauge<long> ActiveSessions =
Meter.CreateObservableGauge(
"deal.sessions.active",
(Func<long>)(() => (long)Interlocked.Read(ref DealMetrics._activeSessions)),
description: "Активные непросроченные сессии пользователей и операторов.");
/// <summary>
/// Доля израсходованного ИИ-бюджета по тенантам
/// </summary>
public static readonly ObservableGauge<double> AiBudgetUsedRatio =
Meter.CreateObservableGauge(
AiBudgetUsedRatioName,
ObserveBudgetRatios,
description: "Доля израсходованного ИИ-бюджета периода по тенантам (0..1).");
private static readonly ConcurrentDictionary<string, double> BudgetRatios = new(StringComparer.Ordinal);
private static long _pipelineQueueDepth;
private static long _mlOutboxDepth;
private static long _activeSessions;
/// <summary>
/// Записывает успешный платный ИИ-вызов
/// </summary>
/// <param name="promptTokens">Токены запроса (неотрицательные).</param>
/// <param name="completionTokens">Токены ответа (неотрицательные).</param>
public static void RecordAiUsage(long promptTokens, long completionTokens)
{
AiCalls.Add(1);
if (promptTokens > 0)
{
AiTokens.Add(promptTokens, new TagList { { TokenTypeTagName, TokenTypePrompt } });
}
if (completionTokens > 0)
{
AiTokens.Add(completionTokens, new TagList { { TokenTypeTagName, TokenTypeCompletion } });
}
}
/// <summary>
/// Записывает вызов локального ML
/// </summary>
/// <param name="promptTokens">Оценка токенов входного текста (неотрицательная).</param>
public static void RecordMlUsage(long promptTokens)
{
MlCalls.Add(1);
if (promptTokens > 0)
{
MlTokens.Add(promptTokens, new TagList { { TokenTypeTagName, TokenTypePrompt } });
}
}
/// <summary>
/// Записывает событие аудита по типу и актору
/// </summary>
/// <param name="eventType">Тип события (константа <c>AuditEvents</c>).</param>
/// <param name="actorType">Тип актора (tenant/operator/system).</param>
public static void RecordAuditEvent(string eventType, string actorType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(eventType);
ArgumentException.ThrowIfNullOrWhiteSpace(actorType);
AuditEvents.Add(1, new TagList { { AuditEventTagName, eventType }, { AuditActorTagName, actorType } });
}
/// <summary>
/// Публикует глубину очереди пайплайна
/// </summary>
/// <param name="depth">Суммарная глубина (new+filtered) по всем тенантам.</param>
public static void SetPipelineQueueDepth(long depth) => Interlocked.Exchange(ref _pipelineQueueDepth, depth);
/// <summary>
/// Публикует глубину очереди обучения ML
/// </summary>
/// <param name="depth">Суммарная глубина MlOutbox по всем тенантам.</param>
public static void SetMlOutboxDepth(long depth) => Interlocked.Exchange(ref _mlOutboxDepth, depth);
/// <summary>
/// Публикует число активных сессий
/// </summary>
/// <param name="count">Активные непросроченные сессии пользователей и операторов.</param>
public static void SetActiveSessions(long count) => Interlocked.Exchange(ref _activeSessions, count);
/// <summary>
/// Публикует доли расхода ИИ-бюджета по тенантам (заменяет предыдущий снимок)
/// </summary>
/// <param name="ratios">Пары (id тенанта, доля 0..1).</param>
public static void ReplaceBudgetRatios(IEnumerable<(string TenantId, double Ratio)> ratios)
{
BudgetRatios.Clear();
foreach ((string tenantId, double ratio) in ratios)
{
BudgetRatios[tenantId] = Math.Clamp(ratio, 0.0, 1.0);
}
}
// Снимок долей расхода бюджета для observable-gauge (по измерению на тенант).
private static IEnumerable<Measurement<double>> ObserveBudgetRatios()
=> BudgetRatios.Select(pair => new Measurement<double>(
pair.Value,
new TagList { { TenantTagName, pair.Key } }));
}
@@ -0,0 +1,35 @@
using Deal.SharedKernel.Tenants.Models;
namespace Deal.SharedKernel.Tenants.Abstractions;
/// <summary>
/// Контекст текущего тенанта запроса.
/// </summary>
public interface ITenantContext
{
/// <summary>
/// Идентификатор текущего тенанта или null для системного контекста
/// </summary>
public TenantId? TenantId { get; }
/// <summary>
/// Признак наличия текущего тенанта
/// </summary>
public bool HasTenant { get; }
/// <summary>
/// Имя схемы текущего тенанта или null для системного контекста
/// </summary>
public string? SchemaName { get; }
/// <summary>
/// Устанавливает текущего тенанта запроса.
/// </summary>
/// <param name="tenantId">Идентификатор тенанта.</param>
public void SetTenant(TenantId tenantId);
/// <summary>
/// Сбрасывает контекст в системный
/// </summary>
public void Reset();
}
@@ -0,0 +1,64 @@
using Deal.SharedKernel.Utilities;
namespace Deal.SharedKernel.Tenants.Models;
/// <summary>
/// Идентификатор тенанта.
/// </summary>
public readonly record struct TenantId
{
/// <summary>
/// Значение идентификатора
/// </summary>
public string Value { get; }
/// <summary>
/// Создаёт идентификатор из строки; значение обязано быть 32 hex-символами.
/// </summary>
/// <param name="value">Значение в формате Guid "N" (32 hex).</param>
/// <exception cref="ArgumentException">Значение не является 32 hex-символами.</exception>
public TenantId(string value)
{
if (!IsValid(value))
{
throw new ArgumentException("TenantId должен быть 32 hex-символами (Guid N)", nameof(value));
}
Value = value;
}
/// <summary>
/// Создаёт идентификатор из Guid.
/// </summary>
/// <param name="id">Guid тенанта.</param>
/// <returns>Идентификатор тенанта (значение — формат "N").</returns>
public static TenantId FromGuid(Guid id) => new(id.ToString("N"));
/// <summary>
/// True — строка является валидным значением TenantId
/// </summary>
/// <param name="value">Проверяемая строка.</param>
/// <returns>True — значение пригодно для TenantId.</returns>
public static bool IsValid(string? value)
{
if (value is null || value.Length != 32)
{
return false;
}
foreach (char c in value)
{
if (!Uri.IsHexDigit(c))
{
return false;
}
}
return true;
}
/// <summary>
/// Имя схемы Postgres для тенанта.
/// </summary>
public string SchemaName => $"tenant_{Value}";
}
@@ -0,0 +1,28 @@
using System.Security.Cryptography;
using Deal.SharedKernel;
using Deal.SharedKernel.Utilities;
namespace Deal.SharedKernel.Utilities;
/// <summary>
/// Криптостойкие url-safe токены
/// </summary>
public static class UrlSafeToken
{
/// <summary>
/// Новый токен: <paramref name="byteCount"/> случайных байт в Base64Url
/// </summary>
/// <param name="byteCount">Число случайных байт (≥ 1).</param>
/// <returns>Строка длиной ceil(byteCount·4/3) без padding.</returns>
public static string New(int byteCount)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(byteCount);
byte[] bytes = new byte[byteCount];
RandomNumberGenerator.Fill(bytes);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}