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:
@@ -0,0 +1,382 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Операции пространства «Выбранные» — partial-часть <see cref="CardsService"/>
|
||||
/// </summary>
|
||||
public sealed partial class CardsService
|
||||
{
|
||||
/// <summary>
|
||||
/// 400 перенос по стадии
|
||||
/// </summary>
|
||||
public const string UnknownStageDetail = "Неизвестная стадия";
|
||||
|
||||
private const string PlannedStage = CardsDefaultContainers.Planned;
|
||||
|
||||
private const string RejectedStage = CardsDefaultContainers.Rejected;
|
||||
|
||||
private const string TakenCommentText = "Взял в работу.";
|
||||
|
||||
/// <summary>
|
||||
/// 400 ссылка: пустой url после Trim.
|
||||
/// </summary>
|
||||
public const string EmptyLinkDetail = "Пустая ссылка";
|
||||
|
||||
private const string HttpsUrlScheme = "https://";
|
||||
|
||||
private const string HttpUrlScheme = "http://";
|
||||
|
||||
// Ключ тела PATCH: заголовок (JSON-строка).
|
||||
private const string PatchKeyTitle = "title";
|
||||
|
||||
// Ключ тела PATCH: краткое содержание (JSON-строка).
|
||||
private const string PatchKeySummary = "summary";
|
||||
|
||||
// Ключ тела PATCH: контактная строка (JSON-строка).
|
||||
private const string PatchKeyContact = "contact";
|
||||
|
||||
// Ключ тела PATCH: текст технического задания (JSON-строка).
|
||||
private const string PatchKeyTzText = "tzText";
|
||||
|
||||
// Ключ тела PATCH: стек (JSON-массив строк либо null — очистка).
|
||||
private const string PatchKeyStack = "stack";
|
||||
|
||||
// Ключ тела PATCH: бюджет (JSON-объект {from,to,cur} либо null/не-объект — очистка).
|
||||
private const string PatchKeyBudget = "budget";
|
||||
|
||||
private static readonly CardBudgetDto ClearedBudget = new(From: null, To: null, Cur: string.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// Карточки пространства «Выбранные»
|
||||
/// </summary>
|
||||
/// <param name="containerId">Фильтр по контейнеру-стадии; null — все стадии «Выбранных».</param>
|
||||
/// <returns>Полные карточки в порядке UpdatedAt DESC; пусто — карточек нет.</returns>
|
||||
public Task<IReadOnlyList<CardDto>> ListSelectedCardsAsync(string? containerId, CancellationToken ct)
|
||||
{
|
||||
return _store.ListSelectedCardsAsync(containerId, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ручное создание «локальной» карточки без внешнего источника.
|
||||
/// </summary>
|
||||
/// <param name="draft">Начальные поля карточки (тело POST /api/cards, см. <see cref="CardLocalCreateDto"/>).</param>
|
||||
/// <returns>Созданная карточка (полное чтение после записи).</returns>
|
||||
public async Task<CardDto> CreateLocalCardAsync(CardLocalCreateDto draft, CancellationToken ct)
|
||||
{
|
||||
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
DateTimeOffset receivedAt = DateTimeOffset.FromUnixTimeMilliseconds(nowMs);
|
||||
string containerId = draft.ContainerId is not null && CardsDefaultContainers.Contains(draft.ContainerId)
|
||||
? draft.ContainerId
|
||||
: PlannedStage;
|
||||
var snapshot = new CardSnapshot
|
||||
{
|
||||
Id = PrefixId.New(KanbanIdPrefixes.Card),
|
||||
Col = containerId,
|
||||
IsNew = false,
|
||||
Local = true,
|
||||
Title = draft.Title.Trim(),
|
||||
Summary = draft.Summary,
|
||||
Source = new SourceRef { Kind = SourceKinds.Local, ReceivedAt = receivedAt },
|
||||
Content = new SourceContent { Text = draft.Summary },
|
||||
ReceivedAt = receivedAt,
|
||||
Stack = draft.Stack ?? Array.Empty<string>(),
|
||||
BudgetFrom = draft.Budget?.From,
|
||||
BudgetTo = draft.Budget?.To,
|
||||
BudgetCur = draft.Budget?.Cur ?? string.Empty,
|
||||
Contact = draft.Contact,
|
||||
TzText = draft.TzText,
|
||||
History = new[] { new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, "createdLocal", null) },
|
||||
};
|
||||
await _store.AddCardAsync(snapshot, ct);
|
||||
return await _store.GetCardAsync(snapshot.Id, ct)
|
||||
?? throw new InvalidOperationException("Карточка не прочиталась после создания: " + snapshot.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// «Взять в работу»
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <returns>Карточка в стадии planned; null — карточки нет (404).</returns>
|
||||
public async Task<CardDto?> TakeCardAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (CardsDefaultContainers.Contains(card.Col))
|
||||
{
|
||||
return card;
|
||||
}
|
||||
|
||||
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var entry = new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, null, PlannedStage);
|
||||
if (!await _store.MoveCardStageAsync(cardId, PlannedStage, entry, nowMs, ct))
|
||||
{
|
||||
// Карточка исчезла между чтением и переносом (гонка с удалением).
|
||||
return null;
|
||||
}
|
||||
|
||||
await _store.AddCommentAsync(
|
||||
PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, TakenCommentText, ct);
|
||||
|
||||
return await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new InvalidOperationException("Карточка не прочиталась после take: " + cardId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Точечная правка полей карточки по телу PATCH — presence-aware.
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="body">Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется).</param>
|
||||
/// <returns>Обновлённая карточка или null — карточки нет (404).</returns>
|
||||
public async Task<CardDto?> PatchCardAsync(
|
||||
string cardId,
|
||||
IReadOnlyDictionary<string, JsonElement> body,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(body);
|
||||
|
||||
bool updated = await _store.PatchCardAsync(cardId, ResolvePatch(body), ct);
|
||||
return updated ? await _store.GetCardAsync(cardId, ct) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Добавляет ссылку карточке
|
||||
/// </summary>
|
||||
/// <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=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);
|
||||
if (card is null)
|
||||
{
|
||||
return new CardResultDto(null, null);
|
||||
}
|
||||
|
||||
string normalizedUrl = (url ?? string.Empty).Trim();
|
||||
if (normalizedUrl.Length == 0)
|
||||
{
|
||||
return new CardResultDto(EmptyLinkDetail, null);
|
||||
}
|
||||
|
||||
if (!normalizedUrl.StartsWith(HttpsUrlScheme, StringComparison.Ordinal)
|
||||
&& !normalizedUrl.StartsWith(HttpUrlScheme, StringComparison.Ordinal))
|
||||
{
|
||||
normalizedUrl = HttpsUrlScheme + normalizedUrl;
|
||||
}
|
||||
|
||||
string trimmedName = (name ?? string.Empty).Trim();
|
||||
var link = new CardLinkDto(
|
||||
PrefixId.New(KanbanIdPrefixes.Link),
|
||||
trimmedName.Length == 0 ? normalizedUrl : trimmedName,
|
||||
normalizedUrl);
|
||||
if (!await _store.AddLinkAsync(cardId, link, ct))
|
||||
{
|
||||
return new CardResultDto(null, null);
|
||||
}
|
||||
|
||||
CardDto saved = await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new InvalidOperationException("Карточка не прочиталась после добавления ссылки: " + cardId);
|
||||
return new CardResultDto(null, saved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет ссылку карточки по id
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="linkId">Id удаляемой ссылки (<c>pl_...</c>).</param>
|
||||
/// <returns>Результат: Card=null без Error (404) | Card — карточка без ссылки.</returns>
|
||||
public async Task<CardResultDto> RemoveLinkAsync(
|
||||
string cardId,
|
||||
string linkId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!await _store.RemoveLinkAsync(cardId, linkId, ct))
|
||||
{
|
||||
return new CardResultDto(null, null);
|
||||
}
|
||||
|
||||
CardDto saved = await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new InvalidOperationException("Карточка не прочиталась после удаления ссылки: " + cardId);
|
||||
return new CardResultDto(null, saved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Перенос карточки по контейнерам-стадиям «Выбранных»
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="containerId">Новый контейнер-стадия — id каталога <see cref="CardsDefaultContainers"/>.</param>
|
||||
/// <returns>Результат: Error «Неизвестная стадия» (400) | Card=null без Error (карточки нет, 404) | Card — карточка после переноса.</returns>
|
||||
public async Task<CardResultDto> MoveStageCardAsync(
|
||||
string cardId,
|
||||
string containerId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!CardsDefaultContainers.Contains(containerId))
|
||||
{
|
||||
return new CardResultDto(UnknownStageDetail, null);
|
||||
}
|
||||
|
||||
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var entry = new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, null, containerId);
|
||||
bool moved = await _store.MoveCardStageAsync(cardId, containerId, entry, nowMs, ct);
|
||||
if (!moved)
|
||||
{
|
||||
return new CardResultDto(null, null);
|
||||
}
|
||||
|
||||
CardDto card = await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new InvalidOperationException("Карточка не прочиталась после move: " + cardId);
|
||||
return new CardResultDto(null, card);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Полная ручная очистка терминальной стадии «Отклонено» — hard-delete строк
|
||||
/// </summary>
|
||||
/// <returns>Сколько карточек удалено (0 — стадия пуста).</returns>
|
||||
public Task<int> ClearRejectedAsync(CancellationToken ct)
|
||||
{
|
||||
return _store.ClearStageAsync(RejectedStage, ct);
|
||||
}
|
||||
|
||||
private static CardPatch ResolvePatch(IReadOnlyDictionary<string, JsonElement> body)
|
||||
{
|
||||
string? title = null;
|
||||
string? summary = null;
|
||||
string? contact = null;
|
||||
string? tzText = null;
|
||||
IReadOnlyList<string>? stack = null;
|
||||
CardBudgetDto? budget = null;
|
||||
foreach ((string key, JsonElement value) in body)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case PatchKeyTitle:
|
||||
if (ReadTextValue(value, out string parsedTitle))
|
||||
{
|
||||
title = parsedTitle;
|
||||
}
|
||||
|
||||
break;
|
||||
case PatchKeySummary:
|
||||
if (ReadTextValue(value, out string parsedSummary))
|
||||
{
|
||||
summary = parsedSummary;
|
||||
}
|
||||
|
||||
break;
|
||||
case PatchKeyContact:
|
||||
if (ReadTextValue(value, out string parsedContact))
|
||||
{
|
||||
contact = parsedContact;
|
||||
}
|
||||
|
||||
break;
|
||||
case PatchKeyTzText:
|
||||
if (ReadTextValue(value, out string parsedTzText))
|
||||
{
|
||||
tzText = parsedTzText;
|
||||
}
|
||||
|
||||
break;
|
||||
case PatchKeyStack:
|
||||
stack = ReadStack(value);
|
||||
break;
|
||||
case PatchKeyBudget:
|
||||
budget = ReadBudget(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new CardPatch(title, summary, contact, tzText, stack, budget, null, null, null);
|
||||
}
|
||||
|
||||
// Читает JSON-строку текстового поля; null/не-строка → ключ не применяется.
|
||||
// element: Значение ключа тела.
|
||||
// text: Прочитанная строка (при успехе).
|
||||
// Возвращает: True — значение строковое (в т.ч. пустое — очистка текста).
|
||||
private static bool ReadTextValue(JsonElement element, out string text)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
text = element.GetString() ?? string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
text = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Читает стек: массив строк (не-строки отбрасываются); null/не-массив → пустой стек.
|
||||
// element: Значение ключа stack.
|
||||
// Возвращает: Новый стек (полная замена массива).
|
||||
private static IReadOnlyList<string> ReadStack(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var items = new List<string>();
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
items.Add(item.GetString() ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// Читает бюджет: объект {from,to,cur} → пара границ + валюта; null/не-объект → очистка.
|
||||
// element: Значение ключа budget.
|
||||
// Возвращает: Бюджет патча: валюта пуста — «бюджета нет» (очистка).
|
||||
private static CardBudgetDto ReadBudget(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return ClearedBudget;
|
||||
}
|
||||
|
||||
double? from = ReadNumber(element, "from");
|
||||
double? to = ReadNumber(element, "to");
|
||||
string cur = ReadText(element, "cur");
|
||||
return new CardBudgetDto(from, to, cur);
|
||||
}
|
||||
|
||||
// Читает число поля объекта бюджета: число → значение; иное — пусто.
|
||||
// obj: Объект бюджета.
|
||||
// fieldName: Имя поля (from/to).
|
||||
// Возвращает: Граница либо null.
|
||||
private static double? ReadNumber(JsonElement obj, string fieldName)
|
||||
{
|
||||
return obj.TryGetProperty(fieldName, out JsonElement element) && element.ValueKind == JsonValueKind.Number
|
||||
? element.GetDouble()
|
||||
: null;
|
||||
}
|
||||
|
||||
// Читает строку поля объекта бюджета: строка → значение; отсутствие/иное → пусто.
|
||||
// obj: Объект бюджета.
|
||||
// fieldName: Имя поля (cur).
|
||||
// Возвращает: Код валюты либо пустая строка.
|
||||
private static string ReadText(JsonElement obj, string fieldName)
|
||||
{
|
||||
return obj.TryGetProperty(fieldName, out JsonElement element) && element.ValueKind == JsonValueKind.String
|
||||
? element.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user